From ce7a3e1f14fe64cfc0a24ce17f330cacb2180926 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 10 Sep 2025 12:22:42 +0100 Subject: [PATCH 001/177] initial contribution Signed-off-by: Andrew Fiddian-Green --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + bundles/org.openhab.binding.homekit/NOTICE | 13 +++ bundles/org.openhab.binding.homekit/README.md | 95 ++++++++++++++++ bundles/org.openhab.binding.homekit/pom.xml | 17 +++ .../src/main/feature/feature.xml | 9 ++ .../internal/HomeKitBindingConstants.java | 34 ++++++ .../internal/HomeKitConfiguration.java | 31 ++++++ .../homekit/internal/HomeKitHandler.java | 104 ++++++++++++++++++ .../internal/HomeKitHandlerFactory.java | 55 +++++++++ .../src/main/resources/OH-INF/addon/addon.xml | 10 ++ .../resources/OH-INF/i18n/homekit.properties | 3 + .../resources/OH-INF/thing/thing-types.xml | 48 ++++++++ bundles/pom.xml | 1 + 14 files changed, 426 insertions(+) create mode 100644 bundles/org.openhab.binding.homekit/NOTICE create mode 100644 bundles/org.openhab.binding.homekit/README.md create mode 100644 bundles/org.openhab.binding.homekit/pom.xml create mode 100644 bundles/org.openhab.binding.homekit/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitBindingConstants.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitConfiguration.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandler.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandlerFactory.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml create mode 100644 bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties create mode 100644 bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml diff --git a/CODEOWNERS b/CODEOWNERS index feb897954f746..fd137b0b22099 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -165,6 +165,7 @@ /bundles/org.openhab.binding.heos/ @Wire82 /bundles/org.openhab.binding.herzborg/ @Sonic-Amiga /bundles/org.openhab.binding.homeconnect/ @bruestel +/bundles/org.openhab.binding.homekit/ @andrewfg /bundles/org.openhab.binding.homematic/ @FStolte @gerrieg @mdicke2s /bundles/org.openhab.binding.homewizard/ @Daniel-42 /bundles/org.openhab.binding.hpprinter/ @cossey diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 36758fa8e50f3..57b61e7dfbc7f 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -811,6 +811,11 @@ org.openhab.binding.homeconnect ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.homekit + ${project.version} + org.openhab.addons.bundles org.openhab.binding.homematic diff --git a/bundles/org.openhab.binding.homekit/NOTICE b/bundles/org.openhab.binding.homekit/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md new file mode 100644 index 0000000000000..53f6f0bf126f3 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/README.md @@ -0,0 +1,95 @@ +# HomeKit Binding + +_Give some details about what this binding is meant for - a protocol, system, specific device._ + +_If possible, provide some resources like pictures (only PNG is supported currently), a video, etc. to give an impression of what can be done with this binding._ +_You can place such resources into a `doc` folder next to this README.md._ + +_Put each sentence in a separate line to improve readability of diffs._ + +## Supported Things + +_Please describe the different supported things / devices including their ThingTypeUID within this section._ +_Which different types are supported, which models were tested etc.?_ +_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._ + +- `bridge`: Short description of the Bridge, if any +- `sample`: Short description of the Thing with the ThingTypeUID `sample` + +## Discovery + +_Describe the available auto-discovery features here._ +_Mention for what it works and what needs to be kept in mind when using it._ + +## Binding Configuration + +_If your binding requires or supports general configuration settings, please create a folder ```cfg``` and place the configuration file ```.cfg``` inside it._ +_In this section, you should link to this file and provide some information about the options._ +_The file could e.g. look like:_ + +``` +# Configuration for the HomeKit Binding +# +# Default secret key for the pairing of the HomeKit Thing. +# It has to be between 10-40 (alphanumeric) characters. +# This may be changed by the user for security reasons. +secret=openHABSecret +``` + +_Note that it is planned to generate some part of this based on the information that is available within ```src/main/resources/OH-INF/binding``` of your binding._ + +_If your binding does not offer any generic configurations, you can remove this section completely._ + +## Thing Configuration + +_Describe what is needed to manually configure a thing, either through the UI or via a thing-file._ +_This should be mainly about its mandatory and optional configuration parameters._ + +_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._ + +### `sample` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|-----------------|---------|---------------------------------------|---------|----------|----------| +| hostname | text | Hostname or IP address of the device | N/A | yes | no | +| password | text | Password to access the device | N/A | yes | no | +| refreshInterval | integer | Interval the device is polled in sec. | 600 | no | yes | + +## Channels + +_Here you should provide information about available channel types, what their meaning is and how they can be used._ + +_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._ + +| Channel | Type | Read/Write | Description | +|---------|--------|------------|-----------------------------| +| control | Switch | RW | This is the control channel | + +## Full Example + +_Provide a full usage example based on textual configuration files._ +_*.things, *.items examples are mandatory as textual configuration is well used by many users._ +_*.sitemap examples are optional._ + +### Thing Configuration + +```java +Example thing configuration goes here. +``` + +### Item Configuration + +```java +Example item configuration goes here. +``` + +### Sitemap Configuration + +```perl +Optional Sitemap configuration goes here. +Remove this section, if not needed. +``` + +## Any custom content here! + +_Feel free to add additional sections for whatever you think should also be mentioned about your binding!_ diff --git a/bundles/org.openhab.binding.homekit/pom.xml b/bundles/org.openhab.binding.homekit/pom.xml new file mode 100644 index 0000000000000..7edd7ac121c86 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.1.0-SNAPSHOT + + + org.openhab.binding.homekit + + openHAB Add-ons :: Bundles :: HomeKit Binding + + diff --git a/bundles/org.openhab.binding.homekit/src/main/feature/feature.xml b/bundles/org.openhab.binding.homekit/src/main/feature/feature.xml new file mode 100644 index 0000000000000..8f4ae1078b2a0 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.homekit/${project.version} + + diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitBindingConstants.java new file mode 100644 index 0000000000000..999a06f5787ec --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitBindingConstants.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link HomeKitBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomeKitBindingConstants { + + private static final String BINDING_ID = "homekit"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_SAMPLE = new ThingTypeUID(BINDING_ID, "sample"); + + // List of all Channel ids + public static final String CHANNEL_1 = "channel1"; +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitConfiguration.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitConfiguration.java new file mode 100644 index 0000000000000..b2fdc0babd391 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link HomeKitConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomeKitConfiguration { + + /** + * Sample configuration parameters. Replace with your own. + */ + public String hostname = ""; + public String password = ""; + public int refreshInterval = 600; +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandler.java new file mode 100644 index 0000000000000..58c22cfd5a7a9 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandler.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.openhab.binding.homekit.internal.HomeKitBindingConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomeKitHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomeKitHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(HomeKitHandler.class); + + private @Nullable HomeKitConfiguration config; + + public HomeKitHandler(Thing thing) { + super(thing); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (CHANNEL_1.equals(channelUID.getId())) { + if (command instanceof RefreshType) { + // TODO: handle data refresh + } + + // TODO: handle command + + // Note: if communication with thing fails for some reason, + // indicate that by setting the status with detail information: + // updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + // "Could not control device at IP address x.x.x.x"); + } + } + + @Override + public void initialize() { + config = getConfigAs(HomeKitConfiguration.class); + + // TODO: Initialize the handler. + // The framework requires you to return from this method quickly, i.e. any network access must be done in + // the background initialization below. + // Also, before leaving this method a thing status from one of ONLINE, OFFLINE or UNKNOWN must be set. This + // might already be the real thing status in case you can decide it directly. + // In case you can not decide the thing status directly (e.g. for long running connection handshake using WAN + // access or similar) you should set status UNKNOWN here and then decide the real status asynchronously in the + // background. + + // set the thing status to UNKNOWN temporarily and let the background task decide for the real status. + // the framework is then able to reuse the resources from the thing handler initialization. + // we set this upfront to reliably check status updates in unit tests. + updateStatus(ThingStatus.UNKNOWN); + + // Example for background initialization: + scheduler.execute(() -> { + boolean thingReachable = true; // + // when done do: + if (thingReachable) { + updateStatus(ThingStatus.ONLINE); + } else { + updateStatus(ThingStatus.OFFLINE); + } + }); + + // These logging types should be primarily used by bindings + // logger.trace("Example trace message"); + // logger.debug("Example debug message"); + // logger.warn("Example warn message"); + // + // Logging to INFO should be avoided normally. + // See https://www.openhab.org/docs/developer/guidelines.html#f-logging + + // Note: When initialization can NOT be done set the status with more details for further + // analysis. See also class ThingStatusDetail for all available status details. + // Add a description to give user information to understand why thing does not work as expected. E.g. + // updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + // "Can not access device as username and/or password are invalid"); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandlerFactory.java new file mode 100644 index 0000000000000..ab96745aa95c2 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandlerFactory.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.openhab.binding.homekit.internal.HomeKitBindingConstants.*; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Component; + +/** + * The {@link HomeKitHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.homekit", service = ThingHandlerFactory.class) +public class HomeKitHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_SAMPLE); + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_SAMPLE.equals(thingTypeUID)) { + return new HomeKitHandler(thing); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000000..47ae1bbe28864 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,10 @@ + + + + binding + HomeKit Binding + This is the binding for HomeKit. + + diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties new file mode 100644 index 0000000000000..0c2f44015a92d --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -0,0 +1,3 @@ +# FIXME: please add all English translations to this file so the texts can be translated using Crowdin +# FIXME: to generate the content of this file run: mvn i18n:generate-default-translations +# FIXME: see also: https://www.openhab.org/docs/developer/utils/i18n.html diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000000..1f3f186669367 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,48 @@ + + + + + + + + + Sample thing for HomeKit Binding + + + + + + + + network-address + + Hostname or IP address of the device + + + password + + Password to access the device + + + + Interval the device is polled in sec. + 600 + true + + + + + + + Number:Temperature + + Sample channel for HomeKit Binding + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 4c06bd05024f6..53f6fe20487a5 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -198,6 +198,7 @@ org.openhab.binding.heos org.openhab.binding.herzborg org.openhab.binding.homeconnect + org.openhab.binding.homekit org.openhab.binding.homematic org.openhab.binding.homewizard org.openhab.binding.hpprinter From 72bb54a4d16589fd933cd16359b806ef46bd39cb Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 10 Sep 2025 15:05:48 +0100 Subject: [PATCH 002/177] work in progress Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/pom.xml | 8 +- .../homekit/internal/HomeKitHandler.java | 104 ------------------ ...ants.java => HomekitBindingConstants.java} | 8 +- .../HomekitBridgeConfiguration.java} | 6 +- .../config/HomekitDeviceConfiguration.java | 30 +++++ .../HomekitMdnsDiscoveryParticipant.java | 89 +++++++++++++++ .../handler/HomekitBridgeHandler.java | 52 +++++++++ .../handler/HomekitDeviceHandler.java | 54 +++++++++ .../HomekitHandlerFactory.java} | 15 +-- 9 files changed, 248 insertions(+), 118 deletions(-) delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandler.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{HomeKitBindingConstants.java => HomekitBindingConstants.java} (73%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{HomeKitConfiguration.java => config/HomekitBridgeConfiguration.java} (78%) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{HomeKitHandlerFactory.java => handler/HomekitHandlerFactory.java} (76%) diff --git a/bundles/org.openhab.binding.homekit/pom.xml b/bundles/org.openhab.binding.homekit/pom.xml index 7edd7ac121c86..4f201ca26408a 100644 --- a/bundles/org.openhab.binding.homekit/pom.xml +++ b/bundles/org.openhab.binding.homekit/pom.xml @@ -13,5 +13,11 @@ org.openhab.binding.homekit openHAB Add-ons :: Bundles :: HomeKit Binding - + + + net.i2p.crypto + eddsa + 0.3.0 + + diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandler.java deleted file mode 100644 index 58c22cfd5a7a9..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandler.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal; - -import static org.openhab.binding.homekit.internal.HomeKitBindingConstants.*; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.core.thing.ChannelUID; -import org.openhab.core.thing.Thing; -import org.openhab.core.thing.ThingStatus; -import org.openhab.core.thing.binding.BaseThingHandler; -import org.openhab.core.types.Command; -import org.openhab.core.types.RefreshType; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The {@link HomeKitHandler} is responsible for handling commands, which are - * sent to one of the channels. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class HomeKitHandler extends BaseThingHandler { - - private final Logger logger = LoggerFactory.getLogger(HomeKitHandler.class); - - private @Nullable HomeKitConfiguration config; - - public HomeKitHandler(Thing thing) { - super(thing); - } - - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - if (CHANNEL_1.equals(channelUID.getId())) { - if (command instanceof RefreshType) { - // TODO: handle data refresh - } - - // TODO: handle command - - // Note: if communication with thing fails for some reason, - // indicate that by setting the status with detail information: - // updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - // "Could not control device at IP address x.x.x.x"); - } - } - - @Override - public void initialize() { - config = getConfigAs(HomeKitConfiguration.class); - - // TODO: Initialize the handler. - // The framework requires you to return from this method quickly, i.e. any network access must be done in - // the background initialization below. - // Also, before leaving this method a thing status from one of ONLINE, OFFLINE or UNKNOWN must be set. This - // might already be the real thing status in case you can decide it directly. - // In case you can not decide the thing status directly (e.g. for long running connection handshake using WAN - // access or similar) you should set status UNKNOWN here and then decide the real status asynchronously in the - // background. - - // set the thing status to UNKNOWN temporarily and let the background task decide for the real status. - // the framework is then able to reuse the resources from the thing handler initialization. - // we set this upfront to reliably check status updates in unit tests. - updateStatus(ThingStatus.UNKNOWN); - - // Example for background initialization: - scheduler.execute(() -> { - boolean thingReachable = true; // - // when done do: - if (thingReachable) { - updateStatus(ThingStatus.ONLINE); - } else { - updateStatus(ThingStatus.OFFLINE); - } - }); - - // These logging types should be primarily used by bindings - // logger.trace("Example trace message"); - // logger.debug("Example debug message"); - // logger.warn("Example warn message"); - // - // Logging to INFO should be avoided normally. - // See https://www.openhab.org/docs/developer/guidelines.html#f-logging - - // Note: When initialization can NOT be done set the status with more details for further - // analysis. See also class ThingStatusDetail for all available status details. - // Add a description to give user information to understand why thing does not work as expected. E.g. - // updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - // "Can not access device as username and/or password are invalid"); - } -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java similarity index 73% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitBindingConstants.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 999a06f5787ec..f25df1ecbf829 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -16,19 +16,21 @@ import org.openhab.core.thing.ThingTypeUID; /** - * The {@link HomeKitBindingConstants} class defines common constants, which are + * The {@link HomekitBindingConstants} class defines common constants, which are * used across the whole binding. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomeKitBindingConstants { +public class HomekitBindingConstants { private static final String BINDING_ID = "homekit"; // List of all Thing Type UIDs - public static final ThingTypeUID THING_TYPE_SAMPLE = new ThingTypeUID(BINDING_ID, "sample"); + public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); + public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device"); // List of all Channel ids + // TODO public static final String CHANNEL_1 = "channel1"; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitConfiguration.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitBridgeConfiguration.java similarity index 78% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitConfiguration.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitBridgeConfiguration.java index b2fdc0babd391..f3d46a80cf254 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitConfiguration.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitBridgeConfiguration.java @@ -10,17 +10,17 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal; +package org.openhab.binding.homekit.internal.config; import org.eclipse.jdt.annotation.NonNullByDefault; /** - * The {@link HomeKitConfiguration} class contains fields mapping thing configuration parameters. + * The {@link HomekitBridgeConfiguration} contains fields mapping bridge configuration parameters. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomeKitConfiguration { +public class HomekitBridgeConfiguration { /** * Sample configuration parameters. Replace with your own. diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java new file mode 100644 index 0000000000000..148d1cd3f539c --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link HomekitBridgeConfiguration} contains fields mapping device configuration parameters. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitDeviceConfiguration { + + public @Nullable String ipV4Address; // dotted ipV4 address of the device + public @Nullable String protocolVersion; // e.g. "1.0" HAP protocol version + public @Nullable Integer deviceCategory; // e.g. 2 the HomeKit device category + public @Nullable String pairingCode; // e.g. "031-45-154" the device pairing code +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java new file mode 100644 index 0000000000000..583b92adaaa3e --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.discovery; + +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.THING_TYPE_DEVICE; + +import java.util.Set; + +import javax.jmdns.ServiceInfo; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomekitMdnsDiscoveryParticipant} is responsible for discovering new HomeKit server devices. + * It uses the central {@link org.openhab.core.config.discovery.mdns.internal.MDNSDiscoveryService}. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +@Component(service = MDNSDiscoveryParticipant.class, immediate = true) +public class HomekitMdnsDiscoveryParticipant implements MDNSDiscoveryParticipant { + + private static final String SERVICE_TYPE = "_hap._tcp.local."; + + private final Logger logger = LoggerFactory.getLogger(HomekitMdnsDiscoveryParticipant.class); + + @Override + public Set getSupportedThingTypeUIDs() { + return Set.of(THING_TYPE_DEVICE); + } + + @Override + public String getServiceType() { + return SERVICE_TYPE; + } + + @Override + public @Nullable DiscoveryResult createResult(ServiceInfo service) { + ThingUID uid = getThingUID(service); + if (uid != null) { + String ipV4Address = service.getHostAddresses()[0]; + String macAddress = service.getPropertyString("id"); // HomeKit device ID is the MAC address + String modelName = service.getPropertyString("md"); // HomeKit device model name + String deviceCategory = service.getPropertyString("ci"); // HomeKit device category + String protocolVersion = service.getPropertyString("pv"); // HomeKit protocol version + + return DiscoveryResultBuilder.create(uid) // + .withLabel("%s on (%s)".formatted(modelName, ipV4Address)) // + .withProperty(Thing.PROPERTY_MODEL_ID, modelName) // + .withProperty(Thing.PROPERTY_MAC_ADDRESS, macAddress) // + .withProperty("protocolVersion", protocolVersion) // + .withProperty("ipV4Address", ipV4Address) // + .withProperty("deviceCategory", deviceCategory) // + .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS).build(); + } + return null; + } + + @Override + public @Nullable ThingUID getThingUID(ServiceInfo service) { + String macAddress = service.getPropertyString("id"); + if (macAddress != null) { + return new ThingUID(THING_TYPE_DEVICE, macAddress.replace(":", "-").toLowerCase()); + } else { + logger.warn("Discovered HomeKit device without MAC address property - ignoring"); + return null; + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java new file mode 100644 index 0000000000000..cd7aca0aa4b2f --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.config.HomekitBridgeConfiguration; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomekitBridgeHandler} is responsible for marshalling communications with HomeKit device servers. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitBridgeHandler extends BaseBridgeHandler { + + private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); + + private @Nullable HomekitBridgeConfiguration config; + + public HomekitBridgeHandler(Bridge bridge) { + super(bridge); + } + + @Override + public void initialize() { + // TODO initialize the overall HomeKit user credentials + // TODO initialise mDNS discovery of HomeKit device servers + // TODO set state to ONLINE if successful + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // Not used - Bridge has no channels + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java new file mode 100644 index 0000000000000..2a71634826b6f --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.config.HomekitDeviceConfiguration; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomekitDeviceHandler} is represents a HomeKit device server. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitDeviceHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(HomekitDeviceHandler.class); + + private @Nullable HomekitDeviceConfiguration config; + + public HomekitDeviceHandler(Thing thing) { + super(thing); + // TODO Auto-generated constructor stub + } + + @Override + public void initialize() { + // TODO Auto-generated method stub + + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // TODO Auto-generated method stub + + } + +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java similarity index 76% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandlerFactory.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java index ab96745aa95c2..b470e65d8dd47 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomeKitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java @@ -10,14 +10,15 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal; +package org.openhab.binding.homekit.internal.handler; -import static org.openhab.binding.homekit.internal.HomeKitBindingConstants.*; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.binding.BaseThingHandlerFactory; @@ -26,16 +27,16 @@ import org.osgi.service.component.annotations.Component; /** - * The {@link HomeKitHandlerFactory} is responsible for creating things and thing + * The {@link HomekitHandlerFactory} is responsible for creating things and thing * handlers. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault @Component(configurationPid = "binding.homekit", service = ThingHandlerFactory.class) -public class HomeKitHandlerFactory extends BaseThingHandlerFactory { +public class HomekitHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_SAMPLE); + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE); @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { @@ -46,8 +47,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); - if (THING_TYPE_SAMPLE.equals(thingTypeUID)) { - return new HomeKitHandler(thing); + if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { + return new HomekitBridgeHandler((Bridge) thing); } return null; From f20fc8127a91ba208dd98ded00c68062fa2dafdd Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 10 Sep 2025 16:40:48 +0100 Subject: [PATCH 003/177] work in progress Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/pom.xml | 17 ++- .../homekit/internal/PairingManager.java | 77 ++++++++++ .../binding/homekit/internal/SRPClient.java | 143 ++++++++++++++++++ .../binding/homekit/internal/SessionKeys.java | 54 +++++++ .../binding/homekit/internal/TLV8Codec.java | 85 +++++++++++ .../handler/HomekitDeviceHandler.java | 3 - 6 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java diff --git a/bundles/org.openhab.binding.homekit/pom.xml b/bundles/org.openhab.binding.homekit/pom.xml index 4f201ca26408a..6eb121d98bdf8 100644 --- a/bundles/org.openhab.binding.homekit/pom.xml +++ b/bundles/org.openhab.binding.homekit/pom.xml @@ -13,11 +13,14 @@ org.openhab.binding.homekit openHAB Add-ons :: Bundles :: HomeKit Binding - - - net.i2p.crypto - eddsa - 0.3.0 - - + + + + org.bouncycastle + bcprov-jdk18on + + 1.78 + + + diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java new file mode 100644 index 0000000000000..e80492734a844 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; + +/** + * @author Andrew Fiddian-Green - Initial contribution + */ +public class PairingManager { + + private final SRPClient srpClient; + private final HttpClient client; + + public PairingManager(String setupCode) { + this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + this.srpClient = new SRPClient(setupCode); + } + + public SessionKeys pair(String accessoryAddress) throws Exception { + // Step 1: M1 — Start Pairing + byte[] m1 = TLV8Codec.encode(Map.of(0x00, new byte[] { 0x00 }, 0x01, new byte[] { 0x01 })); + byte[] resp1 = post(accessoryAddress + "/pair-setup", m1); + + // Step 2: M2 — Receive SRP salt and public key + Map tlv2 = TLV8Codec.decode(resp1); + srpClient.processChallenge(tlv2.get(0x03), tlv2.get(0x04)); // salt, server public key + + // Step 3: M3 — Send SRP public key and proof + Map m3 = srpClient.generateClientProof(); + byte[] resp3 = post(accessoryAddress + "/pair-setup", TLV8Codec.encode(m3)); + + // Step 4: M4 — Verify server proof + Map tlv4 = TLV8Codec.decode(resp3); + srpClient.verifyServerProof(tlv4.get(0x04)); + + // Step 5: M5 — Exchange encrypted identifiers + Map m5 = srpClient.generateEncryptedIdentifiers(); + byte[] resp5 = post(accessoryAddress + "/pair-setup", TLV8Codec.encode(m5)); + + // Step 6: M6 — Final confirmation + Map tlv6 = TLV8Codec.decode(resp5); + srpClient.verifyAccessoryIdentifiers(tlv6); + + // Derive session keys + return srpClient.deriveSessionKeys(); + } + + private byte[] post(String url, byte[] payload) throws Exception { + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/pairing+tlv8").header("Accept", "application/pairing+tlv8") + .POST(HttpRequest.BodyPublishers.ofByteArray(payload)).build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() != 200) { + throw new RuntimeException("Pairing failed: HTTP " + response.statusCode()); + } + + return response.body(); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java new file mode 100644 index 0000000000000..baaf117a5d3ca --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Map; + +import org.bouncycastle.crypto.modes.ChaCha20Poly1305; +import org.bouncycastle.crypto.params.AEADParameters; +import org.bouncycastle.crypto.params.KeyParameter; + +/** + * @author Andrew Fiddian-Green - Initial contribution + */ +public class SRPClient { + + private static final BigInteger N = new BigInteger("..."); // TODO 3072-bit safe prime + private static final BigInteger g = BigInteger.valueOf(5); + + private final String setupCode; + private BigInteger a; // private ephemeral + private BigInteger A; // public ephemeral + private BigInteger B; // server public + private byte[] salt; + private byte[] K; // shared session key + + public SRPClient(String setupCode) { + this.setupCode = setupCode; + } + + public void processChallenge(byte[] salt, byte[] serverPublicKey) throws Exception { + this.salt = salt; + this.B = new BigInteger(1, serverPublicKey); + + SecureRandom random = new SecureRandom(); + this.a = new BigInteger(256, random); + this.A = g.modPow(a, N); + } + + public Map generateClientProof() throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + byte[] xH = digest.digest((new String(salt) + setupCode).getBytes()); + BigInteger x = new BigInteger(1, xH); + + BigInteger u = computeU(A, B); + BigInteger S = (B.subtract(g.modPow(x, N))).modPow(a.add(u.multiply(x)), N); + this.K = digest.digest(S.toByteArray()); + + byte[] M1 = computeM1(A, B, K); + return Map.of(0x03, A.toByteArray(), 0x04, M1); + } + + public void verifyServerProof(byte[] M2) throws Exception { + byte[] expected = computeM2(A, computeM1(A, B, K), K); + if (!MessageDigest.isEqual(M2, expected)) { + throw new SecurityException("Server proof mismatch"); + } + } + + public Map generateEncryptedIdentifiers() throws Exception { + // Encrypt controller identifier and public key using shared key K + byte[] plaintext = "...".getBytes(); // TODO input TLV8 encoded identifiers + byte[] nonce = generateNonce(); + byte[] encrypted = encryptChaCha20Poly1305(K, nonce, plaintext); + return Map.of(0x05, nonce, 0x06, encrypted); + } + + public void verifyAccessoryIdentifiers(Map tlv6) throws Exception { + byte[] nonce = tlv6.get(0x05); + byte[] encrypted = tlv6.get(0x06); + byte[] decrypted = decryptChaCha20Poly1305(K, nonce, encrypted); + // TODO Parse TLV8 and validate accessory identity + } + + public SessionKeys deriveSessionKeys() { + return new SessionKeys(K); + } + + private BigInteger computeU(BigInteger A, BigInteger B) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + byte[] uH = digest.digest(concat(A.toByteArray(), B.toByteArray())); + return new BigInteger(1, uH); + } + + private byte[] computeM1(BigInteger A, BigInteger B, byte[] K) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + return digest.digest(concat(A.toByteArray(), B.toByteArray(), K)); + } + + private byte[] computeM2(BigInteger A, byte[] M1, byte[] K) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-512"); + return digest.digest(concat(A.toByteArray(), M1, K)); + } + + private byte[] concat(byte[]... arrays) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] arr : arrays) { + out.write(arr, 0, arr.length); + } + return out.toByteArray(); + } + + private byte[] generateNonce() { + byte[] nonce = new byte[12]; + new SecureRandom().nextBytes(nonce); + return nonce; + } + + private byte[] encryptChaCha20Poly1305(byte[] key, byte[] nonce, byte[] plaintext) throws Exception { + ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); + cipher.init(true, params); + + byte[] ciphertext = new byte[cipher.getOutputSize(plaintext.length)]; + int len = cipher.processBytes(plaintext, 0, plaintext.length, ciphertext, 0); + cipher.doFinal(ciphertext, len); + return ciphertext; + } + + private byte[] decryptChaCha20Poly1305(byte[] key, byte[] nonce, byte[] ciphertext) throws Exception { + ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); + cipher.init(false, params); + + byte[] plaintext = new byte[cipher.getOutputSize(ciphertext.length)]; + int len = cipher.processBytes(ciphertext, 0, ciphertext.length, plaintext, 0); + cipher.doFinal(plaintext, len); + return plaintext; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java new file mode 100644 index 0000000000000..b0866625cf24f --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import java.nio.charset.StandardCharsets; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * @author Andrew Fiddian-Green - Initial contribution + */ +public class SessionKeys { + + private static final String HMAC_ALGO = "HmacSHA512"; + + public final byte[] writeKey; // Controller → Accessory + public final byte[] readKey; // Accessory → Controller + + public SessionKeys(byte[] sharedSecret) { + byte[] salt = "Control-Salt".getBytes(StandardCharsets.UTF_8); + this.writeKey = hkdf(sharedSecret, salt, "Control-Write-Encryption-Key".getBytes(StandardCharsets.UTF_8), 32); + this.readKey = hkdf(sharedSecret, salt, "Control-Read-Encryption-Key".getBytes(StandardCharsets.UTF_8), 32); + } + + private byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int length) { + try { + Mac mac = Mac.getInstance(HMAC_ALGO); + mac.init(new SecretKeySpec(salt, HMAC_ALGO)); + byte[] prk = mac.doFinal(ikm); + + mac.init(new SecretKeySpec(prk, HMAC_ALGO)); + mac.update(info); + mac.update((byte) 0x01); + byte[] okm = mac.doFinal(); + + byte[] result = new byte[length]; + System.arraycopy(okm, 0, result, 0, length); + return result; + } catch (Exception e) { + throw new RuntimeException("HKDF derivation failed", e); + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java new file mode 100644 index 0000000000000..e4d8357a9a1cc --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @author Andrew Fiddian-Green - Initial contribution + */ +public class TLV8Codec { + + public static final int MAX_TLV_LENGTH = 255; + + /** + * Encodes a map of TLV8 key-value pairs into a byte array. + */ + public static byte[] encode(Map tlvMap) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + for (Map.Entry entry : tlvMap.entrySet()) { + int type = entry.getKey(); + byte[] value = entry.getValue(); + + int offset = 0; + while (offset < value.length) { + int chunkLength = Math.min(MAX_TLV_LENGTH, value.length - offset); + out.write(type); + out.write(chunkLength); + out.write(value, offset, chunkLength); + offset += chunkLength; + } + } + + return out.toByteArray(); + } + + /** + * Decodes a TLV8 byte array into a map of key-value pairs. + */ + public static Map decode(byte[] data) { + Map tempMap = new LinkedHashMap<>(); + int index = 0; + + while (index + 2 <= data.length) { + int type = data[index++] & 0xFF; + int length = data[index++] & 0xFF; + + if (index + length > data.length) { + throw new IllegalArgumentException("Invalid TLV8 length"); + } + + byte[] chunk = Arrays.copyOfRange(data, index, index + length); + index += length; + + tempMap.computeIfAbsent(type, k -> new ByteArrayOutputStream()).writeBytes(chunk); + } + + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : tempMap.entrySet()) { + result.put(entry.getKey(), entry.getValue().toByteArray()); + } + + return result; + } + + /** + * Convenience method to encode a single TLV8 pair. + */ + public static byte[] encode(int type, byte[] value) { + return encode(Map.of(type, value)); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 2a71634826b6f..34cc62ce54454 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -42,13 +42,10 @@ public HomekitDeviceHandler(Thing thing) { @Override public void initialize() { // TODO Auto-generated method stub - } @Override public void handleCommand(ChannelUID channelUID, Command command) { // TODO Auto-generated method stub - } - } From 62aaa90fff997ee0c2634a40a5586654a9796738 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 10 Sep 2025 20:24:46 +0100 Subject: [PATCH 004/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/ChaCha20.java | 53 ++++++++++ .../internal/HomekitBindingConstants.java | 2 +- .../homekit/internal/HttpTransport.java | 30 ++++++ .../homekit/internal/PairingManager.java | 8 +- .../binding/homekit/internal/SRPClient.java | 30 +----- .../internal/SecureAccessoryClient.java | 70 ++++++++++++++ .../homekit/internal/SecureSession.java | 50 ++++++++++ .../config/HomekitDeviceConfiguration.java | 1 - .../HomekitAccessoryDiscoveryService.java | 64 +++++++++++++ .../HomekitMdnsDiscoveryParticipant.java | 18 ++-- .../internal/dto/HomekitAccessories.java | 28 ++++++ .../internal/dto/HomekitAccessory.java | 29 ++++++ .../internal/dto/HomekitCharacteristic.java | 32 +++++++ .../homekit/internal/dto/HomekitService.java | 30 ++++++ .../handler/BaseHomekitServerHandler.java | 79 +++++++++++++++ .../handler/HomekitAccessoryHandler.java | 85 ++++++++++++++++ .../handler/HomekitBridgeHandler.java | 96 ++++++++++++++++--- .../handler/HomekitDeviceHandler.java | 51 ---------- .../handler/HomekitHandlerFactory.java | 18 +++- 19 files changed, 665 insertions(+), 109 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HttpTransport.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureAccessoryClient.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitAccessoryDiscoveryService.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/BaseHomekitServerHandler.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java new file mode 100644 index 0000000000000..b0627d703d411 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import org.bouncycastle.crypto.modes.ChaCha20Poly1305; +import org.bouncycastle.crypto.params.AEADParameters; +import org.bouncycastle.crypto.params.KeyParameter; + +/** + * @author Andrew Fiddian-Green - Initial contribution + */ +public class ChaCha20 { + + public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) { + try { + ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); + cipher.init(true, params); + + byte[] out = new byte[cipher.getOutputSize(plaintext.length)]; + int len = cipher.processBytes(plaintext, 0, plaintext.length, out, 0); + cipher.doFinal(out, len); + return out; + } catch (Exception e) { + throw new RuntimeException("Encryption failed", e); + } + } + + public static byte[] decrypt(byte[] key, byte[] nonce, byte[] ciphertext) { + try { + ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); + cipher.init(false, params); + + byte[] out = new byte[cipher.getOutputSize(ciphertext.length)]; + int len = cipher.processBytes(ciphertext, 0, ciphertext.length, out, 0); + cipher.doFinal(out, len); + return out; + } catch (Exception e) { + throw new RuntimeException("Decryption failed", e); + } + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index f25df1ecbf829..717a0e328f80c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -28,7 +28,7 @@ public class HomekitBindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); - public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device"); + public static final ThingTypeUID THING_TYPE_ACCESSORY = new ThingTypeUID(BINDING_ID, "accessory"); // List of all Channel ids // TODO diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HttpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HttpTransport.java new file mode 100644 index 0000000000000..a14bdde82a724 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HttpTransport.java @@ -0,0 +1,30 @@ +package org.openhab.binding.homekit.internal; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +public class HttpTransport { + + private final HttpClient client; + + public HttpTransport() { + this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + } + + public byte[] post(String url, byte[] payload) throws Exception { + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/pairing+tlv8").header("Accept", "application/pairing+tlv8") + .POST(HttpRequest.BodyPublishers.ofByteArray(payload)).build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + + if (response.statusCode() != 200) { + throw new RuntimeException("Pairing failed: HTTP " + response.statusCode()); + } + + return response.body(); + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java index e80492734a844..6f94de6c85e17 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java @@ -25,10 +25,10 @@ public class PairingManager { private final SRPClient srpClient; - private final HttpClient client; + private final HttpClient httpClient; - public PairingManager(String setupCode) { - this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + public PairingManager(HttpClient httpClient, String setupCode) { + this.httpClient = httpClient; this.srpClient = new SRPClient(setupCode); } @@ -66,7 +66,7 @@ private byte[] post(String url, byte[] payload) throws Exception { .header("Content-Type", "application/pairing+tlv8").header("Accept", "application/pairing+tlv8") .POST(HttpRequest.BodyPublishers.ofByteArray(payload)).build(); - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); if (response.statusCode() != 200) { throw new RuntimeException("Pairing failed: HTTP " + response.statusCode()); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java index baaf117a5d3ca..d1ac0a601f4fb 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java @@ -18,10 +18,6 @@ import java.security.SecureRandom; import java.util.Map; -import org.bouncycastle.crypto.modes.ChaCha20Poly1305; -import org.bouncycastle.crypto.params.AEADParameters; -import org.bouncycastle.crypto.params.KeyParameter; - /** * @author Andrew Fiddian-Green - Initial contribution */ @@ -74,14 +70,14 @@ public Map generateEncryptedIdentifiers() throws Exception { // Encrypt controller identifier and public key using shared key K byte[] plaintext = "...".getBytes(); // TODO input TLV8 encoded identifiers byte[] nonce = generateNonce(); - byte[] encrypted = encryptChaCha20Poly1305(K, nonce, plaintext); + byte[] encrypted = ChaCha20.encrypt(K, nonce, plaintext); return Map.of(0x05, nonce, 0x06, encrypted); } public void verifyAccessoryIdentifiers(Map tlv6) throws Exception { byte[] nonce = tlv6.get(0x05); byte[] encrypted = tlv6.get(0x06); - byte[] decrypted = decryptChaCha20Poly1305(K, nonce, encrypted); + byte[] decrypted = ChaCha20.decrypt(K, nonce, encrypted); // TODO Parse TLV8 and validate accessory identity } @@ -118,26 +114,4 @@ private byte[] generateNonce() { new SecureRandom().nextBytes(nonce); return nonce; } - - private byte[] encryptChaCha20Poly1305(byte[] key, byte[] nonce, byte[] plaintext) throws Exception { - ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); - AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); - cipher.init(true, params); - - byte[] ciphertext = new byte[cipher.getOutputSize(plaintext.length)]; - int len = cipher.processBytes(plaintext, 0, plaintext.length, ciphertext, 0); - cipher.doFinal(ciphertext, len); - return ciphertext; - } - - private byte[] decryptChaCha20Poly1305(byte[] key, byte[] nonce, byte[] ciphertext) throws Exception { - ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); - AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); - cipher.init(false, params); - - byte[] plaintext = new byte[cipher.getOutputSize(ciphertext.length)]; - int len = cipher.processBytes(ciphertext, 0, ciphertext.length, plaintext, 0); - cipher.doFinal(plaintext, len); - return plaintext; - } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureAccessoryClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureAccessoryClient.java new file mode 100644 index 0000000000000..2519fa98f43fc --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureAccessoryClient.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * @author Andrew Fiddian-Green - Initial contribution + */ +public class SecureAccessoryClient { + + private final SecureSession session; + private final HttpClient httpClient; + private final String baseUrl; + + public SecureAccessoryClient(HttpClient httpClient, SecureSession session, String baseUrl) { + this.httpClient = httpClient; + this.session = session; + this.baseUrl = baseUrl; + } + + public String readCharacteristic(String aid, String iid) throws Exception { + String query = String.format("?id=%s.%s", aid, iid); + URI uri = URI.create(baseUrl + "/characteristics" + query); + + HttpRequest request = HttpRequest.newBuilder().uri(uri).timeout(Duration.ofSeconds(5)) + .header("Accept", "application/json").GET().build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return response.body(); // Optionally decrypt if accessory uses encrypted reads + } + + public void writeCharacteristic(String aid, String iid, Object value) throws Exception { + String json = String.format("{\"characteristics\":[{\"aid\":%s,\"iid\":%s,\"value\":%s}]}", aid, iid, + formatValue(value)); + + byte[] encryptedPayload = session.encrypt(json.getBytes()); + URI uri = URI.create(baseUrl + "/characteristics"); + + HttpRequest request = HttpRequest.newBuilder().uri(uri).timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofByteArray(encryptedPayload)).build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() != 200) { + throw new RuntimeException("Write failed: HTTP " + response.statusCode()); + } + } + + private String formatValue(Object value) { + if (value instanceof Boolean || value instanceof Number) { + return value.toString(); + } + return "\"" + value.toString() + "\""; + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java new file mode 100644 index 0000000000000..259f7db69d698 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * @author Andrew Fiddian-Green - Initial contribution + */ +public class SecureSession { + + private final byte[] writeKey; + private final byte[] readKey; + private final AtomicInteger writeCounter = new AtomicInteger(0); + private final AtomicInteger readCounter = new AtomicInteger(0); + + public SecureSession(SessionKeys keys) { + this.writeKey = keys.writeKey; + this.readKey = keys.readKey; + } + + public byte[] encrypt(byte[] plaintext) { + byte[] nonce = generateNonce(writeCounter.getAndIncrement()); + return ChaCha20.encrypt(writeKey, nonce, plaintext); + } + + public byte[] decrypt(byte[] ciphertext) { + byte[] nonce = generateNonce(readCounter.getAndIncrement()); + return ChaCha20.decrypt(readKey, nonce, ciphertext); + } + + private byte[] generateNonce(int counter) { + byte[] nonce = new byte[12]; + nonce[4] = (byte) ((counter >> 24) & 0xFF); + nonce[5] = (byte) ((counter >> 16) & 0xFF); + nonce[6] = (byte) ((counter >> 8) & 0xFF); + nonce[7] = (byte) (counter & 0xFF); + return nonce; + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java index 148d1cd3f539c..d360fabab01f1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java @@ -22,7 +22,6 @@ */ @NonNullByDefault public class HomekitDeviceConfiguration { - public @Nullable String ipV4Address; // dotted ipV4 address of the device public @Nullable String protocolVersion; // e.g. "1.0" HAP protocol version public @Nullable Integer deviceCategory; // e.g. 2 the HomeKit device category diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitAccessoryDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitAccessoryDiscoveryService.java new file mode 100644 index 0000000000000..97700c76b4f41 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitAccessoryDiscoveryService.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.discovery; + +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.THING_TYPE_ACCESSORY; + +import java.util.List; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.dto.HomekitAccessory; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; + +/** + * Discovery service to find resource things on a Hue Bridge that is running CLIP 2. + * + * @author Andrew Fiddian-Green - Initial Contribution + */ +@NonNullByDefault +@Component(service = DiscoveryService.class) +public class HomekitAccessoryDiscoveryService extends AbstractDiscoveryService { + + protected HomekitAccessoryDiscoveryService() { + super(Set.of(THING_TYPE_ACCESSORY), 10, false); + } + + @Override + protected void startScan() { + // do nothing + } + + public void accessoriesDscovered(Thing bridge, List accessories) { + accessories.forEach(accessory -> { + if (accessory.aid != null && accessory.services != null) { + accessory.services.forEach(service -> { + if (service.type != null && service.iid != null) { + String id = "%d-%d".formatted(accessory.aid, service.iid); + ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), id); + thingDiscovered(DiscoveryResultBuilder.create(uid) // + .withBridge(bridge.getUID()) // + .withLabel(service.type) // + .withProperty("uid", uid.toString()) // + .withRepresentationProperty("uid").build()); + } + }); + } + }); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 583b92adaaa3e..5a3ffdd8add6b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -12,7 +12,7 @@ */ package org.openhab.binding.homekit.internal.discovery; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.THING_TYPE_DEVICE; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.util.Set; @@ -46,7 +46,7 @@ public class HomekitMdnsDiscoveryParticipant implements MDNSDiscoveryParticipant @Override public Set getSupportedThingTypeUIDs() { - return Set.of(THING_TYPE_DEVICE); + return Set.of(THING_TYPE_ACCESSORY); } @Override @@ -80,10 +80,16 @@ public String getServiceType() { public @Nullable ThingUID getThingUID(ServiceInfo service) { String macAddress = service.getPropertyString("id"); if (macAddress != null) { - return new ThingUID(THING_TYPE_DEVICE, macAddress.replace(":", "-").toLowerCase()); - } else { - logger.warn("Discovered HomeKit device without MAC address property - ignoring"); - return null; + String deviceCategory = service.getPropertyString("ci"); // HomeKit device category + if (deviceCategory != null) { + if ("2".equals(deviceCategory)) { + return new ThingUID(THING_TYPE_BRIDGE, macAddress.replace(":", "-").toLowerCase()); + } else { + return new ThingUID(THING_TYPE_ACCESSORY, macAddress.replace(":", "-").toLowerCase()); + } + } } + logger.warn("Discovered HomeKit device without valid properties - ignoring"); + return null; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java new file mode 100644 index 0000000000000..0330f0d09ff30 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.dto; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Accessories DTO + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitAccessories { + public @Nullable List accessories; +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java new file mode 100644 index 0000000000000..00d33d51908e3 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.dto; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Accessory DTO + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitAccessory { + public @Nullable Integer aid; // e.g. 1 + public @Nullable List services; +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java new file mode 100644 index 0000000000000..2731ac28bccdc --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.dto; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Characteristic DTO + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitCharacteristic { + public @Nullable String type; // e.g. public.hap.characteristic.on + public @Nullable Integer iid; // e.g. 10 + public @Nullable String value; // e.g. true + public @Nullable List perms; // e.g. ["read", "write", "events"] + public @Nullable String format; // e.g. "bool" +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java new file mode 100644 index 0000000000000..f37f63ec8975e --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.dto; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Service DTO + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitService { + public @Nullable String type; // e.g. public.hap.service.lightbulb + public @Nullable Integer iid; // e.g. 10 + public @Nullable List characteristics; +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/BaseHomekitServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/BaseHomekitServerHandler.java new file mode 100644 index 0000000000000..dc788acd5ddfb --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/BaseHomekitServerHandler.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.handler; + +import java.net.http.HttpClient; +import java.time.Duration; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.PairingManager; +import org.openhab.binding.homekit.internal.SecureAccessoryClient; +import org.openhab.binding.homekit.internal.SecureSession; +import org.openhab.binding.homekit.internal.SessionKeys; +import org.openhab.binding.homekit.internal.discovery.HomekitAccessoryDiscoveryService; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link BaseHomekitServerHandler} handles I/O with HomeKit servers. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class BaseHomekitServerHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(BaseHomekitServerHandler.class); + + protected final HttpClient httpClient; + protected final HomekitAccessoryDiscoveryService discoveryService; + + protected @Nullable SecureAccessoryClient accessoryClient; + protected @Nullable SessionKeys keys; + protected @Nullable SecureSession session; + protected @Nullable String accessoryAddress; + protected @Nullable String setupCode; + + public BaseHomekitServerHandler(Thing thing, HomekitAccessoryDiscoveryService discoveryService) { + super(thing); + this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + this.discoveryService = discoveryService; + } + + @Override + public void initialize() { + this.accessoryAddress = getConfig().get("ipV4Address").toString(); + this.setupCode = getConfig().get("setupCode").toString(); + try { + // pairing and session setup + this.keys = new PairingManager(httpClient, setupCode).pair(accessoryAddress); + this.session = new SecureSession(keys); + this.accessoryClient = new SecureAccessoryClient(httpClient, session, accessoryAddress); + updateStatus(ThingStatus.ONLINE); + } catch (Exception e) { + logger.error("Failed to initialize HomeKit client", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // override in subclass + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java new file mode 100644 index 0000000000000..87fb6c459b969 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.handler; + +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.SecureAccessoryClient; +import org.openhab.binding.homekit.internal.discovery.HomekitAccessoryDiscoveryService; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link HomekitAccessoryHandler} is an instance of a {@link BaseHomekitServerHandler} that + * handles a single HomeKit accessory. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitAccessoryHandler extends BaseHomekitServerHandler { + + private final Logger logger = LoggerFactory.getLogger(HomekitAccessoryHandler.class); + + public HomekitAccessoryHandler(Thing thing, HomekitAccessoryDiscoveryService discoveryService) { + super(thing, discoveryService); + } + + @Override + public void initialize() { + super.initialize(); + scheduler.scheduleAtFixedRate(this::poll, 0, 60, TimeUnit.SECONDS); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + SecureAccessoryClient accessoryClient = this.accessoryClient; + if (accessoryClient != null) { + String channelId = channelUID.getId(); + try { + switch (channelId) { + case "power": + boolean value = command.equals(OnOffType.ON); + accessoryClient.writeCharacteristic("1", "10", value); // Example AID/IID + break; + // TODO Add more channels here + default: + logger.warn("Unhandled channel: {}", channelId); + } + } catch (Exception e) { + logger.error("Failed to send command to accessory", e); + } + } + } + + private void poll() { + SecureAccessoryClient accessoryClient = this.accessoryClient; + if (accessoryClient != null) { + try { + String power = accessoryClient.readCharacteristic("1", "10"); // Example AID/IID + // Parse powerState and update channel state accordingly + if ("true".equals(power)) { + updateState(new ChannelUID(getThing().getUID(), "power"), OnOffType.ON); + } else { + updateState(new ChannelUID(getThing().getUID(), "power"), OnOffType.OFF); + } + } catch (Exception e) { + logger.error("Failed to poll accessory state", e); + } + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index cd7aca0aa4b2f..6e1daf34f6e71 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -12,41 +12,107 @@ */ package org.openhab.binding.homekit.internal.handler; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; + import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.config.HomekitBridgeConfiguration; +import org.openhab.binding.homekit.internal.SecureSession; +import org.openhab.binding.homekit.internal.discovery.HomekitAccessoryDiscoveryService; +import org.openhab.binding.homekit.internal.dto.HomekitAccessories; +import org.openhab.binding.homekit.internal.dto.HomekitAccessory; import org.openhab.core.thing.Bridge; -import org.openhab.core.thing.ChannelUID; -import org.openhab.core.thing.binding.BaseBridgeHandler; -import org.openhab.core.types.Command; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.BridgeHandler; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.builder.BridgeBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; + /** - * The {@link HomekitBridgeHandler} is responsible for marshalling communications with HomeKit device servers. + * The {@link HomekitBridgeHandler} is an instance of a {@link BaseHomekitServerHandler} that + * marshals communications with multiple HomeKit accessories within a HomeKit bridge server. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomekitBridgeHandler extends BaseBridgeHandler { +public class HomekitBridgeHandler extends BaseHomekitServerHandler implements BridgeHandler { private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); - private @Nullable HomekitBridgeConfiguration config; + private static final Gson GSON = new Gson(); + + public HomekitBridgeHandler(Bridge bridge, HomekitAccessoryDiscoveryService discoveryService) { + super(bridge, discoveryService); + } + + @Override + public Bridge getThing() { + return (Bridge) super.getThing(); + } - public HomekitBridgeHandler(Bridge bridge) { - super(bridge); + /** + * Creates a bridge builder, which allows to modify the bridge. The method + * {@link BaseThingHandler#updateThing(Thing)} must be called to persist the changes. + * + * @return {@link BridgeBuilder} which builds an exact copy of the bridge + */ + @Override + protected BridgeBuilder editThing() { + return BridgeBuilder.create(thing.getThingTypeUID(), thing.getUID()).withBridge(thing.getBridgeUID()) + .withChannels(thing.getChannels()).withConfiguration(thing.getConfiguration()) + .withLabel(thing.getLabel()).withLocation(thing.getLocation()).withProperties(thing.getProperties()) + .withSemanticEquipmentTag(thing.getSemanticEquipmentTag()); } @Override public void initialize() { - // TODO initialize the overall HomeKit user credentials - // TODO initialise mDNS discovery of HomeKit device servers - // TODO set state to ONLINE if successful + super.initialize(); + scheduler.submit(() -> { + List accessories = getAccessories(); + discoveryService.accessoriesDscovered(thing, accessories); + }); } @Override - public void handleCommand(ChannelUID channelUID, Command command) { - // Not used - Bridge has no channels + public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) { + // TODO Auto-generated method stub } + + @Override + public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { + // TODO Auto-generated method stub + + } + + /** + * Get information about embedded accessories and their respective channels + */ + private List getAccessories() { + HomekitAccessories result = null; + SecureSession session = this.session; + if (session != null) { + URI uri = URI.create(accessoryAddress + "/accessories"); + HttpRequest request = HttpRequest.newBuilder().uri(uri).timeout(Duration.ofSeconds(5)) + .header("Accept", "application/json").GET().build(); + HttpResponse response; + try { + response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() == 200) { + byte[] decrypted = session.decrypt(response.body()); + result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), HomekitAccessories.class); + } + } catch (IOException | InterruptedException e) { + } + } + return result == null ? List.of() : result.accessories == null ? List.of() : result.accessories; + } + } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java deleted file mode 100644 index 34cc62ce54454..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.handler; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.config.HomekitDeviceConfiguration; -import org.openhab.core.thing.ChannelUID; -import org.openhab.core.thing.Thing; -import org.openhab.core.thing.binding.BaseThingHandler; -import org.openhab.core.types.Command; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The {@link HomekitDeviceHandler} is represents a HomeKit device server. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class HomekitDeviceHandler extends BaseThingHandler { - - private final Logger logger = LoggerFactory.getLogger(HomekitDeviceHandler.class); - - private @Nullable HomekitDeviceConfiguration config; - - public HomekitDeviceHandler(Thing thing) { - super(thing); - // TODO Auto-generated constructor stub - } - - @Override - public void initialize() { - // TODO Auto-generated method stub - } - - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - // TODO Auto-generated method stub - } -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java index b470e65d8dd47..29967fdb54b48 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java @@ -18,13 +18,16 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.discovery.HomekitAccessoryDiscoveryService; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; /** * The {@link HomekitHandlerFactory} is responsible for creating things and thing @@ -33,10 +36,16 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -@Component(configurationPid = "binding.homekit", service = ThingHandlerFactory.class) +@Component(service = ThingHandlerFactory.class) public class HomekitHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE); + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_ACCESSORY); + private final HomekitAccessoryDiscoveryService discoveryService; + + @Activate + public HomekitHandlerFactory(@Reference HomekitAccessoryDiscoveryService discoveryService) { + this.discoveryService = discoveryService; + } @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { @@ -48,7 +57,10 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { - return new HomekitBridgeHandler((Bridge) thing); + return new HomekitBridgeHandler((Bridge) thing, discoveryService); + } + if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { + return new HomekitAccessoryHandler(thing, discoveryService); } return null; From 1a1824a3914216e3bf0f0b8d68991f27cc75abe4 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 11 Sep 2025 00:36:06 +0100 Subject: [PATCH 005/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/ChaCha20.java | 23 ++++ .../internal/HomekitBindingConstants.java | 8 +- .../homekit/internal/HttpTransport.java | 30 ----- .../homekit/internal/PairingManager.java | 60 ++++++---- .../binding/homekit/internal/SRPClient.java | 62 +++++++++- .../internal/SecureAccessoryClient.java | 70 ------------ .../homekit/internal/SecureClient.java | 106 ++++++++++++++++++ .../homekit/internal/SecureSession.java | 23 ++++ .../binding/homekit/internal/SessionKeys.java | 4 + .../binding/homekit/internal/TLV8Codec.java | 4 + .../config/HomekitBridgeConfiguration.java | 31 ----- .../config/HomekitDeviceConfiguration.java | 29 ----- ...ava => HomekitDeviceDiscoveryService.java} | 18 +-- .../HomekitMdnsDiscoveryParticipant.java | 17 ++- .../internal/dto/HomekitAccessories.java | 4 +- .../internal/dto/HomekitAccessory.java | 4 +- .../internal/dto/HomekitCharacteristic.java | 4 +- .../homekit/internal/dto/HomekitService.java | 4 +- .../handler/BaseHomekitServerHandler.java | 79 ------------- .../handler/HomekitBaseServerHandler.java | 93 +++++++++++++++ .../handler/HomekitBridgeHandler.java | 69 +++++++----- ...Handler.java => HomekitDeviceHandler.java} | 37 ++++-- .../handler/HomekitHandlerFactory.java | 26 +++-- 23 files changed, 474 insertions(+), 331 deletions(-) delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HttpTransport.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureAccessoryClient.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureClient.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitBridgeConfiguration.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/{HomekitAccessoryDiscoveryService.java => HomekitDeviceDiscoveryService.java} (70%) delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/BaseHomekitServerHandler.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/{HomekitAccessoryHandler.java => HomekitDeviceHandler.java} (65%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java index b0627d703d411..aca3a8878d5e1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java @@ -17,10 +17,25 @@ import org.bouncycastle.crypto.params.KeyParameter; /** + * ChaCha20 encryption and decryption utility class. + * Uses BouncyCastle's ChaCha20Poly1305 implementation. + * Requires a 32-byte key and a 12-byte nonce. + * The nonce must be unique for each encryption operation with the same key. + * The ciphertext includes the authentication tag. + * See RFC 8439 for more details. + * * @author Andrew Fiddian-Green - Initial contribution */ public class ChaCha20 { + /** + * Encrypts the given plaintext using ChaCha20-Poly1305. + * + * @param key 32-byte encryption key + * @param nonce 12-byte nonce + * @param plaintext data to encrypt + * @return encrypted data (ciphertext + authentication tag) + */ public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) { try { ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); @@ -36,6 +51,14 @@ public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) { } } + /** + * Decrypts the given ciphertext using ChaCha20-Poly1305. + * + * @param key 32-byte decryption key + * @param nonce 12-byte nonce + * @param ciphertext data to decrypt (ciphertext + authentication tag) + * @return decrypted data (plaintext) + */ public static byte[] decrypt(byte[] key, byte[] nonce, byte[] ciphertext) { try { ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 717a0e328f80c..ce7d9a3abfb67 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -16,8 +16,7 @@ import org.openhab.core.thing.ThingTypeUID; /** - * The {@link HomekitBindingConstants} class defines common constants, which are - * used across the whole binding. + * Defines common constants which are used across the whole HomeKit binding. * * @author Andrew Fiddian-Green - Initial contribution */ @@ -28,9 +27,6 @@ public class HomekitBindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); - public static final ThingTypeUID THING_TYPE_ACCESSORY = new ThingTypeUID(BINDING_ID, "accessory"); + public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device"); - // List of all Channel ids - // TODO - public static final String CHANNEL_1 = "channel1"; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HttpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HttpTransport.java deleted file mode 100644 index a14bdde82a724..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HttpTransport.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.openhab.binding.homekit.internal; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; - -public class HttpTransport { - - private final HttpClient client; - - public HttpTransport() { - this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); - } - - public byte[] post(String url, byte[] payload) throws Exception { - HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(5)) - .header("Content-Type", "application/pairing+tlv8").header("Accept", "application/pairing+tlv8") - .POST(HttpRequest.BodyPublishers.ofByteArray(payload)).build(); - - HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); - - if (response.statusCode() != 200) { - throw new RuntimeException("Pairing failed: HTTP " + response.statusCode()); - } - - return response.body(); - } -} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java index 6f94de6c85e17..8c47d6f1d2997 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java @@ -12,14 +12,23 @@ */ package org.openhab.binding.homekit.internal; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.BytesContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; /** + * Handles the 6-step pairing process with a HomeKit accessory. + * Uses SRP for secure key exchange and derives session keys. + * Communicates with the accessory using HTTP and TLV8 encoding. + * Requires the accessory's setup code for pairing. + * Returns session keys upon successful pairing. + * * @author Andrew Fiddian-Green - Initial contribution */ public class PairingManager { @@ -32,10 +41,15 @@ public PairingManager(HttpClient httpClient, String setupCode) { this.srpClient = new SRPClient(setupCode); } - public SessionKeys pair(String accessoryAddress) throws Exception { + /** + * Initiates the pairing process with the accessory at the given address. + * + * @param baseUrl the base URL of the accessory (e.g., "http://123.123.123.123:port") + */ + public SessionKeys pair(String baseUrl) throws Exception { // Step 1: M1 — Start Pairing byte[] m1 = TLV8Codec.encode(Map.of(0x00, new byte[] { 0x00 }, 0x01, new byte[] { 0x01 })); - byte[] resp1 = post(accessoryAddress + "/pair-setup", m1); + byte[] resp1 = post(baseUrl + "/pair-setup", m1); // Step 2: M2 — Receive SRP salt and public key Map tlv2 = TLV8Codec.decode(resp1); @@ -43,7 +57,7 @@ public SessionKeys pair(String accessoryAddress) throws Exception { // Step 3: M3 — Send SRP public key and proof Map m3 = srpClient.generateClientProof(); - byte[] resp3 = post(accessoryAddress + "/pair-setup", TLV8Codec.encode(m3)); + byte[] resp3 = post(baseUrl + "/pair-setup", TLV8Codec.encode(m3)); // Step 4: M4 — Verify server proof Map tlv4 = TLV8Codec.decode(resp3); @@ -51,7 +65,7 @@ public SessionKeys pair(String accessoryAddress) throws Exception { // Step 5: M5 — Exchange encrypted identifiers Map m5 = srpClient.generateEncryptedIdentifiers(); - byte[] resp5 = post(accessoryAddress + "/pair-setup", TLV8Codec.encode(m5)); + byte[] resp5 = post(baseUrl + "/pair-setup", TLV8Codec.encode(m5)); // Step 6: M6 — Final confirmation Map tlv6 = TLV8Codec.decode(resp5); @@ -61,17 +75,25 @@ public SessionKeys pair(String accessoryAddress) throws Exception { return srpClient.deriveSessionKeys(); } + /** + * Sends a POST request with the given payload to the specified URL. + * + * @param url the target URL + * @param payload the request body + * @return the response body + * @throws Exception if an error occurs during the request + */ private byte[] post(String url, byte[] payload) throws Exception { - HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofSeconds(5)) - .header("Content-Type", "application/pairing+tlv8").header("Accept", "application/pairing+tlv8") - .POST(HttpRequest.BodyPublishers.ofByteArray(payload)).build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); - - if (response.statusCode() != 200) { - throw new RuntimeException("Pairing failed: HTTP " + response.statusCode()); + Request request = httpClient.newRequest(url) // + .timeout(5, TimeUnit.SECONDS) // + .method(HttpMethod.POST) // + .header(HttpHeader.CONTENT_TYPE, "application/pairing+tlv8") // + .header(HttpHeader.ACCEPT, "application/json") // + .content(new BytesContentProvider(payload)); + ContentResponse response = request.send(); + if (response.getStatus() != 200) { + throw new RuntimeException("Pairing failed: HTTP " + response.getStatus()); } - - return response.body(); + return response.getContent(); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java index d1ac0a601f4fb..215b8231bdf78 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java @@ -19,11 +19,32 @@ import java.util.Map; /** + * Implements the client side of the Secure Remote Password (SRP) protocol for HomeKit pairing. + * This class handles the SRP handshake, proof generation, and verification. + * It also manages the encryption and decryption of identifiers using the shared session key. + * * @author Andrew Fiddian-Green - Initial contribution */ public class SRPClient { - private static final BigInteger N = new BigInteger("..."); // TODO 3072-bit safe prime + // HomeKit 3072-bit prime from RFC 5054 + public static final String N_HEX = + //@formatter:off + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" + + "020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" + + "4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" + + "98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" + + "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" + + "3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33" + + "A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" + + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864" + + "D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E2" + + "08E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"; + //@formatter:on + + private static final BigInteger N = new BigInteger(N_HEX); private static final BigInteger g = BigInteger.valueOf(5); private final String setupCode; @@ -37,15 +58,28 @@ public SRPClient(String setupCode) { this.setupCode = setupCode; } + /** + * Processes the server's SRP challenge by storing the salt and server public key, + * and generating the client's ephemeral keys. + * + * @param salt The salt provided by the server. + * @param serverPublicKey The server's public key (B). + * @throws Exception If an error occurs during processing. + */ public void processChallenge(byte[] salt, byte[] serverPublicKey) throws Exception { this.salt = salt; this.B = new BigInteger(1, serverPublicKey); - SecureRandom random = new SecureRandom(); this.a = new BigInteger(256, random); this.A = g.modPow(a, N); } + /** + * Generates the client's proof of knowledge (M1) and returns it along with the client's public key (A). + * + * @return A map containing the client's public key (A) and proof (M1). + * @throws Exception If an error occurs during proof generation. + */ public Map generateClientProof() throws Exception { MessageDigest digest = MessageDigest.getInstance("SHA-512"); byte[] xH = digest.digest((new String(salt) + setupCode).getBytes()); @@ -59,6 +93,12 @@ public Map generateClientProof() throws Exception { return Map.of(0x03, A.toByteArray(), 0x04, M1); } + /** + * Verifies the server's proof (M2) against the expected value. + * + * @param M2 The server's proof to verify. + * @throws Exception If an error occurs during verification or if the proof does not match. + */ public void verifyServerProof(byte[] M2) throws Exception { byte[] expected = computeM2(A, computeM1(A, B, K), K); if (!MessageDigest.isEqual(M2, expected)) { @@ -66,6 +106,13 @@ public void verifyServerProof(byte[] M2) throws Exception { } } + /** + * Generates encrypted identifiers using the shared session key (K). + * This includes encrypting the controller's identifier and public key. + * + * @return A map containing the nonce and encrypted data. + * @throws Exception If an error occurs during encryption. + */ public Map generateEncryptedIdentifiers() throws Exception { // Encrypt controller identifier and public key using shared key K byte[] plaintext = "...".getBytes(); // TODO input TLV8 encoded identifiers @@ -74,6 +121,12 @@ public Map generateEncryptedIdentifiers() throws Exception { return Map.of(0x05, nonce, 0x06, encrypted); } + /** + * Verifies the accessory's encrypted identifiers using the shared session key (K). + * + * @param tlv6 A map containing the nonce and encrypted data from the accessory. + * @throws Exception If an error occurs during decryption or verification. + */ public void verifyAccessoryIdentifiers(Map tlv6) throws Exception { byte[] nonce = tlv6.get(0x05); byte[] encrypted = tlv6.get(0x06); @@ -81,6 +134,11 @@ public void verifyAccessoryIdentifiers(Map tlv6) throws Excepti // TODO Parse TLV8 and validate accessory identity } + /** + * Derives session keys for encrypting and decrypting messages between the HomeKit controller and accessory. + * + * @return An instance of SessionKeys containing the derived read and write keys. + */ public SessionKeys deriveSessionKeys() { return new SessionKeys(K); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureAccessoryClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureAccessoryClient.java deleted file mode 100644 index 2519fa98f43fc..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureAccessoryClient.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; - -/** - * @author Andrew Fiddian-Green - Initial contribution - */ -public class SecureAccessoryClient { - - private final SecureSession session; - private final HttpClient httpClient; - private final String baseUrl; - - public SecureAccessoryClient(HttpClient httpClient, SecureSession session, String baseUrl) { - this.httpClient = httpClient; - this.session = session; - this.baseUrl = baseUrl; - } - - public String readCharacteristic(String aid, String iid) throws Exception { - String query = String.format("?id=%s.%s", aid, iid); - URI uri = URI.create(baseUrl + "/characteristics" + query); - - HttpRequest request = HttpRequest.newBuilder().uri(uri).timeout(Duration.ofSeconds(5)) - .header("Accept", "application/json").GET().build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - return response.body(); // Optionally decrypt if accessory uses encrypted reads - } - - public void writeCharacteristic(String aid, String iid, Object value) throws Exception { - String json = String.format("{\"characteristics\":[{\"aid\":%s,\"iid\":%s,\"value\":%s}]}", aid, iid, - formatValue(value)); - - byte[] encryptedPayload = session.encrypt(json.getBytes()); - URI uri = URI.create(baseUrl + "/characteristics"); - - HttpRequest request = HttpRequest.newBuilder().uri(uri).timeout(Duration.ofSeconds(5)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofByteArray(encryptedPayload)).build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.discarding()); - if (response.statusCode() != 200) { - throw new RuntimeException("Write failed: HTTP " + response.statusCode()); - } - } - - private String formatValue(Object value) { - if (value instanceof Boolean || value instanceof Number) { - return value.toString(); - } - return "\"" + value.toString() + "\""; - } -} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureClient.java new file mode 100644 index 0000000000000..d72767326bc17 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureClient.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.BytesContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; + +import com.google.gson.JsonSyntaxException; + +/** + * HTTP client methods for reading and writing HomeKit accessory characteristics over a secure session. + * It handles encryption and decryption of requests and responses. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +public class SecureClient { + + private final SecureSession session; + private final HttpClient httpClient; + private final String baseUrl; + + public SecureClient(HttpClient httpClient, SecureSession session, String baseUrl) { + this.httpClient = httpClient; + this.session = session; + this.baseUrl = baseUrl; + } + + /** + * Reads a characteristic from the accessory. + * + * @param aid Accessory ID + * @param iid Instance ID + * @return JSON response as String + */ + public String readCharacteristic(String aid, String iid) { + String query = String.format("?id=%s.%s", aid, iid); + Request request = httpClient.newRequest(baseUrl + "/characteristics" + query) // + .timeout(5, TimeUnit.SECONDS) // + .method(HttpMethod.GET) // + .header(HttpHeader.ACCEPT, "application/json"); + try { + ContentResponse response = request.send(); + if (response.getStatus() == 200) { + byte[] encrypted = response.getContent(); + return new String(session.decrypt(encrypted), StandardCharsets.UTF_8); + } + } catch (TimeoutException | ExecutionException | InterruptedException | JsonSyntaxException e) { + } + return ""; + } + + /** + * Writes a characteristic to the accessory. + * + * @param aid Accessory ID + * @param iid Instance ID + * @param value Value to write (String, Number, Boolean) + * @throws Exception on communication or encryption errors + */ + public void writeCharacteristic(String aid, String iid, Object value) throws Exception { + String json = String.format("{\"characteristics\":[{\"aid\":%s,\"iid\":%s,\"value\":%s}]}", aid, iid, + formatValue(value)); + byte[] encryptedPayload = session.encrypt(json.getBytes()); + Request request = httpClient.newRequest(baseUrl + "/characteristics") // + .timeout(5, TimeUnit.SECONDS) // + .method(HttpMethod.PUT) // + .header(HttpHeader.CONTENT_TYPE, "application/json") // + .content(new BytesContentProvider(encryptedPayload)); + try { + ContentResponse response = request.send(); + if (response.getStatus() != 200) { + throw new RuntimeException("Write failed: HTTP " + response.getStatus()); + } + } catch (TimeoutException | ExecutionException | InterruptedException | JsonSyntaxException e) { + } + } + + /* + * Formats the value for JSON. Strings are quoted, numbers and booleans are not. + */ + private String formatValue(Object value) { + if (value instanceof Boolean || value instanceof Number) { + return value.toString(); + } + return "\"" + value.toString() + "\""; + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java index 259f7db69d698..ba82f263ea1bf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java @@ -15,6 +15,10 @@ import java.util.concurrent.atomic.AtomicInteger; /** + * Manages a secure session using ChaCha20 encryption for a HomeKit accessory. + * This class handles encryption and decryption of messages using session keys. + * It maintains separate counters for read and write operations to ensure nonce uniqueness. + * * @author Andrew Fiddian-Green - Initial contribution */ public class SecureSession { @@ -29,16 +33,35 @@ public SecureSession(SessionKeys keys) { this.readKey = keys.readKey; } + /** + * * Encrypts the given plaintext using the write key and a unique nonce. + * + * @param plaintext The plaintext to encrypt. + * @return The encrypted ciphertext. + */ public byte[] encrypt(byte[] plaintext) { byte[] nonce = generateNonce(writeCounter.getAndIncrement()); return ChaCha20.encrypt(writeKey, nonce, plaintext); } + /** + * Decrypts the given ciphertext using the read key and a unique nonce. + * + * @param ciphertext The ciphertext to decrypt. + * @return The decrypted plaintext. + */ public byte[] decrypt(byte[] ciphertext) { byte[] nonce = generateNonce(readCounter.getAndIncrement()); return ChaCha20.decrypt(readKey, nonce, ciphertext); } + /** + * * Generates a 12-byte nonce using the given counter. + * The first 4 bytes are zero, and the last 8 bytes are the counter in big-endian format. + * + * @param counter The counter value. + * @return The generated nonce. + */ private byte[] generateNonce(int counter) { byte[] nonce = new byte[12]; nonce[4] = (byte) ((counter >> 24) & 0xFF); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java index b0866625cf24f..44f3466527d54 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java @@ -18,6 +18,10 @@ import javax.crypto.spec.SecretKeySpec; /** + * Derives session keys for encrypting and decrypting messages between a HomeKit controller and accessory. + * Uses HKDF with HMAC-SHA512 as the underlying hash function. + * The derived keys are used for ChaCha20 encryption in the secure session. + * * @author Andrew Fiddian-Green - Initial contribution */ public class SessionKeys { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java index e4d8357a9a1cc..9960768334981 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java @@ -18,6 +18,10 @@ import java.util.Map; /** + * Utility class for encoding and decoding TLV8 (Type-Length-Value) data. + * TLV8 is used in HomeKit for structured data exchange. + * Handles splitting and combining values that exceed the maximum length of 255 bytes. + * * @author Andrew Fiddian-Green - Initial contribution */ public class TLV8Codec { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitBridgeConfiguration.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitBridgeConfiguration.java deleted file mode 100644 index f3d46a80cf254..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitBridgeConfiguration.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.config; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * The {@link HomekitBridgeConfiguration} contains fields mapping bridge configuration parameters. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class HomekitBridgeConfiguration { - - /** - * Sample configuration parameters. Replace with your own. - */ - public String hostname = ""; - public String password = ""; - public int refreshInterval = 600; -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java deleted file mode 100644 index d360fabab01f1..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/config/HomekitDeviceConfiguration.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.config; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; - -/** - * The {@link HomekitBridgeConfiguration} contains fields mapping device configuration parameters. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class HomekitDeviceConfiguration { - public @Nullable String ipV4Address; // dotted ipV4 address of the device - public @Nullable String protocolVersion; // e.g. "1.0" HAP protocol version - public @Nullable Integer deviceCategory; // e.g. 2 the HomeKit device category - public @Nullable String pairingCode; // e.g. "031-45-154" the device pairing code -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitAccessoryDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitDeviceDiscoveryService.java similarity index 70% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitAccessoryDiscoveryService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitDeviceDiscoveryService.java index 97700c76b4f41..19b70a2c828f9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitAccessoryDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitDeviceDiscoveryService.java @@ -12,7 +12,7 @@ */ package org.openhab.binding.homekit.internal.discovery; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.THING_TYPE_ACCESSORY; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.THING_TYPE_DEVICE; import java.util.List; import java.util.Set; @@ -27,30 +27,32 @@ import org.osgi.service.component.annotations.Component; /** - * Discovery service to find resource things on a Hue Bridge that is running CLIP 2. + * Discovery service component that publishes newly discovered child accessories of a HomeKit bridge accessory. + * No active scanning is performed; it relies on being informed of new accessories by the bridge handler. + * Discovered accessories are published with a ThingUID based on their accessory ID (aid) and service ID (iid). * * @author Andrew Fiddian-Green - Initial Contribution */ @NonNullByDefault @Component(service = DiscoveryService.class) -public class HomekitAccessoryDiscoveryService extends AbstractDiscoveryService { +public class HomekitDeviceDiscoveryService extends AbstractDiscoveryService { - protected HomekitAccessoryDiscoveryService() { - super(Set.of(THING_TYPE_ACCESSORY), 10, false); + protected HomekitDeviceDiscoveryService() { + super(Set.of(THING_TYPE_DEVICE), 10, false); } @Override protected void startScan() { - // do nothing + // no scanning is done; it relies on being informed of new accessories } - public void accessoriesDscovered(Thing bridge, List accessories) { + public void devicesDiscovered(Thing bridge, List accessories) { accessories.forEach(accessory -> { if (accessory.aid != null && accessory.services != null) { accessory.services.forEach(service -> { if (service.type != null && service.iid != null) { String id = "%d-%d".formatted(accessory.aid, service.iid); - ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), id); + ThingUID uid = new ThingUID(THING_TYPE_DEVICE, bridge.getUID(), id); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // .withLabel(service.type) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 5a3ffdd8add6b..ec21a37acdb85 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -31,8 +31,17 @@ import org.slf4j.LoggerFactory; /** - * The {@link HomekitMdnsDiscoveryParticipant} is responsible for discovering new HomeKit server devices. - * It uses the central {@link org.openhab.core.config.discovery.mdns.internal.MDNSDiscoveryService}. + * Discovers new HomeKit server devices. + * HomeKit devices advertise themselves using mDNS with the service type "_hap._tcp.local.". + * Each device is identified by its MAC address, which is included in the mDNS properties. + * The device category is also included, allowing differentiation between bridges and accessories. + * The discovery participant creates a ThingUID based on the MAC address and device category. + * Discovered devices are published as Things of type + * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_DEVICE} + * or {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGE}. + * Discovered Things include properties such as model name, protocol version, and IP address. + * This class does not perform active scanning; instead, it relies on the central mDNS discovery + * service to notify it of new services. * * @author Andrew Fiddian-Green - Initial contribution */ @@ -46,7 +55,7 @@ public class HomekitMdnsDiscoveryParticipant implements MDNSDiscoveryParticipant @Override public Set getSupportedThingTypeUIDs() { - return Set.of(THING_TYPE_ACCESSORY); + return Set.of(THING_TYPE_DEVICE); } @Override @@ -85,7 +94,7 @@ public String getServiceType() { if ("2".equals(deviceCategory)) { return new ThingUID(THING_TYPE_BRIDGE, macAddress.replace(":", "-").toLowerCase()); } else { - return new ThingUID(THING_TYPE_ACCESSORY, macAddress.replace(":", "-").toLowerCase()); + return new ThingUID(THING_TYPE_DEVICE, macAddress.replace(":", "-").toLowerCase()); } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java index 0330f0d09ff30..c3d5fda574c73 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java @@ -18,7 +18,9 @@ import org.eclipse.jdt.annotation.Nullable; /** - * Accessories DTO + * HomeKit accessories DTO. + * Used to deserialize the JSON response from the /accessories endpoint of a HomeKit bridge. + * Contains a list of HomeKitAccessory objects. * * @author Andrew Fiddian-Green - Initial contribution */ diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java index 00d33d51908e3..f1226a0eceffc 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java @@ -18,7 +18,9 @@ import org.eclipse.jdt.annotation.Nullable; /** - * Accessory DTO + * HomeKit accessory DTO + * Used to deserialize individual accessories from the /accessories endpoint of a HomeKit bridge. + * Each accessory has an accessory ID (aid) and a list of services. * * @author Andrew Fiddian-Green - Initial contribution */ diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java index 2731ac28bccdc..0b98958026123 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java @@ -18,7 +18,9 @@ import org.eclipse.jdt.annotation.Nullable; /** - * Characteristic DTO + * HomeKit characteristic DTO. + * Used to deserialize individual characteristics from the /accessories endpoint of a HomeKit bridge. + * Each characteristic has a type, instance ID (iid), value, permissions (perms), and format. * * @author Andrew Fiddian-Green - Initial contribution */ diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java index f37f63ec8975e..b6ef9427549ba 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java @@ -18,7 +18,9 @@ import org.eclipse.jdt.annotation.Nullable; /** - * Service DTO + * HomeKit service DTO. + * Used to deserialize individual services from the /accessories endpoint of a HomeKit bridge. + * Each service has a type, instance ID (iid), and a list of characteristics. * * @author Andrew Fiddian-Green - Initial contribution */ diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/BaseHomekitServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/BaseHomekitServerHandler.java deleted file mode 100644 index dc788acd5ddfb..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/BaseHomekitServerHandler.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.handler; - -import java.net.http.HttpClient; -import java.time.Duration; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.PairingManager; -import org.openhab.binding.homekit.internal.SecureAccessoryClient; -import org.openhab.binding.homekit.internal.SecureSession; -import org.openhab.binding.homekit.internal.SessionKeys; -import org.openhab.binding.homekit.internal.discovery.HomekitAccessoryDiscoveryService; -import org.openhab.core.thing.ChannelUID; -import org.openhab.core.thing.Thing; -import org.openhab.core.thing.ThingStatus; -import org.openhab.core.thing.ThingStatusDetail; -import org.openhab.core.thing.binding.BaseThingHandler; -import org.openhab.core.types.Command; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The {@link BaseHomekitServerHandler} handles I/O with HomeKit servers. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class BaseHomekitServerHandler extends BaseThingHandler { - - private final Logger logger = LoggerFactory.getLogger(BaseHomekitServerHandler.class); - - protected final HttpClient httpClient; - protected final HomekitAccessoryDiscoveryService discoveryService; - - protected @Nullable SecureAccessoryClient accessoryClient; - protected @Nullable SessionKeys keys; - protected @Nullable SecureSession session; - protected @Nullable String accessoryAddress; - protected @Nullable String setupCode; - - public BaseHomekitServerHandler(Thing thing, HomekitAccessoryDiscoveryService discoveryService) { - super(thing); - this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); - this.discoveryService = discoveryService; - } - - @Override - public void initialize() { - this.accessoryAddress = getConfig().get("ipV4Address").toString(); - this.setupCode = getConfig().get("setupCode").toString(); - try { - // pairing and session setup - this.keys = new PairingManager(httpClient, setupCode).pair(accessoryAddress); - this.session = new SecureSession(keys); - this.accessoryClient = new SecureAccessoryClient(httpClient, session, accessoryAddress); - updateStatus(ThingStatus.ONLINE); - } catch (Exception e) { - logger.error("Failed to initialize HomeKit client", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); - } - } - - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - // override in subclass - } -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java new file mode 100644 index 0000000000000..3096f7b6c090e --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.homekit.internal.PairingManager; +import org.openhab.binding.homekit.internal.SecureClient; +import org.openhab.binding.homekit.internal.SecureSession; +import org.openhab.binding.homekit.internal.SessionKeys; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles I/O with HomeKit server devices -- either simple accessories or bridge accessories that + * contain child accessories. If the handler is for a HomeKit bridge or a stand alone HomeKit accessory + * device it performs the pairing and secure session setup. If the handler is for a HomeKit accessory + * that is part of a bridge, it uses the pairing and session from the bridge handler. + * Subclasses should override the handleCommand method to handle commands for specific channels. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitBaseServerHandler extends BaseThingHandler { + + private final Logger logger = LoggerFactory.getLogger(HomekitBaseServerHandler.class); + + protected final HttpClient httpClient; + + protected @Nullable SecureClient client; + protected @Nullable SessionKeys keys; + protected @Nullable SecureSession session; + protected @Nullable String baseUrl; + protected @Nullable String setupCode; + + public HomekitBaseServerHandler(Thing thing, HttpClientFactory httpClientFactory) { + super(thing); + this.httpClient = httpClientFactory.getCommonHttpClient(); + } + + @Override + public void initialize() { + Bridge bridge = getBridge(); + if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + // accessory is part of a bridge, so use the bridge's pairing and session + this.keys = bridgeHandler.keys; + this.session = bridgeHandler.session; + this.client = bridgeHandler.client; + if (this.client != null) { + updateStatus(ThingStatus.ONLINE); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not connected"); + } + } else { + // standalone accessory or brige accessory, do pairing and session setup here + this.baseUrl = "https://" + getConfig().get("ipV4Address").toString(); + this.setupCode = getConfig().get("setupCode").toString(); + try { + this.keys = new PairingManager(httpClient, setupCode).pair(baseUrl); + this.session = new SecureSession(keys); + this.client = new SecureClient(httpClient, session, baseUrl); + updateStatus(ThingStatus.ONLINE); + } catch (Exception e) { + logger.error("Failed to initialize HomeKit client", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + } + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // override in subclass + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 6e1daf34f6e71..837f95c06aeb8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -12,19 +12,22 @@ */ package org.openhab.binding.homekit.internal.handler; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; -import java.time.Duration; import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; import org.openhab.binding.homekit.internal.SecureSession; -import org.openhab.binding.homekit.internal.discovery.HomekitAccessoryDiscoveryService; +import org.openhab.binding.homekit.internal.discovery.HomekitDeviceDiscoveryService; import org.openhab.binding.homekit.internal.dto.HomekitAccessories; import org.openhab.binding.homekit.internal.dto.HomekitAccessory; +import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.binding.BaseThingHandler; @@ -35,22 +38,30 @@ import org.slf4j.LoggerFactory; import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; /** - * The {@link HomekitBridgeHandler} is an instance of a {@link BaseHomekitServerHandler} that - * marshals communications with multiple HomeKit accessories within a HomeKit bridge server. + * Handler for HomeKit bridge devices. + * It marshals the communications with multiple HomeKit child accessories within a HomeKit bridge server. + * It uses the /accessories endpoint to discover embedded accessories and their services. + * It notifies the {@link HomekitDeviceDiscoveryService} when accessories are discovered. + * It does not currently handle commands for channels, that is left to the child accessory handlers. + * It extends {@link HomekitBaseServerHandler} to handle pairing and secure session setup. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomekitBridgeHandler extends BaseHomekitServerHandler implements BridgeHandler { +public class HomekitBridgeHandler extends HomekitBaseServerHandler implements BridgeHandler { private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); private static final Gson GSON = new Gson(); + private final HomekitDeviceDiscoveryService discoveryService; - public HomekitBridgeHandler(Bridge bridge, HomekitAccessoryDiscoveryService discoveryService) { - super(bridge, discoveryService); + public HomekitBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, + HomekitDeviceDiscoveryService discoveryService) { + super(bridge, httpClientFactory); + this.discoveryService = discoveryService; } @Override @@ -77,7 +88,7 @@ public void initialize() { super.initialize(); scheduler.submit(() -> { List accessories = getAccessories(); - discoveryService.accessoriesDscovered(thing, accessories); + discoveryService.devicesDiscovered(thing, accessories); }); } @@ -89,30 +100,38 @@ public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) @Override public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { // TODO Auto-generated method stub - } /** - * Get information about embedded accessories and their respective channels + * Get information about embedded accessories and their respective channels. + * Uses the /accessories endpoint. + * Returns an empty list if there was a problem. + * Requires a valid secure session. + * + * @return list of accessories (may be empty) + * @see HomeKit HTTP */ private List getAccessories() { - HomekitAccessories result = null; SecureSession session = this.session; if (session != null) { - URI uri = URI.create(accessoryAddress + "/accessories"); - HttpRequest request = HttpRequest.newBuilder().uri(uri).timeout(Duration.ofSeconds(5)) - .header("Accept", "application/json").GET().build(); - HttpResponse response; + Request request = httpClient.newRequest(baseUrl + "/accessories") // + .timeout(5, TimeUnit.SECONDS) // + .method(HttpMethod.GET) // + .header(HttpHeader.ACCEPT, "application/json"); try { - response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()); - if (response.statusCode() == 200) { - byte[] decrypted = session.decrypt(response.body()); - result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), HomekitAccessories.class); + ContentResponse response = request.send(); + if (response.getStatus() == 200) { + byte[] decrypted = session.decrypt(response.getContent()); + HomekitAccessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), + HomekitAccessories.class); + if (result != null && result.accessories != null) { + return result.accessories; + } } - } catch (IOException | InterruptedException e) { + } catch (TimeoutException | ExecutionException | InterruptedException | JsonSyntaxException e) { } } - return result == null ? List.of() : result.accessories == null ? List.of() : result.accessories; + return List.of(); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java similarity index 65% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 87fb6c459b969..c8be455e6888a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -15,8 +15,8 @@ import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.SecureAccessoryClient; -import org.openhab.binding.homekit.internal.discovery.HomekitAccessoryDiscoveryService; +import org.openhab.binding.homekit.internal.SecureClient; +import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.library.types.OnOffType; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -25,29 +25,38 @@ import org.slf4j.LoggerFactory; /** - * The {@link HomekitAccessoryHandler} is an instance of a {@link BaseHomekitServerHandler} that - * handles a single HomeKit accessory. + * Handles a single HomeKit accessory. + * It provides a polling mechanism to regularly update the state of the accessory. + * It also handles commands sent to the accessory's channels. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomekitAccessoryHandler extends BaseHomekitServerHandler { +public class HomekitDeviceHandler extends HomekitBaseServerHandler { - private final Logger logger = LoggerFactory.getLogger(HomekitAccessoryHandler.class); + private final Logger logger = LoggerFactory.getLogger(HomekitDeviceHandler.class); - public HomekitAccessoryHandler(Thing thing, HomekitAccessoryDiscoveryService discoveryService) { - super(thing, discoveryService); + public HomekitDeviceHandler(Thing thing, HttpClientFactory httpClientFactory) { + super(thing, httpClientFactory); } @Override public void initialize() { super.initialize(); - scheduler.scheduleAtFixedRate(this::poll, 0, 60, TimeUnit.SECONDS); + String interval = getConfig().get("pollingInterval").toString(); + try { + int intervalSeconds = Integer.parseInt(interval); + if (intervalSeconds > 0) { + scheduler.scheduleAtFixedRate(this::poll, 0, intervalSeconds, TimeUnit.SECONDS); + } + } catch (NumberFormatException e) { + logger.warn("Invalid polling interval configuration: {}", interval); + } } @Override public void handleCommand(ChannelUID channelUID, Command command) { - SecureAccessoryClient accessoryClient = this.accessoryClient; + SecureClient accessoryClient = this.client; if (accessoryClient != null) { String channelId = channelUID.getId(); try { @@ -66,11 +75,15 @@ public void handleCommand(ChannelUID channelUID, Command command) { } } + /** + * Polls the accessory for its current state and updates the corresponding channels. + * This method is called periodically by a scheduled executor. + */ private void poll() { - SecureAccessoryClient accessoryClient = this.accessoryClient; + SecureClient accessoryClient = this.client; if (accessoryClient != null) { try { - String power = accessoryClient.readCharacteristic("1", "10"); // Example AID/IID + String power = accessoryClient.readCharacteristic("1", "10"); // TODO example AID/IID // Parse powerState and update channel state accordingly if ("true".equals(power)) { updateState(new ChannelUID(getThing().getUID(), "power"), OnOffType.ON); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java index 29967fdb54b48..029f38596ff53 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java @@ -18,7 +18,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.discovery.HomekitAccessoryDiscoveryService; +import org.openhab.binding.homekit.internal.discovery.HomekitDeviceDiscoveryService; +import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; @@ -30,8 +31,8 @@ import org.osgi.service.component.annotations.Reference; /** - * The {@link HomekitHandlerFactory} is responsible for creating things and thing - * handlers. + * Creates things and thing handlers. Supports HomeKit bridges and accessories. + * Passes on a {@link HomekitDeviceDiscoveryService} so that created things can to manage discovery of accessories. * * @author Andrew Fiddian-Green - Initial contribution */ @@ -39,12 +40,16 @@ @Component(service = ThingHandlerFactory.class) public class HomekitHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_ACCESSORY); - private final HomekitAccessoryDiscoveryService discoveryService; + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE); + + private final HomekitDeviceDiscoveryService discoveryService; + private final HttpClientFactory httpClientFactory; @Activate - public HomekitHandlerFactory(@Reference HomekitAccessoryDiscoveryService discoveryService) { + public HomekitHandlerFactory(@Reference HomekitDeviceDiscoveryService discoveryService, + @Reference HttpClientFactory httpClientFactory) { this.discoveryService = discoveryService; + this.httpClientFactory = httpClientFactory; } @Override @@ -55,14 +60,11 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { @Override protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); - if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { - return new HomekitBridgeHandler((Bridge) thing, discoveryService); + return new HomekitBridgeHandler((Bridge) thing, httpClientFactory, discoveryService); + } else if (THING_TYPE_DEVICE.equals(thingTypeUID)) { + return new HomekitDeviceHandler(thing, httpClientFactory); } - if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { - return new HomekitAccessoryHandler(thing, discoveryService); - } - return null; } } From f11ec7f444782d4e758fef924c2c9d78c33aee64 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 11 Sep 2025 19:57:05 +0100 Subject: [PATCH 006/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 13 +- .../homekit/internal/SecureClient.java | 106 ---------- ...ce.java => AccessoryDiscoveryService.java} | 10 +- .../HomekitMdnsDiscoveryParticipant.java | 7 +- ...mekitAccessories.java => Accessories.java} | 4 +- .../{HomekitAccessory.java => Accessory.java} | 13 +- .../homekit/internal/dto/Characteristic.java | 72 +++++++ .../internal/dto/HomekitCharacteristic.java | 34 --- .../homekit/internal/dto/HomekitService.java | 32 --- .../binding/homekit/internal/dto/Service.java | 79 +++++++ .../homekit/internal/enums/AccessoryType.java | 83 ++++++++ .../internal/enums/CharacteristicType.java | 195 ++++++++++++++++++ .../homekit/internal/enums/ServiceType.java | 84 ++++++++ .../handler/HomekitBaseServerHandler.java | 39 ++-- .../handler/HomekitBridgeHandler.java | 50 ++--- .../handler/HomekitDeviceHandler.java | 20 +- .../handler/HomekitHandlerFactory.java | 8 +- .../internal/{ => network}/ChaCha20.java | 2 +- .../network/CharacteristicsManager.java | 77 +++++++ .../internal/network/HttpTransport.java | 111 ++++++++++ .../{ => network}/PairingManager.java | 48 +---- .../internal/{ => network}/SRPClient.java | 15 +- .../internal/{ => network}/SecureSession.java | 2 +- .../internal/{ => network}/SessionKeys.java | 2 +- .../internal/{ => network}/TLV8Codec.java | 2 +- .../resources/OH-INF/thing/thing-types.xml | 61 +++--- 26 files changed, 845 insertions(+), 324 deletions(-) delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureClient.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/{HomekitDeviceDiscoveryService.java => AccessoryDiscoveryService.java} (86%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/{HomekitAccessories.java => Accessories.java} (89%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/{HomekitAccessory.java => Accessory.java} (72%) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{ => network}/ChaCha20.java (98%) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{ => network}/PairingManager.java (55%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{ => network}/SRPClient.java (94%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{ => network}/SecureSession.java (97%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{ => network}/SessionKeys.java (97%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{ => network}/TLV8Codec.java (98%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index ce7d9a3abfb67..9482f1515f30c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -23,10 +23,21 @@ @NonNullByDefault public class HomekitBindingConstants { - private static final String BINDING_ID = "homekit"; + public static final String BINDING_ID = "homekit"; // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device"); + // configuration parameters + public static final String PAIRING_CODE = "pairingCode"; + public static final String IP_V4_ADDRESS = "ipV4Address"; + + // HomeKit HTTP endpoints and content types + public static final String ENDPOINT_PAIRING = "pair-setup"; + public static final String ENDPOINT_ACCESSORIES = "accessories"; + public static final String ENDPOINT_CHARACTERISTICS = "characteristics"; + public static final String CONTENT_TYPE_PAIRING = "application/pairing+tlv8"; + public static final String CONTENT_TYPE_HAP = "application/hap+json"; + } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureClient.java deleted file mode 100644 index d72767326bc17..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureClient.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal; - -import java.nio.charset.StandardCharsets; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.client.util.BytesContentProvider; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpMethod; - -import com.google.gson.JsonSyntaxException; - -/** - * HTTP client methods for reading and writing HomeKit accessory characteristics over a secure session. - * It handles encryption and decryption of requests and responses. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -public class SecureClient { - - private final SecureSession session; - private final HttpClient httpClient; - private final String baseUrl; - - public SecureClient(HttpClient httpClient, SecureSession session, String baseUrl) { - this.httpClient = httpClient; - this.session = session; - this.baseUrl = baseUrl; - } - - /** - * Reads a characteristic from the accessory. - * - * @param aid Accessory ID - * @param iid Instance ID - * @return JSON response as String - */ - public String readCharacteristic(String aid, String iid) { - String query = String.format("?id=%s.%s", aid, iid); - Request request = httpClient.newRequest(baseUrl + "/characteristics" + query) // - .timeout(5, TimeUnit.SECONDS) // - .method(HttpMethod.GET) // - .header(HttpHeader.ACCEPT, "application/json"); - try { - ContentResponse response = request.send(); - if (response.getStatus() == 200) { - byte[] encrypted = response.getContent(); - return new String(session.decrypt(encrypted), StandardCharsets.UTF_8); - } - } catch (TimeoutException | ExecutionException | InterruptedException | JsonSyntaxException e) { - } - return ""; - } - - /** - * Writes a characteristic to the accessory. - * - * @param aid Accessory ID - * @param iid Instance ID - * @param value Value to write (String, Number, Boolean) - * @throws Exception on communication or encryption errors - */ - public void writeCharacteristic(String aid, String iid, Object value) throws Exception { - String json = String.format("{\"characteristics\":[{\"aid\":%s,\"iid\":%s,\"value\":%s}]}", aid, iid, - formatValue(value)); - byte[] encryptedPayload = session.encrypt(json.getBytes()); - Request request = httpClient.newRequest(baseUrl + "/characteristics") // - .timeout(5, TimeUnit.SECONDS) // - .method(HttpMethod.PUT) // - .header(HttpHeader.CONTENT_TYPE, "application/json") // - .content(new BytesContentProvider(encryptedPayload)); - try { - ContentResponse response = request.send(); - if (response.getStatus() != 200) { - throw new RuntimeException("Write failed: HTTP " + response.getStatus()); - } - } catch (TimeoutException | ExecutionException | InterruptedException | JsonSyntaxException e) { - } - } - - /* - * Formats the value for JSON. Strings are quoted, numbers and booleans are not. - */ - private String formatValue(Object value) { - if (value instanceof Boolean || value instanceof Number) { - return value.toString(); - } - return "\"" + value.toString() + "\""; - } -} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitDeviceDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java similarity index 86% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitDeviceDiscoveryService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java index 19b70a2c828f9..ca464979c9446 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitDeviceDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java @@ -18,7 +18,7 @@ import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.dto.HomekitAccessory; +import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.core.config.discovery.AbstractDiscoveryService; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.DiscoveryService; @@ -35,18 +35,18 @@ */ @NonNullByDefault @Component(service = DiscoveryService.class) -public class HomekitDeviceDiscoveryService extends AbstractDiscoveryService { +public class AccessoryDiscoveryService extends AbstractDiscoveryService { - protected HomekitDeviceDiscoveryService() { + protected AccessoryDiscoveryService() { super(Set.of(THING_TYPE_DEVICE), 10, false); } @Override protected void startScan() { - // no scanning is done; it relies on being informed of new accessories + // no scanning is done; we rely on being informed of new accessories } - public void devicesDiscovered(Thing bridge, List accessories) { + public void devicesDiscovered(Thing bridge, List accessories) { accessories.forEach(accessory -> { if (accessory.aid != null && accessory.services != null) { accessory.services.forEach(service -> { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index ec21a37acdb85..2caa3ad4290d8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -20,6 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.enums.AccessoryType; import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; @@ -90,12 +91,14 @@ public String getServiceType() { String macAddress = service.getPropertyString("id"); if (macAddress != null) { String deviceCategory = service.getPropertyString("ci"); // HomeKit device category - if (deviceCategory != null) { - if ("2".equals(deviceCategory)) { + try { + AccessoryType category = AccessoryType.from(deviceCategory); + if (AccessoryType.BRIDGE.equals(category)) { return new ThingUID(THING_TYPE_BRIDGE, macAddress.replace(":", "-").toLowerCase()); } else { return new ThingUID(THING_TYPE_DEVICE, macAddress.replace(":", "-").toLowerCase()); } + } catch (IllegalArgumentException e) { } } logger.warn("Discovered HomeKit device without valid properties - ignoring"); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java similarity index 89% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java index c3d5fda574c73..5b702306f20a8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessories.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java @@ -25,6 +25,6 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomekitAccessories { - public @Nullable List accessories; +public class Accessories { + public @Nullable List accessories; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java similarity index 72% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index f1226a0eceffc..047984106b7e3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitAccessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -16,6 +16,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.enums.AccessoryType; /** * HomeKit accessory DTO @@ -25,7 +26,15 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomekitAccessory { +public class Accessory { public @Nullable Integer aid; // e.g. 1 - public @Nullable List services; + public @Nullable List services; + + public AccessoryType getAccessoryType() { + Integer aid = this.aid; + if (aid == null) { + return AccessoryType.OTHER; + } + return AccessoryType.from(aid); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java new file mode 100644 index 0000000000000..bca46775a43ea --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.dto; + +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.BINDING_ID; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.enums.CharacteristicType; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.semantics.SemanticTag; +import org.openhab.core.semantics.model.DefaultSemanticTags.Point; +import org.openhab.core.semantics.model.DefaultSemanticTags.Property; +import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.thing.type.ChannelTypeBuilder; +import org.openhab.core.thing.type.ChannelTypeUID; + +import com.google.gson.annotations.SerializedName; + +/** + * HomeKit characteristic DTO. + * Used to deserialize individual characteristics from the /accessories endpoint of a HomeKit bridge. + * Each characteristic has a type, instance ID (iid), value, permissions (perms), and format. + * This class also includes a method to convert the characteristic to an openHAB ChannelType, if possible. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class Characteristic { + public @Nullable @SerializedName("iid") Integer instanceId; // e.g. 10 + public @Nullable @SerializedName("type") String characteristicId; // e.g. '25' = 'public.hap.characteristic.on' + public @Nullable @SerializedName("value") String dataValue; // e.g. true + public @Nullable @SerializedName("format") String dataFormat; // e.g. "bool" + public @Nullable @SerializedName("perms") List permissions; // e.g. ["read", "write", "events"] + + /** + * Converts this characteristic to an openHAB ChannelType, if possible. + * Returns null if the characteristic ID or data format is missing or unrecognized. + * + * @return the corresponding ChannelType, or null if not mappable + */ + public @Nullable ChannelType getChannelType() { + String characId = this.characteristicId; + String dataFormat = this.dataFormat; + if (characId == null || dataFormat == null) { + return null; + } + + CharacteristicType characType = CharacteristicType.from(characId); + + String label = "label"; // TODO determine label based on characType + String itemType = CoreItemFactory.SWITCH; // TODO determine item type based on characType + String category = "sensor"; // TODO determine category based on characType + SemanticTag point = Point.STATUS; // TODO determine point based on characType + SemanticTag property = Property.AIR_QUALITY; // TODO determine property based on characteristicType + + return ChannelTypeBuilder.state(new ChannelTypeUID(BINDING_ID, characId), label, itemType) + .withTags(point, property).withCategory(category).build(); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java deleted file mode 100644 index 0b98958026123..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitCharacteristic.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.dto; - -import java.util.List; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; - -/** - * HomeKit characteristic DTO. - * Used to deserialize individual characteristics from the /accessories endpoint of a HomeKit bridge. - * Each characteristic has a type, instance ID (iid), value, permissions (perms), and format. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class HomekitCharacteristic { - public @Nullable String type; // e.g. public.hap.characteristic.on - public @Nullable Integer iid; // e.g. 10 - public @Nullable String value; // e.g. true - public @Nullable List perms; // e.g. ["read", "write", "events"] - public @Nullable String format; // e.g. "bool" -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java deleted file mode 100644 index b6ef9427549ba..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/HomekitService.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.dto; - -import java.util.List; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; - -/** - * HomeKit service DTO. - * Used to deserialize individual services from the /accessories endpoint of a HomeKit bridge. - * Each service has a type, instance ID (iid), and a list of characteristics. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class HomekitService { - public @Nullable String type; // e.g. public.hap.service.lightbulb - public @Nullable Integer iid; // e.g. 10 - public @Nullable List characteristics; -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java new file mode 100644 index 0000000000000..a0ed92bf3799c --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.dto; + +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.BINDING_ID; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.enums.ServiceType; +import org.openhab.core.thing.type.ChannelDefinition; +import org.openhab.core.thing.type.ChannelDefinitionBuilder; +import org.openhab.core.thing.type.ChannelGroupType; +import org.openhab.core.thing.type.ChannelGroupTypeBuilder; +import org.openhab.core.thing.type.ChannelGroupTypeUID; +import org.openhab.core.thing.type.ChannelType; + +import com.google.gson.annotations.SerializedName; + +/** + * HomeKit service DTO. + * Used to deserialize individual services from the /accessories endpoint of a HomeKit bridge. + * Each service has a type, instance ID (iid), and a list of characteristics. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class Service { + public @Nullable @SerializedName("type") String serviceId; // e.g. '96' = 'public.hap.service.battery' + public @Nullable @SerializedName("iid") Integer instanceId; // e.g. 10 + public @Nullable List characteristics; + + public ServiceType getServiceType() { + Integer iid = this.iid; + if (iid == null) { + return ServiceType.UNKNOWN; + } + return ServiceType.from(iid); + } + + public @Nullable ChannelGroupType getChannelType() { + String serviceId = this.serviceId; + List characteristics = this.characteristics; + if (serviceId == null || characteristics == null) { + return null; + } + + ServiceType serviceType = ServiceType.from(serviceId); + + String label = "label"; // TODO determine label based on characType + String category = "sensor"; // TODO determine category based on characType + + List channelDefinitions = new ArrayList<>(); + for (Characteristic characteristic : characteristics) { + ChannelType ct = characteristic.getChannelType(); + if (ct == null) { + continue; + } + channelDefinitions.add(new ChannelDefinitionBuilder(ct.getUID().getId(), ct.getUID()) + .withLabel(ct.getLabel()).withDescription(ct.getDescription()).build()); + } + + return ChannelGroupTypeBuilder.instance(new ChannelGroupTypeUID(BINDING_ID, serviceId), label) + .withChannelDefinitions(channelDefinitions).withCategory(category).build(); + } + +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java new file mode 100644 index 0000000000000..9a99a8b19b69a --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.enums; + +/** + * Enumeration of HomeKit accessory categories with their corresponding numeric IDs and labels. + * This enum provides a mapping between category IDs used in HomeKit and human-readable labels. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +public enum AccessoryType { + // TODO manually check the Homekit specification pdf to ensure all types are covered + OTHER(1, "Other"), + BRIDGE(2, "Bridge"), + FAN(3, "Fan"), + GARAGE(4, "Garage"), + LIGHTBULB(5, "Light Bulb"), + DOOR_LOCK(6, "Door Lock"), + OUTLET(7, "Outlet"), + SWITCH(8, "Switch"), + THERMOSTAT(9, "Thermostat"), + SENSOR(10, "Sensor"), + SECURITY_SYSTEM(11, "Security System"), + DOOR(12, "Door"), + WINDOW(13, "Window"), + WINDOW_COVERING(14, "Window Covering"), + PROGRAMMABLE_SWITCH(15, "Programmable Switch"), + RANGE_EXTENDER(16, "Range Extender"), + IP_CAMERA(17, "IP Camera"), + VIDEO_DOORBELL(18, "Video Doorbell"), + AIR_PURIFIER(19, "Air Purifier"), + HEATER(20, "Heater"), + AIR_CONDITIONER(21, "Air Conditioner"), + HUMIDIFIER(22, "Humidifier"), + DEHUMIDIFIER(23, "Dehumidifier"), + APPLE_TV(24, "Apple TV"), + SPEAKER(26, "Speaker"), + AIRPORT(27, "AirPort"), + SPRINKLER(28, "Sprinkler"), + FAUCET(29, "Faucet"), + SHOWER_HEAD(30, "Shower Head"), + TELEVISION(31, "Television"), + TARGET_CONTROLLER(32, "Target Controller"); + + private final int id; + private final String label; + + AccessoryType(int category, String label) { + this.id = category; + this.label = label; + } + + public int getId() { + return id; + } + + public String getLabel() { + return label; + } + + public static AccessoryType from(String id) throws NumberFormatException { + return from(Integer.parseInt(id)); + } + + public static AccessoryType from(int id) throws IllegalArgumentException { + for (AccessoryType value : values()) { + if (value.id == id) { + return value; + } + } + throw new IllegalArgumentException("Unknown ID: " + id); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java new file mode 100644 index 0000000000000..b9d5cf17856b7 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -0,0 +1,195 @@ +package org.openhab.binding.homekit.internal.enums; + +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.BINDING_ID; + +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.semantics.model.DefaultSemanticTags.Point; +import org.openhab.core.semantics.model.DefaultSemanticTags.Property; +import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.thing.type.ChannelTypeBuilder; +import org.openhab.core.thing.type.ChannelTypeUID; + +public enum CharacteristicType { + //@formatter:off + ACCESSORY_PROPERTIES(0xA6, "public.hap.characteristic.accessory-properties"), + ACTIVE(0xB0, "public.hap.characteristic.active"), + ACTIVE_IDENTIFIER(0xE7, "public.hap.characteristic.active-identifier"), + ADMINISTRATOR_ONLY_ACCESS(0x01, "public.hap.characteristic.administrator-only-access"), + AIR_PARTICULATE_DENSITY(0x64, "public.hap.characteristic.air-particulate.density"), + AIR_PARTICULATE_SIZE(0x65, "public.hap.characteristic.air-particulate.size"), + AIR_PURIFIER_STATE_CURRENT(0xA9, "public.hap.characteristic.air-purifier.state.current"), + AIR_PURIFIER_STATE_TARGET(0xA8, "public.hap.characteristic.air-purifier.state.target"), + AIR_QUALITY(0x95, "public.hap.characteristic.air-quality"), + AUDIO_FEEDBACK(0x05, "public.hap.characteristic.audio-feedback"), + BATTERY_LEVEL(0x68, "public.hap.characteristic.battery-level"), + BRIGHTNESS(0x08, "public.hap.characteristic.brightness"), + BUTTON_EVENT(0x126, "public.hap.characteristic.button-event"), + CARBON_DIOXIDE_DETECTED(0x92, "public.hap.characteristic.carbon-dioxide.detected"), + CARBON_DIOXIDE_LEVEL(0x93, "public.hap.characteristic.carbon-dioxide.level"), + CARBON_DIOXIDE_PEAK_LEVEL(0x94, "public.hap.characteristic.carbon-dioxide.peak-level"), + CARBON_MONOXIDE_DETECTED(0x69, "public.hap.characteristic.carbon-monoxide.detected"), + CARBON_MONOXIDE_LEVEL(0x90, "public.hap.characteristic.carbon-monoxide.level"), + CARBON_MONOXIDE_PEAK_LEVEL(0x91, "public.hap.characteristic.carbon-monoxide.peak-level"), + CHARGING_STATE(0x8F, "public.hap.characteristic.charging-state"), + COLOR_TEMPERATURE(0xCE, "public.hap.characteristic.color-temperature"), + CONTACT_STATE(0x6A, "public.hap.characteristic.contact-state"), + DENSITY_NO2(0xC4, "public.hap.characteristic.density.no2"), + DENSITY_OZONE(0xC3, "public.hap.characteristic.density.ozone"), + DENSITY_PM10(0xC7, "public.hap.characteristic.density.pm10"), + DENSITY_PM2_5(0xC5, "public.hap.characteristic.density.pm2_5"), + DENSITY_SO2(0xC5, "public.hap.characteristic.density.so2"), + DENSITY_VOC(0xC8, "public.hap.characteristic.density.voc"), + DOOR_STATE_CURRENT(0xE7, "public.hap.characteristic.door-state.current"), + DOOR_STATE_TARGET(0x32, "public.hap.characteristic.door-state.target"), + FAN_STATE_CURRENT(0xAF, "public.hap.characteristic.fan.state.current"), + FAN_STATE_TARGET(0xBF, "public.hap.characteristic.fan.state.target"), + FILTER_CHANGE_INDICATION(0xAC, "public.hap.characteristic.filter.change-indication"), + FILTER_LIFE_LEVEL(0xAB, "public.hap.characteristic.filter.life-level"), + FILTER_RESET_INDICATION(0xAD, "public.hap.characteristic.filter.reset-indication"), + FIRMWARE_REVISION(0x52, "public.hap.characteristic.firmware.revision"), + HARDWARE_REVISION(0x53, "public.hap.characteristic.hardware.revision"), + HEATER_COOLER_STATE_CURRENT(0xB1, "public.hap.characteristic.heater-cooler.state.current"), + HEATER_COOLER_STATE_TARGET(0xB2, "public.hap.characteristic.heater-cooler.state.target"), + HEATING_COOLING_CURRENT(0x0F, "public.hap.characteristic.heating-cooling.current"), + HEATING_COOLING_TARGET(0x33, "public.hap.characteristic.heating-cooling.target"), + HORIZONTAL_TILT_CURRENT(0x6C, "public.hap.characteristic.horizontal-tilt.current"), + HORIZONTAL_TILT_TARGET(0x7B, "public.hap.characteristic.horizontal-tilt.target"), + HUE(0x13, "public.hap.characteristic.hue"), + HUMIDIFIER_DEHUMIDIFIER_STATE_CURRENT(0xB3, "public.hap.characteristic.humidifier-dehumidifier.state.current"), + HUMIDIFIER_DEHUMIDIFIER_STATE_TARGET(0xB4, "public.hap.characteristic.humidifier-dehumidifier.state.target"), + IDENTIFY(0x14, "public.hap.characteristic.identify"), + IMAGE_MIRROR(0x11F, "public.hap.characteristic.image-mirror"), + IMAGE_ROTATION(0x11E, "public.hap.characteristic.image-rotation"), + IN_USE(0xD2, "public.hap.characteristic.in-use"), + INPUT_EVENT(0x73, "public.hap.characteristic.input-event"), + IS_CONFIGURED(0xD6, "public.hap.characteristic.is-configured"), + LEAK_DETECTED(0x70, "public.hap.characteristic.leak-detected"), + LIGHT_LEVEL_CURRENT(0x6B, "public.hap.characteristic.light-level.current"), + LOCK_MANAGEMENT_AUTO_SECURE_TIMEOUT(0x1A, "public.hap.characteristic.lock-management.auto-secure-timeout"), + LOCK_MANAGEMENT_CONTROL_POINT(0x19, "public.hap.characteristic.lock-management.control-point"), + LOCK_MECHANISM_CURRENT_STATE(0x1D, "public.hap.characteristic.lock-mechanism.current-state"), + LOCK_MECHANISM_LAST_KNOWN_ACTION(0x1C, "public.hap.characteristic.lock-mechanism.last-known-action"), + LOCK_MECHANISM_TARGET_STATE(0x1E, "public.hap.characteristic.lock-mechanism.target-state"), + LOCK_PHYSICAL_CONTROLS(0xA7, "public.hap.characteristic.lock-physical-controls"), + LOGS(0x1F, "public.hap.characteristic.logs"), + MANUFACTURER(0x20, "public.hap.characteristic.manufacturer"), + MODEL(0x21, "public.hap.characteristic.model"), + MOTION_DETECTED(0x22, "public.hap.characteristic.motion-detected"), + MUTE(0x11A, "public.hap.characteristic.mute"), + NAME(0x23, "public.hap.characteristic.name"), + NIGHT_VISION(0x11B, "public.hap.characteristic.night-vision"), + OBSTRUCTION_DETECTED(0x24, "public.hap.characteristic.obstruction-detected"), + OCCUPANCY_DETECTED(0x71, "public.hap.characteristic.occupancy-detected"), + ON(0x25, "public.hap.characteristic.on"), + OUTLET_IN_USE(0x26, "public.hap.characteristic.outlet-in-use"), + PAIRING_FEATURES(0x4F, "public.hap.characteristic.pairing.features"), + PAIRING_PAIR_SETUP(0x4C, "public.hap.characteristic.pairing.pair-setup"), + PAIRING_PAIR_VERIFY(0x4E, "public.hap.characteristic.pairing.pair-verify"), + PAIRING_PAIRINGS(0x50, "public.hap.characteristic.pairing.pairings"), + POSITION_CURRENT(0x6D, "public.hap.characteristic.position.current"), + POSITION_HOLD(0x6F, "public.hap.characteristic.position.hold"), + POSITION_STATE(0x72, "public.hap.characteristic.position.state"), + POSITION_TARGET(0x7C, "public.hap.characteristic.position.target"), + PROGRAM_MODE(0xD1, "public.hap.characteristic.program-mode"), + RELATIVE_HUMIDITY_CURRENT(0x10, "public.hap.characteristic.relative-humidity.current"), + RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD(0xC9, "public.hap.characteristic.relative-humidity.dehumidifier-threshold"), + RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD(0xCA, "public.hap.characteristic.relative-humidity.humidifier-threshold"), + RELATIVE_HUMIDITY_TARGET(0x34, "public.hap.characteristic.relative-humidity.target"), + REMAINING_DURATION(0xD4, "public.hap.characteristic.remaining-duration"), + ROTATION_DIRECTION(0x28, "public.hap.characteristic.rotation.direction"), + ROTATION_SPEED(0x29, "public.hap.characteristic.rotation.speed"), + SATURATION(0x2F, "public.hap.characteristic.saturation"), + SECURITY_SYSTEM_ALARM_TYPE(0x8E, "public.hap.characteristic.security-system.alarm-type"), + SECURITY_SYSTEM_STATE_CURRENT(0x66, "public.hap.characteristic.security-system-state.current"), + SECURITY_SYSTEM_STATE_TARGET(0x67, "public.hap.characteristic.security-system-state.target"), + SELECTED_AUDIO_STREAM_CONFIGURATION(0x128, "public.hap.characteristic.selected-audio-stream-configuration"), + SELECTED_RTP_STREAM_CONFIGURATION(0x117, "public.hap.characteristic.selected-rtp-stream-configuration"), + SERIAL_NUMBER(0x30, "public.hap.characteristic.serial-number"), + SERVICE_LABEL_INDEX(0xCB, "public.hap.characteristic.service-label-index"), + SERVICE_LABEL_NAMESPACE(0xCD, "public.hap.characteristic.service-label-namespace"), + SET_DURATION(0xD3, "public.hap.characteristic.set-duration"), + SETUP_DATA_STREAM_TRANSPORT(0x131, "public.hap.characteristic.setup-data-stream-transport"), + SETUP_ENDPOINTS(0x118, "public.hap.characteristic.setup-endpoints"), + SIRI_INPUT_TYPE(0x132, "public.hap.characteristic.siri-input-type"), + SLAT_STATE_CURRENT(0xAA, "public.hap.characteristic.slat.state.current"), + SMOKE_DETECTED(0x76, "public.hap.characteristic.smoke-detected"), + STATUS_ACTIVE(0x75, "public.hap.characteristic.status-active"), + STATUS_FAULT(0x77, "public.hap.characteristic.status-fault"), + STATUS_JAMMED(0x78, "public.hap.characteristic.status-jammed"), + STATUS_LO_BATT(0x79, "public.hap.characteristic.status-lo-batt"), + STATUS_TAMPERED(0x7A, "public.hap.characteristic.status-tampered"), + STREAMING_STATUS(0x120, "public.hap.characteristic.streaming-status"), + SUPPORTED_AUDIO_CONFIGURATION(0x115, "public.hap.characteristic.supported-audio-configuration"), + SUPPORTED_DATA_STREAM_TRANSPORT_CONFIGURATION(0x130, "public.hap.characteristic.supported-data-stream-transport-configuration"), + SUPPORTED_RTP_CONFIGURATION(0x116, "public.hap.characteristic.supported-rtp-configuration"), + SUPPORTED_TARGET_CONFIGURATION(0x123, "public.hap.characteristic.supported-target-configuration"), + SUPPORTED_VIDEO_STREAM_CONFIGURATION(0x114, "public.hap.characteristic.supported-video-stream-configuration"), + SWING_MODE(0xB6, "public.hap.characteristic.swing-mode"), + TARGET_LIST(0x124, "public.hap.characteristic.target-list"), + TEMPERATURE_COOLING_THRESHOLD(0x0D, "public.hap.characteristic.temperature.cooling-threshold"), + TEMPERATURE_CURRENT(0x11, "public.hap.characteristic.temperature.current"), + TEMPERATURE_HEATING_THRESHOLD(0x12, "public.hap.characteristic.temperature.heating-threshold"), + TEMPERATURE_TARGET(0x35, "public.hap.characteristic.temperature.target"), + TEMPERATURE_UNITS(0x36, "public.hap.characteristic.temperature.units"), + TILT_CURRENT(0xC1, "public.hap.characteristic.tilt.current"), + TILT_TARGET(0xC2, "public.hap.characteristic.tilt.target"), + TYPE_SLAT(0xC0, "public.hap.characteristic.type.slat"), + VALVE_TYPE(0xD5, "public.hap.characteristic.valve-type"), + VERSION(0x37, "public.hap.characteristic.version"), + VERTICAL_TILT_CURRENT(0x6E, "public.hap.characteristic.vertical-tilt.current"), + VERTICAL_TILT_TARGET(0x7D, "public.hap.characteristic.vertical-tilt.target"), + VOLUME(0x119, "public.hap.characteristic.volume"), + WATER_LEVEL(0xB5, "public.hap.characteristic.water-level"), + ZOOM_DIGITAL(0x11D, "public.hap.characteristic.zoom-digital"), + ZOOM_OPTICAL(0x11C, "public.hap.characteristic.zoom-optical"); + //@formatter:on + + private final int id; + private final String type; + + CharacteristicType(int id, String type) { + this.id = id; + this.type = type; + } + + public int getId() { + return id; + } + + public String getType() { + return type; + } + + public String getShortType() { + int lastIndex = type.lastIndexOf("."); + return type.substring(lastIndex + 1); + } + + public static CharacteristicType from(int id) throws IllegalArgumentException { + for (CharacteristicType value : values()) { + if (value.id == id) { + return value; + } + } + throw new IllegalArgumentException("Unknown ID: " + id); + } + + public static CharacteristicType from(String type) throws IllegalArgumentException { + for (CharacteristicType value : values()) { + if (value.type.equals(type)) { + return value; + } + } + throw new IllegalArgumentException("Unknown Type: " + type); + } + + public ChannelType getChannelType() { + String channelTypeId = "typeId"; // TODO provide a unique channel type ID based on characteristic + String label = "label"; // TODO provide a meaningful label based on characteristic + String itemType = CoreItemFactory.SWITCH; // TODO determine the appropriate item type based on characteristic + String category = "sensor"; // Default category, adjust as needed + return ChannelTypeBuilder.state(new ChannelTypeUID(BINDING_ID, channelTypeId), label, itemType) + .withTags(Point.STATUS, Property.AIR_QUALITY) // Adjust tags as appropriate + .withCategory(category).build(); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java new file mode 100644 index 0000000000000..0c1da9952875b --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -0,0 +1,84 @@ +package org.openhab.binding.homekit.internal.enums; + +public enum ServiceType { + ACCESSORY_INFORMATION(0x3E, "public.hap.service.accessory-information"), + AIR_PURIFIER(0xBB, "public.hap.service.air-purifier"), + AUDIO_STREAM_MANAGEMENT(0x127, "public.hap.service.audio-stream-management"), + BATTERY(0x96, "public.hap.service.battery"), + CAMERA_RTP_STREAM_MANAGEMENT(0x110, "public.hap.service.camera-rtp-stream-management"), + DATA_STREAM_TRANSPORT_MANAGEMENT(0x129, "public.hap.service.data-stream-transport-management"), + DOOR(0x81, "public.hap.service.door"), + DOORBELL(0x121, "public.hap.service.doorbell"), + FANV2(0xB7, "public.hap.service.fanv2"), + FAUCET(0xD7, "public.hap.service.faucet"), + FILTER_MAINTENANCE(0xBA, "public.hap.service.filter-maintenance"), + GARAGE_DOOR_OPENER(0x41, "public.hap.service.garage-door-opener"), + HEATER_COOLER(0xBC, "public.hap.service.heater-cooler"), + HUMIDIFIER_DEHUMIDIFIER(0xBD, "public.hap.service.humidifier-dehumidifier"), + IRRIGATION_SYSTEM(0xCF, "public.hap.service.irrigation-system"), + LIGHTBULB(0x43, "public.hap.service.lightbulb"), + LOCK_MANAGEMENT(0x44, "public.hap.service.lock-management"), + LOCK_MECHANISM(0x45, "public.hap.service.lock-mechanism"), + MICROPHONE(0x112, "public.hap.service.microphone"), + OUTLET(0x47, "public.hap.service.outlet"), + PAIRING(0x55, "public.hap.service.pairing"), + PROTOCOL_INFORMATION_SERVICE(0xA2, "public.hap.service.protocol.information.service"), + SECURITY_SYSTEM(0x7E, "public.hap.service.security-system"), + SENSOR_AIR_QUALITY(0x8D, "public.hap.service.sensor.air-quality"), + SENSOR_CARBON_DIOXIDE(0x97, "public.hap.service.sensor.carbon-dioxide"), + SENSOR_CARBON_MONOXIDE(0x7F, "public.hap.service.sensor.carbon-monoxide"), + SENSOR_CONTACT(0x80, "public.hap.service.sensor.contact"), + SENSOR_HUMIDITY(0x82, "public.hap.service.sensor.humidity"), + SENSOR_LEAK(0x83, "public.hap.service.sensor.leak"), + SENSOR_LIGHT(0x84, "public.hap.service.sensor.light"), + SENSOR_MOTION(0x85, "public.hap.service.sensor.motion"), + SENSOR_OCCUPANCY(0x86, "public.hap.service.sensor.occupancy"), + SENSOR_SMOKE(0x87, "public.hap.service.sensor.smoke"), + SENSOR_TEMPERATURE(0x8A, "public.hap.service.sensor.temperature"), + SERVICE_LABEL(0xCC, "public.hap.service.service-label"), + SIRI(0x133, "public.hap.service.siri"), + SPEAKER(0x113, "public.hap.service.speaker"), + STATELESS_PROGRAMMABLE_SWITCH(0x89, "public.hap.service.stateless-programmable-switch"), + SWITCH(0x49, "public.hap.service.switch"), + TARGET_CONTROL(0x125, "public.hap.service.target-control"), + TARGET_CONTROL_MANAGEMENT(0x122, "public.hap.service.target-control-management"), + THERMOSTAT(0x4A, "public.hap.service.thermostat"), + VALVE(0xD0, "public.hap.service.valve"), + VERTICAL_SLAT(0xB9, "public.hap.service.vertical-slat"), + WINDOW(0x8B, "public.hap.service.window"), + WINDOW_COVERING(0x8C, "public.hap.service.window-covering"); + + private final int id; + private final String type; + + ServiceType(int id, String type) { + this.id = id; + this.type = type; + } + + public int getId() { + return id; + } + + public String getType() { + return type; + } + + public String getShortType() { + int lastIndex = type.lastIndexOf("."); + return type.substring(lastIndex + 1); + } + + public static ServiceType from(String id) throws NumberFormatException { + return from(Integer.parseInt(id)); + } + + public static ServiceType from(int id) throws IllegalArgumentException { + for (ServiceType value : values()) { + if (value.id == id) { + return value; + } + } + throw new IllegalArgumentException("Unknown ID: " + id); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 3096f7b6c090e..7738813484adb 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -12,13 +12,14 @@ */ package org.openhab.binding.homekit.internal.handler; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; + import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.jetty.client.HttpClient; -import org.openhab.binding.homekit.internal.PairingManager; -import org.openhab.binding.homekit.internal.SecureClient; -import org.openhab.binding.homekit.internal.SecureSession; -import org.openhab.binding.homekit.internal.SessionKeys; +import org.openhab.binding.homekit.internal.network.CharacteristicsManager; +import org.openhab.binding.homekit.internal.network.HttpTransport; +import org.openhab.binding.homekit.internal.network.PairingManager; +import org.openhab.binding.homekit.internal.network.SecureSession; +import org.openhab.binding.homekit.internal.network.SessionKeys; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; @@ -44,24 +45,24 @@ public class HomekitBaseServerHandler extends BaseThingHandler { private final Logger logger = LoggerFactory.getLogger(HomekitBaseServerHandler.class); - protected final HttpClient httpClient; + protected final HttpTransport httpTransport; - protected @Nullable SecureClient client; - protected @Nullable SessionKeys keys; - protected @Nullable SecureSession session; - protected @Nullable String baseUrl; - protected @Nullable String setupCode; + protected @NonNullByDefault({}) CharacteristicsManager client; + protected @NonNullByDefault({}) SessionKeys keys; + protected @NonNullByDefault({}) SecureSession session; + protected @NonNullByDefault({}) String baseUrl; + protected @NonNullByDefault({}) String pairingCode; public HomekitBaseServerHandler(Thing thing, HttpClientFactory httpClientFactory) { super(thing); - this.httpClient = httpClientFactory.getCommonHttpClient(); + this.httpTransport = new HttpTransport(httpClientFactory.getCommonHttpClient()); } @Override public void initialize() { Bridge bridge = getBridge(); if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { - // accessory is part of a bridge, so use the bridge's pairing and session + // accessory is hosted by a bridge, so use the bridge's pairing and session this.keys = bridgeHandler.keys; this.session = bridgeHandler.session; this.client = bridgeHandler.client; @@ -71,13 +72,13 @@ public void initialize() { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not connected"); } } else { - // standalone accessory or brige accessory, do pairing and session setup here - this.baseUrl = "https://" + getConfig().get("ipV4Address").toString(); - this.setupCode = getConfig().get("setupCode").toString(); + // standalone accessory or brige accessory, so do pairing and session setup here + this.baseUrl = "http://" + getConfig().get(IP_V4_ADDRESS).toString(); + this.pairingCode = getConfig().get(PAIRING_CODE).toString(); try { - this.keys = new PairingManager(httpClient, setupCode).pair(baseUrl); + this.keys = new PairingManager(httpTransport, pairingCode).pair(baseUrl); this.session = new SecureSession(keys); - this.client = new SecureClient(httpClient, session, baseUrl); + this.client = new CharacteristicsManager(httpTransport, session, baseUrl); updateStatus(ThingStatus.ONLINE); } catch (Exception e) { logger.error("Failed to initialize HomeKit client", e); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 837f95c06aeb8..8953eba1fbd09 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -12,21 +12,16 @@ */ package org.openhab.binding.homekit.internal.handler; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; + import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpMethod; -import org.openhab.binding.homekit.internal.SecureSession; -import org.openhab.binding.homekit.internal.discovery.HomekitDeviceDiscoveryService; -import org.openhab.binding.homekit.internal.dto.HomekitAccessories; -import org.openhab.binding.homekit.internal.dto.HomekitAccessory; +import org.openhab.binding.homekit.internal.discovery.AccessoryDiscoveryService; +import org.openhab.binding.homekit.internal.dto.Accessories; +import org.openhab.binding.homekit.internal.dto.Accessory; +import org.openhab.binding.homekit.internal.network.SecureSession; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; @@ -38,13 +33,12 @@ import org.slf4j.LoggerFactory; import com.google.gson.Gson; -import com.google.gson.JsonSyntaxException; /** * Handler for HomeKit bridge devices. * It marshals the communications with multiple HomeKit child accessories within a HomeKit bridge server. * It uses the /accessories endpoint to discover embedded accessories and their services. - * It notifies the {@link HomekitDeviceDiscoveryService} when accessories are discovered. + * It notifies the {@link AccessoryDiscoveryService} when accessories are discovered. * It does not currently handle commands for channels, that is left to the child accessory handlers. * It extends {@link HomekitBaseServerHandler} to handle pairing and secure session setup. * @@ -56,10 +50,11 @@ public class HomekitBridgeHandler extends HomekitBaseServerHandler implements Br private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); private static final Gson GSON = new Gson(); - private final HomekitDeviceDiscoveryService discoveryService; + + private final AccessoryDiscoveryService discoveryService; public HomekitBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, - HomekitDeviceDiscoveryService discoveryService) { + AccessoryDiscoveryService discoveryService) { super(bridge, httpClientFactory); this.discoveryService = discoveryService; } @@ -87,7 +82,7 @@ protected BridgeBuilder editThing() { public void initialize() { super.initialize(); scheduler.submit(() -> { - List accessories = getAccessories(); + List accessories = getAccessories(); discoveryService.devicesDiscovered(thing, accessories); }); } @@ -111,27 +106,20 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { * @return list of accessories (may be empty) * @see HomeKit HTTP */ - private List getAccessories() { + private List getAccessories() { SecureSession session = this.session; if (session != null) { - Request request = httpClient.newRequest(baseUrl + "/accessories") // - .timeout(5, TimeUnit.SECONDS) // - .method(HttpMethod.GET) // - .header(HttpHeader.ACCEPT, "application/json"); try { - ContentResponse response = request.send(); - if (response.getStatus() == 200) { - byte[] decrypted = session.decrypt(response.getContent()); - HomekitAccessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), - HomekitAccessories.class); - if (result != null && result.accessories != null) { - return result.accessories; - } + byte[] encrypted = httpTransport.get(baseUrl, ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); + byte[] decrypted = session.decrypt(encrypted); + Accessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), + Accessories.class); + if (result != null && result.accessories != null) { + return result.accessories; } - } catch (TimeoutException | ExecutionException | InterruptedException | JsonSyntaxException e) { + } catch (Exception e) { } } return List.of(); } - } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index c8be455e6888a..37190db4dbb70 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -15,7 +15,7 @@ import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.SecureClient; +import org.openhab.binding.homekit.internal.network.CharacteristicsManager; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.library.types.OnOffType; import org.openhab.core.thing.ChannelUID; @@ -56,14 +56,14 @@ public void initialize() { @Override public void handleCommand(ChannelUID channelUID, Command command) { - SecureClient accessoryClient = this.client; + CharacteristicsManager accessoryClient = this.client; if (accessoryClient != null) { String channelId = channelUID.getId(); try { switch (channelId) { case "power": boolean value = command.equals(OnOffType.ON); - accessoryClient.writeCharacteristic("1", "10", value); // Example AID/IID + // accessoryClient.writeCharacteristic("1", "10", value); // Example AID/IID break; // TODO Add more channels here default: @@ -80,16 +80,16 @@ public void handleCommand(ChannelUID channelUID, Command command) { * This method is called periodically by a scheduled executor. */ private void poll() { - SecureClient accessoryClient = this.client; + CharacteristicsManager accessoryClient = this.client; if (accessoryClient != null) { try { - String power = accessoryClient.readCharacteristic("1", "10"); // TODO example AID/IID + // String power = accessoryClient.readCharacteristic("1", "10"); // TODO example AID/IID // Parse powerState and update channel state accordingly - if ("true".equals(power)) { - updateState(new ChannelUID(getThing().getUID(), "power"), OnOffType.ON); - } else { - updateState(new ChannelUID(getThing().getUID(), "power"), OnOffType.OFF); - } + // if ("true".equals(power)) { + // updateState(new ChannelUID(getThing().getUID(), "power"), OnOffType.ON); + // } else { + // updateState(new ChannelUID(getThing().getUID(), "power"), OnOffType.OFF); + // } } catch (Exception e) { logger.error("Failed to poll accessory state", e); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java index 029f38596ff53..82f7c5a55e912 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java @@ -18,7 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.discovery.HomekitDeviceDiscoveryService; +import org.openhab.binding.homekit.internal.discovery.AccessoryDiscoveryService; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; @@ -32,7 +32,7 @@ /** * Creates things and thing handlers. Supports HomeKit bridges and accessories. - * Passes on a {@link HomekitDeviceDiscoveryService} so that created things can to manage discovery of accessories. + * Passes on a {@link AccessoryDiscoveryService} so that created things can to manage discovery of accessories. * * @author Andrew Fiddian-Green - Initial contribution */ @@ -42,11 +42,11 @@ public class HomekitHandlerFactory extends BaseThingHandlerFactory { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE); - private final HomekitDeviceDiscoveryService discoveryService; + private final AccessoryDiscoveryService discoveryService; private final HttpClientFactory httpClientFactory; @Activate - public HomekitHandlerFactory(@Reference HomekitDeviceDiscoveryService discoveryService, + public HomekitHandlerFactory(@Reference AccessoryDiscoveryService discoveryService, @Reference HttpClientFactory httpClientFactory) { this.discoveryService = discoveryService; this.httpClientFactory = httpClientFactory; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java similarity index 98% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java index aca3a8878d5e1..2f6f72d4f00ed 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/ChaCha20.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal; +package org.openhab.binding.homekit.internal.network; import org.bouncycastle.crypto.modes.ChaCha20Poly1305; import org.bouncycastle.crypto.params.AEADParameters; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java new file mode 100644 index 0000000000000..c81073322acdc --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.network; + +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; + +import java.nio.charset.StandardCharsets; + +/** + * HTTP client methods for reading and writing HomeKit accessory characteristics over a secure session. + * It handles encryption and decryption of requests and responses. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +public class CharacteristicsManager { + + private static final String JSON_TEMPLATE = "{\"%s\":[{\"aid\":%s,\"iid\":%s,\"value\":%s}]}"; + + private final SecureSession session; + private final HttpTransport httpTransport; + private final String baseUrl; + + public CharacteristicsManager(HttpTransport httpTransport, SecureSession session, String baseUrl) { + this.httpTransport = httpTransport; + this.session = session; + this.baseUrl = baseUrl; + } + + /** + * Reads a characteristic from the accessory. + * + * @param aid Accessory ID + * @param iid Instance ID + * @return JSON response as String + * @throws Exception on communication or encryption errors + */ + public String readCharacteristic(String aid, String iid) throws Exception { + String endpoint = "%s?id=%s.%s".formatted(ENDPOINT_CHARACTERISTICS, aid, iid); + byte[] encrypted = httpTransport.get(baseUrl, endpoint, CONTENT_TYPE_HAP); + byte[] decrypted = session.decrypt(encrypted); + return new String(decrypted, StandardCharsets.UTF_8); + } + + /** + * Writes a characteristic to the accessory. + * + * @param aid Accessory ID + * @param iid Instance ID + * @param value Value to write (String, Number, Boolean) + * @throws Exception on communication or encryption errors + */ + public void writeCharacteristic(String aid, String iid, Object value) throws Exception { + String json = JSON_TEMPLATE.formatted(ENDPOINT_CHARACTERISTICS, aid, iid, formatValue(value)); + byte[] encrypted = session.encrypt(json.getBytes()); + httpTransport.put(baseUrl, ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, encrypted); + } + + /* + * Formats the value for JSON. Strings are quoted, numbers and booleans are not. + */ + private String formatValue(Object value) { + if (value instanceof Boolean || value instanceof Number) { + return value.toString(); + } + return "\"" + value.toString() + "\""; + } +} \ No newline at end of file diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java new file mode 100644 index 0000000000000..c1cc3ad5c7d21 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.network; + +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.BytesContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; + +/** + * Handles HTTP transport for HomeKit communication. + * It provides methods for sending GET, POST, and PUT requests with appropriate headers and content types. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HttpTransport { + + private final HttpClient httpClient; + + public HttpTransport(HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Sends a GET request to the specified URL and endpoint, expecting a response of the given content type. + * + * @param url the target URL + * @param endpoint the endpoint path + * @param contentType the expected content type of the response + * + * @return the response body + * @throws Exception if an error occurs during the request + */ + public byte[] get(String baseUrl, String endpoint, String contentType) throws Exception { + String url = baseUrl + "/" + endpoint; + Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.GET) + .header(HttpHeader.ACCEPT, contentType); + + ContentResponse response = request.send(); + if (response.getStatus() != 200) { + throw new RuntimeException("GET %s HTTP %d".formatted(url, response.getStatus())); + } + + return response.getContent(); + } + + /** + * Sends a POST request with the given payload and content type to the specified URL and endpoint. + * + * @param url the target URL + * @param endpoint the endpoint path + * @param contentType the content type of the request + * @param content the request body + * + * @return the response body + * @throws Exception if an error occurs during the request + */ + public byte[] post(String baseUrl, String endpoint, String contentType, byte[] content) throws Exception { + String url = baseUrl + "/" + endpoint; + Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.POST) + .header(HttpHeader.CONTENT_TYPE, contentType).content(new BytesContentProvider(content)); + + ContentResponse response = request.send(); + if (response.getStatus() != 200) { + throw new RuntimeException("POST %s HTTP %d".formatted(url, response.getStatus())); + } + + return response.getContent(); + } + + /** + * Sends a PUT request with the given payload and content type to the specified URL and endpoint. + * + * @param url the target URL + * @param endpoint the endpoint path + * @param contentType the content type of the request + * @param content the request body + * + * @return the response body + * @throws Exception if an error occurs during the request + */ + public byte[] put(String baseUrl, String endpoint, String contentType, byte[] content) throws Exception { + String url = baseUrl + "/" + endpoint; + Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.POST) + .header(HttpHeader.ACCEPT, contentType).header(HttpHeader.CONTENT_TYPE, contentType) + .content(new BytesContentProvider(content)); + + ContentResponse response = request.send(); + if (response.getStatus() != 200) { + throw new RuntimeException("PUT %s error: HTTP %d".formatted(url, response.getStatus())); + } + + return response.getContent(); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java similarity index 55% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java index 8c47d6f1d2997..5dd73ca071118 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/PairingManager.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java @@ -10,17 +10,11 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal; +package org.openhab.binding.homekit.internal.network; -import java.util.Map; -import java.util.concurrent.TimeUnit; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.client.util.BytesContentProvider; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpMethod; +import java.util.Map; /** * Handles the 6-step pairing process with a HomeKit accessory. @@ -34,11 +28,11 @@ public class PairingManager { private final SRPClient srpClient; - private final HttpClient httpClient; + private final HttpTransport httpTransport; - public PairingManager(HttpClient httpClient, String setupCode) { - this.httpClient = httpClient; - this.srpClient = new SRPClient(setupCode); + public PairingManager(HttpTransport httpTransport, String pairingCode) { + this.httpTransport = httpTransport; + this.srpClient = new SRPClient(pairingCode); } /** @@ -49,7 +43,7 @@ public PairingManager(HttpClient httpClient, String setupCode) { public SessionKeys pair(String baseUrl) throws Exception { // Step 1: M1 — Start Pairing byte[] m1 = TLV8Codec.encode(Map.of(0x00, new byte[] { 0x00 }, 0x01, new byte[] { 0x01 })); - byte[] resp1 = post(baseUrl + "/pair-setup", m1); + byte[] resp1 = httpTransport.post(baseUrl, ENDPOINT_PAIRING, CONTENT_TYPE_PAIRING, m1); // Step 2: M2 — Receive SRP salt and public key Map tlv2 = TLV8Codec.decode(resp1); @@ -57,7 +51,7 @@ public SessionKeys pair(String baseUrl) throws Exception { // Step 3: M3 — Send SRP public key and proof Map m3 = srpClient.generateClientProof(); - byte[] resp3 = post(baseUrl + "/pair-setup", TLV8Codec.encode(m3)); + byte[] resp3 = httpTransport.post(baseUrl, ENDPOINT_PAIRING, CONTENT_TYPE_PAIRING, TLV8Codec.encode(m3)); // Step 4: M4 — Verify server proof Map tlv4 = TLV8Codec.decode(resp3); @@ -65,7 +59,7 @@ public SessionKeys pair(String baseUrl) throws Exception { // Step 5: M5 — Exchange encrypted identifiers Map m5 = srpClient.generateEncryptedIdentifiers(); - byte[] resp5 = post(baseUrl + "/pair-setup", TLV8Codec.encode(m5)); + byte[] resp5 = httpTransport.post(baseUrl, ENDPOINT_PAIRING, CONTENT_TYPE_PAIRING, TLV8Codec.encode(m5)); // Step 6: M6 — Final confirmation Map tlv6 = TLV8Codec.decode(resp5); @@ -74,26 +68,4 @@ public SessionKeys pair(String baseUrl) throws Exception { // Derive session keys return srpClient.deriveSessionKeys(); } - - /** - * Sends a POST request with the given payload to the specified URL. - * - * @param url the target URL - * @param payload the request body - * @return the response body - * @throws Exception if an error occurs during the request - */ - private byte[] post(String url, byte[] payload) throws Exception { - Request request = httpClient.newRequest(url) // - .timeout(5, TimeUnit.SECONDS) // - .method(HttpMethod.POST) // - .header(HttpHeader.CONTENT_TYPE, "application/pairing+tlv8") // - .header(HttpHeader.ACCEPT, "application/json") // - .content(new BytesContentProvider(payload)); - ContentResponse response = request.send(); - if (response.getStatus() != 200) { - throw new RuntimeException("Pairing failed: HTTP " + response.getStatus()); - } - return response.getContent(); - } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java similarity index 94% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java index 215b8231bdf78..5ac9b943e8950 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SRPClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal; +package org.openhab.binding.homekit.internal.network; import java.io.ByteArrayOutputStream; import java.math.BigInteger; @@ -47,15 +47,16 @@ public class SRPClient { private static final BigInteger N = new BigInteger(N_HEX); private static final BigInteger g = BigInteger.valueOf(5); - private final String setupCode; + private final String pairingCode; + private BigInteger a; // private ephemeral private BigInteger A; // public ephemeral private BigInteger B; // server public - private byte[] salt; + private byte[] salt; // from server private byte[] K; // shared session key - public SRPClient(String setupCode) { - this.setupCode = setupCode; + public SRPClient(String pairingCode) { + this.pairingCode = pairingCode; } /** @@ -82,7 +83,7 @@ public void processChallenge(byte[] salt, byte[] serverPublicKey) throws Excepti */ public Map generateClientProof() throws Exception { MessageDigest digest = MessageDigest.getInstance("SHA-512"); - byte[] xH = digest.digest((new String(salt) + setupCode).getBytes()); + byte[] xH = digest.digest((new String(salt) + pairingCode).getBytes()); BigInteger x = new BigInteger(1, xH); BigInteger u = computeU(A, B); @@ -131,7 +132,7 @@ public void verifyAccessoryIdentifiers(Map tlv6) throws Excepti byte[] nonce = tlv6.get(0x05); byte[] encrypted = tlv6.get(0x06); byte[] decrypted = ChaCha20.decrypt(K, nonce, encrypted); - // TODO Parse TLV8 and validate accessory identity + // TODO parse decrypted TLV8 and specificall validate accessory identity } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java similarity index 97% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java index ba82f263ea1bf..7a3d96483e8bd 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal; +package org.openhab.binding.homekit.internal.network; import java.util.concurrent.atomic.AtomicInteger; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java similarity index 97% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java index 44f3466527d54..8fa59e476fc2e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/SessionKeys.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal; +package org.openhab.binding.homekit.internal.network; import java.nio.charset.StandardCharsets; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java similarity index 98% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java index 9960768334981..f2766725dae72 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/TLV8Codec.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal; +package org.openhab.binding.homekit.internal.network; import java.io.ByteArrayOutputStream; import java.util.Arrays; diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 1f3f186669367..e8b834f5ea0fd 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -4,45 +4,52 @@ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0" xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd"> - - - - - - Sample thing for HomeKit Binding - - - - - + + + HomeKit Accessory Device - + network-address - - Hostname or IP address of the device + + IP v4 address of the HomeKit accessory device + true - + password Password to access the device + true - + Interval the device is polled in sec. - 600 + 60 true - - - Number:Temperature - - Sample channel for HomeKit Binding - + + + HomeKit Accessory Bridge + + + network-address + + IP v4 address of the HomeKit accessory device + + + password + + Password to access the device + + + + Interval the device is polled in sec. + 60 + true + + + + From 44179715e357699e46b6437deffa588b915dea72 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Sep 2025 19:27:46 +0100 Subject: [PATCH 007/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../discovery/AccessoryDiscoveryService.java | 10 +- .../HomekitMdnsDiscoveryParticipant.java | 10 +- .../homekit/internal/dto/Accessories.java | 6 +- .../homekit/internal/dto/Accessory.java | 89 ++- .../homekit/internal/dto/Characteristic.java | 577 +++++++++++++++++- .../binding/homekit/internal/dto/Service.java | 77 ++- .../homekit/internal/enums/AccessoryType.java | 23 +- .../internal/enums/CharacteristicType.java | 47 +- .../internal/enums/DataFormatType.java | 35 ++ .../homekit/internal/enums/ServiceType.java | 23 +- .../handler/HomekitBaseServerHandler.java | 48 +- .../handler/HomekitBridgeHandler.java | 47 +- .../handler/HomekitDeviceHandler.java | 27 +- .../handler/HomekitHandlerFactory.java | 51 +- .../HomekitStorageBasedTypeProvider.java | 31 + 15 files changed, 911 insertions(+), 190 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/DataFormatType.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitStorageBasedTypeProvider.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java index ca464979c9446..608da480e06ba 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java @@ -37,7 +37,7 @@ @Component(service = DiscoveryService.class) public class AccessoryDiscoveryService extends AbstractDiscoveryService { - protected AccessoryDiscoveryService() { + public AccessoryDiscoveryService() { super(Set.of(THING_TYPE_DEVICE), 10, false); } @@ -48,14 +48,14 @@ protected void startScan() { public void devicesDiscovered(Thing bridge, List accessories) { accessories.forEach(accessory -> { - if (accessory.aid != null && accessory.services != null) { + if (accessory.accessoryId != null && accessory.services != null) { accessory.services.forEach(service -> { - if (service.type != null && service.iid != null) { - String id = "%d-%d".formatted(accessory.aid, service.iid); + if (service.instanceId != null) { + String id = "%d-%d".formatted(accessory.accessoryId, service.instanceId); ThingUID uid = new ThingUID(THING_TYPE_DEVICE, bridge.getUID(), id); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // - .withLabel(service.type) // + .withLabel(service.toString()) // .withProperty("uid", uid.toString()) // .withRepresentationProperty("uid").build()); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 2caa3ad4290d8..a77177a47d511 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -90,13 +90,13 @@ public String getServiceType() { public @Nullable ThingUID getThingUID(ServiceInfo service) { String macAddress = service.getPropertyString("id"); if (macAddress != null) { - String deviceCategory = service.getPropertyString("ci"); // HomeKit device category + macAddress = macAddress.replace(":", "-").toLowerCase(); + String accessoryType = service.getPropertyString("ci"); // HomeKit accessory type try { - AccessoryType category = AccessoryType.from(deviceCategory); - if (AccessoryType.BRIDGE.equals(category)) { - return new ThingUID(THING_TYPE_BRIDGE, macAddress.replace(":", "-").toLowerCase()); + if (AccessoryType.BRIDGE.equals(AccessoryType.from(Integer.parseInt(accessoryType)))) { + return new ThingUID(THING_TYPE_BRIDGE, macAddress); } else { - return new ThingUID(THING_TYPE_DEVICE, macAddress.replace(":", "-").toLowerCase()); + return new ThingUID(THING_TYPE_DEVICE, macAddress); } } catch (IllegalArgumentException e) { } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java index 5b702306f20a8..96c6904a92e71 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java @@ -14,9 +14,6 @@ import java.util.List; -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; - /** * HomeKit accessories DTO. * Used to deserialize the JSON response from the /accessories endpoint of a HomeKit bridge. @@ -24,7 +21,6 @@ * * @author Andrew Fiddian-Green - Initial contribution */ -@NonNullByDefault public class Accessories { - public @Nullable List accessories; + public List accessories; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 047984106b7e3..66699dad5cdd1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -14,9 +14,12 @@ import java.util.List; -import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.AccessoryType; +import org.openhab.core.semantics.SemanticTag; +import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment; + +import com.google.gson.annotations.SerializedName; /** * HomeKit accessory DTO @@ -25,16 +28,92 @@ * * @author Andrew Fiddian-Green - Initial contribution */ -@NonNullByDefault public class Accessory { - public @Nullable Integer aid; // e.g. 1 - public @Nullable List services; + public @SerializedName("aid") Integer accessoryId; // e.g. 1 + public List services; + + @Override + public String toString() { + return getAccessoryType().toString(); + } public AccessoryType getAccessoryType() { - Integer aid = this.aid; + Integer aid = this.accessoryId; if (aid == null) { return AccessoryType.OTHER; } return AccessoryType.from(aid); } + + /** + * Maps the accessory type to a corresponding semantic equipment tag. + * Returns null if there is no suitable mapping. + * + * @return the corresponding SemanticTag or null if none exists + */ + public @Nullable SemanticTag getSemanticEquipmentTag() { + switch (getAccessoryType()) { + case BRIDGE: + return Equipment.NETWORK_APPLIANCE; + case FAN: + return Equipment.FAN; + case OUTLET: + return Equipment.POWER_OUTLET; + case SWITCH: + return Equipment.CONTROL_DEVICE; + case THERMOSTAT: + return Equipment.THERMOSTAT; + case WINDOW: + return Equipment.WINDOW; + case WINDOW_COVERING: + return Equipment.WINDOW_COVERING; + case DOOR: + return Equipment.DOOR; + case AIR_PURIFIER: + return Equipment.AIR_FILTER; + case AIR_CONDITIONER: + return Equipment.AIR_CONDITIONER; + case SECURITY_SYSTEM: + return Equipment.ALARM_SYSTEM; + case SENSOR: + return Equipment.SENSOR; + case AIRPORT: + return Equipment.NETWORK_APPLIANCE; + case APPLE_TV: + return Equipment.MEDIA_PLAYER; + case DEHUMIDIFIER: + return Equipment.DEHUMIDIFIER; + case DOOR_LOCK: + return Equipment.LOCK; + case FAUCET: + return Equipment.HOT_WATER_FAUCET; + case GARAGE_DOOR: + return Equipment.GARAGE_DOOR; + case HEATER: + return Equipment.HVAC; + case HUMIDIFIER: + return Equipment.HUMIDIFIER; + case IP_CAMERA: + return Equipment.CAMERA; + case LIGHTING: + return Equipment.LIGHT_SOURCE; + case PROGRAMMABLE_SWITCH: + return Equipment.CONTROL_DEVICE; + case REMOTE: + return Equipment.REMOTE_CONTROL; + case SHOWER_HEAD: + return Equipment.SHOWER; + case SPEAKER: + return Equipment.SPEAKER; + case SPRINKLER: + return Equipment.IRRIGATION; + case TELEVISION: + return Equipment.TELEVISION; + case VIDEO_DOORBELL: + return Equipment.DOORBELL; + case OTHER: + case RESERVED: + } + return null; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index bca46775a43ea..285832dff838b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -14,18 +14,29 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.BINDING_ID; +import java.math.BigDecimal; import java.util.List; +import java.util.Objects; + +import javax.measure.Unit; -import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.CharacteristicType; +import org.openhab.binding.homekit.internal.enums.DataFormatType; +import org.openhab.binding.homekit.internal.provider.HomekitStorageBasedTypeProvider; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Point; import org.openhab.core.semantics.model.DefaultSemanticTags.Property; +import org.openhab.core.thing.type.ChannelDefinition; +import org.openhab.core.thing.type.ChannelDefinitionBuilder; import org.openhab.core.thing.type.ChannelType; import org.openhab.core.thing.type.ChannelTypeBuilder; import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.thing.type.StateChannelTypeBuilder; +import org.openhab.core.types.StateDescriptionFragment; +import org.openhab.core.types.StateDescriptionFragmentBuilder; +import org.openhab.core.types.util.UnitUtils; import com.google.gson.annotations.SerializedName; @@ -37,36 +48,558 @@ * * @author Andrew Fiddian-Green - Initial contribution */ -@NonNullByDefault public class Characteristic { - public @Nullable @SerializedName("iid") Integer instanceId; // e.g. 10 - public @Nullable @SerializedName("type") String characteristicId; // e.g. '25' = 'public.hap.characteristic.on' - public @Nullable @SerializedName("value") String dataValue; // e.g. true - public @Nullable @SerializedName("format") String dataFormat; // e.g. "bool" - public @Nullable @SerializedName("perms") List permissions; // e.g. ["read", "write", "events"] + + // invariant fields that define a unique characteristic + public @SerializedName("type") String characteristicId; // e.g. '25' => 'public.hap.characteristic.on' + public @SerializedName("format") String dataFormat; // e.g. "bool" + public @SerializedName("unit") String unit; // e.g. "celsius" + public @SerializedName("maxValue") Double maxValue; // e.g. 100 + public @SerializedName("minValue") Double minValue; // e.g. 0 + public @SerializedName("minStep") Double minStep; + public @SerializedName("perms") List permissions; // e.g. ["pr", "pw", "ev"] + + // ephemeral fields that may change over time or across instances + public @SerializedName("iid") Integer instanceId; // e.g. 10 + public @SerializedName("value") String dataValue; // e.g. true + public @SerializedName("description") String description; + + // configuration information fields + public @SerializedName("ev") Boolean eventNotification; // e.g. true + public @SerializedName("maxLen") Double maxLen; // e.g. 64 /** - * Converts this characteristic to an openHAB ChannelType, if possible. - * Returns null if the characteristic ID or data format is missing or unrecognized. + * The hash only includes the invariant fields as needed to define a fully unique characteristic. + * The instanceId, dataValue and description are excluded as they depend on accessory instance and state. * - * @return the corresponding ChannelType, or null if not mappable + * @return hash code */ - public @Nullable ChannelType getChannelType() { - String characId = this.characteristicId; - String dataFormat = this.dataFormat; - if (characId == null || dataFormat == null) { + @Override + public int hashCode() { + return Objects.hash(characteristicId, dataFormat, unit, minValue, maxValue, minStep, permissions); + } + + /** + * Builds a ChannelDefinition and ChannelType based on the characteristic properties. + * Registers the ChannelType with the provided {@link HomekitStorageBasedTypeProvider}. + * Returns null if the characteristic cannot be mapped to a channel definition. + * Examines characteristic type, data format, permissions, and other properties + * to determine appropriate channel type, item type, tags, category, and attributes. + * + * @param typeProvider the HomekitTypeProvider to register the channel type with + * @return the ChannelDefinition or null if it cannot be mapped + */ + public @Nullable ChannelDefinition buildAndRegisterChannelDefinition(HomekitStorageBasedTypeProvider typeProvider) { + CharacteristicType characteristicType; + try { + characteristicType = CharacteristicType.from(Integer.parseInt(characteristicId)); + } catch (IllegalArgumentException e) { + return null; + } + + DataFormatType dataFormatType; + try { + dataFormatType = DataFormatType.from(dataFormat); + } catch (IllegalArgumentException e) { return null; } - CharacteristicType characType = CharacteristicType.from(characId); + Unit unit = null; + String temp = this.unit; + if (temp != null) { + unit = UnitUtils.parseUnit(temp); + } + + // determine channel type and attributes based on characteristic properties + boolean isReadOnly = !permissions.contains("pw"); + boolean isString = DataFormatType.STRING == dataFormatType; + boolean isBoolean = DataFormatType.BOOL == dataFormatType; + boolean isNumber = !isString && !isBoolean; + boolean isStateChannel = true; + + String itemType = null; + String category = null; + String numberSuffix = null; + SemanticTag pointTag = null; + SemanticTag propertyTag = null; + + if (isReadOnly) { + if (isBoolean) { + itemType = CoreItemFactory.SWITCH; + pointTag = Point.STATUS; + category = "switch"; + } else if (isNumber) { + itemType = CoreItemFactory.NUMBER; + pointTag = Point.MEASUREMENT; + } else if (isString) { + itemType = CoreItemFactory.STRING; + pointTag = Point.STATUS; + } + } else { + if (isBoolean) { + itemType = CoreItemFactory.SWITCH; + pointTag = Point.SWITCH; + category = "switch"; + } else if (isNumber) { + itemType = CoreItemFactory.NUMBER; + pointTag = Point.SETPOINT; + } else if (isString) { + itemType = CoreItemFactory.STRING; + pointTag = Point.CONTROL; + } + } + + switch (characteristicType) { + case ACCESSORY_PROPERTIES: + case ACTIVE: + case ACTIVE_IDENTIFIER: + case ADMINISTRATOR_ONLY_ACCESS: + break; + + case AIR_PARTICULATE_DENSITY: + numberSuffix = "Density"; + propertyTag = Property.PARTICULATE_MATTER; + break; + + case AIR_PARTICULATE_SIZE: + numberSuffix = "Length"; + propertyTag = Property.PARTICULATE_MATTER; + break; + + case AIR_PURIFIER_STATE_CURRENT: + case AIR_PURIFIER_STATE_TARGET: + propertyTag = Property.ENABLED; + break; + + case AIR_QUALITY: + numberSuffix = "Dimensionless"; + propertyTag = Property.AIR_QUALITY; + break; + + case AUDIO_FEEDBACK: + break; + + case BATTERY_LEVEL: + numberSuffix = "Dimensionless"; + propertyTag = Property.ENERGY; + category = "battery"; + break; + + case BRIGHTNESS: + itemType = CoreItemFactory.DIMMER; + propertyTag = Property.BRIGHTNESS; + category = "light"; + break; + + case BUTTON_EVENT: + isStateChannel = false; + break; + + case CARBON_DIOXIDE_DETECTED: + pointTag = Point.ALARM; + propertyTag = Property.CO2; + category = "co2"; + break; + + case CARBON_DIOXIDE_LEVEL: + case CARBON_DIOXIDE_PEAK_LEVEL: + numberSuffix = "Density"; + propertyTag = Property.CO2; + category = "co2"; + break; + + case CARBON_MONOXIDE_DETECTED: + pointTag = Point.ALARM; + propertyTag = Property.CO; + category = "alarm"; + break; + + case CARBON_MONOXIDE_LEVEL: + case CARBON_MONOXIDE_PEAK_LEVEL: + numberSuffix = "Density"; + propertyTag = Property.CO; + break; + + case CHARGING_STATE: + propertyTag = Property.ENERGY; + category = "battery"; + break; + + case COLOR_TEMPERATURE: + numberSuffix = unit == null ? "Dimensionless" : "Temperature"; + propertyTag = Property.COLOR_TEMPERATURE; + category = "light"; + break; + + case CONTACT_STATE: + break; + + case DENSITY_NO2: + numberSuffix = "Density"; + propertyTag = Property.AIR_QUALITY; + break; + + case DENSITY_OZONE: + numberSuffix = "Density"; + propertyTag = Property.OZONE; + break; + + case DENSITY_PM10: + case DENSITY_PM2_5: + numberSuffix = "Density"; + propertyTag = Property.PARTICULATE_MATTER; + break; + + case DENSITY_SO2: + numberSuffix = "Density"; + propertyTag = Property.AIR_QUALITY; + break; + + case DENSITY_VOC: + numberSuffix = "Density"; + propertyTag = Property.VOC; + break; + + case DOOR_STATE_CURRENT: + case DOOR_STATE_TARGET: + propertyTag = Property.OPEN_STATE; + break; + + case FAN_STATE_CURRENT: + case FAN_STATE_TARGET: + propertyTag = Property.POWER; + break; + + case FILTER_CHANGE_INDICATION: + case FILTER_LIFE_LEVEL: + case FILTER_RESET_INDICATION: + break; + + case FIRMWARE_REVISION: + case HARDWARE_REVISION: + break; + + case HEATER_COOLER_STATE_CURRENT: + case HEATER_COOLER_STATE_TARGET: + propertyTag = Property.POWER; + category = "heating"; + break; + + case HEATING_COOLING_CURRENT: + case HEATING_COOLING_TARGET: + propertyTag = Property.MODE; + category = "heating"; + break; + + case HORIZONTAL_TILT_CURRENT: + case HORIZONTAL_TILT_TARGET: + propertyTag = Property.TILT; + break; + + case HUE: + numberSuffix = "Dimensionless"; + propertyTag = Property.COLOR; + itemType = CoreItemFactory.COLOR; + category = "color"; + break; + + case HUMIDIFIER_DEHUMIDIFIER_STATE_CURRENT: + case HUMIDIFIER_DEHUMIDIFIER_STATE_TARGET: + propertyTag = Property.HUMIDITY; + category = "humidity"; + break; + + case IDENTIFY: + isStateChannel = false; + break; + + case IMAGE_MIRROR: + case IMAGE_ROTATION: + category = "image"; + break; + + case INPUT_EVENT: + isStateChannel = false; + break; + + case IN_USE: + case IS_CONFIGURED: + break; + + case LEAK_DETECTED: + pointTag = Point.ALARM; + propertyTag = Property.WATER; + category = "alarm"; + break; + + case LIGHT_LEVEL_CURRENT: + numberSuffix = "Illuminance"; + propertyTag = Property.ILLUMINANCE; + break; + + case LOCK_MANAGEMENT_AUTO_SECURE_TIMEOUT: + case LOCK_MANAGEMENT_CONTROL_POINT: + case LOCK_MECHANISM_LAST_KNOWN_ACTION: + case LOCK_PHYSICAL_CONTROLS: + category = "lock"; + break; + + case LOCK_MECHANISM_CURRENT_STATE: + case LOCK_MECHANISM_TARGET_STATE: + propertyTag = Property.LOCK_STATE; + category = "lock"; + break; + + case LOGS: + case MANUFACTURER: + case MODEL: + break; + + case MOTION_DETECTED: + propertyTag = Property.MOTION; + category = "motion"; + break; + + case MUTE: + propertyTag = Property.SOUND_VOLUME; + category = "sound"; + break; + + case NAME: + case NIGHT_VISION: + case OBSTRUCTION_DETECTED: + break; + + case OCCUPANCY_DETECTED: + propertyTag = Property.PRESENCE; + break; + + case ON: + propertyTag = Property.POWER; + category = "switch"; + break; + + case OUTLET_IN_USE: + break; + + case PAIRING_FEATURES: + case PAIRING_PAIRINGS: + case PAIRING_PAIR_SETUP: + case PAIRING_PAIR_VERIFY: + break; + + case POSITION_CURRENT: + case POSITION_HOLD: + case POSITION_STATE: + case POSITION_TARGET: + propertyTag = Property.OPENING; + break; + + case PROGRAM_MODE: + propertyTag = Property.MODE; + break; + + case RELATIVE_HUMIDITY_CURRENT: + case RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD: + case RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD: + case RELATIVE_HUMIDITY_TARGET: + numberSuffix = "Dimensionless"; + propertyTag = Property.HUMIDITY; + category = "humidity"; + break; + + case REMAINING_DURATION: + propertyTag = Property.DURATION; + break; + + case ROTATION_DIRECTION: + case ROTATION_SPEED: + break; + + case SATURATION: + numberSuffix = "Dimensionless"; + propertyTag = Property.COLOR; + itemType = CoreItemFactory.COLOR; + category = "color"; + break; + + case SECURITY_SYSTEM_ALARM_TYPE: + pointTag = Point.ALARM; + break; + + case SECURITY_SYSTEM_STATE_CURRENT: + case SECURITY_SYSTEM_STATE_TARGET: + propertyTag = Property.ENABLED; + break; + + case SELECTED_AUDIO_STREAM_CONFIGURATION: + case SELECTED_RTP_STREAM_CONFIGURATION: + case SERIAL_NUMBER: + case SERVICE_LABEL_INDEX: + case SERVICE_LABEL_NAMESPACE: + case SETUP_DATA_STREAM_TRANSPORT: + case SETUP_ENDPOINTS: + break; + + case SET_DURATION: + propertyTag = Property.DURATION; + break; + + case SIRI_INPUT_TYPE: + break; + + case SLAT_STATE_CURRENT: + propertyTag = Property.TILT; + break; + + case SMOKE_DETECTED: + pointTag = Point.ALARM; + propertyTag = Property.SMOKE; + category = "smoke"; + break; + + case STATUS_ACTIVE: + break; + + case STATUS_FAULT: + pointTag = Point.ALARM; + break; + + case STATUS_JAMMED: + pointTag = Point.ALARM; + break; + + case STATUS_LO_BATT: + pointTag = Point.ALARM; + propertyTag = Property.LOW_BATTERY; + category = "battery"; + break; + + case STATUS_TAMPERED: + pointTag = Point.ALARM; + propertyTag = Property.TAMPERED; + category = "lock"; + break; + + case STREAMING_STATUS: + propertyTag = Property.MEDIA_CONTROL; + break; + + case SUPPORTED_AUDIO_CONFIGURATION: + case SUPPORTED_DATA_STREAM_TRANSPORT_CONFIGURATION: + case SUPPORTED_RTP_CONFIGURATION: + case SUPPORTED_TARGET_CONFIGURATION: + case SUPPORTED_VIDEO_STREAM_CONFIGURATION: + break; + + case SWING_MODE: + propertyTag = Property.AIRFLOW; + break; + + case TARGET_LIST: + break; + + case TEMPERATURE_COOLING_THRESHOLD: + case TEMPERATURE_CURRENT: + case TEMPERATURE_HEATING_THRESHOLD: + case TEMPERATURE_TARGET: + propertyTag = Property.TEMPERATURE; + category = "temperature"; + break; + + case TEMPERATURE_UNITS: + category = "temperature"; + break; + + case TILT_CURRENT: + case TILT_TARGET: + propertyTag = Property.TILT; + break; + + case TYPE_SLAT: + case VALVE_TYPE: + case VERSION: + break; + + case VERTICAL_TILT_CURRENT: + case VERTICAL_TILT_TARGET: + propertyTag = Property.TILT; + break; + + case VOLUME: + propertyTag = Property.SOUND_VOLUME; + category = "sound"; + break; + + case WATER_LEVEL: + propertyTag = Property.WATER; + break; + + case ZOOM_DIGITAL: + case ZOOM_OPTICAL: + break; + } + + if (CoreItemFactory.NUMBER.equals(itemType) && numberSuffix != null) { + itemType = itemType + ":" + numberSuffix; + } + + /* + * different accessories may have the same characteristicId, but their other properties + * e.g. min, max, step, unit may be different so we must ensure unique channel type UIDs + */ + ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, Integer.toHexString(hashCode())); + + ChannelType channelType; + if (isStateChannel) { + if (itemType == null) { + return null; + } + + // build StateDescriptionFragment if any relevant properties are present + StateDescriptionFragment stateDescriptionFragment = null; + if (minValue != null || maxValue != null || minStep != null || temp != null) { + StateDescriptionFragmentBuilder builder = StateDescriptionFragmentBuilder.create(); + builder.withReadOnly(isReadOnly); + if (minValue != null) { + builder.withMinimum(BigDecimal.valueOf(minValue)); + } + if (maxValue != null) { + builder.withMaximum(BigDecimal.valueOf(maxValue)); + } + if (minStep != null) { + builder.withStep(BigDecimal.valueOf(minStep)); + } + if (unit != null) { + builder.withPattern("%.0f " + unit.getSymbol()); + } + stateDescriptionFragment = builder.build(); + } + + StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, characteristicType.toString(), itemType); + if (stateDescriptionFragment != null) { + builder.withStateDescriptionFragment(stateDescriptionFragment); + } + if (category != null) { + builder.withCategory(category); + } + if (pointTag != null) { + if (propertyTag != null) { + builder.withTags(pointTag, propertyTag); + } else { + builder.withTags(pointTag); + } + } + // state channel + channelType = builder.build(); + } else { + // trigger channel + channelType = ChannelTypeBuilder.trigger(uid, characteristicType.toString()).build(); + } - String label = "label"; // TODO determine label based on characType - String itemType = CoreItemFactory.SWITCH; // TODO determine item type based on characType - String category = "sensor"; // TODO determine category based on characType - SemanticTag point = Point.STATUS; // TODO determine point based on characType - SemanticTag property = Property.AIR_QUALITY; // TODO determine property based on characteristicType + typeProvider.putChannelType(channelType); - return ChannelTypeBuilder.state(new ChannelTypeUID(BINDING_ID, characId), label, itemType) - .withTags(point, property).withCategory(category).build(); + return new ChannelDefinitionBuilder(Integer.toString(instanceId), uid).withLabel(characteristicType.toString()) + .withDescription(description).build(); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index a0ed92bf3799c..88398ecae549a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -14,18 +14,18 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.BINDING_ID; -import java.util.ArrayList; import java.util.List; +import java.util.Objects; -import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.ServiceType; +import org.openhab.binding.homekit.internal.provider.HomekitStorageBasedTypeProvider; import org.openhab.core.thing.type.ChannelDefinition; -import org.openhab.core.thing.type.ChannelDefinitionBuilder; +import org.openhab.core.thing.type.ChannelGroupDefinition; import org.openhab.core.thing.type.ChannelGroupType; import org.openhab.core.thing.type.ChannelGroupTypeBuilder; import org.openhab.core.thing.type.ChannelGroupTypeUID; -import org.openhab.core.thing.type.ChannelType; import com.google.gson.annotations.SerializedName; @@ -36,44 +36,55 @@ * * @author Andrew Fiddian-Green - Initial contribution */ -@NonNullByDefault public class Service { - public @Nullable @SerializedName("type") String serviceId; // e.g. '96' = 'public.hap.service.battery' - public @Nullable @SerializedName("iid") Integer instanceId; // e.g. 10 - public @Nullable List characteristics; + public @SerializedName("type") String serviceId; // e.g. '96' => 'public.hap.service.battery' + public @SerializedName("iid") Integer instanceId; // e.g. 10 + public List characteristics; - public ServiceType getServiceType() { - Integer iid = this.iid; - if (iid == null) { - return ServiceType.UNKNOWN; - } - return ServiceType.from(iid); + /** + * The hash only includes the invariant fields as needed to define a fully unique channel group type. + * The instanceId is excluded as it depends on the accessory instance. + * The characteristics are included as they define the channels within the channel group. + * + * @return hash code + */ + @Override + public int hashCode() { + return Objects.hash(serviceId, instanceId, characteristics); } - public @Nullable ChannelGroupType getChannelType() { - String serviceId = this.serviceId; - List characteristics = this.characteristics; - if (serviceId == null || characteristics == null) { + /** + * Builds a {@link ChannelGroupDefinition} and {@link ChannelGroupType} based on the service properties. + * Registers the {@link ChannelGroupType} with the provided {@link HomekitStorageBasedTypeProvider}. + * Returns null if the service type is unknown or if no valid channel definitions can be created. + * + * @param typeProvider the HomekitStorageBasedTypeProvider to register the channel group type with + * @return the created ChannelGroupDefinition or null if creation failed + */ + public @Nullable ChannelGroupDefinition buildAndRegisterChannelGroupDefinition( + HomekitStorageBasedTypeProvider typeProvider) { + ServiceType serviceType = ServiceType.from(Integer.parseInt(serviceId)); + try { + serviceType = ServiceType.from(Integer.parseInt(serviceId)); + } catch (IllegalArgumentException e) { return null; } - ServiceType serviceType = ServiceType.from(serviceId); - - String label = "label"; // TODO determine label based on characType - String category = "sensor"; // TODO determine category based on characType + List<@NonNull ChannelDefinition> channelDefinitions = characteristics.stream() + .map(c -> c.buildAndRegisterChannelDefinition(typeProvider)).filter(Objects::nonNull).toList(); - List channelDefinitions = new ArrayList<>(); - for (Characteristic characteristic : characteristics) { - ChannelType ct = characteristic.getChannelType(); - if (ct == null) { - continue; - } - channelDefinitions.add(new ChannelDefinitionBuilder(ct.getUID().getId(), ct.getUID()) - .withLabel(ct.getLabel()).withDescription(ct.getDescription()).build()); + if (channelDefinitions.isEmpty()) { + return null; } - return ChannelGroupTypeBuilder.instance(new ChannelGroupTypeUID(BINDING_ID, serviceId), label) - .withChannelDefinitions(channelDefinitions).withCategory(category).build(); - } + ChannelGroupTypeUID uid = new ChannelGroupTypeUID(BINDING_ID, Integer.toHexString(hashCode())); + ChannelGroupType type = ChannelGroupTypeBuilder.instance(uid, serviceId) // + .withDescription(serviceType.toString()) // + .withChannelDefinitions(channelDefinitions) // + .build(); + + typeProvider.putChannelGroupType(type); + return new ChannelGroupDefinition(Integer.toString(instanceId), uid, serviceType.getTypeSuffix(), null); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java index 9a99a8b19b69a..11d606077e119 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java @@ -23,8 +23,8 @@ public enum AccessoryType { OTHER(1, "Other"), BRIDGE(2, "Bridge"), FAN(3, "Fan"), - GARAGE(4, "Garage"), - LIGHTBULB(5, "Light Bulb"), + GARAGE_DOOR(4, "Garage Door"), + LIGHTING(5, "Lighting"), DOOR_LOCK(6, "Door Lock"), OUTLET(7, "Outlet"), SWITCH(8, "Switch"), @@ -35,7 +35,7 @@ public enum AccessoryType { WINDOW(13, "Window"), WINDOW_COVERING(14, "Window Covering"), PROGRAMMABLE_SWITCH(15, "Programmable Switch"), - RANGE_EXTENDER(16, "Range Extender"), + RESERVED(16, "Reserved"), IP_CAMERA(17, "IP Camera"), VIDEO_DOORBELL(18, "Video Doorbell"), AIR_PURIFIER(19, "Air Purifier"), @@ -48,9 +48,9 @@ public enum AccessoryType { AIRPORT(27, "AirPort"), SPRINKLER(28, "Sprinkler"), FAUCET(29, "Faucet"), - SHOWER_HEAD(30, "Shower Head"), + SHOWER_HEAD(30, "Shower"), TELEVISION(31, "Television"), - TARGET_CONTROLLER(32, "Target Controller"); + REMOTE(32, "Remote"); private final int id; private final String label; @@ -60,24 +60,17 @@ public enum AccessoryType { this.label = label; } - public int getId() { - return id; - } - - public String getLabel() { + @Override + public String toString() { return label; } - public static AccessoryType from(String id) throws NumberFormatException { - return from(Integer.parseInt(id)); - } - public static AccessoryType from(int id) throws IllegalArgumentException { for (AccessoryType value : values()) { if (value.id == id) { return value; } } - throw new IllegalArgumentException("Unknown ID: " + id); + return OTHER; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index b9d5cf17856b7..1887ca3e4ef1c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -1,14 +1,5 @@ package org.openhab.binding.homekit.internal.enums; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.BINDING_ID; - -import org.openhab.core.library.CoreItemFactory; -import org.openhab.core.semantics.model.DefaultSemanticTags.Point; -import org.openhab.core.semantics.model.DefaultSemanticTags.Property; -import org.openhab.core.thing.type.ChannelType; -import org.openhab.core.thing.type.ChannelTypeBuilder; -import org.openhab.core.thing.type.ChannelTypeUID; - public enum CharacteristicType { //@formatter:off ACCESSORY_PROPERTIES(0xA6, "public.hap.characteristic.accessory-properties"), @@ -152,15 +143,20 @@ public enum CharacteristicType { this.type = type; } - public int getId() { - return id; - } - - public String getType() { - return type; + /** + * Returns the name of the enum constant in `First Letter Capitals`. + */ + @Override + public String toString() { + String[] parts = name().toLowerCase().split("_"); + StringBuilder builder = new StringBuilder(parts[0]); + for (int i = 1; i < parts.length; i++) { + builder.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1)); + } + return builder.toString(); } - public String getShortType() { + public String getTypeSuffix() { int lastIndex = type.lastIndexOf("."); return type.substring(lastIndex + 1); } @@ -173,23 +169,4 @@ public static CharacteristicType from(int id) throws IllegalArgumentException { } throw new IllegalArgumentException("Unknown ID: " + id); } - - public static CharacteristicType from(String type) throws IllegalArgumentException { - for (CharacteristicType value : values()) { - if (value.type.equals(type)) { - return value; - } - } - throw new IllegalArgumentException("Unknown Type: " + type); - } - - public ChannelType getChannelType() { - String channelTypeId = "typeId"; // TODO provide a unique channel type ID based on characteristic - String label = "label"; // TODO provide a meaningful label based on characteristic - String itemType = CoreItemFactory.SWITCH; // TODO determine the appropriate item type based on characteristic - String category = "sensor"; // Default category, adjust as needed - return ChannelTypeBuilder.state(new ChannelTypeUID(BINDING_ID, channelTypeId), label, itemType) - .withTags(Point.STATUS, Property.AIR_QUALITY) // Adjust tags as appropriate - .withCategory(category).build(); - } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/DataFormatType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/DataFormatType.java new file mode 100644 index 0000000000000..36746aba6d352 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/DataFormatType.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.enums; + +/** + * Enumeration of HomeKit characteristic data types. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +public enum DataFormatType { + BOOL, + UINT8, + UINT16, + UINT32, + UINT64, + INT, + FLOAT, + STRING, + TLV8, + DATA; + + public static DataFormatType from(String dataFormat) throws IllegalArgumentException { + return valueOf(dataFormat.toUpperCase()); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index 0c1da9952875b..0b3886b7ac830 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -56,23 +56,24 @@ public enum ServiceType { this.type = type; } - public int getId() { - return id; - } - - public String getType() { - return type; + /** + * Returns the name of the enum constant in `First Letter Capitals`. + */ + @Override + public String toString() { + String[] parts = name().toLowerCase().split("_"); + StringBuilder builder = new StringBuilder(parts[0]); + for (int i = 1; i < parts.length; i++) { + builder.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1)); + } + return builder.toString(); } - public String getShortType() { + public String getTypeSuffix() { int lastIndex = type.lastIndexOf("."); return type.substring(lastIndex + 1); } - public static ServiceType from(String id) throws NumberFormatException { - return from(Integer.parseInt(id)); - } - public static ServiceType from(int id) throws IllegalArgumentException { for (ServiceType value : values()) { if (value.id == id) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 7738813484adb..d0c1fefdcd538 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -14,7 +14,13 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.dto.Accessories; +import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.network.CharacteristicsManager; import org.openhab.binding.homekit.internal.network.HttpTransport; import org.openhab.binding.homekit.internal.network.PairingManager; @@ -31,6 +37,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.Gson; + /** * Handles I/O with HomeKit server devices -- either simple accessories or bridge accessories that * contain child accessories. If the handler is for a HomeKit bridge or a stand alone HomeKit accessory @@ -45,9 +53,13 @@ public class HomekitBaseServerHandler extends BaseThingHandler { private final Logger logger = LoggerFactory.getLogger(HomekitBaseServerHandler.class); + protected static final Gson GSON = new Gson(); protected final HttpTransport httpTransport; + protected final List accessories = new ArrayList<>(); + + protected boolean isChildAccessory = false; - protected @NonNullByDefault({}) CharacteristicsManager client; + protected @NonNullByDefault({}) CharacteristicsManager charactersticsManager; protected @NonNullByDefault({}) SessionKeys keys; protected @NonNullByDefault({}) SecureSession session; protected @NonNullByDefault({}) String baseUrl; @@ -63,22 +75,25 @@ public void initialize() { Bridge bridge = getBridge(); if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { // accessory is hosted by a bridge, so use the bridge's pairing and session + this.isChildAccessory = true; this.keys = bridgeHandler.keys; this.session = bridgeHandler.session; - this.client = bridgeHandler.client; - if (this.client != null) { + this.charactersticsManager = bridgeHandler.charactersticsManager; + if (this.charactersticsManager != null) { updateStatus(ThingStatus.ONLINE); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not connected"); } } else { // standalone accessory or brige accessory, so do pairing and session setup here + this.isChildAccessory = false; this.baseUrl = "http://" + getConfig().get(IP_V4_ADDRESS).toString(); this.pairingCode = getConfig().get(PAIRING_CODE).toString(); try { this.keys = new PairingManager(httpTransport, pairingCode).pair(baseUrl); this.session = new SecureSession(keys); - this.client = new CharacteristicsManager(httpTransport, session, baseUrl); + this.charactersticsManager = new CharacteristicsManager(httpTransport, session, baseUrl); + scheduler.submit(() -> getAccessories()); updateStatus(ThingStatus.ONLINE); } catch (Exception e) { logger.error("Failed to initialize HomeKit client", e); @@ -91,4 +106,29 @@ public void initialize() { public void handleCommand(ChannelUID channelUID, Command command) { // override in subclass } + + /** + * Get information about embedded accessories and their respective channels. + * Uses the /accessories endpoint. + * Returns an empty list if there was a problem. + * Requires a valid secure session. + * + * @return list of accessories (may be empty) + * @see HomeKit HTTP + */ + protected void getAccessories() { + SecureSession session = this.session; + if (session != null) { + try { + byte[] encrypted = httpTransport.get(baseUrl, ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); + byte[] decrypted = session.decrypt(encrypted); + Accessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), Accessories.class); + if (result != null && result.accessories instanceof List accessoryList) { + accessories.clear(); + accessories.addAll(accessoryList); + } + } catch (Exception e) { + } + } + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 8953eba1fbd09..98dc947e0569c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -12,16 +12,8 @@ */ package org.openhab.binding.homekit.internal.handler; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; - -import java.nio.charset.StandardCharsets; -import java.util.List; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.discovery.AccessoryDiscoveryService; -import org.openhab.binding.homekit.internal.dto.Accessories; -import org.openhab.binding.homekit.internal.dto.Accessory; -import org.openhab.binding.homekit.internal.network.SecureSession; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; @@ -32,8 +24,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.Gson; - /** * Handler for HomeKit bridge devices. * It marshals the communications with multiple HomeKit child accessories within a HomeKit bridge server. @@ -48,10 +38,7 @@ public class HomekitBridgeHandler extends HomekitBaseServerHandler implements BridgeHandler { private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); - - private static final Gson GSON = new Gson(); - - private final AccessoryDiscoveryService discoveryService; + protected final AccessoryDiscoveryService discoveryService; public HomekitBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, AccessoryDiscoveryService discoveryService) { @@ -81,10 +68,6 @@ protected BridgeBuilder editThing() { @Override public void initialize() { super.initialize(); - scheduler.submit(() -> { - List accessories = getAccessories(); - discoveryService.devicesDiscovered(thing, accessories); - }); } @Override @@ -97,29 +80,11 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { // TODO Auto-generated method stub } - /** - * Get information about embedded accessories and their respective channels. - * Uses the /accessories endpoint. - * Returns an empty list if there was a problem. - * Requires a valid secure session. - * - * @return list of accessories (may be empty) - * @see HomeKit HTTP - */ - private List getAccessories() { - SecureSession session = this.session; - if (session != null) { - try { - byte[] encrypted = httpTransport.get(baseUrl, ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); - byte[] decrypted = session.decrypt(encrypted); - Accessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), - Accessories.class); - if (result != null && result.accessories != null) { - return result.accessories; - } - } catch (Exception e) { - } + @Override + protected void getAccessories() { + super.getAccessories(); + if (!accessories.isEmpty()) { + discoveryService.devicesDiscovered(thing, accessories); } - return List.of(); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 37190db4dbb70..d921cfcea7c44 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -56,8 +56,8 @@ public void initialize() { @Override public void handleCommand(ChannelUID channelUID, Command command) { - CharacteristicsManager accessoryClient = this.client; - if (accessoryClient != null) { + CharacteristicsManager charactersticsManager = this.charactersticsManager; + if (charactersticsManager != null) { String channelId = channelUID.getId(); try { switch (channelId) { @@ -80,8 +80,8 @@ public void handleCommand(ChannelUID channelUID, Command command) { * This method is called periodically by a scheduled executor. */ private void poll() { - CharacteristicsManager accessoryClient = this.client; - if (accessoryClient != null) { + CharacteristicsManager charactersticsManager = this.charactersticsManager; + if (charactersticsManager != null) { try { // String power = accessoryClient.readCharacteristic("1", "10"); // TODO example AID/IID // Parse powerState and update channel state accordingly @@ -95,4 +95,23 @@ private void poll() { } } } + + @Override + protected void getAccessories() { + if (!isChildAccessory) { + // child accessories shall not fetch accessories again + super.getAccessories(); + } + createChannels(); + } + + /** + * Creates channels for the accessory based on its services and characteristics. + * Only parses the one relevant accessory in the list, as this handler is for a single accessory. + * Iterates through that accessory's services and characteristics to create appropriate channels. + * Each service creates a channel group, and each characteristic creates a channel within that group. + */ + private void createChannels() { + // TODO Auto-generated method stub + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java index 82f7c5a55e912..2ba2b0c2132bc 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java @@ -14,11 +14,13 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.util.Hashtable; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.discovery.AccessoryDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; @@ -26,6 +28,8 @@ import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.framework.ServiceRegistration; +import org.osgi.service.component.ComponentContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -42,16 +46,22 @@ public class HomekitHandlerFactory extends BaseThingHandlerFactory { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE); - private final AccessoryDiscoveryService discoveryService; private final HttpClientFactory httpClientFactory; + private @Nullable ServiceRegistration discoveryServiceRegistration; + private @Nullable AccessoryDiscoveryService discoveryService; + @Activate - public HomekitHandlerFactory(@Reference AccessoryDiscoveryService discoveryService, - @Reference HttpClientFactory httpClientFactory) { - this.discoveryService = discoveryService; + public HomekitHandlerFactory(@Reference HttpClientFactory httpClientFactory) { this.httpClientFactory = httpClientFactory; } + @Override + protected void deactivate(ComponentContext componentContext) { + unregisterDiscoveryService(); + super.deactivate(componentContext); + } + @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); @@ -61,10 +71,41 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { - return new HomekitBridgeHandler((Bridge) thing, httpClientFactory, discoveryService); + return new HomekitBridgeHandler((Bridge) thing, httpClientFactory, registerDiscoveryService()); } else if (THING_TYPE_DEVICE.equals(thingTypeUID)) { return new HomekitDeviceHandler(thing, httpClientFactory); } return null; } + + /** + * Registers the AccessoryDiscoveryService if not already registered and returns it. + * + * @return the registered AccessoryDiscoveryService + */ + private AccessoryDiscoveryService registerDiscoveryService() { + AccessoryDiscoveryService service = this.discoveryService; + if (service == null) { + service = new AccessoryDiscoveryService(); + this.discoveryService = service; + } + ServiceRegistration registration = this.discoveryServiceRegistration; + if (registration == null) { + registration = bundleContext.registerService(DiscoveryService.class.getName(), service, new Hashtable<>()); + this.discoveryServiceRegistration = registration; + } + return service; + } + + /** + * Unregisters the AccessoryDiscoveryService if it is registered. + */ + private void unregisterDiscoveryService() { + ServiceRegistration registration = this.discoveryServiceRegistration; + if (registration != null) { + registration.unregister(); + } + this.discoveryService = null; + this.discoveryServiceRegistration = null; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitStorageBasedTypeProvider.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitStorageBasedTypeProvider.java new file mode 100644 index 0000000000000..2a5d7c5fe865d --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitStorageBasedTypeProvider.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.provider; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.storage.StorageService; +import org.openhab.core.thing.binding.AbstractStorageBasedTypeProvider; + +/** + * The {@link HomekitStorageBasedTypeProvider} is responsible for loading and storing HomeKit specific channel and + * channel group types. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitStorageBasedTypeProvider extends AbstractStorageBasedTypeProvider { + + protected HomekitStorageBasedTypeProvider(StorageService storageService) { + super(storageService); + } +} From 62179190a6b1738bb478b468c06e20ab9a39539f Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Sep 2025 23:43:22 +0100 Subject: [PATCH 008/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 1 - .../homekit/internal/enums/AccessoryType.java | 3 ++ .../internal/enums/CharacteristicType.java | 20 ++++++++ .../internal/enums/DataFormatType.java | 3 ++ .../homekit/internal/enums/ServiceType.java | 20 ++++++++ .../handler/HomekitBridgeHandler.java | 9 ++-- .../handler/HomekitDeviceHandler.java | 5 +- .../homekit/internal/network/ChaCha20.java | 16 +++++-- .../network/CharacteristicsManager.java | 5 +- .../internal/network/HttpTransport.java | 36 ++++++++++---- .../internal/network/PairingManager.java | 34 ++++++++++++- .../homekit/internal/network/SRPClient.java | 25 ++++++---- .../internal/network/SecureSession.java | 12 +++-- .../homekit/internal/network/SessionKeys.java | 10 ++-- .../homekit/internal/network/TLV8Codec.java | 8 +++- .../resources/OH-INF/thing/thing-types.xml | 48 +++++++++---------- 16 files changed, 188 insertions(+), 67 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 9482f1515f30c..4a2cbfcf1a63a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -39,5 +39,4 @@ public class HomekitBindingConstants { public static final String ENDPOINT_CHARACTERISTICS = "characteristics"; public static final String CONTENT_TYPE_PAIRING = "application/pairing+tlv8"; public static final String CONTENT_TYPE_HAP = "application/hap+json"; - } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java index 11d606077e119..14214aa8e4aac 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java @@ -12,12 +12,15 @@ */ package org.openhab.binding.homekit.internal.enums; +import org.eclipse.jdt.annotation.NonNullByDefault; + /** * Enumeration of HomeKit accessory categories with their corresponding numeric IDs and labels. * This enum provides a mapping between category IDs used in HomeKit and human-readable labels. * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public enum AccessoryType { // TODO manually check the Homekit specification pdf to ensure all types are covered OTHER(1, "Other"), diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index 1887ca3e4ef1c..d8ebe3f9d9112 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -1,5 +1,25 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ package org.openhab.binding.homekit.internal.enums; +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of HomeKit characteristic types. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault public enum CharacteristicType { //@formatter:off ACCESSORY_PROPERTIES(0xA6, "public.hap.characteristic.accessory-properties"), diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/DataFormatType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/DataFormatType.java index 36746aba6d352..cb5e63051ffed 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/DataFormatType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/DataFormatType.java @@ -12,11 +12,14 @@ */ package org.openhab.binding.homekit.internal.enums; +import org.eclipse.jdt.annotation.NonNullByDefault; + /** * Enumeration of HomeKit characteristic data types. * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public enum DataFormatType { BOOL, UINT8, diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index 0b3886b7ac830..834db11c60df7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -1,5 +1,25 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ package org.openhab.binding.homekit.internal.enums; +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of HomeKit service types. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault public enum ServiceType { ACCESSORY_INFORMATION(0x3E, "public.hap.service.accessory-information"), AIR_PURIFIER(0xBB, "public.hap.service.air-purifier"), diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 98dc947e0569c..f2141412446bf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -17,12 +17,9 @@ import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; -import org.openhab.core.thing.binding.BaseThingHandler; import org.openhab.core.thing.binding.BridgeHandler; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.builder.BridgeBuilder; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Handler for HomeKit bridge devices. @@ -37,7 +34,7 @@ @NonNullByDefault public class HomekitBridgeHandler extends HomekitBaseServerHandler implements BridgeHandler { - private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); + // private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); protected final AccessoryDiscoveryService discoveryService; public HomekitBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, @@ -52,8 +49,8 @@ public Bridge getThing() { } /** - * Creates a bridge builder, which allows to modify the bridge. The method - * {@link BaseThingHandler#updateThing(Thing)} must be called to persist the changes. + * Creates a bridge builder, which allows to modify the bridge. The 'updateThing(Thing)' method + * must be called to persist the changes. * * @return {@link BridgeBuilder} which builds an exact copy of the bridge */ diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index d921cfcea7c44..e6df4efbc7b3f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -17,7 +17,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.network.CharacteristicsManager; import org.openhab.core.io.net.http.HttpClientFactory; -import org.openhab.core.library.types.OnOffType; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.types.Command; @@ -47,7 +46,7 @@ public void initialize() { try { int intervalSeconds = Integer.parseInt(interval); if (intervalSeconds > 0) { - scheduler.scheduleAtFixedRate(this::poll, 0, intervalSeconds, TimeUnit.SECONDS); + scheduler.scheduleWithFixedDelay(this::poll, 0, intervalSeconds, TimeUnit.SECONDS); } } catch (NumberFormatException e) { logger.warn("Invalid polling interval configuration: {}", interval); @@ -62,7 +61,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { try { switch (channelId) { case "power": - boolean value = command.equals(OnOffType.ON); + // boolean value = command.equals(OnOffType.ON); // accessoryClient.writeCharacteristic("1", "10", value); // Example AID/IID break; // TODO Add more channels here diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java index 2f6f72d4f00ed..4012df6df1267 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java @@ -12,9 +12,12 @@ */ package org.openhab.binding.homekit.internal.network; +import java.security.GeneralSecurityException; + import org.bouncycastle.crypto.modes.ChaCha20Poly1305; import org.bouncycastle.crypto.params.AEADParameters; import org.bouncycastle.crypto.params.KeyParameter; +import org.eclipse.jdt.annotation.NonNullByDefault; /** * ChaCha20 encryption and decryption utility class. @@ -26,6 +29,7 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class ChaCha20 { /** @@ -35,8 +39,9 @@ public class ChaCha20 { * @param nonce 12-byte nonce * @param plaintext data to encrypt * @return encrypted data (ciphertext + authentication tag) + * @throws GeneralSecurityException */ - public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) { + public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) throws GeneralSecurityException { try { ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); @@ -47,7 +52,7 @@ public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) { cipher.doFinal(out, len); return out; } catch (Exception e) { - throw new RuntimeException("Encryption failed", e); + throw new GeneralSecurityException("Encryption failed", e); } } @@ -58,8 +63,9 @@ public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) { * @param nonce 12-byte nonce * @param ciphertext data to decrypt (ciphertext + authentication tag) * @return decrypted data (plaintext) + * @throws GeneralSecurityException */ - public static byte[] decrypt(byte[] key, byte[] nonce, byte[] ciphertext) { + public static byte[] decrypt(byte[] key, byte[] nonce, byte[] ciphertext) throws GeneralSecurityException { try { ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); @@ -70,7 +76,7 @@ public static byte[] decrypt(byte[] key, byte[] nonce, byte[] ciphertext) { cipher.doFinal(out, len); return out; } catch (Exception e) { - throw new RuntimeException("Decryption failed", e); + throw new GeneralSecurityException("Decryption failed", e); } } -} \ No newline at end of file +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java index c81073322acdc..bec374b658baa 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java @@ -16,12 +16,15 @@ import java.nio.charset.StandardCharsets; +import org.eclipse.jdt.annotation.NonNullByDefault; + /** * HTTP client methods for reading and writing HomeKit accessory characteristics over a secure session. * It handles encryption and decryption of requests and responses. * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class CharacteristicsManager { private static final String JSON_TEMPLATE = "{\"%s\":[{\"aid\":%s,\"iid\":%s,\"value\":%s}]}"; @@ -74,4 +77,4 @@ private String formatValue(Object value) { } return "\"" + value.toString() + "\""; } -} \ No newline at end of file +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java index c1cc3ad5c7d21..cdaff6d464eb7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java @@ -12,7 +12,10 @@ */ package org.openhab.binding.homekit.internal.network; +import java.io.IOException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jetty.client.HttpClient; @@ -45,16 +48,21 @@ public HttpTransport(HttpClient httpClient) { * @param contentType the expected content type of the response * * @return the response body - * @throws Exception if an error occurs during the request + * + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException */ - public byte[] get(String baseUrl, String endpoint, String contentType) throws Exception { + public byte[] get(String baseUrl, String endpoint, String contentType) + throws IOException, InterruptedException, TimeoutException, ExecutionException { String url = baseUrl + "/" + endpoint; Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.GET) .header(HttpHeader.ACCEPT, contentType); ContentResponse response = request.send(); if (response.getStatus() != 200) { - throw new RuntimeException("GET %s HTTP %d".formatted(url, response.getStatus())); + throw new IOException("GET %s HTTP %d".formatted(url, response.getStatus())); } return response.getContent(); @@ -69,16 +77,21 @@ public byte[] get(String baseUrl, String endpoint, String contentType) throws Ex * @param content the request body * * @return the response body - * @throws Exception if an error occurs during the request + * + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException */ - public byte[] post(String baseUrl, String endpoint, String contentType, byte[] content) throws Exception { + public byte[] post(String baseUrl, String endpoint, String contentType, byte[] content) + throws IOException, InterruptedException, TimeoutException, ExecutionException { String url = baseUrl + "/" + endpoint; Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.POST) .header(HttpHeader.CONTENT_TYPE, contentType).content(new BytesContentProvider(content)); ContentResponse response = request.send(); if (response.getStatus() != 200) { - throw new RuntimeException("POST %s HTTP %d".formatted(url, response.getStatus())); + throw new IOException("POST %s HTTP %d".formatted(url, response.getStatus())); } return response.getContent(); @@ -93,9 +106,14 @@ public byte[] post(String baseUrl, String endpoint, String contentType, byte[] c * @param content the request body * * @return the response body - * @throws Exception if an error occurs during the request + * + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException */ - public byte[] put(String baseUrl, String endpoint, String contentType, byte[] content) throws Exception { + public byte[] put(String baseUrl, String endpoint, String contentType, byte[] content) + throws IOException, InterruptedException, TimeoutException, ExecutionException { String url = baseUrl + "/" + endpoint; Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.POST) .header(HttpHeader.ACCEPT, contentType).header(HttpHeader.CONTENT_TYPE, contentType) @@ -103,7 +121,7 @@ public byte[] put(String baseUrl, String endpoint, String contentType, byte[] co ContentResponse response = request.send(); if (response.getStatus() != 200) { - throw new RuntimeException("PUT %s error: HTTP %d".formatted(url, response.getStatus())); + throw new IOException("PUT %s error: HTTP %d".formatted(url, response.getStatus())); } return response.getContent(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java index 5dd73ca071118..d744d4f22cc69 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java @@ -14,7 +14,12 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; /** * Handles the 6-step pairing process with a HomeKit accessory. @@ -25,6 +30,7 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class PairingManager { private final SRPClient srpClient; @@ -47,7 +53,7 @@ public SessionKeys pair(String baseUrl) throws Exception { // Step 2: M2 — Receive SRP salt and public key Map tlv2 = TLV8Codec.decode(resp1); - srpClient.processChallenge(tlv2.get(0x03), tlv2.get(0x04)); // salt, server public key + srpClient.processChallenge(Objects.requireNonNull(tlv2.get(0x03)), Objects.requireNonNull(tlv2.get(0x04))); // Step 3: M3 — Send SRP public key and proof Map m3 = srpClient.generateClientProof(); @@ -55,7 +61,7 @@ public SessionKeys pair(String baseUrl) throws Exception { // Step 4: M4 — Verify server proof Map tlv4 = TLV8Codec.decode(resp3); - srpClient.verifyServerProof(tlv4.get(0x04)); + srpClient.verifyServerProof(Objects.requireNonNull(tlv4.get(0x04))); // Step 5: M5 — Exchange encrypted identifiers Map m5 = srpClient.generateEncryptedIdentifiers(); @@ -68,4 +74,28 @@ public SessionKeys pair(String baseUrl) throws Exception { // Derive session keys return srpClient.deriveSessionKeys(); } + + public void removePairing(String baseUrl, String pairingIdentifier, SecureSession secureSession) throws Exception { + // Step 1: Construct TLV for remove pairing (State = 0x01, Method = 0x04) + Map tlv = new HashMap<>(); + tlv.put(0x00, new byte[] { 0x01 }); // State + tlv.put(0x01, new byte[] { 0x04 }); // Method: Remove pairing + tlv.put(0x03, pairingIdentifier.getBytes(StandardCharsets.UTF_8)); // Identifier to remove + + byte[] plaintext = TLV8Codec.encode(tlv); + + // Step 2: Encrypt with session keys + byte[] encrypted = secureSession.encrypt(plaintext); + + // Step 3: Send remove pairing request + byte[] response = httpTransport.post(baseUrl, ENDPOINT_PAIRING, CONTENT_TYPE_PAIRING, encrypted); + + // Step 4: Decrypt and verify response + byte[] decrypted = secureSession.decrypt(response); + Map tlvResp = TLV8Codec.decode(decrypted); + + if (Objects.requireNonNull(tlvResp.get(0x00))[0] != 0x02) { + throw new IllegalStateException("Unexpected response state during pairing removal"); + } + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java index 5ac9b943e8950..86c55d28449b9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java @@ -17,6 +17,10 @@ import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.jose4j.jwt.GeneralJwtException; /** * Implements the client side of the Secure Remote Password (SRP) protocol for HomeKit pairing. @@ -25,6 +29,7 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class SRPClient { // HomeKit 3072-bit prime from RFC 5054 @@ -49,11 +54,11 @@ public class SRPClient { private final String pairingCode; - private BigInteger a; // private ephemeral - private BigInteger A; // public ephemeral - private BigInteger B; // server public - private byte[] salt; // from server - private byte[] K; // shared session key + private @NonNullByDefault({}) BigInteger a; // private ephemeral + private @NonNullByDefault({}) BigInteger A; // public ephemeral + private @NonNullByDefault({}) BigInteger B; // server public + private @NonNullByDefault({}) byte[] salt; // from server + private @NonNullByDefault({}) byte[] K; // shared session key public SRPClient(String pairingCode) { this.pairingCode = pairingCode; @@ -129,18 +134,20 @@ public Map generateEncryptedIdentifiers() throws Exception { * @throws Exception If an error occurs during decryption or verification. */ public void verifyAccessoryIdentifiers(Map tlv6) throws Exception { - byte[] nonce = tlv6.get(0x05); - byte[] encrypted = tlv6.get(0x06); + byte[] encrypted = Objects.requireNonNull(tlv6.get(0x06)); + byte[] nonce = Objects.requireNonNull(tlv6.get(0x05)); + @SuppressWarnings("unused") + // TODO parse decrypted TLV8 and specifically validate accessory identity byte[] decrypted = ChaCha20.decrypt(K, nonce, encrypted); - // TODO parse decrypted TLV8 and specificall validate accessory identity } /** * Derives session keys for encrypting and decrypting messages between the HomeKit controller and accessory. * * @return An instance of SessionKeys containing the derived read and write keys. + * @throws GeneralJwtException */ - public SessionKeys deriveSessionKeys() { + public SessionKeys deriveSessionKeys() throws GeneralJwtException { return new SessionKeys(K); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java index 7a3d96483e8bd..a09cade3cbb93 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java @@ -12,8 +12,11 @@ */ package org.openhab.binding.homekit.internal.network; +import java.security.GeneralSecurityException; import java.util.concurrent.atomic.AtomicInteger; +import org.eclipse.jdt.annotation.NonNullByDefault; + /** * Manages a secure session using ChaCha20 encryption for a HomeKit accessory. * This class handles encryption and decryption of messages using session keys. @@ -21,6 +24,7 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class SecureSession { private final byte[] writeKey; @@ -38,8 +42,9 @@ public SecureSession(SessionKeys keys) { * * @param plaintext The plaintext to encrypt. * @return The encrypted ciphertext. + * @throws GeneralSecurityException */ - public byte[] encrypt(byte[] plaintext) { + public byte[] encrypt(byte[] plaintext) throws GeneralSecurityException { byte[] nonce = generateNonce(writeCounter.getAndIncrement()); return ChaCha20.encrypt(writeKey, nonce, plaintext); } @@ -49,8 +54,9 @@ public byte[] encrypt(byte[] plaintext) { * * @param ciphertext The ciphertext to decrypt. * @return The decrypted plaintext. + * @throws GeneralSecurityException */ - public byte[] decrypt(byte[] ciphertext) { + public byte[] decrypt(byte[] ciphertext) throws GeneralSecurityException { byte[] nonce = generateNonce(readCounter.getAndIncrement()); return ChaCha20.decrypt(readKey, nonce, ciphertext); } @@ -70,4 +76,4 @@ private byte[] generateNonce(int counter) { nonce[7] = (byte) (counter & 0xFF); return nonce; } -} \ No newline at end of file +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java index 8fa59e476fc2e..6ec163ac36c55 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java @@ -17,6 +17,9 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.jose4j.jwt.GeneralJwtException; + /** * Derives session keys for encrypting and decrypting messages between a HomeKit controller and accessory. * Uses HKDF with HMAC-SHA512 as the underlying hash function. @@ -24,6 +27,7 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class SessionKeys { private static final String HMAC_ALGO = "HmacSHA512"; @@ -31,13 +35,13 @@ public class SessionKeys { public final byte[] writeKey; // Controller → Accessory public final byte[] readKey; // Accessory → Controller - public SessionKeys(byte[] sharedSecret) { + public SessionKeys(byte[] sharedSecret) throws GeneralJwtException { byte[] salt = "Control-Salt".getBytes(StandardCharsets.UTF_8); this.writeKey = hkdf(sharedSecret, salt, "Control-Write-Encryption-Key".getBytes(StandardCharsets.UTF_8), 32); this.readKey = hkdf(sharedSecret, salt, "Control-Read-Encryption-Key".getBytes(StandardCharsets.UTF_8), 32); } - private byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int length) { + private byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int length) throws GeneralJwtException { try { Mac mac = Mac.getInstance(HMAC_ALGO); mac.init(new SecretKeySpec(salt, HMAC_ALGO)); @@ -52,7 +56,7 @@ private byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int length) { System.arraycopy(okm, 0, result, 0, length); return result; } catch (Exception e) { - throw new RuntimeException("HKDF derivation failed", e); + throw new GeneralJwtException("HKDF derivation failed", e); } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java index f2766725dae72..d3a4f3a8acee4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java @@ -17,6 +17,8 @@ import java.util.LinkedHashMap; import java.util.Map; +import org.eclipse.jdt.annotation.NonNullByDefault; + /** * Utility class for encoding and decoding TLV8 (Type-Length-Value) data. * TLV8 is used in HomeKit for structured data exchange. @@ -24,6 +26,7 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class TLV8Codec { public static final int MAX_TLV_LENGTH = 255; @@ -69,7 +72,10 @@ public static Map decode(byte[] data) { byte[] chunk = Arrays.copyOfRange(data, index, index + length); index += length; - tempMap.computeIfAbsent(type, k -> new ByteArrayOutputStream()).writeBytes(chunk); + ByteArrayOutputStream stream = tempMap.computeIfAbsent(type, k -> new ByteArrayOutputStream()); + if (stream != null) { + stream.writeBytes(chunk); + } } Map result = new LinkedHashMap<>(); diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index e8b834f5ea0fd..e32f153b3eb11 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -12,13 +12,13 @@ network-address IP v4 address of the HomeKit accessory device - true + true password Password to access the device - true + true @@ -29,27 +29,27 @@ - - - HomeKit Accessory Bridge - - - network-address - - IP v4 address of the HomeKit accessory device - - - password - - Password to access the device - - - - Interval the device is polled in sec. - 60 - true - - - + + + HomeKit Accessory Bridge + + + network-address + + IP v4 address of the HomeKit accessory device + + + password + + Password to access the device + + + + Interval the device is polled in sec. + 60 + true + + + From 46a79041007feebdfb7b095f8992822ab9d4d689 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Sep 2025 23:52:03 +0100 Subject: [PATCH 009/177] fix wrong exception type Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/network/SRPClient.java | 6 +++--- .../binding/homekit/internal/network/SessionKeys.java | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java index 86c55d28449b9..287490da42376 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java @@ -14,13 +14,13 @@ import java.io.ByteArrayOutputStream; import java.math.BigInteger; +import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Map; import java.util.Objects; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.jose4j.jwt.GeneralJwtException; /** * Implements the client side of the Secure Remote Password (SRP) protocol for HomeKit pairing. @@ -145,9 +145,9 @@ public void verifyAccessoryIdentifiers(Map tlv6) throws Excepti * Derives session keys for encrypting and decrypting messages between the HomeKit controller and accessory. * * @return An instance of SessionKeys containing the derived read and write keys. - * @throws GeneralJwtException + * @throws GeneralSecurityException */ - public SessionKeys deriveSessionKeys() throws GeneralJwtException { + public SessionKeys deriveSessionKeys() throws GeneralSecurityException { return new SessionKeys(K); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java index 6ec163ac36c55..c833a375da3f8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java @@ -13,12 +13,12 @@ package org.openhab.binding.homekit.internal.network; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.jose4j.jwt.GeneralJwtException; /** * Derives session keys for encrypting and decrypting messages between a HomeKit controller and accessory. @@ -35,13 +35,13 @@ public class SessionKeys { public final byte[] writeKey; // Controller → Accessory public final byte[] readKey; // Accessory → Controller - public SessionKeys(byte[] sharedSecret) throws GeneralJwtException { + public SessionKeys(byte[] sharedSecret) throws GeneralSecurityException { byte[] salt = "Control-Salt".getBytes(StandardCharsets.UTF_8); this.writeKey = hkdf(sharedSecret, salt, "Control-Write-Encryption-Key".getBytes(StandardCharsets.UTF_8), 32); this.readKey = hkdf(sharedSecret, salt, "Control-Read-Encryption-Key".getBytes(StandardCharsets.UTF_8), 32); } - private byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int length) throws GeneralJwtException { + private byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int length) throws GeneralSecurityException { try { Mac mac = Mac.getInstance(HMAC_ALGO); mac.init(new SecretKeySpec(salt, HMAC_ALGO)); @@ -56,7 +56,7 @@ private byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int length) throws Gen System.arraycopy(okm, 0, result, 0, length); return result; } catch (Exception e) { - throw new GeneralJwtException("HKDF derivation failed", e); + throw new GeneralSecurityException("HKDF derivation failed", e); } } } From 9de2d04588bf12ebf97a079467dfb87374850429 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 13 Sep 2025 14:59:20 +0100 Subject: [PATCH 010/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 23 +++++- ...java => HomekitChildDiscoveryService.java} | 29 ++++---- .../HomekitMdnsDiscoveryParticipant.java | 14 ++-- .../homekit/internal/dto/Accessory.java | 21 +++++- .../homekit/internal/dto/Characteristic.java | 70 ++++++++----------- .../binding/homekit/internal/dto/Service.java | 24 +++---- .../handler/HomekitBaseServerHandler.java | 15 ++-- .../handler/HomekitBridgeHandler.java | 10 +-- .../handler/HomekitDeviceHandler.java | 67 ++++++++++++++++-- .../handler/HomekitHandlerFactory.java | 20 +++--- ...Provider.java => HomekitTypeProvider.java} | 10 ++- 11 files changed, 198 insertions(+), 105 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/{AccessoryDiscoveryService.java => HomekitChildDiscoveryService.java} (65%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/{HomekitStorageBasedTypeProvider.java => HomekitTypeProvider.java} (58%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 4a2cbfcf1a63a..5e1a252dc9076 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -29,11 +29,28 @@ public class HomekitBindingConstants { public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device"); + // labels + public static final String THING_LABEL_FMT = "Model %s on %s"; + public static final String CHILD_LABEL_FMT = "Accessory %d on %s"; + public static final String GROUP_TYPE_LABEL = "Channel group type"; + public static final String CHANNEL_TYPE_LABEL = "Channel type"; + + // UID id formats + public static final String CHILD_FMT = "child-%x"; // e.g. child-123abc; + public static final String GROUP_TYPE_FMT = "group-%x"; // e.g. group-123abc; + public static final String CHANNEL_TYPE_FMT = "channel-%x"; // e.g. channel-123abc; + // configuration parameters - public static final String PAIRING_CODE = "pairingCode"; - public static final String IP_V4_ADDRESS = "ipV4Address"; + public static final String CONFIG_PAIRING_CODE = "pairingCode"; + public static final String CONFIG_IP_V4_ADDRESS = "ipV4Address"; + public static final String CONFIG_POLLING_INTERVAL = "pollingInterval"; + + // properties + public static final String PROPERTY_UID = "uid"; + public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; + public static final String PROPERTY_DEVICE_CATEGORY = "deviceCategory"; - // HomeKit HTTP endpoints and content types + // HomeKit HTTP URI endpoints and content types public static final String ENDPOINT_PAIRING = "pair-setup"; public static final String ENDPOINT_ACCESSORIES = "accessories"; public static final String ENDPOINT_CHARACTERISTICS = "characteristics"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java similarity index 65% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 608da480e06ba..7461b65e5c930 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/AccessoryDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -12,9 +12,9 @@ */ package org.openhab.binding.homekit.internal.discovery; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.THING_TYPE_DEVICE; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; -import java.util.List; +import java.util.Collection; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -35,9 +35,9 @@ */ @NonNullByDefault @Component(service = DiscoveryService.class) -public class AccessoryDiscoveryService extends AbstractDiscoveryService { +public class HomekitChildDiscoveryService extends AbstractDiscoveryService { - public AccessoryDiscoveryService() { + public HomekitChildDiscoveryService() { super(Set.of(THING_TYPE_DEVICE), 10, false); } @@ -46,20 +46,17 @@ protected void startScan() { // no scanning is done; we rely on being informed of new accessories } - public void devicesDiscovered(Thing bridge, List accessories) { + public void devicesDiscovered(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { if (accessory.accessoryId != null && accessory.services != null) { - accessory.services.forEach(service -> { - if (service.instanceId != null) { - String id = "%d-%d".formatted(accessory.accessoryId, service.instanceId); - ThingUID uid = new ThingUID(THING_TYPE_DEVICE, bridge.getUID(), id); - thingDiscovered(DiscoveryResultBuilder.create(uid) // - .withBridge(bridge.getUID()) // - .withLabel(service.toString()) // - .withProperty("uid", uid.toString()) // - .withRepresentationProperty("uid").build()); - } - }); + ThingUID uid = new ThingUID(THING_TYPE_DEVICE, bridge.getUID(), + CHILD_FMT.formatted(accessory.accessoryId)); // accessory ID is unique per bridge + + thingDiscovered(DiscoveryResultBuilder.create(uid) // + .withBridge(bridge.getUID()) // + .withLabel(CHILD_LABEL_FMT.formatted(accessory.accessoryId, bridge.getLabel())) // + .withProperty(PROPERTY_UID, uid.toString()) // + .withRepresentationProperty(PROPERTY_UID).build()); } }); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index a77177a47d511..ea64023e9aad2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -75,12 +75,12 @@ public String getServiceType() { String protocolVersion = service.getPropertyString("pv"); // HomeKit protocol version return DiscoveryResultBuilder.create(uid) // - .withLabel("%s on (%s)".formatted(modelName, ipV4Address)) // + .withLabel(THING_LABEL_FMT.formatted(modelName, ipV4Address)) // + .withProperty(CONFIG_IP_V4_ADDRESS, ipV4Address) // .withProperty(Thing.PROPERTY_MODEL_ID, modelName) // .withProperty(Thing.PROPERTY_MAC_ADDRESS, macAddress) // - .withProperty("protocolVersion", protocolVersion) // - .withProperty("ipV4Address", ipV4Address) // - .withProperty("deviceCategory", deviceCategory) // + .withProperty(PROPERTY_PROTOCOL_VERSION, protocolVersion) // + .withProperty(PROPERTY_DEVICE_CATEGORY, deviceCategory) // .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS).build(); } return null; @@ -90,13 +90,13 @@ public String getServiceType() { public @Nullable ThingUID getThingUID(ServiceInfo service) { String macAddress = service.getPropertyString("id"); if (macAddress != null) { - macAddress = macAddress.replace(":", "-").toLowerCase(); + String id = macAddress.replace(":", "").replace("-", "").toLowerCase(); // e.g. "a1b2c3d4e5f6" String accessoryType = service.getPropertyString("ci"); // HomeKit accessory type try { if (AccessoryType.BRIDGE.equals(AccessoryType.from(Integer.parseInt(accessoryType)))) { - return new ThingUID(THING_TYPE_BRIDGE, macAddress); + return new ThingUID(THING_TYPE_BRIDGE, id); } else { - return new ThingUID(THING_TYPE_DEVICE, macAddress); + return new ThingUID(THING_TYPE_DEVICE, id); } } catch (IllegalArgumentException e) { } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 66699dad5cdd1..9c6bbda96f196 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -13,11 +13,15 @@ package org.openhab.binding.homekit.internal.dto; import java.util.List; +import java.util.Objects; +import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.AccessoryType; +import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment; +import org.openhab.core.thing.type.ChannelGroupDefinition; import com.google.gson.annotations.SerializedName; @@ -28,9 +32,10 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class Accessory { - public @SerializedName("aid") Integer accessoryId; // e.g. 1 - public List services; + public @NonNullByDefault({}) @SerializedName("aid") Integer accessoryId; // e.g. 1 + public @NonNullByDefault({}) List services; @Override public String toString() { @@ -116,4 +121,16 @@ public AccessoryType getAccessoryType() { } return null; } + + /** + * Builds and registers channel group definitions for all services of this accessory. + * Services that do not map to a channel group definition are ignored. + * + * @param typeProvider the HomeKit type provider used to look up channel group definitions + * @return a list of channel group definitions for the services of this accessory + */ + public List buildAndRegisterChannelGroupDefinitions(HomekitTypeProvider typeProvider) { + return services.stream().map(s -> s.buildAndRegisterChannelGroupDefinition(typeProvider)) + .filter(Objects::nonNull).toList(); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 285832dff838b..b3014c72a66a3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -12,18 +12,20 @@ */ package org.openhab.binding.homekit.internal.dto; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.BINDING_ID; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.math.BigDecimal; import java.util.List; import java.util.Objects; +import java.util.Optional; import javax.measure.Unit; +import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.DataFormatType; -import org.openhab.binding.homekit.internal.provider.HomekitStorageBasedTypeProvider; +import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Point; @@ -48,25 +50,27 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class Characteristic { // invariant fields that define a unique characteristic - public @SerializedName("type") String characteristicId; // e.g. '25' => 'public.hap.characteristic.on' - public @SerializedName("format") String dataFormat; // e.g. "bool" - public @SerializedName("unit") String unit; // e.g. "celsius" - public @SerializedName("maxValue") Double maxValue; // e.g. 100 - public @SerializedName("minValue") Double minValue; // e.g. 0 - public @SerializedName("minStep") Double minStep; - public @SerializedName("perms") List permissions; // e.g. ["pr", "pw", "ev"] + public @NonNullByDefault({}) @SerializedName("type") String characteristicId; // e.g. '25' => + // 'public.hap.characteristic.on' + public @NonNullByDefault({}) @SerializedName("format") String dataFormat; // e.g. "bool" + public @NonNullByDefault({}) @SerializedName("unit") String unit; // e.g. "celsius" + public @NonNullByDefault({}) @SerializedName("maxValue") Double maxValue; // e.g. 100 + public @NonNullByDefault({}) @SerializedName("minValue") Double minValue; // e.g. 0 + public @NonNullByDefault({}) @SerializedName("minStep") Double minStep; + public @NonNullByDefault({}) @SerializedName("perms") List permissions; // e.g. ["pr", "pw", "ev"] // ephemeral fields that may change over time or across instances - public @SerializedName("iid") Integer instanceId; // e.g. 10 - public @SerializedName("value") String dataValue; // e.g. true - public @SerializedName("description") String description; + public @NonNullByDefault({}) @SerializedName("iid") Integer instanceId; // e.g. 10 + public @NonNullByDefault({}) @SerializedName("value") String dataValue; // e.g. true + public @NonNullByDefault({}) @SerializedName("description") String description; // configuration information fields - public @SerializedName("ev") Boolean eventNotification; // e.g. true - public @SerializedName("maxLen") Double maxLen; // e.g. 64 + // public @NonNullByDefault({}) @SerializedName("ev") Boolean eventNotification; // e.g. true + // public @NonNullByDefault({}) @SerializedName("maxLen") Double maxLen; // e.g. 64 /** * The hash only includes the invariant fields as needed to define a fully unique characteristic. @@ -81,7 +85,7 @@ public int hashCode() { /** * Builds a ChannelDefinition and ChannelType based on the characteristic properties. - * Registers the ChannelType with the provided {@link HomekitStorageBasedTypeProvider}. + * Registers the ChannelType with the provided {@link HomekitTypeProvider}. * Returns null if the characteristic cannot be mapped to a channel definition. * Examines characteristic type, data format, permissions, and other properties * to determine appropriate channel type, item type, tags, category, and attributes. @@ -89,7 +93,7 @@ public int hashCode() { * @param typeProvider the HomekitTypeProvider to register the channel type with * @return the ChannelDefinition or null if it cannot be mapped */ - public @Nullable ChannelDefinition buildAndRegisterChannelDefinition(HomekitStorageBasedTypeProvider typeProvider) { + public @Nullable ChannelDefinition buildAndRegisterChannelDefinition(HomekitTypeProvider typeProvider) { CharacteristicType characteristicType; try { characteristicType = CharacteristicType.from(Integer.parseInt(characteristicId)); @@ -548,7 +552,7 @@ public int hashCode() { * different accessories may have the same characteristicId, but their other properties * e.g. min, max, step, unit may be different so we must ensure unique channel type UIDs */ - ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, Integer.toHexString(hashCode())); + ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, CHANNEL_TYPE_FMT.formatted(hashCode())); ChannelType channelType; if (isStateChannel) { @@ -557,32 +561,20 @@ public int hashCode() { } // build StateDescriptionFragment if any relevant properties are present - StateDescriptionFragment stateDescriptionFragment = null; + StateDescriptionFragment stateDescr = null; if (minValue != null || maxValue != null || minStep != null || temp != null) { StateDescriptionFragmentBuilder builder = StateDescriptionFragmentBuilder.create(); builder.withReadOnly(isReadOnly); - if (minValue != null) { - builder.withMinimum(BigDecimal.valueOf(minValue)); - } - if (maxValue != null) { - builder.withMaximum(BigDecimal.valueOf(maxValue)); - } - if (minStep != null) { - builder.withStep(BigDecimal.valueOf(minStep)); - } - if (unit != null) { - builder.withPattern("%.0f " + unit.getSymbol()); - } - stateDescriptionFragment = builder.build(); + Optional.ofNullable(minValue).map(v -> BigDecimal.valueOf(v)).ifPresent(builder::withMinimum); + Optional.ofNullable(maxValue).map(v -> BigDecimal.valueOf(v)).ifPresent(builder::withMaximum); + Optional.ofNullable(minStep).map(s -> BigDecimal.valueOf(s)).ifPresent(builder::withStep); + Optional.ofNullable(unit).map(u -> "%.0f " + u.getSymbol()).ifPresent(builder::withPattern); + stateDescr = builder.build(); } - StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, characteristicType.toString(), itemType); - if (stateDescriptionFragment != null) { - builder.withStateDescriptionFragment(stateDescriptionFragment); - } - if (category != null) { - builder.withCategory(category); - } + StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, CHANNEL_TYPE_LABEL, itemType); + Optional.ofNullable(stateDescr).ifPresent(builder::withStateDescriptionFragment); + Optional.ofNullable(category).ifPresent(builder::withCategory); if (pointTag != null) { if (propertyTag != null) { builder.withTags(pointTag, propertyTag); @@ -594,7 +586,7 @@ public int hashCode() { channelType = builder.build(); } else { // trigger channel - channelType = ChannelTypeBuilder.trigger(uid, characteristicType.toString()).build(); + channelType = ChannelTypeBuilder.trigger(uid, CHANNEL_TYPE_LABEL).build(); } typeProvider.putChannelType(channelType); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index 88398ecae549a..446d1f255c4d4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -12,15 +12,15 @@ */ package org.openhab.binding.homekit.internal.dto; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.BINDING_ID; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.util.List; import java.util.Objects; -import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.ServiceType; -import org.openhab.binding.homekit.internal.provider.HomekitStorageBasedTypeProvider; +import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelGroupDefinition; import org.openhab.core.thing.type.ChannelGroupType; @@ -36,10 +36,11 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class Service { - public @SerializedName("type") String serviceId; // e.g. '96' => 'public.hap.service.battery' - public @SerializedName("iid") Integer instanceId; // e.g. 10 - public List characteristics; + public @NonNullByDefault({}) @SerializedName("type") String serviceId; // e.g. '96' => 'public.hap.service.battery' + public @NonNullByDefault({}) @SerializedName("iid") Integer instanceId; // e.g. 10 + public @NonNullByDefault({}) List characteristics; /** * The hash only includes the invariant fields as needed to define a fully unique channel group type. @@ -55,14 +56,13 @@ public int hashCode() { /** * Builds a {@link ChannelGroupDefinition} and {@link ChannelGroupType} based on the service properties. - * Registers the {@link ChannelGroupType} with the provided {@link HomekitStorageBasedTypeProvider}. + * Registers the {@link ChannelGroupType} with the provided {@link HomekitTypeProvider}. * Returns null if the service type is unknown or if no valid channel definitions can be created. * * @param typeProvider the HomekitStorageBasedTypeProvider to register the channel group type with * @return the created ChannelGroupDefinition or null if creation failed */ - public @Nullable ChannelGroupDefinition buildAndRegisterChannelGroupDefinition( - HomekitStorageBasedTypeProvider typeProvider) { + public @Nullable ChannelGroupDefinition buildAndRegisterChannelGroupDefinition(HomekitTypeProvider typeProvider) { ServiceType serviceType = ServiceType.from(Integer.parseInt(serviceId)); try { serviceType = ServiceType.from(Integer.parseInt(serviceId)); @@ -70,15 +70,15 @@ public int hashCode() { return null; } - List<@NonNull ChannelDefinition> channelDefinitions = characteristics.stream() + List channelDefinitions = characteristics.stream() .map(c -> c.buildAndRegisterChannelDefinition(typeProvider)).filter(Objects::nonNull).toList(); if (channelDefinitions.isEmpty()) { return null; } - ChannelGroupTypeUID uid = new ChannelGroupTypeUID(BINDING_ID, Integer.toHexString(hashCode())); - ChannelGroupType type = ChannelGroupTypeBuilder.instance(uid, serviceId) // + ChannelGroupTypeUID uid = new ChannelGroupTypeUID(BINDING_ID, GROUP_TYPE_FMT.formatted(hashCode())); + ChannelGroupType type = ChannelGroupTypeBuilder.instance(uid, GROUP_TYPE_LABEL) // .withDescription(serviceType.toString()) // .withChannelDefinitions(channelDefinitions) // .build(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index d0c1fefdcd538..ba5292d312d1a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -15,8 +15,12 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.dto.Accessories; @@ -55,7 +59,7 @@ public class HomekitBaseServerHandler extends BaseThingHandler { protected static final Gson GSON = new Gson(); protected final HttpTransport httpTransport; - protected final List accessories = new ArrayList<>(); + protected final Map accessories = new HashMap<>(); protected boolean isChildAccessory = false; @@ -87,8 +91,8 @@ public void initialize() { } else { // standalone accessory or brige accessory, so do pairing and session setup here this.isChildAccessory = false; - this.baseUrl = "http://" + getConfig().get(IP_V4_ADDRESS).toString(); - this.pairingCode = getConfig().get(PAIRING_CODE).toString(); + this.baseUrl = "http://" + getConfig().get(CONFIG_IP_V4_ADDRESS).toString(); + this.pairingCode = getConfig().get(CONFIG_PAIRING_CODE).toString(); try { this.keys = new PairingManager(httpTransport, pairingCode).pair(baseUrl); this.session = new SecureSession(keys); @@ -125,7 +129,8 @@ protected void getAccessories() { Accessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), Accessories.class); if (result != null && result.accessories instanceof List accessoryList) { accessories.clear(); - accessories.addAll(accessoryList); + accessories.putAll(accessoryList.stream().filter(a -> Objects.nonNull(a.accessoryId)) + .collect(Collectors.toMap(a -> a.accessoryId, Function.identity()))); } } catch (Exception e) { } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index f2141412446bf..9272d85652be7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -13,7 +13,7 @@ package org.openhab.binding.homekit.internal.handler; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.discovery.AccessoryDiscoveryService; +import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; @@ -25,7 +25,7 @@ * Handler for HomeKit bridge devices. * It marshals the communications with multiple HomeKit child accessories within a HomeKit bridge server. * It uses the /accessories endpoint to discover embedded accessories and their services. - * It notifies the {@link AccessoryDiscoveryService} when accessories are discovered. + * It notifies the {@link HomekitChildDiscoveryService} when accessories are discovered. * It does not currently handle commands for channels, that is left to the child accessory handlers. * It extends {@link HomekitBaseServerHandler} to handle pairing and secure session setup. * @@ -35,10 +35,10 @@ public class HomekitBridgeHandler extends HomekitBaseServerHandler implements BridgeHandler { // private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); - protected final AccessoryDiscoveryService discoveryService; + protected final HomekitChildDiscoveryService discoveryService; public HomekitBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, - AccessoryDiscoveryService discoveryService) { + HomekitChildDiscoveryService discoveryService) { super(bridge, httpClientFactory); this.discoveryService = discoveryService; } @@ -81,7 +81,7 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { protected void getAccessories() { super.getAccessories(); if (!accessories.isEmpty()) { - discoveryService.devicesDiscovered(thing, accessories); + discoveryService.devicesDiscovered(thing, accessories.values()); } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index e6df4efbc7b3f..4e0df69914bcf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -12,13 +12,25 @@ */ package org.openhab.binding.homekit.internal.handler; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.network.CharacteristicsManager; +import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.builder.ChannelBuilder; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.type.ChannelGroupType; +import org.openhab.core.thing.type.ChannelType; import org.openhab.core.types.Command; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,15 +46,17 @@ public class HomekitDeviceHandler extends HomekitBaseServerHandler { private final Logger logger = LoggerFactory.getLogger(HomekitDeviceHandler.class); + private final HomekitTypeProvider typeProvider; - public HomekitDeviceHandler(Thing thing, HttpClientFactory httpClientFactory) { + public HomekitDeviceHandler(Thing thing, HttpClientFactory httpClientFactory, HomekitTypeProvider typeProvider) { super(thing, httpClientFactory); + this.typeProvider = typeProvider; } @Override public void initialize() { super.initialize(); - String interval = getConfig().get("pollingInterval").toString(); + String interval = getConfig().get(CONFIG_POLLING_INTERVAL).toString(); try { int intervalSeconds = Integer.parseInt(interval); if (intervalSeconds > 0) { @@ -106,11 +120,54 @@ protected void getAccessories() { /** * Creates channels for the accessory based on its services and characteristics. - * Only parses the one relevant accessory in the list, as this handler is for a single accessory. + * Only parses the one relevant accessory in the list, as each handler is for a single accessory. * Iterates through that accessory's services and characteristics to create appropriate channels. - * Each service creates a channel group, and each characteristic creates a channel within that group. + * Each service creates a channel group, and each characteristic creates a channel within it. */ private void createChannels() { - // TODO Auto-generated method stub + if (accessories.isEmpty()) { + return; + } + String uidProperty = thing.getProperties().get(PROPERTY_UID); + if (uidProperty == null) { + return; + } + int accessoryIdIndex = uidProperty.lastIndexOf("-"); + if (accessoryIdIndex < 0) { + return; + } + Integer accessoryId; + try { + accessoryId = Integer.parseInt(uidProperty.substring(accessoryIdIndex + 1)); + } catch (NumberFormatException e) { + return; + } + Accessory accessory = accessories.get(accessoryId); + if (accessory == null) { + return; + } + + // create the channels + List channels = new ArrayList<>(); + accessory.buildAndRegisterChannelGroupDefinitions(typeProvider).forEach(groupDef -> { + ChannelGroupType groupType = typeProvider.getChannelGroupType(groupDef.getTypeUID(), null); + if (groupType != null) { + groupType.getChannelDefinitions().forEach(channelDef -> { + ChannelType channelType = typeProvider.getChannelType(channelDef.getChannelTypeUID(), null); + if (channelType != null) { + ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), channelDef.getId()); + ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()); + Optional.ofNullable(channelDef.getLabel()).ifPresent(builder::withLabel); + Optional.ofNullable(channelDef.getDescription()).ifPresent(builder::withDescription); + channels.add(builder.build()); + } + }); + } + }); + + // update thing with new channels + ThingBuilder builder = editThing().withChannels(channels); + Optional.ofNullable(accessory.getSemanticEquipmentTag()).ifPresent(builder::withSemanticEquipmentTag); + updateThing(builder.build()); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java index 2ba2b0c2132bc..133fe28b8bbeb 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java @@ -19,7 +19,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.discovery.AccessoryDiscoveryService; +import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; +import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; @@ -36,7 +37,7 @@ /** * Creates things and thing handlers. Supports HomeKit bridges and accessories. - * Passes on a {@link AccessoryDiscoveryService} so that created things can to manage discovery of accessories. + * Passes on a {@link HomekitChildDiscoveryService} so that created things can to manage discovery of accessories. * * @author Andrew Fiddian-Green - Initial contribution */ @@ -47,13 +48,16 @@ public class HomekitHandlerFactory extends BaseThingHandlerFactory { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE); private final HttpClientFactory httpClientFactory; + private final HomekitTypeProvider typeProvider; private @Nullable ServiceRegistration discoveryServiceRegistration; - private @Nullable AccessoryDiscoveryService discoveryService; + private @Nullable HomekitChildDiscoveryService discoveryService; @Activate - public HomekitHandlerFactory(@Reference HttpClientFactory httpClientFactory) { + public HomekitHandlerFactory(@Reference HttpClientFactory httpClientFactory, + @Reference HomekitTypeProvider typeProvider) { this.httpClientFactory = httpClientFactory; + this.typeProvider = typeProvider; } @Override @@ -73,7 +77,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { return new HomekitBridgeHandler((Bridge) thing, httpClientFactory, registerDiscoveryService()); } else if (THING_TYPE_DEVICE.equals(thingTypeUID)) { - return new HomekitDeviceHandler(thing, httpClientFactory); + return new HomekitDeviceHandler(thing, httpClientFactory, typeProvider); } return null; } @@ -83,10 +87,10 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { * * @return the registered AccessoryDiscoveryService */ - private AccessoryDiscoveryService registerDiscoveryService() { - AccessoryDiscoveryService service = this.discoveryService; + private HomekitChildDiscoveryService registerDiscoveryService() { + HomekitChildDiscoveryService service = this.discoveryService; if (service == null) { - service = new AccessoryDiscoveryService(); + service = new HomekitChildDiscoveryService(); this.discoveryService = service; } ServiceRegistration registration = this.discoveryServiceRegistration; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitStorageBasedTypeProvider.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitTypeProvider.java similarity index 58% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitStorageBasedTypeProvider.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitTypeProvider.java index 2a5d7c5fe865d..a4543e60d402d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitStorageBasedTypeProvider.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitTypeProvider.java @@ -15,17 +15,21 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.storage.StorageService; import org.openhab.core.thing.binding.AbstractStorageBasedTypeProvider; +import org.openhab.core.thing.type.ChannelGroupTypeProvider; +import org.openhab.core.thing.type.ChannelTypeProvider; +import org.osgi.service.component.annotations.Component; /** - * The {@link HomekitStorageBasedTypeProvider} is responsible for loading and storing HomeKit specific channel and + * The {@link HomekitTypeProvider} is responsible for loading and storing HomeKit specific channel and * channel group types. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomekitStorageBasedTypeProvider extends AbstractStorageBasedTypeProvider { +@Component(service = { HomekitTypeProvider.class, ChannelTypeProvider.class, ChannelGroupTypeProvider.class }) +public class HomekitTypeProvider extends AbstractStorageBasedTypeProvider { - protected HomekitStorageBasedTypeProvider(StorageService storageService) { + protected HomekitTypeProvider(StorageService storageService) { super(storageService); } } From 84ff44a275cd8c72a337e13f77eccadb01c2cb9c Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 14 Sep 2025 18:08:46 +0100 Subject: [PATCH 011/177] work in progress Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/pom.xml | 2 +- .../internal/HomekitBindingConstants.java | 2 - .../homekit/internal/crypto/CryptoUtils.java | 135 +++++++++ .../homekit/internal/crypto/SrpClient.java | 269 ++++++++++++++++++ .../TLV8Codec.java => crypto/Tlv8Codec.java} | 6 +- .../homekit/internal/dto/Accessory.java | 11 +- .../homekit/internal/dto/Characteristic.java | 96 +++---- .../binding/homekit/internal/dto/Service.java | 29 +- .../internal/enums/CharacteristicType.java | 5 +- .../homekit/internal/enums/PairingMethod.java | 45 +++ .../homekit/internal/enums/PairingState.java | 45 +++ .../homekit/internal/enums/ServiceType.java | 5 +- .../homekit/internal/enums/TlvType.java | 46 +++ .../handler/HomekitBaseServerHandler.java | 16 +- .../handler/HomekitDeviceHandler.java | 8 +- .../handler/HomekitHandlerFactory.java | 2 +- .../homekit/internal/network/ChaCha20.java | 82 ------ .../internal/network/PairingManager.java | 101 ------- .../homekit/internal/network/SRPClient.java | 183 ------------ .../homekit/internal/network/SessionKeys.java | 62 ---- .../HomekitTypeProvider.java | 2 +- .../CharacteristicReadWriteService.java} | 8 +- .../services/PairingRemoveService.java | 111 ++++++++ .../services/PairingSetupService.java | 137 +++++++++ .../services/PairingVerifyService.java | 157 ++++++++++ .../{network => session}/SecureSession.java | 20 +- .../homekit/internal/session/SessionKeys.java | 39 +++ .../{network => transport}/HttpTransport.java | 2 +- 28 files changed, 1078 insertions(+), 548 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{network/TLV8Codec.java => crypto/Tlv8Codec.java} (92%) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{provider => persistance}/HomekitTypeProvider.java (95%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{network/CharacteristicsManager.java => services/CharacteristicReadWriteService.java} (88%) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{network => session}/SecureSession.java (80%) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SessionKeys.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{network => transport}/HttpTransport.java (98%) diff --git a/bundles/org.openhab.binding.homekit/pom.xml b/bundles/org.openhab.binding.homekit/pom.xml index 6eb121d98bdf8..feedced85502b 100644 --- a/bundles/org.openhab.binding.homekit/pom.xml +++ b/bundles/org.openhab.binding.homekit/pom.xml @@ -19,7 +19,7 @@ org.bouncycastle bcprov-jdk18on - 1.78 + 1.81 diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 5e1a252dc9076..b7fc6619c4d68 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -37,8 +37,6 @@ public class HomekitBindingConstants { // UID id formats public static final String CHILD_FMT = "child-%x"; // e.g. child-123abc; - public static final String GROUP_TYPE_FMT = "group-%x"; // e.g. group-123abc; - public static final String CHANNEL_TYPE_FMT = "channel-%x"; // e.g. channel-123abc; // configuration parameters public static final String CONFIG_PAIRING_CODE = "pairingCode"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java new file mode 100644 index 0000000000000..776c6d30e0365 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.crypto; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Map; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.InvalidCipherTextException; +import org.bouncycastle.crypto.agreement.X25519Agreement; +import org.bouncycastle.crypto.digests.SHA512Digest; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.generators.X25519KeyPairGenerator; +import org.bouncycastle.crypto.modes.ChaCha20Poly1305; +import org.bouncycastle.crypto.params.AEADParameters; +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.bouncycastle.crypto.params.HKDFParameters; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.X25519KeyGenerationParameters; +import org.bouncycastle.crypto.signers.Ed25519Signer; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.enums.TlvType; + +/** + * Utility class for cryptographic operations used in HomeKit communication. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class CryptoUtils { + + private static final SecureRandom random = new SecureRandom(); + + // Generate ephemeral Curve25519 key pair + public static AsymmetricCipherKeyPair generateCurve25519KeyPair() { + X25519KeyPairGenerator generator = new X25519KeyPairGenerator(); + generator.init(new X25519KeyGenerationParameters(random)); + return generator.generateKeyPair(); + } + + // Compute shared secret using ECDH + public static byte[] computeSharedSecret(AsymmetricKeyParameter privateKey, AsymmetricKeyParameter peerPublicKey) { + X25519Agreement agreement = new X25519Agreement(); + agreement.init(privateKey); + byte[] sharedSecret = new byte[agreement.getAgreementSize()]; + agreement.calculateAgreement(peerPublicKey, sharedSecret, 0); + return sharedSecret; + } + + // HKDF-SHA512 key derivation + public static byte[] hkdf(byte[] ikm, String salt, String info) { + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); + hkdf.init( + new HKDFParameters(ikm, salt.getBytes(StandardCharsets.UTF_8), info.getBytes(StandardCharsets.UTF_8))); + byte[] output = new byte[32]; + hkdf.generateBytes(output, 0, output.length); + return output; + } + + // Encrypt with ChaCha20-Poly1305 + public static byte[] encrypt(byte[] key, String nonceStr, byte[] plaintext) throws InvalidCipherTextException { + return encrypt(key, nonceStr.getBytes(StandardCharsets.UTF_8), plaintext); + } + + // Encrypt with ChaCha20-Poly1305 + public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) throws InvalidCipherTextException { + ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce); + cipher.init(true, params); + + byte[] out = new byte[cipher.getOutputSize(plaintext.length)]; + int len = cipher.processBytes(plaintext, 0, plaintext.length, out, 0); + cipher.doFinal(out, len); + return out; + } + + // Decrypt with ChaCha20-Poly1305 + public static byte[] decrypt(byte[] key, String nonceStr, byte[] ciphertext) throws InvalidCipherTextException { + return decrypt(key, nonceStr.getBytes(StandardCharsets.UTF_8), ciphertext); + } + + // Decrypt with ChaCha20-Poly1305 + public static byte[] decrypt(byte[] key, byte[] nonce, byte[] ciphertext) throws InvalidCipherTextException { + ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce); + cipher.init(false, params); + + byte[] out = new byte[cipher.getOutputSize(ciphertext.length)]; + int len = cipher.processBytes(ciphertext, 0, ciphertext.length, out, 0); + cipher.doFinal(out, len); + return out; + } + + // Sign Pair-Verify message with Ed25519 + public static byte[] signVerifyMessage(Ed25519PrivateKeyParameters privateKey, byte[] message) { + Ed25519Signer signer = new Ed25519Signer(); + signer.init(true, privateKey); + signer.update(message, 0, message.length); + return signer.generateSignature(); + } + + // Validate accessory identity and signature + public static void validateAccessory(Map tlv) { + byte[] identifier = tlv.get(TlvType.IDENTIFIER.key); + byte[] signature = tlv.get(TlvType.SIGNATURE.key); + byte[] publicKey = tlv.get(TlvType.PUBLIC_KEY.key); + + if (identifier == null || signature == null || publicKey == null) { + throw new SecurityException("Missing accessory credentials"); + } + + Ed25519PublicKeyParameters pubKey = new Ed25519PublicKeyParameters(publicKey, 0); + Ed25519Signer verifier = new Ed25519Signer(); + verifier.init(false, pubKey); + verifier.update(identifier, 0, identifier.length); + + boolean valid = verifier.verifySignature(signature); + if (!valid) { + throw new SecurityException("Accessory signature verification failed"); + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java new file mode 100644 index 0000000000000..6e013716032c0 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.crypto; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Map; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.digests.SHA512Digest; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.generators.X25519KeyPairGenerator; +import org.bouncycastle.crypto.params.HKDFParameters; +import org.bouncycastle.crypto.params.X25519KeyGenerationParameters; +import org.bouncycastle.crypto.params.X25519PublicKeyParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.enums.TlvType; +import org.openhab.binding.homekit.internal.session.SessionKeys; + +/** + * Manages the SRP (Secure Remote Password) protocol for pairing with a HomeKit accessory. + * This class handles the SRP steps, including key generation, proof verification, + * and encryption of identifiers. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class SrpClient { + + // HomeKit 3072-bit prime from RFC 5054 + public static final String N_HEX = + //@formatter:off + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" + + "020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" + + "4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" + + "98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" + + "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" + + "3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33" + + "A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" + + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864" + + "D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E2" + + "08E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"; + //@formatter:on + + private static final BigInteger N = new BigInteger(N_HEX); + private static final BigInteger g = BigInteger.valueOf(5); + + private static final byte[] PAIR_USER = "Pair-Setup".getBytes(StandardCharsets.UTF_8); + private static final byte[] PAIR_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); + private static final byte[] PAIR_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); + private static final byte[] NONCE_M5 = "PS-Msg05".getBytes(StandardCharsets.UTF_8); + private static final byte[] NONCE_M6 = "PS-Msg06".getBytes(StandardCharsets.UTF_8); + + private final String accessoryPairingCode; + private final SecureRandom random = new SecureRandom(); + + // SRP internals + private @Nullable BigInteger a = null; // client private exponent + private @Nullable BigInteger A = null; // client public value + private @Nullable BigInteger B = null; // server public value + private @Nullable BigInteger x = null; // private key derived from salt + (username:pin) + private @Nullable BigInteger k = null; // SRP multiplier + private @Nullable BigInteger u = null; // scrambling parameter + private @Nullable BigInteger S = null; // shared secret + private byte[] K = new byte[0]; // session key (H(S)) + private byte[] M1 = new byte[0]; // client proof + private byte[] salt = new byte[0]; // server salt + + // Curve25519 key‐pair for identifier exchange + private final AsymmetricCipherKeyPair x25519KeyPair; + + // Accessory credentials after M6 + private @Nullable String accessoryIdentifier; + private byte[] accessoryPublicKey = new byte[0]; + + public SrpClient(String accessoryPairingCode) { + this.accessoryPairingCode = accessoryPairingCode; + + // Generate Curve25519 key‐pair once + X25519KeyPairGenerator gen = new X25519KeyPairGenerator(); + gen.init(new X25519KeyGenerationParameters(random)); + this.x25519KeyPair = gen.generateKeyPair(); + } + + /** + * M2 — Store salt and accessory public key (B). + */ + public void processChallenge(byte[] salt, byte[] serverPublicKey) throws NoSuchAlgorithmException { + this.B = new BigInteger(1, serverPublicKey); + this.salt = salt; + + // Precompute k = H(N || g) + this.k = new BigInteger(1, MessageDigest.getInstance("SHA-512") + .digest(concat(BigIntUtils.toUnsignedByteArray(N), BigIntUtils.toUnsignedByteArray(g)))); + + // Precompute x = H(salt || H(username:pin)) + byte[] inner = MessageDigest.getInstance("SHA-512") + .digest((PAIR_USER + ":" + accessoryPairingCode).getBytes(StandardCharsets.UTF_8)); + + this.x = new BigInteger(1, MessageDigest.getInstance("SHA-512").digest(concat(salt, inner))); + } + + /** + * M3 — Client public key A. + */ + public byte[] getPublicKey() { + BigInteger A = this.A; + if (A == null) { + // a = random, A = g^a mod N + this.a = new BigInteger(N.bitLength(), random).mod(N); + A = g.modPow(a, N); + this.A = A; + } + return BigIntUtils.toUnsignedByteArray(A); + } + + /** + * M3 — Client proof M1 = H( H(N)^H(g) || H(username) || salt || A || B || K ). + */ + public byte[] getClientProof() throws Exception { + if (M1.length == 0) { + MessageDigest sha512 = MessageDigest.getInstance("SHA-512"); + + // u = H(A || B) + sha512.update(BigIntUtils.toUnsignedByteArray(A)); + sha512.update(BigIntUtils.toUnsignedByteArray(B)); + this.u = new BigInteger(1, sha512.digest()); + + BigInteger B = this.B; + BigInteger k = this.k; + BigInteger a = this.a; + BigInteger u = this.u; + BigInteger x = this.x; + if (B == null || k == null || a == null || u == null || x == null) { + throw new IllegalStateException("SRP internal state not initialized"); + } + + // S = ( B - k·g^x )^( a + u·x ) mod N + BigInteger gx = g.modPow(x, N); + BigInteger tmp = B.subtract(k.multiply(gx)).mod(N); + BigInteger exp = a.add(u.multiply(x)); + this.S = tmp.modPow(exp, N); + + // K = H(S) + this.K = MessageDigest.getInstance("SHA-512").digest(BigIntUtils.toUnsignedByteArray(S)); + + // compute proof M1 + byte[] HN = MessageDigest.getInstance("SHA-512").digest(BigIntUtils.toUnsignedByteArray(N)); + byte[] Hg = MessageDigest.getInstance("SHA-512").digest(BigIntUtils.toUnsignedByteArray(g)); + byte[] Hxor = xor(HN, Hg); + byte[] Hu = MessageDigest.getInstance("SHA-512").digest(PAIR_USER); + + sha512.reset(); + sha512.update(Hxor); + sha512.update(Hu); + sha512.update(salt); + sha512.update(BigIntUtils.toUnsignedByteArray(A)); + sha512.update(BigIntUtils.toUnsignedByteArray(B)); + sha512.update(K); + this.M1 = sha512.digest(); + } + return M1; + } + + /** + * M4 — Verify server proof M2 = H( A || M1 || K ). + */ + public void verifyServerProof(byte[] serverProof) throws Exception { + MessageDigest sha512 = MessageDigest.getInstance("SHA-512"); + sha512.update(BigIntUtils.toUnsignedByteArray(A)); + sha512.update(M1); + sha512.update(K); + byte[] expected = sha512.digest(); + + if (!Arrays.equals(expected, serverProof)) { + throw new SecurityException("SRP server proof mismatch"); + } + } + + /** + * M5 — Encrypt controller identifier + Curve25519 public key. + */ + public byte[] getEncryptedIdentifiers() throws Exception { + Map tlv = Map.of( // + TlvType.IDENTIFIER.key, PAIR_USER, // + TlvType.PUBLIC_KEY.key, ((X25519PublicKeyParameters) x25519KeyPair.getPublic()).getEncoded()); + byte[] plain = Tlv8Codec.encode(tlv); + return CryptoUtils.encrypt(deriveSessionKeys().getWriteKey(), plain, NONCE_M5); + } + + /** + * M6 — Decrypt and store accessory identifier + Curve25519 public key. + */ + public void verifyAccessoryIdentifiers(byte[] encryptedData) throws Exception { + byte[] decrypted = CryptoUtils.encrypt(deriveSessionKeys().getReadKey(), encryptedData, NONCE_M6); + Map accTlv = Tlv8Codec.decode(decrypted); + + byte[] idBytes = accTlv.get(TlvType.IDENTIFIER.key); + byte[] pkBytes = accTlv.get(TlvType.PUBLIC_KEY.key); + if (idBytes == null || pkBytes == null) { + throw new SecurityException("Missing accessory credentials in M6"); + } + this.accessoryIdentifier = new String(idBytes, StandardCharsets.UTF_8); + this.accessoryPublicKey = pkBytes; + } + + /** + * After M6, derive the 32‐byte session key using HKDF(S, salt, info). + */ + public SessionKeys deriveSessionKeys() { + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); + hkdf.init(new HKDFParameters(K, PAIR_SALT, PAIR_INFO)); + byte[] sessionKey = new byte[32]; + hkdf.generateBytes(sessionKey, 0, sessionKey.length); + return new SessionKeys(sessionKey, sessionKey); + } + + // ——— Internals ——————————————————————————————————————————— + + private static byte[] xor(byte[] a, byte[] b) { + byte[] out = new byte[Math.min(a.length, b.length)]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) (a[i] ^ b[i]); + } + return out; + } + + private static class BigIntUtils { + static byte[] toUnsignedByteArray(@Nullable BigInteger b) { + if (b == null) { + throw new IllegalStateException("BigInteger is null"); + } + byte[] bytes = b.toByteArray(); + return bytes[0] == 0 ? Arrays.copyOfRange(bytes, 1, bytes.length) : bytes; + } + } + + public static byte[] concat(byte[] a, byte[] b) { + byte[] result = new byte[a.length + b.length]; + System.arraycopy(a, 0, result, 0, a.length); + System.arraycopy(b, 0, result, a.length, b.length); + return result; + } + + public @Nullable String getAccessoryIdentifier() { + return accessoryIdentifier; + } + + public byte[] getAccessoryPublicKey() { + return accessoryPublicKey; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/Tlv8Codec.java similarity index 92% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/Tlv8Codec.java index d3a4f3a8acee4..de8aa0771efa5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/TLV8Codec.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/Tlv8Codec.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.network; +package org.openhab.binding.homekit.internal.crypto; import java.io.ByteArrayOutputStream; import java.util.Arrays; @@ -21,13 +21,11 @@ /** * Utility class for encoding and decoding TLV8 (Type-Length-Value) data. - * TLV8 is used in HomeKit for structured data exchange. - * Handles splitting and combining values that exceed the maximum length of 255 bytes. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class TLV8Codec { +public class Tlv8Codec { public static final int MAX_TLV_LENGTH = 255; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 9c6bbda96f196..6a01c0dfb737f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -18,7 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.AccessoryType; -import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment; import org.openhab.core.thing.type.ChannelGroupDefinition; @@ -124,10 +124,13 @@ public AccessoryType getAccessoryType() { /** * Builds and registers channel group definitions for all services of this accessory. - * Services that do not map to a channel group definition are ignored. + * Each child service registers a ChannelGroupType and returns a ChannelGroupDefinition thereof. + * Each grandchild category registers a ChannelType and returns a ChannelDefinition thereof. + * Child services that do not map to a channel group definition are ignored. + * Grandchild categories that do not map to a channel definition are ignored. * - * @param typeProvider the HomeKit type provider used to look up channel group definitions - * @return a list of channel group definitions for the services of this accessory + * @param typeProvider the HomeKit type provider used to look up channel group definitions. + * @return a list of channel group definition instances for the services of this accessory. */ public List buildAndRegisterChannelGroupDefinitions(HomekitTypeProvider typeProvider) { return services.stream().map(s -> s.buildAndRegisterChannelGroupDefinition(typeProvider)) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index b3014c72a66a3..190ec45b78ef5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -16,7 +16,6 @@ import java.math.BigDecimal; import java.util.List; -import java.util.Objects; import java.util.Optional; import javax.measure.Unit; @@ -25,7 +24,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.DataFormatType; -import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Point; @@ -52,40 +51,22 @@ */ @NonNullByDefault public class Characteristic { - - // invariant fields that define a unique characteristic - public @NonNullByDefault({}) @SerializedName("type") String characteristicId; // e.g. '25' => - // 'public.hap.characteristic.on' + public @NonNullByDefault({}) @SerializedName("type") String characteristicId; // 25 = public.hap.characteristic.on public @NonNullByDefault({}) @SerializedName("format") String dataFormat; // e.g. "bool" + public @NonNullByDefault({}) @SerializedName("perms") List permissions; // e.g. ["pr", "pw", "ev"] + public @NonNullByDefault({}) @SerializedName("iid") Integer instanceId; // e.g. 10 public @NonNullByDefault({}) @SerializedName("unit") String unit; // e.g. "celsius" public @NonNullByDefault({}) @SerializedName("maxValue") Double maxValue; // e.g. 100 public @NonNullByDefault({}) @SerializedName("minValue") Double minValue; // e.g. 0 public @NonNullByDefault({}) @SerializedName("minStep") Double minStep; - public @NonNullByDefault({}) @SerializedName("perms") List permissions; // e.g. ["pr", "pw", "ev"] - - // ephemeral fields that may change over time or across instances - public @NonNullByDefault({}) @SerializedName("iid") Integer instanceId; // e.g. 10 public @NonNullByDefault({}) @SerializedName("value") String dataValue; // e.g. true public @NonNullByDefault({}) @SerializedName("description") String description; - - // configuration information fields - // public @NonNullByDefault({}) @SerializedName("ev") Boolean eventNotification; // e.g. true - // public @NonNullByDefault({}) @SerializedName("maxLen") Double maxLen; // e.g. 64 + public @NonNullByDefault({}) @SerializedName("ev") Boolean eventNotification; // e.g. true /** - * The hash only includes the invariant fields as needed to define a fully unique characteristic. - * The instanceId, dataValue and description are excluded as they depend on accessory instance and state. - * - * @return hash code - */ - @Override - public int hashCode() { - return Objects.hash(characteristicId, dataFormat, unit, minValue, maxValue, minStep, permissions); - } - - /** - * Builds a ChannelDefinition and ChannelType based on the characteristic properties. - * Registers the ChannelType with the provided {@link HomekitTypeProvider}. + * Builds a ChannelType and a ChannelDefinition based on the characteristic properties. + * Registers the ChannelType with the provided HomekitTypeProvider. + * Returns a ChannelDefinition that is specific instance of ChannelType. * Returns null if the characteristic cannot be mapped to a channel definition. * Examines characteristic type, data format, permissions, and other properties * to determine appropriate channel type, item type, tags, category, and attributes. @@ -108,12 +89,6 @@ public int hashCode() { return null; } - Unit unit = null; - String temp = this.unit; - if (temp != null) { - unit = UnitUtils.parseUnit(temp); - } - // determine channel type and attributes based on characteristic properties boolean isReadOnly = !permissions.contains("pw"); boolean isString = DataFormatType.STRING == dataFormatType; @@ -549,31 +524,16 @@ public int hashCode() { } /* - * different accessories may have the same characteristicId, but their other properties - * e.g. min, max, step, unit may be different so we must ensure unique channel type UIDs + * NOTE: different accessories may have the same characteristicType, but their other + * properties e.g. min, max, step, unit may be different */ - ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, CHANNEL_TYPE_FMT.formatted(hashCode())); - + ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, characteristicType.getGroupTypeId()); ChannelType channelType; if (isStateChannel) { if (itemType == null) { return null; } - - // build StateDescriptionFragment if any relevant properties are present - StateDescriptionFragment stateDescr = null; - if (minValue != null || maxValue != null || minStep != null || temp != null) { - StateDescriptionFragmentBuilder builder = StateDescriptionFragmentBuilder.create(); - builder.withReadOnly(isReadOnly); - Optional.ofNullable(minValue).map(v -> BigDecimal.valueOf(v)).ifPresent(builder::withMinimum); - Optional.ofNullable(maxValue).map(v -> BigDecimal.valueOf(v)).ifPresent(builder::withMaximum); - Optional.ofNullable(minStep).map(s -> BigDecimal.valueOf(s)).ifPresent(builder::withStep); - Optional.ofNullable(unit).map(u -> "%.0f " + u.getSymbol()).ifPresent(builder::withPattern); - stateDescr = builder.build(); - } - StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, CHANNEL_TYPE_LABEL, itemType); - Optional.ofNullable(stateDescr).ifPresent(builder::withStateDescriptionFragment); Optional.ofNullable(category).ifPresent(builder::withCategory); if (pointTag != null) { if (propertyTag != null) { @@ -582,16 +542,42 @@ public int hashCode() { builder.withTags(pointTag); } } - // state channel channelType = builder.build(); } else { - // trigger channel channelType = ChannelTypeBuilder.trigger(uid, CHANNEL_TYPE_LABEL).build(); } + // persist the channel _type_, and return the definition of a specific _instance_ of that type typeProvider.putChannelType(channelType); - return new ChannelDefinitionBuilder(Integer.toString(instanceId), uid).withLabel(characteristicType.toString()) - .withDescription(description).build(); + .build(); + } + + /** + * Build StateDescriptionFragment if any relevant properties are present + * + * @return StateDescriptionFragment or null if not applicable + */ + public @Nullable StateDescriptionFragment getStateDescriptionFragment() { + StateDescriptionFragment stateDescr = null; + if (minValue != null || maxValue != null || minStep != null || unit != null) { + Unit unit = null; + String temp = this.unit; + if (temp != null) { + unit = UnitUtils.parseUnit(temp); + } + StateDescriptionFragmentBuilder builder = StateDescriptionFragmentBuilder.create(); + builder.withReadOnly(!permissions.contains("pw")); + Optional.ofNullable(minValue).map(v -> BigDecimal.valueOf(v)).ifPresent(builder::withMinimum); + Optional.ofNullable(maxValue).map(v -> BigDecimal.valueOf(v)).ifPresent(builder::withMaximum); + Optional.ofNullable(minStep).map(s -> BigDecimal.valueOf(s)).ifPresent(builder::withStep); + Optional.ofNullable(unit).map(u -> "%.0f " + u.getSymbol()).ifPresent(builder::withPattern); + stateDescr = builder.build(); + } + return stateDescr; + } + + public @Nullable String getDescription() { + return description; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index 446d1f255c4d4..a53113d761347 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.ServiceType; -import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelGroupDefinition; import org.openhab.core.thing.type.ChannelGroupType; @@ -43,20 +43,9 @@ public class Service { public @NonNullByDefault({}) List characteristics; /** - * The hash only includes the invariant fields as needed to define a fully unique channel group type. - * The instanceId is excluded as it depends on the accessory instance. - * The characteristics are included as they define the channels within the channel group. - * - * @return hash code - */ - @Override - public int hashCode() { - return Objects.hash(serviceId, instanceId, characteristics); - } - - /** - * Builds a {@link ChannelGroupDefinition} and {@link ChannelGroupType} based on the service properties. - * Registers the {@link ChannelGroupType} with the provided {@link HomekitTypeProvider}. + * Builds a ChannelGroupDefinition and a ChannelGroupType based on the service properties. + * Registers the ChannelGroupType with the provided HomekitTypeProvider. + * Returns a ChannelGroupDefinition that is specific instance of ChannelGroupType. * Returns null if the service type is unknown or if no valid channel definitions can be created. * * @param typeProvider the HomekitStorageBasedTypeProvider to register the channel group type with @@ -77,14 +66,14 @@ public int hashCode() { return null; } - ChannelGroupTypeUID uid = new ChannelGroupTypeUID(BINDING_ID, GROUP_TYPE_FMT.formatted(hashCode())); - ChannelGroupType type = ChannelGroupTypeBuilder.instance(uid, GROUP_TYPE_LABEL) // + ChannelGroupTypeUID groupTypeUID = new ChannelGroupTypeUID(BINDING_ID, serviceType.getChannelTypeId()); + ChannelGroupType groupType = ChannelGroupTypeBuilder.instance(groupTypeUID, GROUP_TYPE_LABEL) // .withDescription(serviceType.toString()) // .withChannelDefinitions(channelDefinitions) // .build(); - typeProvider.putChannelGroupType(type); - - return new ChannelGroupDefinition(Integer.toString(instanceId), uid, serviceType.getTypeSuffix(), null); + // persist the group _type_, and return the definition of a specific _instance_ of that type + typeProvider.putChannelGroupType(groupType); + return new ChannelGroupDefinition(Integer.toString(instanceId), groupTypeUID, serviceType.toString(), null); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index d8ebe3f9d9112..d15c312ebbfe4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -176,9 +176,8 @@ public String toString() { return builder.toString(); } - public String getTypeSuffix() { - int lastIndex = type.lastIndexOf("."); - return type.substring(lastIndex + 1); + public String getGroupTypeId() { + return type.replace("-", "_").replace(".", "-"); // convert to OH channel-group-type format } public static CharacteristicType from(int id) throws IllegalArgumentException { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java new file mode 100644 index 0000000000000..195f670e193ea --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of pairing methods used in HomeKit communication. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum PairingMethod { + SETUP(0x00), + SETUP_AUTH(0x01), + VERIFY(0x02), + ADD(0x03), + REMOVE(0x04), + LIST(0x05); + + public final byte value; + + PairingMethod(int value) { + this.value = (byte) value; + } + + public static PairingMethod from(byte b) { + for (PairingMethod state : values()) { + if (state.value == b) { + return state; + } + } + throw new IllegalArgumentException("Unknown pairing state: " + b); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java new file mode 100644 index 0000000000000..9a7af98e1eeb9 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of pairing states used in the HomeKit pairing process. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum PairingState { + M1(0x01), + M2(0x02), + M3(0x03), + M4(0x04), + M5(0x05), + M6(0x06); + + public final byte value; + + PairingState(int value) { + this.value = (byte) value; + } + + public static PairingState from(byte b) { + for (PairingState state : values()) { + if (state.value == b) { + return state; + } + } + throw new IllegalArgumentException("Unknown pairing state: " + b); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index 834db11c60df7..74fe91e52e65c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -89,9 +89,8 @@ public String toString() { return builder.toString(); } - public String getTypeSuffix() { - int lastIndex = type.lastIndexOf("."); - return type.substring(lastIndex + 1); + public String getChannelTypeId() { + return type.replace("-", "_").replace(".", "-"); // covert to OH channel type format } public static ServiceType from(int id) throws IllegalArgumentException { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java new file mode 100644 index 0000000000000..5f398e9ca5501 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of TLV (Type-Length-Value) types used in HomeKit communication. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum TlvType { + METHOD(0x00), + IDENTIFIER(0x01), + SALT(0x02), + PUBLIC_KEY(0x03), + PROOF(0x04), + ENCRYPTED_DATA(0x05), + STATE(0x06), + ERROR(0x07), + RETRY_DELAY(0x08), + CERTIFICATE(0x09), + SIGNATURE(0x0A), + PERMISSIONS(0x0B), + FRAGMENT_DATA(0x0C), + FRAGMENT_LAST(0x0D), + FLAGS(0x13), + SEPERATOR((byte) 0xFF); + + public final int key; + + TlvType(int key) { + this.key = key; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index ba5292d312d1a..8ecd173813aae 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -25,11 +25,11 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; -import org.openhab.binding.homekit.internal.network.CharacteristicsManager; -import org.openhab.binding.homekit.internal.network.HttpTransport; -import org.openhab.binding.homekit.internal.network.PairingManager; -import org.openhab.binding.homekit.internal.network.SecureSession; -import org.openhab.binding.homekit.internal.network.SessionKeys; +import org.openhab.binding.homekit.internal.services.CharacteristicReadWriteService; +import org.openhab.binding.homekit.internal.services.PairingSetupService; +import org.openhab.binding.homekit.internal.session.SecureSession; +import org.openhab.binding.homekit.internal.session.SessionKeys; +import org.openhab.binding.homekit.internal.transport.HttpTransport; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; @@ -63,7 +63,7 @@ public class HomekitBaseServerHandler extends BaseThingHandler { protected boolean isChildAccessory = false; - protected @NonNullByDefault({}) CharacteristicsManager charactersticsManager; + protected @NonNullByDefault({}) CharacteristicReadWriteService charactersticsManager; protected @NonNullByDefault({}) SessionKeys keys; protected @NonNullByDefault({}) SecureSession session; protected @NonNullByDefault({}) String baseUrl; @@ -94,9 +94,9 @@ public void initialize() { this.baseUrl = "http://" + getConfig().get(CONFIG_IP_V4_ADDRESS).toString(); this.pairingCode = getConfig().get(CONFIG_PAIRING_CODE).toString(); try { - this.keys = new PairingManager(httpTransport, pairingCode).pair(baseUrl); + this.keys = new PairingSetupService(httpTransport, pairingCode).pair(baseUrl); this.session = new SecureSession(keys); - this.charactersticsManager = new CharacteristicsManager(httpTransport, session, baseUrl); + this.charactersticsManager = new CharacteristicReadWriteService(httpTransport, session, baseUrl); scheduler.submit(() -> getAccessories()); updateStatus(ThingStatus.ONLINE); } catch (Exception e) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 4e0df69914bcf..c04e6e797c067 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -21,8 +21,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.dto.Accessory; -import org.openhab.binding.homekit.internal.network.CharacteristicsManager; -import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.services.CharacteristicReadWriteService; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; @@ -69,7 +69,7 @@ public void initialize() { @Override public void handleCommand(ChannelUID channelUID, Command command) { - CharacteristicsManager charactersticsManager = this.charactersticsManager; + CharacteristicReadWriteService charactersticsManager = this.charactersticsManager; if (charactersticsManager != null) { String channelId = channelUID.getId(); try { @@ -93,7 +93,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { * This method is called periodically by a scheduled executor. */ private void poll() { - CharacteristicsManager charactersticsManager = this.charactersticsManager; + CharacteristicReadWriteService charactersticsManager = this.charactersticsManager; if (charactersticsManager != null) { try { // String power = accessoryClient.readCharacteristic("1", "10"); // TODO example AID/IID diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java index 133fe28b8bbeb..9ea2eec50d850 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java @@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; -import org.openhab.binding.homekit.internal.provider.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java deleted file mode 100644 index 4012df6df1267..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/ChaCha20.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.network; - -import java.security.GeneralSecurityException; - -import org.bouncycastle.crypto.modes.ChaCha20Poly1305; -import org.bouncycastle.crypto.params.AEADParameters; -import org.bouncycastle.crypto.params.KeyParameter; -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * ChaCha20 encryption and decryption utility class. - * Uses BouncyCastle's ChaCha20Poly1305 implementation. - * Requires a 32-byte key and a 12-byte nonce. - * The nonce must be unique for each encryption operation with the same key. - * The ciphertext includes the authentication tag. - * See RFC 8439 for more details. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class ChaCha20 { - - /** - * Encrypts the given plaintext using ChaCha20-Poly1305. - * - * @param key 32-byte encryption key - * @param nonce 12-byte nonce - * @param plaintext data to encrypt - * @return encrypted data (ciphertext + authentication tag) - * @throws GeneralSecurityException - */ - public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) throws GeneralSecurityException { - try { - ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); - AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); - cipher.init(true, params); - - byte[] out = new byte[cipher.getOutputSize(plaintext.length)]; - int len = cipher.processBytes(plaintext, 0, plaintext.length, out, 0); - cipher.doFinal(out, len); - return out; - } catch (Exception e) { - throw new GeneralSecurityException("Encryption failed", e); - } - } - - /** - * Decrypts the given ciphertext using ChaCha20-Poly1305. - * - * @param key 32-byte decryption key - * @param nonce 12-byte nonce - * @param ciphertext data to decrypt (ciphertext + authentication tag) - * @return decrypted data (plaintext) - * @throws GeneralSecurityException - */ - public static byte[] decrypt(byte[] key, byte[] nonce, byte[] ciphertext) throws GeneralSecurityException { - try { - ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); - AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); - cipher.init(false, params); - - byte[] out = new byte[cipher.getOutputSize(ciphertext.length)]; - int len = cipher.processBytes(ciphertext, 0, ciphertext.length, out, 0); - cipher.doFinal(out, len); - return out; - } catch (Exception e) { - throw new GeneralSecurityException("Decryption failed", e); - } - } -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java deleted file mode 100644 index d744d4f22cc69..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/PairingManager.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.network; - -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; - -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * Handles the 6-step pairing process with a HomeKit accessory. - * Uses SRP for secure key exchange and derives session keys. - * Communicates with the accessory using HTTP and TLV8 encoding. - * Requires the accessory's setup code for pairing. - * Returns session keys upon successful pairing. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class PairingManager { - - private final SRPClient srpClient; - private final HttpTransport httpTransport; - - public PairingManager(HttpTransport httpTransport, String pairingCode) { - this.httpTransport = httpTransport; - this.srpClient = new SRPClient(pairingCode); - } - - /** - * Initiates the pairing process with the accessory at the given address. - * - * @param baseUrl the base URL of the accessory (e.g., "http://123.123.123.123:port") - */ - public SessionKeys pair(String baseUrl) throws Exception { - // Step 1: M1 — Start Pairing - byte[] m1 = TLV8Codec.encode(Map.of(0x00, new byte[] { 0x00 }, 0x01, new byte[] { 0x01 })); - byte[] resp1 = httpTransport.post(baseUrl, ENDPOINT_PAIRING, CONTENT_TYPE_PAIRING, m1); - - // Step 2: M2 — Receive SRP salt and public key - Map tlv2 = TLV8Codec.decode(resp1); - srpClient.processChallenge(Objects.requireNonNull(tlv2.get(0x03)), Objects.requireNonNull(tlv2.get(0x04))); - - // Step 3: M3 — Send SRP public key and proof - Map m3 = srpClient.generateClientProof(); - byte[] resp3 = httpTransport.post(baseUrl, ENDPOINT_PAIRING, CONTENT_TYPE_PAIRING, TLV8Codec.encode(m3)); - - // Step 4: M4 — Verify server proof - Map tlv4 = TLV8Codec.decode(resp3); - srpClient.verifyServerProof(Objects.requireNonNull(tlv4.get(0x04))); - - // Step 5: M5 — Exchange encrypted identifiers - Map m5 = srpClient.generateEncryptedIdentifiers(); - byte[] resp5 = httpTransport.post(baseUrl, ENDPOINT_PAIRING, CONTENT_TYPE_PAIRING, TLV8Codec.encode(m5)); - - // Step 6: M6 — Final confirmation - Map tlv6 = TLV8Codec.decode(resp5); - srpClient.verifyAccessoryIdentifiers(tlv6); - - // Derive session keys - return srpClient.deriveSessionKeys(); - } - - public void removePairing(String baseUrl, String pairingIdentifier, SecureSession secureSession) throws Exception { - // Step 1: Construct TLV for remove pairing (State = 0x01, Method = 0x04) - Map tlv = new HashMap<>(); - tlv.put(0x00, new byte[] { 0x01 }); // State - tlv.put(0x01, new byte[] { 0x04 }); // Method: Remove pairing - tlv.put(0x03, pairingIdentifier.getBytes(StandardCharsets.UTF_8)); // Identifier to remove - - byte[] plaintext = TLV8Codec.encode(tlv); - - // Step 2: Encrypt with session keys - byte[] encrypted = secureSession.encrypt(plaintext); - - // Step 3: Send remove pairing request - byte[] response = httpTransport.post(baseUrl, ENDPOINT_PAIRING, CONTENT_TYPE_PAIRING, encrypted); - - // Step 4: Decrypt and verify response - byte[] decrypted = secureSession.decrypt(response); - Map tlvResp = TLV8Codec.decode(decrypted); - - if (Objects.requireNonNull(tlvResp.get(0x00))[0] != 0x02) { - throw new IllegalStateException("Unexpected response state during pairing removal"); - } - } -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java deleted file mode 100644 index 287490da42376..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SRPClient.java +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.network; - -import java.io.ByteArrayOutputStream; -import java.math.BigInteger; -import java.security.GeneralSecurityException; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.util.Map; -import java.util.Objects; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * Implements the client side of the Secure Remote Password (SRP) protocol for HomeKit pairing. - * This class handles the SRP handshake, proof generation, and verification. - * It also manages the encryption and decryption of identifiers using the shared session key. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class SRPClient { - - // HomeKit 3072-bit prime from RFC 5054 - public static final String N_HEX = - //@formatter:off - "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" + - "020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" + - "4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + - "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" + - "98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" + - "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + - "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" + - "3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33" + - "A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" + - "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864" + - "D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E2" + - "08E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"; - //@formatter:on - - private static final BigInteger N = new BigInteger(N_HEX); - private static final BigInteger g = BigInteger.valueOf(5); - - private final String pairingCode; - - private @NonNullByDefault({}) BigInteger a; // private ephemeral - private @NonNullByDefault({}) BigInteger A; // public ephemeral - private @NonNullByDefault({}) BigInteger B; // server public - private @NonNullByDefault({}) byte[] salt; // from server - private @NonNullByDefault({}) byte[] K; // shared session key - - public SRPClient(String pairingCode) { - this.pairingCode = pairingCode; - } - - /** - * Processes the server's SRP challenge by storing the salt and server public key, - * and generating the client's ephemeral keys. - * - * @param salt The salt provided by the server. - * @param serverPublicKey The server's public key (B). - * @throws Exception If an error occurs during processing. - */ - public void processChallenge(byte[] salt, byte[] serverPublicKey) throws Exception { - this.salt = salt; - this.B = new BigInteger(1, serverPublicKey); - SecureRandom random = new SecureRandom(); - this.a = new BigInteger(256, random); - this.A = g.modPow(a, N); - } - - /** - * Generates the client's proof of knowledge (M1) and returns it along with the client's public key (A). - * - * @return A map containing the client's public key (A) and proof (M1). - * @throws Exception If an error occurs during proof generation. - */ - public Map generateClientProof() throws Exception { - MessageDigest digest = MessageDigest.getInstance("SHA-512"); - byte[] xH = digest.digest((new String(salt) + pairingCode).getBytes()); - BigInteger x = new BigInteger(1, xH); - - BigInteger u = computeU(A, B); - BigInteger S = (B.subtract(g.modPow(x, N))).modPow(a.add(u.multiply(x)), N); - this.K = digest.digest(S.toByteArray()); - - byte[] M1 = computeM1(A, B, K); - return Map.of(0x03, A.toByteArray(), 0x04, M1); - } - - /** - * Verifies the server's proof (M2) against the expected value. - * - * @param M2 The server's proof to verify. - * @throws Exception If an error occurs during verification or if the proof does not match. - */ - public void verifyServerProof(byte[] M2) throws Exception { - byte[] expected = computeM2(A, computeM1(A, B, K), K); - if (!MessageDigest.isEqual(M2, expected)) { - throw new SecurityException("Server proof mismatch"); - } - } - - /** - * Generates encrypted identifiers using the shared session key (K). - * This includes encrypting the controller's identifier and public key. - * - * @return A map containing the nonce and encrypted data. - * @throws Exception If an error occurs during encryption. - */ - public Map generateEncryptedIdentifiers() throws Exception { - // Encrypt controller identifier and public key using shared key K - byte[] plaintext = "...".getBytes(); // TODO input TLV8 encoded identifiers - byte[] nonce = generateNonce(); - byte[] encrypted = ChaCha20.encrypt(K, nonce, plaintext); - return Map.of(0x05, nonce, 0x06, encrypted); - } - - /** - * Verifies the accessory's encrypted identifiers using the shared session key (K). - * - * @param tlv6 A map containing the nonce and encrypted data from the accessory. - * @throws Exception If an error occurs during decryption or verification. - */ - public void verifyAccessoryIdentifiers(Map tlv6) throws Exception { - byte[] encrypted = Objects.requireNonNull(tlv6.get(0x06)); - byte[] nonce = Objects.requireNonNull(tlv6.get(0x05)); - @SuppressWarnings("unused") - // TODO parse decrypted TLV8 and specifically validate accessory identity - byte[] decrypted = ChaCha20.decrypt(K, nonce, encrypted); - } - - /** - * Derives session keys for encrypting and decrypting messages between the HomeKit controller and accessory. - * - * @return An instance of SessionKeys containing the derived read and write keys. - * @throws GeneralSecurityException - */ - public SessionKeys deriveSessionKeys() throws GeneralSecurityException { - return new SessionKeys(K); - } - - private BigInteger computeU(BigInteger A, BigInteger B) throws Exception { - MessageDigest digest = MessageDigest.getInstance("SHA-512"); - byte[] uH = digest.digest(concat(A.toByteArray(), B.toByteArray())); - return new BigInteger(1, uH); - } - - private byte[] computeM1(BigInteger A, BigInteger B, byte[] K) throws Exception { - MessageDigest digest = MessageDigest.getInstance("SHA-512"); - return digest.digest(concat(A.toByteArray(), B.toByteArray(), K)); - } - - private byte[] computeM2(BigInteger A, byte[] M1, byte[] K) throws Exception { - MessageDigest digest = MessageDigest.getInstance("SHA-512"); - return digest.digest(concat(A.toByteArray(), M1, K)); - } - - private byte[] concat(byte[]... arrays) { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - for (byte[] arr : arrays) { - out.write(arr, 0, arr.length); - } - return out.toByteArray(); - } - - private byte[] generateNonce() { - byte[] nonce = new byte[12]; - new SecureRandom().nextBytes(nonce); - return nonce; - } -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java deleted file mode 100644 index c833a375da3f8..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SessionKeys.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.network; - -import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * Derives session keys for encrypting and decrypting messages between a HomeKit controller and accessory. - * Uses HKDF with HMAC-SHA512 as the underlying hash function. - * The derived keys are used for ChaCha20 encryption in the secure session. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class SessionKeys { - - private static final String HMAC_ALGO = "HmacSHA512"; - - public final byte[] writeKey; // Controller → Accessory - public final byte[] readKey; // Accessory → Controller - - public SessionKeys(byte[] sharedSecret) throws GeneralSecurityException { - byte[] salt = "Control-Salt".getBytes(StandardCharsets.UTF_8); - this.writeKey = hkdf(sharedSecret, salt, "Control-Write-Encryption-Key".getBytes(StandardCharsets.UTF_8), 32); - this.readKey = hkdf(sharedSecret, salt, "Control-Read-Encryption-Key".getBytes(StandardCharsets.UTF_8), 32); - } - - private byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int length) throws GeneralSecurityException { - try { - Mac mac = Mac.getInstance(HMAC_ALGO); - mac.init(new SecretKeySpec(salt, HMAC_ALGO)); - byte[] prk = mac.doFinal(ikm); - - mac.init(new SecretKeySpec(prk, HMAC_ALGO)); - mac.update(info); - mac.update((byte) 0x01); - byte[] okm = mac.doFinal(); - - byte[] result = new byte[length]; - System.arraycopy(okm, 0, result, 0, length); - return result; - } catch (Exception e) { - throw new GeneralSecurityException("HKDF derivation failed", e); - } - } -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitTypeProvider.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java similarity index 95% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitTypeProvider.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java index a4543e60d402d..ad6559a66cebc 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/provider/HomekitTypeProvider.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.provider; +package org.openhab.binding.homekit.internal.persistance; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.storage.StorageService; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/CharacteristicReadWriteService.java similarity index 88% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/CharacteristicReadWriteService.java index bec374b658baa..1dcaca43ec330 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/CharacteristicsManager.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/CharacteristicReadWriteService.java @@ -10,13 +10,15 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.network; +package org.openhab.binding.homekit.internal.services; import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.nio.charset.StandardCharsets; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.session.SecureSession; +import org.openhab.binding.homekit.internal.transport.HttpTransport; /** * HTTP client methods for reading and writing HomeKit accessory characteristics over a secure session. @@ -25,7 +27,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class CharacteristicsManager { +public class CharacteristicReadWriteService { private static final String JSON_TEMPLATE = "{\"%s\":[{\"aid\":%s,\"iid\":%s,\"value\":%s}]}"; @@ -33,7 +35,7 @@ public class CharacteristicsManager { private final HttpTransport httpTransport; private final String baseUrl; - public CharacteristicsManager(HttpTransport httpTransport, SecureSession session, String baseUrl) { + public CharacteristicReadWriteService(HttpTransport httpTransport, SecureSession session, String baseUrl) { this.httpTransport = httpTransport; this.session = session; this.baseUrl = baseUrl; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java new file mode 100644 index 0000000000000..661dc3432860c --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.services; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; +import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.PairingMethod; +import org.openhab.binding.homekit.internal.enums.PairingState; +import org.openhab.binding.homekit.internal.enums.TlvType; +import org.openhab.binding.homekit.internal.session.SessionKeys; +import org.openhab.binding.homekit.internal.transport.HttpTransport; + +/** + * Service to remove an existing pairing with a HomeKit accessory. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class PairingRemoveService { + + private final HttpTransport http; + private final String baseUrl; + private final SessionKeys sessionKeys; + private final String controllerIdentifier; + + public PairingRemoveService(HttpTransport http, String baseUrl, SessionKeys sessionKeys, + String controllerIdentifier) { + this.http = http; + this.baseUrl = baseUrl; + this.sessionKeys = sessionKeys; + this.controllerIdentifier = controllerIdentifier; + } + + public void remove() throws Exception { + // M1 Construct TLV payload for RemovePairing + Map tlv1 = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M1.value }, // + TlvType.METHOD.key, new byte[] { PairingMethod.REMOVE.value }, // + TlvType.IDENTIFIER.key, controllerIdentifier.getBytes(StandardCharsets.UTF_8)); + Validator.validate(PairingMethod.REMOVE, tlv1); + byte[] encoded = Tlv8Codec.encode(tlv1); + + // Encrypt payload using write key + byte[] encrypted = CryptoUtils.encrypt(sessionKeys.getWriteKey(), "PV-Msg05", encoded); + + // Send to /pairings endpoint + byte[] response = http.post(baseUrl, "/pairings", "application/pairing+tlv8", encrypted); + + // M2 Decrypt response using read key + byte[] decrypted = CryptoUtils.decrypt(sessionKeys.getReadKey(), "PV-Msg06", response); + Map tlv2 = Tlv8Codec.decode(decrypted); + Validator.validate(PairingMethod.REMOVE, tlv2); + } + + /** + * Helper that validates the TLV map for the specification required pairing state. + */ + protected static class Validator { + + private static final Map> SPECIFICATION_REQUIRED_KEYS = Map.of( // + PairingState.M1, Set.of(TlvType.STATE.key, TlvType.METHOD.key, TlvType.IDENTIFIER.key), // + PairingState.M2, Set.of(TlvType.STATE.key)); + + /** + * Validates the TLV map for the specification required pairing state. + * + * @throws IllegalArgumentException if required keys are missing or state is invalid + */ + public static void validate(PairingMethod method, Map tlv) throws IllegalArgumentException { + if (tlv.containsKey(TlvType.ERROR.key)) { + throw new IllegalArgumentException( + "Pairing method '%s' action failed with unknown error".formatted(method.name())); + } + + byte[] stateBytes = tlv.get(TlvType.STATE.key); + if (stateBytes == null || stateBytes.length != 1) { + throw new IllegalArgumentException("Missing or invalid 'STATE' TLV (0x06)"); + } + + PairingState state = PairingState.from(stateBytes[0]); + Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); + + if (expectedKeys == null) { + throw new IllegalArgumentException( + "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); + } + + for (Integer key : expectedKeys) { + if (!tlv.containsKey(key)) { + throw new IllegalArgumentException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." + .formatted(method.name(), state.name(), key)); + } + } + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java new file mode 100644 index 0000000000000..5df562f0344b3 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.services; + +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.crypto.SrpClient; +import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.PairingMethod; +import org.openhab.binding.homekit.internal.enums.PairingState; +import org.openhab.binding.homekit.internal.enums.TlvType; +import org.openhab.binding.homekit.internal.session.SessionKeys; +import org.openhab.binding.homekit.internal.transport.HttpTransport; + +/** + * Handles the 6-step pairing process with a HomeKit accessory. + * Uses SRP for secure key exchange and derives session keys. + * Communicates with the accessory using HTTP and TLV8 encoding. + * Requires the accessory's setup code for pairing. + * Returns session keys upon successful pairing. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class PairingSetupService { + + private static final String ENDPOINT_PAIR_SETUP = "/pair-setup"; + private static final String CONTENT_TYPE_TLV8 = "application/pairing+tlv8"; + + private final HttpTransport httpTransport; + private final SrpClient srpClient; + + public PairingSetupService(HttpTransport httpTransport, String accessoryPairingCode) { + this.httpTransport = httpTransport; + this.srpClient = new SrpClient(accessoryPairingCode); + } + + public SessionKeys pair(String baseUrl) throws Exception { + // M1 — Start Pair-Setup + Map tlv1 = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M1.value }, // + TlvType.METHOD.key, new byte[] { PairingMethod.SETUP.value }); + Validator.validate(PairingMethod.SETUP, tlv1); + byte[] resp1 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv1)); + + // M2 — Receive salt & accessory SRP public key + Map tlv2 = Tlv8Codec.decode(resp1); + Validator.validate(PairingMethod.SETUP, tlv2); + srpClient.processChallenge(tlv2.get(TlvType.SALT.key), tlv2.get(TlvType.PUBLIC_KEY.key)); + + // M3 — Send client SRP public key & proof + Map tlv3 = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M3.value }, // + TlvType.PUBLIC_KEY.key, srpClient.getPublicKey(), // + TlvType.PROOF.key, srpClient.getClientProof()); + Validator.validate(PairingMethod.SETUP, tlv3); + byte[] resp3 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv3)); + + // M4 — Verify accessory SRP proof + Map tlv4 = Tlv8Codec.decode(resp3); + Validator.validate(PairingMethod.SETUP, tlv4); + srpClient.verifyServerProof(tlv4.get(TlvType.PROOF.key)); + + // M5 — Exchange encrypted identifiers + Map tlv5 = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M5.value }, // + TlvType.ENCRYPTED_DATA.key, srpClient.getEncryptedIdentifiers()); + Validator.validate(PairingMethod.SETUP, tlv5); + byte[] resp5 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv5)); + + // M6 — Final confirmation & accessory credentials + Map tlv6 = Tlv8Codec.decode(resp5); + Validator.validate(PairingMethod.SETUP, tlv6); + srpClient.verifyAccessoryIdentifiers(tlv6.get(TlvType.ENCRYPTED_DATA.key)); + + // Derive and return session keys + return srpClient.deriveSessionKeys(); + } + + /** + * Helper that validates the TLV map for the specification required pairing state. + */ + protected static class Validator { + + private static final Map> SPECIFICATION_REQUIRED_KEYS = Map.of( // + PairingState.M1, Set.of(TlvType.STATE.key, TlvType.METHOD.key), // + PairingState.M2, Set.of(TlvType.STATE.key, TlvType.SALT.key, TlvType.PUBLIC_KEY.key), // + PairingState.M3, Set.of(TlvType.STATE.key, TlvType.PUBLIC_KEY.key, TlvType.PROOF.key), // + PairingState.M4, Set.of(TlvType.STATE.key, TlvType.PROOF.key), // + PairingState.M5, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key), // + PairingState.M6, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key)); + + /** + * Validates the TLV map for the specification required pairing state. + * + * @throws IllegalArgumentException if required keys are missing or state is invalid + */ + public static void validate(PairingMethod method, Map tlv) throws IllegalArgumentException { + if (tlv.containsKey(TlvType.ERROR.key)) { + throw new IllegalArgumentException( + "Pairing method '%s' action failed with unknown error".formatted(method.name())); + } + + byte[] stateBytes = tlv.get(TlvType.STATE.key); + if (stateBytes == null || stateBytes.length != 1) { + throw new IllegalArgumentException("Missing or invalid 'STATE' TLV (0x06)"); + } + + PairingState state = PairingState.from(stateBytes[0]); + Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); + + if (expectedKeys == null) { + throw new IllegalArgumentException( + "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); + } + + for (Integer key : expectedKeys) { + if (!tlv.containsKey(key)) { + throw new IllegalArgumentException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." + .formatted(method.name(), state.name(), key)); + } + } + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java new file mode 100644 index 0000000000000..dd2e913901ce4 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java @@ -0,0 +1,157 @@ +package org.openhab.binding.homekit.internal.services; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; + +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.X25519PublicKeyParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; +import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.PairingMethod; +import org.openhab.binding.homekit.internal.enums.PairingState; +import org.openhab.binding.homekit.internal.enums.TlvType; +import org.openhab.binding.homekit.internal.session.SessionKeys; +import org.openhab.binding.homekit.internal.transport.HttpTransport; + +/** + * Handles the 3-step pair-verify process with a HomeKit accessory. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class PairingVerifyService { + + private static final String PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info"; + private static final String PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt"; + private static final String PV_MSG02 = "PV-Msg02"; + private static final String PV_MSG03 = "PV-Msg03"; + private static final String CONTENT_TYPE_TLV = "application/pairing+tlv8"; + private static final String ENDPOINT_PAIR_VERIFY = "/pair-verify"; + private static final String CONTROL_WRITE_ENCRYPTION_KEY = "Control-Write-Encryption-Key"; + private static final String CONTROL_READ_ENCRYPTION_KEY = "Control-Read-Encryption-Key"; + private static final String CONTROL_SALT = "Control-Salt"; + + private final HttpTransport http; + private final String baseUrl; + private final String accessoryIdentifier; + private final Ed25519PrivateKeyParameters controllerPrivateKey; + private final AsymmetricCipherKeyPair controllerEphemeralKeyPair; + + public PairingVerifyService(HttpTransport http, String baseUrl, String accessoryIdentifier, + Ed25519PrivateKeyParameters controllerPrivateKey) { + this.http = http; + this.baseUrl = baseUrl; + this.accessoryIdentifier = accessoryIdentifier; + this.controllerPrivateKey = controllerPrivateKey; + this.controllerEphemeralKeyPair = CryptoUtils.generateCurve25519KeyPair(); + } + + public SessionKeys verify() throws Exception { + // M1 — Send controller ephemeral public key + byte[] controllerPublicKey = ((X25519PublicKeyParameters) controllerEphemeralKeyPair.getPublic()).getEncoded(); + + Map tlv1 = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M1.value }, // + // TLVType.METHOD.key, new byte[] { PairingMethod.VERIFY.value }, // not required in Apple spec + TlvType.PUBLIC_KEY.key, controllerPublicKey); + Validator.validate(PairingMethod.VERIFY, tlv1); + + byte[] resp1 = http.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv1)); + + // M2 — Receive accessory ephemeral public key and encrypted TLV + Map tlv2 = Tlv8Codec.decode(resp1); + Validator.validate(PairingMethod.VERIFY, tlv2); + + byte[] accessoryPublicKeyBytes = tlv2.getOrDefault(TlvType.PUBLIC_KEY.key, new byte[0]); + byte[] encrypted = tlv2.getOrDefault(TlvType.ENCRYPTED_DATA.key, new byte[0]); + + X25519PublicKeyParameters accessoryEphemeralKey = new X25519PublicKeyParameters(accessoryPublicKeyBytes, 0); + byte[] sharedSecret = CryptoUtils.computeSharedSecret(controllerEphemeralKeyPair.getPrivate(), + accessoryEphemeralKey); + + byte[] sessionKey = CryptoUtils.hkdf(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); + byte[] decrypted = CryptoUtils.decrypt(sessionKey, PV_MSG02, encrypted); + Map innerTLV = Tlv8Codec.decode(decrypted); + CryptoUtils.validateAccessory(innerTLV); // validates identifier + signature + + // M3 — Send encrypted controller identifier and signature + byte[] verifyPayload = concat(controllerPublicKey, accessoryPublicKeyBytes); + byte[] signature = CryptoUtils.signVerifyMessage(controllerPrivateKey, verifyPayload); + + byte[] controllerInfo = Tlv8Codec.encode(Map.of( // + TlvType.IDENTIFIER.key, accessoryIdentifier.getBytes(StandardCharsets.UTF_8), // + TlvType.SIGNATURE.key, signature)); + byte[] encryptedM3 = CryptoUtils.encrypt(sessionKey, PV_MSG03, controllerInfo); + + Map tlv3 = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M3.value }, // + TlvType.ENCRYPTED_DATA.key, encryptedM3); + Validator.validate(PairingMethod.VERIFY, tlv3); + + byte[] resp3 = http.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv3)); + + // M4 — Final confirmation + Map tlv4 = Tlv8Codec.decode(resp3); + Validator.validate(PairingMethod.VERIFY, tlv4); + + // Derive directional session keys + byte[] readKey = CryptoUtils.hkdf(sharedSecret, CONTROL_SALT, CONTROL_READ_ENCRYPTION_KEY); + byte[] writeKey = CryptoUtils.hkdf(sharedSecret, CONTROL_SALT, CONTROL_WRITE_ENCRYPTION_KEY); + + return new SessionKeys(readKey, writeKey); + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } + + /** + * Helper that validates the TLV map for the specification required pairing state. + */ + protected static class Validator { + + private static final Map> SPECIFICATION_REQUIRED_KEYS = Map.of( // + PairingState.M1, Set.of(TlvType.STATE.key, TlvType.PUBLIC_KEY.key), // TLVType.METHOD not required + PairingState.M2, Set.of(TlvType.STATE.key, TlvType.PUBLIC_KEY.key, TlvType.ENCRYPTED_DATA.key), // + PairingState.M3, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key), // + PairingState.M4, Set.of(TlvType.STATE.key)); + + /** + * Validates the TLV map for the specification required pairing state. + * + * @throws IllegalArgumentException if required keys are missing or state is invalid + */ + public static void validate(PairingMethod method, Map tlv) throws IllegalArgumentException { + if (tlv.containsKey(TlvType.ERROR.key)) { + throw new IllegalArgumentException( + "Pairing method '%s' action failed with unknown error".formatted(method.name())); + } + + byte[] stateBytes = tlv.get(TlvType.STATE.key); + if (stateBytes == null || stateBytes.length != 1) { + throw new IllegalArgumentException("Missing or invalid 'STATE' TLV (0x06)"); + } + + PairingState state = PairingState.from(stateBytes[0]); + Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); + + if (expectedKeys == null) { + throw new IllegalArgumentException( + "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); + } + + for (Integer key : expectedKeys) { + if (!tlv.containsKey(key)) { + throw new IllegalArgumentException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." + .formatted(method.name(), state.name(), key)); + } + } + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java similarity index 80% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index a09cade3cbb93..aaf1b01fdde79 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -10,12 +10,12 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.network; +package org.openhab.binding.homekit.internal.session; -import java.security.GeneralSecurityException; import java.util.concurrent.atomic.AtomicInteger; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; /** * Manages a secure session using ChaCha20 encryption for a HomeKit accessory. @@ -33,8 +33,8 @@ public class SecureSession { private final AtomicInteger readCounter = new AtomicInteger(0); public SecureSession(SessionKeys keys) { - this.writeKey = keys.writeKey; - this.readKey = keys.readKey; + this.writeKey = keys.getWriteKey(); + this.readKey = keys.getReadKey(); } /** @@ -42,11 +42,11 @@ public SecureSession(SessionKeys keys) { * * @param plaintext The plaintext to encrypt. * @return The encrypted ciphertext. - * @throws GeneralSecurityException + * @throws Exception */ - public byte[] encrypt(byte[] plaintext) throws GeneralSecurityException { + public byte[] encrypt(byte[] plaintext) throws Exception { byte[] nonce = generateNonce(writeCounter.getAndIncrement()); - return ChaCha20.encrypt(writeKey, nonce, plaintext); + return CryptoUtils.encrypt(writeKey, nonce, plaintext); } /** @@ -54,11 +54,11 @@ public byte[] encrypt(byte[] plaintext) throws GeneralSecurityException { * * @param ciphertext The ciphertext to decrypt. * @return The decrypted plaintext. - * @throws GeneralSecurityException + * @throws Exception */ - public byte[] decrypt(byte[] ciphertext) throws GeneralSecurityException { + public byte[] decrypt(byte[] ciphertext) throws Exception { byte[] nonce = generateNonce(readCounter.getAndIncrement()); - return ChaCha20.decrypt(readKey, nonce, ciphertext); + return CryptoUtils.decrypt(readKey, nonce, ciphertext); } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SessionKeys.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SessionKeys.java new file mode 100644 index 0000000000000..a2f2a2c2b9eb4 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SessionKeys.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.session; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Holds the read and write session keys for a secure HomeKit session. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class SessionKeys { + private final byte[] readKey; + private final byte[] writeKey; + + public SessionKeys(byte[] readKey, byte[] writeKey) { + this.readKey = readKey; + this.writeKey = writeKey; + } + + public byte[] getReadKey() { + return readKey; + } + + public byte[] getWriteKey() { + return writeKey; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java similarity index 98% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java index cdaff6d464eb7..9df204526465c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/network/HttpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.network; +package org.openhab.binding.homekit.internal.transport; import java.io.IOException; import java.util.concurrent.ExecutionException; From 6151a883b00b243eed38579e7d0a1e8f6649c020 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 14 Sep 2025 19:17:08 +0100 Subject: [PATCH 012/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/crypto/SrpClient.java | 15 ++++++++------- .../services/PairingSetupService.java | 19 ++++++++++++++++--- .../services/PairingVerifyService.java | 12 ++++++++++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java index 6e013716032c0..4bf2ca37114b7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java @@ -79,16 +79,16 @@ public class SrpClient { private @Nullable BigInteger k = null; // SRP multiplier private @Nullable BigInteger u = null; // scrambling parameter private @Nullable BigInteger S = null; // shared secret - private byte[] K = new byte[0]; // session key (H(S)) - private byte[] M1 = new byte[0]; // client proof - private byte[] salt = new byte[0]; // server salt + private byte @Nullable [] K = null; // session key (H(S)) + private byte @Nullable [] M1 = null; // client proof + private byte @Nullable [] salt = null; // server salt // Curve25519 key‐pair for identifier exchange private final AsymmetricCipherKeyPair x25519KeyPair; // Accessory credentials after M6 private @Nullable String accessoryIdentifier; - private byte[] accessoryPublicKey = new byte[0]; + private byte @Nullable [] accessoryPublicKey = null; public SrpClient(String accessoryPairingCode) { this.accessoryPairingCode = accessoryPairingCode; @@ -135,7 +135,7 @@ public byte[] getPublicKey() { * M3 — Client proof M1 = H( H(N)^H(g) || H(username) || salt || A || B || K ). */ public byte[] getClientProof() throws Exception { - if (M1.length == 0) { + if (M1 != null) { MessageDigest sha512 = MessageDigest.getInstance("SHA-512"); // u = H(A || B) @@ -176,7 +176,8 @@ public byte[] getClientProof() throws Exception { sha512.update(K); this.M1 = sha512.digest(); } - return M1; + byte @Nullable [] M1 = this.M1; + return M1 != null ? M1 : new byte[0]; } /** @@ -263,7 +264,7 @@ public static byte[] concat(byte[] a, byte[] b) { return accessoryIdentifier; } - public byte[] getAccessoryPublicKey() { + public byte @Nullable [] getAccessoryPublicKey() { return accessoryPublicKey; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java index 5df562f0344b3..ea814bde09119 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java @@ -58,7 +58,12 @@ public SessionKeys pair(String baseUrl) throws Exception { // M2 — Receive salt & accessory SRP public key Map tlv2 = Tlv8Codec.decode(resp1); Validator.validate(PairingMethod.SETUP, tlv2); - srpClient.processChallenge(tlv2.get(TlvType.SALT.key), tlv2.get(TlvType.PUBLIC_KEY.key)); + byte[] salt = tlv2.get(TlvType.SALT.key); + byte[] key = tlv2.get(TlvType.PUBLIC_KEY.key); + if (salt == null || key == null) { + throw new IllegalArgumentException("Missing salt public key TLV in M2 response"); + } + srpClient.processChallenge(salt, key); // M3 — Send client SRP public key & proof Map tlv3 = Map.of( // @@ -71,7 +76,11 @@ public SessionKeys pair(String baseUrl) throws Exception { // M4 — Verify accessory SRP proof Map tlv4 = Tlv8Codec.decode(resp3); Validator.validate(PairingMethod.SETUP, tlv4); - srpClient.verifyServerProof(tlv4.get(TlvType.PROOF.key)); + byte[] proof = tlv4.get(TlvType.PROOF.key); + if (proof == null) { + throw new IllegalArgumentException("Missing proof TLV in M4 response"); + } + srpClient.verifyServerProof(proof); // M5 — Exchange encrypted identifiers Map tlv5 = Map.of( // @@ -83,7 +92,11 @@ public SessionKeys pair(String baseUrl) throws Exception { // M6 — Final confirmation & accessory credentials Map tlv6 = Tlv8Codec.decode(resp5); Validator.validate(PairingMethod.SETUP, tlv6); - srpClient.verifyAccessoryIdentifiers(tlv6.get(TlvType.ENCRYPTED_DATA.key)); + byte[] data = tlv6.get(TlvType.ENCRYPTED_DATA.key); + if (data == null) { + throw new IllegalArgumentException("Missing data TLV in M6 response"); + } + srpClient.verifyAccessoryIdentifiers(data); // Derive and return session keys return srpClient.deriveSessionKeys(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java index dd2e913901ce4..aa9ba276eee18 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java @@ -1,3 +1,15 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ package org.openhab.binding.homekit.internal.services; import java.nio.charset.StandardCharsets; From 81706c8cb6e1d22da6a5e8dcef4490fa9b67b66d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 14 Sep 2025 23:15:08 +0100 Subject: [PATCH 013/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/crypto/SrpClient.java | 2 +- .../homekit/internal/crypto/Tlv8Codec.java | 2 +- .../services/PairingRemoveService.java | 12 ++++++------ .../internal/services/PairingSetupService.java | 18 +++++++++--------- .../services/PairingVerifyService.java | 12 ++++++------ 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java index 4bf2ca37114b7..fe5cd4b4244e3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java @@ -135,7 +135,7 @@ public byte[] getPublicKey() { * M3 — Client proof M1 = H( H(N)^H(g) || H(username) || salt || A || B || K ). */ public byte[] getClientProof() throws Exception { - if (M1 != null) { + if (M1 == null) { MessageDigest sha512 = MessageDigest.getInstance("SHA-512"); // u = H(A || B) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/Tlv8Codec.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/Tlv8Codec.java index de8aa0771efa5..1e7d0ee55ce41 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/Tlv8Codec.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/Tlv8Codec.java @@ -64,7 +64,7 @@ public static Map decode(byte[] data) { int length = data[index++] & 0xFF; if (index + length > data.length) { - throw new IllegalArgumentException("Invalid TLV8 length"); + throw new SecurityException("Invalid TLV8 length"); } byte[] chunk = Arrays.copyOfRange(data, index, index + length); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java index 661dc3432860c..92dccef9622ed 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java @@ -79,30 +79,30 @@ protected static class Validator { /** * Validates the TLV map for the specification required pairing state. * - * @throws IllegalArgumentException if required keys are missing or state is invalid + * @throws SecurityException if required keys are missing or state is invalid */ - public static void validate(PairingMethod method, Map tlv) throws IllegalArgumentException { + public static void validate(PairingMethod method, Map tlv) throws SecurityException { if (tlv.containsKey(TlvType.ERROR.key)) { - throw new IllegalArgumentException( + throw new SecurityException( "Pairing method '%s' action failed with unknown error".formatted(method.name())); } byte[] stateBytes = tlv.get(TlvType.STATE.key); if (stateBytes == null || stateBytes.length != 1) { - throw new IllegalArgumentException("Missing or invalid 'STATE' TLV (0x06)"); + throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); } PairingState state = PairingState.from(stateBytes[0]); Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); if (expectedKeys == null) { - throw new IllegalArgumentException( + throw new SecurityException( "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); } for (Integer key : expectedKeys) { if (!tlv.containsKey(key)) { - throw new IllegalArgumentException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." + throw new SecurityException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." .formatted(method.name(), state.name(), key)); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java index ea814bde09119..663d66b317e47 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java @@ -61,7 +61,7 @@ public SessionKeys pair(String baseUrl) throws Exception { byte[] salt = tlv2.get(TlvType.SALT.key); byte[] key = tlv2.get(TlvType.PUBLIC_KEY.key); if (salt == null || key == null) { - throw new IllegalArgumentException("Missing salt public key TLV in M2 response"); + throw new SecurityException("Missing salt public key TLV in M2 response"); } srpClient.processChallenge(salt, key); @@ -78,7 +78,7 @@ public SessionKeys pair(String baseUrl) throws Exception { Validator.validate(PairingMethod.SETUP, tlv4); byte[] proof = tlv4.get(TlvType.PROOF.key); if (proof == null) { - throw new IllegalArgumentException("Missing proof TLV in M4 response"); + throw new SecurityException("Missing proof TLV in M4 response"); } srpClient.verifyServerProof(proof); @@ -94,7 +94,7 @@ public SessionKeys pair(String baseUrl) throws Exception { Validator.validate(PairingMethod.SETUP, tlv6); byte[] data = tlv6.get(TlvType.ENCRYPTED_DATA.key); if (data == null) { - throw new IllegalArgumentException("Missing data TLV in M6 response"); + throw new SecurityException("Missing data TLV in M6 response"); } srpClient.verifyAccessoryIdentifiers(data); @@ -118,30 +118,30 @@ protected static class Validator { /** * Validates the TLV map for the specification required pairing state. * - * @throws IllegalArgumentException if required keys are missing or state is invalid + * @throws SecurityException if required keys are missing or state is invalid */ - public static void validate(PairingMethod method, Map tlv) throws IllegalArgumentException { + public static void validate(PairingMethod method, Map tlv) throws SecurityException { if (tlv.containsKey(TlvType.ERROR.key)) { - throw new IllegalArgumentException( + throw new SecurityException( "Pairing method '%s' action failed with unknown error".formatted(method.name())); } byte[] stateBytes = tlv.get(TlvType.STATE.key); if (stateBytes == null || stateBytes.length != 1) { - throw new IllegalArgumentException("Missing or invalid 'STATE' TLV (0x06)"); + throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); } PairingState state = PairingState.from(stateBytes[0]); Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); if (expectedKeys == null) { - throw new IllegalArgumentException( + throw new SecurityException( "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); } for (Integer key : expectedKeys) { if (!tlv.containsKey(key)) { - throw new IllegalArgumentException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." + throw new SecurityException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." .formatted(method.name(), state.name(), key)); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java index aa9ba276eee18..293b761178b5b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java @@ -137,30 +137,30 @@ protected static class Validator { /** * Validates the TLV map for the specification required pairing state. * - * @throws IllegalArgumentException if required keys are missing or state is invalid + * @throws SecurityException if required keys are missing or state is invalid */ - public static void validate(PairingMethod method, Map tlv) throws IllegalArgumentException { + public static void validate(PairingMethod method, Map tlv) throws SecurityException { if (tlv.containsKey(TlvType.ERROR.key)) { - throw new IllegalArgumentException( + throw new SecurityException( "Pairing method '%s' action failed with unknown error".formatted(method.name())); } byte[] stateBytes = tlv.get(TlvType.STATE.key); if (stateBytes == null || stateBytes.length != 1) { - throw new IllegalArgumentException("Missing or invalid 'STATE' TLV (0x06)"); + throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); } PairingState state = PairingState.from(stateBytes[0]); Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); if (expectedKeys == null) { - throw new IllegalArgumentException( + throw new SecurityException( "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); } for (Integer key : expectedKeys) { if (!tlv.containsKey(key)) { - throw new IllegalArgumentException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." + throw new SecurityException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." .formatted(method.name(), state.name(), key)); } } From 0477c23857fdd41ae634be469ae4e3853a62844a Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 15 Sep 2025 19:45:57 +0100 Subject: [PATCH 014/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 1 + .../homekit/internal/crypto/CryptoUtils.java | 40 +++- .../homekit/internal/crypto/SrpClient.java | 45 +++-- .../HomekitChildDiscoveryService.java | 12 +- .../homekit/internal/dto/Accessory.java | 38 ++-- .../homekit/internal/dto/Characteristic.java | 94 ++++----- .../binding/homekit/internal/dto/Service.java | 27 ++- .../homekit/internal/enums/AccessoryType.java | 10 +- .../internal/enums/CharacteristicType.java | 30 +-- .../homekit/internal/enums/ServiceType.java | 30 +-- .../HomekitHandlerFactory.java | 4 +- .../handler/HomekitBaseServerHandler.java | 183 ++++++++++++++---- .../handler/HomekitDeviceHandler.java | 112 ++++++++--- .../CharacteristicReadWriteService.java | 2 +- .../PairingRemoveService.java | 13 +- .../PairingSetupService.java | 20 +- .../PairingVerifyService.java | 52 ++--- .../internal/session/SecureSession.java | 20 +- 18 files changed, 460 insertions(+), 273 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{handler => factory}/HomekitHandlerFactory.java (95%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{services => hap_services}/CharacteristicReadWriteService.java (98%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{services => hap_services}/PairingRemoveService.java (89%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{services => hap_services}/PairingSetupService.java (88%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{services => hap_services}/PairingVerifyService.java (76%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index b7fc6619c4d68..73a57a51a036b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -47,6 +47,7 @@ public class HomekitBindingConstants { public static final String PROPERTY_UID = "uid"; public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_DEVICE_CATEGORY = "deviceCategory"; + public static final String PROPERTY_CONTROLLER_PRIVATE_KEY = "controllerPrivateKey"; // HomeKit HTTP URI endpoints and content types public static final String ENDPOINT_PAIRING = "pair-setup"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index 776c6d30e0365..ab44d0c8ec340 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -70,11 +70,6 @@ public static byte[] hkdf(byte[] ikm, String salt, String info) { return output; } - // Encrypt with ChaCha20-Poly1305 - public static byte[] encrypt(byte[] key, String nonceStr, byte[] plaintext) throws InvalidCipherTextException { - return encrypt(key, nonceStr.getBytes(StandardCharsets.UTF_8), plaintext); - } - // Encrypt with ChaCha20-Poly1305 public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) throws InvalidCipherTextException { ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); @@ -87,11 +82,6 @@ public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) throws return out; } - // Decrypt with ChaCha20-Poly1305 - public static byte[] decrypt(byte[] key, String nonceStr, byte[] ciphertext) throws InvalidCipherTextException { - return decrypt(key, nonceStr.getBytes(StandardCharsets.UTF_8), ciphertext); - } - // Decrypt with ChaCha20-Poly1305 public static byte[] decrypt(byte[] key, byte[] nonce, byte[] ciphertext) throws InvalidCipherTextException { ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); @@ -132,4 +122,34 @@ public static void validateAccessory(Map tlv) { throw new SecurityException("Accessory signature verification failed"); } } + + /** + * Generates a 12-byte nonce using the given counter. + * The first 4 bytes are zero, and the last 8 bytes are the counter in big-endian format. + * + * @param counter The counter value. + * @return The generated nonce. + */ + public static byte[] generateNonce(int counter) { + byte[] nonce = new byte[12]; + nonce[4] = (byte) ((counter >> 24) & 0xFF); + nonce[5] = (byte) ((counter >> 16) & 0xFF); + nonce[6] = (byte) ((counter >> 8) & 0xFF); + nonce[7] = (byte) (counter & 0xFF); + return nonce; + } + + /** + * Generates a 12-byte nonce using the given label. + * The first 4 bytes are zero, and the last 8 bytes come from the label. + * + * @param counter The counter value. + * @return The generated nonce. + */ + public static byte[] generateNonce(String label) { + byte[] nonce = new byte[12]; + byte[] labelBytes = label.getBytes(StandardCharsets.UTF_8); + System.arraycopy(labelBytes, 0, nonce, 4, Math.min(labelBytes.length, 8)); + return nonce; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java index fe5cd4b4244e3..2104467793d18 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java @@ -18,15 +18,13 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.Map; -import org.bouncycastle.crypto.AsymmetricCipherKeyPair; import org.bouncycastle.crypto.digests.SHA512Digest; import org.bouncycastle.crypto.generators.HKDFBytesGenerator; -import org.bouncycastle.crypto.generators.X25519KeyPairGenerator; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.HKDFParameters; -import org.bouncycastle.crypto.params.X25519KeyGenerationParameters; -import org.bouncycastle.crypto.params.X25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.TlvType; @@ -65,11 +63,12 @@ public class SrpClient { private static final byte[] PAIR_USER = "Pair-Setup".getBytes(StandardCharsets.UTF_8); private static final byte[] PAIR_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); private static final byte[] PAIR_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); - private static final byte[] NONCE_M5 = "PS-Msg05".getBytes(StandardCharsets.UTF_8); - private static final byte[] NONCE_M6 = "PS-Msg06".getBytes(StandardCharsets.UTF_8); + private static final byte[] NONCE_M5 = CryptoUtils.generateNonce("PS-Msg05"); + private static final byte[] NONCE_M6 = CryptoUtils.generateNonce("PS-Msg06"); private final String accessoryPairingCode; private final SecureRandom random = new SecureRandom(); + private final byte[] controllerIdentifier; // SRP internals private @Nullable BigInteger a = null; // client private exponent @@ -84,19 +83,15 @@ public class SrpClient { private byte @Nullable [] salt = null; // server salt // Curve25519 key‐pair for identifier exchange - private final AsymmetricCipherKeyPair x25519KeyPair; + // private final AsymmetricCipherKeyPair x25519KeyPair; // Accessory credentials after M6 private @Nullable String accessoryIdentifier; private byte @Nullable [] accessoryPublicKey = null; - public SrpClient(String accessoryPairingCode) { + public SrpClient(String accessoryPairingCode, String controllerIdentifier) { this.accessoryPairingCode = accessoryPairingCode; - - // Generate Curve25519 key‐pair once - X25519KeyPairGenerator gen = new X25519KeyPairGenerator(); - gen.init(new X25519KeyGenerationParameters(random)); - this.x25519KeyPair = gen.generateKeyPair(); + this.controllerIdentifier = controllerIdentifier.getBytes(StandardCharsets.UTF_8); } /** @@ -198,12 +193,24 @@ public void verifyServerProof(byte[] serverProof) throws Exception { /** * M5 — Encrypt controller identifier + Curve25519 public key. */ - public byte[] getEncryptedIdentifiers() throws Exception { - Map tlv = Map.of( // - TlvType.IDENTIFIER.key, PAIR_USER, // - TlvType.PUBLIC_KEY.key, ((X25519PublicKeyParameters) x25519KeyPair.getPublic()).getEncoded()); - byte[] plain = Tlv8Codec.encode(tlv); - return CryptoUtils.encrypt(deriveSessionKeys().getWriteKey(), plain, NONCE_M5); + public byte[] getEncryptedIdentifiers(Ed25519PrivateKeyParameters controllerPrivateKey) throws Exception { + // Step 1: Build TLV with controller identifier and public key + Map subTlv = new LinkedHashMap<>(); + subTlv.put(TlvType.IDENTIFIER.key, controllerIdentifier); + subTlv.put(TlvType.PUBLIC_KEY.key, controllerPrivateKey.generatePublicKey().getEncoded()); + + // Step 2: Sign the TLV with controller's private key + byte[] message = Tlv8Codec.encode(subTlv); + byte[] signature = CryptoUtils.signVerifyMessage(controllerPrivateKey, message); + subTlv.put(TlvType.SIGNATURE.key, signature); + + // Step 3: Encrypt the signed TLV using SRP-derived session key + byte[] plaintext = Tlv8Codec.encode(subTlv); + SessionKeys sessionKeys = deriveSessionKeys(); + byte[] encryptionKey = sessionKeys.getWriteKey(); + + byte[] encrypted = CryptoUtils.encrypt(encryptionKey, NONCE_M5, plaintext); + return encrypted; } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 7461b65e5c930..47ca4b500e227 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -48,13 +48,17 @@ protected void startScan() { public void devicesDiscovered(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { - if (accessory.accessoryId != null && accessory.services != null) { - ThingUID uid = new ThingUID(THING_TYPE_DEVICE, bridge.getUID(), - CHILD_FMT.formatted(accessory.accessoryId)); // accessory ID is unique per bridge + if (accessory.aid != null && accessory.services != null) { + ThingUID uid = new ThingUID(THING_TYPE_DEVICE, bridge.getUID(), CHILD_FMT.formatted(accessory.aid)); // accessory + // ID + // is + // unique + // per + // bridge thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // - .withLabel(CHILD_LABEL_FMT.formatted(accessory.accessoryId, bridge.getLabel())) // + .withLabel(CHILD_LABEL_FMT.formatted(accessory.aid, bridge.getLabel())) // .withProperty(PROPERTY_UID, uid.toString()) // .withRepresentationProperty(PROPERTY_UID).build()); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 6a01c0dfb737f..6326b5ad3e5f9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -23,8 +23,6 @@ import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment; import org.openhab.core.thing.type.ChannelGroupDefinition; -import com.google.gson.annotations.SerializedName; - /** * HomeKit accessory DTO * Used to deserialize individual accessories from the /accessories endpoint of a HomeKit bridge. @@ -34,16 +32,26 @@ */ @NonNullByDefault public class Accessory { - public @NonNullByDefault({}) @SerializedName("aid") Integer accessoryId; // e.g. 1 + public @NonNullByDefault({}) Integer aid; // e.g. 1 public @NonNullByDefault({}) List services; - @Override - public String toString() { - return getAccessoryType().toString(); + /** + * Builds and registers channel group definitions for all services of this accessory. + * Each child service registers a ChannelGroupType and returns a ChannelGroupDefinition thereof. + * Each grandchild category registers a ChannelType and returns a ChannelDefinition thereof. + * Child services that do not map to a channel group definition are ignored. + * Grandchild categories that do not map to a channel definition are ignored. + * + * @param typeProvider the HomeKit type provider used to look up channel group definitions. + * @return a list of channel group definition instances for the services of this accessory. + */ + public List buildAndRegisterChannelGroupDefinitions(HomekitTypeProvider typeProvider) { + return services.stream().map(s -> s.buildAndRegisterChannelGroupDefinition(typeProvider)) + .filter(Objects::nonNull).toList(); } public AccessoryType getAccessoryType() { - Integer aid = this.accessoryId; + Integer aid = this.aid; if (aid == null) { return AccessoryType.OTHER; } @@ -122,18 +130,8 @@ public AccessoryType getAccessoryType() { return null; } - /** - * Builds and registers channel group definitions for all services of this accessory. - * Each child service registers a ChannelGroupType and returns a ChannelGroupDefinition thereof. - * Each grandchild category registers a ChannelType and returns a ChannelDefinition thereof. - * Child services that do not map to a channel group definition are ignored. - * Grandchild categories that do not map to a channel definition are ignored. - * - * @param typeProvider the HomeKit type provider used to look up channel group definitions. - * @return a list of channel group definition instances for the services of this accessory. - */ - public List buildAndRegisterChannelGroupDefinitions(HomekitTypeProvider typeProvider) { - return services.stream().map(s -> s.buildAndRegisterChannelGroupDefinition(typeProvider)) - .filter(Objects::nonNull).toList(); + @Override + public String toString() { + return getAccessoryType().toString(); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 190ec45b78ef5..8ae4ad5c0a86d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -14,12 +14,11 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; -import java.math.BigDecimal; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; -import javax.measure.Unit; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.CharacteristicType; @@ -35,11 +34,6 @@ import org.openhab.core.thing.type.ChannelTypeBuilder; import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.thing.type.StateChannelTypeBuilder; -import org.openhab.core.types.StateDescriptionFragment; -import org.openhab.core.types.StateDescriptionFragmentBuilder; -import org.openhab.core.types.util.UnitUtils; - -import com.google.gson.annotations.SerializedName; /** * HomeKit characteristic DTO. @@ -51,17 +45,17 @@ */ @NonNullByDefault public class Characteristic { - public @NonNullByDefault({}) @SerializedName("type") String characteristicId; // 25 = public.hap.characteristic.on - public @NonNullByDefault({}) @SerializedName("format") String dataFormat; // e.g. "bool" - public @NonNullByDefault({}) @SerializedName("perms") List permissions; // e.g. ["pr", "pw", "ev"] - public @NonNullByDefault({}) @SerializedName("iid") Integer instanceId; // e.g. 10 - public @NonNullByDefault({}) @SerializedName("unit") String unit; // e.g. "celsius" - public @NonNullByDefault({}) @SerializedName("maxValue") Double maxValue; // e.g. 100 - public @NonNullByDefault({}) @SerializedName("minValue") Double minValue; // e.g. 0 - public @NonNullByDefault({}) @SerializedName("minStep") Double minStep; - public @NonNullByDefault({}) @SerializedName("value") String dataValue; // e.g. true - public @NonNullByDefault({}) @SerializedName("description") String description; - public @NonNullByDefault({}) @SerializedName("ev") Boolean eventNotification; // e.g. true + public @NonNullByDefault({}) String type; // 25 = public.hap.characteristic.on + public @NonNullByDefault({}) String format; // e.g. "bool" + public @NonNullByDefault({}) List perms; // e.g. ["pr", "pw", "ev"] + public @NonNullByDefault({}) Integer iid; // e.g. 10 + public @NonNullByDefault({}) String unit; // e.g. "celsius" + public @NonNullByDefault({}) Double maxValue; // e.g. 100 + public @NonNullByDefault({}) Double minValue; // e.g. 0 + public @NonNullByDefault({}) Double minStep; + public @NonNullByDefault({}) String value; // e.g. true + public @NonNullByDefault({}) String description; + public @NonNullByDefault({}) Boolean ev; // e.g. true /** * Builds a ChannelType and a ChannelDefinition based on the characteristic properties. @@ -75,22 +69,20 @@ public class Characteristic { * @return the ChannelDefinition or null if it cannot be mapped */ public @Nullable ChannelDefinition buildAndRegisterChannelDefinition(HomekitTypeProvider typeProvider) { - CharacteristicType characteristicType; - try { - characteristicType = CharacteristicType.from(Integer.parseInt(characteristicId)); - } catch (IllegalArgumentException e) { + CharacteristicType characteristicType = getCharacteristicType(); + if (characteristicType == null) { return null; } DataFormatType dataFormatType; try { - dataFormatType = DataFormatType.from(dataFormat); + dataFormatType = DataFormatType.from(format); } catch (IllegalArgumentException e) { return null; } // determine channel type and attributes based on characteristic properties - boolean isReadOnly = !permissions.contains("pw"); + boolean isReadOnly = !perms.contains("pw"); boolean isString = DataFormatType.STRING == dataFormatType; boolean isBoolean = DataFormatType.BOOL == dataFormatType; boolean isNumber = !isString && !isBoolean; @@ -547,37 +539,37 @@ public class Characteristic { channelType = ChannelTypeBuilder.trigger(uid, CHANNEL_TYPE_LABEL).build(); } - // persist the channel _type_, and return the definition of a specific _instance_ of that type + // persist the channel _type_ typeProvider.putChannelType(channelType); - return new ChannelDefinitionBuilder(Integer.toString(instanceId), uid).withLabel(characteristicType.toString()) - .build(); + + /* + * expose the non ephemeral fields, that are not exposed via normal channel definition attributes, + * through properties instead e.g. minValue, maxValue, minStep, format, unit, perms, ev + */ + Map properties = new HashMap<>(); + Optional.ofNullable(minValue).map(v -> v.toString()).ifPresent(s -> properties.put("minValue", s)); + Optional.ofNullable(maxValue).map(v -> v.toString()).ifPresent(s -> properties.put("maxValue", s)); + Optional.ofNullable(minStep).map(v -> v.toString()).ifPresent(s -> properties.put("minStep", s)); + Optional.ofNullable(format).ifPresent(s -> properties.put("format", s)); + Optional.ofNullable(unit).ifPresent(s -> properties.put("unit", s)); + Optional.ofNullable(perms).map(l -> String.join(",", l)).ifPresent(s -> properties.put("perms", s)); + Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> properties.put("ev", s)); + + // return the definition of a specific _instance_ of the channel _type_ + return new ChannelDefinitionBuilder(Integer.toString(iid), uid).withProperties(properties) + .withLabel(characteristicType.toString()).build(); } - /** - * Build StateDescriptionFragment if any relevant properties are present - * - * @return StateDescriptionFragment or null if not applicable - */ - public @Nullable StateDescriptionFragment getStateDescriptionFragment() { - StateDescriptionFragment stateDescr = null; - if (minValue != null || maxValue != null || minStep != null || unit != null) { - Unit unit = null; - String temp = this.unit; - if (temp != null) { - unit = UnitUtils.parseUnit(temp); - } - StateDescriptionFragmentBuilder builder = StateDescriptionFragmentBuilder.create(); - builder.withReadOnly(!permissions.contains("pw")); - Optional.ofNullable(minValue).map(v -> BigDecimal.valueOf(v)).ifPresent(builder::withMinimum); - Optional.ofNullable(maxValue).map(v -> BigDecimal.valueOf(v)).ifPresent(builder::withMaximum); - Optional.ofNullable(minStep).map(s -> BigDecimal.valueOf(s)).ifPresent(builder::withStep); - Optional.ofNullable(unit).map(u -> "%.0f " + u.getSymbol()).ifPresent(builder::withPattern); - stateDescr = builder.build(); + public @Nullable CharacteristicType getCharacteristicType() { + try { + return CharacteristicType.from(Integer.parseInt(type)); + } catch (IllegalArgumentException e) { + return null; } - return stateDescr; } - public @Nullable String getDescription() { - return description; + @Override + public String toString() { + return getCharacteristicType() instanceof CharacteristicType ct ? ct.getType() : "Unknown"; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index a53113d761347..2707e9a79dae3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -27,8 +27,6 @@ import org.openhab.core.thing.type.ChannelGroupTypeBuilder; import org.openhab.core.thing.type.ChannelGroupTypeUID; -import com.google.gson.annotations.SerializedName; - /** * HomeKit service DTO. * Used to deserialize individual services from the /accessories endpoint of a HomeKit bridge. @@ -38,8 +36,8 @@ */ @NonNullByDefault public class Service { - public @NonNullByDefault({}) @SerializedName("type") String serviceId; // e.g. '96' => 'public.hap.service.battery' - public @NonNullByDefault({}) @SerializedName("iid") Integer instanceId; // e.g. 10 + public @NonNullByDefault({}) String type; // e.g. '96' => 'public.hap.service.battery' + public @NonNullByDefault({}) Integer iid; // e.g. 10 public @NonNullByDefault({}) List characteristics; /** @@ -52,10 +50,8 @@ public class Service { * @return the created ChannelGroupDefinition or null if creation failed */ public @Nullable ChannelGroupDefinition buildAndRegisterChannelGroupDefinition(HomekitTypeProvider typeProvider) { - ServiceType serviceType = ServiceType.from(Integer.parseInt(serviceId)); - try { - serviceType = ServiceType.from(Integer.parseInt(serviceId)); - } catch (IllegalArgumentException e) { + ServiceType serviceType = getServiceType(); + if (serviceType == null) { return null; } @@ -74,6 +70,19 @@ public class Service { // persist the group _type_, and return the definition of a specific _instance_ of that type typeProvider.putChannelGroupType(groupType); - return new ChannelGroupDefinition(Integer.toString(instanceId), groupTypeUID, serviceType.toString(), null); + return new ChannelGroupDefinition(Integer.toString(iid), groupTypeUID, serviceType.toString(), null); + } + + public @Nullable ServiceType getServiceType() { + try { + return ServiceType.from(Integer.parseInt(type)); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Override + public String toString() { + return getServiceType() instanceof ServiceType st ? st.getType() : "Unknown"; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java index 14214aa8e4aac..f1341c51d771c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java @@ -63,11 +63,6 @@ public enum AccessoryType { this.label = label; } - @Override - public String toString() { - return label; - } - public static AccessoryType from(int id) throws IllegalArgumentException { for (AccessoryType value : values()) { if (value.id == id) { @@ -76,4 +71,9 @@ public static AccessoryType from(int id) throws IllegalArgumentException { } return OTHER; } + + @Override + public String toString() { + return label; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index d15c312ebbfe4..5486e8614946c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -163,6 +163,23 @@ public enum CharacteristicType { this.type = type; } + public static CharacteristicType from(int id) throws IllegalArgumentException { + for (CharacteristicType value : values()) { + if (value.id == id) { + return value; + } + } + throw new IllegalArgumentException("Unknown ID: " + id); + } + + public String getGroupTypeId() { + return type.replace("-", "_").replace(".", "-"); // convert to OH channel-group-type format + } + + public String getType() { + return type; + } + /** * Returns the name of the enum constant in `First Letter Capitals`. */ @@ -175,17 +192,4 @@ public String toString() { } return builder.toString(); } - - public String getGroupTypeId() { - return type.replace("-", "_").replace(".", "-"); // convert to OH channel-group-type format - } - - public static CharacteristicType from(int id) throws IllegalArgumentException { - for (CharacteristicType value : values()) { - if (value.id == id) { - return value; - } - } - throw new IllegalArgumentException("Unknown ID: " + id); - } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index 74fe91e52e65c..2a19e4682126d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -76,6 +76,23 @@ public enum ServiceType { this.type = type; } + public static ServiceType from(int id) throws IllegalArgumentException { + for (ServiceType value : values()) { + if (value.id == id) { + return value; + } + } + throw new IllegalArgumentException("Unknown ID: " + id); + } + + public String getChannelTypeId() { + return type.replace("-", "_").replace(".", "-"); // convert to OH channel type format + } + + public String getType() { + return type; + } + /** * Returns the name of the enum constant in `First Letter Capitals`. */ @@ -88,17 +105,4 @@ public String toString() { } return builder.toString(); } - - public String getChannelTypeId() { - return type.replace("-", "_").replace(".", "-"); // covert to OH channel type format - } - - public static ServiceType from(int id) throws IllegalArgumentException { - for (ServiceType value : values()) { - if (value.id == id) { - return value; - } - } - throw new IllegalArgumentException("Unknown ID: " + id); - } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java similarity index 95% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index 9ea2eec50d850..22dc945805728 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.handler; +package org.openhab.binding.homekit.internal.factory; import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; @@ -20,6 +20,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; +import org.openhab.binding.homekit.internal.handler.HomekitBridgeHandler; +import org.openhab.binding.homekit.internal.handler.HomekitDeviceHandler; import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.io.net.http.HttpClientFactory; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 8ecd173813aae..468585fcf6a99 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -15,6 +15,8 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -22,11 +24,14 @@ import java.util.function.Function; import java.util.stream.Collectors; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; -import org.openhab.binding.homekit.internal.services.CharacteristicReadWriteService; -import org.openhab.binding.homekit.internal.services.PairingSetupService; +import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; +import org.openhab.binding.homekit.internal.hap_services.PairingSetupService; +import org.openhab.binding.homekit.internal.hap_services.PairingVerifyService; import org.openhab.binding.homekit.internal.session.SecureSession; import org.openhab.binding.homekit.internal.session.SessionKeys; import org.openhab.binding.homekit.internal.transport.HttpTransport; @@ -55,35 +60,89 @@ @NonNullByDefault public class HomekitBaseServerHandler extends BaseThingHandler { + protected static final Gson GSON = new Gson(); + private final Logger logger = LoggerFactory.getLogger(HomekitBaseServerHandler.class); - protected static final Gson GSON = new Gson(); protected final HttpTransport httpTransport; protected final Map accessories = new HashMap<>(); protected boolean isChildAccessory = false; - protected @NonNullByDefault({}) CharacteristicReadWriteService charactersticsManager; - protected @NonNullByDefault({}) SessionKeys keys; + protected @NonNullByDefault({}) CharacteristicReadWriteService rwService; protected @NonNullByDefault({}) SecureSession session; protected @NonNullByDefault({}) String baseUrl; protected @NonNullByDefault({}) String pairingCode; + protected @NonNullByDefault({}) Integer accessoryId; + protected @Nullable Ed25519PrivateKeyParameters controllerPrivateKey = null; public HomekitBaseServerHandler(Thing thing, HttpClientFactory httpClientFactory) { super(thing); this.httpTransport = new HttpTransport(httpClientFactory.getCommonHttpClient()); } + /** + * Get information about embedded accessories and their respective channels. + * Uses the /accessories endpoint. + * Returns an empty list if there was a problem. + * Requires a valid secure session. + * + * @return list of accessories (may be empty) + * @see HomeKit HTTP + */ + protected void getAccessories() { + SecureSession session = this.session; + if (session != null) { + try { + byte[] encrypted = httpTransport.get(baseUrl, ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); + byte[] decrypted = session.decrypt(encrypted); + Accessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), Accessories.class); + if (result != null && result.accessories instanceof List accessoryList) { + accessories.clear(); + accessories.putAll(accessoryList.stream().filter(a -> Objects.nonNull(a.aid)) + .collect(Collectors.toMap(a -> a.aid, Function.identity()))); + } + } catch (Exception e) { + } + } + } + + /** + * Extracts the accessory ID from the thing's UID property. + * The UID is expected to end with "-". + * + * @return the accessory ID, or null if it cannot be determined + */ + protected @Nullable Integer getAccessoryId() { + String uidProperty = thing.getProperties().get(PROPERTY_UID); + if (uidProperty == null) { + return null; + } + int accessoryIdIndex = uidProperty.lastIndexOf("-"); + if (accessoryIdIndex < 0) { + return null; + } + try { + return Integer.parseInt(uidProperty.substring(accessoryIdIndex + 1)); + } catch (NumberFormatException e) { + return null; + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // override in subclass + } + @Override public void initialize() { Bridge bridge = getBridge(); if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { - // accessory is hosted by a bridge, so use the bridge's pairing and session + // accessory is hosted by a bridge, so use bridge's pairing session and read/write service this.isChildAccessory = true; - this.keys = bridgeHandler.keys; this.session = bridgeHandler.session; - this.charactersticsManager = bridgeHandler.charactersticsManager; - if (this.charactersticsManager != null) { + this.rwService = bridgeHandler.rwService; + if (this.rwService != null) { updateStatus(ThingStatus.ONLINE); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not connected"); @@ -92,48 +151,90 @@ public void initialize() { // standalone accessory or brige accessory, so do pairing and session setup here this.isChildAccessory = false; this.baseUrl = "http://" + getConfig().get(CONFIG_IP_V4_ADDRESS).toString(); - this.pairingCode = getConfig().get(CONFIG_PAIRING_CODE).toString(); + scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread + } + } + + /** + * Restores an existing pairing or creates a new one if necessary. + * Updates the thing status accordingly. + */ + private void initializePairing() { + pairingCode = getConfig().get(CONFIG_PAIRING_CODE).toString(); + accessoryId = getAccessoryId(); + if (accessoryId == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid accessory ID"); + return; + } + + restoreControllerPrivateKey(); + Ed25519PrivateKeyParameters controllerPrivateKey = this.controllerPrivateKey; + + if (controllerPrivateKey != null) { + // Perform Pair-Verify with existing key try { - this.keys = new PairingSetupService(httpTransport, pairingCode).pair(baseUrl); - this.session = new SecureSession(keys); - this.charactersticsManager = new CharacteristicReadWriteService(httpTransport, session, baseUrl); - scheduler.submit(() -> getAccessories()); + SessionKeys sessionKeys = new PairingVerifyService(httpTransport, baseUrl, accessoryId.toString(), + controllerPrivateKey).verify(); + + this.session = new SecureSession(sessionKeys); + this.rwService = new CharacteristicReadWriteService(httpTransport, session, baseUrl); + + logger.debug("Restored pairing was verified for accessory {}", accessoryId); updateStatus(ThingStatus.ONLINE); + + return; } catch (Exception e) { - logger.error("Failed to initialize HomeKit client", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + logger.debug("Restored pairing was not verified for accessory {}", accessoryId); + this.controllerPrivateKey = null; + storeControllerPrivateKey(); + // fall through to create new pairing } } + + // Create new controller private key + controllerPrivateKey = new Ed25519PrivateKeyParameters(new SecureRandom()); + logger.debug("Created new controller private key for accessory {}", accessoryId); + + try { + // Perform Pair-Setup + SessionKeys sessionKeys = new PairingSetupService(httpTransport, baseUrl, pairingCode, controllerPrivateKey, + thing.getUID().toString()).pair(); + + // Perform Pair-Verify immediately after Pair-Setup + sessionKeys = new PairingVerifyService(httpTransport, baseUrl, accessoryId.toString(), controllerPrivateKey) + .verify(); + + this.session = new SecureSession(sessionKeys); + this.rwService = new CharacteristicReadWriteService(httpTransport, session, baseUrl); + this.controllerPrivateKey = controllerPrivateKey; + storeControllerPrivateKey(); + + updateStatus(ThingStatus.ONLINE); + logger.debug("Pairing and verification completed for accessory {}", accessoryId); + } catch (Exception e) { + logger.warn("Pairing and verification failed for accessory {}", accessoryId); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing failed"); + } } - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - // override in subclass + /** + * Restores the controller's private key from the thing's properties. + * The private key is expected to have been stored as a Base64-encoded string. + */ + private void restoreControllerPrivateKey() { + String encoded = thing.getProperties().get(PROPERTY_CONTROLLER_PRIVATE_KEY); + controllerPrivateKey = encoded == null ? null + : new Ed25519PrivateKeyParameters(Base64.getDecoder().decode(encoded), 0); } /** - * Get information about embedded accessories and their respective channels. - * Uses the /accessories endpoint. - * Returns an empty list if there was a problem. - * Requires a valid secure session. - * - * @return list of accessories (may be empty) - * @see HomeKit HTTP + * Stores the controller's private key in the thing's properties. + * The private key is stored as a Base64-encoded string. */ - protected void getAccessories() { - SecureSession session = this.session; - if (session != null) { - try { - byte[] encrypted = httpTransport.get(baseUrl, ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); - byte[] decrypted = session.decrypt(encrypted); - Accessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), Accessories.class); - if (result != null && result.accessories instanceof List accessoryList) { - accessories.clear(); - accessories.putAll(accessoryList.stream().filter(a -> Objects.nonNull(a.accessoryId)) - .collect(Collectors.toMap(a -> a.accessoryId, Function.identity()))); - } - } catch (Exception e) { - } - } + private void storeControllerPrivateKey() { + Ed25519PrivateKeyParameters controllerPrivateKey = this.controllerPrivateKey; + String property = controllerPrivateKey == null ? null + : Base64.getEncoder().encodeToString(controllerPrivateKey.getEncoded()); + thing.setProperty(PROPERTY_CONTROLLER_PRIVATE_KEY, property); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index c04e6e797c067..0730a2e85209e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -12,18 +12,26 @@ */ package org.openhab.binding.homekit.internal.handler; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.CONFIG_POLLING_INTERVAL; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; +import javax.measure.Unit; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.dto.Accessory; +import org.openhab.binding.homekit.internal.enums.DataFormatType; +import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; -import org.openhab.binding.homekit.internal.services.CharacteristicReadWriteService; import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.library.types.QuantityType; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -32,6 +40,7 @@ import org.openhab.core.thing.type.ChannelGroupType; import org.openhab.core.thing.type.ChannelType; import org.openhab.core.types.Command; +import org.openhab.core.types.util.UnitUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,24 +77,72 @@ public void initialize() { } @Override - public void handleCommand(ChannelUID channelUID, Command command) { - CharacteristicReadWriteService charactersticsManager = this.charactersticsManager; - if (charactersticsManager != null) { - String channelId = channelUID.getId(); - try { - switch (channelId) { - case "power": - // boolean value = command.equals(OnOffType.ON); - // accessoryClient.writeCharacteristic("1", "10", value); // Example AID/IID - break; - // TODO Add more channels here - default: - logger.warn("Unhandled channel: {}", channelId); + public void handleCommand(ChannelUID channelUID, Command commandArg) { + Channel channel = thing.getChannel(channelUID); + if (channel == null) { + logger.warn("Received command for unknown channel: {}", channelUID); + return; + } + CharacteristicReadWriteService writer = this.rwService; + if (writer == null) { + logger.warn("No writer service available to handle command for channel: {}", channelUID); + return; + } + + Object command = commandArg; + Map properties = channel.getProperties(); + + // convert QuantityTypes to the characteristic's unit + if (command instanceof QuantityType quantity) { + Unit unit = UnitUtils.parseUnit(Optional.ofNullable(properties.get("unit")).orElse(null)); + if (unit != null && !unit.equals(quantity.getUnit()) && quantity.getUnit().isCompatible(unit)) { + command = quantity.toUnit(unit); + } + } + + if (command instanceof Number number) { + // clamp numbers to characteristic's min/max limits + Double min = Optional.ofNullable(properties.get("minValue")).map(s -> Double.valueOf(s)).orElse(null); + if (min != null && number.doubleValue() < min.doubleValue()) { + command = min; + } + Double max = Optional.ofNullable(properties.get("maxValue")).map(s -> Double.valueOf(s)).orElse(null); + if (max != null && number.doubleValue() > max.doubleValue()) { + command = max; + } + + // comply with characteristic's data format + String format = properties.get("format"); + if (format != null) { + try { + command = switch (DataFormatType.valueOf(format)) { + case UINT8, UINT16, UINT32, UINT64, INT -> Integer.valueOf(number.intValue()); + case FLOAT -> Float.valueOf(number.floatValue()); + case STRING -> String.valueOf(number); + case BOOL -> Boolean.valueOf(number.intValue() != 0); + default -> command; + }; + } catch (IllegalArgumentException e) { + logger.warn("Unexpected format for channel {}: {}", channelUID, properties.get("format")); } - } catch (Exception e) { - logger.error("Failed to send command to accessory", e); } } + + // convert on/off to boolean + if (command instanceof OnOffType onOff) { + command = Boolean.valueOf(onOff == OnOffType.ON); + } + + // convert open/closed to boolean + if (command instanceof OpenClosedType openClosed) { + command = Boolean.valueOf(openClosed == OpenClosedType.OPEN); + } + + try { + writer.writeCharacteristic(thing.getUID().getId(), channelUID.getId(), Objects.requireNonNull(command)); + } catch (Exception e) { + logger.warn("Failed to send command '{}' as '{}' to accessory", commandArg, command, e); + } } /** @@ -93,7 +150,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { * This method is called periodically by a scheduled executor. */ private void poll() { - CharacteristicReadWriteService charactersticsManager = this.charactersticsManager; + CharacteristicReadWriteService charactersticsManager = this.rwService; if (charactersticsManager != null) { try { // String power = accessoryClient.readCharacteristic("1", "10"); // TODO example AID/IID @@ -128,18 +185,8 @@ private void createChannels() { if (accessories.isEmpty()) { return; } - String uidProperty = thing.getProperties().get(PROPERTY_UID); - if (uidProperty == null) { - return; - } - int accessoryIdIndex = uidProperty.lastIndexOf("-"); - if (accessoryIdIndex < 0) { - return; - } - Integer accessoryId; - try { - accessoryId = Integer.parseInt(uidProperty.substring(accessoryIdIndex + 1)); - } catch (NumberFormatException e) { + Integer accessoryId = getAccessoryId(); + if (accessoryId == null) { return; } Accessory accessory = accessories.get(accessoryId); @@ -156,7 +203,8 @@ private void createChannels() { ChannelType channelType = typeProvider.getChannelType(channelDef.getChannelTypeUID(), null); if (channelType != null) { ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), channelDef.getId()); - ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()); + ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()) + .withProperties(channelDef.getProperties()); Optional.ofNullable(channelDef.getLabel()).ifPresent(builder::withLabel); Optional.ofNullable(channelDef.getDescription()).ifPresent(builder::withDescription); channels.add(builder.build()); @@ -165,7 +213,7 @@ private void createChannels() { } }); - // update thing with new channels + // update thing with the new channels ThingBuilder builder = editThing().withChannels(channels); Optional.ofNullable(accessory.getSemanticEquipmentTag()).ifPresent(builder::withSemanticEquipmentTag); updateThing(builder.build()); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/CharacteristicReadWriteService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java similarity index 98% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/CharacteristicReadWriteService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java index 1dcaca43ec330..d1e325510ffd6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/CharacteristicReadWriteService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.services; +package org.openhab.binding.homekit.internal.hap_services; import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java similarity index 89% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java index 92dccef9622ed..f119764d45e3b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingRemoveService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.services; +package org.openhab.binding.homekit.internal.hap_services; import java.nio.charset.StandardCharsets; import java.util.Map; @@ -33,6 +33,11 @@ @NonNullByDefault public class PairingRemoveService { + private static final String CONTENT_TYPE = "application/pairing+tlv8"; + private static final String ENDPOINT = "/pairings"; + private static final byte[] NONCE_M5 = CryptoUtils.generateNonce("PV-Msg05"); + private static final byte[] NONCE_M6 = CryptoUtils.generateNonce("PV-Msg06"); + private final HttpTransport http; private final String baseUrl; private final SessionKeys sessionKeys; @@ -56,13 +61,13 @@ public void remove() throws Exception { byte[] encoded = Tlv8Codec.encode(tlv1); // Encrypt payload using write key - byte[] encrypted = CryptoUtils.encrypt(sessionKeys.getWriteKey(), "PV-Msg05", encoded); + byte[] encrypted = CryptoUtils.encrypt(sessionKeys.getWriteKey(), NONCE_M5, encoded); // Send to /pairings endpoint - byte[] response = http.post(baseUrl, "/pairings", "application/pairing+tlv8", encrypted); + byte[] response = http.post(baseUrl, ENDPOINT, CONTENT_TYPE, encrypted); // M2 Decrypt response using read key - byte[] decrypted = CryptoUtils.decrypt(sessionKeys.getReadKey(), "PV-Msg06", response); + byte[] decrypted = CryptoUtils.decrypt(sessionKeys.getReadKey(), NONCE_M6, response); Map tlv2 = Tlv8Codec.decode(decrypted); Validator.validate(PairingMethod.REMOVE, tlv2); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingSetupService.java similarity index 88% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingSetupService.java index 663d66b317e47..102e8f13e9ab6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingSetupService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingSetupService.java @@ -10,11 +10,12 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.services; +package org.openhab.binding.homekit.internal.hap_services; import java.util.Map; import java.util.Set; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.crypto.SrpClient; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; @@ -41,13 +42,18 @@ public class PairingSetupService { private final HttpTransport httpTransport; private final SrpClient srpClient; + private final String baseUrl; + private final Ed25519PrivateKeyParameters controllerPrivateKey; - public PairingSetupService(HttpTransport httpTransport, String accessoryPairingCode) { + public PairingSetupService(HttpTransport httpTransport, String baseUrl, String accessoryPairingCode, + Ed25519PrivateKeyParameters controllerPrivateKey, String controllerUniqueId) { this.httpTransport = httpTransport; - this.srpClient = new SrpClient(accessoryPairingCode); + this.baseUrl = baseUrl; + this.srpClient = new SrpClient(accessoryPairingCode, controllerUniqueId); + this.controllerPrivateKey = controllerPrivateKey; } - public SessionKeys pair(String baseUrl) throws Exception { + public SessionKeys pair() throws Exception { // M1 — Start Pair-Setup Map tlv1 = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // @@ -83,9 +89,9 @@ public SessionKeys pair(String baseUrl) throws Exception { srpClient.verifyServerProof(proof); // M5 — Exchange encrypted identifiers - Map tlv5 = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M5.value }, // - TlvType.ENCRYPTED_DATA.key, srpClient.getEncryptedIdentifiers()); + byte[] encryptedIdentifiers = srpClient.getEncryptedIdentifiers(controllerPrivateKey); + Map tlv5 = Map.of(TlvType.STATE.key, new byte[] { PairingState.M5.value }, + TlvType.ENCRYPTED_DATA.key, encryptedIdentifiers); Validator.validate(PairingMethod.SETUP, tlv5); byte[] resp5 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv5)); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java similarity index 76% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java index 293b761178b5b..7e83a9c8fc8e3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/services/PairingVerifyService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.services; +package org.openhab.binding.homekit.internal.hap_services; import java.nio.charset.StandardCharsets; import java.util.Map; @@ -38,40 +38,43 @@ public class PairingVerifyService { private static final String PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info"; private static final String PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt"; - private static final String PV_MSG02 = "PV-Msg02"; - private static final String PV_MSG03 = "PV-Msg03"; private static final String CONTENT_TYPE_TLV = "application/pairing+tlv8"; private static final String ENDPOINT_PAIR_VERIFY = "/pair-verify"; private static final String CONTROL_WRITE_ENCRYPTION_KEY = "Control-Write-Encryption-Key"; private static final String CONTROL_READ_ENCRYPTION_KEY = "Control-Read-Encryption-Key"; private static final String CONTROL_SALT = "Control-Salt"; + private static final byte[] NONCE_M2 = CryptoUtils.generateNonce("PV-Msg02"); + private static final byte[] NONCE_M3 = CryptoUtils.generateNonce("PV-Msg03"); - private final HttpTransport http; + private final HttpTransport httpTransport; private final String baseUrl; - private final String accessoryIdentifier; + private final byte[] accessoryIdentifier; private final Ed25519PrivateKeyParameters controllerPrivateKey; - private final AsymmetricCipherKeyPair controllerEphemeralKeyPair; - public PairingVerifyService(HttpTransport http, String baseUrl, String accessoryIdentifier, + public PairingVerifyService(HttpTransport httpTransport, String baseUrl, String accessoryIdentifier, Ed25519PrivateKeyParameters controllerPrivateKey) { - this.http = http; + this.httpTransport = httpTransport; this.baseUrl = baseUrl; - this.accessoryIdentifier = accessoryIdentifier; + this.accessoryIdentifier = accessoryIdentifier.getBytes(StandardCharsets.UTF_8); this.controllerPrivateKey = controllerPrivateKey; - this.controllerEphemeralKeyPair = CryptoUtils.generateCurve25519KeyPair(); } public SessionKeys verify() throws Exception { - // M1 — Send controller ephemeral public key - byte[] controllerPublicKey = ((X25519PublicKeyParameters) controllerEphemeralKeyPair.getPublic()).getEncoded(); - + // M1 — Create controller ephemeral public key and send it to accessory + AsymmetricCipherKeyPair controllerEphemeralKeys = CryptoUtils.generateCurve25519KeyPair(); + byte[] controllerEphemeralPublicKeyBytes; + if (controllerEphemeralKeys.getPublic() instanceof X25519PublicKeyParameters x25519) { + controllerEphemeralPublicKeyBytes = x25519.getEncoded(); + } else { + throw new IllegalStateException("Generated controller ephemeral public key is not X25519"); + } Map tlv1 = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // - // TLVType.METHOD.key, new byte[] { PairingMethod.VERIFY.value }, // not required in Apple spec - TlvType.PUBLIC_KEY.key, controllerPublicKey); + // TLVType.METHOD.key, new byte[] { PairingMethod.VERIFY.value }, // not required ?? + TlvType.PUBLIC_KEY.key, controllerEphemeralPublicKeyBytes); Validator.validate(PairingMethod.VERIFY, tlv1); - byte[] resp1 = http.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv1)); + byte[] resp1 = httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv1)); // M2 — Receive accessory ephemeral public key and encrypted TLV Map tlv2 = Tlv8Codec.decode(resp1); @@ -80,30 +83,29 @@ public SessionKeys verify() throws Exception { byte[] accessoryPublicKeyBytes = tlv2.getOrDefault(TlvType.PUBLIC_KEY.key, new byte[0]); byte[] encrypted = tlv2.getOrDefault(TlvType.ENCRYPTED_DATA.key, new byte[0]); - X25519PublicKeyParameters accessoryEphemeralKey = new X25519PublicKeyParameters(accessoryPublicKeyBytes, 0); - byte[] sharedSecret = CryptoUtils.computeSharedSecret(controllerEphemeralKeyPair.getPrivate(), - accessoryEphemeralKey); + X25519PublicKeyParameters accessoryEphemeralKeys = new X25519PublicKeyParameters(accessoryPublicKeyBytes, 0); + byte[] sharedSecret = CryptoUtils.computeSharedSecret(controllerEphemeralKeys.getPrivate(), + accessoryEphemeralKeys); byte[] sessionKey = CryptoUtils.hkdf(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); - byte[] decrypted = CryptoUtils.decrypt(sessionKey, PV_MSG02, encrypted); + byte[] decrypted = CryptoUtils.decrypt(sessionKey, NONCE_M2, encrypted); Map innerTLV = Tlv8Codec.decode(decrypted); CryptoUtils.validateAccessory(innerTLV); // validates identifier + signature // M3 — Send encrypted controller identifier and signature - byte[] verifyPayload = concat(controllerPublicKey, accessoryPublicKeyBytes); + byte[] verifyPayload = concat(controllerEphemeralPublicKeyBytes, accessoryPublicKeyBytes); byte[] signature = CryptoUtils.signVerifyMessage(controllerPrivateKey, verifyPayload); - byte[] controllerInfo = Tlv8Codec.encode(Map.of( // - TlvType.IDENTIFIER.key, accessoryIdentifier.getBytes(StandardCharsets.UTF_8), // + TlvType.IDENTIFIER.key, accessoryIdentifier, // TlvType.SIGNATURE.key, signature)); - byte[] encryptedM3 = CryptoUtils.encrypt(sessionKey, PV_MSG03, controllerInfo); + byte[] encryptedM3 = CryptoUtils.encrypt(sessionKey, NONCE_M3, controllerInfo); Map tlv3 = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M3.value }, // TlvType.ENCRYPTED_DATA.key, encryptedM3); Validator.validate(PairingMethod.VERIFY, tlv3); - byte[] resp3 = http.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv3)); + byte[] resp3 = httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv3)); // M4 — Final confirmation Map tlv4 = Tlv8Codec.decode(resp3); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index aaf1b01fdde79..84205f67d52b5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -45,7 +45,7 @@ public SecureSession(SessionKeys keys) { * @throws Exception */ public byte[] encrypt(byte[] plaintext) throws Exception { - byte[] nonce = generateNonce(writeCounter.getAndIncrement()); + byte[] nonce = CryptoUtils.generateNonce(writeCounter.getAndIncrement()); return CryptoUtils.encrypt(writeKey, nonce, plaintext); } @@ -57,23 +57,7 @@ public byte[] encrypt(byte[] plaintext) throws Exception { * @throws Exception */ public byte[] decrypt(byte[] ciphertext) throws Exception { - byte[] nonce = generateNonce(readCounter.getAndIncrement()); + byte[] nonce = CryptoUtils.generateNonce(readCounter.getAndIncrement()); return CryptoUtils.decrypt(readKey, nonce, ciphertext); } - - /** - * * Generates a 12-byte nonce using the given counter. - * The first 4 bytes are zero, and the last 8 bytes are the counter in big-endian format. - * - * @param counter The counter value. - * @return The generated nonce. - */ - private byte[] generateNonce(int counter) { - byte[] nonce = new byte[12]; - nonce[4] = (byte) ((counter >> 24) & 0xFF); - nonce[5] = (byte) ((counter >> 16) & 0xFF); - nonce[6] = (byte) ((counter >> 8) & 0xFF); - nonce[7] = (byte) (counter & 0xFF); - return nonce; - } } From 1358bc2ddb7d9e8d63b0457677d10234f3791a4f Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 16 Sep 2025 19:02:44 +0100 Subject: [PATCH 015/177] work in progress Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 73 ++---- .../HomekitChildDiscoveryService.java | 8 +- .../homekit/internal/dto/Characteristic.java | 4 +- .../handler/HomekitBaseServerHandler.java | 30 ++- .../handler/HomekitDeviceHandler.java | 240 +++++++++++++----- .../CharacteristicReadWriteService.java | 9 +- .../hap_services/PairingRemoveService.java | 8 +- .../resources/OH-INF/i18n/homekit.properties | 29 ++- .../resources/OH-INF/thing/thing-types.xml | 18 +- 9 files changed, 273 insertions(+), 146 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 53f6f0bf126f3..83283dbb8e1e0 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -1,44 +1,28 @@ # HomeKit Binding -_Give some details about what this binding is meant for - a protocol, system, specific device._ - -_If possible, provide some resources like pictures (only PNG is supported currently), a video, etc. to give an impression of what can be done with this binding._ -_You can place such resources into a `doc` folder next to this README.md._ - -_Put each sentence in a separate line to improve readability of diffs._ +This binding allows pairing with HomeKit accessory devices and importing their services as channel groups and their respective service- characteristics as channels. ## Supported Things -_Please describe the different supported things / devices including their ThingTypeUID within this section._ -_Which different types are supported, which models were tested etc.?_ -_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._ +There are two types of Things supported: + +- `device`: This integrates a single HomeKit accessory, whereby its services appear as channel groups the services respective service- characteristics appear as channels. +- `bridge`: This integrates a HomeKit bridge accessory containing multiple child `device` Things. +So Things of type `device` either represent a stand-alone accessories or a child of a `bridge` Thing. -- `bridge`: Short description of the Bridge, if any -- `sample`: Short description of the Thing with the ThingTypeUID `sample` +Things of type `bridge` and stand-alone `device` Things both communicate directly with their HomeKit device over the LAN. +Whereas child `device` Things communicate via their respective `bridge` Thing. ## Discovery -_Describe the available auto-discovery features here._ -_Mention for what it works and what needs to be kept in mind when using it._ +Both `bridge` and stand-alone `device` Things will be auto discovered via mDNS. +And once a `bridge` Thing has been instantiated, and paired, its child `device` Things will also be auto discovered ## Binding Configuration -_If your binding requires or supports general configuration settings, please create a folder ```cfg``` and place the configuration file ```.cfg``` inside it._ -_In this section, you should link to this file and provide some information about the options._ -_The file could e.g. look like:_ - -``` -# Configuration for the HomeKit Binding -# -# Default secret key for the pairing of the HomeKit Thing. -# It has to be between 10-40 (alphanumeric) characters. -# This may be changed by the user for security reasons. -secret=openHABSecret -``` - -_Note that it is planned to generate some part of this based on the information that is available within ```src/main/resources/OH-INF/binding``` of your binding._ - -_If your binding does not offer any generic configurations, you can remove this section completely._ +The `bridge` and stand-alone `device` Things need to be paired with their respective HomeKit accessories. +This requires entering the HomeKit pairing code as a configuration parameter in the binding. +Note that HomeKit accessories can only be paired with one controller, so if it it already paired with something else, you will need to remove that pairing first. ## Thing Configuration @@ -47,29 +31,22 @@ _This should be mainly about its mandatory and optional configuration parameters _Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._ -### `sample` Thing Configuration - -| Name | Type | Description | Default | Required | Advanced | -|-----------------|---------|---------------------------------------|---------|----------|----------| -| hostname | text | Hostname or IP address of the device | N/A | yes | no | -| password | text | Password to access the device | N/A | yes | no | -| refreshInterval | integer | Interval the device is polled in sec. | 600 | no | yes | +### `bridge` and stand-alone `device` Thing Configuration -## Channels +| Name | Type | Description | Default | Required | Advanced | +|-------------------|---------|---------------------------------------------------|---------|-----------|-----------| +| `ipV4Address` | text | IP v4 address of the HomeKit accessory. | N/A | see below | see below | +| `pairingCode` | text | Code used for pairing with the HomeKit accessory. | N/A | see below | see below | +| `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | -_Here you should provide information about available channel types, what their meaning is and how they can be used._ +Things of type `bridge` and stand-alone `device` Things require both an `ipv4Address` and a `pairingCode`. +The `ipv4Address` is set by the mDNS auto- discovery process. +However the `pairingCode` must be entered manually. +Child `device` Things do not require neither an `ipv4Address` nor a `pairingCode`. -_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._ - -| Channel | Type | Read/Write | Description | -|---------|--------|------------|-----------------------------| -| control | Switch | RW | This is the control channel | - -## Full Example +## Channels -_Provide a full usage example based on textual configuration files._ -_*.things, *.items examples are mandatory as textual configuration is well used by many users._ -_*.sitemap examples are optional._ +Channels will be auto- created depending on the services and respective service- characteristis of the HomeKit accessory. ### Thing Configuration diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 47ca4b500e227..e73f5806ce9e2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -49,12 +49,8 @@ protected void startScan() { public void devicesDiscovered(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { if (accessory.aid != null && accessory.services != null) { - ThingUID uid = new ThingUID(THING_TYPE_DEVICE, bridge.getUID(), CHILD_FMT.formatted(accessory.aid)); // accessory - // ID - // is - // unique - // per - // bridge + // accessory ID is unique per bridge + ThingUID uid = new ThingUID(THING_TYPE_DEVICE, bridge.getUID(), CHILD_FMT.formatted(accessory.aid)); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 8ae4ad5c0a86d..14f5be6e9a224 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -35,6 +35,8 @@ import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.thing.type.StateChannelTypeBuilder; +import com.google.gson.JsonElement; + /** * HomeKit characteristic DTO. * Used to deserialize individual characteristics from the /accessories endpoint of a HomeKit bridge. @@ -53,7 +55,7 @@ public class Characteristic { public @NonNullByDefault({}) Double maxValue; // e.g. 100 public @NonNullByDefault({}) Double minValue; // e.g. 0 public @NonNullByDefault({}) Double minStep; - public @NonNullByDefault({}) String value; // e.g. true + public @NonNullByDefault({}) JsonElement value; // e.g. true, 23, "Some String" public @NonNullByDefault({}) String description; public @NonNullByDefault({}) Boolean ev; // e.g. true diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 468585fcf6a99..3e9681841a388 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -30,6 +30,7 @@ import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; +import org.openhab.binding.homekit.internal.hap_services.PairingRemoveService; import org.openhab.binding.homekit.internal.hap_services.PairingSetupService; import org.openhab.binding.homekit.internal.hap_services.PairingVerifyService; import org.openhab.binding.homekit.internal.session.SecureSession; @@ -58,7 +59,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomekitBaseServerHandler extends BaseThingHandler { +public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected static final Gson GSON = new Gson(); @@ -74,6 +75,8 @@ public class HomekitBaseServerHandler extends BaseThingHandler { protected @NonNullByDefault({}) String baseUrl; protected @NonNullByDefault({}) String pairingCode; protected @NonNullByDefault({}) Integer accessoryId; + protected @NonNullByDefault({}) SessionKeys sessionKeys; + protected @Nullable Ed25519PrivateKeyParameters controllerPrivateKey = null; public HomekitBaseServerHandler(Thing thing, HttpClientFactory httpClientFactory) { @@ -131,7 +134,22 @@ protected void getAccessories() { @Override public void handleCommand(ChannelUID channelUID, Command command) { - // override in subclass + // this is an abstract thing with no channels, so do nothing + } + + @Override + public void handleRemoval() { + super.handleRemoval(); + if (!isChildAccessory) { + // unpair and clear stored keys if this is NOT a child accessory + try { + new PairingRemoveService(httpTransport, baseUrl, sessionKeys, thing.getUID().toString()).remove(); + this.controllerPrivateKey = null; + storeControllerPrivateKey(); + } catch (Exception e) { + logger.warn("Failed to remove pairing for accessory {}", accessoryId); + } + } } @Override @@ -173,7 +191,7 @@ private void initializePairing() { if (controllerPrivateKey != null) { // Perform Pair-Verify with existing key try { - SessionKeys sessionKeys = new PairingVerifyService(httpTransport, baseUrl, accessoryId.toString(), + this.sessionKeys = new PairingVerifyService(httpTransport, baseUrl, accessoryId.toString(), controllerPrivateKey).verify(); this.session = new SecureSession(sessionKeys); @@ -197,12 +215,12 @@ private void initializePairing() { try { // Perform Pair-Setup - SessionKeys sessionKeys = new PairingSetupService(httpTransport, baseUrl, pairingCode, controllerPrivateKey, + this.sessionKeys = new PairingSetupService(httpTransport, baseUrl, pairingCode, controllerPrivateKey, thing.getUID().toString()).pair(); // Perform Pair-Verify immediately after Pair-Setup - sessionKeys = new PairingVerifyService(httpTransport, baseUrl, accessoryId.toString(), controllerPrivateKey) - .verify(); + this.sessionKeys = new PairingVerifyService(httpTransport, baseUrl, accessoryId.toString(), + controllerPrivateKey).verify(); this.session = new SecureSession(sessionKeys); this.rwService = new CharacteristicReadWriteService(httpTransport, session, baseUrl); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 0730a2e85209e..a4589bb2ab51a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -17,7 +17,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -25,13 +24,21 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.dto.Accessory; +import org.openhab.binding.homekit.internal.dto.Characteristic; +import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.DataFormatType; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.library.types.PercentType; import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -39,11 +46,17 @@ import org.openhab.core.thing.binding.builder.ThingBuilder; import org.openhab.core.thing.type.ChannelGroupType; import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; import org.openhab.core.types.util.UnitUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; + /** * Handles a single HomeKit accessory. * It provides a polling mechanism to regularly update the state of the accessory. @@ -77,7 +90,7 @@ public void initialize() { } @Override - public void handleCommand(ChannelUID channelUID, Command commandArg) { + public void handleCommand(ChannelUID channelUID, Command command) { Channel channel = thing.getChannel(channelUID); if (channel == null) { logger.warn("Received command for unknown channel: {}", channelUID); @@ -88,60 +101,14 @@ public void handleCommand(ChannelUID channelUID, Command commandArg) { logger.warn("No writer service available to handle command for channel: {}", channelUID); return; } - - Object command = commandArg; - Map properties = channel.getProperties(); - - // convert QuantityTypes to the characteristic's unit - if (command instanceof QuantityType quantity) { - Unit unit = UnitUtils.parseUnit(Optional.ofNullable(properties.get("unit")).orElse(null)); - if (unit != null && !unit.equals(quantity.getUnit()) && quantity.getUnit().isCompatible(unit)) { - command = quantity.toUnit(unit); - } - } - - if (command instanceof Number number) { - // clamp numbers to characteristic's min/max limits - Double min = Optional.ofNullable(properties.get("minValue")).map(s -> Double.valueOf(s)).orElse(null); - if (min != null && number.doubleValue() < min.doubleValue()) { - command = min; - } - Double max = Optional.ofNullable(properties.get("maxValue")).map(s -> Double.valueOf(s)).orElse(null); - if (max != null && number.doubleValue() > max.doubleValue()) { - command = max; - } - - // comply with characteristic's data format - String format = properties.get("format"); - if (format != null) { - try { - command = switch (DataFormatType.valueOf(format)) { - case UINT8, UINT16, UINT32, UINT64, INT -> Integer.valueOf(number.intValue()); - case FLOAT -> Float.valueOf(number.floatValue()); - case STRING -> String.valueOf(number); - case BOOL -> Boolean.valueOf(number.intValue() != 0); - default -> command; - }; - } catch (IllegalArgumentException e) { - logger.warn("Unexpected format for channel {}: {}", channelUID, properties.get("format")); - } - } - } - - // convert on/off to boolean - if (command instanceof OnOffType onOff) { - command = Boolean.valueOf(onOff == OnOffType.ON); - } - - // convert open/closed to boolean - if (command instanceof OpenClosedType openClosed) { - command = Boolean.valueOf(openClosed == OpenClosedType.OPEN); - } - try { - writer.writeCharacteristic(thing.getUID().getId(), channelUID.getId(), Objects.requireNonNull(command)); + Integer aid = getAccessoryId(); + if (aid != null) { + Object object = convertCommandToObject(command, channel); + writer.writeCharacteristic(aid.toString(), channelUID.getId(), object); + } } catch (Exception e) { - logger.warn("Failed to send command '{}' as '{}' to accessory", commandArg, command, e); + logger.warn("Failed to send command '{}' as '{}' to accessory", command, command, e); } } @@ -150,16 +117,27 @@ public void handleCommand(ChannelUID channelUID, Command commandArg) { * This method is called periodically by a scheduled executor. */ private void poll() { - CharacteristicReadWriteService charactersticsManager = this.rwService; - if (charactersticsManager != null) { + CharacteristicReadWriteService rwService = this.rwService; + if (rwService != null) { try { - // String power = accessoryClient.readCharacteristic("1", "10"); // TODO example AID/IID - // Parse powerState and update channel state accordingly - // if ("true".equals(power)) { - // updateState(new ChannelUID(getThing().getUID(), "power"), OnOffType.ON); - // } else { - // updateState(new ChannelUID(getThing().getUID(), "power"), OnOffType.OFF); - // } + Integer aid = getAccessoryId(); + List queries = thing.getChannels().stream() + .map(c -> "%s.%s".formatted(aid, Integer.valueOf(c.getUID().getId()))).toList(); + if (queries.isEmpty()) { + return; + } + String jsonResponse = rwService.readCharacteristic(String.join(",", queries)); + Service service = GSON.fromJson(jsonResponse, Service.class); + if (service != null && service.characteristics instanceof List characteristics) { + for (Characteristic characteristic : characteristics) { + for (Channel channel : thing.getChannels()) { + if (channel.getUID().getId().equals(String.valueOf(characteristic.iid)) + && characteristic.value instanceof JsonElement element) { + updateState(channel.getUID(), convertJsonToState(element, channel)); + } + } + } + } } catch (Exception e) { logger.error("Failed to poll accessory state", e); } @@ -218,4 +196,140 @@ private void createChannels() { Optional.ofNullable(accessory.getSemanticEquipmentTag()).ifPresent(builder::withSemanticEquipmentTag); updateThing(builder.build()); } + + /** + * Converts an openHAB Command to a suitable object for writing to a HomeKit characteristic. + * It handles various conversions including unit conversion, clamping to min/max values, + * and converting specific types like OnOffType and OpenClosedType to boolean. + * + * @param command the command to convert + * @param channel the channel for which the command is being converted + * + * @return the converted object suitable for HomeKit characteristic + */ + private Object convertCommandToObject(Command command, Channel channel) { + Object object = command; + Map properties = channel.getProperties(); + + // handle HSBType as not directly supported by HomeKit + if (object instanceof HSBType) { + // TODO special handling => TBD + logger.warn("HSBType command handling is not yet implemented for channel {}", channel.getUID()); + } + + // convert QuantityTypes to the characteristic's unit + if (object instanceof QuantityType quantity) { + Unit unit = UnitUtils.parseUnit(Optional.ofNullable(properties.get("unit")).orElse(null)); + if (unit != null && !unit.equals(quantity.getUnit()) && quantity.getUnit().isCompatible(unit)) { + QuantityType temp = quantity.toUnit(unit); + object = temp != null ? temp : quantity; + } + } + + if (object instanceof Number number) { + // clamp numbers to characteristic's min/max limits + Double min = Optional.ofNullable(properties.get("minValue")).map(s -> Double.valueOf(s)).orElse(null); + if (min != null && number.doubleValue() < min.doubleValue()) { + object = min; + } + Double max = Optional.ofNullable(properties.get("maxValue")).map(s -> Double.valueOf(s)).orElse(null); + if (max != null && number.doubleValue() > max.doubleValue()) { + object = max; + } + + // comply with characteristic's data format + String format = properties.get("format"); + if (format != null) { + try { + object = switch (DataFormatType.valueOf(format)) { + case UINT8, UINT16, UINT32, UINT64, INT -> Integer.valueOf(number.intValue()); + case FLOAT -> Float.valueOf(number.floatValue()); + case STRING -> String.valueOf(number); + case BOOL -> Boolean.valueOf(number.intValue() != 0); + default -> object; + }; + } catch (IllegalArgumentException e) { + logger.warn("Unexpected format {} for channel {}", format, channel.getUID()); + } + } + } + + // convert on/off to boolean + if (object instanceof OnOffType onOff) { + object = Boolean.valueOf(onOff == OnOffType.ON); + } + + // convert open/closed to boolean + if (object instanceof OpenClosedType openClosed) { + object = Boolean.valueOf(openClosed == OpenClosedType.OPEN); + } + + // convert datetime to string + if (object instanceof DateTimeType dateTime) { + object = dateTime.toFullString(); + } + + return object; + } + + /** + * Converts a Characteristic's 'value' JSON element to an openHAB State based on the channel's accepted item type. + * Handles various data formats including boolean, string, and number. + * + * @param element the JSON element containing the value + * @param channel the channel for which the state is being converted + * + * @return the corresponding openHAB State, or UnDefType.UNDEF if conversion is not possible + */ + private State convertJsonToState(JsonElement element, Channel channel) { + if (!element.isJsonPrimitive()) { + return UnDefType.UNDEF; + } + JsonPrimitive value = element.getAsJsonPrimitive(); + + String acceptedItemType = (channel.getChannelTypeUID() instanceof ChannelTypeUID uid + && typeProvider.getChannelType(uid, null) instanceof ChannelType channelType + && channelType.getItemType() instanceof String itemType) ? itemType : "unknown"; + + if (value.isBoolean()) { + return switch (acceptedItemType) { + case CoreItemFactory.SWITCH -> OnOffType.from(value.getAsBoolean()); + case CoreItemFactory.CONTACT -> value.getAsBoolean() ? OpenClosedType.OPEN : OpenClosedType.CLOSED; + default -> UnDefType.UNDEF; + }; + } else if (value.isString()) { + return switch (acceptedItemType) { + case CoreItemFactory.DATETIME -> DateTimeType.valueOf(value.getAsString()); + default -> StringType.valueOf(value.getAsString()); + }; + } else if (value.isNumber()) { + return switch (acceptedItemType) { + case CoreItemFactory.COLOR -> { + logger.warn("HSBType command handling is not yet implemented for channel {}", channel.getUID()); + yield UnDefType.UNDEF; + } + case CoreItemFactory.SWITCH -> OnOffType.from(value.getAsInt() != 0); + case CoreItemFactory.CONTACT -> value.getAsInt() != 0 ? OpenClosedType.OPEN : OpenClosedType.CLOSED; + case CoreItemFactory.DIMMER -> new PercentType(value.getAsInt()); + case CoreItemFactory.ROLLERSHUTTER -> new PercentType(value.getAsInt()); + case CoreItemFactory.NUMBER -> new DecimalType(value.getAsNumber()); + default -> { + if (acceptedItemType.startsWith(CoreItemFactory.NUMBER)) { + int index = acceptedItemType.indexOf(":"); + if (index > 0) { + String targetDimension = acceptedItemType.substring(index + 1); + Unit sourceUnit = UnitUtils + .parseUnit(Optional.ofNullable(channel.getProperties().get("unit")).orElse(null)); + if (sourceUnit != null && targetDimension.equals(UnitUtils.getDimensionName(sourceUnit))) { + yield QuantityType.valueOf(value.getAsNumber().doubleValue(), sourceUnit); + } + } + yield new DecimalType(value.getAsNumber()); + } + yield StringType.valueOf(value.getAsString()); + } + }; + } + return UnDefType.UNDEF; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java index d1e325510ffd6..53083dd45b7a4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java @@ -42,15 +42,14 @@ public CharacteristicReadWriteService(HttpTransport httpTransport, SecureSession } /** - * Reads a characteristic from the accessory. + * Reads characteristic(s) from the accessory. * - * @param aid Accessory ID - * @param iid Instance ID + * @param query the query string e.g. "1.10,1.11" for aid 1 and iid 10 and 11 * @return JSON response as String * @throws Exception on communication or encryption errors */ - public String readCharacteristic(String aid, String iid) throws Exception { - String endpoint = "%s?id=%s.%s".formatted(ENDPOINT_CHARACTERISTICS, aid, iid); + public String readCharacteristic(String query) throws Exception { + String endpoint = "%s?id=%s".formatted(ENDPOINT_CHARACTERISTICS, query); byte[] encrypted = httpTransport.get(baseUrl, endpoint, CONTENT_TYPE_HAP); byte[] decrypted = session.decrypt(encrypted); return new String(decrypted, StandardCharsets.UTF_8); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java index f119764d45e3b..69dead7a46b63 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java @@ -38,14 +38,14 @@ public class PairingRemoveService { private static final byte[] NONCE_M5 = CryptoUtils.generateNonce("PV-Msg05"); private static final byte[] NONCE_M6 = CryptoUtils.generateNonce("PV-Msg06"); - private final HttpTransport http; + private final HttpTransport httpTransport; private final String baseUrl; private final SessionKeys sessionKeys; private final String controllerIdentifier; - public PairingRemoveService(HttpTransport http, String baseUrl, SessionKeys sessionKeys, + public PairingRemoveService(HttpTransport httpTransport, String baseUrl, SessionKeys sessionKeys, String controllerIdentifier) { - this.http = http; + this.httpTransport = httpTransport; this.baseUrl = baseUrl; this.sessionKeys = sessionKeys; this.controllerIdentifier = controllerIdentifier; @@ -64,7 +64,7 @@ public void remove() throws Exception { byte[] encrypted = CryptoUtils.encrypt(sessionKeys.getWriteKey(), NONCE_M5, encoded); // Send to /pairings endpoint - byte[] response = http.post(baseUrl, ENDPOINT, CONTENT_TYPE, encrypted); + byte[] response = httpTransport.post(baseUrl, ENDPOINT, CONTENT_TYPE, encrypted); // M2 Decrypt response using read key byte[] decrypted = CryptoUtils.decrypt(sessionKeys.getReadKey(), NONCE_M6, response); diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 0c2f44015a92d..25e2f4c2bff27 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -1,3 +1,26 @@ -# FIXME: please add all English translations to this file so the texts can be translated using Crowdin -# FIXME: to generate the content of this file run: mvn i18n:generate-default-translations -# FIXME: see also: https://www.openhab.org/docs/developer/utils/i18n.html +# add-on + +addon.homekit.name = HomeKit Binding +addon.homekit.description = This is the binding for HomeKit. + +# thing types + +thing-type.homekit.bridge.label = HomeKit Bridge +thing-type.homekit.bridge.description = HomeKit Accessory Bridge +thing-type.homekit.device.label = HomeKit Device +thing-type.homekit.device.description = HomeKit Accessory Device + +# thing types config + +thing-type.config.homekit.bridge.ipV4Address.label = IP Address +thing-type.config.homekit.bridge.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.bridge.pairingCode.label = Pairing Code +thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit accessory. +thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval +thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.device.ipV4Address.label = IP Address +thing-type.config.homekit.device.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.device.pairingCode.label = Pairing Code +thing-type.config.homekit.device.pairingCode.description = Code used for pairing with the HomeKit accessory. +thing-type.config.homekit.device.refreshInterval.label = Refresh Interval +thing-type.config.homekit.device.refreshInterval.description = Interval at which the accessory is polled in sec. diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index e32f153b3eb11..7010c82f7b05f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -11,18 +11,16 @@ network-address - IP v4 address of the HomeKit accessory device - true + IP v4 address of the HomeKit accessory. password - - Password to access the device - true + + Code used for pairing with the HomeKit accessory. - Interval the device is polled in sec. + Interval at which the accessory is polled in sec. 60 true @@ -36,16 +34,16 @@ network-address - IP v4 address of the HomeKit accessory device + IP v4 address of the HomeKit accessory. password - - Password to access the device + + Code used for pairing with the HomeKit accessory. - Interval the device is polled in sec. + Interval at which the accessory is polled in sec. 60 true From 22878c275bbe064baf10603f37313ca1173119ee Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 16 Sep 2025 19:28:24 +0100 Subject: [PATCH 016/177] line endings Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 144 +++++++++--------- .../resources/OH-INF/i18n/homekit.properties | 52 +++---- 2 files changed, 98 insertions(+), 98 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 83283dbb8e1e0..e9ae904d780e4 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -1,72 +1,72 @@ -# HomeKit Binding - -This binding allows pairing with HomeKit accessory devices and importing their services as channel groups and their respective service- characteristics as channels. - -## Supported Things - -There are two types of Things supported: - -- `device`: This integrates a single HomeKit accessory, whereby its services appear as channel groups the services respective service- characteristics appear as channels. -- `bridge`: This integrates a HomeKit bridge accessory containing multiple child `device` Things. -So Things of type `device` either represent a stand-alone accessories or a child of a `bridge` Thing. - -Things of type `bridge` and stand-alone `device` Things both communicate directly with their HomeKit device over the LAN. -Whereas child `device` Things communicate via their respective `bridge` Thing. - -## Discovery - -Both `bridge` and stand-alone `device` Things will be auto discovered via mDNS. -And once a `bridge` Thing has been instantiated, and paired, its child `device` Things will also be auto discovered - -## Binding Configuration - -The `bridge` and stand-alone `device` Things need to be paired with their respective HomeKit accessories. -This requires entering the HomeKit pairing code as a configuration parameter in the binding. -Note that HomeKit accessories can only be paired with one controller, so if it it already paired with something else, you will need to remove that pairing first. - -## Thing Configuration - -_Describe what is needed to manually configure a thing, either through the UI or via a thing-file._ -_This should be mainly about its mandatory and optional configuration parameters._ - -_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._ - -### `bridge` and stand-alone `device` Thing Configuration - -| Name | Type | Description | Default | Required | Advanced | -|-------------------|---------|---------------------------------------------------|---------|-----------|-----------| -| `ipV4Address` | text | IP v4 address of the HomeKit accessory. | N/A | see below | see below | -| `pairingCode` | text | Code used for pairing with the HomeKit accessory. | N/A | see below | see below | -| `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | - -Things of type `bridge` and stand-alone `device` Things require both an `ipv4Address` and a `pairingCode`. -The `ipv4Address` is set by the mDNS auto- discovery process. -However the `pairingCode` must be entered manually. -Child `device` Things do not require neither an `ipv4Address` nor a `pairingCode`. - -## Channels - -Channels will be auto- created depending on the services and respective service- characteristis of the HomeKit accessory. - -### Thing Configuration - -```java -Example thing configuration goes here. -``` - -### Item Configuration - -```java -Example item configuration goes here. -``` - -### Sitemap Configuration - -```perl -Optional Sitemap configuration goes here. -Remove this section, if not needed. -``` - -## Any custom content here! - -_Feel free to add additional sections for whatever you think should also be mentioned about your binding!_ +# HomeKit Binding + +This binding allows pairing with HomeKit accessory devices and importing their services as channel groups and their respective service- characteristics as channels. + +## Supported Things + +There are two types of Things supported: + +- `device`: This integrates a single HomeKit accessory, whereby its services appear as channel groups their respective service- characteristics appear as channels. +- `bridge`: This integrates a HomeKit bridge accessory containing multiple child `device` Things. +So Things of type `device` either represent a stand-alone accessories or a child of a `bridge` Thing. + +Things of type `bridge` and stand-alone `device` Things both communicate directly with their HomeKit device over the LAN. +Whereas child `device` Things communicate via their respective `bridge` Thing. + +## Discovery + +Both `bridge` and stand-alone `device` Things will be auto discovered via mDNS. +And once a `bridge` Thing has been instantiated, and paired, its child `device` Things will also be auto discovered + +## Binding Configuration + +The `bridge` and stand-alone `device` Things need to be paired with their respective HomeKit accessories. +This requires entering the HomeKit pairing code as a configuration parameter in the binding. +Note that HomeKit accessories can only be paired with one controller, so if it is already paired with something else, you will need to remove that pairing first. + +## Thing Configuration + +_Describe what is needed to manually configure a thing, either through the UI or via a thing-file._ +_This should be mainly about its mandatory and optional configuration parameters._ + +_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._ + +### `bridge` and stand-alone `device` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|-------------------|---------|---------------------------------------------------|---------|-----------|-----------| +| `ipV4Address` | text | IP v4 address of the HomeKit accessory. | N/A | see below | see below | +| `pairingCode` | text | Code used for pairing with the HomeKit accessory. | N/A | see below | see below | +| `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | + +Things of type `bridge` and stand-alone `device` Things require both an `ipv4Address` and a `pairingCode`. +The `ipv4Address` is set by the mDNS auto- discovery process. +However the `pairingCode` must be entered manually. +Child `device` Things do not require neither an `ipv4Address` nor a `pairingCode`. + +## Channels + +Channels will be auto- created depending on the services and respective service- characteristis of the HomeKit accessory. + +### Thing Configuration + +```java +Example thing configuration goes here. +``` + +### Item Configuration + +```java +Example item configuration goes here. +``` + +### Sitemap Configuration + +```perl +Optional Sitemap configuration goes here. +Remove this section, if not needed. +``` + +## Any custom content here! + +_Feel free to add additional sections for whatever you think should also be mentioned about your binding!_ diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 25e2f4c2bff27..a06af1629eca7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -1,26 +1,26 @@ -# add-on - -addon.homekit.name = HomeKit Binding -addon.homekit.description = This is the binding for HomeKit. - -# thing types - -thing-type.homekit.bridge.label = HomeKit Bridge -thing-type.homekit.bridge.description = HomeKit Accessory Bridge -thing-type.homekit.device.label = HomeKit Device -thing-type.homekit.device.description = HomeKit Accessory Device - -# thing types config - -thing-type.config.homekit.bridge.ipV4Address.label = IP Address -thing-type.config.homekit.bridge.ipV4Address.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.bridge.pairingCode.label = Pairing Code -thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit accessory. -thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval -thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the accessory is polled in sec. -thing-type.config.homekit.device.ipV4Address.label = IP Address -thing-type.config.homekit.device.ipV4Address.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.device.pairingCode.label = Pairing Code -thing-type.config.homekit.device.pairingCode.description = Code used for pairing with the HomeKit accessory. -thing-type.config.homekit.device.refreshInterval.label = Refresh Interval -thing-type.config.homekit.device.refreshInterval.description = Interval at which the accessory is polled in sec. +# add-on + +addon.homekit.name = HomeKit Binding +addon.homekit.description = This is the binding for HomeKit. + +# thing types + +thing-type.homekit.bridge.label = HomeKit Bridge +thing-type.homekit.bridge.description = HomeKit Accessory Bridge +thing-type.homekit.device.label = HomeKit Device +thing-type.homekit.device.description = HomeKit Accessory Device + +# thing types config + +thing-type.config.homekit.bridge.ipV4Address.label = IP Address +thing-type.config.homekit.bridge.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.bridge.pairingCode.label = Pairing Code +thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit accessory. +thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval +thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.device.ipV4Address.label = IP Address +thing-type.config.homekit.device.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.device.pairingCode.label = Pairing Code +thing-type.config.homekit.device.pairingCode.description = Code used for pairing with the HomeKit accessory. +thing-type.config.homekit.device.refreshInterval.label = Refresh Interval +thing-type.config.homekit.device.refreshInterval.description = Interval at which the accessory is polled in sec. From f03016a716f1575fd6202720ac35236ca1a41c83 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 16 Sep 2025 19:31:39 +0100 Subject: [PATCH 017/177] Update bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/transport/HttpTransport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java index 9df204526465c..3a38327f15688 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java @@ -115,7 +115,7 @@ public byte[] post(String baseUrl, String endpoint, String contentType, byte[] c public byte[] put(String baseUrl, String endpoint, String contentType, byte[] content) throws IOException, InterruptedException, TimeoutException, ExecutionException { String url = baseUrl + "/" + endpoint; - Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.POST) + Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.PUT) .header(HttpHeader.ACCEPT, contentType).header(HttpHeader.CONTENT_TYPE, contentType) .content(new BytesContentProvider(content)); From a457726bb1992753e3e6ee55f9c3115848d021c4 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 16 Sep 2025 19:31:54 +0100 Subject: [PATCH 018/177] Update bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/persistance/HomekitTypeProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java index ad6559a66cebc..d2c31976dde8b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.persistance; +package org.openhab.binding.homekit.internal.persistence; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.storage.StorageService; From 1115774cf48a0a27aff1b0539f7aeca94916b032 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 16 Sep 2025 20:07:25 +0100 Subject: [PATCH 019/177] adopt first round copilot suggestions Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/crypto/SrpClient.java | 2 +- .../openhab/binding/homekit/internal/dto/Accessory.java | 2 +- .../binding/homekit/internal/dto/Characteristic.java | 2 +- .../org/openhab/binding/homekit/internal/dto/Service.java | 2 +- .../openhab/binding/homekit/internal/enums/TlvType.java | 2 +- .../homekit/internal/factory/HomekitHandlerFactory.java | 2 +- .../homekit/internal/handler/HomekitDeviceHandler.java | 8 +++++--- .../internal/hap_services/PairingVerifyService.java | 2 +- .../{persistance => persistence}/HomekitTypeProvider.java | 5 ++++- 9 files changed, 16 insertions(+), 11 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{persistance => persistence}/HomekitTypeProvider.java (85%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java index 2104467793d18..34fab98702fd0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java @@ -217,7 +217,7 @@ public byte[] getEncryptedIdentifiers(Ed25519PrivateKeyParameters controllerPriv * M6 — Decrypt and store accessory identifier + Curve25519 public key. */ public void verifyAccessoryIdentifiers(byte[] encryptedData) throws Exception { - byte[] decrypted = CryptoUtils.encrypt(deriveSessionKeys().getReadKey(), encryptedData, NONCE_M6); + byte[] decrypted = CryptoUtils.decrypt(deriveSessionKeys().getReadKey(), encryptedData, NONCE_M6); Map accTlv = Tlv8Codec.decode(decrypted); byte[] idBytes = accTlv.get(TlvType.IDENTIFIER.key); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 6326b5ad3e5f9..a75403af14044 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -18,7 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.AccessoryType; -import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment; import org.openhab.core.thing.type.ChannelGroupDefinition; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 14f5be6e9a224..639bdc2836b13 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -23,7 +23,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.DataFormatType; -import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Point; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index 2707e9a79dae3..f582ca062d6d5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -20,7 +20,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.ServiceType; -import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelGroupDefinition; import org.openhab.core.thing.type.ChannelGroupType; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java index 5f398e9ca5501..2822cecd5723c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java @@ -36,7 +36,7 @@ public enum TlvType { FRAGMENT_DATA(0x0C), FRAGMENT_LAST(0x0D), FLAGS(0x13), - SEPERATOR((byte) 0xFF); + SEPARATOR((byte) 0xFF); public final int key; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index 22dc945805728..e705c6f93c982 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -22,7 +22,7 @@ import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; import org.openhab.binding.homekit.internal.handler.HomekitBridgeHandler; import org.openhab.binding.homekit.internal.handler.HomekitDeviceHandler; -import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index a4589bb2ab51a..e27388cde9b50 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -28,7 +28,7 @@ import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.DataFormatType; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; -import org.openhab.binding.homekit.internal.persistance.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.DateTimeType; @@ -101,14 +101,16 @@ public void handleCommand(ChannelUID channelUID, Command command) { logger.warn("No writer service available to handle command for channel: {}", channelUID); return; } + Object object = null; try { Integer aid = getAccessoryId(); if (aid != null) { - Object object = convertCommandToObject(command, channel); + object = convertCommandToObject(command, channel); writer.writeCharacteristic(aid.toString(), channelUID.getId(), object); } } catch (Exception e) { - logger.warn("Failed to send command '{}' as '{}' to accessory", command, command, e); + logger.warn("Failed to send command '{}' as object '{}' to accessory for '{}", command, object, channelUID, + e); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java index 7e83a9c8fc8e3..d311e247ba8e6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java @@ -70,7 +70,7 @@ public SessionKeys verify() throws Exception { } Map tlv1 = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // - // TLVType.METHOD.key, new byte[] { PairingMethod.VERIFY.value }, // not required ?? + // Per HAP Section 5.6.2, the METHOD TLV is not required in the M1 message of pair-verify TlvType.PUBLIC_KEY.key, controllerEphemeralPublicKeyBytes); Validator.validate(PairingMethod.VERIFY, tlv1); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java similarity index 85% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java index d2c31976dde8b..d40cdb5c1751c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistance/HomekitTypeProvider.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java @@ -17,7 +17,9 @@ import org.openhab.core.thing.binding.AbstractStorageBasedTypeProvider; import org.openhab.core.thing.type.ChannelGroupTypeProvider; import org.openhab.core.thing.type.ChannelTypeProvider; +import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; /** * The {@link HomekitTypeProvider} is responsible for loading and storing HomeKit specific channel and @@ -29,7 +31,8 @@ @Component(service = { HomekitTypeProvider.class, ChannelTypeProvider.class, ChannelGroupTypeProvider.class }) public class HomekitTypeProvider extends AbstractStorageBasedTypeProvider { - protected HomekitTypeProvider(StorageService storageService) { + @Activate + public HomekitTypeProvider(@Reference StorageService storageService) { super(storageService); } } From adc4f258912267b53daf57423744d8978a1eb37a Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 16 Sep 2025 20:11:58 +0100 Subject: [PATCH 020/177] Update README.md Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index e9ae904d780e4..7bde2124093fe 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -8,7 +8,7 @@ There are two types of Things supported: - `device`: This integrates a single HomeKit accessory, whereby its services appear as channel groups their respective service- characteristics appear as channels. - `bridge`: This integrates a HomeKit bridge accessory containing multiple child `device` Things. -So Things of type `device` either represent a stand-alone accessories or a child of a `bridge` Thing. + So Things of type `device` either represent a stand-alone accessories or a child of a `bridge` Thing. Things of type `bridge` and stand-alone `device` Things both communicate directly with their HomeKit device over the LAN. Whereas child `device` Things communicate via their respective `bridge` Thing. From 16864eb933e858fce80d72f8351e47ad7ff09b38 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 16 Sep 2025 23:23:07 +0100 Subject: [PATCH 021/177] adopt copilot second suggestions Signed-off-by: Andrew Fiddian-Green --- .../openhab/binding/homekit/internal/crypto/SrpClient.java | 5 ++--- .../internal/discovery/HomekitMdnsDiscoveryParticipant.java | 2 ++ .../binding/homekit/internal/enums/CharacteristicType.java | 4 ++-- .../homekit/internal/handler/HomekitBaseServerHandler.java | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java index 34fab98702fd0..c9d59c16bfc14 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java @@ -106,9 +106,8 @@ public void processChallenge(byte[] salt, byte[] serverPublicKey) throws NoSuchA .digest(concat(BigIntUtils.toUnsignedByteArray(N), BigIntUtils.toUnsignedByteArray(g)))); // Precompute x = H(salt || H(username:pin)) - byte[] inner = MessageDigest.getInstance("SHA-512") - .digest((PAIR_USER + ":" + accessoryPairingCode).getBytes(StandardCharsets.UTF_8)); - + byte[] inner = MessageDigest.getInstance("SHA-512").digest( + concat(PAIR_USER, concat(new byte[] { ':' }, accessoryPairingCode.getBytes(StandardCharsets.UTF_8)))); this.x = new BigInteger(1, MessageDigest.getInstance("SHA-512").digest(concat(salt, inner))); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index ea64023e9aad2..2789ee244ff5c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -99,6 +99,8 @@ public String getServiceType() { return new ThingUID(THING_TYPE_DEVICE, id); } } catch (IllegalArgumentException e) { + logger.warn("Failed to parse accessory type '{}' for HomeKit device with MAC '{}'", accessoryType, + macAddress); } } logger.warn("Discovered HomeKit device without valid properties - ignoring"); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index 5486e8614946c..255a795f4e723 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -47,10 +47,10 @@ public enum CharacteristicType { DENSITY_NO2(0xC4, "public.hap.characteristic.density.no2"), DENSITY_OZONE(0xC3, "public.hap.characteristic.density.ozone"), DENSITY_PM10(0xC7, "public.hap.characteristic.density.pm10"), - DENSITY_PM2_5(0xC5, "public.hap.characteristic.density.pm2_5"), + DENSITY_PM2_5(0xC6, "public.hap.characteristic.density.pm2_5"), DENSITY_SO2(0xC5, "public.hap.characteristic.density.so2"), DENSITY_VOC(0xC8, "public.hap.characteristic.density.voc"), - DOOR_STATE_CURRENT(0xE7, "public.hap.characteristic.door-state.current"), + DOOR_STATE_CURRENT(0x0E, "public.hap.characteristic.door-state.current"), DOOR_STATE_TARGET(0x32, "public.hap.characteristic.door-state.target"), FAN_STATE_CURRENT(0xAF, "public.hap.characteristic.fan.state.current"), FAN_STATE_TARGET(0xBF, "public.hap.characteristic.fan.state.target"), diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 3e9681841a388..adfdc069425af 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -106,6 +106,7 @@ protected void getAccessories() { .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } } catch (Exception e) { + logger.warn("Failed to get accessories: {}", e.getMessage()); } } } From a42501aa2d7e5cf0668dd5737418ee41044edc1c Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 19 Sep 2025 00:15:36 +0100 Subject: [PATCH 022/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 4 +- .../homekit/internal/crypto/CryptoUtils.java | 55 +-- .../homekit/internal/crypto/SRPclient.java | 331 +++++++++++++ .../homekit/internal/crypto/SrpClient.java | 276 ----------- .../homekit/internal/dto/Accessories.java | 6 + .../homekit/internal/dto/Accessory.java | 4 + .../homekit/internal/dto/Characteristic.java | 40 +- .../binding/homekit/internal/dto/Service.java | 12 +- .../internal/enums/CharacteristicType.java | 14 +- .../homekit/internal/enums/ServiceType.java | 36 +- .../handler/HomekitBaseServerHandler.java | 68 +-- .../handler/HomekitDeviceHandler.java | 4 +- .../hap_services/PairSetupClient.java | 262 +++++++++++ ...rifyService.java => PairVerifyClient.java} | 142 +++--- .../hap_services/PairingSetupService.java | 156 ------- .../binding/homekit/internal/SRPserver.java | 228 +++++++++ .../homekit/internal/TestChannelCreation.java | 435 ++++++++++++++++++ .../homekit/internal/TestPairSetup.java | 154 +++++++ .../homekit/internal/TestPairVerify.java | 179 +++++++ 19 files changed, 1812 insertions(+), 594 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/{PairingVerifyService.java => PairVerifyClient.java} (52%) delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingSetupService.java create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 73a57a51a036b..fda0c38cdcaf7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -32,8 +32,8 @@ public class HomekitBindingConstants { // labels public static final String THING_LABEL_FMT = "Model %s on %s"; public static final String CHILD_LABEL_FMT = "Accessory %d on %s"; - public static final String GROUP_TYPE_LABEL = "Channel group type"; - public static final String CHANNEL_TYPE_LABEL = "Channel type"; + public static final String GROUP_TYPE_LABEL_FMT = "Channel group type: %s"; + public static final String CHANNEL_TYPE_LABEL_FMT = "Channel type: %s"; // UID id formats public static final String CHILD_FMT = "child-%x"; // e.g. child-123abc; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index ab44d0c8ec340..e46fc7d1883f5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -13,26 +13,23 @@ package org.openhab.binding.homekit.internal.crypto; import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; import java.security.SecureRandom; -import java.util.Map; -import org.bouncycastle.crypto.AsymmetricCipherKeyPair; import org.bouncycastle.crypto.InvalidCipherTextException; -import org.bouncycastle.crypto.agreement.X25519Agreement; import org.bouncycastle.crypto.digests.SHA512Digest; import org.bouncycastle.crypto.generators.HKDFBytesGenerator; -import org.bouncycastle.crypto.generators.X25519KeyPairGenerator; import org.bouncycastle.crypto.modes.ChaCha20Poly1305; import org.bouncycastle.crypto.params.AEADParameters; -import org.bouncycastle.crypto.params.AsymmetricKeyParameter; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.bouncycastle.crypto.params.HKDFParameters; import org.bouncycastle.crypto.params.KeyParameter; -import org.bouncycastle.crypto.params.X25519KeyGenerationParameters; +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.X25519PublicKeyParameters; import org.bouncycastle.crypto.signers.Ed25519Signer; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.enums.TlvType; /** * Utility class for cryptographic operations used in HomeKit communication. @@ -42,22 +39,18 @@ @NonNullByDefault public class CryptoUtils { - private static final SecureRandom random = new SecureRandom(); - - // Generate ephemeral Curve25519 key pair - public static AsymmetricCipherKeyPair generateCurve25519KeyPair() { - X25519KeyPairGenerator generator = new X25519KeyPairGenerator(); - generator.init(new X25519KeyGenerationParameters(random)); - return generator.generateKeyPair(); + // Generate ephemeral X25519 (Curve25519) key pair + public static X25519PrivateKeyParameters generateX25519KeyPair() + throws NoSuchAlgorithmException, NoSuchProviderException { + return new X25519PrivateKeyParameters(new SecureRandom()); } // Compute shared secret using ECDH - public static byte[] computeSharedSecret(AsymmetricKeyParameter privateKey, AsymmetricKeyParameter peerPublicKey) { - X25519Agreement agreement = new X25519Agreement(); - agreement.init(privateKey); - byte[] sharedSecret = new byte[agreement.getAgreementSize()]; - agreement.calculateAgreement(peerPublicKey, sharedSecret, 0); - return sharedSecret; + public static byte[] computeSharedSecret(X25519PrivateKeyParameters clientPrivateKey, + X25519PublicKeyParameters serverPublicKey) { + byte[] secret = new byte[32]; + clientPrivateKey.generateSecret(serverPublicKey, secret, 0); + return secret; } // HKDF-SHA512 key derivation @@ -102,25 +95,11 @@ public static byte[] signVerifyMessage(Ed25519PrivateKeyParameters privateKey, b return signer.generateSignature(); } - // Validate accessory identity and signature - public static void validateAccessory(Map tlv) { - byte[] identifier = tlv.get(TlvType.IDENTIFIER.key); - byte[] signature = tlv.get(TlvType.SIGNATURE.key); - byte[] publicKey = tlv.get(TlvType.PUBLIC_KEY.key); - - if (identifier == null || signature == null || publicKey == null) { - throw new SecurityException("Missing accessory credentials"); - } - - Ed25519PublicKeyParameters pubKey = new Ed25519PublicKeyParameters(publicKey, 0); + public static boolean verifyVerifyMessage(Ed25519PublicKeyParameters publicKey, byte[] message, byte[] signature) { Ed25519Signer verifier = new Ed25519Signer(); - verifier.init(false, pubKey); - verifier.update(identifier, 0, identifier.length); - - boolean valid = verifier.verifySignature(signature); - if (!valid) { - throw new SecurityException("Accessory signature verification failed"); - } + verifier.init(false, publicKey); + verifier.update(message, 0, message.length); + return verifier.verifySignature(signature); } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java new file mode 100644 index 0000000000000..2b5fdd9540de6 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.crypto; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.bouncycastle.crypto.digests.SHA512Digest; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.bouncycastle.crypto.params.HKDFParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.enums.TlvType; +import org.openhab.binding.homekit.internal.session.SessionKeys; + +/** + * Manages the SRP (Stanford Secure Remote Password) protocol for pairing with a HomeKit accessory. + * This class handles the SRP steps, including key generation, proof verification, and encryption of identifiers. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class SRPclient { + + public static final String PAIR_SETUP = "Pair-Setup"; + + private static final BigInteger N = new BigInteger(""" + FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 + 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 + 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED + EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05 + 98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB + 9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B + E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718 + 3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33 + A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7 + ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864 + D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2 + 08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF + """.replaceAll("\\s+", ""), 16); + + private static final BigInteger g = BigInteger.valueOf(5); + private static final BigInteger k = computeK(); + private static final SecureRandom random = new SecureRandom(); + + private final String I; // username + private final byte[] s; // salt + private final BigInteger x; // private key derived from password + + private @NonNullByDefault({}) BigInteger a; // client private ephemeral + private @NonNullByDefault({}) BigInteger A; // client public key + private @NonNullByDefault({}) BigInteger B; // server public key + private @NonNullByDefault({}) BigInteger u; // scrambling parameter + private @NonNullByDefault({}) BigInteger S; // shared secret + private @NonNullByDefault({}) byte[] K; // session key + private @NonNullByDefault({}) byte[] M1; // client proof + + private @Nullable String serverIdentifier; + private byte @Nullable [] serverPublicKey; + + // HomeKit‐specific constants for HKDF/ChaCha20‐Poly1305 + private static final byte[] PAIR_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); + private static final byte[] PAIR_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); + private static final byte[] PAIR_SIGN_SALT = "Pair-Setup-Sign-Salt".getBytes(StandardCharsets.UTF_8); + private static final byte[] PAIR_SIGN_INFO = "Pair-Setup-Sign-Info".getBytes(StandardCharsets.UTF_8); + private static final byte[] PAIR_NONCE_M5 = CryptoUtils.generateNonce("PS-Msg05"); + private static final byte[] PAIR_NONCE_M6 = CryptoUtils.generateNonce("PS-Msg06"); + + /** + * M1 — Initializes the SRP client with the given username, password, and salt. + * + * @param username the username (I). + * @param password the password (P). + * @param salt the salt (s) provided by the server. + * @throws Exception if an error occurs during initialization. + */ + public SRPclient(String username, String password, byte[] salt) throws Exception { + this.I = username; + this.s = salt; + + // Compute verifier: v = g^x mod N where x = H(salt || H(username || ":" || password)) + byte[] hIP = sha512((username + ":" + password).getBytes(StandardCharsets.UTF_8)); + byte[] xHash = sha512(concat(salt, hIP)); + this.x = new BigInteger(1, xHash); + } + + /** + * M2 — Process the server's challenge by storing the server's Curve25519 public key B. + * + * @param serverPublicKey the server's Curve25519 public key B. + */ + public void processChallenge(byte[] serverPublicKey) { + this.B = new BigInteger(1, serverPublicKey); + } + + /** + * M3 — Generate the client's Curve25519 ephemeral key pair (a, A) and return the public key A. + * + * @return the client's Curve25519 public key A. + */ + public byte[] getPublicKey() { + if (A == null) { + this.a = new BigInteger(N.bitLength(), random).mod(N); + this.A = g.modPow(a, N); + } + return toUnsigned(A, N); + } + + /** + * M3 — Compute the client proof M1 = H(H(N) ⊕ H(g) || H(I) || salt || A || B || K). + * + * @return the client proof M1. + * @throws IllegalStateException if the SRP state is not properly initialized. + */ + public byte[] getClientProof() throws Exception { + if (M1 != null) { + return M1; + } + if (A == null || B == null || a == null) { + throw new IllegalStateException("SRP state not initialized"); + } + if (B.mod(N).equals(BigInteger.ZERO)) { + throw new SecurityException("Invalid server public key"); + } + + // Compute u = H(PAD(A) || PAD(B)) + byte[] uHash = sha512(concat(toUnsigned(A, N), toUnsigned(B, N))); + this.u = new BigInteger(1, uHash); + if (u.equals(BigInteger.ZERO)) { + throw new SecurityException("Invalid scrambling parameter"); + } + + // Compute S = (B - k·g^x)^(a + u·x) mod N + BigInteger gx = g.modPow(x, N); + BigInteger base = B.subtract(k.multiply(gx)).mod(N); + BigInteger exp = a.add(u.multiply(x)); + this.S = base.modPow(exp, N); + + // Compute session key K = H(S) + this.K = sha512(toUnsigned(S, N)); + + // Compute client proof M1 = H(H(N) ⊕ H(g) || H(I) || salt || A || B || K) + byte[] HN = sha512(toUnsigned(N, N)); + byte[] Hg = sha512(toUnsigned(g, N)); + byte[] Hxor = xor(HN, Hg); + byte[] HI = sha512(I.getBytes(StandardCharsets.UTF_8)); + + this.M1 = sha512(concat(Hxor, HI, s, toUnsigned(A, N), toUnsigned(B, N), K)); + return M1; + } + + /** + * M4 — Verify the server's proof M2 = H(A || M1 || K). + * + * @param serverProof the server's proof to verify. + * @throws SecurityException if the proof does not match. + */ + public void verifyServerProof(byte[] serverProof) throws Exception { + byte[] expected = sha512(concat(toUnsigned(A, N), M1, K)); + if (!Arrays.equals(expected, serverProof)) { + throw new SecurityException("SRP server proof mismatch"); + } + } + + /** + * M5 — Derive the 32‐byte signing key from HKDF(S, salt, info). + */ + public byte[] deriveIOSDeviceXKey() { + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); + hkdf.init(new HKDFParameters(toUnsigned(S, N), PAIR_SIGN_SALT, PAIR_SIGN_INFO)); + byte[] xKey = new byte[32]; + hkdf.generateBytes(xKey, 0, xKey.length); + return xKey; + } + + /** + * M5 — Encrypt the derived Curve25519 key, the accessory identifier, and the long term Curve25519 public key. + * + * @param accessoryId UTF-8 string identifier of the controller. + * @param signingKey Ed25519 private key for signing the TLV. + * + * @return the ChaCha20-Poly1305‐encrypted TLV blob for M5. + */ + public byte[] getEncryptedDeviceInfoBlob(byte[] iOSDeviceXKey, String pairingIdentifier, + Ed25519PublicKeyParameters controllerLongTermKey, Ed25519PrivateKeyParameters signingKey) + throws Exception { + // 1) Build sub-TLV with iOSDeviceXKey, pairing identifier, and controller long-term public key + byte[] blob = concat(iOSDeviceXKey, pairingIdentifier.getBytes(StandardCharsets.UTF_8), + controllerLongTermKey.getEncoded()); + Map subTlv = new LinkedHashMap<>(); + subTlv.put(TlvType.IDENTIFIER.key, blob); + byte[] controllerPk = signingKey.generatePublicKey().getEncoded(); + subTlv.put(TlvType.PUBLIC_KEY.key, controllerPk); + + // 2) Encode & sign the sub-TLV + byte[] msg = Tlv8Codec.encode(subTlv); + byte[] signature = CryptoUtils.signVerifyMessage(signingKey, msg); + subTlv.put(TlvType.SIGNATURE.key, signature); + + // 3) Re-encode the signed TLV + byte[] plaintext = Tlv8Codec.encode(subTlv); + + // 4) Encrypt with session write key and fixed nonce + byte[] writeKey = deriveSessionKeys().getWriteKey(); + return CryptoUtils.encrypt(writeKey, PAIR_NONCE_M5, plaintext); + } + + /** + * M6 — Decrypt and store accessory identifier + Curve25519 public key. + */ + public void verifyAccessoryIdentifiers(byte[] encryptedData) throws Exception { + // 1) Decrypt using the session's read key and fixed nonce + byte[] decrypted = CryptoUtils.decrypt(deriveSessionKeys().getReadKey(), PAIR_NONCE_M6, encryptedData); + + // 2) Parse TLV to extract accessory identifier and public key + Map tlv = Tlv8Codec.decode(decrypted); + byte[] idBytes = tlv.get(TlvType.IDENTIFIER.key); + byte[] pkBytes = tlv.get(TlvType.PUBLIC_KEY.key); + + if (idBytes == null || pkBytes == null) { + throw new SecurityException("Missing accessory credentials in M6"); + } + + // 3) Store for later use + this.serverIdentifier = new String(idBytes, StandardCharsets.UTF_8); + this.serverPublicKey = pkBytes; + } + + /** + * After M6 — Derive the 32‐byte session key with HKDF(K, salt, info). + */ + public SessionKeys deriveSessionKeys() { + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); + hkdf.init(new HKDFParameters(K, PAIR_SALT, PAIR_INFO)); + + byte[] sessionKey = new byte[32]; + hkdf.generateBytes(sessionKey, 0, sessionKey.length); + + // HomeKit uses the same key for read/write + return new SessionKeys(sessionKey, sessionKey); + } + + /* + * Returns the stored server identifier after M6. + * + * @return the server's identifier string, or null if not yet set. + */ + public @Nullable String getServerIdentifier() { + return serverIdentifier; + } + + /* + * Returns the stored server SRP public key after M6. + * + * @return the server's Curve25519 public key, or null if not yet set. + */ + public byte @Nullable [] getServerPublicKey() { + return serverPublicKey; + } + + // ─── Utility Methods ────────────────────────────────────────────────────── + + private static BigInteger computeK() { + try { + byte[] paddedN = toUnsigned(N, N); + byte[] paddedG = toUnsigned(g, N); + byte[] hash = sha512(concat(paddedN, paddedG)); + return new BigInteger(1, hash); + } catch (Exception e) { + throw new RuntimeException("Failed to compute k", e); + } + } + + private static byte[] sha512(byte[] data) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-512"); + return md.digest(data); + } + + private static byte[] concat(byte[]... parts) { + int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); + byte[] out = new byte[total]; + int pos = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, pos, p.length); + pos += p.length; + } + return out; + } + + private static byte[] xor(byte[] a, byte[] b) { + if (a.length != b.length) { + throw new IllegalArgumentException("xor length mismatch"); + } + byte[] out = new byte[a.length]; + for (int i = 0; i < a.length; i++) { + out[i] = (byte) (a[i] ^ b[i]); + } + return out; + } + + private static byte[] toUnsigned(BigInteger v, BigInteger N) { + int len = (N.bitLength() + 7) / 8; + byte[] raw = v.toByteArray(); + if (raw.length == len) { + return raw; + } + if (raw.length == len + 1 && raw[0] == 0) { + return Arrays.copyOfRange(raw, 1, raw.length); + } + byte[] padded = new byte[len]; + System.arraycopy(raw, 0, padded, len - raw.length, raw.length); + return padded; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java deleted file mode 100644 index c9d59c16bfc14..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SrpClient.java +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.crypto; - -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.Map; - -import org.bouncycastle.crypto.digests.SHA512Digest; -import org.bouncycastle.crypto.generators.HKDFBytesGenerator; -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; -import org.bouncycastle.crypto.params.HKDFParameters; -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.session.SessionKeys; - -/** - * Manages the SRP (Secure Remote Password) protocol for pairing with a HomeKit accessory. - * This class handles the SRP steps, including key generation, proof verification, - * and encryption of identifiers. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class SrpClient { - - // HomeKit 3072-bit prime from RFC 5054 - public static final String N_HEX = - //@formatter:off - "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" + - "020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" + - "4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + - "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" + - "98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" + - "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + - "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" + - "3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33" + - "A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" + - "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864" + - "D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E2" + - "08E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"; - //@formatter:on - - private static final BigInteger N = new BigInteger(N_HEX); - private static final BigInteger g = BigInteger.valueOf(5); - - private static final byte[] PAIR_USER = "Pair-Setup".getBytes(StandardCharsets.UTF_8); - private static final byte[] PAIR_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); - private static final byte[] PAIR_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); - private static final byte[] NONCE_M5 = CryptoUtils.generateNonce("PS-Msg05"); - private static final byte[] NONCE_M6 = CryptoUtils.generateNonce("PS-Msg06"); - - private final String accessoryPairingCode; - private final SecureRandom random = new SecureRandom(); - private final byte[] controllerIdentifier; - - // SRP internals - private @Nullable BigInteger a = null; // client private exponent - private @Nullable BigInteger A = null; // client public value - private @Nullable BigInteger B = null; // server public value - private @Nullable BigInteger x = null; // private key derived from salt + (username:pin) - private @Nullable BigInteger k = null; // SRP multiplier - private @Nullable BigInteger u = null; // scrambling parameter - private @Nullable BigInteger S = null; // shared secret - private byte @Nullable [] K = null; // session key (H(S)) - private byte @Nullable [] M1 = null; // client proof - private byte @Nullable [] salt = null; // server salt - - // Curve25519 key‐pair for identifier exchange - // private final AsymmetricCipherKeyPair x25519KeyPair; - - // Accessory credentials after M6 - private @Nullable String accessoryIdentifier; - private byte @Nullable [] accessoryPublicKey = null; - - public SrpClient(String accessoryPairingCode, String controllerIdentifier) { - this.accessoryPairingCode = accessoryPairingCode; - this.controllerIdentifier = controllerIdentifier.getBytes(StandardCharsets.UTF_8); - } - - /** - * M2 — Store salt and accessory public key (B). - */ - public void processChallenge(byte[] salt, byte[] serverPublicKey) throws NoSuchAlgorithmException { - this.B = new BigInteger(1, serverPublicKey); - this.salt = salt; - - // Precompute k = H(N || g) - this.k = new BigInteger(1, MessageDigest.getInstance("SHA-512") - .digest(concat(BigIntUtils.toUnsignedByteArray(N), BigIntUtils.toUnsignedByteArray(g)))); - - // Precompute x = H(salt || H(username:pin)) - byte[] inner = MessageDigest.getInstance("SHA-512").digest( - concat(PAIR_USER, concat(new byte[] { ':' }, accessoryPairingCode.getBytes(StandardCharsets.UTF_8)))); - this.x = new BigInteger(1, MessageDigest.getInstance("SHA-512").digest(concat(salt, inner))); - } - - /** - * M3 — Client public key A. - */ - public byte[] getPublicKey() { - BigInteger A = this.A; - if (A == null) { - // a = random, A = g^a mod N - this.a = new BigInteger(N.bitLength(), random).mod(N); - A = g.modPow(a, N); - this.A = A; - } - return BigIntUtils.toUnsignedByteArray(A); - } - - /** - * M3 — Client proof M1 = H( H(N)^H(g) || H(username) || salt || A || B || K ). - */ - public byte[] getClientProof() throws Exception { - if (M1 == null) { - MessageDigest sha512 = MessageDigest.getInstance("SHA-512"); - - // u = H(A || B) - sha512.update(BigIntUtils.toUnsignedByteArray(A)); - sha512.update(BigIntUtils.toUnsignedByteArray(B)); - this.u = new BigInteger(1, sha512.digest()); - - BigInteger B = this.B; - BigInteger k = this.k; - BigInteger a = this.a; - BigInteger u = this.u; - BigInteger x = this.x; - if (B == null || k == null || a == null || u == null || x == null) { - throw new IllegalStateException("SRP internal state not initialized"); - } - - // S = ( B - k·g^x )^( a + u·x ) mod N - BigInteger gx = g.modPow(x, N); - BigInteger tmp = B.subtract(k.multiply(gx)).mod(N); - BigInteger exp = a.add(u.multiply(x)); - this.S = tmp.modPow(exp, N); - - // K = H(S) - this.K = MessageDigest.getInstance("SHA-512").digest(BigIntUtils.toUnsignedByteArray(S)); - - // compute proof M1 - byte[] HN = MessageDigest.getInstance("SHA-512").digest(BigIntUtils.toUnsignedByteArray(N)); - byte[] Hg = MessageDigest.getInstance("SHA-512").digest(BigIntUtils.toUnsignedByteArray(g)); - byte[] Hxor = xor(HN, Hg); - byte[] Hu = MessageDigest.getInstance("SHA-512").digest(PAIR_USER); - - sha512.reset(); - sha512.update(Hxor); - sha512.update(Hu); - sha512.update(salt); - sha512.update(BigIntUtils.toUnsignedByteArray(A)); - sha512.update(BigIntUtils.toUnsignedByteArray(B)); - sha512.update(K); - this.M1 = sha512.digest(); - } - byte @Nullable [] M1 = this.M1; - return M1 != null ? M1 : new byte[0]; - } - - /** - * M4 — Verify server proof M2 = H( A || M1 || K ). - */ - public void verifyServerProof(byte[] serverProof) throws Exception { - MessageDigest sha512 = MessageDigest.getInstance("SHA-512"); - sha512.update(BigIntUtils.toUnsignedByteArray(A)); - sha512.update(M1); - sha512.update(K); - byte[] expected = sha512.digest(); - - if (!Arrays.equals(expected, serverProof)) { - throw new SecurityException("SRP server proof mismatch"); - } - } - - /** - * M5 — Encrypt controller identifier + Curve25519 public key. - */ - public byte[] getEncryptedIdentifiers(Ed25519PrivateKeyParameters controllerPrivateKey) throws Exception { - // Step 1: Build TLV with controller identifier and public key - Map subTlv = new LinkedHashMap<>(); - subTlv.put(TlvType.IDENTIFIER.key, controllerIdentifier); - subTlv.put(TlvType.PUBLIC_KEY.key, controllerPrivateKey.generatePublicKey().getEncoded()); - - // Step 2: Sign the TLV with controller's private key - byte[] message = Tlv8Codec.encode(subTlv); - byte[] signature = CryptoUtils.signVerifyMessage(controllerPrivateKey, message); - subTlv.put(TlvType.SIGNATURE.key, signature); - - // Step 3: Encrypt the signed TLV using SRP-derived session key - byte[] plaintext = Tlv8Codec.encode(subTlv); - SessionKeys sessionKeys = deriveSessionKeys(); - byte[] encryptionKey = sessionKeys.getWriteKey(); - - byte[] encrypted = CryptoUtils.encrypt(encryptionKey, NONCE_M5, plaintext); - return encrypted; - } - - /** - * M6 — Decrypt and store accessory identifier + Curve25519 public key. - */ - public void verifyAccessoryIdentifiers(byte[] encryptedData) throws Exception { - byte[] decrypted = CryptoUtils.decrypt(deriveSessionKeys().getReadKey(), encryptedData, NONCE_M6); - Map accTlv = Tlv8Codec.decode(decrypted); - - byte[] idBytes = accTlv.get(TlvType.IDENTIFIER.key); - byte[] pkBytes = accTlv.get(TlvType.PUBLIC_KEY.key); - if (idBytes == null || pkBytes == null) { - throw new SecurityException("Missing accessory credentials in M6"); - } - this.accessoryIdentifier = new String(idBytes, StandardCharsets.UTF_8); - this.accessoryPublicKey = pkBytes; - } - - /** - * After M6, derive the 32‐byte session key using HKDF(S, salt, info). - */ - public SessionKeys deriveSessionKeys() { - HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); - hkdf.init(new HKDFParameters(K, PAIR_SALT, PAIR_INFO)); - byte[] sessionKey = new byte[32]; - hkdf.generateBytes(sessionKey, 0, sessionKey.length); - return new SessionKeys(sessionKey, sessionKey); - } - - // ——— Internals ——————————————————————————————————————————— - - private static byte[] xor(byte[] a, byte[] b) { - byte[] out = new byte[Math.min(a.length, b.length)]; - for (int i = 0; i < out.length; i++) { - out[i] = (byte) (a[i] ^ b[i]); - } - return out; - } - - private static class BigIntUtils { - static byte[] toUnsignedByteArray(@Nullable BigInteger b) { - if (b == null) { - throw new IllegalStateException("BigInteger is null"); - } - byte[] bytes = b.toByteArray(); - return bytes[0] == 0 ? Arrays.copyOfRange(bytes, 1, bytes.length) : bytes; - } - } - - public static byte[] concat(byte[] a, byte[] b) { - byte[] result = new byte[a.length + b.length]; - System.arraycopy(a, 0, result, 0, a.length); - System.arraycopy(b, 0, result, a.length, b.length); - return result; - } - - public @Nullable String getAccessoryIdentifier() { - return accessoryIdentifier; - } - - public byte @Nullable [] getAccessoryPublicKey() { - return accessoryPublicKey; - } -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java index 96c6904a92e71..874b0d9016166 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java @@ -14,6 +14,8 @@ import java.util.List; +import org.eclipse.jdt.annotation.Nullable; + /** * HomeKit accessories DTO. * Used to deserialize the JSON response from the /accessories endpoint of a HomeKit bridge. @@ -23,4 +25,8 @@ */ public class Accessories { public List accessories; + + public @Nullable Accessory getAccessory(Integer aid) { + return accessories.stream().filter(a -> aid.equals(a.aid)).findFirst().orElse(null); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index a75403af14044..9f02b53869086 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -130,6 +130,10 @@ public AccessoryType getAccessoryType() { return null; } + public @Nullable Service getService(Integer iid) { + return services.stream().filter(s -> iid.equals(s.iid)).findFirst().orElse(null); + } + @Override public String toString() { return getAccessoryType().toString(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 639bdc2836b13..96f452670a8e4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -51,7 +51,7 @@ public class Characteristic { public @NonNullByDefault({}) String format; // e.g. "bool" public @NonNullByDefault({}) List perms; // e.g. ["pr", "pw", "ev"] public @NonNullByDefault({}) Integer iid; // e.g. 10 - public @NonNullByDefault({}) String unit; // e.g. "celsius" + public @NonNullByDefault({}) String unit; // e.g. "celsius" or "percentage" public @NonNullByDefault({}) Double maxValue; // e.g. 100 public @NonNullByDefault({}) Double minValue; // e.g. 0 public @NonNullByDefault({}) Double minStep; @@ -83,12 +83,18 @@ public class Characteristic { return null; } + // convert "percentage" to "percent" as SimpleUnitFormat does handle the former + if ("percentage".equals(unit)) { + unit = "percent"; + } + // determine channel type and attributes based on characteristic properties boolean isReadOnly = !perms.contains("pw"); boolean isString = DataFormatType.STRING == dataFormatType; boolean isBoolean = DataFormatType.BOOL == dataFormatType; boolean isNumber = !isString && !isBoolean; boolean isStateChannel = true; + boolean isPercentage = "percent".equals(unit); String itemType = null; String category = null; @@ -102,8 +108,8 @@ public class Characteristic { pointTag = Point.STATUS; category = "switch"; } else if (isNumber) { - itemType = CoreItemFactory.NUMBER; - pointTag = Point.MEASUREMENT; + itemType = isPercentage ? CoreItemFactory.DIMMER : CoreItemFactory.NUMBER; + pointTag = isPercentage ? Point.STATUS : Point.MEASUREMENT; } else if (isString) { itemType = CoreItemFactory.STRING; pointTag = Point.STATUS; @@ -114,8 +120,8 @@ public class Characteristic { pointTag = Point.SWITCH; category = "switch"; } else if (isNumber) { - itemType = CoreItemFactory.NUMBER; - pointTag = Point.SETPOINT; + itemType = isPercentage ? CoreItemFactory.DIMMER : CoreItemFactory.NUMBER; + pointTag = isPercentage ? Point.CONTROL : Point.SETPOINT; } else if (isString) { itemType = CoreItemFactory.STRING; pointTag = Point.CONTROL; @@ -127,6 +133,7 @@ public class Characteristic { case ACTIVE: case ACTIVE_IDENTIFIER: case ADMINISTRATOR_ONLY_ACCESS: + itemType = null; break; case AIR_PARTICULATE_DENSITY: @@ -250,6 +257,7 @@ public class Characteristic { case FIRMWARE_REVISION: case HARDWARE_REVISION: + itemType = null; break; case HEATER_COOLER_STATE_CURRENT: @@ -283,7 +291,7 @@ public class Characteristic { break; case IDENTIFY: - isStateChannel = false; + itemType = null; break; case IMAGE_MIRROR: @@ -297,6 +305,7 @@ public class Characteristic { case IN_USE: case IS_CONFIGURED: + itemType = null; break; case LEAK_DETECTED: @@ -326,6 +335,7 @@ public class Characteristic { case LOGS: case MANUFACTURER: case MODEL: + itemType = null; break; case MOTION_DETECTED: @@ -339,6 +349,9 @@ public class Characteristic { break; case NAME: + itemType = null; + break; + case NIGHT_VISION: case OBSTRUCTION_DETECTED: break; @@ -353,12 +366,14 @@ public class Characteristic { break; case OUTLET_IN_USE: + itemType = null; break; case PAIRING_FEATURES: case PAIRING_PAIRINGS: case PAIRING_PAIR_SETUP: case PAIRING_PAIR_VERIFY: + itemType = null; break; case POSITION_CURRENT: @@ -412,6 +427,7 @@ public class Characteristic { case SERVICE_LABEL_NAMESPACE: case SETUP_DATA_STREAM_TRANSPORT: case SETUP_ENDPOINTS: + itemType = null; break; case SET_DURATION: @@ -419,6 +435,7 @@ public class Characteristic { break; case SIRI_INPUT_TYPE: + itemType = null; break; case SLAT_STATE_CURRENT: @@ -463,6 +480,7 @@ public class Characteristic { case SUPPORTED_RTP_CONFIGURATION: case SUPPORTED_TARGET_CONFIGURATION: case SUPPORTED_VIDEO_STREAM_CONFIGURATION: + itemType = null; break; case SWING_MODE: @@ -470,6 +488,7 @@ public class Characteristic { break; case TARGET_LIST: + itemType = null; break; case TEMPERATURE_COOLING_THRESHOLD: @@ -492,6 +511,7 @@ public class Characteristic { case TYPE_SLAT: case VALVE_TYPE: case VERSION: + itemType = null; break; case VERTICAL_TILT_CURRENT: @@ -510,6 +530,7 @@ public class Characteristic { case ZOOM_DIGITAL: case ZOOM_OPTICAL: + itemType = null; break; } @@ -522,12 +543,13 @@ public class Characteristic { * properties e.g. min, max, step, unit may be different */ ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, characteristicType.getGroupTypeId()); + String label = CHANNEL_TYPE_LABEL_FMT.formatted(characteristicType.toString()); ChannelType channelType; if (isStateChannel) { if (itemType == null) { return null; } - StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, CHANNEL_TYPE_LABEL, itemType); + StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, label, itemType); Optional.ofNullable(category).ifPresent(builder::withCategory); if (pointTag != null) { if (propertyTag != null) { @@ -538,7 +560,7 @@ public class Characteristic { } channelType = builder.build(); } else { - channelType = ChannelTypeBuilder.trigger(uid, CHANNEL_TYPE_LABEL).build(); + channelType = ChannelTypeBuilder.trigger(uid, label).build(); } // persist the channel _type_ @@ -564,7 +586,7 @@ public class Characteristic { public @Nullable CharacteristicType getCharacteristicType() { try { - return CharacteristicType.from(Integer.parseInt(type)); + return CharacteristicType.from(Integer.parseInt(type, 16)); } catch (IllegalArgumentException e) { return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index f582ca062d6d5..ddd4ce91cfd36 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -63,8 +63,8 @@ public class Service { } ChannelGroupTypeUID groupTypeUID = new ChannelGroupTypeUID(BINDING_ID, serviceType.getChannelTypeId()); - ChannelGroupType groupType = ChannelGroupTypeBuilder.instance(groupTypeUID, GROUP_TYPE_LABEL) // - .withDescription(serviceType.toString()) // + String label = GROUP_TYPE_LABEL_FMT.formatted(serviceType.toString()); + ChannelGroupType groupType = ChannelGroupTypeBuilder.instance(groupTypeUID, label) // .withChannelDefinitions(channelDefinitions) // .build(); @@ -75,14 +75,18 @@ public class Service { public @Nullable ServiceType getServiceType() { try { - return ServiceType.from(Integer.parseInt(type)); + return ServiceType.from(Integer.parseInt(type, 16)); } catch (IllegalArgumentException e) { return null; } } + public @Nullable Characteristic getCharacteristic(Integer iid) { + return characteristics.stream().filter(c -> iid.equals(c.iid)).findFirst().orElse(null); + } + @Override public String toString() { - return getServiceType() instanceof ServiceType st ? st.getType() : "Unknown"; + return getServiceType() instanceof ServiceType st ? st.getTypeName() : "Unknown"; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index 255a795f4e723..ae67af10a110a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -12,6 +12,9 @@ */ package org.openhab.binding.homekit.internal.enums; +import java.util.Arrays; +import java.util.stream.Collectors; + import org.eclipse.jdt.annotation.NonNullByDefault; /** @@ -181,15 +184,12 @@ public String getType() { } /** - * Returns the name of the enum constant in `First Letter Capitals`. + * Returns the name of the enum constant in Title Case. */ @Override public String toString() { - String[] parts = name().toLowerCase().split("_"); - StringBuilder builder = new StringBuilder(parts[0]); - for (int i = 1; i < parts.length; i++) { - builder.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1)); - } - return builder.toString(); + return Arrays.stream(name().split("_")).map( + word -> word.isEmpty() ? word : Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase()) + .collect(Collectors.joining(" ")); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index 2a19e4682126d..2438db1e44771 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -12,6 +12,9 @@ */ package org.openhab.binding.homekit.internal.enums; +import java.util.Arrays; +import java.util.stream.Collectors; + import org.eclipse.jdt.annotation.NonNullByDefault; /** @@ -36,7 +39,7 @@ public enum ServiceType { HEATER_COOLER(0xBC, "public.hap.service.heater-cooler"), HUMIDIFIER_DEHUMIDIFIER(0xBD, "public.hap.service.humidifier-dehumidifier"), IRRIGATION_SYSTEM(0xCF, "public.hap.service.irrigation-system"), - LIGHTBULB(0x43, "public.hap.service.lightbulb"), + LIGHT_BULB(0x43, "public.hap.service.lightbulb"), LOCK_MANAGEMENT(0x44, "public.hap.service.lock-management"), LOCK_MECHANISM(0x45, "public.hap.service.lock-mechanism"), MICROPHONE(0x112, "public.hap.service.microphone"), @@ -68,41 +71,38 @@ public enum ServiceType { WINDOW(0x8B, "public.hap.service.window"), WINDOW_COVERING(0x8C, "public.hap.service.window-covering"); - private final int id; - private final String type; + private final int type; + private final String typeName; - ServiceType(int id, String type) { - this.id = id; + ServiceType(int type, String typeName) { this.type = type; + this.typeName = typeName; } - public static ServiceType from(int id) throws IllegalArgumentException { + public static ServiceType from(int type) throws IllegalArgumentException { for (ServiceType value : values()) { - if (value.id == id) { + if (value.type == type) { return value; } } - throw new IllegalArgumentException("Unknown ID: " + id); + throw new IllegalArgumentException("Unknown ID: " + type); } public String getChannelTypeId() { - return type.replace("-", "_").replace(".", "-"); // convert to OH channel type format + return typeName.replace("-", "_").replace(".", "-"); // convert to OH channel type format } - public String getType() { - return type; + public String getTypeName() { + return typeName; } /** - * Returns the name of the enum constant in `First Letter Capitals`. + * Returns the name of the enum constant in Title Case. */ @Override public String toString() { - String[] parts = name().toLowerCase().split("_"); - StringBuilder builder = new StringBuilder(parts[0]); - for (int i = 1; i < parts.length; i++) { - builder.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1)); - } - return builder.toString(); + return Arrays.stream(name().split("_")).map( + word -> word.isEmpty() ? word : Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase()) + .collect(Collectors.joining(" ")); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index adfdc069425af..b5e93185161ac 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -25,14 +25,16 @@ import java.util.stream.Collectors; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.crypto.SRPclient; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; +import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; +import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; import org.openhab.binding.homekit.internal.hap_services.PairingRemoveService; -import org.openhab.binding.homekit.internal.hap_services.PairingSetupService; -import org.openhab.binding.homekit.internal.hap_services.PairingVerifyService; import org.openhab.binding.homekit.internal.session.SecureSession; import org.openhab.binding.homekit.internal.session.SessionKeys; import org.openhab.binding.homekit.internal.transport.HttpTransport; @@ -140,16 +142,21 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override public void handleRemoval() { - super.handleRemoval(); - if (!isChildAccessory) { - // unpair and clear stored keys if this is NOT a child accessory - try { - new PairingRemoveService(httpTransport, baseUrl, sessionKeys, thing.getUID().toString()).remove(); - this.controllerPrivateKey = null; - storeControllerPrivateKey(); - } catch (Exception e) { - logger.warn("Failed to remove pairing for accessory {}", accessoryId); - } + if (isChildAccessory) { + updateStatus(ThingStatus.REMOVED); + } else { + updateStatus(ThingStatus.REMOVING); + scheduler.submit(() -> { + // unpair and clear stored keys if this is NOT a child accessory + try { + new PairingRemoveService(httpTransport, baseUrl, sessionKeys, thing.getUID().toString()).remove(); + this.controllerPrivateKey = null; + storeControllerPrivateSigningKey(); + updateStatus(ThingStatus.REMOVED); + } catch (Exception e) { + logger.warn("Failed to remove pairing for accessory {}", accessoryId); + } + }); } } @@ -186,14 +193,17 @@ private void initializePairing() { return; } - restoreControllerPrivateKey(); - Ed25519PrivateKeyParameters controllerPrivateKey = this.controllerPrivateKey; + restoreControllerPrivateSigningKey(); + Ed25519PrivateKeyParameters controllerPrivateSigningKey = this.controllerPrivateKey; + Ed25519PublicKeyParameters TODO_serverPublicSigningKey = controllerPrivateSigningKey.generatePublicKey(); // TODO - if (controllerPrivateKey != null) { + if (controllerPrivateSigningKey != null) { // Perform Pair-Verify with existing key try { - this.sessionKeys = new PairingVerifyService(httpTransport, baseUrl, accessoryId.toString(), - controllerPrivateKey).verify(); + PairVerifyClient client = new PairVerifyClient(httpTransport, baseUrl, accessoryId.toString(), + controllerPrivateSigningKey, TODO_serverPublicSigningKey); + + this.sessionKeys = client.verify(); this.session = new SecureSession(sessionKeys); this.rwService = new CharacteristicReadWriteService(httpTransport, session, baseUrl); @@ -205,28 +215,32 @@ private void initializePairing() { } catch (Exception e) { logger.debug("Restored pairing was not verified for accessory {}", accessoryId); this.controllerPrivateKey = null; - storeControllerPrivateKey(); + storeControllerPrivateSigningKey(); // fall through to create new pairing } } // Create new controller private key - controllerPrivateKey = new Ed25519PrivateKeyParameters(new SecureRandom()); + controllerPrivateSigningKey = new Ed25519PrivateKeyParameters(new SecureRandom()); logger.debug("Created new controller private key for accessory {}", accessoryId); try { // Perform Pair-Setup - this.sessionKeys = new PairingSetupService(httpTransport, baseUrl, pairingCode, controllerPrivateKey, - thing.getUID().toString()).pair(); + PairSetupClient pairSetupClient = new PairSetupClient(httpTransport, baseUrl, thing.getUID().toString(), + controllerPrivateSigningKey, SRPclient.PAIR_SETUP, pairingCode); + + this.sessionKeys = pairSetupClient.pair(); // Perform Pair-Verify immediately after Pair-Setup - this.sessionKeys = new PairingVerifyService(httpTransport, baseUrl, accessoryId.toString(), - controllerPrivateKey).verify(); + PairVerifyClient pairVerifyClient = new PairVerifyClient(httpTransport, baseUrl, accessoryId.toString(), + controllerPrivateSigningKey, TODO_serverPublicSigningKey); + + this.sessionKeys = pairVerifyClient.verify(); this.session = new SecureSession(sessionKeys); this.rwService = new CharacteristicReadWriteService(httpTransport, session, baseUrl); - this.controllerPrivateKey = controllerPrivateKey; - storeControllerPrivateKey(); + this.controllerPrivateKey = controllerPrivateSigningKey; + storeControllerPrivateSigningKey(); updateStatus(ThingStatus.ONLINE); logger.debug("Pairing and verification completed for accessory {}", accessoryId); @@ -240,7 +254,7 @@ private void initializePairing() { * Restores the controller's private key from the thing's properties. * The private key is expected to have been stored as a Base64-encoded string. */ - private void restoreControllerPrivateKey() { + private void restoreControllerPrivateSigningKey() { String encoded = thing.getProperties().get(PROPERTY_CONTROLLER_PRIVATE_KEY); controllerPrivateKey = encoded == null ? null : new Ed25519PrivateKeyParameters(Base64.getDecoder().decode(encoded), 0); @@ -250,7 +264,7 @@ private void restoreControllerPrivateKey() { * Stores the controller's private key in the thing's properties. * The private key is stored as a Base64-encoded string. */ - private void storeControllerPrivateKey() { + private void storeControllerPrivateSigningKey() { Ed25519PrivateKeyParameters controllerPrivateKey = this.controllerPrivateKey; String property = controllerPrivateKey == null ? null : Base64.getEncoder().encodeToString(controllerPrivateKey.getEncoded()); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index e27388cde9b50..89f05297456db 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -57,6 +57,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import tech.units.indriya.format.SimpleUnitFormat; + /** * Handles a single HomeKit accessory. * It provides a polling mechanism to regularly update the state of the accessory. @@ -221,7 +223,7 @@ private Object convertCommandToObject(Command command, Channel channel) { // convert QuantityTypes to the characteristic's unit if (object instanceof QuantityType quantity) { - Unit unit = UnitUtils.parseUnit(Optional.ofNullable(properties.get("unit")).orElse(null)); + Unit unit = properties.get("unit") instanceof String p ? SimpleUnitFormat.getInstance().parse(p) : null; if (unit != null && !unit.equals(quantity.getUnit()) && quantity.getUnit().isCompatible(unit)) { QuantityType temp = quantity.toUnit(unit); object = temp != null ? temp : quantity; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java new file mode 100644 index 0000000000000..836979cc6b45d --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.hap_services; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeoutException; + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.crypto.SRPclient; +import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.PairingMethod; +import org.openhab.binding.homekit.internal.enums.PairingState; +import org.openhab.binding.homekit.internal.enums.TlvType; +import org.openhab.binding.homekit.internal.session.SessionKeys; +import org.openhab.binding.homekit.internal.transport.HttpTransport; + +/** + * Handles the 6-step pairing process with a HomeKit accessory. + * Uses SRP for secure key exchange and derives session keys. + * Communicates with the accessory using HTTP and TLV8 encoding. + * Requires the accessory's setup code for pairing. + * Returns session keys upon successful pairing. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class PairSetupClient { + + private static final String ENDPOINT_PAIR_SETUP = "/pair-setup"; + + private static final String CONTENT_TYPE_TLV8 = "application/pairing+tlv8"; + private final HttpTransport httpTransport; + private final String baseUrl; + private final String password; + private final String username; + private final Ed25519PrivateKeyParameters clientPrivateSigningKey; + private final String accessoryIdentifier; + + private @NonNullByDefault({}) SRPclient client = null; + + public PairSetupClient(HttpTransport httpTransport, String baseUrl, String accessoryIdentifier, + Ed25519PrivateKeyParameters clientPrivateSigningKey, String username, String password) throws Exception { + this.httpTransport = httpTransport; + this.baseUrl = baseUrl; + this.password = password; + this.username = username; + this.clientPrivateSigningKey = clientPrivateSigningKey; + this.accessoryIdentifier = accessoryIdentifier; + } + + /** + * Executes the 6-step pairing process with the accessory. + * + * @return SessionKeys containing the derived session keys + * @throws Exception if any step of the pairing process fails + */ + public SessionKeys pair() throws Exception { + byte[] response; + + // Execute the 6-step pairing process + response = doClientStepM1(); + doClientStepM2(response); + response = doClientStepM3(); + doClientStepM4(response); + response = doClientStepM5(); + doClientStepM6(response); + + return client.deriveSessionKeys(); + } + + /** + * Returns the SRP public key generated during the pairing process. + * + * @return byte array containing the SRP public key + * @throws IllegalStateException if the SRP client is not initialized + */ + public byte[] getPublicKey() throws IllegalStateException { + SRPclient client = this.client; + if (client == null) { + throw new IllegalStateException("SRP Client not initialized"); + } + return client.getPublicKey(); + } + + /** + * Executes step M1 of the pairing process: Start Pair-Setup. + * + * @return byte array containing the response from the accessory + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + * @throws Exception if an error occurs during execution + */ + private byte[] doClientStepM1() throws Exception { + Map tlv = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M1.value }, // + TlvType.METHOD.key, new byte[] { PairingMethod.SETUP.value }); + Validator.validate(PairingMethod.SETUP, tlv); + + return httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + } + + /** + * Executes step M2 of the pairing process: Receive salt & accessory SRP public key. + * And initializes the SRP client with the received parameters. + * + * @param response byte array containing the response from step M1 + * @throws Exception if an error occurs during processing + */ + private void doClientStepM2(byte[] response) throws Exception { + Map tlv = Tlv8Codec.decode(response); + Validator.validate(PairingMethod.SETUP, tlv); + + byte[] serverSalt = tlv.get(TlvType.SALT.key); + byte[] serverPublicKey = tlv.get(TlvType.PUBLIC_KEY.key); + if (serverSalt == null || serverPublicKey == null) { + throw new SecurityException("Missing salt or public key TLV in M2 response"); + } + SRPclient client = new SRPclient(username, password, serverSalt); + client.processChallenge(serverPublicKey); + + this.client = client; + } + + /** + * Executes step M3 of the pairing process: Send client SRP public key & proof. + * + * @return byte array containing the response from the accessory + * @throws Exception if an error occurs during processing + */ + private byte[] doClientStepM3() throws Exception { + SRPclient client = this.client; + if (client == null) { + throw new IllegalStateException("SrpClient not initialized"); + } + Map tlv = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M3.value }, // + TlvType.PUBLIC_KEY.key, client.getPublicKey(), // + TlvType.PROOF.key, client.getClientProof()); + Validator.validate(PairingMethod.SETUP, tlv); + + return httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + } + + /** + * Executes step M4 of the pairing process: Verify accessory SRP proof. + * + * @param response byte array containing the response from step M3 + * @throws Exception if an error occurs during processing + */ + private void doClientStepM4(byte[] response) throws Exception { + SRPclient client = this.client; + if (client == null) { + throw new IllegalStateException("SrpClient not initialized"); + } + Map tlv = Tlv8Codec.decode(response); + Validator.validate(PairingMethod.SETUP, tlv); + + byte[] proof = tlv.get(TlvType.PROOF.key); + if (proof == null) { + throw new SecurityException("Missing proof TLV in M4 response"); + } + client.verifyServerProof(proof); + } + + /** + * Executes step M5 of the pairing process: Exchange encrypted identifiers. + * + * @return byte array containing the response from the accessory + * @throws Exception if an error occurs during processing + */ + private byte[] doClientStepM5() throws Exception { + byte[] encryptedIdentifiers = client.getEncryptedDeviceInfoBlob(client.deriveIOSDeviceXKey(), + accessoryIdentifier, clientPrivateSigningKey.generatePublicKey(), clientPrivateSigningKey); + Map tlv = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M5.value }, // + TlvType.ENCRYPTED_DATA.key, encryptedIdentifiers); + Validator.validate(PairingMethod.SETUP, tlv); + + return httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + } + + /** + * Executes step M6 of the pairing process: Final confirmation & accessory credentials. + * + * @param response byte array containing the response from step M5 + * @throws Exception if an error occurs during processing + */ + private void doClientStepM6(byte[] response) throws Exception { + SRPclient client = this.client; + if (client == null) { + throw new IllegalStateException("SrpClient not initialized"); + } + Map tlv = Tlv8Codec.decode(response); + Validator.validate(PairingMethod.SETUP, tlv); + + byte[] data = tlv.get(TlvType.ENCRYPTED_DATA.key); + if (data == null) { + throw new SecurityException("Missing data TLV in M6 response"); + } + client.verifyAccessoryIdentifiers(data); + } + + /** + * Helper that validates the TLV map for the specification required pairing state. + */ + public static class Validator { + + private static final Map> SPECIFICATION_REQUIRED_KEYS = Map.of( // + PairingState.M1, Set.of(TlvType.STATE.key, TlvType.METHOD.key), // + PairingState.M2, Set.of(TlvType.STATE.key, TlvType.SALT.key, TlvType.PUBLIC_KEY.key), // + PairingState.M3, Set.of(TlvType.STATE.key, TlvType.PUBLIC_KEY.key, TlvType.PROOF.key), // + PairingState.M4, Set.of(TlvType.STATE.key, TlvType.PROOF.key), // + PairingState.M5, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key), // + PairingState.M6, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key)); + + /** + * Validates the TLV map for the specification required pairing state. + * + * @throws SecurityException if required keys are missing or state is invalid + */ + public static void validate(PairingMethod method, Map tlv) throws SecurityException { + if (tlv.containsKey(TlvType.ERROR.key)) { + throw new SecurityException( + "Pairing method '%s' action failed with unknown error".formatted(method.name())); + } + + byte[] stateBytes = tlv.get(TlvType.STATE.key); + if (stateBytes == null || stateBytes.length != 1) { + throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); + } + + PairingState state = PairingState.from(stateBytes[0]); + Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); + + if (expectedKeys == null) { + throw new SecurityException( + "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); + } + + for (Integer key : expectedKeys) { + if (!tlv.containsKey(key)) { + throw new SecurityException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." + .formatted(method.name(), state.name(), key)); + } + } + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java similarity index 52% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index d311e247ba8e6..ae47392c1f420 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingVerifyService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -13,12 +13,16 @@ package org.openhab.binding.homekit.internal.hap_services; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.Map; +import java.util.Objects; import java.util.Set; -import org.bouncycastle.crypto.AsymmetricCipherKeyPair; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; import org.bouncycastle.crypto.params.X25519PublicKeyParameters; +import org.bouncycastle.crypto.signers.Ed25519Signer; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; @@ -34,7 +38,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class PairingVerifyService { +public class PairVerifyClient { private static final String PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info"; private static final String PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt"; @@ -43,73 +47,95 @@ public class PairingVerifyService { private static final String CONTROL_WRITE_ENCRYPTION_KEY = "Control-Write-Encryption-Key"; private static final String CONTROL_READ_ENCRYPTION_KEY = "Control-Read-Encryption-Key"; private static final String CONTROL_SALT = "Control-Salt"; - private static final byte[] NONCE_M2 = CryptoUtils.generateNonce("PV-Msg02"); - private static final byte[] NONCE_M3 = CryptoUtils.generateNonce("PV-Msg03"); + private static final byte[] VERIFY_NONCE_M2 = CryptoUtils.generateNonce("PV-Msg02"); + private static final byte[] VERIFY_NONCE_M3 = CryptoUtils.generateNonce("PV-Msg03"); private final HttpTransport httpTransport; private final String baseUrl; - private final byte[] accessoryIdentifier; - private final Ed25519PrivateKeyParameters controllerPrivateKey; + private final byte[] clientIdentifier; + private final Ed25519PrivateKeyParameters clientPrivateSigningKey; + private final Ed25519PublicKeyParameters serverPublicSigningKey; - public PairingVerifyService(HttpTransport httpTransport, String baseUrl, String accessoryIdentifier, - Ed25519PrivateKeyParameters controllerPrivateKey) { + public PairVerifyClient(HttpTransport httpTransport, String baseUrl, String clientIdentifier, + Ed25519PrivateKeyParameters clientPrivateSigningKey, Ed25519PublicKeyParameters serverPublicSigningKey) { this.httpTransport = httpTransport; this.baseUrl = baseUrl; - this.accessoryIdentifier = accessoryIdentifier.getBytes(StandardCharsets.UTF_8); - this.controllerPrivateKey = controllerPrivateKey; + this.clientIdentifier = clientIdentifier.getBytes(StandardCharsets.UTF_8); + this.clientPrivateSigningKey = clientPrivateSigningKey; + this.serverPublicSigningKey = serverPublicSigningKey; } + /** + * Executes the 4-step pairing verification process with the accessory. + * + * @return SessionKeys containing the derived session keys + * @throws Exception if any step of the pairing process fails + */ public SessionKeys verify() throws Exception { - // M1 — Create controller ephemeral public key and send it to accessory - AsymmetricCipherKeyPair controllerEphemeralKeys = CryptoUtils.generateCurve25519KeyPair(); - byte[] controllerEphemeralPublicKeyBytes; - if (controllerEphemeralKeys.getPublic() instanceof X25519PublicKeyParameters x25519) { - controllerEphemeralPublicKeyBytes = x25519.getEncoded(); - } else { - throw new IllegalStateException("Generated controller ephemeral public key is not X25519"); - } - Map tlv1 = Map.of( // + Map tlv; + byte[] encoded; + byte[] response; + byte[] encrypted; + byte[] decrypted; + + // M1 — Create new random client ephemeral X25519 public key and send it to server + X25519PrivateKeyParameters clientKey = CryptoUtils.generateX25519KeyPair(); + byte[] clientKeyBytes = clientKey.generatePublicKey().getEncoded(); + tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // - // Per HAP Section 5.6.2, the METHOD TLV is not required in the M1 message of pair-verify - TlvType.PUBLIC_KEY.key, controllerEphemeralPublicKeyBytes); - Validator.validate(PairingMethod.VERIFY, tlv1); - - byte[] resp1 = httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv1)); - - // M2 — Receive accessory ephemeral public key and encrypted TLV - Map tlv2 = Tlv8Codec.decode(resp1); - Validator.validate(PairingMethod.VERIFY, tlv2); - - byte[] accessoryPublicKeyBytes = tlv2.getOrDefault(TlvType.PUBLIC_KEY.key, new byte[0]); - byte[] encrypted = tlv2.getOrDefault(TlvType.ENCRYPTED_DATA.key, new byte[0]); + TlvType.PUBLIC_KEY.key, clientKeyBytes); + Validator.validate(PairingMethod.VERIFY, tlv); + encoded = Tlv8Codec.encode(tlv); + response = httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, encoded); + + // M2 — Receive server ephemeral X25519 public key and encrypted TLV + tlv = Tlv8Codec.decode(response); + Validator.validate(PairingMethod.VERIFY, tlv); + byte[] serverKeyBytes = tlv.get(TlvType.PUBLIC_KEY.key); + X25519PublicKeyParameters serverKey = new X25519PublicKeyParameters(serverKeyBytes, 0); + + byte[] sharedSecret = CryptoUtils.computeSharedSecret(clientKey, serverKey); + byte[] sessionKey = CryptoUtils.hkdf(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); - X25519PublicKeyParameters accessoryEphemeralKeys = new X25519PublicKeyParameters(accessoryPublicKeyBytes, 0); - byte[] sharedSecret = CryptoUtils.computeSharedSecret(controllerEphemeralKeys.getPrivate(), - accessoryEphemeralKeys); + encrypted = tlv.get(TlvType.ENCRYPTED_DATA.key); + decrypted = CryptoUtils.decrypt(sessionKey, VERIFY_NONCE_M2, Objects.requireNonNull(encrypted)); + tlv = Tlv8Codec.decode(decrypted); // inner tlv - byte[] sessionKey = CryptoUtils.hkdf(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); - byte[] decrypted = CryptoUtils.decrypt(sessionKey, NONCE_M2, encrypted); - Map innerTLV = Tlv8Codec.decode(decrypted); - CryptoUtils.validateAccessory(innerTLV); // validates identifier + signature + // validate identifier + signature + byte[] identifier = tlv.get(TlvType.IDENTIFIER.key); + byte[] signature = tlv.get(TlvType.SIGNATURE.key); + if (identifier == null || signature == null) { + throw new SecurityException("Accessory identifier or signature missing"); + } + Ed25519Signer verifier = new Ed25519Signer(); + verifier.init(false, serverPublicSigningKey); + verifier.update(identifier, 0, identifier.length); + boolean valid = verifier.verifySignature(signature); + if (!valid) { + throw new SecurityException("Accessory signature verification failed"); + } + System.out.println("Verified accessory identifier: " + new String(identifier, StandardCharsets.UTF_8)); // M3 — Send encrypted controller identifier and signature - byte[] verifyPayload = concat(controllerEphemeralPublicKeyBytes, accessoryPublicKeyBytes); - byte[] signature = CryptoUtils.signVerifyMessage(controllerPrivateKey, verifyPayload); - byte[] controllerInfo = Tlv8Codec.encode(Map.of( // - TlvType.IDENTIFIER.key, accessoryIdentifier, // - TlvType.SIGNATURE.key, signature)); - byte[] encryptedM3 = CryptoUtils.encrypt(sessionKey, NONCE_M3, controllerInfo); - - Map tlv3 = Map.of( // + byte[] payload = concat(clientKeyBytes, serverKeyBytes); + signature = CryptoUtils.signVerifyMessage(clientPrivateSigningKey, payload); + tlv = Map.of( // + TlvType.IDENTIFIER.key, clientIdentifier, // + TlvType.SIGNATURE.key, signature); + encoded = Tlv8Codec.encode(tlv); + encrypted = CryptoUtils.encrypt(sessionKey, VERIFY_NONCE_M3, encoded); + + tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M3.value }, // - TlvType.ENCRYPTED_DATA.key, encryptedM3); - Validator.validate(PairingMethod.VERIFY, tlv3); + TlvType.ENCRYPTED_DATA.key, encrypted); + Validator.validate(PairingMethod.VERIFY, tlv); - byte[] resp3 = httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv3)); + encoded = Tlv8Codec.encode(tlv); + response = httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, encoded); // M4 — Final confirmation - Map tlv4 = Tlv8Codec.decode(resp3); - Validator.validate(PairingMethod.VERIFY, tlv4); + tlv = Tlv8Codec.decode(response); + Validator.validate(PairingMethod.VERIFY, tlv); // Derive directional session keys byte[] readKey = CryptoUtils.hkdf(sharedSecret, CONTROL_SALT, CONTROL_READ_ENCRYPTION_KEY); @@ -118,17 +144,21 @@ public SessionKeys verify() throws Exception { return new SessionKeys(readKey, writeKey); } - private static byte[] concat(byte[] a, byte[] b) { - byte[] out = new byte[a.length + b.length]; - System.arraycopy(a, 0, out, 0, a.length); - System.arraycopy(b, 0, out, a.length, b.length); + private static byte[] concat(byte[]... parts) { + int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); + byte[] out = new byte[total]; + int pos = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, pos, p.length); + pos += p.length; + } return out; } /** * Helper that validates the TLV map for the specification required pairing state. */ - protected static class Validator { + public static class Validator { private static final Map> SPECIFICATION_REQUIRED_KEYS = Map.of( // PairingState.M1, Set.of(TlvType.STATE.key, TlvType.PUBLIC_KEY.key), // TLVType.METHOD not required diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingSetupService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingSetupService.java deleted file mode 100644 index 102e8f13e9ab6..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingSetupService.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.hap_services; - -import java.util.Map; -import java.util.Set; - -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.crypto.SrpClient; -import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; -import org.openhab.binding.homekit.internal.enums.PairingMethod; -import org.openhab.binding.homekit.internal.enums.PairingState; -import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.session.SessionKeys; -import org.openhab.binding.homekit.internal.transport.HttpTransport; - -/** - * Handles the 6-step pairing process with a HomeKit accessory. - * Uses SRP for secure key exchange and derives session keys. - * Communicates with the accessory using HTTP and TLV8 encoding. - * Requires the accessory's setup code for pairing. - * Returns session keys upon successful pairing. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class PairingSetupService { - - private static final String ENDPOINT_PAIR_SETUP = "/pair-setup"; - private static final String CONTENT_TYPE_TLV8 = "application/pairing+tlv8"; - - private final HttpTransport httpTransport; - private final SrpClient srpClient; - private final String baseUrl; - private final Ed25519PrivateKeyParameters controllerPrivateKey; - - public PairingSetupService(HttpTransport httpTransport, String baseUrl, String accessoryPairingCode, - Ed25519PrivateKeyParameters controllerPrivateKey, String controllerUniqueId) { - this.httpTransport = httpTransport; - this.baseUrl = baseUrl; - this.srpClient = new SrpClient(accessoryPairingCode, controllerUniqueId); - this.controllerPrivateKey = controllerPrivateKey; - } - - public SessionKeys pair() throws Exception { - // M1 — Start Pair-Setup - Map tlv1 = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M1.value }, // - TlvType.METHOD.key, new byte[] { PairingMethod.SETUP.value }); - Validator.validate(PairingMethod.SETUP, tlv1); - byte[] resp1 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv1)); - - // M2 — Receive salt & accessory SRP public key - Map tlv2 = Tlv8Codec.decode(resp1); - Validator.validate(PairingMethod.SETUP, tlv2); - byte[] salt = tlv2.get(TlvType.SALT.key); - byte[] key = tlv2.get(TlvType.PUBLIC_KEY.key); - if (salt == null || key == null) { - throw new SecurityException("Missing salt public key TLV in M2 response"); - } - srpClient.processChallenge(salt, key); - - // M3 — Send client SRP public key & proof - Map tlv3 = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M3.value }, // - TlvType.PUBLIC_KEY.key, srpClient.getPublicKey(), // - TlvType.PROOF.key, srpClient.getClientProof()); - Validator.validate(PairingMethod.SETUP, tlv3); - byte[] resp3 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv3)); - - // M4 — Verify accessory SRP proof - Map tlv4 = Tlv8Codec.decode(resp3); - Validator.validate(PairingMethod.SETUP, tlv4); - byte[] proof = tlv4.get(TlvType.PROOF.key); - if (proof == null) { - throw new SecurityException("Missing proof TLV in M4 response"); - } - srpClient.verifyServerProof(proof); - - // M5 — Exchange encrypted identifiers - byte[] encryptedIdentifiers = srpClient.getEncryptedIdentifiers(controllerPrivateKey); - Map tlv5 = Map.of(TlvType.STATE.key, new byte[] { PairingState.M5.value }, - TlvType.ENCRYPTED_DATA.key, encryptedIdentifiers); - Validator.validate(PairingMethod.SETUP, tlv5); - byte[] resp5 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv5)); - - // M6 — Final confirmation & accessory credentials - Map tlv6 = Tlv8Codec.decode(resp5); - Validator.validate(PairingMethod.SETUP, tlv6); - byte[] data = tlv6.get(TlvType.ENCRYPTED_DATA.key); - if (data == null) { - throw new SecurityException("Missing data TLV in M6 response"); - } - srpClient.verifyAccessoryIdentifiers(data); - - // Derive and return session keys - return srpClient.deriveSessionKeys(); - } - - /** - * Helper that validates the TLV map for the specification required pairing state. - */ - protected static class Validator { - - private static final Map> SPECIFICATION_REQUIRED_KEYS = Map.of( // - PairingState.M1, Set.of(TlvType.STATE.key, TlvType.METHOD.key), // - PairingState.M2, Set.of(TlvType.STATE.key, TlvType.SALT.key, TlvType.PUBLIC_KEY.key), // - PairingState.M3, Set.of(TlvType.STATE.key, TlvType.PUBLIC_KEY.key, TlvType.PROOF.key), // - PairingState.M4, Set.of(TlvType.STATE.key, TlvType.PROOF.key), // - PairingState.M5, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key), // - PairingState.M6, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key)); - - /** - * Validates the TLV map for the specification required pairing state. - * - * @throws SecurityException if required keys are missing or state is invalid - */ - public static void validate(PairingMethod method, Map tlv) throws SecurityException { - if (tlv.containsKey(TlvType.ERROR.key)) { - throw new SecurityException( - "Pairing method '%s' action failed with unknown error".formatted(method.name())); - } - - byte[] stateBytes = tlv.get(TlvType.STATE.key); - if (stateBytes == null || stateBytes.length != 1) { - throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); - } - - PairingState state = PairingState.from(stateBytes[0]); - Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); - - if (expectedKeys == null) { - throw new SecurityException( - "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); - } - - for (Integer key : expectedKeys) { - if (!tlv.containsKey(key)) { - throw new SecurityException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." - .formatted(method.name(), state.name(), key)); - } - } - } - } -} diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java new file mode 100644 index 0000000000000..405292eb789a4 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Map; + +import org.bouncycastle.crypto.digests.SHA512Digest; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.HKDFParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; +import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.TlvType; + +/** + * Simulated Stanford Secure Remote Protocol test server used for JUnits tests. + * The implementation is intentionally separate from the Client implementation in order avoid self referencing tests. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class SRPserver { + + // Constants (HomeKit SRP-6a) + private static final BigInteger N = new BigInteger(""" + FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 + 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 + 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED + EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05 + 98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB + 9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B + E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718 + 3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33 + A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7 + ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864 + D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2 + 08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF + """.replaceAll("\\s+", ""), 16); + + private static final BigInteger g = BigInteger.valueOf(5); + private static final BigInteger k = computeK(); + private static final SecureRandom random = new SecureRandom(); + + // HomeKit‐specific constants for HKDF/ChaCha20‐Poly1305 + private static final byte[] PAIR_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); + private static final byte[] PAIR_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); + private static final byte[] PAIR_NONCE_M6 = CryptoUtils.generateNonce("PS-Msg06"); + + // Session state + private final String I; // username + private final byte[] s; // salt + private final BigInteger v; // verifier + private final BigInteger b; // private SRP key ephemeral value + private final BigInteger B; // public SRP key ephemeral value + + private byte @Nullable [] K = null; + private byte @Nullable [] clientPublicSigningKey = null; + + public SRPserver(String username, String password, byte[] salt) throws Exception { + this.I = username; + this.s = salt; + + // Compute verifier once + byte[] hIP = sha512((username + ":" + password).getBytes(StandardCharsets.UTF_8)); + BigInteger x = new BigInteger(1, sha512(concat(salt, hIP))); + this.v = g.modPow(x, N); + + // Generate ephemeral b and compute public B + this.b = new BigInteger(N.bitLength(), random).mod(N); + BigInteger gb = g.modPow(b, N); + this.B = k.multiply(v).add(gb).mod(N); + } + + /** + * * M2 — Get server public key B + * + * @return Server public key B + */ + public byte[] getPublicKey() { + return toUnsigned(B, N); + } + + /** + * M4 — Compute server proof M2 = H(A || M1 || K) + * + * @param clientPublicSigningKey Client Curve25519 public key A (32 bytes) + * @return Server proof M2 + */ + public byte[] computeServerProof(byte[] clientPublicSigningKey) throws Exception { + this.clientPublicSigningKey = clientPublicSigningKey; + BigInteger clientPublic = new BigInteger(1, clientPublicSigningKey); + if (clientPublic.mod(N).equals(BigInteger.ZERO)) { + throw new SecurityException("Invalid client public key"); + } + + // Compute u = H(PAD(A) || PAD(B)) + byte[] uHash = sha512(concat(toUnsigned(clientPublic, N), toUnsigned(B, N))); + BigInteger u = new BigInteger(1, uHash); + if (u.equals(BigInteger.ZERO)) { + throw new SecurityException("Invalid scrambling parameter"); + } + + // Compute shared secret S = (A * v^u)^b mod N + BigInteger vu = v.modPow(u, N); + BigInteger S = clientPublic.multiply(vu).mod(N).modPow(b, N); + this.K = sha512(toUnsigned(S, N)); + + // Compute M1 = H(H(N) ⊕ H(g) || H(I) || salt || A || B || K) + byte[] HN = sha512(toUnsigned(N, N)); + byte[] Hg = sha512(toUnsigned(g, N)); + byte[] Hxor = xor(HN, Hg); + byte[] HI = sha512(I.getBytes(StandardCharsets.UTF_8)); + + byte[] M1 = sha512(concat(Hxor, HI, s, toUnsigned(clientPublic, N), toUnsigned(B, N), K)); + + // Compute M2 = H(A || M1 || K) + return sha512(concat(toUnsigned(clientPublic, N), M1, K)); + } + + /** + * M6 — Encrypt accessory identifier and Curve25519 public key. And sign the TLV with Ed25519 key. + * + * @param serverIdentifier UTF-8 string identifier of the accessory + * @param serverPublicSigningKey Curve25519 public key (32 bytes) + * @param accessoryPrivateKey Ed25519 private key for signing the TLV + * @return encrypted TLV payload for M6 + */ + public byte[] createEncryptedData(String serverIdentifier, byte[] serverPublicSigningKey, + Ed25519PrivateKeyParameters accessoryPrivateKey) throws Exception { + if (K == null) { + throw new IllegalStateException("Session key K not established"); + } + // 1) Build sub-TLV with controller signing public key, pairing identifier, nd server signing public key + byte[] complexIdentifier = concat(clientPublicSigningKey, serverIdentifier.getBytes(StandardCharsets.UTF_8), + serverPublicSigningKey); + Map subTlv = Map.of( // + TlvType.IDENTIFIER.key, complexIdentifier, // + TlvType.PUBLIC_KEY.key, serverPublicSigningKey); + + // 2) Encode and sign the TLV + byte[] message = Tlv8Codec.encode(subTlv); + byte[] signature = CryptoUtils.signVerifyMessage(accessoryPrivateKey, message); + subTlv.put(TlvType.SIGNATURE.key, signature); + + // 3) Re-encode signed TLV + byte[] plaintext = Tlv8Codec.encode(subTlv); + + // 4) Derive session key using HKDF(S, salt, info) + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); + hkdf.init(new HKDFParameters(K, PAIR_SALT, PAIR_INFO)); + byte[] sessionKey = new byte[32]; + hkdf.generateBytes(sessionKey, 0, sessionKey.length); + + // 5) Encrypt using ChaCha20-Poly1305 + return CryptoUtils.encrypt(sessionKey, PAIR_NONCE_M6, plaintext); + } + + // ─── Utility Methods ────────────────────────────────────────────────────── + + private static BigInteger computeK() { + try { + byte[] paddedN = toUnsigned(N, N); + byte[] paddedG = toUnsigned(g, N); + byte[] hash = sha512(concat(paddedN, paddedG)); + return new BigInteger(1, hash); + } catch (Exception e) { + throw new RuntimeException("Failed to compute k", e); + } + } + + private static byte[] sha512(byte[] data) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-512"); + return md.digest(data); + } + + private static byte[] concat(byte[]... parts) { + int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); + byte[] out = new byte[total]; + int pos = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, pos, p.length); + pos += p.length; + } + return out; + } + + private static byte[] xor(byte[] a, byte[] b) { + if (a.length != b.length) { + throw new IllegalArgumentException("xor length mismatch"); + } + byte[] out = new byte[a.length]; + for (int i = 0; i < a.length; i++) { + out[i] = (byte) (a[i] ^ b[i]); + } + return out; + } + + private static byte[] toUnsigned(BigInteger v, BigInteger N) { + int len = (N.bitLength() + 7) / 8; + byte[] raw = v.toByteArray(); + if (raw.length == len) { + return raw; + } + if (raw.length == len + 1 && raw[0] == 0) { + return Arrays.copyOfRange(raw, 1, raw.length); + } + byte[] padded = new byte[len]; + System.arraycopy(raw, 0, padded, len - raw.length, raw.length); + return padded; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java new file mode 100644 index 0000000000000..e6afcb6a5115e --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -0,0 +1,435 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.homekit.internal.dto.Accessories; +import org.openhab.binding.homekit.internal.dto.Accessory; +import org.openhab.binding.homekit.internal.dto.Characteristic; +import org.openhab.binding.homekit.internal.dto.Service; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.thing.type.ChannelDefinition; +import org.openhab.core.thing.type.ChannelGroupDefinition; +import org.openhab.core.thing.type.ChannelGroupType; +import org.openhab.core.thing.type.ChannelType; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +/** + * Test cases for loading channel creation data from JSON. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestChannelDataLoad { + + // Chapter 6.6.4 Example Accessory Attribute Database in JSON + private static final String TEST_JSON = """ + { + "accessories": [ + { + "aid": 1, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "value": "Acme Light Bridge", + "perms": [ + "pr" + ], + "format": "string", + "iid": 2 + }, + { + "type": "20", + "value": "Acme", + "perms": [ + "pr" + ], + "format": "string", + "iid": 3 + }, + { + "type": "30", + "value": "037A2BABF19D", + "perms": [ + "pr" + ], + "format": "string", + "iid": 4 + }, + { + "type": "21", + "value": "Bridge1,1", + "perms": [ + "pr" + ], + "format": "string", + "iid": 5 + }, + { + "type": "14", + "value": null, + "perms": [ + "pw" + ], + "format": "bool", + "iid": 6 + }, + { + "type": "52", + "value": "100.1.1", + "perms": [ + "pr" + ], + "format": "string", + "iid": 7 + } + ] + }, + { + "type": "A2", + "iid": 8, + "characteristics": [ + { + "type": "37", + "value": "01.01.00", + "perms": [ + "pr" + ], + "format": "string", + "iid": 9 + } + ] + } + ] + }, + { + "aid": 2, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "value": "Acme LED Light Bulb", + "perms": [ + "pr" + ], + "format": "string", + "iid": 2 + }, + { + "type": "20", + "value": "Acme", + "perms": [ + "pr" + ], + "format": "string", + "iid": 3 + }, + { + "type": "30", + "value": "099DB48E9E28", + "perms": [ + "pr" + ], + "format": "string", + "iid": 4 + }, + { + "type": "21", + "value": "LEDBulb1,1", + "perms": [ + "pr" + ], + "format": "string", + "iid": 5 + }, + { + "type": "14", + "value": null, + "perms": [ + "pw" + ], + "format": "bool", + "iid": 6 + } + ] + }, + { + "type": "43", + "iid": 7, + "characteristics": [ + { + "type": "25", + "value": true, + "perms": [ + "pr", + "pw" + ], + "format": "bool", + "iid": 8 + }, + { + "type": "8", + "value": 50, + "perms": [ + "pr", + "pw" + ], + "iid": 9, + "maxValue": 100, + "minStep": 1, + "minValue": 20, + "format": "int", + "unit": "percentage" + } + ] + } + ] + }, + { + "aid": 3, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "value": "Acme LED Light Bulb", + "perms": [ + "pr" + ], + "format": "string", + "iid": 2 + }, + { + "type": "20", + "value": "Acme", + "perms": [ + "pr" + ], + "format": "string", + "iid": 3 + }, + { + "type": "30", + "value": "099DB48E9E28", + "perms": [ + "pr" + ], + "format": "string", + "iid": 4 + }, + { + "type": "21", + "value": "LEDBulb1,1", + "perms": [ + "pr" + ], + "format": "string", + "iid": 5 + }, + { + "type": "14", + "value": null, + "perms": [ + "pw" + ], + "format": "bool", + "iid": 6 + } + ] + }, + { + "type": "43", + "iid": 7, + "characteristics": [ + { + "type": "25", + "value": true, + "perms": [ + "pr", + "pw" + ], + "format": "bool", + "iid": 8 + }, + { + "type": "8", + "value": 50, + "perms": [ + "pr", + "pw" + ], + "iid": 9, + "maxValue": 100, + "minStep": 1, + "minValue": 20, + "format": "int", + "unit": "percentage" + } + ] + } + ] + } + ] + } + """; + + private static final Gson GSON = new Gson(); + + @Test + void testGenericJsonParsing() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + assertNotNull(accessories.accessories); + assertEquals(3, accessories.accessories.size()); + for (Accessory accessory : accessories.accessories) { + assertNotNull(accessory.aid); + assertNotNull(accessory.services); + assertTrue(accessory.services.size() > 0); + for (var service : accessory.services) { + assertNotNull(service.type); + assertNotNull(service.iid); + assertNotNull(service.characteristics); + assertTrue(service.characteristics.size() > 0); + for (var characteristic : service.characteristics) { + assertNotNull(characteristic.type); + assertNotNull(characteristic.iid); + assertNotNull(characteristic.perms); + assertTrue(characteristic.perms.size() > 0); + assertNotNull(characteristic.format); + } + } + } + } + + @Test + void testDetailJsonParsing() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + Accessory accessory = accessories.getAccessory(1); + assertNotNull(accessory); + assertEquals(1, accessory.aid); + assertEquals(2, accessory.services.size()); + Service service = accessory.getService(1); + assertNotNull(service); + assertEquals("3E", service.type); + assertEquals(6, service.characteristics.size()); + Characteristic characteristic = service.getCharacteristic(2); + assertNotNull(characteristic); + JsonElement value = characteristic.value; + assertNotNull(value); + assertTrue(value.isJsonPrimitive()); + assertTrue(value.getAsJsonPrimitive().isString()); + String valueString = value.getAsString(); + assertEquals("Acme Light Bridge", valueString); + } + + @Test + void testChannelDefinitions() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + + HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + List channelGroupTypes = new ArrayList<>(); + List channelTypes = new ArrayList<>(); + + doAnswer(invocation -> { + ChannelGroupType arg = invocation.getArgument(0); + channelGroupTypes.add(arg); + return null; + }).when(typeProvider).putChannelGroupType(any(ChannelGroupType.class)); + + doAnswer(invocation -> { + ChannelType arg = invocation.getArgument(0); + channelTypes.add(arg); + return null; + }).when(typeProvider).putChannelType(any(ChannelType.class)); + + /* + * Test the LED Light Bulb accessory #3 which has live data channels + */ + Accessory accessory = accessories.getAccessory(3); + assertNotNull(accessory); + List channelGroupDefinitions = accessory + .buildAndRegisterChannelGroupDefinitions(typeProvider); + + // There should be one channel group definition for the Light Bulb service + assertNotNull(channelGroupDefinitions); + assertEquals(1, channelGroupDefinitions.size()); + + // Check that the channel group definition and its type UID and label are set + for (ChannelGroupDefinition groupDef : channelGroupDefinitions) { + assertNotNull(groupDef.getId()); + assertNotNull(groupDef.getTypeUID()); + assertNotNull(groupDef.getLabel()); + } + + // There should be one channel group type for the Light Bulb service + assertEquals(1, channelGroupTypes.size()); + + // Check that the channel group type and its UID and label are set + ChannelGroupType channelGroupType = channelGroupTypes.stream() + .filter(cgt -> "public-hap-service-lightbulb".equals(cgt.getUID().getId())).findFirst().orElse(null); + assertNotNull(channelGroupType); + assertEquals("Channel group type: Light Bulb", channelGroupType.getLabel()); + assertEquals("public-hap-service-lightbulb", channelGroupType.getUID().getId()); + + // There should be two channel definitions for the Light Bulb service: On and Brightness + assertEquals(1, channelGroupType.getChannelDefinitions().size()); + + // Check the Brightness channel definition and its properties + ChannelDefinition channelDefinition = channelGroupType.getChannelDefinitions().stream() + .filter(cd -> "Brightness".equals(cd.getLabel())).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("public-hap-characteristic-brightness", channelDefinition.getChannelTypeUID().getId()); + assertEquals("Brightness", channelDefinition.getLabel()); + assertEquals("percent", channelDefinition.getProperties().get("unit")); + assertEquals("int", channelDefinition.getProperties().get("format")); + assertEquals("20.0", channelDefinition.getProperties().get("minValue")); + assertEquals("100.0", channelDefinition.getProperties().get("maxValue")); + assertEquals("1.0", channelDefinition.getProperties().get("minStep")); + assertNotNull(channelDefinition.getProperties().get("perms")); + + // There should be two channel types for the Light Bulb service: On and Brightness + assertEquals(2, channelTypes.size()); + + // Check the Dimmer channel type and its properties + ChannelType channelType = channelTypes.stream().filter(ct -> "Dimmer".equals(ct.getItemType())).findFirst() + .orElse(null); + assertNotNull(channelType); + assertEquals("public-hap-characteristic-brightness", channelType.getUID().getId()); + assertEquals("Channel type: Brightness", channelType.getLabel()); + assertEquals("Dimmer", channelType.getItemType()); + assertEquals("light", channelType.getCategory()); + assertTrue(channelType.getTags().contains("Control")); + assertTrue(channelType.getTags().contains("Brightness")); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java new file mode 100644 index 0000000000000..e5a0f1705b149 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Map; + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.PairingMethod; +import org.openhab.binding.homekit.internal.enums.PairingState; +import org.openhab.binding.homekit.internal.enums.TlvType; +import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; +import org.openhab.binding.homekit.internal.transport.HttpTransport; + +/** + * Test cases for the {@link PairSetupClient} class. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestPairSetup { + + public static final String SALT_HEX = """ + BEB25379 D1A8581E B5A72767 3A2441EE + """; + + public static final String CLIENT_PRIVATE_HEX = """ + 60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393 + """; + + public static final String SERVER_PRIVATE_HEX = """ + E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20 + """; + + @Test + void testPairSetup() throws Exception { + // initialize test parameters + String baseUrl = "http://example.com"; + String username = "alice"; + String password = "password123"; + String clientIdentifier = "11:22:33:44:55:66"; + String serverIdentifier = "AA:BB:CC:DD:EE:FF"; + byte[] serverSalt = hexBlockToByteArray(SALT_HEX); + + // initialize signing keys + Ed25519PrivateKeyParameters clientPrivateSigningKey = new Ed25519PrivateKeyParameters( + hexBlockToByteArray(CLIENT_PRIVATE_HEX)); + Ed25519PrivateKeyParameters serverPrivateSigningKey = new Ed25519PrivateKeyParameters( + hexBlockToByteArray(SERVER_PRIVATE_HEX)); + + // create mock + HttpTransport mockTransport = mock(HttpTransport.class); + + // create SRP client and server + SRPserver server = new SRPserver(username, password, serverSalt); + PairSetupClient client = new PairSetupClient(mockTransport, baseUrl, clientIdentifier, clientPrivateSigningKey, username, + password); + + // mock the HTTP transport to simulate the SRP exchange + doAnswer(invocation -> { + byte[] arg = invocation.getArgument(3); + + // decode and validate the incoming TLV + Map tlv = Tlv8Codec.decode(arg); + PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); + byte[] state = tlv.get(TlvType.STATE.key); + if (state == null || state.length != 1) { + throw new IllegalArgumentException("State missing or invalid"); + } + + // process the message based on the pairing process Mx state + return switch (state[0]) { + case 1 -> getServerResponseM1(server, serverSalt); + case 3 -> getServerResponseM3(server, client); + case 5 -> getServerResponseM5(server, serverIdentifier, serverPrivateSigningKey); + default -> throw new IllegalArgumentException("Unexpected state"); + }; + + }).when(mockTransport).post(anyString(), anyString(), anyString(), any(byte[].class)); + + // execute the pairing setup + client.pair(); + } + + private byte[] getServerResponseM1(SRPserver server, byte[] serverSalt) { + Map tlv = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M2.value }, // + TlvType.SALT.key, serverSalt, // salt + TlvType.PUBLIC_KEY.key, server.getPublicKey() // server public key + ); + + PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); + return Tlv8Codec.encode(tlv); + } + + private byte[] getServerResponseM3(SRPserver server, PairSetupClient client) throws Exception { + byte[] serverProof = server.computeServerProof(client.getPublicKey()); + + Map tlv = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M4.value }, // + TlvType.PROOF.key, serverProof // server proof + ); + + PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); + return Tlv8Codec.encode(tlv); + } + + private byte[] getServerResponseM5(SRPserver server, String serverIdentifier, + Ed25519PrivateKeyParameters serverPrivateSigningKey) throws Exception { + byte[] serverEncyptedData = server.createEncryptedData(serverIdentifier, server.getPublicKey(), + serverPrivateSigningKey); + + Map tlv = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M6.value }, // + TlvType.ENCRYPTED_DATA.key, serverEncyptedData); + + PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); + return Tlv8Codec.encode(tlv); + } + + private static byte[] hexBlockToByteArray(String hexBlock) { + String normalized = hexBlock.replaceAll("\\s+", ""); + if (normalized.length() % 2 != 0) { + throw new IllegalArgumentException("Hex string must have even length"); + } + int len = normalized.length(); + byte[] result = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + int high = Character.digit(normalized.charAt(i), 16); + int low = Character.digit(normalized.charAt(i + 1), 16); + if (high == -1 || low == -1) { + throw new IllegalArgumentException( + "Invalid hex character: " + normalized.charAt(i) + normalized.charAt(i + 1)); + } + result[i / 2] = (byte) ((high << 4) + low); + } + return result; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java new file mode 100644 index 0000000000000..af1db276e17e8 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.X25519PublicKeyParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; +import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.PairingMethod; +import org.openhab.binding.homekit.internal.enums.PairingState; +import org.openhab.binding.homekit.internal.enums.TlvType; +import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; +import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient.Validator; +import org.openhab.binding.homekit.internal.transport.HttpTransport; + +/** + * Test cases for the {@link PairVerifyClient} class. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestPairVerify { + + private static final String PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info"; + private static final String PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt"; + private static final byte[] VERIFY_NONCE_M2 = CryptoUtils.generateNonce("PV-Msg02"); + private static final byte[] VERIFY_NONCE_M3 = CryptoUtils.generateNonce("PV-Msg03"); + + public static final String CLIENT_PRIVATE_HEX = """ + 60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393 + """; + + public static final String SERVER_PRIVATE_HEX = """ + E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20 + """; + + private byte[] sessionKey = new byte[0]; + + @Test + void testPairVerify() throws Exception { + // initialize test parameters + String baseUrl = "http://example.com"; + String clientIdentifier = "11:22:33:44:55:66"; + String serverIdentifier = "AA:BB:CC:DD:EE:FF"; + + // initialize signing keys + Ed25519PrivateKeyParameters clientPrivateSigningKey = new Ed25519PrivateKeyParameters( + hexBlockToByteArray(CLIENT_PRIVATE_HEX)); + Ed25519PrivateKeyParameters serverPrivateSigningKey = new Ed25519PrivateKeyParameters( + hexBlockToByteArray(SERVER_PRIVATE_HEX)); + + // create mock + HttpTransport mockTransport = mock(HttpTransport.class); + + // create SRP client and server + PairVerifyClient client = new PairVerifyClient(mockTransport, baseUrl, clientIdentifier, + clientPrivateSigningKey, serverPrivateSigningKey.generatePublicKey()); + + // mock the HTTP transport to simulate the SRP exchange + doAnswer(invocation -> { + byte[] arg = invocation.getArgument(3); + + // decode and validate the incoming TLV + Map tlv = Tlv8Codec.decode(arg); + PairVerifyClient.Validator.validate(PairingMethod.VERIFY, tlv); + byte[] state = tlv.get(TlvType.STATE.key); + if (state == null || state.length != 1) { + throw new IllegalArgumentException("State missing or invalid"); + } + + // process the message based on the pair verification process Mx state + return switch (state[0]) { + case 1 -> getServerResponseM1(tlv, serverIdentifier, serverPrivateSigningKey); + case 3 -> getServerResponseM3(tlv); + default -> throw new IllegalArgumentException("Unexpected state"); + }; + + }).when(mockTransport).post(anyString(), anyString(), anyString(), any(byte[].class)); + + // execute the pairing verification process + client.verify(); + } + + private byte[] getServerResponseM1(Map tlv, String serverIdentifier, + Ed25519PrivateKeyParameters serverPrivateSigningKey) throws Exception { + X25519PrivateKeyParameters serverKey = CryptoUtils.generateX25519KeyPair(); + + byte[] pairingId = serverIdentifier.getBytes(StandardCharsets.UTF_8); + byte[] clientKeyBytes = tlv.get(TlvType.PUBLIC_KEY.key); + byte[] payload = concat(serverKey.generatePublicKey().getEncoded(), pairingId, + Objects.requireNonNull(clientKeyBytes)); + + byte[] signature = CryptoUtils.signVerifyMessage(serverPrivateSigningKey, payload); + Map tlvInner = Map.of( // + TlvType.IDENTIFIER.key, pairingId, // + TlvType.SIGNATURE.key, signature); + + X25519PublicKeyParameters clientKey = new X25519PublicKeyParameters(clientKeyBytes); + + byte[] sharedSecret = CryptoUtils.computeSharedSecret(serverKey, clientKey); + this.sessionKey = CryptoUtils.hkdf(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); + byte[] plaintext = Tlv8Codec.encode(tlvInner); // TODO ?? authTag see page 40 + byte[] encrypted = CryptoUtils.encrypt(sessionKey, VERIFY_NONCE_M2, plaintext); + + Map tlvOut = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M2.value }, // + TlvType.PUBLIC_KEY.key, serverKey.generatePublicKey().getEncoded(), // + TlvType.ENCRYPTED_DATA.key, encrypted); + + return Tlv8Codec.encode(tlvOut); + } + + private byte[] getServerResponseM3(Map tlv) throws Exception { + if (sessionKey.length == 0) { + throw new IllegalStateException("Session key not established"); + } + byte[] encrypted = tlv.get(TlvType.ENCRYPTED_DATA.key); + byte[] plaintext = CryptoUtils.decrypt(sessionKey, VERIFY_NONCE_M3, Objects.requireNonNull(encrypted)); + + System.out.println("Decrypted M3: " + Arrays.toString(plaintext)); // TODO + + Map tlvOut = Map.of(TlvType.STATE.key, new byte[] { PairingState.M4.value }); + Validator.validate(PairingMethod.VERIFY, tlvOut); + + // no further messages from server + return Tlv8Codec.encode(tlvOut); + } + + private static byte[] hexBlockToByteArray(String hexBlock) { + String normalized = hexBlock.replaceAll("\\s+", ""); + if (normalized.length() % 2 != 0) { + throw new IllegalArgumentException("Hex string must have even length"); + } + int len = normalized.length(); + byte[] result = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + int high = Character.digit(normalized.charAt(i), 16); + int low = Character.digit(normalized.charAt(i + 1), 16); + if (high == -1 || low == -1) { + throw new IllegalArgumentException( + "Invalid hex character: " + normalized.charAt(i) + normalized.charAt(i + 1)); + } + result[i / 2] = (byte) ((high << 4) + low); + } + return result; + } + + private static byte[] concat(byte[]... parts) { + int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); + byte[] out = new byte[total]; + int pos = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, pos, p.length); + pos += p.length; + } + return out; + } +} From b75d1f3981e5864421457728bbbad2e3319fbe7d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 21 Sep 2025 19:12:27 +0100 Subject: [PATCH 023/177] fix unit testing; add support for property channels Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 7 + .../internal/crypto/CryptoConstants.java | 85 +++++ .../homekit/internal/crypto/CryptoUtils.java | 168 ++++++--- .../homekit/internal/crypto/SRPclient.java | 326 +++++------------- .../homekit/internal/dto/Characteristic.java | 30 +- .../internal/enums/CharacteristicType.java | 12 + .../homekit/internal/enums/ErrorCode.java | 47 +++ .../homekit/internal/enums/PairingMethod.java | 2 +- .../homekit/internal/enums/ServiceType.java | 2 +- .../handler/HomekitBaseServerHandler.java | 79 +++-- .../handler/HomekitDeviceHandler.java | 39 ++- ...moveService.java => PairRemoveClient.java} | 48 +-- .../hap_services/PairSetupClient.java | 153 +++----- .../hap_services/PairVerifyClient.java | 164 +++++---- ...onKeys.java => AsymmetricSessionKeys.java} | 4 +- .../internal/session/SecureSession.java | 6 +- .../binding/homekit/internal/SRPserver.java | 228 ------------ .../homekit/internal/SRPtestServer.java | 128 +++++++ .../homekit/internal/TestChannelCreation.java | 42 ++- .../homekit/internal/TestPairSetup.java | 86 +++-- .../homekit/internal/TestPairVerify.java | 104 +++--- 21 files changed, 865 insertions(+), 895 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/{PairingRemoveService.java => PairRemoveClient.java} (63%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/{SessionKeys.java => AsymmetricSessionKeys.java} (89%) delete mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index fda0c38cdcaf7..a9c8c905bfd2c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -14,6 +14,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.type.ChannelTypeUID; /** * Defines common constants which are used across the whole HomeKit binding. @@ -29,6 +30,11 @@ public class HomekitBindingConstants { public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device"); + // specific Channel Type UIDs + public static final String FAKE_PROPERTY_CHANNEL = "property-fake-channel"; + public static final ChannelTypeUID FAKE_PROPERTY_CHANNEL_TYPE_UID = new ChannelTypeUID(BINDING_ID, + FAKE_PROPERTY_CHANNEL); + // labels public static final String THING_LABEL_FMT = "Model %s on %s"; public static final String CHILD_LABEL_FMT = "Accessory %d on %s"; @@ -48,6 +54,7 @@ public class HomekitBindingConstants { public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_DEVICE_CATEGORY = "deviceCategory"; public static final String PROPERTY_CONTROLLER_PRIVATE_KEY = "controllerPrivateKey"; + public static final String PROPERTY_ACCESSORY_PUBLIC_KEY = "accessoryPublicKey"; // HomeKit HTTP URI endpoints and content types public static final String ENDPOINT_PAIRING = "pair-setup"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java new file mode 100644 index 0000000000000..ff11ad9734f12 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.crypto; + +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Constants for cryptographic operations used in HomeKit communication. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class CryptoConstants { + + public static final BigInteger N = new BigInteger(""" + FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 + 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 + 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED + EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05 + 98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB + 9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B + E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718 + 3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33 + A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7 + ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864 + D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2 + 08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF + """.replaceAll("\\s+", ""), 16); + + public static final BigInteger g = BigInteger.valueOf(5); + public static final BigInteger k = computeK(); + + // @formatter:off + public static final String PAIR_SETUP = "Pair-Setup"; + public static final byte[] PAIR_SETUP_ENCRYPT_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); + public static final byte[] PAIR_SETUP_ENCRYPT_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); + + public static final byte[] PS_M5_NONCE = CryptoUtils.generateNonce("PS-Msg05"); + public static final byte[] PS_M6_NONCE = CryptoUtils.generateNonce("PS-Msg06"); + + public static final byte[] PAIR_CONTROLLER_SIGN_SALT = "Pair-Setup-Controller-Sign-Salt".getBytes(StandardCharsets.UTF_8); + public static final byte[] PAIR_CONTROLLER_SIGN_INFO = "Pair-Setup-Controller-Sign-Info".getBytes(StandardCharsets.UTF_8); + + public static final byte[] PAIR_ACCESSORY_SIGN_SALT = "Pair-Setup-Accessory-Sign-Salt".getBytes(StandardCharsets.UTF_8); + public static final byte[] PAIR_ACCESSORY_SIGN_INFO = "Pair-Setup-Accessory-Sign-Info".getBytes(StandardCharsets.UTF_8); + + public static final byte[] CONTROL_SALT = "Control-Salt".getBytes(StandardCharsets.UTF_8); + public static final byte[] CONTROL_READ_ENCRYPTION_KEY = "Control-Read-Encryption-Key".getBytes(StandardCharsets.UTF_8); + public static final byte[] CONTROL_WRITE_ENCRYPTION_KEY = "Control-Write-Encryption-Key".getBytes(StandardCharsets.UTF_8); + + public static final byte[] CHACHA20_POLY1305 = "ChaCha20-Poly1305".getBytes(StandardCharsets.UTF_8); + + public static final byte[] PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info".getBytes(StandardCharsets.UTF_8); + public static final byte[] PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); + + public static final byte[] PV_M2_NONCE = CryptoUtils.generateNonce("PV-Msg02"); + public static final byte[] PV_M3_NONCE = CryptoUtils.generateNonce("PV-Msg03"); + // @formatter:on + + private static BigInteger computeK() { + try { + byte[] paddedN = toUnsigned(N, N); + byte[] paddedG = toUnsigned(g, N); + byte[] hash = sha512(CryptoUtils.concat(paddedN, paddedG)); + return new BigInteger(1, hash); + } catch (Exception e) { + throw new SecurityException("Failed to compute k", e); + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index e46fc7d1883f5..c1a9e940b4ee2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -12,10 +12,13 @@ */ package org.openhab.binding.homekit.internal.crypto; +import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.security.SecureRandom; +import java.util.Arrays; import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.digests.SHA512Digest; @@ -30,6 +33,7 @@ import org.bouncycastle.crypto.params.X25519PublicKeyParameters; import org.bouncycastle.crypto.signers.Ed25519Signer; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; /** * Utility class for cryptographic operations used in HomeKit communication. @@ -39,67 +43,58 @@ @NonNullByDefault public class CryptoUtils { - // Generate ephemeral X25519 (Curve25519) key pair - public static X25519PrivateKeyParameters generateX25519KeyPair() - throws NoSuchAlgorithmException, NoSuchProviderException { - return new X25519PrivateKeyParameters(new SecureRandom()); - } - - // Compute shared secret using ECDH - public static byte[] computeSharedSecret(X25519PrivateKeyParameters clientPrivateKey, - X25519PublicKeyParameters serverPublicKey) { - byte[] secret = new byte[32]; - clientPrivateKey.generateSecret(serverPublicKey, secret, 0); - return secret; - } - - // HKDF-SHA512 key derivation - public static byte[] hkdf(byte[] ikm, String salt, String info) { - HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); - hkdf.init( - new HKDFParameters(ikm, salt.getBytes(StandardCharsets.UTF_8), info.getBytes(StandardCharsets.UTF_8))); - byte[] output = new byte[32]; - hkdf.generateBytes(output, 0, output.length); - return output; - } - - // Encrypt with ChaCha20-Poly1305 - public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plaintext) throws InvalidCipherTextException { - ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); - AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce); - cipher.init(true, params); - - byte[] out = new byte[cipher.getOutputSize(plaintext.length)]; - int len = cipher.processBytes(plaintext, 0, plaintext.length, out, 0); - cipher.doFinal(out, len); + public static byte[] concat(byte[]... parts) { + int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); + byte[] out = new byte[total]; + int pos = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, pos, p.length); + pos += p.length; + } return out; } // Decrypt with ChaCha20-Poly1305 - public static byte[] decrypt(byte[] key, byte[] nonce, byte[] ciphertext) throws InvalidCipherTextException { + public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText, byte @Nullable [] authTag) + throws InvalidCipherTextException { + int length; + if (authTag != null) { + length = cipherText.length - authTag.length; + byte[] cipherTag = Arrays.copyOfRange(cipherText, length, cipherText.length); + if (!Arrays.equals(cipherTag, authTag)) { + throw new InvalidCipherTextException("Authentication tag mismatch"); + } + } else { + length = cipherText.length; + } ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); - AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); cipher.init(false, params); - - byte[] out = new byte[cipher.getOutputSize(ciphertext.length)]; - int len = cipher.processBytes(ciphertext, 0, ciphertext.length, out, 0); - cipher.doFinal(out, len); - return out; + byte[] plainText = new byte[cipher.getOutputSize(length)]; + length = cipher.processBytes(cipherText, 0, length, plainText, 0); + cipher.doFinal(plainText, length); + return plainText; } - // Sign Pair-Verify message with Ed25519 - public static byte[] signVerifyMessage(Ed25519PrivateKeyParameters privateKey, byte[] message) { - Ed25519Signer signer = new Ed25519Signer(); - signer.init(true, privateKey); - signer.update(message, 0, message.length); - return signer.generateSignature(); + // Encrypt with ChaCha20-Poly1305 + public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plainText, byte @Nullable [] authTag) + throws InvalidCipherTextException { + ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); + cipher.init(true, params); + byte[] cipherText = new byte[cipher.getOutputSize(plainText.length)]; + int length = cipher.processBytes(plainText, 0, plainText.length, cipherText, 0); + cipher.doFinal(cipherText, length); + return authTag == null ? cipherText : concat(cipherText, authTag); } - public static boolean verifyVerifyMessage(Ed25519PublicKeyParameters publicKey, byte[] message, byte[] signature) { - Ed25519Signer verifier = new Ed25519Signer(); - verifier.init(false, publicKey); - verifier.update(message, 0, message.length); - return verifier.verifySignature(signature); + // HKDF-SHA512 key derivation + public static byte[] generateHkdfKey(byte[] inputKey, byte[] salt, byte[] info) { + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); + hkdf.init(new HKDFParameters(inputKey, salt, info)); + byte[] output = new byte[32]; + hkdf.generateBytes(output, 0, output.length); + return output; } /** @@ -131,4 +126,75 @@ public static byte[] generateNonce(String label) { System.arraycopy(labelBytes, 0, nonce, 4, Math.min(labelBytes.length, 8)); return nonce; } + + // Compute shared secret using ECDH + public static byte[] generateSharedSecret(X25519PrivateKeyParameters clientPrivateKey, + X25519PublicKeyParameters serverPublicKey) { + byte[] secret = new byte[32]; + clientPrivateKey.generateSecret(serverPublicKey, secret, 0); + return secret; + } + + // Generate ephemeral X25519 (Curve25519) key pair + public static X25519PrivateKeyParameters generateX25519KeyPair() + throws NoSuchAlgorithmException, NoSuchProviderException { + return new X25519PrivateKeyParameters(new SecureRandom()); + } + + public static byte[] sha512(byte[] data) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-512"); + return md.digest(data); + } + + // Sign message with Ed25519 + public static byte[] signMessage(Ed25519PrivateKeyParameters privateKey, byte[] message) { + Ed25519Signer signer = new Ed25519Signer(); + signer.init(true, privateKey); + signer.update(message, 0, message.length); + return signer.generateSignature(); + } + + public static byte[] toUnsigned(BigInteger v, BigInteger N) { + int len = (N.bitLength() + 7) / 8; + byte[] raw = v.toByteArray(); + if (raw.length == len) { + return raw; + } + if (raw.length == len + 1 && raw[0] == 0) { + return Arrays.copyOfRange(raw, 1, raw.length); + } + byte[] padded = new byte[len]; + System.arraycopy(raw, 0, padded, len - raw.length, raw.length); + return padded; + } + + public static boolean verifySignature(Ed25519PublicKeyParameters publicKey, byte[] payLoad, byte[] signature) { + Ed25519Signer verifier = new Ed25519Signer(); + verifier.init(false, publicKey); + verifier.update(payLoad, 0, payLoad.length); + return verifier.verifySignature(signature); + } + + public static byte[] xor(byte[] a, byte[] b) { + if (a.length != b.length) { + throw new IllegalArgumentException("xor length mismatch"); + } + byte[] out = new byte[a.length]; + for (int i = 0; i < a.length; i++) { + out[i] = (byte) (a[i] ^ b[i]); + } + return out; + } + + public static String toSpaceDelimitedHex(byte @Nullable [] bytes) { + if (bytes == null) { + return "null"; + } + StringBuilder sb = new StringBuilder(); + sb.append(String.format("[%03d]", bytes.length)).append(' '); + for (byte b : bytes) { + sb.append(String.format("%02X", b)).append(' '); + } + return sb.toString().trim(); // remove trailing space + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index 2b5fdd9540de6..a51ab568f5cd9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -12,23 +12,20 @@ */ package org.openhab.binding.homekit.internal.crypto; +import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; + import java.math.BigInteger; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Arrays; -import java.util.LinkedHashMap; import java.util.Map; -import org.bouncycastle.crypto.digests.SHA512Digest; -import org.bouncycastle.crypto.generators.HKDFBytesGenerator; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; -import org.bouncycastle.crypto.params.HKDFParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.session.SessionKeys; /** * Manages the SRP (Stanford Secure Remote Password) protocol for pairing with a HomeKit accessory. @@ -39,293 +36,130 @@ @NonNullByDefault public class SRPclient { - public static final String PAIR_SETUP = "Pair-Setup"; - - private static final BigInteger N = new BigInteger(""" - FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 - 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 - 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED - EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05 - 98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB - 9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B - E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718 - 3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33 - A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7 - ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864 - D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2 - 08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF - """.replaceAll("\\s+", ""), 16); - - private static final BigInteger g = BigInteger.valueOf(5); - private static final BigInteger k = computeK(); - private static final SecureRandom random = new SecureRandom(); - private final String I; // username - private final byte[] s; // salt - private final BigInteger x; // private key derived from password - - private @NonNullByDefault({}) BigInteger a; // client private ephemeral - private @NonNullByDefault({}) BigInteger A; // client public key - private @NonNullByDefault({}) BigInteger B; // server public key - private @NonNullByDefault({}) BigInteger u; // scrambling parameter - private @NonNullByDefault({}) BigInteger S; // shared secret - private @NonNullByDefault({}) byte[] K; // session key - private @NonNullByDefault({}) byte[] M1; // client proof - - private @Nullable String serverIdentifier; - private byte @Nullable [] serverPublicKey; - - // HomeKit‐specific constants for HKDF/ChaCha20‐Poly1305 - private static final byte[] PAIR_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); - private static final byte[] PAIR_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); - private static final byte[] PAIR_SIGN_SALT = "Pair-Setup-Sign-Salt".getBytes(StandardCharsets.UTF_8); - private static final byte[] PAIR_SIGN_INFO = "Pair-Setup-Sign-Info".getBytes(StandardCharsets.UTF_8); - private static final byte[] PAIR_NONCE_M5 = CryptoUtils.generateNonce("PS-Msg05"); - private static final byte[] PAIR_NONCE_M6 = CryptoUtils.generateNonce("PS-Msg06"); + private final byte[] s; // server salt + private final BigInteger x; // SRP private key derived from password + private final BigInteger a; // client SRP private ephemeral + private final BigInteger A; // client SRP public key + private final BigInteger B; // server SRP public key + private final BigInteger u; // scrambling parameter + private final BigInteger S; // shared secret + private final byte[] K; // session key + private final byte[] M1; // client proof + + private @Nullable Ed25519PublicKeyParameters serverLongTermPublicKey = null; /** - * M1 — Initializes the SRP client with the given username, password, and salt. + * M2 — Initializes the SRP client with the given password, salt and server public SRP key. * - * @param username the username (I). - * @param password the password (P). - * @param salt the salt (s) provided by the server. + * @param password the password (P) used for authentication. + * @param serverSalt the salt (s) provided by the server. + * @param serverPublicKey the server's public SRP key (B). * @throws Exception if an error occurs during initialization. */ - public SRPclient(String username, String password, byte[] salt) throws Exception { - this.I = username; - this.s = salt; + public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey) throws Exception { + I = PAIR_SETUP; + s = serverSalt; + B = new BigInteger(1, serverPublicKey); - // Compute verifier: v = g^x mod N where x = H(salt || H(username || ":" || password)) - byte[] hIP = sha512((username + ":" + password).getBytes(StandardCharsets.UTF_8)); - byte[] xHash = sha512(concat(salt, hIP)); - this.x = new BigInteger(1, xHash); - } - - /** - * M2 — Process the server's challenge by storing the server's Curve25519 public key B. - * - * @param serverPublicKey the server's Curve25519 public key B. - */ - public void processChallenge(byte[] serverPublicKey) { - this.B = new BigInteger(1, serverPublicKey); - } - - /** - * M3 — Generate the client's Curve25519 ephemeral key pair (a, A) and return the public key A. - * - * @return the client's Curve25519 public key A. - */ - public byte[] getPublicKey() { - if (A == null) { - this.a = new BigInteger(N.bitLength(), random).mod(N); - this.A = g.modPow(a, N); - } - return toUnsigned(A, N); - } + // Generate ephemeral a and compute public A + a = new BigInteger(N.bitLength(), new SecureRandom()).mod(N); + A = g.modPow(a, N); - /** - * M3 — Compute the client proof M1 = H(H(N) ⊕ H(g) || H(I) || salt || A || B || K). - * - * @return the client proof M1. - * @throws IllegalStateException if the SRP state is not properly initialized. - */ - public byte[] getClientProof() throws Exception { - if (M1 != null) { - return M1; - } - if (A == null || B == null || a == null) { - throw new IllegalStateException("SRP state not initialized"); - } - if (B.mod(N).equals(BigInteger.ZERO)) { - throw new SecurityException("Invalid server public key"); - } + // Compute hash x = H(salt || H(username || ":" || password)) + byte[] hIP = sha512((PAIR_SETUP + ":" + password).getBytes(StandardCharsets.UTF_8)); + byte[] xHash = sha512(concat(serverSalt, hIP)); + x = new BigInteger(1, xHash); - // Compute u = H(PAD(A) || PAD(B)) + // Compute scrambling parameter u = H(PAD(A) || PAD(B)) byte[] uHash = sha512(concat(toUnsigned(A, N), toUnsigned(B, N))); - this.u = new BigInteger(1, uHash); + u = new BigInteger(1, uHash); if (u.equals(BigInteger.ZERO)) { throw new SecurityException("Invalid scrambling parameter"); } - // Compute S = (B - k·g^x)^(a + u·x) mod N + // Compute shared secret S = (B - k·g^x)^(a + u·x) mod N BigInteger gx = g.modPow(x, N); BigInteger base = B.subtract(k.multiply(gx)).mod(N); BigInteger exp = a.add(u.multiply(x)); - this.S = base.modPow(exp, N); + S = base.modPow(exp, N); // Compute session key K = H(S) - this.K = sha512(toUnsigned(S, N)); + K = sha512(toUnsigned(S, N)); - // Compute client proof M1 = H(H(N) ⊕ H(g) || H(I) || salt || A || B || K) + // Compute client proof M1 = H(H(N) ⊕ H(g) || H(I) || s || A || B || K) byte[] HN = sha512(toUnsigned(N, N)); byte[] Hg = sha512(toUnsigned(g, N)); byte[] Hxor = xor(HN, Hg); byte[] HI = sha512(I.getBytes(StandardCharsets.UTF_8)); - - this.M1 = sha512(concat(Hxor, HI, s, toUnsigned(A, N), toUnsigned(B, N), K)); - return M1; + M1 = sha512(concat(Hxor, HI, s, toUnsigned(A, N), toUnsigned(B, N), K)); } - /** - * M4 — Verify the server's proof M2 = H(A || M1 || K). - * - * @param serverProof the server's proof to verify. - * @throws SecurityException if the proof does not match. - */ - public void verifyServerProof(byte[] serverProof) throws Exception { - byte[] expected = sha512(concat(toUnsigned(A, N), M1, K)); - if (!Arrays.equals(expected, serverProof)) { - throw new SecurityException("SRP server proof mismatch"); - } - } + public byte[] createEncryptedControllerInfo(byte[] pairingId, + Ed25519PrivateKeyParameters controllerLongTermPrivateKey) throws Exception { + byte[] sharedKey = generateHkdfKey(getSharedSecret(), PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); + byte[] signingKey = controllerLongTermPrivateKey.generatePublicKey().getEncoded(); + byte[] payload = concat(sharedKey, pairingId, signingKey); + byte[] signature = signMessage(controllerLongTermPrivateKey, payload); - /** - * M5 — Derive the 32‐byte signing key from HKDF(S, salt, info). - */ - public byte[] deriveIOSDeviceXKey() { - HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); - hkdf.init(new HKDFParameters(toUnsigned(S, N), PAIR_SIGN_SALT, PAIR_SIGN_INFO)); - byte[] xKey = new byte[32]; - hkdf.generateBytes(xKey, 0, xKey.length); - return xKey; - } - - /** - * M5 — Encrypt the derived Curve25519 key, the accessory identifier, and the long term Curve25519 public key. - * - * @param accessoryId UTF-8 string identifier of the controller. - * @param signingKey Ed25519 private key for signing the TLV. - * - * @return the ChaCha20-Poly1305‐encrypted TLV blob for M5. - */ - public byte[] getEncryptedDeviceInfoBlob(byte[] iOSDeviceXKey, String pairingIdentifier, - Ed25519PublicKeyParameters controllerLongTermKey, Ed25519PrivateKeyParameters signingKey) - throws Exception { - // 1) Build sub-TLV with iOSDeviceXKey, pairing identifier, and controller long-term public key - byte[] blob = concat(iOSDeviceXKey, pairingIdentifier.getBytes(StandardCharsets.UTF_8), - controllerLongTermKey.getEncoded()); - Map subTlv = new LinkedHashMap<>(); - subTlv.put(TlvType.IDENTIFIER.key, blob); - byte[] controllerPk = signingKey.generatePublicKey().getEncoded(); - subTlv.put(TlvType.PUBLIC_KEY.key, controllerPk); - - // 2) Encode & sign the sub-TLV - byte[] msg = Tlv8Codec.encode(subTlv); - byte[] signature = CryptoUtils.signVerifyMessage(signingKey, msg); - subTlv.put(TlvType.SIGNATURE.key, signature); + Map subTlv = Map.of( // + TlvType.IDENTIFIER.key, pairingId, // + TlvType.PUBLIC_KEY.key, signingKey, // + TlvType.SIGNATURE.key, signature); - // 3) Re-encode the signed TLV byte[] plaintext = Tlv8Codec.encode(subTlv); - - // 4) Encrypt with session write key and fixed nonce - byte[] writeKey = deriveSessionKeys().getWriteKey(); - return CryptoUtils.encrypt(writeKey, PAIR_NONCE_M5, plaintext); + byte[] ciphertext = encrypt(getSymmetricKey(), PS_M5_NONCE, plaintext, CHACHA20_POLY1305); + return ciphertext; } - /** - * M6 — Decrypt and store accessory identifier + Curve25519 public key. - */ - public void verifyAccessoryIdentifiers(byte[] encryptedData) throws Exception { - // 1) Decrypt using the session's read key and fixed nonce - byte[] decrypted = CryptoUtils.decrypt(deriveSessionKeys().getReadKey(), PAIR_NONCE_M6, encryptedData); - - // 2) Parse TLV to extract accessory identifier and public key - Map tlv = Tlv8Codec.decode(decrypted); - byte[] idBytes = tlv.get(TlvType.IDENTIFIER.key); - byte[] pkBytes = tlv.get(TlvType.PUBLIC_KEY.key); - - if (idBytes == null || pkBytes == null) { - throw new SecurityException("Missing accessory credentials in M6"); + public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Exception { + Ed25519PublicKeyParameters serverLongTermPublicKey = this.serverLongTermPublicKey; + if (serverLongTermPublicKey == null) { + throw new IllegalStateException("Accessory long-term public key not yet available"); } - - // 3) Store for later use - this.serverIdentifier = new String(idBytes, StandardCharsets.UTF_8); - this.serverPublicKey = pkBytes; + return serverLongTermPublicKey; } - /** - * After M6 — Derive the 32‐byte session key with HKDF(K, salt, info). - */ - public SessionKeys deriveSessionKeys() { - HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); - hkdf.init(new HKDFParameters(K, PAIR_SALT, PAIR_INFO)); - - byte[] sessionKey = new byte[32]; - hkdf.generateBytes(sessionKey, 0, sessionKey.length); - - // HomeKit uses the same key for read/write - return new SessionKeys(sessionKey, sessionKey); + public byte[] getClientProof() { + return M1; } - /* - * Returns the stored server identifier after M6. - * - * @return the server's identifier string, or null if not yet set. - */ - public @Nullable String getServerIdentifier() { - return serverIdentifier; + public byte[] getPublicKey() { + return toUnsigned(A, N); } - /* - * Returns the stored server SRP public key after M6. - * - * @return the server's Curve25519 public key, or null if not yet set. - */ - public byte @Nullable [] getServerPublicKey() { - return serverPublicKey; + private byte[] getSharedSecret() { + return toUnsigned(S, N); } - // ─── Utility Methods ────────────────────────────────────────────────────── - - private static BigInteger computeK() { - try { - byte[] paddedN = toUnsigned(N, N); - byte[] paddedG = toUnsigned(g, N); - byte[] hash = sha512(concat(paddedN, paddedG)); - return new BigInteger(1, hash); - } catch (Exception e) { - throw new RuntimeException("Failed to compute k", e); - } + public byte[] getSymmetricKey() { + return generateHkdfKey(getSharedSecret(), PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); } - private static byte[] sha512(byte[] data) throws Exception { - MessageDigest md = MessageDigest.getInstance("SHA-512"); - return md.digest(data); - } + public void verifyEncryptedAccessoryInfo(byte[] cipherText) throws Exception { + byte[] plainText = decrypt(getSymmetricKey(), PS_M6_NONCE, cipherText, CHACHA20_POLY1305); - private static byte[] concat(byte[]... parts) { - int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); - byte[] out = new byte[total]; - int pos = 0; - for (byte[] p : parts) { - System.arraycopy(p, 0, out, pos, p.length); - pos += p.length; - } - return out; - } + Map subTlv = Tlv8Codec.decode(plainText); + byte[] pairingId = subTlv.get(TlvType.IDENTIFIER.key); + byte[] signingKey = subTlv.get(TlvType.PUBLIC_KEY.key); + byte[] signature = subTlv.get(TlvType.SIGNATURE.key); - private static byte[] xor(byte[] a, byte[] b) { - if (a.length != b.length) { - throw new IllegalArgumentException("xor length mismatch"); - } - byte[] out = new byte[a.length]; - for (int i = 0; i < a.length; i++) { - out[i] = (byte) (a[i] ^ b[i]); + if (pairingId == null || signingKey == null || signature == null) { + throw new SecurityException("Missing accessory credentials in M6"); } - return out; + + byte[] sharedKey = generateHkdfKey(getSharedSecret(), PAIR_ACCESSORY_SIGN_SALT, PAIR_ACCESSORY_SIGN_INFO); + byte[] payload = concat(sharedKey, pairingId, signingKey); + + Ed25519PublicKeyParameters serverLongTermPublicKey = new Ed25519PublicKeyParameters(signingKey, 0); + verifySignature(serverLongTermPublicKey, payload, signature); + this.serverLongTermPublicKey = serverLongTermPublicKey; } - private static byte[] toUnsigned(BigInteger v, BigInteger N) { - int len = (N.bitLength() + 7) / 8; - byte[] raw = v.toByteArray(); - if (raw.length == len) { - return raw; - } - if (raw.length == len + 1 && raw[0] == 0) { - return Arrays.copyOfRange(raw, 1, raw.length); + public void verifyServerProof(byte[] serverProof) throws Exception { + byte[] M2 = sha512(concat(toUnsigned(A, N), M1, K)); + if (!Arrays.equals(M2, serverProof)) { + throw new SecurityException("SRP server proof mismatch"); } - byte[] padded = new byte[len]; - System.arraycopy(raw, 0, padded, len - raw.length, raw.length); - return padded; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 96f452670a8e4..971a56ec110cf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -257,7 +257,7 @@ public class Characteristic { case FIRMWARE_REVISION: case HARDWARE_REVISION: - itemType = null; + itemType = FAKE_PROPERTY_CHANNEL; break; case HEATER_COOLER_STATE_CURRENT: @@ -305,7 +305,7 @@ public class Characteristic { case IN_USE: case IS_CONFIGURED: - itemType = null; + itemType = FAKE_PROPERTY_CHANNEL; break; case LEAK_DETECTED: @@ -333,9 +333,12 @@ public class Characteristic { break; case LOGS: + itemType = null; + break; + case MANUFACTURER: case MODEL: - itemType = null; + itemType = FAKE_PROPERTY_CHANNEL; break; case MOTION_DETECTED: @@ -349,7 +352,7 @@ public class Characteristic { break; case NAME: - itemType = null; + itemType = FAKE_PROPERTY_CHANNEL; break; case NIGHT_VISION: @@ -422,7 +425,6 @@ public class Characteristic { case SELECTED_AUDIO_STREAM_CONFIGURATION: case SELECTED_RTP_STREAM_CONFIGURATION: - case SERIAL_NUMBER: case SERVICE_LABEL_INDEX: case SERVICE_LABEL_NAMESPACE: case SETUP_DATA_STREAM_TRANSPORT: @@ -430,12 +432,16 @@ public class Characteristic { itemType = null; break; + case SERIAL_NUMBER: + itemType = FAKE_PROPERTY_CHANNEL; + break; + case SET_DURATION: propertyTag = Property.DURATION; break; case SIRI_INPUT_TYPE: - itemType = null; + itemType = FAKE_PROPERTY_CHANNEL; break; case SLAT_STATE_CURRENT: @@ -500,7 +506,7 @@ public class Characteristic { break; case TEMPERATURE_UNITS: - category = "temperature"; + itemType = FAKE_PROPERTY_CHANNEL; break; case TILT_CURRENT: @@ -511,7 +517,7 @@ public class Characteristic { case TYPE_SLAT: case VALVE_TYPE: case VERSION: - itemType = null; + itemType = FAKE_PROPERTY_CHANNEL; break; case VERTICAL_TILT_CURRENT: @@ -549,6 +555,14 @@ public class Characteristic { if (itemType == null) { return null; } + if (FAKE_PROPERTY_CHANNEL.equals(itemType)) { + if (value != null && value.isJsonPrimitive()) { + // create fake property channels for characteristics that contain only static information + return new ChannelDefinitionBuilder(characteristicType.toCamelCase(), + FAKE_PROPERTY_CHANNEL_TYPE_UID).withLabel(value.getAsString()).build(); + } + return null; + } StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, label, itemType); Optional.ofNullable(category).ifPresent(builder::withCategory); if (pointTag != null) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index ae67af10a110a..fb61101a80c1f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -192,4 +192,16 @@ public String toString() { word -> word.isEmpty() ? word : Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase()) .collect(Collectors.joining(" ")); } + + public String toCamelCase() { + String[] parts = name().split("_"); + StringBuilder camelCase = new StringBuilder(parts[0].toLowerCase()); + for (int i = 1; i < parts.length; i++) { + String part = parts[i].toLowerCase(); + if (!part.isEmpty()) { + camelCase.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + } + return camelCase.toString(); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java new file mode 100644 index 0000000000000..1a2b39f76cc46 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of error codes used in HomeKit communication. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum ErrorCode { + RESERVED(0x00), + UNKNOWN(0x01), + AUTHENTICATION(0x02), + BACK_OFF(0x03), + MAX_PEERS(0x04), + MAX_TRIES(0x05), + UNAVAILABLE(0x06), + BUSY(0x07); + + public final byte value; + + ErrorCode(int value) { + this.value = (byte) value; + } + + public static ErrorCode from(byte b) { + for (ErrorCode state : values()) { + if (state.value == b) { + return state; + } + } + throw new IllegalArgumentException("Unknown error code: " + b); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java index 195f670e193ea..24cd9fe067a63 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java @@ -40,6 +40,6 @@ public static PairingMethod from(byte b) { return state; } } - throw new IllegalArgumentException("Unknown pairing state: " + b); + throw new IllegalArgumentException("Unknown pairing method: " + b); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index 2438db1e44771..d6700193a6634 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -89,7 +89,7 @@ public static ServiceType from(int type) throws IllegalArgumentException { } public String getChannelTypeId() { - return typeName.replace("-", "_").replace(".", "-"); // convert to OH channel type format + return typeName.replace(".", "-"); // convert to OH channel type format } public String getTypeName() { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index b5e93185161ac..6d887687a6597 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -28,15 +28,14 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.crypto.SRPclient; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; +import org.openhab.binding.homekit.internal.hap_services.PairRemoveClient; import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; -import org.openhab.binding.homekit.internal.hap_services.PairingRemoveService; +import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; import org.openhab.binding.homekit.internal.session.SecureSession; -import org.openhab.binding.homekit.internal.session.SessionKeys; import org.openhab.binding.homekit.internal.transport.HttpTransport; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; @@ -77,9 +76,10 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected @NonNullByDefault({}) String baseUrl; protected @NonNullByDefault({}) String pairingCode; protected @NonNullByDefault({}) Integer accessoryId; - protected @NonNullByDefault({}) SessionKeys sessionKeys; + protected @NonNullByDefault({}) AsymmetricSessionKeys sessionKeys; - protected @Nullable Ed25519PrivateKeyParameters controllerPrivateKey = null; + protected @Nullable Ed25519PrivateKeyParameters controllerLongTermPrivateKey = null; + protected @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; public HomekitBaseServerHandler(Thing thing, HttpClientFactory httpClientFactory) { super(thing); @@ -149,9 +149,10 @@ public void handleRemoval() { scheduler.submit(() -> { // unpair and clear stored keys if this is NOT a child accessory try { - new PairingRemoveService(httpTransport, baseUrl, sessionKeys, thing.getUID().toString()).remove(); - this.controllerPrivateKey = null; - storeControllerPrivateSigningKey(); + PairRemoveClient service = new PairRemoveClient(httpTransport, baseUrl, thing.getUID().toString()); + service.remove(); + this.accessoryLongTermPublicKey = null; + storeLongTermKeys(); updateStatus(ThingStatus.REMOVED); } catch (Exception e) { logger.warn("Failed to remove pairing for accessory {}", accessoryId); @@ -193,15 +194,15 @@ private void initializePairing() { return; } - restoreControllerPrivateSigningKey(); - Ed25519PrivateKeyParameters controllerPrivateSigningKey = this.controllerPrivateKey; - Ed25519PublicKeyParameters TODO_serverPublicSigningKey = controllerPrivateSigningKey.generatePublicKey(); // TODO + restoreLongTermKeys(); - if (controllerPrivateSigningKey != null) { + Ed25519PrivateKeyParameters controllerLongTermPrivateKey = this.controllerLongTermPrivateKey; + Ed25519PublicKeyParameters accessoryLongTermPublicKey = this.accessoryLongTermPublicKey; + if (controllerLongTermPrivateKey != null && accessoryLongTermPublicKey != null) { // Perform Pair-Verify with existing key try { PairVerifyClient client = new PairVerifyClient(httpTransport, baseUrl, accessoryId.toString(), - controllerPrivateSigningKey, TODO_serverPublicSigningKey); + controllerLongTermPrivateKey, accessoryLongTermPublicKey); this.sessionKeys = client.verify(); @@ -214,33 +215,34 @@ private void initializePairing() { return; } catch (Exception e) { logger.debug("Restored pairing was not verified for accessory {}", accessoryId); - this.controllerPrivateKey = null; - storeControllerPrivateSigningKey(); + this.controllerLongTermPrivateKey = null; + storeLongTermKeys(); // fall through to create new pairing } } // Create new controller private key - controllerPrivateSigningKey = new Ed25519PrivateKeyParameters(new SecureRandom()); - logger.debug("Created new controller private key for accessory {}", accessoryId); + controllerLongTermPrivateKey = new Ed25519PrivateKeyParameters(new SecureRandom()); + logger.debug("Created new controller long term private key for accessory {}", accessoryId); try { // Perform Pair-Setup PairSetupClient pairSetupClient = new PairSetupClient(httpTransport, baseUrl, thing.getUID().toString(), - controllerPrivateSigningKey, SRPclient.PAIR_SETUP, pairingCode); + controllerLongTermPrivateKey, pairingCode); - this.sessionKeys = pairSetupClient.pair(); + accessoryLongTermPublicKey = pairSetupClient.pair(); + this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; // Perform Pair-Verify immediately after Pair-Setup PairVerifyClient pairVerifyClient = new PairVerifyClient(httpTransport, baseUrl, accessoryId.toString(), - controllerPrivateSigningKey, TODO_serverPublicSigningKey); + controllerLongTermPrivateKey, accessoryLongTermPublicKey); this.sessionKeys = pairVerifyClient.verify(); - this.session = new SecureSession(sessionKeys); this.rwService = new CharacteristicReadWriteService(httpTransport, session, baseUrl); - this.controllerPrivateKey = controllerPrivateSigningKey; - storeControllerPrivateSigningKey(); + this.controllerLongTermPrivateKey = controllerLongTermPrivateKey; + + storeLongTermKeys(); updateStatus(ThingStatus.ONLINE); logger.debug("Pairing and verification completed for accessory {}", accessoryId); @@ -250,24 +252,31 @@ private void initializePairing() { } } + /** + * Stores the controller's private key in the thing's properties. + * The private key is stored as a Base64-encoded string. + */ + private void storeLongTermKeys() { + Ed25519PrivateKeyParameters controllerKey = this.controllerLongTermPrivateKey; + String property = controllerKey == null ? null : Base64.getEncoder().encodeToString(controllerKey.getEncoded()); + thing.setProperty(PROPERTY_CONTROLLER_PRIVATE_KEY, property); + + Ed25519PublicKeyParameters accessoryKey = this.accessoryLongTermPublicKey; + property = accessoryKey == null ? null : Base64.getEncoder().encodeToString(accessoryKey.getEncoded()); + thing.setProperty(PROPERTY_ACCESSORY_PUBLIC_KEY, property); + } + /** * Restores the controller's private key from the thing's properties. * The private key is expected to have been stored as a Base64-encoded string. */ - private void restoreControllerPrivateSigningKey() { + private void restoreLongTermKeys() { String encoded = thing.getProperties().get(PROPERTY_CONTROLLER_PRIVATE_KEY); - controllerPrivateKey = encoded == null ? null + controllerLongTermPrivateKey = encoded == null ? null : new Ed25519PrivateKeyParameters(Base64.getDecoder().decode(encoded), 0); - } - /** - * Stores the controller's private key in the thing's properties. - * The private key is stored as a Base64-encoded string. - */ - private void storeControllerPrivateSigningKey() { - Ed25519PrivateKeyParameters controllerPrivateKey = this.controllerPrivateKey; - String property = controllerPrivateKey == null ? null - : Base64.getEncoder().encodeToString(controllerPrivateKey.getEncoded()); - thing.setProperty(PROPERTY_CONTROLLER_PRIVATE_KEY, property); + encoded = thing.getProperties().get(PROPERTY_ACCESSORY_PUBLIC_KEY); + accessoryLongTermPublicKey = encoded == null ? null + : new Ed25519PublicKeyParameters(Base64.getDecoder().decode(encoded), 0); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 89f05297456db..1ac3b7e54a018 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -12,7 +12,7 @@ */ package org.openhab.binding.homekit.internal.handler; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.CONFIG_POLLING_INTERVAL; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.util.ArrayList; import java.util.List; @@ -21,6 +21,8 @@ import java.util.concurrent.TimeUnit; import javax.measure.Unit; +import javax.measure.format.UnitFormat; +import javax.measure.spi.ServiceProvider; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.dto.Accessory; @@ -57,8 +59,6 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; -import tech.units.indriya.format.SimpleUnitFormat; - /** * Handles a single HomeKit accessory. * It provides a polling mechanism to regularly update the state of the accessory. @@ -69,6 +69,9 @@ @NonNullByDefault public class HomekitDeviceHandler extends HomekitBaseServerHandler { + private static final UnitFormat UNIT_NAME_PARSER = ServiceProvider.current().getFormatService() + .getUnitFormat("Name"); + private final Logger logger = LoggerFactory.getLogger(HomekitDeviceHandler.class); private final HomekitTypeProvider typeProvider; @@ -178,25 +181,35 @@ private void createChannels() { // create the channels List channels = new ArrayList<>(); + Map properties = thing.getProperties(); accessory.buildAndRegisterChannelGroupDefinitions(typeProvider).forEach(groupDef -> { ChannelGroupType groupType = typeProvider.getChannelGroupType(groupDef.getTypeUID(), null); if (groupType != null) { groupType.getChannelDefinitions().forEach(channelDef -> { - ChannelType channelType = typeProvider.getChannelType(channelDef.getChannelTypeUID(), null); - if (channelType != null) { - ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), channelDef.getId()); - ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()) - .withProperties(channelDef.getProperties()); - Optional.ofNullable(channelDef.getLabel()).ifPresent(builder::withLabel); - Optional.ofNullable(channelDef.getDescription()).ifPresent(builder::withDescription); - channels.add(builder.build()); + if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { + String name = channelDef.getId(); + String value = channelDef.getLabel(); + if (value != null) { + properties.put(name, value); + } + } else { + ChannelType channelType = typeProvider.getChannelType(channelDef.getChannelTypeUID(), null); + if (channelType != null) { + ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), + channelDef.getId()); + ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()) + .withProperties(channelDef.getProperties()); + Optional.ofNullable(channelDef.getLabel()).ifPresent(builder::withLabel); + Optional.ofNullable(channelDef.getDescription()).ifPresent(builder::withDescription); + channels.add(builder.build()); + } } }); } }); // update thing with the new channels - ThingBuilder builder = editThing().withChannels(channels); + ThingBuilder builder = editThing().withProperties(properties).withChannels(channels); Optional.ofNullable(accessory.getSemanticEquipmentTag()).ifPresent(builder::withSemanticEquipmentTag); updateThing(builder.build()); } @@ -223,7 +236,7 @@ private Object convertCommandToObject(Command command, Channel channel) { // convert QuantityTypes to the characteristic's unit if (object instanceof QuantityType quantity) { - Unit unit = properties.get("unit") instanceof String p ? SimpleUnitFormat.getInstance().parse(p) : null; + Unit unit = properties.get("unit") instanceof String p ? UNIT_NAME_PARSER.parse(p) : null; if (unit != null && !unit.equals(quantity.getUnit()) && quantity.getUnit().isCompatible(unit)) { QuantityType temp = quantity.toUnit(unit); object = temp != null ? temp : quantity; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java similarity index 63% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index 69dead7a46b63..96d4d470aee9b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairingRemoveService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -17,12 +17,10 @@ import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.session.SessionKeys; import org.openhab.binding.homekit.internal.transport.HttpTransport; /** @@ -31,49 +29,35 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class PairingRemoveService { +public class PairRemoveClient { private static final String CONTENT_TYPE = "application/pairing+tlv8"; private static final String ENDPOINT = "/pairings"; - private static final byte[] NONCE_M5 = CryptoUtils.generateNonce("PV-Msg05"); - private static final byte[] NONCE_M6 = CryptoUtils.generateNonce("PV-Msg06"); private final HttpTransport httpTransport; private final String baseUrl; - private final SessionKeys sessionKeys; - private final String controllerIdentifier; + private final String pairingID; - public PairingRemoveService(HttpTransport httpTransport, String baseUrl, SessionKeys sessionKeys, - String controllerIdentifier) { + public PairRemoveClient(HttpTransport httpTransport, String baseUrl, String pairingID) { this.httpTransport = httpTransport; this.baseUrl = baseUrl; - this.sessionKeys = sessionKeys; - this.controllerIdentifier = controllerIdentifier; + this.pairingID = pairingID; } public void remove() throws Exception { - // M1 Construct TLV payload for RemovePairing - Map tlv1 = Map.of( // + Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.METHOD.key, new byte[] { PairingMethod.REMOVE.value }, // - TlvType.IDENTIFIER.key, controllerIdentifier.getBytes(StandardCharsets.UTF_8)); - Validator.validate(PairingMethod.REMOVE, tlv1); - byte[] encoded = Tlv8Codec.encode(tlv1); + TlvType.IDENTIFIER.key, pairingID.getBytes(StandardCharsets.UTF_8)); + Validator.validate(PairingMethod.REMOVE, tlv); - // Encrypt payload using write key - byte[] encrypted = CryptoUtils.encrypt(sessionKeys.getWriteKey(), NONCE_M5, encoded); - - // Send to /pairings endpoint - byte[] response = httpTransport.post(baseUrl, ENDPOINT, CONTENT_TYPE, encrypted); - - // M2 Decrypt response using read key - byte[] decrypted = CryptoUtils.decrypt(sessionKeys.getReadKey(), NONCE_M6, response); - Map tlv2 = Tlv8Codec.decode(decrypted); + byte[] response = httpTransport.post(baseUrl, ENDPOINT, CONTENT_TYPE, Tlv8Codec.encode(tlv)); + Map tlv2 = Tlv8Codec.decode(response); Validator.validate(PairingMethod.REMOVE, tlv2); } /** - * Helper that validates the TLV map for the specification required pairing state. + * Helper class that validates the TLV map for the specification required pairing state. */ protected static class Validator { @@ -92,23 +76,23 @@ public static void validate(PairingMethod method, Map tlv) thro "Pairing method '%s' action failed with unknown error".formatted(method.name())); } - byte[] stateBytes = tlv.get(TlvType.STATE.key); - if (stateBytes == null || stateBytes.length != 1) { + byte[] state = tlv.get(TlvType.STATE.key); + if (state == null || state.length != 1) { throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); } - PairingState state = PairingState.from(stateBytes[0]); - Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); + PairingState pairingState = PairingState.from(state[0]); + Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(pairingState); if (expectedKeys == null) { throw new SecurityException( - "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); + "Pairing method '%s' unexpected state '%s'".formatted(method.name(), pairingState.name())); } for (Integer key : expectedKeys) { if (!tlv.containsKey(key)) { throw new SecurityException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." - .formatted(method.name(), state.name(), key)); + .formatted(method.name(), pairingState.name(), key)); } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 836979cc6b45d..c3dd3c1662e87 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -12,19 +12,20 @@ */ package org.openhab.binding.homekit.internal.hap_services; -import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.Map; +import java.util.Objects; import java.util.Set; -import java.util.concurrent.TimeoutException; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.crypto.SRPclient; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.ErrorCode; import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.session.SessionKeys; import org.openhab.binding.homekit.internal.transport.HttpTransport; /** @@ -45,20 +46,16 @@ public class PairSetupClient { private final HttpTransport httpTransport; private final String baseUrl; private final String password; - private final String username; - private final Ed25519PrivateKeyParameters clientPrivateSigningKey; - private final String accessoryIdentifier; + private final byte[] pairingId; + private final Ed25519PrivateKeyParameters clientLongTermPrivateKey; - private @NonNullByDefault({}) SRPclient client = null; - - public PairSetupClient(HttpTransport httpTransport, String baseUrl, String accessoryIdentifier, - Ed25519PrivateKeyParameters clientPrivateSigningKey, String username, String password) throws Exception { + public PairSetupClient(HttpTransport httpTransport, String baseUrl, String pairingId, + Ed25519PrivateKeyParameters clientLongTermPrivateKey, String password) throws Exception { this.httpTransport = httpTransport; this.baseUrl = baseUrl; this.password = password; - this.username = username; - this.clientPrivateSigningKey = clientPrivateSigningKey; - this.accessoryIdentifier = accessoryIdentifier; + this.pairingId = pairingId.getBytes(StandardCharsets.UTF_8); + this.clientLongTermPrivateKey = clientLongTermPrivateKey; } /** @@ -67,72 +64,42 @@ public PairSetupClient(HttpTransport httpTransport, String baseUrl, String acces * @return SessionKeys containing the derived session keys * @throws Exception if any step of the pairing process fails */ - public SessionKeys pair() throws Exception { - byte[] response; - - // Execute the 6-step pairing process - response = doClientStepM1(); - doClientStepM2(response); - response = doClientStepM3(); - doClientStepM4(response); - response = doClientStepM5(); - doClientStepM6(response); - - return client.deriveSessionKeys(); - } - - /** - * Returns the SRP public key generated during the pairing process. - * - * @return byte array containing the SRP public key - * @throws IllegalStateException if the SRP client is not initialized - */ - public byte[] getPublicKey() throws IllegalStateException { - SRPclient client = this.client; - if (client == null) { - throw new IllegalStateException("SRP Client not initialized"); - } - return client.getPublicKey(); + public Ed25519PublicKeyParameters pair() throws Exception { + SRPclient client = doStepM1(); + return client.getAccessoryLongTermPublicKey(); } /** * Executes step M1 of the pairing process: Start Pair-Setup. * * @return byte array containing the response from the accessory - * @throws IOException if an I/O error occurs * @throws InterruptedException if the operation is interrupted - * @throws TimeoutException if the operation times out * @throws Exception if an error occurs during execution */ - private byte[] doClientStepM1() throws Exception { + private SRPclient doStepM1() throws Exception { Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.METHOD.key, new byte[] { PairingMethod.SETUP.value }); Validator.validate(PairingMethod.SETUP, tlv); - - return httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + byte[] response1 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + return doStepM2(response1); } /** * Executes step M2 of the pairing process: Receive salt & accessory SRP public key. * And initializes the SRP client with the received parameters. * - * @param response byte array containing the response from step M1 + * @param response1 byte array containing the response from step M1 * @throws Exception if an error occurs during processing */ - private void doClientStepM2(byte[] response) throws Exception { - Map tlv = Tlv8Codec.decode(response); + private SRPclient doStepM2(byte[] response1) throws Exception { + Map tlv = Tlv8Codec.decode(response1); Validator.validate(PairingMethod.SETUP, tlv); - byte[] serverSalt = tlv.get(TlvType.SALT.key); byte[] serverPublicKey = tlv.get(TlvType.PUBLIC_KEY.key); - if (serverSalt == null || serverPublicKey == null) { - throw new SecurityException("Missing salt or public key TLV in M2 response"); - } - SRPclient client = new SRPclient(username, password, serverSalt); - client.processChallenge(serverPublicKey); - - this.client = client; + SRPclient client = new SRPclient(password, Objects.requireNonNull(serverSalt), + Objects.requireNonNull(serverPublicKey)); + return doStepM3(client); } /** @@ -141,39 +108,28 @@ private void doClientStepM2(byte[] response) throws Exception { * @return byte array containing the response from the accessory * @throws Exception if an error occurs during processing */ - private byte[] doClientStepM3() throws Exception { - SRPclient client = this.client; - if (client == null) { - throw new IllegalStateException("SrpClient not initialized"); - } + private SRPclient doStepM3(SRPclient client) throws Exception { Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M3.value }, // TlvType.PUBLIC_KEY.key, client.getPublicKey(), // TlvType.PROOF.key, client.getClientProof()); Validator.validate(PairingMethod.SETUP, tlv); - - return httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + byte[] response3 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + return doStepM4(client, response3); } /** * Executes step M4 of the pairing process: Verify accessory SRP proof. * - * @param response byte array containing the response from step M3 + * @param response3 byte array containing the response from step M3 * @throws Exception if an error occurs during processing */ - private void doClientStepM4(byte[] response) throws Exception { - SRPclient client = this.client; - if (client == null) { - throw new IllegalStateException("SrpClient not initialized"); - } - Map tlv = Tlv8Codec.decode(response); + private SRPclient doStepM4(SRPclient client, byte[] response3) throws Exception { + Map tlv = Tlv8Codec.decode(response3); Validator.validate(PairingMethod.SETUP, tlv); - byte[] proof = tlv.get(TlvType.PROOF.key); - if (proof == null) { - throw new SecurityException("Missing proof TLV in M4 response"); - } - client.verifyServerProof(proof); + client.verifyServerProof(Objects.requireNonNull(proof)); + return doStepM5(client); } /** @@ -182,40 +138,33 @@ private void doClientStepM4(byte[] response) throws Exception { * @return byte array containing the response from the accessory * @throws Exception if an error occurs during processing */ - private byte[] doClientStepM5() throws Exception { - byte[] encryptedIdentifiers = client.getEncryptedDeviceInfoBlob(client.deriveIOSDeviceXKey(), - accessoryIdentifier, clientPrivateSigningKey.generatePublicKey(), clientPrivateSigningKey); + private SRPclient doStepM5(SRPclient client) throws Exception { + byte[] cipherText = client.createEncryptedControllerInfo(pairingId, clientLongTermPrivateKey); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M5.value }, // - TlvType.ENCRYPTED_DATA.key, encryptedIdentifiers); + TlvType.ENCRYPTED_DATA.key, cipherText); Validator.validate(PairingMethod.SETUP, tlv); - - return httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + byte[] response5 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + return doStepM6(client, response5); } /** * Executes step M6 of the pairing process: Final confirmation & accessory credentials. + * Derives and returns the session keys. * - * @param response byte array containing the response from step M5 + * @param response5 byte array containing the response from step M5 * @throws Exception if an error occurs during processing */ - private void doClientStepM6(byte[] response) throws Exception { - SRPclient client = this.client; - if (client == null) { - throw new IllegalStateException("SrpClient not initialized"); - } - Map tlv = Tlv8Codec.decode(response); + private SRPclient doStepM6(SRPclient client, byte[] response5) throws Exception { + Map tlv = Tlv8Codec.decode(response5); Validator.validate(PairingMethod.SETUP, tlv); - - byte[] data = tlv.get(TlvType.ENCRYPTED_DATA.key); - if (data == null) { - throw new SecurityException("Missing data TLV in M6 response"); - } - client.verifyAccessoryIdentifiers(data); + byte[] ciphertext = tlv.get(TlvType.ENCRYPTED_DATA.key); + client.verifyEncryptedAccessoryInfo(Objects.requireNonNull(ciphertext)); + return client; } /** - * Helper that validates the TLV map for the specification required pairing state. + * Helper class that validates the TLV map for the specification required pairing state. */ public static class Validator { @@ -234,27 +183,29 @@ public static class Validator { */ public static void validate(PairingMethod method, Map tlv) throws SecurityException { if (tlv.containsKey(TlvType.ERROR.key)) { + byte[] err = tlv.get(TlvType.ERROR.key); + ErrorCode code = err != null && err.length > 0 ? ErrorCode.from(err[0]) : ErrorCode.UNKNOWN; throw new SecurityException( - "Pairing method '%s' action failed with unknown error".formatted(method.name())); + "Pairing method '%s' action failed with error '%s'".formatted(method.name(), code.name())); } - byte[] stateBytes = tlv.get(TlvType.STATE.key); - if (stateBytes == null || stateBytes.length != 1) { + byte[] state = tlv.get(TlvType.STATE.key); + if (state == null || state.length != 1) { throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); } - PairingState state = PairingState.from(stateBytes[0]); - Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); + PairingState pairingState = PairingState.from(state[0]); + Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(pairingState); if (expectedKeys == null) { throw new SecurityException( - "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); + "Pairing method '%s' unexpected state '%s'".formatted(method.name(), pairingState.name())); } for (Integer key : expectedKeys) { if (!tlv.containsKey(key)) { throw new SecurityException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." - .formatted(method.name(), state.name(), key)); + .formatted(method.name(), pairingState.name(), key)); } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index ae47392c1f420..a7f9f52c1e469 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -12,8 +12,10 @@ */ package org.openhab.binding.homekit.internal.hap_services; +import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; + import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -22,14 +24,13 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; import org.bouncycastle.crypto.params.X25519PublicKeyParameters; -import org.bouncycastle.crypto.signers.Ed25519Signer; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.session.SessionKeys; +import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; import org.openhab.binding.homekit.internal.transport.HttpTransport; /** @@ -40,29 +41,30 @@ @NonNullByDefault public class PairVerifyClient { - private static final String PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info"; - private static final String PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt"; private static final String CONTENT_TYPE_TLV = "application/pairing+tlv8"; private static final String ENDPOINT_PAIR_VERIFY = "/pair-verify"; - private static final String CONTROL_WRITE_ENCRYPTION_KEY = "Control-Write-Encryption-Key"; - private static final String CONTROL_READ_ENCRYPTION_KEY = "Control-Read-Encryption-Key"; - private static final String CONTROL_SALT = "Control-Salt"; - private static final byte[] VERIFY_NONCE_M2 = CryptoUtils.generateNonce("PV-Msg02"); - private static final byte[] VERIFY_NONCE_M3 = CryptoUtils.generateNonce("PV-Msg03"); private final HttpTransport httpTransport; private final String baseUrl; - private final byte[] clientIdentifier; - private final Ed25519PrivateKeyParameters clientPrivateSigningKey; - private final Ed25519PublicKeyParameters serverPublicSigningKey; - - public PairVerifyClient(HttpTransport httpTransport, String baseUrl, String clientIdentifier, - Ed25519PrivateKeyParameters clientPrivateSigningKey, Ed25519PublicKeyParameters serverPublicSigningKey) { + private final byte[] pairingId; + private final Ed25519PrivateKeyParameters clientLongTermPrivateKey; + private final Ed25519PublicKeyParameters serverLongTermPublicKey; + private final X25519PrivateKeyParameters clientKey; + + private @NonNullByDefault({}) byte[] sharedSecret; + private @NonNullByDefault({}) byte[] sessionKey; + private @NonNullByDefault({}) byte[] readKey; + private @NonNullByDefault({}) byte[] writeKey; + + public PairVerifyClient(HttpTransport httpTransport, String baseUrl, String pairingId, + Ed25519PrivateKeyParameters clientLongTermPrivateKey, Ed25519PublicKeyParameters serverLongTermPublicKey) + throws Exception { this.httpTransport = httpTransport; this.baseUrl = baseUrl; - this.clientIdentifier = clientIdentifier.getBytes(StandardCharsets.UTF_8); - this.clientPrivateSigningKey = clientPrivateSigningKey; - this.serverPublicSigningKey = serverPublicSigningKey; + this.pairingId = pairingId.getBytes(StandardCharsets.UTF_8); + this.clientLongTermPrivateKey = clientLongTermPrivateKey; + this.serverLongTermPublicKey = serverLongTermPublicKey; + this.clientKey = CryptoUtils.generateX25519KeyPair(); } /** @@ -71,92 +73,80 @@ public PairVerifyClient(HttpTransport httpTransport, String baseUrl, String clie * @return SessionKeys containing the derived session keys * @throws Exception if any step of the pairing process fails */ - public SessionKeys verify() throws Exception { - Map tlv; - byte[] encoded; - byte[] response; - byte[] encrypted; - byte[] decrypted; - - // M1 — Create new random client ephemeral X25519 public key and send it to server - X25519PrivateKeyParameters clientKey = CryptoUtils.generateX25519KeyPair(); - byte[] clientKeyBytes = clientKey.generatePublicKey().getEncoded(); - tlv = Map.of( // + public AsymmetricSessionKeys verify() throws Exception { + doStep1(); + return new AsymmetricSessionKeys(readKey, writeKey); + } + + // M1 — Create new random client ephemeral X25519 public key and send it to server + private void doStep1() throws Exception { + byte[] clientKey = this.clientKey.generatePublicKey().getEncoded(); + Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // - TlvType.PUBLIC_KEY.key, clientKeyBytes); + TlvType.PUBLIC_KEY.key, clientKey); Validator.validate(PairingMethod.VERIFY, tlv); - encoded = Tlv8Codec.encode(tlv); - response = httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, encoded); + doStep2(httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); + } - // M2 — Receive server ephemeral X25519 public key and encrypted TLV - tlv = Tlv8Codec.decode(response); + // M2 — Receive server ephemeral X25519 public key and encrypted TLV + private void doStep2(byte[] response1) throws Exception { + Map tlv = Tlv8Codec.decode(response1); Validator.validate(PairingMethod.VERIFY, tlv); + byte[] serverKeyBytes = tlv.get(TlvType.PUBLIC_KEY.key); X25519PublicKeyParameters serverKey = new X25519PublicKeyParameters(serverKeyBytes, 0); - byte[] sharedSecret = CryptoUtils.computeSharedSecret(clientKey, serverKey); - byte[] sessionKey = CryptoUtils.hkdf(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); + sharedSecret = generateSharedSecret(clientKey, serverKey); + sessionKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); - encrypted = tlv.get(TlvType.ENCRYPTED_DATA.key); - decrypted = CryptoUtils.decrypt(sessionKey, VERIFY_NONCE_M2, Objects.requireNonNull(encrypted)); - tlv = Tlv8Codec.decode(decrypted); // inner tlv + byte[] ciphertext = tlv.get(TlvType.ENCRYPTED_DATA.key); + byte[] plaintext = CryptoUtils.decrypt(sessionKey, PV_M2_NONCE, Objects.requireNonNull(ciphertext), + CHACHA20_POLY1305); // validate identifier + signature - byte[] identifier = tlv.get(TlvType.IDENTIFIER.key); - byte[] signature = tlv.get(TlvType.SIGNATURE.key); + Map subTlv = Tlv8Codec.decode(plaintext); + byte[] identifier = subTlv.get(TlvType.IDENTIFIER.key); + byte[] signature = subTlv.get(TlvType.SIGNATURE.key); if (identifier == null || signature == null) { throw new SecurityException("Accessory identifier or signature missing"); } - Ed25519Signer verifier = new Ed25519Signer(); - verifier.init(false, serverPublicSigningKey); - verifier.update(identifier, 0, identifier.length); - boolean valid = verifier.verifySignature(signature); - if (!valid) { - throw new SecurityException("Accessory signature verification failed"); - } - System.out.println("Verified accessory identifier: " + new String(identifier, StandardCharsets.UTF_8)); + verifySignature(serverLongTermPublicKey, identifier, signature); - // M3 — Send encrypted controller identifier and signature - byte[] payload = concat(clientKeyBytes, serverKeyBytes); - signature = CryptoUtils.signVerifyMessage(clientPrivateSigningKey, payload); - tlv = Map.of( // - TlvType.IDENTIFIER.key, clientIdentifier, // - TlvType.SIGNATURE.key, signature); - encoded = Tlv8Codec.encode(tlv); - encrypted = CryptoUtils.encrypt(sessionKey, VERIFY_NONCE_M3, encoded); + doStep3(); + } - tlv = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M3.value }, // - TlvType.ENCRYPTED_DATA.key, encrypted); - Validator.validate(PairingMethod.VERIFY, tlv); + // M3 — Send encrypted controller identifier and signature + private void doStep3() throws Exception { + byte[] sharedKey = generateHkdfKey(sharedSecret, PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); + byte[] signingKey = clientLongTermPrivateKey.generatePublicKey().getEncoded(); + byte[] payload = concat(sharedKey, pairingId, signingKey); + byte[] signature = signMessage(clientLongTermPrivateKey, payload); - encoded = Tlv8Codec.encode(tlv); - response = httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, encoded); + Map subTlv = Map.of( // + TlvType.IDENTIFIER.key, payload, // + TlvType.SIGNATURE.key, signature); - // M4 — Final confirmation - tlv = Tlv8Codec.decode(response); - Validator.validate(PairingMethod.VERIFY, tlv); + byte[] plaintext = Tlv8Codec.encode(subTlv); + byte[] ciphertext = encrypt(sessionKey, PV_M3_NONCE, plaintext, CHACHA20_POLY1305); - // Derive directional session keys - byte[] readKey = CryptoUtils.hkdf(sharedSecret, CONTROL_SALT, CONTROL_READ_ENCRYPTION_KEY); - byte[] writeKey = CryptoUtils.hkdf(sharedSecret, CONTROL_SALT, CONTROL_WRITE_ENCRYPTION_KEY); + Map tlv = Map.of( // + TlvType.STATE.key, new byte[] { PairingState.M3.value }, // + TlvType.ENCRYPTED_DATA.key, ciphertext); + Validator.validate(PairingMethod.VERIFY, tlv); - return new SessionKeys(readKey, writeKey); + doStep4(httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); } - private static byte[] concat(byte[]... parts) { - int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); - byte[] out = new byte[total]; - int pos = 0; - for (byte[] p : parts) { - System.arraycopy(p, 0, out, pos, p.length); - pos += p.length; - } - return out; + // M4 — Final confirmation + private void doStep4(byte[] response3) throws Exception { + Map tlv = Tlv8Codec.decode(response3); + Validator.validate(PairingMethod.VERIFY, tlv); + readKey = CryptoUtils.generateHkdfKey(sharedSecret, CONTROL_SALT, CONTROL_READ_ENCRYPTION_KEY); + writeKey = CryptoUtils.generateHkdfKey(sharedSecret, CONTROL_SALT, CONTROL_WRITE_ENCRYPTION_KEY); } /** - * Helper that validates the TLV map for the specification required pairing state. + * Helper class that validates the TLV map for the specification required pairing state. */ public static class Validator { @@ -177,23 +167,23 @@ public static void validate(PairingMethod method, Map tlv) thro "Pairing method '%s' action failed with unknown error".formatted(method.name())); } - byte[] stateBytes = tlv.get(TlvType.STATE.key); - if (stateBytes == null || stateBytes.length != 1) { + byte[] state = tlv.get(TlvType.STATE.key); + if (state == null || state.length != 1) { throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); } - PairingState state = PairingState.from(stateBytes[0]); - Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(state); + PairingState pairingState = PairingState.from(state[0]); + Set expectedKeys = SPECIFICATION_REQUIRED_KEYS.get(pairingState); if (expectedKeys == null) { throw new SecurityException( - "Pairing method '%s' unexpected state '%s'".formatted(method.name(), state.name())); + "Pairing method '%s' unexpected state '%s'".formatted(method.name(), pairingState.name())); } for (Integer key : expectedKeys) { if (!tlv.containsKey(key)) { throw new SecurityException("Pairing method '%s' state '%s' required TLV '0x%02x' missing." - .formatted(method.name(), state.name(), key)); + .formatted(method.name(), pairingState.name(), key)); } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SessionKeys.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/AsymmetricSessionKeys.java similarity index 89% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SessionKeys.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/AsymmetricSessionKeys.java index a2f2a2c2b9eb4..4cb1360f6a69f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SessionKeys.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/AsymmetricSessionKeys.java @@ -20,11 +20,11 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class SessionKeys { +public class AsymmetricSessionKeys { private final byte[] readKey; private final byte[] writeKey; - public SessionKeys(byte[] readKey, byte[] writeKey) { + public AsymmetricSessionKeys(byte[] readKey, byte[] writeKey) { this.readKey = readKey; this.writeKey = writeKey; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index 84205f67d52b5..96a74a1fba615 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -32,7 +32,7 @@ public class SecureSession { private final AtomicInteger writeCounter = new AtomicInteger(0); private final AtomicInteger readCounter = new AtomicInteger(0); - public SecureSession(SessionKeys keys) { + public SecureSession(AsymmetricSessionKeys keys) { this.writeKey = keys.getWriteKey(); this.readKey = keys.getReadKey(); } @@ -46,7 +46,7 @@ public SecureSession(SessionKeys keys) { */ public byte[] encrypt(byte[] plaintext) throws Exception { byte[] nonce = CryptoUtils.generateNonce(writeCounter.getAndIncrement()); - return CryptoUtils.encrypt(writeKey, nonce, plaintext); + return CryptoUtils.encrypt(writeKey, nonce, plaintext, null); // TODO: AAD } /** @@ -58,6 +58,6 @@ public byte[] encrypt(byte[] plaintext) throws Exception { */ public byte[] decrypt(byte[] ciphertext) throws Exception { byte[] nonce = CryptoUtils.generateNonce(readCounter.getAndIncrement()); - return CryptoUtils.decrypt(readKey, nonce, ciphertext); + return CryptoUtils.decrypt(readKey, nonce, ciphertext, null); // TODO: AAD } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java deleted file mode 100644 index 405292eb789a4..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal; - -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.util.Arrays; -import java.util.Map; - -import org.bouncycastle.crypto.digests.SHA512Digest; -import org.bouncycastle.crypto.generators.HKDFBytesGenerator; -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; -import org.bouncycastle.crypto.params.HKDFParameters; -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.crypto.CryptoUtils; -import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; -import org.openhab.binding.homekit.internal.enums.TlvType; - -/** - * Simulated Stanford Secure Remote Protocol test server used for JUnits tests. - * The implementation is intentionally separate from the Client implementation in order avoid self referencing tests. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class SRPserver { - - // Constants (HomeKit SRP-6a) - private static final BigInteger N = new BigInteger(""" - FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 - 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 - 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED - EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05 - 98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB - 9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B - E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718 - 3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33 - A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7 - ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864 - D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2 - 08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF - """.replaceAll("\\s+", ""), 16); - - private static final BigInteger g = BigInteger.valueOf(5); - private static final BigInteger k = computeK(); - private static final SecureRandom random = new SecureRandom(); - - // HomeKit‐specific constants for HKDF/ChaCha20‐Poly1305 - private static final byte[] PAIR_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); - private static final byte[] PAIR_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); - private static final byte[] PAIR_NONCE_M6 = CryptoUtils.generateNonce("PS-Msg06"); - - // Session state - private final String I; // username - private final byte[] s; // salt - private final BigInteger v; // verifier - private final BigInteger b; // private SRP key ephemeral value - private final BigInteger B; // public SRP key ephemeral value - - private byte @Nullable [] K = null; - private byte @Nullable [] clientPublicSigningKey = null; - - public SRPserver(String username, String password, byte[] salt) throws Exception { - this.I = username; - this.s = salt; - - // Compute verifier once - byte[] hIP = sha512((username + ":" + password).getBytes(StandardCharsets.UTF_8)); - BigInteger x = new BigInteger(1, sha512(concat(salt, hIP))); - this.v = g.modPow(x, N); - - // Generate ephemeral b and compute public B - this.b = new BigInteger(N.bitLength(), random).mod(N); - BigInteger gb = g.modPow(b, N); - this.B = k.multiply(v).add(gb).mod(N); - } - - /** - * * M2 — Get server public key B - * - * @return Server public key B - */ - public byte[] getPublicKey() { - return toUnsigned(B, N); - } - - /** - * M4 — Compute server proof M2 = H(A || M1 || K) - * - * @param clientPublicSigningKey Client Curve25519 public key A (32 bytes) - * @return Server proof M2 - */ - public byte[] computeServerProof(byte[] clientPublicSigningKey) throws Exception { - this.clientPublicSigningKey = clientPublicSigningKey; - BigInteger clientPublic = new BigInteger(1, clientPublicSigningKey); - if (clientPublic.mod(N).equals(BigInteger.ZERO)) { - throw new SecurityException("Invalid client public key"); - } - - // Compute u = H(PAD(A) || PAD(B)) - byte[] uHash = sha512(concat(toUnsigned(clientPublic, N), toUnsigned(B, N))); - BigInteger u = new BigInteger(1, uHash); - if (u.equals(BigInteger.ZERO)) { - throw new SecurityException("Invalid scrambling parameter"); - } - - // Compute shared secret S = (A * v^u)^b mod N - BigInteger vu = v.modPow(u, N); - BigInteger S = clientPublic.multiply(vu).mod(N).modPow(b, N); - this.K = sha512(toUnsigned(S, N)); - - // Compute M1 = H(H(N) ⊕ H(g) || H(I) || salt || A || B || K) - byte[] HN = sha512(toUnsigned(N, N)); - byte[] Hg = sha512(toUnsigned(g, N)); - byte[] Hxor = xor(HN, Hg); - byte[] HI = sha512(I.getBytes(StandardCharsets.UTF_8)); - - byte[] M1 = sha512(concat(Hxor, HI, s, toUnsigned(clientPublic, N), toUnsigned(B, N), K)); - - // Compute M2 = H(A || M1 || K) - return sha512(concat(toUnsigned(clientPublic, N), M1, K)); - } - - /** - * M6 — Encrypt accessory identifier and Curve25519 public key. And sign the TLV with Ed25519 key. - * - * @param serverIdentifier UTF-8 string identifier of the accessory - * @param serverPublicSigningKey Curve25519 public key (32 bytes) - * @param accessoryPrivateKey Ed25519 private key for signing the TLV - * @return encrypted TLV payload for M6 - */ - public byte[] createEncryptedData(String serverIdentifier, byte[] serverPublicSigningKey, - Ed25519PrivateKeyParameters accessoryPrivateKey) throws Exception { - if (K == null) { - throw new IllegalStateException("Session key K not established"); - } - // 1) Build sub-TLV with controller signing public key, pairing identifier, nd server signing public key - byte[] complexIdentifier = concat(clientPublicSigningKey, serverIdentifier.getBytes(StandardCharsets.UTF_8), - serverPublicSigningKey); - Map subTlv = Map.of( // - TlvType.IDENTIFIER.key, complexIdentifier, // - TlvType.PUBLIC_KEY.key, serverPublicSigningKey); - - // 2) Encode and sign the TLV - byte[] message = Tlv8Codec.encode(subTlv); - byte[] signature = CryptoUtils.signVerifyMessage(accessoryPrivateKey, message); - subTlv.put(TlvType.SIGNATURE.key, signature); - - // 3) Re-encode signed TLV - byte[] plaintext = Tlv8Codec.encode(subTlv); - - // 4) Derive session key using HKDF(S, salt, info) - HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA512Digest()); - hkdf.init(new HKDFParameters(K, PAIR_SALT, PAIR_INFO)); - byte[] sessionKey = new byte[32]; - hkdf.generateBytes(sessionKey, 0, sessionKey.length); - - // 5) Encrypt using ChaCha20-Poly1305 - return CryptoUtils.encrypt(sessionKey, PAIR_NONCE_M6, plaintext); - } - - // ─── Utility Methods ────────────────────────────────────────────────────── - - private static BigInteger computeK() { - try { - byte[] paddedN = toUnsigned(N, N); - byte[] paddedG = toUnsigned(g, N); - byte[] hash = sha512(concat(paddedN, paddedG)); - return new BigInteger(1, hash); - } catch (Exception e) { - throw new RuntimeException("Failed to compute k", e); - } - } - - private static byte[] sha512(byte[] data) throws Exception { - MessageDigest md = MessageDigest.getInstance("SHA-512"); - return md.digest(data); - } - - private static byte[] concat(byte[]... parts) { - int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); - byte[] out = new byte[total]; - int pos = 0; - for (byte[] p : parts) { - System.arraycopy(p, 0, out, pos, p.length); - pos += p.length; - } - return out; - } - - private static byte[] xor(byte[] a, byte[] b) { - if (a.length != b.length) { - throw new IllegalArgumentException("xor length mismatch"); - } - byte[] out = new byte[a.length]; - for (int i = 0; i < a.length; i++) { - out[i] = (byte) (a[i] ^ b[i]); - } - return out; - } - - private static byte[] toUnsigned(BigInteger v, BigInteger N) { - int len = (N.bitLength() + 7) / 8; - byte[] raw = v.toByteArray(); - if (raw.length == len) { - return raw; - } - if (raw.length == len + 1 && raw[0] == 0) { - return Arrays.copyOfRange(raw, 1, raw.length); - } - byte[] padded = new byte[len]; - System.arraycopy(raw, 0, padded, len - raw.length, raw.length); - return padded; - } -} diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java new file mode 100644 index 0000000000000..e134241cf57aa --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Map; + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; +import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.TlvType; + +/** + * Simulated Stanford Secure Remote Protocol test server used for JUnits tests. + * The implementation is intentionally separate from the Client implementation in order avoid self referencing tests. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class SRPtestServer { + + private final byte[] serverPairingId; + private final Ed25519PrivateKeyParameters serverLongTermPrivateKey; + + // Session state + private final String I; // username + private final byte[] s; // salt + private final BigInteger v; // verifier + private final BigInteger b; // private SRP key ephemeral value + private final BigInteger B; // public SRP key ephemeral value + + private @NonNullByDefault({}) byte[] K = null; + private @NonNullByDefault({}) BigInteger A; + private @NonNullByDefault({}) BigInteger u; + private @NonNullByDefault({}) BigInteger S; + + public SRPtestServer(String password, byte[] serverSalt, byte[] serverPairingId, + Ed25519PrivateKeyParameters serverLongTermPrivateKey) throws Exception { + this.serverPairingId = serverPairingId; + this.serverLongTermPrivateKey = serverLongTermPrivateKey; + I = PAIR_SETUP; + s = serverSalt; + + // Compute verifier once + byte[] hIP = sha512((I + ":" + password).getBytes(StandardCharsets.UTF_8)); + BigInteger x = new BigInteger(1, sha512(concat(serverSalt, hIP))); + v = g.modPow(x, N); + + // Generate ephemeral b and compute public B + b = new BigInteger(N.bitLength(), new SecureRandom()).mod(N); + BigInteger gb = g.modPow(b, N); + B = k.multiply(v).add(gb).mod(N); + } + + public byte[] createServerProof(byte[] clientPublicKeyA) throws Exception { + BigInteger clientPublicA = new BigInteger(1, clientPublicKeyA); + if (clientPublicA.mod(N).equals(BigInteger.ZERO)) { + throw new SecurityException("Invalid client public key"); + } + A = clientPublicA; + + // u = H(PAD(A) || PAD(B)) + byte[] uHash = sha512(concat(toUnsigned(A, N), toUnsigned(B, N))); + u = new BigInteger(1, uHash); + if (u.equals(BigInteger.ZERO)) { + throw new SecurityException("Invalid scrambling parameter"); + } + + // S = (A * v^u)^b mod N + BigInteger vu = v.modPow(u, N); + BigInteger base = A.multiply(vu).mod(N); + S = base.modPow(b, N); + K = sha512(toUnsigned(S, N)); + + // Compute M1 = H(H(N) ⊕ H(g) || H(I) || salt || A || B || K) + byte[] HN = sha512(toUnsigned(N, N)); + byte[] Hg = sha512(toUnsigned(g, N)); + byte[] Hxor = xor(HN, Hg); + byte[] HI = sha512(I.getBytes(StandardCharsets.UTF_8)); + byte[] M1 = sha512(concat(Hxor, HI, s, toUnsigned(clientPublicA, N), toUnsigned(B, N), K)); + + // Compute M2 = H(A || M1 || K) + return sha512(concat(toUnsigned(clientPublicA, N), M1, K)); + } + + public byte[] createEncryptedAccessoryInfo() throws Exception { + byte[] sharedKey = generateHkdfKey(getSharedSecret(), PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); + byte[] signingKey = serverLongTermPrivateKey.generatePublicKey().getEncoded(); + byte[] payload = concat(sharedKey, serverPairingId, signingKey); + byte[] signature = signMessage(serverLongTermPrivateKey, payload); + + Map subTlv = Map.of( // + TlvType.IDENTIFIER.key, serverPairingId, // + TlvType.PUBLIC_KEY.key, signingKey, // + TlvType.SIGNATURE.key, signature); + + byte[] plaintext = Tlv8Codec.encode(subTlv); + return CryptoUtils.encrypt(getSymmetricKey(), PS_M6_NONCE, plaintext, CHACHA20_POLY1305); + } + + public byte[] getPublicKey() { + return toUnsigned(B, N); + } + + private byte[] getSharedSecret() { + return toUnsigned(S, N); + } + + public byte[] getSymmetricKey() { + return generateHkdfKey(toUnsigned(S, N), PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java index e6afcb6a5115e..0379424523dde 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -40,7 +40,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -class TestChannelDataLoad { +class TestChannelCreation { // Chapter 6.6.4 Example Accessory Attribute Database in JSON private static final String TEST_JSON = """ @@ -313,17 +313,17 @@ void testGenericJsonParsing() { for (Accessory accessory : accessories.accessories) { assertNotNull(accessory.aid); assertNotNull(accessory.services); - assertTrue(accessory.services.size() > 0); + assertTrue(!accessory.services.isEmpty()); for (var service : accessory.services) { assertNotNull(service.type); assertNotNull(service.iid); assertNotNull(service.characteristics); - assertTrue(service.characteristics.size() > 0); + assertTrue(!service.characteristics.isEmpty()); for (var characteristic : service.characteristics) { assertNotNull(characteristic.type); assertNotNull(characteristic.iid); assertNotNull(characteristic.perms); - assertTrue(characteristic.perms.size() > 0); + assertTrue(!characteristic.perms.isEmpty()); assertNotNull(characteristic.format); } } @@ -381,9 +381,9 @@ void testChannelDefinitions() { List channelGroupDefinitions = accessory .buildAndRegisterChannelGroupDefinitions(typeProvider); - // There should be one channel group definition for the Light Bulb service + // There should be one channel group definition for the Light Bulb service and one for the properties assertNotNull(channelGroupDefinitions); - assertEquals(1, channelGroupDefinitions.size()); + assertEquals(2, channelGroupDefinitions.size()); // Check that the channel group definition and its type UID and label are set for (ChannelGroupDefinition groupDef : channelGroupDefinitions) { @@ -392,21 +392,41 @@ void testChannelDefinitions() { assertNotNull(groupDef.getLabel()); } - // There should be one channel group type for the Light Bulb service - assertEquals(1, channelGroupTypes.size()); + // There should be one channel group type for the Light Bulb service and one for the properties + assertEquals(2, channelGroupTypes.size()); - // Check that the channel group type and its UID and label are set + // Check that the public-hap-service-accessory-information channel group type and its UID and label are set ChannelGroupType channelGroupType = channelGroupTypes.stream() + .filter(cgt -> "public-hap-service-accessory-information".equals(cgt.getUID().getId())).findFirst() + .orElse(null); + assertNotNull(channelGroupType); + // There should be four fake channel definitions for the Accessory Information service + assertEquals(4, channelGroupType.getChannelDefinitions().size()); + + // Check the Name fake channel definition + ChannelDefinition channelDefinition = channelGroupType.getChannelDefinitions().stream() + .filter(cd -> "name".equals(cd.getId())).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Acme LED Light Bulb", channelDefinition.getLabel()); + + // Check the Serial Number fake channel definition + channelDefinition = channelGroupType.getChannelDefinitions().stream() + .filter(cd -> "serialNumber".equals(cd.getId())).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("099DB48E9E28", channelDefinition.getLabel()); + + // Check that the channel group type and its UID and label are set + channelGroupType = channelGroupTypes.stream() .filter(cgt -> "public-hap-service-lightbulb".equals(cgt.getUID().getId())).findFirst().orElse(null); assertNotNull(channelGroupType); assertEquals("Channel group type: Light Bulb", channelGroupType.getLabel()); assertEquals("public-hap-service-lightbulb", channelGroupType.getUID().getId()); // There should be two channel definitions for the Light Bulb service: On and Brightness - assertEquals(1, channelGroupType.getChannelDefinitions().size()); + assertEquals(2, channelGroupType.getChannelDefinitions().size()); // Check the Brightness channel definition and its properties - ChannelDefinition channelDefinition = channelGroupType.getChannelDefinitions().stream() + channelDefinition = channelGroupType.getChannelDefinitions().stream() .filter(cd -> "Brightness".equals(cd.getLabel())).findFirst().orElse(null); assertNotNull(channelDefinition); assertEquals("public-hap-characteristic-brightness", channelDefinition.getChannelTypeUID().getId()); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index e5a0f1705b149..2dac0ecf7f34b 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -12,14 +12,23 @@ */ package org.openhab.binding.homekit.internal; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; import java.util.Map; +import java.util.Objects; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; +import org.openhab.binding.homekit.internal.crypto.SRPclient; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; @@ -47,15 +56,49 @@ class TestPairSetup { E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20 """; + private @NonNullByDefault({}) byte[] clientPublicKey; + + @Test + void testBareCrypto() throws InvalidCipherTextException { + byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); + byte[] key = new byte[32]; // 256 bits = 32 bytes + byte[] nonce = CryptoUtils.generateNonce(123); + new SecureRandom().nextBytes(key); + byte[] cipherText = encrypt(key, nonce, plainText0, null); + byte[] plainText1 = decrypt(key, nonce, cipherText, null); + assertArrayEquals(plainText0, plainText1); + byte[] cipherText2 = encrypt(key, nonce, plainText0, CHACHA20_POLY1305); + byte[] plainText2 = decrypt(key, nonce, cipherText2, CHACHA20_POLY1305); + assertArrayEquals(plainText0, plainText2); + assertThrows(InvalidCipherTextException.class, + () -> decrypt(key, nonce, cipherText2, "bad-authTag".getBytes(StandardCharsets.UTF_8))); + } + + @Test + void testSrpClient() throws Exception { + byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); + SRPclient client = new SRPclient("password123", hexBlockToByteArray(SALT_HEX), + hexBlockToByteArray(SERVER_PRIVATE_HEX)); + byte[] key = client.getSymmetricKey(); + byte[] cipherText = encrypt(key, PS_M5_NONCE, plainText0, null); + byte[] plainText1 = decrypt(key, PS_M5_NONCE, cipherText, null); + assertArrayEquals(plainText0, plainText1); + byte[] cipherText2 = encrypt(key, PS_M5_NONCE, plainText0, CHACHA20_POLY1305); + byte[] plainText2 = decrypt(key, PS_M5_NONCE, cipherText2, CHACHA20_POLY1305); + assertArrayEquals(plainText0, plainText2); + assertThrows(InvalidCipherTextException.class, + () -> decrypt(key, PS_M5_NONCE, cipherText2, "bad-authTag".getBytes(StandardCharsets.UTF_8))); + } + @Test void testPairSetup() throws Exception { // initialize test parameters String baseUrl = "http://example.com"; - String username = "alice"; String password = "password123"; - String clientIdentifier = "11:22:33:44:55:66"; - String serverIdentifier = "AA:BB:CC:DD:EE:FF"; + String clientPairingIdentifier = "11:22:33:44:55:66"; + String serverPairingIdentifier = "66:55:44:33:22:11"; byte[] serverSalt = hexBlockToByteArray(SALT_HEX); + byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); // initialize signing keys Ed25519PrivateKeyParameters clientPrivateSigningKey = new Ed25519PrivateKeyParameters( @@ -67,9 +110,9 @@ void testPairSetup() throws Exception { HttpTransport mockTransport = mock(HttpTransport.class); // create SRP client and server - SRPserver server = new SRPserver(username, password, serverSalt); - PairSetupClient client = new PairSetupClient(mockTransport, baseUrl, clientIdentifier, clientPrivateSigningKey, username, - password); + SRPtestServer server = new SRPtestServer(password, serverSalt, serverPairingId, serverPrivateSigningKey); + PairSetupClient client = new PairSetupClient(mockTransport, baseUrl, clientPairingIdentifier, + clientPrivateSigningKey, password); // mock the HTTP transport to simulate the SRP exchange doAnswer(invocation -> { @@ -86,8 +129,8 @@ void testPairSetup() throws Exception { // process the message based on the pairing process Mx state return switch (state[0]) { case 1 -> getServerResponseM1(server, serverSalt); - case 3 -> getServerResponseM3(server, client); - case 5 -> getServerResponseM5(server, serverIdentifier, serverPrivateSigningKey); + case 3 -> getServerResponseM3(server, tlv, client); + case 5 -> getServerResponseM5(server); default -> throw new IllegalArgumentException("Unexpected state"); }; @@ -97,38 +140,33 @@ void testPairSetup() throws Exception { client.pair(); } - private byte[] getServerResponseM1(SRPserver server, byte[] serverSalt) { + private byte[] getServerResponseM1(SRPtestServer server, byte[] serverSalt) { Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M2.value }, // TlvType.SALT.key, serverSalt, // salt TlvType.PUBLIC_KEY.key, server.getPublicKey() // server public key ); - PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); return Tlv8Codec.encode(tlv); } - private byte[] getServerResponseM3(SRPserver server, PairSetupClient client) throws Exception { - byte[] serverProof = server.computeServerProof(client.getPublicKey()); - - Map tlv = Map.of( // + private byte[] getServerResponseM3(SRPtestServer server, Map tlv2, PairSetupClient client) + throws Exception { + clientPublicKey = tlv2.get(TlvType.PUBLIC_KEY.key); + byte[] serverProof = server.createServerProof(Objects.requireNonNull(clientPublicKey)); + Map tlv3 = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M4.value }, // TlvType.PROOF.key, serverProof // server proof ); - - PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); - return Tlv8Codec.encode(tlv); + PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv3); + return Tlv8Codec.encode(tlv3); } - private byte[] getServerResponseM5(SRPserver server, String serverIdentifier, - Ed25519PrivateKeyParameters serverPrivateSigningKey) throws Exception { - byte[] serverEncyptedData = server.createEncryptedData(serverIdentifier, server.getPublicKey(), - serverPrivateSigningKey); - + private byte[] getServerResponseM5(SRPtestServer server) throws Exception { + byte[] cipertext = server.createEncryptedAccessoryInfo(); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M6.value }, // - TlvType.ENCRYPTED_DATA.key, serverEncyptedData); - + TlvType.ENCRYPTED_DATA.key, cipertext); PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); return Tlv8Codec.encode(tlv); } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index af1db276e17e8..cc34b17ab8f08 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -14,24 +14,24 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.Map; import java.util.Objects; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; import org.bouncycastle.crypto.params.X25519PublicKeyParameters; +import org.bouncycastle.util.Arrays; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; -import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; -import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient.Validator; import org.openhab.binding.homekit.internal.transport.HttpTransport; /** @@ -42,11 +42,6 @@ @NonNullByDefault class TestPairVerify { - private static final String PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info"; - private static final String PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt"; - private static final byte[] VERIFY_NONCE_M2 = CryptoUtils.generateNonce("PV-Msg02"); - private static final byte[] VERIFY_NONCE_M3 = CryptoUtils.generateNonce("PV-Msg03"); - public static final String CLIENT_PRIVATE_HEX = """ 60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393 """; @@ -55,27 +50,32 @@ class TestPairVerify { E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20 """; - private byte[] sessionKey = new byte[0]; + private final String baseUrl = "http://example.com"; + private final String clientPairingIdentifier = "11:22:33:44:55:66"; + private final byte[] clientPairingId = clientPairingIdentifier.getBytes(StandardCharsets.UTF_8); + private final String serverPairingIdentifier = "66:55:44:33:22:11"; + private final byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); + + private final Ed25519PrivateKeyParameters clientLongTermPrivateKey = new Ed25519PrivateKeyParameters( + hexBlockToByteArray(CLIENT_PRIVATE_HEX)); + + private final Ed25519PrivateKeyParameters serverLongTermPrivateKey = new Ed25519PrivateKeyParameters( + hexBlockToByteArray(SERVER_PRIVATE_HEX)); + + private @NonNullByDefault({}) X25519PrivateKeyParameters serverKey; + private @NonNullByDefault({}) X25519PublicKeyParameters clientKey; + private @NonNullByDefault({}) byte[] sessionKey; @Test void testPairVerify() throws Exception { - // initialize test parameters - String baseUrl = "http://example.com"; - String clientIdentifier = "11:22:33:44:55:66"; - String serverIdentifier = "AA:BB:CC:DD:EE:FF"; - - // initialize signing keys - Ed25519PrivateKeyParameters clientPrivateSigningKey = new Ed25519PrivateKeyParameters( - hexBlockToByteArray(CLIENT_PRIVATE_HEX)); - Ed25519PrivateKeyParameters serverPrivateSigningKey = new Ed25519PrivateKeyParameters( - hexBlockToByteArray(SERVER_PRIVATE_HEX)); + serverKey = generateX25519KeyPair(); // create mock HttpTransport mockTransport = mock(HttpTransport.class); // create SRP client and server - PairVerifyClient client = new PairVerifyClient(mockTransport, baseUrl, clientIdentifier, - clientPrivateSigningKey, serverPrivateSigningKey.generatePublicKey()); + PairVerifyClient client = new PairVerifyClient(mockTransport, baseUrl, clientPairingIdentifier, + clientLongTermPrivateKey, serverLongTermPrivateKey.generatePublicKey()); // mock the HTTP transport to simulate the SRP exchange doAnswer(invocation -> { @@ -91,7 +91,7 @@ void testPairVerify() throws Exception { // process the message based on the pair verification process Mx state return switch (state[0]) { - case 1 -> getServerResponseM1(tlv, serverIdentifier, serverPrivateSigningKey); + case 1 -> getServerResponseM1(tlv); case 3 -> getServerResponseM3(tlv); default -> throw new IllegalArgumentException("Unexpected state"); }; @@ -102,31 +102,28 @@ void testPairVerify() throws Exception { client.verify(); } - private byte[] getServerResponseM1(Map tlv, String serverIdentifier, - Ed25519PrivateKeyParameters serverPrivateSigningKey) throws Exception { - X25519PrivateKeyParameters serverKey = CryptoUtils.generateX25519KeyPair(); - - byte[] pairingId = serverIdentifier.getBytes(StandardCharsets.UTF_8); + private byte[] getServerResponseM1(Map tlv) throws Exception { byte[] clientKeyBytes = tlv.get(TlvType.PUBLIC_KEY.key); - byte[] payload = concat(serverKey.generatePublicKey().getEncoded(), pairingId, - Objects.requireNonNull(clientKeyBytes)); + byte[] serverKeyBytes = serverKey.generatePublicKey().getEncoded(); + byte[] payload = concat(serverKeyBytes, serverPairingId, Objects.requireNonNull(clientKeyBytes)); + byte[] signature = signMessage(serverLongTermPrivateKey, payload); - byte[] signature = CryptoUtils.signVerifyMessage(serverPrivateSigningKey, payload); Map tlvInner = Map.of( // - TlvType.IDENTIFIER.key, pairingId, // + TlvType.IDENTIFIER.key, serverPairingId, // TlvType.SIGNATURE.key, signature); - X25519PublicKeyParameters clientKey = new X25519PublicKeyParameters(clientKeyBytes); + clientKey = new X25519PublicKeyParameters(clientKeyBytes); + + byte[] sharedSecret = generateSharedSecret(serverKey, clientKey); + sessionKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); - byte[] sharedSecret = CryptoUtils.computeSharedSecret(serverKey, clientKey); - this.sessionKey = CryptoUtils.hkdf(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); - byte[] plaintext = Tlv8Codec.encode(tlvInner); // TODO ?? authTag see page 40 - byte[] encrypted = CryptoUtils.encrypt(sessionKey, VERIFY_NONCE_M2, plaintext); + byte[] plaintext = Tlv8Codec.encode(tlvInner); + byte[] ciphertext = encrypt(sessionKey, PV_M2_NONCE, plaintext, CHACHA20_POLY1305); Map tlvOut = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M2.value }, // TlvType.PUBLIC_KEY.key, serverKey.generatePublicKey().getEncoded(), // - TlvType.ENCRYPTED_DATA.key, encrypted); + TlvType.ENCRYPTED_DATA.key, ciphertext); return Tlv8Codec.encode(tlvOut); } @@ -135,13 +132,27 @@ private byte[] getServerResponseM3(Map tlv) throws Exception { if (sessionKey.length == 0) { throw new IllegalStateException("Session key not established"); } - byte[] encrypted = tlv.get(TlvType.ENCRYPTED_DATA.key); - byte[] plaintext = CryptoUtils.decrypt(sessionKey, VERIFY_NONCE_M3, Objects.requireNonNull(encrypted)); + byte[] ciphertext = tlv.get(TlvType.ENCRYPTED_DATA.key); + if (ciphertext == null) { + throw new SecurityException("Missing ciphertext in M3"); + } + byte[] plaintext = decrypt(sessionKey, PV_M3_NONCE, Objects.requireNonNull(ciphertext), CHACHA20_POLY1305); + + Map subTlv = Tlv8Codec.decode(plaintext); + byte[] information = subTlv.get(TlvType.IDENTIFIER.key); + byte[] signature = subTlv.get(TlvType.SIGNATURE.key); + if (information == null || signature == null) { + throw new SecurityException("Client pairing ID or signature missing"); + } - System.out.println("Decrypted M3: " + Arrays.toString(plaintext)); // TODO + verifySignature(clientLongTermPrivateKey.generatePublicKey(), plaintext, Objects.requireNonNull(signature)); + byte[] pairingId = Arrays.copyOfRange(information, 32, information.length - 32); + if (!Arrays.areEqual(clientPairingId, pairingId)) { + throw new SecurityException("Client pairing ID does not match"); + } Map tlvOut = Map.of(TlvType.STATE.key, new byte[] { PairingState.M4.value }); - Validator.validate(PairingMethod.VERIFY, tlvOut); + PairVerifyClient.Validator.validate(PairingMethod.VERIFY, tlvOut); // no further messages from server return Tlv8Codec.encode(tlvOut); @@ -165,15 +176,4 @@ private static byte[] hexBlockToByteArray(String hexBlock) { } return result; } - - private static byte[] concat(byte[]... parts) { - int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); - byte[] out = new byte[total]; - int pos = 0; - for (byte[] p : parts) { - System.arraycopy(p, 0, out, pos, p.length); - pos += p.length; - } - return out; - } } From 9a63e20c8aff79081dc39453dca6af84986a685e Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 21 Sep 2025 21:34:31 +0100 Subject: [PATCH 024/177] Update bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java Co-authored-by: Jacob Laursen Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/HomekitBindingConstants.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index a9c8c905bfd2c..0e5ff475f4324 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -47,7 +47,7 @@ public class HomekitBindingConstants { // configuration parameters public static final String CONFIG_PAIRING_CODE = "pairingCode"; public static final String CONFIG_IP_V4_ADDRESS = "ipV4Address"; - public static final String CONFIG_POLLING_INTERVAL = "pollingInterval"; + public static final String CONFIG_POLLING_INTERVAL = "refreshInterval"; // properties public static final String PROPERTY_UID = "uid"; From 02fb31e07f138557fed1c34f411cbf4b964fc703 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 21 Sep 2025 22:53:19 +0100 Subject: [PATCH 025/177] adopt reviewer suggestions Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/HomekitBindingConstants.java | 2 +- .../internal/handler/HomekitDeviceHandler.java | 12 ++++++------ .../src/main/resources/OH-INF/thing/thing-types.xml | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 0e5ff475f4324..d998906310c9a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -47,7 +47,7 @@ public class HomekitBindingConstants { // configuration parameters public static final String CONFIG_PAIRING_CODE = "pairingCode"; public static final String CONFIG_IP_V4_ADDRESS = "ipV4Address"; - public static final String CONFIG_POLLING_INTERVAL = "refreshInterval"; + public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; // properties public static final String PROPERTY_UID = "uid"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 1ac3b7e54a018..36540a0357df8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -83,14 +83,14 @@ public HomekitDeviceHandler(Thing thing, HttpClientFactory httpClientFactory, Ho @Override public void initialize() { super.initialize(); - String interval = getConfig().get(CONFIG_POLLING_INTERVAL).toString(); + String refreshInterval = getConfig().get(CONFIG_REFRESH_INTERVAL).toString(); try { - int intervalSeconds = Integer.parseInt(interval); - if (intervalSeconds > 0) { - scheduler.scheduleWithFixedDelay(this::poll, 0, intervalSeconds, TimeUnit.SECONDS); + int refreshIntervalSeconds = Integer.parseInt(refreshInterval); + if (refreshIntervalSeconds > 0) { + scheduler.scheduleWithFixedDelay(this::refresh, 0, refreshIntervalSeconds, TimeUnit.SECONDS); } } catch (NumberFormatException e) { - logger.warn("Invalid polling interval configuration: {}", interval); + logger.warn("Invalid refresh interval configuration: {}", refreshInterval); } } @@ -123,7 +123,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { * Polls the accessory for its current state and updates the corresponding channels. * This method is called periodically by a scheduled executor. */ - private void poll() { + private void refresh() { CharacteristicReadWriteService rwService = this.rwService; if (rwService != null) { try { diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 7010c82f7b05f..582e9acc99c4d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -27,7 +27,7 @@ - + HomeKit Accessory Bridge @@ -48,6 +48,6 @@ true - + From 8e6fbe88290f375ecbb4fde9483cbc3d7e81e44d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 22 Sep 2025 13:17:16 +0100 Subject: [PATCH 026/177] implement IP transport plus various fixes Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/pom.xml | 1 - .../internal/crypto/CryptoConstants.java | 2 - .../homekit/internal/crypto/CryptoUtils.java | 41 ++-- .../homekit/internal/crypto/SRPclient.java | 4 +- .../handler/HomekitBaseServerHandler.java | 113 +++++----- .../CharacteristicReadWriteService.java | 21 +- .../hap_services/PairRemoveClient.java | 12 +- .../hap_services/PairSetupClient.java | 16 +- .../hap_services/PairVerifyClient.java | 19 +- .../internal/session/SecureSession.java | 54 +++-- .../internal/transport/HttpTransport.java | 129 ------------ .../internal/transport/IpTransport.java | 193 ++++++++++++++++++ .../homekit/internal/SRPtestServer.java | 2 +- .../homekit/internal/TestChannelCreation.java | 1 + .../homekit/internal/TestPairSetup.java | 66 ++---- .../homekit/internal/TestPairVerify.java | 40 +--- 16 files changed, 372 insertions(+), 342 deletions(-) delete mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java diff --git a/bundles/org.openhab.binding.homekit/pom.xml b/bundles/org.openhab.binding.homekit/pom.xml index feedced85502b..8922ce99696ad 100644 --- a/bundles/org.openhab.binding.homekit/pom.xml +++ b/bundles/org.openhab.binding.homekit/pom.xml @@ -18,7 +18,6 @@ org.bouncycastle bcprov-jdk18on - 1.81 diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java index ff11ad9734f12..b14d47f32c3e4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java @@ -63,8 +63,6 @@ public class CryptoConstants { public static final byte[] CONTROL_READ_ENCRYPTION_KEY = "Control-Read-Encryption-Key".getBytes(StandardCharsets.UTF_8); public static final byte[] CONTROL_WRITE_ENCRYPTION_KEY = "Control-Write-Encryption-Key".getBytes(StandardCharsets.UTF_8); - public static final byte[] CHACHA20_POLY1305 = "ChaCha20-Poly1305".getBytes(StandardCharsets.UTF_8); - public static final byte[] PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info".getBytes(StandardCharsets.UTF_8); public static final byte[] PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index c1a9e940b4ee2..6387c53ea48ed 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -55,18 +55,8 @@ public static byte[] concat(byte[]... parts) { } // Decrypt with ChaCha20-Poly1305 - public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText, byte @Nullable [] authTag) - throws InvalidCipherTextException { - int length; - if (authTag != null) { - length = cipherText.length - authTag.length; - byte[] cipherTag = Arrays.copyOfRange(cipherText, length, cipherText.length); - if (!Arrays.equals(cipherTag, authTag)) { - throw new InvalidCipherTextException("Authentication tag mismatch"); - } - } else { - length = cipherText.length; - } + public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText) throws InvalidCipherTextException { + int length = cipherText.length; ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); cipher.init(false, params); @@ -77,15 +67,14 @@ public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText, byte @ } // Encrypt with ChaCha20-Poly1305 - public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plainText, byte @Nullable [] authTag) - throws InvalidCipherTextException { + public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plainText) throws InvalidCipherTextException { ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); cipher.init(true, params); byte[] cipherText = new byte[cipher.getOutputSize(plainText.length)]; int length = cipher.processBytes(plainText, 0, plainText.length, cipherText, 0); cipher.doFinal(cipherText, length); - return authTag == null ? cipherText : concat(cipherText, authTag); + return cipherText; } // HKDF-SHA512 key derivation @@ -186,15 +175,33 @@ public static byte[] xor(byte[] a, byte[] b) { return out; } - public static String toSpaceDelimitedHex(byte @Nullable [] bytes) { + public static String asHex(byte @Nullable [] bytes) { if (bytes == null) { return "null"; } StringBuilder sb = new StringBuilder(); - sb.append(String.format("[%03d]", bytes.length)).append(' '); for (byte b : bytes) { sb.append(String.format("%02X", b)).append(' '); } return sb.toString().trim(); // remove trailing space } + + public static byte[] fromHex(String hexBlock) { + String normalized = hexBlock.replaceAll("\\s+", ""); + if (normalized.length() % 2 != 0) { + throw new IllegalArgumentException("Hex string must have even length"); + } + int len = normalized.length(); + byte[] result = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + int high = Character.digit(normalized.charAt(i), 16); + int low = Character.digit(normalized.charAt(i + 1), 16); + if (high == -1 || low == -1) { + throw new IllegalArgumentException( + "Invalid hex character: " + normalized.charAt(i) + normalized.charAt(i + 1)); + } + result[i / 2] = (byte) ((high << 4) + low); + } + return result; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index a51ab568f5cd9..d5575c8e7c8e2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -108,7 +108,7 @@ public byte[] createEncryptedControllerInfo(byte[] pairingId, TlvType.SIGNATURE.key, signature); byte[] plaintext = Tlv8Codec.encode(subTlv); - byte[] ciphertext = encrypt(getSymmetricKey(), PS_M5_NONCE, plaintext, CHACHA20_POLY1305); + byte[] ciphertext = encrypt(getSymmetricKey(), PS_M5_NONCE, plaintext); return ciphertext; } @@ -137,7 +137,7 @@ public byte[] getSymmetricKey() { } public void verifyEncryptedAccessoryInfo(byte[] cipherText) throws Exception { - byte[] plainText = decrypt(getSymmetricKey(), PS_M6_NONCE, cipherText, CHACHA20_POLY1305); + byte[] plainText = decrypt(getSymmetricKey(), PS_M6_NONCE, cipherText); Map subTlv = Tlv8Codec.decode(plainText); byte[] pairingId = subTlv.get(TlvType.IDENTIFIER.key); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 6d887687a6597..782d3b76a91e3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -34,9 +34,7 @@ import org.openhab.binding.homekit.internal.hap_services.PairRemoveClient; import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; -import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; -import org.openhab.binding.homekit.internal.session.SecureSession; -import org.openhab.binding.homekit.internal.transport.HttpTransport; +import org.openhab.binding.homekit.internal.transport.IpTransport; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; @@ -66,24 +64,31 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { private final Logger logger = LoggerFactory.getLogger(HomekitBaseServerHandler.class); - protected final HttpTransport httpTransport; protected final Map accessories = new HashMap<>(); protected boolean isChildAccessory = false; protected @NonNullByDefault({}) CharacteristicReadWriteService rwService; - protected @NonNullByDefault({}) SecureSession session; - protected @NonNullByDefault({}) String baseUrl; protected @NonNullByDefault({}) String pairingCode; protected @NonNullByDefault({}) Integer accessoryId; - protected @NonNullByDefault({}) AsymmetricSessionKeys sessionKeys; + protected @NonNullByDefault({}) IpTransport ipTransport; protected @Nullable Ed25519PrivateKeyParameters controllerLongTermPrivateKey = null; protected @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; public HomekitBaseServerHandler(Thing thing, HttpClientFactory httpClientFactory) { super(thing); - this.httpTransport = new HttpTransport(httpClientFactory.getCommonHttpClient()); + } + + @Override + public void dispose() { + if (!isChildAccessory) { + try { + ipTransport.close(); + } catch (Exception e) { + } + } + super.dispose(); } /** @@ -96,20 +101,16 @@ public HomekitBaseServerHandler(Thing thing, HttpClientFactory httpClientFactory * @see HomeKit HTTP */ protected void getAccessories() { - SecureSession session = this.session; - if (session != null) { - try { - byte[] encrypted = httpTransport.get(baseUrl, ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); - byte[] decrypted = session.decrypt(encrypted); - Accessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), Accessories.class); - if (result != null && result.accessories instanceof List accessoryList) { - accessories.clear(); - accessories.putAll(accessoryList.stream().filter(a -> Objects.nonNull(a.aid)) - .collect(Collectors.toMap(a -> a.aid, Function.identity()))); - } - } catch (Exception e) { - logger.warn("Failed to get accessories: {}", e.getMessage()); + try { + byte[] decrypted = ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); + Accessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), Accessories.class); + if (result != null && result.accessories instanceof List accessoryList) { + accessories.clear(); + accessories.putAll(accessoryList.stream().filter(a -> Objects.nonNull(a.aid)) + .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } + } catch (Exception e) { + logger.warn("Failed to get accessories: {}", e.getMessage()); } } @@ -149,9 +150,9 @@ public void handleRemoval() { scheduler.submit(() -> { // unpair and clear stored keys if this is NOT a child accessory try { - PairRemoveClient service = new PairRemoveClient(httpTransport, baseUrl, thing.getUID().toString()); + PairRemoveClient service = new PairRemoveClient(ipTransport, thing.getUID().toString()); service.remove(); - this.accessoryLongTermPublicKey = null; + accessoryLongTermPublicKey = null; storeLongTermKeys(); updateStatus(ThingStatus.REMOVED); } catch (Exception e) { @@ -166,19 +167,25 @@ public void initialize() { Bridge bridge = getBridge(); if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { // accessory is hosted by a bridge, so use bridge's pairing session and read/write service - this.isChildAccessory = true; - this.session = bridgeHandler.session; - this.rwService = bridgeHandler.rwService; - if (this.rwService != null) { + isChildAccessory = true; + ipTransport = bridgeHandler.ipTransport; + rwService = bridgeHandler.rwService; + if (rwService != null) { updateStatus(ThingStatus.ONLINE); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not connected"); } } else { // standalone accessory or brige accessory, so do pairing and session setup here - this.isChildAccessory = false; - this.baseUrl = "http://" + getConfig().get(CONFIG_IP_V4_ADDRESS).toString(); - scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread + isChildAccessory = false; + try { + ipTransport = new IpTransport(getConfig().get(CONFIG_IP_V4_ADDRESS).toString()); + scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread + } catch (Exception e) { + logger.warn("Failed to create transport: {}", e.getMessage()); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "Failed to connect to accessory"); + } } } @@ -201,13 +208,11 @@ private void initializePairing() { if (controllerLongTermPrivateKey != null && accessoryLongTermPublicKey != null) { // Perform Pair-Verify with existing key try { - PairVerifyClient client = new PairVerifyClient(httpTransport, baseUrl, accessoryId.toString(), + PairVerifyClient client = new PairVerifyClient(ipTransport, accessoryId.toString(), controllerLongTermPrivateKey, accessoryLongTermPublicKey); - this.sessionKeys = client.verify(); - - this.session = new SecureSession(sessionKeys); - this.rwService = new CharacteristicReadWriteService(httpTransport, session, baseUrl); + ipTransport.setSessionKeys(client.verify()); + rwService = new CharacteristicReadWriteService(ipTransport); logger.debug("Restored pairing was verified for accessory {}", accessoryId); updateStatus(ThingStatus.ONLINE); @@ -227,19 +232,19 @@ private void initializePairing() { try { // Perform Pair-Setup - PairSetupClient pairSetupClient = new PairSetupClient(httpTransport, baseUrl, thing.getUID().toString(), + PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, thing.getUID().toString(), controllerLongTermPrivateKey, pairingCode); accessoryLongTermPublicKey = pairSetupClient.pair(); this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; // Perform Pair-Verify immediately after Pair-Setup - PairVerifyClient pairVerifyClient = new PairVerifyClient(httpTransport, baseUrl, accessoryId.toString(), + PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, accessoryId.toString(), controllerLongTermPrivateKey, accessoryLongTermPublicKey); - this.sessionKeys = pairVerifyClient.verify(); - this.session = new SecureSession(sessionKeys); - this.rwService = new CharacteristicReadWriteService(httpTransport, session, baseUrl); + ipTransport.setSessionKeys(pairVerifyClient.verify()); + rwService = new CharacteristicReadWriteService(ipTransport); + this.controllerLongTermPrivateKey = controllerLongTermPrivateKey; storeLongTermKeys(); @@ -252,20 +257,6 @@ private void initializePairing() { } } - /** - * Stores the controller's private key in the thing's properties. - * The private key is stored as a Base64-encoded string. - */ - private void storeLongTermKeys() { - Ed25519PrivateKeyParameters controllerKey = this.controllerLongTermPrivateKey; - String property = controllerKey == null ? null : Base64.getEncoder().encodeToString(controllerKey.getEncoded()); - thing.setProperty(PROPERTY_CONTROLLER_PRIVATE_KEY, property); - - Ed25519PublicKeyParameters accessoryKey = this.accessoryLongTermPublicKey; - property = accessoryKey == null ? null : Base64.getEncoder().encodeToString(accessoryKey.getEncoded()); - thing.setProperty(PROPERTY_ACCESSORY_PUBLIC_KEY, property); - } - /** * Restores the controller's private key from the thing's properties. * The private key is expected to have been stored as a Base64-encoded string. @@ -279,4 +270,18 @@ private void restoreLongTermKeys() { accessoryLongTermPublicKey = encoded == null ? null : new Ed25519PublicKeyParameters(Base64.getDecoder().decode(encoded), 0); } + + /** + * Stores the controller's private key in the thing's properties. + * The private key is stored as a Base64-encoded string. + */ + private void storeLongTermKeys() { + Ed25519PrivateKeyParameters controllerKey = this.controllerLongTermPrivateKey; + String property = controllerKey == null ? null : Base64.getEncoder().encodeToString(controllerKey.getEncoded()); + thing.setProperty(PROPERTY_CONTROLLER_PRIVATE_KEY, property); + + Ed25519PublicKeyParameters accessoryKey = this.accessoryLongTermPublicKey; + property = accessoryKey == null ? null : Base64.getEncoder().encodeToString(accessoryKey.getEncoded()); + thing.setProperty(PROPERTY_ACCESSORY_PUBLIC_KEY, property); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java index 53083dd45b7a4..03ad8bcdeaf4f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java @@ -17,8 +17,7 @@ import java.nio.charset.StandardCharsets; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.session.SecureSession; -import org.openhab.binding.homekit.internal.transport.HttpTransport; +import org.openhab.binding.homekit.internal.transport.IpTransport; /** * HTTP client methods for reading and writing HomeKit accessory characteristics over a secure session. @@ -31,14 +30,10 @@ public class CharacteristicReadWriteService { private static final String JSON_TEMPLATE = "{\"%s\":[{\"aid\":%s,\"iid\":%s,\"value\":%s}]}"; - private final SecureSession session; - private final HttpTransport httpTransport; - private final String baseUrl; + private final IpTransport ipTransport; - public CharacteristicReadWriteService(HttpTransport httpTransport, SecureSession session, String baseUrl) { - this.httpTransport = httpTransport; - this.session = session; - this.baseUrl = baseUrl; + public CharacteristicReadWriteService(IpTransport ipTransport) { + this.ipTransport = ipTransport; } /** @@ -50,9 +45,8 @@ public CharacteristicReadWriteService(HttpTransport httpTransport, SecureSession */ public String readCharacteristic(String query) throws Exception { String endpoint = "%s?id=%s".formatted(ENDPOINT_CHARACTERISTICS, query); - byte[] encrypted = httpTransport.get(baseUrl, endpoint, CONTENT_TYPE_HAP); - byte[] decrypted = session.decrypt(encrypted); - return new String(decrypted, StandardCharsets.UTF_8); + byte[] result = ipTransport.get(endpoint, CONTENT_TYPE_HAP); + return new String(result, StandardCharsets.UTF_8); } /** @@ -65,8 +59,7 @@ public String readCharacteristic(String query) throws Exception { */ public void writeCharacteristic(String aid, String iid, Object value) throws Exception { String json = JSON_TEMPLATE.formatted(ENDPOINT_CHARACTERISTICS, aid, iid, formatValue(value)); - byte[] encrypted = session.encrypt(json.getBytes()); - httpTransport.put(baseUrl, ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, encrypted); + ipTransport.put(ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, json.getBytes()); } /* diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index 96d4d470aee9b..a8a869854ac7f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -21,7 +21,7 @@ import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.transport.HttpTransport; +import org.openhab.binding.homekit.internal.transport.IpTransport; /** * Service to remove an existing pairing with a HomeKit accessory. @@ -34,13 +34,11 @@ public class PairRemoveClient { private static final String CONTENT_TYPE = "application/pairing+tlv8"; private static final String ENDPOINT = "/pairings"; - private final HttpTransport httpTransport; - private final String baseUrl; + private final IpTransport ipTransport; private final String pairingID; - public PairRemoveClient(HttpTransport httpTransport, String baseUrl, String pairingID) { - this.httpTransport = httpTransport; - this.baseUrl = baseUrl; + public PairRemoveClient(IpTransport ipTransport, String pairingID) { + this.ipTransport = ipTransport; this.pairingID = pairingID; } @@ -51,7 +49,7 @@ public void remove() throws Exception { TlvType.IDENTIFIER.key, pairingID.getBytes(StandardCharsets.UTF_8)); Validator.validate(PairingMethod.REMOVE, tlv); - byte[] response = httpTransport.post(baseUrl, ENDPOINT, CONTENT_TYPE, Tlv8Codec.encode(tlv)); + byte[] response = ipTransport.post(ENDPOINT, CONTENT_TYPE, Tlv8Codec.encode(tlv)); Map tlv2 = Tlv8Codec.decode(response); Validator.validate(PairingMethod.REMOVE, tlv2); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index c3dd3c1662e87..f8a13bafff44d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -26,7 +26,7 @@ import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.transport.HttpTransport; +import org.openhab.binding.homekit.internal.transport.IpTransport; /** * Handles the 6-step pairing process with a HomeKit accessory. @@ -43,16 +43,14 @@ public class PairSetupClient { private static final String ENDPOINT_PAIR_SETUP = "/pair-setup"; private static final String CONTENT_TYPE_TLV8 = "application/pairing+tlv8"; - private final HttpTransport httpTransport; - private final String baseUrl; + private final IpTransport ipTransport; private final String password; private final byte[] pairingId; private final Ed25519PrivateKeyParameters clientLongTermPrivateKey; - public PairSetupClient(HttpTransport httpTransport, String baseUrl, String pairingId, + public PairSetupClient(IpTransport ipTransport, String pairingId, Ed25519PrivateKeyParameters clientLongTermPrivateKey, String password) throws Exception { - this.httpTransport = httpTransport; - this.baseUrl = baseUrl; + this.ipTransport = ipTransport; this.password = password; this.pairingId = pairingId.getBytes(StandardCharsets.UTF_8); this.clientLongTermPrivateKey = clientLongTermPrivateKey; @@ -81,7 +79,7 @@ private SRPclient doStepM1() throws Exception { TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.METHOD.key, new byte[] { PairingMethod.SETUP.value }); Validator.validate(PairingMethod.SETUP, tlv); - byte[] response1 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + byte[] response1 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); return doStepM2(response1); } @@ -114,7 +112,7 @@ private SRPclient doStepM3(SRPclient client) throws Exception { TlvType.PUBLIC_KEY.key, client.getPublicKey(), // TlvType.PROOF.key, client.getClientProof()); Validator.validate(PairingMethod.SETUP, tlv); - byte[] response3 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + byte[] response3 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); return doStepM4(client, response3); } @@ -144,7 +142,7 @@ private SRPclient doStepM5(SRPclient client) throws Exception { TlvType.STATE.key, new byte[] { PairingState.M5.value }, // TlvType.ENCRYPTED_DATA.key, cipherText); Validator.validate(PairingMethod.SETUP, tlv); - byte[] response5 = httpTransport.post(baseUrl, ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); + byte[] response5 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); return doStepM6(client, response5); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index a7f9f52c1e469..bdea6819c9106 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -31,7 +31,7 @@ import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; -import org.openhab.binding.homekit.internal.transport.HttpTransport; +import org.openhab.binding.homekit.internal.transport.IpTransport; /** * Handles the 3-step pair-verify process with a HomeKit accessory. @@ -44,8 +44,7 @@ public class PairVerifyClient { private static final String CONTENT_TYPE_TLV = "application/pairing+tlv8"; private static final String ENDPOINT_PAIR_VERIFY = "/pair-verify"; - private final HttpTransport httpTransport; - private final String baseUrl; + private final IpTransport ipTransport; private final byte[] pairingId; private final Ed25519PrivateKeyParameters clientLongTermPrivateKey; private final Ed25519PublicKeyParameters serverLongTermPublicKey; @@ -56,11 +55,10 @@ public class PairVerifyClient { private @NonNullByDefault({}) byte[] readKey; private @NonNullByDefault({}) byte[] writeKey; - public PairVerifyClient(HttpTransport httpTransport, String baseUrl, String pairingId, + public PairVerifyClient(IpTransport ipTransport, String pairingId, Ed25519PrivateKeyParameters clientLongTermPrivateKey, Ed25519PublicKeyParameters serverLongTermPublicKey) throws Exception { - this.httpTransport = httpTransport; - this.baseUrl = baseUrl; + this.ipTransport = ipTransport; this.pairingId = pairingId.getBytes(StandardCharsets.UTF_8); this.clientLongTermPrivateKey = clientLongTermPrivateKey; this.serverLongTermPublicKey = serverLongTermPublicKey; @@ -85,7 +83,7 @@ private void doStep1() throws Exception { TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.PUBLIC_KEY.key, clientKey); Validator.validate(PairingMethod.VERIFY, tlv); - doStep2(httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); + doStep2(ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); } // M2 — Receive server ephemeral X25519 public key and encrypted TLV @@ -100,8 +98,7 @@ private void doStep2(byte[] response1) throws Exception { sessionKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); byte[] ciphertext = tlv.get(TlvType.ENCRYPTED_DATA.key); - byte[] plaintext = CryptoUtils.decrypt(sessionKey, PV_M2_NONCE, Objects.requireNonNull(ciphertext), - CHACHA20_POLY1305); + byte[] plaintext = CryptoUtils.decrypt(sessionKey, PV_M2_NONCE, Objects.requireNonNull(ciphertext)); // validate identifier + signature Map subTlv = Tlv8Codec.decode(plaintext); @@ -127,14 +124,14 @@ private void doStep3() throws Exception { TlvType.SIGNATURE.key, signature); byte[] plaintext = Tlv8Codec.encode(subTlv); - byte[] ciphertext = encrypt(sessionKey, PV_M3_NONCE, plaintext, CHACHA20_POLY1305); + byte[] ciphertext = encrypt(sessionKey, PV_M3_NONCE, plaintext); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M3.value }, // TlvType.ENCRYPTED_DATA.key, ciphertext); Validator.validate(PairingMethod.VERIFY, tlv); - doStep4(httpTransport.post(baseUrl, ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); + doStep4(ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); } // M4 — Final confirmation diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index 96a74a1fba615..396276504439b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -12,10 +12,16 @@ */ package org.openhab.binding.homekit.internal.session; +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.ByteBuffer; import java.util.concurrent.atomic.AtomicInteger; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.crypto.CryptoUtils; /** * Manages a secure session using ChaCha20 encryption for a HomeKit accessory. @@ -27,37 +33,53 @@ @NonNullByDefault public class SecureSession { + private final InputStream in; + private final OutputStream out; private final byte[] writeKey; private final byte[] readKey; private final AtomicInteger writeCounter = new AtomicInteger(0); private final AtomicInteger readCounter = new AtomicInteger(0); - public SecureSession(AsymmetricSessionKeys keys) { - this.writeKey = keys.getWriteKey(); - this.readKey = keys.getReadKey(); + public SecureSession(Socket socket, AsymmetricSessionKeys keys) throws IOException { + in = socket.getInputStream(); + out = socket.getOutputStream(); + writeKey = keys.getWriteKey(); + readKey = keys.getReadKey(); } /** - * * Encrypts the given plaintext using the write key and a unique nonce. + * Encrypts the given plaintext using the write key and a unique nonce and sends it. * - * @param plaintext The plaintext to encrypt. - * @return The encrypted ciphertext. + * @param plaintext the plaintext to be encrypted and sent. * @throws Exception */ - public byte[] encrypt(byte[] plaintext) throws Exception { - byte[] nonce = CryptoUtils.generateNonce(writeCounter.getAndIncrement()); - return CryptoUtils.encrypt(writeKey, nonce, plaintext, null); // TODO: AAD + public void send(byte[] plaintext) throws Exception { + byte[] nonce = generateNonce(writeCounter.getAndIncrement()); + byte[] ciphertext = encrypt(writeKey, nonce, plaintext); + ByteBuffer buf = ByteBuffer.allocate(2 + ciphertext.length); + buf.order(java.nio.ByteOrder.LITTLE_ENDIAN); + buf.putShort((short) ciphertext.length); + buf.put(ciphertext); + out.write(buf.array()); + out.flush(); } /** - * Decrypts the given ciphertext using the read key and a unique nonce. + * Reads the cipertext and decrypts it using the read key and a unique nonce. * - * @param ciphertext The ciphertext to decrypt. - * @return The decrypted plaintext. + * @return the received ciphertext decrypted. * @throws Exception */ - public byte[] decrypt(byte[] ciphertext) throws Exception { - byte[] nonce = CryptoUtils.generateNonce(readCounter.getAndIncrement()); - return CryptoUtils.decrypt(readKey, nonce, ciphertext, null); // TODO: AAD + public byte[] receive() throws Exception { + int lo = in.read(); + int hi = in.read(); + if (lo < 0 || hi < 0) { + throw new IllegalStateException("Stream closed"); + } + int length = (lo & 0xFF) | ((hi & 0xFF) << 8); + byte[] ciphertext = in.readNBytes(length); + byte[] nonce = generateNonce(readCounter.getAndIncrement()); + byte[] plaintext = decrypt(readKey, nonce, ciphertext); + return plaintext; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java deleted file mode 100644 index 3a38327f15688..0000000000000 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/HttpTransport.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2010-2025 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.homekit.internal.transport; - -import java.io.IOException; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.client.api.ContentResponse; -import org.eclipse.jetty.client.api.Request; -import org.eclipse.jetty.client.util.BytesContentProvider; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpMethod; - -/** - * Handles HTTP transport for HomeKit communication. - * It provides methods for sending GET, POST, and PUT requests with appropriate headers and content types. - * - * @author Andrew Fiddian-Green - Initial contribution - */ -@NonNullByDefault -public class HttpTransport { - - private final HttpClient httpClient; - - public HttpTransport(HttpClient httpClient) { - this.httpClient = httpClient; - } - - /** - * Sends a GET request to the specified URL and endpoint, expecting a response of the given content type. - * - * @param url the target URL - * @param endpoint the endpoint path - * @param contentType the expected content type of the response - * - * @return the response body - * - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - */ - public byte[] get(String baseUrl, String endpoint, String contentType) - throws IOException, InterruptedException, TimeoutException, ExecutionException { - String url = baseUrl + "/" + endpoint; - Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.GET) - .header(HttpHeader.ACCEPT, contentType); - - ContentResponse response = request.send(); - if (response.getStatus() != 200) { - throw new IOException("GET %s HTTP %d".formatted(url, response.getStatus())); - } - - return response.getContent(); - } - - /** - * Sends a POST request with the given payload and content type to the specified URL and endpoint. - * - * @param url the target URL - * @param endpoint the endpoint path - * @param contentType the content type of the request - * @param content the request body - * - * @return the response body - * - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - */ - public byte[] post(String baseUrl, String endpoint, String contentType, byte[] content) - throws IOException, InterruptedException, TimeoutException, ExecutionException { - String url = baseUrl + "/" + endpoint; - Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.POST) - .header(HttpHeader.CONTENT_TYPE, contentType).content(new BytesContentProvider(content)); - - ContentResponse response = request.send(); - if (response.getStatus() != 200) { - throw new IOException("POST %s HTTP %d".formatted(url, response.getStatus())); - } - - return response.getContent(); - } - - /** - * Sends a PUT request with the given payload and content type to the specified URL and endpoint. - * - * @param url the target URL - * @param endpoint the endpoint path - * @param contentType the content type of the request - * @param content the request body - * - * @return the response body - * - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - */ - public byte[] put(String baseUrl, String endpoint, String contentType, byte[] content) - throws IOException, InterruptedException, TimeoutException, ExecutionException { - String url = baseUrl + "/" + endpoint; - Request request = httpClient.newRequest(url).timeout(5, TimeUnit.SECONDS).method(HttpMethod.PUT) - .header(HttpHeader.ACCEPT, contentType).header(HttpHeader.CONTENT_TYPE, contentType) - .content(new BytesContentProvider(content)); - - ContentResponse response = request.send(); - if (response.getStatus() != 200) { - throw new IOException("PUT %s error: HTTP %d".formatted(url, response.getStatus())); - } - - return response.getContent(); - } -} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java new file mode 100644 index 0000000000000..956a1ea266294 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.transport; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; +import org.openhab.binding.homekit.internal.session.SecureSession; + +/** + * This provides the IP transport layer for HomeKit communication. + * It provides methods for sending GET, POST, and PUT requests with appropriate headers and content types. + * It supports both plain and secure (encrypted) communication based on whether session keys have been set. + * It handles building HTTP requests, sending them over a socket, and parsing HTTP responses. + * It throws exceptions for various error conditions, including IO issues, timeouts, and non-200 HTTP responses. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class IpTransport implements AutoCloseable { + + private static final int TIMEOUT = Duration.ofSeconds(5).toMillisPart(); + + private final String host; // ip address with optional port e.g. "192.168.1.42:9123" + private final Socket socket; + + private @Nullable SecureSession secureSession = null; + + /** + * Creates a new IpTransport instance with the given socket and session keys. + */ + public IpTransport(String host) throws Exception { + this.host = host; + String[] parts = host.split(":"); + int port = (parts.length > 1) ? Integer.parseInt(parts[1]) : 80; // default to port 80 + socket = new Socket(); + socket.connect(new InetSocketAddress(host, port), TIMEOUT); + socket.setKeepAlive(false); // HAP spec forbids TCP keepalive + } + + public void setSessionKeys(AsymmetricSessionKeys keys) throws Exception { + secureSession = new SecureSession(socket, keys); + } + + public byte[] get(String endpoint, String contentType) + throws IOException, InterruptedException, TimeoutException, ExecutionException { + return execute("GET", endpoint, contentType, new byte[0]); + } + + public byte[] post(String endpoint, String contentType, byte[] content) + throws IOException, InterruptedException, TimeoutException, ExecutionException { + return execute("POST", endpoint, contentType, content); + } + + public byte[] put(String endpoint, String contentType, byte[] content) + throws IOException, InterruptedException, TimeoutException, ExecutionException { + return execute("PUT", endpoint, contentType, content); + } + + private byte[] execute(String method, String endpoint, String contentType, byte[] body) + throws IOException, InterruptedException, TimeoutException, ExecutionException { + try { + byte[] request = buildRequest(method, endpoint, contentType, body); + byte[] response; + + SecureSession secureSession = this.secureSession; + if (secureSession != null) { + secureSession.send(request); + response = secureSession.receive(); + } else { + OutputStream out = socket.getOutputStream(); + InputStream in = socket.getInputStream(); + out.write(request); + out.flush(); + response = readPlainResponse(in); + } + + return parseResponse(response); + } catch (IOException | InterruptedException | TimeoutException e) { + throw e; + } catch (Exception e) { + throw new ExecutionException(e); + } + } + + private byte[] buildRequest(String method, String endpoint, String contentType, byte[] body) throws IOException { + StringBuilder sb = new StringBuilder(); + sb.append(method).append(" ").append(endpoint).append(" HTTP/1.1\r\n"); + sb.append("Host: ").append(host).append("\r\n"); + sb.append("Accept: ").append(contentType).append("\r\n"); + if (!bodyIsEmpty(method)) { + sb.append("Content-Type: ").append(contentType).append("\r\n"); + sb.append("Content-Length: ").append(body.length).append("\r\n"); + } else { + sb.append("Content-Length: 0\r\n"); + } + sb.append("\r\n"); + + byte[] headerBytes = sb.toString().getBytes(StandardCharsets.UTF_8); + if (bodyIsEmpty(method)) { + return headerBytes; + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(headerBytes); + out.write(body); + return out.toByteArray(); + } + + private boolean bodyIsEmpty(String method) { + return "GET".equals(method) || "DELETE".equals(method); + } + + private byte[] readPlainResponse(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int read; + while ((read = in.read(buf)) != -1) { + out.write(buf, 0, read); + if (read < buf.length) { + break; // crude EOF detection + } + } + return out.toByteArray(); + } + + private byte[] parseResponse(byte[] raw) throws IOException { + ByteArrayInputStream in = new ByteArrayInputStream(raw); + String statusLine = readLine(in); + String[] parts = statusLine.split(" ", 3); + int status = Integer.parseInt(parts[1]); + + Map headers = new HashMap<>(); + String line; + while (!(line = readLine(in)).isEmpty()) { + int idx = line.indexOf(':'); + String name = line.substring(0, idx).trim().toLowerCase(); + String value = line.substring(idx + 1).trim(); + headers.put(name, value); + } + + if (status != 200) { + throw new IOException("HTTP " + status); + } + + int len = Integer.parseInt(headers.getOrDefault("content-length", "0")); + return in.readNBytes(len); + } + + private static String readLine(ByteArrayInputStream in) throws IOException { + StringBuilder sb = new StringBuilder(); + int ch; + while ((ch = in.read()) >= 0) { + if (ch == '\r') { + continue; + } + if (ch == '\n') { + break; + } + sb.append((char) ch); + } + return sb.toString(); + } + + @Override + public void close() throws Exception { + socket.close(); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java index e134241cf57aa..10b0f6260df9b 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java @@ -111,7 +111,7 @@ public byte[] createEncryptedAccessoryInfo() throws Exception { TlvType.SIGNATURE.key, signature); byte[] plaintext = Tlv8Codec.encode(subTlv); - return CryptoUtils.encrypt(getSymmetricKey(), PS_M6_NONCE, plaintext, CHACHA20_POLY1305); + return CryptoUtils.encrypt(getSymmetricKey(), PS_M6_NONCE, plaintext); } public byte[] getPublicKey() { diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java index 0379424523dde..49b9139e9f8ab 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -358,6 +358,7 @@ void testChannelDefinitions() { assertNotNull(accessories); HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + List channelGroupTypes = new ArrayList<>(); List channelTypes = new ArrayList<>(); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index 2dac0ecf7f34b..d8209b04a2e1e 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -12,10 +12,10 @@ */ package org.openhab.binding.homekit.internal; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.PS_M5_NONCE; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; import java.nio.charset.StandardCharsets; @@ -27,14 +27,13 @@ import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; -import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.crypto.SRPclient; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; -import org.openhab.binding.homekit.internal.transport.HttpTransport; +import org.openhab.binding.homekit.internal.transport.IpTransport; /** * Test cases for the {@link PairSetupClient} class. @@ -62,61 +61,49 @@ class TestPairSetup { void testBareCrypto() throws InvalidCipherTextException { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); byte[] key = new byte[32]; // 256 bits = 32 bytes - byte[] nonce = CryptoUtils.generateNonce(123); + byte[] nonce = generateNonce(123); new SecureRandom().nextBytes(key); - byte[] cipherText = encrypt(key, nonce, plainText0, null); - byte[] plainText1 = decrypt(key, nonce, cipherText, null); + byte[] cipherText = encrypt(key, nonce, plainText0); + byte[] plainText1 = decrypt(key, nonce, cipherText); assertArrayEquals(plainText0, plainText1); - byte[] cipherText2 = encrypt(key, nonce, plainText0, CHACHA20_POLY1305); - byte[] plainText2 = decrypt(key, nonce, cipherText2, CHACHA20_POLY1305); - assertArrayEquals(plainText0, plainText2); - assertThrows(InvalidCipherTextException.class, - () -> decrypt(key, nonce, cipherText2, "bad-authTag".getBytes(StandardCharsets.UTF_8))); } @Test void testSrpClient() throws Exception { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); - SRPclient client = new SRPclient("password123", hexBlockToByteArray(SALT_HEX), - hexBlockToByteArray(SERVER_PRIVATE_HEX)); + SRPclient client = new SRPclient("password123", fromHex(SALT_HEX), fromHex(SERVER_PRIVATE_HEX)); byte[] key = client.getSymmetricKey(); - byte[] cipherText = encrypt(key, PS_M5_NONCE, plainText0, null); - byte[] plainText1 = decrypt(key, PS_M5_NONCE, cipherText, null); + byte[] cipherText = encrypt(key, PS_M5_NONCE, plainText0); + byte[] plainText1 = decrypt(key, PS_M5_NONCE, cipherText); assertArrayEquals(plainText0, plainText1); - byte[] cipherText2 = encrypt(key, PS_M5_NONCE, plainText0, CHACHA20_POLY1305); - byte[] plainText2 = decrypt(key, PS_M5_NONCE, cipherText2, CHACHA20_POLY1305); - assertArrayEquals(plainText0, plainText2); - assertThrows(InvalidCipherTextException.class, - () -> decrypt(key, PS_M5_NONCE, cipherText2, "bad-authTag".getBytes(StandardCharsets.UTF_8))); } @Test void testPairSetup() throws Exception { // initialize test parameters - String baseUrl = "http://example.com"; String password = "password123"; String clientPairingIdentifier = "11:22:33:44:55:66"; String serverPairingIdentifier = "66:55:44:33:22:11"; - byte[] serverSalt = hexBlockToByteArray(SALT_HEX); + byte[] serverSalt = fromHex(SALT_HEX); byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); // initialize signing keys Ed25519PrivateKeyParameters clientPrivateSigningKey = new Ed25519PrivateKeyParameters( - hexBlockToByteArray(CLIENT_PRIVATE_HEX)); + fromHex(CLIENT_PRIVATE_HEX)); Ed25519PrivateKeyParameters serverPrivateSigningKey = new Ed25519PrivateKeyParameters( - hexBlockToByteArray(SERVER_PRIVATE_HEX)); + fromHex(SERVER_PRIVATE_HEX)); // create mock - HttpTransport mockTransport = mock(HttpTransport.class); + IpTransport mockTransport = mock(IpTransport.class); // create SRP client and server SRPtestServer server = new SRPtestServer(password, serverSalt, serverPairingId, serverPrivateSigningKey); - PairSetupClient client = new PairSetupClient(mockTransport, baseUrl, clientPairingIdentifier, - clientPrivateSigningKey, password); + PairSetupClient client = new PairSetupClient(mockTransport, clientPairingIdentifier, clientPrivateSigningKey, + password); // mock the HTTP transport to simulate the SRP exchange doAnswer(invocation -> { - byte[] arg = invocation.getArgument(3); + byte[] arg = invocation.getArgument(2); // decode and validate the incoming TLV Map tlv = Tlv8Codec.decode(arg); @@ -134,7 +121,7 @@ void testPairSetup() throws Exception { default -> throw new IllegalArgumentException("Unexpected state"); }; - }).when(mockTransport).post(anyString(), anyString(), anyString(), any(byte[].class)); + }).when(mockTransport).post(anyString(), anyString(), any(byte[].class)); // execute the pairing setup client.pair(); @@ -170,23 +157,4 @@ private byte[] getServerResponseM5(SRPtestServer server) throws Exception { PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); return Tlv8Codec.encode(tlv); } - - private static byte[] hexBlockToByteArray(String hexBlock) { - String normalized = hexBlock.replaceAll("\\s+", ""); - if (normalized.length() % 2 != 0) { - throw new IllegalArgumentException("Hex string must have even length"); - } - int len = normalized.length(); - byte[] result = new byte[len / 2]; - for (int i = 0; i < len; i += 2) { - int high = Character.digit(normalized.charAt(i), 16); - int low = Character.digit(normalized.charAt(i + 1), 16); - if (high == -1 || low == -1) { - throw new IllegalArgumentException( - "Invalid hex character: " + normalized.charAt(i) + normalized.charAt(i + 1)); - } - result[i / 2] = (byte) ((high << 4) + low); - } - return result; - } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index cc34b17ab8f08..14c5ec06d5d31 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -32,7 +32,7 @@ import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; -import org.openhab.binding.homekit.internal.transport.HttpTransport; +import org.openhab.binding.homekit.internal.transport.IpTransport; /** * Test cases for the {@link PairVerifyClient} class. @@ -50,17 +50,16 @@ class TestPairVerify { E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20 """; - private final String baseUrl = "http://example.com"; private final String clientPairingIdentifier = "11:22:33:44:55:66"; private final byte[] clientPairingId = clientPairingIdentifier.getBytes(StandardCharsets.UTF_8); private final String serverPairingIdentifier = "66:55:44:33:22:11"; private final byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); private final Ed25519PrivateKeyParameters clientLongTermPrivateKey = new Ed25519PrivateKeyParameters( - hexBlockToByteArray(CLIENT_PRIVATE_HEX)); + fromHex(CLIENT_PRIVATE_HEX)); private final Ed25519PrivateKeyParameters serverLongTermPrivateKey = new Ed25519PrivateKeyParameters( - hexBlockToByteArray(SERVER_PRIVATE_HEX)); + fromHex(SERVER_PRIVATE_HEX)); private @NonNullByDefault({}) X25519PrivateKeyParameters serverKey; private @NonNullByDefault({}) X25519PublicKeyParameters clientKey; @@ -71,15 +70,15 @@ void testPairVerify() throws Exception { serverKey = generateX25519KeyPair(); // create mock - HttpTransport mockTransport = mock(HttpTransport.class); + IpTransport mockTransport = mock(IpTransport.class); // create SRP client and server - PairVerifyClient client = new PairVerifyClient(mockTransport, baseUrl, clientPairingIdentifier, - clientLongTermPrivateKey, serverLongTermPrivateKey.generatePublicKey()); + PairVerifyClient client = new PairVerifyClient(mockTransport, clientPairingIdentifier, clientLongTermPrivateKey, + serverLongTermPrivateKey.generatePublicKey()); // mock the HTTP transport to simulate the SRP exchange doAnswer(invocation -> { - byte[] arg = invocation.getArgument(3); + byte[] arg = invocation.getArgument(2); // decode and validate the incoming TLV Map tlv = Tlv8Codec.decode(arg); @@ -96,7 +95,7 @@ void testPairVerify() throws Exception { default -> throw new IllegalArgumentException("Unexpected state"); }; - }).when(mockTransport).post(anyString(), anyString(), anyString(), any(byte[].class)); + }).when(mockTransport).post(anyString(), anyString(), any(byte[].class)); // execute the pairing verification process client.verify(); @@ -118,7 +117,7 @@ private byte[] getServerResponseM1(Map tlv) throws Exception { sessionKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); byte[] plaintext = Tlv8Codec.encode(tlvInner); - byte[] ciphertext = encrypt(sessionKey, PV_M2_NONCE, plaintext, CHACHA20_POLY1305); + byte[] ciphertext = encrypt(sessionKey, PV_M2_NONCE, plaintext); Map tlvOut = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M2.value }, // @@ -136,7 +135,7 @@ private byte[] getServerResponseM3(Map tlv) throws Exception { if (ciphertext == null) { throw new SecurityException("Missing ciphertext in M3"); } - byte[] plaintext = decrypt(sessionKey, PV_M3_NONCE, Objects.requireNonNull(ciphertext), CHACHA20_POLY1305); + byte[] plaintext = decrypt(sessionKey, PV_M3_NONCE, Objects.requireNonNull(ciphertext)); Map subTlv = Tlv8Codec.decode(plaintext); byte[] information = subTlv.get(TlvType.IDENTIFIER.key); @@ -157,23 +156,4 @@ private byte[] getServerResponseM3(Map tlv) throws Exception { // no further messages from server return Tlv8Codec.encode(tlvOut); } - - private static byte[] hexBlockToByteArray(String hexBlock) { - String normalized = hexBlock.replaceAll("\\s+", ""); - if (normalized.length() % 2 != 0) { - throw new IllegalArgumentException("Hex string must have even length"); - } - int len = normalized.length(); - byte[] result = new byte[len / 2]; - for (int i = 0; i < len; i += 2) { - int high = Character.digit(normalized.charAt(i), 16); - int low = Character.digit(normalized.charAt(i + 1), 16); - if (high == -1 || low == -1) { - throw new IllegalArgumentException( - "Invalid hex character: " + normalized.charAt(i) + normalized.charAt(i + 1)); - } - result[i / 2] = (byte) ((high << 4) + low); - } - return result; - } } From 44775fd6abc68d560dbaf6dbcd9ea97a231adfa1 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 22 Sep 2025 18:43:09 +0100 Subject: [PATCH 027/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 1 + .../HomekitChildDiscoveryService.java | 4 +- .../HomekitMdnsDiscoveryParticipant.java | 2 +- .../homekit/internal/dto/Characteristic.java | 11 +- .../binding/homekit/internal/dto/Service.java | 4 +- .../internal/enums/CharacteristicType.java | 2 +- .../homekit/internal/enums/ServiceType.java | 18 +- .../factory/HomekitHandlerFactory.java | 15 +- .../handler/HomekitBaseServerHandler.java | 308 +++++++++++++++++- .../handler/HomekitBridgeHandler.java | 24 +- .../handler/HomekitDeviceHandler.java | 79 +++-- .../resources/OH-INF/i18n/homekit.properties | 54 +-- .../resources/OH-INF/thing/thing-types.xml | 20 +- .../homekit/internal/TestChannelCreation.java | 2 +- 14 files changed, 427 insertions(+), 117 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index d998906310c9a..f006e4067dde3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -29,6 +29,7 @@ public class HomekitBindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device"); + public static final ThingTypeUID THING_TYPE_CHILD = new ThingTypeUID(BINDING_ID, "child"); // specific Channel Type UIDs public static final String FAKE_PROPERTY_CHANNEL = "property-fake-channel"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index e73f5806ce9e2..bf79da5b61798 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -38,7 +38,7 @@ public class HomekitChildDiscoveryService extends AbstractDiscoveryService { public HomekitChildDiscoveryService() { - super(Set.of(THING_TYPE_DEVICE), 10, false); + super(Set.of(THING_TYPE_CHILD), 10, false); } @Override @@ -50,7 +50,7 @@ public void devicesDiscovered(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { if (accessory.aid != null && accessory.services != null) { // accessory ID is unique per bridge - ThingUID uid = new ThingUID(THING_TYPE_DEVICE, bridge.getUID(), CHILD_FMT.formatted(accessory.aid)); + ThingUID uid = new ThingUID(THING_TYPE_CHILD, bridge.getUID(), CHILD_FMT.formatted(accessory.aid)); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 2789ee244ff5c..089c892b91e7b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -103,7 +103,7 @@ public String getServiceType() { macAddress); } } - logger.warn("Discovered HomeKit device without valid properties - ignoring"); + logger.warn("Ignoring discovered HomeKit service {} without properties", service.getNiceTextString()); return null; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 971a56ec110cf..b15277dccdc17 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -83,18 +83,13 @@ public class Characteristic { return null; } - // convert "percentage" to "percent" as SimpleUnitFormat does handle the former - if ("percentage".equals(unit)) { - unit = "percent"; - } - // determine channel type and attributes based on characteristic properties boolean isReadOnly = !perms.contains("pw"); boolean isString = DataFormatType.STRING == dataFormatType; boolean isBoolean = DataFormatType.BOOL == dataFormatType; boolean isNumber = !isString && !isBoolean; boolean isStateChannel = true; - boolean isPercentage = "percent".equals(unit); + boolean isPercentage = "percentage".equals(unit); String itemType = null; String category = null; @@ -206,7 +201,7 @@ public class Characteristic { break; case COLOR_TEMPERATURE: - numberSuffix = unit == null ? "Dimensionless" : "Temperature"; + numberSuffix = isPercentage || unit == null ? "Dimensionless" : "Temperature"; propertyTag = Property.COLOR_TEMPERATURE; category = "light"; break; @@ -548,7 +543,7 @@ public class Characteristic { * NOTE: different accessories may have the same characteristicType, but their other * properties e.g. min, max, step, unit may be different */ - ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, characteristicType.getGroupTypeId()); + ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, characteristicType.getOpenhabType()); String label = CHANNEL_TYPE_LABEL_FMT.formatted(characteristicType.toString()); ChannelType channelType; if (isStateChannel) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index ddd4ce91cfd36..a5b05498f2484 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -62,7 +62,7 @@ public class Service { return null; } - ChannelGroupTypeUID groupTypeUID = new ChannelGroupTypeUID(BINDING_ID, serviceType.getChannelTypeId()); + ChannelGroupTypeUID groupTypeUID = new ChannelGroupTypeUID(BINDING_ID, serviceType.getOpenhabType()); String label = GROUP_TYPE_LABEL_FMT.formatted(serviceType.toString()); ChannelGroupType groupType = ChannelGroupTypeBuilder.instance(groupTypeUID, label) // .withChannelDefinitions(channelDefinitions) // @@ -87,6 +87,6 @@ public class Service { @Override public String toString() { - return getServiceType() instanceof ServiceType st ? st.getTypeName() : "Unknown"; + return getServiceType() instanceof ServiceType st ? st.getType() : "Unknown"; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index fb61101a80c1f..978a30faf7b65 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -175,7 +175,7 @@ public static CharacteristicType from(int id) throws IllegalArgumentException { throw new IllegalArgumentException("Unknown ID: " + id); } - public String getGroupTypeId() { + public String getOpenhabType() { return type.replace("-", "_").replace(".", "-"); // convert to OH channel-group-type format } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index d6700193a6634..e877db3cb7ed8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -71,29 +71,29 @@ public enum ServiceType { WINDOW(0x8B, "public.hap.service.window"), WINDOW_COVERING(0x8C, "public.hap.service.window-covering"); - private final int type; - private final String typeName; + private final int id; + private final String type; - ServiceType(int type, String typeName) { + ServiceType(int id, String type) { + this.id = id; this.type = type; - this.typeName = typeName; } public static ServiceType from(int type) throws IllegalArgumentException { for (ServiceType value : values()) { - if (value.type == type) { + if (value.id == type) { return value; } } throw new IllegalArgumentException("Unknown ID: " + type); } - public String getChannelTypeId() { - return typeName.replace(".", "-"); // convert to OH channel type format + public String getOpenhabType() { + return type.replace(".", "-"); // convert to OH channel type format } - public String getTypeName() { - return typeName; + public String getType() { + return type; } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index e705c6f93c982..1294cda35b23e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -24,7 +24,6 @@ import org.openhab.binding.homekit.internal.handler.HomekitDeviceHandler; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.config.discovery.DiscoveryService; -import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; @@ -47,18 +46,16 @@ @Component(service = ThingHandlerFactory.class) public class HomekitHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE); + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE, + THING_TYPE_CHILD); - private final HttpClientFactory httpClientFactory; private final HomekitTypeProvider typeProvider; private @Nullable ServiceRegistration discoveryServiceRegistration; private @Nullable HomekitChildDiscoveryService discoveryService; @Activate - public HomekitHandlerFactory(@Reference HttpClientFactory httpClientFactory, - @Reference HomekitTypeProvider typeProvider) { - this.httpClientFactory = httpClientFactory; + public HomekitHandlerFactory(@Reference HomekitTypeProvider typeProvider) { this.typeProvider = typeProvider; } @@ -77,9 +74,11 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { - return new HomekitBridgeHandler((Bridge) thing, httpClientFactory, registerDiscoveryService()); + return new HomekitBridgeHandler((Bridge) thing, registerDiscoveryService()); } else if (THING_TYPE_DEVICE.equals(thingTypeUID)) { - return new HomekitDeviceHandler(thing, httpClientFactory, typeProvider); + return new HomekitDeviceHandler(thing, typeProvider); + } else if (THING_TYPE_CHILD.equals(thingTypeUID)) { + return new HomekitDeviceHandler(thing, typeProvider); } return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 782d3b76a91e3..98be68fa38195 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -14,7 +14,6 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; -import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; import java.util.HashMap; @@ -35,7 +34,6 @@ import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; import org.openhab.binding.homekit.internal.transport.IpTransport; -import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -76,7 +74,7 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected @Nullable Ed25519PrivateKeyParameters controllerLongTermPrivateKey = null; protected @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; - public HomekitBaseServerHandler(Thing thing, HttpClientFactory httpClientFactory) { + public HomekitBaseServerHandler(Thing thing) { super(thing); } @@ -100,20 +98,34 @@ public void dispose() { * @return list of accessories (may be empty) * @see HomeKit HTTP */ - protected void getAccessories() { + private void fetchAccessories() { + logger.info("Fetching accessories for BASE thing {}", thing.getUID()); try { - byte[] decrypted = ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); - Accessories result = GSON.fromJson(new String(decrypted, StandardCharsets.UTF_8), Accessories.class); - if (result != null && result.accessories instanceof List accessoryList) { + // byte[] json = ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); + // Accessories container = GSON.fromJson(new String(json, StandardCharsets.UTF_8), Accessories.class); + // TODO REMOVE TEST CODE + Accessories container = GSON.fromJson(TODO_REMOVE_TEST_JSON, Accessories.class); + // Accessories result = GSON.fromJson(TODO_REMOVE_TEST_JSON, Accessories.class); + if (container != null && container.accessories instanceof List accessoryList) { accessories.clear(); accessories.putAll(accessoryList.stream().filter(a -> Objects.nonNull(a.aid)) .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } + logger.info("Fetched {} accessories", accessories.size()); + scheduler.submit(() -> accessoriesLoaded()); // notify subclass in scheduler thread } catch (Exception e) { logger.warn("Failed to get accessories: {}", e.getMessage()); } } + /** + * Called when accessories have been loaded from the /accessories endpoint. + * Subclasses should override to perform any processing required. + * This method is called in the context of the scheduler thread, so should not + * perform long blocking operations. + */ + protected abstract void accessoriesLoaded(); + /** * Extracts the accessory ID from the thing's UID property. * The UID is expected to end with "-". @@ -170,17 +182,21 @@ public void initialize() { isChildAccessory = true; ipTransport = bridgeHandler.ipTransport; rwService = bridgeHandler.rwService; - if (rwService != null) { - updateStatus(ThingStatus.ONLINE); - } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not connected"); - } + // TODO remove comment <= if (rwService != null) { + updateStatus(ThingStatus.ONLINE); + fetchAccessories(); + // TODO remove comment <= } else { + // TODO remove comment <= updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not + // connected"); + // TODO remove comment <=} } else { // standalone accessory or brige accessory, so do pairing and session setup here isChildAccessory = false; try { - ipTransport = new IpTransport(getConfig().get(CONFIG_IP_V4_ADDRESS).toString()); - scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread + // TODO => ipTransport = new IpTransport(getConfig().get(CONFIG_IP_V4_ADDRESS).toString()); + // TODO => scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread + updateStatus(ThingStatus.ONLINE); // TODO <= remove when above code is enabled + fetchAccessories(); // TODO <= remove when above code is enabled } catch (Exception e) { logger.warn("Failed to create transport: {}", e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, @@ -216,6 +232,7 @@ private void initializePairing() { logger.debug("Restored pairing was verified for accessory {}", accessoryId); updateStatus(ThingStatus.ONLINE); + fetchAccessories(); return; } catch (Exception e) { @@ -249,8 +266,10 @@ private void initializePairing() { storeLongTermKeys(); - updateStatus(ThingStatus.ONLINE); logger.debug("Pairing and verification completed for accessory {}", accessoryId); + updateStatus(ThingStatus.ONLINE); + fetchAccessories(); + } catch (Exception e) { logger.warn("Pairing and verification failed for accessory {}", accessoryId); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing failed"); @@ -284,4 +303,263 @@ private void storeLongTermKeys() { property = accessoryKey == null ? null : Base64.getEncoder().encodeToString(accessoryKey.getEncoded()); thing.setProperty(PROPERTY_ACCESSORY_PUBLIC_KEY, property); } + + public static final String TODO_REMOVE_TEST_JSON = """ + { + "accessories": [ + { + "aid": 1, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "value": "Acme Light Bridge", + "perms": [ + "pr" + ], + "format": "string", + "iid": 2 + }, + { + "type": "20", + "value": "Acme", + "perms": [ + "pr" + ], + "format": "string", + "iid": 3 + }, + { + "type": "30", + "value": "037A2BABF19D", + "perms": [ + "pr" + ], + "format": "string", + "iid": 4 + }, + { + "type": "21", + "value": "Bridge1,1", + "perms": [ + "pr" + ], + "format": "string", + "iid": 5 + }, + { + "type": "14", + "value": null, + "perms": [ + "pw" + ], + "format": "bool", + "iid": 6 + }, + { + "type": "52", + "value": "100.1.1", + "perms": [ + "pr" + ], + "format": "string", + "iid": 7 + } + ] + }, + { + "type": "A2", + "iid": 8, + "characteristics": [ + { + "type": "37", + "value": "01.01.00", + "perms": [ + "pr" + ], + "format": "string", + "iid": 9 + } + ] + } + ] + }, + { + "aid": 2, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "value": "Acme LED Light Bulb", + "perms": [ + "pr" + ], + "format": "string", + "iid": 2 + }, + { + "type": "20", + "value": "Acme", + "perms": [ + "pr" + ], + "format": "string", + "iid": 3 + }, + { + "type": "30", + "value": "099DB48E9E28", + "perms": [ + "pr" + ], + "format": "string", + "iid": 4 + }, + { + "type": "21", + "value": "LEDBulb1,1", + "perms": [ + "pr" + ], + "format": "string", + "iid": 5 + }, + { + "type": "14", + "value": null, + "perms": [ + "pw" + ], + "format": "bool", + "iid": 6 + } + ] + }, + { + "type": "43", + "iid": 7, + "characteristics": [ + { + "type": "25", + "value": true, + "perms": [ + "pr", + "pw" + ], + "format": "bool", + "iid": 8 + }, + { + "type": "8", + "value": 50, + "perms": [ + "pr", + "pw" + ], + "iid": 9, + "maxValue": 100, + "minStep": 1, + "minValue": 20, + "format": "int", + "unit": "percentage" + } + ] + } + ] + }, + { + "aid": 3, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "value": "Acme LED Light Bulb", + "perms": [ + "pr" + ], + "format": "string", + "iid": 2 + }, + { + "type": "20", + "value": "Acme", + "perms": [ + "pr" + ], + "format": "string", + "iid": 3 + }, + { + "type": "30", + "value": "099DB48E9E28", + "perms": [ + "pr" + ], + "format": "string", + "iid": 4 + }, + { + "type": "21", + "value": "LEDBulb1,1", + "perms": [ + "pr" + ], + "format": "string", + "iid": 5 + }, + { + "type": "14", + "value": null, + "perms": [ + "pw" + ], + "format": "bool", + "iid": 6 + } + ] + }, + { + "type": "43", + "iid": 7, + "characteristics": [ + { + "type": "25", + "value": true, + "perms": [ + "pr", + "pw" + ], + "format": "bool", + "iid": 8 + }, + { + "type": "8", + "value": 50, + "perms": [ + "pr", + "pw" + ], + "iid": 9, + "maxValue": 100, + "minStep": 1, + "minValue": 20, + "format": "int", + "unit": "percentage" + } + ] + } + ] + } + ] + } + """; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 9272d85652be7..66b19443887ec 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -14,12 +14,13 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; -import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.binding.BridgeHandler; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.builder.BridgeBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Handler for HomeKit bridge devices. @@ -34,12 +35,11 @@ @NonNullByDefault public class HomekitBridgeHandler extends HomekitBaseServerHandler implements BridgeHandler { - // private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); - protected final HomekitChildDiscoveryService discoveryService; + private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); + private final HomekitChildDiscoveryService discoveryService; - public HomekitBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, - HomekitChildDiscoveryService discoveryService) { - super(bridge, httpClientFactory); + public HomekitBridgeHandler(Bridge bridge, HomekitChildDiscoveryService discoveryService) { + super(bridge); this.discoveryService = discoveryService; } @@ -69,7 +69,9 @@ public void initialize() { @Override public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) { - // TODO Auto-generated method stub + if (childHandler instanceof HomekitDeviceHandler homekitDeviceHandler) { + homekitDeviceHandler.accessoriesLoaded(); + } } @Override @@ -78,10 +80,8 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { } @Override - protected void getAccessories() { - super.getAccessories(); - if (!accessories.isEmpty()) { - discoveryService.devicesDiscovered(thing, accessories.values()); - } + protected void accessoriesLoaded() { + logger.info("Bridge accessories loaded {}", accessories.size()); + discoveryService.devicesDiscovered(thing, accessories.values()); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 36540a0357df8..83bdca0c3144c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -15,14 +15,14 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; import javax.measure.Unit; -import javax.measure.format.UnitFormat; -import javax.measure.spi.ServiceProvider; +import javax.measure.format.MeasurementParseException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.dto.Accessory; @@ -31,7 +31,6 @@ import org.openhab.binding.homekit.internal.enums.DataFormatType; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; -import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DecimalType; @@ -69,29 +68,28 @@ @NonNullByDefault public class HomekitDeviceHandler extends HomekitBaseServerHandler { - private static final UnitFormat UNIT_NAME_PARSER = ServiceProvider.current().getFormatService() - .getUnitFormat("Name"); - private final Logger logger = LoggerFactory.getLogger(HomekitDeviceHandler.class); private final HomekitTypeProvider typeProvider; - public HomekitDeviceHandler(Thing thing, HttpClientFactory httpClientFactory, HomekitTypeProvider typeProvider) { - super(thing, httpClientFactory); + public HomekitDeviceHandler(Thing thing, HomekitTypeProvider typeProvider) { + super(thing); this.typeProvider = typeProvider; } @Override public void initialize() { super.initialize(); - String refreshInterval = getConfig().get(CONFIG_REFRESH_INTERVAL).toString(); - try { - int refreshIntervalSeconds = Integer.parseInt(refreshInterval); - if (refreshIntervalSeconds > 0) { - scheduler.scheduleWithFixedDelay(this::refresh, 0, refreshIntervalSeconds, TimeUnit.SECONDS); + if (getConfig().get(CONFIG_REFRESH_INTERVAL) instanceof Object refreshInterval) { + try { + int refreshIntervalSeconds = Integer.parseInt(refreshInterval.toString()); + if (refreshIntervalSeconds > 0) { + scheduler.scheduleWithFixedDelay(this::refresh, 0, refreshIntervalSeconds, TimeUnit.SECONDS); + return; + } + } catch (NumberFormatException e) { } - } catch (NumberFormatException e) { - logger.warn("Invalid refresh interval configuration: {}", refreshInterval); } + logger.warn("Invalid refresh interval configuration, polling disabled"); } @Override @@ -152,12 +150,10 @@ private void refresh() { } @Override - protected void getAccessories() { - if (!isChildAccessory) { - // child accessories shall not fetch accessories again - super.getAccessories(); - } - createChannels(); + protected void accessoriesLoaded() { + logger.info("Thing accessories loaded {}", accessories.size()); + // create channels based on the fetched accessories + createChannels(); // do this asynchronously } /** @@ -167,25 +163,30 @@ protected void getAccessories() { * Each service creates a channel group, and each characteristic creates a channel within it. */ private void createChannels() { + logger.info("Creating channels accessories {}", accessories.size()); if (accessories.isEmpty()) { return; } Integer accessoryId = getAccessoryId(); + logger.info("Creating channels accessoryId {}", accessoryId); if (accessoryId == null) { return; } Accessory accessory = accessories.get(accessoryId); + logger.info("Creating channels accessory {}", accessory); if (accessory == null) { return; } // create the channels List channels = new ArrayList<>(); - Map properties = thing.getProperties(); + Map properties = new HashMap<>(thing.getProperties()); // keep existing properties accessory.buildAndRegisterChannelGroupDefinitions(typeProvider).forEach(groupDef -> { + logger.info("Creating channels groupDef {}", groupDef.getId()); ChannelGroupType groupType = typeProvider.getChannelGroupType(groupDef.getTypeUID(), null); if (groupType != null) { groupType.getChannelDefinitions().forEach(channelDef -> { + logger.info("Creating channels channelDef {}", channelDef.getId()); if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { String name = channelDef.getId(); String value = channelDef.getLabel(); @@ -208,10 +209,12 @@ private void createChannels() { } }); - // update thing with the new channels - ThingBuilder builder = editThing().withProperties(properties).withChannels(channels); - Optional.ofNullable(accessory.getSemanticEquipmentTag()).ifPresent(builder::withSemanticEquipmentTag); - updateThing(builder.build()); + if (!channels.isEmpty() || !properties.isEmpty()) { + logger.warn("Updating thing with {}/{} channels/properties", channels.size(), properties.size()); + ThingBuilder builder = editThing().withProperties(properties).withChannels(channels); + Optional.ofNullable(accessory.getSemanticEquipmentTag()).ifPresent(builder::withSemanticEquipmentTag); + updateThing(builder.build()); + } } /** @@ -236,10 +239,13 @@ private Object convertCommandToObject(Command command, Channel channel) { // convert QuantityTypes to the characteristic's unit if (object instanceof QuantityType quantity) { - Unit unit = properties.get("unit") instanceof String p ? UNIT_NAME_PARSER.parse(p) : null; - if (unit != null && !unit.equals(quantity.getUnit()) && quantity.getUnit().isCompatible(unit)) { - QuantityType temp = quantity.toUnit(unit); - object = temp != null ? temp : quantity; + if (properties.get("unit") instanceof String unit) { + try { + QuantityType temp = quantity.toUnit(normalizedUnitString(unit)); + object = temp != null ? temp : quantity; + } catch (MeasurementParseException e) { + logger.warn("Unexpected unit {} for channel {}", unit, channel.getUID()); + } } } @@ -289,6 +295,19 @@ private Object convertCommandToObject(Command command, Channel channel) { return object; } + /** + * Convert a HomeKit formatted unit string to OH format. + */ + private static String normalizedUnitString(String unit) { + if (unit.isEmpty()) { + return unit; + } + if ("percentage".equals(unit)) { // special case HomeKit "percentage" => "Percent" + return "Percent"; + } + return unit.substring(0, 1).toUpperCase() + unit.substring(1).toLowerCase(); // e.g. celsius => Celsius + } + /** * Converts a Characteristic's 'value' JSON element to an openHAB State based on the channel's accepted item type. * Handles various data formats including boolean, string, and number. diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index a06af1629eca7..f279fce52b755 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -1,26 +1,28 @@ -# add-on - -addon.homekit.name = HomeKit Binding -addon.homekit.description = This is the binding for HomeKit. - -# thing types - -thing-type.homekit.bridge.label = HomeKit Bridge -thing-type.homekit.bridge.description = HomeKit Accessory Bridge -thing-type.homekit.device.label = HomeKit Device -thing-type.homekit.device.description = HomeKit Accessory Device - -# thing types config - -thing-type.config.homekit.bridge.ipV4Address.label = IP Address -thing-type.config.homekit.bridge.ipV4Address.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.bridge.pairingCode.label = Pairing Code -thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit accessory. -thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval -thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the accessory is polled in sec. -thing-type.config.homekit.device.ipV4Address.label = IP Address -thing-type.config.homekit.device.ipV4Address.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.device.pairingCode.label = Pairing Code -thing-type.config.homekit.device.pairingCode.description = Code used for pairing with the HomeKit accessory. -thing-type.config.homekit.device.refreshInterval.label = Refresh Interval -thing-type.config.homekit.device.refreshInterval.description = Interval at which the accessory is polled in sec. +# add-on + +addon.homekit.name = HomeKit Binding +addon.homekit.description = This is the binding for HomeKit. + +# thing types + +thing-type.homekit.bridge.label = HomeKit Bridge +thing-type.homekit.bridge.description = HomeKit Accessory Bridge +thing-type.homekit.child.label = HomeKit Device +thing-type.homekit.child.description = HomeKit Accessory Device +thing-type.homekit.device.label = HomeKit Device +thing-type.homekit.device.description = HomeKit Accessory Device + +# thing types config + +thing-type.config.homekit.bridge.ipV4Address.label = IP Address +thing-type.config.homekit.bridge.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.bridge.pairingCode.label = Pairing Code +thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit accessory. +thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval +thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.device.ipV4Address.label = IP Address +thing-type.config.homekit.device.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.device.pairingCode.label = Pairing Code +thing-type.config.homekit.device.pairingCode.description = Code used for pairing with the HomeKit accessory. +thing-type.config.homekit.device.refreshInterval.label = Refresh Interval +thing-type.config.homekit.device.refreshInterval.description = Interval at which the accessory is polled in sec. diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 582e9acc99c4d..0e759fefaafed 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -8,12 +8,12 @@ HomeKit Accessory Device - + network-address IP v4 address of the HomeKit accessory. - + password Code used for pairing with the HomeKit accessory. @@ -27,6 +27,22 @@ + + + + + + HomeKit Accessory Device + + + + Interval at which the accessory is polled in sec. + 60 + true + + + + HomeKit Accessory Bridge diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java index 49b9139e9f8ab..dbda67e96f020 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -432,7 +432,7 @@ void testChannelDefinitions() { assertNotNull(channelDefinition); assertEquals("public-hap-characteristic-brightness", channelDefinition.getChannelTypeUID().getId()); assertEquals("Brightness", channelDefinition.getLabel()); - assertEquals("percent", channelDefinition.getProperties().get("unit")); + assertEquals("percentage", channelDefinition.getProperties().get("unit")); assertEquals("int", channelDefinition.getProperties().get("format")); assertEquals("20.0", channelDefinition.getProperties().get("minValue")); assertEquals("100.0", channelDefinition.getProperties().get("maxValue")); From de1d5a7870c6cc64cb3ffbb476bae92795ee76aa Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 22 Sep 2025 19:10:28 +0100 Subject: [PATCH 028/177] improve channel ids Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/HomekitBindingConstants.java | 2 +- .../discovery/HomekitChildDiscoveryService.java | 2 +- .../homekit/internal/dto/Characteristic.java | 2 +- .../binding/homekit/internal/dto/Service.java | 2 +- .../homekit/internal/enums/CharacteristicType.java | 2 +- .../binding/homekit/internal/enums/ServiceType.java | 2 +- .../homekit/internal/TestChannelCreation.java | 13 ++++++------- 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index f006e4067dde3..cee844726066d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -43,7 +43,7 @@ public class HomekitBindingConstants { public static final String CHANNEL_TYPE_LABEL_FMT = "Channel type: %s"; // UID id formats - public static final String CHILD_FMT = "child-%x"; // e.g. child-123abc; + public static final String ACCESSORY_FMT = "accessory-%d"; // e.g. accessory-3 // configuration parameters public static final String CONFIG_PAIRING_CODE = "pairingCode"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index bf79da5b61798..f166196d235a5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -50,7 +50,7 @@ public void devicesDiscovered(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { if (accessory.aid != null && accessory.services != null) { // accessory ID is unique per bridge - ThingUID uid = new ThingUID(THING_TYPE_CHILD, bridge.getUID(), CHILD_FMT.formatted(accessory.aid)); + ThingUID uid = new ThingUID(THING_TYPE_CHILD, bridge.getUID(), ACCESSORY_FMT.formatted(accessory.aid)); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index b15277dccdc17..c587432708489 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -589,7 +589,7 @@ public class Characteristic { Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> properties.put("ev", s)); // return the definition of a specific _instance_ of the channel _type_ - return new ChannelDefinitionBuilder(Integer.toString(iid), uid).withProperties(properties) + return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), uid).withProperties(properties) .withLabel(characteristicType.toString()).build(); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index a5b05498f2484..fa262a223a963 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -70,7 +70,7 @@ public class Service { // persist the group _type_, and return the definition of a specific _instance_ of that type typeProvider.putChannelGroupType(groupType); - return new ChannelGroupDefinition(Integer.toString(iid), groupTypeUID, serviceType.toString(), null); + return new ChannelGroupDefinition(serviceType.getOpenhabType(), groupTypeUID, serviceType.toString(), null); } public @Nullable ServiceType getServiceType() { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index 978a30faf7b65..c5f6be34155b7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -176,7 +176,7 @@ public static CharacteristicType from(int id) throws IllegalArgumentException { } public String getOpenhabType() { - return type.replace("-", "_").replace(".", "-"); // convert to OH channel-group-type format + return type.replace("public.hap.characteristic.", "").replace(".", "-"); // convert to OH channel type format } public String getType() { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index e877db3cb7ed8..a33888ccd04c7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -89,7 +89,7 @@ public static ServiceType from(int type) throws IllegalArgumentException { } public String getOpenhabType() { - return type.replace(".", "-"); // convert to OH channel type format + return type.replace("public.hap.service.", "").replace(".", "-"); // convert to OH channel type format } public String getType() { diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java index dbda67e96f020..ca3846cbc800e 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -398,8 +398,7 @@ void testChannelDefinitions() { // Check that the public-hap-service-accessory-information channel group type and its UID and label are set ChannelGroupType channelGroupType = channelGroupTypes.stream() - .filter(cgt -> "public-hap-service-accessory-information".equals(cgt.getUID().getId())).findFirst() - .orElse(null); + .filter(cgt -> "accessory-information".equals(cgt.getUID().getId())).findFirst().orElse(null); assertNotNull(channelGroupType); // There should be four fake channel definitions for the Accessory Information service assertEquals(4, channelGroupType.getChannelDefinitions().size()); @@ -417,11 +416,11 @@ void testChannelDefinitions() { assertEquals("099DB48E9E28", channelDefinition.getLabel()); // Check that the channel group type and its UID and label are set - channelGroupType = channelGroupTypes.stream() - .filter(cgt -> "public-hap-service-lightbulb".equals(cgt.getUID().getId())).findFirst().orElse(null); + channelGroupType = channelGroupTypes.stream().filter(cgt -> "lightbulb".equals(cgt.getUID().getId())) + .findFirst().orElse(null); assertNotNull(channelGroupType); assertEquals("Channel group type: Light Bulb", channelGroupType.getLabel()); - assertEquals("public-hap-service-lightbulb", channelGroupType.getUID().getId()); + assertEquals("lightbulb", channelGroupType.getUID().getId()); // There should be two channel definitions for the Light Bulb service: On and Brightness assertEquals(2, channelGroupType.getChannelDefinitions().size()); @@ -430,7 +429,7 @@ void testChannelDefinitions() { channelDefinition = channelGroupType.getChannelDefinitions().stream() .filter(cd -> "Brightness".equals(cd.getLabel())).findFirst().orElse(null); assertNotNull(channelDefinition); - assertEquals("public-hap-characteristic-brightness", channelDefinition.getChannelTypeUID().getId()); + assertEquals("brightness", channelDefinition.getChannelTypeUID().getId()); assertEquals("Brightness", channelDefinition.getLabel()); assertEquals("percentage", channelDefinition.getProperties().get("unit")); assertEquals("int", channelDefinition.getProperties().get("format")); @@ -446,7 +445,7 @@ void testChannelDefinitions() { ChannelType channelType = channelTypes.stream().filter(ct -> "Dimmer".equals(ct.getItemType())).findFirst() .orElse(null); assertNotNull(channelType); - assertEquals("public-hap-characteristic-brightness", channelType.getUID().getId()); + assertEquals("brightness", channelType.getUID().getId()); assertEquals("Channel type: Brightness", channelType.getLabel()); assertEquals("Dimmer", channelType.getItemType()); assertEquals("light", channelType.getCategory()); From 99c53397c7098ddb301df39913565237adb9ab8a Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 22 Sep 2025 19:40:33 +0100 Subject: [PATCH 029/177] refactoring Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitBaseServerHandler.java | 7 +- .../handler/HomekitDeviceHandler.java | 283 +++++++++--------- 2 files changed, 146 insertions(+), 144 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 98be68fa38195..0691f8fc4b83f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -119,10 +119,9 @@ private void fetchAccessories() { } /** - * Called when accessories have been loaded from the /accessories endpoint. - * Subclasses should override to perform any processing required. - * This method is called in the context of the scheduler thread, so should not - * perform long blocking operations. + * Called when the thing handler has been initialized, the pairing verified, and the accessories have been loaded. + * Subclasses override this to perform any processing required. + * This method is called in the context of a scheduler thread, to avoid blocking operations. */ protected abstract void accessoriesLoaded(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 83bdca0c3144c..4bb9d9e4c6866 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -77,8 +77,16 @@ public HomekitDeviceHandler(Thing thing, HomekitTypeProvider typeProvider) { } @Override - public void initialize() { - super.initialize(); + protected void accessoriesLoaded() { + createChannels(); // create channels based on the fetched accessories + } + + /** + * Called when the thing handler has been initialized, the pairing verified, the accessories loaded, + * and the channels and properties created. + * Sets up a scheduled task to periodically refresh the state of the accessory. + */ + private void channelsAndPropertiesLoaded() { if (getConfig().get(CONFIG_REFRESH_INTERVAL) instanceof Object refreshInterval) { try { int refreshIntervalSeconds = Integer.parseInt(refreshInterval.toString()); @@ -92,131 +100,6 @@ public void initialize() { logger.warn("Invalid refresh interval configuration, polling disabled"); } - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - Channel channel = thing.getChannel(channelUID); - if (channel == null) { - logger.warn("Received command for unknown channel: {}", channelUID); - return; - } - CharacteristicReadWriteService writer = this.rwService; - if (writer == null) { - logger.warn("No writer service available to handle command for channel: {}", channelUID); - return; - } - Object object = null; - try { - Integer aid = getAccessoryId(); - if (aid != null) { - object = convertCommandToObject(command, channel); - writer.writeCharacteristic(aid.toString(), channelUID.getId(), object); - } - } catch (Exception e) { - logger.warn("Failed to send command '{}' as object '{}' to accessory for '{}", command, object, channelUID, - e); - } - } - - /** - * Polls the accessory for its current state and updates the corresponding channels. - * This method is called periodically by a scheduled executor. - */ - private void refresh() { - CharacteristicReadWriteService rwService = this.rwService; - if (rwService != null) { - try { - Integer aid = getAccessoryId(); - List queries = thing.getChannels().stream() - .map(c -> "%s.%s".formatted(aid, Integer.valueOf(c.getUID().getId()))).toList(); - if (queries.isEmpty()) { - return; - } - String jsonResponse = rwService.readCharacteristic(String.join(",", queries)); - Service service = GSON.fromJson(jsonResponse, Service.class); - if (service != null && service.characteristics instanceof List characteristics) { - for (Characteristic characteristic : characteristics) { - for (Channel channel : thing.getChannels()) { - if (channel.getUID().getId().equals(String.valueOf(characteristic.iid)) - && characteristic.value instanceof JsonElement element) { - updateState(channel.getUID(), convertJsonToState(element, channel)); - } - } - } - } - } catch (Exception e) { - logger.error("Failed to poll accessory state", e); - } - } - } - - @Override - protected void accessoriesLoaded() { - logger.info("Thing accessories loaded {}", accessories.size()); - // create channels based on the fetched accessories - createChannels(); // do this asynchronously - } - - /** - * Creates channels for the accessory based on its services and characteristics. - * Only parses the one relevant accessory in the list, as each handler is for a single accessory. - * Iterates through that accessory's services and characteristics to create appropriate channels. - * Each service creates a channel group, and each characteristic creates a channel within it. - */ - private void createChannels() { - logger.info("Creating channels accessories {}", accessories.size()); - if (accessories.isEmpty()) { - return; - } - Integer accessoryId = getAccessoryId(); - logger.info("Creating channels accessoryId {}", accessoryId); - if (accessoryId == null) { - return; - } - Accessory accessory = accessories.get(accessoryId); - logger.info("Creating channels accessory {}", accessory); - if (accessory == null) { - return; - } - - // create the channels - List channels = new ArrayList<>(); - Map properties = new HashMap<>(thing.getProperties()); // keep existing properties - accessory.buildAndRegisterChannelGroupDefinitions(typeProvider).forEach(groupDef -> { - logger.info("Creating channels groupDef {}", groupDef.getId()); - ChannelGroupType groupType = typeProvider.getChannelGroupType(groupDef.getTypeUID(), null); - if (groupType != null) { - groupType.getChannelDefinitions().forEach(channelDef -> { - logger.info("Creating channels channelDef {}", channelDef.getId()); - if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { - String name = channelDef.getId(); - String value = channelDef.getLabel(); - if (value != null) { - properties.put(name, value); - } - } else { - ChannelType channelType = typeProvider.getChannelType(channelDef.getChannelTypeUID(), null); - if (channelType != null) { - ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), - channelDef.getId()); - ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()) - .withProperties(channelDef.getProperties()); - Optional.ofNullable(channelDef.getLabel()).ifPresent(builder::withLabel); - Optional.ofNullable(channelDef.getDescription()).ifPresent(builder::withDescription); - channels.add(builder.build()); - } - } - }); - } - }); - - if (!channels.isEmpty() || !properties.isEmpty()) { - logger.warn("Updating thing with {}/{} channels/properties", channels.size(), properties.size()); - ThingBuilder builder = editThing().withProperties(properties).withChannels(channels); - Optional.ofNullable(accessory.getSemanticEquipmentTag()).ifPresent(builder::withSemanticEquipmentTag); - updateThing(builder.build()); - } - } - /** * Converts an openHAB Command to a suitable object for writing to a HomeKit characteristic. * It handles various conversions including unit conversion, clamping to min/max values, @@ -295,19 +178,6 @@ private Object convertCommandToObject(Command command, Channel channel) { return object; } - /** - * Convert a HomeKit formatted unit string to OH format. - */ - private static String normalizedUnitString(String unit) { - if (unit.isEmpty()) { - return unit; - } - if ("percentage".equals(unit)) { // special case HomeKit "percentage" => "Percent" - return "Percent"; - } - return unit.substring(0, 1).toUpperCase() + unit.substring(1).toLowerCase(); // e.g. celsius => Celsius - } - /** * Converts a Characteristic's 'value' JSON element to an openHAB State based on the channel's accepted item type. * Handles various data formats including boolean, string, and number. @@ -368,4 +238,137 @@ private State convertJsonToState(JsonElement element, Channel channel) { } return UnDefType.UNDEF; } + + /** + * Creates channels for the accessory based on its services and characteristics. + * Only parses the one relevant accessory in the list, as each handler is for a single accessory. + * Iterates through that accessory's services and characteristics to create appropriate channels. + * Each service creates a channel group, and each characteristic creates a channel within it. + */ + private void createChannels() { + if (accessories.isEmpty()) { + return; + } + Integer accessoryId = getAccessoryId(); + if (accessoryId == null) { + return; + } + Accessory accessory = accessories.get(accessoryId); + if (accessory == null) { + return; + } + + // create the channels + List channels = new ArrayList<>(); + Map properties = new HashMap<>(thing.getProperties()); // keep existing properties + accessory.buildAndRegisterChannelGroupDefinitions(typeProvider).forEach(groupDef -> { + ChannelGroupType groupType = typeProvider.getChannelGroupType(groupDef.getTypeUID(), null); + if (groupType != null) { + groupType.getChannelDefinitions().forEach(channelDef -> { + logger.info("Creating channels channelDef {}", channelDef.getId()); + if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { + String name = channelDef.getId(); + String value = channelDef.getLabel(); + if (value != null) { + properties.put(name, value); + } + } else { + ChannelType channelType = typeProvider.getChannelType(channelDef.getChannelTypeUID(), null); + if (channelType != null) { + ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), + channelDef.getId()); + ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()) + .withProperties(channelDef.getProperties()); + Optional.ofNullable(channelDef.getLabel()).ifPresent(builder::withLabel); + Optional.ofNullable(channelDef.getDescription()).ifPresent(builder::withDescription); + channels.add(builder.build()); + } + } + }); + } + }); + + if (!channels.isEmpty() || !properties.isEmpty()) { + logger.debug("Updating thing with {} channels, {} properties", channels.size(), properties.size()); + ThingBuilder builder = editThing().withProperties(properties).withChannels(channels); + Optional.ofNullable(accessory.getSemanticEquipmentTag()).ifPresent(builder::withSemanticEquipmentTag); + updateThing(builder.build()); + channelsAndPropertiesLoaded(); + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + Channel channel = thing.getChannel(channelUID); + if (channel == null) { + logger.warn("Received command for unknown channel: {}", channelUID); + return; + } + CharacteristicReadWriteService writer = this.rwService; + if (writer == null) { + logger.warn("No writer service available to handle command for channel: {}", channelUID); + return; + } + Object object = null; + try { + Integer aid = getAccessoryId(); + if (aid != null) { + object = convertCommandToObject(command, channel); + writer.writeCharacteristic(aid.toString(), channelUID.getId(), object); + } + } catch (Exception e) { + logger.warn("Failed to send command '{}' as object '{}' to accessory for '{}", command, object, channelUID, + e); + } + } + + @Override + public void initialize() { + super.initialize(); + } + + /** + * Convert a HomeKit formatted unit string to OH format. + */ + private String normalizedUnitString(String unit) { + if (unit.isEmpty()) { + return unit; + } + if ("percentage".equals(unit)) { // special case HomeKit "percentage" => "Percent" + return "Percent"; + } + return unit.substring(0, 1).toUpperCase() + unit.substring(1).toLowerCase(); // e.g. celsius => Celsius + } + + /** + * Polls the accessory for its current state and updates the corresponding channels. + * This method is called periodically by a scheduled executor. + */ + private void refresh() { + CharacteristicReadWriteService rwService = this.rwService; + if (rwService != null) { + try { + Integer aid = getAccessoryId(); + List queries = thing.getChannels().stream() + .map(c -> "%s.%s".formatted(aid, Integer.valueOf(c.getUID().getId()))).toList(); + if (queries.isEmpty()) { + return; + } + String jsonResponse = rwService.readCharacteristic(String.join(",", queries)); + Service service = GSON.fromJson(jsonResponse, Service.class); + if (service != null && service.characteristics instanceof List characteristics) { + for (Characteristic characteristic : characteristics) { + for (Channel channel : thing.getChannels()) { + if (channel.getUID().getId().equals(String.valueOf(characteristic.iid)) + && characteristic.value instanceof JsonElement element) { + updateState(channel.getUID(), convertJsonToState(element, channel)); + } + } + } + } + } catch (Exception e) { + logger.error("Failed to poll accessory state", e); + } + } + } } From 89b85ecd4c2284f1163445fb01bd5803c020f827 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 22 Sep 2025 21:57:29 +0100 Subject: [PATCH 030/177] small fixes Signed-off-by: Andrew Fiddian-Green --- .../internal/enums/CharacteristicType.java | 1 + .../homekit/internal/enums/ServiceType.java | 1 + .../handler/HomekitBaseServerHandler.java | 12 ++-- .../handler/HomekitBridgeHandler.java | 6 +- .../handler/HomekitDeviceHandler.java | 2 +- .../resources/OH-INF/i18n/homekit.properties | 58 ++++++++++--------- 6 files changed, 41 insertions(+), 39 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index c5f6be34155b7..b71f911592564 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -24,6 +24,7 @@ */ @NonNullByDefault public enum CharacteristicType { + // TODO manually check the Homekit specification pdf to ensure all types are covered //@formatter:off ACCESSORY_PROPERTIES(0xA6, "public.hap.characteristic.accessory-properties"), ACTIVE(0xB0, "public.hap.characteristic.active"), diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index a33888ccd04c7..27646e1f7a381 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -24,6 +24,7 @@ */ @NonNullByDefault public enum ServiceType { + // TODO manually check the Homekit specification pdf to ensure all types are covered ACCESSORY_INFORMATION(0x3E, "public.hap.service.accessory-information"), AIR_PURIFIER(0xBB, "public.hap.service.air-purifier"), AUDIO_STREAM_MANAGEMENT(0x127, "public.hap.service.audio-stream-management"), diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 0691f8fc4b83f..8ed0138fe7a88 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -99,7 +99,6 @@ public void dispose() { * @see HomeKit HTTP */ private void fetchAccessories() { - logger.info("Fetching accessories for BASE thing {}", thing.getUID()); try { // byte[] json = ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); // Accessories container = GSON.fromJson(new String(json, StandardCharsets.UTF_8), Accessories.class); @@ -111,7 +110,7 @@ private void fetchAccessories() { accessories.putAll(accessoryList.stream().filter(a -> Objects.nonNull(a.aid)) .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } - logger.info("Fetched {} accessories", accessories.size()); + logger.debug("Fetched {} accessories", accessories.size()); scheduler.submit(() -> accessoriesLoaded()); // notify subclass in scheduler thread } catch (Exception e) { logger.warn("Failed to get accessories: {}", e.getMessage()); @@ -157,7 +156,6 @@ public void handleRemoval() { if (isChildAccessory) { updateStatus(ThingStatus.REMOVED); } else { - updateStatus(ThingStatus.REMOVING); scheduler.submit(() -> { // unpair and clear stored keys if this is NOT a child accessory try { @@ -182,8 +180,8 @@ public void initialize() { ipTransport = bridgeHandler.ipTransport; rwService = bridgeHandler.rwService; // TODO remove comment <= if (rwService != null) { - updateStatus(ThingStatus.ONLINE); fetchAccessories(); + updateStatus(ThingStatus.ONLINE); // TODO remove comment <= } else { // TODO remove comment <= updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not // connected"); @@ -194,8 +192,8 @@ public void initialize() { try { // TODO => ipTransport = new IpTransport(getConfig().get(CONFIG_IP_V4_ADDRESS).toString()); // TODO => scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread - updateStatus(ThingStatus.ONLINE); // TODO <= remove when above code is enabled fetchAccessories(); // TODO <= remove when above code is enabled + updateStatus(ThingStatus.ONLINE); } catch (Exception e) { logger.warn("Failed to create transport: {}", e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, @@ -230,8 +228,8 @@ private void initializePairing() { rwService = new CharacteristicReadWriteService(ipTransport); logger.debug("Restored pairing was verified for accessory {}", accessoryId); - updateStatus(ThingStatus.ONLINE); fetchAccessories(); + updateStatus(ThingStatus.ONLINE); return; } catch (Exception e) { @@ -266,8 +264,8 @@ private void initializePairing() { storeLongTermKeys(); logger.debug("Pairing and verification completed for accessory {}", accessoryId); - updateStatus(ThingStatus.ONLINE); fetchAccessories(); + updateStatus(ThingStatus.ONLINE); } catch (Exception e) { logger.warn("Pairing and verification failed for accessory {}", accessoryId); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 66b19443887ec..4d16614353da6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -76,12 +76,12 @@ public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) @Override public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { - // TODO Auto-generated method stub + // do nothing } @Override protected void accessoriesLoaded() { - logger.info("Bridge accessories loaded {}", accessories.size()); - discoveryService.devicesDiscovered(thing, accessories.values()); + logger.debug("Bridge accessories loaded {}", accessories.size()); + discoveryService.devicesDiscovered(thing, accessories.values()); // discover child accessories } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 4bb9d9e4c6866..b40aafb15dac1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -78,6 +78,7 @@ public HomekitDeviceHandler(Thing thing, HomekitTypeProvider typeProvider) { @Override protected void accessoriesLoaded() { + logger.debug("Thing accessories loaded {}", accessories.size()); createChannels(); // create channels based on the fetched accessories } @@ -265,7 +266,6 @@ private void createChannels() { ChannelGroupType groupType = typeProvider.getChannelGroupType(groupDef.getTypeUID(), null); if (groupType != null) { groupType.getChannelDefinitions().forEach(channelDef -> { - logger.info("Creating channels channelDef {}", channelDef.getId()); if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { String name = channelDef.getId(); String value = channelDef.getLabel(); diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index f279fce52b755..39dc77ef4273d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -1,28 +1,30 @@ -# add-on - -addon.homekit.name = HomeKit Binding -addon.homekit.description = This is the binding for HomeKit. - -# thing types - -thing-type.homekit.bridge.label = HomeKit Bridge -thing-type.homekit.bridge.description = HomeKit Accessory Bridge -thing-type.homekit.child.label = HomeKit Device -thing-type.homekit.child.description = HomeKit Accessory Device -thing-type.homekit.device.label = HomeKit Device -thing-type.homekit.device.description = HomeKit Accessory Device - -# thing types config - -thing-type.config.homekit.bridge.ipV4Address.label = IP Address -thing-type.config.homekit.bridge.ipV4Address.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.bridge.pairingCode.label = Pairing Code -thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit accessory. -thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval -thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the accessory is polled in sec. -thing-type.config.homekit.device.ipV4Address.label = IP Address -thing-type.config.homekit.device.ipV4Address.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.device.pairingCode.label = Pairing Code -thing-type.config.homekit.device.pairingCode.description = Code used for pairing with the HomeKit accessory. -thing-type.config.homekit.device.refreshInterval.label = Refresh Interval -thing-type.config.homekit.device.refreshInterval.description = Interval at which the accessory is polled in sec. +# add-on + +addon.homekit.name = HomeKit Binding +addon.homekit.description = This is the binding for HomeKit. + +# thing types + +thing-type.homekit.bridge.label = HomeKit Bridge +thing-type.homekit.bridge.description = HomeKit Accessory Bridge +thing-type.homekit.child.label = HomeKit Device +thing-type.homekit.child.description = HomeKit Accessory Device +thing-type.homekit.device.label = HomeKit Device +thing-type.homekit.device.description = HomeKit Accessory Device + +# thing types config + +thing-type.config.homekit.bridge.ipV4Address.label = IP Address +thing-type.config.homekit.bridge.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.bridge.pairingCode.label = Pairing Code +thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit accessory. +thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval +thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.child.refreshInterval.label = Refresh Interval +thing-type.config.homekit.child.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.device.ipV4Address.label = IP Address +thing-type.config.homekit.device.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.device.pairingCode.label = Pairing Code +thing-type.config.homekit.device.pairingCode.description = Code used for pairing with the HomeKit accessory. +thing-type.config.homekit.device.refreshInterval.label = Refresh Interval +thing-type.config.homekit.device.refreshInterval.description = Interval at which the accessory is polled in sec. From a08cc7195912f3893f8e58468946ff7bfc7b1ac6 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 23 Sep 2025 18:45:35 +0100 Subject: [PATCH 031/177] adopt reviewer suggestions Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 12 +- .../HomekitChildDiscoveryService.java | 13 +- .../HomekitMdnsDiscoveryParticipant.java | 46 +++-- .../homekit/internal/dto/Accessory.java | 58 +++++- .../homekit/internal/dto/Characteristic.java | 184 ++++++++++++++++-- .../binding/homekit/internal/dto/Service.java | 35 +++- .../homekit/internal/enums/AccessoryType.java | 12 +- .../internal/enums/CharacteristicType.java | 3 +- .../homekit/internal/enums/ServiceType.java | 7 +- .../factory/HomekitHandlerFactory.java | 7 +- .../handler/HomekitBaseServerHandler.java | 39 ++-- .../handler/HomekitBridgeHandler.java | 7 + .../handler/HomekitDeviceHandler.java | 34 ++-- .../resources/OH-INF/i18n/homekit.properties | 22 +-- .../resources/OH-INF/thing/thing-types.xml | 22 +-- .../homekit/internal/TestChannelCreation.java | 2 +- 16 files changed, 352 insertions(+), 151 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index cee844726066d..e8186c8ec3c4b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -28,8 +28,7 @@ public class HomekitBindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); - public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device"); - public static final ThingTypeUID THING_TYPE_CHILD = new ThingTypeUID(BINDING_ID, "child"); + public static final ThingTypeUID THING_TYPE_ACCESSORY = new ThingTypeUID(BINDING_ID, "accessory"); // specific Channel Type UIDs public static final String FAKE_PROPERTY_CHANNEL = "property-fake-channel"; @@ -38,20 +37,17 @@ public class HomekitBindingConstants { // labels public static final String THING_LABEL_FMT = "Model %s on %s"; - public static final String CHILD_LABEL_FMT = "Accessory %d on %s"; + public static final String ACCESSORY_LABEL_FMT = "Accessory %d on %s"; public static final String GROUP_TYPE_LABEL_FMT = "Channel group type: %s"; public static final String CHANNEL_TYPE_LABEL_FMT = "Channel type: %s"; - // UID id formats - public static final String ACCESSORY_FMT = "accessory-%d"; // e.g. accessory-3 - // configuration parameters + public static final String CONFIG_HOST = "host"; public static final String CONFIG_PAIRING_CODE = "pairingCode"; - public static final String CONFIG_IP_V4_ADDRESS = "ipV4Address"; public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; // properties - public static final String PROPERTY_UID = "uid"; + public static final String PROPERTY_ACCESSORY_UID = "accessoryUID"; public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_DEVICE_CATEGORY = "deviceCategory"; public static final String PROPERTY_CONTROLLER_PRIVATE_KEY = "controllerPrivateKey"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index f166196d235a5..62dfeb2e615bf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -38,7 +38,7 @@ public class HomekitChildDiscoveryService extends AbstractDiscoveryService { public HomekitChildDiscoveryService() { - super(Set.of(THING_TYPE_CHILD), 10, false); + super(Set.of(THING_TYPE_ACCESSORY), 10, false); } @Override @@ -50,13 +50,14 @@ public void devicesDiscovered(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { if (accessory.aid != null && accessory.services != null) { // accessory ID is unique per bridge - ThingUID uid = new ThingUID(THING_TYPE_CHILD, bridge.getUID(), ACCESSORY_FMT.formatted(accessory.aid)); - + ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), accessory.aid.toString()); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // - .withLabel(CHILD_LABEL_FMT.formatted(accessory.aid, bridge.getLabel())) // - .withProperty(PROPERTY_UID, uid.toString()) // - .withRepresentationProperty(PROPERTY_UID).build()); + .withLabel(ACCESSORY_LABEL_FMT.formatted(accessory.aid, bridge.getLabel())) // + .withProperty(CONFIG_HOST, "n/a") // + .withProperty(CONFIG_PAIRING_CODE, "n/a") // + .withProperty(PROPERTY_ACCESSORY_UID, uid.toString()) // + .withRepresentationProperty(PROPERTY_ACCESSORY_UID).build()); } }); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 089c892b91e7b..0322c08996ae4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -38,7 +38,7 @@ * The device category is also included, allowing differentiation between bridges and accessories. * The discovery participant creates a ThingUID based on the MAC address and device category. * Discovered devices are published as Things of type - * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_DEVICE} + * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_ACCESSORY} * or {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGE}. * Discovered Things include properties such as model name, protocol version, and IP address. * This class does not perform active scanning; instead, it relies on the central mDNS discovery @@ -56,7 +56,7 @@ public class HomekitMdnsDiscoveryParticipant implements MDNSDiscoveryParticipant @Override public Set getSupportedThingTypeUIDs() { - return Set.of(THING_TYPE_DEVICE); + return Set.of(THING_TYPE_ACCESSORY); } @Override @@ -68,21 +68,30 @@ public String getServiceType() { public @Nullable DiscoveryResult createResult(ServiceInfo service) { ThingUID uid = getThingUID(service); if (uid != null) { - String ipV4Address = service.getHostAddresses()[0]; + String host = service.getHostAddresses()[0]; String macAddress = service.getPropertyString("id"); // HomeKit device ID is the MAC address String modelName = service.getPropertyString("md"); // HomeKit device model name String deviceCategory = service.getPropertyString("ci"); // HomeKit device category String protocolVersion = service.getPropertyString("pv"); // HomeKit protocol version - return DiscoveryResultBuilder.create(uid) // - .withLabel(THING_LABEL_FMT.formatted(modelName, ipV4Address)) // - .withProperty(CONFIG_IP_V4_ADDRESS, ipV4Address) // + DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); + builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), host)) // + .withProperty(CONFIG_HOST, host) // .withProperty(Thing.PROPERTY_MODEL_ID, modelName) // .withProperty(Thing.PROPERTY_MAC_ADDRESS, macAddress) // .withProperty(PROPERTY_PROTOCOL_VERSION, protocolVersion) // .withProperty(PROPERTY_DEVICE_CATEGORY, deviceCategory) // - .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS).build(); + .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); + + if (!isBridge(service)) { + // '1' means we shall use the first (and only) accessory + ThingUID accessoryUid = new ThingUID(THING_TYPE_ACCESSORY, "1"); + builder.withProperty(PROPERTY_ACCESSORY_UID, accessoryUid.toString()); + } + + return builder.build(); } + logger.debug("Ignoring discovered HAP service {} with bad properties", service.getName()); return null; } @@ -91,19 +100,18 @@ public String getServiceType() { String macAddress = service.getPropertyString("id"); if (macAddress != null) { String id = macAddress.replace(":", "").replace("-", "").toLowerCase(); // e.g. "a1b2c3d4e5f6" - String accessoryType = service.getPropertyString("ci"); // HomeKit accessory type - try { - if (AccessoryType.BRIDGE.equals(AccessoryType.from(Integer.parseInt(accessoryType)))) { - return new ThingUID(THING_TYPE_BRIDGE, id); - } else { - return new ThingUID(THING_TYPE_DEVICE, id); - } - } catch (IllegalArgumentException e) { - logger.warn("Failed to parse accessory type '{}' for HomeKit device with MAC '{}'", accessoryType, - macAddress); - } + return isBridge(service) ? new ThingUID(THING_TYPE_BRIDGE, id) : new ThingUID(THING_TYPE_ACCESSORY, id); } - logger.warn("Ignoring discovered HomeKit service {} without properties", service.getNiceTextString()); return null; } + + private boolean isBridge(ServiceInfo service) { + String ci = service.getPropertyString("ci"); // accessory type i.e. 'category' + try { + return AccessoryType.BRIDGE == AccessoryType.from(Integer.parseInt(ci)); + } catch (IllegalArgumentException e) { + logger.warn("Failed to parse accessory category '{}' for HAP service '{}'", ci, service.getName()); + } + return false; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 9f02b53869086..2a97654eddadf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -18,11 +18,15 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.AccessoryType; +import org.openhab.binding.homekit.internal.enums.CharacteristicType; +import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment; import org.openhab.core.thing.type.ChannelGroupDefinition; +import com.google.gson.JsonElement; + /** * HomeKit accessory DTO * Used to deserialize individual accessories from the /accessories endpoint of a HomeKit bridge. @@ -34,6 +38,13 @@ public class Accessory { public @NonNullByDefault({}) Integer aid; // e.g. 1 public @NonNullByDefault({}) List services; + public @NonNullByDefault({}) String name; + public @NonNullByDefault({}) String manufacturer; + public @NonNullByDefault({}) String model; + public @NonNullByDefault({}) String serialNumber; + public @NonNullByDefault({}) String firmwareRevision; + public @NonNullByDefault({}) String hardwareRevision; + public @NonNullByDefault({}) Integer category; /** * Builds and registers channel group definitions for all services of this accessory. @@ -51,11 +62,11 @@ public List buildAndRegisterChannelGroupDefinitions(Home } public AccessoryType getAccessoryType() { - Integer aid = this.aid; - if (aid == null) { + Integer category = this.category; + if (category == null) { return AccessoryType.OTHER; } - return AccessoryType.from(aid); + return AccessoryType.from(category); } /** @@ -124,12 +135,51 @@ public AccessoryType getAccessoryType() { return Equipment.TELEVISION; case VIDEO_DOORBELL: return Equipment.DOORBELL; + case AUDIO_RECEIVER: + return Equipment.RECEIVER; + case RANGE_EXTENDER: + return Equipment.NETWORK_APPLIANCE; + case ROUTER: + return Equipment.ROUTER; + case SMART_SPEAKER: + return Equipment.SPEAKER; + case TV_SET_TOP_BOX: + case TV_STREAMING_STICK: + return Equipment.MEDIA_PLAYER; case OTHER: - case RESERVED: + break; } return null; } + /** + * Gets the label for this accessory instance. If the accessory has a non-blank name, that is returned. Otherwise, + * if the accessory has an Accessory Information service with a Name characteristic, that is returned. Otherwise, + * the accessory type is returned in Title Case. + */ + public String getAccessoryInstanceLabel() { + if (name != null && !name.isBlank()) { + return name; + } + if (services instanceof List serviceList) { + for (Service s : serviceList) { + if (s.getServiceType() == ServiceType.ACCESSORY_INFORMATION) { + if (s.characteristics instanceof List characteristics) { + for (Characteristic c : characteristics) { + if (c.getCharacteristicType() == CharacteristicType.NAME) { + if (c.value instanceof JsonElement v && v.isJsonPrimitive() + && v.getAsJsonPrimitive().isString()) { + return v.getAsJsonPrimitive().getAsString(); + } + } + } + } + } + } + } + return toString(); + } + public @Nullable Service getService(Integer iid) { return services.stream().filter(s -> iid.equals(s.iid)).findFirst().orElse(null); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index c587432708489..7167e7fc0e57b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -17,6 +17,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -91,6 +92,15 @@ public class Characteristic { boolean isStateChannel = true; boolean isPercentage = "percentage".equals(unit); + String uom = unit == null ? null : switch (unit) { + case "celsius" -> "°C"; + case "percentage" -> "%"; + case "arcdegrees" -> "°"; + case "lux" -> "lx"; // lux + case "seconds" -> "s"; + default -> unit; // may be null or a custom unit + }; + String itemType = null; String category = null; String numberSuffix = null; @@ -99,7 +109,7 @@ public class Characteristic { if (isReadOnly) { if (isBoolean) { - itemType = CoreItemFactory.SWITCH; + itemType = CoreItemFactory.CONTACT; pointTag = Point.STATUS; category = "switch"; } else if (isNumber) { @@ -132,22 +142,27 @@ public class Characteristic { break; case AIR_PARTICULATE_DENSITY: + uom = "µg/m³"; numberSuffix = "Density"; propertyTag = Property.PARTICULATE_MATTER; break; case AIR_PARTICULATE_SIZE: - numberSuffix = "Length"; - propertyTag = Property.PARTICULATE_MATTER; + itemType = FAKE_PROPERTY_CHANNEL; break; case AIR_PURIFIER_STATE_CURRENT: + // TODO numeric enum 3 states + propertyTag = Property.MODE; + break; + case AIR_PURIFIER_STATE_TARGET: + itemType = CoreItemFactory.SWITCH; propertyTag = Property.ENABLED; break; case AIR_QUALITY: - numberSuffix = "Dimensionless"; + // TODO numeric enum 5 states propertyTag = Property.AIR_QUALITY; break; @@ -171,6 +186,7 @@ public class Characteristic { break; case CARBON_DIOXIDE_DETECTED: + itemType = CoreItemFactory.CONTACT; pointTag = Point.ALARM; propertyTag = Property.CO2; category = "co2"; @@ -178,12 +194,14 @@ public class Characteristic { case CARBON_DIOXIDE_LEVEL: case CARBON_DIOXIDE_PEAK_LEVEL: + uom = "ppm"; numberSuffix = "Density"; propertyTag = Property.CO2; category = "co2"; break; case CARBON_MONOXIDE_DETECTED: + itemType = CoreItemFactory.CONTACT; pointTag = Point.ALARM; propertyTag = Property.CO; category = "alarm"; @@ -191,63 +209,93 @@ public class Characteristic { case CARBON_MONOXIDE_LEVEL: case CARBON_MONOXIDE_PEAK_LEVEL: + uom = "ppm"; numberSuffix = "Density"; propertyTag = Property.CO; break; case CHARGING_STATE: - propertyTag = Property.ENERGY; + // TODO numeric enum 3 states + propertyTag = Property.MODE; category = "battery"; break; case COLOR_TEMPERATURE: - numberSuffix = isPercentage || unit == null ? "Dimensionless" : "Temperature"; + uom = "Mirek"; + numberSuffix = "Temperature"; propertyTag = Property.COLOR_TEMPERATURE; category = "light"; break; case CONTACT_STATE: + itemType = CoreItemFactory.CONTACT; + pointTag = Point.STATUS; + category = "switch"; break; case DENSITY_NO2: + uom = "µg/m³"; numberSuffix = "Density"; propertyTag = Property.AIR_QUALITY; break; case DENSITY_OZONE: + uom = "µg/m³"; numberSuffix = "Density"; propertyTag = Property.OZONE; break; case DENSITY_PM10: case DENSITY_PM2_5: + uom = "µg/m³"; numberSuffix = "Density"; propertyTag = Property.PARTICULATE_MATTER; break; case DENSITY_SO2: + uom = "µg/m³"; numberSuffix = "Density"; propertyTag = Property.AIR_QUALITY; break; case DENSITY_VOC: + uom = "µg/m³"; numberSuffix = "Density"; propertyTag = Property.VOC; break; case DOOR_STATE_CURRENT: + // TODO numeric enum 5 states + propertyTag = Property.OPEN_STATE; + break; + case DOOR_STATE_TARGET: + itemType = CoreItemFactory.SWITCH; propertyTag = Property.OPEN_STATE; break; case FAN_STATE_CURRENT: + // TODO numeric enum 3 states + propertyTag = Property.MODE; + break; + case FAN_STATE_TARGET: - propertyTag = Property.POWER; + itemType = CoreItemFactory.SWITCH; + propertyTag = Property.MODE; break; case FILTER_CHANGE_INDICATION: + itemType = CoreItemFactory.CONTACT; + break; + case FILTER_LIFE_LEVEL: + itemType = CoreItemFactory.NUMBER; + uom = "%"; + numberSuffix = "Dimensionless"; + break; + case FILTER_RESET_INDICATION: + itemType = CoreItemFactory.SWITCH; break; case FIRMWARE_REVISION: @@ -257,31 +305,45 @@ public class Characteristic { case HEATER_COOLER_STATE_CURRENT: case HEATER_COOLER_STATE_TARGET: - propertyTag = Property.POWER; + // TODO numeric enum 3 states + propertyTag = Property.MODE; category = "heating"; break; case HEATING_COOLING_CURRENT: + // TODO numeric enum 3 states + propertyTag = Property.MODE; + category = "heating"; + break; + case HEATING_COOLING_TARGET: + // TODO numeric enum 4 states propertyTag = Property.MODE; category = "heating"; break; case HORIZONTAL_TILT_CURRENT: case HORIZONTAL_TILT_TARGET: + numberSuffix = "Angle"; propertyTag = Property.TILT; break; case HUE: - numberSuffix = "Dimensionless"; + numberSuffix = "Angle"; propertyTag = Property.COLOR; itemType = CoreItemFactory.COLOR; category = "color"; break; case HUMIDIFIER_DEHUMIDIFIER_STATE_CURRENT: + // TODO numeric enum 4 states + propertyTag = Property.MODE; + category = "humidity"; + break; + case HUMIDIFIER_DEHUMIDIFIER_STATE_TARGET: - propertyTag = Property.HUMIDITY; + // TODO numeric enum 3 states + propertyTag = Property.MODE; category = "humidity"; break; @@ -290,11 +352,18 @@ public class Characteristic { break; case IMAGE_MIRROR: + itemType = CoreItemFactory.SWITCH; + category = "image"; + break; + case IMAGE_ROTATION: + // TODO numeric enum 4 states + propertyTag = Property.MODE; category = "image"; break; case INPUT_EVENT: + // TODO numeric enum 3 states isStateChannel = false; break; @@ -304,6 +373,7 @@ public class Characteristic { break; case LEAK_DETECTED: + itemType = CoreItemFactory.CONTACT; pointTag = Point.ALARM; propertyTag = Property.WATER; category = "alarm"; @@ -315,14 +385,31 @@ public class Characteristic { break; case LOCK_MANAGEMENT_AUTO_SECURE_TIMEOUT: + numberSuffix = "Duration"; + break; + case LOCK_MANAGEMENT_CONTROL_POINT: + // TODO tlv8 + itemType = null; + break; + case LOCK_MECHANISM_LAST_KNOWN_ACTION: + // TODO numeric enum 9 states + break; + case LOCK_PHYSICAL_CONTROLS: + itemType = CoreItemFactory.SWITCH; category = "lock"; break; case LOCK_MECHANISM_CURRENT_STATE: + itemType = CoreItemFactory.SWITCH; + propertyTag = Property.LOCK_STATE; + category = "lock"; + break; + case LOCK_MECHANISM_TARGET_STATE: + // TODO numeric enum 4 states propertyTag = Property.LOCK_STATE; category = "lock"; break; @@ -337,11 +424,13 @@ public class Characteristic { break; case MOTION_DETECTED: + itemType = CoreItemFactory.CONTACT; propertyTag = Property.MOTION; category = "motion"; break; case MUTE: + itemType = CoreItemFactory.SWITCH; propertyTag = Property.SOUND_VOLUME; category = "sound"; break; @@ -351,10 +440,16 @@ public class Characteristic { break; case NIGHT_VISION: + itemType = CoreItemFactory.SWITCH; + propertyTag = Property.ENABLED; + break; + case OBSTRUCTION_DETECTED: + itemType = CoreItemFactory.CONTACT; break; case OCCUPANCY_DETECTED: + itemType = CoreItemFactory.CONTACT; propertyTag = Property.PRESENCE; break; @@ -364,7 +459,7 @@ public class Characteristic { break; case OUTLET_IN_USE: - itemType = null; + itemType = FAKE_PROPERTY_CHANNEL; break; case PAIRING_FEATURES: @@ -374,48 +469,71 @@ public class Characteristic { itemType = null; break; - case POSITION_CURRENT: case POSITION_HOLD: + itemType = CoreItemFactory.SWITCH; + break; + + case POSITION_CURRENT: + propertyTag = Property.OPENING; + break; + case POSITION_STATE: + // TODO numeric enum 3 states + propertyTag = Property.OPENING; + break; + case POSITION_TARGET: + itemType = CoreItemFactory.DIMMER; propertyTag = Property.OPENING; break; case PROGRAM_MODE: + // TODO numeric enum 3 states propertyTag = Property.MODE; break; - case RELATIVE_HUMIDITY_CURRENT: case RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD: case RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD: + case RELATIVE_HUMIDITY_CURRENT: case RELATIVE_HUMIDITY_TARGET: + itemType = CoreItemFactory.NUMBER; numberSuffix = "Dimensionless"; propertyTag = Property.HUMIDITY; category = "humidity"; break; case REMAINING_DURATION: + uom = "s"; + numberSuffix = "Duration"; propertyTag = Property.DURATION; break; case ROTATION_DIRECTION: + itemType = CoreItemFactory.SWITCH; + propertyTag = Property.MODE; + break; + case ROTATION_SPEED: + itemType = CoreItemFactory.DIMMER; + propertyTag = Property.SPEED; break; case SATURATION: - numberSuffix = "Dimensionless"; + itemType = CoreItemFactory.DIMMER; propertyTag = Property.COLOR; itemType = CoreItemFactory.COLOR; category = "color"; break; case SECURITY_SYSTEM_ALARM_TYPE: + itemType = CoreItemFactory.CONTACT; pointTag = Point.ALARM; break; case SECURITY_SYSTEM_STATE_CURRENT: case SECURITY_SYSTEM_STATE_TARGET: - propertyTag = Property.ENABLED; + // TODO numeric enum 4 states + propertyTag = Property.MODE; break; case SELECTED_AUDIO_STREAM_CONFIGURATION: @@ -432,6 +550,8 @@ public class Characteristic { break; case SET_DURATION: + uom = "s"; + numberSuffix = "Duration"; propertyTag = Property.DURATION; break; @@ -440,36 +560,44 @@ public class Characteristic { break; case SLAT_STATE_CURRENT: + // TODO numeric enum 3 states propertyTag = Property.TILT; break; case SMOKE_DETECTED: + itemType = CoreItemFactory.CONTACT; pointTag = Point.ALARM; propertyTag = Property.SMOKE; category = "smoke"; break; case STATUS_ACTIVE: + itemType = CoreItemFactory.CONTACT; + propertyTag = Property.MODE; break; case STATUS_FAULT: + itemType = CoreItemFactory.CONTACT; pointTag = Point.ALARM; break; case STATUS_JAMMED: + itemType = CoreItemFactory.CONTACT; pointTag = Point.ALARM; + propertyTag = Property.OPENING; break; case STATUS_LO_BATT: + itemType = CoreItemFactory.CONTACT; pointTag = Point.ALARM; propertyTag = Property.LOW_BATTERY; category = "battery"; break; case STATUS_TAMPERED: + itemType = CoreItemFactory.CONTACT; pointTag = Point.ALARM; propertyTag = Property.TAMPERED; - category = "lock"; break; case STREAMING_STATUS: @@ -485,6 +613,7 @@ public class Characteristic { break; case SWING_MODE: + itemType = CoreItemFactory.SWITCH; propertyTag = Property.AIRFLOW; break; @@ -506,6 +635,7 @@ public class Characteristic { case TILT_CURRENT: case TILT_TARGET: + numberSuffix = "Angle"; propertyTag = Property.TILT; break; @@ -517,15 +647,18 @@ public class Characteristic { case VERTICAL_TILT_CURRENT: case VERTICAL_TILT_TARGET: + numberSuffix = "Angle"; propertyTag = Property.TILT; break; case VOLUME: + itemType = CoreItemFactory.DIMMER; propertyTag = Property.SOUND_VOLUME; category = "sound"; break; case WATER_LEVEL: + numberSuffix = "Dimensionless"; propertyTag = Property.WATER; break; @@ -544,7 +677,7 @@ public class Characteristic { * properties e.g. min, max, step, unit may be different */ ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, characteristicType.getOpenhabType()); - String label = CHANNEL_TYPE_LABEL_FMT.formatted(characteristicType.toString()); + String typeLabel = CHANNEL_TYPE_LABEL_FMT.formatted(characteristicType.toString()); ChannelType channelType; if (isStateChannel) { if (itemType == null) { @@ -558,7 +691,7 @@ public class Characteristic { } return null; } - StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, label, itemType); + StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, typeLabel, itemType); Optional.ofNullable(category).ifPresent(builder::withCategory); if (pointTag != null) { if (propertyTag != null) { @@ -569,7 +702,7 @@ public class Characteristic { } channelType = builder.build(); } else { - channelType = ChannelTypeBuilder.trigger(uid, label).build(); + channelType = ChannelTypeBuilder.trigger(uid, typeLabel).build(); } // persist the channel _type_ @@ -580,17 +713,26 @@ public class Characteristic { * through properties instead e.g. minValue, maxValue, minStep, format, unit, perms, ev */ Map properties = new HashMap<>(); + Optional.ofNullable(iid).map(v -> v.toString()).ifPresent(s -> properties.put("iid", s)); Optional.ofNullable(minValue).map(v -> v.toString()).ifPresent(s -> properties.put("minValue", s)); Optional.ofNullable(maxValue).map(v -> v.toString()).ifPresent(s -> properties.put("maxValue", s)); Optional.ofNullable(minStep).map(v -> v.toString()).ifPresent(s -> properties.put("minStep", s)); Optional.ofNullable(format).ifPresent(s -> properties.put("format", s)); - Optional.ofNullable(unit).ifPresent(s -> properties.put("unit", s)); + Optional.ofNullable(uom).ifPresent(s -> properties.put("unit", s)); Optional.ofNullable(perms).map(l -> String.join(",", l)).ifPresent(s -> properties.put("perms", s)); Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> properties.put("ev", s)); // return the definition of a specific _instance_ of the channel _type_ return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), uid).withProperties(properties) - .withLabel(characteristicType.toString()).build(); + .withLabel(getChannelInstanceLabel()).build(); + } + + /* + * Returns the 'description' field if it is present. Otherwise returns the Characteristic type in Title Case. + */ + private String getChannelInstanceLabel() { + return description != null && !description.isBlank() ? description + : Objects.requireNonNull(getCharacteristicType()).toString(); } public @Nullable CharacteristicType getCharacteristicType() { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index fa262a223a963..93d86b95e239d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -19,6 +19,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.thing.type.ChannelDefinition; @@ -27,6 +28,8 @@ import org.openhab.core.thing.type.ChannelGroupTypeBuilder; import org.openhab.core.thing.type.ChannelGroupTypeUID; +import com.google.gson.JsonElement; + /** * HomeKit service DTO. * Used to deserialize individual services from the /accessories endpoint of a HomeKit bridge. @@ -38,6 +41,7 @@ public class Service { public @NonNullByDefault({}) String type; // e.g. '96' => 'public.hap.service.battery' public @NonNullByDefault({}) Integer iid; // e.g. 10 + public @NonNullByDefault({}) String name; public @NonNullByDefault({}) List characteristics; /** @@ -63,19 +67,42 @@ public class Service { } ChannelGroupTypeUID groupTypeUID = new ChannelGroupTypeUID(BINDING_ID, serviceType.getOpenhabType()); - String label = GROUP_TYPE_LABEL_FMT.formatted(serviceType.toString()); - ChannelGroupType groupType = ChannelGroupTypeBuilder.instance(groupTypeUID, label) // + String typeLabel = GROUP_TYPE_LABEL_FMT.formatted(serviceType.toString()); + ChannelGroupType groupType = ChannelGroupTypeBuilder.instance(groupTypeUID, typeLabel) // .withChannelDefinitions(channelDefinitions) // .build(); // persist the group _type_, and return the definition of a specific _instance_ of that type typeProvider.putChannelGroupType(groupType); - return new ChannelGroupDefinition(serviceType.getOpenhabType(), groupTypeUID, serviceType.toString(), null); + return new ChannelGroupDefinition(serviceType.getOpenhabType(), groupTypeUID, getGroupInstanceLabel(), null); + } + + /* + * Returns the 'name' field if it is present. Otherwise searches for a characterstic of type + * CharacteristicType.NAME and if present returns that value. Otherwise returns the service + * type in Title Case.. + */ + public String getGroupInstanceLabel() { + if (name != null && !name.isBlank()) { + return name; + } + if (characteristics instanceof List characteristics) { + for (Characteristic c : characteristics) { + if (c.getCharacteristicType() == CharacteristicType.NAME) { + if (c.value instanceof JsonElement v && v.isJsonPrimitive() && v.getAsJsonPrimitive().isString()) { + return v.getAsJsonPrimitive().getAsString(); + } + } + } + } + return Objects.requireNonNull(getServiceType()).toString(); } public @Nullable ServiceType getServiceType() { try { - return ServiceType.from(Integer.parseInt(type, 16)); + // convert "00000113-0000-1000-8000-0026BB765291" to "00000113" + String firstPart = type.split("-")[0]; + return ServiceType.from(Integer.parseUnsignedInt(firstPart, 16)); } catch (IllegalArgumentException e) { return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java index f1341c51d771c..4243cfef20cdf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java @@ -22,8 +22,7 @@ */ @NonNullByDefault public enum AccessoryType { - // TODO manually check the Homekit specification pdf to ensure all types are covered - OTHER(1, "Other"), + OTHER(1, "Other Accessory"), BRIDGE(2, "Bridge"), FAN(3, "Fan"), GARAGE_DOOR(4, "Garage Door"), @@ -38,7 +37,7 @@ public enum AccessoryType { WINDOW(13, "Window"), WINDOW_COVERING(14, "Window Covering"), PROGRAMMABLE_SWITCH(15, "Programmable Switch"), - RESERVED(16, "Reserved"), + RANGE_EXTENDER(16, "Range Extender"), IP_CAMERA(17, "IP Camera"), VIDEO_DOORBELL(18, "Video Doorbell"), AIR_PURIFIER(19, "Air Purifier"), @@ -47,13 +46,18 @@ public enum AccessoryType { HUMIDIFIER(22, "Humidifier"), DEHUMIDIFIER(23, "Dehumidifier"), APPLE_TV(24, "Apple TV"), + SMART_SPEAKER(25, "Smart Speaker"), SPEAKER(26, "Speaker"), AIRPORT(27, "AirPort"), SPRINKLER(28, "Sprinkler"), FAUCET(29, "Faucet"), SHOWER_HEAD(30, "Shower"), TELEVISION(31, "Television"), - REMOTE(32, "Remote"); + REMOTE(32, "Remote"), + ROUTER(33, "Router"), + AUDIO_RECEIVER(34, "Audio Receiver"), + TV_SET_TOP_BOX(35, "TV Set Top Box"), + TV_STREAMING_STICK(36, "TV Streaming Stick"); private final int id; private final String label; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index b71f911592564..9c0ed67a9d859 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -24,7 +24,6 @@ */ @NonNullByDefault public enum CharacteristicType { - // TODO manually check the Homekit specification pdf to ensure all types are covered //@formatter:off ACCESSORY_PROPERTIES(0xA6, "public.hap.characteristic.accessory-properties"), ACTIVE(0xB0, "public.hap.characteristic.active"), @@ -177,7 +176,7 @@ public static CharacteristicType from(int id) throws IllegalArgumentException { } public String getOpenhabType() { - return type.replace("public.hap.characteristic.", "").replace(".", "-"); // convert to OH channel type format + return type.substring(26).replace(".", "-"); // convert to OH channel type format } public String getType() { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index 27646e1f7a381..b47913f1a4b74 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -24,7 +24,6 @@ */ @NonNullByDefault public enum ServiceType { - // TODO manually check the Homekit specification pdf to ensure all types are covered ACCESSORY_INFORMATION(0x3E, "public.hap.service.accessory-information"), AIR_PURIFIER(0xBB, "public.hap.service.air-purifier"), AUDIO_STREAM_MANAGEMENT(0x127, "public.hap.service.audio-stream-management"), @@ -33,12 +32,14 @@ public enum ServiceType { DATA_STREAM_TRANSPORT_MANAGEMENT(0x129, "public.hap.service.data-stream-transport-management"), DOOR(0x81, "public.hap.service.door"), DOORBELL(0x121, "public.hap.service.doorbell"), + FAN(0x40, "public.hap.service.fan"), FANV2(0xB7, "public.hap.service.fanv2"), FAUCET(0xD7, "public.hap.service.faucet"), FILTER_MAINTENANCE(0xBA, "public.hap.service.filter-maintenance"), GARAGE_DOOR_OPENER(0x41, "public.hap.service.garage-door-opener"), HEATER_COOLER(0xBC, "public.hap.service.heater-cooler"), HUMIDIFIER_DEHUMIDIFIER(0xBD, "public.hap.service.humidifier-dehumidifier"), + INPUT_SOURCE(0xD9, "public.hap.service.input-source"), IRRIGATION_SYSTEM(0xCF, "public.hap.service.irrigation-system"), LIGHT_BULB(0x43, "public.hap.service.lightbulb"), LOCK_MANAGEMENT(0x44, "public.hap.service.lock-management"), @@ -61,11 +62,13 @@ public enum ServiceType { SENSOR_TEMPERATURE(0x8A, "public.hap.service.sensor.temperature"), SERVICE_LABEL(0xCC, "public.hap.service.service-label"), SIRI(0x133, "public.hap.service.siri"), + SMART_SPEAKER(0x228, "public.hap.service.smart-speaker"), SPEAKER(0x113, "public.hap.service.speaker"), STATELESS_PROGRAMMABLE_SWITCH(0x89, "public.hap.service.stateless-programmable-switch"), SWITCH(0x49, "public.hap.service.switch"), TARGET_CONTROL(0x125, "public.hap.service.target-control"), TARGET_CONTROL_MANAGEMENT(0x122, "public.hap.service.target-control-management"), + TELEVISION(0xD8, "public.hap.service.television"), THERMOSTAT(0x4A, "public.hap.service.thermostat"), VALVE(0xD0, "public.hap.service.valve"), VERTICAL_SLAT(0xB9, "public.hap.service.vertical-slat"), @@ -90,7 +93,7 @@ public static ServiceType from(int type) throws IllegalArgumentException { } public String getOpenhabType() { - return type.replace("public.hap.service.", "").replace(".", "-"); // convert to OH channel type format + return type.substring(19).replace(".", "-"); // convert to OH channel type format } public String getType() { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index 1294cda35b23e..93572e92e3ef4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -46,8 +46,7 @@ @Component(service = ThingHandlerFactory.class) public class HomekitHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_DEVICE, - THING_TYPE_CHILD); + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_ACCESSORY); private final HomekitTypeProvider typeProvider; @@ -75,9 +74,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { return new HomekitBridgeHandler((Bridge) thing, registerDiscoveryService()); - } else if (THING_TYPE_DEVICE.equals(thingTypeUID)) { - return new HomekitDeviceHandler(thing, typeProvider); - } else if (THING_TYPE_CHILD.equals(thingTypeUID)) { + } else if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { return new HomekitDeviceHandler(thing, typeProvider); } return null; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 8ed0138fe7a88..7420a45a7b383 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -35,12 +35,11 @@ import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; import org.openhab.binding.homekit.internal.transport.IpTransport; import org.openhab.core.thing.Bridge; -import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.BaseThingHandler; -import org.openhab.core.types.Command; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -100,11 +99,10 @@ public void dispose() { */ private void fetchAccessories() { try { - // byte[] json = ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP); - // Accessories container = GSON.fromJson(new String(json, StandardCharsets.UTF_8), Accessories.class); - // TODO REMOVE TEST CODE - Accessories container = GSON.fromJson(TODO_REMOVE_TEST_JSON, Accessories.class); - // Accessories result = GSON.fromJson(TODO_REMOVE_TEST_JSON, Accessories.class); + String json = TODO_REMOVE_TEST_JSON; + // String json = new String(ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP), + // StandardCharsets.UTF_8); + Accessories container = GSON.fromJson(json, Accessories.class); if (container != null && container.accessories instanceof List accessoryList) { accessories.clear(); accessories.putAll(accessoryList.stream().filter(a -> Objects.nonNull(a.aid)) @@ -125,30 +123,19 @@ private void fetchAccessories() { protected abstract void accessoriesLoaded(); /** - * Extracts the accessory ID from the thing's UID property. - * The UID is expected to end with "-". + * Extracts the accessory ID from the 'Accessory UID' property. * * @return the accessory ID, or null if it cannot be determined */ protected @Nullable Integer getAccessoryId() { - String uidProperty = thing.getProperties().get(PROPERTY_UID); - if (uidProperty == null) { - return null; - } - int accessoryIdIndex = uidProperty.lastIndexOf("-"); - if (accessoryIdIndex < 0) { - return null; - } - try { - return Integer.parseInt(uidProperty.substring(accessoryIdIndex + 1)); - } catch (NumberFormatException e) { - return null; + String accessoryUid = thing.getProperties().get(PROPERTY_ACCESSORY_UID); + if (accessoryUid != null) { + try { + return Integer.parseInt(new ThingUID(accessoryUid).getId()); + } catch (NumberFormatException e) { + } } - } - - @Override - public void handleCommand(ChannelUID channelUID, Command command) { - // this is an abstract thing with no channels, so do nothing + return null; } @Override diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 4d16614353da6..da0348ced6ea9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -15,10 +15,12 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.binding.BridgeHandler; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.builder.BridgeBuilder; +import org.openhab.core.types.Command; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -84,4 +86,9 @@ protected void accessoriesLoaded() { logger.debug("Bridge accessories loaded {}", accessories.size()); discoveryService.devicesDiscovered(thing, accessories.values()); // discover child accessories } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // do nothing + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index b40aafb15dac1..050a8618d87ac 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -40,6 +40,7 @@ import org.openhab.core.library.types.PercentType; import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.StringType; +import org.openhab.core.semantics.SemanticTag; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -125,7 +126,7 @@ private Object convertCommandToObject(Command command, Channel channel) { if (object instanceof QuantityType quantity) { if (properties.get("unit") instanceof String unit) { try { - QuantityType temp = quantity.toUnit(normalizedUnitString(unit)); + QuantityType temp = quantity.toUnit(unit); object = temp != null ? temp : quantity; } catch (MeasurementParseException e) { logger.warn("Unexpected unit {} for channel {}", unit, channel.getUID()); @@ -288,10 +289,22 @@ private void createChannels() { } }); - if (!channels.isEmpty() || !properties.isEmpty()) { - logger.debug("Updating thing with {} channels, {} properties", channels.size(), properties.size()); + String oldLabel = thing.getLabel(); + String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; + List newChannels = !channels.isEmpty() ? channels : null; + Map newProperties = !properties.isEmpty() ? properties : null; + SemanticTag newTag = accessory.getSemanticEquipmentTag(); + + if (newLabel != null || newChannels != null || newProperties != null || newTag != null) { + logger.debug("Updating thing {} channels, {} properties, label {}, tag {}", channels.size(), + properties.size(), newLabel, newTag); + ThingBuilder builder = editThing().withProperties(properties).withChannels(channels); - Optional.ofNullable(accessory.getSemanticEquipmentTag()).ifPresent(builder::withSemanticEquipmentTag); + Optional.ofNullable(newLabel).ifPresent(builder::withLabel); + Optional.ofNullable(newChannels).ifPresent(builder::withChannels); + Optional.ofNullable(newProperties).ifPresent(builder::withProperties); + Optional.ofNullable(newTag).ifPresent(builder::withSemanticEquipmentTag); + updateThing(builder.build()); channelsAndPropertiesLoaded(); } @@ -327,19 +340,6 @@ public void initialize() { super.initialize(); } - /** - * Convert a HomeKit formatted unit string to OH format. - */ - private String normalizedUnitString(String unit) { - if (unit.isEmpty()) { - return unit; - } - if ("percentage".equals(unit)) { // special case HomeKit "percentage" => "Percent" - return "Percent"; - } - return unit.substring(0, 1).toUpperCase() + unit.substring(1).toLowerCase(); // e.g. celsius => Celsius - } - /** * Polls the accessory for its current state and updates the corresponding channels. * This method is called periodically by a scheduled executor. diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 39dc77ef4273d..04776eb96451b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -1,16 +1,14 @@ # add-on addon.homekit.name = HomeKit Binding -addon.homekit.description = This is the binding for HomeKit. +addon.homekit.description = This is the binding for a HomeKit client. # thing types thing-type.homekit.bridge.label = HomeKit Bridge thing-type.homekit.bridge.description = HomeKit Accessory Bridge -thing-type.homekit.child.label = HomeKit Device -thing-type.homekit.child.description = HomeKit Accessory Device -thing-type.homekit.device.label = HomeKit Device -thing-type.homekit.device.description = HomeKit Accessory Device +thing-type.homekit.accessory.label = HomeKit Accessory +thing-type.homekit.accessory.description = HomeKit Accessory Device # thing types config @@ -20,11 +18,9 @@ thing-type.config.homekit.bridge.pairingCode.label = Pairing Code thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit accessory. thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the accessory is polled in sec. -thing-type.config.homekit.child.refreshInterval.label = Refresh Interval -thing-type.config.homekit.child.refreshInterval.description = Interval at which the accessory is polled in sec. -thing-type.config.homekit.device.ipV4Address.label = IP Address -thing-type.config.homekit.device.ipV4Address.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.device.pairingCode.label = Pairing Code -thing-type.config.homekit.device.pairingCode.description = Code used for pairing with the HomeKit accessory. -thing-type.config.homekit.device.refreshInterval.label = Refresh Interval -thing-type.config.homekit.device.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.accessory.ipV4Address.label = IP Address +thing-type.config.homekit.accessory.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.accessory.pairingCode.label = Pairing Code +thing-type.config.homekit.accessory.pairingCode.description = Code used for pairing with the HomeKit accessory. +thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval +thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 0e759fefaafed..3c9655c9add06 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -4,11 +4,11 @@ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0" xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd"> - + HomeKit Accessory Device - + network-address IP v4 address of the HomeKit accessory. @@ -27,27 +27,11 @@ - - - - - - HomeKit Accessory Device - - - - Interval at which the accessory is polled in sec. - 60 - true - - - - HomeKit Accessory Bridge - + network-address IP v4 address of the HomeKit accessory. diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java index ca3846cbc800e..e4ba925c56176 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -431,7 +431,7 @@ void testChannelDefinitions() { assertNotNull(channelDefinition); assertEquals("brightness", channelDefinition.getChannelTypeUID().getId()); assertEquals("Brightness", channelDefinition.getLabel()); - assertEquals("percentage", channelDefinition.getProperties().get("unit")); + assertEquals("%", channelDefinition.getProperties().get("unit")); assertEquals("int", channelDefinition.getProperties().get("format")); assertEquals("20.0", channelDefinition.getProperties().get("minValue")); assertEquals("100.0", channelDefinition.getProperties().get("maxValue")); From 5b695432c541ed6466a6a4407b9d37166f338003 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 24 Sep 2025 15:01:56 +0100 Subject: [PATCH 032/177] fix jmdns issue; refactoring Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 5 +- .../HomekitChildDiscoveryService.java | 4 +- .../HomekitMdnsDiscoveryParticipant.java | 106 ++++++++++++------ .../homekit/internal/dto/Accessory.java | 8 +- ...essoryType.java => AccessoryCategory.java} | 8 +- .../handler/HomekitDeviceHandler.java | 4 + 6 files changed, 89 insertions(+), 46 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/{AccessoryType.java => AccessoryCategory.java} (91%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index e8186c8ec3c4b..a24a0de86ef56 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -36,8 +36,7 @@ public class HomekitBindingConstants { FAKE_PROPERTY_CHANNEL); // labels - public static final String THING_LABEL_FMT = "Model %s on %s"; - public static final String ACCESSORY_LABEL_FMT = "Accessory %d on %s"; + public static final String THING_LABEL_FMT = "%s on %s"; public static final String GROUP_TYPE_LABEL_FMT = "Channel group type: %s"; public static final String CHANNEL_TYPE_LABEL_FMT = "Channel type: %s"; @@ -49,7 +48,7 @@ public class HomekitBindingConstants { // properties public static final String PROPERTY_ACCESSORY_UID = "accessoryUID"; public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; - public static final String PROPERTY_DEVICE_CATEGORY = "deviceCategory"; + public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; public static final String PROPERTY_CONTROLLER_PRIVATE_KEY = "controllerPrivateKey"; public static final String PROPERTY_ACCESSORY_PUBLIC_KEY = "accessoryPublicKey"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 62dfeb2e615bf..2d863369c0757 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -49,11 +49,11 @@ protected void startScan() { public void devicesDiscovered(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { if (accessory.aid != null && accessory.services != null) { - // accessory ID is unique per bridge + // accessory ID should be unique per bridge ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), accessory.aid.toString()); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // - .withLabel(ACCESSORY_LABEL_FMT.formatted(accessory.aid, bridge.getLabel())) // + .withLabel(THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), bridge.getLabel())) // .withProperty(CONFIG_HOST, "n/a") // .withProperty(CONFIG_PAIRING_CODE, "n/a") // .withProperty(PROPERTY_ACCESSORY_UID, uid.toString()) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 0322c08996ae4..d8d7cdcab3b37 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -14,13 +14,16 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; import java.util.Set; import javax.jmdns.ServiceInfo; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.enums.AccessoryType; +import org.openhab.binding.homekit.internal.enums.AccessoryCategory; import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; @@ -68,50 +71,87 @@ public String getServiceType() { public @Nullable DiscoveryResult createResult(ServiceInfo service) { ThingUID uid = getThingUID(service); if (uid != null) { - String host = service.getHostAddresses()[0]; - String macAddress = service.getPropertyString("id"); // HomeKit device ID is the MAC address - String modelName = service.getPropertyString("md"); // HomeKit device model name - String deviceCategory = service.getPropertyString("ci"); // HomeKit device category - String protocolVersion = service.getPropertyString("pv"); // HomeKit protocol version - - DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); - builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), host)) // - .withProperty(CONFIG_HOST, host) // - .withProperty(Thing.PROPERTY_MODEL_ID, modelName) // - .withProperty(Thing.PROPERTY_MAC_ADDRESS, macAddress) // - .withProperty(PROPERTY_PROTOCOL_VERSION, protocolVersion) // - .withProperty(PROPERTY_DEVICE_CATEGORY, deviceCategory) // - .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); - - if (!isBridge(service)) { - // '1' means we shall use the first (and only) accessory - ThingUID accessoryUid = new ThingUID(THING_TYPE_ACCESSORY, "1"); - builder.withProperty(PROPERTY_ACCESSORY_UID, accessoryUid.toString()); + Map properties = getProperties(service); + + String mac = properties.get("id"); // MAC address + String host = service.getHostAddresses()[0]; // ipV4 address + AccessoryCategory cat; + try { + String ci = properties.getOrDefault("ci", ""); // accessory category + cat = AccessoryCategory.from(Integer.parseInt(ci)); + } catch (IllegalArgumentException e) { + cat = null; } - return builder.build(); + if (host != null && mac != null && cat != null) { + DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); + builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), host)) // + .withProperty(CONFIG_HOST, host) // + .withProperty(Thing.PROPERTY_MAC_ADDRESS, mac) // + .withProperty(PROPERTY_ACCESSORY_CATEGORY, cat.toString()) // + .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); + + if (AccessoryCategory.BRIDGE == cat) { + // setting to '1' means force the first (i.e. only) accessory + ThingUID accessoryUid = new ThingUID(THING_TYPE_ACCESSORY, "1"); + builder.withProperty(PROPERTY_ACCESSORY_UID, accessoryUid.toString()); + } + String model = properties.get("md"); + if (model != null) { + builder.withProperty(Thing.PROPERTY_MODEL_ID, model); + } + String serial = properties.get("s#"); + if (serial != null) { + builder.withProperty(Thing.PROPERTY_SERIAL_NUMBER, serial); + } + String protocolVersion = properties.get("pv"); + if (protocolVersion != null) { + builder.withProperty(PROPERTY_PROTOCOL_VERSION, protocolVersion); + } + + return builder.build(); + } } - logger.debug("Ignoring discovered HAP service {} with bad properties", service.getName()); return null; } @Override public @Nullable ThingUID getThingUID(ServiceInfo service) { - String macAddress = service.getPropertyString("id"); - if (macAddress != null) { - String id = macAddress.replace(":", "").replace("-", "").toLowerCase(); // e.g. "a1b2c3d4e5f6" - return isBridge(service) ? new ThingUID(THING_TYPE_BRIDGE, id) : new ThingUID(THING_TYPE_ACCESSORY, id); + Map properties = getProperties(service); + + String mac = properties.get("id"); // MAC address + AccessoryCategory cat; + try { + String ci = properties.getOrDefault("ci", ""); + cat = AccessoryCategory.from(Integer.parseInt(ci)); + } catch (IllegalArgumentException e) { + cat = null; } + + if (mac != null && cat != null) { + return new ThingUID(AccessoryCategory.BRIDGE == cat ? THING_TYPE_BRIDGE : THING_TYPE_ACCESSORY, + mac.replace(":", "").toLowerCase()); // thing id example "a1b2c3d4e5f6" + } + return null; } - private boolean isBridge(ServiceInfo service) { - String ci = service.getPropertyString("ci"); // accessory type i.e. 'category' - try { - return AccessoryType.BRIDGE == AccessoryType.from(Integer.parseInt(ci)); - } catch (IllegalArgumentException e) { - logger.warn("Failed to parse accessory category '{}' for HAP service '{}'", ci, service.getName()); + /** + * Work around for a known JmDNS bug when parsing TXT strings with a '0' byte terminator. + */ + private Map getProperties(ServiceInfo service) { + Map map = new HashMap<>(); + byte[] bytes = service.getTextBytes(); + int i = 0; + while (i < bytes.length) { + int len = bytes[i++] & 0xFF; + if (len == 0) { + break; + } + String[] parts = new String(bytes, i, len, StandardCharsets.UTF_8).split("="); + map.put(parts[0], parts.length > 1 ? parts[1] : ""); + i += len; } - return false; + return map; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 2a97654eddadf..69ab1009d4861 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -17,7 +17,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.enums.AccessoryType; +import org.openhab.binding.homekit.internal.enums.AccessoryCategory; import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; @@ -61,12 +61,12 @@ public List buildAndRegisterChannelGroupDefinitions(Home .filter(Objects::nonNull).toList(); } - public AccessoryType getAccessoryType() { + public AccessoryCategory getAccessoryType() { Integer category = this.category; if (category == null) { - return AccessoryType.OTHER; + return AccessoryCategory.OTHER; } - return AccessoryType.from(category); + return AccessoryCategory.from(category); } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java similarity index 91% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java index 4243cfef20cdf..d5a61086e443c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java @@ -21,7 +21,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public enum AccessoryType { +public enum AccessoryCategory { OTHER(1, "Other Accessory"), BRIDGE(2, "Bridge"), FAN(3, "Fan"), @@ -62,13 +62,13 @@ public enum AccessoryType { private final int id; private final String label; - AccessoryType(int category, String label) { + AccessoryCategory(int category, String label) { this.id = category; this.label = label; } - public static AccessoryType from(int id) throws IllegalArgumentException { - for (AccessoryType value : values()) { + public static AccessoryCategory from(int id) throws IllegalArgumentException { + for (AccessoryCategory value : values()) { if (value.id == id) { return value; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 050a8618d87ac..f958fda3b4c0b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -256,6 +256,10 @@ private void createChannels() { return; } Accessory accessory = accessories.get(accessoryId); + if (accessory == null && !isChildAccessory && accessories.size() > 0) { + // fallback to the first accessory if the specific one is not found (should not normally happen) + accessory = accessories.values().iterator().next(); + } if (accessory == null) { return; } From f837ce76d52848c3cbe5622ceca9684e96ccf8d9 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 26 Sep 2025 10:48:35 +0100 Subject: [PATCH 033/177] work in progress Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/pom.xml | 2 +- .../internal/crypto/CryptoConstants.java | 12 +- .../homekit/internal/crypto/CryptoUtils.java | 159 +++++---- .../homekit/internal/crypto/SRPclient.java | 87 +++-- .../HomekitMdnsDiscoveryParticipant.java | 8 +- .../handler/HomekitBaseServerHandler.java | 321 ++---------------- .../handler/HomekitDeviceHandler.java | 2 +- .../hap_services/PairRemoveClient.java | 18 +- .../hap_services/PairSetupClient.java | 22 +- .../hap_services/PairVerifyClient.java | 11 +- .../internal/transport/IpTransport.java | 16 +- .../src/main/resources/OH-INF/addon/addon.xml | 2 +- .../resources/OH-INF/thing/thing-types.xml | 1 + .../{SRPtestServer.java => SRPserver.java} | 81 +++-- .../internal/TestAppleTestVectors.java | 246 ++++++++++++++ .../homekit/internal/TestPairSetup.java | 21 +- .../homekit/internal/TestPairVerify.java | 4 +- 17 files changed, 548 insertions(+), 465 deletions(-) rename bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/{SRPtestServer.java => SRPserver.java} (61%) create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java diff --git a/bundles/org.openhab.binding.homekit/pom.xml b/bundles/org.openhab.binding.homekit/pom.xml index 8922ce99696ad..afd612ddae0e4 100644 --- a/bundles/org.openhab.binding.homekit/pom.xml +++ b/bundles/org.openhab.binding.homekit/pom.xml @@ -12,7 +12,7 @@ org.openhab.binding.homekit - openHAB Add-ons :: Bundles :: HomeKit Binding + openHAB Add-ons :: Bundles :: HomeKit Client Binding diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java index b14d47f32c3e4..579a2756ac21f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java @@ -50,8 +50,8 @@ public class CryptoConstants { public static final byte[] PAIR_SETUP_ENCRYPT_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); public static final byte[] PAIR_SETUP_ENCRYPT_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); - public static final byte[] PS_M5_NONCE = CryptoUtils.generateNonce("PS-Msg05"); - public static final byte[] PS_M6_NONCE = CryptoUtils.generateNonce("PS-Msg06"); + public static final byte[] PS_M5_NONCE = "PS-Msg05".getBytes(StandardCharsets.UTF_8); + public static final byte[] PS_M6_NONCE = "PS-Msg06".getBytes(StandardCharsets.UTF_8); public static final byte[] PAIR_CONTROLLER_SIGN_SALT = "Pair-Setup-Controller-Sign-Salt".getBytes(StandardCharsets.UTF_8); public static final byte[] PAIR_CONTROLLER_SIGN_INFO = "Pair-Setup-Controller-Sign-Info".getBytes(StandardCharsets.UTF_8); @@ -66,14 +66,14 @@ public class CryptoConstants { public static final byte[] PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info".getBytes(StandardCharsets.UTF_8); public static final byte[] PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); - public static final byte[] PV_M2_NONCE = CryptoUtils.generateNonce("PV-Msg02"); - public static final byte[] PV_M3_NONCE = CryptoUtils.generateNonce("PV-Msg03"); + public static final byte[] PV_M2_NONCE = "PV-Msg02".getBytes(StandardCharsets.UTF_8); + public static final byte[] PV_M3_NONCE = "PV-Msg03".getBytes(StandardCharsets.UTF_8); // @formatter:on private static BigInteger computeK() { try { - byte[] paddedN = toUnsigned(N, N); - byte[] paddedG = toUnsigned(g, N); + byte[] paddedN = toUnsigned(N, 384); + byte[] paddedG = toUnsigned(g, 384); byte[] hash = sha512(CryptoUtils.concat(paddedN, paddedG)); return new BigInteger(1, hash); } catch (Exception e) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index 6387c53ea48ed..09b5a25e46171 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -13,7 +13,8 @@ package org.openhab.binding.homekit.internal.crypto; import java.math.BigInteger; -import java.nio.charset.StandardCharsets; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; @@ -34,6 +35,8 @@ import org.bouncycastle.crypto.signers.Ed25519Signer; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Utility class for cryptographic operations used in HomeKit communication. @@ -42,6 +45,7 @@ */ @NonNullByDefault public class CryptoUtils { + private static final Logger logger = LoggerFactory.getLogger(CryptoUtils.class); public static byte[] concat(byte[]... parts) { int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); @@ -56,24 +60,29 @@ public static byte[] concat(byte[]... parts) { // Decrypt with ChaCha20-Poly1305 public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText) throws InvalidCipherTextException { - int length = cipherText.length; + byte[] aad = new byte[0]; // AAD = none + byte[] nonce96 = new byte[12]; // 96 bit nonce + System.arraycopy(nonce, 0, nonce96, 4, 8); ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); - AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce96, aad); cipher.init(false, params); - byte[] plainText = new byte[cipher.getOutputSize(length)]; - length = cipher.processBytes(cipherText, 0, length, plainText, 0); - cipher.doFinal(plainText, length); + byte[] plainText = new byte[cipher.getOutputSize(cipherText.length)]; + int offset = cipher.processBytes(cipherText, 0, cipherText.length, plainText, 0); + cipher.doFinal(plainText, offset); return plainText; } // Encrypt with ChaCha20-Poly1305 public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plainText) throws InvalidCipherTextException { + byte[] aad = new byte[0]; // AAD = none + byte[] nonce96 = new byte[12]; // 96 bit nonce + System.arraycopy(nonce, 0, nonce96, 4, 8); ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); - AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce, null); + AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce96, aad); cipher.init(true, params); byte[] cipherText = new byte[cipher.getOutputSize(plainText.length)]; - int length = cipher.processBytes(plainText, 0, plainText.length, cipherText, 0); - cipher.doFinal(cipherText, length); + int offset = cipher.processBytes(plainText, 0, plainText.length, cipherText, 0); + cipher.doFinal(cipherText, offset); return cipherText; } @@ -87,33 +96,16 @@ public static byte[] generateHkdfKey(byte[] inputKey, byte[] salt, byte[] info) } /** - * Generates a 12-byte nonce using the given counter. - * The first 4 bytes are zero, and the last 8 bytes are the counter in big-endian format. + * Generates an 64 bit nonce using the given counter. * * @param counter The counter value. * @return The generated nonce. */ public static byte[] generateNonce(int counter) { - byte[] nonce = new byte[12]; - nonce[4] = (byte) ((counter >> 24) & 0xFF); - nonce[5] = (byte) ((counter >> 16) & 0xFF); - nonce[6] = (byte) ((counter >> 8) & 0xFF); - nonce[7] = (byte) (counter & 0xFF); - return nonce; - } - - /** - * Generates a 12-byte nonce using the given label. - * The first 4 bytes are zero, and the last 8 bytes come from the label. - * - * @param counter The counter value. - * @return The generated nonce. - */ - public static byte[] generateNonce(String label) { - byte[] nonce = new byte[12]; - byte[] labelBytes = label.getBytes(StandardCharsets.UTF_8); - System.arraycopy(labelBytes, 0, nonce, 4, Math.min(labelBytes.length, 8)); - return nonce; + ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); + buf.putInt(0); // high 4 bytes = zero + buf.putInt(counter); // low 4 bytes = counter + return buf.array(); // total = 8 bytes } // Compute shared secret using ECDH @@ -143,17 +135,76 @@ public static byte[] signMessage(Ed25519PrivateKeyParameters privateKey, byte[] return signer.generateSignature(); } - public static byte[] toUnsigned(BigInteger v, BigInteger N) { - int len = (N.bitLength() + 7) / 8; - byte[] raw = v.toByteArray(); - if (raw.length == len) { + public static BigInteger toBigInteger(String hexBlock) { + String plainHex = hexBlock.replaceAll("\\s+", ""); + if (plainHex.length() % 2 != 0) { + throw new IllegalArgumentException("Hex string must have even length"); + } + return new BigInteger(plainHex, 16); + } + + public static byte[] toBytes(String hexBlock) { + String plainHex = hexBlock.replaceAll("\\s+", ""); + if (plainHex.length() % 2 != 0) { + throw new IllegalArgumentException("Hex string must have even length"); + } + int length = plainHex.length(); + byte[] result = new byte[length / 2]; + for (int i = 0; i < length; i += 2) { + int hi = Character.digit(plainHex.charAt(i), 16); + int lo = Character.digit(plainHex.charAt(i + 1), 16); + if (hi == -1 || lo == -1) { + throw new IllegalArgumentException( + "Invalid hex character: " + plainHex.charAt(i) + plainHex.charAt(i + 1)); + } + result[i / 2] = (byte) ((hi << 4) + lo); + } + return result; + } + + public static String toHex(byte @Nullable [] bytes) { + if (bytes == null) { + return "null"; + } + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02X", b)).append(' '); + } + return sb.toString().trim(); // remove trailing space + } + + /** + * Converts a BigInteger to an unsigned byte array of the specified length. + * If the byte array representation of the BigInteger is shorter than the specified length, + * it is left-padded with zeros. If it is longer, an exception is thrown. + * + * @param bigInteger the BigInteger to convert. + * @param length the desired length of the resulting byte array. + * @return a byte array of the given length representing the unsigned BigInteger. + * @throws IllegalArgumentException if the BigInteger cannot fit in the specified length. + */ + public static byte[] toUnsigned(BigInteger bigInteger, int length) { + byte[] raw = bigInteger.toByteArray(); + if (raw.length == length && raw[0] != 0) { return raw; } - if (raw.length == len + 1 && raw[0] == 0) { - return Arrays.copyOfRange(raw, 1, raw.length); + + byte[] unsigned; + if (raw[0] == 0) { + // strip leading sign byte + unsigned = new byte[raw.length - 1]; + System.arraycopy(raw, 1, unsigned, 0, unsigned.length); + } else { + unsigned = raw; + } + + if (unsigned.length == length) { + return unsigned; } - byte[] padded = new byte[len]; - System.arraycopy(raw, 0, padded, len - raw.length, raw.length); + + // pad to fixed length + byte[] padded = new byte[length]; + System.arraycopy(unsigned, 0, padded, length - unsigned.length, unsigned.length); return padded; } @@ -174,34 +225,4 @@ public static byte[] xor(byte[] a, byte[] b) { } return out; } - - public static String asHex(byte @Nullable [] bytes) { - if (bytes == null) { - return "null"; - } - StringBuilder sb = new StringBuilder(); - for (byte b : bytes) { - sb.append(String.format("%02X", b)).append(' '); - } - return sb.toString().trim(); // remove trailing space - } - - public static byte[] fromHex(String hexBlock) { - String normalized = hexBlock.replaceAll("\\s+", ""); - if (normalized.length() % 2 != 0) { - throw new IllegalArgumentException("Hex string must have even length"); - } - int len = normalized.length(); - byte[] result = new byte[len / 2]; - for (int i = 0; i < len; i += 2) { - int high = Character.digit(normalized.charAt(i), 16); - int low = Character.digit(normalized.charAt(i + 1), 16); - if (high == -1 || low == -1) { - throw new IllegalArgumentException( - "Invalid hex character: " + normalized.charAt(i) + normalized.charAt(i + 1)); - } - result[i / 2] = (byte) ((high << 4) + low); - } - return result; - } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index d5575c8e7c8e2..bbd46fdf662c9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -36,16 +36,18 @@ @NonNullByDefault public class SRPclient { + public final BigInteger A; // client SRP public key + public final BigInteger a; // client SRP private ephemeral + public final BigInteger B; // server SRP public key + public final byte[] K; // session key + public final byte[] M1; // client proof + public final BigInteger S; // shared secret + public final BigInteger u; // scrambling parameter + public final BigInteger x; // SRP private key derived from password + private final String I; // username private final byte[] s; // server salt - private final BigInteger x; // SRP private key derived from password - private final BigInteger a; // client SRP private ephemeral - private final BigInteger A; // client SRP public key - private final BigInteger B; // server SRP public key - private final BigInteger u; // scrambling parameter - private final BigInteger S; // shared secret - private final byte[] K; // session key - private final byte[] M1; // client proof + private final byte[] M2; // expected server proof private @Nullable Ed25519PublicKeyParameters serverLongTermPublicKey = null; @@ -55,24 +57,34 @@ public class SRPclient { * @param password the password (P) used for authentication. * @param serverSalt the salt (s) provided by the server. * @param serverPublicKey the server's public SRP key (B). + * @param user the username (I). If null, "Pair-Setup" is used. + * @param clientPrivateKey the client's private SRP key (a). If null, a random key is generated. + * * @throws Exception if an error occurs during initialization. */ - public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey) throws Exception { - I = PAIR_SETUP; + public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey, @Nullable String user, + byte @Nullable [] clientPrivateKey) throws Exception { + // set username, salt and server public key s = serverSalt; B = new BigInteger(1, serverPublicKey); + I = user != null ? user : PAIR_SETUP; // default username is "Pair-Setup" - // Generate ephemeral a and compute public A - a = new BigInteger(N.bitLength(), new SecureRandom()).mod(N); + // Apply or create ephemeral a and compute public A + byte[] clientKey = clientPrivateKey; + if (clientKey == null) { + clientKey = new byte[32]; + new SecureRandom().nextBytes(clientKey); + } + a = new BigInteger(1, clientKey); A = g.modPow(a, N); // Compute hash x = H(salt || H(username || ":" || password)) - byte[] hIP = sha512((PAIR_SETUP + ":" + password).getBytes(StandardCharsets.UTF_8)); + byte[] hIP = sha512((I + ":" + password).getBytes(StandardCharsets.UTF_8)); byte[] xHash = sha512(concat(serverSalt, hIP)); x = new BigInteger(1, xHash); // Compute scrambling parameter u = H(PAD(A) || PAD(B)) - byte[] uHash = sha512(concat(toUnsigned(A, N), toUnsigned(B, N))); + byte[] uHash = sha512(concat(toUnsigned(A, 384), toUnsigned(B, 384))); u = new BigInteger(1, uHash); if (u.equals(BigInteger.ZERO)) { throw new SecurityException("Invalid scrambling parameter"); @@ -85,19 +97,35 @@ public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey) thr S = base.modPow(exp, N); // Compute session key K = H(S) - K = sha512(toUnsigned(S, N)); + K = sha512(toUnsigned(S, 384)); - // Compute client proof M1 = H(H(N) ⊕ H(g) || H(I) || s || A || B || K) - byte[] HN = sha512(toUnsigned(N, N)); - byte[] Hg = sha512(toUnsigned(g, N)); + // Compute client proof M1 = H(H(N) xor H(g) || H(I) || s || A || B || K) + byte[] HN = sha512(toUnsigned(N, 384)); + byte[] Hg = sha512(toUnsigned(g, 1)); byte[] Hxor = xor(HN, Hg); byte[] HI = sha512(I.getBytes(StandardCharsets.UTF_8)); - M1 = sha512(concat(Hxor, HI, s, toUnsigned(A, N), toUnsigned(B, N), K)); + M1 = sha512(concat(Hxor, HI, s, toUnsigned(A, 384), toUnsigned(B, 384), K)); + + // Compute expected server proof M2 = H(A || M1 || K) + M2 = sha512(concat(toUnsigned(A, 384), M1, K)); + } + + /** + * M1 - Simplified constructor when user and client private key are not provided. + * + * @param password the password (P) used for authentication. + * @param serverSalt the salt (s) provided by the server. + * @param serverPublicKey the server's public SRP key (B). + * + * @throws Exception if an error occurs during initialization. + */ + public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey) throws Exception { + this(password, serverSalt, serverPublicKey, null, null); } public byte[] createEncryptedControllerInfo(byte[] pairingId, Ed25519PrivateKeyParameters controllerLongTermPrivateKey) throws Exception { - byte[] sharedKey = generateHkdfKey(getSharedSecret(), PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); + byte[] sharedKey = generateHkdfKey(toUnsigned(S, 384), PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); byte[] signingKey = controllerLongTermPrivateKey.generatePublicKey().getEncoded(); byte[] payload = concat(sharedKey, pairingId, signingKey); byte[] signature = signMessage(controllerLongTermPrivateKey, payload); @@ -120,20 +148,12 @@ public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Excepti return serverLongTermPublicKey; } - public byte[] getClientProof() { - return M1; - } - - public byte[] getPublicKey() { - return toUnsigned(A, N); - } - - private byte[] getSharedSecret() { - return toUnsigned(S, N); + public byte[] getSymmetricKey() { + return generateHkdfKey(toUnsigned(S, 384), PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); } - public byte[] getSymmetricKey() { - return generateHkdfKey(getSharedSecret(), PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + public byte[] getScramblingParameter() { + return toUnsigned(u, 64); } public void verifyEncryptedAccessoryInfo(byte[] cipherText) throws Exception { @@ -148,7 +168,7 @@ public void verifyEncryptedAccessoryInfo(byte[] cipherText) throws Exception { throw new SecurityException("Missing accessory credentials in M6"); } - byte[] sharedKey = generateHkdfKey(getSharedSecret(), PAIR_ACCESSORY_SIGN_SALT, PAIR_ACCESSORY_SIGN_INFO); + byte[] sharedKey = generateHkdfKey(toUnsigned(S, 384), PAIR_ACCESSORY_SIGN_SALT, PAIR_ACCESSORY_SIGN_INFO); byte[] payload = concat(sharedKey, pairingId, signingKey); Ed25519PublicKeyParameters serverLongTermPublicKey = new Ed25519PublicKeyParameters(signingKey, 0); @@ -157,7 +177,6 @@ public void verifyEncryptedAccessoryInfo(byte[] cipherText) throws Exception { } public void verifyServerProof(byte[] serverProof) throws Exception { - byte[] M2 = sha512(concat(toUnsigned(A, N), M1, K)); if (!Arrays.equals(M2, serverProof)) { throw new SecurityException("SRP server proof mismatch"); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index d8d7cdcab3b37..ec41f95225ea6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -31,8 +31,6 @@ import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingUID; import org.osgi.service.component.annotations.Component; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Discovers new HomeKit server devices. @@ -55,8 +53,6 @@ public class HomekitMdnsDiscoveryParticipant implements MDNSDiscoveryParticipant private static final String SERVICE_TYPE = "_hap._tcp.local."; - private final Logger logger = LoggerFactory.getLogger(HomekitMdnsDiscoveryParticipant.class); - @Override public Set getSupportedThingTypeUIDs() { return Set.of(THING_TYPE_ACCESSORY); @@ -75,6 +71,10 @@ public String getServiceType() { String mac = properties.get("id"); // MAC address String host = service.getHostAddresses()[0]; // ipV4 address + int port = service.getPort(); + if (port != 0) { + host = host + ":" + port; + } AccessoryCategory cat; try { String ci = properties.getOrDefault("ci", ""); // accessory category diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 7420a45a7b383..49d23711b2e60 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -14,6 +14,7 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; import java.util.HashMap; @@ -99,9 +100,7 @@ public void dispose() { */ private void fetchAccessories() { try { - String json = TODO_REMOVE_TEST_JSON; - // String json = new String(ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP), - // StandardCharsets.UTF_8); + String json = new String(ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP), StandardCharsets.UTF_8); Accessories container = GSON.fromJson(json, Accessories.class); if (container != null && container.accessories instanceof List accessoryList) { accessories.clear(); @@ -111,7 +110,7 @@ private void fetchAccessories() { logger.debug("Fetched {} accessories", accessories.size()); scheduler.submit(() -> accessoriesLoaded()); // notify subclass in scheduler thread } catch (Exception e) { - logger.warn("Failed to get accessories: {}", e.getMessage()); + logger.debug("Failed to get accessories", e); } } @@ -166,26 +165,29 @@ public void initialize() { isChildAccessory = true; ipTransport = bridgeHandler.ipTransport; rwService = bridgeHandler.rwService; - // TODO remove comment <= if (rwService != null) { - fetchAccessories(); - updateStatus(ThingStatus.ONLINE); - // TODO remove comment <= } else { - // TODO remove comment <= updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not - // connected"); - // TODO remove comment <=} + if (rwService != null) { + fetchAccessories(); + updateStatus(ThingStatus.ONLINE); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not connected"); + } } else { - // standalone accessory or brige accessory, so do pairing and session setup here + // standalone accessory or bridge accessory, so do pairing and session setup here isChildAccessory = false; + Object host = getConfig().get(CONFIG_HOST); + if (host == null || !(host instanceof String hostString) || hostString.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid host"); + return; + } try { - // TODO => ipTransport = new IpTransport(getConfig().get(CONFIG_IP_V4_ADDRESS).toString()); - // TODO => scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread - fetchAccessories(); // TODO <= remove when above code is enabled - updateStatus(ThingStatus.ONLINE); + ipTransport = new IpTransport(hostString); } catch (Exception e) { - logger.warn("Failed to create transport: {}", e.getMessage()); + logger.debug("Failed to create transport", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Failed to connect to accessory"); + return; } + scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread } } @@ -194,20 +196,25 @@ public void initialize() { * Updates the thing status accordingly. */ private void initializePairing() { - pairingCode = getConfig().get(CONFIG_PAIRING_CODE).toString(); - accessoryId = getAccessoryId(); + Object pairingConfig = getConfig().get(CONFIG_PAIRING_CODE); + if (pairingConfig == null || !(pairingConfig instanceof String pairingCode) || pairingCode.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid pairing code"); + return; + } + this.pairingCode = pairingCode; + this.accessoryId = getAccessoryId(); if (accessoryId == null) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid accessory ID"); return; } restoreLongTermKeys(); - Ed25519PrivateKeyParameters controllerLongTermPrivateKey = this.controllerLongTermPrivateKey; Ed25519PublicKeyParameters accessoryLongTermPublicKey = this.accessoryLongTermPublicKey; + if (controllerLongTermPrivateKey != null && accessoryLongTermPublicKey != null) { - // Perform Pair-Verify with existing key try { + logger.debug("Starting Pair-Verify with existing key for accessory {}", accessoryId); PairVerifyClient client = new PairVerifyClient(ipTransport, accessoryId.toString(), controllerLongTermPrivateKey, accessoryLongTermPublicKey); @@ -220,7 +227,7 @@ private void initializePairing() { return; } catch (Exception e) { - logger.debug("Restored pairing was not verified for accessory {}", accessoryId); + logger.debug("Restored pairing was not verified for accessory {}", accessoryId, e); this.controllerLongTermPrivateKey = null; storeLongTermKeys(); // fall through to create new pairing @@ -232,13 +239,15 @@ private void initializePairing() { logger.debug("Created new controller long term private key for accessory {}", accessoryId); try { - // Perform Pair-Setup + logger.debug("Starting Pair-Setup for accessory {}", accessoryId); PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, thing.getUID().toString(), controllerLongTermPrivateKey, pairingCode); accessoryLongTermPublicKey = pairSetupClient.pair(); this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; + logger.debug("Pair-Setup completed; starting Pair-Verify for accessory {}", accessoryId); + // Perform Pair-Verify immediately after Pair-Setup PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, accessoryId.toString(), controllerLongTermPrivateKey, accessoryLongTermPublicKey); @@ -248,15 +257,14 @@ private void initializePairing() { this.controllerLongTermPrivateKey = controllerLongTermPrivateKey; - storeLongTermKeys(); - logger.debug("Pairing and verification completed for accessory {}", accessoryId); + storeLongTermKeys(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); } catch (Exception e) { - logger.warn("Pairing and verification failed for accessory {}", accessoryId); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing failed"); + logger.warn("Pairing and verification failed for accessory {}", accessoryId, e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing / Verification failed"); } } @@ -287,263 +295,4 @@ private void storeLongTermKeys() { property = accessoryKey == null ? null : Base64.getEncoder().encodeToString(accessoryKey.getEncoded()); thing.setProperty(PROPERTY_ACCESSORY_PUBLIC_KEY, property); } - - public static final String TODO_REMOVE_TEST_JSON = """ - { - "accessories": [ - { - "aid": 1, - "services": [ - { - "type": "3E", - "iid": 1, - "characteristics": [ - { - "type": "23", - "value": "Acme Light Bridge", - "perms": [ - "pr" - ], - "format": "string", - "iid": 2 - }, - { - "type": "20", - "value": "Acme", - "perms": [ - "pr" - ], - "format": "string", - "iid": 3 - }, - { - "type": "30", - "value": "037A2BABF19D", - "perms": [ - "pr" - ], - "format": "string", - "iid": 4 - }, - { - "type": "21", - "value": "Bridge1,1", - "perms": [ - "pr" - ], - "format": "string", - "iid": 5 - }, - { - "type": "14", - "value": null, - "perms": [ - "pw" - ], - "format": "bool", - "iid": 6 - }, - { - "type": "52", - "value": "100.1.1", - "perms": [ - "pr" - ], - "format": "string", - "iid": 7 - } - ] - }, - { - "type": "A2", - "iid": 8, - "characteristics": [ - { - "type": "37", - "value": "01.01.00", - "perms": [ - "pr" - ], - "format": "string", - "iid": 9 - } - ] - } - ] - }, - { - "aid": 2, - "services": [ - { - "type": "3E", - "iid": 1, - "characteristics": [ - { - "type": "23", - "value": "Acme LED Light Bulb", - "perms": [ - "pr" - ], - "format": "string", - "iid": 2 - }, - { - "type": "20", - "value": "Acme", - "perms": [ - "pr" - ], - "format": "string", - "iid": 3 - }, - { - "type": "30", - "value": "099DB48E9E28", - "perms": [ - "pr" - ], - "format": "string", - "iid": 4 - }, - { - "type": "21", - "value": "LEDBulb1,1", - "perms": [ - "pr" - ], - "format": "string", - "iid": 5 - }, - { - "type": "14", - "value": null, - "perms": [ - "pw" - ], - "format": "bool", - "iid": 6 - } - ] - }, - { - "type": "43", - "iid": 7, - "characteristics": [ - { - "type": "25", - "value": true, - "perms": [ - "pr", - "pw" - ], - "format": "bool", - "iid": 8 - }, - { - "type": "8", - "value": 50, - "perms": [ - "pr", - "pw" - ], - "iid": 9, - "maxValue": 100, - "minStep": 1, - "minValue": 20, - "format": "int", - "unit": "percentage" - } - ] - } - ] - }, - { - "aid": 3, - "services": [ - { - "type": "3E", - "iid": 1, - "characteristics": [ - { - "type": "23", - "value": "Acme LED Light Bulb", - "perms": [ - "pr" - ], - "format": "string", - "iid": 2 - }, - { - "type": "20", - "value": "Acme", - "perms": [ - "pr" - ], - "format": "string", - "iid": 3 - }, - { - "type": "30", - "value": "099DB48E9E28", - "perms": [ - "pr" - ], - "format": "string", - "iid": 4 - }, - { - "type": "21", - "value": "LEDBulb1,1", - "perms": [ - "pr" - ], - "format": "string", - "iid": 5 - }, - { - "type": "14", - "value": null, - "perms": [ - "pw" - ], - "format": "bool", - "iid": 6 - } - ] - }, - { - "type": "43", - "iid": 7, - "characteristics": [ - { - "type": "25", - "value": true, - "perms": [ - "pr", - "pw" - ], - "format": "bool", - "iid": 8 - }, - { - "type": "8", - "value": 50, - "perms": [ - "pr", - "pw" - ], - "iid": 9, - "maxValue": 100, - "minStep": 1, - "minValue": 20, - "format": "int", - "unit": "percentage" - } - ] - } - ] - } - ] - } - """; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index f958fda3b4c0b..a04c0a100cabf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -256,7 +256,7 @@ private void createChannels() { return; } Accessory accessory = accessories.get(accessoryId); - if (accessory == null && !isChildAccessory && accessories.size() > 0) { + if (accessory == null && !isChildAccessory && !accessories.isEmpty()) { // fallback to the first accessory if the specific one is not found (should not normally happen) accessory = accessories.values().iterator().next(); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index a8a869854ac7f..9f3dc78b4b0d9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -22,6 +22,8 @@ import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; import org.openhab.binding.homekit.internal.transport.IpTransport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Service to remove an existing pairing with a HomeKit accessory. @@ -31,25 +33,29 @@ @NonNullByDefault public class PairRemoveClient { + private static final String ENDPOINT_PAIR_REMOVE = "/pairings"; private static final String CONTENT_TYPE = "application/pairing+tlv8"; - private static final String ENDPOINT = "/pairings"; + + private final Logger logger = LoggerFactory.getLogger(PairRemoveClient.class); private final IpTransport ipTransport; - private final String pairingID; + private final String pairingId; - public PairRemoveClient(IpTransport ipTransport, String pairingID) { + public PairRemoveClient(IpTransport ipTransport, String pairingId) { + logger.debug("Created with pairingId:{}", pairingId); this.ipTransport = ipTransport; - this.pairingID = pairingID; + this.pairingId = pairingId; } public void remove() throws Exception { + logger.debug("Starting Pair-Remove"); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.METHOD.key, new byte[] { PairingMethod.REMOVE.value }, // - TlvType.IDENTIFIER.key, pairingID.getBytes(StandardCharsets.UTF_8)); + TlvType.IDENTIFIER.key, pairingId.getBytes(StandardCharsets.UTF_8)); Validator.validate(PairingMethod.REMOVE, tlv); - byte[] response = ipTransport.post(ENDPOINT, CONTENT_TYPE, Tlv8Codec.encode(tlv)); + byte[] response = ipTransport.post(ENDPOINT_PAIR_REMOVE, CONTENT_TYPE, Tlv8Codec.encode(tlv)); Map tlv2 = Tlv8Codec.decode(response); Validator.validate(PairingMethod.REMOVE, tlv2); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index f8a13bafff44d..089756fab6fd7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -20,6 +20,7 @@ import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.crypto.SRPclient; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; import org.openhab.binding.homekit.internal.enums.ErrorCode; @@ -27,6 +28,8 @@ import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; import org.openhab.binding.homekit.internal.transport.IpTransport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Handles the 6-step pairing process with a HomeKit accessory. @@ -41,17 +44,20 @@ public class PairSetupClient { private static final String ENDPOINT_PAIR_SETUP = "/pair-setup"; - private static final String CONTENT_TYPE_TLV8 = "application/pairing+tlv8"; + + private final Logger logger = LoggerFactory.getLogger(PairSetupClient.class); + private final IpTransport ipTransport; private final String password; private final byte[] pairingId; private final Ed25519PrivateKeyParameters clientLongTermPrivateKey; public PairSetupClient(IpTransport ipTransport, String pairingId, - Ed25519PrivateKeyParameters clientLongTermPrivateKey, String password) throws Exception { + Ed25519PrivateKeyParameters clientLongTermPrivateKey, String pairingCode) throws Exception { + logger.debug("Created with pairingId:{}, pairingCode:{}", pairingId, pairingCode); this.ipTransport = ipTransport; - this.password = password; + this.password = pairingCode; this.pairingId = pairingId.getBytes(StandardCharsets.UTF_8); this.clientLongTermPrivateKey = clientLongTermPrivateKey; } @@ -75,6 +81,7 @@ public Ed25519PublicKeyParameters pair() throws Exception { * @throws Exception if an error occurs during execution */ private SRPclient doStepM1() throws Exception { + logger.debug("Starting Pair-Setup M1"); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.METHOD.key, new byte[] { PairingMethod.SETUP.value }); @@ -91,6 +98,7 @@ private SRPclient doStepM1() throws Exception { * @throws Exception if an error occurs during processing */ private SRPclient doStepM2(byte[] response1) throws Exception { + logger.debug("Starting Pair-Setup M2"); Map tlv = Tlv8Codec.decode(response1); Validator.validate(PairingMethod.SETUP, tlv); byte[] serverSalt = tlv.get(TlvType.SALT.key); @@ -107,10 +115,11 @@ private SRPclient doStepM2(byte[] response1) throws Exception { * @throws Exception if an error occurs during processing */ private SRPclient doStepM3(SRPclient client) throws Exception { + logger.debug("Starting Pair-Setup M3"); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M3.value }, // - TlvType.PUBLIC_KEY.key, client.getPublicKey(), // - TlvType.PROOF.key, client.getClientProof()); + TlvType.PUBLIC_KEY.key, CryptoUtils.toUnsigned(client.A, 384), // + TlvType.PROOF.key, client.M1); Validator.validate(PairingMethod.SETUP, tlv); byte[] response3 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); return doStepM4(client, response3); @@ -123,6 +132,7 @@ private SRPclient doStepM3(SRPclient client) throws Exception { * @throws Exception if an error occurs during processing */ private SRPclient doStepM4(SRPclient client, byte[] response3) throws Exception { + logger.debug("Starting Pair-Setup M4"); Map tlv = Tlv8Codec.decode(response3); Validator.validate(PairingMethod.SETUP, tlv); byte[] proof = tlv.get(TlvType.PROOF.key); @@ -137,6 +147,7 @@ private SRPclient doStepM4(SRPclient client, byte[] response3) throws Exception * @throws Exception if an error occurs during processing */ private SRPclient doStepM5(SRPclient client) throws Exception { + logger.debug("Starting Pair-Setup M5"); byte[] cipherText = client.createEncryptedControllerInfo(pairingId, clientLongTermPrivateKey); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M5.value }, // @@ -154,6 +165,7 @@ private SRPclient doStepM5(SRPclient client) throws Exception { * @throws Exception if an error occurs during processing */ private SRPclient doStepM6(SRPclient client, byte[] response5) throws Exception { + logger.debug("Starting Pair-Setup M6"); Map tlv = Tlv8Codec.decode(response5); Validator.validate(PairingMethod.SETUP, tlv); byte[] ciphertext = tlv.get(TlvType.ENCRYPTED_DATA.key); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index bdea6819c9106..832575f857b87 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -32,6 +32,8 @@ import org.openhab.binding.homekit.internal.enums.TlvType; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; import org.openhab.binding.homekit.internal.transport.IpTransport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Handles the 3-step pair-verify process with a HomeKit accessory. @@ -41,8 +43,10 @@ @NonNullByDefault public class PairVerifyClient { - private static final String CONTENT_TYPE_TLV = "application/pairing+tlv8"; private static final String ENDPOINT_PAIR_VERIFY = "/pair-verify"; + private static final String CONTENT_TYPE_TLV = "application/pairing+tlv8"; + + private final Logger logger = LoggerFactory.getLogger(PairVerifyClient.class); private final IpTransport ipTransport; private final byte[] pairingId; @@ -58,6 +62,7 @@ public class PairVerifyClient { public PairVerifyClient(IpTransport ipTransport, String pairingId, Ed25519PrivateKeyParameters clientLongTermPrivateKey, Ed25519PublicKeyParameters serverLongTermPublicKey) throws Exception { + logger.debug("Created with pairingId:{}", pairingId); this.ipTransport = ipTransport; this.pairingId = pairingId.getBytes(StandardCharsets.UTF_8); this.clientLongTermPrivateKey = clientLongTermPrivateKey; @@ -78,6 +83,7 @@ public AsymmetricSessionKeys verify() throws Exception { // M1 — Create new random client ephemeral X25519 public key and send it to server private void doStep1() throws Exception { + logger.debug("Starting Pair-Verify M1"); byte[] clientKey = this.clientKey.generatePublicKey().getEncoded(); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // @@ -88,6 +94,7 @@ private void doStep1() throws Exception { // M2 — Receive server ephemeral X25519 public key and encrypted TLV private void doStep2(byte[] response1) throws Exception { + logger.debug("Starting Pair-Verify M2"); Map tlv = Tlv8Codec.decode(response1); Validator.validate(PairingMethod.VERIFY, tlv); @@ -114,6 +121,7 @@ private void doStep2(byte[] response1) throws Exception { // M3 — Send encrypted controller identifier and signature private void doStep3() throws Exception { + logger.debug("Starting Pair-Verify M3"); byte[] sharedKey = generateHkdfKey(sharedSecret, PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); byte[] signingKey = clientLongTermPrivateKey.generatePublicKey().getEncoded(); byte[] payload = concat(sharedKey, pairingId, signingKey); @@ -136,6 +144,7 @@ private void doStep3() throws Exception { // M4 — Final confirmation private void doStep4(byte[] response3) throws Exception { + logger.debug("Starting Pair-Verify M4"); Map tlv = Tlv8Codec.decode(response3); Validator.validate(PairingMethod.VERIFY, tlv); readKey = CryptoUtils.generateHkdfKey(sharedSecret, CONTROL_SALT, CONTROL_READ_ENCRYPTION_KEY); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 956a1ea266294..260b131346a31 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -28,8 +28,11 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; import org.openhab.binding.homekit.internal.session.SecureSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * This provides the IP transport layer for HomeKit communication. @@ -43,7 +46,9 @@ @NonNullByDefault public class IpTransport implements AutoCloseable { - private static final int TIMEOUT = Duration.ofSeconds(5).toMillisPart(); + private static final int CONNECT_TIMEOUT = Duration.ofSeconds(5).toMillisPart(); + + private final Logger logger = LoggerFactory.getLogger(IpTransport.class); private final String host; // ip address with optional port e.g. "192.168.1.42:9123" private final Socket socket; @@ -54,12 +59,15 @@ public class IpTransport implements AutoCloseable { * Creates a new IpTransport instance with the given socket and session keys. */ public IpTransport(String host) throws Exception { + logger.debug("Connecting to {}", host); this.host = host; String[] parts = host.split(":"); - int port = (parts.length > 1) ? Integer.parseInt(parts[1]) : 80; // default to port 80 + String ipAddress = parts[0]; + int port = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; socket = new Socket(); - socket.connect(new InetSocketAddress(host, port), TIMEOUT); + socket.connect(new InetSocketAddress(ipAddress, port), CONNECT_TIMEOUT); socket.setKeepAlive(false); // HAP spec forbids TCP keepalive + logger.debug("Connected to {}:{}", ipAddress, port); } public void setSessionKeys(AsymmetricSessionKeys keys) throws Exception { @@ -83,6 +91,7 @@ public byte[] put(String endpoint, String contentType, byte[] content) private byte[] execute(String method, String endpoint, String contentType, byte[] body) throws IOException, InterruptedException, TimeoutException, ExecutionException { + logger.trace("{} {} Content-Type:{} Body:{}", method, endpoint, contentType, CryptoUtils.toHex(body)); try { byte[] request = buildRequest(method, endpoint, contentType, body); byte[] response; @@ -99,6 +108,7 @@ private byte[] execute(String method, String endpoint, String contentType, byte[ response = readPlainResponse(in); } + logger.trace("Response: {}", CryptoUtils.toHex(response)); return parseResponse(response); } catch (IOException | InterruptedException | TimeoutException e) { throw e; diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml index 47ae1bbe28864..097ec8e963df7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml @@ -5,6 +5,6 @@ binding HomeKit Binding - This is the binding for HomeKit. + This is the binding for a HomeKit client. diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 3c9655c9add06..8fe5c7fa3f1b9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -27,6 +27,7 @@ + HomeKit Accessory Bridge diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java similarity index 61% rename from bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java rename to bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java index 10b0f6260df9b..c391f5bb7af0d 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPtestServer.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -22,6 +22,7 @@ import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; import org.openhab.binding.homekit.internal.enums.TlvType; @@ -33,37 +34,55 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class SRPtestServer { - - private final byte[] serverPairingId; - private final Ed25519PrivateKeyParameters serverLongTermPrivateKey; +public class SRPserver { // Session state + public @NonNullByDefault({}) BigInteger A; // client public SRP key + public final BigInteger b; // server private SRP key + public final BigInteger B; // server public SRP key + public @NonNullByDefault({}) byte[] K = null; // session key + public @NonNullByDefault({}) BigInteger S; // shared secret + public @NonNullByDefault({}) BigInteger u; // scrambling parameter + public final BigInteger v; // verifier + private final String I; // username private final byte[] s; // salt - private final BigInteger v; // verifier - private final BigInteger b; // private SRP key ephemeral value - private final BigInteger B; // public SRP key ephemeral value - - private @NonNullByDefault({}) byte[] K = null; - private @NonNullByDefault({}) BigInteger A; - private @NonNullByDefault({}) BigInteger u; - private @NonNullByDefault({}) BigInteger S; + private final byte[] serverPairingId; + private final Ed25519PrivateKeyParameters serverLongTermPrivateKey; - public SRPtestServer(String password, byte[] serverSalt, byte[] serverPairingId, - Ed25519PrivateKeyParameters serverLongTermPrivateKey) throws Exception { + /** + * Create a SRP server instance with the given parameters. + * + * @param password the password to use + * @param serverSalt the salt to use + * @param serverPairingId the pairing ID of the server + * @param serverLongTermPrivateKey the long term private key of the server + * @param username the username to use (or null for default "Pair-Setup") + * @param serverPrivateKey optional 32 byte private key to use for b, or null to generate a new one + * + * @throws Exception on any error + */ + public SRPserver(String password, byte[] serverSalt, byte[] serverPairingId, + Ed25519PrivateKeyParameters serverLongTermPrivateKey, @Nullable String username, + byte @Nullable [] serverPrivateKey) throws Exception { this.serverPairingId = serverPairingId; this.serverLongTermPrivateKey = serverLongTermPrivateKey; - I = PAIR_SETUP; + I = username != null ? username : PAIR_SETUP; s = serverSalt; - // Compute verifier once + // x = H(salt || H(username || ":" || password)) + // v = g^x mod N byte[] hIP = sha512((I + ":" + password).getBytes(StandardCharsets.UTF_8)); BigInteger x = new BigInteger(1, sha512(concat(serverSalt, hIP))); v = g.modPow(x, N); - // Generate ephemeral b and compute public B - b = new BigInteger(N.bitLength(), new SecureRandom()).mod(N); + // Apply or create ephemeral b and compute public B + byte[] serverKey = serverPrivateKey; + if (serverKey == null) { + serverKey = new byte[32]; + new SecureRandom().nextBytes(serverKey); + } + b = new BigInteger(1, serverKey); BigInteger gb = g.modPow(b, N); B = k.multiply(v).add(gb).mod(N); } @@ -76,7 +95,7 @@ public byte[] createServerProof(byte[] clientPublicKeyA) throws Exception { A = clientPublicA; // u = H(PAD(A) || PAD(B)) - byte[] uHash = sha512(concat(toUnsigned(A, N), toUnsigned(B, N))); + byte[] uHash = sha512(concat(toUnsigned(A, 384), toUnsigned(B, 384))); u = new BigInteger(1, uHash); if (u.equals(BigInteger.ZERO)) { throw new SecurityException("Invalid scrambling parameter"); @@ -86,21 +105,21 @@ public byte[] createServerProof(byte[] clientPublicKeyA) throws Exception { BigInteger vu = v.modPow(u, N); BigInteger base = A.multiply(vu).mod(N); S = base.modPow(b, N); - K = sha512(toUnsigned(S, N)); + K = sha512(toUnsigned(S, 384)); - // Compute M1 = H(H(N) ⊕ H(g) || H(I) || salt || A || B || K) - byte[] HN = sha512(toUnsigned(N, N)); - byte[] Hg = sha512(toUnsigned(g, N)); + // Compute M1 = H(H(N) xor H(g) || H(I) || salt || A || B || K) + byte[] HN = sha512(toUnsigned(N, 384)); + byte[] Hg = sha512(toUnsigned(g, 1)); byte[] Hxor = xor(HN, Hg); byte[] HI = sha512(I.getBytes(StandardCharsets.UTF_8)); - byte[] M1 = sha512(concat(Hxor, HI, s, toUnsigned(clientPublicA, N), toUnsigned(B, N), K)); + byte[] M1 = sha512(concat(Hxor, HI, s, toUnsigned(clientPublicA, 384), toUnsigned(B, 384), K)); // Compute M2 = H(A || M1 || K) - return sha512(concat(toUnsigned(clientPublicA, N), M1, K)); + return sha512(concat(toUnsigned(clientPublicA, 384), M1, K)); } public byte[] createEncryptedAccessoryInfo() throws Exception { - byte[] sharedKey = generateHkdfKey(getSharedSecret(), PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); + byte[] sharedKey = generateHkdfKey(toUnsigned(S, 384), PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); byte[] signingKey = serverLongTermPrivateKey.generatePublicKey().getEncoded(); byte[] payload = concat(sharedKey, serverPairingId, signingKey); byte[] signature = signMessage(serverLongTermPrivateKey, payload); @@ -114,15 +133,7 @@ public byte[] createEncryptedAccessoryInfo() throws Exception { return CryptoUtils.encrypt(getSymmetricKey(), PS_M6_NONCE, plaintext); } - public byte[] getPublicKey() { - return toUnsigned(B, N); - } - - private byte[] getSharedSecret() { - return toUnsigned(S, N); - } - public byte[] getSymmetricKey() { - return generateHkdfKey(toUnsigned(S, N), PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + return generateHkdfKey(toUnsigned(S, 384), PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java new file mode 100644 index 0000000000000..a273978d44aec --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.concurrent.atomic.AtomicReference; + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; +import org.openhab.binding.homekit.internal.crypto.SRPclient; + +/** + * Tests to validate the code against the test vectors in Apple HomeKit Accessory Protocol + * Specification chapter 5.5.2 SRP Test Vectors. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestAppleTestVectors { + // Modulus N + private static final String N_hex = """ + FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 + 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 + 4FE1356D 6D51C245 E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED + EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D C2007CB8 A163BF05 + 98DA4836 1C55D39A 69163FA8 FD24CF5F 83655D23 DCA3AD96 1C62F356 208552BB + 9ED52907 7096966D 670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B + E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9 DE2BCBF6 95581718 + 3995497C EA956AE5 15D22618 98FA0510 15728E5A 8AAAC42D AD33170D 04507A33 + A85521AB DF1CBA64 ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7 + ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B F12FFA06 D98A0864 + D8760273 3EC86A64 521F2B18 177B200C BBE11757 7A615D6C 770988C0 BAD946E2 + 08E24FA0 74E5AB31 43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF + """; + + // Generator g + private static final String g_hex = """ + 05 + """; + + // Private key a + private static final String a_hex = """ + 60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393 + """; + + // Public key A + private static final String A_hex = """ + FAB6F5D2 615D1E32 3512E799 1CC37443 F487DA60 4CA8C923 0FCB04E5 41DCE628 + 0B27CA46 80B0374F 179DC3BD C7553FE6 2459798C 701AD864 A91390A2 8C93B644 + ADBF9C00 745B942B 79F9012A 21B9B787 82319D83 A1F83628 66FBD6F4 6BFC0DDB + 2E1AB6E4 B45A9906 B82E37F0 5D6F97F6 A3EB6E18 2079759C 4F684783 7B62321A + C1B4FA68 641FCB4B B98DD697 A0C73641 385F4BAB 25B79358 4CC39FC8 D48D4BD8 + 67A9A3C1 0F8EA121 70268E34 FE3BBE6F F89998D6 0DA2F3E4 283CBEC1 393D52AF + 724A5723 0C604E9F BCE583D7 613E6BFF D67596AD 121A8707 EEC46944 95703368 + 6A155F64 4D5C5863 B48F61BD BF19A53E AB6DAD0A 186B8C15 2E5F5D8C AD4B0EF8 + AA4EA500 8834C3CD 342E5E0F 167AD045 92CD8BD2 79639398 EF9E114D FAAAB919 + E14E8509 89224DDD 98576D79 385D2210 902E9F9B 1F2D86CF A47EE244 635465F7 + 1058421A 0184BE51 DD10CC9D 079E6F16 04E7AA9B 7CF7883C 7D4CE12B 06EBE160 + 81E23F27 A231D184 32D7D1BB 55C28AE2 1FFCF005 F57528D1 5A88881B B3BBB7FE + """; + + // Private key b + private static final String b_hex = """ + E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20 + """; + + // Public key B + private static final String B_hex = """ + 40F57088 A482D4C7 733384FE 0D301FDD CA9080AD 7D4F6FDF 09A01006 C3CB6D56 + 2E41639A E8FA21DE 3B5DBA75 85B27558 9BDB2798 63C56280 7B2B9908 3CD1429C + DBE89E25 BFBD7E3C AD3173B2 E3C5A0B1 74DA6D53 91E6A06E 465F037A 40062548 + 39A56BF7 6DA84B1C 94E0AE20 8576156F E5C140A4 BA4FFC9E 38C3B07B 88845FC6 + F7DDDA93 381FE0CA 6084C4CD 2D336E54 51C464CC B6EC65E7 D16E548A 273E8262 + 84AF2559 B6264274 215960FF F47BDD63 D3AFF064 D6137AF7 69661C9D 4FEE4738 + 2603C88E AA098058 1D077584 61B777E4 356DDA58 35198B51 FEEA308D 70F75450 + B71675C0 8C7D8302 FD7539DD 1FF2A11C B4258AA7 0D234436 AA42B6A0 615F3F91 + 5D55CC3B 966B2716 B36E4D1A 06CE5E5D 2EA3BEE5 A1270E87 51DA45B6 0B997B0F + FDB0F996 2FEE4F03 BEE780BA 0A845B1D 92714217 83AE6601 A61EA2E3 42E4F2E8 + BC935A40 9EAD19F2 21BD1B74 E2964DD1 9FC845F6 0EFC0933 8B60B6B2 56D8CAC8 + 89CCA306 CC370A0B 18C8B886 E95DA0AF 5235FEF4 393020D2 B7F30569 04759042 + """; + + // Salt s + private static final String s_hex = """ + BEB25379 D1A8581E B5A72767 3A2441EE + """; + + // Verifier v + private static final String v_hex = """ + 9B5E0617 01EA7AEB 39CF6E35 19655A85 3CF94C75 CAF2555E F1FAF759 BB79CB47 + 7014E04A 88D68FFC 05323891 D4C205B8 DE81C2F2 03D8FAD1 B24D2C10 9737F1BE + BBD71F91 2447C4A0 3C26B9FA D8EDB3E7 80778E30 2529ED1E E138CCFC 36D4BA31 + 3CC48B14 EA8C22A0 186B222E 655F2DF5 603FD75D F76B3B08 FF895006 9ADD03A7 + 54EE4AE8 8587CCE1 BFDE3679 4DBAE459 2B7B904F 442B041C B17AEBAD 1E3AEBE3 + CBE99DE6 5F4BB1FA 00B0E7AF 06863DB5 3B02254E C66E781E 3B62A821 2C86BEB0 + D50B5BA6 D0B478D8 C4E9BBCE C2176532 6FBD1405 8D2BBDE2 C33045F0 3873E539 + 48D78B79 4F0790E4 8C36AED6 E880F557 427B2FC0 6DB5E1E2 E1D7E661 AC482D18 + E528D729 5EF74372 95FF1A72 D4027717 13F16876 DD050AE5 B7AD53CC B90855C9 + 39566483 58ADFD96 6422F524 98732D68 D1D7FBEF 10D78034 AB8DCB6F 0FCF885C + C2B2EA2C 3E6AC866 09EA058A 9DA8CC63 531DC915 414DF568 B09482DD AC1954DE + C7EB714F 6FF7D44C D5B86F6B D1158109 30637C01 D0F6013B C9740FA2 C633BA89 + """; + + // Scrambling parameter u + private static final String u_hex = """ + 03AE5F3C 3FA9EFF1 A50D7DBB 8D2F60A1 EA66EA71 2D50AE97 6EE34641 A1CD0E51 + C4683DA3 83E8595D 6CB56A15 D5FBC754 3E07FBDD D316217E 01A391A1 8EF06DFF + """; + + // Premaster secret S + private static final String S_hex = """ + F1036FEC D017C823 9C0D5AF7 E0FCF0D4 08B009E3 6411618A 60B23AAB BFC38339 + 72682312 14BAACDC 94CA1C53 F442FB51 C1B027C3 18AE238E 16414D60 D1881B66 + 486ADE10 ED02BA33 D098F6CE 9BCF1BB0 C46CA2C4 7F2F174C 59A9C61E 2560899B + 83EF6113 1E6FB30B 714F4E43 B735C9FE 6080477C 1B83E409 3E4D456B 9BCA492C + F9339D45 BC42E67C E6C02C24 3E49F5DA 42A869EC 855780E8 4207B8A1 EA6501C4 + 78AAC0DF D3D22614 F531A00D 826B7954 AE8B14A9 85A42931 5E6DD366 4CF47181 + 496A9432 9CDE8005 CAE63C2F 9CA4969B FE840019 24037C44 6559BDBB 9DB9D4DD + 142FBCD7 5EEF2E16 2C843065 D99E8F05 762C4DB7 ABD9DB20 3D41AC85 A58C05BD + 4E2DBF82 2A934523 D54E0653 D376CE8B 56DCB452 7DDDC1B9 94DC7509 463A7468 + D7F02B1B EB168571 4CE1DD1E 71808A13 7F788847 B7C6B7BF A1364474 B3B7E894 + 78954F6A 8E68D45B 85A88E4E BFEC1336 8EC0891C 3BC86CF5 00978801 78D86135 + E7287234 58538858 D715B7B2 47406222 C1019F53 603F0169 52D49710 0858824C + """; + + // Session key K + private static final String K_hex = """ + 5CBC219D B052138E E1148C71 CD449896 3D682549 CE91CA24 F098468F 06015BEB + 6AF245C2 093F98C3 651BCA83 AB8CAB2B 580BBF02 184FEFDF 26142F73 DF95AC50 + """; + + private static final String I = "alice"; + private static final String p = "password123"; + + @Test + void testBasicConversions() { + BigInteger N1 = CryptoUtils.toBigInteger(N_hex); + assertEquals(3072, N1.bitLength()); + + BigInteger N2 = new BigInteger(1, CryptoUtils.toBytes(N_hex)); + assertEquals(3072, N2.bitLength()); + + assertEquals(N1, N2); + + byte[] nBytes = CryptoUtils.toUnsigned(N1, 384); + assertEquals(384, nBytes.length); + assertArrayEquals(CryptoUtils.toBytes(N_hex), nBytes); + + BigInteger g1 = new BigInteger(1, CryptoUtils.toBytes(g_hex)); + assertEquals(5, g1.intValue()); + assertEquals(g1, CryptoUtils.toBigInteger(g_hex)); + } + + @Test + void testClientKeyConversion() { + BigInteger N = CryptoUtils.toBigInteger(N_hex); + BigInteger g = CryptoUtils.toBigInteger(g_hex); + + BigInteger a = CryptoUtils.toBigInteger(a_hex); + BigInteger A = CryptoUtils.toBigInteger(A_hex); + + BigInteger calcA = g.modPow(a, N); + + assertEquals(A, calcA); + + byte[] act; + byte[] exp; + + act = CryptoUtils.toUnsigned(a, 32); + exp = CryptoUtils.toBytes(a_hex); + assertArrayEquals(exp, act); + + act = CryptoUtils.toUnsigned(A, 384); + exp = CryptoUtils.toBytes(A_hex); + assertArrayEquals(exp, act); + } + + @Test + void testClientVectors() { + byte[] a = CryptoUtils.toBytes(a_hex); + byte[] A = CryptoUtils.toBytes(A_hex); + byte[] B = CryptoUtils.toBytes(B_hex); + byte[] s = CryptoUtils.toBytes(s_hex); + byte[] u = CryptoUtils.toBytes(u_hex); + byte[] S = CryptoUtils.toBytes(S_hex); + byte[] K = CryptoUtils.toBytes(K_hex); + + AtomicReference clientRef = new AtomicReference<>(); + + assertDoesNotThrow(() -> clientRef.set(new SRPclient(p, s, B, I, a))); + + SRPclient client = clientRef.get(); + assertNotNull(client); + + assertArrayEquals(A, CryptoUtils.toUnsigned(client.A, 384)); + assertArrayEquals(u, CryptoUtils.toUnsigned(client.u, 64)); + assertArrayEquals(S, CryptoUtils.toUnsigned(client.S, 384)); + assertArrayEquals(K, client.K); + } + + @Test + void testServerVectors() { + byte[] b = CryptoUtils.toBytes(b_hex); + byte[] A = CryptoUtils.toBytes(A_hex); + byte[] B = CryptoUtils.toBytes(B_hex); + byte[] s = CryptoUtils.toBytes(s_hex); + byte[] u = CryptoUtils.toBytes(u_hex); + byte[] v = CryptoUtils.toBytes(v_hex); + byte[] S = CryptoUtils.toBytes(S_hex); + byte[] K = CryptoUtils.toBytes(K_hex); + + Ed25519PrivateKeyParameters dummyLTPK = new Ed25519PrivateKeyParameters(new SecureRandom()); + byte[] dummyPID = "serverPairingId".getBytes(StandardCharsets.UTF_8); + + AtomicReference serverRef = new AtomicReference<>(); + assertDoesNotThrow(() -> serverRef.set(new SRPserver(p, s, dummyPID, dummyLTPK, I, b))); + + SRPserver server = serverRef.get(); + assertNotNull(server); + + assertArrayEquals(b, CryptoUtils.toUnsigned(server.b, 32)); + assertArrayEquals(B, CryptoUtils.toUnsigned(server.B, 384)); + assertArrayEquals(v, CryptoUtils.toUnsigned(server.v, 384)); + + assertDoesNotThrow(() -> server.createServerProof(A)); + assertArrayEquals(u, CryptoUtils.toUnsigned(server.u, 64)); + assertArrayEquals(S, CryptoUtils.toUnsigned(server.S, 384)); + assertArrayEquals(K, server.K); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index d8209b04a2e1e..1788abc64c861 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -23,7 +23,6 @@ import java.util.Map; import java.util.Objects; -import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; @@ -58,7 +57,7 @@ class TestPairSetup { private @NonNullByDefault({}) byte[] clientPublicKey; @Test - void testBareCrypto() throws InvalidCipherTextException { + void testBareCrypto() throws Exception { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); byte[] key = new byte[32]; // 256 bits = 32 bytes byte[] nonce = generateNonce(123); @@ -71,7 +70,7 @@ void testBareCrypto() throws InvalidCipherTextException { @Test void testSrpClient() throws Exception { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); - SRPclient client = new SRPclient("password123", fromHex(SALT_HEX), fromHex(SERVER_PRIVATE_HEX)); + SRPclient client = new SRPclient("password123", toBytes(SALT_HEX), toBytes(SERVER_PRIVATE_HEX)); byte[] key = client.getSymmetricKey(); byte[] cipherText = encrypt(key, PS_M5_NONCE, plainText0); byte[] plainText1 = decrypt(key, PS_M5_NONCE, cipherText); @@ -84,20 +83,20 @@ void testPairSetup() throws Exception { String password = "password123"; String clientPairingIdentifier = "11:22:33:44:55:66"; String serverPairingIdentifier = "66:55:44:33:22:11"; - byte[] serverSalt = fromHex(SALT_HEX); + byte[] serverSalt = toBytes(SALT_HEX); byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); // initialize signing keys Ed25519PrivateKeyParameters clientPrivateSigningKey = new Ed25519PrivateKeyParameters( - fromHex(CLIENT_PRIVATE_HEX)); + toBytes(CLIENT_PRIVATE_HEX)); Ed25519PrivateKeyParameters serverPrivateSigningKey = new Ed25519PrivateKeyParameters( - fromHex(SERVER_PRIVATE_HEX)); + toBytes(SERVER_PRIVATE_HEX)); // create mock IpTransport mockTransport = mock(IpTransport.class); // create SRP client and server - SRPtestServer server = new SRPtestServer(password, serverSalt, serverPairingId, serverPrivateSigningKey); + SRPserver server = new SRPserver(password, serverSalt, serverPairingId, serverPrivateSigningKey, null, null); PairSetupClient client = new PairSetupClient(mockTransport, clientPairingIdentifier, clientPrivateSigningKey, password); @@ -127,17 +126,17 @@ void testPairSetup() throws Exception { client.pair(); } - private byte[] getServerResponseM1(SRPtestServer server, byte[] serverSalt) { + private byte[] getServerResponseM1(SRPserver server, byte[] serverSalt) { Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M2.value }, // TlvType.SALT.key, serverSalt, // salt - TlvType.PUBLIC_KEY.key, server.getPublicKey() // server public key + TlvType.PUBLIC_KEY.key, toUnsigned(server.B, 384) // server public key ); PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); return Tlv8Codec.encode(tlv); } - private byte[] getServerResponseM3(SRPtestServer server, Map tlv2, PairSetupClient client) + private byte[] getServerResponseM3(SRPserver server, Map tlv2, PairSetupClient client) throws Exception { clientPublicKey = tlv2.get(TlvType.PUBLIC_KEY.key); byte[] serverProof = server.createServerProof(Objects.requireNonNull(clientPublicKey)); @@ -149,7 +148,7 @@ private byte[] getServerResponseM3(SRPtestServer server, Map tl return Tlv8Codec.encode(tlv3); } - private byte[] getServerResponseM5(SRPtestServer server) throws Exception { + private byte[] getServerResponseM5(SRPserver server) throws Exception { byte[] cipertext = server.createEncryptedAccessoryInfo(); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M6.value }, // diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index 14c5ec06d5d31..7e3bb3efe64e5 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -56,10 +56,10 @@ class TestPairVerify { private final byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); private final Ed25519PrivateKeyParameters clientLongTermPrivateKey = new Ed25519PrivateKeyParameters( - fromHex(CLIENT_PRIVATE_HEX)); + toBytes(CLIENT_PRIVATE_HEX)); private final Ed25519PrivateKeyParameters serverLongTermPrivateKey = new Ed25519PrivateKeyParameters( - fromHex(SERVER_PRIVATE_HEX)); + toBytes(SERVER_PRIVATE_HEX)); private @NonNullByDefault({}) X25519PrivateKeyParameters serverKey; private @NonNullByDefault({}) X25519PublicKeyParameters clientKey; From 7adc7f4e2834168fab3d7bb8e5bfb7bbd55b5297 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 26 Sep 2025 15:47:35 +0100 Subject: [PATCH 034/177] pairing setup now working Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/crypto/CryptoUtils.java | 3 - .../homekit/internal/crypto/SRPclient.java | 98 ++++++++++++------- .../hap_services/PairSetupClient.java | 43 ++++---- .../hap_services/PairVerifyClient.java | 33 ++++--- .../binding/homekit/internal/SRPserver.java | 8 +- .../internal/TestAppleTestVectors.java | 2 +- .../homekit/internal/TestPairSetup.java | 18 ++-- 7 files changed, 115 insertions(+), 90 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index 09b5a25e46171..65692ea8ea3a8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -35,8 +35,6 @@ import org.bouncycastle.crypto.signers.Ed25519Signer; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Utility class for cryptographic operations used in HomeKit communication. @@ -45,7 +43,6 @@ */ @NonNullByDefault public class CryptoUtils { - private static final Logger logger = LoggerFactory.getLogger(CryptoUtils.class); public static byte[] concat(byte[]... parts) { int total = Arrays.stream(parts).mapToInt(p -> p.length).sum(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index bbd46fdf662c9..c973f0021a87b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -51,6 +51,19 @@ public class SRPclient { private @Nullable Ed25519PublicKeyParameters serverLongTermPublicKey = null; + /** + * M1 - Simplified constructor when user and client private key are not provided. + * + * @param password the password (P) used for authentication. + * @param serverSalt the salt (s) provided by the server. + * @param serverPublicKey the server's public SRP key (B). + * + * @throws Exception if an error occurs during initialization. + */ + public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey) throws Exception { + this(password, serverSalt, serverPublicKey, null, null); + } + /** * M2 — Initializes the SRP client with the given password, salt and server public SRP key. * @@ -110,22 +123,42 @@ public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey, @Nu M2 = sha512(concat(toUnsigned(A, 384), M1, K)); } + public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Exception { + Ed25519PublicKeyParameters serverLongTermPublicKey = this.serverLongTermPublicKey; + if (serverLongTermPublicKey == null) { + throw new IllegalStateException("Accessory long-term public key not yet available"); + } + return serverLongTermPublicKey; + } + + public byte[] getScramblingParameter() { + return toUnsigned(u, 64); + } + + public byte[] getSymmetricKey() { + return generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + } + + public void m4VerifyServerProof(byte[] serverProof) throws Exception { + if (!Arrays.equals(M2, serverProof)) { + throw new SecurityException("SRP server proof mismatch"); + } + } + /** - * M1 - Simplified constructor when user and client private key are not provided. + * M5 - Creates an encrypted TLV containing controller information to be sent to the accessory. + * The TLV includes the client's pairing Id and the client's LTPK, plus also a signature over a + * concatenation of { shared session key, client pairing identifier, client LTPK } created by + * the client's long term secret key. * - * @param password the password (P) used for authentication. - * @param serverSalt the salt (s) provided by the server. - * @param serverPublicKey the server's public SRP key (B). - * - * @throws Exception if an error occurs during initialization. + * @param pairingId the pairing identifier. + * @param controllerLongTermPrivateKey the controller's long-term private key for signing. + * @return the encrypted controller information as a byte array. + * @throws Exception if an error occurs during the encryption or signing process. */ - public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey) throws Exception { - this(password, serverSalt, serverPublicKey, null, null); - } - - public byte[] createEncryptedControllerInfo(byte[] pairingId, - Ed25519PrivateKeyParameters controllerLongTermPrivateKey) throws Exception { - byte[] sharedKey = generateHkdfKey(toUnsigned(S, 384), PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); + public byte[] m5EncodeClientInfoAndSign(byte[] pairingId, Ed25519PrivateKeyParameters controllerLongTermPrivateKey) + throws Exception { + byte[] sharedKey = generateHkdfKey(K, PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); byte[] signingKey = controllerLongTermPrivateKey.generatePublicKey().getEncoded(); byte[] payload = concat(sharedKey, pairingId, signingKey); byte[] signature = signMessage(controllerLongTermPrivateKey, payload); @@ -140,23 +173,16 @@ public byte[] createEncryptedControllerInfo(byte[] pairingId, return ciphertext; } - public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Exception { - Ed25519PublicKeyParameters serverLongTermPublicKey = this.serverLongTermPublicKey; - if (serverLongTermPublicKey == null) { - throw new IllegalStateException("Accessory long-term public key not yet available"); - } - return serverLongTermPublicKey; - } - - public byte[] getSymmetricKey() { - return generateHkdfKey(toUnsigned(S, 384), PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); - } - - public byte[] getScramblingParameter() { - return toUnsigned(u, 64); - } - - public void verifyEncryptedAccessoryInfo(byte[] cipherText) throws Exception { + /** + * M6 - Decrypts the accessory's sub TLV containing information received in M6. Extracts the + * server pairing identifier, server LTPK, and server signature. Then validates the server + * signature against a local copy created using the provided LTPK over a locally created + * concatentation of { shared key, pairing identifier, accessory LTPK} . + * + * @param cipherText the encrypted accessory information received from the accessory. + * @throws Exception if an error occurs during decryption or signature verification. + */ + public void m6DecodeServerInfoAndVerify(byte[] cipherText) throws Exception { byte[] plainText = decrypt(getSymmetricKey(), PS_M6_NONCE, cipherText); Map subTlv = Tlv8Codec.decode(plainText); @@ -168,17 +194,13 @@ public void verifyEncryptedAccessoryInfo(byte[] cipherText) throws Exception { throw new SecurityException("Missing accessory credentials in M6"); } - byte[] sharedKey = generateHkdfKey(toUnsigned(S, 384), PAIR_ACCESSORY_SIGN_SALT, PAIR_ACCESSORY_SIGN_INFO); + byte[] sharedKey = generateHkdfKey(K, PAIR_ACCESSORY_SIGN_SALT, PAIR_ACCESSORY_SIGN_INFO); byte[] payload = concat(sharedKey, pairingId, signingKey); Ed25519PublicKeyParameters serverLongTermPublicKey = new Ed25519PublicKeyParameters(signingKey, 0); - verifySignature(serverLongTermPublicKey, payload, signature); - this.serverLongTermPublicKey = serverLongTermPublicKey; - } - - public void verifyServerProof(byte[] serverProof) throws Exception { - if (!Arrays.equals(M2, serverProof)) { - throw new SecurityException("SRP server proof mismatch"); + if (!verifySignature(serverLongTermPublicKey, payload, signature)) { + throw new SecurityException("Accessory signature verification failed"); } + this.serverLongTermPublicKey = serverLongTermPublicKey; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 089756fab6fd7..d78e504fcccb9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -69,7 +69,7 @@ public PairSetupClient(IpTransport ipTransport, String pairingId, * @throws Exception if any step of the pairing process fails */ public Ed25519PublicKeyParameters pair() throws Exception { - SRPclient client = doStepM1(); + SRPclient client = m1Execute(); return client.getAccessoryLongTermPublicKey(); } @@ -80,14 +80,14 @@ public Ed25519PublicKeyParameters pair() throws Exception { * @throws InterruptedException if the operation is interrupted * @throws Exception if an error occurs during execution */ - private SRPclient doStepM1() throws Exception { - logger.debug("Starting Pair-Setup M1"); + private SRPclient m1Execute() throws Exception { + logger.debug("Pair-Setup M1: Send pairing start request to server"); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.METHOD.key, new byte[] { PairingMethod.SETUP.value }); Validator.validate(PairingMethod.SETUP, tlv); byte[] response1 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); - return doStepM2(response1); + return m2Execute(response1); } /** @@ -97,15 +97,15 @@ private SRPclient doStepM1() throws Exception { * @param response1 byte array containing the response from step M1 * @throws Exception if an error occurs during processing */ - private SRPclient doStepM2(byte[] response1) throws Exception { - logger.debug("Starting Pair-Setup M2"); + private SRPclient m2Execute(byte[] response1) throws Exception { + logger.debug("Pair-Setup M2: Read server salt and PK; initialize SRP client"); Map tlv = Tlv8Codec.decode(response1); Validator.validate(PairingMethod.SETUP, tlv); byte[] serverSalt = tlv.get(TlvType.SALT.key); byte[] serverPublicKey = tlv.get(TlvType.PUBLIC_KEY.key); SRPclient client = new SRPclient(password, Objects.requireNonNull(serverSalt), Objects.requireNonNull(serverPublicKey)); - return doStepM3(client); + return m3Execute(client); } /** @@ -114,15 +114,15 @@ private SRPclient doStepM2(byte[] response1) throws Exception { * @return byte array containing the response from the accessory * @throws Exception if an error occurs during processing */ - private SRPclient doStepM3(SRPclient client) throws Exception { - logger.debug("Starting Pair-Setup M3"); + private SRPclient m3Execute(SRPclient client) throws Exception { + logger.debug("Pair-Setup M3: Send client PK and M1 proof to server"); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M3.value }, // TlvType.PUBLIC_KEY.key, CryptoUtils.toUnsigned(client.A, 384), // TlvType.PROOF.key, client.M1); Validator.validate(PairingMethod.SETUP, tlv); byte[] response3 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); - return doStepM4(client, response3); + return m4Execute(client, response3); } /** @@ -131,30 +131,31 @@ private SRPclient doStepM3(SRPclient client) throws Exception { * @param response3 byte array containing the response from step M3 * @throws Exception if an error occurs during processing */ - private SRPclient doStepM4(SRPclient client, byte[] response3) throws Exception { - logger.debug("Starting Pair-Setup M4"); + private SRPclient m4Execute(SRPclient client, byte[] response3) throws Exception { + logger.debug("Pair-Setup M4: Read server M2 proof; and verify it"); Map tlv = Tlv8Codec.decode(response3); Validator.validate(PairingMethod.SETUP, tlv); byte[] proof = tlv.get(TlvType.PROOF.key); - client.verifyServerProof(Objects.requireNonNull(proof)); - return doStepM5(client); + client.m4VerifyServerProof(Objects.requireNonNull(proof)); + return m5Execute(client); } /** * Executes step M5 of the pairing process: Exchange encrypted identifiers. + * Sends the session key, pairing identifier, client LTPK, and signature to the accessory. * * @return byte array containing the response from the accessory * @throws Exception if an error occurs during processing */ - private SRPclient doStepM5(SRPclient client) throws Exception { - logger.debug("Starting Pair-Setup M5"); - byte[] cipherText = client.createEncryptedControllerInfo(pairingId, clientLongTermPrivateKey); + private SRPclient m5Execute(SRPclient client) throws Exception { + logger.debug("Pair-Setup M5: Send client session key, pairing id, LTPK, and sig to server"); + byte[] cipherText = client.m5EncodeClientInfoAndSign(pairingId, clientLongTermPrivateKey); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M5.value }, // TlvType.ENCRYPTED_DATA.key, cipherText); Validator.validate(PairingMethod.SETUP, tlv); byte[] response5 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); - return doStepM6(client, response5); + return m6Execute(client, response5); } /** @@ -164,12 +165,12 @@ private SRPclient doStepM5(SRPclient client) throws Exception { * @param response5 byte array containing the response from step M5 * @throws Exception if an error occurs during processing */ - private SRPclient doStepM6(SRPclient client, byte[] response5) throws Exception { - logger.debug("Starting Pair-Setup M6"); + private SRPclient m6Execute(SRPclient client, byte[] response5) throws Exception { + logger.debug("Pair-Setup M6: Read server session key, pairing id, LTPK, and sig; and verify it"); Map tlv = Tlv8Codec.decode(response5); Validator.validate(PairingMethod.SETUP, tlv); byte[] ciphertext = tlv.get(TlvType.ENCRYPTED_DATA.key); - client.verifyEncryptedAccessoryInfo(Objects.requireNonNull(ciphertext)); + client.m6DecodeServerInfoAndVerify(Objects.requireNonNull(ciphertext)); return client; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index 832575f857b87..e5605bfa75705 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -27,6 +27,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.ErrorCode; import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; @@ -77,24 +78,24 @@ public PairVerifyClient(IpTransport ipTransport, String pairingId, * @throws Exception if any step of the pairing process fails */ public AsymmetricSessionKeys verify() throws Exception { - doStep1(); + m1Execute(); return new AsymmetricSessionKeys(readKey, writeKey); } // M1 — Create new random client ephemeral X25519 public key and send it to server - private void doStep1() throws Exception { - logger.debug("Starting Pair-Verify M1"); + private void m1Execute() throws Exception { + logger.debug("Pair-Verify M1: Send verification start request with client ephemeral X25519 PK to server"); byte[] clientKey = this.clientKey.generatePublicKey().getEncoded(); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.PUBLIC_KEY.key, clientKey); Validator.validate(PairingMethod.VERIFY, tlv); - doStep2(ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); + m2Execute(ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); } // M2 — Receive server ephemeral X25519 public key and encrypted TLV - private void doStep2(byte[] response1) throws Exception { - logger.debug("Starting Pair-Verify M2"); + private void m2Execute(byte[] response1) throws Exception { + logger.debug("Pair-Verify M2: Read server ephemeral X25519 PK and encrypted id; validate signature"); Map tlv = Tlv8Codec.decode(response1); Validator.validate(PairingMethod.VERIFY, tlv); @@ -114,14 +115,16 @@ private void doStep2(byte[] response1) throws Exception { if (identifier == null || signature == null) { throw new SecurityException("Accessory identifier or signature missing"); } - verifySignature(serverLongTermPublicKey, identifier, signature); + if (!verifySignature(serverLongTermPublicKey, identifier, signature)) { + // TODO throw new SecurityException("Accessory signature verification failed"); + } - doStep3(); + m3Execute(); } // M3 — Send encrypted controller identifier and signature - private void doStep3() throws Exception { - logger.debug("Starting Pair-Verify M3"); + private void m3Execute() throws Exception { + logger.debug("Pair-Verify M3: Send encrypted controller id with signature"); byte[] sharedKey = generateHkdfKey(sharedSecret, PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); byte[] signingKey = clientLongTermPrivateKey.generatePublicKey().getEncoded(); byte[] payload = concat(sharedKey, pairingId, signingKey); @@ -139,12 +142,12 @@ private void doStep3() throws Exception { TlvType.ENCRYPTED_DATA.key, ciphertext); Validator.validate(PairingMethod.VERIFY, tlv); - doStep4(ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); + m4Execute(ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); } // M4 — Final confirmation - private void doStep4(byte[] response3) throws Exception { - logger.debug("Starting Pair-Verify M4"); + private void m4Execute(byte[] response3) throws Exception { + logger.debug("Pair-Verify M4: Validation confirmed; derive session keys"); Map tlv = Tlv8Codec.decode(response3); Validator.validate(PairingMethod.VERIFY, tlv); readKey = CryptoUtils.generateHkdfKey(sharedSecret, CONTROL_SALT, CONTROL_READ_ENCRYPTION_KEY); @@ -169,8 +172,10 @@ public static class Validator { */ public static void validate(PairingMethod method, Map tlv) throws SecurityException { if (tlv.containsKey(TlvType.ERROR.key)) { + byte[] err = tlv.get(TlvType.ERROR.key); + ErrorCode code = err != null && err.length > 0 ? ErrorCode.from(err[0]) : ErrorCode.UNKNOWN; throw new SecurityException( - "Pairing method '%s' action failed with unknown error".formatted(method.name())); + "Pairing method '%s' action failed with error '%s'".formatted(method.name(), code.name())); } byte[] state = tlv.get(TlvType.STATE.key); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java index c391f5bb7af0d..41eee72778d1e 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -87,7 +87,7 @@ public SRPserver(String password, byte[] serverSalt, byte[] serverPairingId, B = k.multiply(v).add(gb).mod(N); } - public byte[] createServerProof(byte[] clientPublicKeyA) throws Exception { + public byte[] m3CreateServerProof(byte[] clientPublicKeyA) throws Exception { BigInteger clientPublicA = new BigInteger(1, clientPublicKeyA); if (clientPublicA.mod(N).equals(BigInteger.ZERO)) { throw new SecurityException("Invalid client public key"); @@ -118,8 +118,8 @@ public byte[] createServerProof(byte[] clientPublicKeyA) throws Exception { return sha512(concat(toUnsigned(clientPublicA, 384), M1, K)); } - public byte[] createEncryptedAccessoryInfo() throws Exception { - byte[] sharedKey = generateHkdfKey(toUnsigned(S, 384), PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); + public byte[] m5EncodeServerInfoAndSign() throws Exception { + byte[] sharedKey = generateHkdfKey(K, PAIR_ACCESSORY_SIGN_SALT, PAIR_ACCESSORY_SIGN_INFO); byte[] signingKey = serverLongTermPrivateKey.generatePublicKey().getEncoded(); byte[] payload = concat(sharedKey, serverPairingId, signingKey); byte[] signature = signMessage(serverLongTermPrivateKey, payload); @@ -134,6 +134,6 @@ public byte[] createEncryptedAccessoryInfo() throws Exception { } public byte[] getSymmetricKey() { - return generateHkdfKey(toUnsigned(S, 384), PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + return generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java index a273978d44aec..195d005d2b063 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java @@ -238,7 +238,7 @@ void testServerVectors() { assertArrayEquals(B, CryptoUtils.toUnsigned(server.B, 384)); assertArrayEquals(v, CryptoUtils.toUnsigned(server.v, 384)); - assertDoesNotThrow(() -> server.createServerProof(A)); + assertDoesNotThrow(() -> server.m3CreateServerProof(A)); assertArrayEquals(u, CryptoUtils.toUnsigned(server.u, 64)); assertArrayEquals(S, CryptoUtils.toUnsigned(server.S, 384)); assertArrayEquals(K, server.K); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index 1788abc64c861..06829d98a74c4 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -114,9 +114,9 @@ void testPairSetup() throws Exception { // process the message based on the pairing process Mx state return switch (state[0]) { - case 1 -> getServerResponseM1(server, serverSalt); - case 3 -> getServerResponseM3(server, tlv, client); - case 5 -> getServerResponseM5(server); + case 1 -> m1GetServerResponse(server, serverSalt); + case 3 -> m3GetServerResponse(server, tlv, client); + case 5 -> m5GetServerResponse(server); default -> throw new IllegalArgumentException("Unexpected state"); }; @@ -126,7 +126,7 @@ void testPairSetup() throws Exception { client.pair(); } - private byte[] getServerResponseM1(SRPserver server, byte[] serverSalt) { + private byte[] m1GetServerResponse(SRPserver server, byte[] serverSalt) { Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M2.value }, // TlvType.SALT.key, serverSalt, // salt @@ -136,10 +136,10 @@ TlvType.PUBLIC_KEY.key, toUnsigned(server.B, 384) // server public key return Tlv8Codec.encode(tlv); } - private byte[] getServerResponseM3(SRPserver server, Map tlv2, PairSetupClient client) + private byte[] m3GetServerResponse(SRPserver server, Map tlv2, PairSetupClient client) throws Exception { clientPublicKey = tlv2.get(TlvType.PUBLIC_KEY.key); - byte[] serverProof = server.createServerProof(Objects.requireNonNull(clientPublicKey)); + byte[] serverProof = server.m3CreateServerProof(Objects.requireNonNull(clientPublicKey)); Map tlv3 = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M4.value }, // TlvType.PROOF.key, serverProof // server proof @@ -148,11 +148,11 @@ private byte[] getServerResponseM3(SRPserver server, Map tlv2, return Tlv8Codec.encode(tlv3); } - private byte[] getServerResponseM5(SRPserver server) throws Exception { - byte[] cipertext = server.createEncryptedAccessoryInfo(); + private byte[] m5GetServerResponse(SRPserver server) throws Exception { + byte[] cipherText = server.m5EncodeServerInfoAndSign(); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M6.value }, // - TlvType.ENCRYPTED_DATA.key, cipertext); + TlvType.ENCRYPTED_DATA.key, cipherText); PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); return Tlv8Codec.encode(tlv); } From 84a2439e780286ec38004538082cf285b469e632 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 28 Sep 2025 16:07:53 +0100 Subject: [PATCH 035/177] refactoring Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 10 ++- .../homekit/internal/crypto/CryptoUtils.java | 22 ++--- .../homekit/internal/crypto/SRPclient.java | 83 +++++++++--------- .../handler/HomekitBaseServerHandler.java | 22 ++--- .../hap_services/PairRemoveClient.java | 17 ++-- .../hap_services/PairSetupClient.java | 71 +++++++-------- .../hap_services/PairVerifyClient.java | 87 +++++++++---------- .../homekit/internal/TestPairSetup.java | 17 ++-- .../homekit/internal/TestPairVerify.java | 73 ++++++++-------- 9 files changed, 201 insertions(+), 201 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index a24a0de86ef56..daf1b7561d9c0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -49,13 +49,15 @@ public class HomekitBindingConstants { public static final String PROPERTY_ACCESSORY_UID = "accessoryUID"; public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; - public static final String PROPERTY_CONTROLLER_PRIVATE_KEY = "controllerPrivateKey"; + public static final String PROPERTY_CONTROLLER_PRIVATE_KEY = "controllerSecretKey"; public static final String PROPERTY_ACCESSORY_PUBLIC_KEY = "accessoryPublicKey"; // HomeKit HTTP URI endpoints and content types - public static final String ENDPOINT_PAIRING = "pair-setup"; - public static final String ENDPOINT_ACCESSORIES = "accessories"; - public static final String ENDPOINT_CHARACTERISTICS = "characteristics"; + public static final String ENDPOINT_ACCESSORIES = "/accessories"; + public static final String ENDPOINT_CHARACTERISTICS = "/characteristics"; + public static final String ENDPOINT_PAIR_SETUP = "/pair-setup"; + public static final String ENDPOINT_PAIR_VERIFY = "/pair-verify"; + public static final String CONTENT_TYPE_PAIRING = "application/pairing+tlv8"; public static final String CONTENT_TYPE_HAP = "application/hap+json"; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index 65692ea8ea3a8..68bab9c1681f8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -33,6 +33,7 @@ import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; import org.bouncycastle.crypto.params.X25519PublicKeyParameters; import org.bouncycastle.crypto.signers.Ed25519Signer; +import org.bouncycastle.util.encoders.Hex; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -106,10 +107,10 @@ public static byte[] generateNonce(int counter) { } // Compute shared secret using ECDH - public static byte[] generateSharedSecret(X25519PrivateKeyParameters clientPrivateKey, - X25519PublicKeyParameters serverPublicKey) { + public static byte[] generateSharedSecret(X25519PrivateKeyParameters clientEphemeralSecretKey, + X25519PublicKeyParameters serverEphemeralPublicKey) { byte[] secret = new byte[32]; - clientPrivateKey.generateSecret(serverPublicKey, secret, 0); + clientEphemeralSecretKey.generateSecret(serverEphemeralPublicKey, secret, 0); return secret; } @@ -125,9 +126,9 @@ public static byte[] sha512(byte[] data) throws Exception { } // Sign message with Ed25519 - public static byte[] signMessage(Ed25519PrivateKeyParameters privateKey, byte[] message) { + public static byte[] signMessage(Ed25519PrivateKeyParameters secretKey, byte[] message) { Ed25519Signer signer = new Ed25519Signer(); - signer.init(true, privateKey); + signer.init(true, secretKey); signer.update(message, 0, message.length); return signer.generateSignature(); } @@ -160,14 +161,7 @@ public static byte[] toBytes(String hexBlock) { } public static String toHex(byte @Nullable [] bytes) { - if (bytes == null) { - return "null"; - } - StringBuilder sb = new StringBuilder(); - for (byte b : bytes) { - sb.append(String.format("%02X", b)).append(' '); - } - return sb.toString().trim(); // remove trailing space + return bytes == null ? "null" : Hex.toHexString(bytes); } /** @@ -205,7 +199,7 @@ public static byte[] toUnsigned(BigInteger bigInteger, int length) { return padded; } - public static boolean verifySignature(Ed25519PublicKeyParameters publicKey, byte[] payLoad, byte[] signature) { + public static boolean verifySignature(Ed25519PublicKeyParameters publicKey, byte[] signature, byte[] payLoad) { Ed25519Signer verifier = new Ed25519Signer(); verifier.init(false, publicKey); verifier.update(payLoad, 0, payLoad.length); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index c973f0021a87b..b26233f159e86 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -54,45 +54,45 @@ public class SRPclient { /** * M1 - Simplified constructor when user and client private key are not provided. * - * @param password the password (P) used for authentication. + * @param passwordP the password (P) used for authentication. * @param serverSalt the salt (s) provided by the server. - * @param serverPublicKey the server's public SRP key (B). + * @param serverEphemeralPublicKey the server's public SRP key (B). * * @throws Exception if an error occurs during initialization. */ - public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey) throws Exception { - this(password, serverSalt, serverPublicKey, null, null); + public SRPclient(String passwordP, byte[] serverSalt, byte[] serverEphemeralPublicKey) throws Exception { + this(passwordP, serverSalt, serverEphemeralPublicKey, null, null); } /** * M2 — Initializes the SRP client with the given password, salt and server public SRP key. * - * @param password the password (P) used for authentication. + * @param password_p the password (P) used for authentication. * @param serverSalt the salt (s) provided by the server. - * @param serverPublicKey the server's public SRP key (B). - * @param user the username (I). If null, "Pair-Setup" is used. - * @param clientPrivateKey the client's private SRP key (a). If null, a random key is generated. + * @param serverEphemeralPublicKey the server's public SRP key (B). + * @param user_I the username (I). If null, "Pair-Setup" is used. + * @param clientEphemeralSecretKey the client's private SRP key (a). If null, a random key is generated. * * @throws Exception if an error occurs during initialization. */ - public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey, @Nullable String user, - byte @Nullable [] clientPrivateKey) throws Exception { + public SRPclient(String password_p, byte[] serverSalt, byte[] serverEphemeralPublicKey, @Nullable String user_I, + byte @Nullable [] clientEphemeralSecretKey) throws Exception { // set username, salt and server public key s = serverSalt; - B = new BigInteger(1, serverPublicKey); - I = user != null ? user : PAIR_SETUP; // default username is "Pair-Setup" + B = new BigInteger(1, serverEphemeralPublicKey); + I = user_I != null ? user_I : PAIR_SETUP; // default username is "Pair-Setup" // Apply or create ephemeral a and compute public A - byte[] clientKey = clientPrivateKey; - if (clientKey == null) { - clientKey = new byte[32]; - new SecureRandom().nextBytes(clientKey); + byte[] client_a = clientEphemeralSecretKey; + if (client_a == null) { + client_a = new byte[32]; + new SecureRandom().nextBytes(client_a); } - a = new BigInteger(1, clientKey); + a = new BigInteger(1, client_a); A = g.modPow(a, N); // Compute hash x = H(salt || H(username || ":" || password)) - byte[] hIP = sha512((I + ":" + password).getBytes(StandardCharsets.UTF_8)); + byte[] hIP = sha512((I + ":" + password_p).getBytes(StandardCharsets.UTF_8)); byte[] xHash = sha512(concat(serverSalt, hIP)); x = new BigInteger(1, xHash); @@ -123,7 +123,11 @@ public SRPclient(String password, byte[] serverSalt, byte[] serverPublicKey, @Nu M2 = sha512(concat(toUnsigned(A, 384), M1, K)); } - public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Exception { + public byte[] getScramblingParameter() { + return toUnsigned(u, 64); + } + + public Ed25519PublicKeyParameters getServerLongTermPublicKey() throws Exception { Ed25519PublicKeyParameters serverLongTermPublicKey = this.serverLongTermPublicKey; if (serverLongTermPublicKey == null) { throw new IllegalStateException("Accessory long-term public key not yet available"); @@ -131,11 +135,7 @@ public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Excepti return serverLongTermPublicKey; } - public byte[] getScramblingParameter() { - return toUnsigned(u, 64); - } - - public byte[] getSymmetricKey() { + public byte[] getSharedKey() { return generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); } @@ -152,25 +152,24 @@ public void m4VerifyServerProof(byte[] serverProof) throws Exception { * the client's long term secret key. * * @param pairingId the pairing identifier. - * @param controllerLongTermPrivateKey the controller's long-term private key for signing. + * @param clientLongTermSecretKey the controller's long-term private key for signing. * @return the encrypted controller information as a byte array. * @throws Exception if an error occurs during the encryption or signing process. */ - public byte[] m5EncodeClientInfoAndSign(byte[] pairingId, Ed25519PrivateKeyParameters controllerLongTermPrivateKey) + public byte[] m5EncodeClientInfoAndSign(byte[] pairingId, Ed25519PrivateKeyParameters clientLongTermSecretKey) throws Exception { byte[] sharedKey = generateHkdfKey(K, PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); - byte[] signingKey = controllerLongTermPrivateKey.generatePublicKey().getEncoded(); - byte[] payload = concat(sharedKey, pairingId, signingKey); - byte[] signature = signMessage(controllerLongTermPrivateKey, payload); + byte[] clientSigningKey = clientLongTermSecretKey.generatePublicKey().getEncoded(); + byte[] clientSignature = signMessage(clientLongTermSecretKey, concat(sharedKey, pairingId, clientSigningKey)); Map subTlv = Map.of( // TlvType.IDENTIFIER.key, pairingId, // - TlvType.PUBLIC_KEY.key, signingKey, // - TlvType.SIGNATURE.key, signature); + TlvType.PUBLIC_KEY.key, clientSigningKey, // + TlvType.SIGNATURE.key, clientSignature); - byte[] plaintext = Tlv8Codec.encode(subTlv); - byte[] ciphertext = encrypt(getSymmetricKey(), PS_M5_NONCE, plaintext); - return ciphertext; + byte[] plainText = Tlv8Codec.encode(subTlv); + byte[] cipherText = encrypt(getSharedKey(), PS_M5_NONCE, plainText); + return cipherText; } /** @@ -183,22 +182,22 @@ public byte[] m5EncodeClientInfoAndSign(byte[] pairingId, Ed25519PrivateKeyParam * @throws Exception if an error occurs during decryption or signature verification. */ public void m6DecodeServerInfoAndVerify(byte[] cipherText) throws Exception { - byte[] plainText = decrypt(getSymmetricKey(), PS_M6_NONCE, cipherText); + byte[] plainText = decrypt(getSharedKey(), PS_M6_NONCE, cipherText); Map subTlv = Tlv8Codec.decode(plainText); - byte[] pairingId = subTlv.get(TlvType.IDENTIFIER.key); - byte[] signingKey = subTlv.get(TlvType.PUBLIC_KEY.key); - byte[] signature = subTlv.get(TlvType.SIGNATURE.key); + byte[] serverPairingId = subTlv.get(TlvType.IDENTIFIER.key); + byte[] serverSigningKey = subTlv.get(TlvType.PUBLIC_KEY.key); + byte[] serverSignature = subTlv.get(TlvType.SIGNATURE.key); - if (pairingId == null || signingKey == null || signature == null) { + if (serverPairingId == null || serverSigningKey == null || serverSignature == null) { throw new SecurityException("Missing accessory credentials in M6"); } byte[] sharedKey = generateHkdfKey(K, PAIR_ACCESSORY_SIGN_SALT, PAIR_ACCESSORY_SIGN_INFO); - byte[] payload = concat(sharedKey, pairingId, signingKey); - Ed25519PublicKeyParameters serverLongTermPublicKey = new Ed25519PublicKeyParameters(signingKey, 0); - if (!verifySignature(serverLongTermPublicKey, payload, signature)) { + Ed25519PublicKeyParameters serverLongTermPublicKey = new Ed25519PublicKeyParameters(serverSigningKey, 0); + if (!verifySignature(serverLongTermPublicKey, serverSignature, + concat(sharedKey, serverPairingId, serverSigningKey))) { throw new SecurityException("Accessory signature verification failed"); } this.serverLongTermPublicKey = serverLongTermPublicKey; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 49d23711b2e60..e83bf9ac3506d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -71,7 +71,7 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected @NonNullByDefault({}) Integer accessoryId; protected @NonNullByDefault({}) IpTransport ipTransport; - protected @Nullable Ed25519PrivateKeyParameters controllerLongTermPrivateKey = null; + protected @Nullable Ed25519PrivateKeyParameters controllerLongTermSecretKey = null; protected @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; public HomekitBaseServerHandler(Thing thing) { @@ -209,14 +209,14 @@ private void initializePairing() { } restoreLongTermKeys(); - Ed25519PrivateKeyParameters controllerLongTermPrivateKey = this.controllerLongTermPrivateKey; + Ed25519PrivateKeyParameters controllerLongTermSecretKey = this.controllerLongTermSecretKey; Ed25519PublicKeyParameters accessoryLongTermPublicKey = this.accessoryLongTermPublicKey; - if (controllerLongTermPrivateKey != null && accessoryLongTermPublicKey != null) { + if (controllerLongTermSecretKey != null && accessoryLongTermPublicKey != null) { try { logger.debug("Starting Pair-Verify with existing key for accessory {}", accessoryId); PairVerifyClient client = new PairVerifyClient(ipTransport, accessoryId.toString(), - controllerLongTermPrivateKey, accessoryLongTermPublicKey); + controllerLongTermSecretKey, accessoryLongTermPublicKey); ipTransport.setSessionKeys(client.verify()); rwService = new CharacteristicReadWriteService(ipTransport); @@ -228,20 +228,20 @@ private void initializePairing() { return; } catch (Exception e) { logger.debug("Restored pairing was not verified for accessory {}", accessoryId, e); - this.controllerLongTermPrivateKey = null; + this.controllerLongTermSecretKey = null; storeLongTermKeys(); // fall through to create new pairing } } // Create new controller private key - controllerLongTermPrivateKey = new Ed25519PrivateKeyParameters(new SecureRandom()); + controllerLongTermSecretKey = new Ed25519PrivateKeyParameters(new SecureRandom()); logger.debug("Created new controller long term private key for accessory {}", accessoryId); try { logger.debug("Starting Pair-Setup for accessory {}", accessoryId); PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, thing.getUID().toString(), - controllerLongTermPrivateKey, pairingCode); + controllerLongTermSecretKey, pairingCode); accessoryLongTermPublicKey = pairSetupClient.pair(); this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; @@ -250,12 +250,12 @@ private void initializePairing() { // Perform Pair-Verify immediately after Pair-Setup PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, accessoryId.toString(), - controllerLongTermPrivateKey, accessoryLongTermPublicKey); + controllerLongTermSecretKey, accessoryLongTermPublicKey); ipTransport.setSessionKeys(pairVerifyClient.verify()); rwService = new CharacteristicReadWriteService(ipTransport); - this.controllerLongTermPrivateKey = controllerLongTermPrivateKey; + this.controllerLongTermSecretKey = controllerLongTermSecretKey; logger.debug("Pairing and verification completed for accessory {}", accessoryId); storeLongTermKeys(); @@ -274,7 +274,7 @@ private void initializePairing() { */ private void restoreLongTermKeys() { String encoded = thing.getProperties().get(PROPERTY_CONTROLLER_PRIVATE_KEY); - controllerLongTermPrivateKey = encoded == null ? null + controllerLongTermSecretKey = encoded == null ? null : new Ed25519PrivateKeyParameters(Base64.getDecoder().decode(encoded), 0); encoded = thing.getProperties().get(PROPERTY_ACCESSORY_PUBLIC_KEY); @@ -287,7 +287,7 @@ private void restoreLongTermKeys() { * The private key is stored as a Base64-encoded string. */ private void storeLongTermKeys() { - Ed25519PrivateKeyParameters controllerKey = this.controllerLongTermPrivateKey; + Ed25519PrivateKeyParameters controllerKey = this.controllerLongTermSecretKey; String property = controllerKey == null ? null : Base64.getEncoder().encodeToString(controllerKey.getEncoded()); thing.setProperty(PROPERTY_CONTROLLER_PRIVATE_KEY, property); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index 9f3dc78b4b0d9..28cd00c9c7b5f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -18,6 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; +import org.openhab.binding.homekit.internal.enums.ErrorCode; import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; @@ -39,20 +40,20 @@ public class PairRemoveClient { private final Logger logger = LoggerFactory.getLogger(PairRemoveClient.class); private final IpTransport ipTransport; - private final String pairingId; + private final String serverPairingId; - public PairRemoveClient(IpTransport ipTransport, String pairingId) { - logger.debug("Created with pairingId:{}", pairingId); + public PairRemoveClient(IpTransport ipTransport, String serverPairingId) { + logger.debug("Created with pairingId:{}", serverPairingId); this.ipTransport = ipTransport; - this.pairingId = pairingId; + this.serverPairingId = serverPairingId; } public void remove() throws Exception { - logger.debug("Starting Pair-Remove"); + logger.debug("Pair-Remove: starting removal"); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.METHOD.key, new byte[] { PairingMethod.REMOVE.value }, // - TlvType.IDENTIFIER.key, pairingId.getBytes(StandardCharsets.UTF_8)); + TlvType.IDENTIFIER.key, serverPairingId.getBytes(StandardCharsets.UTF_8)); Validator.validate(PairingMethod.REMOVE, tlv); byte[] response = ipTransport.post(ENDPOINT_PAIR_REMOVE, CONTENT_TYPE, Tlv8Codec.encode(tlv)); @@ -76,8 +77,10 @@ protected static class Validator { */ public static void validate(PairingMethod method, Map tlv) throws SecurityException { if (tlv.containsKey(TlvType.ERROR.key)) { + byte[] err = tlv.get(TlvType.ERROR.key); + ErrorCode code = err != null && err.length > 0 ? ErrorCode.from(err[0]) : ErrorCode.UNKNOWN; throw new SecurityException( - "Pairing method '%s' action failed with unknown error".formatted(method.name())); + "Pairing method '%s' action failed with error '%s'".formatted(method.name(), code.name())); } byte[] state = tlv.get(TlvType.STATE.key); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index d78e504fcccb9..44eda77344bf4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -12,6 +12,9 @@ */ package org.openhab.binding.homekit.internal.hap_services; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.toHex; + import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Objects; @@ -43,23 +46,20 @@ @NonNullByDefault public class PairSetupClient { - private static final String ENDPOINT_PAIR_SETUP = "/pair-setup"; - private static final String CONTENT_TYPE_TLV8 = "application/pairing+tlv8"; - private final Logger logger = LoggerFactory.getLogger(PairSetupClient.class); private final IpTransport ipTransport; private final String password; - private final byte[] pairingId; - private final Ed25519PrivateKeyParameters clientLongTermPrivateKey; + private final byte[] clientPairingId; + private final Ed25519PrivateKeyParameters clientLongTermSecretKey; - public PairSetupClient(IpTransport ipTransport, String pairingId, - Ed25519PrivateKeyParameters clientLongTermPrivateKey, String pairingCode) throws Exception { - logger.debug("Created with pairingId:{}, pairingCode:{}", pairingId, pairingCode); + public PairSetupClient(IpTransport ipTransport, String clientPairingId, + Ed25519PrivateKeyParameters clientLongTermSecretKey, String pairingCode) throws Exception { + logger.debug("Created with client pairingId:{}, pairingCode:{}", clientPairingId, pairingCode); this.ipTransport = ipTransport; this.password = pairingCode; - this.pairingId = pairingId.getBytes(StandardCharsets.UTF_8); - this.clientLongTermPrivateKey = clientLongTermPrivateKey; + this.clientPairingId = clientPairingId.getBytes(StandardCharsets.UTF_8); + this.clientLongTermSecretKey = clientLongTermSecretKey; } /** @@ -70,7 +70,7 @@ public PairSetupClient(IpTransport ipTransport, String pairingId, */ public Ed25519PublicKeyParameters pair() throws Exception { SRPclient client = m1Execute(); - return client.getAccessoryLongTermPublicKey(); + return client.getServerLongTermPublicKey(); } /** @@ -86,57 +86,60 @@ private SRPclient m1Execute() throws Exception { TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.METHOD.key, new byte[] { PairingMethod.SETUP.value }); Validator.validate(PairingMethod.SETUP, tlv); - byte[] response1 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); - return m2Execute(response1); + byte[] m1Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); + return m2Execute(m1Response); } /** * Executes step M2 of the pairing process: Receive salt & accessory SRP public key. * And initializes the SRP client with the received parameters. * - * @param response1 byte array containing the response from step M1 + * @param m1Response byte array containing the response from step M1 * @throws Exception if an error occurs during processing */ - private SRPclient m2Execute(byte[] response1) throws Exception { - logger.debug("Pair-Setup M2: Read server salt and PK; initialize SRP client"); - Map tlv = Tlv8Codec.decode(response1); + private SRPclient m2Execute(byte[] m1Response) throws Exception { + logger.debug("Pair-Setup M2: Read server salt and ephemeral PK; initialize SRP client"); + Map tlv = Tlv8Codec.decode(m1Response); Validator.validate(PairingMethod.SETUP, tlv); byte[] serverSalt = tlv.get(TlvType.SALT.key); byte[] serverPublicKey = tlv.get(TlvType.PUBLIC_KEY.key); + logger.trace("ServerSalt: {}", toHex(serverSalt)); + logger.trace("ServerPKey: {}", toHex(serverPublicKey)); SRPclient client = new SRPclient(password, Objects.requireNonNull(serverSalt), Objects.requireNonNull(serverPublicKey)); return m3Execute(client); } /** - * Executes step M3 of the pairing process: Send client SRP public key & proof. + * Executes step M3 of the pairing process: Send client SRP public key & M1 proof. * * @return byte array containing the response from the accessory * @throws Exception if an error occurs during processing */ private SRPclient m3Execute(SRPclient client) throws Exception { - logger.debug("Pair-Setup M3: Send client PK and M1 proof to server"); + logger.debug("Pair-Setup M3: Send client epehemeral PK and M1 proof to server"); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M3.value }, // TlvType.PUBLIC_KEY.key, CryptoUtils.toUnsigned(client.A, 384), // TlvType.PROOF.key, client.M1); Validator.validate(PairingMethod.SETUP, tlv); - byte[] response3 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); - return m4Execute(client, response3); + byte[] m3Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); + return m4Execute(client, m3Response); } /** * Executes step M4 of the pairing process: Verify accessory SRP proof. * - * @param response3 byte array containing the response from step M3 + * @param m3Response byte array containing the response from step M3 * @throws Exception if an error occurs during processing */ - private SRPclient m4Execute(SRPclient client, byte[] response3) throws Exception { + private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exception { logger.debug("Pair-Setup M4: Read server M2 proof; and verify it"); - Map tlv = Tlv8Codec.decode(response3); + Map tlv = Tlv8Codec.decode(m3Response); Validator.validate(PairingMethod.SETUP, tlv); - byte[] proof = tlv.get(TlvType.PROOF.key); - client.m4VerifyServerProof(Objects.requireNonNull(proof)); + byte[] serverProofM2 = tlv.get(TlvType.PROOF.key); + logger.trace("ServerM2: {}", toHex(serverProofM2)); + client.m4VerifyServerProof(Objects.requireNonNull(serverProofM2)); return m5Execute(client); } @@ -149,28 +152,28 @@ private SRPclient m4Execute(SRPclient client, byte[] response3) throws Exception */ private SRPclient m5Execute(SRPclient client) throws Exception { logger.debug("Pair-Setup M5: Send client session key, pairing id, LTPK, and sig to server"); - byte[] cipherText = client.m5EncodeClientInfoAndSign(pairingId, clientLongTermPrivateKey); + byte[] cipherText = client.m5EncodeClientInfoAndSign(clientPairingId, clientLongTermSecretKey); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M5.value }, // TlvType.ENCRYPTED_DATA.key, cipherText); Validator.validate(PairingMethod.SETUP, tlv); - byte[] response5 = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_TLV8, Tlv8Codec.encode(tlv)); - return m6Execute(client, response5); + byte[] m5Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); + return m6Execute(client, m5Response); } /** * Executes step M6 of the pairing process: Final confirmation & accessory credentials. * Derives and returns the session keys. * - * @param response5 byte array containing the response from step M5 + * @param m5Response byte array containing the response from step M5 * @throws Exception if an error occurs during processing */ - private SRPclient m6Execute(SRPclient client, byte[] response5) throws Exception { + private SRPclient m6Execute(SRPclient client, byte[] m5Response) throws Exception { logger.debug("Pair-Setup M6: Read server session key, pairing id, LTPK, and sig; and verify it"); - Map tlv = Tlv8Codec.decode(response5); + Map tlv = Tlv8Codec.decode(m5Response); Validator.validate(PairingMethod.SETUP, tlv); - byte[] ciphertext = tlv.get(TlvType.ENCRYPTED_DATA.key); - client.m6DecodeServerInfoAndVerify(Objects.requireNonNull(ciphertext)); + byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.key); + client.m6DecodeServerInfoAndVerify(Objects.requireNonNull(cipherText)); return client; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index e5605bfa75705..b240d8b689bc6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -12,6 +12,7 @@ */ package org.openhab.binding.homekit.internal.hap_services; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; @@ -44,31 +45,29 @@ @NonNullByDefault public class PairVerifyClient { - private static final String ENDPOINT_PAIR_VERIFY = "/pair-verify"; - private static final String CONTENT_TYPE_TLV = "application/pairing+tlv8"; - private final Logger logger = LoggerFactory.getLogger(PairVerifyClient.class); private final IpTransport ipTransport; - private final byte[] pairingId; - private final Ed25519PrivateKeyParameters clientLongTermPrivateKey; + private final byte[] clientPairingId; + private final Ed25519PrivateKeyParameters clientLongTermSecretKey; private final Ed25519PublicKeyParameters serverLongTermPublicKey; - private final X25519PrivateKeyParameters clientKey; + private final X25519PrivateKeyParameters clientEphemeralSecretKey; + private @NonNullByDefault({}) X25519PublicKeyParameters serverEphemeralPublicKey; private @NonNullByDefault({}) byte[] sharedSecret; - private @NonNullByDefault({}) byte[] sessionKey; + private @NonNullByDefault({}) byte[] sharedKey; private @NonNullByDefault({}) byte[] readKey; private @NonNullByDefault({}) byte[] writeKey; - public PairVerifyClient(IpTransport ipTransport, String pairingId, - Ed25519PrivateKeyParameters clientLongTermPrivateKey, Ed25519PublicKeyParameters serverLongTermPublicKey) + public PairVerifyClient(IpTransport ipTransport, String clientPairingId, + Ed25519PrivateKeyParameters clientLongTermSecretKey, Ed25519PublicKeyParameters serverLongTermPublicKey) throws Exception { - logger.debug("Created with pairingId:{}", pairingId); + logger.debug("Created with pairingId:{}", clientPairingId); this.ipTransport = ipTransport; - this.pairingId = pairingId.getBytes(StandardCharsets.UTF_8); - this.clientLongTermPrivateKey = clientLongTermPrivateKey; + this.clientPairingId = clientPairingId.getBytes(StandardCharsets.UTF_8); + this.clientLongTermSecretKey = clientLongTermSecretKey; this.serverLongTermPublicKey = serverLongTermPublicKey; - this.clientKey = CryptoUtils.generateX25519KeyPair(); + this.clientEphemeralSecretKey = CryptoUtils.generateX25519KeyPair(); } /** @@ -85,38 +84,38 @@ public AsymmetricSessionKeys verify() throws Exception { // M1 — Create new random client ephemeral X25519 public key and send it to server private void m1Execute() throws Exception { logger.debug("Pair-Verify M1: Send verification start request with client ephemeral X25519 PK to server"); - byte[] clientKey = this.clientKey.generatePublicKey().getEncoded(); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // - TlvType.PUBLIC_KEY.key, clientKey); + TlvType.PUBLIC_KEY.key, clientEphemeralSecretKey.generatePublicKey().getEncoded()); Validator.validate(PairingMethod.VERIFY, tlv); - m2Execute(ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); + byte[] m1Response = ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); + m2Execute(m1Response); } // M2 — Receive server ephemeral X25519 public key and encrypted TLV - private void m2Execute(byte[] response1) throws Exception { + private void m2Execute(byte[] m1Response) throws Exception { logger.debug("Pair-Verify M2: Read server ephemeral X25519 PK and encrypted id; validate signature"); - Map tlv = Tlv8Codec.decode(response1); + Map tlv = Tlv8Codec.decode(m1Response); Validator.validate(PairingMethod.VERIFY, tlv); - byte[] serverKeyBytes = tlv.get(TlvType.PUBLIC_KEY.key); - X25519PublicKeyParameters serverKey = new X25519PublicKeyParameters(serverKeyBytes, 0); - - sharedSecret = generateSharedSecret(clientKey, serverKey); - sessionKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); + serverEphemeralPublicKey = new X25519PublicKeyParameters(tlv.get(TlvType.PUBLIC_KEY.key), 0); + sharedSecret = generateSharedSecret(clientEphemeralSecretKey, serverEphemeralPublicKey); + sharedKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); - byte[] ciphertext = tlv.get(TlvType.ENCRYPTED_DATA.key); - byte[] plaintext = CryptoUtils.decrypt(sessionKey, PV_M2_NONCE, Objects.requireNonNull(ciphertext)); + byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.key); + byte[] plainText = CryptoUtils.decrypt(sharedKey, PV_M2_NONCE, Objects.requireNonNull(cipherText)); // validate identifier + signature - Map subTlv = Tlv8Codec.decode(plaintext); - byte[] identifier = subTlv.get(TlvType.IDENTIFIER.key); - byte[] signature = subTlv.get(TlvType.SIGNATURE.key); - if (identifier == null || signature == null) { + Map subTlv = Tlv8Codec.decode(plainText); + byte[] serverPairingId = subTlv.get(TlvType.IDENTIFIER.key); + byte[] serverSignature = subTlv.get(TlvType.SIGNATURE.key); + if (serverPairingId == null || serverSignature == null) { throw new SecurityException("Accessory identifier or signature missing"); } - if (!verifySignature(serverLongTermPublicKey, identifier, signature)) { - // TODO throw new SecurityException("Accessory signature verification failed"); + + if (!verifySignature(serverLongTermPublicKey, serverSignature, concat(serverEphemeralPublicKey.getEncoded(), + serverPairingId, clientEphemeralSecretKey.generatePublicKey().getEncoded()))) { + throw new SecurityException("Client signature invalid"); } m3Execute(); @@ -125,30 +124,30 @@ private void m2Execute(byte[] response1) throws Exception { // M3 — Send encrypted controller identifier and signature private void m3Execute() throws Exception { logger.debug("Pair-Verify M3: Send encrypted controller id with signature"); - byte[] sharedKey = generateHkdfKey(sharedSecret, PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); - byte[] signingKey = clientLongTermPrivateKey.generatePublicKey().getEncoded(); - byte[] payload = concat(sharedKey, pairingId, signingKey); - byte[] signature = signMessage(clientLongTermPrivateKey, payload); + byte[] clientSignature = signMessage(clientLongTermSecretKey, + concat(clientEphemeralSecretKey.generatePublicKey().getEncoded(), clientPairingId, + serverEphemeralPublicKey.getEncoded())); Map subTlv = Map.of( // - TlvType.IDENTIFIER.key, payload, // - TlvType.SIGNATURE.key, signature); + TlvType.IDENTIFIER.key, clientPairingId, // + TlvType.SIGNATURE.key, clientSignature); - byte[] plaintext = Tlv8Codec.encode(subTlv); - byte[] ciphertext = encrypt(sessionKey, PV_M3_NONCE, plaintext); + byte[] plainText = Tlv8Codec.encode(subTlv); + byte[] cipherText = encrypt(sharedKey, PV_M3_NONCE, plainText); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M3.value }, // - TlvType.ENCRYPTED_DATA.key, ciphertext); + TlvType.ENCRYPTED_DATA.key, cipherText); Validator.validate(PairingMethod.VERIFY, tlv); - m4Execute(ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_TLV, Tlv8Codec.encode(tlv))); + byte[] m3Response = ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); + m4Execute(m3Response); } // M4 — Final confirmation - private void m4Execute(byte[] response3) throws Exception { - logger.debug("Pair-Verify M4: Validation confirmed; derive session keys"); - Map tlv = Tlv8Codec.decode(response3); + private void m4Execute(byte[] m3Response) throws Exception { + logger.debug("Pair-Verify M4: Confirm validation; derive session keys"); + Map tlv = Tlv8Codec.decode(m3Response); Validator.validate(PairingMethod.VERIFY, tlv); readKey = CryptoUtils.generateHkdfKey(sharedSecret, CONTROL_SALT, CONTROL_READ_ENCRYPTION_KEY); writeKey = CryptoUtils.generateHkdfKey(sharedSecret, CONTROL_SALT, CONTROL_WRITE_ENCRYPTION_KEY); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index 06829d98a74c4..f19a51a5ebc36 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -71,9 +71,9 @@ void testBareCrypto() throws Exception { void testSrpClient() throws Exception { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); SRPclient client = new SRPclient("password123", toBytes(SALT_HEX), toBytes(SERVER_PRIVATE_HEX)); - byte[] key = client.getSymmetricKey(); - byte[] cipherText = encrypt(key, PS_M5_NONCE, plainText0); - byte[] plainText1 = decrypt(key, PS_M5_NONCE, cipherText); + byte[] sharedKey = client.getSharedKey(); + byte[] cipherText = encrypt(sharedKey, PS_M5_NONCE, plainText0); + byte[] plainText1 = decrypt(sharedKey, PS_M5_NONCE, cipherText); assertArrayEquals(plainText0, plainText1); } @@ -81,24 +81,23 @@ void testSrpClient() throws Exception { void testPairSetup() throws Exception { // initialize test parameters String password = "password123"; - String clientPairingIdentifier = "11:22:33:44:55:66"; + String clientPairingId = "11:22:33:44:55:66"; String serverPairingIdentifier = "66:55:44:33:22:11"; byte[] serverSalt = toBytes(SALT_HEX); byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); // initialize signing keys - Ed25519PrivateKeyParameters clientPrivateSigningKey = new Ed25519PrivateKeyParameters( + Ed25519PrivateKeyParameters clientLongTermSecretKey = new Ed25519PrivateKeyParameters( toBytes(CLIENT_PRIVATE_HEX)); - Ed25519PrivateKeyParameters serverPrivateSigningKey = new Ed25519PrivateKeyParameters( + Ed25519PrivateKeyParameters serverLongTermSecretKey = new Ed25519PrivateKeyParameters( toBytes(SERVER_PRIVATE_HEX)); // create mock IpTransport mockTransport = mock(IpTransport.class); // create SRP client and server - SRPserver server = new SRPserver(password, serverSalt, serverPairingId, serverPrivateSigningKey, null, null); - PairSetupClient client = new PairSetupClient(mockTransport, clientPairingIdentifier, clientPrivateSigningKey, - password); + SRPserver server = new SRPserver(password, serverSalt, serverPairingId, serverLongTermSecretKey, null, null); + PairSetupClient client = new PairSetupClient(mockTransport, clientPairingId, clientLongTermSecretKey, password); // mock the HTTP transport to simulate the SRP exchange doAnswer(invocation -> { diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index 7e3bb3efe64e5..b7d914354c2ff 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -24,7 +24,6 @@ import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; import org.bouncycastle.crypto.params.X25519PublicKeyParameters; -import org.bouncycastle.util.Arrays; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; @@ -51,7 +50,6 @@ class TestPairVerify { """; private final String clientPairingIdentifier = "11:22:33:44:55:66"; - private final byte[] clientPairingId = clientPairingIdentifier.getBytes(StandardCharsets.UTF_8); private final String serverPairingIdentifier = "66:55:44:33:22:11"; private final byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); @@ -61,13 +59,13 @@ class TestPairVerify { private final Ed25519PrivateKeyParameters serverLongTermPrivateKey = new Ed25519PrivateKeyParameters( toBytes(SERVER_PRIVATE_HEX)); - private @NonNullByDefault({}) X25519PrivateKeyParameters serverKey; - private @NonNullByDefault({}) X25519PublicKeyParameters clientKey; - private @NonNullByDefault({}) byte[] sessionKey; + private @NonNullByDefault({}) X25519PrivateKeyParameters serverEphemeralSecretKey; + private @NonNullByDefault({}) X25519PublicKeyParameters clientEphemeralPublicKey; + private @NonNullByDefault({}) byte[] sharedKey; @Test void testPairVerify() throws Exception { - serverKey = generateX25519KeyPair(); + serverEphemeralSecretKey = generateX25519KeyPair(); // create mock IpTransport mockTransport = mock(IpTransport.class); @@ -90,8 +88,8 @@ void testPairVerify() throws Exception { // process the message based on the pair verification process Mx state return switch (state[0]) { - case 1 -> getServerResponseM1(tlv); - case 3 -> getServerResponseM3(tlv); + case 1 -> m1GetServerResponse(tlv); + case 3 -> m3GetServerResponse(tlv); default -> throw new IllegalArgumentException("Unexpected state"); }; @@ -101,53 +99,56 @@ void testPairVerify() throws Exception { client.verify(); } - private byte[] getServerResponseM1(Map tlv) throws Exception { - byte[] clientKeyBytes = tlv.get(TlvType.PUBLIC_KEY.key); - byte[] serverKeyBytes = serverKey.generatePublicKey().getEncoded(); - byte[] payload = concat(serverKeyBytes, serverPairingId, Objects.requireNonNull(clientKeyBytes)); - byte[] signature = signMessage(serverLongTermPrivateKey, payload); + private byte[] m1GetServerResponse(Map tlv) throws Exception { + byte[] clientEphemeralPublicKey = tlv.get(TlvType.PUBLIC_KEY.key); + byte[] serverEphemeralPublicKey = this.serverEphemeralSecretKey.generatePublicKey().getEncoded(); + if (clientEphemeralPublicKey == null) { + throw new SecurityException("Client public key missing"); + } + byte[] serverSignature = signMessage(serverLongTermPrivateKey, + concat(serverEphemeralPublicKey, serverPairingId, clientEphemeralPublicKey)); Map tlvInner = Map.of( // TlvType.IDENTIFIER.key, serverPairingId, // - TlvType.SIGNATURE.key, signature); + TlvType.SIGNATURE.key, serverSignature); - clientKey = new X25519PublicKeyParameters(clientKeyBytes); + this.clientEphemeralPublicKey = new X25519PublicKeyParameters(clientEphemeralPublicKey); - byte[] sharedSecret = generateSharedSecret(serverKey, clientKey); - sessionKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); + byte[] sharedSecret = generateSharedSecret(serverEphemeralSecretKey, this.clientEphemeralPublicKey); + sharedKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); - byte[] plaintext = Tlv8Codec.encode(tlvInner); - byte[] ciphertext = encrypt(sessionKey, PV_M2_NONCE, plaintext); + byte[] plainText = Tlv8Codec.encode(tlvInner); + byte[] cipherText = encrypt(sharedKey, PV_M2_NONCE, plainText); Map tlvOut = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M2.value }, // - TlvType.PUBLIC_KEY.key, serverKey.generatePublicKey().getEncoded(), // - TlvType.ENCRYPTED_DATA.key, ciphertext); + TlvType.PUBLIC_KEY.key, serverEphemeralPublicKey, // + TlvType.ENCRYPTED_DATA.key, cipherText); return Tlv8Codec.encode(tlvOut); } - private byte[] getServerResponseM3(Map tlv) throws Exception { - if (sessionKey.length == 0) { + private byte[] m3GetServerResponse(Map tlv) throws Exception { + if (sharedKey.length == 0) { throw new IllegalStateException("Session key not established"); } - byte[] ciphertext = tlv.get(TlvType.ENCRYPTED_DATA.key); - if (ciphertext == null) { - throw new SecurityException("Missing ciphertext in M3"); + byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.key); + if (cipherText == null) { + throw new SecurityException("Server cipher text missing"); } - byte[] plaintext = decrypt(sessionKey, PV_M3_NONCE, Objects.requireNonNull(ciphertext)); + byte[] plainText = decrypt(sharedKey, PV_M3_NONCE, Objects.requireNonNull(cipherText)); - Map subTlv = Tlv8Codec.decode(plaintext); - byte[] information = subTlv.get(TlvType.IDENTIFIER.key); - byte[] signature = subTlv.get(TlvType.SIGNATURE.key); - if (information == null || signature == null) { - throw new SecurityException("Client pairing ID or signature missing"); + Map subTlv = Tlv8Codec.decode(plainText); + byte[] clientPairingId = subTlv.get(TlvType.IDENTIFIER.key); + byte[] clientSignature = subTlv.get(TlvType.SIGNATURE.key); + if (clientPairingId == null || clientSignature == null) { + throw new SecurityException("Client pairing Id or signature missing"); } - verifySignature(clientLongTermPrivateKey.generatePublicKey(), plaintext, Objects.requireNonNull(signature)); - byte[] pairingId = Arrays.copyOfRange(information, 32, information.length - 32); - if (!Arrays.areEqual(clientPairingId, pairingId)) { - throw new SecurityException("Client pairing ID does not match"); + if (!verifySignature(clientLongTermPrivateKey.generatePublicKey(), clientSignature, + concat(clientEphemeralPublicKey.getEncoded(), clientPairingId, + serverEphemeralSecretKey.generatePublicKey().getEncoded()))) { + throw new SecurityException("Client signature invalid"); } Map tlvOut = Map.of(TlvType.STATE.key, new byte[] { PairingState.M4.value }); From f9684c659a9e43c5afa6cce80f2151e96f7c56e3 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 28 Sep 2025 19:25:25 +0100 Subject: [PATCH 036/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitBaseServerHandler.java | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index e83bf9ac3506d..7daae4329e76b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -151,7 +151,7 @@ public void handleRemoval() { storeLongTermKeys(); updateStatus(ThingStatus.REMOVED); } catch (Exception e) { - logger.warn("Failed to remove pairing for accessory {}", accessoryId); + logger.warn("Failed to remove pairing for {}", thing.getUID()); } }); } @@ -211,23 +211,24 @@ private void initializePairing() { restoreLongTermKeys(); Ed25519PrivateKeyParameters controllerLongTermSecretKey = this.controllerLongTermSecretKey; Ed25519PublicKeyParameters accessoryLongTermPublicKey = this.accessoryLongTermPublicKey; + String controllerPairingId = thing.getUID().toString(); if (controllerLongTermSecretKey != null && accessoryLongTermPublicKey != null) { try { - logger.debug("Starting Pair-Verify with existing key for accessory {}", accessoryId); - PairVerifyClient client = new PairVerifyClient(ipTransport, accessoryId.toString(), + logger.debug("Starting Pair-Verify with existing key for {}", controllerPairingId); + PairVerifyClient client = new PairVerifyClient(ipTransport, controllerPairingId, controllerLongTermSecretKey, accessoryLongTermPublicKey); ipTransport.setSessionKeys(client.verify()); rwService = new CharacteristicReadWriteService(ipTransport); - logger.debug("Restored pairing was verified for accessory {}", accessoryId); + logger.debug("Restored pairing was verified for {}", controllerPairingId); fetchAccessories(); updateStatus(ThingStatus.ONLINE); return; } catch (Exception e) { - logger.debug("Restored pairing was not verified for accessory {}", accessoryId, e); + logger.debug("Restored pairing was not verified for {}", controllerPairingId, e); this.controllerLongTermSecretKey = null; storeLongTermKeys(); // fall through to create new pairing @@ -236,20 +237,20 @@ private void initializePairing() { // Create new controller private key controllerLongTermSecretKey = new Ed25519PrivateKeyParameters(new SecureRandom()); - logger.debug("Created new controller long term private key for accessory {}", accessoryId); + logger.debug("Created new controller long term private key for {}", controllerPairingId); try { - logger.debug("Starting Pair-Setup for accessory {}", accessoryId); - PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, thing.getUID().toString(), + logger.debug("Starting Pair-Setup for {}", controllerPairingId); + PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, controllerPairingId, controllerLongTermSecretKey, pairingCode); accessoryLongTermPublicKey = pairSetupClient.pair(); this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; - logger.debug("Pair-Setup completed; starting Pair-Verify for accessory {}", accessoryId); + logger.debug("Pair-Setup completed; starting Pair-Verify for {}", controllerPairingId); // Perform Pair-Verify immediately after Pair-Setup - PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, accessoryId.toString(), + PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, controllerPairingId, controllerLongTermSecretKey, accessoryLongTermPublicKey); ipTransport.setSessionKeys(pairVerifyClient.verify()); @@ -257,14 +258,14 @@ private void initializePairing() { this.controllerLongTermSecretKey = controllerLongTermSecretKey; - logger.debug("Pairing and verification completed for accessory {}", accessoryId); + logger.debug("Pairing and verification completed for {}", controllerPairingId); storeLongTermKeys(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); } catch (Exception e) { - logger.warn("Pairing and verification failed for accessory {}", accessoryId, e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing / Verification failed"); + logger.warn("Pairing / verification failed for {}", controllerPairingId, e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing / verification failed"); } } From 403e955e1e49ddfc7e009887dae63f0277619f2c Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 29 Sep 2025 18:44:44 +0100 Subject: [PATCH 037/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/crypto/CryptoUtils.java | 8 +- .../homekit/internal/crypto/SRPclient.java | 4 +- .../hap_services/PairVerifyClient.java | 4 +- .../internal/session/SecureSession.java | 143 +++++++++++++++--- .../internal/transport/IpTransport.java | 5 +- .../binding/homekit/internal/SRPserver.java | 2 +- .../homekit/internal/TestPairSetup.java | 8 +- .../homekit/internal/TestPairVerify.java | 4 +- 8 files changed, 137 insertions(+), 41 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index 68bab9c1681f8..cfd7e599e1cc0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -57,8 +57,8 @@ public static byte[] concat(byte[]... parts) { } // Decrypt with ChaCha20-Poly1305 - public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText) throws InvalidCipherTextException { - byte[] aad = new byte[0]; // AAD = none + public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText, byte[] aad) + throws InvalidCipherTextException { byte[] nonce96 = new byte[12]; // 96 bit nonce System.arraycopy(nonce, 0, nonce96, 4, 8); ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); @@ -71,8 +71,8 @@ public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText) throws } // Encrypt with ChaCha20-Poly1305 - public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plainText) throws InvalidCipherTextException { - byte[] aad = new byte[0]; // AAD = none + public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plainText, byte[] aad) + throws InvalidCipherTextException { byte[] nonce96 = new byte[12]; // 96 bit nonce System.arraycopy(nonce, 0, nonce96, 4, 8); ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index b26233f159e86..fc2f525f800d5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -168,7 +168,7 @@ public byte[] m5EncodeClientInfoAndSign(byte[] pairingId, Ed25519PrivateKeyParam TlvType.SIGNATURE.key, clientSignature); byte[] plainText = Tlv8Codec.encode(subTlv); - byte[] cipherText = encrypt(getSharedKey(), PS_M5_NONCE, plainText); + byte[] cipherText = encrypt(getSharedKey(), PS_M5_NONCE, plainText, new byte[0]); return cipherText; } @@ -182,7 +182,7 @@ public byte[] m5EncodeClientInfoAndSign(byte[] pairingId, Ed25519PrivateKeyParam * @throws Exception if an error occurs during decryption or signature verification. */ public void m6DecodeServerInfoAndVerify(byte[] cipherText) throws Exception { - byte[] plainText = decrypt(getSharedKey(), PS_M6_NONCE, cipherText); + byte[] plainText = decrypt(getSharedKey(), PS_M6_NONCE, cipherText, new byte[0]); Map subTlv = Tlv8Codec.decode(plainText); byte[] serverPairingId = subTlv.get(TlvType.IDENTIFIER.key); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index b240d8b689bc6..f061dc2dc9ce4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -103,7 +103,7 @@ private void m2Execute(byte[] m1Response) throws Exception { sharedKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.key); - byte[] plainText = CryptoUtils.decrypt(sharedKey, PV_M2_NONCE, Objects.requireNonNull(cipherText)); + byte[] plainText = CryptoUtils.decrypt(sharedKey, PV_M2_NONCE, Objects.requireNonNull(cipherText), new byte[0]); // validate identifier + signature Map subTlv = Tlv8Codec.decode(plainText); @@ -133,7 +133,7 @@ private void m3Execute() throws Exception { TlvType.SIGNATURE.key, clientSignature); byte[] plainText = Tlv8Codec.encode(subTlv); - byte[] cipherText = encrypt(sharedKey, PV_M3_NONCE, plainText); + byte[] cipherText = encrypt(sharedKey, PV_M3_NONCE, plainText, new byte[0]); Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M3.value }, // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index 396276504439b..edc043de57b43 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -14,11 +14,13 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; -import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicInteger; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -48,38 +50,133 @@ public SecureSession(Socket socket, AsymmetricSessionKeys keys) throws IOExcepti } /** - * Encrypts the given plaintext using the write key and a unique nonce and sends it. + * Sends multiple data frames over the output stream. Splits the plaintext into chunks <= 1024 bytes, + * encrypts them, and sends them as separate frames. * - * @param plaintext the plaintext to be encrypted and sent. - * @throws Exception + * @param plainTextIn the complete plaintext message to be sent. + * @throws Exception if an error occurs during encryption or sending. */ - public void send(byte[] plaintext) throws Exception { - byte[] nonce = generateNonce(writeCounter.getAndIncrement()); - byte[] ciphertext = encrypt(writeKey, nonce, plaintext); - ByteBuffer buf = ByteBuffer.allocate(2 + ciphertext.length); - buf.order(java.nio.ByteOrder.LITTLE_ENDIAN); - buf.putShort((short) ciphertext.length); - buf.put(ciphertext); - out.write(buf.array()); + public void send(byte[] plainTextIn) throws Exception { + ByteArrayInputStream plainText = new ByteArrayInputStream(plainTextIn); + while (plainText.available() > 0) { + sendFrame(plainText); + } out.flush(); } /** - * Reads the cipertext and decrypts it using the read key and a unique nonce. + * Sends a single data frame over the output stream. This method reads up to 1024 bytes from the + * input plaintext, encrypts it, and sends it as a frame with a 2-byte length prefix, and a 16 byte + * tag. The length prefix is included in the cipher AAD to ensure integrity. The write counter is + * incremented after sending the frame to ensure nonce uniqueness. * - * @return the received ciphertext decrypted. - * @throws Exception + * @param plainText the input stream containing the plaintext to be sent. + * @throws Exception if an error occurs during encryption or sending. + */ + private void sendFrame(ByteArrayInputStream plainText) throws Exception { + int frameLen = Math.min(1024, plainText.available()); + byte[] frameAad = new byte[] { (byte) (frameLen & 0xFF), (byte) ((frameLen >> 8) & 0xFF) }; + out.write(frameAad, 0, frameAad.length); + byte[] chunk = plainText.readNBytes(frameLen); + byte[] nonce = generateNonce(writeCounter.getAndIncrement()); + out.write(encrypt(writeKey, nonce, chunk, frameAad)); // AAD = lenBytes; outputs extra 16 byte tag + } + + /** + * Reads multiple data frames from the input stream until a complete HTTP message is reconstructed. + * Repeatedly calls receiveFrame() to read and decrypt individual frames. It accumulates the decrypted + * plaintext until it detects the end of the HTTP message. The end of the message is determined by checking + * for the presence of complete HTTP headers and a Content-Length header. + * + * @return the complete decrypted HTTP message as a byte array. + * @throws Exception if an error occurs during reading or decryption. */ public byte[] receive() throws Exception { - int lo = in.read(); - int hi = in.read(); - if (lo < 0 || hi < 0) { - throw new IllegalStateException("Stream closed"); + HttpPayloadParser httpParser = new HttpPayloadParser(); + ByteArrayOutputStream plainText = new ByteArrayOutputStream(); + do { + byte[] frame = receiveFrame(); + httpParser.accept(frame); + plainText.write(frame); + } while (!httpParser.isComplete()); + return plainText.toByteArray(); + } + + /** + * Reads a single frame from the input stream, decrypts it, and returns the plaintext. Reads the 2-byte length + * prefix, retrieves the corresponding ciphertext, and decrypts it. The length prefix is included in the cipher + * AAD to ensure integrity. The read counter is incremented after reading the frame to ensure nonce uniqueness. + * + * @return the decrypted plaintext of the single frame. + * @throws Exception if an error occurs during reading or decryption. + */ + private byte[] receiveFrame() throws Exception { + byte[] frameAad = in.readNBytes(2); + int frameLen = (frameAad[0] & 0xFF) + ((frameAad[1] & 0xFF) << 8); + if (frameLen < 0 || frameLen > 1024) { + throw new SecurityException("Invalid frame length"); } - int length = (lo & 0xFF) | ((hi & 0xFF) << 8); - byte[] ciphertext = in.readNBytes(length); + byte[] cipherText = in.readNBytes(frameLen + 16); // read 16 extra bytes for the auth tag byte[] nonce = generateNonce(readCounter.getAndIncrement()); - byte[] plaintext = decrypt(readKey, nonce, ciphertext); - return plaintext; + return decrypt(readKey, nonce, cipherText, frameAad); + } + + /** + * Internal helper class to parse incoming HTTP messages and determine when a complete message has been received. + * It accumulates header data until the end of headers is detected, then reads the Content-Length header to + * determine how many bytes of body to expect. It tracks the number of body bytes read to know when the full + * message has been received. + */ + private static class HttpPayloadParser { + private final ByteArrayOutputStream headerBuffer = new ByteArrayOutputStream(); + private int contentLength = -1; + private int bodyBytesRead = 0; + private boolean headersComplete = false; + + public void accept(byte[] data) { + int offset = 0; + + if (!headersComplete) { + for (int i = 0; i < data.length - 3; i++) { + if (data[i] == '\r' && data[i + 1] == '\n' && data[i + 2] == '\r' && data[i + 3] == '\n') { + headersComplete = true; + offset = i + 4; + headerBuffer.write(data, 0, offset); + parseHeaders(); + break; + } + } + if (!headersComplete) { + try { + headerBuffer.write(data); + } catch (IOException e) { + // should never happen with ByteArrayOutputStream + } + return; + } + } + + if (headersComplete && contentLength != -1) { + bodyBytesRead += data.length - offset; + } + } + + private void parseHeaders() { + String headers = headerBuffer.toString(StandardCharsets.UTF_8); + for (String line : headers.split("\r\n")) { + if (line.regionMatches(true, 0, "Content-Length:", 0, 15)) { + try { + contentLength = Integer.parseInt(line.substring(15).trim()); + } catch (NumberFormatException ignored) { + contentLength = -1; + } + break; + } + } + } + + public boolean isComplete() { + return headersComplete && contentLength != -1 && bodyBytesRead >= contentLength; + } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 260b131346a31..2e3df2a4c2503 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -28,7 +28,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; import org.openhab.binding.homekit.internal.session.SecureSession; import org.slf4j.Logger; @@ -91,10 +90,10 @@ public byte[] put(String endpoint, String contentType, byte[] content) private byte[] execute(String method, String endpoint, String contentType, byte[] body) throws IOException, InterruptedException, TimeoutException, ExecutionException { - logger.trace("{} {} Content-Type:{} Body:{}", method, endpoint, contentType, CryptoUtils.toHex(body)); try { byte[] request = buildRequest(method, endpoint, contentType, body); byte[] response; + logger.trace("Request:\n{}", new String(request, StandardCharsets.UTF_8)); SecureSession secureSession = this.secureSession; if (secureSession != null) { @@ -108,7 +107,7 @@ private byte[] execute(String method, String endpoint, String contentType, byte[ response = readPlainResponse(in); } - logger.trace("Response: {}", CryptoUtils.toHex(response)); + logger.trace("Response:\n{}", new String(response, StandardCharsets.UTF_8)); return parseResponse(response); } catch (IOException | InterruptedException | TimeoutException e) { throw e; diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java index 41eee72778d1e..45a696aa0069e 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -130,7 +130,7 @@ public byte[] m5EncodeServerInfoAndSign() throws Exception { TlvType.SIGNATURE.key, signature); byte[] plaintext = Tlv8Codec.encode(subTlv); - return CryptoUtils.encrypt(getSymmetricKey(), PS_M6_NONCE, plaintext); + return CryptoUtils.encrypt(getSymmetricKey(), PS_M6_NONCE, plaintext, new byte[0]); } public byte[] getSymmetricKey() { diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index f19a51a5ebc36..80a0f3edead13 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -62,8 +62,8 @@ void testBareCrypto() throws Exception { byte[] key = new byte[32]; // 256 bits = 32 bytes byte[] nonce = generateNonce(123); new SecureRandom().nextBytes(key); - byte[] cipherText = encrypt(key, nonce, plainText0); - byte[] plainText1 = decrypt(key, nonce, cipherText); + byte[] cipherText = encrypt(key, nonce, plainText0, new byte[0]); + byte[] plainText1 = decrypt(key, nonce, cipherText, new byte[0]); assertArrayEquals(plainText0, plainText1); } @@ -72,8 +72,8 @@ void testSrpClient() throws Exception { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); SRPclient client = new SRPclient("password123", toBytes(SALT_HEX), toBytes(SERVER_PRIVATE_HEX)); byte[] sharedKey = client.getSharedKey(); - byte[] cipherText = encrypt(sharedKey, PS_M5_NONCE, plainText0); - byte[] plainText1 = decrypt(sharedKey, PS_M5_NONCE, cipherText); + byte[] cipherText = encrypt(sharedKey, PS_M5_NONCE, plainText0, new byte[0]); + byte[] plainText1 = decrypt(sharedKey, PS_M5_NONCE, cipherText, new byte[0]); assertArrayEquals(plainText0, plainText1); } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index b7d914354c2ff..0f386a0feb654 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -118,7 +118,7 @@ private byte[] m1GetServerResponse(Map tlv) throws Exception { sharedKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); byte[] plainText = Tlv8Codec.encode(tlvInner); - byte[] cipherText = encrypt(sharedKey, PV_M2_NONCE, plainText); + byte[] cipherText = encrypt(sharedKey, PV_M2_NONCE, plainText, new byte[0]); Map tlvOut = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M2.value }, // @@ -136,7 +136,7 @@ private byte[] m3GetServerResponse(Map tlv) throws Exception { if (cipherText == null) { throw new SecurityException("Server cipher text missing"); } - byte[] plainText = decrypt(sharedKey, PV_M3_NONCE, Objects.requireNonNull(cipherText)); + byte[] plainText = decrypt(sharedKey, PV_M3_NONCE, Objects.requireNonNull(cipherText), new byte[0]); Map subTlv = Tlv8Codec.decode(plainText); byte[] clientPairingId = subTlv.get(TlvType.IDENTIFIER.key); From ffdce471566f05aaa3f60c1f8c13c08b3476607e Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 30 Sep 2025 14:49:39 +0100 Subject: [PATCH 038/177] first working build Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 45 ++++++------- .../homekit/internal/crypto/CryptoUtils.java | 17 ++--- .../HomekitChildDiscoveryService.java | 1 - .../binding/homekit/internal/dto/Service.java | 3 +- .../factory/HomekitHandlerFactory.java | 2 +- .../handler/HomekitBaseServerHandler.java | 5 +- .../handler/HomekitBridgeHandler.java | 48 ++++++++++++- .../handler/HomekitDeviceHandler.java | 51 +++++++++++--- .../internal/session/SecureSession.java | 34 +++++----- .../internal/transport/IpTransport.java | 2 +- .../resources/OH-INF/i18n/homekit.properties | 12 ++-- .../resources/OH-INF/thing/thing-types.xml | 10 +-- .../homekit/internal/TestChannelCreation.java | 67 +++++++++++-------- .../homekit/internal/TestPairSetup.java | 6 +- 14 files changed, 190 insertions(+), 113 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 7bde2124093fe..b0684bea30778 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -6,43 +6,39 @@ This binding allows pairing with HomeKit accessory devices and importing their s There are two types of Things supported: -- `device`: This integrates a single HomeKit accessory, whereby its services appear as channel groups their respective service- characteristics appear as channels. -- `bridge`: This integrates a HomeKit bridge accessory containing multiple child `device` Things. - So Things of type `device` either represent a stand-alone accessories or a child of a `bridge` Thing. +- `accessory`: This integrates a single HomeKit accessory, whereby its services appear as channel groups their respective service- characteristics appear as channels. +- `bridge`: This integrates a HomeKit bridge accessory containing multiple child `accessory` Things. + So Things of type `accessory` either represent a stand-alone accessories or a child of a `bridge` Thing. -Things of type `bridge` and stand-alone `device` Things both communicate directly with their HomeKit device over the LAN. -Whereas child `device` Things communicate via their respective `bridge` Thing. +Things of type `bridge` and stand-alone `accessory` Things both communicate directly with their HomeKit device over the LAN. +Whereas child `accessory` Things communicate via their respective `bridge` Thing. ## Discovery -Both `bridge` and stand-alone `device` Things will be auto discovered via mDNS. -And once a `bridge` Thing has been instantiated, and paired, its child `device` Things will also be auto discovered +Both `bridge` and stand-alone `accessory` Things will be auto discovered via mDNS. +And once a `bridge` Thing has been instantiated, and paired, its child `accessory` Things will also be auto discovered. -## Binding Configuration +## Thing Configuration -The `bridge` and stand-alone `device` Things need to be paired with their respective HomeKit accessories. +The `bridge` and stand-alone `accessory` Things need to be paired with their respective HomeKit accessories. This requires entering the HomeKit pairing code as a configuration parameter in the binding. Note that HomeKit accessories can only be paired with one controller, so if it is already paired with something else, you will need to remove that pairing first. -## Thing Configuration - -_Describe what is needed to manually configure a thing, either through the UI or via a thing-file._ -_This should be mainly about its mandatory and optional configuration parameters._ - -_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._ - -### `bridge` and stand-alone `device` Thing Configuration +Things are mostly automatically configured when they are discovered. +However the following are the . | Name | Type | Description | Default | Required | Advanced | |-------------------|---------|---------------------------------------------------|---------|-----------|-----------| -| `ipV4Address` | text | IP v4 address of the HomeKit accessory. | N/A | see below | see below | +| `host` | text | IP v4 address of the HomeKit accessory. | N/A | see below | see below | | `pairingCode` | text | Code used for pairing with the HomeKit accessory. | N/A | see below | see below | | `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | -Things of type `bridge` and stand-alone `device` Things require both an `ipv4Address` and a `pairingCode`. -The `ipv4Address` is set by the mDNS auto- discovery process. -However the `pairingCode` must be entered manually. -Child `device` Things do not require neither an `ipv4Address` nor a `pairingCode`. +Things of type `bridge` and stand-alone `accessory` Things require both an `host` and a `pairingCode`. +The `host` is set by the mDNS auto- discovery process. +And the `pairingCode` must be entered manually. + +Child `accessory` Things do not require neither a `host` nor a `pairingCode`. +Therefore these parameters are preset to `n/a`. ## Channels @@ -50,9 +46,8 @@ Channels will be auto- created depending on the services and respective service- ### Thing Configuration -```java -Example thing configuration goes here. -``` +Things are mostly automatically configured when they are discovered. +So for this reason it is extremely difficult to create Things via a '.things' file. ### Item Configuration diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index cfd7e599e1cc0..f73b6222bfdbf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -57,10 +57,10 @@ public static byte[] concat(byte[]... parts) { } // Decrypt with ChaCha20-Poly1305 - public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText, byte[] aad) + public static byte[] decrypt(byte[] key, byte[] nonce64, byte[] cipherText, byte[] aad) throws InvalidCipherTextException { byte[] nonce96 = new byte[12]; // 96 bit nonce - System.arraycopy(nonce, 0, nonce96, 4, 8); + System.arraycopy(nonce64, 0, nonce96, 4, 8); ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce96, aad); cipher.init(false, params); @@ -71,10 +71,10 @@ public static byte[] decrypt(byte[] key, byte[] nonce, byte[] cipherText, byte[] } // Encrypt with ChaCha20-Poly1305 - public static byte[] encrypt(byte[] key, byte[] nonce, byte[] plainText, byte[] aad) + public static byte[] encrypt(byte[] key, byte[] nonce64, byte[] plainText, byte[] aad) throws InvalidCipherTextException { byte[] nonce96 = new byte[12]; // 96 bit nonce - System.arraycopy(nonce, 0, nonce96, 4, 8); + System.arraycopy(nonce64, 0, nonce96, 4, 8); ChaCha20Poly1305 cipher = new ChaCha20Poly1305(); AEADParameters params = new AEADParameters(new KeyParameter(key), 128, nonce96, aad); cipher.init(true, params); @@ -94,16 +94,13 @@ public static byte[] generateHkdfKey(byte[] inputKey, byte[] salt, byte[] info) } /** - * Generates an 64 bit nonce using the given counter. + * Generates a 64 bit nonce using the given counter. * * @param counter The counter value. * @return The generated nonce. */ - public static byte[] generateNonce(int counter) { - ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); - buf.putInt(0); // high 4 bytes = zero - buf.putInt(counter); // low 4 bytes = counter - return buf.array(); // total = 8 bytes + public static byte[] generateNonce64(int counter) { + return ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(counter).array(); } // Compute shared secret using ECDH diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 2d863369c0757..61a25c8f583f5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -56,7 +56,6 @@ public void devicesDiscovered(Thing bridge, Collection accessories) { .withLabel(THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), bridge.getLabel())) // .withProperty(CONFIG_HOST, "n/a") // .withProperty(CONFIG_PAIRING_CODE, "n/a") // - .withProperty(PROPERTY_ACCESSORY_UID, uid.toString()) // .withRepresentationProperty(PROPERTY_ACCESSORY_UID).build()); } }); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index 93d86b95e239d..d0b701a288b09 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -43,6 +43,7 @@ public class Service { public @NonNullByDefault({}) Integer iid; // e.g. 10 public @NonNullByDefault({}) String name; public @NonNullByDefault({}) List characteristics; + public @NonNullByDefault({}) Boolean primary; /** * Builds a ChannelGroupDefinition and a ChannelGroupType based on the service properties. @@ -55,7 +56,7 @@ public class Service { */ public @Nullable ChannelGroupDefinition buildAndRegisterChannelGroupDefinition(HomekitTypeProvider typeProvider) { ServiceType serviceType = getServiceType(); - if (serviceType == null) { + if (serviceType == null || ServiceType.ACCESSORY_INFORMATION == serviceType) { return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index 93572e92e3ef4..4288271124a18 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -73,7 +73,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { - return new HomekitBridgeHandler((Bridge) thing, registerDiscoveryService()); + return new HomekitBridgeHandler((Bridge) thing, typeProvider, registerDiscoveryService()); } else if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { return new HomekitDeviceHandler(thing, typeProvider); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 7daae4329e76b..918348c64a535 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -34,6 +34,7 @@ import org.openhab.binding.homekit.internal.hap_services.PairRemoveClient; import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.binding.homekit.internal.transport.IpTransport; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; @@ -63,6 +64,7 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { private final Logger logger = LoggerFactory.getLogger(HomekitBaseServerHandler.class); protected final Map accessories = new HashMap<>(); + protected final HomekitTypeProvider typeProvider; protected boolean isChildAccessory = false; @@ -74,8 +76,9 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected @Nullable Ed25519PrivateKeyParameters controllerLongTermSecretKey = null; protected @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; - public HomekitBaseServerHandler(Thing thing) { + public HomekitBaseServerHandler(Thing thing, HomekitTypeProvider typeProvider) { super(thing); + this.typeProvider = typeProvider; } @Override diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index da0348ced6ea9..fb1aa4345e842 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -12,14 +12,22 @@ */ package org.openhab.binding.homekit.internal.handler; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.FAKE_PROPERTY_CHANNEL_TYPE_UID; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; +import org.openhab.binding.homekit.internal.dto.Accessory; +import org.openhab.binding.homekit.internal.dto.Characteristic; +import org.openhab.binding.homekit.internal.dto.Service; +import org.openhab.binding.homekit.internal.enums.ServiceType; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.binding.BridgeHandler; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.builder.BridgeBuilder; +import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.types.Command; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,8 +48,9 @@ public class HomekitBridgeHandler extends HomekitBaseServerHandler implements Br private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); private final HomekitChildDiscoveryService discoveryService; - public HomekitBridgeHandler(Bridge bridge, HomekitChildDiscoveryService discoveryService) { - super(bridge); + public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, + HomekitChildDiscoveryService discoveryService) { + super(bridge, typeProvider); this.discoveryService = discoveryService; } @@ -85,10 +94,45 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { protected void accessoriesLoaded() { logger.debug("Bridge accessories loaded {}", accessories.size()); discoveryService.devicesDiscovered(thing, accessories.values()); // discover child accessories + createProperties(); // create properties from accessory information } @Override public void handleCommand(ChannelUID channelUID, Command command) { // do nothing } + + /** + * Creates properties for the bridge based on the characteristics within the ACCESSORY_INFORMATION + * service (if any). + */ + private void createProperties() { + if (accessories.isEmpty()) { + return; + } + Integer accessoryId = getAccessoryId(); + if (accessoryId == null) { + return; + } + Accessory accessory = accessories.get(accessoryId); + if (accessory == null) { + return; + } + // search for the accessory information service and collect its properties + for (Service service : accessory.services) { + if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { + for (Characteristic characteristic : service.characteristics) { + ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(typeProvider); + if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { + String name = channelDef.getId(); + String value = channelDef.getLabel(); + if (value != null) { + thing.setProperty(name, value); + } + } + } + break; // only one accessory information service per accessory + } + } + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index a04c0a100cabf..e5c858a3427bf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -19,12 +19,14 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import javax.measure.Unit; import javax.measure.format.MeasurementParseException; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.dto.Service; @@ -50,6 +52,7 @@ import org.openhab.core.thing.type.ChannelType; import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; import org.openhab.core.types.util.UnitUtils; @@ -69,12 +72,14 @@ @NonNullByDefault public class HomekitDeviceHandler extends HomekitBaseServerHandler { + private static final int INITIAL_DELAY_SECONDS = 2; + private final Logger logger = LoggerFactory.getLogger(HomekitDeviceHandler.class); - private final HomekitTypeProvider typeProvider; + + private @Nullable ScheduledFuture refreshTask; public HomekitDeviceHandler(Thing thing, HomekitTypeProvider typeProvider) { - super(thing); - this.typeProvider = typeProvider; + super(thing, typeProvider); } @Override @@ -93,7 +98,11 @@ private void channelsAndPropertiesLoaded() { try { int refreshIntervalSeconds = Integer.parseInt(refreshInterval.toString()); if (refreshIntervalSeconds > 0) { - scheduler.scheduleWithFixedDelay(this::refresh, 0, refreshIntervalSeconds, TimeUnit.SECONDS); + ScheduledFuture task = refreshTask; + if (task == null || task.isCancelled() || task.isDone()) { + refreshTask = scheduler.scheduleWithFixedDelay(this::refresh, INITIAL_DELAY_SECONDS, + refreshIntervalSeconds, TimeUnit.SECONDS); + } return; } } catch (NumberFormatException e) { @@ -264,7 +273,7 @@ private void createChannels() { return; } - // create the channels + // create the channels and properties List channels = new ArrayList<>(); Map properties = new HashMap<>(thing.getProperties()); // keep existing properties accessory.buildAndRegisterChannelGroupDefinitions(typeProvider).forEach(groupDef -> { @@ -321,6 +330,9 @@ public void handleCommand(ChannelUID channelUID, Command command) { logger.warn("Received command for unknown channel: {}", channelUID); return; } + if (command == RefreshType.REFRESH) { + return; + } CharacteristicReadWriteService writer = this.rwService; if (writer == null) { logger.warn("No writer service available to handle command for channel: {}", channelUID); @@ -344,6 +356,16 @@ public void initialize() { super.initialize(); } + @Override + public void dispose() { + ScheduledFuture task = refreshTask; + if (task != null) { + task.cancel(true); + } + refreshTask = null; + super.dispose(); + } + /** * Polls the accessory for its current state and updates the corresponding channels. * This method is called periodically by a scheduled executor. @@ -353,17 +375,26 @@ private void refresh() { if (rwService != null) { try { Integer aid = getAccessoryId(); - List queries = thing.getChannels().stream() - .map(c -> "%s.%s".formatted(aid, Integer.valueOf(c.getUID().getId()))).toList(); + List queries = new ArrayList<>(); + thing.getChannels().stream().forEach(c -> { + String iid = c.getProperties().get("iid"); + if (iid != null) { + queries.add("%s.%s".formatted(aid, iid)); + } + }); if (queries.isEmpty()) { return; } String jsonResponse = rwService.readCharacteristic(String.join(",", queries)); Service service = GSON.fromJson(jsonResponse, Service.class); if (service != null && service.characteristics instanceof List characteristics) { - for (Characteristic characteristic : characteristics) { - for (Channel channel : thing.getChannels()) { - if (channel.getUID().getId().equals(String.valueOf(characteristic.iid)) + for (Channel channel : thing.getChannels()) { + String iid = channel.getProperties().get("iid"); + if (iid == null) { + continue; + } + for (Characteristic characteristic : characteristics) { + if (iid.equals(String.valueOf(characteristic.iid)) && characteristic.value instanceof JsonElement element) { updateState(channel.getUID(), convertJsonToState(element, channel)); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index edc043de57b43..8aa3956fe1fda 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -20,6 +20,8 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicInteger; @@ -53,13 +55,13 @@ public SecureSession(Socket socket, AsymmetricSessionKeys keys) throws IOExcepti * Sends multiple data frames over the output stream. Splits the plaintext into chunks <= 1024 bytes, * encrypts them, and sends them as separate frames. * - * @param plainTextIn the complete plaintext message to be sent. + * @param plainText the complete plaintext message to be sent. * @throws Exception if an error occurs during encryption or sending. */ - public void send(byte[] plainTextIn) throws Exception { - ByteArrayInputStream plainText = new ByteArrayInputStream(plainTextIn); - while (plainText.available() > 0) { - sendFrame(plainText); + public void send(byte[] plainText) throws Exception { + ByteArrayInputStream plainTextStream = new ByteArrayInputStream(plainText); + while (plainTextStream.available() > 0) { + sendFrame(plainTextStream); } out.flush(); } @@ -70,16 +72,16 @@ public void send(byte[] plainTextIn) throws Exception { * tag. The length prefix is included in the cipher AAD to ensure integrity. The write counter is * incremented after sending the frame to ensure nonce uniqueness. * - * @param plainText the input stream containing the plaintext to be sent. + * @param plainTextStream the input stream containing the plaintext to be sent. * @throws Exception if an error occurs during encryption or sending. */ - private void sendFrame(ByteArrayInputStream plainText) throws Exception { - int frameLen = Math.min(1024, plainText.available()); - byte[] frameAad = new byte[] { (byte) (frameLen & 0xFF), (byte) ((frameLen >> 8) & 0xFF) }; - out.write(frameAad, 0, frameAad.length); - byte[] chunk = plainText.readNBytes(frameLen); - byte[] nonce = generateNonce(writeCounter.getAndIncrement()); - out.write(encrypt(writeKey, nonce, chunk, frameAad)); // AAD = lenBytes; outputs extra 16 byte tag + private void sendFrame(ByteArrayInputStream plainTextStream) throws Exception { + short frameLen = (short) Math.min(1024, plainTextStream.available()); + ByteBuffer frameAad = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(frameLen); + out.write(frameAad.array(), 0, frameAad.array().length); // send length prefix + byte[] plainText = plainTextStream.readNBytes(frameLen); + byte[] nonce64 = generateNonce64(writeCounter.getAndIncrement()); + out.write(encrypt(writeKey, nonce64, plainText, frameAad.array())); // AAD = lenBytes; outputs extra 16 byte tag } /** @@ -112,13 +114,13 @@ public byte[] receive() throws Exception { */ private byte[] receiveFrame() throws Exception { byte[] frameAad = in.readNBytes(2); - int frameLen = (frameAad[0] & 0xFF) + ((frameAad[1] & 0xFF) << 8); + short frameLen = ByteBuffer.wrap(frameAad).order(ByteOrder.LITTLE_ENDIAN).getShort(); if (frameLen < 0 || frameLen > 1024) { throw new SecurityException("Invalid frame length"); } byte[] cipherText = in.readNBytes(frameLen + 16); // read 16 extra bytes for the auth tag - byte[] nonce = generateNonce(readCounter.getAndIncrement()); - return decrypt(readKey, nonce, cipherText, frameAad); + byte[] nonce64 = generateNonce64(readCounter.getAndIncrement()); + return decrypt(readKey, nonce64, cipherText, frameAad); } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 2e3df2a4c2503..1dcc4cde0ede9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -88,7 +88,7 @@ public byte[] put(String endpoint, String contentType, byte[] content) return execute("PUT", endpoint, contentType, content); } - private byte[] execute(String method, String endpoint, String contentType, byte[] body) + private synchronized byte[] execute(String method, String endpoint, String contentType, byte[] body) throws IOException, InterruptedException, TimeoutException, ExecutionException { try { byte[] request = buildRequest(method, endpoint, contentType, body); diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 04776eb96451b..4a7f7dd21ab5d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -12,14 +12,12 @@ thing-type.homekit.accessory.description = HomeKit Accessory Device # thing types config -thing-type.config.homekit.bridge.ipV4Address.label = IP Address -thing-type.config.homekit.bridge.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.bridge.host.label = IP Address +thing-type.config.homekit.bridge.host.description = IP v4 address of the HomeKit bridge. thing-type.config.homekit.bridge.pairingCode.label = Pairing Code -thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit accessory. -thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval -thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the accessory is polled in sec. -thing-type.config.homekit.accessory.ipV4Address.label = IP Address -thing-type.config.homekit.accessory.ipV4Address.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit bridge. +thing-type.config.homekit.accessory.host.label = IP Address +thing-type.config.homekit.accessory.host.description = IP v4 address of the HomeKit accessory. thing-type.config.homekit.accessory.pairingCode.label = Pairing Code thing-type.config.homekit.accessory.pairingCode.description = Code used for pairing with the HomeKit accessory. thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 8fe5c7fa3f1b9..234578c108918 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -35,18 +35,12 @@ network-address - IP v4 address of the HomeKit accessory. + IP v4 address of the HomeKit bridge. password - Code used for pairing with the HomeKit accessory. - - - - Interval at which the accessory is polled in sec. - 60 - true + Code used for pairing with the HomeKit bridge. diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java index e4ba925c56176..914000d43126c 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -15,9 +15,12 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.FAKE_PROPERTY_CHANNEL_TYPE_UID; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; @@ -25,6 +28,7 @@ import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.dto.Service; +import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelGroupDefinition; @@ -42,7 +46,7 @@ @NonNullByDefault class TestChannelCreation { - // Chapter 6.6.4 Example Accessory Attribute Database in JSON + // Apple HomeKit Specification Chapter 6.6.4 Example Accessory Attribute Database in JSON private static final String TEST_JSON = """ { "accessories": [ @@ -382,9 +386,9 @@ void testChannelDefinitions() { List channelGroupDefinitions = accessory .buildAndRegisterChannelGroupDefinitions(typeProvider); - // There should be one channel group definition for the Light Bulb service and one for the properties + // There should be just one channel group definition for the Light Bulb service assertNotNull(channelGroupDefinitions); - assertEquals(2, channelGroupDefinitions.size()); + assertEquals(1, channelGroupDefinitions.size()); // Check that the channel group definition and its type UID and label are set for (ChannelGroupDefinition groupDef : channelGroupDefinitions) { @@ -393,31 +397,12 @@ void testChannelDefinitions() { assertNotNull(groupDef.getLabel()); } - // There should be one channel group type for the Light Bulb service and one for the properties - assertEquals(2, channelGroupTypes.size()); - - // Check that the public-hap-service-accessory-information channel group type and its UID and label are set - ChannelGroupType channelGroupType = channelGroupTypes.stream() - .filter(cgt -> "accessory-information".equals(cgt.getUID().getId())).findFirst().orElse(null); - assertNotNull(channelGroupType); - // There should be four fake channel definitions for the Accessory Information service - assertEquals(4, channelGroupType.getChannelDefinitions().size()); - - // Check the Name fake channel definition - ChannelDefinition channelDefinition = channelGroupType.getChannelDefinitions().stream() - .filter(cd -> "name".equals(cd.getId())).findFirst().orElse(null); - assertNotNull(channelDefinition); - assertEquals("Acme LED Light Bulb", channelDefinition.getLabel()); - - // Check the Serial Number fake channel definition - channelDefinition = channelGroupType.getChannelDefinitions().stream() - .filter(cd -> "serialNumber".equals(cd.getId())).findFirst().orElse(null); - assertNotNull(channelDefinition); - assertEquals("099DB48E9E28", channelDefinition.getLabel()); + // There should be just one channel group type for the Light Bulb service + assertEquals(1, channelGroupTypes.size()); // Check that the channel group type and its UID and label are set - channelGroupType = channelGroupTypes.stream().filter(cgt -> "lightbulb".equals(cgt.getUID().getId())) - .findFirst().orElse(null); + ChannelGroupType channelGroupType = channelGroupTypes.stream() + .filter(cgt -> "lightbulb".equals(cgt.getUID().getId())).findFirst().orElse(null); assertNotNull(channelGroupType); assertEquals("Channel group type: Light Bulb", channelGroupType.getLabel()); assertEquals("lightbulb", channelGroupType.getUID().getId()); @@ -426,7 +411,7 @@ void testChannelDefinitions() { assertEquals(2, channelGroupType.getChannelDefinitions().size()); // Check the Brightness channel definition and its properties - channelDefinition = channelGroupType.getChannelDefinitions().stream() + ChannelDefinition channelDefinition = channelGroupType.getChannelDefinitions().stream() .filter(cd -> "Brightness".equals(cd.getLabel())).findFirst().orElse(null); assertNotNull(channelDefinition); assertEquals("brightness", channelDefinition.getChannelTypeUID().getId()); @@ -451,5 +436,33 @@ void testChannelDefinitions() { assertEquals("light", channelType.getCategory()); assertTrue(channelType.getTags().contains("Control")); assertTrue(channelType.getTags().contains("Brightness")); + + // get the accessory information for the bridge (accessory 1) and create properties from it + accessory = accessories.getAccessory(1); + assertNotNull(accessory); + Map properties = new HashMap<>(); + for (Service service : accessory.services) { + if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { + for (Characteristic characteristic : service.characteristics) { + ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(typeProvider); + if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { + String name = channelDef.getId(); + String value = channelDef.getLabel(); + if (value != null) { + properties.put(name, value); + } + } + } + break; + } + } + + // there should be five properties + assertEquals(5, properties.size()); + assertEquals("Acme Light Bridge", properties.get("name")); + assertEquals("Acme", properties.get("manufacturer")); + assertEquals("037A2BABF19D", properties.get("serialNumber")); + assertEquals("Bridge1,1", properties.get("model")); + assertEquals("100.1.1", properties.get("firmwareRevision")); } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index 80a0f3edead13..d2455941ed305 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -60,10 +60,10 @@ class TestPairSetup { void testBareCrypto() throws Exception { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); byte[] key = new byte[32]; // 256 bits = 32 bytes - byte[] nonce = generateNonce(123); + byte[] nonce64 = generateNonce64(123); new SecureRandom().nextBytes(key); - byte[] cipherText = encrypt(key, nonce, plainText0, new byte[0]); - byte[] plainText1 = decrypt(key, nonce, cipherText, new byte[0]); + byte[] cipherText = encrypt(key, nonce64, plainText0, new byte[0]); + byte[] plainText1 = decrypt(key, nonce64, cipherText, new byte[0]); assertArrayEquals(plainText0, plainText1); } From b78f7405ad51a7103e7121ac5956942a47458aca Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 30 Sep 2025 15:01:02 +0100 Subject: [PATCH 039/177] readme fixes Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index b0684bea30778..632c862f4f411 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -6,7 +6,7 @@ This binding allows pairing with HomeKit accessory devices and importing their s There are two types of Things supported: -- `accessory`: This integrates a single HomeKit accessory, whereby its services appear as channel groups their respective service- characteristics appear as channels. +- `accessory`: This integrates a single HomeKit accessory, whereby its services appear as channel groups, and the respective characteristics appear as channels. - `bridge`: This integrates a HomeKit bridge accessory containing multiple child `accessory` Things. So Things of type `accessory` either represent a stand-alone accessories or a child of a `bridge` Thing. @@ -16,7 +16,7 @@ Whereas child `accessory` Things communicate via their respective `bridge` Thing ## Discovery Both `bridge` and stand-alone `accessory` Things will be auto discovered via mDNS. -And once a `bridge` Thing has been instantiated, and paired, its child `accessory` Things will also be auto discovered. +Once a `bridge` Thing has been instantiated and paired, its child `accessory` Things will also be auto- discovered. ## Thing Configuration @@ -33,7 +33,7 @@ However the following are the . | `pairingCode` | text | Code used for pairing with the HomeKit accessory. | N/A | see below | see below | | `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | -Things of type `bridge` and stand-alone `accessory` Things require both an `host` and a `pairingCode`. +Things of type `bridge` and stand-alone `accessory` Things require both a `host` and a `pairingCode`. The `host` is set by the mDNS auto- discovery process. And the `pairingCode` must be entered manually. @@ -42,7 +42,7 @@ Therefore these parameters are preset to `n/a`. ## Channels -Channels will be auto- created depending on the services and respective service- characteristis of the HomeKit accessory. +Channels will be auto-created depending on the services and characteristics published by the HomeKit accessory. ### Thing Configuration From bbe4b4cb6a93ba0bb3f82aecad3461ba858f2197 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 30 Sep 2025 18:57:39 +0100 Subject: [PATCH 040/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../discovery/HomekitChildDiscoveryService.java | 1 + .../homekit/internal/dto/Characteristic.java | 1 - .../internal/handler/HomekitBridgeHandler.java | 4 +--- .../internal/handler/HomekitDeviceHandler.java | 5 ++++- .../internal/persistence/HomekitTypeProvider.java | 14 ++++++++++++-- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 61a25c8f583f5..2d863369c0757 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -56,6 +56,7 @@ public void devicesDiscovered(Thing bridge, Collection accessories) { .withLabel(THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), bridge.getLabel())) // .withProperty(CONFIG_HOST, "n/a") // .withProperty(CONFIG_PAIRING_CODE, "n/a") // + .withProperty(PROPERTY_ACCESSORY_UID, uid.toString()) // .withRepresentationProperty(PROPERTY_ACCESSORY_UID).build()); } }); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 7167e7fc0e57b..872fdc8bf1036 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -521,7 +521,6 @@ public class Characteristic { case SATURATION: itemType = CoreItemFactory.DIMMER; propertyTag = Property.COLOR; - itemType = CoreItemFactory.COLOR; category = "color"; break; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index fb1aa4345e842..2d24107fb5327 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -80,9 +80,7 @@ public void initialize() { @Override public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) { - if (childHandler instanceof HomekitDeviceHandler homekitDeviceHandler) { - homekitDeviceHandler.accessoriesLoaded(); - } + // do nothing } @Override diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index e5c858a3427bf..9e5a683998fa1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -285,6 +285,7 @@ private void createChannels() { String value = channelDef.getLabel(); if (value != null) { properties.put(name, value); + logger.trace("Discovered property {}={} for thing {}", name, value, thing.getUID()); } } else { ChannelType channelType = typeProvider.getChannelType(channelDef.getChannelTypeUID(), null); @@ -296,6 +297,8 @@ private void createChannels() { Optional.ofNullable(channelDef.getLabel()).ifPresent(builder::withLabel); Optional.ofNullable(channelDef.getDescription()).ifPresent(builder::withDescription); channels.add(builder.build()); + logger.trace("Discovered channel {} of type {} for thing {}", channelUID, + channelType.getUID(), thing.getUID()); } } }); @@ -346,7 +349,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { writer.writeCharacteristic(aid.toString(), channelUID.getId(), object); } } catch (Exception e) { - logger.warn("Failed to send command '{}' as object '{}' to accessory for '{}", command, object, channelUID, + logger.warn("Failed to send command '{}' as object '{}' to accessory for '{}'", command, object, channelUID, e); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java index d40cdb5c1751c..e571ac5d6de54 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java @@ -16,7 +16,9 @@ import org.openhab.core.storage.StorageService; import org.openhab.core.thing.binding.AbstractStorageBasedTypeProvider; import org.openhab.core.thing.type.ChannelGroupTypeProvider; +import org.openhab.core.thing.type.ChannelGroupTypeRegistry; import org.openhab.core.thing.type.ChannelTypeProvider; +import org.openhab.core.thing.type.ChannelTypeRegistry; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -28,11 +30,19 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -@Component(service = { HomekitTypeProvider.class, ChannelTypeProvider.class, ChannelGroupTypeProvider.class }) +@Component(service = { ChannelTypeProvider.class, ChannelGroupTypeProvider.class, HomekitTypeProvider.class }) public class HomekitTypeProvider extends AbstractStorageBasedTypeProvider { + /** + * Creates a HomekitTypeProvider which uses the given {@link StorageService} to persist the types. It forces + * that OSGI loads {@link StorageService}, {@link ChannelTypeRegistry}, and {@link ChannelGroupTypeRegistry} + * before this component gets loaded. Which ensures this component is active before the handler factory gets + * loaded, and therefore before any thing handlers are created and could start creating channels. + */ @Activate - public HomekitTypeProvider(@Reference StorageService storageService) { + public HomekitTypeProvider(@Reference StorageService storageService, + @Reference ChannelTypeRegistry channelTypeRegistry, + @Reference ChannelGroupTypeRegistry channelGroupTypeRegistry) { super(storageService); } } From e6477eeed575145db0fc22d71d0029a544108f99 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 1 Oct 2025 12:49:54 +0100 Subject: [PATCH 041/177] unique xxx-type uids Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 6 ++++- .../HomekitChildDiscoveryService.java | 24 +++++++++++++++---- .../homekit/internal/dto/Characteristic.java | 15 +++++++----- .../binding/homekit/internal/dto/Service.java | 16 ++++++++----- .../handler/HomekitBaseServerHandler.java | 5 ++++ .../handler/HomekitBridgeHandler.java | 8 ++++++- .../homekit/internal/TestChannelCreation.java | 8 +++---- 7 files changed, 60 insertions(+), 22 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index daf1b7561d9c0..67df31a97b1be 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -35,9 +35,13 @@ public class HomekitBindingConstants { public static final ChannelTypeUID FAKE_PROPERTY_CHANNEL_TYPE_UID = new ChannelTypeUID(BINDING_ID, FAKE_PROPERTY_CHANNEL); + // prefixes for channel-group-type and channel-type UIDs + public static final String CHANNEL_GROUP_TYPE_ID_FMT = "channel-group-type-%s"; + public static final String CHANNEL_TYPE_ID_FMT = "channel-type-%s"; + // labels public static final String THING_LABEL_FMT = "%s on %s"; - public static final String GROUP_TYPE_LABEL_FMT = "Channel group type: %s"; + public static final String CHANNEL_GROUP_TYPE_LABEL_FMT = "Channel group type: %s"; public static final String CHANNEL_TYPE_LABEL_FMT = "Channel type: %s"; // configuration parameters diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 2d863369c0757..67855e99f54e8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -15,10 +15,12 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.util.Collection; +import java.util.HashSet; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.dto.Accessory; +import org.openhab.binding.homekit.internal.handler.HomekitBridgeHandler; import org.openhab.core.config.discovery.AbstractDiscoveryService; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.DiscoveryService; @@ -28,7 +30,6 @@ /** * Discovery service component that publishes newly discovered child accessories of a HomeKit bridge accessory. - * No active scanning is performed; it relies on being informed of new accessories by the bridge handler. * Discovered accessories are published with a ThingUID based on their accessory ID (aid) and service ID (iid). * * @author Andrew Fiddian-Green - Initial Contribution @@ -37,16 +38,31 @@ @Component(service = DiscoveryService.class) public class HomekitChildDiscoveryService extends AbstractDiscoveryService { + private static final int TIMEOUT_SECONDS = 10; + + private final Set bridgeHandlers = new HashSet<>(); + public HomekitChildDiscoveryService() { - super(Set.of(THING_TYPE_ACCESSORY), 10, false); + super(Set.of(THING_TYPE_ACCESSORY), TIMEOUT_SECONDS); + } + + public void addBridgeHandler(HomekitBridgeHandler handler) { + bridgeHandlers.add(handler); + startScan(); + } + + public void removeBridgeHandler(HomekitBridgeHandler handler) { + bridgeHandlers.remove(handler); } @Override protected void startScan() { - // no scanning is done; we rely on being informed of new accessories + for (HomekitBridgeHandler handler : bridgeHandlers) { + discoverChildren(handler.getThing(), handler.getAccessories()); + } } - public void devicesDiscovered(Thing bridge, Collection accessories) { + private void discoverChildren(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { if (accessory.aid != null && accessory.services != null) { // accessory ID should be unique per bridge diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 872fdc8bf1036..293ef63090b50 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -675,8 +675,11 @@ public class Characteristic { * NOTE: different accessories may have the same characteristicType, but their other * properties e.g. min, max, step, unit may be different */ - ChannelTypeUID uid = new ChannelTypeUID(BINDING_ID, characteristicType.getOpenhabType()); - String typeLabel = CHANNEL_TYPE_LABEL_FMT.formatted(characteristicType.toString()); + ChannelTypeUID channelTypeUid = new ChannelTypeUID(BINDING_ID, + CHANNEL_TYPE_ID_FMT.formatted(characteristicType.getOpenhabType())); + + String channelTypeLabel = CHANNEL_TYPE_LABEL_FMT.formatted(characteristicType.toString()); + ChannelType channelType; if (isStateChannel) { if (itemType == null) { @@ -690,7 +693,7 @@ public class Characteristic { } return null; } - StateChannelTypeBuilder builder = ChannelTypeBuilder.state(uid, typeLabel, itemType); + StateChannelTypeBuilder builder = ChannelTypeBuilder.state(channelTypeUid, channelTypeLabel, itemType); Optional.ofNullable(category).ifPresent(builder::withCategory); if (pointTag != null) { if (propertyTag != null) { @@ -701,7 +704,7 @@ public class Characteristic { } channelType = builder.build(); } else { - channelType = ChannelTypeBuilder.trigger(uid, typeLabel).build(); + channelType = ChannelTypeBuilder.trigger(channelTypeUid, channelTypeLabel).build(); } // persist the channel _type_ @@ -722,8 +725,8 @@ public class Characteristic { Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> properties.put("ev", s)); // return the definition of a specific _instance_ of the channel _type_ - return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), uid).withProperties(properties) - .withLabel(getChannelInstanceLabel()).build(); + return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), channelTypeUid) + .withProperties(properties).withLabel(getChannelInstanceLabel()).build(); } /* diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index d0b701a288b09..3539fccdd7b2f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -67,15 +67,19 @@ public class Service { return null; } - ChannelGroupTypeUID groupTypeUID = new ChannelGroupTypeUID(BINDING_ID, serviceType.getOpenhabType()); - String typeLabel = GROUP_TYPE_LABEL_FMT.formatted(serviceType.toString()); - ChannelGroupType groupType = ChannelGroupTypeBuilder.instance(groupTypeUID, typeLabel) // + ChannelGroupTypeUID channelGroupTypeUID = new ChannelGroupTypeUID(BINDING_ID, + CHANNEL_GROUP_TYPE_ID_FMT.formatted(serviceType.getOpenhabType())); + + String channelGroupTypeLabel = CHANNEL_GROUP_TYPE_LABEL_FMT.formatted(serviceType.toString()); + + ChannelGroupType channelGroupType = ChannelGroupTypeBuilder.instance(channelGroupTypeUID, channelGroupTypeLabel) // .withChannelDefinitions(channelDefinitions) // .build(); // persist the group _type_, and return the definition of a specific _instance_ of that type - typeProvider.putChannelGroupType(groupType); - return new ChannelGroupDefinition(serviceType.getOpenhabType(), groupTypeUID, getGroupInstanceLabel(), null); + typeProvider.putChannelGroupType(channelGroupType); + return new ChannelGroupDefinition(serviceType.getOpenhabType(), channelGroupTypeUID, + getChannelGroupInstanceLabel(), null); } /* @@ -83,7 +87,7 @@ public class Service { * CharacteristicType.NAME and if present returns that value. Otherwise returns the service * type in Title Case.. */ - public String getGroupInstanceLabel() { + public String getChannelGroupInstanceLabel() { if (name != null && !name.isBlank()) { return name; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 918348c64a535..5bff3770ea070 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -17,6 +17,7 @@ import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -299,4 +300,8 @@ private void storeLongTermKeys() { property = accessoryKey == null ? null : Base64.getEncoder().encodeToString(accessoryKey.getEncoded()); thing.setProperty(PROPERTY_ACCESSORY_PUBLIC_KEY, property); } + + public Collection getAccessories() { + return accessories.values(); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 2d24107fb5327..dae1cbe051434 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -91,7 +91,7 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { @Override protected void accessoriesLoaded() { logger.debug("Bridge accessories loaded {}", accessories.size()); - discoveryService.devicesDiscovered(thing, accessories.values()); // discover child accessories + discoveryService.addBridgeHandler(this); // discover child accessories createProperties(); // create properties from accessory information } @@ -100,6 +100,12 @@ public void handleCommand(ChannelUID channelUID, Command command) { // do nothing } + @Override + public void dispose() { + discoveryService.removeBridgeHandler(this); + super.dispose(); + } + /** * Creates properties for the bridge based on the characteristics within the ACCESSORY_INFORMATION * service (if any). diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java index 914000d43126c..a76308eaecad1 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -402,10 +402,10 @@ void testChannelDefinitions() { // Check that the channel group type and its UID and label are set ChannelGroupType channelGroupType = channelGroupTypes.stream() - .filter(cgt -> "lightbulb".equals(cgt.getUID().getId())).findFirst().orElse(null); + .filter(cgt -> "channel-group-type-lightbulb".equals(cgt.getUID().getId())).findFirst().orElse(null); assertNotNull(channelGroupType); assertEquals("Channel group type: Light Bulb", channelGroupType.getLabel()); - assertEquals("lightbulb", channelGroupType.getUID().getId()); + assertEquals("channel-group-type-lightbulb", channelGroupType.getUID().getId()); // There should be two channel definitions for the Light Bulb service: On and Brightness assertEquals(2, channelGroupType.getChannelDefinitions().size()); @@ -414,7 +414,7 @@ void testChannelDefinitions() { ChannelDefinition channelDefinition = channelGroupType.getChannelDefinitions().stream() .filter(cd -> "Brightness".equals(cd.getLabel())).findFirst().orElse(null); assertNotNull(channelDefinition); - assertEquals("brightness", channelDefinition.getChannelTypeUID().getId()); + assertEquals("channel-type-brightness", channelDefinition.getChannelTypeUID().getId()); assertEquals("Brightness", channelDefinition.getLabel()); assertEquals("%", channelDefinition.getProperties().get("unit")); assertEquals("int", channelDefinition.getProperties().get("format")); @@ -430,7 +430,7 @@ void testChannelDefinitions() { ChannelType channelType = channelTypes.stream().filter(ct -> "Dimmer".equals(ct.getItemType())).findFirst() .orElse(null); assertNotNull(channelType); - assertEquals("brightness", channelType.getUID().getId()); + assertEquals("channel-type-brightness", channelType.getUID().getId()); assertEquals("Channel type: Brightness", channelType.getLabel()); assertEquals("Dimmer", channelType.getItemType()); assertEquals("light", channelType.getCategory()); From 13d29913c91876cd057fed49cd73a2fbb12cf85d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 1 Oct 2025 16:22:47 +0100 Subject: [PATCH 042/177] refactor type provider code Signed-off-by: Andrew Fiddian-Green --- .../factory/HomekitHandlerFactory.java | 12 ++++- .../handler/HomekitDeviceHandler.java | 44 +++++++++++-------- .../persistence/HomekitTypeProvider.java | 12 +---- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index 4288271124a18..b88f119823317 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -30,6 +30,8 @@ import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.openhab.core.thing.type.ChannelGroupTypeRegistry; +import org.openhab.core.thing.type.ChannelTypeRegistry; import org.osgi.framework.ServiceRegistration; import org.osgi.service.component.ComponentContext; import org.osgi.service.component.annotations.Activate; @@ -49,13 +51,19 @@ public class HomekitHandlerFactory extends BaseThingHandlerFactory { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_ACCESSORY); private final HomekitTypeProvider typeProvider; + private final ChannelTypeRegistry channelTypeRegistry; + private final ChannelGroupTypeRegistry channelGroupTypeRegistry; private @Nullable ServiceRegistration discoveryServiceRegistration; private @Nullable HomekitChildDiscoveryService discoveryService; @Activate - public HomekitHandlerFactory(@Reference HomekitTypeProvider typeProvider) { + public HomekitHandlerFactory(@Reference HomekitTypeProvider typeProvider, + @Reference ChannelTypeRegistry channelTypeRegistry, + @Reference ChannelGroupTypeRegistry channelGroupTypeRegistry) { this.typeProvider = typeProvider; + this.channelTypeRegistry = channelTypeRegistry; + this.channelGroupTypeRegistry = channelGroupTypeRegistry; } @Override @@ -75,7 +83,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { return new HomekitBridgeHandler((Bridge) thing, typeProvider, registerDiscoveryService()); } else if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { - return new HomekitDeviceHandler(thing, typeProvider); + return new HomekitDeviceHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry); } return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 9e5a683998fa1..30cdf0458e891 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -49,7 +49,9 @@ import org.openhab.core.thing.binding.builder.ChannelBuilder; import org.openhab.core.thing.binding.builder.ThingBuilder; import org.openhab.core.thing.type.ChannelGroupType; +import org.openhab.core.thing.type.ChannelGroupTypeRegistry; import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.thing.type.ChannelTypeRegistry; import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; @@ -75,11 +77,16 @@ public class HomekitDeviceHandler extends HomekitBaseServerHandler { private static final int INITIAL_DELAY_SECONDS = 2; private final Logger logger = LoggerFactory.getLogger(HomekitDeviceHandler.class); + private final ChannelTypeRegistry channelTypeRegistry; + private final ChannelGroupTypeRegistry channelGroupTypeRegistry; private @Nullable ScheduledFuture refreshTask; - public HomekitDeviceHandler(Thing thing, HomekitTypeProvider typeProvider) { + public HomekitDeviceHandler(Thing thing, HomekitTypeProvider typeProvider, ChannelTypeRegistry channelTypeRegistry, + ChannelGroupTypeRegistry channelGroupTypeRegistry) { super(thing, typeProvider); + this.channelTypeRegistry = channelTypeRegistry; + this.channelGroupTypeRegistry = channelGroupTypeRegistry; } @Override @@ -277,28 +284,27 @@ private void createChannels() { List channels = new ArrayList<>(); Map properties = new HashMap<>(thing.getProperties()); // keep existing properties accessory.buildAndRegisterChannelGroupDefinitions(typeProvider).forEach(groupDef -> { - ChannelGroupType groupType = typeProvider.getChannelGroupType(groupDef.getTypeUID(), null); - if (groupType != null) { - groupType.getChannelDefinitions().forEach(channelDef -> { - if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { - String name = channelDef.getId(); - String value = channelDef.getLabel(); + ChannelGroupType channelGroupType = channelGroupTypeRegistry.getChannelGroupType(groupDef.getTypeUID()); + if (channelGroupType != null) { + channelGroupType.getChannelDefinitions().forEach(chanDef -> { + if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(chanDef.getChannelTypeUID())) { + String name = chanDef.getId(); + String value = chanDef.getLabel(); if (value != null) { properties.put(name, value); - logger.trace("Discovered property {}={} for thing {}", name, value, thing.getUID()); + logger.trace("Built property {}={} for thing {}", name, value, thing.getUID()); } } else { - ChannelType channelType = typeProvider.getChannelType(channelDef.getChannelTypeUID(), null); + ChannelType channelType = channelTypeRegistry.getChannelType(chanDef.getChannelTypeUID()); if (channelType != null) { - ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), - channelDef.getId()); + ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), chanDef.getId()); ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()) - .withProperties(channelDef.getProperties()); - Optional.ofNullable(channelDef.getLabel()).ifPresent(builder::withLabel); - Optional.ofNullable(channelDef.getDescription()).ifPresent(builder::withDescription); + .withProperties(chanDef.getProperties()); + Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); + Optional.ofNullable(chanDef.getDescription()).ifPresent(builder::withDescription); channels.add(builder.build()); - logger.trace("Discovered channel {} of type {} for thing {}", channelUID, - channelType.getUID(), thing.getUID()); + logger.trace("Built channel {} of type {} for thing {}", channelUID, channelType.getUID(), + thing.getUID()); } } }); @@ -312,9 +318,6 @@ private void createChannels() { SemanticTag newTag = accessory.getSemanticEquipmentTag(); if (newLabel != null || newChannels != null || newProperties != null || newTag != null) { - logger.debug("Updating thing {} channels, {} properties, label {}, tag {}", channels.size(), - properties.size(), newLabel, newTag); - ThingBuilder builder = editThing().withProperties(properties).withChannels(channels); Optional.ofNullable(newLabel).ifPresent(builder::withLabel); Optional.ofNullable(newChannels).ifPresent(builder::withChannels); @@ -322,6 +325,9 @@ private void createChannels() { Optional.ofNullable(newTag).ifPresent(builder::withSemanticEquipmentTag); updateThing(builder.build()); + logger.debug("Updated thing {} channels, {} properties, label {}, tag {}", channels.size(), + properties.size(), newLabel, newTag); + channelsAndPropertiesLoaded(); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java index e571ac5d6de54..4580e56068d68 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java @@ -16,9 +16,7 @@ import org.openhab.core.storage.StorageService; import org.openhab.core.thing.binding.AbstractStorageBasedTypeProvider; import org.openhab.core.thing.type.ChannelGroupTypeProvider; -import org.openhab.core.thing.type.ChannelGroupTypeRegistry; import org.openhab.core.thing.type.ChannelTypeProvider; -import org.openhab.core.thing.type.ChannelTypeRegistry; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -33,16 +31,8 @@ @Component(service = { ChannelTypeProvider.class, ChannelGroupTypeProvider.class, HomekitTypeProvider.class }) public class HomekitTypeProvider extends AbstractStorageBasedTypeProvider { - /** - * Creates a HomekitTypeProvider which uses the given {@link StorageService} to persist the types. It forces - * that OSGI loads {@link StorageService}, {@link ChannelTypeRegistry}, and {@link ChannelGroupTypeRegistry} - * before this component gets loaded. Which ensures this component is active before the handler factory gets - * loaded, and therefore before any thing handlers are created and could start creating channels. - */ @Activate - public HomekitTypeProvider(@Reference StorageService storageService, - @Reference ChannelTypeRegistry channelTypeRegistry, - @Reference ChannelGroupTypeRegistry channelGroupTypeRegistry) { + public HomekitTypeProvider(@Reference StorageService storageService) { super(storageService); } } From b4eeea48335974001e8fe049961795e94ac5b0cf Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 2 Oct 2025 23:35:57 +0100 Subject: [PATCH 043/177] refactoring and fix write characteristic Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 12 ++++- .../HomekitChildDiscoveryService.java | 5 +- .../HomekitMdnsDiscoveryParticipant.java | 6 +-- .../homekit/internal/dto/Characteristic.java | 17 +++--- .../handler/HomekitDeviceHandler.java | 54 ++++++++++++------- .../CharacteristicReadWriteService.java | 21 ++------ 6 files changed, 61 insertions(+), 54 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 67df31a97b1be..09b65b8ea1134 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -49,13 +49,23 @@ public class HomekitBindingConstants { public static final String CONFIG_PAIRING_CODE = "pairingCode"; public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; - // properties + // thing properties public static final String PROPERTY_ACCESSORY_UID = "accessoryUID"; public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; public static final String PROPERTY_CONTROLLER_PRIVATE_KEY = "controllerSecretKey"; public static final String PROPERTY_ACCESSORY_PUBLIC_KEY = "accessoryPublicKey"; + // channel properties + public static final String PROPERTY_IID = "iid"; + public static final String PROPERTY_MIN_VALUE = "minValue"; + public static final String PROPERTY_MAX_VALUE = "maxValue"; + public static final String PROPERTY_MIN_STEP = "minStep"; + public static final String PROPERTY_FORMAT = "format"; + public static final String PROPERTY_UNIT = "unit"; + public static final String PROPERTY_PERMS = "perms"; + public static final String PROPERTY_EV = "ev"; + // HomeKit HTTP URI endpoints and content types public static final String ENDPOINT_ACCESSORIES = "/accessories"; public static final String ENDPOINT_CHARACTERISTICS = "/characteristics"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 67855e99f54e8..96c6a26b6b15f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -64,9 +64,8 @@ protected void startScan() { private void discoverChildren(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { - if (accessory.aid != null && accessory.services != null) { - // accessory ID should be unique per bridge - ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), accessory.aid.toString()); + if (accessory.aid instanceof Integer aid && aid != 1 && accessory.services != null) { + ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), aid.toString()); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // .withLabel(THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), bridge.getLabel())) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index ec41f95225ea6..9e97a5e4a551c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -89,13 +89,9 @@ public String getServiceType() { .withProperty(CONFIG_HOST, host) // .withProperty(Thing.PROPERTY_MAC_ADDRESS, mac) // .withProperty(PROPERTY_ACCESSORY_CATEGORY, cat.toString()) // + .withProperty(PROPERTY_ACCESSORY_UID, new ThingUID(THING_TYPE_ACCESSORY, "1").toString()) // .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); - if (AccessoryCategory.BRIDGE == cat) { - // setting to '1' means force the first (i.e. only) accessory - ThingUID accessoryUid = new ThingUID(THING_TYPE_ACCESSORY, "1"); - builder.withProperty(PROPERTY_ACCESSORY_UID, accessoryUid.toString()); - } String model = properties.get("md"); if (model != null) { builder.withProperty(Thing.PROPERTY_MODEL_ID, model); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 293ef63090b50..61aa1cc5ca552 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -59,6 +59,7 @@ public class Characteristic { public @NonNullByDefault({}) JsonElement value; // e.g. true, 23, "Some String" public @NonNullByDefault({}) String description; public @NonNullByDefault({}) Boolean ev; // e.g. true + public @NonNullByDefault({}) Integer aid; // e.g. 10 /** * Builds a ChannelType and a ChannelDefinition based on the characteristic properties. @@ -715,14 +716,14 @@ public class Characteristic { * through properties instead e.g. minValue, maxValue, minStep, format, unit, perms, ev */ Map properties = new HashMap<>(); - Optional.ofNullable(iid).map(v -> v.toString()).ifPresent(s -> properties.put("iid", s)); - Optional.ofNullable(minValue).map(v -> v.toString()).ifPresent(s -> properties.put("minValue", s)); - Optional.ofNullable(maxValue).map(v -> v.toString()).ifPresent(s -> properties.put("maxValue", s)); - Optional.ofNullable(minStep).map(v -> v.toString()).ifPresent(s -> properties.put("minStep", s)); - Optional.ofNullable(format).ifPresent(s -> properties.put("format", s)); - Optional.ofNullable(uom).ifPresent(s -> properties.put("unit", s)); - Optional.ofNullable(perms).map(l -> String.join(",", l)).ifPresent(s -> properties.put("perms", s)); - Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> properties.put("ev", s)); + Optional.ofNullable(iid).map(v -> v.toString()).ifPresent(s -> properties.put(PROPERTY_IID, s)); + Optional.ofNullable(minValue).map(v -> v.toString()).ifPresent(s -> properties.put(PROPERTY_MIN_VALUE, s)); + Optional.ofNullable(maxValue).map(v -> v.toString()).ifPresent(s -> properties.put(PROPERTY_MAX_VALUE, s)); + Optional.ofNullable(minStep).map(v -> v.toString()).ifPresent(s -> properties.put(PROPERTY_MIN_STEP, s)); + Optional.ofNullable(format).ifPresent(s -> properties.put(PROPERTY_FORMAT, s)); + Optional.ofNullable(uom).ifPresent(s -> properties.put(PROPERTY_UNIT, s)); + Optional.ofNullable(perms).map(l -> String.join(",", l)).ifPresent(s -> properties.put(PROPERTY_PERMS, s)); + Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> properties.put(PROPERTY_EV, s)); // return the definition of a specific _instance_ of the channel _type_ return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), channelTypeUid) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 30cdf0458e891..1d5d16127ccf8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -128,9 +128,8 @@ private void channelsAndPropertiesLoaded() { * * @return the converted object suitable for HomeKit characteristic */ - private Object convertCommandToObject(Command command, Channel channel) { + private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { Object object = command; - Map properties = channel.getProperties(); // handle HSBType as not directly supported by HomeKit if (object instanceof HSBType) { @@ -140,7 +139,7 @@ private Object convertCommandToObject(Command command, Channel channel) { // convert QuantityTypes to the characteristic's unit if (object instanceof QuantityType quantity) { - if (properties.get("unit") instanceof String unit) { + if (channel.getProperties().get(PROPERTY_UNIT) instanceof String unit) { try { QuantityType temp = quantity.toUnit(unit); object = temp != null ? temp : quantity; @@ -152,20 +151,22 @@ private Object convertCommandToObject(Command command, Channel channel) { if (object instanceof Number number) { // clamp numbers to characteristic's min/max limits - Double min = Optional.ofNullable(properties.get("minValue")).map(s -> Double.valueOf(s)).orElse(null); + Double min = Optional.ofNullable(channel.getProperties().get(PROPERTY_MIN_VALUE)) + .map(s -> Double.valueOf(s)).orElse(null); if (min != null && number.doubleValue() < min.doubleValue()) { object = min; } - Double max = Optional.ofNullable(properties.get("maxValue")).map(s -> Double.valueOf(s)).orElse(null); + Double max = Optional.ofNullable(channel.getProperties().get(PROPERTY_MAX_VALUE)) + .map(s -> Double.valueOf(s)).orElse(null); if (max != null && number.doubleValue() > max.doubleValue()) { object = max; } // comply with characteristic's data format - String format = properties.get("format"); + String format = channel.getProperties().get(PROPERTY_FORMAT); if (format != null) { try { - object = switch (DataFormatType.valueOf(format)) { + object = switch (DataFormatType.from(format)) { case UINT8, UINT16, UINT32, UINT64, INT -> Integer.valueOf(number.intValue()); case FLOAT -> Float.valueOf(number.floatValue()); case STRING -> String.valueOf(number); @@ -173,7 +174,7 @@ private Object convertCommandToObject(Command command, Channel channel) { default -> object; }; } catch (IllegalArgumentException e) { - logger.warn("Unexpected format {} for channel {}", format, channel.getUID()); + logger.warn("Unexpected format {} for channel {}", format, channel.getUID(), e); } } } @@ -193,7 +194,8 @@ private Object convertCommandToObject(Command command, Channel channel) { object = dateTime.toFullString(); } - return object; + return object instanceof Number num ? new JsonPrimitive(num) + : object instanceof Boolean bool ? new JsonPrimitive(bool) : new JsonPrimitive(object.toString()); } /** @@ -242,8 +244,8 @@ private State convertJsonToState(JsonElement element, Channel channel) { int index = acceptedItemType.indexOf(":"); if (index > 0) { String targetDimension = acceptedItemType.substring(index + 1); - Unit sourceUnit = UnitUtils - .parseUnit(Optional.ofNullable(channel.getProperties().get("unit")).orElse(null)); + Unit sourceUnit = UnitUtils.parseUnit( + Optional.ofNullable(channel.getProperties().get(PROPERTY_UNIT)).orElse(null)); if (sourceUnit != null && targetDimension.equals(UnitUtils.getDimensionName(sourceUnit))) { yield QuantityType.valueOf(value.getAsNumber().doubleValue(), sourceUnit); } @@ -347,16 +349,20 @@ public void handleCommand(ChannelUID channelUID, Command command) { logger.warn("No writer service available to handle command for channel: {}", channelUID); return; } - Object object = null; try { Integer aid = getAccessoryId(); - if (aid != null) { - object = convertCommandToObject(command, channel); - writer.writeCharacteristic(aid.toString(), channelUID.getId(), object); + String iid = channel.getProperties().get(PROPERTY_IID); + if (aid != null && iid != null) { + Service service = new Service(); + Characteristic characteristic = new Characteristic(); + characteristic.aid = aid; + characteristic.iid = Integer.parseInt(iid); + characteristic.value = commandToJsonPrimitive(command, channel); + service.characteristics = List.of(characteristic); + writer.writeCharacteristic(GSON.toJson(service)); } } catch (Exception e) { - logger.warn("Failed to send command '{}' as object '{}' to accessory for '{}'", command, object, channelUID, - e); + logger.warn("Failed to send command '{}' to '{}'", command, channelUID, e); } } @@ -365,6 +371,16 @@ public void initialize() { super.initialize(); } + @Override + public void handleRemoval() { + ScheduledFuture task = refreshTask; + if (task != null) { + task.cancel(true); + } + refreshTask = null; + super.handleRemoval(); + } + @Override public void dispose() { ScheduledFuture task = refreshTask; @@ -386,7 +402,7 @@ private void refresh() { Integer aid = getAccessoryId(); List queries = new ArrayList<>(); thing.getChannels().stream().forEach(c -> { - String iid = c.getProperties().get("iid"); + String iid = c.getProperties().get(PROPERTY_IID); if (iid != null) { queries.add("%s.%s".formatted(aid, iid)); } @@ -398,7 +414,7 @@ private void refresh() { Service service = GSON.fromJson(jsonResponse, Service.class); if (service != null && service.characteristics instanceof List characteristics) { for (Channel channel : thing.getChannels()) { - String iid = channel.getProperties().get("iid"); + String iid = channel.getProperties().get(PROPERTY_IID); if (iid == null) { continue; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java index 03ad8bcdeaf4f..dafc0172cb6ab 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java @@ -28,8 +28,6 @@ @NonNullByDefault public class CharacteristicReadWriteService { - private static final String JSON_TEMPLATE = "{\"%s\":[{\"aid\":%s,\"iid\":%s,\"value\":%s}]}"; - private final IpTransport ipTransport; public CharacteristicReadWriteService(IpTransport ipTransport) { @@ -52,23 +50,10 @@ public String readCharacteristic(String query) throws Exception { /** * Writes a characteristic to the accessory. * - * @param aid Accessory ID - * @param iid Instance ID - * @param value Value to write (String, Number, Boolean) + * @param json the JSON string to write. * @throws Exception on communication or encryption errors */ - public void writeCharacteristic(String aid, String iid, Object value) throws Exception { - String json = JSON_TEMPLATE.formatted(ENDPOINT_CHARACTERISTICS, aid, iid, formatValue(value)); - ipTransport.put(ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, json.getBytes()); - } - - /* - * Formats the value for JSON. Strings are quoted, numbers and booleans are not. - */ - private String formatValue(Object value) { - if (value instanceof Boolean || value instanceof Number) { - return value.toString(); - } - return "\"" + value.toString() + "\""; + public void writeCharacteristic(String json) throws Exception { + ipTransport.put(ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, json.getBytes(StandardCharsets.UTF_8)); } } From bd268e5b5cca875039a27e0cf7f2b74b4211e1d8 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 3 Oct 2025 12:42:43 +0100 Subject: [PATCH 044/177] fix issues with characteristic write Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitBaseServerHandler.java | 8 +- .../handler/HomekitDeviceHandler.java | 6 +- ...ava => CharacteristicReadWriteClient.java} | 4 +- .../internal/session/SecureSession.java | 88 ++++++++++--------- .../internal/transport/IpTransport.java | 2 +- 5 files changed, 55 insertions(+), 53 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/{CharacteristicReadWriteService.java => CharacteristicReadWriteClient.java} (94%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 5bff3770ea070..34b4d7b5b27ce 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -31,7 +31,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; -import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; +import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; import org.openhab.binding.homekit.internal.hap_services.PairRemoveClient; import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; @@ -69,7 +69,7 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected boolean isChildAccessory = false; - protected @NonNullByDefault({}) CharacteristicReadWriteService rwService; + protected @NonNullByDefault({}) CharacteristicReadWriteClient rwService; protected @NonNullByDefault({}) String pairingCode; protected @NonNullByDefault({}) Integer accessoryId; protected @NonNullByDefault({}) IpTransport ipTransport; @@ -224,7 +224,7 @@ private void initializePairing() { controllerLongTermSecretKey, accessoryLongTermPublicKey); ipTransport.setSessionKeys(client.verify()); - rwService = new CharacteristicReadWriteService(ipTransport); + rwService = new CharacteristicReadWriteClient(ipTransport); logger.debug("Restored pairing was verified for {}", controllerPairingId); fetchAccessories(); @@ -258,7 +258,7 @@ private void initializePairing() { controllerLongTermSecretKey, accessoryLongTermPublicKey); ipTransport.setSessionKeys(pairVerifyClient.verify()); - rwService = new CharacteristicReadWriteService(ipTransport); + rwService = new CharacteristicReadWriteClient(ipTransport); this.controllerLongTermSecretKey = controllerLongTermSecretKey; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java index 1d5d16127ccf8..0c3df7199d6c0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java @@ -31,7 +31,7 @@ import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.DataFormatType; -import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteService; +import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.DateTimeType; @@ -344,7 +344,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { if (command == RefreshType.REFRESH) { return; } - CharacteristicReadWriteService writer = this.rwService; + CharacteristicReadWriteClient writer = this.rwService; if (writer == null) { logger.warn("No writer service available to handle command for channel: {}", channelUID); return; @@ -396,7 +396,7 @@ public void dispose() { * This method is called periodically by a scheduled executor. */ private void refresh() { - CharacteristicReadWriteService rwService = this.rwService; + CharacteristicReadWriteClient rwService = this.rwService; if (rwService != null) { try { Integer aid = getAccessoryId(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java similarity index 94% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java index dafc0172cb6ab..4d244644a973e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java @@ -26,11 +26,11 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class CharacteristicReadWriteService { +public class CharacteristicReadWriteClient { private final IpTransport ipTransport; - public CharacteristicReadWriteService(IpTransport ipTransport) { + public CharacteristicReadWriteClient(IpTransport ipTransport) { this.ipTransport = ipTransport; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index 8aa3956fe1fda..fec934e65cd61 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -24,6 +24,8 @@ import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -98,9 +100,9 @@ public byte[] receive() throws Exception { ByteArrayOutputStream plainText = new ByteArrayOutputStream(); do { byte[] frame = receiveFrame(); - httpParser.accept(frame); plainText.write(frame); - } while (!httpParser.isComplete()); + httpParser.accept(frame); + } while (!httpParser.readComplete()); return plainText.toByteArray(); } @@ -130,55 +132,55 @@ private byte[] receiveFrame() throws Exception { * message has been received. */ private static class HttpPayloadParser { - private final ByteArrayOutputStream headerBuffer = new ByteArrayOutputStream(); - private int contentLength = -1; - private int bodyBytesRead = 0; - private boolean headersComplete = false; - - public void accept(byte[] data) { - int offset = 0; + private static final String NEWLINE_REGEX = "\\r?\\n"; + private static final String END_OF_HEADERS = "\r\n\r\n"; + private static final int MAX_CONTENT_LENGTH = 65536; + private static final int MAX_HEADER_BLOCK_SIZE = 2048; + private static final Pattern CONTENT_LENGTH_PATTERN = Pattern.compile("(?i)^content-length:\\s*(\\d+)$"); - if (!headersComplete) { - for (int i = 0; i < data.length - 3; i++) { - if (data[i] == '\r' && data[i + 1] == '\n' && data[i + 2] == '\r' && data[i + 3] == '\n') { - headersComplete = true; - offset = i + 4; - headerBuffer.write(data, 0, offset); - parseHeaders(); - break; - } - } - if (!headersComplete) { - try { - headerBuffer.write(data); - } catch (IOException e) { - // should never happen with ByteArrayOutputStream - } - return; - } + private final ByteArrayOutputStream headerBuffer = new ByteArrayOutputStream(); + private boolean headersDone = false; + private int contentLength = 0; + private int bytesAccepted = 0; + private int headerLength = -1; + + public void accept(byte[] data) throws SecurityException { + bytesAccepted += data.length; + if (headersDone) { + return; } - - if (headersComplete && contentLength != -1) { - bodyBytesRead += data.length - offset; + try { + headerBuffer.write(data); + } catch (IOException e) { + // should never occur with ByteArrayOutputStream } - } - - private void parseHeaders() { - String headers = headerBuffer.toString(StandardCharsets.UTF_8); - for (String line : headers.split("\r\n")) { - if (line.regionMatches(true, 0, "Content-Length:", 0, 15)) { - try { - contentLength = Integer.parseInt(line.substring(15).trim()); - } catch (NumberFormatException ignored) { - contentLength = -1; + if (headerBuffer.size() > MAX_HEADER_BLOCK_SIZE) { + throw new SecurityException("Header buffer overload"); + } + String temp = new String(headerBuffer.toByteArray(), StandardCharsets.ISO_8859_1); + int offset = temp.indexOf(END_OF_HEADERS); + if (offset >= 0) { + headersDone = true; + headerLength = offset + END_OF_HEADERS.length(); + for (String httpHeader : temp.split(NEWLINE_REGEX)) { + Matcher matcher = CONTENT_LENGTH_PATTERN.matcher(httpHeader); + if (matcher.find()) { + try { + contentLength = Integer.parseInt(matcher.group(1)); + if (contentLength < 0 || contentLength > MAX_CONTENT_LENGTH) { + throw new SecurityException("Invalid Content-Length"); + } + } catch (NumberFormatException e) { + // should never occur due to regex + } + break; } - break; } } } - public boolean isComplete() { - return headersComplete && contentLength != -1 && bodyBytesRead >= contentLength; + public boolean readComplete() { + return headersDone && (bytesAccepted - headerLength >= contentLength); } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 1dcc4cde0ede9..eabe6f8e92c4c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -172,7 +172,7 @@ private byte[] parseResponse(byte[] raw) throws IOException { headers.put(name, value); } - if (status != 200) { + if (status >= 300) { throw new IOException("HTTP " + status); } From e6fbd44b4fcb7d00ecbd8e525822cd5320cea375 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 3 Oct 2025 18:04:51 +0100 Subject: [PATCH 045/177] throw different exception if port is missing Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/transport/IpTransport.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index eabe6f8e92c4c..61cc59f3bd077 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -61,12 +61,18 @@ public IpTransport(String host) throws Exception { logger.debug("Connecting to {}", host); this.host = host; String[] parts = host.split(":"); + if (parts.length < 1) { + throw new IllegalArgumentException("Missing host: " + host); + } + if (parts.length < 2) { + throw new IllegalArgumentException("Missing port: " + host); + } String ipAddress = parts[0]; - int port = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + int port = Integer.parseInt(parts[1]); socket = new Socket(); socket.connect(new InetSocketAddress(ipAddress, port), CONNECT_TIMEOUT); socket.setKeepAlive(false); // HAP spec forbids TCP keepalive - logger.debug("Connected to {}:{}", ipAddress, port); + logger.debug("Connected to {}", host); } public void setSessionKeys(AsymmetricSessionKeys keys) throws Exception { From 46feb3280980dd8846cc13a9db8ac0236b9943ee Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 4 Oct 2025 11:27:48 +0100 Subject: [PATCH 046/177] exact length client id; add validators Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/crypto/CryptoUtils.java | 6 ++ .../handler/HomekitBaseServerHandler.java | 57 ++++++++++++------- .../hap_services/PairRemoveClient.java | 14 +++-- .../hap_services/PairSetupClient.java | 10 ++-- .../hap_services/PairVerifyClient.java | 10 ++-- .../homekit/internal/TestPairSetup.java | 5 +- .../homekit/internal/TestPairVerify.java | 8 +-- 7 files changed, 68 insertions(+), 42 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index f73b6222bfdbf..d3fdff3e50faf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -122,6 +122,12 @@ public static byte[] sha512(byte[] data) throws Exception { return md.digest(data); } + // Create 64 bit (8-byte) hash + public static byte[] sha64(byte[] data) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return Arrays.copyOf(md.digest(data), 8); + } + // Sign message with Ed25519 public static byte[] signMessage(Ed25519PrivateKeyParameters secretKey, byte[] message) { Ed25519Signer signer = new Ed25519Signer(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 34b4d7b5b27ce..9b0c89a3f5624 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -23,12 +23,14 @@ import java.util.Map; import java.util.Objects; import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; @@ -62,6 +64,13 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected static final Gson GSON = new Gson(); + // pattern matcherfor pairing code XXX-XX-XXX + protected static final Pattern PAIRING_CODE_PATTERN = Pattern.compile("^\\d{3}-\\d{2}-\\d{3}$"); + + // pattern matcher for host ipv4 address 123.123.123.123:12345 + protected static final Pattern HOST_PATTERN = Pattern.compile( + "^(((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)):(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]?\\d{1,4})$"); + private final Logger logger = LoggerFactory.getLogger(HomekitBaseServerHandler.class); protected final Map accessories = new HashMap<>(); @@ -73,6 +82,7 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected @NonNullByDefault({}) String pairingCode; protected @NonNullByDefault({}) Integer accessoryId; protected @NonNullByDefault({}) IpTransport ipTransport; + protected @NonNullByDefault({}) byte[] clientPairingId; protected @Nullable Ed25519PrivateKeyParameters controllerLongTermSecretKey = null; protected @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; @@ -105,10 +115,10 @@ public void dispose() { private void fetchAccessories() { try { String json = new String(ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP), StandardCharsets.UTF_8); - Accessories container = GSON.fromJson(json, Accessories.class); - if (container != null && container.accessories instanceof List accessoryList) { + Accessories acc0 = GSON.fromJson(json, Accessories.class); + if (acc0 instanceof Accessories acc1 && acc1.accessories instanceof List acc2) { accessories.clear(); - accessories.putAll(accessoryList.stream().filter(a -> Objects.nonNull(a.aid)) + accessories.putAll(acc2.stream().filter(a -> Objects.nonNull(a.aid)) .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } logger.debug("Fetched {} accessories", accessories.size()); @@ -149,7 +159,7 @@ public void handleRemoval() { scheduler.submit(() -> { // unpair and clear stored keys if this is NOT a child accessory try { - PairRemoveClient service = new PairRemoveClient(ipTransport, thing.getUID().toString()); + PairRemoveClient service = new PairRemoveClient(ipTransport, clientPairingId); service.remove(); accessoryLongTermPublicKey = null; storeLongTermKeys(); @@ -179,7 +189,7 @@ public void initialize() { // standalone accessory or bridge accessory, so do pairing and session setup here isChildAccessory = false; Object host = getConfig().get(CONFIG_HOST); - if (host == null || !(host instanceof String hostString) || hostString.isEmpty()) { + if (host == null || !(host instanceof String hostString) || !HOST_PATTERN.matcher(hostString).matches()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid host"); return; } @@ -187,8 +197,7 @@ public void initialize() { ipTransport = new IpTransport(hostString); } catch (Exception e) { logger.debug("Failed to create transport", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "Failed to connect to accessory"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Failed to connect"); return; } scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread @@ -201,11 +210,20 @@ public void initialize() { */ private void initializePairing() { Object pairingConfig = getConfig().get(CONFIG_PAIRING_CODE); - if (pairingConfig == null || !(pairingConfig instanceof String pairingCode) || pairingCode.isEmpty()) { + if (pairingConfig == null || !(pairingConfig instanceof String pairingCode) + || !PAIRING_CODE_PATTERN.matcher(pairingCode).matches()) { + logger.debug("Pairing code must match XXX-XX-XXX"); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid pairing code"); return; } this.pairingCode = pairingCode; + try { + clientPairingId = CryptoUtils.sha64(thing.getUID().toString().getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + logger.debug("Eroor creating client Id", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Error creating client Id"); + return; + } this.accessoryId = getAccessoryId(); if (accessoryId == null) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid accessory ID"); @@ -215,24 +233,23 @@ private void initializePairing() { restoreLongTermKeys(); Ed25519PrivateKeyParameters controllerLongTermSecretKey = this.controllerLongTermSecretKey; Ed25519PublicKeyParameters accessoryLongTermPublicKey = this.accessoryLongTermPublicKey; - String controllerPairingId = thing.getUID().toString(); if (controllerLongTermSecretKey != null && accessoryLongTermPublicKey != null) { try { - logger.debug("Starting Pair-Verify with existing key for {}", controllerPairingId); - PairVerifyClient client = new PairVerifyClient(ipTransport, controllerPairingId, + logger.debug("Starting Pair-Verify with existing key for {}", clientPairingId); + PairVerifyClient client = new PairVerifyClient(ipTransport, clientPairingId, controllerLongTermSecretKey, accessoryLongTermPublicKey); ipTransport.setSessionKeys(client.verify()); rwService = new CharacteristicReadWriteClient(ipTransport); - logger.debug("Restored pairing was verified for {}", controllerPairingId); + logger.debug("Restored pairing was verified for {}", clientPairingId); fetchAccessories(); updateStatus(ThingStatus.ONLINE); return; } catch (Exception e) { - logger.debug("Restored pairing was not verified for {}", controllerPairingId, e); + logger.debug("Restored pairing was not verified for {}", clientPairingId, e); this.controllerLongTermSecretKey = null; storeLongTermKeys(); // fall through to create new pairing @@ -241,20 +258,20 @@ private void initializePairing() { // Create new controller private key controllerLongTermSecretKey = new Ed25519PrivateKeyParameters(new SecureRandom()); - logger.debug("Created new controller long term private key for {}", controllerPairingId); + logger.debug("Created new controller long term private key for {}", clientPairingId); try { - logger.debug("Starting Pair-Setup for {}", controllerPairingId); - PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, controllerPairingId, + logger.debug("Starting Pair-Setup for {}", clientPairingId); + PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, clientPairingId, controllerLongTermSecretKey, pairingCode); accessoryLongTermPublicKey = pairSetupClient.pair(); this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; - logger.debug("Pair-Setup completed; starting Pair-Verify for {}", controllerPairingId); + logger.debug("Pair-Setup completed; starting Pair-Verify for {}", clientPairingId); // Perform Pair-Verify immediately after Pair-Setup - PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, controllerPairingId, + PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, clientPairingId, controllerLongTermSecretKey, accessoryLongTermPublicKey); ipTransport.setSessionKeys(pairVerifyClient.verify()); @@ -262,13 +279,13 @@ private void initializePairing() { this.controllerLongTermSecretKey = controllerLongTermSecretKey; - logger.debug("Pairing and verification completed for {}", controllerPairingId); + logger.debug("Pairing and verification completed for {}", clientPairingId); storeLongTermKeys(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); } catch (Exception e) { - logger.warn("Pairing / verification failed for {}", controllerPairingId, e); + logger.warn("Pairing / verification failed for {}", clientPairingId, e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing / verification failed"); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index 28cd00c9c7b5f..faf2d5d494a5f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -12,7 +12,6 @@ */ package org.openhab.binding.homekit.internal.hap_services; -import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Set; @@ -40,12 +39,15 @@ public class PairRemoveClient { private final Logger logger = LoggerFactory.getLogger(PairRemoveClient.class); private final IpTransport ipTransport; - private final String serverPairingId; + private final byte[] clientPairingId; - public PairRemoveClient(IpTransport ipTransport, String serverPairingId) { - logger.debug("Created with pairingId:{}", serverPairingId); + public PairRemoveClient(IpTransport ipTransport, byte[] clientPairingId) throws Exception { + if (clientPairingId.length != 8) { + throw new IllegalArgumentException("Client Id must be exactly 8 bytes"); + } + logger.debug("Created.."); this.ipTransport = ipTransport; - this.serverPairingId = serverPairingId; + this.clientPairingId = clientPairingId; } public void remove() throws Exception { @@ -53,7 +55,7 @@ public void remove() throws Exception { Map tlv = Map.of( // TlvType.STATE.key, new byte[] { PairingState.M1.value }, // TlvType.METHOD.key, new byte[] { PairingMethod.REMOVE.value }, // - TlvType.IDENTIFIER.key, serverPairingId.getBytes(StandardCharsets.UTF_8)); + TlvType.IDENTIFIER.key, clientPairingId); Validator.validate(PairingMethod.REMOVE, tlv); byte[] response = ipTransport.post(ENDPOINT_PAIR_REMOVE, CONTENT_TYPE, Tlv8Codec.encode(tlv)); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 44eda77344bf4..27820033bd72c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -15,7 +15,6 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.toHex; -import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -53,12 +52,15 @@ public class PairSetupClient { private final byte[] clientPairingId; private final Ed25519PrivateKeyParameters clientLongTermSecretKey; - public PairSetupClient(IpTransport ipTransport, String clientPairingId, + public PairSetupClient(IpTransport ipTransport, byte[] clientPairingId, Ed25519PrivateKeyParameters clientLongTermSecretKey, String pairingCode) throws Exception { - logger.debug("Created with client pairingId:{}, pairingCode:{}", clientPairingId, pairingCode); + if (clientPairingId.length != 8) { + throw new IllegalArgumentException("Client Id must be exactly 8 bytes"); + } + logger.debug("Created with pairingCode:{}", pairingCode); this.ipTransport = ipTransport; this.password = pairingCode; - this.clientPairingId = clientPairingId.getBytes(StandardCharsets.UTF_8); + this.clientPairingId = clientPairingId; this.clientLongTermSecretKey = clientLongTermSecretKey; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index f061dc2dc9ce4..5703cb1cabbc6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -16,7 +16,6 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; -import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -59,12 +58,15 @@ public class PairVerifyClient { private @NonNullByDefault({}) byte[] readKey; private @NonNullByDefault({}) byte[] writeKey; - public PairVerifyClient(IpTransport ipTransport, String clientPairingId, + public PairVerifyClient(IpTransport ipTransport, byte[] clientPairingId, Ed25519PrivateKeyParameters clientLongTermSecretKey, Ed25519PublicKeyParameters serverLongTermPublicKey) throws Exception { - logger.debug("Created with pairingId:{}", clientPairingId); + if (clientPairingId.length != 8) { + throw new IllegalArgumentException("Client Id must be exactly 8 bytes"); + } + logger.debug("Created.."); this.ipTransport = ipTransport; - this.clientPairingId = clientPairingId.getBytes(StandardCharsets.UTF_8); + this.clientPairingId = clientPairingId; this.clientLongTermSecretKey = clientLongTermSecretKey; this.serverLongTermPublicKey = serverLongTermPublicKey; this.clientEphemeralSecretKey = CryptoUtils.generateX25519KeyPair(); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index d2455941ed305..fe78116b5db21 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -81,10 +81,9 @@ void testSrpClient() throws Exception { void testPairSetup() throws Exception { // initialize test parameters String password = "password123"; - String clientPairingId = "11:22:33:44:55:66"; - String serverPairingIdentifier = "66:55:44:33:22:11"; + byte[] clientPairingId = new byte[] { 11, 22, 33, 44, 55, 66, 77, 88 }; byte[] serverSalt = toBytes(SALT_HEX); - byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); + byte[] serverPairingId = new byte[] { 88, 77, 66, 55, 44, 33, 22, 11 }; // initialize signing keys Ed25519PrivateKeyParameters clientLongTermSecretKey = new Ed25519PrivateKeyParameters( diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index 0f386a0feb654..28c12d05a5a0f 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -17,7 +17,6 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; -import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Objects; @@ -49,9 +48,8 @@ class TestPairVerify { E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20 """; - private final String clientPairingIdentifier = "11:22:33:44:55:66"; - private final String serverPairingIdentifier = "66:55:44:33:22:11"; - private final byte[] serverPairingId = serverPairingIdentifier.getBytes(StandardCharsets.UTF_8); + byte[] clientPairingId = new byte[] { 11, 22, 33, 44, 55, 66, 77, 88 }; + byte[] serverPairingId = new byte[] { 88, 77, 66, 55, 44, 33, 22, 11 }; private final Ed25519PrivateKeyParameters clientLongTermPrivateKey = new Ed25519PrivateKeyParameters( toBytes(CLIENT_PRIVATE_HEX)); @@ -71,7 +69,7 @@ void testPairVerify() throws Exception { IpTransport mockTransport = mock(IpTransport.class); // create SRP client and server - PairVerifyClient client = new PairVerifyClient(mockTransport, clientPairingIdentifier, clientLongTermPrivateKey, + PairVerifyClient client = new PairVerifyClient(mockTransport, clientPairingId, clientLongTermPrivateKey, serverLongTermPrivateKey.generatePublicKey()); // mock the HTTP transport to simulate the SRP exchange From 05eae283141aff7bee46ba0e24d1c12a7531347a Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 4 Oct 2025 12:20:36 +0100 Subject: [PATCH 047/177] cleaner log cosmetics Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitBaseServerHandler.java | 16 ++++++++-------- .../homekit/internal/transport/IpTransport.java | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java index 9b0c89a3f5624..338b9c9b7e28d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java @@ -236,20 +236,20 @@ private void initializePairing() { if (controllerLongTermSecretKey != null && accessoryLongTermPublicKey != null) { try { - logger.debug("Starting Pair-Verify with existing key for {}", clientPairingId); + logger.debug("Starting Pair-Verify with existing key for {}", thing.getUID()); PairVerifyClient client = new PairVerifyClient(ipTransport, clientPairingId, controllerLongTermSecretKey, accessoryLongTermPublicKey); ipTransport.setSessionKeys(client.verify()); rwService = new CharacteristicReadWriteClient(ipTransport); - logger.debug("Restored pairing was verified for {}", clientPairingId); + logger.debug("Restored pairing was verified for {}", thing.getUID()); fetchAccessories(); updateStatus(ThingStatus.ONLINE); return; } catch (Exception e) { - logger.debug("Restored pairing was not verified for {}", clientPairingId, e); + logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); this.controllerLongTermSecretKey = null; storeLongTermKeys(); // fall through to create new pairing @@ -258,17 +258,17 @@ private void initializePairing() { // Create new controller private key controllerLongTermSecretKey = new Ed25519PrivateKeyParameters(new SecureRandom()); - logger.debug("Created new controller long term private key for {}", clientPairingId); + logger.debug("Created new controller long term private key for {}", thing.getUID()); try { - logger.debug("Starting Pair-Setup for {}", clientPairingId); + logger.debug("Starting Pair-Setup for {}", thing.getUID()); PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, clientPairingId, controllerLongTermSecretKey, pairingCode); accessoryLongTermPublicKey = pairSetupClient.pair(); this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; - logger.debug("Pair-Setup completed; starting Pair-Verify for {}", clientPairingId); + logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); // Perform Pair-Verify immediately after Pair-Setup PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, clientPairingId, @@ -279,13 +279,13 @@ private void initializePairing() { this.controllerLongTermSecretKey = controllerLongTermSecretKey; - logger.debug("Pairing and verification completed for {}", clientPairingId); + logger.debug("Pairing and verification completed for {}", thing.getUID()); storeLongTermKeys(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); } catch (Exception e) { - logger.warn("Pairing / verification failed for {}", clientPairingId, e); + logger.warn("Pairing / verification failed for {}", thing.getUID(), e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing / verification failed"); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 61cc59f3bd077..9343f7e09d60f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -98,8 +98,8 @@ private synchronized byte[] execute(String method, String endpoint, String conte throws IOException, InterruptedException, TimeoutException, ExecutionException { try { byte[] request = buildRequest(method, endpoint, contentType, body); + logger.trace("Request:\n{}", new String(request, StandardCharsets.ISO_8859_1)); byte[] response; - logger.trace("Request:\n{}", new String(request, StandardCharsets.UTF_8)); SecureSession secureSession = this.secureSession; if (secureSession != null) { @@ -113,7 +113,7 @@ private synchronized byte[] execute(String method, String endpoint, String conte response = readPlainResponse(in); } - logger.trace("Response:\n{}", new String(response, StandardCharsets.UTF_8)); + logger.trace("Response:\n{}", new String(response, StandardCharsets.ISO_8859_1)); return parseResponse(response); } catch (IOException | InterruptedException | TimeoutException e) { throw e; From 4ec0df024defcfa080096abe7f1314500733435d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 4 Oct 2025 19:22:23 +0100 Subject: [PATCH 048/177] send TLVs in specified order; refactor TLV enums Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/crypto/SRPclient.java | 15 +++--- .../homekit/internal/enums/ErrorCode.java | 6 +-- .../homekit/internal/enums/PairingMethod.java | 6 +-- .../homekit/internal/enums/PairingState.java | 6 +-- .../homekit/internal/enums/TlvType.java | 6 +-- .../hap_services/PairRemoveClient.java | 19 ++++---- .../hap_services/PairSetupClient.java | 47 ++++++++++--------- .../hap_services/PairVerifyClient.java | 41 ++++++++-------- .../binding/homekit/internal/SRPserver.java | 6 +-- .../homekit/internal/TestPairSetup.java | 18 +++---- .../homekit/internal/TestPairVerify.java | 22 ++++----- 11 files changed, 98 insertions(+), 94 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index fc2f525f800d5..d11b95617b466 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -19,6 +19,7 @@ import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.Map; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; @@ -162,10 +163,10 @@ public byte[] m5EncodeClientInfoAndSign(byte[] pairingId, Ed25519PrivateKeyParam byte[] clientSigningKey = clientLongTermSecretKey.generatePublicKey().getEncoded(); byte[] clientSignature = signMessage(clientLongTermSecretKey, concat(sharedKey, pairingId, clientSigningKey)); - Map subTlv = Map.of( // - TlvType.IDENTIFIER.key, pairingId, // - TlvType.PUBLIC_KEY.key, clientSigningKey, // - TlvType.SIGNATURE.key, clientSignature); + Map subTlv = new LinkedHashMap<>(); + subTlv.put(TlvType.IDENTIFIER.value, pairingId); + subTlv.put(TlvType.PUBLIC_KEY.value, clientSigningKey); + subTlv.put(TlvType.SIGNATURE.value, clientSignature); byte[] plainText = Tlv8Codec.encode(subTlv); byte[] cipherText = encrypt(getSharedKey(), PS_M5_NONCE, plainText, new byte[0]); @@ -185,9 +186,9 @@ public void m6DecodeServerInfoAndVerify(byte[] cipherText) throws Exception { byte[] plainText = decrypt(getSharedKey(), PS_M6_NONCE, cipherText, new byte[0]); Map subTlv = Tlv8Codec.decode(plainText); - byte[] serverPairingId = subTlv.get(TlvType.IDENTIFIER.key); - byte[] serverSigningKey = subTlv.get(TlvType.PUBLIC_KEY.key); - byte[] serverSignature = subTlv.get(TlvType.SIGNATURE.key); + byte[] serverPairingId = subTlv.get(TlvType.IDENTIFIER.value); + byte[] serverSigningKey = subTlv.get(TlvType.PUBLIC_KEY.value); + byte[] serverSignature = subTlv.get(TlvType.SIGNATURE.value); if (serverPairingId == null || serverSigningKey == null || serverSignature == null) { throw new SecurityException("Missing accessory credentials in M6"); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java index 1a2b39f76cc46..d10e579959e76 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java @@ -36,12 +36,12 @@ public enum ErrorCode { this.value = (byte) value; } - public static ErrorCode from(byte b) { + public static ErrorCode from(byte value) { for (ErrorCode state : values()) { - if (state.value == b) { + if (state.value == value) { return state; } } - throw new IllegalArgumentException("Unknown error code: " + b); + throw new IllegalArgumentException("Unknown error code: " + value); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java index 24cd9fe067a63..cf9a997def65b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java @@ -34,12 +34,12 @@ public enum PairingMethod { this.value = (byte) value; } - public static PairingMethod from(byte b) { + public static PairingMethod from(byte value) { for (PairingMethod state : values()) { - if (state.value == b) { + if (state.value == value) { return state; } } - throw new IllegalArgumentException("Unknown pairing method: " + b); + throw new IllegalArgumentException("Unknown pairing method: " + value); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java index 9a7af98e1eeb9..c506c61eef8f4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java @@ -34,12 +34,12 @@ public enum PairingState { this.value = (byte) value; } - public static PairingState from(byte b) { + public static PairingState from(byte value) { for (PairingState state : values()) { - if (state.value == b) { + if (state.value == value) { return state; } } - throw new IllegalArgumentException("Unknown pairing state: " + b); + throw new IllegalArgumentException("Unknown pairing state: " + value); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java index 2822cecd5723c..4c1f7b38e5a8b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/TlvType.java @@ -38,9 +38,9 @@ public enum TlvType { FLAGS(0x13), SEPARATOR((byte) 0xFF); - public final int key; + public final int value; - TlvType(int key) { - this.key = key; + TlvType(int value) { + this.value = value; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index faf2d5d494a5f..69e1b71fd9ca7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -12,6 +12,7 @@ */ package org.openhab.binding.homekit.internal.hap_services; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -52,10 +53,10 @@ public PairRemoveClient(IpTransport ipTransport, byte[] clientPairingId) throws public void remove() throws Exception { logger.debug("Pair-Remove: starting removal"); - Map tlv = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M1.value }, // - TlvType.METHOD.key, new byte[] { PairingMethod.REMOVE.value }, // - TlvType.IDENTIFIER.key, clientPairingId); + Map tlv = new LinkedHashMap<>(); + tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); + tlv.put(TlvType.METHOD.value, new byte[] { PairingMethod.REMOVE.value }); + tlv.put(TlvType.IDENTIFIER.value, clientPairingId); Validator.validate(PairingMethod.REMOVE, tlv); byte[] response = ipTransport.post(ENDPOINT_PAIR_REMOVE, CONTENT_TYPE, Tlv8Codec.encode(tlv)); @@ -69,8 +70,8 @@ public void remove() throws Exception { protected static class Validator { private static final Map> SPECIFICATION_REQUIRED_KEYS = Map.of( // - PairingState.M1, Set.of(TlvType.STATE.key, TlvType.METHOD.key, TlvType.IDENTIFIER.key), // - PairingState.M2, Set.of(TlvType.STATE.key)); + PairingState.M1, Set.of(TlvType.STATE.value, TlvType.METHOD.value, TlvType.IDENTIFIER.value), // + PairingState.M2, Set.of(TlvType.STATE.value)); /** * Validates the TLV map for the specification required pairing state. @@ -78,14 +79,14 @@ protected static class Validator { * @throws SecurityException if required keys are missing or state is invalid */ public static void validate(PairingMethod method, Map tlv) throws SecurityException { - if (tlv.containsKey(TlvType.ERROR.key)) { - byte[] err = tlv.get(TlvType.ERROR.key); + if (tlv.containsKey(TlvType.ERROR.value)) { + byte[] err = tlv.get(TlvType.ERROR.value); ErrorCode code = err != null && err.length > 0 ? ErrorCode.from(err[0]) : ErrorCode.UNKNOWN; throw new SecurityException( "Pairing method '%s' action failed with error '%s'".formatted(method.name(), code.name())); } - byte[] state = tlv.get(TlvType.STATE.key); + byte[] state = tlv.get(TlvType.STATE.value); if (state == null || state.length != 1) { throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 27820033bd72c..545bf3dda8636 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -15,6 +15,7 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.toHex; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -84,9 +85,9 @@ public Ed25519PublicKeyParameters pair() throws Exception { */ private SRPclient m1Execute() throws Exception { logger.debug("Pair-Setup M1: Send pairing start request to server"); - Map tlv = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M1.value }, // - TlvType.METHOD.key, new byte[] { PairingMethod.SETUP.value }); + Map tlv = new LinkedHashMap<>(); + tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); + tlv.put(TlvType.METHOD.value, new byte[] { PairingMethod.SETUP.value }); Validator.validate(PairingMethod.SETUP, tlv); byte[] m1Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); return m2Execute(m1Response); @@ -103,8 +104,8 @@ private SRPclient m2Execute(byte[] m1Response) throws Exception { logger.debug("Pair-Setup M2: Read server salt and ephemeral PK; initialize SRP client"); Map tlv = Tlv8Codec.decode(m1Response); Validator.validate(PairingMethod.SETUP, tlv); - byte[] serverSalt = tlv.get(TlvType.SALT.key); - byte[] serverPublicKey = tlv.get(TlvType.PUBLIC_KEY.key); + byte[] serverSalt = tlv.get(TlvType.SALT.value); + byte[] serverPublicKey = tlv.get(TlvType.PUBLIC_KEY.value); logger.trace("ServerSalt: {}", toHex(serverSalt)); logger.trace("ServerPKey: {}", toHex(serverPublicKey)); SRPclient client = new SRPclient(password, Objects.requireNonNull(serverSalt), @@ -120,10 +121,10 @@ private SRPclient m2Execute(byte[] m1Response) throws Exception { */ private SRPclient m3Execute(SRPclient client) throws Exception { logger.debug("Pair-Setup M3: Send client epehemeral PK and M1 proof to server"); - Map tlv = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M3.value }, // - TlvType.PUBLIC_KEY.key, CryptoUtils.toUnsigned(client.A, 384), // - TlvType.PROOF.key, client.M1); + Map tlv = new LinkedHashMap<>(); + tlv.put(TlvType.STATE.value, new byte[] { PairingState.M3.value }); + tlv.put(TlvType.PUBLIC_KEY.value, CryptoUtils.toUnsigned(client.A, 384)); + tlv.put(TlvType.PROOF.value, client.M1); Validator.validate(PairingMethod.SETUP, tlv); byte[] m3Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); return m4Execute(client, m3Response); @@ -139,7 +140,7 @@ private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exceptio logger.debug("Pair-Setup M4: Read server M2 proof; and verify it"); Map tlv = Tlv8Codec.decode(m3Response); Validator.validate(PairingMethod.SETUP, tlv); - byte[] serverProofM2 = tlv.get(TlvType.PROOF.key); + byte[] serverProofM2 = tlv.get(TlvType.PROOF.value); logger.trace("ServerM2: {}", toHex(serverProofM2)); client.m4VerifyServerProof(Objects.requireNonNull(serverProofM2)); return m5Execute(client); @@ -155,9 +156,9 @@ private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exceptio private SRPclient m5Execute(SRPclient client) throws Exception { logger.debug("Pair-Setup M5: Send client session key, pairing id, LTPK, and sig to server"); byte[] cipherText = client.m5EncodeClientInfoAndSign(clientPairingId, clientLongTermSecretKey); - Map tlv = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M5.value }, // - TlvType.ENCRYPTED_DATA.key, cipherText); + Map tlv = new LinkedHashMap<>(); + tlv.put(TlvType.STATE.value, new byte[] { PairingState.M5.value }); + tlv.put(TlvType.ENCRYPTED_DATA.value, cipherText); Validator.validate(PairingMethod.SETUP, tlv); byte[] m5Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); return m6Execute(client, m5Response); @@ -174,7 +175,7 @@ private SRPclient m6Execute(SRPclient client, byte[] m5Response) throws Exceptio logger.debug("Pair-Setup M6: Read server session key, pairing id, LTPK, and sig; and verify it"); Map tlv = Tlv8Codec.decode(m5Response); Validator.validate(PairingMethod.SETUP, tlv); - byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.key); + byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.value); client.m6DecodeServerInfoAndVerify(Objects.requireNonNull(cipherText)); return client; } @@ -185,12 +186,12 @@ private SRPclient m6Execute(SRPclient client, byte[] m5Response) throws Exceptio public static class Validator { private static final Map> SPECIFICATION_REQUIRED_KEYS = Map.of( // - PairingState.M1, Set.of(TlvType.STATE.key, TlvType.METHOD.key), // - PairingState.M2, Set.of(TlvType.STATE.key, TlvType.SALT.key, TlvType.PUBLIC_KEY.key), // - PairingState.M3, Set.of(TlvType.STATE.key, TlvType.PUBLIC_KEY.key, TlvType.PROOF.key), // - PairingState.M4, Set.of(TlvType.STATE.key, TlvType.PROOF.key), // - PairingState.M5, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key), // - PairingState.M6, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key)); + PairingState.M1, Set.of(TlvType.STATE.value, TlvType.METHOD.value), // + PairingState.M2, Set.of(TlvType.STATE.value, TlvType.SALT.value, TlvType.PUBLIC_KEY.value), // + PairingState.M3, Set.of(TlvType.STATE.value, TlvType.PUBLIC_KEY.value, TlvType.PROOF.value), // + PairingState.M4, Set.of(TlvType.STATE.value, TlvType.PROOF.value), // + PairingState.M5, Set.of(TlvType.STATE.value, TlvType.ENCRYPTED_DATA.value), // + PairingState.M6, Set.of(TlvType.STATE.value, TlvType.ENCRYPTED_DATA.value)); /** * Validates the TLV map for the specification required pairing state. @@ -198,14 +199,14 @@ public static class Validator { * @throws SecurityException if required keys are missing or state is invalid */ public static void validate(PairingMethod method, Map tlv) throws SecurityException { - if (tlv.containsKey(TlvType.ERROR.key)) { - byte[] err = tlv.get(TlvType.ERROR.key); + if (tlv.containsKey(TlvType.ERROR.value)) { + byte[] err = tlv.get(TlvType.ERROR.value); ErrorCode code = err != null && err.length > 0 ? ErrorCode.from(err[0]) : ErrorCode.UNKNOWN; throw new SecurityException( "Pairing method '%s' action failed with error '%s'".formatted(method.name(), code.name())); } - byte[] state = tlv.get(TlvType.STATE.key); + byte[] state = tlv.get(TlvType.STATE.value); if (state == null || state.length != 1) { throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index 5703cb1cabbc6..8c30f8b5c0337 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -16,6 +16,7 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -86,9 +87,9 @@ public AsymmetricSessionKeys verify() throws Exception { // M1 — Create new random client ephemeral X25519 public key and send it to server private void m1Execute() throws Exception { logger.debug("Pair-Verify M1: Send verification start request with client ephemeral X25519 PK to server"); - Map tlv = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M1.value }, // - TlvType.PUBLIC_KEY.key, clientEphemeralSecretKey.generatePublicKey().getEncoded()); + Map tlv = new LinkedHashMap<>(); + tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); + tlv.put(TlvType.PUBLIC_KEY.value, clientEphemeralSecretKey.generatePublicKey().getEncoded()); Validator.validate(PairingMethod.VERIFY, tlv); byte[] m1Response = ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); m2Execute(m1Response); @@ -100,17 +101,17 @@ private void m2Execute(byte[] m1Response) throws Exception { Map tlv = Tlv8Codec.decode(m1Response); Validator.validate(PairingMethod.VERIFY, tlv); - serverEphemeralPublicKey = new X25519PublicKeyParameters(tlv.get(TlvType.PUBLIC_KEY.key), 0); + serverEphemeralPublicKey = new X25519PublicKeyParameters(tlv.get(TlvType.PUBLIC_KEY.value), 0); sharedSecret = generateSharedSecret(clientEphemeralSecretKey, serverEphemeralPublicKey); sharedKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); - byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.key); + byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.value); byte[] plainText = CryptoUtils.decrypt(sharedKey, PV_M2_NONCE, Objects.requireNonNull(cipherText), new byte[0]); // validate identifier + signature Map subTlv = Tlv8Codec.decode(plainText); - byte[] serverPairingId = subTlv.get(TlvType.IDENTIFIER.key); - byte[] serverSignature = subTlv.get(TlvType.SIGNATURE.key); + byte[] serverPairingId = subTlv.get(TlvType.IDENTIFIER.value); + byte[] serverSignature = subTlv.get(TlvType.SIGNATURE.value); if (serverPairingId == null || serverSignature == null) { throw new SecurityException("Accessory identifier or signature missing"); } @@ -130,16 +131,16 @@ private void m3Execute() throws Exception { concat(clientEphemeralSecretKey.generatePublicKey().getEncoded(), clientPairingId, serverEphemeralPublicKey.getEncoded())); - Map subTlv = Map.of( // - TlvType.IDENTIFIER.key, clientPairingId, // - TlvType.SIGNATURE.key, clientSignature); + Map subTlv = new LinkedHashMap<>(); + subTlv.put(TlvType.IDENTIFIER.value, clientPairingId); + subTlv.put(TlvType.SIGNATURE.value, clientSignature); byte[] plainText = Tlv8Codec.encode(subTlv); byte[] cipherText = encrypt(sharedKey, PV_M3_NONCE, plainText, new byte[0]); - Map tlv = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M3.value }, // - TlvType.ENCRYPTED_DATA.key, cipherText); + Map tlv = new LinkedHashMap<>(); + tlv.put(TlvType.STATE.value, new byte[] { PairingState.M3.value }); + tlv.put(TlvType.ENCRYPTED_DATA.value, cipherText); Validator.validate(PairingMethod.VERIFY, tlv); byte[] m3Response = ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); @@ -161,10 +162,10 @@ private void m4Execute(byte[] m3Response) throws Exception { public static class Validator { private static final Map> SPECIFICATION_REQUIRED_KEYS = Map.of( // - PairingState.M1, Set.of(TlvType.STATE.key, TlvType.PUBLIC_KEY.key), // TLVType.METHOD not required - PairingState.M2, Set.of(TlvType.STATE.key, TlvType.PUBLIC_KEY.key, TlvType.ENCRYPTED_DATA.key), // - PairingState.M3, Set.of(TlvType.STATE.key, TlvType.ENCRYPTED_DATA.key), // - PairingState.M4, Set.of(TlvType.STATE.key)); + PairingState.M1, Set.of(TlvType.STATE.value, TlvType.PUBLIC_KEY.value), // TLVType.METHOD not required + PairingState.M2, Set.of(TlvType.STATE.value, TlvType.PUBLIC_KEY.value, TlvType.ENCRYPTED_DATA.value), // + PairingState.M3, Set.of(TlvType.STATE.value, TlvType.ENCRYPTED_DATA.value), // + PairingState.M4, Set.of(TlvType.STATE.value)); /** * Validates the TLV map for the specification required pairing state. @@ -172,14 +173,14 @@ public static class Validator { * @throws SecurityException if required keys are missing or state is invalid */ public static void validate(PairingMethod method, Map tlv) throws SecurityException { - if (tlv.containsKey(TlvType.ERROR.key)) { - byte[] err = tlv.get(TlvType.ERROR.key); + if (tlv.containsKey(TlvType.ERROR.value)) { + byte[] err = tlv.get(TlvType.ERROR.value); ErrorCode code = err != null && err.length > 0 ? ErrorCode.from(err[0]) : ErrorCode.UNKNOWN; throw new SecurityException( "Pairing method '%s' action failed with error '%s'".formatted(method.name(), code.name())); } - byte[] state = tlv.get(TlvType.STATE.key); + byte[] state = tlv.get(TlvType.STATE.value); if (state == null || state.length != 1) { throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java index 45a696aa0069e..9331c18fafb72 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -125,9 +125,9 @@ public byte[] m5EncodeServerInfoAndSign() throws Exception { byte[] signature = signMessage(serverLongTermPrivateKey, payload); Map subTlv = Map.of( // - TlvType.IDENTIFIER.key, serverPairingId, // - TlvType.PUBLIC_KEY.key, signingKey, // - TlvType.SIGNATURE.key, signature); + TlvType.IDENTIFIER.value, serverPairingId, // + TlvType.PUBLIC_KEY.value, signingKey, // + TlvType.SIGNATURE.value, signature); byte[] plaintext = Tlv8Codec.encode(subTlv); return CryptoUtils.encrypt(getSymmetricKey(), PS_M6_NONCE, plaintext, new byte[0]); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index fe78116b5db21..b0d08b2e73f3d 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -105,7 +105,7 @@ void testPairSetup() throws Exception { // decode and validate the incoming TLV Map tlv = Tlv8Codec.decode(arg); PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); - byte[] state = tlv.get(TlvType.STATE.key); + byte[] state = tlv.get(TlvType.STATE.value); if (state == null || state.length != 1) { throw new IllegalArgumentException("State missing or invalid"); } @@ -126,9 +126,9 @@ void testPairSetup() throws Exception { private byte[] m1GetServerResponse(SRPserver server, byte[] serverSalt) { Map tlv = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M2.value }, // - TlvType.SALT.key, serverSalt, // salt - TlvType.PUBLIC_KEY.key, toUnsigned(server.B, 384) // server public key + TlvType.STATE.value, new byte[] { PairingState.M2.value }, // + TlvType.SALT.value, serverSalt, // salt + TlvType.PUBLIC_KEY.value, toUnsigned(server.B, 384) // server public key ); PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); return Tlv8Codec.encode(tlv); @@ -136,11 +136,11 @@ TlvType.PUBLIC_KEY.key, toUnsigned(server.B, 384) // server public key private byte[] m3GetServerResponse(SRPserver server, Map tlv2, PairSetupClient client) throws Exception { - clientPublicKey = tlv2.get(TlvType.PUBLIC_KEY.key); + clientPublicKey = tlv2.get(TlvType.PUBLIC_KEY.value); byte[] serverProof = server.m3CreateServerProof(Objects.requireNonNull(clientPublicKey)); Map tlv3 = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M4.value }, // - TlvType.PROOF.key, serverProof // server proof + TlvType.STATE.value, new byte[] { PairingState.M4.value }, // + TlvType.PROOF.value, serverProof // server proof ); PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv3); return Tlv8Codec.encode(tlv3); @@ -149,8 +149,8 @@ private byte[] m3GetServerResponse(SRPserver server, Map tlv2, private byte[] m5GetServerResponse(SRPserver server) throws Exception { byte[] cipherText = server.m5EncodeServerInfoAndSign(); Map tlv = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M6.value }, // - TlvType.ENCRYPTED_DATA.key, cipherText); + TlvType.STATE.value, new byte[] { PairingState.M6.value }, // + TlvType.ENCRYPTED_DATA.value, cipherText); PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); return Tlv8Codec.encode(tlv); } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index 28c12d05a5a0f..9b99405dc44cd 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -79,7 +79,7 @@ void testPairVerify() throws Exception { // decode and validate the incoming TLV Map tlv = Tlv8Codec.decode(arg); PairVerifyClient.Validator.validate(PairingMethod.VERIFY, tlv); - byte[] state = tlv.get(TlvType.STATE.key); + byte[] state = tlv.get(TlvType.STATE.value); if (state == null || state.length != 1) { throw new IllegalArgumentException("State missing or invalid"); } @@ -98,7 +98,7 @@ void testPairVerify() throws Exception { } private byte[] m1GetServerResponse(Map tlv) throws Exception { - byte[] clientEphemeralPublicKey = tlv.get(TlvType.PUBLIC_KEY.key); + byte[] clientEphemeralPublicKey = tlv.get(TlvType.PUBLIC_KEY.value); byte[] serverEphemeralPublicKey = this.serverEphemeralSecretKey.generatePublicKey().getEncoded(); if (clientEphemeralPublicKey == null) { throw new SecurityException("Client public key missing"); @@ -107,8 +107,8 @@ private byte[] m1GetServerResponse(Map tlv) throws Exception { concat(serverEphemeralPublicKey, serverPairingId, clientEphemeralPublicKey)); Map tlvInner = Map.of( // - TlvType.IDENTIFIER.key, serverPairingId, // - TlvType.SIGNATURE.key, serverSignature); + TlvType.IDENTIFIER.value, serverPairingId, // + TlvType.SIGNATURE.value, serverSignature); this.clientEphemeralPublicKey = new X25519PublicKeyParameters(clientEphemeralPublicKey); @@ -119,9 +119,9 @@ private byte[] m1GetServerResponse(Map tlv) throws Exception { byte[] cipherText = encrypt(sharedKey, PV_M2_NONCE, plainText, new byte[0]); Map tlvOut = Map.of( // - TlvType.STATE.key, new byte[] { PairingState.M2.value }, // - TlvType.PUBLIC_KEY.key, serverEphemeralPublicKey, // - TlvType.ENCRYPTED_DATA.key, cipherText); + TlvType.STATE.value, new byte[] { PairingState.M2.value }, // + TlvType.PUBLIC_KEY.value, serverEphemeralPublicKey, // + TlvType.ENCRYPTED_DATA.value, cipherText); return Tlv8Codec.encode(tlvOut); } @@ -130,15 +130,15 @@ private byte[] m3GetServerResponse(Map tlv) throws Exception { if (sharedKey.length == 0) { throw new IllegalStateException("Session key not established"); } - byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.key); + byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.value); if (cipherText == null) { throw new SecurityException("Server cipher text missing"); } byte[] plainText = decrypt(sharedKey, PV_M3_NONCE, Objects.requireNonNull(cipherText), new byte[0]); Map subTlv = Tlv8Codec.decode(plainText); - byte[] clientPairingId = subTlv.get(TlvType.IDENTIFIER.key); - byte[] clientSignature = subTlv.get(TlvType.SIGNATURE.key); + byte[] clientPairingId = subTlv.get(TlvType.IDENTIFIER.value); + byte[] clientSignature = subTlv.get(TlvType.SIGNATURE.value); if (clientPairingId == null || clientSignature == null) { throw new SecurityException("Client pairing Id or signature missing"); } @@ -149,7 +149,7 @@ private byte[] m3GetServerResponse(Map tlv) throws Exception { throw new SecurityException("Client signature invalid"); } - Map tlvOut = Map.of(TlvType.STATE.key, new byte[] { PairingState.M4.value }); + Map tlvOut = Map.of(TlvType.STATE.value, new byte[] { PairingState.M4.value }); PairVerifyClient.Validator.validate(PairingMethod.VERIFY, tlvOut); // no further messages from server From a4e7e9622b320a3c8a3556af84e7d8f4524da5a0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 5 Oct 2025 10:09:28 +0100 Subject: [PATCH 049/177] improve getProperties fix Signed-off-by: Andrew Fiddian-Green --- .../discovery/HomekitMdnsDiscoveryParticipant.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 9e97a5e4a551c..54eafef627648 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -133,7 +133,8 @@ public String getServiceType() { } /** - * Work around for a known JmDNS bug when parsing TXT strings with a '0' byte terminator. + * The JmDNS library getProperties() method has a bug whereby it fails to return any properties + * in the case that the TXT record contains zero length parts. This is a drop in replacement. */ private Map getProperties(ServiceInfo service) { Map map = new HashMap<>(); @@ -141,11 +142,11 @@ private Map getProperties(ServiceInfo service) { int i = 0; while (i < bytes.length) { int len = bytes[i++] & 0xFF; - if (len == 0) { - break; + if (len == 0) { // skip zero length parts + continue; } String[] parts = new String(bytes, i, len, StandardCharsets.UTF_8).split("="); - map.put(parts[0], parts.length > 1 ? parts[1] : ""); + map.put(parts[0], parts.length < 2 ? "" : parts[1].replaceFirst("\u0000$", "")); // strip zero endings i += len; } return map; From 75f41c378a834717a2b997b845e82ffd295007e0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 5 Oct 2025 19:06:02 +0100 Subject: [PATCH 050/177] improved testing and logging Signed-off-by: Andrew Fiddian-Green --- .../internal/crypto/CryptoConstants.java | 8 +- .../homekit/internal/crypto/CryptoUtils.java | 9 +- .../homekit/internal/crypto/SRPclient.java | 127 +++++++++++------- .../hap_services/PairSetupClient.java | 31 +++-- .../hap_services/PairVerifyClient.java | 6 +- .../binding/homekit/internal/SRPserver.java | 83 ++++++++---- .../internal/TestAppleTestVectors.java | 4 +- .../homekit/internal/TestPairSetup.java | 37 ++--- .../homekit/internal/TestPairVerify.java | 72 +++++----- 9 files changed, 219 insertions(+), 158 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java index 579a2756ac21f..ca0c9fd590766 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java @@ -53,11 +53,11 @@ public class CryptoConstants { public static final byte[] PS_M5_NONCE = "PS-Msg05".getBytes(StandardCharsets.UTF_8); public static final byte[] PS_M6_NONCE = "PS-Msg06".getBytes(StandardCharsets.UTF_8); - public static final byte[] PAIR_CONTROLLER_SIGN_SALT = "Pair-Setup-Controller-Sign-Salt".getBytes(StandardCharsets.UTF_8); - public static final byte[] PAIR_CONTROLLER_SIGN_INFO = "Pair-Setup-Controller-Sign-Info".getBytes(StandardCharsets.UTF_8); + public static final byte[] PAIR_SETUP_CONTROLLER_SIGN_SALT = "Pair-Setup-Controller-Sign-Salt".getBytes(StandardCharsets.UTF_8); + public static final byte[] PAIR_SETUP_CONTROLLER_SIGN_INFO = "Pair-Setup-Controller-Sign-Info".getBytes(StandardCharsets.UTF_8); - public static final byte[] PAIR_ACCESSORY_SIGN_SALT = "Pair-Setup-Accessory-Sign-Salt".getBytes(StandardCharsets.UTF_8); - public static final byte[] PAIR_ACCESSORY_SIGN_INFO = "Pair-Setup-Accessory-Sign-Info".getBytes(StandardCharsets.UTF_8); + public static final byte[] PAIR_SETUP_ACCESSORY_SIGN_SALT = "Pair-Setup-Accessory-Sign-Salt".getBytes(StandardCharsets.UTF_8); + public static final byte[] PAIR_SETUP_ACCESSORY_SIGN_INFO = "Pair-Setup-Accessory-Sign-Info".getBytes(StandardCharsets.UTF_8); public static final byte[] CONTROL_SALT = "Control-Salt".getBytes(StandardCharsets.UTF_8); public static final byte[] CONTROL_READ_ENCRYPTION_KEY = "Control-Read-Encryption-Key".getBytes(StandardCharsets.UTF_8); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index d3fdff3e50faf..43929016900af 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -202,11 +202,14 @@ public static byte[] toUnsigned(BigInteger bigInteger, int length) { return padded; } - public static boolean verifySignature(Ed25519PublicKeyParameters publicKey, byte[] signature, byte[] payLoad) { + public static void verifySignature(Ed25519PublicKeyParameters publicKey, byte[] signature, byte[] payload) + throws Exception { Ed25519Signer verifier = new Ed25519Signer(); verifier.init(false, publicKey); - verifier.update(payLoad, 0, payLoad.length); - return verifier.verifySignature(signature); + verifier.update(payload, 0, payload.length); + if (!verifier.verifySignature(signature)) { + throw new SecurityException("Signature verification failed"); + } } public static byte[] xor(byte[] a, byte[] b) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index d11b95617b466..f99d9dcb95274 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -27,6 +27,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.TlvType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Manages the SRP (Stanford Secure Remote Password) protocol for pairing with a HomeKit accessory. @@ -37,50 +39,52 @@ @NonNullByDefault public class SRPclient { + private final Logger logger = LoggerFactory.getLogger(SRPclient.class); + public final BigInteger A; // client SRP public key public final BigInteger a; // client SRP private ephemeral public final BigInteger B; // server SRP public key - public final byte[] K; // session key + public final byte[] S; // shared secret + public final byte[] K; // Apple SRP style session key = H(S) public final byte[] M1; // client proof - public final BigInteger S; // shared secret public final BigInteger u; // scrambling parameter public final BigInteger x; // SRP private key derived from password private final String I; // username private final byte[] s; // server salt - private final byte[] M2; // expected server proof + private final byte[] M2; // expected accessory server proof - private @Nullable Ed25519PublicKeyParameters serverLongTermPublicKey = null; + private @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; /** * M1 - Simplified constructor when user and client private key are not provided. * - * @param passwordP the password (P) used for authentication. + * @param password_P the password (P) used for authentication. * @param serverSalt the salt (s) provided by the server. * @param serverEphemeralPublicKey the server's public SRP key (B). * * @throws Exception if an error occurs during initialization. */ - public SRPclient(String passwordP, byte[] serverSalt, byte[] serverEphemeralPublicKey) throws Exception { - this(passwordP, serverSalt, serverEphemeralPublicKey, null, null); + public SRPclient(String password_P, byte[] serverSalt, byte[] serverEphemeralPublicKey) throws Exception { + this(password_P, serverSalt, serverEphemeralPublicKey, null, null); } /** * M2 — Initializes the SRP client with the given password, salt and server public SRP key. * - * @param password_p the password (P) used for authentication. + * @param password_P the password (P) used for authentication. * @param serverSalt the salt (s) provided by the server. - * @param serverEphemeralPublicKey the server's public SRP key (B). + * @param accessoryEphemeralPublicKey the server's public SRP key (B). * @param user_I the username (I). If null, "Pair-Setup" is used. * @param clientEphemeralSecretKey the client's private SRP key (a). If null, a random key is generated. * * @throws Exception if an error occurs during initialization. */ - public SRPclient(String password_p, byte[] serverSalt, byte[] serverEphemeralPublicKey, @Nullable String user_I, + public SRPclient(String password_P, byte[] serverSalt, byte[] accessoryEphemeralPublicKey, @Nullable String user_I, byte @Nullable [] clientEphemeralSecretKey) throws Exception { // set username, salt and server public key s = serverSalt; - B = new BigInteger(1, serverEphemeralPublicKey); + B = new BigInteger(1, accessoryEphemeralPublicKey); I = user_I != null ? user_I : PAIR_SETUP; // default username is "Pair-Setup" // Apply or create ephemeral a and compute public A @@ -93,7 +97,7 @@ public SRPclient(String password_p, byte[] serverSalt, byte[] serverEphemeralPub A = g.modPow(a, N); // Compute hash x = H(salt || H(username || ":" || password)) - byte[] hIP = sha512((I + ":" + password_p).getBytes(StandardCharsets.UTF_8)); + byte[] hIP = sha512((I + ":" + password_P).getBytes(StandardCharsets.UTF_8)); byte[] xHash = sha512(concat(serverSalt, hIP)); x = new BigInteger(1, xHash); @@ -104,14 +108,14 @@ public SRPclient(String password_p, byte[] serverSalt, byte[] serverEphemeralPub throw new SecurityException("Invalid scrambling parameter"); } - // Compute shared secret S = (B - k·g^x)^(a + u·x) mod N + // Compute shared secret S = (B - k·g^x)^(a + u·x) mod N (384 bytes) BigInteger gx = g.modPow(x, N); BigInteger base = B.subtract(k.multiply(gx)).mod(N); BigInteger exp = a.add(u.multiply(x)); - S = base.modPow(exp, N); + S = toUnsigned(base.modPow(exp, N), 384); - // Compute session key K = H(S) - K = sha512(toUnsigned(S, 384)); + // Compute 'Apple SRP style' session key K = H(S) (64 bytes) + K = sha512(S); // Compute client proof M1 = H(H(N) xor H(g) || H(I) || s || A || B || K) byte[] HN = sha512(toUnsigned(N, 384)); @@ -122,25 +126,30 @@ public SRPclient(String password_p, byte[] serverSalt, byte[] serverEphemeralPub // Compute expected server proof M2 = H(A || M1 || K) M2 = sha512(concat(toUnsigned(A, 384), M1, K)); + + if (logger.isTraceEnabled()) { + logger.trace( + "Pair-Setup M2: SRP client initialized:\n - K: {}\n - S: {}\n - Controller M1: {}\n - Expected M2: {}\n", + toHex(K), toHex(S), toHex(M1), toHex(M2)); + } } public byte[] getScramblingParameter() { return toUnsigned(u, 64); } - public Ed25519PublicKeyParameters getServerLongTermPublicKey() throws Exception { - Ed25519PublicKeyParameters serverLongTermPublicKey = this.serverLongTermPublicKey; - if (serverLongTermPublicKey == null) { + public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Exception { + Ed25519PublicKeyParameters accessoryLTPK = this.accessoryLongTermPublicKey; + if (accessoryLTPK == null) { throw new IllegalStateException("Accessory long-term public key not yet available"); } - return serverLongTermPublicKey; - } - - public byte[] getSharedKey() { - return generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + return accessoryLTPK; } - public void m4VerifyServerProof(byte[] serverProof) throws Exception { + public void m4VerifyAccessoryProof(byte[] serverProof) throws Exception { + if (logger.isTraceEnabled()) { + logger.trace("Pair-Setup M4: Accessory info:\n - Accessory M2: {}", toHex(serverProof)); + } if (!Arrays.equals(M2, serverProof)) { throw new SecurityException("SRP server proof mismatch"); } @@ -152,24 +161,37 @@ public void m4VerifyServerProof(byte[] serverProof) throws Exception { * concatenation of { shared session key, client pairing identifier, client LTPK } created by * the client's long term secret key. * - * @param pairingId the pairing identifier. - * @param clientLongTermSecretKey the controller's long-term private key for signing. + * @param iOSDeviceId the pairing identifier. + * @param iOSDeviceLongTermPrivateKey the controller's long-term private key for signing. * @return the encrypted controller information as a byte array. * @throws Exception if an error occurs during the encryption or signing process. */ - public byte[] m5EncodeClientInfoAndSign(byte[] pairingId, Ed25519PrivateKeyParameters clientLongTermSecretKey) - throws Exception { - byte[] sharedKey = generateHkdfKey(K, PAIR_CONTROLLER_SIGN_SALT, PAIR_CONTROLLER_SIGN_INFO); - byte[] clientSigningKey = clientLongTermSecretKey.generatePublicKey().getEncoded(); - byte[] clientSignature = signMessage(clientLongTermSecretKey, concat(sharedKey, pairingId, clientSigningKey)); + public byte[] m5EncodeControllerInfoAndSign(byte[] iOSDeviceId, + Ed25519PrivateKeyParameters iOSDeviceLongTermPrivateKey) throws Exception { + byte[] iOSDeviceX = generateHkdfKey(K, PAIR_SETUP_CONTROLLER_SIGN_SALT, PAIR_SETUP_CONTROLLER_SIGN_INFO); + byte[] iOSDeviceLTPK = iOSDeviceLongTermPrivateKey.generatePublicKey().getEncoded(); + byte[] iOSDeviceInfo = concat(iOSDeviceX, iOSDeviceId, iOSDeviceLTPK); + byte[] iOSDeviceSignature = signMessage(iOSDeviceLongTermPrivateKey, iOSDeviceInfo); Map subTlv = new LinkedHashMap<>(); - subTlv.put(TlvType.IDENTIFIER.value, pairingId); - subTlv.put(TlvType.PUBLIC_KEY.value, clientSigningKey); - subTlv.put(TlvType.SIGNATURE.value, clientSignature); + subTlv.put(TlvType.IDENTIFIER.value, iOSDeviceId); + subTlv.put(TlvType.PUBLIC_KEY.value, iOSDeviceLTPK); + subTlv.put(TlvType.SIGNATURE.value, iOSDeviceSignature); byte[] plainText = Tlv8Codec.encode(subTlv); - byte[] cipherText = encrypt(getSharedKey(), PS_M5_NONCE, plainText, new byte[0]); + byte[] encryptKey = generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + + if (logger.isTraceEnabled()) { + logger.trace( + "Pair-Setup M5: Controller info:\n - X: {}\n - LTPK: {}\n - Info: {}\n - Signature: {}\n - Plain text: {}\n - Key: {}", // + toHex(iOSDeviceX), toHex(iOSDeviceLTPK), toHex(iOSDeviceInfo), toHex(iOSDeviceSignature), + toHex(plainText), toHex(encryptKey)); + } + byte[] cipherText = encrypt(encryptKey, PS_M5_NONCE, plainText, new byte[0]); + + if (logger.isTraceEnabled()) { + logger.trace("Pair-Setup M5: Controller info:\n - Cipher text: {}", toHex(cipherText)); + } return cipherText; } @@ -182,25 +204,34 @@ public byte[] m5EncodeClientInfoAndSign(byte[] pairingId, Ed25519PrivateKeyParam * @param cipherText the encrypted accessory information received from the accessory. * @throws Exception if an error occurs during decryption or signature verification. */ - public void m6DecodeServerInfoAndVerify(byte[] cipherText) throws Exception { - byte[] plainText = decrypt(getSharedKey(), PS_M6_NONCE, cipherText, new byte[0]); + public void m6DecodeAccessoryInfoAndVerify(byte[] cipherText) throws Exception { + byte[] decryptKey = generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + if (logger.isTraceEnabled()) { + logger.trace("Pair-Setup M6: Accessory info:\n - Cipher text: {}\n - Key: {}", toHex(cipherText), + toHex(decryptKey)); + } + byte[] plainText = decrypt(decryptKey, PS_M6_NONCE, cipherText, new byte[0]); Map subTlv = Tlv8Codec.decode(plainText); - byte[] serverPairingId = subTlv.get(TlvType.IDENTIFIER.value); - byte[] serverSigningKey = subTlv.get(TlvType.PUBLIC_KEY.value); - byte[] serverSignature = subTlv.get(TlvType.SIGNATURE.value); + byte[] accessoryPairingId = subTlv.get(TlvType.IDENTIFIER.value); + byte[] accessoryLTPK = subTlv.get(TlvType.PUBLIC_KEY.value); + byte[] accessorySignature = subTlv.get(TlvType.SIGNATURE.value); - if (serverPairingId == null || serverSigningKey == null || serverSignature == null) { + if (accessoryPairingId == null || accessoryLTPK == null || accessorySignature == null) { throw new SecurityException("Missing accessory credentials in M6"); } - byte[] sharedKey = generateHkdfKey(K, PAIR_ACCESSORY_SIGN_SALT, PAIR_ACCESSORY_SIGN_INFO); + Ed25519PublicKeyParameters accessoryLongTermPublicKey = new Ed25519PublicKeyParameters(accessoryLTPK, 0); + byte[] accessoryX = generateHkdfKey(K, PAIR_SETUP_ACCESSORY_SIGN_SALT, PAIR_SETUP_ACCESSORY_SIGN_INFO); + byte[] accessoryInfo = concat(accessoryX, accessoryPairingId, accessoryLTPK); - Ed25519PublicKeyParameters serverLongTermPublicKey = new Ed25519PublicKeyParameters(serverSigningKey, 0); - if (!verifySignature(serverLongTermPublicKey, serverSignature, - concat(sharedKey, serverPairingId, serverSigningKey))) { - throw new SecurityException("Accessory signature verification failed"); + if (logger.isTraceEnabled()) { + logger.trace( + "Pair-Setup M6: Accessory info:\n - Plain text: {}\n - X: {}\n - LTPK: {}\n - Info: {}\n - Signature: {}", + toHex(plainText), toHex(accessoryX), toHex(accessoryLTPK), toHex(accessoryInfo), + toHex(accessorySignature)); } - this.serverLongTermPublicKey = serverLongTermPublicKey; + verifySignature(accessoryLongTermPublicKey, accessorySignature, accessoryInfo); + this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 545bf3dda8636..fee156736e61a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -58,7 +58,7 @@ public PairSetupClient(IpTransport ipTransport, byte[] clientPairingId, if (clientPairingId.length != 8) { throw new IllegalArgumentException("Client Id must be exactly 8 bytes"); } - logger.debug("Created with pairingCode:{}", pairingCode); + logger.debug("Created with pairing code: {}", pairingCode); this.ipTransport = ipTransport; this.password = pairingCode; this.clientPairingId = clientPairingId; @@ -73,7 +73,7 @@ public PairSetupClient(IpTransport ipTransport, byte[] clientPairingId, */ public Ed25519PublicKeyParameters pair() throws Exception { SRPclient client = m1Execute(); - return client.getServerLongTermPublicKey(); + return client.getAccessoryLongTermPublicKey(); } /** @@ -101,13 +101,15 @@ private SRPclient m1Execute() throws Exception { * @throws Exception if an error occurs during processing */ private SRPclient m2Execute(byte[] m1Response) throws Exception { - logger.debug("Pair-Setup M2: Read server salt and ephemeral PK; initialize SRP client"); + logger.debug("Pair-Setup M2: Read server salt and accessory ephemeral PK; initialize SRP client"); Map tlv = Tlv8Codec.decode(m1Response); Validator.validate(PairingMethod.SETUP, tlv); byte[] serverSalt = tlv.get(TlvType.SALT.value); byte[] serverPublicKey = tlv.get(TlvType.PUBLIC_KEY.value); - logger.trace("ServerSalt: {}", toHex(serverSalt)); - logger.trace("ServerPKey: {}", toHex(serverPublicKey)); + if (logger.isTraceEnabled()) { + logger.trace("Pair-Setup M2: Receive accessory data\n - Server salt: {}\n - Accessory PK: {}", + toHex(serverSalt), toHex(serverPublicKey)); + } SRPclient client = new SRPclient(password, Objects.requireNonNull(serverSalt), Objects.requireNonNull(serverPublicKey)); return m3Execute(client); @@ -120,12 +122,16 @@ private SRPclient m2Execute(byte[] m1Response) throws Exception { * @throws Exception if an error occurs during processing */ private SRPclient m3Execute(SRPclient client) throws Exception { - logger.debug("Pair-Setup M3: Send client epehemeral PK and M1 proof to server"); + logger.debug("Pair-Setup M3: Send controller ephemeral PK and M1 proof to accessory"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M3.value }); tlv.put(TlvType.PUBLIC_KEY.value, CryptoUtils.toUnsigned(client.A, 384)); tlv.put(TlvType.PROOF.value, client.M1); Validator.validate(PairingMethod.SETUP, tlv); + if (logger.isTraceEnabled()) { + logger.trace("Pair-Setup M3: Send data\n - Controller PK: {}\n - Controller M1: {}", + toHex(CryptoUtils.toUnsigned(client.A, 384)), toHex(client.M1)); + } byte[] m3Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); return m4Execute(client, m3Response); } @@ -137,12 +143,11 @@ private SRPclient m3Execute(SRPclient client) throws Exception { * @throws Exception if an error occurs during processing */ private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exception { - logger.debug("Pair-Setup M4: Read server M2 proof; and verify it"); + logger.debug("Pair-Setup M4: Read accessory M2 proof; and verify it"); Map tlv = Tlv8Codec.decode(m3Response); Validator.validate(PairingMethod.SETUP, tlv); byte[] serverProofM2 = tlv.get(TlvType.PROOF.value); - logger.trace("ServerM2: {}", toHex(serverProofM2)); - client.m4VerifyServerProof(Objects.requireNonNull(serverProofM2)); + client.m4VerifyAccessoryProof(Objects.requireNonNull(serverProofM2)); return m5Execute(client); } @@ -154,8 +159,8 @@ private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exceptio * @throws Exception if an error occurs during processing */ private SRPclient m5Execute(SRPclient client) throws Exception { - logger.debug("Pair-Setup M5: Send client session key, pairing id, LTPK, and sig to server"); - byte[] cipherText = client.m5EncodeClientInfoAndSign(clientPairingId, clientLongTermSecretKey); + logger.debug("Pair-Setup M5: Send controller id, LTPK, and signature to accessory"); + byte[] cipherText = client.m5EncodeControllerInfoAndSign(clientPairingId, clientLongTermSecretKey); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M5.value }); tlv.put(TlvType.ENCRYPTED_DATA.value, cipherText); @@ -172,11 +177,11 @@ private SRPclient m5Execute(SRPclient client) throws Exception { * @throws Exception if an error occurs during processing */ private SRPclient m6Execute(SRPclient client, byte[] m5Response) throws Exception { - logger.debug("Pair-Setup M6: Read server session key, pairing id, LTPK, and sig; and verify it"); + logger.debug("Pair-Setup M6: Read accessory id, LTPK, and signature; and verify it"); Map tlv = Tlv8Codec.decode(m5Response); Validator.validate(PairingMethod.SETUP, tlv); byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.value); - client.m6DecodeServerInfoAndVerify(Objects.requireNonNull(cipherText)); + client.m6DecodeAccessoryInfoAndVerify(Objects.requireNonNull(cipherText)); return client; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index 8c30f8b5c0337..d0539b70dd25d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -116,10 +116,8 @@ private void m2Execute(byte[] m1Response) throws Exception { throw new SecurityException("Accessory identifier or signature missing"); } - if (!verifySignature(serverLongTermPublicKey, serverSignature, concat(serverEphemeralPublicKey.getEncoded(), - serverPairingId, clientEphemeralSecretKey.generatePublicKey().getEncoded()))) { - throw new SecurityException("Client signature invalid"); - } + verifySignature(serverLongTermPublicKey, serverSignature, concat(serverEphemeralPublicKey.getEncoded(), + serverPairingId, clientEphemeralSecretKey.generatePublicKey().getEncoded())); m3Execute(); } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java index 9331c18fafb72..fae2f99d11023 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -21,6 +21,7 @@ import java.util.Map; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.crypto.CryptoUtils; @@ -40,33 +41,33 @@ public class SRPserver { public @NonNullByDefault({}) BigInteger A; // client public SRP key public final BigInteger b; // server private SRP key public final BigInteger B; // server public SRP key - public @NonNullByDefault({}) byte[] K = null; // session key - public @NonNullByDefault({}) BigInteger S; // shared secret + public @NonNullByDefault({}) byte[] S = null; // shared secret + public @NonNullByDefault({}) byte[] K = null; // Apple SRP style session key = H(S) public @NonNullByDefault({}) BigInteger u; // scrambling parameter public final BigInteger v; // verifier private final String I; // username private final byte[] s; // salt - private final byte[] serverPairingId; - private final Ed25519PrivateKeyParameters serverLongTermPrivateKey; + private final byte[] accessoryId; + private final Ed25519PrivateKeyParameters accessoryLongTermPrivateKey; /** * Create a SRP server instance with the given parameters. * * @param password the password to use * @param serverSalt the salt to use - * @param serverPairingId the pairing ID of the server - * @param serverLongTermPrivateKey the long term private key of the server + * @param accessoryId the pairing ID of the server + * @param accessoryLongTermPrivateKey the long term private key of the server * @param username the username to use (or null for default "Pair-Setup") - * @param serverPrivateKey optional 32 byte private key to use for b, or null to generate a new one + * @param accessoryPrivateKey optional 32 byte private key to use for b, or null to generate a new one * * @throws Exception on any error */ - public SRPserver(String password, byte[] serverSalt, byte[] serverPairingId, - Ed25519PrivateKeyParameters serverLongTermPrivateKey, @Nullable String username, - byte @Nullable [] serverPrivateKey) throws Exception { - this.serverPairingId = serverPairingId; - this.serverLongTermPrivateKey = serverLongTermPrivateKey; + public SRPserver(String password, byte[] serverSalt, byte[] accessoryId, + Ed25519PrivateKeyParameters accessoryLongTermPrivateKey, @Nullable String username, + byte @Nullable [] accessoryPrivateKey) throws Exception { + this.accessoryId = accessoryId; + this.accessoryLongTermPrivateKey = accessoryLongTermPrivateKey; I = username != null ? username : PAIR_SETUP; s = serverSalt; @@ -77,7 +78,7 @@ public SRPserver(String password, byte[] serverSalt, byte[] serverPairingId, v = g.modPow(x, N); // Apply or create ephemeral b and compute public B - byte[] serverKey = serverPrivateKey; + byte[] serverKey = accessoryPrivateKey; if (serverKey == null) { serverKey = new byte[32]; new SecureRandom().nextBytes(serverKey); @@ -101,11 +102,13 @@ public byte[] m3CreateServerProof(byte[] clientPublicKeyA) throws Exception { throw new SecurityException("Invalid scrambling parameter"); } - // S = (A * v^u)^b mod N + // S = (A * v^u)^b mod N (384 bytes) BigInteger vu = v.modPow(u, N); BigInteger base = A.multiply(vu).mod(N); - S = base.modPow(b, N); - K = sha512(toUnsigned(S, 384)); + S = toUnsigned(base.modPow(b, N), 384); + + // Compute 'Apple SRP style' session key K = H(S) (64 bytes) + K = sha512(S); // Compute M1 = H(H(N) xor H(g) || H(I) || salt || A || B || K) byte[] HN = sha512(toUnsigned(N, 384)); @@ -118,22 +121,44 @@ public byte[] m3CreateServerProof(byte[] clientPublicKeyA) throws Exception { return sha512(concat(toUnsigned(clientPublicA, 384), M1, K)); } - public byte[] m5EncodeServerInfoAndSign() throws Exception { - byte[] sharedKey = generateHkdfKey(K, PAIR_ACCESSORY_SIGN_SALT, PAIR_ACCESSORY_SIGN_INFO); - byte[] signingKey = serverLongTermPrivateKey.generatePublicKey().getEncoded(); - byte[] payload = concat(sharedKey, serverPairingId, signingKey); - byte[] signature = signMessage(serverLongTermPrivateKey, payload); + public void m5DecodeControllerInfoAndVerify(Map tlv5) throws Exception { + byte[] cipherText = tlv5.get(TlvType.ENCRYPTED_DATA.value); + if (cipherText == null) { + throw new IllegalArgumentException("Missing encrypted data"); + } - Map subTlv = Map.of( // - TlvType.IDENTIFIER.value, serverPairingId, // - TlvType.PUBLIC_KEY.value, signingKey, // - TlvType.SIGNATURE.value, signature); + byte[] decryptKey = generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + byte[] plainText = CryptoUtils.decrypt(decryptKey, PS_M5_NONCE, cipherText, new byte[0]); - byte[] plaintext = Tlv8Codec.encode(subTlv); - return CryptoUtils.encrypt(getSymmetricKey(), PS_M6_NONCE, plaintext, new byte[0]); + Map subTlv = Tlv8Codec.decode(plainText); + byte[] iOSDeviceId = subTlv.get(TlvType.IDENTIFIER.value); + byte[] iOSDeviceLTPK = subTlv.get(TlvType.PUBLIC_KEY.value); + byte[] iOSDeviceSignature = subTlv.get(TlvType.SIGNATURE.value); + + if (iOSDeviceId == null || iOSDeviceLTPK == null || iOSDeviceSignature == null) { + throw new IllegalArgumentException("Missing identifier, public key or signature"); + } + + byte[] iOSDeviceX = generateHkdfKey(K, PAIR_SETUP_CONTROLLER_SIGN_SALT, PAIR_SETUP_CONTROLLER_SIGN_INFO); + byte[] iOSDeviceInfo = concat(iOSDeviceX, iOSDeviceId, iOSDeviceLTPK); + + Ed25519PublicKeyParameters iOSDeviceLongTermPublicKey = new Ed25519PublicKeyParameters(iOSDeviceLTPK, 0); + verifySignature(iOSDeviceLongTermPublicKey, iOSDeviceSignature, iOSDeviceInfo); } - public byte[] getSymmetricKey() { - return generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + public byte[] m6EncodeAccessoryInfoAndSign() throws Exception { + byte[] accessoryX = generateHkdfKey(K, PAIR_SETUP_ACCESSORY_SIGN_SALT, PAIR_SETUP_ACCESSORY_SIGN_INFO); + byte[] accessoryLTPK = accessoryLongTermPrivateKey.generatePublicKey().getEncoded(); + byte[] accessoryInfo = concat(accessoryX, accessoryId, accessoryLTPK); + byte[] accessorySignature = signMessage(accessoryLongTermPrivateKey, accessoryInfo); + + Map subTlv = Map.of( // + TlvType.IDENTIFIER.value, accessoryId, // + TlvType.PUBLIC_KEY.value, accessoryLTPK, // + TlvType.SIGNATURE.value, accessorySignature); + + byte[] plaintext = Tlv8Codec.encode(subTlv); + byte[] encryptKey = generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + return CryptoUtils.encrypt(encryptKey, PS_M6_NONCE, plaintext, new byte[0]); } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java index 195d005d2b063..b61981ec4d5f0 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java @@ -210,7 +210,7 @@ void testClientVectors() { assertArrayEquals(A, CryptoUtils.toUnsigned(client.A, 384)); assertArrayEquals(u, CryptoUtils.toUnsigned(client.u, 64)); - assertArrayEquals(S, CryptoUtils.toUnsigned(client.S, 384)); + assertArrayEquals(S, client.S); assertArrayEquals(K, client.K); } @@ -240,7 +240,7 @@ void testServerVectors() { assertDoesNotThrow(() -> server.m3CreateServerProof(A)); assertArrayEquals(u, CryptoUtils.toUnsigned(server.u, 64)); - assertArrayEquals(S, CryptoUtils.toUnsigned(server.S, 384)); + assertArrayEquals(S, server.S); assertArrayEquals(K, server.K); } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index b0d08b2e73f3d..0bebc2ec485c2 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.PS_M5_NONCE; +import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; import java.nio.charset.StandardCharsets; @@ -71,7 +71,7 @@ void testBareCrypto() throws Exception { void testSrpClient() throws Exception { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); SRPclient client = new SRPclient("password123", toBytes(SALT_HEX), toBytes(SERVER_PRIVATE_HEX)); - byte[] sharedKey = client.getSharedKey(); + byte[] sharedKey = generateHkdfKey(client.K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); byte[] cipherText = encrypt(sharedKey, PS_M5_NONCE, plainText0, new byte[0]); byte[] plainText1 = decrypt(sharedKey, PS_M5_NONCE, cipherText, new byte[0]); assertArrayEquals(plainText0, plainText1); @@ -81,22 +81,22 @@ void testSrpClient() throws Exception { void testPairSetup() throws Exception { // initialize test parameters String password = "password123"; - byte[] clientPairingId = new byte[] { 11, 22, 33, 44, 55, 66, 77, 88 }; + byte[] iOSDeviceId = new byte[] { 11, 22, 33, 44, 55, 66, 77, 88 }; byte[] serverSalt = toBytes(SALT_HEX); - byte[] serverPairingId = new byte[] { 88, 77, 66, 55, 44, 33, 22, 11 }; + byte[] accessoryId = new byte[] { 88, 77, 66, 55, 44, 33, 22, 11 }; // initialize signing keys - Ed25519PrivateKeyParameters clientLongTermSecretKey = new Ed25519PrivateKeyParameters( + Ed25519PrivateKeyParameters controllerLongTermSecretKey = new Ed25519PrivateKeyParameters( toBytes(CLIENT_PRIVATE_HEX)); - Ed25519PrivateKeyParameters serverLongTermSecretKey = new Ed25519PrivateKeyParameters( + Ed25519PrivateKeyParameters accessoryLongTermSecretKey = new Ed25519PrivateKeyParameters( toBytes(SERVER_PRIVATE_HEX)); // create mock IpTransport mockTransport = mock(IpTransport.class); // create SRP client and server - SRPserver server = new SRPserver(password, serverSalt, serverPairingId, serverLongTermSecretKey, null, null); - PairSetupClient client = new PairSetupClient(mockTransport, clientPairingId, clientLongTermSecretKey, password); + SRPserver server = new SRPserver(password, serverSalt, accessoryId, accessoryLongTermSecretKey, null, null); + PairSetupClient client = new PairSetupClient(mockTransport, iOSDeviceId, controllerLongTermSecretKey, password); // mock the HTTP transport to simulate the SRP exchange doAnswer(invocation -> { @@ -112,9 +112,9 @@ void testPairSetup() throws Exception { // process the message based on the pairing process Mx state return switch (state[0]) { - case 1 -> m1GetServerResponse(server, serverSalt); - case 3 -> m3GetServerResponse(server, tlv, client); - case 5 -> m5GetServerResponse(server); + case 1 -> m1GetAccessoryResponse(server, serverSalt); + case 3 -> m3GetAccessoryResponse(server, tlv, client); + case 5 -> m5GetAccessoryResponse(server, tlv); default -> throw new IllegalArgumentException("Unexpected state"); }; @@ -124,7 +124,7 @@ void testPairSetup() throws Exception { client.pair(); } - private byte[] m1GetServerResponse(SRPserver server, byte[] serverSalt) { + private byte[] m1GetAccessoryResponse(SRPserver server, byte[] serverSalt) { Map tlv = Map.of( // TlvType.STATE.value, new byte[] { PairingState.M2.value }, // TlvType.SALT.value, serverSalt, // salt @@ -134,7 +134,7 @@ TlvType.PUBLIC_KEY.value, toUnsigned(server.B, 384) // server public key return Tlv8Codec.encode(tlv); } - private byte[] m3GetServerResponse(SRPserver server, Map tlv2, PairSetupClient client) + private byte[] m3GetAccessoryResponse(SRPserver server, Map tlv2, PairSetupClient client) throws Exception { clientPublicKey = tlv2.get(TlvType.PUBLIC_KEY.value); byte[] serverProof = server.m3CreateServerProof(Objects.requireNonNull(clientPublicKey)); @@ -146,12 +146,13 @@ private byte[] m3GetServerResponse(SRPserver server, Map tlv2, return Tlv8Codec.encode(tlv3); } - private byte[] m5GetServerResponse(SRPserver server) throws Exception { - byte[] cipherText = server.m5EncodeServerInfoAndSign(); - Map tlv = Map.of( // + private byte[] m5GetAccessoryResponse(SRPserver server, Map tlv5) throws Exception { + server.m5DecodeControllerInfoAndVerify(tlv5); + byte[] cipherText = server.m6EncodeAccessoryInfoAndSign(); + Map tlv6 = Map.of( // TlvType.STATE.value, new byte[] { PairingState.M6.value }, // TlvType.ENCRYPTED_DATA.value, cipherText); - PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); - return Tlv8Codec.encode(tlv); + PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv6); + return Tlv8Codec.encode(tlv6); } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index 9b99405dc44cd..cd87914b71492 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -48,29 +48,29 @@ class TestPairVerify { E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20 """; - byte[] clientPairingId = new byte[] { 11, 22, 33, 44, 55, 66, 77, 88 }; - byte[] serverPairingId = new byte[] { 88, 77, 66, 55, 44, 33, 22, 11 }; + byte[] controllerId = new byte[] { 11, 22, 33, 44, 55, 66, 77, 88 }; + byte[] accessoryId = new byte[] { 88, 77, 66, 55, 44, 33, 22, 11 }; - private final Ed25519PrivateKeyParameters clientLongTermPrivateKey = new Ed25519PrivateKeyParameters( + private final Ed25519PrivateKeyParameters controllerLongTermPrivateKey = new Ed25519PrivateKeyParameters( toBytes(CLIENT_PRIVATE_HEX)); - private final Ed25519PrivateKeyParameters serverLongTermPrivateKey = new Ed25519PrivateKeyParameters( + private final Ed25519PrivateKeyParameters accessoryLongTermPrivateKey = new Ed25519PrivateKeyParameters( toBytes(SERVER_PRIVATE_HEX)); - private @NonNullByDefault({}) X25519PrivateKeyParameters serverEphemeralSecretKey; - private @NonNullByDefault({}) X25519PublicKeyParameters clientEphemeralPublicKey; - private @NonNullByDefault({}) byte[] sharedKey; + private @NonNullByDefault({}) X25519PrivateKeyParameters accessoryEphemeralSecretKey; + private @NonNullByDefault({}) X25519PublicKeyParameters controllerEphemeralPublicKey; + private @NonNullByDefault({}) byte[] cryptoKey; @Test void testPairVerify() throws Exception { - serverEphemeralSecretKey = generateX25519KeyPair(); + accessoryEphemeralSecretKey = generateX25519KeyPair(); // create mock IpTransport mockTransport = mock(IpTransport.class); // create SRP client and server - PairVerifyClient client = new PairVerifyClient(mockTransport, clientPairingId, clientLongTermPrivateKey, - serverLongTermPrivateKey.generatePublicKey()); + PairVerifyClient client = new PairVerifyClient(mockTransport, controllerId, controllerLongTermPrivateKey, + accessoryLongTermPrivateKey.generatePublicKey()); // mock the HTTP transport to simulate the SRP exchange doAnswer(invocation -> { @@ -86,8 +86,8 @@ void testPairVerify() throws Exception { // process the message based on the pair verification process Mx state return switch (state[0]) { - case 1 -> m1GetServerResponse(tlv); - case 3 -> m3GetServerResponse(tlv); + case 1 -> m1GetAccessoryResponse(tlv); + case 3 -> m3GetAccessoryResponse(tlv); default -> throw new IllegalArgumentException("Unexpected state"); }; @@ -97,57 +97,55 @@ void testPairVerify() throws Exception { client.verify(); } - private byte[] m1GetServerResponse(Map tlv) throws Exception { - byte[] clientEphemeralPublicKey = tlv.get(TlvType.PUBLIC_KEY.value); - byte[] serverEphemeralPublicKey = this.serverEphemeralSecretKey.generatePublicKey().getEncoded(); - if (clientEphemeralPublicKey == null) { + private byte[] m1GetAccessoryResponse(Map tlv) throws Exception { + byte[] controllerEphemeralPublicKey = tlv.get(TlvType.PUBLIC_KEY.value); + byte[] accessoryEphemeralPublicKey = accessoryEphemeralSecretKey.generatePublicKey().getEncoded(); + if (controllerEphemeralPublicKey == null) { throw new SecurityException("Client public key missing"); } - byte[] serverSignature = signMessage(serverLongTermPrivateKey, - concat(serverEphemeralPublicKey, serverPairingId, clientEphemeralPublicKey)); + byte[] accessorySignature = signMessage(accessoryLongTermPrivateKey, + concat(accessoryEphemeralPublicKey, accessoryId, controllerEphemeralPublicKey)); Map tlvInner = Map.of( // - TlvType.IDENTIFIER.value, serverPairingId, // - TlvType.SIGNATURE.value, serverSignature); + TlvType.IDENTIFIER.value, accessoryId, // + TlvType.SIGNATURE.value, accessorySignature); - this.clientEphemeralPublicKey = new X25519PublicKeyParameters(clientEphemeralPublicKey); + this.controllerEphemeralPublicKey = new X25519PublicKeyParameters(controllerEphemeralPublicKey); - byte[] sharedSecret = generateSharedSecret(serverEphemeralSecretKey, this.clientEphemeralPublicKey); - sharedKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); + byte[] sharedSecret = generateSharedSecret(accessoryEphemeralSecretKey, this.controllerEphemeralPublicKey); + cryptoKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); byte[] plainText = Tlv8Codec.encode(tlvInner); - byte[] cipherText = encrypt(sharedKey, PV_M2_NONCE, plainText, new byte[0]); + byte[] cipherText = encrypt(cryptoKey, PV_M2_NONCE, plainText, new byte[0]); Map tlvOut = Map.of( // TlvType.STATE.value, new byte[] { PairingState.M2.value }, // - TlvType.PUBLIC_KEY.value, serverEphemeralPublicKey, // + TlvType.PUBLIC_KEY.value, accessoryEphemeralPublicKey, // TlvType.ENCRYPTED_DATA.value, cipherText); return Tlv8Codec.encode(tlvOut); } - private byte[] m3GetServerResponse(Map tlv) throws Exception { - if (sharedKey.length == 0) { + private byte[] m3GetAccessoryResponse(Map tlv) throws Exception { + if (cryptoKey.length == 0) { throw new IllegalStateException("Session key not established"); } byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.value); if (cipherText == null) { throw new SecurityException("Server cipher text missing"); } - byte[] plainText = decrypt(sharedKey, PV_M3_NONCE, Objects.requireNonNull(cipherText), new byte[0]); + byte[] plainText = decrypt(cryptoKey, PV_M3_NONCE, Objects.requireNonNull(cipherText), new byte[0]); Map subTlv = Tlv8Codec.decode(plainText); - byte[] clientPairingId = subTlv.get(TlvType.IDENTIFIER.value); - byte[] clientSignature = subTlv.get(TlvType.SIGNATURE.value); - if (clientPairingId == null || clientSignature == null) { - throw new SecurityException("Client pairing Id or signature missing"); + byte[] controllerId = subTlv.get(TlvType.IDENTIFIER.value); + byte[] controllerSignature = subTlv.get(TlvType.SIGNATURE.value); + if (controllerId == null || controllerSignature == null) { + throw new SecurityException("Controller Id or signature missing"); } - if (!verifySignature(clientLongTermPrivateKey.generatePublicKey(), clientSignature, - concat(clientEphemeralPublicKey.getEncoded(), clientPairingId, - serverEphemeralSecretKey.generatePublicKey().getEncoded()))) { - throw new SecurityException("Client signature invalid"); - } + byte[] controllerInfo = concat(controllerEphemeralPublicKey.getEncoded(), controllerId, + accessoryEphemeralSecretKey.generatePublicKey().getEncoded()); + verifySignature(controllerLongTermPrivateKey.generatePublicKey(), controllerSignature, controllerInfo); Map tlvOut = Map.of(TlvType.STATE.value, new byte[] { PairingState.M4.value }); PairVerifyClient.Validator.validate(PairingMethod.VERIFY, tlvOut); From 608bcb93b935b22ceadc5e7ffa4cddcf22e414c7 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 6 Oct 2025 15:47:32 +0100 Subject: [PATCH 051/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../internal/crypto/CryptoConstants.java | 18 +++++++++++++----- .../homekit/internal/crypto/SRPclient.java | 7 ++++--- .../HomekitMdnsDiscoveryParticipant.java | 2 +- .../internal/hap_services/PairSetupClient.java | 4 ++-- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java index ca0c9fd590766..dddf6e13daa71 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java @@ -50,8 +50,8 @@ public class CryptoConstants { public static final byte[] PAIR_SETUP_ENCRYPT_INFO = "Pair-Setup-Encrypt-Info".getBytes(StandardCharsets.UTF_8); public static final byte[] PAIR_SETUP_ENCRYPT_SALT = "Pair-Setup-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); - public static final byte[] PS_M5_NONCE = "PS-Msg05".getBytes(StandardCharsets.UTF_8); - public static final byte[] PS_M6_NONCE = "PS-Msg06".getBytes(StandardCharsets.UTF_8); + public static final byte[] PS_M5_NONCE = nonce("PS-Msg05"); + public static final byte[] PS_M6_NONCE = nonce("PS-Msg06"); public static final byte[] PAIR_SETUP_CONTROLLER_SIGN_SALT = "Pair-Setup-Controller-Sign-Salt".getBytes(StandardCharsets.UTF_8); public static final byte[] PAIR_SETUP_CONTROLLER_SIGN_INFO = "Pair-Setup-Controller-Sign-Info".getBytes(StandardCharsets.UTF_8); @@ -66,18 +66,26 @@ public class CryptoConstants { public static final byte[] PAIR_VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info".getBytes(StandardCharsets.UTF_8); public static final byte[] PAIR_VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt".getBytes(StandardCharsets.UTF_8); - public static final byte[] PV_M2_NONCE = "PV-Msg02".getBytes(StandardCharsets.UTF_8); - public static final byte[] PV_M3_NONCE = "PV-Msg03".getBytes(StandardCharsets.UTF_8); + public static final byte[] PV_M2_NONCE = nonce("PV-Msg02"); + public static final byte[] PV_M3_NONCE = nonce("PV-Msg03"); // @formatter:on private static BigInteger computeK() { try { byte[] paddedN = toUnsigned(N, 384); byte[] paddedG = toUnsigned(g, 384); - byte[] hash = sha512(CryptoUtils.concat(paddedN, paddedG)); + byte[] hash = sha512(concat(paddedN, paddedG)); return new BigInteger(1, hash); } catch (Exception e) { throw new SecurityException("Failed to compute k", e); } } + + private static byte[] nonce(String input) { + // ByteBuffer nonce = ByteBuffer.allocate(12); + // nonce.put(input.getBytes(StandardCharsets.UTF_8)); + // nonce.putInt(0); + // return nonce.array(); // 12-byte nonce + return input.getBytes(StandardCharsets.UTF_8); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index f99d9dcb95274..afb289540396f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -146,11 +146,12 @@ public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Excepti return accessoryLTPK; } - public void m4VerifyAccessoryProof(byte[] serverProof) throws Exception { + public void m4VerifyAccessoryProof(byte[] accessoryProof) throws Exception { if (logger.isTraceEnabled()) { - logger.trace("Pair-Setup M4: Accessory info:\n - Accessory M2: {}", toHex(serverProof)); + logger.trace("Pair-Setup M4: Accessory info:\n - Controller M2: {}\n - Accessory M2: {}", toHex(M2), + toHex(accessoryProof)); } - if (!Arrays.equals(M2, serverProof)) { + if (!Arrays.equals(M2, accessoryProof)) { throw new SecurityException("SRP server proof mismatch"); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 54eafef627648..fce5773d9e58a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -146,7 +146,7 @@ private Map getProperties(ServiceInfo service) { continue; } String[] parts = new String(bytes, i, len, StandardCharsets.UTF_8).split("="); - map.put(parts[0], parts.length < 2 ? "" : parts[1].replaceFirst("\u0000$", "")); // strip zero endings + map.put(parts[0], parts.length < 2 ? "" : parts[1].replaceFirst("\\u0000$", "")); // strip zero endings i += len; } return map; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index fee156736e61a..690999b735b74 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -146,8 +146,8 @@ private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exceptio logger.debug("Pair-Setup M4: Read accessory M2 proof; and verify it"); Map tlv = Tlv8Codec.decode(m3Response); Validator.validate(PairingMethod.SETUP, tlv); - byte[] serverProofM2 = tlv.get(TlvType.PROOF.value); - client.m4VerifyAccessoryProof(Objects.requireNonNull(serverProofM2)); + byte[] accessoryProofM2 = tlv.get(TlvType.PROOF.value); + client.m4VerifyAccessoryProof(Objects.requireNonNull(accessoryProofM2)); return m5Execute(client); } From 9bb63fc58dd55c98a1fcd6f7c6dc294a74867cb8 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 6 Oct 2025 23:49:00 +0100 Subject: [PATCH 052/177] refactor http parsing; support chunked Signed-off-by: Andrew Fiddian-Green --- .../internal/session/HttpPayloadParser.java | 284 ++++++++++++++++++ .../internal/session/SecureSession.java | 75 +---- .../internal/transport/IpTransport.java | 90 ++++-- .../internal/TestHttpPayloadParser.java | 217 +++++++++++++ 4 files changed, 566 insertions(+), 100 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java new file mode 100644 index 0000000000000..0bea7dabf8f17 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.session; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Helper class to parse incoming HTTP messages and determine when a complete message has been received. + * It accumulates header data until the end of headers is detected, then reads the Content-Length header to + * determine how many bytes of content to expect. It tracks the number of content bytes read to know when the full + * message has been received. It also supports chunked transfer encoding. If the content exceeds a maximum + * allowed length, a SecurityException is thrown. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HttpPayloadParser { + + private static final String NEWLINE_REGEX = "\\r?\\n"; + private static final int MAX_CONTENT_LENGTH = 65536; + private static final int MAX_HEADER_BLOCK_SIZE = 2048; + private static final Pattern CONTENT_LENGTH_PATTERN = Pattern.compile("(?i)^content-length:\\s*(\\d+)$"); + private static final Pattern CHUNKED_ENCODING_PATTERN = Pattern.compile("(?i)^transfer-encoding:\\s*chunked$"); + + private final ByteArrayOutputStream headerBuffer = new ByteArrayOutputStream(); + private final ByteArrayOutputStream contentBuffer = new ByteArrayOutputStream(); + private final ByteArrayOutputStream chunkHeaderBuffer = new ByteArrayOutputStream(); + + private boolean headersDone = false; + private int contentLength = -1; + private int headersLength = -1; + private boolean isChunked = false; + private int currentChunkRemaining = -1; + private boolean finalChunkSeen = false; + + /** + * Accepts a byte array representing a fragment of the HTTP message (either headers or content). + * It accumulates header data until the end of headers is detected, then processes content data + * according to the Content-Length or chunked transfer encoding. If the content exceeds the maximum + * allowed length, a SecurityException is thrown. + * + * @param frame the byte array containing a fragment of the HTTP message. + * @throws SecurityException if the content exceeds maximum allowed length or if headers are malformed. + */ + public void accept(byte[] frame) throws SecurityException { + if (frame.length == 0) { + return; + } + + if (!headersDone) { + headerBuffer.write(frame, 0, frame.length); + if (headerBuffer.size() > MAX_HEADER_BLOCK_SIZE) { + throw new SecurityException("Header buffer overload"); + } + byte[] headerBytes = headerBuffer.toByteArray(); + int index = indexOfDoubleCRLF(headerBytes, 0); + if (index >= 0) { + headersDone = true; + headersLength = index + 4; // length of "\r\n\r\n" + + // parse headers for content-length and chunked encoding + for (String httpHeader : new String(headerBytes, StandardCharsets.ISO_8859_1).split(NEWLINE_REGEX)) { + Matcher matcher = CONTENT_LENGTH_PATTERN.matcher(httpHeader); + if (matcher.find()) { + try { + contentLength = Integer.parseInt(matcher.group(1)); + if (contentLength < 0 || contentLength > MAX_CONTENT_LENGTH) { + throw new SecurityException("Invalid Content-Length"); + } + } catch (NumberFormatException e) { + throw new SecurityException("Malformed Content-Length header: " + matcher.group(1)); + } + } else { + matcher = CHUNKED_ENCODING_PATTERN.matcher(httpHeader); + if (matcher.find()) { + isChunked = true; + } + } + } + + // move any bytes after headers into content processing buffer + byte[] leftover = new byte[headerBytes.length - headersLength]; + System.arraycopy(headerBuffer.toByteArray(), headersLength, leftover, 0, leftover.length); + if (leftover.length > 0) { + // process leftover through the chunked/fixed-length logic below + processContentBytes(leftover); + } + headerBuffer.reset(); + headerBuffer.write(headerBytes, 0, headersLength); + } + return; // no content processing until headers are done + } + processContentBytes(frame); + } + + public byte[] getContent() { + return contentBuffer.toByteArray(); + } + + public byte[] getHeaders() { + return headerBuffer.toByteArray(); + } + + /** + * Determines if the complete HTTP message (headers and content) has been read. + * For chunked encoding, checks if the final chunk has been seen. + * For fixed-length bodies, checks if the expected content length has been reached. + * If neither chunked nor content-length is specified, it returns false as the end of the message + * cannot be determined. + * + * @return true if the complete HTTP message has been read, false otherwise. + */ + public boolean isComplete() { + if (!headersDone) { + return false; + } + if (isChunked) { + return finalChunkSeen; + } + if (contentLength >= 0) { + return contentBuffer.size() >= contentLength; + } + return false; + } + + /** + * Parses chunked transfer encoding from the given byte array and appends the decoded content data + * to the contentBuffer. It handles chunk size lines, chunk data, and the final zero-length chunk. + * If the chunked data is malformed or exceeds maximum allowed length, a SecurityException is thrown. + * + * @param block the byte array containing chunked data to be parsed. + * @throws SecurityException if the chunked data is malformed or exceeds maximum allowed length. + */ + private void parseChunkedBytes(byte[] block) throws SecurityException { + int pos = 0, blockLength = block.length; + while (pos < blockLength && !finalChunkSeen) { + // are we expecting a new chunk-size line? + if (currentChunkRemaining == -1) { + // look for CRLF wholly inside this data block + int lfPos = indexOfCRLF(block, pos); + // or CR at end of buffer + LF at start of data + boolean boundaryLF = chunkHeaderBuffer.size() > 0 + && chunkHeaderBuffer.toByteArray()[chunkHeaderBuffer.size() - 1] == '\r' && pos < blockLength + && block[pos] == '\n'; + if (lfPos < 0 && !boundaryLF) { + // no complete CRLF yet - buffer everything + chunkHeaderBuffer.write(block, pos, blockLength - pos); + return; + } + // we have a CRLF either wholly in data, or spanning buffer + data + byte[] chunkHeaderBytes; + if (boundaryLF) { + // CR was in buffer, LF is data[pos] - drop buffer trailing '\r' and data[pos] + byte[] chunkHeaderPrefix = chunkHeaderBuffer.toByteArray(); + // copy prefix without the last byte + chunkHeaderBytes = Arrays.copyOf(chunkHeaderPrefix, chunkHeaderPrefix.length - 1); + pos += 1; // consume the '\n' + } else { + // entire line is in data block + int chunkHeaderLen = lfPos - pos; + chunkHeaderBytes = new byte[chunkHeaderLen]; + System.arraycopy(block, pos, chunkHeaderBytes, 0, chunkHeaderLen); + pos = lfPos + 2; // skip '\r\n' + } + + String chunkHeader = new String(chunkHeaderBytes, StandardCharsets.ISO_8859_1).trim(); + int chunkSize; + try { + chunkSize = Integer.parseInt(chunkHeader, 16); + } catch (NumberFormatException e) { + throw new SecurityException("Invalid chunk size: " + chunkHeader); + } + chunkHeaderBuffer.reset(); + if (chunkSize == 0) { + finalChunkSeen = true; + return; + } + currentChunkRemaining = chunkSize; + } + + // we are in the middle of a chunk + int take = Math.min(currentChunkRemaining, blockLength - pos); + if (take > 0) { + contentBuffer.write(block, pos, take); + pos += take; + currentChunkRemaining -= take; + if (contentBuffer.size() > MAX_CONTENT_LENGTH) { + throw new SecurityException("Content exceeds maximum allowed length"); + } + } + + // once we finish this chunk, we must consume the trailing CRLF + if (currentChunkRemaining == 0) { + if (blockLength - pos >= 2) { + if (block[pos] == '\r' && block[pos + 1] == '\n') { + pos += 2; + currentChunkRemaining = -1; + } else { + throw new SecurityException("Missing CRLF after chunk data"); + } + } else { + // buffer partial CRLF after chunk content for next accept() + chunkHeaderBuffer.write(block, pos, blockLength - pos); + return; + } + } + } + } + + /** + * Processes content bytes according to whether the transfer encoding is chunked or fixed-length. + * For chunked encoding, it calls parseChunkedBytes() to handle chunk parsing. + * For fixed-length bodies, it appends up to contentLength bytes to the content buffer. + * If no content-length is specified and not chunked, it treats the content as a stream and appends all data. + * If the content exceeds the maximum allowed length, a SecurityException is thrown. + * + * @param data the byte array containing content data to be processed. + * @throws SecurityException if the content exceeds maximum allowed length. + */ + private void processContentBytes(byte[] data) throws SecurityException { + if (isChunked) { + parseChunkedBytes(data); + } else if (contentLength >= 0) { + // fixed-length content: accept up to contentLength + int toCopy = Math.min(data.length, contentLength - contentBuffer.size()); + if (toCopy > 0) { + contentBuffer.write(data, 0, toCopy); + } + } else { + // no content-length (and not chunked): treat as a stream + contentBuffer.write(data, 0, data.length); + } + if (contentBuffer.size() > MAX_CONTENT_LENGTH) { + throw new SecurityException("Content exceeds maximum allowed length"); + } + } + + /** + * Finds the index of the CRLF sequence in the given byte array starting from a specified index. + * + * @param buf the byte array to search + * @param from the starting index for the search + * @return the index of the CRLF sequence, or -1 if not found + */ + public static int indexOfCRLF(byte[] buf, int from) { + for (int i = from; i + 1 < buf.length; i++) { + if (buf[i] == '\r' && buf[i + 1] == '\n') { + return i; + } + } + return -1; + } + + /** + * Finds the index of the double CRLF sequence in the given byte array. + * + * @param data the byte array to search + * @return the index of the double CRLF sequence, or -1 if not found + */ + public static int indexOfDoubleCRLF(byte[] data, int start) { + for (int i = start; i + 3 < data.length; i++) { + if (data[i] == '\r' && data[i + 1] == '\n' && data[i + 2] == '\r' && data[i + 3] == '\n') { + return i; + } + } + return -1; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index fec934e65cd61..fa976f2f800df 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -15,17 +15,13 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; import java.nio.ByteBuffer; import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicInteger; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -90,20 +86,18 @@ private void sendFrame(ByteArrayInputStream plainTextStream) throws Exception { * Reads multiple data frames from the input stream until a complete HTTP message is reconstructed. * Repeatedly calls receiveFrame() to read and decrypt individual frames. It accumulates the decrypted * plaintext until it detects the end of the HTTP message. The end of the message is determined by checking - * for the presence of complete HTTP headers and a Content-Length header. + * for the presence of complete HTTP headers and a completed Content-Length, or a complete chunked payload. * - * @return the complete decrypted HTTP message as a byte array. + * @return a 2D byte array where the first element is the HTTP headers and the second element is the content. * @throws Exception if an error occurs during reading or decryption. */ - public byte[] receive() throws Exception { + public byte[][] receive() throws Exception { HttpPayloadParser httpParser = new HttpPayloadParser(); - ByteArrayOutputStream plainText = new ByteArrayOutputStream(); do { byte[] frame = receiveFrame(); - plainText.write(frame); httpParser.accept(frame); - } while (!httpParser.readComplete()); - return plainText.toByteArray(); + } while (!httpParser.isComplete()); + return new byte[][] { httpParser.getHeaders(), httpParser.getContent() }; } /** @@ -124,63 +118,4 @@ private byte[] receiveFrame() throws Exception { byte[] nonce64 = generateNonce64(readCounter.getAndIncrement()); return decrypt(readKey, nonce64, cipherText, frameAad); } - - /** - * Internal helper class to parse incoming HTTP messages and determine when a complete message has been received. - * It accumulates header data until the end of headers is detected, then reads the Content-Length header to - * determine how many bytes of body to expect. It tracks the number of body bytes read to know when the full - * message has been received. - */ - private static class HttpPayloadParser { - private static final String NEWLINE_REGEX = "\\r?\\n"; - private static final String END_OF_HEADERS = "\r\n\r\n"; - private static final int MAX_CONTENT_LENGTH = 65536; - private static final int MAX_HEADER_BLOCK_SIZE = 2048; - private static final Pattern CONTENT_LENGTH_PATTERN = Pattern.compile("(?i)^content-length:\\s*(\\d+)$"); - - private final ByteArrayOutputStream headerBuffer = new ByteArrayOutputStream(); - private boolean headersDone = false; - private int contentLength = 0; - private int bytesAccepted = 0; - private int headerLength = -1; - - public void accept(byte[] data) throws SecurityException { - bytesAccepted += data.length; - if (headersDone) { - return; - } - try { - headerBuffer.write(data); - } catch (IOException e) { - // should never occur with ByteArrayOutputStream - } - if (headerBuffer.size() > MAX_HEADER_BLOCK_SIZE) { - throw new SecurityException("Header buffer overload"); - } - String temp = new String(headerBuffer.toByteArray(), StandardCharsets.ISO_8859_1); - int offset = temp.indexOf(END_OF_HEADERS); - if (offset >= 0) { - headersDone = true; - headerLength = offset + END_OF_HEADERS.length(); - for (String httpHeader : temp.split(NEWLINE_REGEX)) { - Matcher matcher = CONTENT_LENGTH_PATTERN.matcher(httpHeader); - if (matcher.find()) { - try { - contentLength = Integer.parseInt(matcher.group(1)); - if (contentLength < 0 || contentLength > MAX_CONTENT_LENGTH) { - throw new SecurityException("Invalid Content-Length"); - } - } catch (NumberFormatException e) { - // should never occur due to regex - } - break; - } - } - } - } - - public boolean readComplete() { - return headersDone && (bytesAccepted - headerLength >= contentLength); - } - } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 9343f7e09d60f..08ffeb54599ed 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -21,14 +21,13 @@ import java.net.Socket; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.HashMap; -import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; +import org.openhab.binding.homekit.internal.session.HttpPayloadParser; import org.openhab.binding.homekit.internal.session.SecureSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -94,12 +93,12 @@ public byte[] put(String endpoint, String contentType, byte[] content) return execute("PUT", endpoint, contentType, content); } - private synchronized byte[] execute(String method, String endpoint, String contentType, byte[] body) + private synchronized byte[] execute(String method, String endpoint, String contentType, byte[] content) throws IOException, InterruptedException, TimeoutException, ExecutionException { try { - byte[] request = buildRequest(method, endpoint, contentType, body); + byte[] request = buildRequest(method, endpoint, contentType, content); logger.trace("Request:\n{}", new String(request, StandardCharsets.ISO_8859_1)); - byte[] response; + byte[][] response; SecureSession secureSession = this.secureSession; if (secureSession != null) { @@ -113,8 +112,12 @@ private synchronized byte[] execute(String method, String endpoint, String conte response = readPlainResponse(in); } - logger.trace("Response:\n{}", new String(response, StandardCharsets.ISO_8859_1)); - return parseResponse(response); + if (logger.isTraceEnabled()) { + logger.trace("Response:\n{}{}", new String(response[0], StandardCharsets.ISO_8859_1), + new String(response[1], StandardCharsets.ISO_8859_1)); + } + checkHeaders(response[0]); + return response[1]; } catch (IOException | InterruptedException | TimeoutException e) { throw e; } catch (Exception e) { @@ -122,35 +125,52 @@ private synchronized byte[] execute(String method, String endpoint, String conte } } - private byte[] buildRequest(String method, String endpoint, String contentType, byte[] body) throws IOException { + /** + * Builds an HTTP request with the given method, endpoint, content type, and content. + * + * @param method the HTTP method (e.g., "GET", "POST", "PUT") + * @param endpoint the endpoint to which the request is sent + * @param contentType the content type of the request + * @param content the content of the request + * @return the complete HTTP request as a byte array + * @throws IOException if an I/O error occurs + */ + private byte[] buildRequest(String method, String endpoint, String contentType, byte[] content) throws IOException { StringBuilder sb = new StringBuilder(); sb.append(method).append(" ").append(endpoint).append(" HTTP/1.1\r\n"); sb.append("Host: ").append(host).append("\r\n"); sb.append("Accept: ").append(contentType).append("\r\n"); - if (!bodyIsEmpty(method)) { + if (!contentIsEmpty(method)) { sb.append("Content-Type: ").append(contentType).append("\r\n"); - sb.append("Content-Length: ").append(body.length).append("\r\n"); + sb.append("Content-Length: ").append(content.length).append("\r\n"); } else { sb.append("Content-Length: 0\r\n"); } sb.append("\r\n"); byte[] headerBytes = sb.toString().getBytes(StandardCharsets.UTF_8); - if (bodyIsEmpty(method)) { + if (contentIsEmpty(method)) { return headerBytes; } ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(headerBytes); - out.write(body); + out.write(content); return out.toByteArray(); } - private boolean bodyIsEmpty(String method) { + private boolean contentIsEmpty(String method) { return "GET".equals(method) || "DELETE".equals(method); } - private byte[] readPlainResponse(InputStream in) throws IOException { + /* + * Reads a plain (non-secure) HTTP response from the input stream. + * + * @return a 2D byte array where the first element is the HTTP headers and the second element is the content. + * + * @throws IOException if an I/O error occurs or if the response is invalid. + */ + private byte[][] readPlainResponse(InputStream in) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buf = new byte[4096]; int read; @@ -160,30 +180,40 @@ private byte[] readPlainResponse(InputStream in) throws IOException { break; // crude EOF detection } } - return out.toByteArray(); + byte[] data = out.toByteArray(); + int headersEnd = HttpPayloadParser.indexOfDoubleCRLF(data, 0); + if (headersEnd < 0) { + throw new IOException("Invalid HTTP response"); + } + headersEnd += 4; // move past the \r\n\r\n + byte[] headers = new byte[headersEnd]; + byte[] content = new byte[data.length - headersEnd]; + System.arraycopy(data, 0, headers, 0, headers.length); + System.arraycopy(data, headersEnd, content, 0, content.length); + return new byte[][] { headers, content }; } - private byte[] parseResponse(byte[] raw) throws IOException { - ByteArrayInputStream in = new ByteArrayInputStream(raw); + /** + * Checks the HTTP headers for a successful response (status code < 300). + * + * @throws IOException if the response indicates an error. + */ + private void checkHeaders(byte[] headers) throws IOException { + ByteArrayInputStream in = new ByteArrayInputStream(headers); String statusLine = readLine(in); String[] parts = statusLine.split(" ", 3); - int status = Integer.parseInt(parts[1]); - - Map headers = new HashMap<>(); - String line; - while (!(line = readLine(in)).isEmpty()) { - int idx = line.indexOf(':'); - String name = line.substring(0, idx).trim().toLowerCase(); - String value = line.substring(idx + 1).trim(); - headers.put(name, value); + if (parts.length < 3) { + throw new IOException("Invalid HTTP response: " + statusLine); + } + int status; + try { + status = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + throw new IOException("Invalid HTTP response: " + statusLine); } - if (status >= 300) { throw new IOException("HTTP " + status); } - - int len = Integer.parseInt(headers.getOrDefault("content-length", "0")); - return in.readNBytes(len); } private static String readLine(ByteArrayInputStream in) throws IOException { diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java new file mode 100644 index 0000000000000..e9ab92e7236ef --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.homekit.internal.session.HttpPayloadParser; + +/** + * Test cases for the {@link HttpPayloadParser} HTTP parsing. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestHttpPayloadParser { + + private static final String HEADERS_A = "HTTP/1.1 200 OK\r\nContent-type: application/hap+json\r\n"; + private static final String HEADERS_B = "content-length: %d\r\n"; + private static final String HEADERS_C = "transfer-encoding: chunked\r\n"; + private static final String HEADERS_Z1 = "connection: keep-alive\r\n\r"; + private static final String HEADERS_Z2 = "\n"; + private static final String HEADERS_Z = HEADERS_Z1 + HEADERS_Z2; + + private static final String CONTENT = "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"; + private static final String CHUNK_1 = "%x\r"; + private static final String CHUNK_2 = "\n"; + private static final String CHUNK = CHUNK_1 + CHUNK_2; + private static final String CRLF = "\r\n"; + + @Test + void testHttpWithChunkedContentOk() { + HttpPayloadParser parser = new HttpPayloadParser(); + String h = HEADERS_A + HEADERS_C + HEADERS_Z; + String hc = h + CHUNK.formatted(100) + CONTENT + CRLF + CHUNK.formatted(0) + CRLF; + parser.accept(hc.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + assertEquals(h, new String(headers)); + } + + @Test + void testHttpWithChunkedContentOkManyPartial() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(HEADERS_A.substring(0, 8).getBytes()); + parser.accept(HEADERS_A.substring(8).getBytes()); + parser.accept(HEADERS_C.substring(0, 14).getBytes()); + parser.accept(HEADERS_C.substring(14).getBytes()); + parser.accept(HEADERS_Z.substring(0, 19).getBytes()); + parser.accept(HEADERS_Z.substring(19).getBytes()); + parser.accept(CHUNK.formatted(100).getBytes()); + parser.accept(CONTENT.substring(0, 51).getBytes()); + parser.accept(CONTENT.substring(51).getBytes()); + parser.accept(CRLF.getBytes()); + parser.accept(CHUNK.formatted(0).getBytes()); + parser.accept(CRLF.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + String h = HEADERS_A + HEADERS_C + HEADERS_Z; + assertEquals(h, new String(headers)); + } + + @Test + void testHttpWithChunkedContentOkManyPartialAndSplitChunkHeader() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(HEADERS_A.substring(0, 8).getBytes()); + parser.accept(HEADERS_A.substring(8).getBytes()); + parser.accept(HEADERS_C.substring(0, 14).getBytes()); + parser.accept(HEADERS_C.substring(14).getBytes()); + parser.accept(HEADERS_Z.substring(0, 19).getBytes()); + parser.accept(HEADERS_Z.substring(19).getBytes()); + parser.accept(CHUNK_1.formatted(100).getBytes()); + parser.accept(CHUNK_2.getBytes()); + parser.accept(CONTENT.substring(0, 51).getBytes()); + parser.accept(CONTENT.substring(51).getBytes()); + parser.accept(CRLF.getBytes()); + parser.accept(CHUNK.formatted(0).getBytes()); + parser.accept(CRLF.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + String h = HEADERS_A + HEADERS_C + HEADERS_Z; + assertEquals(h, new String(headers)); + } + + @Test + void testHttpWithContentDiscardExtra() { + HttpPayloadParser parser = new HttpPayloadParser(); + String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; + String hc = h + CONTENT + "EXTRA"; + parser.accept(hc.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + assertEquals(h, new String(headers)); + } + + @Test + void testHttpWithContentManyPartialOk() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(HEADERS_A.substring(0, 11).getBytes()); + parser.accept(HEADERS_A.substring(11).getBytes()); + parser.accept(HEADERS_B.substring(0, 11).getBytes()); + parser.accept(HEADERS_B.substring(11).formatted(100).getBytes()); + parser.accept(HEADERS_Z.substring(0, 12).getBytes()); + parser.accept(HEADERS_Z.substring(12).getBytes()); + parser.accept(CONTENT.substring(0, 42).getBytes()); + parser.accept(CONTENT.substring(42).getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; + assertEquals(h, new String(headers)); + } + + @Test + void testHttpWithContentManyPartialOkAndSplitCRLF() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(HEADERS_A.substring(0, 11).getBytes()); + parser.accept(HEADERS_A.substring(11).getBytes()); + parser.accept(HEADERS_B.substring(0, 11).getBytes()); + parser.accept(HEADERS_B.substring(11).formatted(100).getBytes()); + parser.accept(HEADERS_Z1.getBytes()); + parser.accept(HEADERS_Z2.getBytes()); + parser.accept(CONTENT.substring(0, 42).getBytes()); + parser.accept(CONTENT.substring(42).getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; + assertEquals(h, new String(headers)); + } + + @Test + void testHttpWithContentOk() { + HttpPayloadParser parser = new HttpPayloadParser(); + String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + assertEquals(h, new String(headers)); + } + + @Test + void testHttpWithMultipleFrames() { + HttpPayloadParser parser = new HttpPayloadParser(); + String h = HEADERS_A + HEADERS_B.formatted(300) + HEADERS_Z; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertFalse(parser.isComplete()); + parser.accept(CONTENT.getBytes()); + parser.accept(CONTENT.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(300, content.length); + } + + @Test + void testHttpWithNoContentLength() { + HttpPayloadParser parser = new HttpPayloadParser(); + String h = HEADERS_A + HEADERS_B; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertFalse(parser.isComplete()); + } + + @Test + void testHttpWithWrongContentLength() { + HttpPayloadParser parser = new HttpPayloadParser(); + String h = HEADERS_A + HEADERS_B.formatted(200) + HEADERS_Z; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertFalse(parser.isComplete()); + } + + @Test + void testHttpWithZeroContentLength() { + HttpPayloadParser parser = new HttpPayloadParser(); + String h = HEADERS_A + HEADERS_B.formatted(0) + HEADERS_Z; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(h, new String(headers)); + } +} From 97704ca24733a8791cc151b4ca549cb983809f04 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 7 Oct 2025 19:02:00 +0100 Subject: [PATCH 053/177] work in progress Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 1 + .../homekit/internal/dto/Characteristic.java | 18 ++++- .../factory/HomekitHandlerFactory.java | 4 +- ...dler.java => HomekitAccessoryHandler.java} | 50 ++++++++++--- ....java => HomekitBaseAccessoryHandler.java} | 6 +- .../handler/HomekitBridgeHandler.java | 4 +- .../internal/session/HttpPayloadParser.java | 31 ++++++++ .../internal/session/SecureSession.java | 14 +++- .../internal/transport/IpTransport.java | 71 ++++++++----------- .../internal/TestHttpPayloadParser.java | 51 +++++++++++++ 10 files changed, 188 insertions(+), 62 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/{HomekitDeviceHandler.java => HomekitAccessoryHandler.java} (86%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/{HomekitBaseServerHandler.java => HomekitBaseAccessoryHandler.java} (98%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 09b65b8ea1134..9389a841a8af7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -65,6 +65,7 @@ public class HomekitBindingConstants { public static final String PROPERTY_UNIT = "unit"; public static final String PROPERTY_PERMS = "perms"; public static final String PROPERTY_EV = "ev"; + public static final String PROPERTY_BOOLEAN_DATA_TYPE = "booleanDataType"; // HomeKit HTTP URI endpoints and content types public static final String ENDPOINT_ACCESSORIES = "/accessories"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 61aa1cc5ca552..8704c896b1d43 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -37,6 +37,7 @@ import org.openhab.core.thing.type.StateChannelTypeBuilder; import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; /** * HomeKit characteristic DTO. @@ -102,6 +103,18 @@ public class Characteristic { default -> unit; // may be null or a custom unit }; + String booleanDataType = null; + if ("bool".equals(format) && value != null && value.isJsonPrimitive()) { + // some characteristics have "bool" with non-boolean value types e.g. numbers 0,1 or strings "true","false" + JsonPrimitive prim = value.getAsJsonPrimitive(); + if (prim.isNumber()) { + booleanDataType = "number"; + } + if (prim.isString()) { + booleanDataType = "string"; + } + } + String itemType = null; String category = null; String numberSuffix = null; @@ -724,6 +737,7 @@ public class Characteristic { Optional.ofNullable(uom).ifPresent(s -> properties.put(PROPERTY_UNIT, s)); Optional.ofNullable(perms).map(l -> String.join(",", l)).ifPresent(s -> properties.put(PROPERTY_PERMS, s)); Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> properties.put(PROPERTY_EV, s)); + Optional.ofNullable(booleanDataType).ifPresent(s -> properties.put(PROPERTY_BOOLEAN_DATA_TYPE, s)); // return the definition of a specific _instance_ of the channel _type_ return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), channelTypeUid) @@ -740,7 +754,9 @@ private String getChannelInstanceLabel() { public @Nullable CharacteristicType getCharacteristicType() { try { - return CharacteristicType.from(Integer.parseInt(type, 16)); + // convert "00000113-0000-1000-8000-0026BB765291" to "00000113" + String firstPart = type.split("-")[0]; + return CharacteristicType.from(Integer.parseInt(firstPart, 16)); } catch (IllegalArgumentException e) { return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index b88f119823317..fde936f35e044 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -20,8 +20,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; +import org.openhab.binding.homekit.internal.handler.HomekitAccessoryHandler; import org.openhab.binding.homekit.internal.handler.HomekitBridgeHandler; -import org.openhab.binding.homekit.internal.handler.HomekitDeviceHandler; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.thing.Bridge; @@ -83,7 +83,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { return new HomekitBridgeHandler((Bridge) thing, typeProvider, registerDiscoveryService()); } else if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { - return new HomekitDeviceHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry); + return new HomekitAccessoryHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry); } return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java similarity index 86% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 0c3df7199d6c0..bda8a72ec8c78 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitDeviceHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -72,18 +72,18 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomekitDeviceHandler extends HomekitBaseServerHandler { +public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { private static final int INITIAL_DELAY_SECONDS = 2; - private final Logger logger = LoggerFactory.getLogger(HomekitDeviceHandler.class); + private final Logger logger = LoggerFactory.getLogger(HomekitAccessoryHandler.class); private final ChannelTypeRegistry channelTypeRegistry; private final ChannelGroupTypeRegistry channelGroupTypeRegistry; private @Nullable ScheduledFuture refreshTask; - public HomekitDeviceHandler(Thing thing, HomekitTypeProvider typeProvider, ChannelTypeRegistry channelTypeRegistry, - ChannelGroupTypeRegistry channelGroupTypeRegistry) { + public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, + ChannelTypeRegistry channelTypeRegistry, ChannelGroupTypeRegistry channelGroupTypeRegistry) { super(thing, typeProvider); this.channelTypeRegistry = channelTypeRegistry; this.channelGroupTypeRegistry = channelGroupTypeRegistry; @@ -141,7 +141,7 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { if (object instanceof QuantityType quantity) { if (channel.getProperties().get(PROPERTY_UNIT) instanceof String unit) { try { - QuantityType temp = quantity.toUnit(unit); + QuantityType temp = quantity.toInvertibleUnit(unit); object = temp != null ? temp : quantity; } catch (MeasurementParseException e) { logger.warn("Unexpected unit {} for channel {}", unit, channel.getUID()); @@ -194,6 +194,15 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { object = dateTime.toFullString(); } + // comply with the characteristic's boolean data type + if (object instanceof Boolean bool + && channel.getProperties().get(PROPERTY_BOOLEAN_DATA_TYPE) instanceof String booleanDataType) { + switch (booleanDataType) { + case "number" -> object = Integer.valueOf(bool ? 1 : 0); + case "string" -> object = bool ? "true" : "false"; + } + } + return object instanceof Number num ? new JsonPrimitive(num) : object instanceof Boolean bool ? new JsonPrimitive(bool) : new JsonPrimitive(object.toString()); } @@ -286,27 +295,50 @@ private void createChannels() { List channels = new ArrayList<>(); Map properties = new HashMap<>(thing.getProperties()); // keep existing properties accessory.buildAndRegisterChannelGroupDefinitions(typeProvider).forEach(groupDef -> { + logger.trace("+ChannelGroupDefinition id:{}, typeUID:{}, label:{}, description:{}", groupDef.getId(), + groupDef.getTypeUID(), groupDef.getLabel(), groupDef.getDescription()); + ChannelGroupType channelGroupType = channelGroupTypeRegistry.getChannelGroupType(groupDef.getTypeUID()); if (channelGroupType != null) { + logger.trace("++ChannelGroupType UID:{}, label:{}, category:{}, description:{}", + channelGroupType.getUID(), channelGroupType.getLabel(), channelGroupType.getCategory(), + channelGroupType.getDescription()); + channelGroupType.getChannelDefinitions().forEach(chanDef -> { + logger.trace( + "+++ChannelDefinition id:{}, label:{}, description:{}, channelTypeUID:{}, autoUpdatePolicy:{}, properties:{}", + chanDef.getId(), chanDef.getLabel(), chanDef.getDescription(), chanDef.getChannelTypeUID(), + chanDef.getAutoUpdatePolicy(), chanDef.getProperties()); + if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(chanDef.getChannelTypeUID())) { String name = chanDef.getId(); String value = chanDef.getLabel(); if (value != null) { properties.put(name, value); - logger.trace("Built property {}={} for thing {}", name, value, thing.getUID()); + logger.trace("++++Property '{}:{}'", name, value); } } else { ChannelType channelType = channelTypeRegistry.getChannelType(chanDef.getChannelTypeUID()); if (channelType != null) { + logger.trace( + "++++ChannelType category:{}, description:{}, itemType:{}, label:{}, autoUpdatePolicy:{}, itemType:{}, kind:{}, tags:{}, uid:{}, unitHint:{}", + channelType.getCategory(), channelType.getDescription(), channelType.getItemType(), + channelType.getLabel(), channelType.getAutoUpdatePolicy(), + channelType.getItemType(), channelType.getKind(), channelType.getTags(), + channelType.getUID(), channelType.getUnitHint()); + ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), chanDef.getId()); ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()) .withProperties(chanDef.getProperties()); Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); Optional.ofNullable(chanDef.getDescription()).ifPresent(builder::withDescription); - channels.add(builder.build()); - logger.trace("Built channel {} of type {} for thing {}", channelUID, channelType.getUID(), - thing.getUID()); + Channel channel = builder.build(); + channels.add(channel); + + logger.trace( + "+++++Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", + channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), + channel.getKind(), channel.getLabel(), channel.getProperties(), channel.getUID()); } } }); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java similarity index 98% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 338b9c9b7e28d..84fe30317a789 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseServerHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -60,7 +60,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public abstract class HomekitBaseServerHandler extends BaseThingHandler { +public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler { protected static final Gson GSON = new Gson(); @@ -71,7 +71,7 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected static final Pattern HOST_PATTERN = Pattern.compile( "^(((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)):(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]?\\d{1,4})$"); - private final Logger logger = LoggerFactory.getLogger(HomekitBaseServerHandler.class); + private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); protected final Map accessories = new HashMap<>(); protected final HomekitTypeProvider typeProvider; @@ -87,7 +87,7 @@ public abstract class HomekitBaseServerHandler extends BaseThingHandler { protected @Nullable Ed25519PrivateKeyParameters controllerLongTermSecretKey = null; protected @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; - public HomekitBaseServerHandler(Thing thing, HomekitTypeProvider typeProvider) { + public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider) { super(thing); this.typeProvider = typeProvider; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index dae1cbe051434..52164da4875d3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -38,12 +38,12 @@ * It uses the /accessories endpoint to discover embedded accessories and their services. * It notifies the {@link HomekitChildDiscoveryService} when accessories are discovered. * It does not currently handle commands for channels, that is left to the child accessory handlers. - * It extends {@link HomekitBaseServerHandler} to handle pairing and secure session setup. + * It extends {@link HomekitBaseAccessoryHandler} to handle pairing and secure session setup. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HomekitBridgeHandler extends HomekitBaseServerHandler implements BridgeHandler { +public class HomekitBridgeHandler extends HomekitBaseAccessoryHandler implements BridgeHandler { private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); private final HomekitChildDiscoveryService discoveryService; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java index 0bea7dabf8f17..f5ab385adfba8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java @@ -37,6 +37,7 @@ public class HttpPayloadParser { private static final int MAX_HEADER_BLOCK_SIZE = 2048; private static final Pattern CONTENT_LENGTH_PATTERN = Pattern.compile("(?i)^content-length:\\s*(\\d+)$"); private static final Pattern CHUNKED_ENCODING_PATTERN = Pattern.compile("(?i)^transfer-encoding:\\s*chunked$"); + private static final Pattern STATUS_LINE_PATTERN = Pattern.compile("HTTP/\\d\\.\\d\\s+(\\d{3})"); private final ByteArrayOutputStream headerBuffer = new ByteArrayOutputStream(); private final ByteArrayOutputStream contentBuffer = new ByteArrayOutputStream(); @@ -136,9 +137,39 @@ public boolean isComplete() { if (contentLength >= 0) { return contentBuffer.size() >= contentLength; } + // no content-length and not chunked: check status code + int statusCode = getHttpStatusCode(headerBuffer.toByteArray()); + if (statusCode == 204 || (statusCode >= 100 && statusCode < 200)) { + return true; // no-body responses + } + if (statusCode >= 400 && statusCode < 600) { + return true; // treat error responses as complete even without body + } return false; } + /** + * Extracts the HTTP status code from the given header byte array. + * It looks for the status line in the format "HTTP/x.x xxx" and parses the three-digit status code. + * If no valid status line is found or if the status code is malformed, a SecurityException is thrown. + * + * @param headerBytes the byte array containing HTTP headers. + * @return the extracted HTTP status code as an integer. + * @throws SecurityException if no valid status line is found or if the status code is malformed. + */ + public static int getHttpStatusCode(byte[] headerBytes) { + String headers = new String(headerBytes, StandardCharsets.ISO_8859_1); + Matcher matcher = STATUS_LINE_PATTERN.matcher(headers); + if (matcher.find()) { + try { + return Integer.parseInt(matcher.group(1)); + } catch (NumberFormatException e) { + throw new SecurityException("Malformed HTTP status code: " + matcher.group(1)); + } + } + throw new SecurityException("Missing HTTP status line"); + } + /** * Parses chunked transfer encoding from the given byte array and appends the decoded content data * to the contentBuffer. It handles chunk size lines, chunk data, and the final zero-length chunk. diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index fa976f2f800df..a39aea667883d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -15,6 +15,7 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -88,16 +89,23 @@ private void sendFrame(ByteArrayInputStream plainTextStream) throws Exception { * plaintext until it detects the end of the HTTP message. The end of the message is determined by checking * for the presence of complete HTTP headers and a completed Content-Length, or a complete chunked payload. * - * @return a 2D byte array where the first element is the HTTP headers and the second element is the content. + * @param trace if true, captures the raw decrypted frames for debugging purposes. + * @return a 3D byte array where the first element is the HTTP headers, the second element is the content, + * and the third is the raw trace (if enabled). * @throws Exception if an error occurs during reading or decryption. */ - public byte[][] receive() throws Exception { + public byte[][] receive(boolean trace) throws Exception { HttpPayloadParser httpParser = new HttpPayloadParser(); + ByteArrayOutputStream raw = trace ? new ByteArrayOutputStream() : null; do { byte[] frame = receiveFrame(); + if (raw != null) { + raw.write(frame); + } httpParser.accept(frame); } while (!httpParser.isComplete()); - return new byte[][] { httpParser.getHeaders(), httpParser.getContent() }; + return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), + raw != null ? raw.toByteArray() : new byte[0] }; } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 08ffeb54599ed..3cbab53d2ed37 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -12,7 +12,6 @@ */ package org.openhab.binding.homekit.internal.transport; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -44,7 +43,7 @@ @NonNullByDefault public class IpTransport implements AutoCloseable { - private static final int CONNECT_TIMEOUT = Duration.ofSeconds(5).toMillisPart(); + private static final int SOCKET_TIMEOUT = Duration.ofSeconds(5).toMillisPart(); // milliseconds private final Logger logger = LoggerFactory.getLogger(IpTransport.class); @@ -55,6 +54,8 @@ public class IpTransport implements AutoCloseable { /** * Creates a new IpTransport instance with the given socket and session keys. + * + * @param host the IP address and port of the HomeKit accessory */ public IpTransport(String host) throws Exception { logger.debug("Connecting to {}", host); @@ -69,7 +70,8 @@ public IpTransport(String host) throws Exception { String ipAddress = parts[0]; int port = Integer.parseInt(parts[1]); socket = new Socket(); - socket.connect(new InetSocketAddress(ipAddress, port), CONNECT_TIMEOUT); + socket.connect(new InetSocketAddress(ipAddress, port), SOCKET_TIMEOUT); // connect timeout + socket.setSoTimeout(SOCKET_TIMEOUT); // read timeout socket.setKeepAlive(false); // HAP spec forbids TCP keepalive logger.debug("Connected to {}", host); } @@ -97,25 +99,33 @@ private synchronized byte[] execute(String method, String endpoint, String conte throws IOException, InterruptedException, TimeoutException, ExecutionException { try { byte[] request = buildRequest(method, endpoint, contentType, content); - logger.trace("Request:\n{}", new String(request, StandardCharsets.ISO_8859_1)); - byte[][] response; + boolean trace = logger.isTraceEnabled(); + if (trace) { + logger.trace("Request:\n{}", new String(request, StandardCharsets.ISO_8859_1)); + } + + byte[][] response; // 0 = headers, 1 = content, 2 = raw trace (if enabled) SecureSession secureSession = this.secureSession; if (secureSession != null) { secureSession.send(request); - response = secureSession.receive(); + response = secureSession.receive(trace); } else { OutputStream out = socket.getOutputStream(); InputStream in = socket.getInputStream(); out.write(request); out.flush(); - response = readPlainResponse(in); + response = readPlainResponse(in, trace); + } + + if (response.length != 3) { + throw new IOException("Response must contain 3 arrays"); } - if (logger.isTraceEnabled()) { - logger.trace("Response:\n{}{}", new String(response[0], StandardCharsets.ISO_8859_1), - new String(response[1], StandardCharsets.ISO_8859_1)); + if (trace) { + logger.trace("Response:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); } + checkHeaders(response[0]); return response[1]; } catch (IOException | InterruptedException | TimeoutException e) { @@ -166,11 +176,14 @@ private boolean contentIsEmpty(String method) { /* * Reads a plain (non-secure) HTTP response from the input stream. * - * @return a 2D byte array where the first element is the HTTP headers and the second element is the content. + * @param trace if true, captures the raw data for debugging purposes. + * + * @return a 3D byte array where the first element is the HTTP headers, the second element is the content, + * and the third is the raw trace (if enabled). * * @throws IOException if an I/O error occurs or if the response is invalid. */ - private byte[][] readPlainResponse(InputStream in) throws IOException { + private byte[][] readPlainResponse(InputStream in, boolean trace) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buf = new byte[4096]; int read; @@ -190,7 +203,7 @@ private byte[][] readPlainResponse(InputStream in) throws IOException { byte[] content = new byte[data.length - headersEnd]; System.arraycopy(data, 0, headers, 0, headers.length); System.arraycopy(data, headersEnd, content, 0, content.length); - return new byte[][] { headers, content }; + return new byte[][] { headers, content, trace ? data : new byte[0] }; } /** @@ -199,36 +212,10 @@ private byte[][] readPlainResponse(InputStream in) throws IOException { * @throws IOException if the response indicates an error. */ private void checkHeaders(byte[] headers) throws IOException { - ByteArrayInputStream in = new ByteArrayInputStream(headers); - String statusLine = readLine(in); - String[] parts = statusLine.split(" ", 3); - if (parts.length < 3) { - throw new IOException("Invalid HTTP response: " + statusLine); - } - int status; - try { - status = Integer.parseInt(parts[1]); - } catch (NumberFormatException e) { - throw new IOException("Invalid HTTP response: " + statusLine); - } - if (status >= 300) { - throw new IOException("HTTP " + status); - } - } - - private static String readLine(ByteArrayInputStream in) throws IOException { - StringBuilder sb = new StringBuilder(); - int ch; - while ((ch = in.read()) >= 0) { - if (ch == '\r') { - continue; - } - if (ch == '\n') { - break; - } - sb.append((char) ch); + int httpStatusCode = HttpPayloadParser.getHttpStatusCode(headers); + if (httpStatusCode >= 300) { + throw new IOException("HTTP " + httpStatusCode); } - return sb.toString(); } @Override diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java index e9ab92e7236ef..07cb515f40d12 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java @@ -33,6 +33,11 @@ class TestHttpPayloadParser { private static final String HEADERS_Z2 = "\n"; private static final String HEADERS_Z = HEADERS_Z1 + HEADERS_Z2; + private static final String OK_204 = "HTTP/1.1 204 No Content\r\nDate: Tue, 07 Oct 2025 14:00:00 GMT\r\nConnection: close\r\n\r\n"; + private static final String ERROR_403 = "HTTP/1.1 403 Forbidden\r\nTransfer-Encoding: chunked\r\n\r\n"; + private static final String ERROR_404 = "HTTP/1.1 404 Not Found\r\nDate: Tue, 07 Oct 2025 14:00:00 GMT\r\nConnection: close\r\n\r\n"; + private static final String ERROR_500 = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + private static final String CONTENT = "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"; private static final String CHUNK_1 = "%x\r"; private static final String CHUNK_2 = "\n"; @@ -214,4 +219,50 @@ void testHttpWithZeroContentLength() { byte[] headers = parser.getHeaders(); assertEquals(h, new String(headers)); } + + @Test + void testOk204() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(OK_204.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(OK_204, new String(headers)); + } + + @Test + void testError403() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(ERROR_403.getBytes()); + parser.accept(CHUNK.formatted(0).getBytes()); + parser.accept(CRLF.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(ERROR_403, new String(headers)); + } + + @Test + void testError404() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(ERROR_404.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(ERROR_404, new String(headers)); + } + + @Test + void testError500() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(ERROR_500.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(ERROR_500, new String(headers)); + } } From 4931ba16431f4f8172e8c065450c8c328f8a1c24 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 7 Oct 2025 19:13:51 +0100 Subject: [PATCH 054/177] remove useless prefixes Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/HomekitBindingConstants.java | 2 -- .../openhab/binding/homekit/internal/dto/Characteristic.java | 2 +- .../org/openhab/binding/homekit/internal/dto/Service.java | 2 +- .../openhab/binding/homekit/internal/TestChannelCreation.java | 4 ++-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 9389a841a8af7..6e9c4be45a4c4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -41,8 +41,6 @@ public class HomekitBindingConstants { // labels public static final String THING_LABEL_FMT = "%s on %s"; - public static final String CHANNEL_GROUP_TYPE_LABEL_FMT = "Channel group type: %s"; - public static final String CHANNEL_TYPE_LABEL_FMT = "Channel type: %s"; // configuration parameters public static final String CONFIG_HOST = "host"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 8704c896b1d43..0c3a9094260b7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -692,7 +692,7 @@ public class Characteristic { ChannelTypeUID channelTypeUid = new ChannelTypeUID(BINDING_ID, CHANNEL_TYPE_ID_FMT.formatted(characteristicType.getOpenhabType())); - String channelTypeLabel = CHANNEL_TYPE_LABEL_FMT.formatted(characteristicType.toString()); + String channelTypeLabel = characteristicType.toString(); ChannelType channelType; if (isStateChannel) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index 3539fccdd7b2f..83ff46e92ff8d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -70,7 +70,7 @@ public class Service { ChannelGroupTypeUID channelGroupTypeUID = new ChannelGroupTypeUID(BINDING_ID, CHANNEL_GROUP_TYPE_ID_FMT.formatted(serviceType.getOpenhabType())); - String channelGroupTypeLabel = CHANNEL_GROUP_TYPE_LABEL_FMT.formatted(serviceType.toString()); + String channelGroupTypeLabel = serviceType.toString(); ChannelGroupType channelGroupType = ChannelGroupTypeBuilder.instance(channelGroupTypeUID, channelGroupTypeLabel) // .withChannelDefinitions(channelDefinitions) // diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java index a76308eaecad1..a9b74ce2aaa3f 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -404,7 +404,7 @@ void testChannelDefinitions() { ChannelGroupType channelGroupType = channelGroupTypes.stream() .filter(cgt -> "channel-group-type-lightbulb".equals(cgt.getUID().getId())).findFirst().orElse(null); assertNotNull(channelGroupType); - assertEquals("Channel group type: Light Bulb", channelGroupType.getLabel()); + assertEquals("Light Bulb", channelGroupType.getLabel()); assertEquals("channel-group-type-lightbulb", channelGroupType.getUID().getId()); // There should be two channel definitions for the Light Bulb service: On and Brightness @@ -431,7 +431,7 @@ void testChannelDefinitions() { .orElse(null); assertNotNull(channelType); assertEquals("channel-type-brightness", channelType.getUID().getId()); - assertEquals("Channel type: Brightness", channelType.getLabel()); + assertEquals("Brightness", channelType.getLabel()); assertEquals("Dimmer", channelType.getItemType()); assertEquals("light", channelType.getCategory()); assertTrue(channelType.getTags().contains("Control")); From d8fe07c776a1296ec9f5af98013c2cb17860805e Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 8 Oct 2025 15:20:38 +0100 Subject: [PATCH 055/177] work in progress - migrate properties to channel type state description - fix socket timeout handling Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 17 +- .../homekit/internal/dto/Accessory.java | 7 +- .../homekit/internal/dto/Characteristic.java | 158 +++++++++++++----- .../binding/homekit/internal/dto/Service.java | 8 +- .../handler/HomekitAccessoryHandler.java | 67 +++++--- .../handler/HomekitBridgeHandler.java | 3 +- .../internal/transport/IpTransport.java | 29 +++- .../homekit/internal/TestChannelCreation.java | 26 ++- 8 files changed, 221 insertions(+), 94 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 6e9c4be45a4c4..afea3cfbc1718 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -37,7 +37,7 @@ public class HomekitBindingConstants { // prefixes for channel-group-type and channel-type UIDs public static final String CHANNEL_GROUP_TYPE_ID_FMT = "channel-group-type-%s"; - public static final String CHANNEL_TYPE_ID_FMT = "channel-type-%s"; + public static final String CHANNEL_TYPE_ID_FMT = "channel-type-%s-"; // labels public static final String THING_LABEL_FMT = "%s on %s"; @@ -56,14 +56,15 @@ public class HomekitBindingConstants { // channel properties public static final String PROPERTY_IID = "iid"; - public static final String PROPERTY_MIN_VALUE = "minValue"; - public static final String PROPERTY_MAX_VALUE = "maxValue"; - public static final String PROPERTY_MIN_STEP = "minStep"; public static final String PROPERTY_FORMAT = "format"; - public static final String PROPERTY_UNIT = "unit"; - public static final String PROPERTY_PERMS = "perms"; - public static final String PROPERTY_EV = "ev"; - public static final String PROPERTY_BOOLEAN_DATA_TYPE = "booleanDataType"; + public static final String PROPERTY_BOOL_TYPE = "boolType"; + + // public static final String PROPERTY_MIN_VALUE = "minValue"; + // public static final String PROPERTY_MAX_VALUE = "maxValue"; + // public static final String PROPERTY_MIN_STEP = "minStep"; + // public static final String PROPERTY_UNIT = "unit"; + // public static final String PROPERTY_PERMS = "perms"; + // public static final String PROPERTY_EV = "ev"; // HomeKit HTTP URI endpoints and content types public static final String ENDPOINT_ACCESSORIES = "/accessories"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 69ab1009d4861..59582e21f40aa 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -23,6 +23,7 @@ import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment; +import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.type.ChannelGroupDefinition; import com.google.gson.JsonElement; @@ -53,11 +54,13 @@ public class Accessory { * Child services that do not map to a channel group definition are ignored. * Grandchild categories that do not map to a channel definition are ignored. * + * @param thingUID the ThingUID to associate the ChannelGroupDefinitions with * @param typeProvider the HomeKit type provider used to look up channel group definitions. * @return a list of channel group definition instances for the services of this accessory. */ - public List buildAndRegisterChannelGroupDefinitions(HomekitTypeProvider typeProvider) { - return services.stream().map(s -> s.buildAndRegisterChannelGroupDefinition(typeProvider)) + public List buildAndRegisterChannelGroupDefinitions(ThingUID thingUID, + HomekitTypeProvider typeProvider) { + return services.stream().map(s -> s.buildAndRegisterChannelGroupDefinition(thingUID, typeProvider)) .filter(Objects::nonNull).toList(); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 0c3a9094260b7..5bf8f78a29bff 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -14,6 +14,8 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.math.BigDecimal; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -29,15 +31,19 @@ import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Point; import org.openhab.core.semantics.model.DefaultSemanticTags.Property; +import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelDefinitionBuilder; -import org.openhab.core.thing.type.ChannelType; import org.openhab.core.thing.type.ChannelTypeBuilder; import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.thing.type.StateChannelTypeBuilder; +import org.openhab.core.types.StateDescriptionFragment; +import org.openhab.core.types.StateDescriptionFragmentBuilder; +import org.openhab.core.types.StateOption; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import com.google.gson.annotations.SerializedName; /** * HomeKit characteristic DTO. @@ -61,6 +67,8 @@ public class Characteristic { public @NonNullByDefault({}) String description; public @NonNullByDefault({}) Boolean ev; // e.g. true public @NonNullByDefault({}) Integer aid; // e.g. 10 + public @NonNullByDefault({}) @SerializedName("valid-values") List validValues; + public @NonNullByDefault({}) @SerializedName("valid-values-range") List validValuesRange; /** * Builds a ChannelType and a ChannelDefinition based on the characteristic properties. @@ -70,10 +78,12 @@ public class Characteristic { * Examines characteristic type, data format, permissions, and other properties * to determine appropriate channel type, item type, tags, category, and attributes. * + * @param thingUID the ThingUID to associate the ChannelDefinition with * @param typeProvider the HomekitTypeProvider to register the channel type with * @return the ChannelDefinition or null if it cannot be mapped */ - public @Nullable ChannelDefinition buildAndRegisterChannelDefinition(HomekitTypeProvider typeProvider) { + public @Nullable ChannelDefinition buildAndRegisterChannelDefinition(ThingUID thingUID, + HomekitTypeProvider typeProvider) { CharacteristicType characteristicType = getCharacteristicType(); if (characteristicType == null) { return null; @@ -91,6 +101,7 @@ public class Characteristic { boolean isString = DataFormatType.STRING == dataFormatType; boolean isBoolean = DataFormatType.BOOL == dataFormatType; boolean isNumber = !isString && !isBoolean; + boolean isNumberWithSuffix = false; boolean isStateChannel = true; boolean isPercentage = "percentage".equals(unit); @@ -103,15 +114,15 @@ public class Characteristic { default -> unit; // may be null or a custom unit }; - String booleanDataType = null; + String boolType = null; if ("bool".equals(format) && value != null && value.isJsonPrimitive()) { // some characteristics have "bool" with non-boolean value types e.g. numbers 0,1 or strings "true","false" JsonPrimitive prim = value.getAsJsonPrimitive(); if (prim.isNumber()) { - booleanDataType = "number"; + boolType = "number"; } if (prim.isString()) { - booleanDataType = "string"; + boolType = "string"; } } @@ -235,7 +246,7 @@ public class Characteristic { break; case COLOR_TEMPERATURE: - uom = "Mirek"; + uom = "mired"; numberSuffix = "Temperature"; propertyTag = Property.COLOR_TEMPERATURE; category = "light"; @@ -683,65 +694,124 @@ public class Characteristic { if (CoreItemFactory.NUMBER.equals(itemType) && numberSuffix != null) { itemType = itemType + ":" + numberSuffix; + isNumberWithSuffix = true; } /* - * NOTE: different accessories may have the same characteristicType, but their other - * properties e.g. min, max, step, unit may be different + * ================ CREATE FAKE PROPERTY CHANNEL ================= + * + * create and return fake property channel for characteristics that + * are not mapped to a real channel + * */ - ChannelTypeUID channelTypeUid = new ChannelTypeUID(BINDING_ID, - CHANNEL_TYPE_ID_FMT.formatted(characteristicType.getOpenhabType())); + if (FAKE_PROPERTY_CHANNEL.equals(itemType)) { + if (value != null && value.isJsonPrimitive()) { + // create fake property channels for characteristics that contain only static information + return new ChannelDefinitionBuilder(characteristicType.toCamelCase(), FAKE_PROPERTY_CHANNEL_TYPE_UID) + .withLabel(value.getAsString()).build(); + } + return null; + } + /* + * ================ CREATE AND PERSIST THE CHANNEL TYPE ================= + * + * NOTE: different accessories may have the same characteristicType, but + * their other properties e.g. min, max, step, unit may be different, so + * we create and persist a unique channel type ID for each characteristic + * instance + */ + String channelTypeId = CHANNEL_TYPE_ID_FMT.formatted(characteristicType.getOpenhabType()); + if (thingUID.getBridgeIds().isEmpty()) { + channelTypeId += thingUID.getId(); + } else { + channelTypeId += thingUID.getBridgeIds().getFirst() + "-" + thingUID.getId(); + } + ChannelTypeUID channelTypeUid = new ChannelTypeUID(BINDING_ID, channelTypeId); String channelTypeLabel = characteristicType.toString(); - ChannelType channelType; - if (isStateChannel) { + if (!isStateChannel) { + typeProvider.putChannelType(ChannelTypeBuilder.trigger(channelTypeUid, channelTypeLabel).build()); + + } else { if (itemType == null) { return null; } - if (FAKE_PROPERTY_CHANNEL.equals(itemType)) { - if (value != null && value.isJsonPrimitive()) { - // create fake property channels for characteristics that contain only static information - return new ChannelDefinitionBuilder(characteristicType.toCamelCase(), - FAKE_PROPERTY_CHANNEL_TYPE_UID).withLabel(value.getAsString()).build(); + + // build state description fragment + StateDescriptionFragmentBuilder fragBldr = StateDescriptionFragmentBuilder.create() + .withReadOnly(isReadOnly); + if (isNumber) { + Optional.ofNullable(minValue).map(v -> BigDecimal.valueOf(v)).ifPresent(b -> fragBldr.withMinimum(b)); + Optional.ofNullable(maxValue).map(v -> BigDecimal.valueOf(v)).ifPresent(b -> fragBldr.withMaximum(b)); + Optional.ofNullable(minStep).map(v -> BigDecimal.valueOf(v)).ifPresent(b -> fragBldr.withStep(b)); + + if (isPercentage) { + fragBldr.withPattern("%.0f %%"); + } else if (uom != null) { + fragBldr.withPattern("%.1f " + uom); + } + + if (validValues != null && !validValues.isEmpty()) { + List options = validValues.stream().map(v -> v.toString()) + .map(s -> new StateOption(s, s)).toList(); + fragBldr.withOptions(options); + } else + // + if (validValuesRange != null && validValuesRange.size() == 2) { + int min = validValuesRange.stream().mapToInt(Integer::intValue).min().orElse(0); // size check above + int max = validValuesRange.stream().mapToInt(Integer::intValue).max().orElse(0); // ditto + int step = minStep != null ? minStep.intValue() : 1; + List options = new ArrayList<>(); + for (int i = min; i <= max; i += step) { + String s = Integer.toString(i); + options.add(new StateOption(s, s)); + } + fragBldr.withOptions(options); } - return null; } - StateChannelTypeBuilder builder = ChannelTypeBuilder.state(channelTypeUid, channelTypeLabel, itemType); - Optional.ofNullable(category).ifPresent(builder::withCategory); + StateDescriptionFragment stateDescriptionFragment = fragBldr.build(); + + // build channel type + StateChannelTypeBuilder chanTypBldr = ChannelTypeBuilder.state(channelTypeUid, channelTypeLabel, itemType) + .withStateDescriptionFragment(stateDescriptionFragment); + Optional.ofNullable(category).ifPresent(c -> chanTypBldr.withCategory(c)); + if (isNumberWithSuffix && uom != null) { + chanTypBldr.withUnitHint(uom); + } if (pointTag != null) { if (propertyTag != null) { - builder.withTags(pointTag, propertyTag); + chanTypBldr.withTags(pointTag, propertyTag); } else { - builder.withTags(pointTag); + chanTypBldr.withTags(pointTag); } } - channelType = builder.build(); - } else { - channelType = ChannelTypeBuilder.trigger(channelTypeUid, channelTypeLabel).build(); - } - // persist the channel _type_ - typeProvider.putChannelType(channelType); + // persist the (state) channel TYPE + typeProvider.putChannelType(chanTypBldr.build()); + } /* - * expose the non ephemeral fields, that are not exposed via normal channel definition attributes, - * through properties instead e.g. minValue, maxValue, minStep, format, unit, perms, ev + * ================ CREATE AND RETURN CHANNEL DEFINITION ================= + * + * The channel definition contains additional information beyond the what is + * in the channel type e.g. channel id, label, iid, format, boolType, etc. + * so we create and return a channel definition containing this information. */ - Map properties = new HashMap<>(); - Optional.ofNullable(iid).map(v -> v.toString()).ifPresent(s -> properties.put(PROPERTY_IID, s)); - Optional.ofNullable(minValue).map(v -> v.toString()).ifPresent(s -> properties.put(PROPERTY_MIN_VALUE, s)); - Optional.ofNullable(maxValue).map(v -> v.toString()).ifPresent(s -> properties.put(PROPERTY_MAX_VALUE, s)); - Optional.ofNullable(minStep).map(v -> v.toString()).ifPresent(s -> properties.put(PROPERTY_MIN_STEP, s)); - Optional.ofNullable(format).ifPresent(s -> properties.put(PROPERTY_FORMAT, s)); - Optional.ofNullable(uom).ifPresent(s -> properties.put(PROPERTY_UNIT, s)); - Optional.ofNullable(perms).map(l -> String.join(",", l)).ifPresent(s -> properties.put(PROPERTY_PERMS, s)); - Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> properties.put(PROPERTY_EV, s)); - Optional.ofNullable(booleanDataType).ifPresent(s -> properties.put(PROPERTY_BOOLEAN_DATA_TYPE, s)); - - // return the definition of a specific _instance_ of the channel _type_ - return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), channelTypeUid) - .withProperties(properties).withLabel(getChannelInstanceLabel()).build(); + Map props = new HashMap<>(); + Optional.ofNullable(iid).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_IID, s)); + Optional.ofNullable(format).ifPresent(s -> props.put(PROPERTY_FORMAT, s)); + Optional.ofNullable(boolType).ifPresent(s -> props.put(PROPERTY_BOOL_TYPE, s)); + + // Optional.ofNullable(minValue).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_MIN_VALUE, s)); + // Optional.ofNullable(maxValue).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_MAX_VALUE, s)); + // Optional.ofNullable(minStep).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_MIN_STEP, s)); + // Optional.ofNullable(uom).ifPresent(s -> props.put(PROPERTY_UNIT, s)); + // Optional.ofNullable(perms).map(l -> String.join(",", l)).ifPresent(s -> props.put(PROPERTY_PERMS, s)); + // Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> props.put(PROPERTY_EV, s)); + + return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), channelTypeUid).withProperties(props) + .withLabel(getChannelInstanceLabel()).build(); } /* diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index 83ff46e92ff8d..27e793feb63c9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -22,6 +22,7 @@ import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelGroupDefinition; import org.openhab.core.thing.type.ChannelGroupType; @@ -51,17 +52,20 @@ public class Service { * Returns a ChannelGroupDefinition that is specific instance of ChannelGroupType. * Returns null if the service type is unknown or if no valid channel definitions can be created. * + * @param thingUID the ThingUID to associate the ChannelGroupDefinition with * @param typeProvider the HomekitStorageBasedTypeProvider to register the channel group type with * @return the created ChannelGroupDefinition or null if creation failed */ - public @Nullable ChannelGroupDefinition buildAndRegisterChannelGroupDefinition(HomekitTypeProvider typeProvider) { + public @Nullable ChannelGroupDefinition buildAndRegisterChannelGroupDefinition(ThingUID thingUID, + HomekitTypeProvider typeProvider) { ServiceType serviceType = getServiceType(); if (serviceType == null || ServiceType.ACCESSORY_INFORMATION == serviceType) { return null; } List channelDefinitions = characteristics.stream() - .map(c -> c.buildAndRegisterChannelDefinition(typeProvider)).filter(Objects::nonNull).toList(); + .map(c -> c.buildAndRegisterChannelDefinition(thingUID, typeProvider)).filter(Objects::nonNull) + .toList(); if (channelDefinitions.isEmpty()) { return null; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index bda8a72ec8c78..24b0013cce1fb 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -14,6 +14,7 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -56,6 +57,8 @@ import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; import org.openhab.core.types.State; +import org.openhab.core.types.StateDescription; +import org.openhab.core.types.StateDescriptionFragment; import org.openhab.core.types.UnDefType; import org.openhab.core.types.util.UnitUtils; import org.slf4j.Logger; @@ -137,28 +140,29 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { logger.warn("HSBType command handling is not yet implemented for channel {}", channel.getUID()); } + StateDescription stateDescription = getStateDescription(channel); + // convert QuantityTypes to the characteristic's unit if (object instanceof QuantityType quantity) { - if (channel.getProperties().get(PROPERTY_UNIT) instanceof String unit) { + if (stateDescription != null + && UnitUtils.parseUnit(stateDescription.getPattern()) instanceof Unit channelUnit) { try { - QuantityType temp = quantity.toInvertibleUnit(unit); + QuantityType temp = quantity.toUnit(channelUnit); object = temp != null ? temp : quantity; } catch (MeasurementParseException e) { - logger.warn("Unexpected unit {} for channel {}", unit, channel.getUID()); + logger.warn("Unexpected unit {} for channel {}", channelUnit, channel.getUID()); } } } if (object instanceof Number number) { // clamp numbers to characteristic's min/max limits - Double min = Optional.ofNullable(channel.getProperties().get(PROPERTY_MIN_VALUE)) - .map(s -> Double.valueOf(s)).orElse(null); - if (min != null && number.doubleValue() < min.doubleValue()) { + if (stateDescription != null && stateDescription.getMinimum() instanceof BigDecimal min + && min.doubleValue() > number.doubleValue()) { object = min; } - Double max = Optional.ofNullable(channel.getProperties().get(PROPERTY_MAX_VALUE)) - .map(s -> Double.valueOf(s)).orElse(null); - if (max != null && number.doubleValue() > max.doubleValue()) { + if (stateDescription != null && stateDescription.getMaximum() instanceof BigDecimal max + && max.doubleValue() < number.doubleValue()) { object = max; } @@ -196,7 +200,7 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { // comply with the characteristic's boolean data type if (object instanceof Boolean bool - && channel.getProperties().get(PROPERTY_BOOLEAN_DATA_TYPE) instanceof String booleanDataType) { + && channel.getProperties().get(PROPERTY_BOOL_TYPE) instanceof String booleanDataType) { switch (booleanDataType) { case "number" -> object = Integer.valueOf(bool ? 1 : 0); case "string" -> object = bool ? "true" : "false"; @@ -250,14 +254,12 @@ private State convertJsonToState(JsonElement element, Channel channel) { case CoreItemFactory.NUMBER -> new DecimalType(value.getAsNumber()); default -> { if (acceptedItemType.startsWith(CoreItemFactory.NUMBER)) { - int index = acceptedItemType.indexOf(":"); - if (index > 0) { - String targetDimension = acceptedItemType.substring(index + 1); - Unit sourceUnit = UnitUtils.parseUnit( - Optional.ofNullable(channel.getProperties().get(PROPERTY_UNIT)).orElse(null)); - if (sourceUnit != null && targetDimension.equals(UnitUtils.getDimensionName(sourceUnit))) { - yield QuantityType.valueOf(value.getAsNumber().doubleValue(), sourceUnit); - } + String[] itemTypeParts = acceptedItemType.split(":"); + if (itemTypeParts.length > 1 + && getStateDescription(channel) instanceof StateDescriptionFragment stateDescription + && UnitUtils.parseUnit(stateDescription.getPattern()) instanceof Unit channelUnit + && itemTypeParts[1].equalsIgnoreCase(UnitUtils.getDimensionName(channelUnit))) { + yield QuantityType.valueOf(value.getAsNumber().doubleValue(), channelUnit); } yield new DecimalType(value.getAsNumber()); } @@ -294,12 +296,14 @@ private void createChannels() { // create the channels and properties List channels = new ArrayList<>(); Map properties = new HashMap<>(thing.getProperties()); // keep existing properties - accessory.buildAndRegisterChannelGroupDefinitions(typeProvider).forEach(groupDef -> { + accessory.buildAndRegisterChannelGroupDefinitions(thing.getUID(), typeProvider).forEach(groupDef -> { logger.trace("+ChannelGroupDefinition id:{}, typeUID:{}, label:{}, description:{}", groupDef.getId(), groupDef.getTypeUID(), groupDef.getLabel(), groupDef.getDescription()); ChannelGroupType channelGroupType = channelGroupTypeRegistry.getChannelGroupType(groupDef.getTypeUID()); - if (channelGroupType != null) { + if (channelGroupType == null) { + logger.warn("Fata Error: ChannelGroupType {} is not registered", groupDef.getTypeUID()); + } else { logger.trace("++ChannelGroupType UID:{}, label:{}, category:{}, description:{}", channelGroupType.getUID(), channelGroupType.getLabel(), channelGroupType.getCategory(), channelGroupType.getDescription()); @@ -311,6 +315,7 @@ private void createChannels() { chanDef.getAutoUpdatePolicy(), chanDef.getProperties()); if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(chanDef.getChannelTypeUID())) { + // this is a property, not a channel String name = chanDef.getId(); String value = chanDef.getLabel(); if (value != null) { @@ -318,8 +323,11 @@ private void createChannels() { logger.trace("++++Property '{}:{}'", name, value); } } else { + // this is a real channel ChannelType channelType = channelTypeRegistry.getChannelType(chanDef.getChannelTypeUID()); - if (channelType != null) { + if (channelType == null) { + logger.warn("Fatal Error: ChannelType {} is not registered", chanDef.getChannelTypeUID()); + } else { logger.trace( "++++ChannelType category:{}, description:{}, itemType:{}, label:{}, autoUpdatePolicy:{}, itemType:{}, kind:{}, tags:{}, uid:{}, unitHint:{}", channelType.getCategory(), channelType.getDescription(), channelType.getItemType(), @@ -459,8 +467,23 @@ private void refresh() { } } } catch (Exception e) { - logger.error("Failed to poll accessory state", e); + logger.warn("Failed to poll accessory state", e); } } } + + private @Nullable StateDescription getStateDescription(Channel channel) { + ChannelTypeUID uid = channel.getChannelTypeUID(); + ChannelType ct = channelTypeRegistry.getChannelType(uid); + if (ct == null) { + logger.warn("Channel {} is missing a channel type", uid); + return null; + } + StateDescription st = ct.getState(); + if (st == null) { + logger.warn("Channel {} of type {} is missing a state description", uid, ct.getUID()); + return null; + } + return st; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 52164da4875d3..15cfb86f48c14 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -126,7 +126,8 @@ private void createProperties() { for (Service service : accessory.services) { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { for (Characteristic characteristic : service.characteristics) { - ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(typeProvider); + ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thing.getUID(), + typeProvider); if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { String name = channelDef.getId(); String value = channelDef.getLabel(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 3cbab53d2ed37..109b3f6aa2456 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -21,6 +21,10 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -43,9 +47,10 @@ @NonNullByDefault public class IpTransport implements AutoCloseable { - private static final int SOCKET_TIMEOUT = Duration.ofSeconds(5).toMillisPart(); // milliseconds + private static final int TIMEOUT_MILLI_SECONDS = (int) Duration.ofSeconds(10).toMillis(); private final Logger logger = LoggerFactory.getLogger(IpTransport.class); + private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "homekit-io")); private final String host; // ip address with optional port e.g. "192.168.1.42:9123" private final Socket socket; @@ -70,8 +75,8 @@ public IpTransport(String host) throws Exception { String ipAddress = parts[0]; int port = Integer.parseInt(parts[1]); socket = new Socket(); - socket.connect(new InetSocketAddress(ipAddress, port), SOCKET_TIMEOUT); // connect timeout - socket.setSoTimeout(SOCKET_TIMEOUT); // read timeout + socket.connect(new InetSocketAddress(ipAddress, port), TIMEOUT_MILLI_SECONDS); // connect timeout + socket.setSoTimeout(TIMEOUT_MILLI_SECONDS); // read timeout socket.setKeepAlive(false); // HAP spec forbids TCP keepalive logger.debug("Connected to {}", host); } @@ -108,13 +113,25 @@ private synchronized byte[] execute(String method, String endpoint, String conte byte[][] response; // 0 = headers, 1 = content, 2 = raw trace (if enabled) SecureSession secureSession = this.secureSession; if (secureSession != null) { - secureSession.send(request); + Future<@Nullable Void> sendTask = executor.submit(() -> { + secureSession.send(request); + return null; + }); + // the Future.get() call applies a timeout to write operations + sendTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + // the socket applies its internal timeout to read operations response = secureSession.receive(trace); } else { OutputStream out = socket.getOutputStream(); InputStream in = socket.getInputStream(); - out.write(request); - out.flush(); + Future<@Nullable Void> sendTask = executor.submit(() -> { + out.write(request); + out.flush(); + return null; + }); + // the Future.get() call applies a timeout to write operations + sendTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + // the socket applies its internal timeout to read operations response = readPlainResponse(in, trace); } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java index a9b74ce2aaa3f..a8d25e4e89136 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java @@ -17,6 +17,7 @@ import static org.mockito.Mockito.*; import static org.openhab.binding.homekit.internal.HomekitBindingConstants.FAKE_PROPERTY_CHANNEL_TYPE_UID; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -30,10 +31,12 @@ import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelGroupDefinition; import org.openhab.core.thing.type.ChannelGroupType; import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.types.StateDescription; import com.google.gson.Gson; import com.google.gson.JsonElement; @@ -381,10 +384,11 @@ void testChannelDefinitions() { /* * Test the LED Light Bulb accessory #3 which has live data channels */ + ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory3"); Accessory accessory = accessories.getAccessory(3); assertNotNull(accessory); List channelGroupDefinitions = accessory - .buildAndRegisterChannelGroupDefinitions(typeProvider); + .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider); // There should be just one channel group definition for the Light Bulb service assertNotNull(channelGroupDefinitions); @@ -414,14 +418,9 @@ void testChannelDefinitions() { ChannelDefinition channelDefinition = channelGroupType.getChannelDefinitions().stream() .filter(cd -> "Brightness".equals(cd.getLabel())).findFirst().orElse(null); assertNotNull(channelDefinition); - assertEquals("channel-type-brightness", channelDefinition.getChannelTypeUID().getId()); + assertEquals("channel-type-brightness-bridge1-accessory3", channelDefinition.getChannelTypeUID().getId()); assertEquals("Brightness", channelDefinition.getLabel()); - assertEquals("%", channelDefinition.getProperties().get("unit")); assertEquals("int", channelDefinition.getProperties().get("format")); - assertEquals("20.0", channelDefinition.getProperties().get("minValue")); - assertEquals("100.0", channelDefinition.getProperties().get("maxValue")); - assertEquals("1.0", channelDefinition.getProperties().get("minStep")); - assertNotNull(channelDefinition.getProperties().get("perms")); // There should be two channel types for the Light Bulb service: On and Brightness assertEquals(2, channelTypes.size()); @@ -430,13 +429,21 @@ void testChannelDefinitions() { ChannelType channelType = channelTypes.stream().filter(ct -> "Dimmer".equals(ct.getItemType())).findFirst() .orElse(null); assertNotNull(channelType); - assertEquals("channel-type-brightness", channelType.getUID().getId()); + assertEquals("channel-type-brightness-bridge1-accessory3", channelType.getUID().getId()); assertEquals("Brightness", channelType.getLabel()); assertEquals("Dimmer", channelType.getItemType()); assertEquals("light", channelType.getCategory()); assertTrue(channelType.getTags().contains("Control")); assertTrue(channelType.getTags().contains("Brightness")); + StateDescription state = channelType.getState(); + assertNotNull(state); + assertEquals("%.0f %%", state.getPattern()); + assertFalse(state.isReadOnly()); + assertEquals(BigDecimal.valueOf(20.0), state.getMinimum()); + assertEquals(BigDecimal.valueOf(100.0), state.getMaximum()); + assertEquals(BigDecimal.valueOf(1.0), state.getStep()); + // get the accessory information for the bridge (accessory 1) and create properties from it accessory = accessories.getAccessory(1); assertNotNull(accessory); @@ -444,7 +451,8 @@ void testChannelDefinitions() { for (Service service : accessory.services) { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { for (Characteristic characteristic : service.characteristics) { - ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(typeProvider); + ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thingUID, + typeProvider); if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { String name = channelDef.getId(); String value = channelDef.getLabel(); From d2e5f2f7f87cf64d76f9a80a45e2deaaba9849e8 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 9 Oct 2025 11:13:25 +0100 Subject: [PATCH 056/177] implement json key store Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 2 - .../factory/HomekitHandlerFactory.java | 10 ++- .../handler/HomekitAccessoryHandler.java | 6 +- .../handler/HomekitBaseAccessoryHandler.java | 79 ++++++------------- .../handler/HomekitBridgeHandler.java | 5 +- .../internal/persistence/HomekitKeyStore.java | 75 ++++++++++++++++++ 6 files changed, 112 insertions(+), 65 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index afea3cfbc1718..73c45bf27d248 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -51,8 +51,6 @@ public class HomekitBindingConstants { public static final String PROPERTY_ACCESSORY_UID = "accessoryUID"; public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; - public static final String PROPERTY_CONTROLLER_PRIVATE_KEY = "controllerSecretKey"; - public static final String PROPERTY_ACCESSORY_PUBLIC_KEY = "accessoryPublicKey"; // channel properties public static final String PROPERTY_IID = "iid"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index fde936f35e044..75feda766e9b2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -22,6 +22,7 @@ import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; import org.openhab.binding.homekit.internal.handler.HomekitAccessoryHandler; import org.openhab.binding.homekit.internal.handler.HomekitBridgeHandler; +import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.thing.Bridge; @@ -53,6 +54,7 @@ public class HomekitHandlerFactory extends BaseThingHandlerFactory { private final HomekitTypeProvider typeProvider; private final ChannelTypeRegistry channelTypeRegistry; private final ChannelGroupTypeRegistry channelGroupTypeRegistry; + private final HomekitKeyStore keyStore; private @Nullable ServiceRegistration discoveryServiceRegistration; private @Nullable HomekitChildDiscoveryService discoveryService; @@ -60,10 +62,11 @@ public class HomekitHandlerFactory extends BaseThingHandlerFactory { @Activate public HomekitHandlerFactory(@Reference HomekitTypeProvider typeProvider, @Reference ChannelTypeRegistry channelTypeRegistry, - @Reference ChannelGroupTypeRegistry channelGroupTypeRegistry) { + @Reference ChannelGroupTypeRegistry channelGroupTypeRegistry, @Reference HomekitKeyStore keyStore) { this.typeProvider = typeProvider; this.channelTypeRegistry = channelTypeRegistry; this.channelGroupTypeRegistry = channelGroupTypeRegistry; + this.keyStore = keyStore; } @Override @@ -81,9 +84,10 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { - return new HomekitBridgeHandler((Bridge) thing, typeProvider, registerDiscoveryService()); + return new HomekitBridgeHandler((Bridge) thing, typeProvider, registerDiscoveryService(), keyStore); } else if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { - return new HomekitAccessoryHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry); + return new HomekitAccessoryHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry, + keyStore); } return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 24b0013cce1fb..455c3f358426f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -33,6 +33,7 @@ import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.DataFormatType; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; +import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.DateTimeType; @@ -86,8 +87,9 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { private @Nullable ScheduledFuture refreshTask; public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, - ChannelTypeRegistry channelTypeRegistry, ChannelGroupTypeRegistry channelGroupTypeRegistry) { - super(thing, typeProvider); + ChannelTypeRegistry channelTypeRegistry, ChannelGroupTypeRegistry channelGroupTypeRegistry, + HomekitKeyStore keyStore) { + super(thing, typeProvider, keyStore); this.channelTypeRegistry = channelTypeRegistry; this.channelGroupTypeRegistry = channelGroupTypeRegistry; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 84fe30317a789..16753462f0bcf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -15,8 +15,6 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.util.Base64; import java.util.Collection; import java.util.HashMap; import java.util.List; @@ -26,7 +24,6 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -37,6 +34,7 @@ import org.openhab.binding.homekit.internal.hap_services.PairRemoveClient; import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; +import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.binding.homekit.internal.transport.IpTransport; import org.openhab.core.thing.Bridge; @@ -75,6 +73,7 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler { protected final Map accessories = new HashMap<>(); protected final HomekitTypeProvider typeProvider; + protected final HomekitKeyStore keyStore; protected boolean isChildAccessory = false; @@ -84,12 +83,10 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler { protected @NonNullByDefault({}) IpTransport ipTransport; protected @NonNullByDefault({}) byte[] clientPairingId; - protected @Nullable Ed25519PrivateKeyParameters controllerLongTermSecretKey = null; - protected @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; - - public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider) { + public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore) { super(thing); this.typeProvider = typeProvider; + this.keyStore = keyStore; } @Override @@ -161,8 +158,12 @@ public void handleRemoval() { try { PairRemoveClient service = new PairRemoveClient(ipTransport, clientPairingId); service.remove(); - accessoryLongTermPublicKey = null; - storeLongTermKeys(); + String mac = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); + if (mac != null) { + keyStore.setAccessoryKey(mac, null); + } else { + logger.warn("Could not clear key for {} due to missing mac address", thing.getUID()); + } updateStatus(ThingStatus.REMOVED); } catch (Exception e) { logger.warn("Failed to remove pairing for {}", thing.getUID()); @@ -230,15 +231,18 @@ private void initializePairing() { return; } - restoreLongTermKeys(); - Ed25519PrivateKeyParameters controllerLongTermSecretKey = this.controllerLongTermSecretKey; - Ed25519PublicKeyParameters accessoryLongTermPublicKey = this.accessoryLongTermPublicKey; + String mac = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); + if (mac == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Missing MAC address"); + return; + } - if (controllerLongTermSecretKey != null && accessoryLongTermPublicKey != null) { + Ed25519PublicKeyParameters tempAccessoryLTPK = keyStore.getAccessoryKey(mac); + if (tempAccessoryLTPK != null) { try { logger.debug("Starting Pair-Verify with existing key for {}", thing.getUID()); PairVerifyClient client = new PairVerifyClient(ipTransport, clientPairingId, - controllerLongTermSecretKey, accessoryLongTermPublicKey); + keyStore.getControllerKey(), tempAccessoryLTPK); ipTransport.setSessionKeys(client.verify()); rwService = new CharacteristicReadWriteClient(ipTransport); @@ -250,37 +254,28 @@ private void initializePairing() { return; } catch (Exception e) { logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); - this.controllerLongTermSecretKey = null; - storeLongTermKeys(); + keyStore.setAccessoryKey(mac, null); // fall through to create new pairing } } - // Create new controller private key - controllerLongTermSecretKey = new Ed25519PrivateKeyParameters(new SecureRandom()); - logger.debug("Created new controller long term private key for {}", thing.getUID()); - try { logger.debug("Starting Pair-Setup for {}", thing.getUID()); PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, clientPairingId, - controllerLongTermSecretKey, pairingCode); - - accessoryLongTermPublicKey = pairSetupClient.pair(); - this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; + keyStore.getControllerKey(), pairingCode); + tempAccessoryLTPK = pairSetupClient.pair(); logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); // Perform Pair-Verify immediately after Pair-Setup PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, clientPairingId, - controllerLongTermSecretKey, accessoryLongTermPublicKey); + keyStore.getControllerKey(), tempAccessoryLTPK); ipTransport.setSessionKeys(pairVerifyClient.verify()); rwService = new CharacteristicReadWriteClient(ipTransport); - - this.controllerLongTermSecretKey = controllerLongTermSecretKey; + keyStore.setAccessoryKey(mac, tempAccessoryLTPK); logger.debug("Pairing and verification completed for {}", thing.getUID()); - storeLongTermKeys(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); @@ -290,34 +285,6 @@ private void initializePairing() { } } - /** - * Restores the controller's private key from the thing's properties. - * The private key is expected to have been stored as a Base64-encoded string. - */ - private void restoreLongTermKeys() { - String encoded = thing.getProperties().get(PROPERTY_CONTROLLER_PRIVATE_KEY); - controllerLongTermSecretKey = encoded == null ? null - : new Ed25519PrivateKeyParameters(Base64.getDecoder().decode(encoded), 0); - - encoded = thing.getProperties().get(PROPERTY_ACCESSORY_PUBLIC_KEY); - accessoryLongTermPublicKey = encoded == null ? null - : new Ed25519PublicKeyParameters(Base64.getDecoder().decode(encoded), 0); - } - - /** - * Stores the controller's private key in the thing's properties. - * The private key is stored as a Base64-encoded string. - */ - private void storeLongTermKeys() { - Ed25519PrivateKeyParameters controllerKey = this.controllerLongTermSecretKey; - String property = controllerKey == null ? null : Base64.getEncoder().encodeToString(controllerKey.getEncoded()); - thing.setProperty(PROPERTY_CONTROLLER_PRIVATE_KEY, property); - - Ed25519PublicKeyParameters accessoryKey = this.accessoryLongTermPublicKey; - property = accessoryKey == null ? null : Base64.getEncoder().encodeToString(accessoryKey.getEncoded()); - thing.setProperty(PROPERTY_ACCESSORY_PUBLIC_KEY, property); - } - public Collection getAccessories() { return accessories.values(); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 15cfb86f48c14..b59f4ad7e40e4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -20,6 +20,7 @@ import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.ServiceType; +import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; @@ -49,8 +50,8 @@ public class HomekitBridgeHandler extends HomekitBaseAccessoryHandler implements private final HomekitChildDiscoveryService discoveryService; public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, - HomekitChildDiscoveryService discoveryService) { - super(bridge, typeProvider); + HomekitChildDiscoveryService discoveryService, HomekitKeyStore keyStore) { + super(bridge, typeProvider, keyStore); this.discoveryService = discoveryService; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java new file mode 100644 index 0000000000000..2af318e9f2b12 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.persistence; + +import java.security.SecureRandom; +import java.util.Base64; + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.storage.Storage; +import org.openhab.core.storage.StorageService; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link HomekitKeyStore} is responsible for persisting cryptographic keys. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +@Component(service = HomekitKeyStore.class) +public class HomekitKeyStore { + + private static final String CONTROLLER_KEY_ID = "controller"; + + private final Storage storage; + + @Activate + public HomekitKeyStore(@Reference StorageService storageService) { + storage = storageService.getStorage(getClass().getName(), getClass().getClassLoader()); + } + + private String encode(byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + + private byte[] decode(String string) { + return Base64.getDecoder().decode(string); + } + + public @Nullable Ed25519PublicKeyParameters getAccessoryKey(String keyId) { + return storage.get(keyId) instanceof String key ? new Ed25519PublicKeyParameters(decode(key), 0) : null; + } + + public void setAccessoryKey(String keyId, @Nullable Ed25519PublicKeyParameters key) { + if (key == null) { + storage.remove(keyId); + } else { + storage.put(keyId, encode(key.getEncoded())); + } + } + + public Ed25519PrivateKeyParameters getControllerKey() { + String existing = storage.get(CONTROLLER_KEY_ID); + if (existing == null) { + Ed25519PrivateKeyParameters key = new Ed25519PrivateKeyParameters(new SecureRandom()); + storage.put(CONTROLLER_KEY_ID, encode(key.getEncoded())); + return key; + } + return new Ed25519PrivateKeyParameters(decode(existing), 0); + } +} From 43d753015aa08b574f694d430913e72f9879d97b Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 10 Oct 2025 11:11:52 +0100 Subject: [PATCH 057/177] controller id is mac address Signed-off-by: Andrew Fiddian-Green --- .../internal/persistence/HomekitKeyStore.java | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java index 2af318e9f2b12..81e29423f6e85 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java @@ -12,8 +12,11 @@ */ package org.openhab.binding.homekit.internal.persistence; +import java.net.NetworkInterface; +import java.net.SocketException; import java.security.SecureRandom; import java.util.Base64; +import java.util.Enumeration; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; @@ -34,13 +37,13 @@ @Component(service = HomekitKeyStore.class) public class HomekitKeyStore { - private static final String CONTROLLER_KEY_ID = "controller"; - private final Storage storage; + private final String controllerId; @Activate public HomekitKeyStore(@Reference StorageService storageService) { storage = storageService.getStorage(getClass().getName(), getClass().getClassLoader()); + controllerId = getMacAddress(); } private String encode(byte[] bytes) { @@ -64,12 +67,36 @@ public void setAccessoryKey(String keyId, @Nullable Ed25519PublicKeyParameters k } public Ed25519PrivateKeyParameters getControllerKey() { - String existing = storage.get(CONTROLLER_KEY_ID); - if (existing == null) { - Ed25519PrivateKeyParameters key = new Ed25519PrivateKeyParameters(new SecureRandom()); - storage.put(CONTROLLER_KEY_ID, encode(key.getEncoded())); - return key; + String key = storage.get(controllerId); + if (key != null) { + return new Ed25519PrivateKeyParameters(decode(key), 0); + } + Ed25519PrivateKeyParameters newKey = new Ed25519PrivateKeyParameters(new SecureRandom()); + storage.put(controllerId, encode(newKey.getEncoded())); + return newKey; + } + + /** + * Returns the MAC address of the first non-loopback network interface found. + * + * @return the MAC address as a String in the format "XX:XX:XX:XX:XX:XX" + * @throws IllegalStateException if no suitable network interface is found + */ + private String getMacAddress() { + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface ni = interfaces.nextElement(); + if (ni.isUp() && !ni.isLoopback() && !ni.isVirtual() && ni.getHardwareAddress() instanceof byte[] mac) { + String macAddr = ""; + for (int i = 0; i < mac.length; i++) { + macAddr += String.format("%02X%s", mac[i], (i < mac.length - 1) ? ":" : ""); + } + return macAddr; + } + } + } catch (SocketException e) { } - return new Ed25519PrivateKeyParameters(decode(existing), 0); + throw new IllegalStateException("No suitable network interface found for deriving MAC address"); } } From ffa62cd34a8840ba46d661583282f3bb7866267e Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 10 Oct 2025 16:12:57 +0100 Subject: [PATCH 058/177] widen airing code matcher; use UUID for device id Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 9 +++ .../homekit/internal/crypto/CryptoUtils.java | 6 -- .../handler/HomekitBaseAccessoryHandler.java | 66 +++++++++---------- .../hap_services/PairSetupClient.java | 4 +- .../internal/persistence/HomekitKeyStore.java | 57 +++++++--------- .../homekit/internal/TestPairSetup.java | 4 +- 6 files changed, 68 insertions(+), 78 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 73c45bf27d248..f5cb15fee18f6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -12,6 +12,8 @@ */ package org.openhab.binding.homekit.internal; +import java.util.regex.Pattern; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.type.ChannelTypeUID; @@ -72,4 +74,11 @@ public class HomekitBindingConstants { public static final String CONTENT_TYPE_PAIRING = "application/pairing+tlv8"; public static final String CONTENT_TYPE_HAP = "application/hap+json"; + + // pattern matcher for pairing code XXX-XX-XXX or XXXX-XXXX or XXXXXXXX + public static final Pattern PAIRING_CODE_PATTERN = Pattern.compile("\\d{3}-\\d{2}-\\d{3}|\\d{4}-\\d{4}|\\d{8}"); + + // pattern matcher for host ipv4 address 123.123.123.123:12345 + public static final Pattern HOST_PATTERN = Pattern.compile( + "^(((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)):(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]?\\d{1,4})$"); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index 43929016900af..ba24d9f9ac958 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -122,12 +122,6 @@ public static byte[] sha512(byte[] data) throws Exception { return md.digest(data); } - // Create 64 bit (8-byte) hash - public static byte[] sha64(byte[] data) throws Exception { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - return Arrays.copyOf(md.digest(data), 8); - } - // Sign message with Ed25519 public static byte[] signMessage(Ed25519PrivateKeyParameters secretKey, byte[] message) { Ed25519Signer signer = new Ed25519Signer(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 16753462f0bcf..af3dd234f5917 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -21,13 +21,11 @@ import java.util.Map; import java.util.Objects; import java.util.function.Function; -import java.util.regex.Pattern; import java.util.stream.Collectors; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; @@ -62,13 +60,6 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler { protected static final Gson GSON = new Gson(); - // pattern matcherfor pairing code XXX-XX-XXX - protected static final Pattern PAIRING_CODE_PATTERN = Pattern.compile("^\\d{3}-\\d{2}-\\d{3}$"); - - // pattern matcher for host ipv4 address 123.123.123.123:12345 - protected static final Pattern HOST_PATTERN = Pattern.compile( - "^(((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)):(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]?\\d{1,4})$"); - private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); protected final Map accessories = new HashMap<>(); @@ -81,7 +72,6 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler { protected @NonNullByDefault({}) String pairingCode; protected @NonNullByDefault({}) Integer accessoryId; protected @NonNullByDefault({}) IpTransport ipTransport; - protected @NonNullByDefault({}) byte[] clientPairingId; public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore) { super(thing); @@ -156,7 +146,7 @@ public void handleRemoval() { scheduler.submit(() -> { // unpair and clear stored keys if this is NOT a child accessory try { - PairRemoveClient service = new PairRemoveClient(ipTransport, clientPairingId); + PairRemoveClient service = new PairRemoveClient(ipTransport, keyStore.getControllerId()); service.remove(); String mac = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); if (mac != null) { @@ -211,38 +201,31 @@ public void initialize() { */ private void initializePairing() { Object pairingConfig = getConfig().get(CONFIG_PAIRING_CODE); - if (pairingConfig == null || !(pairingConfig instanceof String pairingCode) - || !PAIRING_CODE_PATTERN.matcher(pairingCode).matches()) { - logger.debug("Pairing code must match XXX-XX-XXX"); + if (pairingConfig == null || !(pairingConfig instanceof String pairingConfigString) + || !PAIRING_CODE_PATTERN.matcher(pairingConfigString).matches()) { + logger.debug("Pairing code must match XXX-XX-XXX or XXXX-XXXX or XXXXXXXX"); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid pairing code"); return; } - this.pairingCode = pairingCode; - try { - clientPairingId = CryptoUtils.sha64(thing.getUID().toString().getBytes(StandardCharsets.UTF_8)); - } catch (Exception e) { - logger.debug("Eroor creating client Id", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Error creating client Id"); - return; - } - this.accessoryId = getAccessoryId(); + pairingCode = normalizePairingCode(pairingConfigString); + + accessoryId = getAccessoryId(); if (accessoryId == null) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid accessory ID"); return; } - String mac = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); - if (mac == null) { + final String macAddress = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); + if (macAddress == null) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Missing MAC address"); return; } - Ed25519PublicKeyParameters tempAccessoryLTPK = keyStore.getAccessoryKey(mac); - if (tempAccessoryLTPK != null) { + if (keyStore.getAccessoryKey(macAddress) != null) { try { logger.debug("Starting Pair-Verify with existing key for {}", thing.getUID()); - PairVerifyClient client = new PairVerifyClient(ipTransport, clientPairingId, - keyStore.getControllerKey(), tempAccessoryLTPK); + PairVerifyClient client = new PairVerifyClient(ipTransport, keyStore.getControllerId(), + keyStore.getControllerKey(), Objects.requireNonNull(keyStore.getAccessoryKey(macAddress))); ipTransport.setSessionKeys(client.verify()); rwService = new CharacteristicReadWriteClient(ipTransport); @@ -254,26 +237,26 @@ private void initializePairing() { return; } catch (Exception e) { logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); - keyStore.setAccessoryKey(mac, null); + keyStore.setAccessoryKey(macAddress, null); // fall through to create new pairing } } try { logger.debug("Starting Pair-Setup for {}", thing.getUID()); - PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, clientPairingId, + PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, keyStore.getControllerId(), keyStore.getControllerKey(), pairingCode); - tempAccessoryLTPK = pairSetupClient.pair(); + Ed25519PublicKeyParameters accessoryKey = pairSetupClient.pair(); logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); // Perform Pair-Verify immediately after Pair-Setup - PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, clientPairingId, - keyStore.getControllerKey(), tempAccessoryLTPK); + PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, keyStore.getControllerId(), + keyStore.getControllerKey(), accessoryKey); ipTransport.setSessionKeys(pairVerifyClient.verify()); rwService = new CharacteristicReadWriteClient(ipTransport); - keyStore.setAccessoryKey(mac, tempAccessoryLTPK); + keyStore.setAccessoryKey(macAddress, accessoryKey); logger.debug("Pairing and verification completed for {}", thing.getUID()); fetchAccessories(); @@ -288,4 +271,17 @@ private void initializePairing() { public Collection getAccessories() { return accessories.values(); } + + /** + * Normalize XXX-XX-XXX or XXXX-XXXX or XXXXXXXX to XXX-XX-XXX + */ + private String normalizePairingCode(String input) { + // remove all non-digit character formatting + String digits = input.replaceAll("\\D", ""); + if (digits.length() != 8) { + throw new IllegalArgumentException("Input must contain exactly 8 digits"); + } + // re-format as XXX-XX-XXX + return String.format("%s-%s-%s", digits.substring(0, 3), digits.substring(3, 5), digits.substring(5, 8)); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 690999b735b74..8058f1075ee17 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -55,8 +55,8 @@ public class PairSetupClient { public PairSetupClient(IpTransport ipTransport, byte[] clientPairingId, Ed25519PrivateKeyParameters clientLongTermSecretKey, String pairingCode) throws Exception { - if (clientPairingId.length != 8) { - throw new IllegalArgumentException("Client Id must be exactly 8 bytes"); + if (clientPairingId.length != 16) { + throw new IllegalArgumentException("Client Id must be exactly 16 bytes"); } logger.debug("Created with pairing code: {}", pairingCode); this.ipTransport = ipTransport; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java index 81e29423f6e85..9928b0e237a12 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java @@ -12,11 +12,10 @@ */ package org.openhab.binding.homekit.internal.persistence; -import java.net.NetworkInterface; -import java.net.SocketException; +import java.nio.ByteBuffer; import java.security.SecureRandom; import java.util.Base64; -import java.util.Enumeration; +import java.util.UUID; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; @@ -37,13 +36,14 @@ @Component(service = HomekitKeyStore.class) public class HomekitKeyStore { + private static final String CONTROLLER_ID = "controllerId"; + private static final String CONTROLLER_KEY_ID = "controller"; + private final Storage storage; - private final String controllerId; @Activate public HomekitKeyStore(@Reference StorageService storageService) { storage = storageService.getStorage(getClass().getName(), getClass().getClassLoader()); - controllerId = getMacAddress(); } private String encode(byte[] bytes) { @@ -66,37 +66,28 @@ public void setAccessoryKey(String keyId, @Nullable Ed25519PublicKeyParameters k } } - public Ed25519PrivateKeyParameters getControllerKey() { - String key = storage.get(controllerId); - if (key != null) { - return new Ed25519PrivateKeyParameters(decode(key), 0); + public byte[] getControllerId() { + String controllerId = storage.get(CONTROLLER_ID); + if (controllerId != null) { + return decode(controllerId); } - Ed25519PrivateKeyParameters newKey = new Ed25519PrivateKeyParameters(new SecureRandom()); - storage.put(controllerId, encode(newKey.getEncoded())); - return newKey; + // create a new 16 byte controller ID + ByteBuffer buf = ByteBuffer.allocate(16); + UUID uuid = UUID.randomUUID(); + buf.putLong(uuid.getMostSignificantBits()); + buf.putLong(uuid.getLeastSignificantBits()); + byte[] newControllerId = buf.array(); + storage.put(CONTROLLER_ID, encode(newControllerId)); + return newControllerId; } - /** - * Returns the MAC address of the first non-loopback network interface found. - * - * @return the MAC address as a String in the format "XX:XX:XX:XX:XX:XX" - * @throws IllegalStateException if no suitable network interface is found - */ - private String getMacAddress() { - try { - Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); - while (interfaces.hasMoreElements()) { - NetworkInterface ni = interfaces.nextElement(); - if (ni.isUp() && !ni.isLoopback() && !ni.isVirtual() && ni.getHardwareAddress() instanceof byte[] mac) { - String macAddr = ""; - for (int i = 0; i < mac.length; i++) { - macAddr += String.format("%02X%s", mac[i], (i < mac.length - 1) ? ":" : ""); - } - return macAddr; - } - } - } catch (SocketException e) { + public Ed25519PrivateKeyParameters getControllerKey() { + String controllerKey = storage.get(CONTROLLER_KEY_ID); + if (controllerKey != null) { + return new Ed25519PrivateKeyParameters(decode(controllerKey), 0); } - throw new IllegalStateException("No suitable network interface found for deriving MAC address"); + Ed25519PrivateKeyParameters newControllerKey = new Ed25519PrivateKeyParameters(new SecureRandom()); + storage.put(CONTROLLER_KEY_ID, encode(newControllerKey.getEncoded())); + return newControllerKey; } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index 0bebc2ec485c2..e0fd10ff4e2c0 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -81,9 +81,9 @@ void testSrpClient() throws Exception { void testPairSetup() throws Exception { // initialize test parameters String password = "password123"; - byte[] iOSDeviceId = new byte[] { 11, 22, 33, 44, 55, 66, 77, 88 }; + byte[] iOSDeviceId = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; + byte[] accessoryId = new byte[] { 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 }; byte[] serverSalt = toBytes(SALT_HEX); - byte[] accessoryId = new byte[] { 88, 77, 66, 55, 44, 33, 22, 11 }; // initialize signing keys Ed25519PrivateKeyParameters controllerLongTermSecretKey = new Ed25519PrivateKeyParameters( From 0d4fd4dc73b0856fb13698a4f884dc00cc3c2b61 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 10 Oct 2025 16:57:34 +0100 Subject: [PATCH 059/177] fixes Signed-off-by: Andrew Fiddian-Green --- .../hap_services/PairRemoveClient.java | 12 +++---- .../hap_services/PairSetupClient.java | 18 +++++----- .../hap_services/PairVerifyClient.java | 35 +++++++++---------- .../binding/homekit/internal/SRPserver.java | 15 ++++---- .../homekit/internal/TestPairVerify.java | 4 +-- 5 files changed, 41 insertions(+), 43 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index 69e1b71fd9ca7..adbf21d17d235 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -40,15 +40,15 @@ public class PairRemoveClient { private final Logger logger = LoggerFactory.getLogger(PairRemoveClient.class); private final IpTransport ipTransport; - private final byte[] clientPairingId; + private final byte[] controllerId; - public PairRemoveClient(IpTransport ipTransport, byte[] clientPairingId) throws Exception { - if (clientPairingId.length != 8) { - throw new IllegalArgumentException("Client Id must be exactly 8 bytes"); + public PairRemoveClient(IpTransport ipTransport, byte[] controllerId) throws Exception { + if (controllerId.length != 16) { + throw new IllegalArgumentException("Controller Id must be exactly 16 bytes"); } logger.debug("Created.."); this.ipTransport = ipTransport; - this.clientPairingId = clientPairingId; + this.controllerId = controllerId; } public void remove() throws Exception { @@ -56,7 +56,7 @@ public void remove() throws Exception { Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); tlv.put(TlvType.METHOD.value, new byte[] { PairingMethod.REMOVE.value }); - tlv.put(TlvType.IDENTIFIER.value, clientPairingId); + tlv.put(TlvType.IDENTIFIER.value, controllerId); Validator.validate(PairingMethod.REMOVE, tlv); byte[] response = ipTransport.post(ENDPOINT_PAIR_REMOVE, CONTENT_TYPE, Tlv8Codec.encode(tlv)); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 8058f1075ee17..3c8abff8f50ae 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -50,19 +50,19 @@ public class PairSetupClient { private final IpTransport ipTransport; private final String password; - private final byte[] clientPairingId; - private final Ed25519PrivateKeyParameters clientLongTermSecretKey; + private final byte[] controllerId; + private final Ed25519PrivateKeyParameters controllerKey; - public PairSetupClient(IpTransport ipTransport, byte[] clientPairingId, - Ed25519PrivateKeyParameters clientLongTermSecretKey, String pairingCode) throws Exception { - if (clientPairingId.length != 16) { - throw new IllegalArgumentException("Client Id must be exactly 16 bytes"); + public PairSetupClient(IpTransport ipTransport, byte[] controllerId, Ed25519PrivateKeyParameters controllerKey, + String pairingCode) throws Exception { + if (controllerId.length != 16) { + throw new IllegalArgumentException("Controller Id must be exactly 16 bytes"); } logger.debug("Created with pairing code: {}", pairingCode); this.ipTransport = ipTransport; this.password = pairingCode; - this.clientPairingId = clientPairingId; - this.clientLongTermSecretKey = clientLongTermSecretKey; + this.controllerId = controllerId; + this.controllerKey = controllerKey; } /** @@ -160,7 +160,7 @@ private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exceptio */ private SRPclient m5Execute(SRPclient client) throws Exception { logger.debug("Pair-Setup M5: Send controller id, LTPK, and signature to accessory"); - byte[] cipherText = client.m5EncodeControllerInfoAndSign(clientPairingId, clientLongTermSecretKey); + byte[] cipherText = client.m5EncodeControllerInfoAndSign(controllerId, controllerKey); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M5.value }); tlv.put(TlvType.ENCRYPTED_DATA.value, cipherText); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index d0539b70dd25d..66bf4c23c8fb6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -49,9 +49,9 @@ public class PairVerifyClient { private final IpTransport ipTransport; private final byte[] clientPairingId; - private final Ed25519PrivateKeyParameters clientLongTermSecretKey; - private final Ed25519PublicKeyParameters serverLongTermPublicKey; - private final X25519PrivateKeyParameters clientEphemeralSecretKey; + private final Ed25519PrivateKeyParameters controllerKey; + private final Ed25519PublicKeyParameters accessoryKey; + private final X25519PrivateKeyParameters controllerEphemeralSecretKey; private @NonNullByDefault({}) X25519PublicKeyParameters serverEphemeralPublicKey; private @NonNullByDefault({}) byte[] sharedSecret; @@ -59,18 +59,17 @@ public class PairVerifyClient { private @NonNullByDefault({}) byte[] readKey; private @NonNullByDefault({}) byte[] writeKey; - public PairVerifyClient(IpTransport ipTransport, byte[] clientPairingId, - Ed25519PrivateKeyParameters clientLongTermSecretKey, Ed25519PublicKeyParameters serverLongTermPublicKey) - throws Exception { - if (clientPairingId.length != 8) { - throw new IllegalArgumentException("Client Id must be exactly 8 bytes"); + public PairVerifyClient(IpTransport ipTransport, byte[] controllerId, Ed25519PrivateKeyParameters controllerKey, + Ed25519PublicKeyParameters accessoryKey) throws Exception { + if (controllerId.length != 16) { + throw new IllegalArgumentException("Controller Id must be exactly 16 bytes"); } logger.debug("Created.."); this.ipTransport = ipTransport; - this.clientPairingId = clientPairingId; - this.clientLongTermSecretKey = clientLongTermSecretKey; - this.serverLongTermPublicKey = serverLongTermPublicKey; - this.clientEphemeralSecretKey = CryptoUtils.generateX25519KeyPair(); + this.clientPairingId = controllerId; + this.controllerKey = controllerKey; + this.accessoryKey = accessoryKey; + this.controllerEphemeralSecretKey = CryptoUtils.generateX25519KeyPair(); } /** @@ -89,7 +88,7 @@ private void m1Execute() throws Exception { logger.debug("Pair-Verify M1: Send verification start request with client ephemeral X25519 PK to server"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); - tlv.put(TlvType.PUBLIC_KEY.value, clientEphemeralSecretKey.generatePublicKey().getEncoded()); + tlv.put(TlvType.PUBLIC_KEY.value, controllerEphemeralSecretKey.generatePublicKey().getEncoded()); Validator.validate(PairingMethod.VERIFY, tlv); byte[] m1Response = ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); m2Execute(m1Response); @@ -102,7 +101,7 @@ private void m2Execute(byte[] m1Response) throws Exception { Validator.validate(PairingMethod.VERIFY, tlv); serverEphemeralPublicKey = new X25519PublicKeyParameters(tlv.get(TlvType.PUBLIC_KEY.value), 0); - sharedSecret = generateSharedSecret(clientEphemeralSecretKey, serverEphemeralPublicKey); + sharedSecret = generateSharedSecret(controllerEphemeralSecretKey, serverEphemeralPublicKey); sharedKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.value); @@ -116,8 +115,8 @@ private void m2Execute(byte[] m1Response) throws Exception { throw new SecurityException("Accessory identifier or signature missing"); } - verifySignature(serverLongTermPublicKey, serverSignature, concat(serverEphemeralPublicKey.getEncoded(), - serverPairingId, clientEphemeralSecretKey.generatePublicKey().getEncoded())); + verifySignature(accessoryKey, serverSignature, concat(serverEphemeralPublicKey.getEncoded(), serverPairingId, + controllerEphemeralSecretKey.generatePublicKey().getEncoded())); m3Execute(); } @@ -125,8 +124,8 @@ private void m2Execute(byte[] m1Response) throws Exception { // M3 — Send encrypted controller identifier and signature private void m3Execute() throws Exception { logger.debug("Pair-Verify M3: Send encrypted controller id with signature"); - byte[] clientSignature = signMessage(clientLongTermSecretKey, - concat(clientEphemeralSecretKey.generatePublicKey().getEncoded(), clientPairingId, + byte[] clientSignature = signMessage(controllerKey, + concat(controllerEphemeralSecretKey.generatePublicKey().getEncoded(), clientPairingId, serverEphemeralPublicKey.getEncoded())); Map subTlv = new LinkedHashMap<>(); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java index fae2f99d11023..a3eee536c85b9 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -49,7 +49,7 @@ public class SRPserver { private final String I; // username private final byte[] s; // salt private final byte[] accessoryId; - private final Ed25519PrivateKeyParameters accessoryLongTermPrivateKey; + private final Ed25519PrivateKeyParameters accessoryKey; /** * Create a SRP server instance with the given parameters. @@ -57,17 +57,16 @@ public class SRPserver { * @param password the password to use * @param serverSalt the salt to use * @param accessoryId the pairing ID of the server - * @param accessoryLongTermPrivateKey the long term private key of the server + * @param accessoryKey the long term private key of the server * @param username the username to use (or null for default "Pair-Setup") * @param accessoryPrivateKey optional 32 byte private key to use for b, or null to generate a new one * * @throws Exception on any error */ - public SRPserver(String password, byte[] serverSalt, byte[] accessoryId, - Ed25519PrivateKeyParameters accessoryLongTermPrivateKey, @Nullable String username, - byte @Nullable [] accessoryPrivateKey) throws Exception { + public SRPserver(String password, byte[] serverSalt, byte[] accessoryId, Ed25519PrivateKeyParameters accessoryKey, + @Nullable String username, byte @Nullable [] accessoryPrivateKey) throws Exception { this.accessoryId = accessoryId; - this.accessoryLongTermPrivateKey = accessoryLongTermPrivateKey; + this.accessoryKey = accessoryKey; I = username != null ? username : PAIR_SETUP; s = serverSalt; @@ -148,9 +147,9 @@ public void m5DecodeControllerInfoAndVerify(Map tlv5) throws Ex public byte[] m6EncodeAccessoryInfoAndSign() throws Exception { byte[] accessoryX = generateHkdfKey(K, PAIR_SETUP_ACCESSORY_SIGN_SALT, PAIR_SETUP_ACCESSORY_SIGN_INFO); - byte[] accessoryLTPK = accessoryLongTermPrivateKey.generatePublicKey().getEncoded(); + byte[] accessoryLTPK = accessoryKey.generatePublicKey().getEncoded(); byte[] accessoryInfo = concat(accessoryX, accessoryId, accessoryLTPK); - byte[] accessorySignature = signMessage(accessoryLongTermPrivateKey, accessoryInfo); + byte[] accessorySignature = signMessage(accessoryKey, accessoryInfo); Map subTlv = Map.of( // TlvType.IDENTIFIER.value, accessoryId, // diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index cd87914b71492..b01feaa5962dd 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -48,8 +48,8 @@ class TestPairVerify { E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20 """; - byte[] controllerId = new byte[] { 11, 22, 33, 44, 55, 66, 77, 88 }; - byte[] accessoryId = new byte[] { 88, 77, 66, 55, 44, 33, 22, 11 }; + byte[] controllerId = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; + byte[] accessoryId = new byte[] { 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 }; private final Ed25519PrivateKeyParameters controllerLongTermPrivateKey = new Ed25519PrivateKeyParameters( toBytes(CLIENT_PRIVATE_HEX)); From 05236fcdcb25582eef9a922a576b50d288a6de09 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 11 Oct 2025 14:57:09 +0100 Subject: [PATCH 060/177] add discovery flag properties Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 2 + .../HomekitMdnsDiscoveryParticipant.java | 30 ++++++++++--- .../enums/AccessoryPairingFeature.java | 42 +++++++++++++++++++ .../enums/AccessoryPairingStatus.java | 41 ++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingStatus.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index f5cb15fee18f6..8e7a066fac563 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -53,6 +53,8 @@ public class HomekitBindingConstants { public static final String PROPERTY_ACCESSORY_UID = "accessoryUID"; public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; + public static final String PROPERTY_ACCESSORY_PAIRING_FEATURE = "pairFeature"; + public static final String PROPERTY_ACCESSORY_PAIRED_STATE = "pairStatus"; // channel properties public static final String PROPERTY_IID = "iid"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index fce5773d9e58a..da8cba1db35f5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -24,6 +24,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.AccessoryCategory; +import org.openhab.binding.homekit.internal.enums.AccessoryPairingFeature; +import org.openhab.binding.homekit.internal.enums.AccessoryPairingStatus; import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; @@ -75,20 +77,38 @@ public String getServiceType() { if (port != 0) { host = host + ":" + port; } - AccessoryCategory cat; + AccessoryCategory category; try { String ci = properties.getOrDefault("ci", ""); // accessory category - cat = AccessoryCategory.from(Integer.parseInt(ci)); + category = AccessoryCategory.from(Integer.parseInt(ci)); } catch (IllegalArgumentException e) { - cat = null; + category = null; } - if (host != null && mac != null && cat != null) { + AccessoryPairingFeature pairFeature; + try { + String ff = properties.getOrDefault("ff", ""); // accessory feature flag + pairFeature = AccessoryPairingFeature.from(Integer.parseInt(ff)); + } catch (IllegalArgumentException e) { + pairFeature = AccessoryPairingFeature.NO_HAP; + } + + AccessoryPairingStatus pairStatus; + try { + String sf = properties.getOrDefault("sf", ""); // accessory status flag + pairStatus = AccessoryPairingStatus.from(Integer.parseInt(sf)); + } catch (IllegalArgumentException e) { + pairStatus = AccessoryPairingStatus.UNPAIRED; + } + + if (host != null && mac != null && category != null) { DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), host)) // .withProperty(CONFIG_HOST, host) // .withProperty(Thing.PROPERTY_MAC_ADDRESS, mac) // - .withProperty(PROPERTY_ACCESSORY_CATEGORY, cat.toString()) // + .withProperty(PROPERTY_ACCESSORY_PAIRING_FEATURE, pairFeature.toString()) // + .withProperty(PROPERTY_ACCESSORY_PAIRED_STATE, pairStatus.toString()) // + .withProperty(PROPERTY_ACCESSORY_CATEGORY, category.toString()) // .withProperty(PROPERTY_ACCESSORY_UID, new ThingUID(THING_TYPE_ACCESSORY, "1").toString()) // .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java new file mode 100644 index 0000000000000..f399d19d07cbb --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of pairing feature flags of a HomeKit accessory + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum AccessoryPairingFeature { + NO_HAP(0x00), // no support for HAP Pairing + APPLE_AUTH(0x01), // supports HAP Pairing with Apple authentication coprocessor + SOFTWARE_AUTH(0x02); // supports HAP Pairing with software authentication + + public final byte value; + + AccessoryPairingFeature(int value) { + this.value = (byte) value; + } + + public static AccessoryPairingFeature from(int value) { + for (AccessoryPairingFeature state : values()) { + if (state.value == value) { + return state; + } + } + throw new IllegalArgumentException("Unknown pairing feature: " + value); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingStatus.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingStatus.java new file mode 100644 index 0000000000000..ca15b1e8c9b68 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingStatus.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of paired status flag of a HomeKit accessories. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum AccessoryPairingStatus { + PAIRED(0x00), + UNPAIRED(0x01); + + public final byte value; + + AccessoryPairingStatus(int value) { + this.value = (byte) value; + } + + public static AccessoryPairingStatus from(int value) { + for (AccessoryPairingStatus state : values()) { + if (state.value == value) { + return state; + } + } + throw new IllegalArgumentException("Unknown pairing feature: " + value); + } +} From dc23790cfdb1b441e3c04268aa4a5081b561b51c Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 11 Oct 2025 16:35:58 +0100 Subject: [PATCH 061/177] fix channel fields; tweak ff param enum Signed-off-by: Andrew Fiddian-Green --- .../discovery/HomekitMdnsDiscoveryParticipant.java | 2 +- .../homekit/internal/enums/AccessoryPairingFeature.java | 6 +++--- .../homekit/internal/handler/HomekitAccessoryHandler.java | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index da8cba1db35f5..59c6aeb404a92 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -90,7 +90,7 @@ public String getServiceType() { String ff = properties.getOrDefault("ff", ""); // accessory feature flag pairFeature = AccessoryPairingFeature.from(Integer.parseInt(ff)); } catch (IllegalArgumentException e) { - pairFeature = AccessoryPairingFeature.NO_HAP; + pairFeature = AccessoryPairingFeature.NO; } AccessoryPairingStatus pairStatus; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java index f399d19d07cbb..1e46dba6d050c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java @@ -21,9 +21,9 @@ */ @NonNullByDefault public enum AccessoryPairingFeature { - NO_HAP(0x00), // no support for HAP Pairing - APPLE_AUTH(0x01), // supports HAP Pairing with Apple authentication coprocessor - SOFTWARE_AUTH(0x02); // supports HAP Pairing with software authentication + NO(0x00), // no support for HAP Pairing + YES(0x01), // supports pairing via software, or Apple authentication coprocessor + SECURE_HTTP_DEPRECATED(0x02); // supports pairing via secure HTTP (deprecated) public final byte value; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 455c3f358426f..48b8f674af864 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -338,8 +338,11 @@ private void createChannels() { channelType.getUID(), channelType.getUnitHint()); ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), chanDef.getId()); - ChannelBuilder builder = ChannelBuilder.create(channelUID).withType(channelType.getUID()) - .withProperties(chanDef.getProperties()); + ChannelBuilder builder = ChannelBuilder.create(channelUID) + .withAcceptedItemType(channelType.getItemType()) + .withAutoUpdatePolicy(channelType.getAutoUpdatePolicy()) + .withDefaultTags(channelType.getTags()).withKind(channelType.getKind()) + .withProperties(chanDef.getProperties()).withType(channelType.getUID()); Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); Optional.ofNullable(chanDef.getDescription()).ifPresent(builder::withDescription); Channel channel = builder.build(); From 04dc7a37c5f9c3220aea1774016119ef1a5600a4 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 12 Oct 2025 17:19:20 +0100 Subject: [PATCH 062/177] use uuid strings; improve logging Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitBaseAccessoryHandler.java | 8 +++---- .../hap_services/PairRemoveClient.java | 15 ++++++++++++ .../hap_services/PairSetupClient.java | 24 ++++++++++++------- .../hap_services/PairVerifyClient.java | 14 +++++++++++ .../internal/persistence/HomekitKeyStore.java | 23 +++++++----------- 5 files changed, 58 insertions(+), 26 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index af3dd234f5917..954fe2408522a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -146,7 +146,7 @@ public void handleRemoval() { scheduler.submit(() -> { // unpair and clear stored keys if this is NOT a child accessory try { - PairRemoveClient service = new PairRemoveClient(ipTransport, keyStore.getControllerId()); + PairRemoveClient service = new PairRemoveClient(ipTransport, keyStore.getControllerUUID()); service.remove(); String mac = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); if (mac != null) { @@ -224,7 +224,7 @@ private void initializePairing() { if (keyStore.getAccessoryKey(macAddress) != null) { try { logger.debug("Starting Pair-Verify with existing key for {}", thing.getUID()); - PairVerifyClient client = new PairVerifyClient(ipTransport, keyStore.getControllerId(), + PairVerifyClient client = new PairVerifyClient(ipTransport, keyStore.getControllerUUID(), keyStore.getControllerKey(), Objects.requireNonNull(keyStore.getAccessoryKey(macAddress))); ipTransport.setSessionKeys(client.verify()); @@ -244,14 +244,14 @@ private void initializePairing() { try { logger.debug("Starting Pair-Setup for {}", thing.getUID()); - PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, keyStore.getControllerId(), + PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, keyStore.getControllerUUID(), keyStore.getControllerKey(), pairingCode); Ed25519PublicKeyParameters accessoryKey = pairSetupClient.pair(); logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); // Perform Pair-Verify immediately after Pair-Setup - PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, keyStore.getControllerId(), + PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, keyStore.getControllerUUID(), keyStore.getControllerKey(), accessoryKey); ipTransport.setSessionKeys(pairVerifyClient.verify()); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index adbf21d17d235..d5df92a03e957 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -12,6 +12,8 @@ */ package org.openhab.binding.homekit.internal.hap_services; +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.toHex; + import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -57,13 +59,26 @@ public void remove() throws Exception { tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); tlv.put(TlvType.METHOD.value, new byte[] { PairingMethod.REMOVE.value }); tlv.put(TlvType.IDENTIFIER.value, controllerId); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.REMOVE, tlv); byte[] response = ipTransport.post(ENDPOINT_PAIR_REMOVE, CONTENT_TYPE, Tlv8Codec.encode(tlv)); + logger.debug("Pair-Remove: processing response"); Map tlv2 = Tlv8Codec.decode(response); + loggerTraceTlv(tlv2); Validator.validate(PairingMethod.REMOVE, tlv2); } + private void loggerTraceTlv(Map tlv) { + if (logger.isTraceEnabled()) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : tlv.entrySet()) { + sb.append(String.format("\n - 0x%02x: %s", entry.getKey(), toHex(entry.getValue()))); + } + logger.trace(sb.toString()); + } + } + /** * Helper class that validates the TLV map for the specification required pairing state. */ diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 3c8abff8f50ae..5af3502087534 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -88,6 +88,7 @@ private SRPclient m1Execute() throws Exception { Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); tlv.put(TlvType.METHOD.value, new byte[] { PairingMethod.SETUP.value }); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.SETUP, tlv); byte[] m1Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); return m2Execute(m1Response); @@ -103,13 +104,10 @@ private SRPclient m1Execute() throws Exception { private SRPclient m2Execute(byte[] m1Response) throws Exception { logger.debug("Pair-Setup M2: Read server salt and accessory ephemeral PK; initialize SRP client"); Map tlv = Tlv8Codec.decode(m1Response); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.SETUP, tlv); byte[] serverSalt = tlv.get(TlvType.SALT.value); byte[] serverPublicKey = tlv.get(TlvType.PUBLIC_KEY.value); - if (logger.isTraceEnabled()) { - logger.trace("Pair-Setup M2: Receive accessory data\n - Server salt: {}\n - Accessory PK: {}", - toHex(serverSalt), toHex(serverPublicKey)); - } SRPclient client = new SRPclient(password, Objects.requireNonNull(serverSalt), Objects.requireNonNull(serverPublicKey)); return m3Execute(client); @@ -127,11 +125,8 @@ private SRPclient m3Execute(SRPclient client) throws Exception { tlv.put(TlvType.STATE.value, new byte[] { PairingState.M3.value }); tlv.put(TlvType.PUBLIC_KEY.value, CryptoUtils.toUnsigned(client.A, 384)); tlv.put(TlvType.PROOF.value, client.M1); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.SETUP, tlv); - if (logger.isTraceEnabled()) { - logger.trace("Pair-Setup M3: Send data\n - Controller PK: {}\n - Controller M1: {}", - toHex(CryptoUtils.toUnsigned(client.A, 384)), toHex(client.M1)); - } byte[] m3Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); return m4Execute(client, m3Response); } @@ -145,6 +140,7 @@ private SRPclient m3Execute(SRPclient client) throws Exception { private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exception { logger.debug("Pair-Setup M4: Read accessory M2 proof; and verify it"); Map tlv = Tlv8Codec.decode(m3Response); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.SETUP, tlv); byte[] accessoryProofM2 = tlv.get(TlvType.PROOF.value); client.m4VerifyAccessoryProof(Objects.requireNonNull(accessoryProofM2)); @@ -164,6 +160,7 @@ private SRPclient m5Execute(SRPclient client) throws Exception { Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M5.value }); tlv.put(TlvType.ENCRYPTED_DATA.value, cipherText); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.SETUP, tlv); byte[] m5Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); return m6Execute(client, m5Response); @@ -179,12 +176,23 @@ private SRPclient m5Execute(SRPclient client) throws Exception { private SRPclient m6Execute(SRPclient client, byte[] m5Response) throws Exception { logger.debug("Pair-Setup M6: Read accessory id, LTPK, and signature; and verify it"); Map tlv = Tlv8Codec.decode(m5Response); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.SETUP, tlv); byte[] cipherText = tlv.get(TlvType.ENCRYPTED_DATA.value); client.m6DecodeAccessoryInfoAndVerify(Objects.requireNonNull(cipherText)); return client; } + private void loggerTraceTlv(Map tlv) { + if (logger.isTraceEnabled()) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : tlv.entrySet()) { + sb.append(String.format("\n - 0x%02x: %s", entry.getKey(), toHex(entry.getValue()))); + } + logger.trace(sb.toString()); + } + } + /** * Helper class that validates the TLV map for the specification required pairing state. */ diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index 66bf4c23c8fb6..0c2154789913e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -89,6 +89,7 @@ private void m1Execute() throws Exception { Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); tlv.put(TlvType.PUBLIC_KEY.value, controllerEphemeralSecretKey.generatePublicKey().getEncoded()); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.VERIFY, tlv); byte[] m1Response = ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); m2Execute(m1Response); @@ -98,6 +99,7 @@ private void m1Execute() throws Exception { private void m2Execute(byte[] m1Response) throws Exception { logger.debug("Pair-Verify M2: Read server ephemeral X25519 PK and encrypted id; validate signature"); Map tlv = Tlv8Codec.decode(m1Response); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.VERIFY, tlv); serverEphemeralPublicKey = new X25519PublicKeyParameters(tlv.get(TlvType.PUBLIC_KEY.value), 0); @@ -138,6 +140,7 @@ private void m3Execute() throws Exception { Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M3.value }); tlv.put(TlvType.ENCRYPTED_DATA.value, cipherText); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.VERIFY, tlv); byte[] m3Response = ipTransport.post(ENDPOINT_PAIR_VERIFY, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); @@ -148,11 +151,22 @@ private void m3Execute() throws Exception { private void m4Execute(byte[] m3Response) throws Exception { logger.debug("Pair-Verify M4: Confirm validation; derive session keys"); Map tlv = Tlv8Codec.decode(m3Response); + loggerTraceTlv(tlv); Validator.validate(PairingMethod.VERIFY, tlv); readKey = CryptoUtils.generateHkdfKey(sharedSecret, CONTROL_SALT, CONTROL_READ_ENCRYPTION_KEY); writeKey = CryptoUtils.generateHkdfKey(sharedSecret, CONTROL_SALT, CONTROL_WRITE_ENCRYPTION_KEY); } + private void loggerTraceTlv(Map tlv) { + if (logger.isTraceEnabled()) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : tlv.entrySet()) { + sb.append(String.format("\n - 0x%02x: %s", entry.getKey(), toHex(entry.getValue()))); + } + logger.trace(sb.toString()); + } + } + /** * Helper class that validates the TLV map for the specification required pairing state. */ diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java index 9928b0e237a12..619b8dfa8ae90 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java @@ -12,7 +12,7 @@ */ package org.openhab.binding.homekit.internal.persistence; -import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; import java.util.UUID; @@ -36,7 +36,7 @@ @Component(service = HomekitKeyStore.class) public class HomekitKeyStore { - private static final String CONTROLLER_ID = "controllerId"; + private static final String CONTROLLER_UUID = "controllerUUID"; private static final String CONTROLLER_KEY_ID = "controller"; private final Storage storage; @@ -66,19 +66,14 @@ public void setAccessoryKey(String keyId, @Nullable Ed25519PublicKeyParameters k } } - public byte[] getControllerId() { - String controllerId = storage.get(CONTROLLER_ID); - if (controllerId != null) { - return decode(controllerId); + public byte[] getControllerUUID() { + String controllerUUID = storage.get(CONTROLLER_UUID); + if (controllerUUID != null) { + return decode(controllerUUID); } - // create a new 16 byte controller ID - ByteBuffer buf = ByteBuffer.allocate(16); - UUID uuid = UUID.randomUUID(); - buf.putLong(uuid.getMostSignificantBits()); - buf.putLong(uuid.getLeastSignificantBits()); - byte[] newControllerId = buf.array(); - storage.put(CONTROLLER_ID, encode(newControllerId)); - return newControllerId; + byte[] newControllerUUID = UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8); + storage.put(CONTROLLER_UUID, encode(newControllerUUID)); + return newControllerUUID; } public Ed25519PrivateKeyParameters getControllerKey() { From d7a7ed69589e4cbd8439cc47c49e27e98f8e6769 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 12 Oct 2025 18:14:38 +0100 Subject: [PATCH 063/177] fix build error Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/hap_services/PairRemoveClient.java | 2 +- .../binding/homekit/internal/hap_services/PairSetupClient.java | 2 +- .../binding/homekit/internal/hap_services/PairVerifyClient.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index d5df92a03e957..85b4f850f32aa 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -75,7 +75,7 @@ private void loggerTraceTlv(Map tlv) { for (Map.Entry entry : tlv.entrySet()) { sb.append(String.format("\n - 0x%02x: %s", entry.getKey(), toHex(entry.getValue()))); } - logger.trace(sb.toString()); + logger.trace("{}", sb.toString()); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 5af3502087534..7699fccd85238 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -189,7 +189,7 @@ private void loggerTraceTlv(Map tlv) { for (Map.Entry entry : tlv.entrySet()) { sb.append(String.format("\n - 0x%02x: %s", entry.getKey(), toHex(entry.getValue()))); } - logger.trace(sb.toString()); + logger.trace("{}", sb.toString()); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index 0c2154789913e..2484e387b72fc 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -163,7 +163,7 @@ private void loggerTraceTlv(Map tlv) { for (Map.Entry entry : tlv.entrySet()) { sb.append(String.format("\n - 0x%02x: %s", entry.getKey(), toHex(entry.getValue()))); } - logger.trace(sb.toString()); + logger.trace("{}", sb.toString()); } } From dc647a79da3d2133296cc4c83d4e00ae4681513f Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 12 Oct 2025 18:28:29 +0100 Subject: [PATCH 064/177] remove id size checks Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/hap_services/PairRemoveClient.java | 3 --- .../binding/homekit/internal/hap_services/PairSetupClient.java | 3 --- .../homekit/internal/hap_services/PairVerifyClient.java | 3 --- 3 files changed, 9 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index 85b4f850f32aa..8853b19ab0344 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -45,9 +45,6 @@ public class PairRemoveClient { private final byte[] controllerId; public PairRemoveClient(IpTransport ipTransport, byte[] controllerId) throws Exception { - if (controllerId.length != 16) { - throw new IllegalArgumentException("Controller Id must be exactly 16 bytes"); - } logger.debug("Created.."); this.ipTransport = ipTransport; this.controllerId = controllerId; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 7699fccd85238..bcc6213239505 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -55,9 +55,6 @@ public class PairSetupClient { public PairSetupClient(IpTransport ipTransport, byte[] controllerId, Ed25519PrivateKeyParameters controllerKey, String pairingCode) throws Exception { - if (controllerId.length != 16) { - throw new IllegalArgumentException("Controller Id must be exactly 16 bytes"); - } logger.debug("Created with pairing code: {}", pairingCode); this.ipTransport = ipTransport; this.password = pairingCode; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index 2484e387b72fc..c6a0b30441bf0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -61,9 +61,6 @@ public class PairVerifyClient { public PairVerifyClient(IpTransport ipTransport, byte[] controllerId, Ed25519PrivateKeyParameters controllerKey, Ed25519PublicKeyParameters accessoryKey) throws Exception { - if (controllerId.length != 16) { - throw new IllegalArgumentException("Controller Id must be exactly 16 bytes"); - } logger.debug("Created.."); this.ipTransport = ipTransport; this.clientPairingId = controllerId; From 18d972d0a7bfbd260a047fabc37895d034bc34c4 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 12 Oct 2025 18:46:33 +0100 Subject: [PATCH 065/177] add length to tlv trace logs Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/hap_services/PairRemoveClient.java | 3 ++- .../binding/homekit/internal/hap_services/PairSetupClient.java | 3 ++- .../homekit/internal/hap_services/PairVerifyClient.java | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index 8853b19ab0344..01e427ab8e88e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -70,7 +70,8 @@ private void loggerTraceTlv(Map tlv) { if (logger.isTraceEnabled()) { StringBuilder sb = new StringBuilder(); for (Map.Entry entry : tlv.entrySet()) { - sb.append(String.format("\n - 0x%02x: %s", entry.getKey(), toHex(entry.getValue()))); + sb.append(String.format("\n - 0x%02x: %s {%d}", entry.getKey(), toHex(entry.getValue()), + entry.getValue().length)); } logger.trace("{}", sb.toString()); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index bcc6213239505..c6013de396219 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -184,7 +184,8 @@ private void loggerTraceTlv(Map tlv) { if (logger.isTraceEnabled()) { StringBuilder sb = new StringBuilder(); for (Map.Entry entry : tlv.entrySet()) { - sb.append(String.format("\n - 0x%02x: %s", entry.getKey(), toHex(entry.getValue()))); + sb.append(String.format("\n - 0x%02x: %s {%d}", entry.getKey(), toHex(entry.getValue()), + entry.getValue().length)); } logger.trace("{}", sb.toString()); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index c6a0b30441bf0..954845fea4a69 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -158,7 +158,8 @@ private void loggerTraceTlv(Map tlv) { if (logger.isTraceEnabled()) { StringBuilder sb = new StringBuilder(); for (Map.Entry entry : tlv.entrySet()) { - sb.append(String.format("\n - 0x%02x: %s", entry.getKey(), toHex(entry.getValue()))); + sb.append(String.format("\n - 0x%02x: %s {%d}", entry.getKey(), toHex(entry.getValue()), + entry.getValue().length)); } logger.trace("{}", sb.toString()); } From 422ddd4fd454ce1e6aca3da27353a889295025a0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 12 Oct 2025 22:08:12 +0100 Subject: [PATCH 066/177] uom tweaks Signed-off-by: Andrew Fiddian-Green --- .../openhab/binding/homekit/internal/dto/Characteristic.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 5bf8f78a29bff..d76b5c06a1a18 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -220,7 +220,7 @@ public class Characteristic { case CARBON_DIOXIDE_LEVEL: case CARBON_DIOXIDE_PEAK_LEVEL: uom = "ppm"; - numberSuffix = "Density"; + numberSuffix = "Dimensionless"; propertyTag = Property.CO2; category = "co2"; break; @@ -235,7 +235,7 @@ public class Characteristic { case CARBON_MONOXIDE_LEVEL: case CARBON_MONOXIDE_PEAK_LEVEL: uom = "ppm"; - numberSuffix = "Density"; + numberSuffix = "Dimensionless"; propertyTag = Property.CO; break; @@ -650,6 +650,7 @@ public class Characteristic { case TEMPERATURE_HEATING_THRESHOLD: case TEMPERATURE_TARGET: propertyTag = Property.TEMPERATURE; + numberSuffix = "Temperature"; category = "temperature"; break; From 59a005b6bf9a666a17b27f48715e132623a0da40 Mon Sep 17 00:00:00 2001 From: Jacob Laursen Date: Mon, 13 Oct 2025 00:10:43 +0200 Subject: [PATCH 067/177] Simplify accessory discovery Signed-off-by: Jacob Laursen --- .../HomekitChildDiscoveryService.java | 24 ++-------- .../factory/HomekitHandlerFactory.java | 48 +------------------ .../handler/HomekitBridgeHandler.java | 15 +++--- 3 files changed, 13 insertions(+), 74 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 96c6a26b6b15f..5aa5cd65b9a33 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -15,13 +15,12 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.util.Collection; -import java.util.HashSet; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.handler.HomekitBridgeHandler; -import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.thing.Thing; @@ -36,30 +35,17 @@ */ @NonNullByDefault @Component(service = DiscoveryService.class) -public class HomekitChildDiscoveryService extends AbstractDiscoveryService { +public class HomekitChildDiscoveryService extends AbstractThingHandlerDiscoveryService { private static final int TIMEOUT_SECONDS = 10; - private final Set bridgeHandlers = new HashSet<>(); - public HomekitChildDiscoveryService() { - super(Set.of(THING_TYPE_ACCESSORY), TIMEOUT_SECONDS); - } - - public void addBridgeHandler(HomekitBridgeHandler handler) { - bridgeHandlers.add(handler); - startScan(); - } - - public void removeBridgeHandler(HomekitBridgeHandler handler) { - bridgeHandlers.remove(handler); + super(HomekitBridgeHandler.class, Set.of(THING_TYPE_ACCESSORY), TIMEOUT_SECONDS); } @Override - protected void startScan() { - for (HomekitBridgeHandler handler : bridgeHandlers) { - discoverChildren(handler.getThing(), handler.getAccessories()); - } + public void startScan() { + discoverChildren(thingHandler.getThing(), thingHandler.getAccessories()); } private void discoverChildren(Thing bridge, Collection accessories) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index 75feda766e9b2..be563ec3509aa 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -14,17 +14,14 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; -import java.util.Hashtable; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; import org.openhab.binding.homekit.internal.handler.HomekitAccessoryHandler; import org.openhab.binding.homekit.internal.handler.HomekitBridgeHandler; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; -import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; @@ -33,15 +30,12 @@ import org.openhab.core.thing.binding.ThingHandlerFactory; import org.openhab.core.thing.type.ChannelGroupTypeRegistry; import org.openhab.core.thing.type.ChannelTypeRegistry; -import org.osgi.framework.ServiceRegistration; -import org.osgi.service.component.ComponentContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; /** * Creates things and thing handlers. Supports HomeKit bridges and accessories. - * Passes on a {@link HomekitChildDiscoveryService} so that created things can to manage discovery of accessories. * * @author Andrew Fiddian-Green - Initial contribution */ @@ -56,9 +50,6 @@ public class HomekitHandlerFactory extends BaseThingHandlerFactory { private final ChannelGroupTypeRegistry channelGroupTypeRegistry; private final HomekitKeyStore keyStore; - private @Nullable ServiceRegistration discoveryServiceRegistration; - private @Nullable HomekitChildDiscoveryService discoveryService; - @Activate public HomekitHandlerFactory(@Reference HomekitTypeProvider typeProvider, @Reference ChannelTypeRegistry channelTypeRegistry, @@ -69,12 +60,6 @@ public HomekitHandlerFactory(@Reference HomekitTypeProvider typeProvider, this.keyStore = keyStore; } - @Override - protected void deactivate(ComponentContext componentContext) { - unregisterDiscoveryService(); - super.deactivate(componentContext); - } - @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); @@ -84,42 +69,11 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { - return new HomekitBridgeHandler((Bridge) thing, typeProvider, registerDiscoveryService(), keyStore); + return new HomekitBridgeHandler((Bridge) thing, typeProvider, keyStore); } else if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { return new HomekitAccessoryHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry, keyStore); } return null; } - - /** - * Registers the AccessoryDiscoveryService if not already registered and returns it. - * - * @return the registered AccessoryDiscoveryService - */ - private HomekitChildDiscoveryService registerDiscoveryService() { - HomekitChildDiscoveryService service = this.discoveryService; - if (service == null) { - service = new HomekitChildDiscoveryService(); - this.discoveryService = service; - } - ServiceRegistration registration = this.discoveryServiceRegistration; - if (registration == null) { - registration = bundleContext.registerService(DiscoveryService.class.getName(), service, new Hashtable<>()); - this.discoveryServiceRegistration = registration; - } - return service; - } - - /** - * Unregisters the AccessoryDiscoveryService if it is registered. - */ - private void unregisterDiscoveryService() { - ServiceRegistration registration = this.discoveryServiceRegistration; - if (registration != null) { - registration.unregister(); - } - this.discoveryService = null; - this.discoveryServiceRegistration = null; - } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index b59f4ad7e40e4..8d133a643d3d7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -14,6 +14,9 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.FAKE_PROPERTY_CHANNEL_TYPE_UID; +import java.util.Collection; +import java.util.Set; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; import org.openhab.binding.homekit.internal.dto.Accessory; @@ -27,6 +30,7 @@ import org.openhab.core.thing.Thing; import org.openhab.core.thing.binding.BridgeHandler; import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.thing.binding.builder.BridgeBuilder; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.types.Command; @@ -47,12 +51,9 @@ public class HomekitBridgeHandler extends HomekitBaseAccessoryHandler implements BridgeHandler { private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); - private final HomekitChildDiscoveryService discoveryService; - public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, - HomekitChildDiscoveryService discoveryService, HomekitKeyStore keyStore) { + public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore) { super(bridge, typeProvider, keyStore); - this.discoveryService = discoveryService; } @Override @@ -92,7 +93,6 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { @Override protected void accessoriesLoaded() { logger.debug("Bridge accessories loaded {}", accessories.size()); - discoveryService.addBridgeHandler(this); // discover child accessories createProperties(); // create properties from accessory information } @@ -102,9 +102,8 @@ public void handleCommand(ChannelUID channelUID, Command command) { } @Override - public void dispose() { - discoveryService.removeBridgeHandler(this); - super.dispose(); + public Collection> getServices() { + return Set.of(HomekitChildDiscoveryService.class); } /** From 9c527b866da1c096b0fa37dab1429134e1753880 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 19 Oct 2025 16:45:12 +0100 Subject: [PATCH 068/177] various - change position dimmer channels to roller shutters - fix value conversion for 2 state numeric enums - fix state options for 3+ state numeric enums Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 9 +- .../homekit/internal/dto/Characteristic.java | 105 +++++++++++++----- .../handler/HomekitAccessoryHandler.java | 44 +++++++- 3 files changed, 116 insertions(+), 42 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 8e7a066fac563..c4769aba4bed6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -59,14 +59,7 @@ public class HomekitBindingConstants { // channel properties public static final String PROPERTY_IID = "iid"; public static final String PROPERTY_FORMAT = "format"; - public static final String PROPERTY_BOOL_TYPE = "boolType"; - - // public static final String PROPERTY_MIN_VALUE = "minValue"; - // public static final String PROPERTY_MAX_VALUE = "maxValue"; - // public static final String PROPERTY_MIN_STEP = "minStep"; - // public static final String PROPERTY_UNIT = "unit"; - // public static final String PROPERTY_PERMS = "perms"; - // public static final String PROPERTY_EV = "ev"; + public static final String PROPERTY_DATA_TYPE = "dataType"; // HomeKit HTTP URI endpoints and content types public static final String ENDPOINT_ACCESSORIES = "/accessories"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index d76b5c06a1a18..031cddd0bce3c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -114,15 +114,15 @@ public class Characteristic { default -> unit; // may be null or a custom unit }; - String boolType = null; + String dataType = null; if ("bool".equals(format) && value != null && value.isJsonPrimitive()) { // some characteristics have "bool" with non-boolean value types e.g. numbers 0,1 or strings "true","false" JsonPrimitive prim = value.getAsJsonPrimitive(); if (prim.isNumber()) { - boolType = "number"; + dataType = "number"; } if (prim.isString()) { - boolType = "string"; + dataType = "string"; } } @@ -177,17 +177,20 @@ public class Characteristic { break; case AIR_PURIFIER_STATE_CURRENT: - // TODO numeric enum 3 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; break; case AIR_PURIFIER_STATE_TARGET: itemType = CoreItemFactory.SWITCH; + dataType = "number"; propertyTag = Property.ENABLED; break; case AIR_QUALITY: - // TODO numeric enum 5 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.AIR_QUALITY; break; @@ -207,11 +210,13 @@ public class Characteristic { break; case BUTTON_EVENT: + // TODO trigger channel with numeric enum 1-3 isStateChannel = false; break; case CARBON_DIOXIDE_DETECTED: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.ALARM; propertyTag = Property.CO2; category = "co2"; @@ -227,6 +232,7 @@ public class Characteristic { case CARBON_MONOXIDE_DETECTED: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.ALARM; propertyTag = Property.CO; category = "alarm"; @@ -240,7 +246,8 @@ public class Characteristic { break; case CHARGING_STATE: - // TODO numeric enum 3 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; category = "battery"; break; @@ -254,6 +261,7 @@ public class Characteristic { case CONTACT_STATE: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.STATUS; category = "switch"; break; @@ -290,27 +298,36 @@ public class Characteristic { break; case DOOR_STATE_CURRENT: - // TODO numeric enum 5 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.OPEN_STATE; + category = "door"; break; case DOOR_STATE_TARGET: itemType = CoreItemFactory.SWITCH; + dataType = "number"; propertyTag = Property.OPEN_STATE; + category = "door"; break; case FAN_STATE_CURRENT: - // TODO numeric enum 3 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; + category = "fan"; break; case FAN_STATE_TARGET: itemType = CoreItemFactory.SWITCH; + dataType = "number"; propertyTag = Property.MODE; + category = "fan"; break; case FILTER_CHANGE_INDICATION: itemType = CoreItemFactory.CONTACT; + dataType = "number"; break; case FILTER_LIFE_LEVEL: @@ -321,6 +338,7 @@ public class Characteristic { case FILTER_RESET_INDICATION: itemType = CoreItemFactory.SWITCH; + dataType = "number"; break; case FIRMWARE_REVISION: @@ -330,19 +348,22 @@ public class Characteristic { case HEATER_COOLER_STATE_CURRENT: case HEATER_COOLER_STATE_TARGET: - // TODO numeric enum 3 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; category = "heating"; break; case HEATING_COOLING_CURRENT: - // TODO numeric enum 3 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; category = "heating"; break; case HEATING_COOLING_TARGET: - // TODO numeric enum 4 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; category = "heating"; break; @@ -351,6 +372,7 @@ public class Characteristic { case HORIZONTAL_TILT_TARGET: numberSuffix = "Angle"; propertyTag = Property.TILT; + category = "rollershutter"; break; case HUE: @@ -361,18 +383,21 @@ public class Characteristic { break; case HUMIDIFIER_DEHUMIDIFIER_STATE_CURRENT: - // TODO numeric enum 4 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; category = "humidity"; break; case HUMIDIFIER_DEHUMIDIFIER_STATE_TARGET: - // TODO numeric enum 3 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; category = "humidity"; break; case IDENTIFY: + // TODO itemType = null; break; @@ -382,7 +407,8 @@ public class Characteristic { break; case IMAGE_ROTATION: - // TODO numeric enum 4 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; category = "image"; break; @@ -399,6 +425,7 @@ public class Characteristic { case LEAK_DETECTED: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.ALARM; propertyTag = Property.WATER; category = "alarm"; @@ -411,6 +438,7 @@ public class Characteristic { case LOCK_MANAGEMENT_AUTO_SECURE_TIMEOUT: numberSuffix = "Duration"; + category = "lock"; break; case LOCK_MANAGEMENT_CONTROL_POINT: @@ -419,11 +447,14 @@ public class Characteristic { break; case LOCK_MECHANISM_LAST_KNOWN_ACTION: - // TODO numeric enum 9 states + itemType = CoreItemFactory.STRING; + dataType = "number"; + category = "lock"; break; case LOCK_PHYSICAL_CONTROLS: itemType = CoreItemFactory.SWITCH; + propertyTag = Property.ENABLED; category = "lock"; break; @@ -434,7 +465,8 @@ public class Characteristic { break; case LOCK_MECHANISM_TARGET_STATE: - // TODO numeric enum 4 states + itemType = CoreItemFactory.SWITCH; + dataType = "number"; propertyTag = Property.LOCK_STATE; category = "lock"; break; @@ -475,6 +507,7 @@ public class Characteristic { case OCCUPANCY_DETECTED: itemType = CoreItemFactory.CONTACT; + dataType = "number"; propertyTag = Property.PRESENCE; break; @@ -495,25 +528,30 @@ public class Characteristic { break; case POSITION_HOLD: + // TODO "stop" command for a roller shutter itemType = CoreItemFactory.SWITCH; + propertyTag = Property.OPENING; break; case POSITION_CURRENT: + itemType = CoreItemFactory.ROLLERSHUTTER; propertyTag = Property.OPENING; break; case POSITION_STATE: - // TODO numeric enum 3 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.OPENING; break; case POSITION_TARGET: - itemType = CoreItemFactory.DIMMER; + itemType = CoreItemFactory.ROLLERSHUTTER; propertyTag = Property.OPENING; break; case PROGRAM_MODE: - // TODO numeric enum 3 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; break; @@ -531,10 +569,12 @@ public class Characteristic { uom = "s"; numberSuffix = "Duration"; propertyTag = Property.DURATION; + category = "time"; break; case ROTATION_DIRECTION: itemType = CoreItemFactory.SWITCH; + dataType = "number"; propertyTag = Property.MODE; break; @@ -551,12 +591,14 @@ public class Characteristic { case SECURITY_SYSTEM_ALARM_TYPE: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.ALARM; break; case SECURITY_SYSTEM_STATE_CURRENT: case SECURITY_SYSTEM_STATE_TARGET: - // TODO numeric enum 4 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.MODE; break; @@ -584,12 +626,14 @@ public class Characteristic { break; case SLAT_STATE_CURRENT: - // TODO numeric enum 3 states + itemType = CoreItemFactory.STRING; + dataType = "number"; propertyTag = Property.TILT; break; case SMOKE_DETECTED: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.ALARM; propertyTag = Property.SMOKE; category = "smoke"; @@ -602,17 +646,20 @@ public class Characteristic { case STATUS_FAULT: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.ALARM; break; case STATUS_JAMMED: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.ALARM; propertyTag = Property.OPENING; break; case STATUS_LO_BATT: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.ALARM; propertyTag = Property.LOW_BATTERY; category = "battery"; @@ -620,12 +667,13 @@ public class Characteristic { case STATUS_TAMPERED: itemType = CoreItemFactory.CONTACT; + dataType = "number"; pointTag = Point.ALARM; propertyTag = Property.TAMPERED; break; case STREAMING_STATUS: - propertyTag = Property.MEDIA_CONTROL; + itemType = null; break; case SUPPORTED_AUDIO_CONFIGURATION: @@ -638,6 +686,7 @@ public class Characteristic { case SWING_MODE: itemType = CoreItemFactory.SWITCH; + dataType = "number"; propertyTag = Property.AIRFLOW; break; @@ -662,6 +711,7 @@ public class Characteristic { case TILT_TARGET: numberSuffix = "Angle"; propertyTag = Property.TILT; + category = "rollershutter"; break; case TYPE_SLAT: @@ -674,6 +724,7 @@ public class Characteristic { case VERTICAL_TILT_TARGET: numberSuffix = "Angle"; propertyTag = Property.TILT; + category = "rollershutter"; break; case VOLUME: @@ -685,6 +736,7 @@ public class Characteristic { case WATER_LEVEL: numberSuffix = "Dimensionless"; propertyTag = Property.WATER; + category = "water"; break; case ZOOM_DIGITAL: @@ -802,14 +854,7 @@ public class Characteristic { Map props = new HashMap<>(); Optional.ofNullable(iid).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_IID, s)); Optional.ofNullable(format).ifPresent(s -> props.put(PROPERTY_FORMAT, s)); - Optional.ofNullable(boolType).ifPresent(s -> props.put(PROPERTY_BOOL_TYPE, s)); - - // Optional.ofNullable(minValue).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_MIN_VALUE, s)); - // Optional.ofNullable(maxValue).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_MAX_VALUE, s)); - // Optional.ofNullable(minStep).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_MIN_STEP, s)); - // Optional.ofNullable(uom).ifPresent(s -> props.put(PROPERTY_UNIT, s)); - // Optional.ofNullable(perms).map(l -> String.join(",", l)).ifPresent(s -> props.put(PROPERTY_PERMS, s)); - // Optional.ofNullable(ev).map(b -> b.toString()).ifPresent(s -> props.put(PROPERTY_EV, s)); + Optional.ofNullable(dataType).ifPresent(s -> props.put(PROPERTY_DATA_TYPE, s)); return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), channelTypeUid).withProperties(props) .withLabel(getChannelInstanceLabel()).build(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 48b8f674af864..25d43fef30214 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -43,7 +43,9 @@ import org.openhab.core.library.types.OpenClosedType; import org.openhab.core.library.types.PercentType; import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StopMoveType; import org.openhab.core.library.types.StringType; +import org.openhab.core.library.types.UpDownType; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; @@ -60,6 +62,7 @@ import org.openhab.core.types.State; import org.openhab.core.types.StateDescription; import org.openhab.core.types.StateDescriptionFragment; +import org.openhab.core.types.StateOption; import org.openhab.core.types.UnDefType; import org.openhab.core.types.util.UnitUtils; import org.slf4j.Logger; @@ -144,6 +147,21 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { StateDescription stateDescription = getStateDescription(channel); + // process Rollershutter commands + if (CoreItemFactory.ROLLERSHUTTER.equals(channel.getAcceptedItemType())) { + if (object instanceof PercentType percent) { + object = new PercentType(100 - percent.intValue()); + } else if (object instanceof OnOffType onOff) { + object = onOff == OnOffType.ON ? PercentType.ZERO : PercentType.HUNDRED; + } else if (object instanceof OpenClosedType openClosed) { + object = openClosed == OpenClosedType.OPEN ? PercentType.ZERO : PercentType.HUNDRED; + } else if (object instanceof UpDownType upDown) { + object = upDown == UpDownType.UP ? PercentType.ZERO : PercentType.HUNDRED; + } else if (object instanceof StopMoveType stopMove && stopMove == StopMoveType.STOP) { + // TODO forward as a command to the POSITION HOLD characteristic (if existing) + } + } + // convert QuantityTypes to the characteristic's unit if (object instanceof QuantityType quantity) { if (stateDescription != null @@ -157,6 +175,23 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { } } + // convert StringType enums to integers + if (object instanceof StringType stringType) { + if (stateDescription != null && stateDescription.getOptions() instanceof List stateOptions) { + for (StateOption option : stateOptions) { + if (stringType.toString().equals(option.getLabel())) { + String val = option.getValue(); + try { + object = Integer.parseInt(val); + } catch (NumberFormatException e) { + logger.warn("Unexpected state option value {} for channel {}", val, channel.getUID(), e); + } + break; + } + } + } + } + if (object instanceof Number number) { // clamp numbers to characteristic's min/max limits if (stateDescription != null && stateDescription.getMinimum() instanceof BigDecimal min @@ -200,10 +235,10 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { object = dateTime.toFullString(); } - // comply with the characteristic's boolean data type + // comply with the characteristic's data type if (object instanceof Boolean bool - && channel.getProperties().get(PROPERTY_BOOL_TYPE) instanceof String booleanDataType) { - switch (booleanDataType) { + && channel.getProperties().get(PROPERTY_DATA_TYPE) instanceof String dataType) { + switch (dataType) { case "number" -> object = Integer.valueOf(bool ? 1 : 0); case "string" -> object = bool ? "true" : "false"; } @@ -252,7 +287,8 @@ private State convertJsonToState(JsonElement element, Channel channel) { case CoreItemFactory.SWITCH -> OnOffType.from(value.getAsInt() != 0); case CoreItemFactory.CONTACT -> value.getAsInt() != 0 ? OpenClosedType.OPEN : OpenClosedType.CLOSED; case CoreItemFactory.DIMMER -> new PercentType(value.getAsInt()); - case CoreItemFactory.ROLLERSHUTTER -> new PercentType(value.getAsInt()); + // convert HomeKit open percent to roller shutter closed percent + case CoreItemFactory.ROLLERSHUTTER -> new PercentType(100 - value.getAsInt()); case CoreItemFactory.NUMBER -> new DecimalType(value.getAsNumber()); default -> { if (acceptedItemType.startsWith(CoreItemFactory.NUMBER)) { From 1f840fa2471d902bc668bebdaa38b99d02422e72 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 19 Oct 2025 17:08:42 +0100 Subject: [PATCH 069/177] create unique thing labels Signed-off-by: Andrew Fiddian-Green --- .../internal/discovery/HomekitChildDiscoveryService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 96c6a26b6b15f..61f09e7a6c419 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -66,9 +66,10 @@ private void discoverChildren(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { if (accessory.aid instanceof Integer aid && aid != 1 && accessory.services != null) { ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), aid.toString()); + String thingLabel = "%s (%d)".formatted(accessory.getAccessoryInstanceLabel(), accessory.aid); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // - .withLabel(THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), bridge.getLabel())) // + .withLabel(THING_LABEL_FMT.formatted(thingLabel, bridge.getLabel())) // .withProperty(CONFIG_HOST, "n/a") // .withProperty(CONFIG_PAIRING_CODE, "n/a") // .withProperty(PROPERTY_ACCESSORY_UID, uid.toString()) // From 59dd44708f8fa86113c443660c6cd239d44172a8 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 19 Oct 2025 17:48:06 +0100 Subject: [PATCH 070/177] add velux junit test stub Signed-off-by: Andrew Fiddian-Green --- ...a => TestChannelCreationForAppleJson.java} | 4 +- .../TestChannelCreationForVeluxJson.java | 1601 +++++++++++++++++ 2 files changed, 1603 insertions(+), 2 deletions(-) rename bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/{TestChannelCreation.java => TestChannelCreationForAppleJson.java} (99%) create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java similarity index 99% rename from bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java rename to bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java index a8d25e4e89136..612f537003a5c 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreation.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java @@ -42,12 +42,12 @@ import com.google.gson.JsonElement; /** - * Test cases for loading channel creation data from JSON. + * Test cases for loading channel creation data from JSON provided in the Apple specification. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -class TestChannelCreation { +class TestChannelCreationForAppleJson { // Apple HomeKit Specification Chapter 6.6.4 Example Accessory Attribute Database in JSON private static final String TEST_JSON = """ diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java new file mode 100644 index 0000000000000..a0aa95d3fc162 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -0,0 +1,1601 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.homekit.internal.dto.Accessories; +import org.openhab.binding.homekit.internal.dto.Accessory; +import org.openhab.binding.homekit.internal.dto.Characteristic; +import org.openhab.binding.homekit.internal.dto.Service; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.thing.type.ChannelGroupType; +import org.openhab.core.thing.type.ChannelType; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; + +/** + * Test cases for loading channel creation data from JSON provided by a Velux KIG 300. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestChannelCreationForVeluxJson { + + // Velux KIG 300 JSON dump + private static final String TEST_JSON = """ + { + "accessories": [ + { + "aid": 1, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Gateway" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Gateway" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "g373a63" + }, + { + "type": "14", + "iid": 6, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 7, + "perms": [ + "pr" + ], + "format": "string", + "value": "202.0.0" + }, + { + "type": "220", + "iid": 13, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "A2", + "iid": 8, + "characteristics": [ + { + "type": "37", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "1.1.0" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "EA22EA53-6227-55EA-AC24-73ACF3EEA0E8", + "iid": 65535, + "characteristics": [ + { + "type": "4D05AE82-5A22-5BD6-A730-B7F8B4F3218D", + "iid": 32, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "00F44C18-042E-5C4E-9A4C-561D44DCD804", + "iid": 30, + "perms": [ + "pr" + ], + "format": "string", + "value": "g373a63" + } + ], + "hidden": true, + "primary": false + } + ] + }, + { + "aid": 2, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Sensor" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Sensor" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "p005519" + }, + { + "type": "14", + "iid": 7, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 6, + "perms": [ + "pr" + ], + "format": "string", + "value": "16.0.0" + }, + { + "type": "220", + "iid": 18, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "8A", + "iid": 8, + "characteristics": [ + { + "type": "23", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "Temperature sensor" + }, + { + "type": "11", + "iid": 10, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "value": 18.4, + "minValue": 0.0, + "maxValue": 50.0, + "minStep": 0.1, + "unit": "celsius" + } + ], + "hidden": false, + "primary": true + }, + { + "type": "82", + "iid": 11, + "characteristics": [ + { + "type": "23", + "iid": 12, + "perms": [ + "pr" + ], + "format": "string", + "value": "Humidity sensor" + }, + { + "type": "10", + "iid": 13, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "value": 65.0, + "minValue": 0.0, + "maxValue": 100.0, + "minStep": 1.0, + "unit": "percentage" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "97", + "iid": 14, + "characteristics": [ + { + "type": "23", + "iid": 15, + "perms": [ + "pr" + ], + "format": "string", + "value": "Carbon Dioxide sensor" + }, + { + "type": "92", + "iid": 16, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 0, + "maxValue": 1, + "minValue": 0, + "minStep": 1 + }, + { + "type": "93", + "iid": 17, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "value": 846.0, + "minValue": 0.0, + "maxValue": 5000.0 + } + ], + "hidden": false, + "primary": false + } + ] + }, + { + "aid": 3, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Sensor" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Sensor" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "p01448d" + }, + { + "type": "14", + "iid": 7, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 6, + "perms": [ + "pr" + ], + "format": "string", + "value": "16.0.0" + }, + { + "type": "220", + "iid": 18, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "8A", + "iid": 8, + "characteristics": [ + { + "type": "23", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "Temperature sensor" + }, + { + "type": "11", + "iid": 10, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "value": 19.7, + "minValue": 0.0, + "maxValue": 50.0, + "minStep": 0.1, + "unit": "celsius" + } + ], + "hidden": false, + "primary": true + }, + { + "type": "82", + "iid": 11, + "characteristics": [ + { + "type": "23", + "iid": 12, + "perms": [ + "pr" + ], + "format": "string", + "value": "Humidity sensor" + }, + { + "type": "10", + "iid": 13, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "value": 60.0, + "minValue": 0.0, + "maxValue": 100.0, + "minStep": 1.0, + "unit": "percentage" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "97", + "iid": 14, + "characteristics": [ + { + "type": "23", + "iid": 15, + "perms": [ + "pr" + ], + "format": "string", + "value": "Carbon Dioxide sensor" + }, + { + "type": "92", + "iid": 16, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 0, + "maxValue": 1, + "minValue": 0, + "minStep": 1 + }, + { + "type": "93", + "iid": 17, + "perms": [ + "pr", + "ev" + ], + "format": "float", + "value": 836.0, + "minValue": 0.0, + "maxValue": 5000.0 + } + ], + "hidden": false, + "primary": false + } + ] + }, + { + "aid": 4, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Window" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Window" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "5636132610170cda" + }, + { + "type": "14", + "iid": 7, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 6, + "perms": [ + "pr" + ], + "format": "string", + "value": "48.0.0" + }, + { + "type": "220", + "iid": 13, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "8B", + "iid": 8, + "characteristics": [ + { + "type": "23", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "Roof Window" + }, + { + "type": "7C", + "iid": 11, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "uint8", + "value": 0, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "6D", + "iid": 10, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 0, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "72", + "iid": 12, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 2, + "maxValue": 2, + "minValue": 0, + "minStep": 1 + } + ], + "hidden": false, + "primary": true + } + ] + }, + { + "aid": 5, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX External Cover" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX External Cover" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "56233d26092b0923" + }, + { + "type": "14", + "iid": 7, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 6, + "perms": [ + "pr" + ], + "format": "string", + "value": "71.0.0" + }, + { + "type": "220", + "iid": 13, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "8C", + "iid": 8, + "characteristics": [ + { + "type": "23", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "Roller Shutter" + }, + { + "type": "7C", + "iid": 11, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "uint8", + "value": 100, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "6D", + "iid": 10, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 100, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "72", + "iid": 12, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 2, + "maxValue": 2, + "minValue": 0, + "minStep": 1 + } + ], + "hidden": false, + "primary": true + } + ] + }, + { + "aid": 6, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX External Cover" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX External Cover" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "56321426101f0e39" + }, + { + "type": "14", + "iid": 7, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 6, + "perms": [ + "pr" + ], + "format": "string", + "value": "16.0.0" + }, + { + "type": "220", + "iid": 13, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "8C", + "iid": 8, + "characteristics": [ + { + "type": "23", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "Roller Shutter" + }, + { + "type": "7C", + "iid": 11, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "uint8", + "value": 100, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "6D", + "iid": 10, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 100, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "72", + "iid": 12, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 2, + "maxValue": 2, + "minValue": 0, + "minStep": 1 + } + ], + "hidden": false, + "primary": true + } + ] + }, + { + "aid": 7, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX External Cover" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX External Cover" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "56321426101e16af" + }, + { + "type": "14", + "iid": 7, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 6, + "perms": [ + "pr" + ], + "format": "string", + "value": "16.0.0" + }, + { + "type": "220", + "iid": 13, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "8C", + "iid": 8, + "characteristics": [ + { + "type": "23", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "Roller Shutter" + }, + { + "type": "7C", + "iid": 11, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "uint8", + "value": 100, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "6D", + "iid": 10, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 100, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "72", + "iid": 12, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 2, + "maxValue": 2, + "minValue": 0, + "minStep": 1 + } + ], + "hidden": false, + "primary": true + } + ] + }, + { + "aid": 8, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Window" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Window" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "5636135a103004bc" + }, + { + "type": "14", + "iid": 7, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 6, + "perms": [ + "pr" + ], + "format": "string", + "value": "48.0.0" + }, + { + "type": "220", + "iid": 13, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "8B", + "iid": 8, + "characteristics": [ + { + "type": "23", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "Roof Window" + }, + { + "type": "7C", + "iid": 11, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "uint8", + "value": 0, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "6D", + "iid": 10, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 0, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "72", + "iid": 12, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 2, + "maxValue": 2, + "minValue": 0, + "minStep": 1 + } + ], + "hidden": false, + "primary": true + } + ] + }, + { + "aid": 9, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Internal Cover" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Internal Cover" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "56251d261028006a" + }, + { + "type": "14", + "iid": 7, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 6, + "perms": [ + "pr" + ], + "format": "string", + "value": "77.0.0" + }, + { + "type": "220", + "iid": 15, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "8C", + "iid": 8, + "characteristics": [ + { + "type": "23", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "Venetian Blinds" + }, + { + "type": "7C", + "iid": 11, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "uint8", + "value": 100, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "6D", + "iid": 10, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 100, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "72", + "iid": 12, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 2, + "maxValue": 2, + "minValue": 0, + "minStep": 1 + }, + { + "type": "6C", + "iid": 13, + "perms": [ + "pr", + "ev" + ], + "format": "int", + "value": -90, + "maxValue": 90, + "minValue": -90, + "unit": "arcdegrees", + "minStep": 1 + }, + { + "type": "7B", + "iid": 14, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "int", + "value": -90, + "maxValue": 90, + "minValue": -90, + "unit": "arcdegrees", + "minStep": 1 + } + ], + "hidden": false, + "primary": true + } + ] + }, + { + "aid": 10, + "services": [ + { + "type": "3E", + "iid": 1, + "characteristics": [ + { + "type": "23", + "iid": 2, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Internal Cover" + }, + { + "type": "20", + "iid": 3, + "perms": [ + "pr" + ], + "format": "string", + "value": "Netatmo" + }, + { + "type": "21", + "iid": 4, + "perms": [ + "pr" + ], + "format": "string", + "value": "VELUX Internal Cover" + }, + { + "type": "30", + "iid": 5, + "perms": [ + "pr" + ], + "format": "string", + "value": "56251d26102d0139" + }, + { + "type": "14", + "iid": 7, + "perms": [ + "pw" + ], + "format": "bool" + }, + { + "type": "52", + "iid": 6, + "perms": [ + "pr" + ], + "format": "string", + "value": "77.0.0" + }, + { + "type": "220", + "iid": 15, + "perms": [ + "pr" + ], + "format": "data", + "value": "+nvrOv1cCQU=" + } + ], + "hidden": false, + "primary": false + }, + { + "type": "8C", + "iid": 8, + "characteristics": [ + { + "type": "23", + "iid": 9, + "perms": [ + "pr" + ], + "format": "string", + "value": "Venetian Blinds" + }, + { + "type": "7C", + "iid": 11, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "uint8", + "value": 20, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "6D", + "iid": 10, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 20, + "maxValue": 100, + "minValue": 0, + "unit": "percentage", + "minStep": 1 + }, + { + "type": "72", + "iid": 12, + "perms": [ + "pr", + "ev" + ], + "format": "uint8", + "value": 2, + "maxValue": 2, + "minValue": 0, + "minStep": 1 + }, + { + "type": "6C", + "iid": 13, + "perms": [ + "pr", + "ev" + ], + "format": "int", + "value": -90, + "maxValue": 90, + "minValue": -90, + "unit": "arcdegrees", + "minStep": 1 + }, + { + "type": "7B", + "iid": 14, + "perms": [ + "pr", + "pw", + "ev" + ], + "format": "int", + "value": -90, + "maxValue": 90, + "minValue": -90, + "unit": "arcdegrees", + "minStep": 1 + } + ], + "hidden": false, + "primary": true + } + ] + } + ] + } + """; + + private static final Gson GSON = new Gson(); + + @Test + void testGenericJsonParsing() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + assertNotNull(accessories.accessories); + assertEquals(10, accessories.accessories.size()); + for (Accessory accessory : accessories.accessories) { + assertNotNull(accessory.aid); + assertNotNull(accessory.services); + assertTrue(!accessory.services.isEmpty()); + for (var service : accessory.services) { + assertNotNull(service.type); + assertNotNull(service.iid); + assertNotNull(service.characteristics); + assertTrue(!service.characteristics.isEmpty()); + for (var characteristic : service.characteristics) { + assertNotNull(characteristic.type); + assertNotNull(characteristic.iid); + assertNotNull(characteristic.perms); + assertTrue(!characteristic.perms.isEmpty()); + assertNotNull(characteristic.format); + } + } + } + } + + @Test + void testDetailJsonParsing() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + Accessory accessory = accessories.getAccessory(1); + assertNotNull(accessory); + assertEquals(1, accessory.aid); + assertEquals(3, accessory.services.size()); + Service service = accessory.getService(1); + assertNotNull(service); + assertEquals("3E", service.type); + assertEquals(7, service.characteristics.size()); + Characteristic characteristic = service.getCharacteristic(2); + assertNotNull(characteristic); + JsonElement value = characteristic.value; + assertNotNull(value); + assertTrue(value.isJsonPrimitive()); + assertTrue(value.getAsJsonPrimitive().isString()); + String valueString = value.getAsString(); + assertEquals("VELUX Gateway", valueString); + } + + @Test + void testChannelDefinitions() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + + HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + + List channelGroupTypes = new ArrayList<>(); + List channelTypes = new ArrayList<>(); + + doAnswer(invocation -> { + ChannelGroupType arg = invocation.getArgument(0); + channelGroupTypes.add(arg); + return null; + }).when(typeProvider).putChannelGroupType(any(ChannelGroupType.class)); + + doAnswer(invocation -> { + ChannelType arg = invocation.getArgument(0); + channelTypes.add(arg); + return null; + }).when(typeProvider).putChannelType(any(ChannelType.class)); + + // TODO test channel definitions for Velux shade or window + + // TODO test channel definitions for a venetian blind with tilt support + + // TODO test channel definitions for Temperature, Humidity, and CO2 sensors + + } +} From 7e2347906770a67b13082cbda8aa325574a12b13 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 19 Oct 2025 18:21:59 +0100 Subject: [PATCH 071/177] spotless Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/TestChannelCreationForVeluxJson.java | 1 - 1 file changed, 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java index a0aa95d3fc162..49754a038d047 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -1596,6 +1596,5 @@ void testChannelDefinitions() { // TODO test channel definitions for a venetian blind with tilt support // TODO test channel definitions for Temperature, Humidity, and CO2 sensors - } } From b4290405ba5b3f6565637f245cf5352159f89483 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 20 Oct 2025 09:46:29 +0100 Subject: [PATCH 072/177] fix rollershutter button commands Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 25d43fef30214..899e081beae79 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -152,11 +152,11 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { if (object instanceof PercentType percent) { object = new PercentType(100 - percent.intValue()); } else if (object instanceof OnOffType onOff) { - object = onOff == OnOffType.ON ? PercentType.ZERO : PercentType.HUNDRED; + object = onOff == OnOffType.ON ? PercentType.HUNDRED : PercentType.ZERO; } else if (object instanceof OpenClosedType openClosed) { - object = openClosed == OpenClosedType.OPEN ? PercentType.ZERO : PercentType.HUNDRED; + object = openClosed == OpenClosedType.OPEN ? PercentType.HUNDRED : PercentType.ZERO; } else if (object instanceof UpDownType upDown) { - object = upDown == UpDownType.UP ? PercentType.ZERO : PercentType.HUNDRED; + object = upDown == UpDownType.UP ? PercentType.HUNDRED : PercentType.ZERO; } else if (object instanceof StopMoveType stopMove && stopMove == StopMoveType.STOP) { // TODO forward as a command to the POSITION HOLD characteristic (if existing) } From 2fdf314e5b5cdbeb64258a5f69f347ead7e22e84 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 20 Oct 2025 13:48:35 +0100 Subject: [PATCH 073/177] add junit intricate use cases, and fixes arising thereforom Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/dto/Characteristic.java | 54 ++- .../handler/HomekitAccessoryHandler.java | 4 +- .../TestChannelCreationForVeluxJson.java | 383 +++++++++++++++++- 3 files changed, 435 insertions(+), 6 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 031cddd0bce3c..8b1a63862cc9d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -104,6 +104,7 @@ public class Characteristic { boolean isNumberWithSuffix = false; boolean isStateChannel = true; boolean isPercentage = "percentage".equals(unit); + boolean isEnumLike = false; String uom = unit == null ? null : switch (unit) { case "celsius" -> "°C"; @@ -179,18 +180,24 @@ public class Characteristic { case AIR_PURIFIER_STATE_CURRENT: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; propertyTag = Property.MODE; + isEnumLike = true; break; case AIR_PURIFIER_STATE_TARGET: itemType = CoreItemFactory.SWITCH; dataType = "number"; + pointTag = Point.CONTROL; propertyTag = Property.ENABLED; break; case AIR_QUALITY: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; propertyTag = Property.AIR_QUALITY; break; @@ -248,6 +255,7 @@ public class Characteristic { case CHARGING_STATE: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; propertyTag = Property.MODE; category = "battery"; break; @@ -300,6 +308,8 @@ public class Characteristic { case DOOR_STATE_CURRENT: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; propertyTag = Property.OPEN_STATE; category = "door"; break; @@ -307,6 +317,7 @@ public class Characteristic { case DOOR_STATE_TARGET: itemType = CoreItemFactory.SWITCH; dataType = "number"; + pointTag = Point.CONTROL; propertyTag = Property.OPEN_STATE; category = "door"; break; @@ -314,6 +325,8 @@ public class Characteristic { case FAN_STATE_CURRENT: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; propertyTag = Property.MODE; category = "fan"; break; @@ -321,6 +334,7 @@ public class Characteristic { case FAN_STATE_TARGET: itemType = CoreItemFactory.SWITCH; dataType = "number"; + pointTag = Point.CONTROL; propertyTag = Property.MODE; category = "fan"; break; @@ -350,6 +364,8 @@ public class Characteristic { case HEATER_COOLER_STATE_TARGET: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = isReadOnly ? Point.STATUS : Point.CONTROL; propertyTag = Property.MODE; category = "heating"; break; @@ -357,6 +373,8 @@ public class Characteristic { case HEATING_COOLING_CURRENT: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; propertyTag = Property.MODE; category = "heating"; break; @@ -364,6 +382,8 @@ public class Characteristic { case HEATING_COOLING_TARGET: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = Point.CONTROL; propertyTag = Property.MODE; category = "heating"; break; @@ -385,6 +405,8 @@ public class Characteristic { case HUMIDIFIER_DEHUMIDIFIER_STATE_CURRENT: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; propertyTag = Property.MODE; category = "humidity"; break; @@ -392,6 +414,8 @@ public class Characteristic { case HUMIDIFIER_DEHUMIDIFIER_STATE_TARGET: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = Point.CONTROL; propertyTag = Property.MODE; category = "humidity"; break; @@ -409,6 +433,8 @@ public class Characteristic { case IMAGE_ROTATION: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = isReadOnly ? Point.STATUS : Point.CONTROL; propertyTag = Property.MODE; category = "image"; break; @@ -448,7 +474,9 @@ public class Characteristic { case LOCK_MECHANISM_LAST_KNOWN_ACTION: itemType = CoreItemFactory.STRING; + pointTag = Point.STATUS; dataType = "number"; + isEnumLike = true; category = "lock"; break; @@ -467,6 +495,7 @@ public class Characteristic { case LOCK_MECHANISM_TARGET_STATE: itemType = CoreItemFactory.SWITCH; dataType = "number"; + pointTag = Point.CONTROL; propertyTag = Property.LOCK_STATE; category = "lock"; break; @@ -508,6 +537,7 @@ public class Characteristic { case OCCUPANCY_DETECTED: itemType = CoreItemFactory.CONTACT; dataType = "number"; + pointTag = Point.STATUS; propertyTag = Property.PRESENCE; break; @@ -541,6 +571,8 @@ public class Characteristic { case POSITION_STATE: itemType = CoreItemFactory.STRING; dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; propertyTag = Property.OPENING; break; @@ -551,7 +583,9 @@ public class Characteristic { case PROGRAM_MODE: itemType = CoreItemFactory.STRING; + pointTag = isReadOnly ? Point.STATUS : Point.CONTROL; dataType = "number"; + isEnumLike = true; propertyTag = Property.MODE; break; @@ -561,6 +595,7 @@ public class Characteristic { case RELATIVE_HUMIDITY_TARGET: itemType = CoreItemFactory.NUMBER; numberSuffix = "Dimensionless"; + pointTag = isReadOnly ? Point.MEASUREMENT : Point.SETPOINT; propertyTag = Property.HUMIDITY; category = "humidity"; break; @@ -598,7 +633,9 @@ public class Characteristic { case SECURITY_SYSTEM_STATE_CURRENT: case SECURITY_SYSTEM_STATE_TARGET: itemType = CoreItemFactory.STRING; + pointTag = isReadOnly ? Point.STATUS : Point.CONTROL; dataType = "number"; + isEnumLike = true; propertyTag = Property.MODE; break; @@ -627,7 +664,9 @@ public class Characteristic { case SLAT_STATE_CURRENT: itemType = CoreItemFactory.STRING; + pointTag = Point.STATUS; dataType = "number"; + isEnumLike = true; propertyTag = Property.TILT; break; @@ -641,6 +680,7 @@ public class Characteristic { case STATUS_ACTIVE: itemType = CoreItemFactory.CONTACT; + pointTag = Point.STATUS; propertyTag = Property.MODE; break; @@ -687,6 +727,7 @@ public class Characteristic { case SWING_MODE: itemType = CoreItemFactory.SWITCH; dataType = "number"; + pointTag = isReadOnly ? Point.STATUS : Point.CONTROL; propertyTag = Property.AIRFLOW; break; @@ -805,12 +846,13 @@ public class Characteristic { fragBldr.withPattern("%.1f " + uom); } + // use valid values to build options for enum-like characteristics if (validValues != null && !validValues.isEmpty()) { List options = validValues.stream().map(v -> v.toString()) .map(s -> new StateOption(s, s)).toList(); fragBldr.withOptions(options); } else - // + // use valid range to build options for enum-like characteristics if (validValuesRange != null && validValuesRange.size() == 2) { int min = validValuesRange.stream().mapToInt(Integer::intValue).min().orElse(0); // size check above int max = validValuesRange.stream().mapToInt(Integer::intValue).max().orElse(0); // ditto @@ -821,6 +863,16 @@ public class Characteristic { options.add(new StateOption(s, s)); } fragBldr.withOptions(options); + } else + // some enum-like characteristics fail to declare valid values/ranges so misuse min/max/step instead + if (isEnumLike && minValue instanceof Double min && maxValue instanceof Double max && max > min + && minStep instanceof Double step && step > 0) { + List options = new ArrayList<>(); + for (int i = min.intValue(); i <= max.intValue(); i += step.intValue()) { + String s = Integer.toString(i); + options.add(new StateOption(s, s)); + } + fragBldr.withOptions(options); } } StateDescriptionFragment stateDescriptionFragment = fragBldr.build(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 899e081beae79..6d99d3647426b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -158,7 +158,9 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { } else if (object instanceof UpDownType upDown) { object = upDown == UpDownType.UP ? PercentType.HUNDRED : PercentType.ZERO; } else if (object instanceof StopMoveType stopMove && stopMove == StopMoveType.STOP) { - // TODO forward as a command to the POSITION HOLD characteristic (if existing) + // TODO handle stop command -- either + // a) command POSITION HOLD '6F' characteristic (if existing) or + // b) move to the current position } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java index 49754a038d047..6acfcacd7011e 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -15,9 +15,13 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.FAKE_PROPERTY_CHANNEL_TYPE_UID; +import java.math.BigDecimal; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; @@ -25,9 +29,16 @@ import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.dto.Service; +import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.type.ChannelDefinition; +import org.openhab.core.thing.type.ChannelGroupDefinition; import org.openhab.core.thing.type.ChannelGroupType; +import org.openhab.core.thing.type.ChannelKind; import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.types.StateDescription; +import org.openhab.core.types.StateOption; import com.google.gson.Gson; import com.google.gson.JsonElement; @@ -1570,7 +1581,7 @@ void testDetailJsonParsing() { } @Test - void testChannelDefinitions() { + void testBridge() { Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); assertNotNull(accessories); @@ -1591,10 +1602,374 @@ void testChannelDefinitions() { return null; }).when(typeProvider).putChannelType(any(ChannelType.class)); - // TODO test channel definitions for Velux shade or window + // get the accessory information for the bridge (accessory 1) and create properties from it + ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory1"); + Accessory accessory = accessories.getAccessory(1); + assertNotNull(accessory); + Map properties = new HashMap<>(); + for (Service service : accessory.services) { + if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { + for (Characteristic characteristic : service.characteristics) { + ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thingUID, + typeProvider); + if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { + String name = channelDef.getId(); + String value = channelDef.getLabel(); + if (value != null) { + properties.put(name, value); + } + } + } + break; + } + } + + // there should be five properties + assertEquals(5, properties.size()); + assertEquals("VELUX Gateway", properties.get("name")); + assertEquals("Netatmo", properties.get("manufacturer")); + assertEquals("g373a63", properties.get("serialNumber")); + assertEquals("VELUX Gateway", properties.get("model")); + assertEquals("202.0.0", properties.get("firmwareRevision")); + } + + @Test + void testSensors() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + + HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + + List channelGroupTypes = new ArrayList<>(); + List channelTypes = new ArrayList<>(); + + doAnswer(invocation -> { + ChannelGroupType arg = invocation.getArgument(0); + channelGroupTypes.add(arg); + return null; + }).when(typeProvider).putChannelGroupType(any(ChannelGroupType.class)); + + doAnswer(invocation -> { + ChannelType arg = invocation.getArgument(0); + channelTypes.add(arg); + return null; + }).when(typeProvider).putChannelType(any(ChannelType.class)); + + // test channel definitions for Temperature, Humidity, and CO2 sensors + ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory2"); + Accessory accessory = accessories.getAccessory(2); + assertNotNull(accessory); + List channelGroupDefinitions = accessory + .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider); + + // There should be three channel group definitions for the temperature, humidity and co2 sensors + assertNotNull(channelGroupDefinitions); + assertEquals(3, channelGroupDefinitions.size()); + + // There should be four channel types for the temperature, humidity, co2 sensors and co2 detector + assertEquals(4, channelTypes.size()); + + // There should be three channel group types for the temperature, humidity and co2 sensors + assertEquals(3, channelGroupTypes.size()); + + // check the temperature sensor + ChannelGroupType groupType = channelGroupTypes.get(0); + assertNotNull(groupType); + + // Check the temperature sensor channel definition and properties + ChannelDefinition channelDefinition = groupType.getChannelDefinitions().stream() + .filter(cd -> "10".equals(cd.getProperties().get("iid"))).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Temperature Current", channelDefinition.getLabel()); + + ChannelType channelType = channelTypes.stream().filter(ct -> "Temperature Current".equals(ct.getLabel())) + .findFirst().orElse(null); + assertNotNull(channelType); + assertEquals("Number:Temperature", channelType.getItemType()); + assertEquals(ChannelKind.STATE, channelType.getKind()); + assertTrue(channelType.getTags().contains("Temperature")); + assertTrue(channelType.getTags().contains("Measurement")); + assertEquals("°C", channelType.getUnitHint()); + StateDescription state = channelType.getState(); + assertNotNull(state); + BigDecimal max = state.getMaximum(); + BigDecimal min = state.getMinimum(); + BigDecimal step = state.getStep(); + assertNotNull(max); + assertNotNull(min); + assertNotNull(step); + assertEquals(50.0, max.doubleValue()); + assertEquals(0.0, min.doubleValue()); + assertEquals(0.1, step.doubleValue()); + assertTrue(state.isReadOnly()); + + // check the humidity sensor + groupType = channelGroupTypes.get(1); + assertNotNull(groupType); + + // Check the humidity sensor channel definition and properties + channelDefinition = groupType.getChannelDefinitions().stream() + .filter(cd -> "13".equals(cd.getProperties().get("iid"))).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Relative Humidity Current", channelDefinition.getLabel()); + + channelType = channelTypes.stream().filter(ct -> "Relative Humidity Current".equals(ct.getLabel())).findFirst() + .orElse(null); + assertNotNull(channelType); + assertEquals("Number:Dimensionless", channelType.getItemType()); + assertEquals(ChannelKind.STATE, channelType.getKind()); + assertTrue(channelType.getTags().contains("Humidity")); + assertTrue(channelType.getTags().contains("Measurement")); + assertEquals("%", channelType.getUnitHint()); + state = channelType.getState(); + assertNotNull(state); + max = state.getMaximum(); + min = state.getMinimum(); + step = state.getStep(); + assertNotNull(max); + assertNotNull(min); + assertNotNull(step); + assertEquals(100.0, max.doubleValue()); + assertEquals(0.0, min.doubleValue()); + assertEquals(1.0, step.doubleValue()); + assertTrue(state.isReadOnly()); + + // check the co2 sensor + groupType = channelGroupTypes.get(2); + assertNotNull(groupType); + + // Check the co2 detected channel definition and properties + channelDefinition = groupType.getChannelDefinitions().stream() + .filter(cd -> "16".equals(cd.getProperties().get("iid"))).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Carbon Dioxide Detected", channelDefinition.getLabel()); + + channelType = channelTypes.stream().filter(ct -> "Carbon Dioxide Detected".equals(ct.getLabel())).findFirst() + .orElse(null); + assertNotNull(channelType); + assertEquals("Contact", channelType.getItemType()); + assertEquals(ChannelKind.STATE, channelType.getKind()); + assertTrue(channelType.getTags().contains("Alarm")); + assertTrue(channelType.getTags().contains("CO2")); + state = channelType.getState(); + assertNotNull(state); + max = state.getMaximum(); + min = state.getMinimum(); + step = state.getStep(); + assertNotNull(max); + assertNotNull(min); + assertNotNull(step); + assertEquals(1.0, max.doubleValue()); + assertEquals(0.0, min.doubleValue()); + assertEquals(1.0, step.doubleValue()); + assertTrue(state.isReadOnly()); + + // Check the co2 level channel definition and properties + channelDefinition = groupType.getChannelDefinitions().stream() + .filter(cd -> "17".equals(cd.getProperties().get("iid"))).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Carbon Dioxide Level", channelDefinition.getLabel()); + + channelType = channelTypes.stream().filter(ct -> "Carbon Dioxide Level".equals(ct.getLabel())).findFirst() + .orElse(null); + assertNotNull(channelType); + assertEquals("Number:Dimensionless", channelType.getItemType()); + assertEquals(ChannelKind.STATE, channelType.getKind()); + assertTrue(channelType.getTags().contains("CO2")); + assertTrue(channelType.getTags().contains("Measurement")); + assertEquals("ppm", channelType.getUnitHint()); + state = channelType.getState(); + assertNotNull(state); + max = state.getMaximum(); + min = state.getMinimum(); + step = state.getStep(); + assertNotNull(max); + assertNotNull(min); + assertNull(step); + assertEquals(5000.0, max.doubleValue()); + assertEquals(0.0, min.doubleValue()); + assertTrue(state.isReadOnly()); + } + + @Test + void testVenetianBlind() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + + HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + + List channelGroupTypes = new ArrayList<>(); + List channelTypes = new ArrayList<>(); + + doAnswer(invocation -> { + ChannelGroupType arg = invocation.getArgument(0); + channelGroupTypes.add(arg); + return null; + }).when(typeProvider).putChannelGroupType(any(ChannelGroupType.class)); + + doAnswer(invocation -> { + ChannelType arg = invocation.getArgument(0); + channelTypes.add(arg); + return null; + }).when(typeProvider).putChannelType(any(ChannelType.class)); + + ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory9"); + Accessory accessory = accessories.getAccessory(9); + assertNotNull(accessory); + List channelGroupDefinitions = accessory + .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider); + + // There should be one channel group definition for the blind + assertNotNull(channelGroupDefinitions); + assertEquals(1, channelGroupDefinitions.size()); + + // There should be five channel types for position target/actual, tilt target/actual, and state + assertEquals(5, channelTypes.size()); + + // There should be one channel group type for the blind + assertEquals(1, channelGroupTypes.size()); + + // check the channels for the blind + ChannelGroupType groupType = channelGroupTypes.get(0); + assertNotNull(groupType); + + // target position channel + ChannelDefinition channelDefinition = groupType.getChannelDefinitions().stream() + .filter(cd -> "11".equals(cd.getProperties().get("iid"))).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Position Target", channelDefinition.getLabel()); + + ChannelType channelType = channelTypes.stream().filter(ct -> "Position Target".equals(ct.getLabel())) + .findFirst().orElse(null); + assertNotNull(channelType); + assertEquals("Rollershutter", channelType.getItemType()); + assertEquals(ChannelKind.STATE, channelType.getKind()); + assertTrue(channelType.getTags().contains("Control")); + assertTrue(channelType.getTags().contains("Opening")); + assertNull(channelType.getUnitHint()); + StateDescription state = channelType.getState(); + assertNotNull(state); + BigDecimal max = state.getMaximum(); + BigDecimal min = state.getMinimum(); + BigDecimal step = state.getStep(); + assertNotNull(max); + assertNotNull(min); + assertNotNull(step); + assertEquals(100.0, max.doubleValue()); + assertEquals(0.0, min.doubleValue()); + assertEquals(1.0, step.doubleValue()); + assertFalse(state.isReadOnly()); + + // current position channel + channelDefinition = groupType.getChannelDefinitions().stream() + .filter(cd -> "10".equals(cd.getProperties().get("iid"))).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Position Current", channelDefinition.getLabel()); + + channelType = channelTypes.stream().filter(ct -> "Position Current".equals(ct.getLabel())).findFirst() + .orElse(null); + assertNotNull(channelType); + assertEquals("Rollershutter", channelType.getItemType()); + assertEquals(ChannelKind.STATE, channelType.getKind()); + assertTrue(channelType.getTags().contains("Status")); + assertTrue(channelType.getTags().contains("Opening")); + assertNull(channelType.getUnitHint()); + state = channelType.getState(); + assertNotNull(state); + max = state.getMaximum(); + min = state.getMinimum(); + step = state.getStep(); + assertNotNull(max); + assertNotNull(min); + assertNotNull(step); + assertEquals(100.0, max.doubleValue()); + assertEquals(0.0, min.doubleValue()); + assertEquals(1.0, step.doubleValue()); + assertTrue(state.isReadOnly()); + + // current tilt channel + channelDefinition = groupType.getChannelDefinitions().stream() + .filter(cd -> "13".equals(cd.getProperties().get("iid"))).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Horizontal Tilt Current", channelDefinition.getLabel()); + + channelType = channelTypes.stream().filter(ct -> "Horizontal Tilt Current".equals(ct.getLabel())).findFirst() + .orElse(null); + assertNotNull(channelType); + assertEquals("Number:Angle", channelType.getItemType()); + assertEquals(ChannelKind.STATE, channelType.getKind()); + assertTrue(channelType.getTags().contains("Measurement")); + assertTrue(channelType.getTags().contains("Tilt")); + assertEquals("°", channelType.getUnitHint()); + state = channelType.getState(); + assertNotNull(state); + max = state.getMaximum(); + min = state.getMinimum(); + step = state.getStep(); + assertNotNull(max); + assertNotNull(min); + assertNotNull(step); + assertEquals(90.0, max.doubleValue()); + assertEquals(-90.0, min.doubleValue()); + assertEquals(1.0, step.doubleValue()); + assertTrue(state.isReadOnly()); + + // target tilt channel + channelDefinition = groupType.getChannelDefinitions().stream() + .filter(cd -> "14".equals(cd.getProperties().get("iid"))).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Horizontal Tilt Target", channelDefinition.getLabel()); + + channelType = channelTypes.stream().filter(ct -> "Horizontal Tilt Target".equals(ct.getLabel())).findFirst() + .orElse(null); + assertNotNull(channelType); + assertEquals("Number:Angle", channelType.getItemType()); + assertEquals(ChannelKind.STATE, channelType.getKind()); + assertTrue(channelType.getTags().contains("Setpoint")); + assertTrue(channelType.getTags().contains("Tilt")); + assertEquals("°", channelType.getUnitHint()); + state = channelType.getState(); + assertNotNull(state); + max = state.getMaximum(); + min = state.getMinimum(); + step = state.getStep(); + assertNotNull(max); + assertNotNull(min); + assertNotNull(step); + assertEquals(90.0, max.doubleValue()); + assertEquals(-90.0, min.doubleValue()); + assertEquals(1.0, step.doubleValue()); + assertFalse(state.isReadOnly()); - // TODO test channel definitions for a venetian blind with tilt support + // position status channel + channelDefinition = groupType.getChannelDefinitions().stream() + .filter(cd -> "12".equals(cd.getProperties().get("iid"))).findFirst().orElse(null); + assertNotNull(channelDefinition); + assertEquals("Position State", channelDefinition.getLabel()); - // TODO test channel definitions for Temperature, Humidity, and CO2 sensors + channelType = channelTypes.stream().filter(ct -> "Position State".equals(ct.getLabel())).findFirst() + .orElse(null); + assertNotNull(channelType); + assertEquals("String", channelType.getItemType()); + assertEquals(ChannelKind.STATE, channelType.getKind()); + assertTrue(channelType.getTags().contains("Status")); + assertTrue(channelType.getTags().contains("Opening")); + assertNull(channelType.getUnitHint()); + state = channelType.getState(); + assertNotNull(state); + max = state.getMaximum(); + min = state.getMinimum(); + step = state.getStep(); + assertNotNull(max); + assertNotNull(min); + assertNotNull(step); + assertEquals(2.0, max.doubleValue()); + assertEquals(0.0, min.doubleValue()); + assertEquals(1.0, step.doubleValue()); + assertTrue(state.isReadOnly()); + List options = state.getOptions(); + assertNotNull(options); + assertEquals(3, options.size()); + assertEquals("2", options.get(2).getValue()); } } From 242054ebac421e41d8896e3551508d79b91484bd Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 20 Oct 2025 21:44:01 +0100 Subject: [PATCH 074/177] work towards using light state machine Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 1 + .../homekit/internal/dto/Characteristic.java | 5 + .../handler/HomekitAccessoryHandler.java | 73 + .../internal/temporary/LightModel.java | 1458 +++++++++++++++++ 4 files changed, 1537 insertions(+) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index c4769aba4bed6..9a3bc8b935cbe 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -60,6 +60,7 @@ public class HomekitBindingConstants { public static final String PROPERTY_IID = "iid"; public static final String PROPERTY_FORMAT = "format"; public static final String PROPERTY_DATA_TYPE = "dataType"; + public static final String PROPERTY_CHARACTERISTIC_TYPE = "characteristicType"; // HomeKit HTTP URI endpoints and content types public static final String ENDPOINT_ACCESSORIES = "/accessories"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 8b1a63862cc9d..bd3323c72566e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -904,6 +904,7 @@ public class Characteristic { * so we create and return a channel definition containing this information. */ Map props = new HashMap<>(); + Optional.ofNullable(type).ifPresent(s -> props.put(PROPERTY_CHARACTERISTIC_TYPE, s)); Optional.ofNullable(iid).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_IID, s)); Optional.ofNullable(format).ifPresent(s -> props.put(PROPERTY_FORMAT, s)); Optional.ofNullable(dataType).ifPresent(s -> props.put(PROPERTY_DATA_TYPE, s)); @@ -921,6 +922,10 @@ private String getChannelInstanceLabel() { } public @Nullable CharacteristicType getCharacteristicType() { + return getCharacteristicType(type); + } + + public static @Nullable CharacteristicType getCharacteristicType(String type) { try { // convert "00000113-0000-1000-8000-0026BB765291" to "00000113" String firstPart = type.split("-")[0]; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 6d99d3647426b..38a4e9c37c5be 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -31,10 +32,14 @@ import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.dto.Service; +import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.DataFormatType; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.temporary.LightModel; +import org.openhab.binding.homekit.internal.temporary.LightModel.LightCapabilities; +import org.openhab.binding.homekit.internal.temporary.LightModel.RgbDataType; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DecimalType; @@ -83,10 +88,14 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { private static final int INITIAL_DELAY_SECONDS = 2; + private static final Set LIGHTING_CHANNELS = Set.of(CharacteristicType.COLOR_TEMPERATURE, + CharacteristicType.SATURATION, CharacteristicType.BRIGHTNESS, CharacteristicType.HUE); + private final Logger logger = LoggerFactory.getLogger(HomekitAccessoryHandler.class); private final ChannelTypeRegistry channelTypeRegistry; private final ChannelGroupTypeRegistry channelGroupTypeRegistry; + private @Nullable LightModel lightModel; private @Nullable ScheduledFuture refreshTask; public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, @@ -390,6 +399,14 @@ private void createChannels() { "+++++Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), channel.getKind(), channel.getLabel(), channel.getProperties(), channel.getUID()); + + // initialise the light model when appropriate + if (channel.getProperties().get(PROPERTY_CHARACTERISTIC_TYPE) instanceof String cxProp + && Characteristic.getCharacteristicType( + cxProp) instanceof CharacteristicType characteristicType + && LIGHTING_CHANNELS.contains(characteristicType)) { + initializeLightModel(characteristicType, channelType); + } } } }); @@ -417,6 +434,62 @@ private void createChannels() { } } + /** + * Initializes or updates the light model based on the characteristic type and channel type. + * Upgrades the light capabilities of the model as necessary when encountering color-related characteristics. + * + * @param characteristicType the type of characteristic being processed + * @param channelType the channel type associated with the characteristic + */ + @SuppressWarnings("incomplete-switch") + private void initializeLightModel(CharacteristicType characteristicType, ChannelType channelType) { + LightModel lightModel = this.lightModel; + if (lightModel == null) { + lightModel = new LightModel(LightCapabilities.BRIGHTNESS, RgbDataType.DEFAULT, null, null, null, null, null, + null); + this.lightModel = lightModel; + } + + LightCapabilities oldCaps = lightModel.configGetLightCapabilities(); + switch (characteristicType) { + // if channel is hue or saturation, upgrade to support color + case HUE: + case SATURATION: + switch (oldCaps) { + case BRIGHTNESS: + LightCapabilities newCaps = LightCapabilities.COLOR; + lightModel.configSetLightCapabilities(newCaps); + break; + case BRIGHTNESS_WITH_COLOR_TEMPERATURE: + newCaps = LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE; + lightModel.configSetLightCapabilities(newCaps); + } + break; + + // if channel is color temperature, upgrade to support color temperature + case COLOR_TEMPERATURE: + switch (oldCaps) { + case BRIGHTNESS: + LightCapabilities newCaps = LightCapabilities.BRIGHTNESS_WITH_COLOR_TEMPERATURE; + lightModel.configSetLightCapabilities(newCaps); + break; + case COLOR: + newCaps = LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE; + lightModel.configSetLightCapabilities(newCaps); + } + + // set the mirek limits based on the channel's state description + StateDescription state = channelType.getState(); + if (state != null) { + if (state.getMinimum() instanceof BigDecimal min) { + lightModel.configSetMirekControlCoolest(min.doubleValue()); + } else if (state.getMaximum() instanceof BigDecimal max) { + lightModel.configSetMirekControlWarmest(max.doubleValue()); + } + } + } + } + @Override public void handleCommand(ChannelUID channelUID, Command command) { Channel channel = thing.getChannel(channelUID); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java new file mode 100644 index 0000000000000..c147fd39af8dc --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java @@ -0,0 +1,1458 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.temporary; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.IncreaseDecreaseType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.openhab.core.util.ColorUtil; + +/** + * NOTE: This class is a temporary copy of the proposed OH Core Light Model. It is introduced here as a proof + * of concept until such time as the OH Core Light Model is available to be used directly. + * + * The {@link LightModel} provides a state machine model for maintaining and modifying the state of a light, + * which is intended to be used within the Thing Handler of a lighting binding. + *

+ * + * It supports lights with different capabilities, including: + *

    + *
  • On/Off only
  • + *
  • On/Off with Brightness
  • + *
  • On/Off with Brightness and Color Temperature
  • + *
  • On/Off with Brightness and Color (HSB, RGB, or CIE XY)
  • + *
  • On/Off with Brightness, Color Temperature, and Color
  • + *
+ * It maintains an internal representation of the state of the light. + * It provides methods to handle commands from openHAB and to update the state from the remote light. + * It also provides configuration methods to set the capabilities and parameters of the light. + * The state machine maintains a consistent state, ensuring that the On/Off state is derived from the + * brightness, and that the color temperature and color are only set if the capabilities are supported. + * It also provides utility methods to convert between different color representations. + *

+ * See also {@link ColorUtil} for other color conversions. + *

+ * To use the model you must initialize the {@link #lightCapabilities} during initialization as follows: + *

    + *
  • ON_OFF: if the light is on-off only.
  • + *
  • BRIGHTNESS: if the light is on-off with brightness.
  • + *
  • BRIGHTNESS_WITH_COLOR_TEMPERATURE: if the light is on-off with color temperature control.
  • + *
  • COLOR: if the light is on-off with brightness and full and color control.
  • + *
  • COLOR_WITH_COLOR_TEMPERATURE: if the light is on-off with brightness, full color, and color temperature + * control.
  • + *
+ * Also set {@link #rgbDataType} to the chosen RGB data type RGB, RGBW, RGBCW etc. + * And optionally set the following configuration parameters: + *
    + *
  • Optionally override {@link #minimumOnBrightness} to a minimum brightness percent in the range [0.1..10.0] + * percent, to consider as being "ON". The default is 1 percent.
  • + *
  • Optionally override {@link #mirekControlWarmest} to a 'warmest' white color temperature in the range + * [{@link #mirekControlCoolest}..1000.0] Mirek/Mired. The default is 500 Mirek/Mired.
  • + *
  • Optionally override {@link #mirekControlCoolest} to a 'coolest' white color temperature in the range + * [100.0.. {@link #mirekControlWarmest}] Mirek/Mired. The default is 153 Mirek/Mired.
  • + *
  • Optionally override {@link #stepSize} to a step size for the IncreaseDecreaseType commands in the range + * [1.0..50.0] percent. The default is 10.0 percent.
  • + *
+ *

+ * The model specifically handles the following "exotic" cases: + *

    + *
  1. It handles inter relationships between the brightness PercentType state, the 'B' part of the HSBType state, and + * the OnOffType state. Where if the brightness goes below the configured {@link #minimumOnBrightness} level the on/off + * state changes from ON to OFF, and the brightness is clamped to 0%. And analogously if the on/off state changes from + * OFF to ON, the brightness changes from 0% to its last non zero value.
  2. + *
  3. It handles IncreaseDecreaseType commands to change the brightness up or down by the configured + * {@link #stepSize}, and ensures that the brightness is clamped in the range [0%..100%].
  4. + *
  5. It handles both color temperature PercentType states and QuantityType states (which may be either in Mirek/Mired + * or Kelvin). Where color temperature PercentType values are internally converted to Mirek/Mired values on the + * percentage scale between the configured {@link #mirekControlCoolest} and {@link #mirekControlWarmest} Mirek/Mired + * values, and vice versa.
  6. + *
  7. When the color temperature changes then the HS values are adapted to match the corresponding color temperature + * point on the Planckian Locus in the CIE color chart.
  8. + *
  9. It handles input/output values in RGB format in the range [0..255]. The behavior depends on the + * {@link #rgbDataType} setting. If {@link #rgbDataType} is DEFAULT the RGB values read/write all three parts of the + * HSBType state. Whereas if it is {@link #rgbDataType} is RGB_NO_BRIGHTNESS the RGB values read/write only + * the 'HS' parts. NOTE: in the latter case, a 'setRGBx()' call followed by a 'getRGBx()' call do not necessarily return + * the same values, since the values are normalized to 100%. Neverthless the ratios between the RGB values do remain + * unchanged.
  10. + *
  11. If {@link #rgbDataType} is RGB_W it handles values in RGBW format. The behavior is similar to the RGB case above + * except that the white channel is derived from the lowest of the RGB values.
  12. + *
  13. If {@link #rgbDataType} is RGB_C_W it handles values in RGBCW format. The behavior is similar to the RGBW case + * above except that the white channel is derived from the RGB values by a custom algorithm.
  14. + *
+ *

+ * A typical use case is within in a ThingHandler as follows: + * + *

+ * {@code
+ * public class LightModelHandler extends BaseThingHandler {
+ *
+ *     // initialize the light model with default capabilities and parameters
+ *     private final LightModel model = new LightModel();
+ *
+ *     @Override
+ *     public void initialize() {
+ *       // Set up the light state machine capabilities.
+ *       model.configSetLightCapabilities(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE);
+ *
+ *       // Optionally: set up the light state machine configuration parameters.
+ *       // These would typically be read from the thing configuration or read from the remote device.
+ *       model.configSetRgbDataType(RgbDataType.RGB_NO_BRIGHTNESS); // RGB data type
+ *       model.configSetMinimumOnBrightness(2); // minimum brightness level when on 2%
+ *       model.configSetIncreaseDecreaseStep(10); // step size for increase/decrease commands
+ *       model.configSetMirekControlCoolest(153); // color temperature control range
+ *       model.configSetMirekControlWarmest(500); // color temperature control range
+ *
+ *       // Optionally: if the light has warm and cool white LEDS then set up their LED color temperatures.
+ *       // These would typically be read from the thing configuration or read from the remote device.
+ *       model.configSetMirekCoolWhiteLED(153);
+ *       model.configSetMirekWarmWhiteLED(500);
+ *
+ *       // now set the status to UNKNOWN to indicate that we are initialized
+ *       updateStatus(ThingStatus.UNKNOWN);
+ *     }
+ *
+ *     @Override
+ *     public void handleCommand(ChannelUID channelUID, Command command) {
+ *         // update the model state based on a command from OpenHAB
+ *         model.handleCommand(command);
+ *
+ *         // or if it is a color temperature command
+ *         model.handleColorTemperatureCommand(command);
+ *
+ *         sendBindingSpecificCommandToUpdateRemoteLight(
+ *              .. model.getOnOff() or
+ *              .. model.getBrightness() or
+ *              .. model.getColor() or
+ *              .. model.getColorTemperature() or
+ *              .. model.getColorTemperaturePercent() or
+ *              .. model.getRGBx() or
+ *              .. model.getXY() or
+ *         );
+ *     }
+ *
+ *     // method that sends the updated state data to the remote light
+ *     private void sendBindingSpecificCommandToUpdateRemoteLight(..) {
+ *       // binding specific code
+ *     }
+ *
+ *     // method that receives data from remote light, and updates the model, and then OH
+ *     private void receiveBindingSpecificDataFromRemoteLight(double... receivedData) {
+ *         // update the model state based on the data received from the remote
+ *         model.setBrightness(receivedData[0]);
+ *         model.setRGBx(receivedData[1], receivedData[2], receivedData[3]);
+ *         model.setMirek(receivedData[4]);
+ *
+ *         // update the OH channels with the new state values
+ *         updateState(onOffChannelUID, model.getOnOff());
+ *         updateState(brightnessChannelUID, model.getBrightness());
+ *         updateState(colorChannelUID, model.getColor());
+ *         updateState(colorTemperatureChannelUID, model.getColorTemperature());
+ *     }
+ * }
+ * }
+ * 
+ * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class LightModel { + + /********************************************************************************* + * SECTION: Common Enumerators for light capabilities. + *********************************************************************************/ + + /** + * Enum for the capabilities of different types of lights + *

+ * Different brands of light support different capabilities. Some only support on-off, some support + * brightness, some support color temperature, and some support full color. This enum + * defines the different combinations of capabilities that a light may support. + */ + public static enum LightCapabilities { + ON_OFF, // on-off only + BRIGHTNESS, // on-off with brightness + BRIGHTNESS_WITH_COLOR_TEMPERATURE, // on-off with brightness and color temperature + COLOR, // on-off with brightness and color + COLOR_WITH_COLOR_TEMPERATURE; // on-off with brightness, color and color temperature + + public boolean supportsBrightness() { + return this != ON_OFF; + } + + public boolean supportsColor() { + return this == COLOR || this == COLOR_WITH_COLOR_TEMPERATURE; + } + + public boolean supportsColorTemperature() { + return this == BRIGHTNESS_WITH_COLOR_TEMPERATURE || this == COLOR_WITH_COLOR_TEMPERATURE; + } + } + + /** + * Enum for the different types of RGB data + *

+ * Different brands of light use different types of RGB data. Some only support plain RGB, some support RGB + * with a single white channel, and some support RGB with both cold and warm white channels. Also some lights + * use their RGBx values to represent only the hue and saturation (only the HS parts), and they have another + * separate control channel for the brightness (B part). Whereby others use the RGBx values to represent the + * hue, saturation and brightness all together (all the HSB parts). + */ + public static enum RgbDataType { + DEFAULT, // supports plain RGB with brightness (i.e. full HSBType) + RGB_NO_BRIGHTNESS, // supports plain RGB but ignores brightness (i.e. only HS parts of HSBType) + RGB_W, // supports 4-element RGB with white channel + RGB_C_W // supports 5-element RGB with cold and warm white channels + } + + /** + * Enum for the LED operating mode + *

+ * Some brands of light are not able to use the RGB leds and the white led(s) at the same time. So they must + * be switched between WHITE_ONLY and RGB_ONLY mode. Whereas others lights can use any combination of RGB and + * White leds at the same time they must be switched COMBINED mode. If the mode is changed at runtime then the + * color and/or color temperature are updated to be consistent with the new mode, while keeping the brightness + * the same. If the light does not support color then the mode is forced to WHITE_ONLY. + */ + public static enum LedOperatingMode { + RGB_ONLY, // operating with RGB LEDs only + COMBINED, // operating with RGB and white LEDs together + WHITE_ONLY // operating with white LED(s) only + } + + /********************************************************************************* + * SECTION: Default Parameters. May be modified during initialization. + *********************************************************************************/ + + /** + * Minimum brightness percent to consider as light "ON" + */ + private double minimumOnBrightness = 1.0; + + /** + * The 'coolest' white color temperature in Mirek/Mired + */ + private double mirekControlCoolest = 153; + + /** + * The 'warmest' white color temperature in Mirek/Mired + */ + private double mirekControlWarmest = 500; + + /* + * Step size for IncreaseDecreaseType commands + */ + private double stepSize = 10.0; // step size for IncreaseDecreaseType commands + + /********************************************************************************* + * SECTION: Capabilities. May be modified during initialization. + *********************************************************************************/ + + /** + * The capabilities supported by the light + */ + private LightCapabilities lightCapabilities = LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE; + + /** + * The RGB data type supported + */ + private RgbDataType rgbDataType = RgbDataType.DEFAULT; + + /** + * The capabilities of the cool white LED + */ + private WhiteLED coolWhiteLed = new WhiteLED(mirekControlCoolest); + + /** + * The capabilities of warm white LED + */ + private WhiteLED warmWhiteLed = new WhiteLED(mirekControlWarmest); + + /********************************************************************************* + * SECTION: Light state variables. Used at run time only. + *********************************************************************************/ + + /** + * Cached Brightness state, never null + */ + private PercentType cachedBrightness = PercentType.ZERO; + + /** + * Cached Color state, never null + */ + private HSBType cachedHSB = new HSBType(); + + /** + * Cached Mirek/Mired state, may be NaN if not (yet) known + */ + private double cachedMirek = Double.NaN; + + /** + * Cached OnOff state, may be null if not (yet) known + */ + private @Nullable OnOffType cachedOnOff = null; + + /** + * The current operating mode of the light, default is WHITE only + */ + private LedOperatingMode ledOperatingMode = LedOperatingMode.WHITE_ONLY; + + /********************************************************************************* + * SECTION: Constructors + *********************************************************************************/ + + /** + * Create a {@link LightModel} with default capabilities and parameters as follows: + *

    + *
  • {@link #lightCapabilities} is COLOR_WITH_COLOR_TEMPERATURE (the light supports brightness control, color + * control, and color temperature control)
  • + *
  • {@link #rgbDataType} is DEFAULT (the light supports plain RGB)
  • + *
  • {@link #minimumOnBrightness} is 1.0 (the minimum brightness percent to consider as light "ON")
  • + *
  • {@link #mirekControlCoolest} is 153 (the 'coolest' white color temperature)
  • + *
  • {@link #mirekControlWarmest} is 500 (the 'warmest' white color temperature)
  • + *
  • {@link #stepSize} is 10.0 (the step size for IncreaseDecreaseType commands)
  • + *
  • coolWhiteLedMirek is 153 Mirek/Mired (the color temperature of the cool white LED)
  • + *
  • warmWhiteLedMirek is 500 Mirek/Mired (the color temperature of the warm white LED)
  • + *
+ */ + public LightModel() { + this(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, null, null, null, null, null); + } + + /** + * Create a {@link LightModel} with the given capabilities. The parameters are set to the default. + * + * @param lightCapabilities the capabilities of the light + * @param rgbDataType the type of RGB data used + */ + public LightModel(LightCapabilities lightCapabilities, RgbDataType rgbDataType) { + this(lightCapabilities, rgbDataType, null, null, null, null, null, null); + } + + /** + * Create a {@link LightModel} with the given capabilities and parameters. The parameters can be + * null to use the default. + * + * @param lightCapabilities the capabilities of the light + * @param rgbDataType the type of RGB data supported + * @param minimumOnBrightness the minimum brightness percent to consider as light "ON" + * @param mirekControlCoolest the 'coolest' white color temperature control value in Mirek/Mired + * @param mirekControlWarmest the 'warmest' white color temperature control value in Mirek/Mired + * @param stepSize the step size for IncreaseDecreaseType commands + * @param coolWhiteLedMirek the color temperature of the cool white LED + * @param warmWhiteLedMirek the color temperature of the warm white LED + * @throws IllegalArgumentException if any of the parameters are out of range + */ + public LightModel(LightCapabilities lightCapabilities, RgbDataType rgbDataType, + @Nullable Double minimumOnBrightness, @Nullable Double mirekControlCoolest, + @Nullable Double mirekControlWarmest, @Nullable Double stepSize, @Nullable Double coolWhiteLedMirek, + @Nullable Double warmWhiteLedMirek) throws IllegalArgumentException { + configSetLightCapabilities(lightCapabilities); + configSetRgbDataType(rgbDataType); + if (minimumOnBrightness != null) { + configSetMinimumOnBrightness(minimumOnBrightness); + } + if (mirekControlWarmest != null) { + configSetMirekControlWarmest(mirekControlWarmest); + } + if (mirekControlCoolest != null) { + configSetMirekControlCoolest(mirekControlCoolest); + } + if (stepSize != null) { + configSetIncreaseDecreaseStep(stepSize); + } + if (coolWhiteLedMirek != null) { + configSetMirekCoolWhiteLED(coolWhiteLedMirek); + } + if (warmWhiteLedMirek != null) { + configSetMirekWarmWhiteLED(warmWhiteLedMirek); + } + } + + /********************************************************************************* + * SECTION: Configuration getters and setters. May be used during initialization. + *********************************************************************************/ + + /** + * Configuration: get the step size for IncreaseDecreaseType commands. + */ + public double configGetIncreaseDecreaseStep() { + return stepSize; + } + + /** + * Configuration: get the light capabilities. + */ + public LightCapabilities configGetLightCapabilities() { + return lightCapabilities; + } + + /** + * Configuration: get the minimum brightness percent to consider as light "ON". + */ + public double configGetMinimumOnBrightness() { + return minimumOnBrightness; + } + + /** + * Configuration: get the coolest color temperature in Mirek/Mired. + */ + public double configGetMirekControlCoolest() { + return mirekControlCoolest; + } + + /** + * Configuration: get the color temperature of the cool white LED in Mirek/Mired. + * + * @return the color temperature of the cool white LED. + */ + public double configGetMirekCoolWhiteLed() { + return coolWhiteLed.getMirek(); + } + + /** + * Configuration: get the warmest color temperature in Mirek/Mired. + */ + public double configGetMirekControlWarmest() { + return mirekControlWarmest; + } + + /** + * Configuration: get the color temperature of the warm white LED in Mirek/Mired. + * + * @return the color temperature of the warm white LED. + */ + public double configGetMirekWarmWhiteLed() { + return warmWhiteLed.getMirek(); + } + + /** + * Configuration: get the supported RGB data type. + */ + public RgbDataType configGetRgbDataType() { + return rgbDataType; + } + + /** + * Configuration: set the step size for IncreaseDecreaseType commands. + * + * @param stepSize the step size in percent. + * @throws IllegalArgumentException if the stepSize parameter is out of range. + */ + public void configSetIncreaseDecreaseStep(double stepSize) throws IllegalArgumentException { + if (stepSize < 1.0 || stepSize > 50.0) { + throw new IllegalArgumentException("Step size '%.1f' out of range [1.0..50.0]".formatted(stepSize)); + } + this.stepSize = stepSize; + } + + /** + * Configuration: set the light capabilities. + */ + public void configSetLightCapabilities(LightCapabilities lightCapabilities) { + this.lightCapabilities = lightCapabilities; + switch (lightCapabilities) { + case COLOR: + ledOperatingMode = LedOperatingMode.RGB_ONLY; + break; + case COLOR_WITH_COLOR_TEMPERATURE: + ledOperatingMode = LedOperatingMode.COMBINED; + break; + default: + ledOperatingMode = LedOperatingMode.WHITE_ONLY; + } + } + + /** + * Configuration: set the minimum brightness percent to consider as light "ON". + * + * @param minimumOnBrightness the minimum brightness percent. + * @throws IllegalArgumentException if the minimumBrightness parameter is out of range. + */ + public void configSetMinimumOnBrightness(double minimumOnBrightness) throws IllegalArgumentException { + if (minimumOnBrightness < 0.1 || minimumOnBrightness > 10.0) { + throw new IllegalArgumentException( + "Minimum brightness '%.1f' out of range [0.1..10.0]".formatted(minimumOnBrightness)); + } + this.minimumOnBrightness = minimumOnBrightness; + } + + /** + * Configuration: set the coolest color temperature in Mirek/Mired. + * + * @param mirekControlCoolest the coolest supported color temperature in Mirek/Mired. + * @throws IllegalArgumentException if the mirekControlCoolest parameter is out of range or not less than + * mirekControlWarmest. + */ + public void configSetMirekControlCoolest(double mirekControlCoolest) throws IllegalArgumentException { + if (mirekControlCoolest < 100.0 || mirekControlCoolest > 1000.0) { + throw new IllegalArgumentException( + "Coolest Mirek/Mired '%.1f' out of range [100.0..1000.0]".formatted(mirekControlCoolest)); + } + if (mirekControlWarmest <= mirekControlCoolest) { + throw new IllegalArgumentException("Warmest Mirek/Mired '%.1f' must be greater than the coolest '%.1f'" + .formatted(mirekControlWarmest, mirekControlCoolest)); + } + this.mirekControlCoolest = mirekControlCoolest; + } + + /** + * Configuration: set the warmest color temperature in Mirek/Mired. + * + * @param mirekControlWarmest the warmest supported color temperature in Mirek/Mired. + * @throws IllegalArgumentException if the mirekControlWarmest parameter is out of range or not greater than + * mirekControlCoolest. + */ + public void configSetMirekControlWarmest(double mirekControlWarmest) throws IllegalArgumentException { + if (mirekControlWarmest < 100.0 || mirekControlWarmest > 1000.0) { + throw new IllegalArgumentException( + "Warmest Mirek/Mired '%.1f' out of range [100.0..1000.0]".formatted(mirekControlWarmest)); + } + if (mirekControlWarmest <= mirekControlCoolest) { + throw new IllegalArgumentException("Warmest Mirek/Mired '%.1f' must be greater than coolest '%.1f'" + .formatted(mirekControlWarmest, mirekControlCoolest)); + } + this.mirekControlWarmest = mirekControlWarmest; + } + + /** + * Configuration: set the color temperature of the cool white LED, and thus set the weightings of its + * individual RGB sub- components. + *

+ * NOTE: If the light has a single white LED then both the 'configSetMirekCoolWhiteLED()' and the + * 'configSetMirekControlWarmest()' methods MUST be called with the identical color temperature. + * + * @param coolLedMirek the color temperature in Mirek/Mired of the cool white LED. + * @throws IllegalArgumentException if the coolLedMirek parameter is out of range. + */ + public void configSetMirekCoolWhiteLED(double coolLedMirek) throws IllegalArgumentException { + if (coolLedMirek < 100.0 || coolLedMirek > 1000.0) { + throw new IllegalArgumentException( + "Cool LED Mirek/Mired '%.1f' out of range [100.0..1000.0]".formatted(coolLedMirek)); + } + coolWhiteLed = new WhiteLED(coolLedMirek); + } + + /** + * Configuration: set the color temperature of the warm white LED, and thus set the weightings of its + * individual RGB sub- components. + *

+ * NOTE: If the light has a single white LED then both the 'configSetMirekCoolWhiteLED()' and the + * 'configSetMirekControlWarmest()' methods MUST be called with the identical color temperature. + * + * @param warmLedMirek the color temperature in Mirek/Mired of the warm white LED. + */ + public void configSetMirekWarmWhiteLED(double warmLedMirek) { + if (warmLedMirek < 100.0 || warmLedMirek > 1000.0) { + throw new IllegalArgumentException( + "Warm LED Mirek/Mired '%.1f' out of range [100.0..1000.0]".formatted(warmLedMirek)); + } + warmWhiteLed = new WhiteLED(warmLedMirek); + } + + /** + * Configuration: set the supported RGB type. + * + * @param rgbType the supported RGB type. + */ + public void configSetRgbDataType(RgbDataType rgbType) { + this.rgbDataType = rgbType; + switch (rgbType) { + case DEFAULT: + case RGB_NO_BRIGHTNESS: + ledOperatingMode = LedOperatingMode.RGB_ONLY; + default: + } + } + + /********************************************************************************* + * SECTION: Runtime State getters, setters, and handlers. Only used at runtime. + *********************************************************************************/ + + /** + * Runtime State: get the brightness or return null if the capability is not supported. + * + * @return PercentType, or null if not supported. + */ + public @Nullable PercentType getBrightness() { + return getBrightness(false); + } + + /** + * Runtime State: get the brightness or return null if the capability is not supported. + * + * @param forceChannelVisible if true return a non-null value even when color is supported. + * @return PercentType, or null if not supported. + */ + public @Nullable PercentType getBrightness(boolean forceChannelVisible) { + return lightCapabilities.supportsBrightness() && (!lightCapabilities.supportsColor() || forceChannelVisible) + ? cachedHSB.getBrightness() + : null; + } + + /** + * Runtime State: get the color or return null if the capability is not supported. + * + * @return HSBType, or null if not supported. + */ + public @Nullable HSBType getColor() { + return lightCapabilities.supportsColor() ? cachedHSB : null; + } + + /** + * Runtime State: get the color temperature or return null if the capability is not supported. + * or the Mirek/Mired value is not known. + * + * @return QuantityType in Kelvin representing the color temperature, or null if not supported + * or the Mirek/Mired value is not known. + */ + public @Nullable QuantityType getColorTemperature() { + if (lightCapabilities.supportsColorTemperature() && !Double.isNaN(cachedMirek)) { + return Objects.requireNonNull( // Mired always converts to Kelvin + QuantityType.valueOf(cachedMirek, Units.MIRED).toInvertibleUnit(Units.KELVIN)); + } + return null; + } + + /** + * Runtime State: get the color temperature in percent or return null if the capability is not supported + * or the Mirek/Mired value is not known. + * + * @return PercentType in range [0..100] representing [coolest..warmest], or null if not supported + * or the Mirek/Mired value is not known. + */ + public @Nullable PercentType getColorTemperaturePercent() { + if (lightCapabilities.supportsColorTemperature() && !Double.isNaN(cachedMirek)) { + double percent = 100 * (cachedMirek - mirekControlCoolest) / (mirekControlWarmest - mirekControlCoolest); + return new PercentType(new BigDecimal(Math.min(Math.max(percent, 0.0), 100.0))); + } + return null; + } + + /** + * Runtime State: get the hue in range [0..360]. + * + * @return double representing the hue in range [0..360]. + */ + public double getHue() { + return cachedHSB.getHue().doubleValue(); + } + + /** + * Runtime State: get the color temperature in Mirek/Mired, may be NaN if not known. + * + * @return double representing the color temperature in Mirek/Mired. + */ + public double getMirek() { + return cachedMirek; + } + + /** + * Runtime State: get the on/off state or null if not supported. + * + * @return OnOffType representing the on/off state or null if not supported. + */ + public @Nullable OnOffType getOnOff() { + return getOnOff(false); + } + + /** + * Runtime State: get the on/off state or null if not supported. + * + * @param forceChannelVisible if true return a non-null value even if brightness or color are supported. + * @return OnOffType representing the on/off state or null if not supported. + */ + public @Nullable OnOffType getOnOff(boolean forceChannelVisible) { + return (!lightCapabilities.supportsColor() && !lightCapabilities.supportsBrightness()) || forceChannelVisible + ? OnOffType.from(cachedHSB.getBrightness().doubleValue() >= minimumOnBrightness) + : null; + } + + /** + * Runtime State: get the RGB(C)(W) values as an array of doubles in range [0..255]. Depending on the value of + * {@link #rgbDataType}, the array length is either 3 (RGB), 4 (RGBW), or 5 (RGBCW). The array is in the order [red, + * green, blue, (cold-)(white), (warm-white)]. Depending on the value, the brightness may or may not be used as + * follows: + * + *

    + *
  • 'RGB_NO_BRIGHTNESS': The return result does not depend on the current brightness. In other words the values + * only relate to the 'HS' part of the {@link HSBType} state. Note: this means that in this case a round trip of + * setRGBx() followed by getRGBx() will NOT necessarily contain identical values, although the RGB ratios will + * certainly be the same.
  • + * + *
  • All other values of {@link #rgbDataType}: The return result depends on the current brightness. In other + * words the values relate to all the 'HSB' parts of the {@link HSBType} state.
  • + *
      + * + * @return double[] representing the RGB(C)(W) components in range [0..255.0] + * @throws IllegalStateException if the RGB data type is not compatible with the current LED operating mode. + */ + public double[] getRGBx() throws IllegalStateException { + HSBType hsb = RgbDataType.RGB_NO_BRIGHTNESS == rgbDataType + ? new HSBType(cachedHSB.getHue(), cachedHSB.getSaturation(), PercentType.HUNDRED) + : cachedHSB; + + /* + * In white only mode the RGB values are all zero. + */ + if (LedOperatingMode.WHITE_ONLY == ledOperatingMode) { + + /* + * If the light has a single white led then its value is determined by the brightness only. + */ + if (RgbDataType.RGB_W == rgbDataType) { + double w = cachedHSB.getBrightness().doubleValue() * 255.0 / 100.0; + return new double[] { 0.0, 0.0, 0.0, w }; + } + + /* + * If the light has a warm and a cool white led, the mix of white values are determined + * by the brightness and the color temperature. + */ + if (RgbDataType.RGB_C_W == rgbDataType) { + double ratio = (cachedMirek - coolWhiteLed.getMirek()) + / (warmWhiteLed.getMirek() + coolWhiteLed.getMirek()); + double bri = cachedHSB.getBrightness().doubleValue() * 255.0 / 100.0; + double cool = bri * ratio; + double warm = bri - cool; + return new double[] { 0.0, 0.0, 0.0, cool, warm }; + } + + throw new IllegalStateException("LED operating mode '%s' not compatible with RGB data type '%s'" + .formatted(ledOperatingMode, rgbDataType)); + } + + /* + * In RGB only mode the RGB values are determined by the HSB values and the white values are always zero. + */ + if (LedOperatingMode.RGB_ONLY == ledOperatingMode) { + + /* + * RGB only - convert HSB to RGB, then scale to [0..255] and pad with zeros for white values. + */ + PercentType[] rgbP = ColorUtil.hsbToRgbPercent(hsb); + double[] rgb = Arrays.stream(rgbP).mapToDouble(p -> p.doubleValue() * 255.0 / 100.0).toArray(); + if (RgbDataType.RGB_W == rgbDataType) { + return new double[] { rgb[0], rgb[1], rgb[2], 0 }; + } else if (RgbDataType.RGB_C_W == rgbDataType) { + return new double[] { rgb[0], rgb[1], rgb[2], 0, 0 }; + } + return rgb; + } + + /* + * In combined mode the RGB and white values are all determined by the HSB values. + */ + if (LedOperatingMode.COMBINED == ledOperatingMode) { + + /* + * RGBCW - convert HSB to RGB, normalize it, then convert to RGBCW, then scale to [0..255] + */ + if (RgbDataType.RGB_C_W == rgbDataType) { + PercentType[] rgbP = ColorUtil.hsbToRgbPercent(hsb); + double[] rgb = Arrays.stream(rgbP).mapToDouble(p -> p.doubleValue() / 100.0).toArray(); + double[] rgbcw = RgbcwMath.rgb2rgbcw(rgb, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + rgbcw = Arrays.stream(rgbcw).map(d -> Math.round(d * 255 * 10) / 10).toArray(); // // round to 1 + return rgbcw; + } else + + /* + * RGBW - convert HSB to RGBW, then scale to [0..255] + */ + if (RgbDataType.RGB_W == rgbDataType) { + PercentType[] rgbwP = ColorUtil.hsbToRgbwPercent(hsb); + double[] rgbw = Arrays.stream(rgbwP).mapToDouble(p -> p.doubleValue() * 255.0 / 100.0).toArray(); + return rgbw; + } + + /* + * RGB only - convert HSB to RGB, then scale to [0..255] + */ + PercentType[] rgbP = ColorUtil.hsbToRgbPercent(hsb); + double[] rgb = Arrays.stream(rgbP).mapToDouble(p -> p.doubleValue() * 255.0 / 100.0).toArray(); + return rgb; + } + + throw new IllegalStateException("Unknown LED operating mode '%s'".formatted(ledOperatingMode)); + } + + /** + * Runtime State: get the saturation in range [0..100]. + * + * @return double representing the saturation in range [0..100]. + */ + public double getSaturation() { + return cachedHSB.getSaturation().doubleValue(); + } + + /** + * Runtime State: get the CIE XY values as an array of doubles in range [0.0..1.0]. + * + * @return double[] representing the XY components in range [0.0..1.0]. + */ + public double[] getXY() { + return ColorUtil.hsbToXY(new HSBType(cachedHSB.getHue(), cachedHSB.getSaturation(), PercentType.HUNDRED)); + } + + /** + * Runtime State: handle a command to change the light's color temperature state. Commands may be one of: + *
        + *
      • {@link PercentType} for color temperature setting.
      • + *
      • {@link QuantityType} for color temperature setting.
      • + *
      + * Other commands are deferred to {@link #handleCommand(Command)} for processing just-in-case. + * + * @param command the command to handle. + * @throws IllegalArgumentException if the command type is not supported. + */ + public void handleColorTemperatureCommand(Command command) throws IllegalArgumentException { + if (command instanceof PercentType warmness) { + zHandleColorTemperature(warmness); + } else if (command instanceof QuantityType temperature) { + zHandleColorTemperature(temperature); + } else { + // defer to the main handler for other command types just-in-case + handleCommand(command); + } + } + + /** + * Runtime State: handle a command to change the light's state. Commands may be one of: + *
        + *
      • {@link HSBType} for color setting
      • + *
      • {@link PercentType} for brightness setting
      • + *
      • {@link OnOffType} for on/off state setting
      • + *
      • {@link IncreaseDecreaseType} for brightness up/down setting
      • + *
      • {@link QuantityType} for color temperature setting
      • + *
      + * + * @param command the command to handle. + * @throws IllegalArgumentException if the command type is not supported. + */ + public void handleCommand(Command command) throws IllegalArgumentException { + if (command instanceof HSBType color) { + zHandleHSBType(color); + } else if (command instanceof PercentType brightness) { + zHandleBrightness(brightness); + } else if (command instanceof OnOffType onOff) { + zHandleOnOff(onOff); + } else if (command instanceof IncreaseDecreaseType incDec) { + zHandleIncreaseDecrease(incDec); + } else if (command instanceof QuantityType temperature) { + zHandleColorTemperature(temperature); + } else { + throw new IllegalArgumentException( + "Command '%s' not supported for light states".formatted(command.getClass().getName())); + } + } + + /** + * Runtime State: update the brightness from the remote light, ensuring it is in the range [0.0..100.0] + * + * @param brightness in the range [0..100] + * @throws IllegalArgumentException if the value is outside the range [0.0 to 100.0] + */ + public void setBrightness(double brightness) throws IllegalArgumentException { + zHandleBrightness(zPercentTypeFrom(brightness)); + } + + /** + * Runtime State: Set the current LED operating mode. Some brands of light are not able to use the RGB leds + * and the white led(s) at the same time. So they must be switched between WHITE_ONLY and RGB_ONLY mode. + * Whereas others lights can use any combination of RGB and White leds at the same time they must be switched + * COMBINED mode. If the mode is changed at runtime then the color and/or color temperature are updated to be + * consistent with the new mode, while keeping the brightness the same. If the light does not support color + * then the mode is forced to WHITE_ONLY. + */ + public void setLedOperatingMode(LedOperatingMode newOperatingMode) { + switch (lightCapabilities) { + case COLOR: + case COLOR_WITH_COLOR_TEMPERATURE: + // only change things if different + if (ledOperatingMode != newOperatingMode) { + ledOperatingMode = newOperatingMode; + double newMirek; + switch (newOperatingMode) { + case RGB_ONLY: + /* + * Force the color to the point on the Planckian locus that corresponds to the color + * temperature. This ensures that the color changes to one that is consistent with the + * prior color temperature. Keeps the original brightness. + */ + newMirek = Double.isNaN(cachedMirek) ? 250 : cachedMirek; // default to 4000 K + break; + case WHITE_ONLY: + /* + * Go to the XY point on the Planckian locus that is closest to the existing color, and + * set the color temperature to the corresponding Mirek/Mired value. Keeps the original + * brightness. + */ + HSBType oldHsb = new HSBType(cachedHSB.getHue(), cachedHSB.getSaturation(), + PercentType.HUNDRED); + double[] xyY = ColorUtil.hsbToXY(oldHsb); + newMirek = 1000000 / ColorUtil.xyToKelvin(new double[] { xyY[0], xyY[1] }); + break; + case COMBINED: // no change - fall through + default: + return; + } + setMirek(newMirek); + } + break; + default: + this.ledOperatingMode = LedOperatingMode.WHITE_ONLY; // force to WHITE mode + } + } + + /** + * Runtime State: update the hue from the remote light, ensuring it is in the range [0.0..360.0] + * + * @param hue in the range [0..360] + * @throws IllegalArgumentException if the hue parameter is not in the range 0.0 to 360.0 + */ + public void setHue(double hue) throws IllegalArgumentException { + HSBType hsb = new HSBType(new DecimalType(hue), cachedHSB.getSaturation(), cachedHSB.getBrightness()); + cachedHSB = hsb; + cachedMirek = zMirekFrom(hsb); + } + + /** + * Runtime State: update the Mirek/Mired color temperature from the remote light, and update the cached HSB color + * accordingly. Constrain the Mirek/Mired value to be within the warmest and coolest limits. If the Mirek/Mired + * value is NaN then the cached color is not updated as we cannot determine what it should be. + * + * @param mirek the color temperature in Mirek/Mired or NaN if not known. + * @throws IllegalArgumentException if the mirek parameter is not in the range + * [mirekControlCoolest..mirekControlWarmest] + */ + public void setMirek(double mirek) throws IllegalArgumentException { + if (mirek < mirekControlCoolest || mirek > mirekControlWarmest) { // NaN is not < or > anything // anything + throw new IllegalArgumentException("Mirek/Mired value '%.1f' out of range [%.1f..%.1f]".formatted(mirek, + mirekControlCoolest, mirekControlWarmest)); + } + if (!Double.isNaN(mirek)) { // don't update color if Mirek/Mired is not known + HSBType hsb = ColorUtil.xyToHsb(ColorUtil.kelvinToXY(1000000 / mirek)); + cachedHSB = new HSBType(hsb.getHue(), hsb.getSaturation(), cachedHSB.getBrightness()); + } + cachedMirek = mirek; + } + + /** + * Runtime State: update the color with RGB(C)(W) fields from the remote light, and update the cached HSB color + * accordingly. The array must be in the order [red, green, blue, (cold-)(white), (warm-white)]. If white is + * present but the light does not support white channel(s) then IllegalArgumentException is thrown. Depending + * on the value of {@link #rgbDataType} the brightness may or may not change as follows: + * + *
        + *
      • 'RGB_NO_BRIGHTNESS' both [255,0,0] and [127.5,0,0] change the color to RED without a change in brightness. + * In other words the values only relate to the 'HS' part of the {@link HSBType} state. Note: this means that in + * this case a round trip of 'setRGBx()' followed by 'getRGBx()' will NOT necessarily contain identical values, + * although the RGB ratios will certainly be the same.
      • + * + *
      • All other values of {@link #rgbDataType}: both [255,0,0] and [127.5,0,0] change the color to RED and the + * former changes the brightness to 100 percent, whereas the latter changes it to 50 percent. In other words the + * values relate to all the 'HSB' parts of the {@link HSBType} state.
      • + *
          + * + * @param rgbxParameter an array of double representing RGB or RGBW values in range [0.0..255.0] + * @throws IllegalArgumentException if the array length is not 3, 4, or 5 depending on the light's capabilities, + * or if any of the values are outside the range [0.0 to 255.0] + */ + public void setRGBx(double[] rgbxParameter) throws IllegalArgumentException { + if (rgbxParameter.length > 5) { + throw new IllegalArgumentException("Too many arguments in RGBx array"); + } + if (rgbxParameter.length < 3 || (RgbDataType.RGB_W == rgbDataType && rgbxParameter.length < 4) + || (RgbDataType.RGB_C_W == rgbDataType && rgbxParameter.length < 5)) { + throw new IllegalArgumentException("Too few arguments in RGBx array"); + } + if (rgbxParameter.length == 3 && ledOperatingMode != LedOperatingMode.RGB_ONLY) { + throw new IllegalArgumentException("White channel(s) mandatory in LED mode " + ledOperatingMode); + } + if (rgbxParameter.length > 3 && ledOperatingMode == LedOperatingMode.RGB_ONLY) { + throw new IllegalArgumentException("White channel(s) not allowed in LED mode " + ledOperatingMode); + } + if (Arrays.stream(rgbxParameter).anyMatch(d -> d < 0.0 || d > 255.0)) { + throw new IllegalArgumentException("RGBx value out of range [0.0..255.0]"); + } + + HSBType hsb; + PercentType brightness; + switch (ledOperatingMode) { + case WHITE_ONLY: + double white; + double mirek; + if (rgbxParameter.length == 5) { + /* + * We have both a C and a W channel so we create a pure white whose brightness + * is determined by both white channels averaged. And the color temperature is + * determined by the ratio of the two white channels. + */ + white = (rgbxParameter[3] + rgbxParameter[4]) / 2.0; + mirek = (coolWhiteLed.getMirek() * rgbxParameter[3] / white) + + (warmWhiteLed.getMirek() * rgbxParameter[4] / white); + } else { + /* + * At this point the rgbxParameter.length can only be 4 so we create a white + * with brightness from the single white channel. And the color temperature + * is determined by the average of the two white LEDs. This is the same as + * having a single white LED with a color temperature equal to the average of + * the two LED temps. + */ + white = rgbxParameter[3]; + mirek = (coolWhiteLed.getMirek() + warmWhiteLed.getMirek()) / 2.0; // average of the two LEDs + } + hsb = ColorUtil.xyToHsb(ColorUtil.kelvinToXY(1000000 / mirek)); + hsb = new HSBType(hsb.getHue(), hsb.getSaturation(), PercentType.HUNDRED); + brightness = zPercentTypeFrom(white * 100.0 / 255.0); + break; + + case RGB_ONLY: + /* + * If we got to this point the rgbxParameter.length can only have the value 3, + * otherwise an exception would have been thrown in the size checks above, so + * we can treat it the same as the COMBINED mode case. + */ + if (rgbxParameter.length != 3) { + return; // safe coding but will never happen + } + // fall through to COMBINED + + case COMBINED: + double[] rgbx; + if (RgbDataType.RGB_C_W == rgbDataType) { + // RGBCW - normalize, convert to RGB, then scale back to [0..255] + rgbx = Arrays.stream(rgbxParameter).map(d -> d / 255.0).toArray(); + rgbx = RgbcwMath.rgbcw2rgb(rgbx, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + rgbx = Arrays.stream(rgbx).map(d -> Math.round(d * 255 * 10) / 10).toArray(); // round to 0.1 + } else { + // RGB or RGBW - pass through RGB(W) values unchanged + rgbx = rgbxParameter; + } + + hsb = ColorUtil.rgbToHsb(Arrays.stream(rgbx).map(d -> d * 100.0 / 255.0) + .mapToObj(d -> zPercentTypeFrom(d)).toArray(PercentType[]::new)); + + brightness = hsb.getBrightness(); + if (RgbDataType.RGB_NO_BRIGHTNESS == rgbDataType) { + hsb = new HSBType(hsb.getHue(), hsb.getSaturation(), cachedHSB.getBrightness()); + } + break; + + default: + return; // safe coding but will never happen + } + + cachedHSB = hsb; + cachedMirek = zMirekFrom(hsb); + if (RgbDataType.RGB_NO_BRIGHTNESS == rgbDataType) { + zHandleBrightness(brightness); + } + } + + /** + * Runtime State: update the saturation from the remote light, ensuring it is in the range [0.0..100.0] + * + * @param saturation in the range [0..100] + * @throws IllegalArgumentException if the value is outside the range [0.0..100.0] + */ + public void setSaturation(double saturation) throws IllegalArgumentException { + HSBType hsb = new HSBType(cachedHSB.getHue(), zPercentTypeFrom(saturation), cachedHSB.getBrightness()); + cachedHSB = hsb; + cachedMirek = zMirekFrom(hsb); + } + + /** + * Runtime State: update the color with CIE XY fields from the remote light, and update the cached HSB color + * accordingly. + * + * @param x the x field in range [0.0..1.0] + * @param y the y field in range [0.0..1.0] + * @throws IllegalArgumentException if any of the XY values are out of range [0.0..1.0] + */ + public void setXY(double x, double y) throws IllegalArgumentException { + double[] xy = new double[] { x, y }; + HSBType hsb = ColorUtil.xyToHsb(xy); + cachedHSB = new HSBType(hsb.getHue(), hsb.getSaturation(), cachedHSB.getBrightness()); + cachedMirek = 1000000 / ColorUtil.xyToKelvin(xy); + } + + /** + * Runtime State: convert a nullable State to a non-null State, using {@link UnDefType}.UNDEF if the input is null. + *

          + * {@code State state = xyz.toNonNull(xyz.getColor())} is a common usage. + * + * @param state the input State, which may be null. + * @return the input State if it is not null, otherwise 'UnDefType.UNDEF'. + */ + public State toNonNull(@Nullable State state) { + return state != null ? state : UnDefType.UNDEF; + } + + /** + * Runtime State: create and return a copy of this LightModel. The copy has the same configuration and + * runtime state as this instance. + * + * @return a copy of this LightModel. + */ + public LightModel copy() { + OnOffType tempOnOff = cachedOnOff; + LightModel copy = new LightModel(lightCapabilities, rgbDataType, minimumOnBrightness, mirekControlCoolest, + mirekControlWarmest, stepSize, coolWhiteLed.getMirek(), warmWhiteLed.getMirek()); + copy.cachedBrightness = PercentType.valueOf(cachedBrightness.toFullString()); + copy.cachedHSB = HSBType.valueOf(cachedHSB.toFullString()); + copy.cachedMirek = cachedMirek; + copy.cachedOnOff = tempOnOff == null ? null : OnOffType.valueOf(tempOnOff.toFullString()); + copy.ledOperatingMode = ledOperatingMode; + return copy; + } + + /********************************************************************************* + * SECTION: Internal private methods. Names have 'z' prefix to indicate private. + *********************************************************************************/ + + /** + * Internal: handle a write brightness command from OH core. + * + * @param brightness the brightness {@link PercentType} to set. + */ + private void zHandleBrightness(PercentType brightness) { + if (brightness.doubleValue() >= minimumOnBrightness) { + cachedBrightness = brightness; + cachedHSB = new HSBType(cachedHSB.getHue(), cachedHSB.getSaturation(), brightness); + cachedOnOff = OnOffType.ON; + } else { + if (OnOffType.ON == cachedOnOff) { + cachedBrightness = cachedHSB.getBrightness(); // cache the last 'ON' state brightness + } + cachedHSB = new HSBType(cachedHSB.getHue(), cachedHSB.getSaturation(), PercentType.ZERO); + cachedOnOff = OnOffType.OFF; + } + } + + /** + * Internal: handle a write color temperature command from OH core. + * + * @param warmness the color temperature warmness {@link PercentType} to set. + */ + private void zHandleColorTemperature(PercentType warmness) { + setMirek(mirekControlCoolest + ((mirekControlWarmest - mirekControlCoolest) * warmness.doubleValue() / 100.0)); + } + + /** + * Internal: handle a write color temperature command from OH core. + * + * @param colorTemperature the color temperature {@link QuantityType} to set. + * @throws IllegalArgumentException if the colorTemperature parameter is not convertible to Mired. + */ + private void zHandleColorTemperature(QuantityType colorTemperature) throws IllegalArgumentException { + QuantityType mirek = colorTemperature.toInvertibleUnit(Units.MIRED); + if (mirek == null) { + throw new IllegalArgumentException( + "Parameter '%s' not convertible to Mirek/Mired".formatted(colorTemperature.toFullString())); + } + setMirek(mirek.doubleValue()); + } + + /** + * Internal: handle a write color command from OH core. + * + * @param hsb the color {@link HSBType} to set. + */ + private void zHandleHSBType(HSBType hsb) { + cachedHSB = hsb; + zHandleBrightness(hsb.getBrightness()); + cachedMirek = zMirekFrom(hsb); + } + + /** + * Internal: handle a write increase/decrease command from OH core, ensuring it is in the range [0.0..100.0] + * + * @param increaseDecrease the {@link IncreaseDecreaseType} command. + */ + private void zHandleIncreaseDecrease(IncreaseDecreaseType increaseDecrease) { + double bri = Math.min(Math.max(cachedHSB.getBrightness().doubleValue() + + ((IncreaseDecreaseType.INCREASE == increaseDecrease ? 1 : -1) * stepSize), 0.0), 100.0); + setBrightness(bri); + } + + /** + * Internal: handle a write on/off command from OH core. + * + * @param onOff the {@link OnOffType} command. + */ + private void zHandleOnOff(OnOffType onOff) { + if (!Objects.equals(onOff, getOnOff())) { + zHandleBrightness(OnOffType.OFF == onOff ? PercentType.ZERO : cachedBrightness); + } + } + + /** + * Internal: return the Mirek/Mired value from the given {@link HSBType} color. The Mirek/Mired value is constrained + * to be within the warmest and coolest limits. + * + * @param hsb the {@link HSBType} color to use to determine the Mirek/Mired value. + */ + private double zMirekFrom(HSBType hsb) { + double[] xyY = ColorUtil.hsbToXY(new HSBType(hsb.getHue(), hsb.getSaturation(), PercentType.HUNDRED)); + double mirek = 1000000 / ColorUtil.xyToKelvin(new double[] { xyY[0], xyY[1] }); + return Math.min(Math.max(mirek, mirekControlCoolest), mirekControlWarmest); + } + + /** + * Internal: create a {@link PercentType} from a double value, ensuring it is in the range [0.0..100.0] + * + * @param value the input value. + * @return a {@link PercentType} representing the input value, constrained to the range [0.0..100.0] + * @throws IllegalArgumentException if the value is outside the range [0.0..100.0] + */ + private PercentType zPercentTypeFrom(double value) throws IllegalArgumentException { + if (value < 0.0 || value > 100.0) { + throw new IllegalArgumentException("PercentType value must be in range [0.0..100.0]: " + value); + } + return new PercentType(new BigDecimal(value)); + } + + /********************************************************************************* + * SECTION: Internal private classes. + *********************************************************************************/ + + /** + * Internal: a class that models the RGB LED sub-components of a white LED light. The RGB component + * weightings are in the range [0.0..1.0] which if scaled to 255 would produce the color temperature + * specified in the constructor at 100% brightness. + * + */ + protected static class WhiteLED { + + private final double[] profile; + private final double mirek; + + /** + * Converts the given Mirek/Mired color temperature to RGB component weighting for the LED, so that its + * output would have the specified color temperature. Each component is in the range [0.0..1.0] + * + * @param ledMirek the color temperature of the LED in Mirek/Mired. + */ + protected WhiteLED(double ledMirek) { + this.profile = Arrays + .stream(ColorUtil.hsbToRgbPercent(ColorUtil.xyToHsb(ColorUtil.kelvinToXY((1000000 / ledMirek))))) + .mapToDouble(p -> p.doubleValue() / 100).toArray(); + this.mirek = ledMirek; + } + + /** + * Get the Mirek/Mired color temperature of the LED. + * + * @return the Mirek/Mired color temperature of the LED. + */ + protected double getMirek() { + return mirek; + } + + /** + * Get the RGB component weighting of the LED. + * + * @return an array of 3 double values representing the RGB components of the LED in the range [0.0..1.0] + * which if scaled to 255 would produce the color temperature specified by the 'mirek' field at + * 100% brightness. + */ + protected double[] getProfile() { + return profile; + } + } + + /** + * Internal: a class containing mathematical utility methods that convert between RGB and RGBCW color arrays + * based on the RGB main values and the RGB sub- component values of the cool and warm white LEDs. + * + * TODO it is intended to move this class to the {@link ColorUtil} utility class, but let's keep it here + * for the time being in order to simplify testing and code review. + */ + public static class RgbcwMath { + + // below this value no RGB -> RGBCW conversion attempted (see method rgb2rgbcw) + private static final double CONVERSION_THRESHOLD = 0.01; + + // step size when iterating over C scalar values for RGB -> RGBCW conversion (see method rgb2rgbcw) + private static final double CONVERSION_ITERATOR_STEP_SIZE = 0.01; + + // default cool and warm white LED RGB profiles used if nothing else is provided in the variable argument lists + private static final double[] COOL_PROFILE = new double[] { 0.95562, 0.976449753, 1.0 }; // 153 Mirek/Mired + private static final double[] WARM_PROFILE = new double[] { 1.0, 0.695614289308524, 0.25572 }; // 500 + + /** + * Composes an RGBCW from the given RGB. Calls {@link #rgb2rgbcw(double[], double[], double[])} with default + * LED profiles. The result depends on the main input RGB values and the RGB sub- component contributions of + * the cold and warm white LEDs. It solves to find the maximum usable C and W scalar values such that none of + * the RGB' channels become negative. It solves for C and W such that: + *

          + * {@code RGB ≈ C * coolProfile + W * warmProfile + RGB'} where {@code RGB'} is the remaining RGB after + * subtracting the scaled cool and warm LED contributions. + *

          + * + * @param rgb a 3-element array of double: [R,G,B]. + * + * @return a 5-element array of double: [R',G',B',C,W], where R', G', B' are the remaining RGB values + * and C and W are the calculated cold and warm white values. + * @throws IllegalArgumentException if the input array length is not 3, or if any of its values are outside + * the range [0.0..1.0] + */ + public static double[] rgb2rgbcw(double[] rgb) throws IllegalArgumentException { + return rgb2rgbcw(rgb, COOL_PROFILE, WARM_PROFILE); + } + + /** + * Composes an RGBCW from the given RGB. The result depends on the main input RGB values and the RGB sub- + * component contributions of the cold and warm white LEDs. It solves to find the maximum usable C and W + * scalar values such that none of the RGB' channels become negative. It solves for C and W such that: + *

          + * {@code RGB ≈ C * coolProfile + W * warmProfile + RGB'} where {@code RGB'} is the remaining RGB after + * subtracting the scaled cool and warm LED contributions. + *

          + * + * @param rgb a 3-element array of double: [R,G,B]. + * @param coolProfile the cool white LED RGB profile, a normalized 3-element [R,G,B] array in the range + * [0.0..1.0]. For example see {@link #COOL_PROFILE}. + * @param warmProfile the warm white LED RGB profile, a normalized 3-element [R,G,B] array in the range + * [0.0..1.0]. For example see {@link #WARM_PROFILE}. + * + * @return a 5-element array of double: [R',G',B',C,W], where R', G', B' are the remaining RGB values + * and C and W are the calculated cold and warm white values. + * @throws IllegalArgumentException if the input array length is not 3, or if any of its values are outside + * the range [0.0..1.0] + */ + public static double[] rgb2rgbcw(double[] rgb, double[] coolProfile, double[] warmProfile) + throws IllegalArgumentException { + if (rgb.length != 3 || Arrays.stream(rgb).anyMatch(d -> d < 0.0 || d > 1.0)) { + throw new IllegalArgumentException("RGB invalid length, or value out of range"); + } + + double[] rgbcw = new double[] { rgb[0], rgb[1], rgb[2], 0.0, 0.0 }; + + // cool/warm contribution is only possible if all rgb values are non- zero + if (rgb[0] < CONVERSION_THRESHOLD || rgb[1] < CONVERSION_THRESHOLD || rgb[2] < CONVERSION_THRESHOLD) { + return rgbcw; + } + + double lowestDelta = 3.0; // lowest total of RGB' elements found so far; starting with the worst case + + // get maximum C scalar such that RGB' channels can't become negative + double coolScalarMax = getMaxScalarForRgbWithProfile(rgb, coolProfile); + + // iterate downwards over C scalar values to solve for the best combination of C and W scalars + for (double coolScalar = coolScalarMax; coolScalar >= 0.0; coolScalar -= CONVERSION_ITERATOR_STEP_SIZE) { + // subtract cool LED profile contributions from RGB to create RGB' + double[] rgbPrime = new double[] { // + rgb[0] - coolProfile[0] * coolScalar, // + rgb[1] - coolProfile[1] * coolScalar, // + rgb[2] - coolProfile[2] * coolScalar, // + Double.NaN, Double.NaN }; // scalar values are dropped in when a new best solution is found + + // get maximum W scalar such that RGB' channels can't become negative + double warmScalar = getMaxScalarForRgbWithProfile(rgbPrime, warmProfile); + + // also subtract warm LED profile contributions from RGB' + rgbPrime[0] = rgbPrime[0] - warmProfile[0] * warmScalar; + rgbPrime[1] = rgbPrime[1] - warmProfile[1] * warmScalar; + rgbPrime[2] = rgbPrime[2] - warmProfile[2] * warmScalar; + + // select the best solution so far that minimizes the total of the RGB' elements + double thisDelta = rgbPrime[0] + rgbPrime[1] + rgbPrime[2]; + if (thisDelta < lowestDelta) { + lowestDelta = thisDelta; + rgbcw = rgbPrime; + rgbcw[3] = coolScalar; // drop in the current C and W scalar values + rgbcw[4] = warmScalar; + } + } + + return rgbcw; + } + + /** + * Decomposes the given RGBCW to an RGB. Calls {@link #rgbcw2rgb(double[], double[], double[])} with default + * LED profiles. The result comprises the main input RGB values plus the RGB sub- component contributions of + * the cold and warm white LEDs. + * + * @param rgbcw a 5-element array of double: [R,G,B,C,W], where R, G, B are the RGB values and C and W are + * the cold and warm white LED RGB profile contributions. + * + * @return double[] a 3-element array of double: [R,G,B]. + * @throws IllegalArgumentException if the input array length is not 5, or if any its values are + * outside the range [0.0..1.0] + */ + public static double[] rgbcw2rgb(double[] rgbcw) throws IllegalArgumentException { + return rgbcw2rgb(rgbcw, COOL_PROFILE, WARM_PROFILE); + } + + /** + * Decomposes the given RGBCW to an RGB. The result comprises the main input RGB values plus the RGB sub- + * component contributions of the cold and warm white LEDs. + * + * @param rgbcw a 5-element array of double: [R,G,B,C,W], where R, G, B are the RGB values and C and W are + * the cold and warm white LED RGB profile contributions. + * @param coolProfile the cool white LED RGB profile, a normalized 3-element [R,G,B] array in the range + * [0.0..1.0]. For example see {@link #COOL_PROFILE}. + * @param warmProfile the warm white LED RGB profile, a normalized 3-element [R,G,B] array in the range + * [0.0..1.0]. For example see {@link #WARM_PROFILE}. + * + * @return double[] a 3-element array of double: [R,G,B]. + * @throws IllegalArgumentException if the input array length is not 5, or if any its values are + * outside the range [0.0..1.0] + */ + public static double[] rgbcw2rgb(double[] rgbcw, double[] coolProfile, double[] warmProfile) + throws IllegalArgumentException { + if (rgbcw.length != 5 || Arrays.stream(rgbcw).anyMatch(d -> d < 0.0 || d > 1.0)) { + throw new IllegalArgumentException("RGB invalid length, or value out of range"); + } + + double coolScalar = rgbcw[3], warmScalar = rgbcw[4]; + + // add c/w contributions to rgb and clamp to 1.0 + return new double[] { // + Math.min(1, rgbcw[0] + coolProfile[0] * coolScalar + warmProfile[0] * warmScalar), // + Math.min(1, rgbcw[1] + coolProfile[1] * coolScalar + warmProfile[1] * warmScalar), // + Math.min(1, rgbcw[2] + coolProfile[2] * coolScalar + warmProfile[2] * warmScalar) }; + } + + /** + * Internal: Returns the maximum scalar value for the given RGB and LED profile such that none of + * the resulting RGB' channels can become negative. Used to determine how much of a given white LED + * profile can be applied. It checks for zero profile values to avoid divide-by-zero errors. + * + * @param rgb a 3-element array of double: [R,G,B]. + * @param profile a 3-element array of double representing an LED profile: [R,G,B]. + * @return double representing the highest scalar value that can be applied to the given RGB LED profile values + * without any of the resulting RGB' channel values becoming negative. + */ + private static double getMaxScalarForRgbWithProfile(double[] rgb, double[] profile) { + return Math.min(Math.min( // + profile[0] > 0 ? rgb[0] / profile[0] : 1, // + profile[1] > 0 ? rgb[1] / profile[1] : 1), // + profile[2] > 0 ? rgb[2] / profile[2] : 1); + } + } +} From a42d692c2d49c60b330e54b185a818abff28149d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 21 Oct 2025 15:24:29 +0100 Subject: [PATCH 075/177] completed light state model Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/dto/Characteristic.java | 13 +- .../internal/enums/CharacteristicType.java | 4 +- .../handler/HomekitAccessoryHandler.java | 309 ++++++++++++------ .../internal/temporary/LightModel.java | 18 + 4 files changed, 243 insertions(+), 101 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index bd3323c72566e..948171925dfc6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -85,10 +85,6 @@ public class Characteristic { public @Nullable ChannelDefinition buildAndRegisterChannelDefinition(ThingUID thingUID, HomekitTypeProvider typeProvider) { CharacteristicType characteristicType = getCharacteristicType(); - if (characteristicType == null) { - return null; - } - DataFormatType dataFormatType; try { dataFormatType = DataFormatType.from(format); @@ -784,6 +780,9 @@ public class Characteristic { case ZOOM_OPTICAL: itemType = null; break; + + default: + return null; } if (CoreItemFactory.NUMBER.equals(itemType) && numberSuffix != null) { @@ -921,17 +920,17 @@ private String getChannelInstanceLabel() { : Objects.requireNonNull(getCharacteristicType()).toString(); } - public @Nullable CharacteristicType getCharacteristicType() { + public CharacteristicType getCharacteristicType() { return getCharacteristicType(type); } - public static @Nullable CharacteristicType getCharacteristicType(String type) { + public static CharacteristicType getCharacteristicType(String type) { try { // convert "00000113-0000-1000-8000-0026BB765291" to "00000113" String firstPart = type.split("-")[0]; return CharacteristicType.from(Integer.parseInt(firstPart, 16)); } catch (IllegalArgumentException e) { - return null; + return CharacteristicType.UNKNOWN_CHARACTERISTIC; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index 9c0ed67a9d859..0a96b4154ef8a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -155,7 +155,9 @@ public enum CharacteristicType { VOLUME(0x119, "public.hap.characteristic.volume"), WATER_LEVEL(0xB5, "public.hap.characteristic.water-level"), ZOOM_DIGITAL(0x11D, "public.hap.characteristic.zoom-digital"), - ZOOM_OPTICAL(0x11C, "public.hap.characteristic.zoom-optical"); + ZOOM_OPTICAL(0x11C, "public.hap.characteristic.zoom-optical"), + // placeholder for any custom or unsupported characteristic + UNKNOWN_CHARACTERISTIC(0xFF, "public.hap.characteristic.unknown"); //@formatter:on private final int id; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 38a4e9c37c5be..6d89db0a2a983 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -19,8 +19,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; -import java.util.Set; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -51,9 +51,11 @@ import org.openhab.core.library.types.StopMoveType; import org.openhab.core.library.types.StringType; import org.openhab.core.library.types.UpDownType; +import org.openhab.core.library.unit.Units; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.DefaultSystemChannelTypeProvider; import org.openhab.core.thing.Thing; import org.openhab.core.thing.binding.builder.ChannelBuilder; import org.openhab.core.thing.binding.builder.ThingBuilder; @@ -88,14 +90,19 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { private static final int INITIAL_DELAY_SECONDS = 2; - private static final Set LIGHTING_CHANNELS = Set.of(CharacteristicType.COLOR_TEMPERATURE, - CharacteristicType.SATURATION, CharacteristicType.BRIGHTNESS, CharacteristicType.HUE); - private final Logger logger = LoggerFactory.getLogger(HomekitAccessoryHandler.class); private final ChannelTypeRegistry channelTypeRegistry; private final ChannelGroupTypeRegistry channelGroupTypeRegistry; - private @Nullable LightModel lightModel; + /* + * Light model to manage combined light characteristics (hue, saturation, brightness, color temperature). + * Used to create a combined HSB channel and handle commands accordingly. + * This is only initialized if the accessory has relevant light characteristics. + */ + private @Nullable LightModel lightModel = null; + private @Nullable ChannelUID lightModelClientChannel = null; // special HSB combined channel + private final Map lightModelServerChannels = new HashMap<>(); + private @Nullable ScheduledFuture refreshTask; public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, @@ -147,13 +154,6 @@ private void channelsAndPropertiesLoaded() { */ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { Object object = command; - - // handle HSBType as not directly supported by HomeKit - if (object instanceof HSBType) { - // TODO special handling => TBD - logger.warn("HSBType command handling is not yet implemented for channel {}", channel.getUID()); - } - StateDescription stateDescription = getStateDescription(channel); // process Rollershutter commands @@ -342,6 +342,8 @@ private void createChannels() { return; } + lightModelInitialize(accessory); + // create the channels and properties List channels = new ArrayList<>(); Map properties = new HashMap<>(thing.getProperties()); // keep existing properties @@ -400,19 +402,14 @@ private void createChannels() { channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), channel.getKind(), channel.getLabel(), channel.getProperties(), channel.getUID()); - // initialise the light model when appropriate - if (channel.getProperties().get(PROPERTY_CHARACTERISTIC_TYPE) instanceof String cxProp - && Characteristic.getCharacteristicType( - cxProp) instanceof CharacteristicType characteristicType - && LIGHTING_CHANNELS.contains(characteristicType)) { - initializeLightModel(characteristicType, channelType); - } } } }); } }); + lightModelFinalize(accessory, channels); + String oldLabel = thing.getLabel(); String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; List newChannels = !channels.isEmpty() ? channels : null; @@ -434,62 +431,6 @@ private void createChannels() { } } - /** - * Initializes or updates the light model based on the characteristic type and channel type. - * Upgrades the light capabilities of the model as necessary when encountering color-related characteristics. - * - * @param characteristicType the type of characteristic being processed - * @param channelType the channel type associated with the characteristic - */ - @SuppressWarnings("incomplete-switch") - private void initializeLightModel(CharacteristicType characteristicType, ChannelType channelType) { - LightModel lightModel = this.lightModel; - if (lightModel == null) { - lightModel = new LightModel(LightCapabilities.BRIGHTNESS, RgbDataType.DEFAULT, null, null, null, null, null, - null); - this.lightModel = lightModel; - } - - LightCapabilities oldCaps = lightModel.configGetLightCapabilities(); - switch (characteristicType) { - // if channel is hue or saturation, upgrade to support color - case HUE: - case SATURATION: - switch (oldCaps) { - case BRIGHTNESS: - LightCapabilities newCaps = LightCapabilities.COLOR; - lightModel.configSetLightCapabilities(newCaps); - break; - case BRIGHTNESS_WITH_COLOR_TEMPERATURE: - newCaps = LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE; - lightModel.configSetLightCapabilities(newCaps); - } - break; - - // if channel is color temperature, upgrade to support color temperature - case COLOR_TEMPERATURE: - switch (oldCaps) { - case BRIGHTNESS: - LightCapabilities newCaps = LightCapabilities.BRIGHTNESS_WITH_COLOR_TEMPERATURE; - lightModel.configSetLightCapabilities(newCaps); - break; - case COLOR: - newCaps = LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE; - lightModel.configSetLightCapabilities(newCaps); - } - - // set the mirek limits based on the channel's state description - StateDescription state = channelType.getState(); - if (state != null) { - if (state.getMinimum() instanceof BigDecimal min) { - lightModel.configSetMirekControlCoolest(min.doubleValue()); - } else if (state.getMaximum() instanceof BigDecimal max) { - lightModel.configSetMirekControlWarmest(max.doubleValue()); - } - } - } - } - @Override public void handleCommand(ChannelUID channelUID, Command command) { Channel channel = thing.getChannel(channelUID); @@ -506,16 +447,12 @@ public void handleCommand(ChannelUID channelUID, Command command) { return; } try { - Integer aid = getAccessoryId(); - String iid = channel.getProperties().get(PROPERTY_IID); - if (aid != null && iid != null) { - Service service = new Service(); - Characteristic characteristic = new Characteristic(); - characteristic.aid = aid; - characteristic.iid = Integer.parseInt(iid); - characteristic.value = commandToJsonPrimitive(command, channel); - service.characteristics = List.of(characteristic); - writer.writeCharacteristic(GSON.toJson(service)); + if (channelUID.equals(lightModelClientChannel)) { + lightModelHandleCommand(command, writer); + } else if (!(command instanceof HSBType)) { + writeChannel(channel, command, writer); + } else { + logger.warn("Failed to send command '{}' to '{}'", command, channelUID); } } catch (Exception e) { logger.warn("Failed to send command '{}' to '{}'", command, channelUID, e); @@ -544,6 +481,9 @@ public void dispose() { task.cancel(true); } refreshTask = null; + lightModel = null; + lightModelServerChannels.clear(); + lightModelClientChannel = null; super.dispose(); } @@ -570,14 +510,18 @@ private void refresh() { Service service = GSON.fromJson(jsonResponse, Service.class); if (service != null && service.characteristics instanceof List characteristics) { for (Channel channel : thing.getChannels()) { - String iid = channel.getProperties().get(PROPERTY_IID); - if (iid == null) { - continue; - } - for (Characteristic characteristic : characteristics) { - if (iid.equals(String.valueOf(characteristic.iid)) - && characteristic.value instanceof JsonElement element) { - updateState(channel.getUID(), convertJsonToState(element, channel)); + ChannelUID channelUID = channel.getUID(); + if (channelUID.equals(lightModelClientChannel)) { + for (Characteristic cxx : characteristics) { + if (lightModelRefresh(cxx)) { + updateState(channelUID, Objects.requireNonNull(lightModel).getHsb()); + } + } + } else if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { + for (Characteristic cxx : characteristics) { + if (iid.equals(String.valueOf(cxx.iid)) && cxx.value instanceof JsonElement element) { + updateState(channelUID, convertJsonToState(element, channel)); + } } } } @@ -602,4 +546,183 @@ private void refresh() { } return st; } + + /** + * Determines if a light model is required for the accessory based on its characteristics. + * If the accessory has color or color temperature characteristics, a LightModel is created and configured. + * + * @param accessory the accessory to check + */ + private void lightModelInitialize(Accessory accessory) { + boolean isColor = false; + boolean isColorTemp = false; + Double minMirek = null; + Double maxMirek = null; + + for (Service service : accessory.services) { + for (Characteristic cxx : service.characteristics) { + CharacteristicType cxxType = cxx.getCharacteristicType(); + if (CharacteristicType.HUE == cxxType || CharacteristicType.SATURATION == cxxType) { + isColor = true; + } else if (CharacteristicType.COLOR_TEMPERATURE == cxxType) { + isColorTemp = true; + maxMirek = cxx.maxValue; + minMirek = cxx.minValue; + } + } + } + + if (!isColor) { + return; + } + + LightCapabilities caps = isColorTemp ? LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE : LightCapabilities.COLOR; + LightModel lightModel = new LightModel(caps, RgbDataType.DEFAULT, null, null, null, null, null, null); + if (minMirek != null) { + lightModel.configSetMirekControlCoolest(minMirek); + } + if (maxMirek != null) { + lightModel.configSetMirekControlWarmest(maxMirek); + } + this.lightModel = lightModel; + } + + /** + * Checks if a characteristic is relevant to the light model. + * + * @param cxx the characteristic to check + * @return true if the characteristic is part of the light model, false otherwise + */ + private boolean lightModelRelevantCharacteristic(Characteristic cxx) { + CharacteristicType cxxType = cxx.getCharacteristicType(); + return CharacteristicType.HUE == cxxType || CharacteristicType.SATURATION == cxxType + || CharacteristicType.BRIGHTNESS == cxxType || CharacteristicType.COLOR_TEMPERATURE == cxxType + || CharacteristicType.ON == cxxType; + } + + /** + * Refreshes the light model state based on the updated characteristic value. + * + * @param cxx the characteristic containing the updated value + * @return true if the light model was updated, false otherwise + */ + private boolean lightModelRefresh(Characteristic cxx) { + LightModel lightModel = this.lightModel; + if (lightModel == null) { + throw new IllegalStateException("Light model is not initialized"); + } + boolean changed = false; + if (lightModelRelevantCharacteristic(cxx) && cxx.value instanceof JsonPrimitive primitiveValue) { + CharacteristicType cxxType = cxx.getCharacteristicType(); + if (primitiveValue.isNumber()) { + changed = true; + switch (cxxType) { + case HUE -> lightModel.setHue(primitiveValue.getAsDouble()); + case SATURATION -> lightModel.setSaturation(primitiveValue.getAsDouble()); + case BRIGHTNESS -> lightModel.setBrightness(primitiveValue.getAsDouble()); + case COLOR_TEMPERATURE -> lightModel.setMirek(primitiveValue.getAsDouble()); + default -> changed = false; + } + } else if (primitiveValue.isBoolean()) { + changed = true; + switch (cxxType) { + case ON -> lightModel.setOnOff(primitiveValue.getAsBoolean()); + default -> changed = false; + } + } + } + return changed; + } + + /** + * Sends a command to update the light model based on an HSBType command. + * + * @param hsbCommand the HSBType command containing hue, saturation, and brightness + * @param writer the CharacteristicReadWriteClient to send the command + * @throws Exception + */ + private void lightModelHandleCommand(Command command, CharacteristicReadWriteClient writer) throws Exception { + LightModel lightModel = this.lightModel; + if (lightModel == null) { + throw new IllegalStateException("Light model is not initialized"); + } + lightModel.handleCommand(command); + if (lightModelServerChannels.get(CharacteristicType.HUE) instanceof Channel channel) { + writeChannel(channel, QuantityType.valueOf(lightModel.getHue(), Units.DEGREE_ANGLE), writer); + } + if (lightModelServerChannels.get(CharacteristicType.SATURATION) instanceof Channel channel) { + writeChannel(channel, new PercentType(BigDecimal.valueOf(lightModel.getSaturation())), writer); + } + if (lightModelServerChannels.get(CharacteristicType.BRIGHTNESS) instanceof Channel channel + && lightModel.getBrightness() instanceof PercentType percentType) { + writeChannel(channel, percentType, writer); + } + if (lightModelServerChannels.get(CharacteristicType.ON) instanceof Channel channel + && lightModel.getOnOff() instanceof OnOffType onOff) { + writeChannel(channel, onOff, writer); + } + } + + /** + * Finalizes the light model channels by mapping the relevant characteristic channels + * and creating a combined HSB channel. + * + * @param accessory the accessory containing the characteristics + * @param channels the list of channels to finalize + */ + private void lightModelFinalize(Accessory accessory, List channels) { + if (lightModel == null) { + return; + } + // map characteristic channels to light model + lightModelServerChannels.clear(); + for (Channel channel : channels) { + String iid = channel.getProperties().get(PROPERTY_IID); + if (iid != null) { + for (Service service : accessory.services) { + for (Characteristic cxx : service.characteristics) { + if (iid.equals(String.valueOf(cxx.iid)) && lightModelRelevantCharacteristic(cxx)) { + CharacteristicType cxxType = cxx.getCharacteristicType(); + lightModelServerChannels.put(cxxType, channel); + } + } + } + } + } + // create combined HSB channel + ChannelUID uid = new ChannelUID(thing.getUID(), "hsb-combined-channel"); + Channel channel = ChannelBuilder.create(uid, CoreItemFactory.COLOR) + .withType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_COLOR).build(); + channels.add(channel); + logger.trace( + "+++++Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", + channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), channel.getKind(), + channel.getLabel(), channel.getProperties(), channel.getUID()); + lightModelClientChannel = uid; + } + + /** + * Writes a command to a specific channel by constructing a Service and embedded Characteristic object. + * + * @param channel the channel to which the command is sent + * @param command the command to send + * @param writer the CharacteristicReadWriteClient to send the command + * + * @throws Exception + */ + private void writeChannel(Channel channel, Command command, CharacteristicReadWriteClient writer) throws Exception { + Integer aid = getAccessoryId(); + String iid = channel.getProperties().get(PROPERTY_IID); + if (aid == null || iid == null) { + throw new IllegalStateException( + "Missing accessory ID or characteristic IID for channel " + channel.getUID()); + } + Service service = new Service(); + Characteristic characteristic = new Characteristic(); + characteristic.aid = aid; + characteristic.iid = Integer.parseInt(iid); + characteristic.value = commandToJsonPrimitive(command, channel); + service.characteristics = List.of(characteristic); + writer.writeCharacteristic(GSON.toJson(service)); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java index c147fd39af8dc..e039e5978e5d8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java @@ -660,6 +660,15 @@ public double getHue() { return cachedHSB.getHue().doubleValue(); } + /** + * Runtime State: get the HSBType color. + * + * @return HSBType representing the color. + */ + public HSBType getHsb() { + return new HSBType(cachedHSB.getHue(), cachedHSB.getSaturation(), cachedHSB.getBrightness()); + } + /** * Runtime State: get the color temperature in Mirek/Mired, may be NaN if not known. * @@ -959,6 +968,15 @@ public void setMirek(double mirek) throws IllegalArgumentException { cachedMirek = mirek; } + /** + * Runtime State: update the on/off state from the remote light. + * + * @param on true for ON, false for OFF + */ + public void setOnOff(boolean on) { + zHandleOnOff(OnOffType.from(on)); + } + /** * Runtime State: update the color with RGB(C)(W) fields from the remote light, and update the cached HSB color * accordingly. The array must be in the order [red, green, blue, (cold-)(white), (warm-white)]. If white is From b91026ea3da1d6e8942aab00dd382012bd0f83da Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 21 Oct 2025 17:07:28 +0100 Subject: [PATCH 076/177] various - implement position hold command - implement connection retry Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 1 - .../homekit/internal/dto/Characteristic.java | 2 - .../handler/HomekitAccessoryHandler.java | 79 ++++++++++++++----- .../handler/HomekitBaseAccessoryHandler.java | 49 +++++++++++- 4 files changed, 106 insertions(+), 25 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 9a3bc8b935cbe..c4769aba4bed6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -60,7 +60,6 @@ public class HomekitBindingConstants { public static final String PROPERTY_IID = "iid"; public static final String PROPERTY_FORMAT = "format"; public static final String PROPERTY_DATA_TYPE = "dataType"; - public static final String PROPERTY_CHARACTERISTIC_TYPE = "characteristicType"; // HomeKit HTTP URI endpoints and content types public static final String ENDPOINT_ACCESSORIES = "/accessories"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 948171925dfc6..17b305c540abf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -554,7 +554,6 @@ public class Characteristic { break; case POSITION_HOLD: - // TODO "stop" command for a roller shutter itemType = CoreItemFactory.SWITCH; propertyTag = Property.OPENING; break; @@ -903,7 +902,6 @@ public class Characteristic { * so we create and return a channel definition containing this information. */ Map props = new HashMap<>(); - Optional.ofNullable(type).ifPresent(s -> props.put(PROPERTY_CHARACTERISTIC_TYPE, s)); Optional.ofNullable(iid).map(v -> v.toString()).ifPresent(s -> props.put(PROPERTY_IID, s)); Optional.ofNullable(format).ifPresent(s -> props.put(PROPERTY_FORMAT, s)); Optional.ofNullable(dataType).ifPresent(s -> props.put(PROPERTY_DATA_TYPE, s)); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 6d89db0a2a983..22dce4d7d0506 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -57,6 +57,8 @@ import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.DefaultSystemChannelTypeProvider; import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.binding.builder.ChannelBuilder; import org.openhab.core.thing.binding.builder.ThingBuilder; import org.openhab.core.thing.type.ChannelGroupType; @@ -103,6 +105,7 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { private @Nullable ChannelUID lightModelClientChannel = null; // special HSB combined channel private final Map lightModelServerChannels = new HashMap<>(); + private @Nullable Channel stopMoveChannel = null; // channel for the stop button (rollershutters) private @Nullable ScheduledFuture refreshTask; public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, @@ -166,10 +169,6 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { object = openClosed == OpenClosedType.OPEN ? PercentType.HUNDRED : PercentType.ZERO; } else if (object instanceof UpDownType upDown) { object = upDown == UpDownType.UP ? PercentType.HUNDRED : PercentType.ZERO; - } else if (object instanceof StopMoveType stopMove && stopMove == StopMoveType.STOP) { - // TODO handle stop command -- either - // a) command POSITION HOLD '6F' characteristic (if existing) or - // b) move to the current position } } @@ -409,6 +408,7 @@ private void createChannels() { }); lightModelFinalize(accessory, channels); + stopMoveFinalize(accessory, channels); String oldLabel = thing.getLabel(); String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; @@ -435,7 +435,7 @@ private void createChannels() { public void handleCommand(ChannelUID channelUID, Command command) { Channel channel = thing.getChannel(channelUID); if (channel == null) { - logger.warn("Received command for unknown channel: {}", channelUID); + logger.warn("Received command for unknown channel '{}'", channelUID); return; } if (command == RefreshType.REFRESH) { @@ -443,19 +443,24 @@ public void handleCommand(ChannelUID channelUID, Command command) { } CharacteristicReadWriteClient writer = this.rwService; if (writer == null) { - logger.warn("No writer service available to handle command for channel: {}", channelUID); + logger.warn("No writer service available to handle command for '{}'", channelUID); return; } try { - if (channelUID.equals(lightModelClientChannel)) { + if (command instanceof HSBType) { + logger.warn("Forbidden to send command '{}' directly to '{}'", command, channelUID); + } else if (command instanceof StopMoveType stopMoveType && StopMoveType.STOP == stopMoveType + && stopMoveChannel instanceof Channel stopMoveChannel) { + writeChannel(stopMoveChannel, OnOffType.ON, writer); + } else if (channelUID.equals(lightModelClientChannel)) { lightModelHandleCommand(command, writer); - } else if (!(command instanceof HSBType)) { - writeChannel(channel, command, writer); } else { - logger.warn("Failed to send command '{}' to '{}'", command, channelUID); + writeChannel(channel, command, writer); } } catch (Exception e) { - logger.warn("Failed to send command '{}' to '{}'", command, channelUID, e); + logger.warn("Failed to send command '{}' to '{}', reconnecting", command, channelUID, e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + startConnectionTask(); } } @@ -476,11 +481,7 @@ public void handleRemoval() { @Override public void dispose() { - ScheduledFuture task = refreshTask; - if (task != null) { - task.cancel(true); - } - refreshTask = null; + cancelRefreshTask(); lightModel = null; lightModelServerChannels.clear(); lightModelClientChannel = null; @@ -527,11 +528,21 @@ private void refresh() { } } } catch (Exception e) { - logger.warn("Failed to poll accessory state", e); + logger.warn("Failed to poll accessory state, reconnecting", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + startConnectionTask(); } } } + private void cancelRefreshTask() { + ScheduledFuture task = refreshTask; + if (task != null) { + task.cancel(true); + } + refreshTask = null; + } + private @Nullable StateDescription getStateDescription(Channel channel) { ChannelTypeUID uid = channel.getChannelTypeUID(); ChannelType ct = channelTypeRegistry.getChannelType(uid); @@ -677,8 +688,7 @@ private void lightModelFinalize(Accessory accessory, List channels) { // map characteristic channels to light model lightModelServerChannels.clear(); for (Channel channel : channels) { - String iid = channel.getProperties().get(PROPERTY_IID); - if (iid != null) { + if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { for (Service service : accessory.services) { for (Characteristic cxx : service.characteristics) { if (iid.equals(String.valueOf(cxx.iid)) && lightModelRelevantCharacteristic(cxx)) { @@ -701,6 +711,28 @@ private void lightModelFinalize(Accessory accessory, List channels) { lightModelClientChannel = uid; } + /** + * Initializes the stop/move button channel by searching for a characteristic of type POSITION_HOLD. + * + * @param accessory the accessory containing the characteristics + * @param channels the list of channels to search + */ + private void stopMoveFinalize(Accessory accessory, List channels) { + for (Channel channel : channels) { + if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { + for (Service service : accessory.services) { + for (Characteristic cxx : service.characteristics) { + if (iid.equals(String.valueOf(cxx.iid)) + && CharacteristicType.POSITION_HOLD == cxx.getCharacteristicType()) { + stopMoveChannel = channel; + return; + } + } + } + } + } + } + /** * Writes a command to a specific channel by constructing a Service and embedded Characteristic object. * @@ -710,7 +742,8 @@ private void lightModelFinalize(Accessory accessory, List channels) { * * @throws Exception */ - private void writeChannel(Channel channel, Command command, CharacteristicReadWriteClient writer) throws Exception { + private synchronized void writeChannel(Channel channel, Command command, CharacteristicReadWriteClient writer) + throws Exception { Integer aid = getAccessoryId(); String iid = channel.getProperties().get(PROPERTY_IID); if (aid == null || iid == null) { @@ -725,4 +758,10 @@ private void writeChannel(Channel channel, Command command, CharacteristicReadWr service.characteristics = List.of(characteristic); writer.writeCharacteristic(GSON.toJson(service)); } + + @Override + protected void startConnectionTask() { + cancelRefreshTask(); + super.startConnectionTask(); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 954fe2408522a..077f97ba8a11d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -20,6 +20,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -60,6 +62,9 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler { protected static final Gson GSON = new Gson(); + private static final int MIN_CONNECTION_DELAY_SECONDS = 2; + private static final int MAX_CONNECTION_DELAY_SECONDS = 600; + private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); protected final Map accessories = new HashMap<>(); @@ -73,6 +78,9 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler { protected @NonNullByDefault({}) Integer accessoryId; protected @NonNullByDefault({}) IpTransport ipTransport; + private int connectionDelaySeconds = MIN_CONNECTION_DELAY_SECONDS; + private @Nullable ScheduledFuture connectionTask; + public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore) { super(thing); this.typeProvider = typeProvider; @@ -81,6 +89,7 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { + cancelConnectionTask(); if (!isChildAccessory) { try { ipTransport.close(); @@ -191,7 +200,8 @@ public void initialize() { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Failed to connect"); return; } - scheduler.execute(() -> initializePairing()); // return fast, do pairing in background thread + cancelConnectionTask(); + startConnectionTask(); } } @@ -199,7 +209,7 @@ public void initialize() { * Restores an existing pairing or creates a new one if necessary. * Updates the thing status accordingly. */ - private void initializePairing() { + private synchronized void initializePairing() { Object pairingConfig = getConfig().get(CONFIG_PAIRING_CODE); if (pairingConfig == null || !(pairingConfig instanceof String pairingConfigString) || !PAIRING_CODE_PATTERN.matcher(pairingConfigString).matches()) { @@ -231,6 +241,7 @@ private void initializePairing() { rwService = new CharacteristicReadWriteClient(ipTransport); logger.debug("Restored pairing was verified for {}", thing.getUID()); + cancelConnectionTask(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); @@ -259,11 +270,13 @@ private void initializePairing() { keyStore.setAccessoryKey(macAddress, accessoryKey); logger.debug("Pairing and verification completed for {}", thing.getUID()); + cancelConnectionTask(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); } catch (Exception e) { logger.warn("Pairing / verification failed for {}", thing.getUID(), e); + startConnectionTask(); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing / verification failed"); } } @@ -284,4 +297,36 @@ private String normalizePairingCode(String input) { // re-format as XXX-XX-XXX return String.format("%s-%s-%s", digits.substring(0, 3), digits.substring(3, 5), digits.substring(5, 8)); } + + /** + * Starts a task to attempt (re) connection. + * The delay increases exponentially up to a maximum of 10 minutes. + * If this handler is a child of a bridge, it delegates to the bridge handler. + */ + protected void startConnectionTask() { + Bridge bridge = getBridge(); + if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + bridgeHandler.startConnectionTask(); + } else { + ScheduledFuture task = connectionTask; + if (task != null) { + task.cancel(false); + } + connectionTask = scheduler.schedule(() -> initializePairing(), connectionDelaySeconds, TimeUnit.SECONDS); + connectionDelaySeconds = Math.min(connectionDelaySeconds * connectionDelaySeconds, + MAX_CONNECTION_DELAY_SECONDS); + } + } + + /** + * Stops the (re) connect task if it is running. Resets the retry exponent. + */ + private void cancelConnectionTask() { + ScheduledFuture task = connectionTask; + if (task != null) { + task.cancel(false); + } + connectionTask = null; + connectionDelaySeconds = MIN_CONNECTION_DELAY_SECONDS; + } } From 78fea428581ad5561f72fe2e6622114f3889cb25 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 21 Oct 2025 17:33:35 +0100 Subject: [PATCH 077/177] implement event/trigger channel update Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/dto/Characteristic.java | 2 -- .../homekit/internal/handler/HomekitAccessoryHandler.java | 6 +++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 17b305c540abf..83c5323c026a5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -213,7 +213,6 @@ public class Characteristic { break; case BUTTON_EVENT: - // TODO trigger channel with numeric enum 1-3 isStateChannel = false; break; @@ -436,7 +435,6 @@ public class Characteristic { break; case INPUT_EVENT: - // TODO numeric enum 3 states isStateChannel = false; break; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 22dce4d7d0506..cab80d30afc3f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -521,7 +521,11 @@ private void refresh() { } else if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { for (Characteristic cxx : characteristics) { if (iid.equals(String.valueOf(cxx.iid)) && cxx.value instanceof JsonElement element) { - updateState(channelUID, convertJsonToState(element, channel)); + State state = convertJsonToState(element, channel); + switch (channel.getKind()) { + case STATE -> updateState(channelUID, state); + case TRIGGER -> triggerChannel(channelUID, state.toFullString()); + } } } } From 06b9586750cd4cb7f56fd84ed8debd1f5a91ccda Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 21 Oct 2025 18:03:22 +0100 Subject: [PATCH 078/177] implement immediate stop Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index cab80d30afc3f..17a1e5fa067f4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -441,21 +441,24 @@ public void handleCommand(ChannelUID channelUID, Command command) { if (command == RefreshType.REFRESH) { return; } - CharacteristicReadWriteClient writer = this.rwService; - if (writer == null) { - logger.warn("No writer service available to handle command for '{}'", channelUID); + CharacteristicReadWriteClient readerWriter = this.rwService; + if (readerWriter == null) { + logger.warn("No reader/writer service available to handle command for '{}'", channelUID); return; } try { if (command instanceof HSBType) { logger.warn("Forbidden to send command '{}' directly to '{}'", command, channelUID); - } else if (command instanceof StopMoveType stopMoveType && StopMoveType.STOP == stopMoveType - && stopMoveChannel instanceof Channel stopMoveChannel) { - writeChannel(stopMoveChannel, OnOffType.ON, writer); + } else if (command instanceof StopMoveType stopMoveType && StopMoveType.STOP == stopMoveType) { + if (stopMoveChannel instanceof Channel stopMoveChannel) { + writeChannel(stopMoveChannel, OnOffType.ON, readerWriter); + } else if (readChannel(channel, readerWriter) instanceof Command actualPosition) { + writeChannel(channel, actualPosition, readerWriter); + } } else if (channelUID.equals(lightModelClientChannel)) { - lightModelHandleCommand(command, writer); + lightModelHandleCommand(command, readerWriter); } else { - writeChannel(channel, command, writer); + writeChannel(channel, command, readerWriter); } } catch (Exception e) { logger.warn("Failed to send command '{}' to '{}', reconnecting", command, channelUID, e); @@ -493,8 +496,8 @@ public void dispose() { * This method is called periodically by a scheduled executor. */ private void refresh() { - CharacteristicReadWriteClient rwService = this.rwService; - if (rwService != null) { + CharacteristicReadWriteClient reader = this.rwService; + if (reader != null) { try { Integer aid = getAccessoryId(); List queries = new ArrayList<>(); @@ -507,7 +510,7 @@ private void refresh() { if (queries.isEmpty()) { return; } - String jsonResponse = rwService.readCharacteristic(String.join(",", queries)); + String jsonResponse = reader.readCharacteristic(String.join(",", queries)); Service service = GSON.fromJson(jsonResponse, Service.class); if (service != null && service.characteristics instanceof List characteristics) { for (Channel channel : thing.getChannels()) { @@ -737,6 +740,33 @@ private void stopMoveFinalize(Accessory accessory, List channels) { } } + /** + * Reads the state of a specific channel by querying the accessory for the characteristic value. + * + * @param channel the channel to read + * @return the current state of the channel, or null if not found + * @throws Exception + */ + private synchronized @Nullable State readChannel(Channel channel, CharacteristicReadWriteClient reader) + throws Exception { + Integer aid = getAccessoryId(); + String iid = channel.getProperties().get(PROPERTY_IID); + if (aid == null || iid == null) { + throw new IllegalStateException( + "Missing accessory ID or characteristic IID for channel " + channel.getUID()); + } + String jsonResponse = reader.readCharacteristic("%s.%s".formatted(aid, iid)); + Service service = GSON.fromJson(jsonResponse, Service.class); + if (service != null && service.characteristics instanceof List characteristics) { + for (Characteristic cxx : characteristics) { + if (iid.equals(String.valueOf(cxx.iid)) && cxx.value instanceof JsonElement element) { + return convertJsonToState(element, channel); + } + } + } + return null; + } + /** * Writes a command to a specific channel by constructing a Service and embedded Characteristic object. * From fa8d0f0d4df7fbcd9f72006d3ae071603f7a20c4 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 21 Oct 2025 21:38:27 +0100 Subject: [PATCH 079/177] various - prepare for event callback listening - downgrade stack track logging Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 15 ++++- .../handler/HomekitBaseAccessoryHandler.java | 42 +++++++++++++- .../internal/session/EventCallback.java | 32 +++++++++++ .../internal/transport/IpTransport.java | 55 +++++++++++++++++++ 4 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventCallback.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 17a1e5fa067f4..f469bf3b95ea9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -352,7 +352,7 @@ private void createChannels() { ChannelGroupType channelGroupType = channelGroupTypeRegistry.getChannelGroupType(groupDef.getTypeUID()); if (channelGroupType == null) { - logger.warn("Fata Error: ChannelGroupType {} is not registered", groupDef.getTypeUID()); + logger.warn("Fatal Error: ChannelGroupType {} is not registered", groupDef.getTypeUID()); } else { logger.trace("++ChannelGroupType UID:{}, label:{}, category:{}, description:{}", channelGroupType.getUID(), channelGroupType.getLabel(), channelGroupType.getCategory(), @@ -461,7 +461,12 @@ public void handleCommand(ChannelUID channelUID, Command command) { writeChannel(channel, command, readerWriter); } } catch (Exception e) { - logger.warn("Failed to send command '{}' to '{}', reconnecting", command, channelUID, e); + if (logger.isTraceEnabled()) { + logger.trace("Failed to send command '{}' to '{}', reconnecting", command, channelUID, e); + } else { + logger.debug("Failed to send command '{}' to '{}', reconnecting: {}", command, channelUID, + e.getMessage()); + } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); startConnectionTask(); } @@ -535,7 +540,11 @@ private void refresh() { } } } catch (Exception e) { - logger.warn("Failed to poll accessory state, reconnecting", e); + if (logger.isTraceEnabled()) { + logger.trace("Failed to poll accessory state, reconnecting", e); + } else { + logger.debug("Failed to poll accessory state, reconnecting: {}", e.getMessage()); + } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); startConnectionTask(); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 077f97ba8a11d..7ba47bf10a09b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -36,6 +36,7 @@ import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.binding.homekit.internal.session.EventCallback; import org.openhab.binding.homekit.internal.transport.IpTransport; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; @@ -58,7 +59,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler { +public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler implements EventCallback { protected static final Gson GSON = new Gson(); @@ -89,6 +90,7 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { + cancelListening(); cancelConnectionTask(); if (!isChildAccessory) { try { @@ -118,7 +120,7 @@ private void fetchAccessories() { .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } logger.debug("Fetched {} accessories", accessories.size()); - scheduler.submit(() -> accessoriesLoaded()); // notify subclass in scheduler thread + scheduler.submit(this::accessoriesLoaded); // notify subclass in scheduler thread } catch (Exception e) { logger.debug("Failed to get accessories", e); } @@ -200,6 +202,7 @@ public void initialize() { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Failed to connect"); return; } + cancelListening(); cancelConnectionTask(); startConnectionTask(); } @@ -312,7 +315,7 @@ protected void startConnectionTask() { if (task != null) { task.cancel(false); } - connectionTask = scheduler.schedule(() -> initializePairing(), connectionDelaySeconds, TimeUnit.SECONDS); + connectionTask = scheduler.schedule(this::initializePairing, connectionDelaySeconds, TimeUnit.SECONDS); connectionDelaySeconds = Math.min(connectionDelaySeconds * connectionDelaySeconds, MAX_CONNECTION_DELAY_SECONDS); } @@ -329,4 +332,37 @@ private void cancelConnectionTask() { connectionTask = null; connectionDelaySeconds = MIN_CONNECTION_DELAY_SECONDS; } + + /** + * Starts listening for events and sets the provided callback to handle incoming events. + * If this handler is a child of a bridge, it delegates to the bridge handler. + * + * @param callback the EventCallback to handle incoming events + */ + protected void startListening() { + Bridge bridge = getBridge(); + if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + bridgeHandler.startListening(); + } else { + IpTransport ipTransport = this.ipTransport; + if (ipTransport != null) { + ipTransport.startListening(this); + } + } + } + + /** + * Cancels listening for events. + */ + private void cancelListening() { + IpTransport ipTransport = this.ipTransport; + if (ipTransport != null) { + ipTransport.cancelListening(); + } + } + + @Override + public void onEvent(byte[][] eventData) { + // TODO handle incoming event 3D byte array by forwarding the data the appropriate accessory thing handler(s) + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventCallback.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventCallback.java new file mode 100644 index 0000000000000..6a8d2ecc983a3 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventCallback.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.session; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Callback interface for handling HTTP 'EVENT' messages with associated HTTP headers and HTTP contents. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public interface EventCallback { + + /* + * Method invoked when an event occurs. Receives a 3D byte array where the first element is the HTTP + * headers, the second element is the content, and the third is the raw trace (if enabled). + * + * @param eventData a 3D array of byte arrays. + */ + public void onEvent(byte[][] eventData); +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 109b3f6aa2456..7cc00cb207ec5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -30,6 +30,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; +import org.openhab.binding.homekit.internal.session.EventCallback; import org.openhab.binding.homekit.internal.session.HttpPayloadParser; import org.openhab.binding.homekit.internal.session.SecureSession; import org.slf4j.Logger; @@ -56,6 +57,8 @@ public class IpTransport implements AutoCloseable { private final Socket socket; private @Nullable SecureSession secureSession = null; + private @Nullable EventCallback eventCallback = null; + private @Nullable Thread listenerThread = null; /** * Creates a new IpTransport instance with the given socket and session keys. @@ -239,4 +242,56 @@ private void checkHeaders(byte[] headers) throws IOException { public void close() throws Exception { socket.close(); } + + /** + * Listens for incoming events and invokes the registered callback. + * This method runs in a loop on a thread, receiving events from the secure session + * and passing them to the event callback until the callback is null, the thread is + * interrupted, or an error occurs. + */ + private void listenForEvents() { + while (!Thread.interrupted()) { + EventCallback eventCallback = this.eventCallback; + SecureSession secureSession = this.secureSession; + if (eventCallback == null || secureSession == null) { + break; + } + try { + byte[][] response = secureSession.receive(logger.isTraceEnabled()); + logger.trace("Event Response:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); + eventCallback.onEvent(response); + } catch (Exception e) { + if (logger.isTraceEnabled()) { + logger.trace("Error while listening for events", e); + } else { + logger.debug("Error while listening for events {}", e.getMessage()); + } + break; + } + } + } + + /** + * Starts listening for events and registers the provided callback. + * + * @param callback the callback to invoke on receiving events + */ + public void startListening(EventCallback callback) { + eventCallback = callback; + Thread listenerThread = new Thread(this::listenForEvents); + this.listenerThread = listenerThread; + listenerThread.start(); + } + + /** + * Cancels listening for events. + */ + public void cancelListening() { + eventCallback = null; + Thread listenerThread = this.listenerThread; + if (listenerThread != null) { + listenerThread.interrupt(); + } + this.listenerThread = null; + } } From 7d936ebc12be437802f42573e18062aa5e700756 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 22 Oct 2025 17:52:59 +0100 Subject: [PATCH 080/177] prepare i18n and event handling Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/dto/Accessory.java | 7 +- .../homekit/internal/dto/Characteristic.java | 31 ++-- .../binding/homekit/internal/dto/Service.java | 8 +- .../factory/HomekitHandlerFactory.java | 14 +- .../handler/HomekitAccessoryHandler.java | 149 +++++++++++------- .../handler/HomekitBaseAccessoryHandler.java | 90 +++++++---- .../handler/HomekitBridgeHandler.java | 9 +- ...{EventCallback.java => EventListener.java} | 11 +- .../internal/transport/IpTransport.java | 136 ++++++++++------ .../resources/OH-INF/i18n/homekit.properties | 10 ++ .../TestChannelCreationForAppleJson.java | 8 +- .../TestChannelCreationForVeluxJson.java | 16 +- 12 files changed, 312 insertions(+), 177 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/{EventCallback.java => EventListener.java} (57%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 59582e21f40aa..5e6d9e765204f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -21,10 +21,12 @@ import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Equipment; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.type.ChannelGroupDefinition; +import org.osgi.framework.Bundle; import com.google.gson.JsonElement; @@ -59,8 +61,9 @@ public class Accessory { * @return a list of channel group definition instances for the services of this accessory. */ public List buildAndRegisterChannelGroupDefinitions(ThingUID thingUID, - HomekitTypeProvider typeProvider) { - return services.stream().map(s -> s.buildAndRegisterChannelGroupDefinition(thingUID, typeProvider)) + HomekitTypeProvider typeProvider, TranslationProvider i18nProvider, Bundle bundle) { + return services.stream() + .map(s -> s.buildAndRegisterChannelGroupDefinition(thingUID, typeProvider, i18nProvider, bundle)) .filter(Objects::nonNull).toList(); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 83c5323c026a5..e402cf7c00be8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -27,6 +27,7 @@ import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.DataFormatType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.semantics.SemanticTag; import org.openhab.core.semantics.model.DefaultSemanticTags.Point; @@ -40,6 +41,7 @@ import org.openhab.core.types.StateDescriptionFragment; import org.openhab.core.types.StateDescriptionFragmentBuilder; import org.openhab.core.types.StateOption; +import org.osgi.framework.Bundle; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; @@ -83,7 +85,7 @@ public class Characteristic { * @return the ChannelDefinition or null if it cannot be mapped */ public @Nullable ChannelDefinition buildAndRegisterChannelDefinition(ThingUID thingUID, - HomekitTypeProvider typeProvider) { + HomekitTypeProvider typeProvider, TranslationProvider i18nProvider, Bundle bundle) { CharacteristicType characteristicType = getCharacteristicType(); DataFormatType dataFormatType; try { @@ -843,32 +845,35 @@ public class Characteristic { } // use valid values to build options for enum-like characteristics + List options = new ArrayList<>(); if (validValues != null && !validValues.isEmpty()) { - List options = validValues.stream().map(v -> v.toString()) - .map(s -> new StateOption(s, s)).toList(); - fragBldr.withOptions(options); + options.addAll(validValues.stream().map(v -> v.toString()).toList()); } else // use valid range to build options for enum-like characteristics if (validValuesRange != null && validValuesRange.size() == 2) { int min = validValuesRange.stream().mapToInt(Integer::intValue).min().orElse(0); // size check above int max = validValuesRange.stream().mapToInt(Integer::intValue).max().orElse(0); // ditto int step = minStep != null ? minStep.intValue() : 1; - List options = new ArrayList<>(); for (int i = min; i <= max; i += step) { - String s = Integer.toString(i); - options.add(new StateOption(s, s)); + options.add(Integer.toString(i)); } - fragBldr.withOptions(options); } else - // some enum-like characteristics fail to declare valid values/ranges so misuse min/max/step instead + // some enum-like characteristics fail to declare valid values/ranges so we misuse min/max/step instead if (isEnumLike && minValue instanceof Double min && maxValue instanceof Double max && max > min && minStep instanceof Double step && step > 0) { - List options = new ArrayList<>(); for (int i = min.intValue(); i <= max.intValue(); i += step.intValue()) { - String s = Integer.toString(i); - options.add(new StateOption(s, s)); + options.add(Integer.toString(i)); } - fragBldr.withOptions(options); + } + + if (!options.isEmpty()) { + String prefix = "characteristic.%s.".formatted(characteristicType.getOpenhabType()); + fragBldr.withOptions(options.stream().map(o -> { + String defaultText = "%s #%s".formatted(characteristicType.toString(), o); + String optionLabel = i18nProvider.getText(bundle, prefix + o, defaultText, null); + optionLabel = optionLabel == null || optionLabel.isBlank() ? defaultText : optionLabel; + return new StateOption(optionLabel, o); + }).toList()); } } StateDescriptionFragment stateDescriptionFragment = fragBldr.build(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index 27e793feb63c9..f8be8b957c53c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -22,12 +22,14 @@ import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelGroupDefinition; import org.openhab.core.thing.type.ChannelGroupType; import org.openhab.core.thing.type.ChannelGroupTypeBuilder; import org.openhab.core.thing.type.ChannelGroupTypeUID; +import org.osgi.framework.Bundle; import com.google.gson.JsonElement; @@ -57,15 +59,15 @@ public class Service { * @return the created ChannelGroupDefinition or null if creation failed */ public @Nullable ChannelGroupDefinition buildAndRegisterChannelGroupDefinition(ThingUID thingUID, - HomekitTypeProvider typeProvider) { + HomekitTypeProvider typeProvider, TranslationProvider i18nProvider, Bundle bundle) { ServiceType serviceType = getServiceType(); if (serviceType == null || ServiceType.ACCESSORY_INFORMATION == serviceType) { return null; } List channelDefinitions = characteristics.stream() - .map(c -> c.buildAndRegisterChannelDefinition(thingUID, typeProvider)).filter(Objects::nonNull) - .toList(); + .map(c -> c.buildAndRegisterChannelDefinition(thingUID, typeProvider, i18nProvider, bundle)) + .filter(Objects::nonNull).toList(); if (channelDefinitions.isEmpty()) { return null; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index be563ec3509aa..f0920cac50bb4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -22,6 +22,7 @@ import org.openhab.binding.homekit.internal.handler.HomekitBridgeHandler; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; @@ -30,6 +31,8 @@ import org.openhab.core.thing.binding.ThingHandlerFactory; import org.openhab.core.thing.type.ChannelGroupTypeRegistry; import org.openhab.core.thing.type.ChannelTypeRegistry; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -49,15 +52,20 @@ public class HomekitHandlerFactory extends BaseThingHandlerFactory { private final ChannelTypeRegistry channelTypeRegistry; private final ChannelGroupTypeRegistry channelGroupTypeRegistry; private final HomekitKeyStore keyStore; + private final TranslationProvider i18nProvider; + private final Bundle bundle; @Activate public HomekitHandlerFactory(@Reference HomekitTypeProvider typeProvider, @Reference ChannelTypeRegistry channelTypeRegistry, - @Reference ChannelGroupTypeRegistry channelGroupTypeRegistry, @Reference HomekitKeyStore keyStore) { + @Reference ChannelGroupTypeRegistry channelGroupTypeRegistry, @Reference HomekitKeyStore keyStore, + @Reference TranslationProvider translationProvider) { this.typeProvider = typeProvider; this.channelTypeRegistry = channelTypeRegistry; this.channelGroupTypeRegistry = channelGroupTypeRegistry; this.keyStore = keyStore; + this.i18nProvider = translationProvider; + this.bundle = FrameworkUtil.getBundle(getClass()); } @Override @@ -69,10 +77,10 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { - return new HomekitBridgeHandler((Bridge) thing, typeProvider, keyStore); + return new HomekitBridgeHandler((Bridge) thing, typeProvider, keyStore, i18nProvider, bundle); } else if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { return new HomekitAccessoryHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry, - keyStore); + keyStore, i18nProvider, bundle); } return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index f469bf3b95ea9..28f5a743fb037 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -40,6 +40,7 @@ import org.openhab.binding.homekit.internal.temporary.LightModel; import org.openhab.binding.homekit.internal.temporary.LightModel.LightCapabilities; import org.openhab.binding.homekit.internal.temporary.LightModel.RgbDataType; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DecimalType; @@ -74,6 +75,7 @@ import org.openhab.core.types.StateOption; import org.openhab.core.types.UnDefType; import org.openhab.core.types.util.UnitUtils; +import org.osgi.framework.Bundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -110,8 +112,8 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, ChannelTypeRegistry channelTypeRegistry, ChannelGroupTypeRegistry channelGroupTypeRegistry, - HomekitKeyStore keyStore) { - super(thing, typeProvider, keyStore); + HomekitKeyStore keyStore, TranslationProvider i18nProvider, Bundle bundle) { + super(thing, typeProvider, keyStore, i18nProvider, bundle); this.channelTypeRegistry = channelTypeRegistry; this.channelGroupTypeRegistry = channelGroupTypeRegistry; } @@ -346,66 +348,74 @@ private void createChannels() { // create the channels and properties List channels = new ArrayList<>(); Map properties = new HashMap<>(thing.getProperties()); // keep existing properties - accessory.buildAndRegisterChannelGroupDefinitions(thing.getUID(), typeProvider).forEach(groupDef -> { - logger.trace("+ChannelGroupDefinition id:{}, typeUID:{}, label:{}, description:{}", groupDef.getId(), - groupDef.getTypeUID(), groupDef.getLabel(), groupDef.getDescription()); - - ChannelGroupType channelGroupType = channelGroupTypeRegistry.getChannelGroupType(groupDef.getTypeUID()); - if (channelGroupType == null) { - logger.warn("Fatal Error: ChannelGroupType {} is not registered", groupDef.getTypeUID()); - } else { - logger.trace("++ChannelGroupType UID:{}, label:{}, category:{}, description:{}", - channelGroupType.getUID(), channelGroupType.getLabel(), channelGroupType.getCategory(), - channelGroupType.getDescription()); - - channelGroupType.getChannelDefinitions().forEach(chanDef -> { - logger.trace( - "+++ChannelDefinition id:{}, label:{}, description:{}, channelTypeUID:{}, autoUpdatePolicy:{}, properties:{}", - chanDef.getId(), chanDef.getLabel(), chanDef.getDescription(), chanDef.getChannelTypeUID(), - chanDef.getAutoUpdatePolicy(), chanDef.getProperties()); - - if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(chanDef.getChannelTypeUID())) { - // this is a property, not a channel - String name = chanDef.getId(); - String value = chanDef.getLabel(); - if (value != null) { - properties.put(name, value); - logger.trace("++++Property '{}:{}'", name, value); - } + accessory.buildAndRegisterChannelGroupDefinitions(thing.getUID(), typeProvider, i18nProvider, bundle) + .forEach(groupDef -> { + logger.trace("+ChannelGroupDefinition id:{}, typeUID:{}, label:{}, description:{}", + groupDef.getId(), groupDef.getTypeUID(), groupDef.getLabel(), groupDef.getDescription()); + + ChannelGroupType channelGroupType = channelGroupTypeRegistry + .getChannelGroupType(groupDef.getTypeUID()); + if (channelGroupType == null) { + logger.warn("Fatal Error: ChannelGroupType {} is not registered", groupDef.getTypeUID()); } else { - // this is a real channel - ChannelType channelType = channelTypeRegistry.getChannelType(chanDef.getChannelTypeUID()); - if (channelType == null) { - logger.warn("Fatal Error: ChannelType {} is not registered", chanDef.getChannelTypeUID()); - } else { - logger.trace( - "++++ChannelType category:{}, description:{}, itemType:{}, label:{}, autoUpdatePolicy:{}, itemType:{}, kind:{}, tags:{}, uid:{}, unitHint:{}", - channelType.getCategory(), channelType.getDescription(), channelType.getItemType(), - channelType.getLabel(), channelType.getAutoUpdatePolicy(), - channelType.getItemType(), channelType.getKind(), channelType.getTags(), - channelType.getUID(), channelType.getUnitHint()); - - ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), chanDef.getId()); - ChannelBuilder builder = ChannelBuilder.create(channelUID) - .withAcceptedItemType(channelType.getItemType()) - .withAutoUpdatePolicy(channelType.getAutoUpdatePolicy()) - .withDefaultTags(channelType.getTags()).withKind(channelType.getKind()) - .withProperties(chanDef.getProperties()).withType(channelType.getUID()); - Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); - Optional.ofNullable(chanDef.getDescription()).ifPresent(builder::withDescription); - Channel channel = builder.build(); - channels.add(channel); + logger.trace("++ChannelGroupType UID:{}, label:{}, category:{}, description:{}", + channelGroupType.getUID(), channelGroupType.getLabel(), channelGroupType.getCategory(), + channelGroupType.getDescription()); + channelGroupType.getChannelDefinitions().forEach(chanDef -> { logger.trace( - "+++++Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", - channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), - channel.getKind(), channel.getLabel(), channel.getProperties(), channel.getUID()); + "+++ChannelDefinition id:{}, label:{}, description:{}, channelTypeUID:{}, autoUpdatePolicy:{}, properties:{}", + chanDef.getId(), chanDef.getLabel(), chanDef.getDescription(), + chanDef.getChannelTypeUID(), chanDef.getAutoUpdatePolicy(), + chanDef.getProperties()); + + if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(chanDef.getChannelTypeUID())) { + // this is a property, not a channel + String name = chanDef.getId(); + String value = chanDef.getLabel(); + if (value != null) { + properties.put(name, value); + logger.trace("++++Property '{}:{}'", name, value); + } + } else { + // this is a real channel + ChannelType channelType = channelTypeRegistry + .getChannelType(chanDef.getChannelTypeUID()); + if (channelType == null) { + logger.warn("Fatal Error: ChannelType {} is not registered", + chanDef.getChannelTypeUID()); + } else { + logger.trace( + "++++ChannelType category:{}, description:{}, itemType:{}, label:{}, autoUpdatePolicy:{}, itemType:{}, kind:{}, tags:{}, uid:{}, unitHint:{}", + channelType.getCategory(), channelType.getDescription(), + channelType.getItemType(), channelType.getLabel(), + channelType.getAutoUpdatePolicy(), channelType.getItemType(), + channelType.getKind(), channelType.getTags(), channelType.getUID(), + channelType.getUnitHint()); + + ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), + chanDef.getId()); + ChannelBuilder builder = ChannelBuilder.create(channelUID) + .withAcceptedItemType(channelType.getItemType()) + .withAutoUpdatePolicy(channelType.getAutoUpdatePolicy()) + .withDefaultTags(channelType.getTags()).withKind(channelType.getKind()) + .withProperties(chanDef.getProperties()).withType(channelType.getUID()); + Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); + Optional.ofNullable(chanDef.getDescription()).ifPresent(builder::withDescription); + Channel channel = builder.build(); + channels.add(channel); + + logger.trace( + "+++++Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", + channel.getAcceptedItemType(), channel.getDefaultTags(), + channel.getDescription(), channel.getKind(), channel.getLabel(), + channel.getProperties(), channel.getUID()); - } + } + } + }); } }); - } - }); lightModelFinalize(accessory, channels); stopMoveFinalize(accessory, channels); @@ -467,7 +477,8 @@ public void handleCommand(ChannelUID channelUID, Command command) { logger.debug("Failed to send command '{}' to '{}', reconnecting: {}", command, channelUID, e.getMessage()); } - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + i18nProvider.getText(bundle, "error.error-sending-command", "Polling error", null)); startConnectionTask(); } } @@ -545,7 +556,8 @@ private void refresh() { } else { logger.debug("Failed to poll accessory state, reconnecting: {}", e.getMessage()); } - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + i18nProvider.getText(bundle, "error.polling-error", "Polling error", null)); startConnectionTask(); } } @@ -807,4 +819,25 @@ protected void startConnectionTask() { cancelRefreshTask(); super.startConnectionTask(); } + + @Override + public void onEvent(String jsonContent) { + Service service = GSON.fromJson(jsonContent, Service.class); + if (service != null && service.characteristics instanceof List characteristics) { + for (Channel channel : thing.getChannels()) { + String iid = channel.getProperties().get(PROPERTY_IID); + if (iid != null) { + for (Characteristic cxx : characteristics) { + if (iid.equals(String.valueOf(cxx.iid)) && cxx.value instanceof JsonElement element) { + State state = convertJsonToState(element, channel); + switch (channel.getKind()) { + case STATE -> updateState(channel.getUID(), state); + case TRIGGER -> triggerChannel(channel.getUID(), state.toFullString()); + } + } + } + } + } + } + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 7ba47bf10a09b..52bb880e6c1c3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -36,14 +36,16 @@ import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; -import org.openhab.binding.homekit.internal.session.EventCallback; +import org.openhab.binding.homekit.internal.session.EventListener; import org.openhab.binding.homekit.internal.transport.IpTransport; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.BaseThingHandler; +import org.osgi.framework.Bundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -59,7 +61,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler implements EventCallback { +public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler implements EventListener { protected static final Gson GSON = new Gson(); @@ -71,6 +73,8 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected final Map accessories = new HashMap<>(); protected final HomekitTypeProvider typeProvider; protected final HomekitKeyStore keyStore; + protected final TranslationProvider i18nProvider; + protected final Bundle bundle; protected boolean isChildAccessory = false; @@ -82,15 +86,18 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple private int connectionDelaySeconds = MIN_CONNECTION_DELAY_SECONDS; private @Nullable ScheduledFuture connectionTask; - public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore) { + public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, + TranslationProvider translationProvider, Bundle bundle) { super(thing); this.typeProvider = typeProvider; this.keyStore = keyStore; + this.i18nProvider = translationProvider; + this.bundle = bundle; } @Override public void dispose() { - cancelListening(); + eventsUnsubscribe(); cancelConnectionTask(); if (!isChildAccessory) { try { @@ -175,8 +182,7 @@ public void handleRemoval() { @Override public void initialize() { - Bridge bridge = getBridge(); - if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + if (getBridgeHandler() instanceof HomekitBridgeHandler bridgeHandler) { // accessory is hosted by a bridge, so use bridge's pairing session and read/write service isChildAccessory = true; ipTransport = bridgeHandler.ipTransport; @@ -185,24 +191,27 @@ public void initialize() { fetchAccessories(); updateStatus(ThingStatus.ONLINE); } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "Bridge is not connected"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, + i18nProvider.getText(bundle, "error.bridge-not-connected", "Bridge not connected", null)); } } else { // standalone accessory or bridge accessory, so do pairing and session setup here isChildAccessory = false; Object host = getConfig().get(CONFIG_HOST); if (host == null || !(host instanceof String hostString) || !HOST_PATTERN.matcher(hostString).matches()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid host"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.invalid-host", "Invalid host", null)); return; } try { ipTransport = new IpTransport(hostString); } catch (Exception e) { logger.debug("Failed to create transport", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Failed to connect"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); return; } - cancelListening(); + eventsUnsubscribe(); cancelConnectionTask(); startConnectionTask(); } @@ -217,20 +226,23 @@ private synchronized void initializePairing() { if (pairingConfig == null || !(pairingConfig instanceof String pairingConfigString) || !PAIRING_CODE_PATTERN.matcher(pairingConfigString).matches()) { logger.debug("Pairing code must match XXX-XX-XXX or XXXX-XXXX or XXXXXXXX"); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid pairing code"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.invalid-pairing-code", "Invalid pairing code", null)); return; } pairingCode = normalizePairingCode(pairingConfigString); accessoryId = getAccessoryId(); if (accessoryId == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid accessory ID"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.invalid-accessory-id", "Invalid accessory ID", null)); return; } final String macAddress = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); if (macAddress == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Missing MAC address"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.missing-mac-address", "Missing MAC address", null)); return; } @@ -280,7 +292,8 @@ private synchronized void initializePairing() { } catch (Exception e) { logger.warn("Pairing / verification failed for {}", thing.getUID(), e); startConnectionTask(); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Pairing / verification failed"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, + "error.pairing-verification-failed", "Pairing / verification failed", null)); } } @@ -334,35 +347,48 @@ private void cancelConnectionTask() { } /** - * Starts listening for events and sets the provided callback to handle incoming events. - * If this handler is a child of a bridge, it delegates to the bridge handler. + * Gets the bridge handler if this thing is connected to a bridge. * - * @param callback the EventCallback to handle incoming events + * @return the HomekitBridgeHandler or null if not connected to a bridge */ - protected void startListening() { - Bridge bridge = getBridge(); - if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { - bridgeHandler.startListening(); - } else { - IpTransport ipTransport = this.ipTransport; - if (ipTransport != null) { - ipTransport.startListening(this); - } + protected @Nullable HomekitBridgeHandler getBridgeHandler() { + return getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler handler + ? handler + : null; + } + + /** + * Gets the IP transport from the bridge handler if connected to a bridge; otherwise, returns + * this handler's IP transport. + * + * @return the IpTransport or null if not available + */ + protected @Nullable IpTransport getIpTransport() { + return getBridgeHandler() instanceof HomekitBridgeHandler h ? h.getIpTransport() : getIpTransport(); + } + + /** + * Subscribes to events from the IP transport. + */ + protected void eventsSubscribe() { + IpTransport ipTransport = getIpTransport(); + if (ipTransport != null) { + ipTransport.subscribe(this); } } /** - * Cancels listening for events. + * Unsubscribes from events from the IP transport. */ - private void cancelListening() { - IpTransport ipTransport = this.ipTransport; + protected void eventsUnsubscribe() { + IpTransport ipTransport = getIpTransport(); if (ipTransport != null) { - ipTransport.cancelListening(); + ipTransport.unsubscribe(this); } } @Override - public void onEvent(byte[][] eventData) { - // TODO handle incoming event 3D byte array by forwarding the data the appropriate accessory thing handler(s) + public void onEvent(String jsonContent) { + // default implementation does nothing; subclasses may override } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 8d133a643d3d7..cceceb0f84f4a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -25,6 +25,7 @@ import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; @@ -34,6 +35,7 @@ import org.openhab.core.thing.binding.builder.BridgeBuilder; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.types.Command; +import org.osgi.framework.Bundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,8 +54,9 @@ public class HomekitBridgeHandler extends HomekitBaseAccessoryHandler implements private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); - public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore) { - super(bridge, typeProvider, keyStore); + public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, + TranslationProvider i18nProvider, Bundle bundle) { + super(bridge, typeProvider, keyStore, i18nProvider, bundle); } @Override @@ -127,7 +130,7 @@ private void createProperties() { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { for (Characteristic characteristic : service.characteristics) { ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thing.getUID(), - typeProvider); + typeProvider, i18nProvider, bundle); if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { String name = channelDef.getId(); String value = channelDef.getLabel(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventCallback.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventListener.java similarity index 57% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventCallback.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventListener.java index 6a8d2ecc983a3..7121945689179 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventCallback.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventListener.java @@ -15,18 +15,17 @@ import org.eclipse.jdt.annotation.NonNullByDefault; /** - * Callback interface for handling HTTP 'EVENT' messages with associated HTTP headers and HTTP contents. + * Callback interface for handling HTTP 'EVENT' message contents. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public interface EventCallback { +public interface EventListener { /* - * Method invoked when an event occurs. Receives a 3D byte array where the first element is the HTTP - * headers, the second element is the content, and the third is the raw trace (if enabled). + * Method invoked when an event occurs. * - * @param eventData a 3D array of byte arrays. + * @param jsonContent string containing the HTTP json content. */ - public void onEvent(byte[][] eventData); + public void onEvent(String jsonContent); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 7cc00cb207ec5..c66a6209cdd2e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -18,8 +18,12 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; +import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -30,7 +34,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; -import org.openhab.binding.homekit.internal.session.EventCallback; +import org.openhab.binding.homekit.internal.session.EventListener; import org.openhab.binding.homekit.internal.session.HttpPayloadParser; import org.openhab.binding.homekit.internal.session.SecureSession; import org.slf4j.Logger; @@ -51,14 +55,17 @@ public class IpTransport implements AutoCloseable { private static final int TIMEOUT_MILLI_SECONDS = (int) Duration.ofSeconds(10).toMillis(); private final Logger logger = LoggerFactory.getLogger(IpTransport.class); - private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "homekit-io")); + + private final ExecutorService writeExecutor = Executors + .newSingleThreadExecutor(r -> new Thread(r, "homekit-writer")); private final String host; // ip address with optional port e.g. "192.168.1.42:9123" private final Socket socket; + private final Set eventListeners = ConcurrentHashMap.newKeySet(); private @Nullable SecureSession secureSession = null; - private @Nullable EventCallback eventCallback = null; - private @Nullable Thread listenerThread = null; + private @Nullable Thread readThread = null; + private @Nullable CompletableFuture readFuture = null; /** * Creates a new IpTransport instance with the given socket and session keys. @@ -86,6 +93,9 @@ public IpTransport(String host) throws Exception { public void setSessionKeys(AsymmetricSessionKeys keys) throws Exception { secureSession = new SecureSession(socket, keys); + Thread thread = new Thread(this::readTask, "homekit-reader"); + thread.start(); + readThread = thread; } public byte[] get(String endpoint, String contentType) @@ -116,25 +126,28 @@ private synchronized byte[] execute(String method, String endpoint, String conte byte[][] response; // 0 = headers, 1 = content, 2 = raw trace (if enabled) SecureSession secureSession = this.secureSession; if (secureSession != null) { - Future<@Nullable Void> sendTask = executor.submit(() -> { + // before we write request, create CompletableFuture to read response (with a timeout) + CompletableFuture readFuture = new CompletableFuture<>(); + this.readFuture = readFuture; + // create Future to write the request (with a timeout) + Future<@Nullable Void> writeTask = writeExecutor.submit(() -> { secureSession.send(request); return null; }); - // the Future.get() call applies a timeout to write operations - sendTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); - // the socket applies its internal timeout to read operations - response = secureSession.receive(trace); + // now wait for both write and read to complete + writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + response = readFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); } else { OutputStream out = socket.getOutputStream(); InputStream in = socket.getInputStream(); - Future<@Nullable Void> sendTask = executor.submit(() -> { + // create Future to write the request (with a timeout) + Future<@Nullable Void> writeTask = writeExecutor.submit(() -> { out.write(request); out.flush(); return null; }); - // the Future.get() call applies a timeout to write operations - sendTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); - // the socket applies its internal timeout to read operations + // now wait for both write and read to complete + writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); response = readPlainResponse(in, trace); } @@ -241,57 +254,78 @@ private void checkHeaders(byte[] headers) throws IOException { @Override public void close() throws Exception { socket.close(); + secureSession = null; + Thread thread = readThread; + if (thread != null) { + thread.interrupt(); + thread.join(); + } } /** - * Listens for incoming events and invokes the registered callback. - * This method runs in a loop on a thread, receiving events from the secure session - * and passing them to the event callback until the callback is null, the thread is - * interrupted, or an error occurs. + * Handles an incoming response message by completing the read future or notifying event listeners. + * + * @param response the received response as a 3D byte array */ - private void listenForEvents() { - while (!Thread.interrupted()) { - EventCallback eventCallback = this.eventCallback; - SecureSession secureSession = this.secureSession; - if (eventCallback == null || secureSession == null) { - break; + private void handleResponse(byte[][] response) { + CompletableFuture future = readFuture; + if (future != null) { + readFuture = null; + future.complete(response); + } + String headers = new String(response[0], StandardCharsets.ISO_8859_1); + if (headers.startsWith("EVENT ")) { + logger.trace("Event:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); + String jsonContent = new String(response[1], StandardCharsets.UTF_8); + for (EventListener eventListener : eventListeners) { + eventListener.onEvent(jsonContent); } + } + } + + /** + * Listens for incoming response messages and invokes the callback. This method runs in a loop on a + * thread, receiving responses from the secure session and passing them to the callback until the + * thread is interrupted, or an error occurs. + */ + private void readTask() { + Throwable cause = null; + do { try { - byte[][] response = secureSession.receive(logger.isTraceEnabled()); - logger.trace("Event Response:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); - eventCallback.onEvent(response); - } catch (Exception e) { - if (logger.isTraceEnabled()) { - logger.trace("Error while listening for events", e); - } else { - logger.debug("Error while listening for events {}", e.getMessage()); + SecureSession session = secureSession; + if (session == null) { + throw new IllegalStateException("Secure session is null"); } + byte[][] response = session.receive(logger.isTraceEnabled()); + handleResponse(response); + } catch (SocketTimeoutException e) { + // ignore socket timeout; continue listening + } catch (Exception e) { + cause = e; break; } + } while (!Thread.currentThread().isInterrupted()); + + CompletableFuture future = readFuture; + if (future != null) { + readFuture = null; + future.completeExceptionally(cause != null ? cause : new InterruptedException("Listener interrupted")); + } + + if (cause != null) { + if (logger.isTraceEnabled()) { + logger.trace("Error while listening for events", cause); + } else { + logger.debug("Error while listening for events {}", cause.getMessage()); + } } } - /** - * Starts listening for events and registers the provided callback. - * - * @param callback the callback to invoke on receiving events - */ - public void startListening(EventCallback callback) { - eventCallback = callback; - Thread listenerThread = new Thread(this::listenForEvents); - this.listenerThread = listenerThread; - listenerThread.start(); + public void subscribe(EventListener listener) { + eventListeners.add(listener); } - /** - * Cancels listening for events. - */ - public void cancelListening() { - eventCallback = null; - Thread listenerThread = this.listenerThread; - if (listenerThread != null) { - listenerThread.interrupt(); - } - this.listenerThread = null; + public void unsubscribe(EventListener listener) { + eventListeners.remove(listener); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 4a7f7dd21ab5d..3201fde778a2b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -22,3 +22,13 @@ thing-type.config.homekit.accessory.pairingCode.label = Pairing Code thing-type.config.homekit.accessory.pairingCode.description = Code used for pairing with the HomeKit accessory. thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. + +error.bridge-not-connected = Bridge not connected +error.invalid-host = Invalid host +error.failed-to-connect = Failed to connect +error.invalid-pairing-code = Invalid pairing code +error.invalid-accessory-id = Invalid accessory ID +error.missing-mac-address = Missing MAC address +error.pairing-verification-failed = Pairing / verification failed +error.polling-error = Polling error +error.error-sending-command = Error sending command diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java index 612f537003a5c..c660474d65006 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java @@ -31,12 +31,14 @@ import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelGroupDefinition; import org.openhab.core.thing.type.ChannelGroupType; import org.openhab.core.thing.type.ChannelType; import org.openhab.core.types.StateDescription; +import org.osgi.framework.Bundle; import com.google.gson.Gson; import com.google.gson.JsonElement; @@ -365,6 +367,8 @@ void testChannelDefinitions() { assertNotNull(accessories); HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + TranslationProvider i18nProvider = mock(TranslationProvider.class); + Bundle bundle = mock(Bundle.class); List channelGroupTypes = new ArrayList<>(); List channelTypes = new ArrayList<>(); @@ -388,7 +392,7 @@ void testChannelDefinitions() { Accessory accessory = accessories.getAccessory(3); assertNotNull(accessory); List channelGroupDefinitions = accessory - .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider); + .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); // There should be just one channel group definition for the Light Bulb service assertNotNull(channelGroupDefinitions); @@ -452,7 +456,7 @@ void testChannelDefinitions() { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { for (Characteristic characteristic : service.characteristics) { ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thingUID, - typeProvider); + typeProvider, i18nProvider, bundle); if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { String name = channelDef.getId(); String value = channelDef.getLabel(); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java index 6acfcacd7011e..850cd42bc1f7c 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -31,6 +31,7 @@ import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelGroupDefinition; @@ -39,6 +40,7 @@ import org.openhab.core.thing.type.ChannelType; import org.openhab.core.types.StateDescription; import org.openhab.core.types.StateOption; +import org.osgi.framework.Bundle; import com.google.gson.Gson; import com.google.gson.JsonElement; @@ -1586,6 +1588,8 @@ void testBridge() { assertNotNull(accessories); HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + TranslationProvider i18nProvider = mock(TranslationProvider.class); + Bundle bundle = mock(Bundle.class); List channelGroupTypes = new ArrayList<>(); List channelTypes = new ArrayList<>(); @@ -1611,7 +1615,7 @@ void testBridge() { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { for (Characteristic characteristic : service.characteristics) { ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thingUID, - typeProvider); + typeProvider, i18nProvider, bundle); if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { String name = channelDef.getId(); String value = channelDef.getLabel(); @@ -1639,6 +1643,8 @@ void testSensors() { assertNotNull(accessories); HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + TranslationProvider i18nProvider = mock(TranslationProvider.class); + Bundle bundle = mock(Bundle.class); List channelGroupTypes = new ArrayList<>(); List channelTypes = new ArrayList<>(); @@ -1660,7 +1666,7 @@ void testSensors() { Accessory accessory = accessories.getAccessory(2); assertNotNull(accessory); List channelGroupDefinitions = accessory - .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider); + .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); // There should be three channel group definitions for the temperature, humidity and co2 sensors assertNotNull(channelGroupDefinitions); @@ -1797,6 +1803,8 @@ void testVenetianBlind() { assertNotNull(accessories); HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + TranslationProvider i18nProvider = mock(TranslationProvider.class); + Bundle bundle = mock(Bundle.class); List channelGroupTypes = new ArrayList<>(); List channelTypes = new ArrayList<>(); @@ -1817,7 +1825,7 @@ void testVenetianBlind() { Accessory accessory = accessories.getAccessory(9); assertNotNull(accessory); List channelGroupDefinitions = accessory - .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider); + .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); // There should be one channel group definition for the blind assertNotNull(channelGroupDefinitions); @@ -1970,6 +1978,6 @@ void testVenetianBlind() { List options = state.getOptions(); assertNotNull(options); assertEquals(3, options.size()); - assertEquals("2", options.get(2).getValue()); + assertEquals("Position State #2", options.get(2).getValue()); } } From 2ce57a11d6c7c13c2b6010634b4febc05b2d749a Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 22 Oct 2025 18:25:42 +0100 Subject: [PATCH 081/177] typo Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 28f5a743fb037..b3fea0d1fa4a5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -478,7 +478,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { e.getMessage()); } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - i18nProvider.getText(bundle, "error.error-sending-command", "Polling error", null)); + i18nProvider.getText(bundle, "error.error-sending-command", "Error sending command", null)); startConnectionTask(); } } From 091507db758a7bb78dac357ebb27f8cf6991607f Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 22 Oct 2025 19:27:52 +0100 Subject: [PATCH 082/177] eventing code completed Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/dto/Characteristic.java | 2 +- .../handler/HomekitAccessoryHandler.java | 67 ++++++++++++++++++- .../handler/HomekitBaseAccessoryHandler.java | 8 +-- .../CharacteristicReadWriteClient.java | 2 +- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index e402cf7c00be8..e4239e279e498 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -67,7 +67,7 @@ public class Characteristic { public @NonNullByDefault({}) Double minStep; public @NonNullByDefault({}) JsonElement value; // e.g. true, 23, "Some String" public @NonNullByDefault({}) String description; - public @NonNullByDefault({}) Boolean ev; // e.g. true + public @NonNullByDefault({}) Boolean ev; // e.g. true (events requested) public @NonNullByDefault({}) Integer aid; // e.g. 10 public @NonNullByDefault({}) @SerializedName("valid-values") List validValues; public @NonNullByDefault({}) @SerializedName("valid-values-range") List validValuesRange; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index b3fea0d1fa4a5..67fd89242d4d3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -17,10 +17,12 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -105,7 +107,9 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { */ private @Nullable LightModel lightModel = null; private @Nullable ChannelUID lightModelClientChannel = null; // special HSB combined channel + private final Map lightModelServerChannels = new HashMap<>(); + private final Set eventedChannels = new HashSet<>(); private @Nullable Channel stopMoveChannel = null; // channel for the stop button (rollershutters) private @Nullable ScheduledFuture refreshTask; @@ -126,8 +130,8 @@ protected void accessoriesLoaded() { /** * Called when the thing handler has been initialized, the pairing verified, the accessories loaded, - * and the channels and properties created. - * Sets up a scheduled task to periodically refresh the state of the accessory. + * and the channels and properties created. Sets up a scheduled task to periodically refresh the state + * of the accessory. And subscribes to evented channels if applicable. */ private void channelsAndPropertiesLoaded() { if (getConfig().get(CONFIG_REFRESH_INTERVAL) instanceof Object refreshInterval) { @@ -144,7 +148,35 @@ private void channelsAndPropertiesLoaded() { } catch (NumberFormatException e) { } } - logger.warn("Invalid refresh interval configuration, polling disabled"); + if (refreshTask == null) { + logger.warn("Invalid refresh interval configuration, polling disabled"); + } + + if (eventedChannels.isEmpty()) { + unsubscribeEvents(); + } else { + CharacteristicReadWriteClient writer = this.rwService; + if (writer != null) { + Service service = new Service(); + service.characteristics = new ArrayList<>(); + for (Channel channel : eventedChannels) { + if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { + Characteristic characteristic = new Characteristic(); + characteristic.iid = Integer.parseInt(iid); + characteristic.aid = getAccessoryId(); + characteristic.ev = true; // enable events + service.characteristics.add(characteristic); + } + } + try { + writer.writeCharacteristic(GSON.toJson(service)); + subscribeEvents(); + logger.debug("Eventing enabled for {} channels", eventedChannels.size()); + } catch (Exception e) { + logger.warn("Failed to subscribe to evented channels, eventing disabled"); + } + } + } } /** @@ -419,6 +451,7 @@ private void createChannels() { lightModelFinalize(accessory, channels); stopMoveFinalize(accessory, channels); + eventingFinalize(accessory, channels); String oldLabel = thing.getLabel(); String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; @@ -504,6 +537,7 @@ public void dispose() { lightModel = null; lightModelServerChannels.clear(); lightModelClientChannel = null; + eventedChannels.clear(); super.dispose(); } @@ -761,6 +795,28 @@ private void stopMoveFinalize(Accessory accessory, List channels) { } } + /** + * Identifies evented channels by checking for characteristics with the 'ev' permission. + * + * @param accessory the accessory containing the characteristics + * @param channels the list of channels to check + */ + private void eventingFinalize(Accessory accessory, List channels) { + eventedChannels.clear(); + for (Channel channel : channels) { + if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { + for (Service service : accessory.services) { + for (Characteristic cxx : service.characteristics) { + if (iid.equals(String.valueOf(cxx.iid)) && cxx.perms instanceof List perms + && perms.contains("ev")) { + eventedChannels.add(channel); + } + } + } + } + } + } + /** * Reads the state of a specific channel by querying the accessory for the characteristic value. * @@ -820,6 +876,11 @@ protected void startConnectionTask() { super.startConnectionTask(); } + /** + * Handles incoming events by updating the corresponding channels based on the characteristic values. + * + * @param jsonContent the JSON content of the event + */ @Override public void onEvent(String jsonContent) { Service service = GSON.fromJson(jsonContent, Service.class); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 52bb880e6c1c3..00c07ec24e824 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -97,7 +97,7 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { - eventsUnsubscribe(); + unsubscribeEvents(); cancelConnectionTask(); if (!isChildAccessory) { try { @@ -211,7 +211,7 @@ public void initialize() { i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); return; } - eventsUnsubscribe(); + unsubscribeEvents(); cancelConnectionTask(); startConnectionTask(); } @@ -370,7 +370,7 @@ private void cancelConnectionTask() { /** * Subscribes to events from the IP transport. */ - protected void eventsSubscribe() { + protected void subscribeEvents() { IpTransport ipTransport = getIpTransport(); if (ipTransport != null) { ipTransport.subscribe(this); @@ -380,7 +380,7 @@ protected void eventsSubscribe() { /** * Unsubscribes from events from the IP transport. */ - protected void eventsUnsubscribe() { + protected void unsubscribeEvents() { IpTransport ipTransport = getIpTransport(); if (ipTransport != null) { ipTransport.unsubscribe(this); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java index 4d244644a973e..26f48ad645055 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java @@ -48,7 +48,7 @@ public String readCharacteristic(String query) throws Exception { } /** - * Writes a characteristic to the accessory. + * Writes characteristic(s) to the accessory. * * @param json the JSON string to write. * @throws Exception on communication or encryption errors From 3257f5984a765bf9fb43f61b8a6add65edbe85b0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 22 Oct 2025 21:45:52 +0100 Subject: [PATCH 083/177] eliminate recursion loop; eliminate duplicate code Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 55 ++++++++----------- .../handler/HomekitBaseAccessoryHandler.java | 2 +- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 67fd89242d4d3..8473be907ceb9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -560,30 +560,8 @@ private void refresh() { if (queries.isEmpty()) { return; } - String jsonResponse = reader.readCharacteristic(String.join(",", queries)); - Service service = GSON.fromJson(jsonResponse, Service.class); - if (service != null && service.characteristics instanceof List characteristics) { - for (Channel channel : thing.getChannels()) { - ChannelUID channelUID = channel.getUID(); - if (channelUID.equals(lightModelClientChannel)) { - for (Characteristic cxx : characteristics) { - if (lightModelRefresh(cxx)) { - updateState(channelUID, Objects.requireNonNull(lightModel).getHsb()); - } - } - } else if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { - for (Characteristic cxx : characteristics) { - if (iid.equals(String.valueOf(cxx.iid)) && cxx.value instanceof JsonElement element) { - State state = convertJsonToState(element, channel); - switch (channel.getKind()) { - case STATE -> updateState(channelUID, state); - case TRIGGER -> triggerChannel(channelUID, state.toFullString()); - } - } - } - } - } - } + String json = reader.readCharacteristic(String.join(",", queries)); + updateChannelsFromJson(json); } catch (Exception e) { if (logger.isTraceEnabled()) { logger.trace("Failed to poll accessory state, reconnecting", e); @@ -879,21 +857,36 @@ protected void startConnectionTask() { /** * Handles incoming events by updating the corresponding channels based on the characteristic values. * - * @param jsonContent the JSON content of the event + * @param json the JSON content of the event */ @Override - public void onEvent(String jsonContent) { - Service service = GSON.fromJson(jsonContent, Service.class); + public void onEvent(String json) { + updateChannelsFromJson(json); + } + + /** + * Updates the channels based on the provided JSON content. + * + * @param json the JSON content containing characteristic values + */ + private void updateChannelsFromJson(String json) { + Service service = GSON.fromJson(json, Service.class); if (service != null && service.characteristics instanceof List characteristics) { for (Channel channel : thing.getChannels()) { - String iid = channel.getProperties().get(PROPERTY_IID); - if (iid != null) { + ChannelUID channelUID = channel.getUID(); + if (channelUID.equals(lightModelClientChannel)) { + for (Characteristic cxx : characteristics) { + if (lightModelRefresh(cxx)) { + updateState(channelUID, Objects.requireNonNull(lightModel).getHsb()); + } + } + } else if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { for (Characteristic cxx : characteristics) { if (iid.equals(String.valueOf(cxx.iid)) && cxx.value instanceof JsonElement element) { State state = convertJsonToState(element, channel); switch (channel.getKind()) { - case STATE -> updateState(channel.getUID(), state); - case TRIGGER -> triggerChannel(channel.getUID(), state.toFullString()); + case STATE -> updateState(channelUID, state); + case TRIGGER -> triggerChannel(channelUID, state.toFullString()); } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 00c07ec24e824..07a4e761d3113 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -364,7 +364,7 @@ private void cancelConnectionTask() { * @return the IpTransport or null if not available */ protected @Nullable IpTransport getIpTransport() { - return getBridgeHandler() instanceof HomekitBridgeHandler h ? h.getIpTransport() : getIpTransport(); + return getBridgeHandler() instanceof HomekitBridgeHandler bridgeHandler ? bridgeHandler.ipTransport : ipTransport; } /** From f7f2acb99b72bcd04138e54beb7f1f5f63acc61a Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 22 Oct 2025 21:50:08 +0100 Subject: [PATCH 084/177] spotless Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitBaseAccessoryHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 07a4e761d3113..c24c99ef38257 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -364,7 +364,8 @@ private void cancelConnectionTask() { * @return the IpTransport or null if not available */ protected @Nullable IpTransport getIpTransport() { - return getBridgeHandler() instanceof HomekitBridgeHandler bridgeHandler ? bridgeHandler.ipTransport : ipTransport; + return getBridgeHandler() instanceof HomekitBridgeHandler bridgeHandler ? bridgeHandler.ipTransport + : ipTransport; } /** From a5c48adea0b3f2889bd6fb794209a98a5c4069d0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 23 Oct 2025 15:05:50 +0100 Subject: [PATCH 085/177] fix evented channels not discovered Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 8473be907ceb9..d6755309c29da 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -143,7 +143,6 @@ private void channelsAndPropertiesLoaded() { refreshTask = scheduler.scheduleWithFixedDelay(this::refresh, INITIAL_DELAY_SECONDS, refreshIntervalSeconds, TimeUnit.SECONDS); } - return; } } catch (NumberFormatException e) { } @@ -793,6 +792,7 @@ private void eventingFinalize(Accessory accessory, List channels) { } } } + logger.debug("Identified {} evented channels", eventedChannels.size()); } /** From a33a1fb271f2403131b7a62ed44f90f48581f317 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 24 Oct 2025 18:18:48 +0100 Subject: [PATCH 086/177] various - expose explicit exception types - fix eventing - eliminate restart loop - support sparse data for lighting model channel links - fix HSB channel Signed-off-by: Andrew Fiddian-Green --- .../internal/crypto/CryptoConstants.java | 7 +- .../homekit/internal/crypto/CryptoUtils.java | 11 +- .../homekit/internal/crypto/SRPclient.java | 25 +- .../HomekitMdnsDiscoveryParticipant.java | 1 + .../homekit/internal/dto/Characteristic.java | 1 - .../enums/AccessoryPairingFeature.java | 2 +- .../enums/AccessoryPairingStatus.java | 2 +- .../homekit/internal/enums/ErrorCode.java | 2 +- .../homekit/internal/enums/PairingMethod.java | 2 +- .../homekit/internal/enums/PairingState.java | 2 +- .../handler/HomekitAccessoryHandler.java | 303 ++++++++++-------- .../handler/HomekitBaseAccessoryHandler.java | 169 ++++++---- .../CharacteristicReadWriteClient.java | 19 +- .../hap_services/PairRemoveClient.java | 7 +- .../hap_services/PairSetupClient.java | 71 +++- .../hap_services/PairVerifyClient.java | 28 +- .../internal/session/SecureSession.java | 21 +- .../internal/transport/IpTransport.java | 118 +++---- .../binding/homekit/internal/SRPserver.java | 13 +- .../homekit/internal/TestPairSetup.java | 17 +- .../homekit/internal/TestPairVerify.java | 13 +- 21 files changed, 497 insertions(+), 337 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java index dddf6e13daa71..0179ac691b266 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java @@ -16,6 +16,7 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -76,16 +77,12 @@ private static BigInteger computeK() { byte[] paddedG = toUnsigned(g, 384); byte[] hash = sha512(concat(paddedN, paddedG)); return new BigInteger(1, hash); - } catch (Exception e) { + } catch (NoSuchAlgorithmException e) { throw new SecurityException("Failed to compute k", e); } } private static byte[] nonce(String input) { - // ByteBuffer nonce = ByteBuffer.allocate(12); - // nonce.put(input.getBytes(StandardCharsets.UTF_8)); - // nonce.putInt(0); - // return nonce.array(); // 12-byte nonce return input.getBytes(StandardCharsets.UTF_8); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index ba24d9f9ac958..9450d321f25d4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -117,7 +117,7 @@ public static X25519PrivateKeyParameters generateX25519KeyPair() return new X25519PrivateKeyParameters(new SecureRandom()); } - public static byte[] sha512(byte[] data) throws Exception { + public static byte[] sha512(byte[] data) throws NoSuchAlgorithmException { MessageDigest md = MessageDigest.getInstance("SHA-512"); return md.digest(data); } @@ -130,7 +130,7 @@ public static byte[] signMessage(Ed25519PrivateKeyParameters secretKey, byte[] m return signer.generateSignature(); } - public static BigInteger toBigInteger(String hexBlock) { + public static BigInteger toBigInteger(String hexBlock) throws IllegalArgumentException { String plainHex = hexBlock.replaceAll("\\s+", ""); if (plainHex.length() % 2 != 0) { throw new IllegalArgumentException("Hex string must have even length"); @@ -138,7 +138,7 @@ public static BigInteger toBigInteger(String hexBlock) { return new BigInteger(plainHex, 16); } - public static byte[] toBytes(String hexBlock) { + public static byte[] toBytes(String hexBlock) throws IllegalArgumentException { String plainHex = hexBlock.replaceAll("\\s+", ""); if (plainHex.length() % 2 != 0) { throw new IllegalArgumentException("Hex string must have even length"); @@ -169,7 +169,6 @@ public static String toHex(byte @Nullable [] bytes) { * @param bigInteger the BigInteger to convert. * @param length the desired length of the resulting byte array. * @return a byte array of the given length representing the unsigned BigInteger. - * @throws IllegalArgumentException if the BigInteger cannot fit in the specified length. */ public static byte[] toUnsigned(BigInteger bigInteger, int length) { byte[] raw = bigInteger.toByteArray(); @@ -197,7 +196,7 @@ public static byte[] toUnsigned(BigInteger bigInteger, int length) { } public static void verifySignature(Ed25519PublicKeyParameters publicKey, byte[] signature, byte[] payload) - throws Exception { + throws SecurityException { Ed25519Signer verifier = new Ed25519Signer(); verifier.init(false, publicKey); verifier.update(payload, 0, payload.length); @@ -206,7 +205,7 @@ public static void verifySignature(Ed25519PublicKeyParameters publicKey, byte[] } } - public static byte[] xor(byte[] a, byte[] b) { + public static byte[] xor(byte[] a, byte[] b) throws IllegalArgumentException { if (a.length != b.length) { throw new IllegalArgumentException("xor length mismatch"); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index afb289540396f..a2d7935cff9fe 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -17,11 +17,13 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Map; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -62,10 +64,10 @@ public class SRPclient { * @param password_P the password (P) used for authentication. * @param serverSalt the salt (s) provided by the server. * @param serverEphemeralPublicKey the server's public SRP key (B). - * - * @throws Exception if an error occurs during initialization. + * @throws NoSuchAlgorithmException */ - public SRPclient(String password_P, byte[] serverSalt, byte[] serverEphemeralPublicKey) throws Exception { + public SRPclient(String password_P, byte[] serverSalt, byte[] serverEphemeralPublicKey) + throws NoSuchAlgorithmException { this(password_P, serverSalt, serverEphemeralPublicKey, null, null); } @@ -77,11 +79,10 @@ public SRPclient(String password_P, byte[] serverSalt, byte[] serverEphemeralPub * @param accessoryEphemeralPublicKey the server's public SRP key (B). * @param user_I the username (I). If null, "Pair-Setup" is used. * @param clientEphemeralSecretKey the client's private SRP key (a). If null, a random key is generated. - * - * @throws Exception if an error occurs during initialization. + * @throws NoSuchAlgorithmException */ public SRPclient(String password_P, byte[] serverSalt, byte[] accessoryEphemeralPublicKey, @Nullable String user_I, - byte @Nullable [] clientEphemeralSecretKey) throws Exception { + byte @Nullable [] clientEphemeralSecretKey) throws NoSuchAlgorithmException { // set username, salt and server public key s = serverSalt; B = new BigInteger(1, accessoryEphemeralPublicKey); @@ -138,7 +139,7 @@ public byte[] getScramblingParameter() { return toUnsigned(u, 64); } - public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Exception { + public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws IllegalStateException { Ed25519PublicKeyParameters accessoryLTPK = this.accessoryLongTermPublicKey; if (accessoryLTPK == null) { throw new IllegalStateException("Accessory long-term public key not yet available"); @@ -146,7 +147,7 @@ public Ed25519PublicKeyParameters getAccessoryLongTermPublicKey() throws Excepti return accessoryLTPK; } - public void m4VerifyAccessoryProof(byte[] accessoryProof) throws Exception { + public void m4VerifyAccessoryProof(byte[] accessoryProof) { if (logger.isTraceEnabled()) { logger.trace("Pair-Setup M4: Accessory info:\n - Controller M2: {}\n - Accessory M2: {}", toHex(M2), toHex(accessoryProof)); @@ -165,10 +166,10 @@ public void m4VerifyAccessoryProof(byte[] accessoryProof) throws Exception { * @param iOSDeviceId the pairing identifier. * @param iOSDeviceLongTermPrivateKey the controller's long-term private key for signing. * @return the encrypted controller information as a byte array. - * @throws Exception if an error occurs during the encryption or signing process. + * @throws InvalidCipherTextException */ public byte[] m5EncodeControllerInfoAndSign(byte[] iOSDeviceId, - Ed25519PrivateKeyParameters iOSDeviceLongTermPrivateKey) throws Exception { + Ed25519PrivateKeyParameters iOSDeviceLongTermPrivateKey) throws InvalidCipherTextException { byte[] iOSDeviceX = generateHkdfKey(K, PAIR_SETUP_CONTROLLER_SIGN_SALT, PAIR_SETUP_CONTROLLER_SIGN_INFO); byte[] iOSDeviceLTPK = iOSDeviceLongTermPrivateKey.generatePublicKey().getEncoded(); byte[] iOSDeviceInfo = concat(iOSDeviceX, iOSDeviceId, iOSDeviceLTPK); @@ -203,9 +204,9 @@ public byte[] m5EncodeControllerInfoAndSign(byte[] iOSDeviceId, * concatentation of { shared key, pairing identifier, accessory LTPK} . * * @param cipherText the encrypted accessory information received from the accessory. - * @throws Exception if an error occurs during decryption or signature verification. + * @throws InvalidCipherTextException */ - public void m6DecodeAccessoryInfoAndVerify(byte[] cipherText) throws Exception { + public void m6DecodeAccessoryInfoAndVerify(byte[] cipherText) throws InvalidCipherTextException { byte[] decryptKey = generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); if (logger.isTraceEnabled()) { logger.trace("Pair-Setup M6: Accessory info:\n - Cipher text: {}\n - Key: {}", toHex(cipherText), diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 59c6aeb404a92..700223bdb77c5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -77,6 +77,7 @@ public String getServiceType() { if (port != 0) { host = host + ":" + port; } + AccessoryCategory category; try { String ci = properties.getOrDefault("ci", ""); // accessory category diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index e4239e279e498..6d64d73f5ec33 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -395,7 +395,6 @@ public class Characteristic { case HUE: numberSuffix = "Angle"; propertyTag = Property.COLOR; - itemType = CoreItemFactory.COLOR; category = "color"; break; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java index 1e46dba6d050c..af48d6fd2d040 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java @@ -31,7 +31,7 @@ public enum AccessoryPairingFeature { this.value = (byte) value; } - public static AccessoryPairingFeature from(int value) { + public static AccessoryPairingFeature from(int value) throws IllegalArgumentException { for (AccessoryPairingFeature state : values()) { if (state.value == value) { return state; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingStatus.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingStatus.java index ca15b1e8c9b68..c6cdddf19fcb2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingStatus.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingStatus.java @@ -30,7 +30,7 @@ public enum AccessoryPairingStatus { this.value = (byte) value; } - public static AccessoryPairingStatus from(int value) { + public static AccessoryPairingStatus from(int value) throws IllegalArgumentException { for (AccessoryPairingStatus state : values()) { if (state.value == value) { return state; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java index d10e579959e76..e7bc8b94fce4a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ErrorCode.java @@ -36,7 +36,7 @@ public enum ErrorCode { this.value = (byte) value; } - public static ErrorCode from(byte value) { + public static ErrorCode from(byte value) throws IllegalArgumentException { for (ErrorCode state : values()) { if (state.value == value) { return state; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java index cf9a997def65b..38ec48fd908b5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingMethod.java @@ -34,7 +34,7 @@ public enum PairingMethod { this.value = (byte) value; } - public static PairingMethod from(byte value) { + public static PairingMethod from(byte value) throws IllegalArgumentException { for (PairingMethod state : values()) { if (state.value == value) { return state; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java index c506c61eef8f4..a2ff0338778c4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/PairingState.java @@ -34,7 +34,7 @@ public enum PairingState { this.value = (byte) value; } - public static PairingState from(byte value) { + public static PairingState from(byte value) throws IllegalArgumentException { for (PairingState state : values()) { if (state.value == value) { return state; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index d6755309c29da..a62a016b39dcd 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -14,6 +14,7 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.io.IOException; import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; @@ -23,11 +24,14 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import javax.measure.Unit; import javax.measure.format.MeasurementParseException; +import javax.measure.quantity.Angle; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -42,6 +46,7 @@ import org.openhab.binding.homekit.internal.temporary.LightModel; import org.openhab.binding.homekit.internal.temporary.LightModel.LightCapabilities; import org.openhab.binding.homekit.internal.temporary.LightModel.RgbDataType; +import org.openhab.binding.homekit.internal.transport.IpTransport; import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.DateTimeType; @@ -56,6 +61,7 @@ import org.openhab.core.library.types.UpDownType; import org.openhab.core.library.unit.Units; import org.openhab.core.semantics.SemanticTag; +import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.DefaultSystemChannelTypeProvider; @@ -96,6 +102,11 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { private static final int INITIAL_DELAY_SECONDS = 2; + // Characteristic types relevant for light model management + private static final Set LIGHT_MODEL_RELEVANT_TYPES = Set.of(CharacteristicType.HUE, + CharacteristicType.SATURATION, CharacteristicType.BRIGHTNESS, CharacteristicType.COLOR_TEMPERATURE, + CharacteristicType.ON); + private final Logger logger = LoggerFactory.getLogger(HomekitAccessoryHandler.class); private final ChannelTypeRegistry channelTypeRegistry; private final ChannelGroupTypeRegistry channelGroupTypeRegistry; @@ -106,9 +117,16 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { * This is only initialized if the accessory has relevant light characteristics. */ private @Nullable LightModel lightModel = null; - private @Nullable ChannelUID lightModelClientChannel = null; // special HSB combined channel + private @Nullable ChannelUID lightModelClientHSBTypeChannel = null; // special HSB combined channel + + /* + * Internal record representing a link between an OH channel and a HomeKit characteristic type & iid. + * Used for light model management. + */ + private record LightModelLink(Channel channel, CharacteristicType cxxType, Integer cxxIid) { + } - private final Map lightModelServerChannels = new HashMap<>(); + private final List lightModelLinks = new ArrayList<>(); private final Set eventedChannels = new HashSet<>(); private @Nullable Channel stopMoveChannel = null; // channel for the stop button (rollershutters) @@ -145,6 +163,7 @@ private void channelsAndPropertiesLoaded() { } } } catch (NumberFormatException e) { + // logged below } } if (refreshTask == null) { @@ -154,27 +173,27 @@ private void channelsAndPropertiesLoaded() { if (eventedChannels.isEmpty()) { unsubscribeEvents(); } else { - CharacteristicReadWriteClient writer = this.rwService; - if (writer != null) { - Service service = new Service(); - service.characteristics = new ArrayList<>(); - for (Channel channel : eventedChannels) { - if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { - Characteristic characteristic = new Characteristic(); - characteristic.iid = Integer.parseInt(iid); - characteristic.aid = getAccessoryId(); - characteristic.ev = true; // enable events - service.characteristics.add(characteristic); - } - } - try { - writer.writeCharacteristic(GSON.toJson(service)); - subscribeEvents(); - logger.debug("Eventing enabled for {} channels", eventedChannels.size()); - } catch (Exception e) { - logger.warn("Failed to subscribe to evented channels, eventing disabled"); + Service service = new Service(); + service.characteristics = new ArrayList<>(); + for (Channel channel : eventedChannels) { + if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { + Characteristic characteristic = new Characteristic(); + characteristic.iid = Integer.parseInt(iid); + characteristic.aid = getAccessoryId(); + characteristic.ev = true; // enable events + service.characteristics.add(characteristic); } } + try { + getRwService().writeCharacteristic(GSON.toJson(service)); + subscribeEvents(); + logger.debug("Eventing enabled for {} channels", eventedChannels.size()); + } catch (IOException | TimeoutException e) { + logger.debug("Communication error subscribing to evented channels"); + } catch (IllegalAccessException | ExecutionException e) { + logger.warn("Unexpected error subscribing to evented channels", e); + } catch (InterruptedException e) { // shutting down + } } } @@ -226,10 +245,10 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { String val = option.getValue(); try { object = Integer.parseInt(val); + break; } catch (NumberFormatException e) { logger.warn("Unexpected state option value {} for channel {}", val, channel.getUID(), e); } - break; } } } @@ -249,17 +268,13 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { // comply with characteristic's data format String format = channel.getProperties().get(PROPERTY_FORMAT); if (format != null) { - try { - object = switch (DataFormatType.from(format)) { - case UINT8, UINT16, UINT32, UINT64, INT -> Integer.valueOf(number.intValue()); - case FLOAT -> Float.valueOf(number.floatValue()); - case STRING -> String.valueOf(number); - case BOOL -> Boolean.valueOf(number.intValue() != 0); - default -> object; - }; - } catch (IllegalArgumentException e) { - logger.warn("Unexpected format {} for channel {}", format, channel.getUID(), e); - } + object = switch (DataFormatType.from(format)) { + case UINT8, UINT16, UINT32, UINT64, INT -> Integer.valueOf(number.intValue()); + case FLOAT -> Float.valueOf(number.floatValue()); + case STRING -> String.valueOf(number); + case BOOL -> Boolean.valueOf(number.intValue() != 0); + default -> object; + }; } } @@ -324,7 +339,7 @@ private State convertJsonToState(JsonElement element, Channel channel) { } else if (value.isNumber()) { return switch (acceptedItemType) { case CoreItemFactory.COLOR -> { - logger.warn("HSBType command handling is not yet implemented for channel {}", channel.getUID()); + logger.warn("Channel {} wrong item type 'COLOR'", channel.getUID()); yield UnDefType.UNDEF; } case CoreItemFactory.SWITCH -> OnOffType.from(value.getAsInt() != 0); @@ -483,36 +498,29 @@ public void handleCommand(ChannelUID channelUID, Command command) { if (command == RefreshType.REFRESH) { return; } - CharacteristicReadWriteClient readerWriter = this.rwService; - if (readerWriter == null) { - logger.warn("No reader/writer service available to handle command for '{}'", channelUID); - return; - } try { if (command instanceof HSBType) { logger.warn("Forbidden to send command '{}' directly to '{}'", command, channelUID); } else if (command instanceof StopMoveType stopMoveType && StopMoveType.STOP == stopMoveType) { if (stopMoveChannel instanceof Channel stopMoveChannel) { - writeChannel(stopMoveChannel, OnOffType.ON, readerWriter); - } else if (readChannel(channel, readerWriter) instanceof Command actualPosition) { - writeChannel(channel, actualPosition, readerWriter); + writeChannel(stopMoveChannel, OnOffType.ON, getRwService()); + } else if (readChannel(channel, getRwService()) instanceof Command actualPosition) { + writeChannel(channel, actualPosition, getRwService()); } - } else if (channelUID.equals(lightModelClientChannel)) { - lightModelHandleCommand(command, readerWriter); - } else { - writeChannel(channel, command, readerWriter); - } - } catch (Exception e) { - if (logger.isTraceEnabled()) { - logger.trace("Failed to send command '{}' to '{}', reconnecting", command, channelUID, e); + } else if (channelUID.equals(lightModelClientHSBTypeChannel)) { + lightModelHandleCommand(command, getRwService()); } else { - logger.debug("Failed to send command '{}' to '{}', reconnecting: {}", command, channelUID, - e.getMessage()); + writeChannel(channel, command, getRwService()); } - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - i18nProvider.getText(bundle, "error.error-sending-command", "Error sending command", null)); - startConnectionTask(); - } + return; + } catch (IOException | TimeoutException e) { + logger.debug("Communication error sending command '{}' to '{}' '{}'", command, channelUID, e.getMessage()); + } catch (IllegalAccessException | ExecutionException e) { + logger.warn("Unexpected error sending command '{}' to '{}'", command, channelUID, e); + } catch (InterruptedException e) { // shutting down + } + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + i18nProvider.getText(bundle, "error.error-sending-command", "Error sending command", null)); } @Override @@ -534,8 +542,8 @@ public void handleRemoval() { public void dispose() { cancelRefreshTask(); lightModel = null; - lightModelServerChannels.clear(); - lightModelClientChannel = null; + lightModelLinks.clear(); + lightModelClientHSBTypeChannel = null; eventedChannels.clear(); super.dispose(); } @@ -545,33 +553,30 @@ public void dispose() { * This method is called periodically by a scheduled executor. */ private void refresh() { - CharacteristicReadWriteClient reader = this.rwService; - if (reader != null) { - try { - Integer aid = getAccessoryId(); - List queries = new ArrayList<>(); - thing.getChannels().stream().forEach(c -> { - String iid = c.getProperties().get(PROPERTY_IID); - if (iid != null) { - queries.add("%s.%s".formatted(aid, iid)); - } - }); - if (queries.isEmpty()) { - return; - } - String json = reader.readCharacteristic(String.join(",", queries)); - updateChannelsFromJson(json); - } catch (Exception e) { - if (logger.isTraceEnabled()) { - logger.trace("Failed to poll accessory state, reconnecting", e); - } else { - logger.debug("Failed to poll accessory state, reconnecting: {}", e.getMessage()); - } - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - i18nProvider.getText(bundle, "error.polling-error", "Polling error", null)); - startConnectionTask(); + Integer aid = getAccessoryId(); + List queries = new ArrayList<>(); + thing.getChannels().stream().forEach(c -> { + String iid = c.getProperties().get(PROPERTY_IID); + if (iid != null) { + queries.add("%s.%s".formatted(aid, iid)); } + }); + if (queries.isEmpty()) { + return; + } + try { + String json = getRwService().readCharacteristic(String.join(",", queries)); + updateChannelsFromJson(json); + return; + } catch (IOException | TimeoutException e) { + logger.debug("Communication error polling accessory '{}', restarting", e.getMessage()); + startConnectionTask(); + } catch (IllegalAccessException | ExecutionException e) { + logger.warn("Unexpected error polling accessory", e); + } catch (InterruptedException e) { // shutting down } + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + i18nProvider.getText(bundle, "error.polling-error", "Polling error", null)); } private void cancelRefreshTask() { @@ -637,33 +642,22 @@ private void lightModelInitialize(Accessory accessory) { this.lightModel = lightModel; } - /** - * Checks if a characteristic is relevant to the light model. - * - * @param cxx the characteristic to check - * @return true if the characteristic is part of the light model, false otherwise - */ - private boolean lightModelRelevantCharacteristic(Characteristic cxx) { - CharacteristicType cxxType = cxx.getCharacteristicType(); - return CharacteristicType.HUE == cxxType || CharacteristicType.SATURATION == cxxType - || CharacteristicType.BRIGHTNESS == cxxType || CharacteristicType.COLOR_TEMPERATURE == cxxType - || CharacteristicType.ON == cxxType; - } - /** * Refreshes the light model state based on the updated characteristic value. * * @param cxx the characteristic containing the updated value * @return true if the light model was updated, false otherwise + * @throws IllegalStateException if the light model is not initialized */ - private boolean lightModelRefresh(Characteristic cxx) { + private boolean lightModelRefresh(Characteristic cxx) throws IllegalStateException { LightModel lightModel = this.lightModel; if (lightModel == null) { throw new IllegalStateException("Light model is not initialized"); } boolean changed = false; - if (lightModelRelevantCharacteristic(cxx) && cxx.value instanceof JsonPrimitive primitiveValue) { - CharacteristicType cxxType = cxx.getCharacteristicType(); + Optional link = lightModelLinks.stream().filter(e -> e.cxxIid.equals(cxx.iid)).findFirst(); + if (link.isPresent() && cxx.value instanceof JsonPrimitive primitiveValue) { + CharacteristicType cxxType = link.get().cxxType; if (primitiveValue.isNumber()) { changed = true; switch (cxxType) { @@ -689,32 +683,42 @@ private boolean lightModelRefresh(Characteristic cxx) { * * @param hsbCommand the HSBType command containing hue, saturation, and brightness * @param writer the CharacteristicReadWriteClient to send the command - * @throws Exception + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws IllegalStateException */ - private void lightModelHandleCommand(Command command, CharacteristicReadWriteClient writer) throws Exception { + private void lightModelHandleCommand(Command command, CharacteristicReadWriteClient writer) + throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { LightModel lightModel = this.lightModel; if (lightModel == null) { throw new IllegalStateException("Light model is not initialized"); } lightModel.handleCommand(command); - if (lightModelServerChannels.get(CharacteristicType.HUE) instanceof Channel channel) { - writeChannel(channel, QuantityType.valueOf(lightModel.getHue(), Units.DEGREE_ANGLE), writer); + Optional link; + link = lightModelLinks.stream().filter(e -> CharacteristicType.HUE == e.cxxType).findFirst(); + if (link.isPresent()) { + QuantityType hue = QuantityType.valueOf(lightModel.getHue(), Units.DEGREE_ANGLE); + writeChannel(link.get().channel, hue, writer); } - if (lightModelServerChannels.get(CharacteristicType.SATURATION) instanceof Channel channel) { - writeChannel(channel, new PercentType(BigDecimal.valueOf(lightModel.getSaturation())), writer); + link = lightModelLinks.stream().filter(e -> CharacteristicType.SATURATION == e.cxxType).findFirst(); + if (link.isPresent()) { + PercentType saturation = new PercentType(BigDecimal.valueOf(lightModel.getSaturation())); + writeChannel(link.get().channel, saturation, writer); } - if (lightModelServerChannels.get(CharacteristicType.BRIGHTNESS) instanceof Channel channel - && lightModel.getBrightness() instanceof PercentType percentType) { - writeChannel(channel, percentType, writer); + link = lightModelLinks.stream().filter(e -> CharacteristicType.BRIGHTNESS == e.cxxType).findFirst(); + if (link.isPresent() && lightModel.getBrightness() instanceof PercentType brightness) { + writeChannel(link.get().channel, brightness, writer); } - if (lightModelServerChannels.get(CharacteristicType.ON) instanceof Channel channel - && lightModel.getOnOff() instanceof OnOffType onOff) { - writeChannel(channel, onOff, writer); + link = lightModelLinks.stream().filter(e -> CharacteristicType.ON == e.cxxType).findFirst(); + if (link.isPresent() && lightModel.getOnOff() instanceof OnOffType onOff) { + writeChannel(link.get().channel, onOff, writer); } } /** - * Finalizes the light model channels by mapping the relevant characteristic channels + * Finalizes the light model channels by mapping the relevant characteristic and channel links * and creating a combined HSB channel. * * @param accessory the accessory containing the characteristics @@ -724,15 +728,17 @@ private void lightModelFinalize(Accessory accessory, List channels) { if (lightModel == null) { return; } - // map characteristic channels to light model - lightModelServerChannels.clear(); + // link channels to characteristic types & iids for the light model + lightModelLinks.clear(); for (Channel channel : channels) { if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { for (Service service : accessory.services) { for (Characteristic cxx : service.characteristics) { - if (iid.equals(String.valueOf(cxx.iid)) && lightModelRelevantCharacteristic(cxx)) { + if (iid.equals(String.valueOf(cxx.iid))) { CharacteristicType cxxType = cxx.getCharacteristicType(); - lightModelServerChannels.put(cxxType, channel); + if (LIGHT_MODEL_RELEVANT_TYPES.contains(cxxType)) { + lightModelLinks.add(new LightModelLink(channel, cxxType, cxx.iid)); + } } } } @@ -747,7 +753,7 @@ private void lightModelFinalize(Accessory accessory, List channels) { "+++++Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), channel.getKind(), channel.getLabel(), channel.getProperties(), channel.getUID()); - lightModelClientChannel = uid; + lightModelClientHSBTypeChannel = uid; } /** @@ -800,10 +806,14 @@ private void eventingFinalize(Accessory accessory, List channels) { * * @param channel the channel to read * @return the current state of the channel, or null if not found - * @throws Exception + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws IllegalStateException */ private synchronized @Nullable State readChannel(Channel channel, CharacteristicReadWriteClient reader) - throws Exception { + throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { Integer aid = getAccessoryId(); String iid = channel.getProperties().get(PROPERTY_IID); if (aid == null || iid == null) { @@ -828,11 +838,14 @@ private void eventingFinalize(Accessory accessory, List channels) { * @param channel the channel to which the command is sent * @param command the command to send * @param writer the CharacteristicReadWriteClient to send the command - * - * @throws Exception + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws IllegalStateException */ private synchronized void writeChannel(Channel channel, Command command, CharacteristicReadWriteClient writer) - throws Exception { + throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { Integer aid = getAccessoryId(); String iid = channel.getProperties().get(PROPERTY_IID); if (aid == null || iid == null) { @@ -848,12 +861,6 @@ private synchronized void writeChannel(Channel channel, Command command, Charact writer.writeCharacteristic(GSON.toJson(service)); } - @Override - protected void startConnectionTask() { - cancelRefreshTask(); - super.startConnectionTask(); - } - /** * Handles incoming events by updating the corresponding channels based on the characteristic values. * @@ -874,7 +881,7 @@ private void updateChannelsFromJson(String json) { if (service != null && service.characteristics instanceof List characteristics) { for (Channel channel : thing.getChannels()) { ChannelUID channelUID = channel.getUID(); - if (channelUID.equals(lightModelClientChannel)) { + if (channelUID.equals(lightModelClientHSBTypeChannel)) { for (Characteristic cxx : characteristics) { if (lightModelRefresh(cxx)) { updateState(channelUID, Objects.requireNonNull(lightModel).getHsb()); @@ -894,4 +901,42 @@ private void updateChannelsFromJson(String json) { } } } + + /** + * Override method to delegate to the bridge IP transport if we are a child accessory. + * + * @return own IpTransport service or bridge IpTransport service if we are a child. + * @throws IllegalAccessException if access to the transport is denied. + */ + @Override + protected IpTransport getIpTransport() throws IllegalAccessException { + if (isChildAccessory) { + if (getBridge() instanceof Bridge bridge + && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + return bridgeHandler.getIpTransport(); + } else { + throw new IllegalAccessException("Cannot access bridge IP transport"); + } + } + return super.getIpTransport(); + } + + /** + * Override method to delegate to the bridge read/write service if we are a child accessory. + * + * @return own CharacteristicReadWriteClient service or bridge service if we are a child. + * @throws IllegalAccessException if access to the service is denied. + */ + @Override + protected CharacteristicReadWriteClient getRwService() throws IllegalAccessException { + if (isChildAccessory) { + if (getBridge() instanceof Bridge bridge + && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + return bridgeHandler.getRwService(); + } else { + throw new IllegalAccessException("Cannot access bridge read/write service"); + } + } + return super.getRwService(); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index c24c99ef38257..15747c4f5da9e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -14,17 +14,23 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.Function; import java.util.stream.Collectors; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -78,13 +84,14 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected boolean isChildAccessory = false; - protected @NonNullByDefault({}) CharacteristicReadWriteClient rwService; - protected @NonNullByDefault({}) String pairingCode; - protected @NonNullByDefault({}) Integer accessoryId; - protected @NonNullByDefault({}) IpTransport ipTransport; - private int connectionDelaySeconds = MIN_CONNECTION_DELAY_SECONDS; + private @Nullable ScheduledFuture connectionTask; + private @Nullable CharacteristicReadWriteClient rwService; + private @Nullable IpTransport ipTransport; + + protected @NonNullByDefault({}) String pairingCode; + protected @NonNullByDefault({}) Integer accessoryId; public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, TranslationProvider translationProvider, Bundle bundle) { @@ -99,11 +106,8 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider public void dispose() { unsubscribeEvents(); cancelConnectionTask(); - if (!isChildAccessory) { - try { - ipTransport.close(); - } catch (Exception e) { - } + if (!isChildAccessory && ipTransport instanceof IpTransport ipTransport) { + ipTransport.close(); } super.dispose(); } @@ -119,7 +123,8 @@ public void dispose() { */ private void fetchAccessories() { try { - String json = new String(ipTransport.get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP), StandardCharsets.UTF_8); + String json = new String(getIpTransport().get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP), + StandardCharsets.UTF_8); Accessories acc0 = GSON.fromJson(json, Accessories.class); if (acc0 instanceof Accessories acc1 && acc1.accessories instanceof List acc2) { accessories.clear(); @@ -128,7 +133,8 @@ private void fetchAccessories() { } logger.debug("Fetched {} accessories", accessories.size()); scheduler.submit(this::accessoriesLoaded); // notify subclass in scheduler thread - } catch (Exception e) { + } catch (IOException | InterruptedException | TimeoutException | ExecutionException + | IllegalAccessException e) { logger.debug("Failed to get accessories", e); } } @@ -153,6 +159,7 @@ private void fetchAccessories() { } catch (NumberFormatException e) { } } + logger.debug("Missing or invalid accessory id"); return null; } @@ -164,7 +171,7 @@ public void handleRemoval() { scheduler.submit(() -> { // unpair and clear stored keys if this is NOT a child accessory try { - PairRemoveClient service = new PairRemoveClient(ipTransport, keyStore.getControllerUUID()); + PairRemoveClient service = new PairRemoveClient(getIpTransport(), keyStore.getControllerUUID()); service.remove(); String mac = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); if (mac != null) { @@ -173,7 +180,8 @@ public void handleRemoval() { logger.warn("Could not clear key for {} due to missing mac address", thing.getUID()); } updateStatus(ThingStatus.REMOVED); - } catch (Exception e) { + } catch (IOException | InterruptedException | TimeoutException | ExecutionException + | IllegalAccessException e) { logger.warn("Failed to remove pairing for {}", thing.getUID()); } }); @@ -182,37 +190,20 @@ public void handleRemoval() { @Override public void initialize() { - if (getBridgeHandler() instanceof HomekitBridgeHandler bridgeHandler) { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { // accessory is hosted by a bridge, so use bridge's pairing session and read/write service isChildAccessory = true; - ipTransport = bridgeHandler.ipTransport; - rwService = bridgeHandler.rwService; - if (rwService != null) { + try { + bridgeHandler.getRwService(); // ensure that bridge has a read/write service fetchAccessories(); updateStatus(ThingStatus.ONLINE); - } else { + } catch (IllegalAccessException e) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, i18nProvider.getText(bundle, "error.bridge-not-connected", "Bridge not connected", null)); } } else { // standalone accessory or bridge accessory, so do pairing and session setup here isChildAccessory = false; - Object host = getConfig().get(CONFIG_HOST); - if (host == null || !(host instanceof String hostString) || !HOST_PATTERN.matcher(hostString).matches()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.invalid-host", "Invalid host", null)); - return; - } - try { - ipTransport = new IpTransport(hostString); - } catch (Exception e) { - logger.debug("Failed to create transport", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); - return; - } - unsubscribeEvents(); - cancelConnectionTask(); startConnectionTask(); } } @@ -222,6 +213,13 @@ public void initialize() { * Updates the thing status accordingly. */ private synchronized void initializePairing() { + Object host = getConfig().get(CONFIG_HOST); + if (host == null || !(host instanceof String hostString) || !HOST_PATTERN.matcher(hostString).matches()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.invalid-host", "Invalid host", null)); + return; + } + Object pairingConfig = getConfig().get(CONFIG_PAIRING_CODE); if (pairingConfig == null || !(pairingConfig instanceof String pairingConfigString) || !PAIRING_CODE_PATTERN.matcher(pairingConfigString).matches()) { @@ -246,50 +244,61 @@ private synchronized void initializePairing() { return; } + // create new transport + try { + ipTransport = new IpTransport(hostString); + } catch (IOException e) { + logger.debug("Failed to create transport", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); + return; + } + if (keyStore.getAccessoryKey(macAddress) != null) { try { logger.debug("Starting Pair-Verify with existing key for {}", thing.getUID()); - PairVerifyClient client = new PairVerifyClient(ipTransport, keyStore.getControllerUUID(), + PairVerifyClient client = new PairVerifyClient(getIpTransport(), keyStore.getControllerUUID(), keyStore.getControllerKey(), Objects.requireNonNull(keyStore.getAccessoryKey(macAddress))); - ipTransport.setSessionKeys(client.verify()); - rwService = new CharacteristicReadWriteClient(ipTransport); + getIpTransport().setSessionKeys(client.verify()); + rwService = new CharacteristicReadWriteClient(getIpTransport()); logger.debug("Restored pairing was verified for {}", thing.getUID()); cancelConnectionTask(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); - - return; - } catch (Exception e) { + return; // pairing restore succeeded => exit + } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException + | InvalidCipherTextException | IOException | InterruptedException | TimeoutException + | ExecutionException e) { logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); - keyStore.setAccessoryKey(macAddress, null); - // fall through to create new pairing + // pairing restore failed => continue to create new pairing } } try { logger.debug("Starting Pair-Setup for {}", thing.getUID()); - PairSetupClient pairSetupClient = new PairSetupClient(ipTransport, keyStore.getControllerUUID(), + PairSetupClient pairSetupClient = new PairSetupClient(getIpTransport(), keyStore.getControllerUUID(), keyStore.getControllerKey(), pairingCode); Ed25519PublicKeyParameters accessoryKey = pairSetupClient.pair(); logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); // Perform Pair-Verify immediately after Pair-Setup - PairVerifyClient pairVerifyClient = new PairVerifyClient(ipTransport, keyStore.getControllerUUID(), + PairVerifyClient pairVerifyClient = new PairVerifyClient(getIpTransport(), keyStore.getControllerUUID(), keyStore.getControllerKey(), accessoryKey); - ipTransport.setSessionKeys(pairVerifyClient.verify()); - rwService = new CharacteristicReadWriteClient(ipTransport); + getIpTransport().setSessionKeys(pairVerifyClient.verify()); + rwService = new CharacteristicReadWriteClient(getIpTransport()); keyStore.setAccessoryKey(macAddress, accessoryKey); logger.debug("Pairing and verification completed for {}", thing.getUID()); cancelConnectionTask(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); - - } catch (Exception e) { + } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException + | InvalidCipherTextException | IOException | InterruptedException | TimeoutException + | ExecutionException e) { logger.warn("Pairing / verification failed for {}", thing.getUID(), e); startConnectionTask(); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, @@ -304,7 +313,7 @@ public Collection getAccessories() { /** * Normalize XXX-XX-XXX or XXXX-XXXX or XXXXXXXX to XXX-XX-XXX */ - private String normalizePairingCode(String input) { + private String normalizePairingCode(String input) throws IllegalArgumentException { // remove all non-digit character formatting String digits = input.replaceAll("\\D", ""); if (digits.length() != 8) { @@ -320,14 +329,18 @@ private String normalizePairingCode(String input) { * If this handler is a child of a bridge, it delegates to the bridge handler. */ protected void startConnectionTask() { - Bridge bridge = getBridge(); - if (bridge != null && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { bridgeHandler.startConnectionTask(); } else { ScheduledFuture task = connectionTask; if (task != null) { task.cancel(false); } + IpTransport ipTransport = this.ipTransport; + if (ipTransport != null) { // clean up prior transport if any + this.ipTransport = null; + ipTransport.close(); + } connectionTask = scheduler.schedule(this::initializePairing, connectionDelaySeconds, TimeUnit.SECONDS); connectionDelaySeconds = Math.min(connectionDelaySeconds * connectionDelaySeconds, MAX_CONNECTION_DELAY_SECONDS); @@ -347,34 +360,47 @@ private void cancelConnectionTask() { } /** - * Gets the bridge handler if this thing is connected to a bridge. + * Gets the IP transport. * - * @return the HomekitBridgeHandler or null if not connected to a bridge + * @throws IllegalAccessException if this is a child accessory or if the transport is not initialized. + * @return the IpTransport */ - protected @Nullable HomekitBridgeHandler getBridgeHandler() { - return getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler handler - ? handler - : null; + protected IpTransport getIpTransport() throws IllegalAccessException { + if (isChildAccessory) { + throw new IllegalAccessException("Child accessories must delegate to bridge IP transport"); + } + IpTransport ipTransport = this.ipTransport; + if (ipTransport == null) { + throw new IllegalAccessException("IP transport not initialized"); + } + return ipTransport; } /** - * Gets the IP transport from the bridge handler if connected to a bridge; otherwise, returns - * this handler's IP transport. + * Gets the read/write service. * - * @return the IpTransport or null if not available + * @throws IllegalAccessException if this is a child accessory or if the service is not initialized + * @return the CharacteristicReadWriteClient */ - protected @Nullable IpTransport getIpTransport() { - return getBridgeHandler() instanceof HomekitBridgeHandler bridgeHandler ? bridgeHandler.ipTransport - : ipTransport; + protected CharacteristicReadWriteClient getRwService() throws IllegalAccessException { + if (isChildAccessory) { + throw new IllegalAccessException("Child accessories must delegate to bridge read/write service"); + } + CharacteristicReadWriteClient rwService = this.rwService; + if (rwService == null) { + throw new IllegalAccessException("Read/write service not initialized"); + } + return rwService; } /** * Subscribes to events from the IP transport. */ protected void subscribeEvents() { - IpTransport ipTransport = getIpTransport(); - if (ipTransport != null) { - ipTransport.subscribe(this); + try { + getIpTransport().subscribe(this); + } catch (IllegalAccessException e) { + logger.debug("Failed to subscribe to events '{}", e.getMessage()); } } @@ -382,14 +408,15 @@ protected void subscribeEvents() { * Unsubscribes from events from the IP transport. */ protected void unsubscribeEvents() { - IpTransport ipTransport = getIpTransport(); - if (ipTransport != null) { - ipTransport.unsubscribe(this); + try { + getIpTransport().unsubscribe(this); + } catch (IllegalAccessException e) { + logger.debug("Failed to unsubscribe from events '{}", e.getMessage()); } } @Override public void onEvent(String jsonContent) { - // default implementation does nothing; subclasses may override + // default implementation does nothing; subclasses must override } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java index 26f48ad645055..623d8b2ae2bf9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java @@ -14,7 +14,10 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.transport.IpTransport; @@ -39,9 +42,13 @@ public CharacteristicReadWriteClient(IpTransport ipTransport) { * * @param query the query string e.g. "1.10,1.11" for aid 1 and iid 10 and 11 * @return JSON response as String - * @throws Exception on communication or encryption errors + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException */ - public String readCharacteristic(String query) throws Exception { + public String readCharacteristic(String query) + throws IOException, InterruptedException, TimeoutException, ExecutionException { String endpoint = "%s?id=%s".formatted(ENDPOINT_CHARACTERISTICS, query); byte[] result = ipTransport.get(endpoint, CONTENT_TYPE_HAP); return new String(result, StandardCharsets.UTF_8); @@ -51,9 +58,13 @@ public String readCharacteristic(String query) throws Exception { * Writes characteristic(s) to the accessory. * * @param json the JSON string to write. - * @throws Exception on communication or encryption errors + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException */ - public void writeCharacteristic(String json) throws Exception { + public void writeCharacteristic(String json) + throws IOException, InterruptedException, TimeoutException, ExecutionException { ipTransport.put(ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, json.getBytes(StandardCharsets.UTF_8)); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index 01e427ab8e88e..a2ac5752d5120 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -14,9 +14,12 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.toHex; +import java.io.IOException; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.crypto.Tlv8Codec; @@ -44,13 +47,13 @@ public class PairRemoveClient { private final IpTransport ipTransport; private final byte[] controllerId; - public PairRemoveClient(IpTransport ipTransport, byte[] controllerId) throws Exception { + public PairRemoveClient(IpTransport ipTransport, byte[] controllerId) { logger.debug("Created.."); this.ipTransport = ipTransport; this.controllerId = controllerId; } - public void remove() throws Exception { + public void remove() throws IOException, InterruptedException, TimeoutException, ExecutionException { logger.debug("Pair-Remove: starting removal"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index c6013de396219..9678957f35ed7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -15,11 +15,16 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.toHex; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -54,7 +59,7 @@ public class PairSetupClient { private final Ed25519PrivateKeyParameters controllerKey; public PairSetupClient(IpTransport ipTransport, byte[] controllerId, Ed25519PrivateKeyParameters controllerKey, - String pairingCode) throws Exception { + String pairingCode) { logger.debug("Created with pairing code: {}", pairingCode); this.ipTransport = ipTransport; this.password = pairingCode; @@ -66,9 +71,16 @@ public PairSetupClient(IpTransport ipTransport, byte[] controllerId, Ed25519Priv * Executes the 6-step pairing process with the accessory. * * @return SessionKeys containing the derived session keys - * @throws Exception if any step of the pairing process fails + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws InvalidCipherTextException + * @throws SecurityException + * @throws NoSuchAlgorithmException */ - public Ed25519PublicKeyParameters pair() throws Exception { + public Ed25519PublicKeyParameters pair() throws NoSuchAlgorithmException, SecurityException, + InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { SRPclient client = m1Execute(); return client.getAccessoryLongTermPublicKey(); } @@ -77,10 +89,16 @@ public Ed25519PublicKeyParameters pair() throws Exception { * Executes step M1 of the pairing process: Start Pair-Setup. * * @return byte array containing the response from the accessory + * @throws ExecutionException + * @throws TimeoutException + * @throws IOException * @throws InterruptedException if the operation is interrupted - * @throws Exception if an error occurs during execution + * @throws InvalidCipherTextException + * @throws SecurityException + * @throws NoSuchAlgorithmException */ - private SRPclient m1Execute() throws Exception { + private SRPclient m1Execute() throws IOException, InterruptedException, TimeoutException, ExecutionException, + NoSuchAlgorithmException, SecurityException, InvalidCipherTextException { logger.debug("Pair-Setup M1: Send pairing start request to server"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); @@ -96,9 +114,16 @@ private SRPclient m1Execute() throws Exception { * And initializes the SRP client with the received parameters. * * @param m1Response byte array containing the response from step M1 - * @throws Exception if an error occurs during processing + * @throws NoSuchAlgorithmException + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws InvalidCipherTextException + * @throws SecurityException */ - private SRPclient m2Execute(byte[] m1Response) throws Exception { + private SRPclient m2Execute(byte[] m1Response) throws NoSuchAlgorithmException, SecurityException, + InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { logger.debug("Pair-Setup M2: Read server salt and accessory ephemeral PK; initialize SRP client"); Map tlv = Tlv8Codec.decode(m1Response); loggerTraceTlv(tlv); @@ -114,9 +139,15 @@ private SRPclient m2Execute(byte[] m1Response) throws Exception { * Executes step M3 of the pairing process: Send client SRP public key & M1 proof. * * @return byte array containing the response from the accessory - * @throws Exception if an error occurs during processing + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws SecurityException + * @throws InvalidCipherTextException */ - private SRPclient m3Execute(SRPclient client) throws Exception { + private SRPclient m3Execute(SRPclient client) throws SecurityException, IOException, InterruptedException, + TimeoutException, ExecutionException, InvalidCipherTextException { logger.debug("Pair-Setup M3: Send controller ephemeral PK and M1 proof to accessory"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M3.value }); @@ -132,9 +163,14 @@ private SRPclient m3Execute(SRPclient client) throws Exception { * Executes step M4 of the pairing process: Verify accessory SRP proof. * * @param m3Response byte array containing the response from step M3 - * @throws Exception if an error occurs during processing + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws InvalidCipherTextException */ - private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exception { + private SRPclient m4Execute(SRPclient client, byte[] m3Response) + throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { logger.debug("Pair-Setup M4: Read accessory M2 proof; and verify it"); Map tlv = Tlv8Codec.decode(m3Response); loggerTraceTlv(tlv); @@ -149,9 +185,14 @@ private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws Exceptio * Sends the session key, pairing identifier, client LTPK, and signature to the accessory. * * @return byte array containing the response from the accessory - * @throws Exception if an error occurs during processing + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws InvalidCipherTextException */ - private SRPclient m5Execute(SRPclient client) throws Exception { + private SRPclient m5Execute(SRPclient client) + throws IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException { logger.debug("Pair-Setup M5: Send controller id, LTPK, and signature to accessory"); byte[] cipherText = client.m5EncodeControllerInfoAndSign(controllerId, controllerKey); Map tlv = new LinkedHashMap<>(); @@ -168,9 +209,9 @@ private SRPclient m5Execute(SRPclient client) throws Exception { * Derives and returns the session keys. * * @param m5Response byte array containing the response from step M5 - * @throws Exception if an error occurs during processing + * @throws InvalidCipherTextException */ - private SRPclient m6Execute(SRPclient client, byte[] m5Response) throws Exception { + private SRPclient m6Execute(SRPclient client, byte[] m5Response) throws InvalidCipherTextException { logger.debug("Pair-Setup M6: Read accessory id, LTPK, and signature; and verify it"); Map tlv = Tlv8Codec.decode(m5Response); loggerTraceTlv(tlv); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index 954845fea4a69..f913832d1d34c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -16,11 +16,17 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; @@ -60,7 +66,7 @@ public class PairVerifyClient { private @NonNullByDefault({}) byte[] writeKey; public PairVerifyClient(IpTransport ipTransport, byte[] controllerId, Ed25519PrivateKeyParameters controllerKey, - Ed25519PublicKeyParameters accessoryKey) throws Exception { + Ed25519PublicKeyParameters accessoryKey) throws NoSuchAlgorithmException, NoSuchProviderException { logger.debug("Created.."); this.ipTransport = ipTransport; this.clientPairingId = controllerId; @@ -73,15 +79,21 @@ public PairVerifyClient(IpTransport ipTransport, byte[] controllerId, Ed25519Pri * Executes the 4-step pairing verification process with the accessory. * * @return SessionKeys containing the derived session keys - * @throws Exception if any step of the pairing process fails + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws InvalidCipherTextException */ - public AsymmetricSessionKeys verify() throws Exception { + public AsymmetricSessionKeys verify() + throws IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException { m1Execute(); return new AsymmetricSessionKeys(readKey, writeKey); } // M1 — Create new random client ephemeral X25519 public key and send it to server - private void m1Execute() throws Exception { + private void m1Execute() + throws IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException { logger.debug("Pair-Verify M1: Send verification start request with client ephemeral X25519 PK to server"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); @@ -93,7 +105,8 @@ private void m1Execute() throws Exception { } // M2 — Receive server ephemeral X25519 public key and encrypted TLV - private void m2Execute(byte[] m1Response) throws Exception { + private void m2Execute(byte[] m1Response) + throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { logger.debug("Pair-Verify M2: Read server ephemeral X25519 PK and encrypted id; validate signature"); Map tlv = Tlv8Codec.decode(m1Response); loggerTraceTlv(tlv); @@ -121,7 +134,8 @@ private void m2Execute(byte[] m1Response) throws Exception { } // M3 — Send encrypted controller identifier and signature - private void m3Execute() throws Exception { + private void m3Execute() + throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { logger.debug("Pair-Verify M3: Send encrypted controller id with signature"); byte[] clientSignature = signMessage(controllerKey, concat(controllerEphemeralSecretKey.generatePublicKey().getEncoded(), clientPairingId, @@ -145,7 +159,7 @@ private void m3Execute() throws Exception { } // M4 — Final confirmation - private void m4Execute(byte[] m3Response) throws Exception { + private void m4Execute(byte[] m3Response) { logger.debug("Pair-Verify M4: Confirm validation; derive session keys"); Map tlv = Tlv8Codec.decode(m3Response); loggerTraceTlv(tlv); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index a39aea667883d..6e2c68d03af7a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -24,6 +24,7 @@ import java.nio.ByteOrder; import java.util.concurrent.atomic.AtomicInteger; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.eclipse.jdt.annotation.NonNullByDefault; /** @@ -55,9 +56,10 @@ public SecureSession(Socket socket, AsymmetricSessionKeys keys) throws IOExcepti * encrypts them, and sends them as separate frames. * * @param plainText the complete plaintext message to be sent. - * @throws Exception if an error occurs during encryption or sending. + * @throws IOException + * @throws InvalidCipherTextException */ - public void send(byte[] plainText) throws Exception { + public void send(byte[] plainText) throws IOException, InvalidCipherTextException { ByteArrayInputStream plainTextStream = new ByteArrayInputStream(plainText); while (plainTextStream.available() > 0) { sendFrame(plainTextStream); @@ -72,9 +74,10 @@ public void send(byte[] plainText) throws Exception { * incremented after sending the frame to ensure nonce uniqueness. * * @param plainTextStream the input stream containing the plaintext to be sent. - * @throws Exception if an error occurs during encryption or sending. + * @throws IOException + * @throws InvalidCipherTextException */ - private void sendFrame(ByteArrayInputStream plainTextStream) throws Exception { + private void sendFrame(ByteArrayInputStream plainTextStream) throws IOException, InvalidCipherTextException { short frameLen = (short) Math.min(1024, plainTextStream.available()); ByteBuffer frameAad = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(frameLen); out.write(frameAad.array(), 0, frameAad.array().length); // send length prefix @@ -92,9 +95,10 @@ private void sendFrame(ByteArrayInputStream plainTextStream) throws Exception { * @param trace if true, captures the raw decrypted frames for debugging purposes. * @return a 3D byte array where the first element is the HTTP headers, the second element is the content, * and the third is the raw trace (if enabled). - * @throws Exception if an error occurs during reading or decryption. + * @throws IOException + * @throws InvalidCipherTextException */ - public byte[][] receive(boolean trace) throws Exception { + public byte[][] receive(boolean trace) throws IOException, InvalidCipherTextException { HttpPayloadParser httpParser = new HttpPayloadParser(); ByteArrayOutputStream raw = trace ? new ByteArrayOutputStream() : null; do { @@ -114,9 +118,10 @@ public byte[][] receive(boolean trace) throws Exception { * AAD to ensure integrity. The read counter is incremented after reading the frame to ensure nonce uniqueness. * * @return the decrypted plaintext of the single frame. - * @throws Exception if an error occurs during reading or decryption. + * @throws IOException + * @throws InvalidCipherTextException */ - private byte[] receiveFrame() throws Exception { + private byte[] receiveFrame() throws IOException, InvalidCipherTextException { byte[] frameAad = in.readNBytes(2); short frameLen = ByteBuffer.wrap(frameAad).order(ByteOrder.LITTLE_ENDIAN).getShort(); if (frameLen < 0 || frameLen > 1024) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index c66a6209cdd2e..346fb3ff702ab 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -31,6 +31,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; @@ -45,7 +46,6 @@ * It provides methods for sending GET, POST, and PUT requests with appropriate headers and content types. * It supports both plain and secure (encrypted) communication based on whether session keys have been set. * It handles building HTTP requests, sending them over a socket, and parsing HTTP responses. - * It throws exceptions for various error conditions, including IO issues, timeouts, and non-200 HTTP responses. * * @author Andrew Fiddian-Green - Initial contribution */ @@ -71,8 +71,10 @@ public class IpTransport implements AutoCloseable { * Creates a new IpTransport instance with the given socket and session keys. * * @param host the IP address and port of the HomeKit accessory + * @throws IOException + * @throws IllegalArgumentException if the host or port are invalid */ - public IpTransport(String host) throws Exception { + public IpTransport(String host) throws IOException, IllegalArgumentException { logger.debug("Connecting to {}", host); this.host = host; String[] parts = host.split(":"); @@ -91,7 +93,7 @@ public IpTransport(String host) throws Exception { logger.debug("Connected to {}", host); } - public void setSessionKeys(AsymmetricSessionKeys keys) throws Exception { + public void setSessionKeys(AsymmetricSessionKeys keys) throws IOException { secureSession = new SecureSession(socket, keys); Thread thread = new Thread(this::readTask, "homekit-reader"); thread.start(); @@ -115,57 +117,51 @@ public byte[] put(String endpoint, String contentType, byte[] content) private synchronized byte[] execute(String method, String endpoint, String contentType, byte[] content) throws IOException, InterruptedException, TimeoutException, ExecutionException { - try { - byte[] request = buildRequest(method, endpoint, contentType, content); - - boolean trace = logger.isTraceEnabled(); - if (trace) { - logger.trace("Request:\n{}", new String(request, StandardCharsets.ISO_8859_1)); - } + byte[] request = buildRequest(method, endpoint, contentType, content); - byte[][] response; // 0 = headers, 1 = content, 2 = raw trace (if enabled) - SecureSession secureSession = this.secureSession; - if (secureSession != null) { - // before we write request, create CompletableFuture to read response (with a timeout) - CompletableFuture readFuture = new CompletableFuture<>(); - this.readFuture = readFuture; - // create Future to write the request (with a timeout) - Future<@Nullable Void> writeTask = writeExecutor.submit(() -> { - secureSession.send(request); - return null; - }); - // now wait for both write and read to complete - writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); - response = readFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); - } else { - OutputStream out = socket.getOutputStream(); - InputStream in = socket.getInputStream(); - // create Future to write the request (with a timeout) - Future<@Nullable Void> writeTask = writeExecutor.submit(() -> { - out.write(request); - out.flush(); - return null; - }); - // now wait for both write and read to complete - writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); - response = readPlainResponse(in, trace); - } + boolean trace = logger.isTraceEnabled(); + if (trace) { + logger.trace("Request:\n{}", new String(request, StandardCharsets.ISO_8859_1)); + } - if (response.length != 3) { - throw new IOException("Response must contain 3 arrays"); - } + byte[][] response; // 0 = headers, 1 = content, 2 = raw trace (if enabled) + SecureSession secureSession = this.secureSession; + if (secureSession != null) { + // before we write request, create CompletableFuture to read response (with a timeout) + CompletableFuture readFuture = new CompletableFuture<>(); + this.readFuture = readFuture; + // create Future to write the request (with a timeout) + Future<@Nullable Void> writeTask = writeExecutor.submit(() -> { + secureSession.send(request); + return null; + }); + // now wait for both write and read to complete + writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + response = readFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + } else { + OutputStream out = socket.getOutputStream(); + InputStream in = socket.getInputStream(); + // create Future to write the request (with a timeout) + Future<@Nullable Void> writeTask = writeExecutor.submit(() -> { + out.write(request); + out.flush(); + return null; + }); + // now wait for both write and read to complete + writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + response = readPlainResponse(in, trace); + } - if (trace) { - logger.trace("Response:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); - } + if (response.length != 3) { + throw new IOException("Response must contain 3 arrays"); + } - checkHeaders(response[0]); - return response[1]; - } catch (IOException | InterruptedException | TimeoutException e) { - throw e; - } catch (Exception e) { - throw new ExecutionException(e); + if (trace) { + logger.trace("Response:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); } + + checkHeaders(response[0]); + return response[1]; } /** @@ -252,13 +248,18 @@ private void checkHeaders(byte[] headers) throws IOException { } @Override - public void close() throws Exception { - socket.close(); + public void close() { secureSession = null; - Thread thread = readThread; - if (thread != null) { - thread.interrupt(); - thread.join(); + eventListeners.clear(); + try { + socket.close(); + Thread thread = readThread; + if (thread != null) { + thread.interrupt(); + thread.join(); + } + } catch (IOException | InterruptedException e) { + // shut down quietly } } @@ -274,7 +275,7 @@ private void handleResponse(byte[][] response) { future.complete(response); } String headers = new String(response[0], StandardCharsets.ISO_8859_1); - if (headers.startsWith("EVENT ")) { + if (headers.startsWith("EVENT")) { logger.trace("Event:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); String jsonContent = new String(response[1], StandardCharsets.UTF_8); for (EventListener eventListener : eventListeners) { @@ -298,9 +299,8 @@ private void readTask() { } byte[][] response = session.receive(logger.isTraceEnabled()); handleResponse(response); - } catch (SocketTimeoutException e) { - // ignore socket timeout; continue listening - } catch (Exception e) { + } catch (SocketTimeoutException e) { // ignore socket timeout; continue listening + } catch (IllegalStateException | InvalidCipherTextException | IOException e) { cause = e; break; } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java index a3eee536c85b9..d1832459a5d5d 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -17,9 +17,11 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Map; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -60,11 +62,11 @@ public class SRPserver { * @param accessoryKey the long term private key of the server * @param username the username to use (or null for default "Pair-Setup") * @param accessoryPrivateKey optional 32 byte private key to use for b, or null to generate a new one + * @throws NoSuchAlgorithmException * - * @throws Exception on any error */ public SRPserver(String password, byte[] serverSalt, byte[] accessoryId, Ed25519PrivateKeyParameters accessoryKey, - @Nullable String username, byte @Nullable [] accessoryPrivateKey) throws Exception { + @Nullable String username, byte @Nullable [] accessoryPrivateKey) throws NoSuchAlgorithmException { this.accessoryId = accessoryId; this.accessoryKey = accessoryKey; I = username != null ? username : PAIR_SETUP; @@ -87,7 +89,7 @@ public SRPserver(String password, byte[] serverSalt, byte[] accessoryId, Ed25519 B = k.multiply(v).add(gb).mod(N); } - public byte[] m3CreateServerProof(byte[] clientPublicKeyA) throws Exception { + public byte[] m3CreateServerProof(byte[] clientPublicKeyA) throws NoSuchAlgorithmException { BigInteger clientPublicA = new BigInteger(1, clientPublicKeyA); if (clientPublicA.mod(N).equals(BigInteger.ZERO)) { throw new SecurityException("Invalid client public key"); @@ -120,7 +122,8 @@ public byte[] m3CreateServerProof(byte[] clientPublicKeyA) throws Exception { return sha512(concat(toUnsigned(clientPublicA, 384), M1, K)); } - public void m5DecodeControllerInfoAndVerify(Map tlv5) throws Exception { + public void m5DecodeControllerInfoAndVerify(Map tlv5) + throws InvalidCipherTextException, IllegalArgumentException { byte[] cipherText = tlv5.get(TlvType.ENCRYPTED_DATA.value); if (cipherText == null) { throw new IllegalArgumentException("Missing encrypted data"); @@ -145,7 +148,7 @@ public void m5DecodeControllerInfoAndVerify(Map tlv5) throws Ex verifySignature(iOSDeviceLongTermPublicKey, iOSDeviceSignature, iOSDeviceInfo); } - public byte[] m6EncodeAccessoryInfoAndSign() throws Exception { + public byte[] m6EncodeAccessoryInfoAndSign() throws InvalidCipherTextException { byte[] accessoryX = generateHkdfKey(K, PAIR_SETUP_ACCESSORY_SIGN_SALT, PAIR_SETUP_ACCESSORY_SIGN_INFO); byte[] accessoryLTPK = accessoryKey.generatePublicKey().getEncoded(); byte[] accessoryInfo = concat(accessoryX, accessoryId, accessoryLTPK); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index e0fd10ff4e2c0..2d4840a59e692 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -18,11 +18,16 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; @@ -57,7 +62,7 @@ class TestPairSetup { private @NonNullByDefault({}) byte[] clientPublicKey; @Test - void testBareCrypto() throws Exception { + void testBareCrypto() throws InvalidCipherTextException { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); byte[] key = new byte[32]; // 256 bits = 32 bytes byte[] nonce64 = generateNonce64(123); @@ -68,7 +73,7 @@ void testBareCrypto() throws Exception { } @Test - void testSrpClient() throws Exception { + void testSrpClient() throws InvalidCipherTextException, NoSuchAlgorithmException { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); SRPclient client = new SRPclient("password123", toBytes(SALT_HEX), toBytes(SERVER_PRIVATE_HEX)); byte[] sharedKey = generateHkdfKey(client.K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); @@ -78,7 +83,8 @@ void testSrpClient() throws Exception { } @Test - void testPairSetup() throws Exception { + void testPairSetup() throws NoSuchAlgorithmException, SecurityException, InvalidCipherTextException, IOException, + InterruptedException, TimeoutException, ExecutionException, IllegalArgumentException { // initialize test parameters String password = "password123"; byte[] iOSDeviceId = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; @@ -135,7 +141,7 @@ TlvType.PUBLIC_KEY.value, toUnsigned(server.B, 384) // server public key } private byte[] m3GetAccessoryResponse(SRPserver server, Map tlv2, PairSetupClient client) - throws Exception { + throws NoSuchAlgorithmException { clientPublicKey = tlv2.get(TlvType.PUBLIC_KEY.value); byte[] serverProof = server.m3CreateServerProof(Objects.requireNonNull(clientPublicKey)); Map tlv3 = Map.of( // @@ -146,7 +152,8 @@ private byte[] m3GetAccessoryResponse(SRPserver server, Map tlv return Tlv8Codec.encode(tlv3); } - private byte[] m5GetAccessoryResponse(SRPserver server, Map tlv5) throws Exception { + private byte[] m5GetAccessoryResponse(SRPserver server, Map tlv5) + throws InvalidCipherTextException { server.m5DecodeControllerInfoAndVerify(tlv5); byte[] cipherText = server.m6EncodeAccessoryInfoAndSign(); Map tlv6 = Map.of( // diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index b01feaa5962dd..35f30d3d5a969 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -17,9 +17,15 @@ import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import org.bouncycastle.crypto.InvalidCipherTextException; import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.bouncycastle.crypto.params.X25519PrivateKeyParameters; import org.bouncycastle.crypto.params.X25519PublicKeyParameters; @@ -62,7 +68,8 @@ class TestPairVerify { private @NonNullByDefault({}) byte[] cryptoKey; @Test - void testPairVerify() throws Exception { + void testPairVerify() throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, + ExecutionException, NoSuchAlgorithmException, NoSuchProviderException, IllegalArgumentException { accessoryEphemeralSecretKey = generateX25519KeyPair(); // create mock @@ -97,7 +104,7 @@ void testPairVerify() throws Exception { client.verify(); } - private byte[] m1GetAccessoryResponse(Map tlv) throws Exception { + private byte[] m1GetAccessoryResponse(Map tlv) throws InvalidCipherTextException { byte[] controllerEphemeralPublicKey = tlv.get(TlvType.PUBLIC_KEY.value); byte[] accessoryEphemeralPublicKey = accessoryEphemeralSecretKey.generatePublicKey().getEncoded(); if (controllerEphemeralPublicKey == null) { @@ -126,7 +133,7 @@ private byte[] m1GetAccessoryResponse(Map tlv) throws Exception return Tlv8Codec.encode(tlvOut); } - private byte[] m3GetAccessoryResponse(Map tlv) throws Exception { + private byte[] m3GetAccessoryResponse(Map tlv) throws InvalidCipherTextException { if (cryptoKey.length == 0) { throw new IllegalStateException("Session key not established"); } From 1e6dd2944d0cfac0dd5183437e5010209ddcfc75 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 24 Oct 2025 18:23:42 +0100 Subject: [PATCH 087/177] suppress log message on closing Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/transport/IpTransport.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 346fb3ff702ab..aeb10abc8a8bb 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -67,6 +67,8 @@ public class IpTransport implements AutoCloseable { private @Nullable Thread readThread = null; private @Nullable CompletableFuture readFuture = null; + private boolean closing = false; + /** * Creates a new IpTransport instance with the given socket and session keys. * @@ -249,6 +251,7 @@ private void checkHeaders(byte[] headers) throws IOException { @Override public void close() { + closing = true; secureSession = null; eventListeners.clear(); try { @@ -312,7 +315,7 @@ private void readTask() { future.completeExceptionally(cause != null ? cause : new InterruptedException("Listener interrupted")); } - if (cause != null) { + if (cause != null && !closing) { if (logger.isTraceEnabled()) { logger.trace("Error while listening for events", cause); } else { From 2f8069047a00b4e1acbbed7934f37ee490bc6f8d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 24 Oct 2025 18:48:28 +0100 Subject: [PATCH 088/177] fix disposition of HS parts of HSB commands Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index a62a016b39dcd..f31f98657021e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -51,7 +51,6 @@ import org.openhab.core.library.CoreItemFactory; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.HSBType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.OpenClosedType; import org.openhab.core.library.types.PercentType; @@ -499,9 +498,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { return; } try { - if (command instanceof HSBType) { - logger.warn("Forbidden to send command '{}' directly to '{}'", command, channelUID); - } else if (command instanceof StopMoveType stopMoveType && StopMoveType.STOP == stopMoveType) { + if (command instanceof StopMoveType stopMoveType && StopMoveType.STOP == stopMoveType) { if (stopMoveChannel instanceof Channel stopMoveChannel) { writeChannel(stopMoveChannel, OnOffType.ON, getRwService()); } else if (readChannel(channel, getRwService()) instanceof Command actualPosition) { From f3a72a5c11878cba93bc3e3d148973a95a34e632 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 24 Oct 2025 22:33:34 +0100 Subject: [PATCH 089/177] fix hsb brightness and on/off Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index f31f98657021e..c15db873ed358 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -658,16 +658,16 @@ private boolean lightModelRefresh(Characteristic cxx) throws IllegalStateExcepti if (primitiveValue.isNumber()) { changed = true; switch (cxxType) { + case ON -> lightModel.setOnOff(primitiveValue.getAsInt() != 0); // number case HUE -> lightModel.setHue(primitiveValue.getAsDouble()); case SATURATION -> lightModel.setSaturation(primitiveValue.getAsDouble()); case BRIGHTNESS -> lightModel.setBrightness(primitiveValue.getAsDouble()); case COLOR_TEMPERATURE -> lightModel.setMirek(primitiveValue.getAsDouble()); default -> changed = false; } - } else if (primitiveValue.isBoolean()) { - changed = true; + } else { switch (cxxType) { - case ON -> lightModel.setOnOff(primitiveValue.getAsBoolean()); + case ON -> lightModel.setOnOff(primitiveValue.getAsBoolean()); // string, boolean default -> changed = false; } } @@ -705,11 +705,11 @@ private void lightModelHandleCommand(Command command, CharacteristicReadWriteCli writeChannel(link.get().channel, saturation, writer); } link = lightModelLinks.stream().filter(e -> CharacteristicType.BRIGHTNESS == e.cxxType).findFirst(); - if (link.isPresent() && lightModel.getBrightness() instanceof PercentType brightness) { + if (link.isPresent() && lightModel.getBrightness(true) instanceof PercentType brightness) { writeChannel(link.get().channel, brightness, writer); } link = lightModelLinks.stream().filter(e -> CharacteristicType.ON == e.cxxType).findFirst(); - if (link.isPresent() && lightModel.getOnOff() instanceof OnOffType onOff) { + if (link.isPresent() && lightModel.getOnOff(true) instanceof OnOffType onOff) { writeChannel(link.get().channel, onOff, writer); } } @@ -874,6 +874,7 @@ public void onEvent(String json) { * @param json the JSON content containing characteristic values */ private void updateChannelsFromJson(String json) { + ChannelUID hsbChannelUID = null; Service service = GSON.fromJson(json, Service.class); if (service != null && service.characteristics instanceof List characteristics) { for (Channel channel : thing.getChannels()) { @@ -881,7 +882,7 @@ private void updateChannelsFromJson(String json) { if (channelUID.equals(lightModelClientHSBTypeChannel)) { for (Characteristic cxx : characteristics) { if (lightModelRefresh(cxx)) { - updateState(channelUID, Objects.requireNonNull(lightModel).getHsb()); + hsbChannelUID = channelUID; } } } else if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { @@ -897,6 +898,9 @@ private void updateChannelsFromJson(String json) { } } } + if (hsbChannelUID != null) { + updateState(hsbChannelUID, Objects.requireNonNull(lightModel).getHsb()); + } } /** From ea6a844c9d5f02456885b6718680421b6a5eb0e8 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 24 Oct 2025 22:59:45 +0100 Subject: [PATCH 090/177] quick update hsb dependent channels Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index c15db873ed358..931d5bea493fe 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -506,6 +506,33 @@ public void handleCommand(ChannelUID channelUID, Command command) { } } else if (channelUID.equals(lightModelClientHSBTypeChannel)) { lightModelHandleCommand(command, getRwService()); + LightModel lightModel = this.lightModel; + if (lightModel != null) { + lightModelLinks.forEach(link -> { + switch (link.cxxType) { + case HUE -> { + QuantityType hue = QuantityType.valueOf(lightModel.getHue(), Units.DEGREE_ANGLE); + updateState(link.channel.getUID(), hue); + } + case SATURATION -> { + PercentType sat = new PercentType(BigDecimal.valueOf(lightModel.getSaturation())); + updateState(link.channel.getUID(), sat); + } + case BRIGHTNESS -> { + if (lightModel.getBrightness(true) instanceof PercentType bri) { + updateState(link.channel.getUID(), bri); + } + } + case ON -> { + if (lightModel.getOnOff(true) instanceof OnOffType onOff) { + updateState(link.channel.getUID(), onOff); + } + } + default -> { + } + } + }); + } } else { writeChannel(channel, command, getRwService()); } From 3bc8ef1c91965ac1dae24559fb5b782d19efbe39 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 24 Oct 2025 23:17:34 +0100 Subject: [PATCH 091/177] flip online state Signed-off-by: Andrew Fiddian-Green --- .../internal/handler/HomekitAccessoryHandler.java | 9 +++++++++ .../internal/handler/HomekitBaseAccessoryHandler.java | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 931d5bea493fe..38af454415e5c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -67,6 +67,7 @@ import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; import org.openhab.core.thing.binding.builder.ChannelBuilder; import org.openhab.core.thing.binding.builder.ThingBuilder; import org.openhab.core.thing.type.ChannelGroupType; @@ -967,4 +968,12 @@ protected CharacteristicReadWriteClient getRwService() throws IllegalAccessExcep } return super.getRwService(); } + + @Override + public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + super.bridgeStatusChanged(bridgeStatusInfo); + if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE && thing.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 15747c4f5da9e..25a1df361fea1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -213,6 +213,8 @@ public void initialize() { * Updates the thing status accordingly. */ private synchronized void initializePairing() { + updateStatus(ThingStatus.UNKNOWN); + Object host = getConfig().get(CONFIG_HOST); if (host == null || !(host instanceof String hostString) || !HOST_PATTERN.matcher(hostString).matches()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, @@ -300,9 +302,9 @@ private synchronized void initializePairing() { | InvalidCipherTextException | IOException | InterruptedException | TimeoutException | ExecutionException e) { logger.warn("Pairing / verification failed for {}", thing.getUID(), e); - startConnectionTask(); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.pairing-verification-failed", "Pairing / verification failed", null)); + startConnectionTask(); } } From f0822c33e700d06f55d445f421bc747f30c59453 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 25 Oct 2025 11:52:46 +0100 Subject: [PATCH 092/177] add i18n for enum values and channel labels Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/dto/Characteristic.java | 35 ++- .../internal/enums/CharacteristicType.java | 11 +- .../resources/OH-INF/i18n/homekit.properties | 210 ++++++++++++++++++ 3 files changed, 243 insertions(+), 13 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 6d64d73f5ec33..a4ccb20f66608 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -19,7 +19,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -181,7 +180,6 @@ public class Characteristic { isEnumLike = true; pointTag = Point.STATUS; propertyTag = Property.MODE; - isEnumLike = true; break; case AIR_PURIFIER_STATE_TARGET: @@ -866,11 +864,11 @@ public class Characteristic { } if (!options.isEmpty()) { - String prefix = "characteristic.%s.".formatted(characteristicType.getOpenhabType()); + String translationKey = "characteristic.%s.".formatted(characteristicType.getOpenhabType()); fragBldr.withOptions(options.stream().map(o -> { - String defaultText = "%s #%s".formatted(characteristicType.toString(), o); - String optionLabel = i18nProvider.getText(bundle, prefix + o, defaultText, null); - optionLabel = optionLabel == null || optionLabel.isBlank() ? defaultText : optionLabel; + String defaultLabel = "%s #%s".formatted(characteristicType.toString(), o); + String optionLabel = i18nProvider.getText(bundle, translationKey + o, defaultLabel, null); + optionLabel = optionLabel == null || optionLabel.isBlank() ? defaultLabel : optionLabel; return new StateOption(optionLabel, o); }).toList()); } @@ -908,16 +906,29 @@ public class Characteristic { Optional.ofNullable(format).ifPresent(s -> props.put(PROPERTY_FORMAT, s)); Optional.ofNullable(dataType).ifPresent(s -> props.put(PROPERTY_DATA_TYPE, s)); - return new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), channelTypeUid).withProperties(props) - .withLabel(getChannelInstanceLabel()).build(); + ChannelDefinitionBuilder channelDefBuilder = new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), + channelTypeUid).withLabel(getChannelLabel(characteristicType, i18nProvider, bundle)) + .withProperties(props); + Optional.ofNullable(getChannelDescription()).ifPresent(d -> channelDefBuilder.withDescription(d)); + return channelDefBuilder.build(); + } + + /* + * Returns the translated characteristic label, or the Characteristic type in Title Case. + */ + private String getChannelLabel(CharacteristicType characteristicType, TranslationProvider i18nProvider, + Bundle bundle) { + String translationKey = "characteristic.%s".formatted(characteristicType.getOpenhabType()); + String defaultLabel = characteristicType.toString(); + String channelLabel = i18nProvider.getText(bundle, translationKey, defaultLabel, null); + return channelLabel == null || channelLabel.isBlank() ? defaultLabel : channelLabel; } /* - * Returns the 'description' field if it is present. Otherwise returns the Characteristic type in Title Case. + * Returns the 'description' field if it is present. Otherwise returns null. */ - private String getChannelInstanceLabel() { - return description != null && !description.isBlank() ? description - : Objects.requireNonNull(getCharacteristicType()).toString(); + private @Nullable String getChannelDescription() { + return description != null && !description.isBlank() ? description : null; } public CharacteristicType getCharacteristicType() { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index 0a96b4154ef8a..fcb49be490a19 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -177,16 +177,22 @@ public static CharacteristicType from(int id) throws IllegalArgumentException { throw new IllegalArgumentException("Unknown ID: " + id); } + /** + * Returns OH type id being a shortened version of the full Homekit type id. e.g. ZOOM_DIGITAL -> zoom-digital + */ public String getOpenhabType() { return type.substring(26).replace(".", "-"); // convert to OH channel type format } + /** + * Returns the full Homekit type id. e.g. ZOOM_DIGITAL -> public.hap.characteristic.zoom-digital + */ public String getType() { return type; } /** - * Returns the name of the enum constant in Title Case. + * Returns the name of the enum constant in Title Case. e.g. ZOOM_DIGITAL -> Zoom Digital */ @Override public String toString() { @@ -195,6 +201,9 @@ public String toString() { .collect(Collectors.joining(" ")); } + /** + * Returns the name of the enum constant in Camel Case. e.g. ZOOM_DIGITAL -> zoomDigital + */ public String toCamelCase() { String[] parts = name().split("_"); StringBuilder camelCase = new StringBuilder(parts[0].toLowerCase()); diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 3201fde778a2b..3c1f49101ab50 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -32,3 +32,213 @@ error.missing-mac-address = Missing MAC address error.pairing-verification-failed = Pairing / verification failed error.polling-error = Polling error error.error-sending-command = Error sending command + +characteristic.accessory-properties = Accessory Properties +characteristic.active = Active +characteristic.active-identifier = Active Identifier +characteristic.administrator-only-access = Administrator Only Access +characteristic.air-particulate-density = Air Particulate Density +characteristic.air-particulate-size = Air Particulate Size +characteristic.air-purifier-state-current = Air Purifier State Current +characteristic.air-purifier-state-current.0 = Inactive +characteristic.air-purifier-state-current.1 = Idle +characteristic.air-purifier-state-current.2 = Purifying Air +characteristic.air-purifier-state-target = Air Purifier State Target +characteristic.air-quality = Air Quality +characteristic.air-quality.0 = Unknown +characteristic.air-quality.1 = Excellent +characteristic.air-quality.2 = Good +characteristic.air-quality.3 = Fair +characteristic.air-quality.4 = Inferior +characteristic.air-quality.5 = Poor +characteristic.audio-feedback = Audio Feedback +characteristic.battery-level = Battery Level +characteristic.brightness = Brightness +characteristic.button-event = Button Event +characteristic.carbon-dioxide-detected = Carbon Dioxide Detected +characteristic.carbon-dioxide-level = Carbon Dioxide Level +characteristic.carbon-dioxide-peak-level = Carbon Dioxide Peak Level +characteristic.carbon-monoxide-detected = Carbon Monoxide Detected +characteristic.carbon-monoxide-level = Carbon Monoxide Level +characteristic.carbon-monoxide-peak-level = Carbon Monoxide Peak Level +characteristic.charging-state = Charging State +characteristic.charging-state.0 Not Charging +characteristic.charging-state.1 Charging +characteristic.charging-state.2 = Not Chargeable +characteristic.color-temperature = Color Temperature +characteristic.contact-state = Contact State +characteristic.density-no2 = Density No2 +characteristic.density-ozone = Density Ozone +characteristic.density-pm10 = Density Pm10 +characteristic.density-pm2_5 = Density Pm2 5 +characteristic.density-so2 = Density So2 +characteristic.density-voc = Density Voc +characteristic.door-state-current = Door State Current +characteristic.door-state-current.0 = Open. The door is fully open. +characteristic.door-state-current.1 = Closed. The door is fully closed. +characteristic.door-state-current.2 = Opening. The door is actively opening. +characteristic.door-state-current.3 = Closing. The door is actively closing. +characteristic.door-state-current.4 = Stopped. The door is not moving, and it is not fully open nor fully closed. +characteristic.door-state-target = Door State Target +characteristic.fan-state-current = Fan State Current +characteristic.fan-state-current.0 = Inactive +characteristic.fan-state-current.1 = Idle +characteristic.fan-state-current.2 = Blowing Air +characteristic.fan-state-target = Fan State Target +characteristic.filter-change-indication = Filter Change Indication +characteristic.filter-life-level = Filter Life Level +characteristic.filter-reset-indication = Filter Reset Indication +characteristic.firmware-revision = Firmware Revision +characteristic.hardware-revision = Hardware Revision +characteristic.heater-cooler-state-current = Heater Cooler State Current +characteristic.heater-cooler-state-current.0 = Inactive +characteristic.heater-cooler-state-current.1 = Idle +characteristic.heater-cooler-state-current.2 = Heating +characteristic.heater-cooler-state-current.3 = Cooling +characteristic.heater-cooler-state-target = Heater Cooler State Target +characteristic.heater-cooler-state-target.0 = Inactive +characteristic.heater-cooler-state-target.1 = Idle +characteristic.heater-cooler-state-target.2 = Heating +characteristic.heater-cooler-state-target.3 = Cooling +characteristic.heating-cooling-current = Heating Cooling Current +characteristic.heating-cooling-current.0 = Off. +characteristic.heating-cooling-current.1 = Heat. The Heater is currently on. +characteristic.heating-cooling-current.2 = Cool. Cooler is currently on. +characteristic.heating-cooling-target = Heating Cooling Target +characteristic.heating-cooling-target.0 = Off +characteristic.heating-cooling-target.1 = Heat. If the current temperature is below the target temperature then turn on heating. +characteristic.heating-cooling-target.2 = Cool. If the current temperature is above the target temperature then turn on cooling. +characteristic.heating-cooling-target.3 = Auto. Turn on heating or cooling to maintain temperature within the heating and cooling threshold of the target temperature. +characteristic.horizontal-tilt-current = Horizontal Tilt Current +characteristic.horizontal-tilt-target = Horizontal Tilt Target +characteristic.hue = Hue +characteristic.humidifier-dehumidifier-state-current = Humidifier Dehumidifier State Current +characteristic.humidifier-dehumidifier-state-current.0 = Inactive +characteristic.humidifier-dehumidifier-state-current.1 = Idle +characteristic.humidifier-dehumidifier-state-current.2 = Humidifying +characteristic.humidifier-dehumidifier-state-current.3 = Dehumidifying +characteristic.humidifier-dehumidifier-state-target = Humidifier Dehumidifier State Target +characteristic.humidifier-dehumidifier-state-target.0 = Humidifier or Dehumidifier +characteristic.humidifier-dehumidifier-state-target.1 = Humidifier +characteristic.humidifier-dehumidifier-state-target.2 = Dehumidifier +characteristic.identify = Identify +characteristic.image-mirror = Image Mirror +characteristic.image-rotation = Image Rotation +characteristic.image-rotation.0 = No rotation +characteristic.image-rotation.90 = Rotated 90 degrees to the right +characteristic.image-rotation.180 = Rotated 180 degrees to the right (flipped vertically) +characteristic.image-rotation.270 = Rotated 270 degrees to the right +characteristic.in-use = In Use +characteristic.input-event = Input Event +characteristic.is-configured = Is Configured +characteristic.leak-detected = Leak Detected +characteristic.light-level-current = Light Level Current +characteristic.lock-management-auto-secure-timeout = Lock Management Auto Secure Timeout +characteristic.lock-management-control-point = Lock Management Control Point +characteristic.lock-mechanism-current-state = Lock Mechanism Current State +characteristic.lock-mechanism-current-state.0 = Unsecured +characteristic.lock-mechanism-current-state.1 = Secured +characteristic.lock-mechanism-current-state.2 = Jammed +characteristic.lock-mechanism-current-state.3 = Unknown +characteristic.lock-mechanism-last-known-action = Lock Mechanism Last Known Action +characteristic.lock-mechanism-last-known-action.0 = Secured using physical movement, interior +characteristic.lock-mechanism-last-known-action.1 = Unsecured using physical movement, interior +characteristic.lock-mechanism-last-known-action.2 = Secured using physical movement, exterior +characteristic.lock-mechanism-last-known-action.3 = Unsecured using physical movement, exterior +characteristic.lock-mechanism-last-known-action.4 = Secured with keypad +characteristic.lock-mechanism-last-known-action.5 = Unsecured with keypad +characteristic.lock-mechanism-last-known-action.6 = Secured remotely +characteristic.lock-mechanism-last-known-action.7 = Unsecured remotely +characteristic.lock-mechanism-last-known-action.8 = Secured with Automatic Secure timeout +characteristic.lock-mechanism-target-state = Lock Mechanism Target State +characteristic.lock-physical-controls = Lock Physical Controls +characteristic.logs = Logs +characteristic.manufacturer = Manufacturer +characteristic.model = Model +characteristic.motion-detected = Motion Detected +characteristic.mute = Mute +characteristic.name = Name +characteristic.night-vision = Night Vision +characteristic.obstruction-detected = Obstruction Detected +characteristic.occupancy-detected = Occupancy Detected +characteristic.on = On +characteristic.outlet-in-use = Outlet In Use +characteristic.pairing-features = Pairing Features +characteristic.pairing-pair-setup = Pairing Pair Setup +characteristic.pairing-pair-verify = Pairing Pair Verify +characteristic.pairing-pairings = Pairing Pairings +characteristic.position-current = Position Current +characteristic.position-hold = Position Hold +characteristic.position-state = Position State +characteristic.position-state.0 = Going to the minimum value specified in metadata +characteristic.position-state.1 = Going to the maximum value specified in metadata +characteristic.position-state.2 = Stopped +characteristic.position-target = Position Target +characteristic.program-mode = Program Mode +characteristic.program-mode.0 = No Programs Scheduled +characteristic.program-mode.1 = Program Scheduled +characteristic.program-mode.2 = Program Scheduled, currently overriden to manual mode +characteristic.relative-humidity-current = Relative Humidity Current +characteristic.relative-humidity-dehumidifier-threshold = Relative Humidity Dehumidifier Threshold +characteristic.relative-humidity-humidifier-threshold = Relative Humidity Humidifier Threshold +characteristic.relative-humidity-target = Relative Humidity Target +characteristic.remaining-duration = Remaining Duration +characteristic.rotation-direction = Rotation Direction +characteristic.rotation-speed = Rotation Speed +characteristic.saturation = Saturation +characteristic.security-system-alarm-type = Security System Alarm Type +characteristic.security-system-state-current = Security System State Current +characteristic.security-system-state-current.0 = Stay Arm. The home is occupied and the residents are active. e.g. morning or evenings +characteristic.security-system-state-current.1 = Away Arm. The home is unoccupied +characteristic.security-system-state-current.2 = Night Arm. The home is occupied and the residents are sleeping +characteristic.security-system-state-current.3 = Disarmed +characteristic.security-system-state-current.4 = Alarm Triggered +characteristic.security-system-state-target = Security System State Target +characteristic.security-system-state-target.0 = Stay Arm. The home is occupied and the residents are active. e.g. morning or evenings +characteristic.security-system-state-target.1 = Away Arm. The home is unoccupied +characteristic.security-system-state-target.2 = Night Arm. The home is occupied and the residents are sleeping +characteristic.security-system-state-target.3 = Disarm +characteristic.selected-audio-stream-configuration = Selected Audio Stream Configuration +characteristic.selected-rtp-stream-configuration = Selected Rtp Stream Configuration +characteristic.serial-number = Serial Number +characteristic.service-label-index = Service Label Index +characteristic.service-label-namespace = Service Label Namespace +characteristic.set-duration = Set Duration +characteristic.setup-data-stream-transport = Setup Data Stream Transport +characteristic.setup-endpoints = Setup Endpoints +characteristic.siri-input-type = Siri Input Type +characteristic.slat-state-current = Slat State Current +characteristic.slat-state-current.0 = Fixed +characteristic.slat-state-current.1 = Jammed +characteristic.slat-state-current.2 = Swinging +characteristic.smoke-detected = Smoke Detected +characteristic.status-active = Status Active +characteristic.status-fault = Status Fault +characteristic.status-jammed = Status Jammed +characteristic.status-lo-batt = Status Lo Batt +characteristic.status-tampered = Status Tampered +characteristic.streaming-status = Streaming Status +characteristic.supported-audio-configuration = Supported Audio Configuration +characteristic.supported-data-stream-transport-configuration = Supported Data Stream Transport Configuration +characteristic.supported-rtp-configuration = Supported Rtp Configuration +characteristic.supported-target-configuration = Supported Target Configuration +characteristic.supported-video-stream-configuration = Supported Video Stream Configuration +characteristic.swing-mode = Swing Mode +characteristic.target-list = Target List +characteristic.temperature-cooling-threshold = Temperature Cooling Threshold +characteristic.temperature-current = Temperature Current +characteristic.temperature-heating-threshold = Temperature Heating Threshold +characteristic.temperature-target = Temperature Target +characteristic.temperature-units = Temperature Units +characteristic.tilt-current = Tilt Current +characteristic.tilt-target = Tilt Target +characteristic.type-slat = Type Slat +characteristic.valve-type = Valve Type +characteristic.version = Version +characteristic.vertical-tilt-current = Vertical Tilt Current +characteristic.vertical-tilt-target = Vertical Tilt Target +characteristic.volume = Volume +characteristic.water-level = Water Level +characteristic.zoom-digital = Zoom Digital +characteristic.zoom-optical = Zoom Optical +characteristic.unknown = Unknown Characteristic From cd7646d0ef44306d75ffcd66bac92a38537527ea Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 25 Oct 2025 12:02:21 +0100 Subject: [PATCH 093/177] restart polling after bridge disconnect and reconnect Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 1 + 1 file changed, 1 insertion(+) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 38af454415e5c..482240ce3edac 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -974,6 +974,7 @@ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { super.bridgeStatusChanged(bridgeStatusInfo); if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE && thing.getStatus() != ThingStatus.ONLINE) { updateStatus(ThingStatus.ONLINE); + channelsAndPropertiesLoaded(); } } } From 4b602abbd3dea728d73d7b5c2caaf25bd6dadbf8 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 25 Oct 2025 16:11:31 +0100 Subject: [PATCH 094/177] revert 3bc8ef1 Signed-off-by: Andrew Fiddian-Green --- .../internal/handler/HomekitAccessoryHandler.java | 10 ---------- .../internal/handler/HomekitBaseAccessoryHandler.java | 4 +--- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 482240ce3edac..931d5bea493fe 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -67,7 +67,6 @@ import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; -import org.openhab.core.thing.ThingStatusInfo; import org.openhab.core.thing.binding.builder.ChannelBuilder; import org.openhab.core.thing.binding.builder.ThingBuilder; import org.openhab.core.thing.type.ChannelGroupType; @@ -968,13 +967,4 @@ protected CharacteristicReadWriteClient getRwService() throws IllegalAccessExcep } return super.getRwService(); } - - @Override - public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { - super.bridgeStatusChanged(bridgeStatusInfo); - if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE && thing.getStatus() != ThingStatus.ONLINE) { - updateStatus(ThingStatus.ONLINE); - channelsAndPropertiesLoaded(); - } - } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 25a1df361fea1..15747c4f5da9e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -213,8 +213,6 @@ public void initialize() { * Updates the thing status accordingly. */ private synchronized void initializePairing() { - updateStatus(ThingStatus.UNKNOWN); - Object host = getConfig().get(CONFIG_HOST); if (host == null || !(host instanceof String hostString) || !HOST_PATTERN.matcher(hostString).matches()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, @@ -302,9 +300,9 @@ private synchronized void initializePairing() { | InvalidCipherTextException | IOException | InterruptedException | TimeoutException | ExecutionException e) { logger.warn("Pairing / verification failed for {}", thing.getUID(), e); + startConnectionTask(); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.pairing-verification-failed", "Pairing / verification failed", null)); - startConnectionTask(); } } From 6b8aa0a12390edf08d18115e1b1528bc9650ea7e Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 25 Oct 2025 16:58:07 +0100 Subject: [PATCH 095/177] various - restore bridgestatuschanged call back - filter event messages by aid Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 931d5bea493fe..acefd8fbb0ec1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -67,6 +67,7 @@ import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; import org.openhab.core.thing.binding.builder.ChannelBuilder; import org.openhab.core.thing.binding.builder.ThingBuilder; import org.openhab.core.thing.type.ChannelGroupType; @@ -901,6 +902,7 @@ public void onEvent(String json) { * @param json the JSON content containing characteristic values */ private void updateChannelsFromJson(String json) { + Integer aid = getAccessoryId(); ChannelUID hsbChannelUID = null; Service service = GSON.fromJson(json, Service.class); if (service != null && service.characteristics instanceof List characteristics) { @@ -908,13 +910,14 @@ private void updateChannelsFromJson(String json) { ChannelUID channelUID = channel.getUID(); if (channelUID.equals(lightModelClientHSBTypeChannel)) { for (Characteristic cxx : characteristics) { - if (lightModelRefresh(cxx)) { + if (Objects.equals(cxx.aid, aid) && lightModelRefresh(cxx)) { hsbChannelUID = channelUID; } } } else if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { for (Characteristic cxx : characteristics) { - if (iid.equals(String.valueOf(cxx.iid)) && cxx.value instanceof JsonElement element) { + if (Objects.equals(cxx.aid, aid) && iid.equals(String.valueOf(cxx.iid)) + && cxx.value instanceof JsonElement element) { State state = convertJsonToState(element, channel); switch (channel.getKind()) { case STATE -> updateState(channelUID, state); @@ -967,4 +970,13 @@ protected CharacteristicReadWriteClient getRwService() throws IllegalAccessExcep } return super.getRwService(); } + + @Override + public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + super.bridgeStatusChanged(bridgeStatusInfo); + if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE && thing.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + channelsAndPropertiesLoaded(); + } + } } From b638163b371fbb87a36299d0136b3610a0466c0f Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 25 Oct 2025 19:56:35 +0100 Subject: [PATCH 096/177] various - delete unnecessary properties - clean up logging Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 2 -- .../HomekitMdnsDiscoveryParticipant.java | 2 -- .../handler/HomekitAccessoryHandler.java | 23 +++++++++++-------- .../handler/HomekitBaseAccessoryHandler.java | 7 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index c4769aba4bed6..e79e2d1a8caa1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -53,8 +53,6 @@ public class HomekitBindingConstants { public static final String PROPERTY_ACCESSORY_UID = "accessoryUID"; public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; - public static final String PROPERTY_ACCESSORY_PAIRING_FEATURE = "pairFeature"; - public static final String PROPERTY_ACCESSORY_PAIRED_STATE = "pairStatus"; // channel properties public static final String PROPERTY_IID = "iid"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 700223bdb77c5..e70af111af46a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -107,8 +107,6 @@ public String getServiceType() { builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), host)) // .withProperty(CONFIG_HOST, host) // .withProperty(Thing.PROPERTY_MAC_ADDRESS, mac) // - .withProperty(PROPERTY_ACCESSORY_PAIRING_FEATURE, pairFeature.toString()) // - .withProperty(PROPERTY_ACCESSORY_PAIRED_STATE, pairStatus.toString()) // .withProperty(PROPERTY_ACCESSORY_CATEGORY, category.toString()) // .withProperty(PROPERTY_ACCESSORY_UID, new ThingUID(THING_TYPE_ACCESSORY, "1").toString()) // .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index acefd8fbb0ec1..9bf49b4ed8a33 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -191,7 +191,8 @@ private void channelsAndPropertiesLoaded() { } catch (IOException | TimeoutException e) { logger.debug("Communication error subscribing to evented channels"); } catch (IllegalAccessException | ExecutionException e) { - logger.warn("Unexpected error subscribing to evented channels", e); + logger.warn("Unexpected error '{}' subscribing to evented channels", e.getMessage()); + logger.debug("Stack trace", e); } catch (InterruptedException e) { // shutting down } } @@ -232,7 +233,7 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { QuantityType temp = quantity.toUnit(channelUnit); object = temp != null ? temp : quantity; } catch (MeasurementParseException e) { - logger.warn("Unexpected unit {} for channel {}", channelUnit, channel.getUID()); + logger.warn("Unexpected unit '{}' for channel '{}'", channelUnit, channel.getUID()); } } } @@ -247,7 +248,7 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { object = Integer.parseInt(val); break; } catch (NumberFormatException e) { - logger.warn("Unexpected state option value {} for channel {}", val, channel.getUID(), e); + logger.warn("Unexpected state option value '{}' for channel '{}'", val, channel.getUID()); } } } @@ -402,7 +403,7 @@ private void createChannels() { ChannelGroupType channelGroupType = channelGroupTypeRegistry .getChannelGroupType(groupDef.getTypeUID()); if (channelGroupType == null) { - logger.warn("Fatal Error: ChannelGroupType {} is not registered", groupDef.getTypeUID()); + logger.warn("Fatal Error: ChannelGroupType '{}' is not registered", groupDef.getTypeUID()); } else { logger.trace("++ChannelGroupType UID:{}, label:{}, category:{}, description:{}", channelGroupType.getUID(), channelGroupType.getLabel(), channelGroupType.getCategory(), @@ -428,7 +429,7 @@ private void createChannels() { ChannelType channelType = channelTypeRegistry .getChannelType(chanDef.getChannelTypeUID()); if (channelType == null) { - logger.warn("Fatal Error: ChannelType {} is not registered", + logger.warn("Fatal Error: ChannelType '{}' is not registered", chanDef.getChannelTypeUID()); } else { logger.trace( @@ -492,7 +493,7 @@ private void createChannels() { public void handleCommand(ChannelUID channelUID, Command command) { Channel channel = thing.getChannel(channelUID); if (channel == null) { - logger.warn("Received command for unknown channel '{}'", channelUID); + logger.warn("Received command '{}' for unknown channel '{}'", command, channelUID); return; } if (command == RefreshType.REFRESH) { @@ -541,7 +542,8 @@ public void handleCommand(ChannelUID channelUID, Command command) { } catch (IOException | TimeoutException e) { logger.debug("Communication error sending command '{}' to '{}' '{}'", command, channelUID, e.getMessage()); } catch (IllegalAccessException | ExecutionException e) { - logger.warn("Unexpected error sending command '{}' to '{}'", command, channelUID, e); + logger.warn("Unexpected error '{}' sending command '{}' to '{}'", e.getMessage(), command, channelUID); + logger.debug("Stack trace", e); } catch (InterruptedException e) { // shutting down } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, @@ -597,7 +599,8 @@ private void refresh() { logger.debug("Communication error polling accessory '{}', restarting", e.getMessage()); startConnectionTask(); } catch (IllegalAccessException | ExecutionException e) { - logger.warn("Unexpected error polling accessory", e); + logger.warn("Unexpected error '{}' polling accessory", e.getMessage()); + logger.debug("Stack trace", e); } catch (InterruptedException e) { // shutting down } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, @@ -616,12 +619,12 @@ private void cancelRefreshTask() { ChannelTypeUID uid = channel.getChannelTypeUID(); ChannelType ct = channelTypeRegistry.getChannelType(uid); if (ct == null) { - logger.warn("Channel {} is missing a channel type", uid); + logger.warn("Channel '{}' is missing a channel type", uid); return null; } StateDescription st = ct.getState(); if (st == null) { - logger.warn("Channel {} of type {} is missing a state description", uid, ct.getUID()); + logger.warn("Channel '{}' of type '{}' is missing a state description", uid, ct.getUID()); return null; } return st; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 15747c4f5da9e..65533cb627dc9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -177,12 +177,12 @@ public void handleRemoval() { if (mac != null) { keyStore.setAccessoryKey(mac, null); } else { - logger.warn("Could not clear key for {} due to missing mac address", thing.getUID()); + logger.warn("Could not clear key for '{}' due to missing mac address", thing.getUID()); } updateStatus(ThingStatus.REMOVED); } catch (IOException | InterruptedException | TimeoutException | ExecutionException | IllegalAccessException e) { - logger.warn("Failed to remove pairing for {}", thing.getUID()); + logger.warn("Failed to remove pairing for '{}'", thing.getUID()); } }); } @@ -299,7 +299,8 @@ private synchronized void initializePairing() { } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException | InvalidCipherTextException | IOException | InterruptedException | TimeoutException | ExecutionException e) { - logger.warn("Pairing / verification failed for {}", thing.getUID(), e); + logger.warn("Pairing / verification failed '{}' for {}", e.getMessage(), thing.getUID()); + logger.debug("Stack trace", e); startConnectionTask(); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.pairing-verification-failed", "Pairing / verification failed", null)); From f52fa736000575529577a1609b7d659b42b2b8d9 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 25 Oct 2025 23:01:11 +0100 Subject: [PATCH 097/177] restart also after command error Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 8 +++++--- .../internal/handler/HomekitBaseAccessoryHandler.java | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 9bf49b4ed8a33..c42de80834e6c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -189,7 +189,7 @@ private void channelsAndPropertiesLoaded() { subscribeEvents(); logger.debug("Eventing enabled for {} channels", eventedChannels.size()); } catch (IOException | TimeoutException e) { - logger.debug("Communication error subscribing to evented channels"); + logger.debug("Communication error '{}' subscribing to evented channels", e.getMessage()); } catch (IllegalAccessException | ExecutionException e) { logger.warn("Unexpected error '{}' subscribing to evented channels", e.getMessage()); logger.debug("Stack trace", e); @@ -540,7 +540,9 @@ public void handleCommand(ChannelUID channelUID, Command command) { } return; } catch (IOException | TimeoutException e) { - logger.debug("Communication error sending command '{}' to '{}' '{}'", command, channelUID, e.getMessage()); + logger.debug("Communication error '{}' sending command '{}' to '{}', restarting", e.getMessage(), command, + channelUID); + startConnectionTask(); } catch (IllegalAccessException | ExecutionException e) { logger.warn("Unexpected error '{}' sending command '{}' to '{}'", e.getMessage(), command, channelUID); logger.debug("Stack trace", e); @@ -596,7 +598,7 @@ private void refresh() { updateChannelsFromJson(json); return; } catch (IOException | TimeoutException e) { - logger.debug("Communication error polling accessory '{}', restarting", e.getMessage()); + logger.debug("Communication error '{}' polling accessory, restarting", e.getMessage()); startConnectionTask(); } catch (IllegalAccessException | ExecutionException e) { logger.warn("Unexpected error '{}' polling accessory", e.getMessage()); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 65533cb627dc9..63be759950f05 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -330,7 +330,8 @@ private String normalizePairingCode(String input) throws IllegalArgumentExceptio * If this handler is a child of a bridge, it delegates to the bridge handler. */ protected void startConnectionTask() { - if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + if (isChildAccessory && getBridge() instanceof Bridge bridge + && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { bridgeHandler.startConnectionTask(); } else { ScheduledFuture task = connectionTask; From aa17e4a7aadd37f81f42ac0f385e71a47982c7dd Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 26 Oct 2025 12:27:47 +0000 Subject: [PATCH 098/177] various - refactoring of (re-) connection process - code style improvements Signed-off-by: Andrew Fiddian-Green --- .../HomekitMdnsDiscoveryParticipant.java | 30 +----- .../handler/HomekitAccessoryHandler.java | 27 ++---- .../handler/HomekitBaseAccessoryHandler.java | 92 +++++++++---------- .../handler/HomekitBridgeHandler.java | 3 +- .../internal/persistence/HomekitKeyStore.java | 6 +- .../internal/transport/IpTransport.java | 12 +-- 6 files changed, 64 insertions(+), 106 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index e70af111af46a..b5ba274ea1e9a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -24,8 +24,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.AccessoryCategory; -import org.openhab.binding.homekit.internal.enums.AccessoryPairingFeature; -import org.openhab.binding.homekit.internal.enums.AccessoryPairingStatus; import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; @@ -67,8 +65,7 @@ public String getServiceType() { @Override public @Nullable DiscoveryResult createResult(ServiceInfo service) { - ThingUID uid = getThingUID(service); - if (uid != null) { + if (getThingUID(service) instanceof ThingUID uid) { Map properties = getProperties(service); String mac = properties.get("id"); // MAC address @@ -86,22 +83,6 @@ public String getServiceType() { category = null; } - AccessoryPairingFeature pairFeature; - try { - String ff = properties.getOrDefault("ff", ""); // accessory feature flag - pairFeature = AccessoryPairingFeature.from(Integer.parseInt(ff)); - } catch (IllegalArgumentException e) { - pairFeature = AccessoryPairingFeature.NO; - } - - AccessoryPairingStatus pairStatus; - try { - String sf = properties.getOrDefault("sf", ""); // accessory status flag - pairStatus = AccessoryPairingStatus.from(Integer.parseInt(sf)); - } catch (IllegalArgumentException e) { - pairStatus = AccessoryPairingStatus.UNPAIRED; - } - if (host != null && mac != null && category != null) { DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), host)) // @@ -111,16 +92,13 @@ public String getServiceType() { .withProperty(PROPERTY_ACCESSORY_UID, new ThingUID(THING_TYPE_ACCESSORY, "1").toString()) // .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); - String model = properties.get("md"); - if (model != null) { + if (properties.get("md") instanceof String model) { builder.withProperty(Thing.PROPERTY_MODEL_ID, model); } - String serial = properties.get("s#"); - if (serial != null) { + if (properties.get("s#") instanceof String serial) { builder.withProperty(Thing.PROPERTY_SERIAL_NUMBER, serial); } - String protocolVersion = properties.get("pv"); - if (protocolVersion != null) { + if (properties.get("pv") instanceof String protocolVersion) { builder.withProperty(PROPERTY_PROTOCOL_VERSION, protocolVersion); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index c42de80834e6c..221984cba0ba0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -267,8 +267,7 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { } // comply with characteristic's data format - String format = channel.getProperties().get(PROPERTY_FORMAT); - if (format != null) { + if (channel.getProperties().get(PROPERTY_FORMAT) instanceof String format) { object = switch (DataFormatType.from(format)) { case UINT8, UINT16, UINT32, UINT64, INT -> Integer.valueOf(number.intValue()); case FLOAT -> Float.valueOf(number.floatValue()); @@ -419,8 +418,7 @@ private void createChannels() { if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(chanDef.getChannelTypeUID())) { // this is a property, not a channel String name = chanDef.getId(); - String value = chanDef.getLabel(); - if (value != null) { + if (chanDef.getLabel() instanceof String value) { properties.put(name, value); logger.trace("++++Property '{}:{}'", name, value); } @@ -508,8 +506,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { } } else if (channelUID.equals(lightModelClientHSBTypeChannel)) { lightModelHandleCommand(command, getRwService()); - LightModel lightModel = this.lightModel; - if (lightModel != null) { + if (lightModel instanceof LightModel lightModel) { lightModelLinks.forEach(link -> { switch (link.cxxType) { case HUE -> { @@ -538,11 +535,11 @@ public void handleCommand(ChannelUID channelUID, Command command) { } else { writeChannel(channel, command, getRwService()); } - return; + return; // success } catch (IOException | TimeoutException e) { logger.debug("Communication error '{}' sending command '{}' to '{}', restarting", e.getMessage(), command, channelUID); - startConnectionTask(); + scheduleConnectionAttempt(); } catch (IllegalAccessException | ExecutionException e) { logger.warn("Unexpected error '{}' sending command '{}' to '{}'", e.getMessage(), command, channelUID); logger.debug("Stack trace", e); @@ -559,11 +556,7 @@ public void initialize() { @Override public void handleRemoval() { - ScheduledFuture task = refreshTask; - if (task != null) { - task.cancel(true); - } - refreshTask = null; + cancelRefreshTask(); super.handleRemoval(); } @@ -585,8 +578,7 @@ private void refresh() { Integer aid = getAccessoryId(); List queries = new ArrayList<>(); thing.getChannels().stream().forEach(c -> { - String iid = c.getProperties().get(PROPERTY_IID); - if (iid != null) { + if (c.getProperties().get(PROPERTY_IID) instanceof String iid) { queries.add("%s.%s".formatted(aid, iid)); } }); @@ -599,7 +591,7 @@ private void refresh() { return; } catch (IOException | TimeoutException e) { logger.debug("Communication error '{}' polling accessory, restarting", e.getMessage()); - startConnectionTask(); + scheduleConnectionAttempt(); } catch (IllegalAccessException | ExecutionException e) { logger.warn("Unexpected error '{}' polling accessory", e.getMessage()); logger.debug("Stack trace", e); @@ -610,8 +602,7 @@ private void refresh() { } private void cancelRefreshTask() { - ScheduledFuture task = refreshTask; - if (task != null) { + if (refreshTask instanceof ScheduledFuture task) { task.cancel(true); } refreshTask = null; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 63be759950f05..745034563a3ce 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -71,8 +71,8 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected static final Gson GSON = new Gson(); - private static final int MIN_CONNECTION_DELAY_SECONDS = 2; - private static final int MAX_CONNECTION_DELAY_SECONDS = 600; + private static final int MIN_CONNECTION_ATTEMPT_DELAY = 2; + private static final int MAX_CONNECTION_ATTEMPT_DELAY = 600; private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); @@ -83,10 +83,11 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected final Bundle bundle; protected boolean isChildAccessory = false; + private boolean isConfigured = false; - private int connectionDelaySeconds = MIN_CONNECTION_DELAY_SECONDS; + private int connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY; - private @Nullable ScheduledFuture connectionTask; + private @Nullable ScheduledFuture connectionAttemptTask; private @Nullable CharacteristicReadWriteClient rwService; private @Nullable IpTransport ipTransport; @@ -105,10 +106,14 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { unsubscribeEvents(); - cancelConnectionTask(); - if (!isChildAccessory && ipTransport instanceof IpTransport ipTransport) { - ipTransport.close(); + if (connectionAttemptTask instanceof ScheduledFuture task) { + task.cancel(true); } + if (ipTransport instanceof IpTransport transport) { + transport.close(); + } + connectionAttemptTask = null; + ipTransport = null; super.dispose(); } @@ -152,8 +157,7 @@ private void fetchAccessories() { * @return the accessory ID, or null if it cannot be determined */ protected @Nullable Integer getAccessoryId() { - String accessoryUid = thing.getProperties().get(PROPERTY_ACCESSORY_UID); - if (accessoryUid != null) { + if (thing.getProperties().get(PROPERTY_ACCESSORY_UID) instanceof String accessoryUid) { try { return Integer.parseInt(new ThingUID(accessoryUid).getId()); } catch (NumberFormatException e) { @@ -173,8 +177,7 @@ public void handleRemoval() { try { PairRemoveClient service = new PairRemoveClient(getIpTransport(), keyStore.getControllerUUID()); service.remove(); - String mac = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); - if (mac != null) { + if (getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS) instanceof String mac) { keyStore.setAccessoryKey(mac, null); } else { logger.warn("Could not clear key for '{}' due to missing mac address", thing.getUID()); @@ -204,7 +207,7 @@ public void initialize() { } else { // standalone accessory or bridge accessory, so do pairing and session setup here isChildAccessory = false; - startConnectionTask(); + scheduleConnectionAttempt(); } } @@ -213,8 +216,9 @@ public void initialize() { * Updates the thing status accordingly. */ private synchronized void initializePairing() { + isConfigured = false; Object host = getConfig().get(CONFIG_HOST); - if (host == null || !(host instanceof String hostString) || !HOST_PATTERN.matcher(hostString).matches()) { + if (host == null || !(host instanceof String hostName) || !HOST_PATTERN.matcher(hostName).matches()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.invalid-host", "Invalid host", null)); return; @@ -243,28 +247,29 @@ private synchronized void initializePairing() { i18nProvider.getText(bundle, "error.missing-mac-address", "Missing MAC address", null)); return; } + isConfigured = true; // create new transport try { - ipTransport = new IpTransport(hostString); + ipTransport = new IpTransport(hostName); } catch (IOException e) { - logger.debug("Failed to create transport", e); + logger.warn("Error '{}' creating transport", e.getMessage()); + logger.debug("Stack trace", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); return; } - if (keyStore.getAccessoryKey(macAddress) != null) { + if (keyStore.getAccessoryKey(macAddress) instanceof Ed25519PublicKeyParameters accessoryKey) { try { logger.debug("Starting Pair-Verify with existing key for {}", thing.getUID()); PairVerifyClient client = new PairVerifyClient(getIpTransport(), keyStore.getControllerUUID(), - keyStore.getControllerKey(), Objects.requireNonNull(keyStore.getAccessoryKey(macAddress))); + keyStore.getControllerKey(), accessoryKey); getIpTransport().setSessionKeys(client.verify()); rwService = new CharacteristicReadWriteClient(getIpTransport()); logger.debug("Restored pairing was verified for {}", thing.getUID()); - cancelConnectionTask(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); return; // pairing restore succeeded => exit @@ -272,7 +277,7 @@ private synchronized void initializePairing() { | InvalidCipherTextException | IOException | InterruptedException | TimeoutException | ExecutionException e) { logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); - // pairing restore failed => continue to create new pairing + // pairing restore failed => continue to try to create a new pairing } } @@ -293,7 +298,6 @@ private synchronized void initializePairing() { keyStore.setAccessoryKey(macAddress, accessoryKey); logger.debug("Pairing and verification completed for {}", thing.getUID()); - cancelConnectionTask(); fetchAccessories(); updateStatus(ThingStatus.ONLINE); } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException @@ -301,7 +305,6 @@ private synchronized void initializePairing() { | ExecutionException e) { logger.warn("Pairing / verification failed '{}' for {}", e.getMessage(), thing.getUID()); logger.debug("Stack trace", e); - startConnectionTask(); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.pairing-verification-failed", "Pairing / verification failed", null)); } @@ -325,40 +328,33 @@ private String normalizePairingCode(String input) throws IllegalArgumentExceptio } /** - * Starts a task to attempt (re) connection. - * The delay increases exponentially up to a maximum of 10 minutes. - * If this handler is a child of a bridge, it delegates to the bridge handler. + * Schedules a connection attempt. */ - protected void startConnectionTask() { - if (isChildAccessory && getBridge() instanceof Bridge bridge - && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { - bridgeHandler.startConnectionTask(); - } else { - ScheduledFuture task = connectionTask; - if (task != null) { - task.cancel(false); - } - IpTransport ipTransport = this.ipTransport; - if (ipTransport != null) { // clean up prior transport if any - this.ipTransport = null; - ipTransport.close(); - } - connectionTask = scheduler.schedule(this::initializePairing, connectionDelaySeconds, TimeUnit.SECONDS); - connectionDelaySeconds = Math.min(connectionDelaySeconds * connectionDelaySeconds, - MAX_CONNECTION_DELAY_SECONDS); + protected void scheduleConnectionAttempt() { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + bridgeHandler.scheduleConnectionAttempt(); + } else if (connectionAttemptTask == null) { + connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, TimeUnit.SECONDS); } } /** - * Stops the (re) connect task if it is running. Resets the retry exponent. + * The (re) connection task. Cleans up any prior transport, then attempts to initialize pairing. + * If successful, resets the retry delay. If not, reschedules itself with an exponentially increased delay. */ - private void cancelConnectionTask() { - ScheduledFuture task = connectionTask; - if (task != null) { - task.cancel(false); + private synchronized void attemptConnect() { + if (ipTransport instanceof IpTransport transport) { // close prior transport (if any) + transport.close(); + ipTransport = null; + } + initializePairing(); + if (isConfigured && thing.getStatus() != ThingStatus.ONLINE) { // config ok but connection failed => try again + connectionAttemptDelay = Math.min(MAX_CONNECTION_ATTEMPT_DELAY, (int) Math.pow(connectionAttemptDelay, 2)); + connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, TimeUnit.SECONDS); + } else { // succeeded => reset delay + connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY; + connectionAttemptTask = null; } - connectionTask = null; - connectionDelaySeconds = MIN_CONNECTION_DELAY_SECONDS; } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index cceceb0f84f4a..0361e37362302 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -133,8 +133,7 @@ private void createProperties() { typeProvider, i18nProvider, bundle); if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { String name = channelDef.getId(); - String value = channelDef.getLabel(); - if (value != null) { + if (channelDef.getLabel() instanceof String value) { thing.setProperty(name, value); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java index 619b8dfa8ae90..94d1f3642f19e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java @@ -67,8 +67,7 @@ public void setAccessoryKey(String keyId, @Nullable Ed25519PublicKeyParameters k } public byte[] getControllerUUID() { - String controllerUUID = storage.get(CONTROLLER_UUID); - if (controllerUUID != null) { + if (storage.get(CONTROLLER_UUID) instanceof String controllerUUID) { return decode(controllerUUID); } byte[] newControllerUUID = UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8); @@ -77,8 +76,7 @@ public byte[] getControllerUUID() { } public Ed25519PrivateKeyParameters getControllerKey() { - String controllerKey = storage.get(CONTROLLER_KEY_ID); - if (controllerKey != null) { + if (storage.get(CONTROLLER_KEY_ID) instanceof String controllerKey) { return new Ed25519PrivateKeyParameters(decode(controllerKey), 0); } Ed25519PrivateKeyParameters newControllerKey = new Ed25519PrivateKeyParameters(new SecureRandom()); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index aeb10abc8a8bb..9ae1bf7c703a3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -127,8 +127,7 @@ private synchronized byte[] execute(String method, String endpoint, String conte } byte[][] response; // 0 = headers, 1 = content, 2 = raw trace (if enabled) - SecureSession secureSession = this.secureSession; - if (secureSession != null) { + if (secureSession instanceof SecureSession secureSession) { // before we write request, create CompletableFuture to read response (with a timeout) CompletableFuture readFuture = new CompletableFuture<>(); this.readFuture = readFuture; @@ -256,8 +255,7 @@ public void close() { eventListeners.clear(); try { socket.close(); - Thread thread = readThread; - if (thread != null) { + if (readThread instanceof Thread thread) { thread.interrupt(); thread.join(); } @@ -272,8 +270,7 @@ public void close() { * @param response the received response as a 3D byte array */ private void handleResponse(byte[][] response) { - CompletableFuture future = readFuture; - if (future != null) { + if (readFuture instanceof CompletableFuture future) { readFuture = null; future.complete(response); } @@ -309,8 +306,7 @@ private void readTask() { } } while (!Thread.currentThread().isInterrupted()); - CompletableFuture future = readFuture; - if (future != null) { + if (readFuture instanceof CompletableFuture future) { readFuture = null; future.completeExceptionally(cause != null ? cause : new InterruptedException("Listener interrupted")); } From fb1c59b5066a186e2d703688ccbd0689f5e9f466 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 26 Oct 2025 16:29:37 +0000 Subject: [PATCH 099/177] documentation Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 47 ++++++++++++------- bundles/org.openhab.io.homekit/README.md | 3 ++ 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 632c862f4f411..7966b848fe85c 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -1,6 +1,7 @@ # HomeKit Binding -This binding allows pairing with HomeKit accessory devices and importing their services as channel groups and their respective service- characteristics as channels. +This binding allows pairing with HomeKit accessories and **imports** their services as channel groups and their respective service- characteristics as channels. +Do not confuse this with the other HomeKit **integration** (https://www.openhab.org/addons/integrations/homekit/) which **exports** openHAB Items to a HomeKit controller. ## Supported Things @@ -24,8 +25,7 @@ The `bridge` and stand-alone `accessory` Things need to be paired with their res This requires entering the HomeKit pairing code as a configuration parameter in the binding. Note that HomeKit accessories can only be paired with one controller, so if it is already paired with something else, you will need to remove that pairing first. -Things are mostly automatically configured when they are discovered. -However the following are the . +The following table shows the thing configuration parameters. | Name | Type | Description | Default | Required | Advanced | |-------------------|---------|---------------------------------------------------|---------|-----------|-----------| @@ -33,35 +33,50 @@ However the following are the . | `pairingCode` | text | Code used for pairing with the HomeKit accessory. | N/A | see below | see below | | `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | -Things of type `bridge` and stand-alone `accessory` Things require both a `host` and a `pairingCode`. +Things of type `bridge` and `accessory` require both a `host` and a `pairingCode`. + The `host` is set by the mDNS auto- discovery process. -And the `pairingCode` must be entered manually. +It must match the format `123.123.123.123:4567` representing its IP v4 address and port. + +The `pairingCode` must be entered manually. +It must match the format `XXX-XX-XXX` or `XXXX-XXXX` or `XXXXXXXX` where `X` is a single digit. Child `accessory` Things do not require neither a `host` nor a `pairingCode`. -Therefore these parameters are preset to `n/a`. +Therefore child things have these parameters preset to `n/a`. ## Channels -Channels will be auto-created depending on the services and characteristics published by the HomeKit accessory. +Channels are auto-created depending on the services and characteristics published by the HomeKit accessory. + +As a general rule openHAB has one channel for each HomeKit charactersitic. +Some HomeKit accessories have separate charactersitics for 'target' and 'current' states. +The two charactersitics may have different values (e.g. for a thermostat). +In all such cases the thing has a channel for each characteristic so that both values can be accessed. + +Some HomeKit characteristics represent fixed information e.g. model number, firmware version, etc. +Such values appear in openHAB as properties of the respectinve thing. + +### Special Extra HSBType Channel + +In openHAB the norm is that lighting objects shall be represented by a single `HSBType` channel which manages hue, saturation, brightness, and on-off states. +By contrast a HomeKit accessory has four separate characteristics for hue, saturation, brightness, and on-off. +So the thing creates one additional `HSBType` channel that amalgamates hue, saturation, brightness, and on-off characteristics, according to the openHAB norm. + +## File Based Configuration ### Thing Configuration -Things are mostly automatically configured when they are discovered. -So for this reason it is extremely difficult to create Things via a '.things' file. +Things are automatically configured when they are discovered. +So for this reason it is extremely difficult to create Things via a '.things' file, and is therefore not recommeneded. ### Item Configuration ```java -Example item configuration goes here. +Number:Temperature Color_Temperature "Color Temperature [%.1f mired]" [ColorTemperature, Setpoint] { channel="homekit:accessory:297b703df234:lightbulb#color-temperature", unit="mired" } ``` ### Sitemap Configuration ```perl -Optional Sitemap configuration goes here. -Remove this section, if not needed. +Slider item=Color_Temperature ``` - -## Any custom content here! - -_Feel free to add additional sections for whatever you think should also be mentioned about your binding!_ diff --git a/bundles/org.openhab.io.homekit/README.md b/bundles/org.openhab.io.homekit/README.md index 1201ef0699227..4e1a58b3aace5 100644 --- a/bundles/org.openhab.io.homekit/README.md +++ b/bundles/org.openhab.io.homekit/README.md @@ -2,6 +2,9 @@ This is an add-on that exposes your openHAB system as a bridge over the HomeKit protocol. +This integration **exports** openHAB Items to a HomeKit controller. +Do not confuse this with the other HomeKit **binding** (https://www.openhab.org/addons/bindings/homekit/) which **imports** data from HomeKit accessories. + Using this add-on, you will be able to control your openHAB system using Apple's Siri, or any of a number of HomeKit enabled iOS apps. In order to do so, you will need to make some configuration changes. HomeKit organizes your home into "accessories" that are made up of a number of "characteristics". From 698f46866748971daf4d3682b923880763e18f17 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 27 Oct 2025 08:17:14 +0000 Subject: [PATCH 100/177] Update bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Andrew Fiddian-Green --- .../src/main/resources/OH-INF/i18n/homekit.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 3c1f49101ab50..86dcaae216cdc 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -62,8 +62,8 @@ characteristic.carbon-monoxide-detected = Carbon Monoxide Detected characteristic.carbon-monoxide-level = Carbon Monoxide Level characteristic.carbon-monoxide-peak-level = Carbon Monoxide Peak Level characteristic.charging-state = Charging State -characteristic.charging-state.0 Not Charging -characteristic.charging-state.1 Charging +characteristic.charging-state.0 = Not Charging +characteristic.charging-state.1 = Charging characteristic.charging-state.2 = Not Chargeable characteristic.color-temperature = Color Temperature characteristic.contact-state = Contact State From cfe9bcf77d1233d89b5865f19bb0a9322e8757b0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 27 Oct 2025 18:08:10 +0000 Subject: [PATCH 101/177] revert prior Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.io.homekit/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/bundles/org.openhab.io.homekit/README.md b/bundles/org.openhab.io.homekit/README.md index 4e1a58b3aace5..1201ef0699227 100644 --- a/bundles/org.openhab.io.homekit/README.md +++ b/bundles/org.openhab.io.homekit/README.md @@ -2,9 +2,6 @@ This is an add-on that exposes your openHAB system as a bridge over the HomeKit protocol. -This integration **exports** openHAB Items to a HomeKit controller. -Do not confuse this with the other HomeKit **binding** (https://www.openhab.org/addons/bindings/homekit/) which **imports** data from HomeKit accessories. - Using this add-on, you will be able to control your openHAB system using Apple's Siri, or any of a number of HomeKit enabled iOS apps. In order to do so, you will need to make some configuration changes. HomeKit organizes your home into "accessories" that are made up of a number of "characteristics". From 2bfab6fa2b2e80006d1b1f006934944f2e56c12f Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 28 Oct 2025 16:13:04 +0000 Subject: [PATCH 102/177] fix http split frame issue Signed-off-by: Andrew Fiddian-Green --- .../internal/transport/IpTransport.java | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 9ae1bf7c703a3..17311947179f7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -32,6 +32,7 @@ import java.util.concurrent.TimeoutException; import org.bouncycastle.crypto.InvalidCipherTextException; +import org.bouncycastle.util.Arrays; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; @@ -214,26 +215,21 @@ private boolean contentIsEmpty(String method) { * @throws IOException if an I/O error occurs or if the response is invalid. */ private byte[][] readPlainResponse(InputStream in, boolean trace) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); + HttpPayloadParser httpParser = new HttpPayloadParser(); + ByteArrayOutputStream raw = trace ? new ByteArrayOutputStream() : null; byte[] buf = new byte[4096]; - int read; - while ((read = in.read(buf)) != -1) { - out.write(buf, 0, read); - if (read < buf.length) { - break; // crude EOF detection + do { + int read = in.read(buf); + if (read > 0) { + byte[] frame = Arrays.copyOf(buf, read); + if (raw != null) { + raw.write(frame); + } + httpParser.accept(frame); } - } - byte[] data = out.toByteArray(); - int headersEnd = HttpPayloadParser.indexOfDoubleCRLF(data, 0); - if (headersEnd < 0) { - throw new IOException("Invalid HTTP response"); - } - headersEnd += 4; // move past the \r\n\r\n - byte[] headers = new byte[headersEnd]; - byte[] content = new byte[data.length - headersEnd]; - System.arraycopy(data, 0, headers, 0, headers.length); - System.arraycopy(data, headersEnd, content, 0, content.length); - return new byte[][] { headers, content, trace ? data : new byte[0] }; + } while (!httpParser.isComplete()); + return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), + raw != null ? raw.toByteArray() : new byte[0] }; } /** From 61aba1f1c0213ab7c3d8cd9843c1d5dab0570115 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 28 Oct 2025 18:02:58 +0000 Subject: [PATCH 103/177] various - state option label and value were reversed - added command response status code logging Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/dto/Characteristic.java | 8 ++- .../homekit/internal/enums/StatusCode.java | 52 +++++++++++++++++++ .../handler/HomekitAccessoryHandler.java | 22 ++++++-- .../CharacteristicReadWriteClient.java | 7 ++- .../TestChannelCreationForVeluxJson.java | 3 +- 5 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/StatusCode.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index a4ccb20f66608..0cd78f04fe81a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -25,6 +25,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.DataFormatType; +import org.openhab.binding.homekit.internal.enums.StatusCode; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.library.CoreItemFactory; @@ -70,6 +71,7 @@ public class Characteristic { public @NonNullByDefault({}) Integer aid; // e.g. 10 public @NonNullByDefault({}) @SerializedName("valid-values") List validValues; public @NonNullByDefault({}) @SerializedName("valid-values-range") List validValuesRange; + public @NonNullByDefault({}) Integer status; /** * Builds a ChannelType and a ChannelDefinition based on the characteristic properties. @@ -869,7 +871,7 @@ public class Characteristic { String defaultLabel = "%s #%s".formatted(characteristicType.toString(), o); String optionLabel = i18nProvider.getText(bundle, translationKey + o, defaultLabel, null); optionLabel = optionLabel == null || optionLabel.isBlank() ? defaultLabel : optionLabel; - return new StateOption(optionLabel, o); + return new StateOption(o, optionLabel); }).toList()); } } @@ -949,4 +951,8 @@ public static CharacteristicType getCharacteristicType(String type) { public String toString() { return getCharacteristicType() instanceof CharacteristicType ct ? ct.getType() : "Unknown"; } + + public @Nullable StatusCode getStatusCode() { + return status instanceof Integer code ? StatusCode.from(code) : null; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/StatusCode.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/StatusCode.java new file mode 100644 index 0000000000000..a856184e2bb97 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/StatusCode.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Enumeration of HomeKit status codes. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum StatusCode { + SUCCESS(0), + INSUFFICIENT_PRIVILEDGES(-70401), // Request denied due to insufficient privileges. + UNABLE_TO_PERFORM_OPERATION(-70402), // Unable to perform operation with requested service or characteristic + RESOURCE_BUSY(-70403), // Resource is busy, try again. + READ_ONLY(-70404), // Cannot write to read only characteristic. + WRITE_ONLY(-70405), // Cannot read from a write only characteristic. + NOTIFICATION_NOT_SUPPORTED(-70406), // Notification is not supported for characteristic. + OUT_OF_RESOURCES(-70407), // Out of resources to process request. + OPERATION_TIMEOUT(-70408), // Operation timed out. + RESOURCE_DOES_NOT_EXIST(-70409), // Resource does not exist. + INVALID_WRITE_VALUE(-70410), // Accessory received an invalid value in a write request. + INSUFFICIENT_AUTHORIZATION(-70411);// Insufficient Authorization + + private final int code; + + StatusCode(int id) { + this.code = id; + } + + public @Nullable static StatusCode from(int code) { + for (StatusCode value : values()) { + if (value.code == code) { + return value; + } + } + return null; + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 221984cba0ba0..b11c9f5cb058c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -40,6 +40,7 @@ import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.DataFormatType; +import org.openhab.binding.homekit.internal.enums.StatusCode; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; @@ -241,14 +242,16 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { // convert StringType enums to integers if (object instanceof StringType stringType) { if (stateDescription != null && stateDescription.getOptions() instanceof List stateOptions) { + String commandString = stringType.toString(); for (StateOption option : stateOptions) { - if (stringType.toString().equals(option.getLabel())) { - String val = option.getValue(); + String optionValue = option.getValue(); + if (commandString.equalsIgnoreCase(optionValue)) { try { - object = Integer.parseInt(val); + object = Integer.parseInt(optionValue); break; } catch (NumberFormatException e) { - logger.warn("Unexpected state option value '{}' for channel '{}'", val, channel.getUID()); + logger.warn("Unexpected state option value '{}' for channel '{}'", optionValue, + channel.getUID()); } } } @@ -879,7 +882,16 @@ private synchronized void writeChannel(Channel channel, Command command, Charact characteristic.iid = Integer.parseInt(iid); characteristic.value = commandToJsonPrimitive(command, channel); service.characteristics = List.of(characteristic); - writer.writeCharacteristic(GSON.toJson(service)); + String response = writer.writeCharacteristic(GSON.toJson(service)); + Service serviceResponse = GSON.fromJson(response, Service.class); // check for errors + if (serviceResponse != null + && serviceResponse.characteristics instanceof List characteristics) { + for (Characteristic cxx : characteristics) { + if (cxx.getStatusCode() instanceof StatusCode code && code != StatusCode.SUCCESS) { + logger.warn("Error writing to channel '{}': {}", channel.getUID(), code); + } + } + } } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java index 623d8b2ae2bf9..8662d9fcfe4ee 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java @@ -58,13 +58,16 @@ public String readCharacteristic(String query) * Writes characteristic(s) to the accessory. * * @param json the JSON string to write. + * @return * @throws ExecutionException * @throws TimeoutException * @throws InterruptedException * @throws IOException */ - public void writeCharacteristic(String json) + public String writeCharacteristic(String json) throws IOException, InterruptedException, TimeoutException, ExecutionException { - ipTransport.put(ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, json.getBytes(StandardCharsets.UTF_8)); + byte[] result = ipTransport.put(ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, + json.getBytes(StandardCharsets.UTF_8)); + return new String(result, StandardCharsets.UTF_8); } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java index 850cd42bc1f7c..f9b241f2e99df 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -1978,6 +1978,7 @@ void testVenetianBlind() { List options = state.getOptions(); assertNotNull(options); assertEquals(3, options.size()); - assertEquals("Position State #2", options.get(2).getValue()); + assertEquals("Position State #2", options.get(2).getLabel()); + assertEquals("2", options.get(2).getValue()); } } From cb67603c9098915c45345bcb391ecc56efa616e6 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 29 Oct 2025 00:33:38 +0000 Subject: [PATCH 104/177] code optimisations Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/transport/IpTransport.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 17311947179f7..04147766f52bf 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -21,6 +21,7 @@ import java.net.SocketTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Arrays; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -32,7 +33,6 @@ import java.util.concurrent.TimeoutException; import org.bouncycastle.crypto.InvalidCipherTextException; -import org.bouncycastle.util.Arrays; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; @@ -219,7 +219,7 @@ private byte[][] readPlainResponse(InputStream in, boolean trace) throws IOExcep ByteArrayOutputStream raw = trace ? new ByteArrayOutputStream() : null; byte[] buf = new byte[4096]; do { - int read = in.read(buf); + int read = in.read(buf, 0, buf.length); if (read > 0) { byte[] frame = Arrays.copyOf(buf, read); if (raw != null) { From e3ad3dc81245d72c85854f767a03baa3d222698a Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 29 Oct 2025 13:49:14 +0000 Subject: [PATCH 105/177] i18n Signed-off-by: Andrew Fiddian-Green --- .../src/main/resources/OH-INF/i18n/homekit.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 86dcaae216cdc..3f3a144aa4c12 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -170,8 +170,8 @@ characteristic.pairing-pairings = Pairing Pairings characteristic.position-current = Position Current characteristic.position-hold = Position Hold characteristic.position-state = Position State -characteristic.position-state.0 = Going to the minimum value specified in metadata -characteristic.position-state.1 = Going to the maximum value specified in metadata +characteristic.position-state.0 = Closing +characteristic.position-state.1 = Opening characteristic.position-state.2 = Stopped characteristic.position-target = Position Target characteristic.program-mode = Program Mode From 70a47c7d4c2fc5e96c231b5afae9add0cd522359 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 29 Oct 2025 14:04:56 +0000 Subject: [PATCH 106/177] handle previously uncaught exceptions Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/session/SecureSession.java | 7 +++++-- .../binding/homekit/internal/transport/IpTransport.java | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index 6e2c68d03af7a..ffc661f159411 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -20,6 +20,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; +import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.concurrent.atomic.AtomicInteger; @@ -98,7 +99,8 @@ private void sendFrame(ByteArrayInputStream plainTextStream) throws IOException, * @throws IOException * @throws InvalidCipherTextException */ - public byte[][] receive(boolean trace) throws IOException, InvalidCipherTextException { + public byte[][] receive(boolean trace) + throws IOException, InvalidCipherTextException, BufferUnderflowException, SecurityException { HttpPayloadParser httpParser = new HttpPayloadParser(); ByteArrayOutputStream raw = trace ? new ByteArrayOutputStream() : null; do { @@ -121,7 +123,8 @@ public byte[][] receive(boolean trace) throws IOException, InvalidCipherTextExce * @throws IOException * @throws InvalidCipherTextException */ - private byte[] receiveFrame() throws IOException, InvalidCipherTextException { + private byte[] receiveFrame() + throws IOException, InvalidCipherTextException, BufferUnderflowException, SecurityException { byte[] frameAad = in.readNBytes(2); short frameLen = ByteBuffer.wrap(frameAad).order(ByteOrder.LITTLE_ENDIAN).getShort(); if (frameLen < 0 || frameLen > 1024) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 04147766f52bf..d068e9c96fe86 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -19,6 +19,7 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketTimeoutException; +import java.nio.BufferUnderflowException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Arrays; @@ -296,7 +297,8 @@ private void readTask() { byte[][] response = session.receive(logger.isTraceEnabled()); handleResponse(response); } catch (SocketTimeoutException e) { // ignore socket timeout; continue listening - } catch (IllegalStateException | InvalidCipherTextException | IOException e) { + } catch (IllegalStateException | InvalidCipherTextException | IOException | BufferUnderflowException + | SecurityException e) { cause = e; break; } From 805155fe2be76033545b73f0cce8ab5b1df4f194 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 31 Oct 2025 17:13:28 +0000 Subject: [PATCH 107/177] use thing action for pairing Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 1 - .../action/HomekitAccessoryActions.java | 69 +++++++ .../HomekitChildDiscoveryService.java | 1 - .../handler/HomekitBaseAccessoryHandler.java | 186 +++++++++++------- .../resources/OH-INF/i18n/homekit.properties | 22 ++- .../resources/OH-INF/thing/thing-types.xml | 10 - 6 files changed, 199 insertions(+), 90 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitAccessoryActions.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index e79e2d1a8caa1..50acd472409e2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -46,7 +46,6 @@ public class HomekitBindingConstants { // configuration parameters public static final String CONFIG_HOST = "host"; - public static final String CONFIG_PAIRING_CODE = "pairingCode"; public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; // thing properties diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitAccessoryActions.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitAccessoryActions.java new file mode 100644 index 0000000000000..dd10466822f94 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitAccessoryActions.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.action; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.handler.HomekitBaseAccessoryHandler; +import org.openhab.core.automation.annotation.ActionInput; +import org.openhab.core.automation.annotation.RuleAction; +import org.openhab.core.thing.binding.ThingActions; +import org.openhab.core.thing.binding.ThingActionsScope; +import org.openhab.core.thing.binding.ThingHandler; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ServiceScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implementation of the {@link ThingActions} interface used for pairing. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@Component(scope = ServiceScope.PROTOTYPE, service = HomekitAccessoryActions.class) +@ThingActionsScope(name = "homekit-pairing") +@NonNullByDefault +public class HomekitAccessoryActions implements ThingActions { + + private final Logger logger = LoggerFactory.getLogger(HomekitAccessoryActions.class); + private @Nullable HomekitBaseAccessoryHandler handler; + + public static void pair(ThingActions actions, String code) { + if (actions instanceof HomekitAccessoryActions accessoryActions) { + accessoryActions.pair(code); + } else { + throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitAccessoryActions"); + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return handler; + } + + @Override + public void setThingHandler(@Nullable ThingHandler handler) { + this.handler = handler instanceof HomekitBaseAccessoryHandler accessoryHandler ? accessoryHandler : null; + } + + @RuleAction(label = "@text/actions.pairing-code.label", description = "@text/actions.pairing-code.description") + public void pair( + @ActionInput(name = "code", label = "@text/actions.pairing-code.label", description = "@text/actions.pairing-code.description") String code) { + HomekitBaseAccessoryHandler handler = this.handler; + if (handler != null) { + handler.pair(code); + } else { + logger.warn("ThingHandler is null."); + } + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 1088ffab456a9..e78fc23886aa0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -57,7 +57,6 @@ private void discoverChildren(Thing bridge, Collection accessories) { .withBridge(bridge.getUID()) // .withLabel(THING_LABEL_FMT.formatted(thingLabel, bridge.getLabel())) // .withProperty(CONFIG_HOST, "n/a") // - .withProperty(CONFIG_PAIRING_CODE, "n/a") // .withProperty(PROPERTY_ACCESSORY_UID, uid.toString()) // .withRepresentationProperty(PROPERTY_ACCESSORY_UID).build()); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 745034563a3ce..8c20d8923d07c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -34,6 +35,7 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.action.HomekitAccessoryActions; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; @@ -51,6 +53,7 @@ import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; import org.osgi.framework.Bundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -91,7 +94,6 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple private @Nullable CharacteristicReadWriteClient rwService; private @Nullable IpTransport ipTransport; - protected @NonNullByDefault({}) String pairingCode; protected @NonNullByDefault({}) Integer accessoryId; public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, @@ -212,52 +214,22 @@ public void initialize() { } /** - * Restores an existing pairing or creates a new one if necessary. + * Restores an existing pairing. * Updates the thing status accordingly. */ - private synchronized void initializePairing() { + private synchronized void verifyPairing() { isConfigured = false; - Object host = getConfig().get(CONFIG_HOST); - if (host == null || !(host instanceof String hostName) || !HOST_PATTERN.matcher(hostName).matches()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.invalid-host", "Invalid host", null)); - return; - } - - Object pairingConfig = getConfig().get(CONFIG_PAIRING_CODE); - if (pairingConfig == null || !(pairingConfig instanceof String pairingConfigString) - || !PAIRING_CODE_PATTERN.matcher(pairingConfigString).matches()) { - logger.debug("Pairing code must match XXX-XX-XXX or XXXX-XXXX or XXXXXXXX"); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.invalid-pairing-code", "Invalid pairing code", null)); - return; - } - pairingCode = normalizePairingCode(pairingConfigString); - - accessoryId = getAccessoryId(); - if (accessoryId == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.invalid-accessory-id", "Invalid accessory ID", null)); - return; - } - - final String macAddress = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); - if (macAddress == null) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.missing-mac-address", "Missing MAC address", null)); - return; + Integer accessoryId = checkedAccessoryId(); + String hostName = checkedHostName(); + String macAddress = checkedMacAddress(); + if (accessoryId == null || hostName == null || macAddress == null) { + return; // configuration error } isConfigured = true; // create new transport - try { - ipTransport = new IpTransport(hostName); - } catch (IOException e) { - logger.warn("Error '{}' creating transport", e.getMessage()); - logger.debug("Stack trace", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); - return; + if (checkedCreateIpTransport(hostName) == null) { + return; // transport creation failed } if (keyStore.getAccessoryKey(macAddress) instanceof Ed25519PublicKeyParameters accessoryKey) { @@ -277,36 +249,14 @@ private synchronized void initializePairing() { | InvalidCipherTextException | IOException | InterruptedException | TimeoutException | ExecutionException e) { logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); - // pairing restore failed => continue to try to create a new pairing + // pairing restore failed => exit and perhaps try again later + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, + "error.pairing-verification-failed", "Pairing / Verification failed", null)); } - } - - try { - logger.debug("Starting Pair-Setup for {}", thing.getUID()); - PairSetupClient pairSetupClient = new PairSetupClient(getIpTransport(), keyStore.getControllerUUID(), - keyStore.getControllerKey(), pairingCode); - - Ed25519PublicKeyParameters accessoryKey = pairSetupClient.pair(); - logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); - - // Perform Pair-Verify immediately after Pair-Setup - PairVerifyClient pairVerifyClient = new PairVerifyClient(getIpTransport(), keyStore.getControllerUUID(), - keyStore.getControllerKey(), accessoryKey); - - getIpTransport().setSessionKeys(pairVerifyClient.verify()); - rwService = new CharacteristicReadWriteClient(getIpTransport()); - keyStore.setAccessoryKey(macAddress, accessoryKey); - - logger.debug("Pairing and verification completed for {}", thing.getUID()); - fetchAccessories(); - updateStatus(ThingStatus.ONLINE); - } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException - | InvalidCipherTextException | IOException | InterruptedException | TimeoutException - | ExecutionException e) { - logger.warn("Pairing / verification failed '{}' for {}", e.getMessage(), thing.getUID()); - logger.debug("Stack trace", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, - "error.pairing-verification-failed", "Pairing / verification failed", null)); + } else { + logger.debug("No stored pairing credentials for {}", thing.getUID()); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); } } @@ -347,7 +297,7 @@ private synchronized void attemptConnect() { transport.close(); ipTransport = null; } - initializePairing(); + verifyPairing(); if (isConfigured && thing.getStatus() != ThingStatus.ONLINE) { // config ok but connection failed => try again connectionAttemptDelay = Math.min(MAX_CONNECTION_ATTEMPT_DELAY, (int) Math.pow(connectionAttemptDelay, 2)); connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, TimeUnit.SECONDS); @@ -417,4 +367,100 @@ protected void unsubscribeEvents() { public void onEvent(String jsonContent) { // default implementation does nothing; subclasses must override } + + @Override + public Collection> getServices() { + return getBridge() != null ? Set.of() : Set.of(HomekitAccessoryActions.class); // only for non-child accessories + } + + private @Nullable String checkedHostName() { + Object host = getConfig().get(CONFIG_HOST); + if (host == null || !(host instanceof String hostName) || !HOST_PATTERN.matcher(hostName).matches()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.invalid-host", "Invalid host", null)); + return null; + } + return hostName; + } + + private @Nullable String checkedMacAddress() { + String macAddress = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); + if (macAddress == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.missing-mac-address", "Missing MAC address", null)); + } + return macAddress; + } + + private @Nullable Integer checkedAccessoryId() { + accessoryId = getAccessoryId(); + if (accessoryId == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.invalid-accessory-id", "Invalid accessory ID", null)); + return null; + } + return accessoryId; + } + + private @Nullable IpTransport checkedCreateIpTransport(String hostName) { + try { + IpTransport ipTransport = new IpTransport(hostName); + this.ipTransport = ipTransport; + return ipTransport; + } catch (IOException e) { + logger.warn("Error '{}' creating transport", e.getMessage()); + logger.debug("Stack trace", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); + } + return null; + } + + /** + * Pairs the accessory using the provided pairing code. + * + * @param code the pairing code + */ + public void pair(String code) { + if (isChildAccessory) { + logger.warn("Cannot pair child accessory '{}'", thing.getUID()); + return; // child accessories cannot be paired directly + } + + if (!PAIRING_CODE_PATTERN.matcher(code).matches()) { + logger.debug("Pairing code must match XXX-XX-XXX or XXXX-XXXX or XXXXXXXX"); + return; // invalid pairing code format + } + String pairingCode = normalizePairingCode(code); + + isConfigured = false; + Integer accessoryId = checkedAccessoryId(); + String hostName = checkedHostName(); + String macAddress = checkedMacAddress(); + if (accessoryId == null || hostName == null || macAddress == null) { + return; // configuration error + } + isConfigured = true; + + // create new transport + if (checkedCreateIpTransport(hostName) == null) { + return; // transport creation failed + } + + try { + logger.debug("Starting Pair-Setup for {}", thing.getUID()); + PairSetupClient pairSetupClient = new PairSetupClient(getIpTransport(), keyStore.getControllerUUID(), + keyStore.getControllerKey(), pairingCode); + pairSetupClient.pair(); + logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); + connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY; // reset delay on manual pairing + scheduleConnectionAttempt(); + } catch (NoSuchAlgorithmException | IllegalAccessException | InvalidCipherTextException | IOException + | InterruptedException | TimeoutException | ExecutionException e) { + logger.warn("Pairing / verification failed '{}' for {}", e.getMessage(), thing.getUID()); + logger.debug("Stack trace", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, + "error.pairing-verification-failed", "Pairing / Verification failed", null)); + } + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 3f3a144aa4c12..b6359ac60e5a8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -5,23 +5,21 @@ addon.homekit.description = This is the binding for a HomeKit client. # thing types +thing-type.homekit.accessory.label = HomeKit Device +thing-type.homekit.accessory.description = HomeKit Accessory Device thing-type.homekit.bridge.label = HomeKit Bridge thing-type.homekit.bridge.description = HomeKit Accessory Bridge -thing-type.homekit.accessory.label = HomeKit Accessory -thing-type.homekit.accessory.description = HomeKit Accessory Device # thing types config -thing-type.config.homekit.bridge.host.label = IP Address -thing-type.config.homekit.bridge.host.description = IP v4 address of the HomeKit bridge. -thing-type.config.homekit.bridge.pairingCode.label = Pairing Code -thing-type.config.homekit.bridge.pairingCode.description = Code used for pairing with the HomeKit bridge. thing-type.config.homekit.accessory.host.label = IP Address thing-type.config.homekit.accessory.host.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.accessory.pairingCode.label = Pairing Code -thing-type.config.homekit.accessory.pairingCode.description = Code used for pairing with the HomeKit accessory. thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.bridge.host.label = IP Address +thing-type.config.homekit.bridge.host.description = IP v4 address of the HomeKit bridge. + +# thing error state messages error.bridge-not-connected = Bridge not connected error.invalid-host = Invalid host @@ -32,6 +30,14 @@ error.missing-mac-address = Missing MAC address error.pairing-verification-failed = Pairing / verification failed error.polling-error = Polling error error.error-sending-command = Error sending command +error.not-paired = Not paired + +# thing actions + +actions.pairing-code.label = Pairing Code +actions.pairing-code.description = The pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. + +# characteristic texts characteristic.accessory-properties = Accessory Properties characteristic.active = Active diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 234578c108918..5287fc635bb38 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -13,11 +13,6 @@ IP v4 address of the HomeKit accessory. - - password - - Code used for pairing with the HomeKit accessory. - Interval at which the accessory is polled in sec. @@ -37,11 +32,6 @@ IP v4 address of the HomeKit bridge. - - password - - Code used for pairing with the HomeKit bridge. - From 2994e8fe04bd8b9eb37f6e2e7e97b2e214b75706 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 1 Nov 2025 11:55:52 +0000 Subject: [PATCH 108/177] fixes for pairing action Signed-off-by: Andrew Fiddian-Green --- ...AccessoryActions.java => HomekitPairingAction.java} | 10 +++++----- .../internal/handler/HomekitBaseAccessoryHandler.java | 10 +++++++--- .../homekit/internal/handler/HomekitBridgeHandler.java | 3 ++- .../src/main/resources/OH-INF/i18n/homekit.properties | 2 +- 4 files changed, 15 insertions(+), 10 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/{HomekitAccessoryActions.java => HomekitPairingAction.java} (88%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitAccessoryActions.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java similarity index 88% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitAccessoryActions.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java index dd10466822f94..747e61dbfb0c9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitAccessoryActions.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java @@ -26,20 +26,20 @@ import org.slf4j.LoggerFactory; /** - * Implementation of the {@link ThingActions} interface used for pairing. + * Implementation of the {@link ThingActions} interface for pairing. * * @author Andrew Fiddian-Green - Initial contribution */ -@Component(scope = ServiceScope.PROTOTYPE, service = HomekitAccessoryActions.class) +@Component(scope = ServiceScope.PROTOTYPE, service = HomekitPairingAction.class) @ThingActionsScope(name = "homekit-pairing") @NonNullByDefault -public class HomekitAccessoryActions implements ThingActions { +public class HomekitPairingAction implements ThingActions { - private final Logger logger = LoggerFactory.getLogger(HomekitAccessoryActions.class); + private final Logger logger = LoggerFactory.getLogger(HomekitPairingAction.class); private @Nullable HomekitBaseAccessoryHandler handler; public static void pair(ThingActions actions, String code) { - if (actions instanceof HomekitAccessoryActions accessoryActions) { + if (actions instanceof HomekitPairingAction accessoryActions) { accessoryActions.pair(code); } else { throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitAccessoryActions"); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 8c20d8923d07c..e442dd6d0b5b0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -35,7 +35,7 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.action.HomekitAccessoryActions; +import org.openhab.binding.homekit.internal.action.HomekitPairingAction; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; @@ -370,7 +370,8 @@ public void onEvent(String jsonContent) { @Override public Collection> getServices() { - return getBridge() != null ? Set.of() : Set.of(HomekitAccessoryActions.class); // only for non-child accessories + // only non child accessories require pairing support + return thing.getBridgeUID() != null ? Set.of() : Set.of(HomekitPairingAction.class); } private @Nullable String checkedHostName() { @@ -451,7 +452,10 @@ public void pair(String code) { logger.debug("Starting Pair-Setup for {}", thing.getUID()); PairSetupClient pairSetupClient = new PairSetupClient(getIpTransport(), keyStore.getControllerUUID(), keyStore.getControllerKey(), pairingCode); - pairSetupClient.pair(); + + Ed25519PublicKeyParameters accessoryKey = pairSetupClient.pair(); + keyStore.setAccessoryKey(macAddress, accessoryKey); + logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY; // reset delay on manual pairing scheduleConnectionAttempt(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 0361e37362302..7efdc7ae0865c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -18,6 +18,7 @@ import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.homekit.internal.action.HomekitPairingAction; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.dto.Characteristic; @@ -106,7 +107,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override public Collection> getServices() { - return Set.of(HomekitChildDiscoveryService.class); + return Set.of(HomekitChildDiscoveryService.class, HomekitPairingAction.class); } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index b6359ac60e5a8..95ed9b026b6b8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -35,7 +35,7 @@ error.not-paired = Not paired # thing actions actions.pairing-code.label = Pairing Code -actions.pairing-code.description = The pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. +actions.pairing-code.description = The 8 digit pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. # characteristic texts From 9be0f3801a2020294cd0dcd0a3d4d4ad051218f0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 1 Nov 2025 12:13:08 +0000 Subject: [PATCH 109/177] i18n Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/action/HomekitPairingAction.java | 2 +- .../src/main/resources/OH-INF/i18n/homekit.properties | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java index 747e61dbfb0c9..e02ab5ed36283 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java @@ -56,7 +56,7 @@ public void setThingHandler(@Nullable ThingHandler handler) { this.handler = handler instanceof HomekitBaseAccessoryHandler accessoryHandler ? accessoryHandler : null; } - @RuleAction(label = "@text/actions.pairing-code.label", description = "@text/actions.pairing-code.description") + @RuleAction(label = "@text/actions.pairing-action.label", description = "@text/actions.pairing-action.description") public void pair( @ActionInput(name = "code", label = "@text/actions.pairing-code.label", description = "@text/actions.pairing-code.description") String code) { HomekitBaseAccessoryHandler handler = this.handler; diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 95ed9b026b6b8..74c1ba38ef245 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -34,6 +34,8 @@ error.not-paired = Not paired # thing actions +actions.pairing-action.label = Eneter Pairing Code +actions.pairing-action.description = Enter the 8 digit pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. actions.pairing-code.label = Pairing Code actions.pairing-code.description = The 8 digit pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. From 4827ae7c369b7c3edf7be214163da0f6d49c32e7 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 1 Nov 2025 14:00:42 +0000 Subject: [PATCH 110/177] add thing action unpair Signed-off-by: Andrew Fiddian-Green --- ...Action.java => HomekitPairingActions.java} | 26 +++++-- .../handler/HomekitBaseAccessoryHandler.java | 70 +++++++++++++------ .../handler/HomekitBridgeHandler.java | 4 +- .../resources/OH-INF/i18n/homekit.properties | 6 +- 4 files changed, 75 insertions(+), 31 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/{HomekitPairingAction.java => HomekitPairingActions.java} (74%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java similarity index 74% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java index e02ab5ed36283..11395f97723f7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingAction.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java @@ -30,22 +30,30 @@ * * @author Andrew Fiddian-Green - Initial contribution */ -@Component(scope = ServiceScope.PROTOTYPE, service = HomekitPairingAction.class) +@Component(scope = ServiceScope.PROTOTYPE, service = HomekitPairingActions.class) @ThingActionsScope(name = "homekit-pairing") @NonNullByDefault -public class HomekitPairingAction implements ThingActions { +public class HomekitPairingActions implements ThingActions { - private final Logger logger = LoggerFactory.getLogger(HomekitPairingAction.class); + private final Logger logger = LoggerFactory.getLogger(HomekitPairingActions.class); private @Nullable HomekitBaseAccessoryHandler handler; public static void pair(ThingActions actions, String code) { - if (actions instanceof HomekitPairingAction accessoryActions) { + if (actions instanceof HomekitPairingActions accessoryActions) { accessoryActions.pair(code); } else { throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitAccessoryActions"); } } + public static void unpair(ThingActions actions) { + if (actions instanceof HomekitPairingActions accessoryActions) { + accessoryActions.unpair(); + } else { + throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitAccessoryActions"); + } + } + @Override public @Nullable ThingHandler getThingHandler() { return handler; @@ -66,4 +74,14 @@ public void pair( logger.warn("ThingHandler is null."); } } + + @RuleAction(label = "@text/actions.unpairing-action.label", description = "@text/actions.unpairing-action.description") + public void unpair() { + HomekitBaseAccessoryHandler handler = this.handler; + if (handler != null) { + handler.unpair(); + } else { + logger.warn("ThingHandler is null."); + } + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index e442dd6d0b5b0..b9d0318103f46 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -35,7 +35,7 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.homekit.internal.action.HomekitPairingAction; +import org.openhab.binding.homekit.internal.action.HomekitPairingActions; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; @@ -140,8 +140,8 @@ private void fetchAccessories() { } logger.debug("Fetched {} accessories", accessories.size()); scheduler.submit(this::accessoriesLoaded); // notify subclass in scheduler thread - } catch (IOException | InterruptedException | TimeoutException | ExecutionException - | IllegalAccessException e) { + } catch (IOException | InterruptedException | TimeoutException | ExecutionException | IllegalAccessException + | IllegalStateException e) { logger.debug("Failed to get accessories", e); } } @@ -175,19 +175,8 @@ public void handleRemoval() { updateStatus(ThingStatus.REMOVED); } else { scheduler.submit(() -> { - // unpair and clear stored keys if this is NOT a child accessory - try { - PairRemoveClient service = new PairRemoveClient(getIpTransport(), keyStore.getControllerUUID()); - service.remove(); - if (getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS) instanceof String mac) { - keyStore.setAccessoryKey(mac, null); - } else { - logger.warn("Could not clear key for '{}' due to missing mac address", thing.getUID()); - } + if (unpairInner()) { updateStatus(ThingStatus.REMOVED); - } catch (IOException | InterruptedException | TimeoutException | ExecutionException - | IllegalAccessException e) { - logger.warn("Failed to remove pairing for '{}'", thing.getUID()); } }); } @@ -247,7 +236,7 @@ private synchronized void verifyPairing() { return; // pairing restore succeeded => exit } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException | InvalidCipherTextException | IOException | InterruptedException | TimeoutException - | ExecutionException e) { + | ExecutionException | IllegalStateException e) { logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); // pairing restore failed => exit and perhaps try again later updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, @@ -313,13 +302,13 @@ private synchronized void attemptConnect() { * @throws IllegalAccessException if this is a child accessory or if the transport is not initialized. * @return the IpTransport */ - protected IpTransport getIpTransport() throws IllegalAccessException { + protected IpTransport getIpTransport() throws IllegalAccessException, IllegalStateException { if (isChildAccessory) { throw new IllegalAccessException("Child accessories must delegate to bridge IP transport"); } IpTransport ipTransport = this.ipTransport; if (ipTransport == null) { - throw new IllegalAccessException("IP transport not initialized"); + throw new IllegalStateException("IP transport not initialized"); } return ipTransport; } @@ -347,7 +336,7 @@ protected CharacteristicReadWriteClient getRwService() throws IllegalAccessExcep protected void subscribeEvents() { try { getIpTransport().subscribe(this); - } catch (IllegalAccessException e) { + } catch (IllegalAccessException | IllegalStateException e) { logger.debug("Failed to subscribe to events '{}", e.getMessage()); } } @@ -358,7 +347,7 @@ protected void subscribeEvents() { protected void unsubscribeEvents() { try { getIpTransport().unsubscribe(this); - } catch (IllegalAccessException e) { + } catch (IllegalAccessException | IllegalStateException e) { logger.debug("Failed to unsubscribe from events '{}", e.getMessage()); } } @@ -371,7 +360,7 @@ public void onEvent(String jsonContent) { @Override public Collection> getServices() { // only non child accessories require pairing support - return thing.getBridgeUID() != null ? Set.of() : Set.of(HomekitPairingAction.class); + return thing.getBridgeUID() != null ? Set.of() : Set.of(HomekitPairingActions.class); } private @Nullable String checkedHostName() { @@ -418,7 +407,7 @@ public Collection> getServices() { } /** - * Pairs the accessory using the provided pairing code. + * Thing Action that pairs the accessory using the provided pairing code. * * @param code the pairing code */ @@ -460,11 +449,46 @@ public void pair(String code) { connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY; // reset delay on manual pairing scheduleConnectionAttempt(); } catch (NoSuchAlgorithmException | IllegalAccessException | InvalidCipherTextException | IOException - | InterruptedException | TimeoutException | ExecutionException e) { + | InterruptedException | TimeoutException | ExecutionException | IllegalStateException e) { logger.warn("Pairing / verification failed '{}' for {}", e.getMessage(), thing.getUID()); logger.debug("Stack trace", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.pairing-verification-failed", "Pairing / Verification failed", null)); } } + + /** + * Inner method to unpair and clear stored key. + */ + private boolean unpairInner() { + if (isChildAccessory) { + logger.warn("Cannot unpair child accessory '{}'", thing.getUID()); + return false; + } + String macAddress = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); + if (macAddress == null) { + logger.warn("Cannot unpair accessory '{}' due to missing mac address property", thing.getUID()); + return false; + } + try { + PairRemoveClient service = new PairRemoveClient(getIpTransport(), keyStore.getControllerUUID()); + service.remove(); + keyStore.setAccessoryKey(macAddress, null); + return true; + } catch (IOException | InterruptedException | TimeoutException | ExecutionException | IllegalAccessException + | IllegalStateException e) { + logger.warn("Error '{}' unpairing accessory '{}'", e.getMessage(), thing.getUID()); + return false; + } + } + + /** + * Thing Action that unpairs the accessory. + */ + public void unpair() { + if (unpairInner()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); + } + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 7efdc7ae0865c..c6b1fc11f33d6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -18,7 +18,7 @@ import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.binding.homekit.internal.action.HomekitPairingAction; +import org.openhab.binding.homekit.internal.action.HomekitPairingActions; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.dto.Characteristic; @@ -107,7 +107,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override public Collection> getServices() { - return Set.of(HomekitChildDiscoveryService.class, HomekitPairingAction.class); + return Set.of(HomekitChildDiscoveryService.class, HomekitPairingActions.class); } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 74c1ba38ef245..6831c5f049916 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -34,10 +34,12 @@ error.not-paired = Not paired # thing actions -actions.pairing-action.label = Eneter Pairing Code -actions.pairing-action.description = Enter the 8 digit pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. +actions.pairing-action.label = Pair Accessory or Bridge +actions.pairing-action.description = Create a pairing between this thing and the respective accessory or bridge. actions.pairing-code.label = Pairing Code actions.pairing-code.description = The 8 digit pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. +actions.unpairing-action.label = Unpair Accessory or Bridge +actions.unpairing-action.description = Remove the pairing between this thing and the respective accessory or bridge. # characteristic texts From 4cf1a208ec2f1571d117ea741b05c415c9489d41 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 3 Nov 2025 23:10:27 +0000 Subject: [PATCH 111/177] various - allow pairing with or without external authentication - fix manual discovery bug when no bridge is present - refactor exception handling - java doc - fix http payload parsing - fix encrypted http reads across frame breaks - rate limit http requests to 5 per second Signed-off-by: Andrew Fiddian-Green --- .../action/HomekitPairingActions.java | 9 +- .../HomekitChildDiscoveryService.java | 4 +- .../handler/HomekitAccessoryHandler.java | 59 +++++--- .../handler/HomekitBaseAccessoryHandler.java | 67 ++++++--- .../CharacteristicReadWriteClient.java | 6 +- .../hap_services/PairRemoveClient.java | 12 +- .../hap_services/PairSetupClient.java | 44 ++++-- .../hap_services/PairVerifyClient.java | 52 +++++-- .../internal/session/HttpPayloadParser.java | 27 ++-- .../internal/session/SecureSession.java | 60 +++++--- .../internal/transport/IpTransport.java | 141 +++++++++++++----- .../resources/OH-INF/i18n/homekit.properties | 2 + .../homekit/internal/TestPairSetup.java | 3 +- 13 files changed, 338 insertions(+), 148 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java index 11395f97723f7..3574d5a292eb0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java @@ -38,9 +38,9 @@ public class HomekitPairingActions implements ThingActions { private final Logger logger = LoggerFactory.getLogger(HomekitPairingActions.class); private @Nullable HomekitBaseAccessoryHandler handler; - public static void pair(ThingActions actions, String code) { + public static void pair(ThingActions actions, String code, boolean auth) { if (actions instanceof HomekitPairingActions accessoryActions) { - accessoryActions.pair(code); + accessoryActions.pair(code, auth); } else { throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitAccessoryActions"); } @@ -66,10 +66,11 @@ public void setThingHandler(@Nullable ThingHandler handler) { @RuleAction(label = "@text/actions.pairing-action.label", description = "@text/actions.pairing-action.description") public void pair( - @ActionInput(name = "code", label = "@text/actions.pairing-code.label", description = "@text/actions.pairing-code.description") String code) { + @ActionInput(name = "code", label = "@text/actions.pairing-code.label", description = "@text/actions.pairing-code.description") String code, + @ActionInput(name = "auth", label = "@text/actions.pairing-auth.label", description = "@text/actions.pairing-auth.description", defaultValue = "false") boolean auth) { HomekitBaseAccessoryHandler handler = this.handler; if (handler != null) { - handler.pair(code); + handler.pair(code, auth); } else { logger.warn("ThingHandler is null."); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index e78fc23886aa0..380a6fe9776d5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -45,7 +45,9 @@ public HomekitChildDiscoveryService() { @Override public void startScan() { - discoverChildren(thingHandler.getThing(), thingHandler.getAccessories()); + if (thingHandler instanceof HomekitBridgeHandler handler) { + discoverChildren(handler.getThing(), handler.getAccessories()); + } } private void discoverChildren(Thing bridge, Collection accessories) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index b11c9f5cb058c..f6ddce1fb23b6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -172,7 +172,11 @@ private void channelsAndPropertiesLoaded() { } if (eventedChannels.isEmpty()) { - unsubscribeEvents(); + try { + unsubscribeEvents(); + } catch (IllegalAccessException | IllegalStateException e) { + logger.warn("Unexpected error '{}' unsubscribing evented channels", e.getMessage()); + } } else { Service service = new Service(); service.characteristics = new ArrayList<>(); @@ -189,12 +193,19 @@ private void channelsAndPropertiesLoaded() { getRwService().writeCharacteristic(GSON.toJson(service)); subscribeEvents(); logger.debug("Eventing enabled for {} channels", eventedChannels.size()); - } catch (IOException | TimeoutException e) { - logger.debug("Communication error '{}' subscribing to evented channels", e.getMessage()); - } catch (IllegalAccessException | ExecutionException e) { - logger.warn("Unexpected error '{}' subscribing to evented channels", e.getMessage()); + } catch (InterruptedException e) { + // shutting down; do nothing + } catch (Exception e) { + if (isCommunicationException(e)) { + // communication exception; log at debug and try to reconnect + logger.debug("Communication error '{}' subscribing to evented channels, reconnecting..", + e.getMessage()); + scheduleConnectionAttempt(); + } else { + // other exception; log at warn and don't try to reconnect + logger.warn("Unexpected error '{}' subscribing to evented channels", e.getMessage()); + } logger.debug("Stack trace", e); - } catch (InterruptedException e) { // shutting down } } } @@ -539,14 +550,19 @@ public void handleCommand(ChannelUID channelUID, Command command) { writeChannel(channel, command, getRwService()); } return; // success - } catch (IOException | TimeoutException e) { - logger.debug("Communication error '{}' sending command '{}' to '{}', restarting", e.getMessage(), command, - channelUID); - scheduleConnectionAttempt(); - } catch (IllegalAccessException | ExecutionException e) { - logger.warn("Unexpected error '{}' sending command '{}' to '{}'", e.getMessage(), command, channelUID); + } catch (InterruptedException e) { + // shutting down; do nothing + } catch (Exception e) { + if (isCommunicationException(e)) { + // communication exception; log at debug and try to reconnect + logger.debug("Communication error '{}' sending command '{}' to '{}', reconnecting..", e.getMessage(), + command, channelUID); + scheduleConnectionAttempt(); + } else { + // other exception; log at warn and don't try to reconnect + logger.warn("Unexpected error '{}' sending command '{}' to '{}'", e.getMessage(), command, channelUID); + } logger.debug("Stack trace", e); - } catch (InterruptedException e) { // shutting down } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, i18nProvider.getText(bundle, "error.error-sending-command", "Error sending command", null)); @@ -592,13 +608,18 @@ private void refresh() { String json = getRwService().readCharacteristic(String.join(",", queries)); updateChannelsFromJson(json); return; - } catch (IOException | TimeoutException e) { - logger.debug("Communication error '{}' polling accessory, restarting", e.getMessage()); - scheduleConnectionAttempt(); - } catch (IllegalAccessException | ExecutionException e) { - logger.warn("Unexpected error '{}' polling accessory", e.getMessage()); + } catch (InterruptedException e) { + // shutting down; do nothing + } catch (Exception e) { + if (isCommunicationException(e)) { + // communication exception; log at debug and try to reconnect + logger.debug("Communication error '{}' polling accessory, reconnecting..", e.getMessage()); + scheduleConnectionAttempt(); + } else { + // other exception; log at warn and don't try to reconnect + logger.warn("Unexpected error '{}' polling accessory", e.getMessage()); + } logger.debug("Stack trace", e); - } catch (InterruptedException e) { // shutting down } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, i18nProvider.getText(bundle, "error.polling-error", "Polling error", null)); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index b9d0318103f46..acb3f3dacc87e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -107,7 +107,11 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { - unsubscribeEvents(); + try { + unsubscribeEvents(); + } catch (IllegalAccessException | IllegalStateException e) { + // closing; ignore + } if (connectionAttemptTask instanceof ScheduledFuture task) { task.cancel(true); } @@ -130,19 +134,26 @@ public void dispose() { */ private void fetchAccessories() { try { + accessories.clear(); String json = new String(getIpTransport().get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP), StandardCharsets.UTF_8); Accessories acc0 = GSON.fromJson(json, Accessories.class); if (acc0 instanceof Accessories acc1 && acc1.accessories instanceof List acc2) { - accessories.clear(); accessories.putAll(acc2.stream().filter(a -> Objects.nonNull(a.aid)) .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } logger.debug("Fetched {} accessories", accessories.size()); scheduler.submit(this::accessoriesLoaded); // notify subclass in scheduler thread - } catch (IOException | InterruptedException | TimeoutException | ExecutionException | IllegalAccessException - | IllegalStateException e) { - logger.debug("Failed to get accessories", e); + } catch (Exception e) { + if (isCommunicationException(e)) { + // communication exception; log at debug and try to reconnect + logger.debug("Communication error '{}' fetching accessories, reconnecting..", e.getMessage()); + scheduleConnectionAttempt(); + } else { + // other exception; log at warn and don't try to reconnect + logger.warn("Unexpected error '{}' fetching accessories", e.getMessage()); + } + logger.debug("Stack trace", e); } } @@ -332,24 +343,22 @@ protected CharacteristicReadWriteClient getRwService() throws IllegalAccessExcep /** * Subscribes to events from the IP transport. + * + * @throws IllegalStateException + * @throws IllegalAccessException */ - protected void subscribeEvents() { - try { - getIpTransport().subscribe(this); - } catch (IllegalAccessException | IllegalStateException e) { - logger.debug("Failed to subscribe to events '{}", e.getMessage()); - } + protected void subscribeEvents() throws IllegalAccessException, IllegalStateException { + getIpTransport().subscribe(this); } /** * Unsubscribes from events from the IP transport. + * + * @throws IllegalStateException + * @throws IllegalAccessException */ - protected void unsubscribeEvents() { - try { - getIpTransport().unsubscribe(this); - } catch (IllegalAccessException | IllegalStateException e) { - logger.debug("Failed to unsubscribe from events '{}", e.getMessage()); - } + protected void unsubscribeEvents() throws IllegalAccessException, IllegalStateException { + getIpTransport().unsubscribe(this); } @Override @@ -399,7 +408,6 @@ public Collection> getServices() { return ipTransport; } catch (IOException e) { logger.warn("Error '{}' creating transport", e.getMessage()); - logger.debug("Stack trace", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); } @@ -410,8 +418,9 @@ public Collection> getServices() { * Thing Action that pairs the accessory using the provided pairing code. * * @param code the pairing code + * @param withExternalAuthentication true to setup with external authentication e.g. from an app, false otherwise */ - public void pair(String code) { + public void pair(String code, boolean withExternalAuthentication) { if (isChildAccessory) { logger.warn("Cannot pair child accessory '{}'", thing.getUID()); return; // child accessories cannot be paired directly @@ -440,7 +449,7 @@ public void pair(String code) { try { logger.debug("Starting Pair-Setup for {}", thing.getUID()); PairSetupClient pairSetupClient = new PairSetupClient(getIpTransport(), keyStore.getControllerUUID(), - keyStore.getControllerKey(), pairingCode); + keyStore.getControllerKey(), pairingCode, withExternalAuthentication); Ed25519PublicKeyParameters accessoryKey = pairSetupClient.pair(); keyStore.setAccessoryKey(macAddress, accessoryKey); @@ -448,8 +457,8 @@ public void pair(String code) { logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY; // reset delay on manual pairing scheduleConnectionAttempt(); - } catch (NoSuchAlgorithmException | IllegalAccessException | InvalidCipherTextException | IOException - | InterruptedException | TimeoutException | ExecutionException | IllegalStateException e) { + } catch (Exception e) { + // catch all; log all exceptions logger.warn("Pairing / verification failed '{}' for {}", e.getMessage(), thing.getUID()); logger.debug("Stack trace", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, @@ -491,4 +500,18 @@ public void unpair() { i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); } } + + /** + * Determines if the given throwable is an IOException or TimeoutException, including checking the cause + * if it is wrapped in an ExecutionException. Used to identify communication-related exceptions that can + * potentially be recovered. + * + * @param throwable the exception to check + * @return true if it's an IOException or TimeoutException, false otherwise + */ + protected boolean isCommunicationException(Throwable throwable) { + return (throwable instanceof IOException || throwable instanceof TimeoutException) ? true + : (throwable instanceof ExecutionException outer) && (outer.getCause() instanceof Throwable inner) + && (inner instanceof IOException || inner instanceof TimeoutException) ? true : false; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java index 8662d9fcfe4ee..a5b123e072205 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java @@ -46,9 +46,10 @@ public CharacteristicReadWriteClient(IpTransport ipTransport) { * @throws TimeoutException * @throws InterruptedException * @throws IOException + * @throws IllegalStateException */ public String readCharacteristic(String query) - throws IOException, InterruptedException, TimeoutException, ExecutionException { + throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { String endpoint = "%s?id=%s".formatted(ENDPOINT_CHARACTERISTICS, query); byte[] result = ipTransport.get(endpoint, CONTENT_TYPE_HAP); return new String(result, StandardCharsets.UTF_8); @@ -63,9 +64,10 @@ public String readCharacteristic(String query) * @throws TimeoutException * @throws InterruptedException * @throws IOException + * @throws IllegalStateException */ public String writeCharacteristic(String json) - throws IOException, InterruptedException, TimeoutException, ExecutionException { + throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { byte[] result = ipTransport.put(ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, json.getBytes(StandardCharsets.UTF_8)); return new String(result, StandardCharsets.UTF_8); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java index a2ac5752d5120..250163f8c5131 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java @@ -53,7 +53,17 @@ public PairRemoveClient(IpTransport ipTransport, byte[] controllerId) { this.controllerId = controllerId; } - public void remove() throws IOException, InterruptedException, TimeoutException, ExecutionException { + /** + * Removes an existing pairing with the accessory. + * + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws IllegalStateException + */ + public void remove() + throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { logger.debug("Pair-Remove: starting removal"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java index 9678957f35ed7..63ac761a690c8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java @@ -57,14 +57,25 @@ public class PairSetupClient { private final String password; private final byte[] controllerId; private final Ed25519PrivateKeyParameters controllerKey; + private final boolean withExternalAuthentication; + /** + * Constructs a PairSetupClient with the given transport, controller ID, controller key, and pairing code. + * + * @param ipTransport the IP transport for communication + * @param controllerId the controller's identifier + * @param controllerKey the controller's long-term private key + * @param pairingCode the accessory's setup code for pairing + * @param withExternalAuthentication whether to use external authentication e.g. from an app + */ public PairSetupClient(IpTransport ipTransport, byte[] controllerId, Ed25519PrivateKeyParameters controllerKey, - String pairingCode) { + String pairingCode, boolean withExternalAuthentication) { logger.debug("Created with pairing code: {}", pairingCode); this.ipTransport = ipTransport; this.password = pairingCode; this.controllerId = controllerId; this.controllerKey = controllerKey; + this.withExternalAuthentication = withExternalAuthentication; } /** @@ -78,9 +89,11 @@ public PairSetupClient(IpTransport ipTransport, byte[] controllerId, Ed25519Priv * @throws InvalidCipherTextException * @throws SecurityException * @throws NoSuchAlgorithmException + * @throws IllegalStateException */ - public Ed25519PublicKeyParameters pair() throws NoSuchAlgorithmException, SecurityException, - InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { + public Ed25519PublicKeyParameters pair() + throws NoSuchAlgorithmException, SecurityException, InvalidCipherTextException, IOException, + InterruptedException, TimeoutException, ExecutionException, IllegalStateException { SRPclient client = m1Execute(); return client.getAccessoryLongTermPublicKey(); } @@ -96,13 +109,15 @@ public Ed25519PublicKeyParameters pair() throws NoSuchAlgorithmException, Securi * @throws InvalidCipherTextException * @throws SecurityException * @throws NoSuchAlgorithmException + * @throws IllegalStateException */ private SRPclient m1Execute() throws IOException, InterruptedException, TimeoutException, ExecutionException, - NoSuchAlgorithmException, SecurityException, InvalidCipherTextException { + NoSuchAlgorithmException, SecurityException, InvalidCipherTextException, IllegalStateException { logger.debug("Pair-Setup M1: Send pairing start request to server"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); - tlv.put(TlvType.METHOD.value, new byte[] { PairingMethod.SETUP.value }); + tlv.put(TlvType.METHOD.value, + new byte[] { withExternalAuthentication ? PairingMethod.SETUP_AUTH.value : PairingMethod.SETUP.value }); loggerTraceTlv(tlv); Validator.validate(PairingMethod.SETUP, tlv); byte[] m1Response = ipTransport.post(ENDPOINT_PAIR_SETUP, CONTENT_TYPE_PAIRING, Tlv8Codec.encode(tlv)); @@ -121,9 +136,11 @@ private SRPclient m1Execute() throws IOException, InterruptedException, TimeoutE * @throws IOException * @throws InvalidCipherTextException * @throws SecurityException + * @throws IllegalStateException */ - private SRPclient m2Execute(byte[] m1Response) throws NoSuchAlgorithmException, SecurityException, - InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { + private SRPclient m2Execute(byte[] m1Response) + throws NoSuchAlgorithmException, SecurityException, InvalidCipherTextException, IOException, + InterruptedException, TimeoutException, ExecutionException, IllegalStateException { logger.debug("Pair-Setup M2: Read server salt and accessory ephemeral PK; initialize SRP client"); Map tlv = Tlv8Codec.decode(m1Response); loggerTraceTlv(tlv); @@ -145,9 +162,10 @@ private SRPclient m2Execute(byte[] m1Response) throws NoSuchAlgorithmException, * @throws IOException * @throws SecurityException * @throws InvalidCipherTextException + * @throws IllegalStateException */ private SRPclient m3Execute(SRPclient client) throws SecurityException, IOException, InterruptedException, - TimeoutException, ExecutionException, InvalidCipherTextException { + TimeoutException, ExecutionException, InvalidCipherTextException, IllegalStateException { logger.debug("Pair-Setup M3: Send controller ephemeral PK and M1 proof to accessory"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M3.value }); @@ -168,9 +186,10 @@ private SRPclient m3Execute(SRPclient client) throws SecurityException, IOExcept * @throws InterruptedException * @throws IOException * @throws InvalidCipherTextException + * @throws IllegalStateException */ - private SRPclient m4Execute(SRPclient client, byte[] m3Response) - throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { + private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws InvalidCipherTextException, IOException, + InterruptedException, TimeoutException, ExecutionException, IllegalStateException { logger.debug("Pair-Setup M4: Read accessory M2 proof; and verify it"); Map tlv = Tlv8Codec.decode(m3Response); loggerTraceTlv(tlv); @@ -190,9 +209,10 @@ private SRPclient m4Execute(SRPclient client, byte[] m3Response) * @throws InterruptedException * @throws IOException * @throws InvalidCipherTextException + * @throws IllegalStateException */ - private SRPclient m5Execute(SRPclient client) - throws IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException { + private SRPclient m5Execute(SRPclient client) throws IOException, InterruptedException, TimeoutException, + ExecutionException, InvalidCipherTextException, IllegalStateException { logger.debug("Pair-Setup M5: Send controller id, LTPK, and signature to accessory"); byte[] cipherText = client.m5EncodeControllerInfoAndSign(controllerId, controllerKey); Map tlv = new LinkedHashMap<>(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java index f913832d1d34c..228eab1d538ac 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java @@ -84,16 +84,26 @@ public PairVerifyClient(IpTransport ipTransport, byte[] controllerId, Ed25519Pri * @throws InterruptedException * @throws IOException * @throws InvalidCipherTextException + * @throws IllegalStateException */ - public AsymmetricSessionKeys verify() - throws IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException { + public AsymmetricSessionKeys verify() throws IOException, InterruptedException, TimeoutException, + ExecutionException, InvalidCipherTextException, IllegalStateException { m1Execute(); return new AsymmetricSessionKeys(readKey, writeKey); } - // M1 — Create new random client ephemeral X25519 public key and send it to server - private void m1Execute() - throws IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException { + /** + * M1 — Create new random client ephemeral X25519 public key and send it to server + * + * @throws IOException + * @throws InterruptedException + * @throws TimeoutException + * @throws ExecutionException + * @throws InvalidCipherTextException + * @throws IllegalStateException + */ + private void m1Execute() throws IOException, InterruptedException, TimeoutException, ExecutionException, + InvalidCipherTextException, IllegalStateException { logger.debug("Pair-Verify M1: Send verification start request with client ephemeral X25519 PK to server"); Map tlv = new LinkedHashMap<>(); tlv.put(TlvType.STATE.value, new byte[] { PairingState.M1.value }); @@ -104,7 +114,16 @@ private void m1Execute() m2Execute(m1Response); } - // M2 — Receive server ephemeral X25519 public key and encrypted TLV + /** + * M2 — Receive server ephemeral X25519 public key and encrypted TLV + * + * @param m1Response + * @throws InvalidCipherTextException + * @throws IOException + * @throws InterruptedException + * @throws TimeoutException + * @throws ExecutionException + */ private void m2Execute(byte[] m1Response) throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { logger.debug("Pair-Verify M2: Read server ephemeral X25519 PK and encrypted id; validate signature"); @@ -133,9 +152,18 @@ private void m2Execute(byte[] m1Response) m3Execute(); } - // M3 — Send encrypted controller identifier and signature - private void m3Execute() - throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { + /** + * M3 — Send encrypted controller identifier and signature + * + * @throws InvalidCipherTextException + * @throws IOException + * @throws InterruptedException + * @throws TimeoutException + * @throws ExecutionException + * @throws IllegalStateException + */ + private void m3Execute() throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, + ExecutionException, IllegalStateException { logger.debug("Pair-Verify M3: Send encrypted controller id with signature"); byte[] clientSignature = signMessage(controllerKey, concat(controllerEphemeralSecretKey.generatePublicKey().getEncoded(), clientPairingId, @@ -158,7 +186,11 @@ private void m3Execute() m4Execute(m3Response); } - // M4 — Final confirmation + /** + * M4 — Final confirmation + * + * @param m3Response + */ private void m4Execute(byte[] m3Response) { logger.debug("Pair-Verify M4: Confirm validation; derive session keys"); Map tlv = Tlv8Codec.decode(m3Response); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java index f5ab385adfba8..04a2509cf3c3f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java @@ -37,7 +37,7 @@ public class HttpPayloadParser { private static final int MAX_HEADER_BLOCK_SIZE = 2048; private static final Pattern CONTENT_LENGTH_PATTERN = Pattern.compile("(?i)^content-length:\\s*(\\d+)$"); private static final Pattern CHUNKED_ENCODING_PATTERN = Pattern.compile("(?i)^transfer-encoding:\\s*chunked$"); - private static final Pattern STATUS_LINE_PATTERN = Pattern.compile("HTTP/\\d\\.\\d\\s+(\\d{3})"); + private static final Pattern STATUS_LINE_PATTERN = Pattern.compile("^(?:HTTP|EVENT)/\\d+\\.\\d+\\s+(\\d{3})"); private final ByteArrayOutputStream headerBuffer = new ByteArrayOutputStream(); private final ByteArrayOutputStream contentBuffer = new ByteArrayOutputStream(); @@ -138,12 +138,17 @@ public boolean isComplete() { return contentBuffer.size() >= contentLength; } // no content-length and not chunked: check status code - int statusCode = getHttpStatusCode(headerBuffer.toByteArray()); - if (statusCode == 204 || (statusCode >= 100 && statusCode < 200)) { - return true; // no-body responses - } - if (statusCode >= 400 && statusCode < 600) { - return true; // treat error responses as complete even without body + try { + int statusCode = getHttpStatusCode(headerBuffer.toByteArray()); + if (statusCode == 204 || (statusCode >= 100 && statusCode < 200)) { + return true; // no-body responses + } + if (statusCode >= 400 && statusCode < 600) { + return true; // treat error responses as complete even without body + } + } catch (IllegalStateException e) { + // malformed status line - treat as complete + return true; } return false; } @@ -155,19 +160,19 @@ public boolean isComplete() { * * @param headerBytes the byte array containing HTTP headers. * @return the extracted HTTP status code as an integer. - * @throws SecurityException if no valid status line is found or if the status code is malformed. + * @throws IllegalStateException if the status line is missing or malformed. */ - public static int getHttpStatusCode(byte[] headerBytes) { + public static int getHttpStatusCode(byte[] headerBytes) throws IllegalStateException { String headers = new String(headerBytes, StandardCharsets.ISO_8859_1); Matcher matcher = STATUS_LINE_PATTERN.matcher(headers); if (matcher.find()) { try { return Integer.parseInt(matcher.group(1)); } catch (NumberFormatException e) { - throw new SecurityException("Malformed HTTP status code: " + matcher.group(1)); + throw new IllegalStateException("Malformed HTTP status code: " + matcher.group(1)); } } - throw new SecurityException("Missing HTTP status line"); + throw new IllegalStateException("Missing HTTP status line"); } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index ffc661f159411..eec2e5afacfa5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -16,11 +16,10 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; import java.io.IOException; -import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; -import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.concurrent.atomic.AtomicInteger; @@ -38,7 +37,9 @@ @NonNullByDefault public class SecureSession { - private final InputStream in; + private static final int SLEEP_INTERVAL_MILLISECONDS = 50; + + private final DataInputStream in; private final OutputStream out; private final byte[] writeKey; private final byte[] readKey; @@ -46,7 +47,7 @@ public class SecureSession { private final AtomicInteger readCounter = new AtomicInteger(0); public SecureSession(Socket socket, AsymmetricSessionKeys keys) throws IOException { - in = socket.getInputStream(); + in = new DataInputStream(socket.getInputStream()); out = socket.getOutputStream(); writeKey = keys.getWriteKey(); readKey = keys.getReadKey(); @@ -89,48 +90,59 @@ private void sendFrame(ByteArrayInputStream plainTextStream) throws IOException, /** * Reads multiple data frames from the input stream until a complete HTTP message is reconstructed. - * Repeatedly calls receiveFrame() to read and decrypt individual frames. It accumulates the decrypted - * plaintext until it detects the end of the HTTP message. The end of the message is determined by checking - * for the presence of complete HTTP headers and a completed Content-Length, or a complete chunked payload. + * Repeatedly whenever there is data available on the input stream, it calls receiveFrame() to read and + * decrypt a frame. It accumulates the decrypted plaintext until it detects the end of the HTTP message. + * The end of the message is determined by checking for the presence of complete HTTP headers and a + * completed Content-Length, or a complete chunked payload. * * @param trace if true, captures the raw decrypted frames for debugging purposes. * @return a 3D byte array where the first element is the HTTP headers, the second element is the content, * and the third is the raw trace (if enabled). * @throws IOException * @throws InvalidCipherTextException + * @throws IllegalStateException if the received data is malformed */ - public byte[][] receive(boolean trace) - throws IOException, InvalidCipherTextException, BufferUnderflowException, SecurityException { + public byte[][] receive(boolean trace) throws IOException, InvalidCipherTextException, IllegalStateException { HttpPayloadParser httpParser = new HttpPayloadParser(); - ByteArrayOutputStream raw = trace ? new ByteArrayOutputStream() : null; + ByteArrayOutputStream traceStream = new ByteArrayOutputStream(); do { - byte[] frame = receiveFrame(); - if (raw != null) { - raw.write(frame); + if (in.available() == 0) { + try { + Thread.sleep(SLEEP_INTERVAL_MILLISECONDS); // wait for data to arrive + } catch (InterruptedException e) { + // coninue + } + } else { + byte[] frame = receiveFrame(); + if (trace) { + traceStream.write(frame); + } + httpParser.accept(frame); } - httpParser.accept(frame); } while (!httpParser.isComplete()); - return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), - raw != null ? raw.toByteArray() : new byte[0] }; + return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), traceStream.toByteArray() }; } /** - * Reads a single frame from the input stream, decrypts it, and returns the plaintext. Reads the 2-byte length - * prefix, retrieves the corresponding ciphertext, and decrypts it. The length prefix is included in the cipher - * AAD to ensure integrity. The read counter is incremented after reading the frame to ensure nonce uniqueness. + * Reads a single frame from the input stream, decrypts it, and returns the plaintext. Blocks until a full frame + * is received or an IO exception occurs. Reads the 2-byte length prefix, retrieves the corresponding ciphertext, + * and decrypts it. The length prefix is included in the cipher AAD to ensure integrity. The read counter is + * incremented after reading the frame to ensure nonce uniqueness. * * @return the decrypted plaintext of the single frame. * @throws IOException * @throws InvalidCipherTextException + * @throws IllegalStateException if the frame length is invalid */ - private byte[] receiveFrame() - throws IOException, InvalidCipherTextException, BufferUnderflowException, SecurityException { - byte[] frameAad = in.readNBytes(2); + private byte[] receiveFrame() throws IOException, InvalidCipherTextException, IllegalStateException { + byte[] frameAad = new byte[2]; // AAD data length prefix + in.readFully(frameAad, 0, frameAad.length); short frameLen = ByteBuffer.wrap(frameAad).order(ByteOrder.LITTLE_ENDIAN).getShort(); if (frameLen < 0 || frameLen > 1024) { - throw new SecurityException("Invalid frame length"); + throw new IllegalStateException("Invalid frame length"); } - byte[] cipherText = in.readNBytes(frameLen + 16); // read 16 extra bytes for the auth tag + byte[] cipherText = new byte[frameLen + 16]; // read 16 extra bytes for the auth tag + in.readFully(cipherText, 0, cipherText.length); byte[] nonce64 = generateNonce64(readCounter.getAndIncrement()); return decrypt(readKey, nonce64, cipherText, frameAad); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index d068e9c96fe86..005b4153b9215 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -18,10 +18,9 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.Socket; -import java.net.SocketTimeoutException; -import java.nio.BufferUnderflowException; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.time.Instant; import java.util.Arrays; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -33,7 +32,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import org.bouncycastle.crypto.InvalidCipherTextException; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; @@ -54,12 +52,11 @@ @NonNullByDefault public class IpTransport implements AutoCloseable { - private static final int TIMEOUT_MILLI_SECONDS = (int) Duration.ofSeconds(10).toMillis(); + private static final int TIMEOUT_MILLI_SECONDS = 10000; + private static final Duration MINIMUM_REQUEST_INTERVAL = Duration.ofMillis(200); private final Logger logger = LoggerFactory.getLogger(IpTransport.class); - - private final ExecutorService writeExecutor = Executors - .newSingleThreadExecutor(r -> new Thread(r, "homekit-writer")); + private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "homekit-io")); private final String host; // ip address with optional port e.g. "192.168.1.42:9123" private final Socket socket; @@ -67,9 +64,10 @@ public class IpTransport implements AutoCloseable { private @Nullable SecureSession secureSession = null; private @Nullable Thread readThread = null; - private @Nullable CompletableFuture readFuture = null; + private @Nullable CompletableFuture readHttpResponseFuture = null; private boolean closing = false; + private Instant earliestNextRequestTime = Instant.MIN; /** * Creates a new IpTransport instance with the given socket and session keys. @@ -92,75 +90,136 @@ public IpTransport(String host) throws IOException, IllegalArgumentException { int port = Integer.parseInt(parts[1]); socket = new Socket(); socket.connect(new InetSocketAddress(ipAddress, port), TIMEOUT_MILLI_SECONDS); // connect timeout - socket.setSoTimeout(TIMEOUT_MILLI_SECONDS); // read timeout socket.setKeepAlive(false); // HAP spec forbids TCP keepalive logger.debug("Connected to {}", host); } public void setSessionKeys(AsymmetricSessionKeys keys) throws IOException { secureSession = new SecureSession(socket, keys); - Thread thread = new Thread(this::readTask, "homekit-reader"); - thread.start(); + Thread thread = new Thread(this::readTask, "homekit-thread"); readThread = thread; + thread.start(); } + /** + * Sends a GET request to the specified endpoint with the given content type. + * + * @param endpoint the endpoint to which the request is sent + * @param contentType the content type of the request + * @return the response content as a byte array + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + * @throws ExecutionException if an error occurs during execution + * @throws IllegalStateException if the state is invalid + */ public byte[] get(String endpoint, String contentType) - throws IOException, InterruptedException, TimeoutException, ExecutionException { + throws IOException, InterruptedException, ExecutionException, IllegalStateException, TimeoutException { return execute("GET", endpoint, contentType, new byte[0]); } + /** + * Sends a POST request to the specified endpoint with the given content type and content. + * + * @param endpoint the endpoint to which the request is sent + * @param contentType the content type of the request + * @param content the content of the request + * @return the response content as a byte array + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + * @throws ExecutionException if an error occurs during execution + * @throws IllegalStateException if the state is invalid + */ public byte[] post(String endpoint, String contentType, byte[] content) - throws IOException, InterruptedException, TimeoutException, ExecutionException { + throws IOException, InterruptedException, ExecutionException, IllegalStateException, TimeoutException { return execute("POST", endpoint, contentType, content); } + /** + * Sends a PUT request to the specified endpoint with the given content type and content. + * + * @param endpoint the endpoint to which the request is sent + * @param contentType the content type of the request + * @param content the content of the request + * @return the response content as a byte array + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + * @throws ExecutionException if an error occurs during execution + * @throws IllegalStateException if the state is invalid + */ public byte[] put(String endpoint, String contentType, byte[] content) - throws IOException, InterruptedException, TimeoutException, ExecutionException { + throws IOException, InterruptedException, ExecutionException, IllegalStateException, TimeoutException { return execute("PUT", endpoint, contentType, content); } + /** + * Executes an HTTP request with the specified method, endpoint, content type, and content. + * + * @param method the HTTP method (e.g., "GET", "POST", "PUT") + * @param endpoint the endpoint to which the request is sent + * @param contentType the content type of the request + * @param content the content of the request + * @return the response content as a byte array + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + * @throws ExecutionException if an error occurs during execution + * @throws IllegalStateException if the state is invalid + */ private synchronized byte[] execute(String method, String endpoint, String contentType, byte[] content) - throws IOException, InterruptedException, TimeoutException, ExecutionException { + throws IOException, InterruptedException, ExecutionException, IllegalStateException, TimeoutException { byte[] request = buildRequest(method, endpoint, contentType, content); + Duration delay = Duration.between(Instant.now(), earliestNextRequestTime); + if (delay.isPositive()) { + Thread.sleep(delay.toMillis()); // rate limit the HTTP requests + } + boolean trace = logger.isTraceEnabled(); if (trace) { - logger.trace("Request:\n{}", new String(request, StandardCharsets.ISO_8859_1)); + logger.trace("HTTP request:\n{}", new String(request, StandardCharsets.ISO_8859_1)); } byte[][] response; // 0 = headers, 1 = content, 2 = raw trace (if enabled) + earliestNextRequestTime = Instant.now().plus(MINIMUM_REQUEST_INTERVAL); // assume zero processing time if (secureSession instanceof SecureSession secureSession) { // before we write request, create CompletableFuture to read response (with a timeout) - CompletableFuture readFuture = new CompletableFuture<>(); - this.readFuture = readFuture; + CompletableFuture readHttpResponseFuture = new CompletableFuture<>(); + this.readHttpResponseFuture = readHttpResponseFuture; // create Future to write the request (with a timeout) - Future<@Nullable Void> writeTask = writeExecutor.submit(() -> { + Future<@Nullable Void> writeTask = executor.submit(() -> { secureSession.send(request); return null; }); // now wait for both write and read to complete writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); - response = readFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + response = readHttpResponseFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); } else { OutputStream out = socket.getOutputStream(); InputStream in = socket.getInputStream(); // create Future to write the request (with a timeout) - Future<@Nullable Void> writeTask = writeExecutor.submit(() -> { + Future<@Nullable Void> writeTask = executor.submit(() -> { out.write(request); out.flush(); return null; }); - // now wait for both write and read to complete + // wait for write to complete writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); - response = readPlainResponse(in, trace); + // create Future to read the response (with a timeout) + Future readTask = executor.submit(() -> readPlainResponse(in, trace)); + // wait for read to complete + response = readTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); } + earliestNextRequestTime = Instant.now().plus(MINIMUM_REQUEST_INTERVAL); // allow actual processing time if (response.length != 3) { throw new IOException("Response must contain 3 arrays"); } if (trace) { - logger.trace("Response:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); + logger.trace("HTTP response:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); } checkHeaders(response[0]); @@ -237,8 +296,9 @@ private byte[][] readPlainResponse(InputStream in, boolean trace) throws IOExcep * Checks the HTTP headers for a successful response (status code < 300). * * @throws IOException if the response indicates an error. + * @throws IllegalStateException if the headers are invalid. */ - private void checkHeaders(byte[] headers) throws IOException { + private void checkHeaders(byte[] headers) throws IOException, IllegalStateException { int httpStatusCode = HttpPayloadParser.getHttpStatusCode(headers); if (httpStatusCode >= 300) { throw new IOException("HTTP " + httpStatusCode); @@ -259,6 +319,7 @@ public void close() { } catch (IOException | InterruptedException e) { // shut down quietly } + readThread = null; } /** @@ -267,17 +328,20 @@ public void close() { * @param response the received response as a 3D byte array */ private void handleResponse(byte[][] response) { - if (readFuture instanceof CompletableFuture future) { - readFuture = null; - future.complete(response); - } String headers = new String(response[0], StandardCharsets.ISO_8859_1); - if (headers.startsWith("EVENT")) { - logger.trace("Event:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); + if (headers.startsWith("HTTP")) { + if (readHttpResponseFuture instanceof CompletableFuture future) { + readHttpResponseFuture = null; + future.complete(response); + } + } else if (headers.startsWith("EVENT")) { + logger.trace("HTTP event:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); String jsonContent = new String(response[1], StandardCharsets.UTF_8); for (EventListener eventListener : eventListeners) { eventListener.onEvent(jsonContent); } + } else { + logger.warn("Unexpected response headers:\n{}", headers); } } @@ -296,25 +360,20 @@ private void readTask() { } byte[][] response = session.receive(logger.isTraceEnabled()); handleResponse(response); - } catch (SocketTimeoutException e) { // ignore socket timeout; continue listening - } catch (IllegalStateException | InvalidCipherTextException | IOException | BufferUnderflowException - | SecurityException e) { + } catch (Exception e) { + // catch all; capture cause and exit cause = e; break; } } while (!Thread.currentThread().isInterrupted()); - if (readFuture instanceof CompletableFuture future) { - readFuture = null; + if (readHttpResponseFuture instanceof CompletableFuture future) { + readHttpResponseFuture = null; future.completeExceptionally(cause != null ? cause : new InterruptedException("Listener interrupted")); } if (cause != null && !closing) { - if (logger.isTraceEnabled()) { - logger.trace("Error while listening for events", cause); - } else { - logger.debug("Error while listening for events {}", cause.getMessage()); - } + logger.debug("Error '{}' while listening for HTTP responses", cause.getMessage(), cause); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 6831c5f049916..e05972d1a42b3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -36,6 +36,8 @@ error.not-paired = Not paired actions.pairing-action.label = Pair Accessory or Bridge actions.pairing-action.description = Create a pairing between this thing and the respective accessory or bridge. +actions.pairing-auth.label = With External Authentication +actions.pairing-auth.description = Set 'true' if pairing requires external authentication e.g. from an app (default false). actions.pairing-code.label = Pairing Code actions.pairing-code.description = The 8 digit pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. actions.unpairing-action.label = Unpair Accessory or Bridge diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index 2d4840a59e692..ede5d2b370279 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -102,7 +102,8 @@ void testPairSetup() throws NoSuchAlgorithmException, SecurityException, Invalid // create SRP client and server SRPserver server = new SRPserver(password, serverSalt, accessoryId, accessoryLongTermSecretKey, null, null); - PairSetupClient client = new PairSetupClient(mockTransport, iOSDeviceId, controllerLongTermSecretKey, password); + PairSetupClient client = new PairSetupClient(mockTransport, iOSDeviceId, controllerLongTermSecretKey, password, + false); // mock the HTTP transport to simulate the SRP exchange doAnswer(invocation -> { From fd286e4e3b97e32a48e717a16f2a56472fc1f572 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 4 Nov 2025 17:49:06 +0000 Subject: [PATCH 112/177] various - fetch accessories once only at top level - subscribe to events once only at top level - use keep-alive at http and socket level Signed-off-by: Andrew Fiddian-Green --- .../HomekitChildDiscoveryService.java | 2 +- .../handler/HomekitAccessoryHandler.java | 72 +++----- .../handler/HomekitBaseAccessoryHandler.java | 165 +++++++++++++----- .../handler/HomekitBridgeHandler.java | 68 +++----- .../internal/transport/IpTransport.java | 26 +-- 5 files changed, 177 insertions(+), 156 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 380a6fe9776d5..355b882fb35a2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -46,7 +46,7 @@ public HomekitChildDiscoveryService() { @Override public void startScan() { if (thingHandler instanceof HomekitBridgeHandler handler) { - discoverChildren(handler.getThing(), handler.getAccessories()); + discoverChildren(handler.getThing(), handler.getAccessories().values()); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index f6ddce1fb23b6..7cf64db932dd8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -18,7 +18,6 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -128,7 +127,7 @@ private record LightModelLink(Channel channel, CharacteristicType cxxType, Integ } private final List lightModelLinks = new ArrayList<>(); - private final Set eventedChannels = new HashSet<>(); + private final List eventedCharacteristics = new ArrayList<>(); private @Nullable Channel stopMoveChannel = null; // channel for the stop button (rollershutters) private @Nullable ScheduledFuture refreshTask; @@ -143,8 +142,9 @@ public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, @Override protected void accessoriesLoaded() { - logger.debug("Thing accessories loaded {}", accessories.size()); - createChannels(); // create channels based on the fetched accessories + createProperties(); + createChannels(); + startRefreshTask(); } /** @@ -152,7 +152,7 @@ protected void accessoriesLoaded() { * and the channels and properties created. Sets up a scheduled task to periodically refresh the state * of the accessory. And subscribes to evented channels if applicable. */ - private void channelsAndPropertiesLoaded() { + private void startRefreshTask() { if (getConfig().get(CONFIG_REFRESH_INTERVAL) instanceof Object refreshInterval) { try { int refreshIntervalSeconds = Integer.parseInt(refreshInterval.toString()); @@ -170,44 +170,6 @@ private void channelsAndPropertiesLoaded() { if (refreshTask == null) { logger.warn("Invalid refresh interval configuration, polling disabled"); } - - if (eventedChannels.isEmpty()) { - try { - unsubscribeEvents(); - } catch (IllegalAccessException | IllegalStateException e) { - logger.warn("Unexpected error '{}' unsubscribing evented channels", e.getMessage()); - } - } else { - Service service = new Service(); - service.characteristics = new ArrayList<>(); - for (Channel channel : eventedChannels) { - if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { - Characteristic characteristic = new Characteristic(); - characteristic.iid = Integer.parseInt(iid); - characteristic.aid = getAccessoryId(); - characteristic.ev = true; // enable events - service.characteristics.add(characteristic); - } - } - try { - getRwService().writeCharacteristic(GSON.toJson(service)); - subscribeEvents(); - logger.debug("Eventing enabled for {} channels", eventedChannels.size()); - } catch (InterruptedException e) { - // shutting down; do nothing - } catch (Exception e) { - if (isCommunicationException(e)) { - // communication exception; log at debug and try to reconnect - logger.debug("Communication error '{}' subscribing to evented channels, reconnecting..", - e.getMessage()); - scheduleConnectionAttempt(); - } else { - // other exception; log at warn and don't try to reconnect - logger.warn("Unexpected error '{}' subscribing to evented channels", e.getMessage()); - } - logger.debug("Stack trace", e); - } - } } /** @@ -387,6 +349,7 @@ && getStateDescription(channel) instanceof StateDescriptionFragment stateDescrip * Each service creates a channel group, and each characteristic creates a channel within it. */ private void createChannels() { + Map accessories = getAccessories(); if (accessories.isEmpty()) { return; } @@ -496,8 +459,6 @@ private void createChannels() { updateThing(builder.build()); logger.debug("Updated thing {} channels, {} properties, label {}, tag {}", channels.size(), properties.size(), newLabel, newTag); - - channelsAndPropertiesLoaded(); } } @@ -585,7 +546,7 @@ public void dispose() { lightModel = null; lightModelLinks.clear(); lightModelClientHSBTypeChannel = null; - eventedChannels.clear(); + eventedCharacteristics.clear(); super.dispose(); } @@ -607,6 +568,9 @@ private void refresh() { try { String json = getRwService().readCharacteristic(String.join(",", queries)); updateChannelsFromJson(json); + if (thing.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } return; } catch (InterruptedException e) { // shutting down; do nothing @@ -830,20 +794,23 @@ private void stopMoveFinalize(Accessory accessory, List channels) { * @param channels the list of channels to check */ private void eventingFinalize(Accessory accessory, List channels) { - eventedChannels.clear(); + eventedCharacteristics.clear(); for (Channel channel : channels) { if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { for (Service service : accessory.services) { for (Characteristic cxx : service.characteristics) { if (iid.equals(String.valueOf(cxx.iid)) && cxx.perms instanceof List perms && perms.contains("ev")) { - eventedChannels.add(channel); + Characteristic eventedCxx = new Characteristic(); + eventedCxx.iid = Integer.parseInt(iid); + eventedCxx.aid = getAccessoryId(); + eventedCharacteristics.add(eventedCxx); } } } } } - logger.debug("Identified {} evented channels", eventedChannels.size()); + logger.debug("Identified {} evented channels", eventedCharacteristics.size()); } /** @@ -1005,7 +972,12 @@ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { super.bridgeStatusChanged(bridgeStatusInfo); if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE && thing.getStatus() != ThingStatus.ONLINE) { updateStatus(ThingStatus.ONLINE); - channelsAndPropertiesLoaded(); + startRefreshTask(); } } + + @Override + protected List getEventedCharacteristics() { + return eventedCharacteristics; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index acb3f3dacc87e..4c1b8f2d049b5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -18,12 +18,13 @@ import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; +import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -38,6 +39,9 @@ import org.openhab.binding.homekit.internal.action.HomekitPairingActions; import org.openhab.binding.homekit.internal.dto.Accessories; import org.openhab.binding.homekit.internal.dto.Accessory; +import org.openhab.binding.homekit.internal.dto.Characteristic; +import org.openhab.binding.homekit.internal.dto.Service; +import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; import org.openhab.binding.homekit.internal.hap_services.PairRemoveClient; import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; @@ -54,6 +58,7 @@ import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.BaseThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.thing.type.ChannelDefinition; import org.osgi.framework.Bundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -79,7 +84,8 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); - protected final Map accessories = new HashMap<>(); + private final Map accessories = new ConcurrentHashMap<>(); + protected final HomekitTypeProvider typeProvider; protected final HomekitKeyStore keyStore; protected final TranslationProvider i18nProvider; @@ -108,8 +114,8 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { try { - unsubscribeEvents(); - } catch (IllegalAccessException | IllegalStateException e) { + enableEventsOrThrow(false); + } catch (Exception e) { // closing; ignore } if (connectionAttemptTask instanceof ScheduledFuture task) { @@ -124,10 +130,7 @@ public void dispose() { } /** - * Get information about embedded accessories and their respective channels. - * Uses the /accessories endpoint. - * Returns an empty list if there was a problem. - * Requires a valid secure session. + * Get information about embedded accessories and their respective channels from the /accessories endpoint. * * @return list of accessories (may be empty) * @see HomeKit HTTP @@ -143,7 +146,7 @@ private void fetchAccessories() { .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } logger.debug("Fetched {} accessories", accessories.size()); - scheduler.submit(this::accessoriesLoaded); // notify subclass in scheduler thread + scheduler.submit(this::accessoriesLoadedTask); // notify subclass (and children) in scheduler thread } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect @@ -157,6 +160,14 @@ private void fetchAccessories() { } } + /** + * Processes the loaded accessories by calling the overloaded abstract methods, and then enables eventing. + */ + private void accessoriesLoadedTask() { + accessoriesLoaded(); + enableEvents(true); + } + /** * Called when the thing handler has been initialized, the pairing verified, and the accessories have been loaded. * Subclasses override this to perform any processing required. @@ -195,17 +206,9 @@ public void handleRemoval() { @Override public void initialize() { - if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + if (getBridge() instanceof Bridge) { // accessory is hosted by a bridge, so use bridge's pairing session and read/write service isChildAccessory = true; - try { - bridgeHandler.getRwService(); // ensure that bridge has a read/write service - fetchAccessories(); - updateStatus(ThingStatus.ONLINE); - } catch (IllegalAccessException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, - i18nProvider.getText(bundle, "error.bridge-not-connected", "Bridge not connected", null)); - } } else { // standalone accessory or bridge accessory, so do pairing and session setup here isChildAccessory = false; @@ -260,8 +263,11 @@ private synchronized void verifyPairing() { } } - public Collection getAccessories() { - return accessories.values(); + public Map getAccessories() { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + return bridgeHandler.getAccessories(); + } + return accessories; } /** @@ -341,30 +347,8 @@ protected CharacteristicReadWriteClient getRwService() throws IllegalAccessExcep return rwService; } - /** - * Subscribes to events from the IP transport. - * - * @throws IllegalStateException - * @throws IllegalAccessException - */ - protected void subscribeEvents() throws IllegalAccessException, IllegalStateException { - getIpTransport().subscribe(this); - } - - /** - * Unsubscribes from events from the IP transport. - * - * @throws IllegalStateException - * @throws IllegalAccessException - */ - protected void unsubscribeEvents() throws IllegalAccessException, IllegalStateException { - getIpTransport().unsubscribe(this); - } - @Override - public void onEvent(String jsonContent) { - // default implementation does nothing; subclasses must override - } + public abstract void onEvent(String jsonContent); @Override public Collection> getServices() { @@ -403,7 +387,7 @@ public Collection> getServices() { private @Nullable IpTransport checkedCreateIpTransport(String hostName) { try { - IpTransport ipTransport = new IpTransport(hostName); + IpTransport ipTransport = new IpTransport(hostName, this); this.ipTransport = ipTransport; return ipTransport; } catch (IOException e) { @@ -514,4 +498,97 @@ protected boolean isCommunicationException(Throwable throwable) { : (throwable instanceof ExecutionException outer) && (outer.getCause() instanceof Throwable inner) && (inner instanceof IOException || inner instanceof TimeoutException) ? true : false; } + + /** + * Creates properties for the accessory based on the characteristics within the ACCESSORY_INFORMATION + * service (if any). + */ + protected void createProperties() { + Map accessories = getAccessories(); + if (accessories.isEmpty()) { + return; + } + Integer accessoryId = getAccessoryId(); + if (accessoryId == null) { + return; + } + Accessory accessory = accessories.get(accessoryId); + if (accessory == null) { + return; + } + // search for the accessory information service and collect its properties + for (Service service : accessory.services) { + if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { + for (Characteristic characteristic : service.characteristics) { + ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thing.getUID(), + typeProvider, i18nProvider, bundle); + if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { + String name = channelDef.getId(); + if (channelDef.getLabel() instanceof String value) { + thing.setProperty(name, value); + } + } + } + break; // only one accessory information service per accessory + } + } + } + + /** + * Gets the list of characteristics that should be evented (subscribed to). + * Subclasses must implement this to return the relevant list of characteristics. + * + * @return list of evented characteristics + */ + protected abstract List getEventedCharacteristics(); + + /** + * Wrapper to enable or disable events with exception handling. + * + * @param enable true to enable events, false to disable + */ + private void enableEvents(boolean enable) { + try { + enableEventsOrThrow(enable); + } catch (InterruptedException e) { + // shutting down; do nothing + } catch (Exception e) { + if (isCommunicationException(e)) { + // communication exception; log at debug and try to reconnect + logger.debug("Communication error '{}' subscribing to events, reconnecting..", e.getMessage()); + scheduleConnectionAttempt(); + } else { + // other exception; log at warn and don't try to reconnect + logger.warn("Unexpected error '{}' subscribing to events", e.getMessage()); + } + logger.debug("Stack trace", e); + } + } + + /** + * Inner method to enable or disable events for the characteristics returned by getEventedCharacteristics(). + * All exceptions are thrown upwards to the caller. + * + * @param enable true to enable events, false to disable + * @throws IllegalStateException if this is a child accessory or if the read/write service is not initialized + * @throws IllegalAccessException if this is a child accessory + * @throws IOException if there is a communication error + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the operation times out + * @throws ExecutionException if there is an execution error + */ + private void enableEventsOrThrow(boolean enable) throws IllegalStateException, IllegalAccessException, IOException, + InterruptedException, TimeoutException, ExecutionException { + if (isChildAccessory) { + return; // child accessories delegate to bridge + } + Service service = new Service(); + service.characteristics = new ArrayList<>(); + service.characteristics.addAll(getEventedCharacteristics().stream().map(characteristic -> { + characteristic.ev = enable; + return characteristic; + }).toList()); + getRwService().writeCharacteristic(GSON.toJson(service)); + logger.debug("Eventing {}abled for {} channels", enable ? "en" : "dis", service.characteristics.size()); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index c6b1fc11f33d6..a4dfb3e8cc39f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -12,18 +12,15 @@ */ package org.openhab.binding.homekit.internal.handler; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.FAKE_PROPERTY_CHANNEL_TYPE_UID; - +import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.action.HomekitPairingActions; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; -import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.dto.Characteristic; -import org.openhab.binding.homekit.internal.dto.Service; -import org.openhab.binding.homekit.internal.enums.ServiceType; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.i18n.TranslationProvider; @@ -34,11 +31,8 @@ import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.thing.binding.builder.BridgeBuilder; -import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.types.Command; import org.osgi.framework.Bundle; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Handler for HomeKit bridge devices. @@ -53,8 +47,6 @@ @NonNullByDefault public class HomekitBridgeHandler extends HomekitBaseAccessoryHandler implements BridgeHandler { - private final Logger logger = LoggerFactory.getLogger(HomekitBridgeHandler.class); - public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, TranslationProvider i18nProvider, Bundle bundle) { super(bridge, typeProvider, keyStore, i18nProvider, bundle); @@ -96,8 +88,21 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { @Override protected void accessoriesLoaded() { - logger.debug("Bridge accessories loaded {}", accessories.size()); - createProperties(); // create properties from accessory information + createProperties(); + getThing().getThings().forEach(thing -> { + if (thing.getHandler() instanceof HomekitAccessoryHandler homekitAccessoryHandler) { + homekitAccessoryHandler.accessoriesLoaded(); + } + }); + } + + @Override + public void onEvent(String jsonContent) { + getThing().getThings().forEach(thing -> { + if (thing.getHandler() instanceof HomekitAccessoryHandler homekitAccessoryHandler) { + homekitAccessoryHandler.onEvent(jsonContent); + } + }); } @Override @@ -110,37 +115,14 @@ public Collection> getServices() { return Set.of(HomekitChildDiscoveryService.class, HomekitPairingActions.class); } - /** - * Creates properties for the bridge based on the characteristics within the ACCESSORY_INFORMATION - * service (if any). - */ - private void createProperties() { - if (accessories.isEmpty()) { - return; - } - Integer accessoryId = getAccessoryId(); - if (accessoryId == null) { - return; - } - Accessory accessory = accessories.get(accessoryId); - if (accessory == null) { - return; - } - // search for the accessory information service and collect its properties - for (Service service : accessory.services) { - if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { - for (Characteristic characteristic : service.characteristics) { - ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thing.getUID(), - typeProvider, i18nProvider, bundle); - if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { - String name = channelDef.getId(); - if (channelDef.getLabel() instanceof String value) { - thing.setProperty(name, value); - } - } - } - break; // only one accessory information service per accessory + @Override + protected List getEventedCharacteristics() { + List result = new ArrayList<>(); + getThing().getThings().forEach(thing -> { + if (thing.getHandler() instanceof HomekitAccessoryHandler homekitAccessoryHandler) { + result.addAll(homekitAccessoryHandler.getEventedCharacteristics()); } - } + }); + return result; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 005b4153b9215..8445f933a96a5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -22,9 +22,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Arrays; -import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -60,7 +58,7 @@ public class IpTransport implements AutoCloseable { private final String host; // ip address with optional port e.g. "192.168.1.42:9123" private final Socket socket; - private final Set eventListeners = ConcurrentHashMap.newKeySet(); + private final EventListener eventListener; private @Nullable SecureSession secureSession = null; private @Nullable Thread readThread = null; @@ -70,13 +68,13 @@ public class IpTransport implements AutoCloseable { private Instant earliestNextRequestTime = Instant.MIN; /** - * Creates a new IpTransport instance with the given socket and session keys. + * Creates a new IpTransport instance on the given host. * * @param host the IP address and port of the HomeKit accessory * @throws IOException * @throws IllegalArgumentException if the host or port are invalid */ - public IpTransport(String host) throws IOException, IllegalArgumentException { + public IpTransport(String host, EventListener listener) throws IOException, IllegalArgumentException { logger.debug("Connecting to {}", host); this.host = host; String[] parts = host.split(":"); @@ -88,9 +86,11 @@ public IpTransport(String host) throws IOException, IllegalArgumentException { } String ipAddress = parts[0]; int port = Integer.parseInt(parts[1]); + eventListener = listener; socket = new Socket(); socket.connect(new InetSocketAddress(ipAddress, port), TIMEOUT_MILLI_SECONDS); // connect timeout - socket.setKeepAlive(false); // HAP spec forbids TCP keepalive + // keep-alive is forbiddden for accessories, but client should use it to avoid multiple reconnection + socket.setKeepAlive(true); logger.debug("Connected to {}", host); } @@ -247,6 +247,7 @@ private byte[] buildRequest(String method, String endpoint, String contentType, } else { sb.append("Content-Length: 0\r\n"); } + sb.append("Connection: keep-alive\r\n"); // force keep-alive sb.append("\r\n"); byte[] headerBytes = sb.toString().getBytes(StandardCharsets.UTF_8); @@ -309,7 +310,6 @@ private void checkHeaders(byte[] headers) throws IOException, IllegalStateExcept public void close() { closing = true; secureSession = null; - eventListeners.clear(); try { socket.close(); if (readThread instanceof Thread thread) { @@ -337,9 +337,7 @@ private void handleResponse(byte[][] response) { } else if (headers.startsWith("EVENT")) { logger.trace("HTTP event:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); String jsonContent = new String(response[1], StandardCharsets.UTF_8); - for (EventListener eventListener : eventListeners) { - eventListener.onEvent(jsonContent); - } + eventListener.onEvent(jsonContent); } else { logger.warn("Unexpected response headers:\n{}", headers); } @@ -376,12 +374,4 @@ private void readTask() { logger.debug("Error '{}' while listening for HTTP responses", cause.getMessage(), cause); } } - - public void subscribe(EventListener listener) { - eventListeners.add(listener); - } - - public void unsubscribe(EventListener listener) { - eventListeners.remove(listener); - } } From 256580e6d4af50f2dc57175c8695a2629fa968d3 Mon Sep 17 00:00:00 2001 From: Jacob Laursen Date: Mon, 3 Nov 2025 21:32:42 +0100 Subject: [PATCH 113/177] Add support for manual configuration Signed-off-by: Jacob Laursen --- .../internal/HomekitBindingConstants.java | 2 +- .../HomekitChildDiscoveryService.java | 5 +++-- .../HomekitMdnsDiscoveryParticipant.java | 2 +- .../handler/HomekitBaseAccessoryHandler.java | 18 ++++++++--------- .../resources/OH-INF/i18n/homekit.properties | 8 ++++++++ .../resources/OH-INF/thing/thing-types.xml | 20 +++++++++++++++++++ 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 50acd472409e2..a5816d465ee64 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -47,9 +47,9 @@ public class HomekitBindingConstants { // configuration parameters public static final String CONFIG_HOST = "host"; public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; + public static final String CONFIG_ACCESSORY_ID = "accessoryID"; // thing properties - public static final String PROPERTY_ACCESSORY_UID = "accessoryUID"; public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 355b882fb35a2..b9741395a1b92 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -59,8 +59,9 @@ private void discoverChildren(Thing bridge, Collection accessories) { .withBridge(bridge.getUID()) // .withLabel(THING_LABEL_FMT.formatted(thingLabel, bridge.getLabel())) // .withProperty(CONFIG_HOST, "n/a") // - .withProperty(PROPERTY_ACCESSORY_UID, uid.toString()) // - .withRepresentationProperty(PROPERTY_ACCESSORY_UID).build()); + .withProperty(Thing.PROPERTY_MAC_ADDRESS, "n/a") // + .withProperty(CONFIG_ACCESSORY_ID, aid.toString()) // + .withRepresentationProperty(CONFIG_ACCESSORY_ID).build()); } }); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index b5ba274ea1e9a..4a9cd2bb6bc0e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -89,7 +89,7 @@ public String getServiceType() { .withProperty(CONFIG_HOST, host) // .withProperty(Thing.PROPERTY_MAC_ADDRESS, mac) // .withProperty(PROPERTY_ACCESSORY_CATEGORY, category.toString()) // - .withProperty(PROPERTY_ACCESSORY_UID, new ThingUID(THING_TYPE_ACCESSORY, "1").toString()) // + .withProperty(CONFIG_ACCESSORY_ID, "1".toString()) // .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); if (properties.get("md") instanceof String model) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 4c1b8f2d049b5..d7c8b24ef1fd6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -15,6 +15,7 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.io.IOException; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; @@ -55,7 +56,6 @@ import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; -import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.BaseThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.thing.type.ChannelDefinition; @@ -176,14 +176,14 @@ private void accessoriesLoadedTask() { protected abstract void accessoriesLoaded(); /** - * Extracts the accessory ID from the 'Accessory UID' property. + * Returns the accessory ID from the 'AccessoryID' configuration parameter. * * @return the accessory ID, or null if it cannot be determined */ protected @Nullable Integer getAccessoryId() { - if (thing.getProperties().get(PROPERTY_ACCESSORY_UID) instanceof String accessoryUid) { + if (getConfig().get(CONFIG_ACCESSORY_ID) instanceof BigDecimal accessoryId) { try { - return Integer.parseInt(new ThingUID(accessoryUid).getId()); + return accessoryId.intValue(); } catch (NumberFormatException e) { } } @@ -367,10 +367,10 @@ public Collection> getServices() { } private @Nullable String checkedMacAddress() { - String macAddress = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); - if (macAddress == null) { + if (!(getConfig().get(Thing.PROPERTY_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.missing-mac-address", "Missing MAC address", null)); + return null; } return macAddress; } @@ -458,9 +458,9 @@ private boolean unpairInner() { logger.warn("Cannot unpair child accessory '{}'", thing.getUID()); return false; } - String macAddress = getThing().getProperties().get(Thing.PROPERTY_MAC_ADDRESS); - if (macAddress == null) { - logger.warn("Cannot unpair accessory '{}' due to missing mac address property", thing.getUID()); + + if (!(getConfig().get(Thing.PROPERTY_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { + logger.warn("Cannot unpair accessory '{}' due to missing mac address configuration", thing.getUID()); return false; } try { diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index e05972d1a42b3..a47c17a52a3dd 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -12,12 +12,20 @@ thing-type.homekit.bridge.description = HomeKit Accessory Bridge # thing types config +thing-type.config.homekit.accessory.accessoryID.label = Accessory ID +thing-type.config.homekit.accessory.accessoryID.description = ID of the accessory. thing-type.config.homekit.accessory.host.label = IP Address thing-type.config.homekit.accessory.host.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.accessory.macAddress.label = MAC Address +thing-type.config.homekit.accessory.macAddress.description = Unique accessory identifier. thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.bridge.accessoryID.label = Accessory ID +thing-type.config.homekit.bridge.accessoryID.description = ID of the accessory. thing-type.config.homekit.bridge.host.label = IP Address thing-type.config.homekit.bridge.host.description = IP v4 address of the HomeKit bridge. +thing-type.config.homekit.bridge.macAddress.label = MAC Address +thing-type.config.homekit.bridge.macAddress.description = Unique accessory identifier. # thing error state messages diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 5287fc635bb38..d0e5426980d87 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -13,6 +13,16 @@ IP v4 address of the HomeKit accessory. + + + Unique accessory identifier. + true + + + + ID of the accessory. + true + Interval at which the accessory is polled in sec. @@ -32,6 +42,16 @@ IP v4 address of the HomeKit bridge. + + + Unique accessory identifier. + true + + + + ID of the accessory. + true + From ffcb31ca2af4428d7096e2ff96f1997466334d8f Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 4 Nov 2025 19:11:14 +0000 Subject: [PATCH 114/177] documentation of pairing thing action Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 7966b848fe85c..0c3b806dc760e 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -19,30 +19,40 @@ Whereas child `accessory` Things communicate via their respective `bridge` Thing Both `bridge` and stand-alone `accessory` Things will be auto discovered via mDNS. Once a `bridge` Thing has been instantiated and paired, its child `accessory` Things will also be auto- discovered. -## Thing Configuration +## Thing Pairing The `bridge` and stand-alone `accessory` Things need to be paired with their respective HomeKit accessories. -This requires entering the HomeKit pairing code as a configuration parameter in the binding. +This requires entering the HomeKit pairing code by means of a Thing Action. + Note that HomeKit accessories can only be paired with one controller, so if it is already paired with something else, you will need to remove that pairing first. +There are two forms of pairing: + +1. Simple pairing. +This works directly between two devices – a HomeKit client (this binding) and a HomeKit accessory. +In this case you need only to enter the pairing code into the Thing Action. +1. Pairing with external authorization. +In addition to the HomeKit client (this binding) and the HomeKit accessory, it requires an additional third party to put the accessory into pairing mode. +Typically the additional third party can be either a) using the accessory's app to put it into pairing mode, or b) pressing a pairing button on the device. + +In either case above, the Pairing Code must be entered manually into the Thing Action dialog. +The Pairing Code must match the format `XXX-XX-XXX` or `XXXX-XXXX` or `XXXXXXXX` where `X` is a single digit. + +For case 1. above, the `With External Authentication` switch must be `OFF`. +Whereas for case 2. above, must be `ON`. + +## Thing Configuration The following table shows the thing configuration parameters. | Name | Type | Description | Default | Required | Advanced | |-------------------|---------|---------------------------------------------------|---------|-----------|-----------| | `host` | text | IP v4 address of the HomeKit accessory. | N/A | see below | see below | -| `pairingCode` | text | Code used for pairing with the HomeKit accessory. | N/A | see below | see below | | `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | -Things of type `bridge` and `accessory` require both a `host` and a `pairingCode`. - The `host` is set by the mDNS auto- discovery process. It must match the format `123.123.123.123:4567` representing its IP v4 address and port. - -The `pairingCode` must be entered manually. -It must match the format `XXX-XX-XXX` or `XXXX-XXXX` or `XXXXXXXX` where `X` is a single digit. - -Child `accessory` Things do not require neither a `host` nor a `pairingCode`. -Therefore child things have these parameters preset to `n/a`. +Child `accessory` Things do not require a `host`. +Therefore child things have this parameter preset to `n/a`. ## Channels From 46abcdcb304769f02a5cbac0938a9922dec47f1f Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 4 Nov 2025 23:45:05 +0000 Subject: [PATCH 115/177] update read me for manual configuration Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 0c3b806dc760e..3948fbbd99ff2 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -19,6 +19,35 @@ Whereas child `accessory` Things communicate via their respective `bridge` Thing Both `bridge` and stand-alone `accessory` Things will be auto discovered via mDNS. Once a `bridge` Thing has been instantiated and paired, its child `accessory` Things will also be auto- discovered. +## Thing Configuration + +The following table shows the thing configuration parameters. + +| Name | Type | Description | Default | Required | Advanced | +|-------------------|---------|---------------------------------------------------|---------|-----------|-----------| +| `host` | text | IP v4 address of the HomeKit accessory. | N/A | see below | no | +| `macAddress` | text | Unique accessory identifier. | N/A | see below | yes | +| `accessoryID` | integer | ID of the accessory | N/A | see below | yes | +| `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | + +NOTE: as a general rule, if you create the things via the Inbox, then all of the above configuration parameters will have their proper values already preset. + +As a gernal rule `host` is set by the mDNS auto- discovery process. +However you can configure it manually if you wish. +It must match the format `123.123.123.123:4567` representing its IP v4 address and port. +Child `accessory` Things do not require a `host`. +Therefore child things have this parameter preset to `n/a`. + +As a general rule, `macAddress` is set by the mDNS auto- discovery process. +However you can configure it manually if you wish. +It must be the unique accessory identifier as found manually via (say) an mDNS discovery app. +Child `accessory` Things do not require a `macAddress`. +Therefore child things have this parameter preset to `n/a`. + +As a general rule, `accessoryID` is set by the mDNS auto- discovery process, or child discovery process. +However you can configure it manually if you wish. +It must be the ID of the accessory within the bridge, or `1` if it is a root accessory. + ## Thing Pairing The `bridge` and stand-alone `accessory` Things need to be paired with their respective HomeKit accessories. @@ -40,20 +69,6 @@ The Pairing Code must match the format `XXX-XX-XXX` or `XXXX-XXXX` or `XXXXXXXX` For case 1. above, the `With External Authentication` switch must be `OFF`. Whereas for case 2. above, must be `ON`. -## Thing Configuration - -The following table shows the thing configuration parameters. - -| Name | Type | Description | Default | Required | Advanced | -|-------------------|---------|---------------------------------------------------|---------|-----------|-----------| -| `host` | text | IP v4 address of the HomeKit accessory. | N/A | see below | see below | -| `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | - -The `host` is set by the mDNS auto- discovery process. -It must match the format `123.123.123.123:4567` representing its IP v4 address and port. -Child `accessory` Things do not require a `host`. -Therefore child things have this parameter preset to `n/a`. - ## Channels Channels are auto-created depending on the services and characteristics published by the HomeKit accessory. From 1d1795603bb9dcc43ff4d72a569a8bf91cb9b1df Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 4 Nov 2025 23:52:15 +0000 Subject: [PATCH 116/177] add example Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 3948fbbd99ff2..4f4a8982eb6ba 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -92,7 +92,15 @@ So the thing creates one additional `HSBType` channel that amalgamates hue, satu ### Thing Configuration Things are automatically configured when they are discovered. -So for this reason it is extremely difficult to create Things via a '.things' file, and is therefore not recommeneded. +So for this reason it is extremely difficult to create Things via a '.things' file, and is therefore not recommended. + +```java +Bridge homekit:bridge:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", accessoryID=1 ] { + Thing accessory 2 "VELUX Sensor" @ "Hallway" [ host="n/a", accessoryID=2 ] + Thing accessory 3 "VELUX Window" @ "Hallway" [ host="n/a", accessoryID=3 ] + Thing accessory 4 "VELUX Window" @ "Small bathroom" [ host="n/a", accessoryID=4 ] +} +``` ### Item Configuration From 19c824661fad377f46ee8ca6a80be0c150d774b0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 5 Nov 2025 23:45:39 +0000 Subject: [PATCH 117/177] various - aid can be long - add config param for mdns service name - http host uses mdns service name instead of ip address - decouple and flush socket after pairing before secure session - disable nagle to force frame flushing - disable event subscribe if no evented channels - update read me - strip to sparse required http headers Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 11 ++- .../internal/HomekitBindingConstants.java | 1 + .../HomekitChildDiscoveryService.java | 3 +- .../HomekitMdnsDiscoveryParticipant.java | 1 + .../homekit/internal/dto/Accessories.java | 2 +- .../homekit/internal/dto/Accessory.java | 2 +- .../homekit/internal/dto/Characteristic.java | 2 +- .../handler/HomekitAccessoryHandler.java | 14 +-- .../handler/HomekitBaseAccessoryHandler.java | 90 +++++++++++++------ .../internal/session/SecureSession.java | 3 + .../internal/transport/IpTransport.java | 41 +++------ .../resources/OH-INF/i18n/homekit.properties | 5 ++ .../resources/OH-INF/thing/thing-types.xml | 10 +++ .../TestChannelCreationForAppleJson.java | 6 +- .../TestChannelCreationForVeluxJson.java | 8 +- 15 files changed, 126 insertions(+), 73 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 4f4a8982eb6ba..98146432f1c01 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -27,12 +27,13 @@ The following table shows the thing configuration parameters. |-------------------|---------|---------------------------------------------------|---------|-----------|-----------| | `host` | text | IP v4 address of the HomeKit accessory. | N/A | see below | no | | `macAddress` | text | Unique accessory identifier. | N/A | see below | yes | -| `accessoryID` | integer | ID of the accessory | N/A | see below | yes | +| `mdnsServiceName` | text | The name of the discovered mDNS service. | N/A | see below | yes | +| `accessoryID` | integer | ID of the accessory. | N/A | see below | yes | | `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | NOTE: as a general rule, if you create the things via the Inbox, then all of the above configuration parameters will have their proper values already preset. -As a gernal rule `host` is set by the mDNS auto- discovery process. +As a general rule `host` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. It must match the format `123.123.123.123:4567` representing its IP v4 address and port. Child `accessory` Things do not require a `host`. @@ -44,6 +45,12 @@ It must be the unique accessory identifier as found manually via (say) an mDNS d Child `accessory` Things do not require a `macAddress`. Therefore child things have this parameter preset to `n/a`. +As a general rule, `mdnsServiceName` is set by the mDNS auto- discovery process. +However you can configure it manually if you wish. +It must be the fully qualified mDNS service name as found manually via (say) an mDNS discovery app. +Child `accessory` Things do not require a `mdnsServiceName`. +Therefore child things have this parameter preset to `n/a`. + As a general rule, `accessoryID` is set by the mDNS auto- discovery process, or child discovery process. However you can configure it manually if you wish. It must be the ID of the accessory within the bridge, or `1` if it is a root accessory. diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index a5816d465ee64..ed73374b35e74 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -46,6 +46,7 @@ public class HomekitBindingConstants { // configuration parameters public static final String CONFIG_HOST = "host"; + public static final String CONFIG_MDNS_SERVICE_NAME = "mdnsServiceName"; public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; public static final String CONFIG_ACCESSORY_ID = "accessoryID"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index b9741395a1b92..26b91a304c60c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -52,7 +52,7 @@ public void startScan() { private void discoverChildren(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { - if (accessory.aid instanceof Integer aid && aid != 1 && accessory.services != null) { + if (accessory.aid instanceof Long aid && aid != 1 && accessory.services != null) { ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), aid.toString()); String thingLabel = "%s (%d)".formatted(accessory.getAccessoryInstanceLabel(), accessory.aid); thingDiscovered(DiscoveryResultBuilder.create(uid) // @@ -61,6 +61,7 @@ private void discoverChildren(Thing bridge, Collection accessories) { .withProperty(CONFIG_HOST, "n/a") // .withProperty(Thing.PROPERTY_MAC_ADDRESS, "n/a") // .withProperty(CONFIG_ACCESSORY_ID, aid.toString()) // + .withProperty(CONFIG_MDNS_SERVICE_NAME, "n/a") // .withRepresentationProperty(CONFIG_ACCESSORY_ID).build()); } }); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 4a9cd2bb6bc0e..78d5e963bd91b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -90,6 +90,7 @@ public String getServiceType() { .withProperty(Thing.PROPERTY_MAC_ADDRESS, mac) // .withProperty(PROPERTY_ACCESSORY_CATEGORY, category.toString()) // .withProperty(CONFIG_ACCESSORY_ID, "1".toString()) // + .withProperty(CONFIG_MDNS_SERVICE_NAME, service.getQualifiedName()) // .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); if (properties.get("md") instanceof String model) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java index 874b0d9016166..5b20f8b3c601b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java @@ -26,7 +26,7 @@ public class Accessories { public List accessories; - public @Nullable Accessory getAccessory(Integer aid) { + public @Nullable Accessory getAccessory(Long aid) { return accessories.stream().filter(a -> aid.equals(a.aid)).findFirst().orElse(null); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 5e6d9e765204f..3b5be4ed216b9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -39,7 +39,7 @@ */ @NonNullByDefault public class Accessory { - public @NonNullByDefault({}) Integer aid; // e.g. 1 + public @NonNullByDefault({}) Long aid; // e.g. 1 public @NonNullByDefault({}) List services; public @NonNullByDefault({}) String name; public @NonNullByDefault({}) String manufacturer; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 0cd78f04fe81a..e24d23aa8fcf1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -68,7 +68,7 @@ public class Characteristic { public @NonNullByDefault({}) JsonElement value; // e.g. true, 23, "Some String" public @NonNullByDefault({}) String description; public @NonNullByDefault({}) Boolean ev; // e.g. true (events requested) - public @NonNullByDefault({}) Integer aid; // e.g. 10 + public @NonNullByDefault({}) Long aid; // e.g. 10 public @NonNullByDefault({}) @SerializedName("valid-values") List validValues; public @NonNullByDefault({}) @SerializedName("valid-values-range") List validValuesRange; public @NonNullByDefault({}) Integer status; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 7cf64db932dd8..c217ccbcff31d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -150,7 +150,7 @@ protected void accessoriesLoaded() { /** * Called when the thing handler has been initialized, the pairing verified, the accessories loaded, * and the channels and properties created. Sets up a scheduled task to periodically refresh the state - * of the accessory. And subscribes to evented channels if applicable. + * of the accessory. */ private void startRefreshTask() { if (getConfig().get(CONFIG_REFRESH_INTERVAL) instanceof Object refreshInterval) { @@ -349,11 +349,11 @@ && getStateDescription(channel) instanceof StateDescriptionFragment stateDescrip * Each service creates a channel group, and each characteristic creates a channel within it. */ private void createChannels() { - Map accessories = getAccessories(); + Map accessories = getAccessories(); if (accessories.isEmpty()) { return; } - Integer accessoryId = getAccessoryId(); + Long accessoryId = getAccessoryId(); if (accessoryId == null) { return; } @@ -555,7 +555,7 @@ public void dispose() { * This method is called periodically by a scheduled executor. */ private void refresh() { - Integer aid = getAccessoryId(); + Long aid = getAccessoryId(); List queries = new ArrayList<>(); thing.getChannels().stream().forEach(c -> { if (c.getProperties().get(PROPERTY_IID) instanceof String iid) { @@ -826,7 +826,7 @@ private void eventingFinalize(Accessory accessory, List channels) { */ private synchronized @Nullable State readChannel(Channel channel, CharacteristicReadWriteClient reader) throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { - Integer aid = getAccessoryId(); + Long aid = getAccessoryId(); String iid = channel.getProperties().get(PROPERTY_IID); if (aid == null || iid == null) { throw new IllegalStateException( @@ -858,7 +858,7 @@ private void eventingFinalize(Accessory accessory, List channels) { */ private synchronized void writeChannel(Channel channel, Command command, CharacteristicReadWriteClient writer) throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { - Integer aid = getAccessoryId(); + Long aid = getAccessoryId(); String iid = channel.getProperties().get(PROPERTY_IID); if (aid == null || iid == null) { throw new IllegalStateException( @@ -898,7 +898,7 @@ public void onEvent(String json) { * @param json the JSON content containing characteristic values */ private void updateChannelsFromJson(String json) { - Integer aid = getAccessoryId(); + Long aid = getAccessoryId(); ChannelUID hsbChannelUID = null; Service service = GSON.fromJson(json, Service.class); if (service != null && service.characteristics instanceof List characteristics) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index d7c8b24ef1fd6..edec2cbb9398b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -79,12 +79,12 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected static final Gson GSON = new Gson(); - private static final int MIN_CONNECTION_ATTEMPT_DELAY = 2; - private static final int MAX_CONNECTION_ATTEMPT_DELAY = 600; + private static final int MIN_CONNECTION_ATTEMPT_DELAY_SECONDS = 2; + private static final int MAX_CONNECTION_ATTEMPT_DELAY_SECONDS = 600; private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); - private final Map accessories = new ConcurrentHashMap<>(); + private final Map accessories = new ConcurrentHashMap<>(); protected final HomekitTypeProvider typeProvider; protected final HomekitKeyStore keyStore; @@ -94,13 +94,13 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected boolean isChildAccessory = false; private boolean isConfigured = false; - private int connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY; + private int connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; private @Nullable ScheduledFuture connectionAttemptTask; private @Nullable CharacteristicReadWriteClient rwService; private @Nullable IpTransport ipTransport; - protected @NonNullByDefault({}) Integer accessoryId; + protected @NonNullByDefault({}) Long accessoryId; public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, TranslationProvider translationProvider, Bundle bundle) { @@ -129,6 +129,11 @@ public void dispose() { super.dispose(); } + private void fetchAccessoriesTask() { + fetchAccessories(); + updateStatus(ThingStatus.ONLINE); + } + /** * Get information about embedded accessories and their respective channels from the /accessories endpoint. * @@ -180,10 +185,10 @@ private void accessoriesLoadedTask() { * * @return the accessory ID, or null if it cannot be determined */ - protected @Nullable Integer getAccessoryId() { + protected @Nullable Long getAccessoryId() { if (getConfig().get(CONFIG_ACCESSORY_ID) instanceof BigDecimal accessoryId) { try { - return accessoryId.intValue(); + return accessoryId.longValue(); } catch (NumberFormatException e) { } } @@ -222,16 +227,17 @@ public void initialize() { */ private synchronized void verifyPairing() { isConfigured = false; - Integer accessoryId = checkedAccessoryId(); + Long accessoryId = checkedAccessoryId(); String hostName = checkedHostName(); String macAddress = checkedMacAddress(); - if (accessoryId == null || hostName == null || macAddress == null) { + String mdnsServiceName = checkedMdnsServiceName(hostName); + if (accessoryId == null || hostName == null || macAddress == null || mdnsServiceName == null) { return; // configuration error } isConfigured = true; // create new transport - if (checkedCreateIpTransport(hostName) == null) { + if (checkedCreateIpTransport(hostName, mdnsServiceName) == null) { return; // transport creation failed } @@ -245,8 +251,7 @@ private synchronized void verifyPairing() { rwService = new CharacteristicReadWriteClient(getIpTransport()); logger.debug("Restored pairing was verified for {}", thing.getUID()); - fetchAccessories(); - updateStatus(ThingStatus.ONLINE); + scheduler.schedule(this::fetchAccessoriesTask, MIN_CONNECTION_ATTEMPT_DELAY_SECONDS, TimeUnit.SECONDS); return; // pairing restore succeeded => exit } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException | InvalidCipherTextException | IOException | InterruptedException | TimeoutException @@ -263,7 +268,7 @@ private synchronized void verifyPairing() { } } - public Map getAccessories() { + public Map getAccessories() { if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { return bridgeHandler.getAccessories(); } @@ -305,10 +310,11 @@ private synchronized void attemptConnect() { } verifyPairing(); if (isConfigured && thing.getStatus() != ThingStatus.ONLINE) { // config ok but connection failed => try again - connectionAttemptDelay = Math.min(MAX_CONNECTION_ATTEMPT_DELAY, (int) Math.pow(connectionAttemptDelay, 2)); + connectionAttemptDelay = Math.min(MAX_CONNECTION_ATTEMPT_DELAY_SECONDS, + (int) Math.pow(connectionAttemptDelay, 2)); connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, TimeUnit.SECONDS); } else { // succeeded => reset delay - connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY; + connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; connectionAttemptTask = null; } } @@ -375,7 +381,36 @@ public Collection> getServices() { return macAddress; } - private @Nullable Integer checkedAccessoryId() { + /** + * Checks and formats the mDNS service name from the configuration. The result is in the + * format 'foobar.hap.tcp.local' or 'foobar.hap.tcp.local:port'; the port is included if it + * is specified in the host name and is not default port '80'; spaces are escaped to '\032'. + * + * @param hostName the host name (may contain port) + * @return the formatted mDNS service name, or null if there is a configuration error + */ + private @Nullable String checkedMdnsServiceName(@Nullable String hostName) { + if (!(getConfig().get(CONFIG_MDNS_SERVICE_NAME) instanceof String mdnsServiceName) + || mdnsServiceName.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.missing-mdns-service-name", "Missing mDNS service name", null)); + return null; + } + if (hostName == null) { + return null; + } + if (mdnsServiceName.endsWith(".")) { + mdnsServiceName = mdnsServiceName.substring(0, mdnsServiceName.length() - 1); + } + mdnsServiceName = mdnsServiceName.replace(" ", "\\032"); + String[] parts = hostName.split(":"); + if (parts.length == 2 && !"80".equals(parts[1])) { + mdnsServiceName += ":" + parts[1]; + } + return mdnsServiceName; + } + + private @Nullable Long checkedAccessoryId() { accessoryId = getAccessoryId(); if (accessoryId == null) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, @@ -385,9 +420,9 @@ public Collection> getServices() { return accessoryId; } - private @Nullable IpTransport checkedCreateIpTransport(String hostName) { + private @Nullable IpTransport checkedCreateIpTransport(String hostName, String mdnsServiceName) { try { - IpTransport ipTransport = new IpTransport(hostName, this); + IpTransport ipTransport = new IpTransport(hostName, mdnsServiceName, this); this.ipTransport = ipTransport; return ipTransport; } catch (IOException e) { @@ -417,16 +452,17 @@ public void pair(String code, boolean withExternalAuthentication) { String pairingCode = normalizePairingCode(code); isConfigured = false; - Integer accessoryId = checkedAccessoryId(); + Long accessoryId = checkedAccessoryId(); String hostName = checkedHostName(); String macAddress = checkedMacAddress(); - if (accessoryId == null || hostName == null || macAddress == null) { + String mdnsServiceName = checkedMdnsServiceName(hostName); + if (accessoryId == null || hostName == null || macAddress == null || mdnsServiceName == null) { return; // configuration error } isConfigured = true; // create new transport - if (checkedCreateIpTransport(hostName) == null) { + if (checkedCreateIpTransport(hostName, mdnsServiceName) == null) { return; // transport creation failed } @@ -439,7 +475,7 @@ public void pair(String code, boolean withExternalAuthentication) { keyStore.setAccessoryKey(macAddress, accessoryKey); logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); - connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY; // reset delay on manual pairing + connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; // reset delay on manual pairing scheduleConnectionAttempt(); } catch (Exception e) { // catch all; log all exceptions @@ -504,11 +540,11 @@ protected boolean isCommunicationException(Throwable throwable) { * service (if any). */ protected void createProperties() { - Map accessories = getAccessories(); + Map accessories = getAccessories(); if (accessories.isEmpty()) { return; } - Integer accessoryId = getAccessoryId(); + Long accessoryId = getAccessoryId(); if (accessoryId == null) { return; } @@ -588,7 +624,9 @@ private void enableEventsOrThrow(boolean enable) throws IllegalStateException, I characteristic.ev = enable; return characteristic; }).toList()); - getRwService().writeCharacteristic(GSON.toJson(service)); - logger.debug("Eventing {}abled for {} channels", enable ? "en" : "dis", service.characteristics.size()); + if (!service.characteristics.isEmpty()) { + getRwService().writeCharacteristic(GSON.toJson(service)); + logger.debug("Eventing {}abled for {} channels", enable ? "en" : "dis", service.characteristics.size()); + } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index eec2e5afacfa5..58d3c3bb7f0e9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -48,6 +48,9 @@ public class SecureSession { public SecureSession(Socket socket, AsymmetricSessionKeys keys) throws IOException { in = new DataInputStream(socket.getInputStream()); + if (in.available() > 0) { + in.readAllBytes(); // clear any pre-existing data + } out = socket.getOutputStream(); writeKey = keys.getWriteKey(); readKey = keys.getReadKey(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 8445f933a96a5..693963f1c2e7e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -56,8 +56,8 @@ public class IpTransport implements AutoCloseable { private final Logger logger = LoggerFactory.getLogger(IpTransport.class); private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "homekit-io")); - private final String host; // ip address with optional port e.g. "192.168.1.42:9123" private final Socket socket; + private final String mdnsServiceName; private final EventListener eventListener; private @Nullable SecureSession secureSession = null; @@ -70,33 +70,24 @@ public class IpTransport implements AutoCloseable { /** * Creates a new IpTransport instance on the given host. * - * @param host the IP address and port of the HomeKit accessory + * @param hostName the IP address and port of the HomeKit accessory * @throws IOException - * @throws IllegalArgumentException if the host or port are invalid */ - public IpTransport(String host, EventListener listener) throws IOException, IllegalArgumentException { - logger.debug("Connecting to {}", host); - this.host = host; - String[] parts = host.split(":"); - if (parts.length < 1) { - throw new IllegalArgumentException("Missing host: " + host); - } - if (parts.length < 2) { - throw new IllegalArgumentException("Missing port: " + host); - } - String ipAddress = parts[0]; - int port = Integer.parseInt(parts[1]); - eventListener = listener; + public IpTransport(String hostName, String mdnsServiceName, EventListener eventListener) throws IOException { + logger.debug("Connecting to {}", hostName); + this.mdnsServiceName = mdnsServiceName; + this.eventListener = eventListener; + String[] parts = hostName.split(":"); socket = new Socket(); - socket.connect(new InetSocketAddress(ipAddress, port), TIMEOUT_MILLI_SECONDS); // connect timeout - // keep-alive is forbiddden for accessories, but client should use it to avoid multiple reconnection - socket.setKeepAlive(true); - logger.debug("Connected to {}", host); + socket.setKeepAlive(true); // keep-alive forbiddden for accessories but client should use it + socket.setTcpNoDelay(true); // disable Nagle algorithm to force immediate flushing of packets + socket.connect(new InetSocketAddress(parts[0], Integer.parseInt(parts[1])), TIMEOUT_MILLI_SECONDS); + logger.debug("Connected to {}", hostName); } public void setSessionKeys(AsymmetricSessionKeys keys) throws IOException { secureSession = new SecureSession(socket, keys); - Thread thread = new Thread(this::readTask, "homekit-thread"); + Thread thread = new Thread(this::readTask, "homekit-read"); readThread = thread; thread.start(); } @@ -239,15 +230,11 @@ private synchronized byte[] execute(String method, String endpoint, String conte private byte[] buildRequest(String method, String endpoint, String contentType, byte[] content) throws IOException { StringBuilder sb = new StringBuilder(); sb.append(method).append(" ").append(endpoint).append(" HTTP/1.1\r\n"); - sb.append("Host: ").append(host).append("\r\n"); - sb.append("Accept: ").append(contentType).append("\r\n"); + sb.append("Host: ").append(mdnsServiceName).append("\r\n"); if (!contentIsEmpty(method)) { - sb.append("Content-Type: ").append(contentType).append("\r\n"); sb.append("Content-Length: ").append(content.length).append("\r\n"); - } else { - sb.append("Content-Length: 0\r\n"); + sb.append("Content-Type: ").append(contentType).append("\r\n"); } - sb.append("Connection: keep-alive\r\n"); // force keep-alive sb.append("\r\n"); byte[] headerBytes = sb.toString().getBytes(StandardCharsets.UTF_8); diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index a47c17a52a3dd..bca54561f0a94 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -18,6 +18,8 @@ thing-type.config.homekit.accessory.host.label = IP Address thing-type.config.homekit.accessory.host.description = IP v4 address of the HomeKit accessory. thing-type.config.homekit.accessory.macAddress.label = MAC Address thing-type.config.homekit.accessory.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.accessory.mdnsServiceName.label = mDNS Service Name +thing-type.config.homekit.accessory.mdnsServiceName.description = The name of the discovered mDNS service. thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. thing-type.config.homekit.bridge.accessoryID.label = Accessory ID @@ -26,6 +28,8 @@ thing-type.config.homekit.bridge.host.label = IP Address thing-type.config.homekit.bridge.host.description = IP v4 address of the HomeKit bridge. thing-type.config.homekit.bridge.macAddress.label = MAC Address thing-type.config.homekit.bridge.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.bridge.mdnsServiceName.label = mDNS Service Name +thing-type.config.homekit.bridge.mdnsServiceName.description = The name of the discovered mDNS service. # thing error state messages @@ -35,6 +39,7 @@ error.failed-to-connect = Failed to connect error.invalid-pairing-code = Invalid pairing code error.invalid-accessory-id = Invalid accessory ID error.missing-mac-address = Missing MAC address +error.missing-mdns-service-name = Missing mDNS service name error.pairing-verification-failed = Pairing / verification failed error.polling-error = Polling error error.error-sending-command = Error sending command diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index d0e5426980d87..ea5cbb07dadd2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -18,6 +18,11 @@ Unique accessory identifier. true + + + The name of the discovered mDNS service. + true + ID of the accessory. @@ -47,6 +52,11 @@ Unique accessory identifier. true + + + The name of the discovered mDNS service. + true + ID of the accessory. diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java index c660474d65006..55d9c6c52690a 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java @@ -343,7 +343,7 @@ void testGenericJsonParsing() { void testDetailJsonParsing() { Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); assertNotNull(accessories); - Accessory accessory = accessories.getAccessory(1); + Accessory accessory = accessories.getAccessory(1L); assertNotNull(accessory); assertEquals(1, accessory.aid); assertEquals(2, accessory.services.size()); @@ -389,7 +389,7 @@ void testChannelDefinitions() { * Test the LED Light Bulb accessory #3 which has live data channels */ ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory3"); - Accessory accessory = accessories.getAccessory(3); + Accessory accessory = accessories.getAccessory(3L); assertNotNull(accessory); List channelGroupDefinitions = accessory .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); @@ -449,7 +449,7 @@ void testChannelDefinitions() { assertEquals(BigDecimal.valueOf(1.0), state.getStep()); // get the accessory information for the bridge (accessory 1) and create properties from it - accessory = accessories.getAccessory(1); + accessory = accessories.getAccessory(1L); assertNotNull(accessory); Map properties = new HashMap<>(); for (Service service : accessory.services) { diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java index f9b241f2e99df..a59748b9c23c7 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -1564,7 +1564,7 @@ void testGenericJsonParsing() { void testDetailJsonParsing() { Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); assertNotNull(accessories); - Accessory accessory = accessories.getAccessory(1); + Accessory accessory = accessories.getAccessory(1L); assertNotNull(accessory); assertEquals(1, accessory.aid); assertEquals(3, accessory.services.size()); @@ -1608,7 +1608,7 @@ void testBridge() { // get the accessory information for the bridge (accessory 1) and create properties from it ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory1"); - Accessory accessory = accessories.getAccessory(1); + Accessory accessory = accessories.getAccessory(1L); assertNotNull(accessory); Map properties = new HashMap<>(); for (Service service : accessory.services) { @@ -1663,7 +1663,7 @@ void testSensors() { // test channel definitions for Temperature, Humidity, and CO2 sensors ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory2"); - Accessory accessory = accessories.getAccessory(2); + Accessory accessory = accessories.getAccessory(2L); assertNotNull(accessory); List channelGroupDefinitions = accessory .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); @@ -1822,7 +1822,7 @@ void testVenetianBlind() { }).when(typeProvider).putChannelType(any(ChannelType.class)); ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory9"); - Accessory accessory = accessories.getAccessory(9); + Accessory accessory = accessories.getAccessory(9L); assertNotNull(accessory); List channelGroupDefinitions = accessory .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); From 917ac5b09671aa24b60a2c4998c34b78561a7746 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 7 Nov 2025 17:32:04 +0000 Subject: [PATCH 118/177] various - use .local host name in hap http calls - thing-type config params - readme - implement refresh - i8n - fix initialization timing Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 38 ++-- .../internal/HomekitBindingConstants.java | 10 +- .../HomekitChildDiscoveryService.java | 4 +- .../HomekitMdnsDiscoveryParticipant.java | 38 +++- .../handler/HomekitAccessoryHandler.java | 57 +++-- .../handler/HomekitBaseAccessoryHandler.java | 195 +++++++++--------- .../handler/HomekitBridgeHandler.java | 56 +++-- .../internal/session/SecureSession.java | 3 - .../internal/transport/IpTransport.java | 19 +- .../resources/OH-INF/i18n/homekit.properties | 20 +- .../resources/OH-INF/thing/thing-types.xml | 16 +- 11 files changed, 237 insertions(+), 219 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 98146432f1c01..37fd36c9ad97d 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -23,32 +23,32 @@ Once a `bridge` Thing has been instantiated and paired, its child `accessory` Th The following table shows the thing configuration parameters. -| Name | Type | Description | Default | Required | Advanced | -|-------------------|---------|---------------------------------------------------|---------|-----------|-----------| -| `host` | text | IP v4 address of the HomeKit accessory. | N/A | see below | no | -| `macAddress` | text | Unique accessory identifier. | N/A | see below | yes | -| `mdnsServiceName` | text | The name of the discovered mDNS service. | N/A | see below | yes | -| `accessoryID` | integer | ID of the accessory. | N/A | see below | yes | -| `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | +| Name | Type | Description | Default | Required | Advanced | +|-------------------|---------|------------------------------------------------------|---------|-----------|-----------| +| `ipAddress` | text | IP v4 address of the HomeKit accessory. | N/A | see below | no | +| `hostName` | text | The fully qualified host name as discovered by mDNS. | N/A | see below | yes | +| `macAddress` | text | Unique accessory identifier. | N/A | see below | yes | +| `accessoryID` | integer | ID of the accessory. | N/A | see below | yes | +| `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | NOTE: as a general rule, if you create the things via the Inbox, then all of the above configuration parameters will have their proper values already preset. -As a general rule `host` is set by the mDNS auto- discovery process. +As a general rule `ipAddress` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. It must match the format `123.123.123.123:4567` representing its IP v4 address and port. -Child `accessory` Things do not require a `host`. +Child `accessory` Things do not require a `ipAddress`. Therefore child things have this parameter preset to `n/a`. -As a general rule, `macAddress` is set by the mDNS auto- discovery process. +As a general rule, `hostName` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. -It must be the unique accessory identifier as found manually via (say) an mDNS discovery app. -Child `accessory` Things do not require a `macAddress`. +It must be the fully qualified host name (e.g. `foobar.local` or, if the port is not 0 or 80, `foobar.local:1234` ) as found manually via (say) an mDNS discovery app. +Child `accessory` Things do not require a `hostName`. Therefore child things have this parameter preset to `n/a`. -As a general rule, `mdnsServiceName` is set by the mDNS auto- discovery process. +As a general rule, `macAddress` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. -It must be the fully qualified mDNS service name as found manually via (say) an mDNS discovery app. -Child `accessory` Things do not require a `mdnsServiceName`. +It must be the unique accessory identifier as found manually via (say) an mDNS discovery app. +Child `accessory` Things do not require a `macAddress`. Therefore child things have this parameter preset to `n/a`. As a general rule, `accessoryID` is set by the mDNS auto- discovery process, or child discovery process. @@ -64,11 +64,11 @@ Note that HomeKit accessories can only be paired with one controller, so if it i There are two forms of pairing: 1. Simple pairing. -This works directly between two devices – a HomeKit client (this binding) and a HomeKit accessory. -In this case you need only to enter the pairing code into the Thing Action. + This works directly between two devices – a HomeKit client (this binding) and a HomeKit accessory. + In this case you need only to enter the pairing code into the Thing Action. 1. Pairing with external authorization. -In addition to the HomeKit client (this binding) and the HomeKit accessory, it requires an additional third party to put the accessory into pairing mode. -Typically the additional third party can be either a) using the accessory's app to put it into pairing mode, or b) pressing a pairing button on the device. + In addition to the HomeKit client (this binding) and the HomeKit accessory, it requires an additional third party to put the accessory into pairing mode. + Typically the additional third party can be either a) using the accessory's app to put it into pairing mode, or b) pressing a pairing button on the device. In either case above, the Pairing Code must be entered manually into the Thing Action dialog. The Pairing Code must match the format `XXX-XX-XXX` or `XXXX-XXXX` or `XXXXXXXX` where `X` is a single digit. diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index ed73374b35e74..9e3b4fc427fc3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -45,8 +45,8 @@ public class HomekitBindingConstants { public static final String THING_LABEL_FMT = "%s on %s"; // configuration parameters - public static final String CONFIG_HOST = "host"; - public static final String CONFIG_MDNS_SERVICE_NAME = "mdnsServiceName"; + public static final String CONFIG_HOST_NAME = "hostName"; + public static final String CONFIG_IP_ADDRESS = "ipAddress"; public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; public static final String CONFIG_ACCESSORY_ID = "accessoryID"; @@ -72,6 +72,10 @@ public class HomekitBindingConstants { public static final Pattern PAIRING_CODE_PATTERN = Pattern.compile("\\d{3}-\\d{2}-\\d{3}|\\d{4}-\\d{4}|\\d{8}"); // pattern matcher for host ipv4 address 123.123.123.123:12345 - public static final Pattern HOST_PATTERN = Pattern.compile( + public static final Pattern IPV4_PATTERN = Pattern.compile( "^(((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)):(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]?\\d{1,4})$"); + + // pattern matcher for a fully qualified host name like foobar.local or foobar.local:12345 + // NOTE: this specially allows '\' characters in the host name -- even if normally not allowed by the RFC + public static final Pattern HOST_PATTERN = Pattern.compile("^([a-zA-Z0-9\\\\-]+)\\.local(?::(\\d{1,5}))?$"); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 26b91a304c60c..c497043aba47b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -58,10 +58,10 @@ private void discoverChildren(Thing bridge, Collection accessories) { thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // .withLabel(THING_LABEL_FMT.formatted(thingLabel, bridge.getLabel())) // - .withProperty(CONFIG_HOST, "n/a") // + .withProperty(CONFIG_HOST_NAME, "n/a") // + .withProperty(CONFIG_IP_ADDRESS, "n/a") // .withProperty(Thing.PROPERTY_MAC_ADDRESS, "n/a") // .withProperty(CONFIG_ACCESSORY_ID, aid.toString()) // - .withProperty(CONFIG_MDNS_SERVICE_NAME, "n/a") // .withRepresentationProperty(CONFIG_ACCESSORY_ID).build()); } }); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 78d5e963bd91b..d19cb6c4752a0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -68,11 +68,11 @@ public String getServiceType() { if (getThingUID(service) instanceof ThingUID uid) { Map properties = getProperties(service); - String mac = properties.get("id"); // MAC address - String host = service.getHostAddresses()[0]; // ipV4 address + String macAddress = properties.get("id"); // MAC address + String ipAddress = service.getHostAddresses()[0]; // ipV4 address int port = service.getPort(); if (port != 0) { - host = host + ":" + port; + ipAddress = ipAddress + ":" + port; } AccessoryCategory category; @@ -83,14 +83,14 @@ public String getServiceType() { category = null; } - if (host != null && mac != null && category != null) { + if (ipAddress != null && macAddress != null && category != null) { DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); - builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), host)) // - .withProperty(CONFIG_HOST, host) // - .withProperty(Thing.PROPERTY_MAC_ADDRESS, mac) // + builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), ipAddress)) // + .withProperty(CONFIG_HOST_NAME, getHostName(service)) // + .withProperty(CONFIG_IP_ADDRESS, ipAddress) // + .withProperty(Thing.PROPERTY_MAC_ADDRESS, macAddress) // .withProperty(PROPERTY_ACCESSORY_CATEGORY, category.toString()) // .withProperty(CONFIG_ACCESSORY_ID, "1".toString()) // - .withProperty(CONFIG_MDNS_SERVICE_NAME, service.getQualifiedName()) // .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); if (properties.get("md") instanceof String model) { @@ -149,4 +149,26 @@ private Map getProperties(ServiceInfo service) { } return map; } + + /** + * Returns the fully qualified host name by ensuring it ends with ".local" plus, if the port is + * neither '0' nor the default 80, the respective suffix e.g. 'foobar.local' or 'foobar.local:12345' + * + * @param service the ServiceInfo object. + * @return the normalized host name. + */ + private String getHostName(ServiceInfo service) { + String hostName = service.getServer(); + if (hostName.endsWith(".")) { + hostName = hostName.substring(0, hostName.length() - 1); + } + if (!hostName.endsWith(".local")) { + hostName += ".local"; + } + int port = service.getPort(); + if (port != 80 && port != 0) { + hostName += ":" + port; + } + return hostName; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index c217ccbcff31d..e7efe24866881 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -67,7 +67,6 @@ import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; -import org.openhab.core.thing.ThingStatusInfo; import org.openhab.core.thing.binding.builder.ChannelBuilder; import org.openhab.core.thing.binding.builder.ThingBuilder; import org.openhab.core.thing.type.ChannelGroupType; @@ -127,7 +126,6 @@ private record LightModelLink(Channel channel, CharacteristicType cxxType, Integ } private final List lightModelLinks = new ArrayList<>(); - private final List eventedCharacteristics = new ArrayList<>(); private @Nullable Channel stopMoveChannel = null; // channel for the stop button (rollershutters) private @Nullable ScheduledFuture refreshTask; @@ -140,13 +138,6 @@ public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, this.channelGroupTypeRegistry = channelGroupTypeRegistry; } - @Override - protected void accessoriesLoaded() { - createProperties(); - createChannels(); - startRefreshTask(); - } - /** * Called when the thing handler has been initialized, the pairing verified, the accessories loaded, * and the channels and properties created. Sets up a scheduled task to periodically refresh the state @@ -470,7 +461,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { return; } if (command == RefreshType.REFRESH) { - return; + refresh(); } try { if (command instanceof StopMoveType stopMoveType && StopMoveType.STOP == stopMoveType) { @@ -532,6 +523,16 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override public void initialize() { super.initialize(); + if (isChildAccessory) { + if (getBridge() instanceof Bridge bridge && bridge.getStatus() == ThingStatus.ONLINE) { + scheduler.submit(() -> { + onAccessoriesLoaded(); + onRootHandlerReady(); + }); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + } + } } @Override @@ -568,9 +569,6 @@ private void refresh() { try { String json = getRwService().readCharacteristic(String.join(",", queries)); updateChannelsFromJson(json); - if (thing.getStatus() != ThingStatus.ONLINE) { - updateStatus(ThingStatus.ONLINE); - } return; } catch (InterruptedException e) { // shutting down; do nothing @@ -882,16 +880,6 @@ private synchronized void writeChannel(Channel channel, Command command, Charact } } - /** - * Handles incoming events by updating the corresponding channels based on the characteristic values. - * - * @param json the JSON content of the event - */ - @Override - public void onEvent(String json) { - updateChannelsFromJson(json); - } - /** * Updates the channels based on the provided JSON content. * @@ -968,16 +956,23 @@ protected CharacteristicReadWriteClient getRwService() throws IllegalAccessExcep } @Override - public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { - super.bridgeStatusChanged(bridgeStatusInfo); - if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE && thing.getStatus() != ThingStatus.ONLINE) { - updateStatus(ThingStatus.ONLINE); - startRefreshTask(); - } + protected boolean checkHandlersInitialized() { + return isInitialized(); + } + + @Override + protected void onAccessoriesLoaded() { + createProperties(); + createChannels(); + } + + @Override + protected void onRootHandlerReady() { + startRefreshTask(); } @Override - protected List getEventedCharacteristics() { - return eventedCharacteristics; + public void onEvent(String json) { + updateChannelsFromJson(json); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index edec2cbb9398b..25cfc17cbd3ba 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -19,6 +19,8 @@ import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -77,30 +79,32 @@ @NonNullByDefault public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler implements EventListener { - protected static final Gson GSON = new Gson(); - private static final int MIN_CONNECTION_ATTEMPT_DELAY_SECONDS = 2; private static final int MAX_CONNECTION_ATTEMPT_DELAY_SECONDS = 600; - private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); + private static final Duration HANDLER_INITIALIZATION_TIMEOUT = Duration.ofSeconds(10); + private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); private final Map accessories = new ConcurrentHashMap<>(); + private final HomekitKeyStore keyStore; - protected final HomekitTypeProvider typeProvider; - protected final HomekitKeyStore keyStore; - protected final TranslationProvider i18nProvider; - protected final Bundle bundle; - - protected boolean isChildAccessory = false; private boolean isConfigured = false; - private int connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; private @Nullable ScheduledFuture connectionAttemptTask; private @Nullable CharacteristicReadWriteClient rwService; private @Nullable IpTransport ipTransport; - protected @NonNullByDefault({}) Long accessoryId; + private @NonNullByDefault({}) Long accessoryId; + + protected static final Gson GSON = new Gson(); + + protected final List eventedCharacteristics = new ArrayList<>(); + protected final HomekitTypeProvider typeProvider; + protected final TranslationProvider i18nProvider; + protected final Bundle bundle; + + protected boolean isChildAccessory = false; public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, TranslationProvider translationProvider, Bundle bundle) { @@ -129,11 +133,6 @@ public void dispose() { super.dispose(); } - private void fetchAccessoriesTask() { - fetchAccessories(); - updateStatus(ThingStatus.ONLINE); - } - /** * Get information about embedded accessories and their respective channels from the /accessories endpoint. * @@ -151,7 +150,7 @@ private void fetchAccessories() { .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } logger.debug("Fetched {} accessories", accessories.size()); - scheduler.submit(this::accessoriesLoadedTask); // notify subclass (and children) in scheduler thread + scheduler.submit(this::processAccessories); } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect @@ -166,20 +165,25 @@ private void fetchAccessories() { } /** - * Processes the loaded accessories by calling the overloaded abstract methods, and then enables eventing. + * Processes the loaded accessories by calling the overloaded abstract methods, then enables eventing, + * and finally sets thing as online. */ - private void accessoriesLoadedTask() { - accessoriesLoaded(); + private void processAccessories() { + Instant timeout = Instant.now().plus(HANDLER_INITIALIZATION_TIMEOUT); + while (!checkHandlersInitialized() && Instant.now().isBefore(timeout)) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; // shutting down + } + } + onAccessoriesLoaded(); + onRootHandlerReady(); enableEvents(true); + updateStatus(ThingStatus.ONLINE); } - /** - * Called when the thing handler has been initialized, the pairing verified, and the accessories have been loaded. - * Subclasses override this to perform any processing required. - * This method is called in the context of a scheduler thread, to avoid blocking operations. - */ - protected abstract void accessoriesLoaded(); - /** * Returns the accessory ID from the 'AccessoryID' configuration parameter. * @@ -211,34 +215,33 @@ public void handleRemoval() { @Override public void initialize() { - if (getBridge() instanceof Bridge) { - // accessory is hosted by a bridge, so use bridge's pairing session and read/write service - isChildAccessory = true; - } else { - // standalone accessory or bridge accessory, so do pairing and session setup here - isChildAccessory = false; + eventedCharacteristics.clear(); + accessories.clear(); + isChildAccessory = getBridge() instanceof Bridge; + if (!isChildAccessory) { scheduleConnectionAttempt(); } + updateStatus(ThingStatus.UNKNOWN); } /** * Restores an existing pairing. * Updates the thing status accordingly. */ - private synchronized void verifyPairing() { + private synchronized boolean verifyPairing() { isConfigured = false; Long accessoryId = checkedAccessoryId(); - String hostName = checkedHostName(); + String ipAddress = checkedIpAddress(); String macAddress = checkedMacAddress(); - String mdnsServiceName = checkedMdnsServiceName(hostName); - if (accessoryId == null || hostName == null || macAddress == null || mdnsServiceName == null) { - return; // configuration error + String hostName = checkedHostName(); + if (accessoryId == null || ipAddress == null || macAddress == null || hostName == null) { + return false; // configuration error } isConfigured = true; // create new transport - if (checkedCreateIpTransport(hostName, mdnsServiceName) == null) { - return; // transport creation failed + if (checkedCreateIpTransport(ipAddress, hostName) == null) { + return false; // transport creation failed } if (keyStore.getAccessoryKey(macAddress) instanceof Ed25519PublicKeyParameters accessoryKey) { @@ -251,8 +254,8 @@ private synchronized void verifyPairing() { rwService = new CharacteristicReadWriteClient(getIpTransport()); logger.debug("Restored pairing was verified for {}", thing.getUID()); - scheduler.schedule(this::fetchAccessoriesTask, MIN_CONNECTION_ATTEMPT_DELAY_SECONDS, TimeUnit.SECONDS); - return; // pairing restore succeeded => exit + scheduler.schedule(this::fetchAccessories, MIN_CONNECTION_ATTEMPT_DELAY_SECONDS, TimeUnit.SECONDS); + return true; // pairing restore succeeded => exit } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException | InvalidCipherTextException | IOException | InterruptedException | TimeoutException | ExecutionException | IllegalStateException e) { @@ -266,6 +269,7 @@ private synchronized void verifyPairing() { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); } + return false; } public Map getAccessories() { @@ -294,8 +298,12 @@ private String normalizePairingCode(String input) throws IllegalArgumentExceptio protected void scheduleConnectionAttempt() { if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { bridgeHandler.scheduleConnectionAttempt(); - } else if (connectionAttemptTask == null) { - connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, TimeUnit.SECONDS); + } else { + ScheduledFuture task = connectionAttemptTask; + if (task == null || task.isDone() || task.isCancelled()) { + connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, + TimeUnit.SECONDS); + } } } @@ -308,14 +316,13 @@ private synchronized void attemptConnect() { transport.close(); ipTransport = null; } - verifyPairing(); - if (isConfigured && thing.getStatus() != ThingStatus.ONLINE) { // config ok but connection failed => try again + if (verifyPairing()) { + connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; + connectionAttemptTask = null; + } else if (isConfigured) { // config ok but connection failed => try again connectionAttemptDelay = Math.min(MAX_CONNECTION_ATTEMPT_DELAY_SECONDS, (int) Math.pow(connectionAttemptDelay, 2)); connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, TimeUnit.SECONDS); - } else { // succeeded => reset delay - connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; - connectionAttemptTask = null; } } @@ -353,23 +360,20 @@ protected CharacteristicReadWriteClient getRwService() throws IllegalAccessExcep return rwService; } - @Override - public abstract void onEvent(String jsonContent); - @Override public Collection> getServices() { // only non child accessories require pairing support return thing.getBridgeUID() != null ? Set.of() : Set.of(HomekitPairingActions.class); } - private @Nullable String checkedHostName() { - Object host = getConfig().get(CONFIG_HOST); - if (host == null || !(host instanceof String hostName) || !HOST_PATTERN.matcher(hostName).matches()) { + private @Nullable String checkedIpAddress() { + Object obj = getConfig().get(CONFIG_IP_ADDRESS); + if (obj == null || !(obj instanceof String ipAddress) || !IPV4_PATTERN.matcher(ipAddress).matches()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.invalid-host", "Invalid host", null)); + i18nProvider.getText(bundle, "error.invalid-ip-address", "Invalid IP address", null)); return null; } - return hostName; + return ipAddress; } private @Nullable String checkedMacAddress() { @@ -381,33 +385,14 @@ public Collection> getServices() { return macAddress; } - /** - * Checks and formats the mDNS service name from the configuration. The result is in the - * format 'foobar.hap.tcp.local' or 'foobar.hap.tcp.local:port'; the port is included if it - * is specified in the host name and is not default port '80'; spaces are escaped to '\032'. - * - * @param hostName the host name (may contain port) - * @return the formatted mDNS service name, or null if there is a configuration error - */ - private @Nullable String checkedMdnsServiceName(@Nullable String hostName) { - if (!(getConfig().get(CONFIG_MDNS_SERVICE_NAME) instanceof String mdnsServiceName) - || mdnsServiceName.isBlank()) { + private @Nullable String checkedHostName() { + Object obj = getConfig().get(CONFIG_HOST_NAME); + if (obj == null || !(obj instanceof String hostName) || !HOST_PATTERN.matcher(hostName).matches()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.missing-mdns-service-name", "Missing mDNS service name", null)); + i18nProvider.getText(bundle, "error.invalid-host-name", "Invalid fully qualified host name", null)); return null; } - if (hostName == null) { - return null; - } - if (mdnsServiceName.endsWith(".")) { - mdnsServiceName = mdnsServiceName.substring(0, mdnsServiceName.length() - 1); - } - mdnsServiceName = mdnsServiceName.replace(" ", "\\032"); - String[] parts = hostName.split(":"); - if (parts.length == 2 && !"80".equals(parts[1])) { - mdnsServiceName += ":" + parts[1]; - } - return mdnsServiceName; + return hostName; } private @Nullable Long checkedAccessoryId() { @@ -420,9 +405,9 @@ public Collection> getServices() { return accessoryId; } - private @Nullable IpTransport checkedCreateIpTransport(String hostName, String mdnsServiceName) { + private @Nullable IpTransport checkedCreateIpTransport(String ipAddress, String hostName) { try { - IpTransport ipTransport = new IpTransport(hostName, mdnsServiceName, this); + IpTransport ipTransport = new IpTransport(ipAddress, hostName, this); this.ipTransport = ipTransport; return ipTransport; } catch (IOException e) { @@ -453,16 +438,16 @@ public void pair(String code, boolean withExternalAuthentication) { isConfigured = false; Long accessoryId = checkedAccessoryId(); - String hostName = checkedHostName(); + String ipAddress = checkedIpAddress(); String macAddress = checkedMacAddress(); - String mdnsServiceName = checkedMdnsServiceName(hostName); - if (accessoryId == null || hostName == null || macAddress == null || mdnsServiceName == null) { + String hostName = checkedHostName(); + if (accessoryId == null || ipAddress == null || macAddress == null || hostName == null) { return; // configuration error } isConfigured = true; // create new transport - if (checkedCreateIpTransport(hostName, mdnsServiceName) == null) { + if (checkedCreateIpTransport(ipAddress, hostName) == null) { return; // transport creation failed } @@ -570,14 +555,6 @@ protected void createProperties() { } } - /** - * Gets the list of characteristics that should be evented (subscribed to). - * Subclasses must implement this to return the relevant list of characteristics. - * - * @return list of evented characteristics - */ - protected abstract List getEventedCharacteristics(); - /** * Wrapper to enable or disable events with exception handling. * @@ -602,7 +579,7 @@ private void enableEvents(boolean enable) { } /** - * Inner method to enable or disable events for the characteristics returned by getEventedCharacteristics(). + * Inner method to enable or disable events members of the eventedCharacteristics list. * All exceptions are thrown upwards to the caller. * * @param enable true to enable events, false to disable @@ -616,11 +593,12 @@ private void enableEvents(boolean enable) { private void enableEventsOrThrow(boolean enable) throws IllegalStateException, IllegalAccessException, IOException, InterruptedException, TimeoutException, ExecutionException { if (isChildAccessory) { - return; // child accessories delegate to bridge + logger.warn("Forbidden to enable/disable events on child accessory '{}'", thing.getUID()); + return; } Service service = new Service(); service.characteristics = new ArrayList<>(); - service.characteristics.addAll(getEventedCharacteristics().stream().map(characteristic -> { + service.characteristics.addAll(eventedCharacteristics.stream().map(characteristic -> { characteristic.ev = enable; return characteristic; }).toList()); @@ -629,4 +607,25 @@ private void enableEventsOrThrow(boolean enable) throws IllegalStateException, I logger.debug("Eventing {}abled for {} channels", enable ? "en" : "dis", service.characteristics.size()); } } + + /** + * Checks if all handler instances are initialized. + * Subclasses override this to implement the waiting logic. + */ + protected abstract boolean checkHandlersInitialized(); + + /** + * Called when the root thing has finished loading the accessories. + * Subclasses override this to perform any processing required. + */ + protected abstract void onAccessoriesLoaded(); + + /** + * Called when the root handler is fully online. + * Subclasses override this to perform any processing required. + */ + protected abstract void onRootHandlerReady(); + + @Override + public abstract void onEvent(String jsonContent); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index a4dfb3e8cc39f..70537919236c4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -12,15 +12,12 @@ */ package org.openhab.binding.homekit.internal.handler; -import java.util.ArrayList; import java.util.Collection; -import java.util.List; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.homekit.internal.action.HomekitPairingActions; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; -import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.i18n.TranslationProvider; @@ -31,6 +28,7 @@ import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.thing.binding.builder.BridgeBuilder; +import org.openhab.core.thing.util.ThingHandlerHelper; import org.openhab.core.types.Command; import org.osgi.framework.Bundle; @@ -72,8 +70,13 @@ protected BridgeBuilder editThing() { } @Override - public void initialize() { - super.initialize(); + public void handleCommand(ChannelUID channelUID, Command command) { + // do nothing + } + + @Override + public Collection> getServices() { + return Set.of(HomekitChildDiscoveryService.class, HomekitPairingActions.class); } @Override @@ -87,42 +90,37 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { } @Override - protected void accessoriesLoaded() { - createProperties(); - getThing().getThings().forEach(thing -> { - if (thing.getHandler() instanceof HomekitAccessoryHandler homekitAccessoryHandler) { - homekitAccessoryHandler.accessoriesLoaded(); - } - }); + protected boolean checkHandlersInitialized() { + return getThing().getThings().stream().allMatch(child -> ThingHandlerHelper.isHandlerInitialized(child)); } @Override - public void onEvent(String jsonContent) { - getThing().getThings().forEach(thing -> { - if (thing.getHandler() instanceof HomekitAccessoryHandler homekitAccessoryHandler) { - homekitAccessoryHandler.onEvent(jsonContent); + protected void onAccessoriesLoaded() { + createProperties(); + getThing().getThings().forEach(child -> { + if (child.getHandler() instanceof HomekitBaseAccessoryHandler childHandler) { + childHandler.onAccessoriesLoaded(); } }); } @Override - public void handleCommand(ChannelUID channelUID, Command command) { - // do nothing - } - - @Override - public Collection> getServices() { - return Set.of(HomekitChildDiscoveryService.class, HomekitPairingActions.class); + protected void onRootHandlerReady() { + eventedCharacteristics.clear(); + getThing().getThings().forEach(child -> { + if (child.getHandler() instanceof HomekitBaseAccessoryHandler childHandler) { + childHandler.onRootHandlerReady(); + eventedCharacteristics.addAll(childHandler.eventedCharacteristics); + } + }); } @Override - protected List getEventedCharacteristics() { - List result = new ArrayList<>(); - getThing().getThings().forEach(thing -> { - if (thing.getHandler() instanceof HomekitAccessoryHandler homekitAccessoryHandler) { - result.addAll(homekitAccessoryHandler.getEventedCharacteristics()); + public void onEvent(String jsonContent) { + getThing().getThings().forEach(child -> { + if (child.getHandler() instanceof HomekitBaseAccessoryHandler childHandler) { + childHandler.onEvent(jsonContent); } }); - return result; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index 58d3c3bb7f0e9..eec2e5afacfa5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -48,9 +48,6 @@ public class SecureSession { public SecureSession(Socket socket, AsymmetricSessionKeys keys) throws IOException { in = new DataInputStream(socket.getInputStream()); - if (in.available() > 0) { - in.readAllBytes(); // clear any pre-existing data - } out = socket.getOutputStream(); writeKey = keys.getWriteKey(); readKey = keys.getReadKey(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 693963f1c2e7e..5a0fe501e111c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -57,7 +57,7 @@ public class IpTransport implements AutoCloseable { private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "homekit-io")); private final Socket socket; - private final String mdnsServiceName; + private final String hostName; private final EventListener eventListener; private @Nullable SecureSession secureSession = null; @@ -70,26 +70,29 @@ public class IpTransport implements AutoCloseable { /** * Creates a new IpTransport instance on the given host. * - * @param hostName the IP address and port of the HomeKit accessory + * @param ipAddress the IP address and port of the HomeKit accessory + * @param hostName the fully qualified host name (e.g. 'foobar.local') of the HomeKit accessory * @throws IOException */ - public IpTransport(String hostName, String mdnsServiceName, EventListener eventListener) throws IOException { - logger.debug("Connecting to {}", hostName); - this.mdnsServiceName = mdnsServiceName; + public IpTransport(String ipAddress, String hostName, EventListener eventListener) throws IOException { + logger.debug("Connecting to {} alias {}", ipAddress, hostName); + this.hostName = hostName; this.eventListener = eventListener; - String[] parts = hostName.split(":"); + String[] parts = ipAddress.split(":"); socket = new Socket(); socket.setKeepAlive(true); // keep-alive forbiddden for accessories but client should use it socket.setTcpNoDelay(true); // disable Nagle algorithm to force immediate flushing of packets socket.connect(new InetSocketAddress(parts[0], Integer.parseInt(parts[1])), TIMEOUT_MILLI_SECONDS); - logger.debug("Connected to {}", hostName); + logger.debug("Connected to {} alias {}", ipAddress, hostName); } public void setSessionKeys(AsymmetricSessionKeys keys) throws IOException { + logger.trace("setSessionKeys()"); secureSession = new SecureSession(socket, keys); Thread thread = new Thread(this::readTask, "homekit-read"); readThread = thread; thread.start(); + logger.trace("setSessionKeys() {}", secureSession); } /** @@ -230,7 +233,7 @@ private synchronized byte[] execute(String method, String endpoint, String conte private byte[] buildRequest(String method, String endpoint, String contentType, byte[] content) throws IOException { StringBuilder sb = new StringBuilder(); sb.append(method).append(" ").append(endpoint).append(" HTTP/1.1\r\n"); - sb.append("Host: ").append(mdnsServiceName).append("\r\n"); + sb.append("Host: ").append(hostName).append("\r\n"); if (!contentIsEmpty(method)) { sb.append("Content-Length: ").append(content.length).append("\r\n"); sb.append("Content-Type: ").append(contentType).append("\r\n"); diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index bca54561f0a94..968a1fbbb500a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -14,32 +14,32 @@ thing-type.homekit.bridge.description = HomeKit Accessory Bridge thing-type.config.homekit.accessory.accessoryID.label = Accessory ID thing-type.config.homekit.accessory.accessoryID.description = ID of the accessory. -thing-type.config.homekit.accessory.host.label = IP Address -thing-type.config.homekit.accessory.host.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.accessory.hostName.label = Host Name +thing-type.config.homekit.accessory.hostName.description = The accessory fully qualified host name as discovered by mDNS. +thing-type.config.homekit.accessory.ipAddress.label = IP Address +thing-type.config.homekit.accessory.ipAddress.description = IP v4 address of the HomeKit accessory. thing-type.config.homekit.accessory.macAddress.label = MAC Address thing-type.config.homekit.accessory.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.accessory.mdnsServiceName.label = mDNS Service Name -thing-type.config.homekit.accessory.mdnsServiceName.description = The name of the discovered mDNS service. thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. thing-type.config.homekit.bridge.accessoryID.label = Accessory ID thing-type.config.homekit.bridge.accessoryID.description = ID of the accessory. -thing-type.config.homekit.bridge.host.label = IP Address -thing-type.config.homekit.bridge.host.description = IP v4 address of the HomeKit bridge. +thing-type.config.homekit.bridge.hostName.label = Host Name +thing-type.config.homekit.bridge.hostName.description = The bridge fully qualified host name as discovered by mDNS. +thing-type.config.homekit.bridge.ipAddress.label = IP Address +thing-type.config.homekit.bridge.ipAddress.description = IP v4 address of the HomeKit bridge. thing-type.config.homekit.bridge.macAddress.label = MAC Address thing-type.config.homekit.bridge.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.bridge.mdnsServiceName.label = mDNS Service Name -thing-type.config.homekit.bridge.mdnsServiceName.description = The name of the discovered mDNS service. # thing error state messages error.bridge-not-connected = Bridge not connected -error.invalid-host = Invalid host +error.invalid-ip-address = Invalid IP address error.failed-to-connect = Failed to connect error.invalid-pairing-code = Invalid pairing code error.invalid-accessory-id = Invalid accessory ID +error.invalid-host-name = Invalid fully qualified host name error.missing-mac-address = Missing MAC address -error.missing-mdns-service-name = Missing mDNS service name error.pairing-verification-failed = Pairing / verification failed error.polling-error = Polling error error.error-sending-command = Error sending command diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index ea5cbb07dadd2..2b34c044a683e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -8,7 +8,7 @@ HomeKit Accessory Device - + network-address IP v4 address of the HomeKit accessory. @@ -18,9 +18,9 @@ Unique accessory identifier. true - - - The name of the discovered mDNS service. + + + The accessory fully qualified host name as discovered by mDNS. true @@ -42,7 +42,7 @@ HomeKit Accessory Bridge - + network-address IP v4 address of the HomeKit bridge. @@ -52,9 +52,9 @@ Unique accessory identifier. true - - - The name of the discovered mDNS service. + + + The bridge fully qualified host name as discovered by mDNS. true From 0f8c236fd609e72b15fe3ee055458f0fc8278773 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 7 Nov 2025 19:04:50 +0000 Subject: [PATCH 119/177] escape host name spaces Signed-off-by: Andrew Fiddian-Green --- .../internal/discovery/HomekitMdnsDiscoveryParticipant.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index d19cb6c4752a0..81bc5e7272fa2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -158,7 +158,7 @@ private Map getProperties(ServiceInfo service) { * @return the normalized host name. */ private String getHostName(ServiceInfo service) { - String hostName = service.getServer(); + String hostName = service.getServer().replace(" ", "\032"); // escape spaces if (hostName.endsWith(".")) { hostName = hostName.substring(0, hostName.length() - 1); } From 579f0e8bb695b886bb50fe8fd03088becbc68c98 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 7 Nov 2025 21:56:33 +0000 Subject: [PATCH 120/177] second try for host name with spaces Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/HomekitBindingConstants.java | 5 +++-- .../internal/discovery/HomekitMdnsDiscoveryParticipant.java | 2 +- .../internal/handler/HomekitBaseAccessoryHandler.java | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 9e3b4fc427fc3..7263443b31513 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -76,6 +76,7 @@ public class HomekitBindingConstants { "^(((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)):(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]?\\d{1,4})$"); // pattern matcher for a fully qualified host name like foobar.local or foobar.local:12345 - // NOTE: this specially allows '\' characters in the host name -- even if normally not allowed by the RFC - public static final Pattern HOST_PATTERN = Pattern.compile("^([a-zA-Z0-9\\\\-]+)\\.local(?::(\\d{1,5}))?$"); + // NOTE: this specially allows space characters in the host name -- even if normally not allowed by the RFC + public static final Pattern HOST_PATTERN = Pattern.compile( + "^([a-zA-Z0-9\\-\\x20]+)\\.local(?::([1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5]))?$"); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 81bc5e7272fa2..d19cb6c4752a0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -158,7 +158,7 @@ private Map getProperties(ServiceInfo service) { * @return the normalized host name. */ private String getHostName(ServiceInfo service) { - String hostName = service.getServer().replace(" ", "\032"); // escape spaces + String hostName = service.getServer(); if (hostName.endsWith(".")) { hostName = hostName.substring(0, hostName.length() - 1); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 25cfc17cbd3ba..99363a861fda1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -392,7 +392,7 @@ public Collection> getServices() { i18nProvider.getText(bundle, "error.invalid-host-name", "Invalid fully qualified host name", null)); return null; } - return hostName; + return hostName.replace(" ", "\\032"); // escape mDNS spaces } private @Nullable Long checkedAccessoryId() { From 7d48c9ff6f7cc425855228f00b899c875d3520f0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 9 Nov 2025 12:42:19 +0000 Subject: [PATCH 121/177] various - iid changes from integer to long - host name changed to mdns derived - add results to thing actions - trigger child discovery faster - refactor chunked decoding; add test - tweak start up sequencing Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 2 +- .../internal/HomekitBindingConstants.java | 4 +- .../action/HomekitPairingActions.java | 19 ++- .../HomekitChildDiscoveryService.java | 14 +- .../HomekitMdnsDiscoveryParticipant.java | 12 +- .../homekit/internal/dto/Accessory.java | 2 +- .../homekit/internal/dto/Characteristic.java | 2 +- .../binding/homekit/internal/dto/Service.java | 4 +- .../handler/HomekitAccessoryHandler.java | 6 +- .../handler/HomekitBaseAccessoryHandler.java | 90 ++++++----- .../handler/HomekitBridgeHandler.java | 20 +++ .../internal/session/HttpPayloadParser.java | 148 +++++++++--------- .../internal/transport/IpTransport.java | 7 +- .../resources/OH-INF/i18n/homekit.properties | 4 + .../TestChannelCreationForAppleJson.java | 4 +- .../TestChannelCreationForVeluxJson.java | 4 +- .../internal/TestHttpChunkedParser.java | 135 ++++++++++++++++ 17 files changed, 332 insertions(+), 145 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 37fd36c9ad97d..41188b7634b46 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -41,7 +41,7 @@ Therefore child things have this parameter preset to `n/a`. As a general rule, `hostName` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. -It must be the fully qualified host name (e.g. `foobar.local` or, if the port is not 0 or 80, `foobar.local:1234` ) as found manually via (say) an mDNS discovery app. +It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.:1234` ) as found manually via (say) an mDNS discovery app. Child `accessory` Things do not require a `hostName`. Therefore child things have this parameter preset to `n/a`. diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 7263443b31513..9e5098ce22386 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -75,8 +75,8 @@ public class HomekitBindingConstants { public static final Pattern IPV4_PATTERN = Pattern.compile( "^(((25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d{2}|[1-9]?\\d)):(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]?\\d{1,4})$"); - // pattern matcher for a fully qualified host name like foobar.local or foobar.local:12345 + // pattern matcher for a fully qualified host name like foobar._hap._tcp.local. or foobar._hap._tcp.local.:12345 // NOTE: this specially allows space characters in the host name -- even if normally not allowed by the RFC public static final Pattern HOST_PATTERN = Pattern.compile( - "^([a-zA-Z0-9\\-\\x20]+)\\.local(?::([1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5]))?$"); + "^([a-zA-Z0-9\\-\\x20]+)\\._hap\\._tcp\\.local\\.(?::([1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5]))?$"); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java index 3574d5a292eb0..ba8785e8ed4e7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java @@ -16,6 +16,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.handler.HomekitBaseAccessoryHandler; import org.openhab.core.automation.annotation.ActionInput; +import org.openhab.core.automation.annotation.ActionOutput; import org.openhab.core.automation.annotation.RuleAction; import org.openhab.core.thing.binding.ThingActions; import org.openhab.core.thing.binding.ThingActionsScope; @@ -38,17 +39,17 @@ public class HomekitPairingActions implements ThingActions { private final Logger logger = LoggerFactory.getLogger(HomekitPairingActions.class); private @Nullable HomekitBaseAccessoryHandler handler; - public static void pair(ThingActions actions, String code, boolean auth) { + public static Boolean pair(ThingActions actions, String code, boolean auth) { if (actions instanceof HomekitPairingActions accessoryActions) { - accessoryActions.pair(code, auth); + return accessoryActions.pair(code, auth); } else { throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitAccessoryActions"); } } - public static void unpair(ThingActions actions) { + public static Boolean unpair(ThingActions actions) { if (actions instanceof HomekitPairingActions accessoryActions) { - accessoryActions.unpair(); + return accessoryActions.unpair(); } else { throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitAccessoryActions"); } @@ -65,24 +66,26 @@ public void setThingHandler(@Nullable ThingHandler handler) { } @RuleAction(label = "@text/actions.pairing-action.label", description = "@text/actions.pairing-action.description") - public void pair( + public @ActionOutput(type = "java.lang.Boolean", label = "@text/actions.pairing-success.label", description = "@text/actions.pairing-success.description") Boolean pair( @ActionInput(name = "code", label = "@text/actions.pairing-code.label", description = "@text/actions.pairing-code.description") String code, @ActionInput(name = "auth", label = "@text/actions.pairing-auth.label", description = "@text/actions.pairing-auth.description", defaultValue = "false") boolean auth) { HomekitBaseAccessoryHandler handler = this.handler; if (handler != null) { - handler.pair(code, auth); + return handler.pair(code, auth); } else { logger.warn("ThingHandler is null."); } + return false; } @RuleAction(label = "@text/actions.unpairing-action.label", description = "@text/actions.unpairing-action.description") - public void unpair() { + public @ActionOutput(type = "java.lang.Boolean", label = "@text/actions.unpairing-success.label", description = "@text/actions.unpairing-success.description") Boolean unpair() { HomekitBaseAccessoryHandler handler = this.handler; if (handler != null) { - handler.unpair(); + return handler.unpair(); } else { logger.warn("ThingHandler is null."); } + return false; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index c497043aba47b..338a9c90d25cb 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -43,6 +43,18 @@ public HomekitChildDiscoveryService() { super(HomekitBridgeHandler.class, Set.of(THING_TYPE_ACCESSORY), TIMEOUT_SECONDS); } + @Override + public void initialize() { + super.initialize(); + thingHandler.registerDiscoveryService(this); + } + + @Override + public void dispose() { + thingHandler.unregisterDiscoveryService(); + super.dispose(); + } + @Override public void startScan() { if (thingHandler instanceof HomekitBridgeHandler handler) { @@ -52,7 +64,7 @@ public void startScan() { private void discoverChildren(Thing bridge, Collection accessories) { accessories.forEach(accessory -> { - if (accessory.aid instanceof Long aid && aid != 1 && accessory.services != null) { + if (accessory.aid instanceof Long aid && aid != 1L && accessory.services != null) { ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), aid.toString()); String thingLabel = "%s (%d)".formatted(accessory.getAccessoryInstanceLabel(), accessory.aid); thingDiscovered(DiscoveryResultBuilder.create(uid) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index d19cb6c4752a0..baf73b98d7f99 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -151,20 +151,14 @@ private Map getProperties(ServiceInfo service) { } /** - * Returns the fully qualified host name by ensuring it ends with ".local" plus, if the port is - * neither '0' nor the default 80, the respective suffix e.g. 'foobar.local' or 'foobar.local:12345' + * Returns the fully qualified host name being the mDNS qualified service name plus, if the port is neither '0' + * nor the default 80, the respective suffix e.g. 'foobar._hap._tcp.local.' or 'foobar._hap._tcp.local.:12345' * * @param service the ServiceInfo object. * @return the normalized host name. */ private String getHostName(ServiceInfo service) { - String hostName = service.getServer(); - if (hostName.endsWith(".")) { - hostName = hostName.substring(0, hostName.length() - 1); - } - if (!hostName.endsWith(".local")) { - hostName += ".local"; - } + String hostName = service.getQualifiedName(); int port = service.getPort(); if (port != 80 && port != 0) { hostName += ":" + port; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 3b5be4ed216b9..3601723501a97 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -186,7 +186,7 @@ public String getAccessoryInstanceLabel() { return toString(); } - public @Nullable Service getService(Integer iid) { + public @Nullable Service getService(Long iid) { return services.stream().filter(s -> iid.equals(s.iid)).findFirst().orElse(null); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index e24d23aa8fcf1..2cb516f11e53f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -60,7 +60,7 @@ public class Characteristic { public @NonNullByDefault({}) String type; // 25 = public.hap.characteristic.on public @NonNullByDefault({}) String format; // e.g. "bool" public @NonNullByDefault({}) List perms; // e.g. ["pr", "pw", "ev"] - public @NonNullByDefault({}) Integer iid; // e.g. 10 + public @NonNullByDefault({}) Long iid; // e.g. 10 public @NonNullByDefault({}) String unit; // e.g. "celsius" or "percentage" public @NonNullByDefault({}) Double maxValue; // e.g. 100 public @NonNullByDefault({}) Double minValue; // e.g. 0 diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index f8be8b957c53c..2fc5a22625a80 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -43,7 +43,7 @@ @NonNullByDefault public class Service { public @NonNullByDefault({}) String type; // e.g. '96' => 'public.hap.service.battery' - public @NonNullByDefault({}) Integer iid; // e.g. 10 + public @NonNullByDefault({}) Long iid; // e.g. 10 public @NonNullByDefault({}) String name; public @NonNullByDefault({}) List characteristics; public @NonNullByDefault({}) Boolean primary; @@ -119,7 +119,7 @@ public String getChannelGroupInstanceLabel() { } } - public @Nullable Characteristic getCharacteristic(Integer iid) { + public @Nullable Characteristic getCharacteristic(Long iid) { return characteristics.stream().filter(c -> iid.equals(c.iid)).findFirst().orElse(null); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index e7efe24866881..35b8b0380cc6f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -122,7 +122,7 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { * Internal record representing a link between an OH channel and a HomeKit characteristic type & iid. * Used for light model management. */ - private record LightModelLink(Channel channel, CharacteristicType cxxType, Integer cxxIid) { + private record LightModelLink(Channel channel, CharacteristicType cxxType, Long cxxIid) { } private final List lightModelLinks = new ArrayList<>(); @@ -800,7 +800,7 @@ private void eventingFinalize(Accessory accessory, List channels) { if (iid.equals(String.valueOf(cxx.iid)) && cxx.perms instanceof List perms && perms.contains("ev")) { Characteristic eventedCxx = new Characteristic(); - eventedCxx.iid = Integer.parseInt(iid); + eventedCxx.iid = Long.parseLong(iid); eventedCxx.aid = getAccessoryId(); eventedCharacteristics.add(eventedCxx); } @@ -865,7 +865,7 @@ private synchronized void writeChannel(Channel channel, Command command, Charact Service service = new Service(); Characteristic characteristic = new Characteristic(); characteristic.aid = aid; - characteristic.iid = Integer.parseInt(iid); + characteristic.iid = Long.parseLong(iid); characteristic.value = commandToJsonPrimitive(command, channel); service.characteristics = List.of(characteristic); String response = writer.writeCharacteristic(GSON.toJson(service)); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 99363a861fda1..8fe605bcf2cc1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -180,8 +180,7 @@ private void processAccessories() { } onAccessoriesLoaded(); onRootHandlerReady(); - enableEvents(true); - updateStatus(ThingStatus.ONLINE); + onThingOnline(); } /** @@ -239,37 +238,41 @@ private synchronized boolean verifyPairing() { } isConfigured = true; + // check if we have a stored key + Ed25519PublicKeyParameters accessoryKey = keyStore.getAccessoryKey(macAddress); + if (accessoryKey == null) { + logger.debug("No stored pairing credentials for {}", thing.getUID()); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); + return false; + } + // create new transport if (checkedCreateIpTransport(ipAddress, hostName) == null) { return false; // transport creation failed } - if (keyStore.getAccessoryKey(macAddress) instanceof Ed25519PublicKeyParameters accessoryKey) { - try { - logger.debug("Starting Pair-Verify with existing key for {}", thing.getUID()); - PairVerifyClient client = new PairVerifyClient(getIpTransport(), keyStore.getControllerUUID(), - keyStore.getControllerKey(), accessoryKey); - - getIpTransport().setSessionKeys(client.verify()); - rwService = new CharacteristicReadWriteClient(getIpTransport()); - - logger.debug("Restored pairing was verified for {}", thing.getUID()); - scheduler.schedule(this::fetchAccessories, MIN_CONNECTION_ATTEMPT_DELAY_SECONDS, TimeUnit.SECONDS); - return true; // pairing restore succeeded => exit - } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException - | InvalidCipherTextException | IOException | InterruptedException | TimeoutException - | ExecutionException | IllegalStateException e) { - logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); - // pairing restore failed => exit and perhaps try again later - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, - "error.pairing-verification-failed", "Pairing / Verification failed", null)); - } - } else { - logger.debug("No stored pairing credentials for {}", thing.getUID()); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); + // attempt to verify pairing + try { + logger.debug("Starting Pair-Verify with existing key for {}", thing.getUID()); + PairVerifyClient client = new PairVerifyClient(getIpTransport(), keyStore.getControllerUUID(), + keyStore.getControllerKey(), accessoryKey); + + getIpTransport().setSessionKeys(client.verify()); + rwService = new CharacteristicReadWriteClient(getIpTransport()); + + logger.debug("Restored pairing was verified for {}", thing.getUID()); + scheduler.schedule(this::fetchAccessories, MIN_CONNECTION_ATTEMPT_DELAY_SECONDS, TimeUnit.SECONDS); + return true; // pairing restore succeeded => exit + } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException + | InvalidCipherTextException | IOException | InterruptedException | TimeoutException + | ExecutionException | IllegalStateException e) { + logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); + // pairing restore failed => exit and perhaps try again later + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, + "error.pairing-verification-failed", "Pairing / Verification failed", null)); + return false; } - return false; } public Map getAccessories() { @@ -387,11 +390,14 @@ public Collection> getServices() { private @Nullable String checkedHostName() { Object obj = getConfig().get(CONFIG_HOST_NAME); - if (obj == null || !(obj instanceof String hostName) || !HOST_PATTERN.matcher(hostName).matches()) { + if (obj == null || !(obj instanceof String hostName)) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.invalid-host-name", "Invalid fully qualified host name", null)); return null; } + if (!HOST_PATTERN.matcher(hostName).matches()) { + logger.warn("Host name '{}' does not match expected pattern; using anyway..", hostName); + } return hostName.replace(" ", "\\032"); // escape mDNS spaces } @@ -424,15 +430,15 @@ public Collection> getServices() { * @param code the pairing code * @param withExternalAuthentication true to setup with external authentication e.g. from an app, false otherwise */ - public void pair(String code, boolean withExternalAuthentication) { + public boolean pair(String code, boolean withExternalAuthentication) { if (isChildAccessory) { logger.warn("Cannot pair child accessory '{}'", thing.getUID()); - return; // child accessories cannot be paired directly + return false; // child accessories cannot be paired directly } if (!PAIRING_CODE_PATTERN.matcher(code).matches()) { logger.debug("Pairing code must match XXX-XX-XXX or XXXX-XXXX or XXXXXXXX"); - return; // invalid pairing code format + return false; // invalid pairing code format } String pairingCode = normalizePairingCode(code); @@ -442,13 +448,13 @@ public void pair(String code, boolean withExternalAuthentication) { String macAddress = checkedMacAddress(); String hostName = checkedHostName(); if (accessoryId == null || ipAddress == null || macAddress == null || hostName == null) { - return; // configuration error + return false; // configuration error } isConfigured = true; // create new transport if (checkedCreateIpTransport(ipAddress, hostName) == null) { - return; // transport creation failed + return false; // transport creation failed } try { @@ -462,12 +468,14 @@ public void pair(String code, boolean withExternalAuthentication) { logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; // reset delay on manual pairing scheduleConnectionAttempt(); + return true; // pairing succeeded } catch (Exception e) { // catch all; log all exceptions logger.warn("Pairing / verification failed '{}' for {}", e.getMessage(), thing.getUID()); logger.debug("Stack trace", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.pairing-verification-failed", "Pairing / Verification failed", null)); + return false; // pairing failed } } @@ -499,11 +507,13 @@ private boolean unpairInner() { /** * Thing Action that unpairs the accessory. */ - public void unpair() { - if (unpairInner()) { + public boolean unpair() { + boolean unpaired = unpairInner(); + if (unpaired) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); } + return unpaired; } /** @@ -628,4 +638,14 @@ private void enableEventsOrThrow(boolean enable) throws IllegalStateException, I @Override public abstract void onEvent(String jsonContent); + + /** + * Called when the thing is fully online. + * Enables eventing and updates the thing status to ONLINE. + * Subclasses override this to perform any extra processing required. + */ + protected void onThingOnline() { + enableEvents(true); + updateStatus(ThingStatus.ONLINE); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 70537919236c4..54fb57ce0bc34 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -16,6 +16,7 @@ import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.action.HomekitPairingActions; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; @@ -45,6 +46,8 @@ @NonNullByDefault public class HomekitBridgeHandler extends HomekitBaseAccessoryHandler implements BridgeHandler { + private @Nullable HomekitChildDiscoveryService childDiscoveryService = null; + public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, TranslationProvider i18nProvider, Bundle bundle) { super(bridge, typeProvider, keyStore, i18nProvider, bundle); @@ -123,4 +126,21 @@ public void onEvent(String jsonContent) { } }); } + + @Override + protected void onThingOnline() { + super.onThingOnline(); + HomekitChildDiscoveryService discoveryService = childDiscoveryService; + if (discoveryService != null) { + discoveryService.startScan(); + } + } + + public void registerDiscoveryService(HomekitChildDiscoveryService discoveryService) { + childDiscoveryService = discoveryService; + } + + public void unregisterDiscoveryService() { + childDiscoveryService = null; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java index 04a2509cf3c3f..053f831c0367e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java @@ -14,7 +14,6 @@ import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -41,13 +40,12 @@ public class HttpPayloadParser { private final ByteArrayOutputStream headerBuffer = new ByteArrayOutputStream(); private final ByteArrayOutputStream contentBuffer = new ByteArrayOutputStream(); - private final ByteArrayOutputStream chunkHeaderBuffer = new ByteArrayOutputStream(); + private final ByteArrayOutputStream chunkDataBuffer = new ByteArrayOutputStream(); private boolean headersDone = false; private int contentLength = -1; private int headersLength = -1; private boolean isChunked = false; - private int currentChunkRemaining = -1; private boolean finalChunkSeen = false; /** @@ -57,9 +55,9 @@ public class HttpPayloadParser { * allowed length, a SecurityException is thrown. * * @param frame the byte array containing a fragment of the HTTP message. - * @throws SecurityException if the content exceeds maximum allowed length or if headers are malformed. + * @throws IllegalStateException */ - public void accept(byte[] frame) throws SecurityException { + public void accept(byte[] frame) throws IllegalStateException { if (frame.length == 0) { return; } @@ -181,79 +179,36 @@ public static int getHttpStatusCode(byte[] headerBytes) throws IllegalStateExcep * If the chunked data is malformed or exceeds maximum allowed length, a SecurityException is thrown. * * @param block the byte array containing chunked data to be parsed. - * @throws SecurityException if the chunked data is malformed or exceeds maximum allowed length. + * @throws IllegalStateException if the chunked data is malformed. */ - private void parseChunkedBytes(byte[] block) throws SecurityException { - int pos = 0, blockLength = block.length; - while (pos < blockLength && !finalChunkSeen) { - // are we expecting a new chunk-size line? - if (currentChunkRemaining == -1) { - // look for CRLF wholly inside this data block - int lfPos = indexOfCRLF(block, pos); - // or CR at end of buffer + LF at start of data - boolean boundaryLF = chunkHeaderBuffer.size() > 0 - && chunkHeaderBuffer.toByteArray()[chunkHeaderBuffer.size() - 1] == '\r' && pos < blockLength - && block[pos] == '\n'; - if (lfPos < 0 && !boundaryLF) { - // no complete CRLF yet - buffer everything - chunkHeaderBuffer.write(block, pos, blockLength - pos); - return; + private void parseChunkedBytes(byte[] block) throws IllegalStateException { + chunkDataBuffer.write(block, 0, block.length); // copy all incoming data into the buffer + byte[] chunkBuffer = chunkDataBuffer.toByteArray(); + if (indexOfFinalChunkMarker(chunkBuffer) >= 0) { + finalChunkSeen = true; + int pos = 0; + int max = chunkBuffer.length; + while (pos < max) { + byte[] sizeBuffer = readln(chunkBuffer, pos); + if ((pos += sizeBuffer.length + 2) >= max) { // move past size and CRLF; exit on overrun + break; } - // we have a CRLF either wholly in data, or spanning buffer + data - byte[] chunkHeaderBytes; - if (boundaryLF) { - // CR was in buffer, LF is data[pos] - drop buffer trailing '\r' and data[pos] - byte[] chunkHeaderPrefix = chunkHeaderBuffer.toByteArray(); - // copy prefix without the last byte - chunkHeaderBytes = Arrays.copyOf(chunkHeaderPrefix, chunkHeaderPrefix.length - 1); - pos += 1; // consume the '\n' - } else { - // entire line is in data block - int chunkHeaderLen = lfPos - pos; - chunkHeaderBytes = new byte[chunkHeaderLen]; - System.arraycopy(block, pos, chunkHeaderBytes, 0, chunkHeaderLen); - pos = lfPos + 2; // skip '\r\n' + if (sizeBuffer.length == 0) { + continue; // some implementations insert empty lines, so skip them } - - String chunkHeader = new String(chunkHeaderBytes, StandardCharsets.ISO_8859_1).trim(); - int chunkSize; + int size; try { - chunkSize = Integer.parseInt(chunkHeader, 16); + size = Integer.parseInt(new String(sizeBuffer, StandardCharsets.ISO_8859_1).trim(), 16); } catch (NumberFormatException e) { - throw new SecurityException("Invalid chunk size: " + chunkHeader); + throw new IllegalStateException("Invalid chunk size: " + sizeBuffer); } - chunkHeaderBuffer.reset(); - if (chunkSize == 0) { - finalChunkSeen = true; - return; + contentBuffer.write(chunkBuffer, pos, size); + if ((pos += size) >= max) { // move past data; exit on overrun + break; } - currentChunkRemaining = chunkSize; - } - - // we are in the middle of a chunk - int take = Math.min(currentChunkRemaining, blockLength - pos); - if (take > 0) { - contentBuffer.write(block, pos, take); - pos += take; - currentChunkRemaining -= take; - if (contentBuffer.size() > MAX_CONTENT_LENGTH) { - throw new SecurityException("Content exceeds maximum allowed length"); - } - } - - // once we finish this chunk, we must consume the trailing CRLF - if (currentChunkRemaining == 0) { - if (blockLength - pos >= 2) { - if (block[pos] == '\r' && block[pos + 1] == '\n') { - pos += 2; - currentChunkRemaining = -1; - } else { - throw new SecurityException("Missing CRLF after chunk data"); - } - } else { - // buffer partial CRLF after chunk content for next accept() - chunkHeaderBuffer.write(block, pos, blockLength - pos); - return; + byte[] leftover = readln(chunkBuffer, pos); // read to the next CRLF after the chunk data + if ((pos += leftover.length + 2) >= max) { // skip leftover data and CRLF; exit on overrun + break; } } } @@ -267,10 +222,10 @@ private void parseChunkedBytes(byte[] block) throws SecurityException { * If the content exceeds the maximum allowed length, a SecurityException is thrown. * * @param data the byte array containing content data to be processed. - * @throws SecurityException if the content exceeds maximum allowed length. + * @throws IllegalStateException */ - private void processContentBytes(byte[] data) throws SecurityException { - if (isChunked) { + private void processContentBytes(byte[] data) throws IllegalStateException { + if (isChunked && !finalChunkSeen) { parseChunkedBytes(data); } else if (contentLength >= 0) { // fixed-length content: accept up to contentLength @@ -283,7 +238,7 @@ private void processContentBytes(byte[] data) throws SecurityException { contentBuffer.write(data, 0, data.length); } if (contentBuffer.size() > MAX_CONTENT_LENGTH) { - throw new SecurityException("Content exceeds maximum allowed length"); + throw new IllegalStateException("Content exceeds maximum allowed length"); } } @@ -317,4 +272,47 @@ public static int indexOfDoubleCRLF(byte[] data, int start) { } return -1; } + + /** + * Finds the index of the final chunk marker ("0\r\n\r\n") in the given byte array. + * + * @param data the byte array to search + * @return the index of the final chunk marker, or -1 if not found + */ + public static int indexOfFinalChunkMarker(byte[] data) { + byte[] marker = new byte[] { '0', '\r', '\n', '\r', '\n' }; + int len = data.length; + // start from the last possible position where the marker could begin + for (int i = len - marker.length; i >= 0; i--) { + boolean match = true; + for (int j = 0; j < marker.length; j++) { + if (data[i + j] != marker[j]) { + match = false; + break; + } + } + if (match) { + return i; // found the final chunk marker + } + } + return -1; // not found + } + + /** + * Reads a line from the given byte array starting at the specified index until a CRLF sequence is found. + * + * @param data the byte array to read from + * @param start the starting index for reading + * @return a byte array containing the line read (excluding CRLF) + * @throws IllegalStateException if no CRLF is found + */ + public static byte[] readln(byte[] data, int start) throws IllegalStateException { + int end = indexOfCRLF(data, start); + if (end < 0) { + throw new IllegalStateException("No CRLF found in chunked data"); + } + byte[] line = new byte[end - start]; + System.arraycopy(data, start, line, 0, line.length); + return line; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 5a0fe501e111c..00a3ad3f640c0 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -255,17 +255,18 @@ private boolean contentIsEmpty(String method) { return "GET".equals(method) || "DELETE".equals(method); } - /* + /** * Reads a plain (non-secure) HTTP response from the input stream. * * @param trace if true, captures the raw data for debugging purposes. * * @return a 3D byte array where the first element is the HTTP headers, the second element is the content, - * and the third is the raw trace (if enabled). + * and the third is the raw trace (if enabled). * * @throws IOException if an I/O error occurs or if the response is invalid. + * @throws IllegalStateException if the response is invalid. */ - private byte[][] readPlainResponse(InputStream in, boolean trace) throws IOException { + private byte[][] readPlainResponse(InputStream in, boolean trace) throws IOException, IllegalStateException { HttpPayloadParser httpParser = new HttpPayloadParser(); ByteArrayOutputStream raw = trace ? new ByteArrayOutputStream() : null; byte[] buf = new byte[4096]; diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 968a1fbbb500a..c57b73ddf50b2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -53,8 +53,12 @@ actions.pairing-auth.label = With External Authentication actions.pairing-auth.description = Set 'true' if pairing requires external authentication e.g. from an app (default false). actions.pairing-code.label = Pairing Code actions.pairing-code.description = The 8 digit pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. +actions.pairing-success.label = Pairing Successful +actions.pairing-success.description = True if pairing was successful. actions.unpairing-action.label = Unpair Accessory or Bridge actions.unpairing-action.description = Remove the pairing between this thing and the respective accessory or bridge. +actions.unpairing-success.label = Unpairing Successful +actions.unpairing-success.description = True if unpairing was successful. # characteristic texts diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java index 55d9c6c52690a..9dfa7358a704f 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java @@ -347,11 +347,11 @@ void testDetailJsonParsing() { assertNotNull(accessory); assertEquals(1, accessory.aid); assertEquals(2, accessory.services.size()); - Service service = accessory.getService(1); + Service service = accessory.getService(1L); assertNotNull(service); assertEquals("3E", service.type); assertEquals(6, service.characteristics.size()); - Characteristic characteristic = service.getCharacteristic(2); + Characteristic characteristic = service.getCharacteristic(2L); assertNotNull(characteristic); JsonElement value = characteristic.value; assertNotNull(value); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java index a59748b9c23c7..ca8f95064817e 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -1568,11 +1568,11 @@ void testDetailJsonParsing() { assertNotNull(accessory); assertEquals(1, accessory.aid); assertEquals(3, accessory.services.size()); - Service service = accessory.getService(1); + Service service = accessory.getService(1L); assertNotNull(service); assertEquals("3E", service.type); assertEquals(7, service.characteristics.size()); - Characteristic characteristic = service.getCharacteristic(2); + Characteristic characteristic = service.getCharacteristic(2L); assertNotNull(characteristic); JsonElement value = characteristic.value; assertNotNull(value); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java new file mode 100644 index 0000000000000..730cb72b77e63 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.homekit.internal.session.HttpPayloadParser; + +/** + * Test cases for HTTP parser; in particular for chunked payloads. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestHttpChunkedParser { + + private final String s0 = "HTTP/1.1 200 OK\r\n"; + private final String s1 = "Content-Type: application/hap+json\r\n"; + private final String s2 = "Content-Length: 0\r\n"; + private final String s3 = "Transfer-Encoding: chunked\r\n"; + private final String crlf = "\r\n"; + private final String s5 = "09\r\n"; + private final String s5err = "err\r\n"; + private final String s6 = "123456789\r\n"; + private final String s7 = "0f\r\n"; + private final String s8 = "123456789abcdef\r\n"; + private final String s9 = "0\r\n"; + + @Test + void testValidChunkedPayload() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s5.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept(s9.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + assertTrue(parser.isComplete()); + assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); + } + + @Test + void testBadChunkedSizePayload() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s5err.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept(s9.getBytes(StandardCharsets.UTF_8)); + assertThrows(IllegalStateException.class, () -> parser.accept(crlf.getBytes(StandardCharsets.UTF_8))); + } + + @Test + void testChunkedPayloadWithEmptyLines() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s5.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s9.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + assertTrue(parser.isComplete()); + assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); + } + + @Test + void testIncompleteChunkedPayload() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s5.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept(s9.getBytes(StandardCharsets.UTF_8)); + assertFalse(parser.isComplete()); + assertEquals("", new String(parser.getContent(), StandardCharsets.UTF_8)); + } + + @Test + void testValidChunkedPayloadWitSplitFrames() { + HttpPayloadParser parser = new HttpPayloadParser(); + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept("\r".getBytes(StandardCharsets.UTF_8)); + parser.accept("\n".getBytes(StandardCharsets.UTF_8)); + parser.accept(s5.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept("0".getBytes(StandardCharsets.UTF_8)); + parser.accept("\r".getBytes(StandardCharsets.UTF_8)); + parser.accept("\n".getBytes(StandardCharsets.UTF_8)); + parser.accept("\r".getBytes(StandardCharsets.UTF_8)); + parser.accept("\n".getBytes(StandardCharsets.UTF_8)); + assertTrue(parser.isComplete()); + assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); + } +} From f62fe439eec8e8909fc58e5a81e114c754730a03 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 9 Nov 2025 14:37:59 +0000 Subject: [PATCH 122/177] various - only update linked channels - update thing status faster Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 35b8b0380cc6f..de71bba15e59d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -528,6 +528,7 @@ public void initialize() { scheduler.submit(() -> { onAccessoriesLoaded(); onRootHandlerReady(); + updateStatus(ThingStatus.ONLINE); }); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); @@ -559,7 +560,7 @@ private void refresh() { Long aid = getAccessoryId(); List queries = new ArrayList<>(); thing.getChannels().stream().forEach(c -> { - if (c.getProperties().get(PROPERTY_IID) instanceof String iid) { + if (isLinked(c.getUID()) && c.getProperties().get(PROPERTY_IID) instanceof String iid) { queries.add("%s.%s".formatted(aid, iid)); } }); @@ -794,7 +795,7 @@ private void stopMoveFinalize(Accessory accessory, List channels) { private void eventingFinalize(Accessory accessory, List channels) { eventedCharacteristics.clear(); for (Channel channel : channels) { - if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { + if (isLinked(channel.getUID()) && channel.getProperties().get(PROPERTY_IID) instanceof String iid) { for (Service service : accessory.services) { for (Characteristic cxx : service.characteristics) { if (iid.equals(String.valueOf(cxx.iid)) && cxx.perms instanceof List perms @@ -912,6 +913,9 @@ private void updateChannelsFromJson(String json) { } } } + if (thing.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } if (hsbChannelUID != null) { updateState(hsbChannelUID, Objects.requireNonNull(lightModel).getHsb()); } From 17a23a853825b47f7af39d814c5dc24396e29631 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 11 Nov 2025 18:45:24 +0000 Subject: [PATCH 123/177] various refactoring - all http calls marshalled through root thing - marshalled http calls throttled to 0.5 Hz - improved start-up sequencing - debounced manual refresh Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 18 +- .../HomekitChildDiscoveryService.java | 1 + .../handler/HomekitAccessoryHandler.java | 336 +++++++++--------- .../handler/HomekitBaseAccessoryHandler.java | 301 +++++++++++++--- .../handler/HomekitBridgeHandler.java | 54 ++- .../CharacteristicReadWriteClient.java | 4 +- .../resources/OH-INF/i18n/homekit.properties | 2 + .../resources/OH-INF/thing/thing-types.xml | 6 + 8 files changed, 473 insertions(+), 249 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 41188b7634b46..81ce086d9333a 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -36,25 +36,31 @@ NOTE: as a general rule, if you create the things via the Inbox, then all of the As a general rule `ipAddress` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. It must match the format `123.123.123.123:4567` representing its IP v4 address and port. -Child `accessory` Things do not require a `ipAddress`. -Therefore child things have this parameter preset to `n/a`. As a general rule, `hostName` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.:1234` ) as found manually via (say) an mDNS discovery app. -Child `accessory` Things do not require a `hostName`. -Therefore child things have this parameter preset to `n/a`. As a general rule, `macAddress` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. It must be the unique accessory identifier as found manually via (say) an mDNS discovery app. -Child `accessory` Things do not require a `macAddress`. -Therefore child things have this parameter preset to `n/a`. As a general rule, `accessoryID` is set by the mDNS auto- discovery process, or child discovery process. However you can configure it manually if you wish. It must be the ID of the accessory within the bridge, or `1` if it is a root accessory. +### Thing Configuration for Child Accessories of a Bridge + +Child accessories are `accessory` things which are hosted by a bridge. +Such accessories do not have an own Internet connetion, so all communications are handled by the bridge. +Therefore the following preset values are applied (and changing these values has no impact): + +- The only *required* parameter is `accessoryID` so it MUST have the correct value. +- The `ipAddress` parameter is not used so it is preset to `n/a`. +- The `hostName` parameter is not used so it is preset to `n/a`. +- The `macAddress` parameter is not used so it is preset to `n/a`. +- The `refreshInterval` parameter is not used so it is preset to `60`. + ## Thing Pairing The `bridge` and stand-alone `accessory` Things need to be paired with their respective HomeKit accessories. diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 338a9c90d25cb..de9d475922f43 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -74,6 +74,7 @@ private void discoverChildren(Thing bridge, Collection accessories) { .withProperty(CONFIG_IP_ADDRESS, "n/a") // .withProperty(Thing.PROPERTY_MAC_ADDRESS, "n/a") // .withProperty(CONFIG_ACCESSORY_ID, aid.toString()) // + .withProperty(CONFIG_REFRESH_INTERVAL, "60") // .withRepresentationProperty(CONFIG_ACCESSORY_ID).build()); } }); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index de71bba15e59d..2aa1cc1989a8b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -14,7 +14,6 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; -import java.io.IOException; import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; @@ -23,10 +22,6 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import javax.measure.Unit; import javax.measure.format.MeasurementParseException; @@ -40,7 +35,6 @@ import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.DataFormatType; import org.openhab.binding.homekit.internal.enums.StatusCode; -import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.binding.homekit.internal.temporary.LightModel; @@ -74,6 +68,7 @@ import org.openhab.core.thing.type.ChannelType; import org.openhab.core.thing.type.ChannelTypeRegistry; import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.thing.util.ThingHandlerHelper; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; import org.openhab.core.types.State; @@ -99,8 +94,6 @@ @NonNullByDefault public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { - private static final int INITIAL_DELAY_SECONDS = 2; - // Characteristic types relevant for light model management private static final Set LIGHT_MODEL_RELEVANT_TYPES = Set.of(CharacteristicType.HUE, CharacteristicType.SATURATION, CharacteristicType.BRIGHTNESS, CharacteristicType.COLOR_TEMPERATURE, @@ -128,7 +121,6 @@ private record LightModelLink(Channel channel, CharacteristicType cxxType, Long private final List lightModelLinks = new ArrayList<>(); private @Nullable Channel stopMoveChannel = null; // channel for the stop button (rollershutters) - private @Nullable ScheduledFuture refreshTask; public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, ChannelTypeRegistry channelTypeRegistry, ChannelGroupTypeRegistry channelGroupTypeRegistry, @@ -138,31 +130,6 @@ public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, this.channelGroupTypeRegistry = channelGroupTypeRegistry; } - /** - * Called when the thing handler has been initialized, the pairing verified, the accessories loaded, - * and the channels and properties created. Sets up a scheduled task to periodically refresh the state - * of the accessory. - */ - private void startRefreshTask() { - if (getConfig().get(CONFIG_REFRESH_INTERVAL) instanceof Object refreshInterval) { - try { - int refreshIntervalSeconds = Integer.parseInt(refreshInterval.toString()); - if (refreshIntervalSeconds > 0) { - ScheduledFuture task = refreshTask; - if (task == null || task.isCancelled() || task.isDone()) { - refreshTask = scheduler.scheduleWithFixedDelay(this::refresh, INITIAL_DELAY_SECONDS, - refreshIntervalSeconds, TimeUnit.SECONDS); - } - } - } catch (NumberFormatException e) { - // logged below - } - } - if (refreshTask == null) { - logger.warn("Invalid refresh interval configuration, polling disabled"); - } - } - /** * Converts an openHAB Command to a suitable object for writing to a HomeKit characteristic. * It handles various conversions including unit conversion, clamping to min/max values, @@ -364,22 +331,24 @@ private void createChannels() { Map properties = new HashMap<>(thing.getProperties()); // keep existing properties accessory.buildAndRegisterChannelGroupDefinitions(thing.getUID(), typeProvider, i18nProvider, bundle) .forEach(groupDef -> { - logger.trace("+ChannelGroupDefinition id:{}, typeUID:{}, label:{}, description:{}", - groupDef.getId(), groupDef.getTypeUID(), groupDef.getLabel(), groupDef.getDescription()); + logger.trace("{} ChannelGroupDefinition id:{}, typeUID:{}, label:{}, description:{}", + thing.getUID(), groupDef.getId(), groupDef.getTypeUID(), groupDef.getLabel(), + groupDef.getDescription()); ChannelGroupType channelGroupType = channelGroupTypeRegistry .getChannelGroupType(groupDef.getTypeUID()); if (channelGroupType == null) { - logger.warn("Fatal Error: ChannelGroupType '{}' is not registered", groupDef.getTypeUID()); + logger.warn("{} fatal error ChannelGroupType '{}' is not registered", thing.getUID(), + groupDef.getTypeUID()); } else { - logger.trace("++ChannelGroupType UID:{}, label:{}, category:{}, description:{}", - channelGroupType.getUID(), channelGroupType.getLabel(), channelGroupType.getCategory(), - channelGroupType.getDescription()); + logger.trace("{} ChannelGroupType UID:{}, label:{}, category:{}, description:{}", + thing.getUID(), channelGroupType.getUID(), channelGroupType.getLabel(), + channelGroupType.getCategory(), channelGroupType.getDescription()); channelGroupType.getChannelDefinitions().forEach(chanDef -> { logger.trace( - "+++ChannelDefinition id:{}, label:{}, description:{}, channelTypeUID:{}, autoUpdatePolicy:{}, properties:{}", - chanDef.getId(), chanDef.getLabel(), chanDef.getDescription(), + "{} ChannelDefinition id:{}, label:{}, description:{}, channelTypeUID:{}, autoUpdatePolicy:{}, properties:{}", + thing.getUID(), chanDef.getId(), chanDef.getLabel(), chanDef.getDescription(), chanDef.getChannelTypeUID(), chanDef.getAutoUpdatePolicy(), chanDef.getProperties()); @@ -388,19 +357,19 @@ private void createChannels() { String name = chanDef.getId(); if (chanDef.getLabel() instanceof String value) { properties.put(name, value); - logger.trace("++++Property '{}:{}'", name, value); + logger.trace("{} Property '{}:{}'", thing.getUID(), name, value); } } else { // this is a real channel ChannelType channelType = channelTypeRegistry .getChannelType(chanDef.getChannelTypeUID()); if (channelType == null) { - logger.warn("Fatal Error: ChannelType '{}' is not registered", + logger.warn("{} fatal rrror ChannelType '{}' is not registered", thing.getUID(), chanDef.getChannelTypeUID()); } else { logger.trace( - "++++ChannelType category:{}, description:{}, itemType:{}, label:{}, autoUpdatePolicy:{}, itemType:{}, kind:{}, tags:{}, uid:{}, unitHint:{}", - channelType.getCategory(), channelType.getDescription(), + "{} ChannelType category:{}, description:{}, itemType:{}, label:{}, autoUpdatePolicy:{}, itemType:{}, kind:{}, tags:{}, uid:{}, unitHint:{}", + thing.getUID(), channelType.getCategory(), channelType.getDescription(), channelType.getItemType(), channelType.getLabel(), channelType.getAutoUpdatePolicy(), channelType.getItemType(), channelType.getKind(), channelType.getTags(), channelType.getUID(), @@ -419,8 +388,8 @@ private void createChannels() { channels.add(channel); logger.trace( - "+++++Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", - channel.getAcceptedItemType(), channel.getDefaultTags(), + "{} Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", + thing.getUID(), channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), channel.getKind(), channel.getLabel(), channel.getProperties(), channel.getUID()); @@ -432,7 +401,7 @@ private void createChannels() { lightModelFinalize(accessory, channels); stopMoveFinalize(accessory, channels); - eventingFinalize(accessory, channels); + eventingPollingFinalize(accessory, channels); String oldLabel = thing.getLabel(); String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; @@ -448,7 +417,9 @@ private void createChannels() { Optional.ofNullable(newTag).ifPresent(builder::withSemanticEquipmentTag); updateThing(builder.build()); - logger.debug("Updated thing {} channels, {} properties, label {}, tag {}", channels.size(), + logger.debug( + "{} updated with {} channels (of which {} polled, {} evented), {} properties, label '{}', tag '{}'", + thing.getUID(), channels.size(), polledCharacteristics.size(), eventedCharacteristics.size(), properties.size(), newLabel, newTag); } } @@ -461,17 +432,18 @@ public void handleCommand(ChannelUID channelUID, Command command) { return; } if (command == RefreshType.REFRESH) { - refresh(); + requestManualRefresh(); + return; } try { if (command instanceof StopMoveType stopMoveType && StopMoveType.STOP == stopMoveType) { if (stopMoveChannel instanceof Channel stopMoveChannel) { - writeChannel(stopMoveChannel, OnOffType.ON, getRwService()); - } else if (readChannel(channel, getRwService()) instanceof Command actualPosition) { - writeChannel(channel, actualPosition, getRwService()); + writeChannel(stopMoveChannel, OnOffType.ON); + } else if (readChannel(channel) instanceof Command actualPosition) { + writeChannel(channel, actualPosition); } } else if (channelUID.equals(lightModelClientHSBTypeChannel)) { - lightModelHandleCommand(command, getRwService()); + lightModelHandleCommand(command); if (lightModel instanceof LightModel lightModel) { lightModelLinks.forEach(link -> { switch (link.cxxType) { @@ -499,7 +471,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { }); } } else { - writeChannel(channel, command, getRwService()); + writeChannel(channel, command); } return; // success } catch (InterruptedException e) { @@ -526,8 +498,7 @@ public void initialize() { if (isChildAccessory) { if (getBridge() instanceof Bridge bridge && bridge.getStatus() == ThingStatus.ONLINE) { scheduler.submit(() -> { - onAccessoriesLoaded(); - onRootHandlerReady(); + onRootThingAccessoriesLoaded(); updateStatus(ThingStatus.ONLINE); }); } else { @@ -536,65 +507,16 @@ public void initialize() { } } - @Override - public void handleRemoval() { - cancelRefreshTask(); - super.handleRemoval(); - } - @Override public void dispose() { - cancelRefreshTask(); lightModel = null; lightModelLinks.clear(); lightModelClientHSBTypeChannel = null; eventedCharacteristics.clear(); + polledCharacteristics.clear(); super.dispose(); } - /** - * Polls the accessory for its current state and updates the corresponding channels. - * This method is called periodically by a scheduled executor. - */ - private void refresh() { - Long aid = getAccessoryId(); - List queries = new ArrayList<>(); - thing.getChannels().stream().forEach(c -> { - if (isLinked(c.getUID()) && c.getProperties().get(PROPERTY_IID) instanceof String iid) { - queries.add("%s.%s".formatted(aid, iid)); - } - }); - if (queries.isEmpty()) { - return; - } - try { - String json = getRwService().readCharacteristic(String.join(",", queries)); - updateChannelsFromJson(json); - return; - } catch (InterruptedException e) { - // shutting down; do nothing - } catch (Exception e) { - if (isCommunicationException(e)) { - // communication exception; log at debug and try to reconnect - logger.debug("Communication error '{}' polling accessory, reconnecting..", e.getMessage()); - scheduleConnectionAttempt(); - } else { - // other exception; log at warn and don't try to reconnect - logger.warn("Unexpected error '{}' polling accessory", e.getMessage()); - } - logger.debug("Stack trace", e); - } - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - i18nProvider.getText(bundle, "error.polling-error", "Polling error", null)); - } - - private void cancelRefreshTask() { - if (refreshTask instanceof ScheduledFuture task) { - task.cancel(true); - } - refreshTask = null; - } - private @Nullable StateDescription getStateDescription(Channel channel) { ChannelTypeUID uid = channel.getChannelTypeUID(); ChannelType ct = channelTypeRegistry.getChannelType(uid); @@ -691,14 +613,14 @@ private boolean lightModelRefresh(Characteristic cxx) throws IllegalStateExcepti * * @param hsbCommand the HSBType command containing hue, saturation, and brightness * @param writer the CharacteristicReadWriteClient to send the command - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws IllegalStateException + * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: + * ExecutionException, + * TimeoutException, + * InterruptedException, + * IOException, + * IllegalStateException */ - private void lightModelHandleCommand(Command command, CharacteristicReadWriteClient writer) - throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { + private void lightModelHandleCommand(Command command) throws Exception { LightModel lightModel = this.lightModel; if (lightModel == null) { throw new IllegalStateException("Light model is not initialized"); @@ -708,20 +630,20 @@ private void lightModelHandleCommand(Command command, CharacteristicReadWriteCli link = lightModelLinks.stream().filter(e -> CharacteristicType.HUE == e.cxxType).findFirst(); if (link.isPresent()) { QuantityType hue = QuantityType.valueOf(lightModel.getHue(), Units.DEGREE_ANGLE); - writeChannel(link.get().channel, hue, writer); + writeChannel(link.get().channel, hue); } link = lightModelLinks.stream().filter(e -> CharacteristicType.SATURATION == e.cxxType).findFirst(); if (link.isPresent()) { PercentType saturation = new PercentType(BigDecimal.valueOf(lightModel.getSaturation())); - writeChannel(link.get().channel, saturation, writer); + writeChannel(link.get().channel, saturation); } link = lightModelLinks.stream().filter(e -> CharacteristicType.BRIGHTNESS == e.cxxType).findFirst(); if (link.isPresent() && lightModel.getBrightness(true) instanceof PercentType brightness) { - writeChannel(link.get().channel, brightness, writer); + writeChannel(link.get().channel, brightness); } link = lightModelLinks.stream().filter(e -> CharacteristicType.ON == e.cxxType).findFirst(); if (link.isPresent() && lightModel.getOnOff(true) instanceof OnOffType onOff) { - writeChannel(link.get().channel, onOff, writer); + writeChannel(link.get().channel, onOff); } } @@ -787,29 +709,51 @@ private void stopMoveFinalize(Accessory accessory, List channels) { } /** - * Identifies evented channels by checking for characteristics with the 'ev' permission. + * Finalizes the polled and evented characteristics by identifying which characteristics are linked + * and adding them to the polledCharacteristics list, and which subset of those are evented and adding + * them also to the eventedCharacteristics list. * * @param accessory the accessory containing the characteristics - * @param channels the list of channels to check + * @param channels the list of channels to check for polled and evented characteristics */ - private void eventingFinalize(Accessory accessory, List channels) { + private void eventingPollingFinalize(Accessory accessory, List channels) { eventedCharacteristics.clear(); + polledCharacteristics.clear(); + + final Long aid = getAccessoryId(); + if (aid == null) { + return; + } + for (Channel channel : channels) { - if (isLinked(channel.getUID()) && channel.getProperties().get(PROPERTY_IID) instanceof String iid) { + final ChannelUID channelUID = channel.getUID(); + if (isLinked(channelUID) && channel.getProperties().get(PROPERTY_IID) instanceof String iidProperty) { + final Long iid; + try { + iid = Long.parseLong(iidProperty); + } catch (NumberFormatException e) { + continue; // error will already have been logged elsewhere + } + nestedLoops: // break marker for nested loops below for (Service service : accessory.services) { - for (Characteristic cxx : service.characteristics) { - if (iid.equals(String.valueOf(cxx.iid)) && cxx.perms instanceof List perms - && perms.contains("ev")) { - Characteristic eventedCxx = new Characteristic(); - eventedCxx.iid = Long.parseLong(iid); - eventedCxx.aid = getAccessoryId(); - eventedCharacteristics.add(eventedCxx); + for (Characteristic characteristic : service.characteristics) { + if (iid.equals(characteristic.iid)) { + Characteristic entry = new Characteristic(); + entry.aid = aid; + entry.iid = iid; + polledCharacteristics.put(channelUID, entry); + if (characteristic.perms instanceof List perms && perms.contains("ev")) { + entry = new Characteristic(); + entry.aid = aid; + entry.iid = iid; + eventedCharacteristics.put(channelUID, entry); + } + break nestedLoops; // break from nested loops; i.e. continue to next channel } } } } } - logger.debug("Identified {} evented channels", eventedCharacteristics.size()); } /** @@ -817,21 +761,21 @@ private void eventingFinalize(Accessory accessory, List channels) { * * @param channel the channel to read * @return the current state of the channel, or null if not found - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws IllegalStateException + * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: + * ExecutionException, + * TimeoutException, + * InterruptedException, + * IOException, + * IllegalStateException */ - private synchronized @Nullable State readChannel(Channel channel, CharacteristicReadWriteClient reader) - throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { + private synchronized @Nullable State readChannel(Channel channel) throws Exception { Long aid = getAccessoryId(); String iid = channel.getProperties().get(PROPERTY_IID); if (aid == null || iid == null) { throw new IllegalStateException( "Missing accessory ID or characteristic IID for channel " + channel.getUID()); } - String jsonResponse = reader.readCharacteristic("%s.%s".formatted(aid, iid)); + String jsonResponse = readCharacteristics("%s.%s".formatted(aid, iid)); Service service = GSON.fromJson(jsonResponse, Service.class); if (service != null && service.characteristics instanceof List characteristics) { for (Characteristic cxx : characteristics) { @@ -849,14 +793,14 @@ private void eventingFinalize(Accessory accessory, List channels) { * @param channel the channel to which the command is sent * @param command the command to send * @param writer the CharacteristicReadWriteClient to send the command - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws IllegalStateException + * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: + * ExecutionException, + * TimeoutException, + * InterruptedException, + * IOException, + * IllegalStateException */ - private synchronized void writeChannel(Channel channel, Command command, CharacteristicReadWriteClient writer) - throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { + private synchronized void writeChannel(Channel channel, Command command) throws Exception { Long aid = getAccessoryId(); String iid = channel.getProperties().get(PROPERTY_IID); if (aid == null || iid == null) { @@ -869,7 +813,7 @@ private synchronized void writeChannel(Channel channel, Command command, Charact characteristic.iid = Long.parseLong(iid); characteristic.value = commandToJsonPrimitive(command, channel); service.characteristics = List.of(characteristic); - String response = writer.writeCharacteristic(GSON.toJson(service)); + String response = writeCharacteristics(GSON.toJson(service)); Service serviceResponse = GSON.fromJson(response, Service.class); // check for errors if (serviceResponse != null && serviceResponse.characteristics instanceof List characteristics) { @@ -940,43 +884,93 @@ protected IpTransport getIpTransport() throws IllegalAccessException { return super.getIpTransport(); } + @Override + protected boolean dependentThingsInitialized() { + return ThingHandlerHelper.isHandlerInitialized(thing); // no children; return own status + } + + @Override + protected void onRootThingAccessoriesLoaded() { + createProperties(); + createChannels(); + } + + @Override + public void onEvent(String json) { + updateChannelsFromJson(json); + } + /** - * Override method to delegate to the bridge read/write service if we are a child accessory. - * - * @return own CharacteristicReadWriteClient service or bridge service if we are a child. - * @throws IllegalAccessException if access to the service is denied. + * When a channel is linked, check if it corresponds to a characteristic in this accessory. + * If so, add it to the polledCharacteristics and eventedCharacteristics maps as appropriate. */ @Override - protected CharacteristicReadWriteClient getRwService() throws IllegalAccessException { - if (isChildAccessory) { - if (getBridge() instanceof Bridge bridge - && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { - return bridgeHandler.getRwService(); - } else { - throw new IllegalAccessException("Cannot access bridge read/write service"); + public void channelLinked(ChannelUID channelUID) { + try { + if (polledCharacteristics.containsKey(channelUID)) { + return; + } + final Channel channel = thing.getChannel(channelUID); + if (channel == null) { + return; // OH core ensures this does not happen + } + final Long aid = getAccessoryId(); + if (aid == null) { + return; // error will already have been logged elsewhere + } + final Accessory accessory = getAccessories().get(aid); + if (accessory == null) { + return; // error will already have been logged elsewhere + } + final String iidProperty = channel.getProperties().get(PROPERTY_IID); + if (iidProperty == null) { + return; // error will already have been logged elsewhere + } + final Long iid; + try { + iid = Long.parseLong(iidProperty); + } catch (NumberFormatException e) { + return; // error will already have been logged elsewhere + } + for (Service service : accessory.services) { + for (Characteristic characteristic : service.characteristics) { + if (iid.equals(characteristic.iid)) { + Characteristic entry = new Characteristic(); + entry.aid = aid; + entry.iid = iid; + polledCharacteristics.put(channelUID, entry); + if (characteristic.perms instanceof List perms && perms.contains("ev")) { + entry = new Characteristic(); + entry.aid = aid; + entry.iid = iid; + eventedCharacteristics.put(channelUID, entry); + } + return; // unique match found; return directly + } + } } + } finally { + super.channelLinked(channelUID); } - return super.getRwService(); - } - - @Override - protected boolean checkHandlersInitialized() { - return isInitialized(); } + /** + * When a channel is unlinked, remove it from the polledCharacteristics and eventedCharacteristics maps. + */ @Override - protected void onAccessoriesLoaded() { - createProperties(); - createChannels(); + public void channelUnlinked(ChannelUID channelUID) { + eventedCharacteristics.remove(channelUID); + polledCharacteristics.remove(channelUID); + super.channelUnlinked(channelUID); } @Override - protected void onRootHandlerReady() { - startRefreshTask(); + protected Map getEventedCharacteristics() { + return eventedCharacteristics; } @Override - public void onEvent(String json) { - updateChannelsFromJson(json); + protected Map getPolledCharacteristics() { + return polledCharacteristics; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 8fe605bcf2cc1..fde93f97c412d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -27,8 +27,10 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -55,6 +57,7 @@ import org.openhab.binding.homekit.internal.transport.IpTransport; import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; @@ -81,6 +84,7 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple private static final int MIN_CONNECTION_ATTEMPT_DELAY_SECONDS = 2; private static final int MAX_CONNECTION_ATTEMPT_DELAY_SECONDS = 600; + private static final int MANUAL_REFRESH_DELAY_SECONDS = 3; private static final Duration HANDLER_INITIALIZATION_TIMEOUT = Duration.ofSeconds(10); @@ -94,17 +98,63 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple private @Nullable ScheduledFuture connectionAttemptTask; private @Nullable CharacteristicReadWriteClient rwService; private @Nullable IpTransport ipTransport; + private @Nullable ScheduledFuture refreshTask; + private @Nullable Future manualRefreshTask; private @NonNullByDefault({}) Long accessoryId; protected static final Gson GSON = new Gson(); - protected final List eventedCharacteristics = new ArrayList<>(); + protected final Map eventedCharacteristics = new ConcurrentHashMap<>(); + protected final Map polledCharacteristics = new ConcurrentHashMap<>(); + protected final HomekitTypeProvider typeProvider; protected final TranslationProvider i18nProvider; protected final Bundle bundle; protected boolean isChildAccessory = false; + protected final Throttler throttler = new Throttler(); + + /** + * A helper class that runs a {@link Callable} and enforces a minimum delay between calls. + * This is to avoid overwhelming accessories with too many requests in a short time. + */ + private class Throttler { + private static final Duration MIN_INTERVAL = Duration.ofSeconds(2); + private @Nullable Instant notBeforeInstant = null; + + /** + * Calls the given task. The method is synchronized to ensure that only one HTTP call is + * executed at a time. It calculates the required delay based on the last call time and + * sleeps if necessary. And it updates the notBeforeInstant after each call to enforce the + * delay. It initializes notBeforeInstant if required. + * + * @param task the task to be called + * @return the String result of the task + * @throws Exception the compiler us to handle any exception, but will actually be more specific + */ + public synchronized String call(Callable task) throws Exception { + try { + Instant next = notBeforeInstant; + if (next == null) { + notBeforeInstant = next = Instant.now().plus(MIN_INTERVAL); + } + long delay = Duration.between(Instant.now(), next).toMillis(); + if (delay > 0) { + delay = Math.min(delay, MIN_INTERVAL.toMillis()); + logger.trace("Throttling call for {} ms to respect minimum interval", delay); + Thread.sleep(delay); + } + return task.call(); + } finally { + notBeforeInstant = Instant.now().plus(MIN_INTERVAL); + } + } + + public void reset() { + notBeforeInstant = null; + } + } public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, TranslationProvider translationProvider, Bundle bundle) { @@ -117,6 +167,7 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { + cancelRefreshTasks(); try { enableEventsOrThrow(false); } catch (Exception e) { @@ -142,15 +193,15 @@ public void dispose() { private void fetchAccessories() { try { accessories.clear(); - String json = new String(getIpTransport().get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP), - StandardCharsets.UTF_8); + String json = throttler.call(() -> new String(getIpTransport().get(ENDPOINT_ACCESSORIES, CONTENT_TYPE_HAP), + StandardCharsets.UTF_8)); Accessories acc0 = GSON.fromJson(json, Accessories.class); if (acc0 instanceof Accessories acc1 && acc1.accessories instanceof List acc2) { accessories.putAll(acc2.stream().filter(a -> Objects.nonNull(a.aid)) .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } - logger.debug("Fetched {} accessories", accessories.size()); - scheduler.submit(this::processAccessories); + logger.debug("{} fetched {} accessories", thing.getUID(), accessories.size()); + scheduler.submit(this::processDependentThings); } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect @@ -165,22 +216,22 @@ private void fetchAccessories() { } /** - * Processes the loaded accessories by calling the overloaded abstract methods, then enables eventing, - * and finally sets thing as online. + * Waits for all dependent accessory things to be initialized, then processes them by calling the + * overloaded abstract 'onRootAccessoriesLoaded' methods, and finally calls the 'onRootThingOnline' + * methods (and its eventual overloaded implementations). */ - private void processAccessories() { + private void processDependentThings() { Instant timeout = Instant.now().plus(HANDLER_INITIALIZATION_TIMEOUT); - while (!checkHandlersInitialized() && Instant.now().isBefore(timeout)) { + while (!dependentThingsInitialized() && Instant.now().isBefore(timeout)) { try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - return; // shutting down + return; // shutting down; exit immediately } } - onAccessoriesLoaded(); - onRootHandlerReady(); - onThingOnline(); + onRootThingAccessoriesLoaded(); + onRootThingOnline(); } /** @@ -201,6 +252,7 @@ private void processAccessories() { @Override public void handleRemoval() { + cancelRefreshTasks(); if (isChildAccessory) { updateStatus(ThingStatus.REMOVED); } else { @@ -260,6 +312,7 @@ private synchronized boolean verifyPairing() { getIpTransport().setSessionKeys(client.verify()); rwService = new CharacteristicReadWriteClient(getIpTransport()); + throttler.reset(); logger.debug("Restored pairing was verified for {}", thing.getUID()); scheduler.schedule(this::fetchAccessories, MIN_CONNECTION_ATTEMPT_DELAY_SECONDS, TimeUnit.SECONDS); @@ -349,17 +402,9 @@ protected IpTransport getIpTransport() throws IllegalAccessException, IllegalSta /** * Gets the read/write service. * - * @throws IllegalAccessException if this is a child accessory or if the service is not initialized * @return the CharacteristicReadWriteClient */ - protected CharacteristicReadWriteClient getRwService() throws IllegalAccessException { - if (isChildAccessory) { - throw new IllegalAccessException("Child accessories must delegate to bridge read/write service"); - } - CharacteristicReadWriteClient rwService = this.rwService; - if (rwService == null) { - throw new IllegalAccessException("Read/write service not initialized"); - } + protected @Nullable CharacteristicReadWriteClient getRwService() { return rwService; } @@ -566,7 +611,8 @@ protected void createProperties() { } /** - * Wrapper to enable or disable events with exception handling. + * Wrapper to enable or disable eventing for members of the eventedCharacteristics list of the + * accessory or its children, with exception handling. * * @param enable true to enable events, false to disable */ @@ -589,63 +635,212 @@ private void enableEvents(boolean enable) { } /** - * Inner method to enable or disable events members of the eventedCharacteristics list. - * All exceptions are thrown upwards to the caller. + * Inner method to enable or disable eventing for members of the eventedCharacteristics list of the + * accessory or its children. All exceptions are thrown upwards to the caller. * * @param enable true to enable events, false to disable - * @throws IllegalStateException if this is a child accessory or if the read/write service is not initialized - * @throws IllegalAccessException if this is a child accessory - * @throws IOException if there is a communication error - * @throws InterruptedException if the operation is interrupted - * @throws TimeoutException if the operation times out - * @throws ExecutionException if there is an execution error + * @throws Exception the compiler requires us to handle any error; but it will actually be one of the following: + * IllegalStateException if this is a child accessory or if the read/write service is not initialized, + * IllegalAccessException if this is a child accessory, + * IOException if there is a communication error, + * InterruptedException if the operation is interrupted, + * TimeoutException if the operation times out, + * ExecutionException if there is an execution error */ - private void enableEventsOrThrow(boolean enable) throws IllegalStateException, IllegalAccessException, IOException, - InterruptedException, TimeoutException, ExecutionException { + private void enableEventsOrThrow(boolean enable) throws Exception { if (isChildAccessory) { logger.warn("Forbidden to enable/disable events on child accessory '{}'", thing.getUID()); return; } Service service = new Service(); service.characteristics = new ArrayList<>(); - service.characteristics.addAll(eventedCharacteristics.stream().map(characteristic -> { - characteristic.ev = enable; - return characteristic; + service.characteristics.addAll(getEventedCharacteristics().values().stream().map(cxx -> { + cxx.ev = enable; + return cxx; }).toList()); - if (!service.characteristics.isEmpty()) { - getRwService().writeCharacteristic(GSON.toJson(service)); - logger.debug("Eventing {}abled for {} channels", enable ? "en" : "dis", service.characteristics.size()); + if (service.characteristics.isEmpty()) { + return; + } + final CharacteristicReadWriteClient rwService = this.rwService; + if (rwService == null) { + throw new IllegalStateException("Read/write service not initialized"); + } + throttler.call(() -> rwService.writeCharacteristics(GSON.toJson(service))); + logger.debug("Eventing {}abled for {} channels", enable ? "en" : "dis", service.characteristics.size()); + } + + /** + * Polls all characteristics in the polledCharacteristics list of the accessory or its children. + * Called periodically by the refresh task and on-demand when RefreshType.REFRESH is called. + */ + private synchronized void refresh() { + List queries = getPolledCharacteristics().values().stream().filter(c -> c.iid != null && c.aid != null) + .map(c -> "%s.%s".formatted(c.aid, c.iid)).toList(); + if (queries.isEmpty()) { + return; + } + final CharacteristicReadWriteClient rwService = this.rwService; + if (rwService == null) { + throw new IllegalStateException("Read/write service not initialized"); + } + try { + String json = throttler.call(() -> rwService.readCharacteristics(String.join(",", queries))); + onEvent(json); + } catch (Exception e) { + if (isCommunicationException(e)) { + // communication exception; log at debug and try to reconnect + logger.debug("Communication error '{}' polling accessories, reconnecting..", e.getMessage()); + scheduleConnectionAttempt(); + } else { + // other exception; log at warn and don't try to reconnect + logger.warn("Unexpected error '{}' polling accessories", e.getMessage()); + } + logger.debug("Stack trace", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + i18nProvider.getText(bundle, "error.polling-error", "Polling error", null)); } } /** - * Checks if all handler instances are initialized. - * Subclasses override this to implement the waiting logic. + * Checks if all dependent accessory things have the reached status UNKNOWN, OFFLINE, or ONLINE. + * Subclasses MUST override this to perform the check. */ - protected abstract boolean checkHandlersInitialized(); + protected abstract boolean dependentThingsInitialized(); /** * Called when the root thing has finished loading the accessories. - * Subclasses override this to perform any processing required. + * Subclasses MUST override this to perform any extra processing required. */ - protected abstract void onAccessoriesLoaded(); + protected abstract void onRootThingAccessoriesLoaded(); /** - * Called when the root handler is fully online. - * Subclasses override this to perform any processing required. + * Gets the evented characteristics list for this accessory or its children. + * Subclasses MUST override this to perform any extra processing required. + * + * @return map of channel UID to characteristic */ - protected abstract void onRootHandlerReady(); + protected abstract Map getEventedCharacteristics(); + + /** + * Gets the polled characteristics list for this accessory or its children. + * Subclasses MUST override this to perform any extra processing required. + * + * @return map of channel UID to characteristic + */ + protected abstract Map getPolledCharacteristics(); @Override - public abstract void onEvent(String jsonContent); + public abstract void onEvent(String json); /** - * Called when the thing is fully online. - * Enables eventing and updates the thing status to ONLINE. - * Subclasses override this to perform any extra processing required. + * Called when the root thing is fully online. Updates the thing status to ONLINE. And if the thing + * is not a child, enables eventing,and starts the refresh task. + * Subclasses MAY override this to perform any extra processing required. */ - protected void onThingOnline() { - enableEvents(true); + protected void onRootThingOnline() { updateStatus(ThingStatus.ONLINE); + if (!isChildAccessory) { + enableEvents(true); + startRootThingRefreshTask(); + } + } + + /** + * Called when the root thing handler has been initialized, the pairing verified, the accessories + * loaded, and the channels and properties created. Sets up a scheduled task to periodically + * refresh the state of the accessory. + */ + private void startRootThingRefreshTask() { + if (getConfig().get(CONFIG_REFRESH_INTERVAL) instanceof Object refreshInterval) { + try { + int refreshIntervalSeconds = Integer.parseInt(refreshInterval.toString()); + if (refreshIntervalSeconds > 0) { + ScheduledFuture task = refreshTask; + if (task == null || task.isCancelled() || task.isDone()) { + refreshTask = scheduler.scheduleWithFixedDelay(this::refresh, refreshIntervalSeconds, + refreshIntervalSeconds, TimeUnit.SECONDS); + } + } + } catch (NumberFormatException e) { + // logged below + } + } + if (refreshTask == null) { + logger.warn("Invalid refresh interval configuration, polling disabled"); + } + } + + /** + * Cancels the refresh tasks if either is running. + */ + private void cancelRefreshTasks() { + if (refreshTask instanceof ScheduledFuture task) { + task.cancel(true); + } + if (manualRefreshTask instanceof Future task) { + task.cancel(true); + } + refreshTask = null; + manualRefreshTask = null; + } + + /** + * Requests a manual refresh by scheduling a refresh task after a short debounce delay. Defers to the + * bridge handler if this is a child accessory. And if a manual refresh task is already scheduled or + * running, it does nothing more. + */ + protected void requestManualRefresh() { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + bridgeHandler.requestManualRefresh(); + } else { + Future task = manualRefreshTask; + if (task == null || task.isDone() || task.isCancelled()) { + manualRefreshTask = scheduler.schedule(this::refresh, MANUAL_REFRESH_DELAY_SECONDS, TimeUnit.SECONDS); + } + } + } + + /** + * Reads characteristic(s) from the accessory. Defers to the bridge handler if this is a child accessory. + * + * @param query a comma delimited HTTP query string e.g. "1.10,1.11" for aid 1 and iid 10 and 11 + * @return JSON response as String + * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: + * ExecutionException, + * TimeoutException, + * InterruptedException, + * IOException, + * IllegalStateException + */ + protected String readCharacteristics(String query) throws Exception { + CharacteristicReadWriteClient rwService = getBridge() instanceof Bridge bridge + && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler ? bridgeHandler.getRwService() + : getRwService(); + if (rwService == null) { + throw new IllegalStateException("Read/write service not initialized"); + } + return throttler.call(() -> rwService.readCharacteristics(query)); + } + + /** + * Writes characteristic(s) to the accessory. Defers to the bridge handler if this is a child accessory. + * + * @param json the JSON to write + * @return the JSON response + * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: + * ExecutionException, + * TimeoutException, + * InterruptedException, + * IOException, + * IllegalStateException + */ + protected String writeCharacteristics(String json) throws Exception { + CharacteristicReadWriteClient rwService = getBridge() instanceof Bridge bridge + && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler ? bridgeHandler.getRwService() + : getRwService(); + if (rwService == null) { + throw new IllegalStateException("Read/write service not initialized"); + } + return throttler.call(() -> rwService.writeCharacteristics(json)); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 54fb57ce0bc34..31d31b2d6c98c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -13,18 +13,21 @@ package org.openhab.binding.homekit.internal.handler; import java.util.Collection; +import java.util.Map; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.action.HomekitPairingActions; import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; +import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.binding.BridgeHandler; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; @@ -93,43 +96,38 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { } @Override - protected boolean checkHandlersInitialized() { + protected boolean dependentThingsInitialized() { return getThing().getThings().stream().allMatch(child -> ThingHandlerHelper.isHandlerInitialized(child)); } @Override - protected void onAccessoriesLoaded() { + protected void onRootThingAccessoriesLoaded() { createProperties(); getThing().getThings().forEach(child -> { - if (child.getHandler() instanceof HomekitBaseAccessoryHandler childHandler) { - childHandler.onAccessoriesLoaded(); + if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { + childAccessoryHandler.onRootThingAccessoriesLoaded(); } }); } @Override - protected void onRootHandlerReady() { - eventedCharacteristics.clear(); + public void onEvent(String jsonContent) { getThing().getThings().forEach(child -> { - if (child.getHandler() instanceof HomekitBaseAccessoryHandler childHandler) { - childHandler.onRootHandlerReady(); - eventedCharacteristics.addAll(childHandler.eventedCharacteristics); + if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { + childAccessoryHandler.onEvent(jsonContent); } }); } @Override - public void onEvent(String jsonContent) { + protected void onRootThingOnline() { + updateStatus(ThingStatus.ONLINE); getThing().getThings().forEach(child -> { - if (child.getHandler() instanceof HomekitBaseAccessoryHandler childHandler) { - childHandler.onEvent(jsonContent); + if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { + childAccessoryHandler.onRootThingOnline(); } }); - } - - @Override - protected void onThingOnline() { - super.onThingOnline(); + super.onRootThingOnline(); HomekitChildDiscoveryService discoveryService = childDiscoveryService; if (discoveryService != null) { discoveryService.startScan(); @@ -143,4 +141,26 @@ public void registerDiscoveryService(HomekitChildDiscoveryService discoveryServi public void unregisterDiscoveryService() { childDiscoveryService = null; } + + @Override + protected Map getEventedCharacteristics() { + eventedCharacteristics.clear(); + getThing().getThings().forEach(child -> { + if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { + eventedCharacteristics.putAll(childAccessoryHandler.getPolledCharacteristics()); + } + }); + return eventedCharacteristics; + } + + @Override + protected Map getPolledCharacteristics() { + polledCharacteristics.clear(); + getThing().getThings().forEach(child -> { + if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { + polledCharacteristics.putAll(childAccessoryHandler.getPolledCharacteristics()); + } + }); + return polledCharacteristics; + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java index a5b123e072205..dc5ebf43a4f7f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java @@ -48,7 +48,7 @@ public CharacteristicReadWriteClient(IpTransport ipTransport) { * @throws IOException * @throws IllegalStateException */ - public String readCharacteristic(String query) + public String readCharacteristics(String query) throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { String endpoint = "%s?id=%s".formatted(ENDPOINT_CHARACTERISTICS, query); byte[] result = ipTransport.get(endpoint, CONTENT_TYPE_HAP); @@ -66,7 +66,7 @@ public String readCharacteristic(String query) * @throws IOException * @throws IllegalStateException */ - public String writeCharacteristic(String json) + public String writeCharacteristics(String json) throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { byte[] result = ipTransport.put(ENDPOINT_CHARACTERISTICS, CONTENT_TYPE_HAP, json.getBytes(StandardCharsets.UTF_8)); diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index c57b73ddf50b2..9b83c8a607807 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -30,6 +30,8 @@ thing-type.config.homekit.bridge.ipAddress.label = IP Address thing-type.config.homekit.bridge.ipAddress.description = IP v4 address of the HomeKit bridge. thing-type.config.homekit.bridge.macAddress.label = MAC Address thing-type.config.homekit.bridge.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval +thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the bridge is polled in sec. # thing error state messages diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 2b34c044a683e..c4e4289d81523 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -62,6 +62,12 @@ ID of the accessory. true + + + Interval at which the bridge is polled in sec. + 60 + true + From 3a16aa9440ec2439a3a9229dd3a161861d86ac2e Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 11 Nov 2025 19:10:16 +0000 Subject: [PATCH 124/177] fix shutdown bug Signed-off-by: Andrew Fiddian-Green --- .../internal/handler/HomekitBaseAccessoryHandler.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index fde93f97c412d..61f80114f68ac 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -168,10 +168,12 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { cancelRefreshTasks(); - try { - enableEventsOrThrow(false); - } catch (Exception e) { - // closing; ignore + if (!isChildAccessory) { + try { + enableEventsOrThrow(false); + } catch (Exception e) { + // closing; ignore + } } if (connectionAttemptTask instanceof ScheduledFuture task) { task.cancel(true); From 0682b4adb62974dc8ebe5c7128381ac4bdc1f8ee Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 12 Nov 2025 17:14:18 +0000 Subject: [PATCH 125/177] ensure unique channel ids Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/dto/Characteristic.java | 5 +++- .../internal/enums/CharacteristicType.java | 2 +- .../handler/HomekitAccessoryHandler.java | 28 ++++++++++++------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 2cb516f11e53f..b9b23e11ae951 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -779,6 +779,9 @@ public class Characteristic { itemType = null; break; + case CUSTOM_CXX: + // custom or unknown characteristic; fall through to default + default: return null; } @@ -943,7 +946,7 @@ public static CharacteristicType getCharacteristicType(String type) { String firstPart = type.split("-")[0]; return CharacteristicType.from(Integer.parseInt(firstPart, 16)); } catch (IllegalArgumentException e) { - return CharacteristicType.UNKNOWN_CHARACTERISTIC; + return CharacteristicType.CUSTOM_CXX; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index fcb49be490a19..478018694304b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -157,7 +157,7 @@ public enum CharacteristicType { ZOOM_DIGITAL(0x11D, "public.hap.characteristic.zoom-digital"), ZOOM_OPTICAL(0x11C, "public.hap.characteristic.zoom-optical"), // placeholder for any custom or unsupported characteristic - UNKNOWN_CHARACTERISTIC(0xFF, "public.hap.characteristic.unknown"); + CUSTOM_CXX(0xFF, "public.hap.characteristic.custom"); //@formatter:on private final int id; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 2aa1cc1989a8b..58f50b14d946d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -327,7 +327,7 @@ private void createChannels() { lightModelInitialize(accessory); // create the channels and properties - List channels = new ArrayList<>(); + Map uniqueChannelsMap = new HashMap<>(); // use map to prevent duplicate Channel ID Map properties = new HashMap<>(thing.getProperties()); // keep existing properties accessory.buildAndRegisterChannelGroupDefinitions(thing.getUID(), typeProvider, i18nProvider, bundle) .forEach(groupDef -> { @@ -375,8 +375,15 @@ private void createChannels() { channelType.getKind(), channelType.getTags(), channelType.getUID(), channelType.getUnitHint()); - ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), - chanDef.getId()); + // if necessary append a suffix to ensure unique channel IDs + final String base = chanDef.getId(); + String channelId = base; + int suffix = 0; + while (uniqueChannelsMap.containsKey(channelId)) { + channelId = "%s-%d".formatted(base, ++suffix); + } + ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), channelId); + ChannelBuilder builder = ChannelBuilder.create(channelUID) .withAcceptedItemType(channelType.getItemType()) .withAutoUpdatePolicy(channelType.getAutoUpdatePolicy()) @@ -385,7 +392,7 @@ private void createChannels() { Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); Optional.ofNullable(chanDef.getDescription()).ifPresent(builder::withDescription); Channel channel = builder.build(); - channels.add(channel); + uniqueChannelsMap.put(channelId, channel); logger.trace( "{} Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", @@ -399,6 +406,7 @@ private void createChannels() { } }); + List channels = new ArrayList<>(uniqueChannelsMap.values()); lightModelFinalize(accessory, channels); stopMoveFinalize(accessory, channels); eventingPollingFinalize(accessory, channels); @@ -407,20 +415,20 @@ private void createChannels() { String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; List newChannels = !channels.isEmpty() ? channels : null; Map newProperties = !properties.isEmpty() ? properties : null; - SemanticTag newTag = accessory.getSemanticEquipmentTag(); + SemanticTag newEquipmentTag = accessory.getSemanticEquipmentTag(); - if (newLabel != null || newChannels != null || newProperties != null || newTag != null) { - ThingBuilder builder = editThing().withProperties(properties).withChannels(channels); + if (newLabel != null || newChannels != null || newProperties != null || newEquipmentTag != null) { + ThingBuilder builder = editThing(); Optional.ofNullable(newLabel).ifPresent(builder::withLabel); Optional.ofNullable(newChannels).ifPresent(builder::withChannels); Optional.ofNullable(newProperties).ifPresent(builder::withProperties); - Optional.ofNullable(newTag).ifPresent(builder::withSemanticEquipmentTag); + Optional.ofNullable(newEquipmentTag).ifPresent(builder::withSemanticEquipmentTag); updateThing(builder.build()); logger.debug( - "{} updated with {} channels (of which {} polled, {} evented), {} properties, label '{}', tag '{}'", + "{} updated with {} channels (of which {} polled, {} evented), {} properties, label: '{}', equipment tag: '{}'", thing.getUID(), channels.size(), polledCharacteristics.size(), eventedCharacteristics.size(), - properties.size(), newLabel, newTag); + properties.size(), newLabel, newEquipmentTag); } } From 4bbb18f474d5240df3258a5bce6d418cf18a1c9d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 12 Nov 2025 18:38:36 +0000 Subject: [PATCH 126/177] add result messages for thing actions Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 6 +++ .../action/HomekitPairingActions.java | 14 +++--- .../handler/HomekitBaseAccessoryHandler.java | 49 ++++++++++++------- .../resources/OH-INF/i18n/homekit.properties | 8 +-- 4 files changed, 50 insertions(+), 27 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 9e5098ce22386..4aca839d3c295 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -79,4 +79,10 @@ public class HomekitBindingConstants { // NOTE: this specially allows space characters in the host name -- even if normally not allowed by the RFC public static final Pattern HOST_PATTERN = Pattern.compile( "^([a-zA-Z0-9\\-\\x20]+)\\._hap\\._tcp\\.local\\.(?::([1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5]))?$"); + + // result messages for ThingActions; !! DO NOT LOCALIZE !! + public static final String ACTION_RESULT_OK = "OK"; + public static final String ACTION_RESULT_OK_FORMAT = ACTION_RESULT_OK + " (%s)"; + public static final String ACTION_RESULT_ERROR = "ERROR"; + public static final String ACTION_RESULT_ERROR_FORMAT = ACTION_RESULT_ERROR + " (%s)"; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java index ba8785e8ed4e7..10e182aeba92d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java @@ -12,6 +12,8 @@ */ package org.openhab.binding.homekit.internal.action; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.ACTION_RESULT_ERROR_FORMAT; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.handler.HomekitBaseAccessoryHandler; @@ -39,7 +41,7 @@ public class HomekitPairingActions implements ThingActions { private final Logger logger = LoggerFactory.getLogger(HomekitPairingActions.class); private @Nullable HomekitBaseAccessoryHandler handler; - public static Boolean pair(ThingActions actions, String code, boolean auth) { + public static String pair(ThingActions actions, String code, boolean auth) { if (actions instanceof HomekitPairingActions accessoryActions) { return accessoryActions.pair(code, auth); } else { @@ -47,7 +49,7 @@ public static Boolean pair(ThingActions actions, String code, boolean auth) { } } - public static Boolean unpair(ThingActions actions) { + public static String unpair(ThingActions actions) { if (actions instanceof HomekitPairingActions accessoryActions) { return accessoryActions.unpair(); } else { @@ -66,7 +68,7 @@ public void setThingHandler(@Nullable ThingHandler handler) { } @RuleAction(label = "@text/actions.pairing-action.label", description = "@text/actions.pairing-action.description") - public @ActionOutput(type = "java.lang.Boolean", label = "@text/actions.pairing-success.label", description = "@text/actions.pairing-success.description") Boolean pair( + public @ActionOutput(type = "java.lang.String", label = "@text/actions.pairing-result.label", description = "@text/actions.pairing-result.description") String pair( @ActionInput(name = "code", label = "@text/actions.pairing-code.label", description = "@text/actions.pairing-code.description") String code, @ActionInput(name = "auth", label = "@text/actions.pairing-auth.label", description = "@text/actions.pairing-auth.description", defaultValue = "false") boolean auth) { HomekitBaseAccessoryHandler handler = this.handler; @@ -74,18 +76,18 @@ public void setThingHandler(@Nullable ThingHandler handler) { return handler.pair(code, auth); } else { logger.warn("ThingHandler is null."); + return ACTION_RESULT_ERROR_FORMAT.formatted("handler"); } - return false; } @RuleAction(label = "@text/actions.unpairing-action.label", description = "@text/actions.unpairing-action.description") - public @ActionOutput(type = "java.lang.Boolean", label = "@text/actions.unpairing-success.label", description = "@text/actions.unpairing-success.description") Boolean unpair() { + public @ActionOutput(type = "java.lang.String", label = "@text/actions.unpairing-result.label", description = "@text/actions.unpairing-result.description") String unpair() { HomekitBaseAccessoryHandler handler = this.handler; if (handler != null) { return handler.unpair(); } else { logger.warn("ThingHandler is null."); + return ACTION_RESULT_ERROR_FORMAT.formatted("handler"); } - return false; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 61f80114f68ac..78e5032f56e2b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -259,7 +259,7 @@ public void handleRemoval() { updateStatus(ThingStatus.REMOVED); } else { scheduler.submit(() -> { - if (unpairInner()) { + if (unpairInner().startsWith(ACTION_RESULT_OK)) { updateStatus(ThingStatus.REMOVED); } }); @@ -476,16 +476,18 @@ public Collection> getServices() { * * @param code the pairing code * @param withExternalAuthentication true to setup with external authentication e.g. from an app, false otherwise + * + * @return OK or ERROR with reason */ - public boolean pair(String code, boolean withExternalAuthentication) { + public String pair(String code, boolean withExternalAuthentication) { if (isChildAccessory) { logger.warn("Cannot pair child accessory '{}'", thing.getUID()); - return false; // child accessories cannot be paired directly + return ACTION_RESULT_ERROR_FORMAT.formatted("child accessory"); } if (!PAIRING_CODE_PATTERN.matcher(code).matches()) { logger.debug("Pairing code must match XXX-XX-XXX or XXXX-XXXX or XXXXXXXX"); - return false; // invalid pairing code format + return ACTION_RESULT_ERROR_FORMAT.formatted("code format"); } String pairingCode = normalizePairingCode(code); @@ -495,13 +497,17 @@ public boolean pair(String code, boolean withExternalAuthentication) { String macAddress = checkedMacAddress(); String hostName = checkedHostName(); if (accessoryId == null || ipAddress == null || macAddress == null || hostName == null) { - return false; // configuration error + return ACTION_RESULT_ERROR_FORMAT.formatted("config error"); } isConfigured = true; + if (keyStore.getAccessoryKey(macAddress) != null) { + return ACTION_RESULT_OK_FORMAT.formatted("already paired"); // OK if already paired + } + // create new transport if (checkedCreateIpTransport(ipAddress, hostName) == null) { - return false; // transport creation failed + return ACTION_RESULT_ERROR_FORMAT.formatted("no transport"); } try { @@ -515,52 +521,61 @@ public boolean pair(String code, boolean withExternalAuthentication) { logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; // reset delay on manual pairing scheduleConnectionAttempt(); - return true; // pairing succeeded + return ACTION_RESULT_OK; // pairing succeeded } catch (Exception e) { // catch all; log all exceptions logger.warn("Pairing / verification failed '{}' for {}", e.getMessage(), thing.getUID()); logger.debug("Stack trace", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.pairing-verification-failed", "Pairing / Verification failed", null)); - return false; // pairing failed + return ACTION_RESULT_ERROR_FORMAT.formatted("pairing error"); } } /** * Inner method to unpair and clear stored key. + * + * @return OK or ERROR with reason */ - private boolean unpairInner() { + private String unpairInner() { if (isChildAccessory) { logger.warn("Cannot unpair child accessory '{}'", thing.getUID()); - return false; + return ACTION_RESULT_ERROR_FORMAT.formatted("child accessory"); } if (!(getConfig().get(Thing.PROPERTY_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { logger.warn("Cannot unpair accessory '{}' due to missing mac address configuration", thing.getUID()); - return false; + return ACTION_RESULT_ERROR_FORMAT.formatted("config error"); + } + + if (keyStore.getAccessoryKey(macAddress) == null) { + return ACTION_RESULT_ERROR_FORMAT.formatted("not paired"); } + try { PairRemoveClient service = new PairRemoveClient(getIpTransport(), keyStore.getControllerUUID()); service.remove(); keyStore.setAccessoryKey(macAddress, null); - return true; + return ACTION_RESULT_OK; } catch (IOException | InterruptedException | TimeoutException | ExecutionException | IllegalAccessException | IllegalStateException e) { logger.warn("Error '{}' unpairing accessory '{}'", e.getMessage(), thing.getUID()); - return false; + return ACTION_RESULT_ERROR_FORMAT.formatted("unpairing error"); } } /** * Thing Action that unpairs the accessory. + * + * @return OK or ERROR with reason */ - public boolean unpair() { - boolean unpaired = unpairInner(); - if (unpaired) { + public String unpair() { + String result = unpairInner(); + if (result.startsWith(ACTION_RESULT_OK)) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); } - return unpaired; + return result; } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 9b83c8a607807..1bc014cce828b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -55,12 +55,12 @@ actions.pairing-auth.label = With External Authentication actions.pairing-auth.description = Set 'true' if pairing requires external authentication e.g. from an app (default false). actions.pairing-code.label = Pairing Code actions.pairing-code.description = The 8 digit pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. -actions.pairing-success.label = Pairing Successful -actions.pairing-success.description = True if pairing was successful. +actions.pairing-result.label = Pairing Result +actions.pairing-result.description = The message describes the result of the pairing attempt. actions.unpairing-action.label = Unpair Accessory or Bridge actions.unpairing-action.description = Remove the pairing between this thing and the respective accessory or bridge. -actions.unpairing-success.label = Unpairing Successful -actions.unpairing-success.description = True if unpairing was successful. +actions.unpairing-result.label = Unpairing Result +actions.unpairing-result.description = The message describes the result of the unpairing attempt. # characteristic texts From c1658de057c2a5732d9dd341a430a217769f9b41 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 12 Nov 2025 18:57:13 +0000 Subject: [PATCH 127/177] small fixes Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 58f50b14d946d..1e60c37b792fb 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -364,7 +364,7 @@ private void createChannels() { ChannelType channelType = channelTypeRegistry .getChannelType(chanDef.getChannelTypeUID()); if (channelType == null) { - logger.warn("{} fatal rrror ChannelType '{}' is not registered", thing.getUID(), + logger.warn("{} fatal error ChannelType '{}' is not registered", thing.getUID(), chanDef.getChannelTypeUID()); } else { logger.trace( @@ -406,14 +406,13 @@ private void createChannels() { } }); - List channels = new ArrayList<>(uniqueChannelsMap.values()); - lightModelFinalize(accessory, channels); - stopMoveFinalize(accessory, channels); - eventingPollingFinalize(accessory, channels); + lightModelFinalize(accessory, uniqueChannelsMap); + stopMoveFinalize(accessory, uniqueChannelsMap); + eventingPollingFinalize(accessory, uniqueChannelsMap); String oldLabel = thing.getLabel(); String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; - List newChannels = !channels.isEmpty() ? channels : null; + List newChannels = !uniqueChannelsMap.isEmpty() ? uniqueChannelsMap.values().stream().toList() : null; Map newProperties = !properties.isEmpty() ? properties : null; SemanticTag newEquipmentTag = accessory.getSemanticEquipmentTag(); @@ -662,13 +661,13 @@ private void lightModelHandleCommand(Command command) throws Exception { * @param accessory the accessory containing the characteristics * @param channels the list of channels to finalize */ - private void lightModelFinalize(Accessory accessory, List channels) { + private void lightModelFinalize(Accessory accessory, Map channels) { if (lightModel == null) { return; } // link channels to characteristic types & iids for the light model lightModelLinks.clear(); - for (Channel channel : channels) { + for (Channel channel : channels.values()) { if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { for (Service service : accessory.services) { for (Characteristic cxx : service.characteristics) { @@ -686,11 +685,11 @@ private void lightModelFinalize(Accessory accessory, List channels) { ChannelUID uid = new ChannelUID(thing.getUID(), "hsb-combined-channel"); Channel channel = ChannelBuilder.create(uid, CoreItemFactory.COLOR) .withType(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_COLOR).build(); - channels.add(channel); + channels.put(uid.getId(), channel); // add to channels map logger.trace( - "+++++Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", - channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), channel.getKind(), - channel.getLabel(), channel.getProperties(), channel.getUID()); + "{} Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", + uid, channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), + channel.getKind(), channel.getLabel(), channel.getProperties(), channel.getUID()); lightModelClientHSBTypeChannel = uid; } @@ -700,8 +699,8 @@ private void lightModelFinalize(Accessory accessory, List channels) { * @param accessory the accessory containing the characteristics * @param channels the list of channels to search */ - private void stopMoveFinalize(Accessory accessory, List channels) { - for (Channel channel : channels) { + private void stopMoveFinalize(Accessory accessory, Map channels) { + for (Channel channel : channels.values()) { if (channel.getProperties().get(PROPERTY_IID) instanceof String iid) { for (Service service : accessory.services) { for (Characteristic cxx : service.characteristics) { @@ -724,7 +723,7 @@ private void stopMoveFinalize(Accessory accessory, List channels) { * @param accessory the accessory containing the characteristics * @param channels the list of channels to check for polled and evented characteristics */ - private void eventingPollingFinalize(Accessory accessory, List channels) { + private void eventingPollingFinalize(Accessory accessory, Map channels) { eventedCharacteristics.clear(); polledCharacteristics.clear(); @@ -733,7 +732,7 @@ private void eventingPollingFinalize(Accessory accessory, List channels return; } - for (Channel channel : channels) { + for (Channel channel : channels.values()) { final ChannelUID channelUID = channel.getUID(); if (isLinked(channelUID) && channel.getProperties().get(PROPERTY_IID) instanceof String iidProperty) { final Long iid; From a94c2963d8d2e93edcc000d273378df34ce91370 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 12 Nov 2025 19:50:19 +0000 Subject: [PATCH 128/177] fix compile error Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 1e60c37b792fb..ffb4e2e449d91 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -426,8 +426,8 @@ private void createChannels() { updateThing(builder.build()); logger.debug( "{} updated with {} channels (of which {} polled, {} evented), {} properties, label: '{}', equipment tag: '{}'", - thing.getUID(), channels.size(), polledCharacteristics.size(), eventedCharacteristics.size(), - properties.size(), newLabel, newEquipmentTag); + thing.getUID(), uniqueChannelsMap.size(), polledCharacteristics.size(), + eventedCharacteristics.size(), properties.size(), newLabel, newEquipmentTag); } } From 6b66f2c06dcb59cc3f8cf57745673f4d2535e878 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 13 Nov 2025 12:40:47 +0000 Subject: [PATCH 129/177] add HSB parts to evented, polled cxx lists Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 86 ++++++++++--------- .../handler/HomekitBaseAccessoryHandler.java | 14 +-- .../handler/HomekitBridgeHandler.java | 4 +- 3 files changed, 56 insertions(+), 48 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index ffb4e2e449d91..5fd1a83550dd8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -718,7 +718,9 @@ private void stopMoveFinalize(Accessory accessory, Map channels /** * Finalizes the polled and evented characteristics by identifying which characteristics are linked * and adding them to the polledCharacteristics list, and which subset of those are evented and adding - * them also to the eventedCharacteristics list. + * them also to the eventedCharacteristics list. In case of the special light model HSB channel then we + * also add the component HUE, SATURATION, BRIGHTNESS, ON, and color temperature characteristsics to + * the list of polled and evented characteristics. * * @param accessory the accessory containing the characteristics * @param channels the list of channels to check for polled and evented characteristics @@ -734,28 +736,35 @@ private void eventingPollingFinalize(Accessory accessory, Map c for (Channel channel : channels.values()) { final ChannelUID channelUID = channel.getUID(); - if (isLinked(channelUID) && channel.getProperties().get(PROPERTY_IID) instanceof String iidProperty) { - final Long iid; - try { - iid = Long.parseLong(iidProperty); - } catch (NumberFormatException e) { - continue; // error will already have been logged elsewhere + if (isLinked(channelUID)) { + Long iid = 0L; + boolean checkChannelLinkByIID = !channelUID.equals(lightModelClientHSBTypeChannel); + if (checkChannelLinkByIID && channel.getProperties().get(PROPERTY_IID) instanceof String iidProperty) { + try { + iid = Long.parseLong(iidProperty); + } catch (NumberFormatException e) { + continue; // error will already have been logged elsewhere + } } + nestedLoops: // break marker for nested loops below for (Service service : accessory.services) { for (Characteristic characteristic : service.characteristics) { - if (iid.equals(characteristic.iid)) { + if ((checkChannelLinkByIID && iid.equals(characteristic.iid)) + || LIGHT_MODEL_RELEVANT_TYPES.contains(characteristic.getCharacteristicType())) { Characteristic entry = new Characteristic(); entry.aid = aid; - entry.iid = iid; - polledCharacteristics.put(channelUID, entry); + entry.iid = characteristic.iid; + polledCharacteristics.put(AID_IID_FORMAT.formatted(entry.aid, entry.iid), entry); if (characteristic.perms instanceof List perms && perms.contains("ev")) { entry = new Characteristic(); entry.aid = aid; - entry.iid = iid; - eventedCharacteristics.put(channelUID, entry); + entry.iid = characteristic.iid; + eventedCharacteristics.put(AID_IID_FORMAT.formatted(entry.aid, entry.iid), entry); + } + if (checkChannelLinkByIID) { + break nestedLoops; // unique match found; continue to next channel } - break nestedLoops; // break from nested loops; i.e. continue to next channel } } } @@ -914,9 +923,6 @@ public void onEvent(String json) { @Override public void channelLinked(ChannelUID channelUID) { try { - if (polledCharacteristics.containsKey(channelUID)) { - return; - } final Channel channel = thing.getChannel(channelUID); if (channel == null) { return; // OH core ensures this does not happen @@ -929,30 +935,38 @@ public void channelLinked(ChannelUID channelUID) { if (accessory == null) { return; // error will already have been logged elsewhere } - final String iidProperty = channel.getProperties().get(PROPERTY_IID); - if (iidProperty == null) { - return; // error will already have been logged elsewhere - } - final Long iid; - try { - iid = Long.parseLong(iidProperty); - } catch (NumberFormatException e) { - return; // error will already have been logged elsewhere + + Long iid = 0L; + boolean checkChannelLinkByIID = !channelUID.equals(lightModelClientHSBTypeChannel); + if (checkChannelLinkByIID) { + final String iidProperty = channel.getProperties().get(PROPERTY_IID); + if (iidProperty == null) { + return; // error will already have been logged elsewhere + } + try { + iid = Long.parseLong(iidProperty); + } catch (NumberFormatException e) { + return; // error will already have been logged elsewhere + } } + for (Service service : accessory.services) { for (Characteristic characteristic : service.characteristics) { - if (iid.equals(characteristic.iid)) { + if ((checkChannelLinkByIID && iid.equals(characteristic.iid)) + || LIGHT_MODEL_RELEVANT_TYPES.contains(characteristic.getCharacteristicType())) { Characteristic entry = new Characteristic(); entry.aid = aid; entry.iid = iid; - polledCharacteristics.put(channelUID, entry); + polledCharacteristics.put(AID_IID_FORMAT.formatted(entry.aid, entry.iid), entry); if (characteristic.perms instanceof List perms && perms.contains("ev")) { entry = new Characteristic(); entry.aid = aid; entry.iid = iid; - eventedCharacteristics.put(channelUID, entry); + eventedCharacteristics.put(AID_IID_FORMAT.formatted(entry.aid, entry.iid), entry); + } + if (checkChannelLinkByIID) { + return; // unique match found; return directly } - return; // unique match found; return directly } } } @@ -961,23 +975,13 @@ public void channelLinked(ChannelUID channelUID) { } } - /** - * When a channel is unlinked, remove it from the polledCharacteristics and eventedCharacteristics maps. - */ - @Override - public void channelUnlinked(ChannelUID channelUID) { - eventedCharacteristics.remove(channelUID); - polledCharacteristics.remove(channelUID); - super.channelUnlinked(channelUID); - } - @Override - protected Map getEventedCharacteristics() { + protected Map getEventedCharacteristics() { return eventedCharacteristics; } @Override - protected Map getPolledCharacteristics() { + protected Map getPolledCharacteristics() { return polledCharacteristics; } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 78e5032f56e2b..eb2ff06a00918 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -57,7 +57,6 @@ import org.openhab.binding.homekit.internal.transport.IpTransport; import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.thing.Bridge; -import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; @@ -105,8 +104,13 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected static final Gson GSON = new Gson(); - protected final Map eventedCharacteristics = new ConcurrentHashMap<>(); - protected final Map polledCharacteristics = new ConcurrentHashMap<>(); + /** + * Maps of evented and polled Characteristics. + * The maps are keyed on the unique "aid,iid" combination to prevent duplicate entries. + */ + protected static final String AID_IID_FORMAT = "%s,%s"; + protected final Map eventedCharacteristics = new ConcurrentHashMap<>(); + protected final Map polledCharacteristics = new ConcurrentHashMap<>(); protected final HomekitTypeProvider typeProvider; protected final TranslationProvider i18nProvider; @@ -736,7 +740,7 @@ private synchronized void refresh() { * * @return map of channel UID to characteristic */ - protected abstract Map getEventedCharacteristics(); + protected abstract Map getEventedCharacteristics(); /** * Gets the polled characteristics list for this accessory or its children. @@ -744,7 +748,7 @@ private synchronized void refresh() { * * @return map of channel UID to characteristic */ - protected abstract Map getPolledCharacteristics(); + protected abstract Map getPolledCharacteristics(); @Override public abstract void onEvent(String json); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 31d31b2d6c98c..537937efa4b36 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -143,7 +143,7 @@ public void unregisterDiscoveryService() { } @Override - protected Map getEventedCharacteristics() { + protected Map getEventedCharacteristics() { eventedCharacteristics.clear(); getThing().getThings().forEach(child -> { if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { @@ -154,7 +154,7 @@ protected Map getEventedCharacteristics() { } @Override - protected Map getPolledCharacteristics() { + protected Map getPolledCharacteristics() { polledCharacteristics.clear(); getThing().getThings().forEach(child -> { if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { From cc30ce8bdf1e7acbf51565b8b711ebe21d3286fa Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 21 Nov 2025 14:46:23 +0000 Subject: [PATCH 130/177] [breaking] various - implement suggested addon finder - duplicate characteristic creates unique channel type and definition - duplicate service creates unique group type and definition - warn about now unexpected duplicate channels - improved logging - new unit tests for duplicate service / characteristic - fix prior unit tests Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 23 +- .../homekit/internal/dto/Characteristic.java | 17 +- .../binding/homekit/internal/dto/Service.java | 8 +- .../handler/HomekitAccessoryHandler.java | 73 +- .../handler/HomekitBaseAccessoryHandler.java | 54 +- .../src/main/resources/OH-INF/addon/addon.xml | 12 + .../TestChannelCreationForAppleJson.java | 9 +- .../TestChannelCreationForAqaraJson.java | 726 ++++++++++++++++++ 8 files changed, 844 insertions(+), 78 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 4aca839d3c295..2cd61fb707213 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -37,9 +37,26 @@ public class HomekitBindingConstants { public static final ChannelTypeUID FAKE_PROPERTY_CHANNEL_TYPE_UID = new ChannelTypeUID(BINDING_ID, FAKE_PROPERTY_CHANNEL); - // prefixes for channel-group-type and channel-type UIDs - public static final String CHANNEL_GROUP_TYPE_ID_FMT = "channel-group-type-%s"; - public static final String CHANNEL_TYPE_ID_FMT = "channel-type-%s-"; + /* + * format string for channel-group-type UIDs which represent services + * format: 'channel-group-type'-[serviceIdentifier]-[serviceIid]-[rootThingId]-[accessoryId] + * example: channel-group-type-accessory-information-1-1234567890abcdef-1 + */ + public static final String CHANNEL_GROUP_TYPE_ID_FMT = "channel-group-type-%s-%d-%s-%s"; + + /* + * format string for channel-type UIDs which represent characteristics + * format: 'channel-type'-[characteristicIdentifier]-[characteristicIid]-[rootThingId]-[accessoryId] + * example: channel-type-occupancy-detected-2694-1234567890abcdef-1 + */ + public static final String CHANNEL_TYPE_ID_FMT = "channel-type-%s-%d-%s-%s"; + + /* + * format string for channel-definition IDs which are used to instantiate channels + * format: [characteristicIdentifier]-[characteristicIid] + * example: occupancy-detected-2694 + */ + public static final String CHANNEL_DEFINITION_ID_FMT = "%s-%d"; // labels public static final String THING_LABEL_FMT = "%s on %s"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index b9b23e11ae951..974615da2dba2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -815,13 +815,12 @@ public class Characteristic { * we create and persist a unique channel type ID for each characteristic * instance */ - String channelTypeId = CHANNEL_TYPE_ID_FMT.formatted(characteristicType.getOpenhabType()); - if (thingUID.getBridgeIds().isEmpty()) { - channelTypeId += thingUID.getId(); - } else { - channelTypeId += thingUID.getBridgeIds().getFirst() + "-" + thingUID.getId(); - } - ChannelTypeUID channelTypeUid = new ChannelTypeUID(BINDING_ID, channelTypeId); + String charactersticIdentifier = characteristicType.getOpenhabType(); + String channelTypeIdentifier = thingUID.getBridgeIds().isEmpty() + ? CHANNEL_TYPE_ID_FMT.formatted(charactersticIdentifier, iid, thingUID.getId(), "1") + : CHANNEL_TYPE_ID_FMT.formatted(charactersticIdentifier, iid, thingUID.getBridgeIds().getFirst(), + thingUID.getId()); + ChannelTypeUID channelTypeUid = new ChannelTypeUID(BINDING_ID, channelTypeIdentifier); String channelTypeLabel = characteristicType.toString(); if (!isStateChannel) { @@ -911,7 +910,9 @@ public class Characteristic { Optional.ofNullable(format).ifPresent(s -> props.put(PROPERTY_FORMAT, s)); Optional.ofNullable(dataType).ifPresent(s -> props.put(PROPERTY_DATA_TYPE, s)); - ChannelDefinitionBuilder channelDefBuilder = new ChannelDefinitionBuilder(characteristicType.getOpenhabType(), + String channelDefinitionIdentifier = CHANNEL_DEFINITION_ID_FMT.formatted(charactersticIdentifier, iid); + + ChannelDefinitionBuilder channelDefBuilder = new ChannelDefinitionBuilder(channelDefinitionIdentifier, channelTypeUid).withLabel(getChannelLabel(characteristicType, i18nProvider, bundle)) .withProperties(props); Optional.ofNullable(getChannelDescription()).ifPresent(d -> channelDefBuilder.withDescription(d)); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index 2fc5a22625a80..7f70517a9642e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -73,8 +73,12 @@ public class Service { return null; } - ChannelGroupTypeUID channelGroupTypeUID = new ChannelGroupTypeUID(BINDING_ID, - CHANNEL_GROUP_TYPE_ID_FMT.formatted(serviceType.getOpenhabType())); + String serviceIdentifier = serviceType.getOpenhabType(); + String channelGroupTypeIdentifier = thingUID.getBridgeIds().isEmpty() + ? CHANNEL_GROUP_TYPE_ID_FMT.formatted(serviceIdentifier, iid, thingUID.getId(), "1") + : CHANNEL_GROUP_TYPE_ID_FMT.formatted(serviceIdentifier, iid, thingUID.getBridgeIds().getFirst(), + thingUID.getId()); + ChannelGroupTypeUID channelGroupTypeUID = new ChannelGroupTypeUID(BINDING_ID, channelGroupTypeIdentifier); String channelGroupTypeLabel = serviceType.toString(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 5fd1a83550dd8..7635fbc1d483c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -165,7 +165,8 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { QuantityType temp = quantity.toUnit(channelUnit); object = temp != null ? temp : quantity; } catch (MeasurementParseException e) { - logger.warn("Unexpected unit '{}' for channel '{}'", channelUnit, channel.getUID()); + logger.warn("{} unexpected unit '{}' for channel '{}'", thing.getUID(), channelUnit, + channel.getUID()); } } } @@ -181,8 +182,8 @@ private JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { object = Integer.parseInt(optionValue); break; } catch (NumberFormatException e) { - logger.warn("Unexpected state option value '{}' for channel '{}'", optionValue, - channel.getUID()); + logger.warn("{} unexpected state option value '{}' for channel '{}'", thing.getUID(), + optionValue, channel.getUID()); } } } @@ -273,7 +274,7 @@ private State convertJsonToState(JsonElement element, Channel channel) { } else if (value.isNumber()) { return switch (acceptedItemType) { case CoreItemFactory.COLOR -> { - logger.warn("Channel {} wrong item type 'COLOR'", channel.getUID()); + logger.warn("{} channel {} wrong item type 'COLOR'", thing.getUID(), channel.getUID()); yield UnDefType.UNDEF; } case CoreItemFactory.SWITCH -> OnOffType.from(value.getAsInt() != 0); @@ -375,31 +376,29 @@ private void createChannels() { channelType.getKind(), channelType.getTags(), channelType.getUID(), channelType.getUnitHint()); - // if necessary append a suffix to ensure unique channel IDs - final String base = chanDef.getId(); - String channelId = base; - int suffix = 0; - while (uniqueChannelsMap.containsKey(channelId)) { - channelId = "%s-%d".formatted(base, ++suffix); + String channelId = chanDef.getId(); + if (uniqueChannelsMap.containsKey(channelId)) { + logger.debug("{} Error duplicate channelId:{}", thing.getUID(), channelId); + } else { + ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), + channelId); + ChannelBuilder builder = ChannelBuilder.create(channelUID) + .withAcceptedItemType(channelType.getItemType()) + .withAutoUpdatePolicy(channelType.getAutoUpdatePolicy()) + .withDefaultTags(channelType.getTags()).withKind(channelType.getKind()) + .withProperties(chanDef.getProperties()).withType(channelType.getUID()); + Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); + Optional.ofNullable(chanDef.getDescription()) + .ifPresent(builder::withDescription); + Channel channel = builder.build(); + uniqueChannelsMap.put(channelId, channel); + + logger.trace( + "{} Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", + thing.getUID(), channel.getAcceptedItemType(), channel.getDefaultTags(), + channel.getDescription(), channel.getKind(), channel.getLabel(), + channel.getProperties(), channel.getUID()); } - ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), channelId); - - ChannelBuilder builder = ChannelBuilder.create(channelUID) - .withAcceptedItemType(channelType.getItemType()) - .withAutoUpdatePolicy(channelType.getAutoUpdatePolicy()) - .withDefaultTags(channelType.getTags()).withKind(channelType.getKind()) - .withProperties(chanDef.getProperties()).withType(channelType.getUID()); - Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); - Optional.ofNullable(chanDef.getDescription()).ifPresent(builder::withDescription); - Channel channel = builder.build(); - uniqueChannelsMap.put(channelId, channel); - - logger.trace( - "{} Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", - thing.getUID(), channel.getAcceptedItemType(), channel.getDefaultTags(), - channel.getDescription(), channel.getKind(), channel.getLabel(), - channel.getProperties(), channel.getUID()); - } } }); @@ -435,7 +434,7 @@ private void createChannels() { public void handleCommand(ChannelUID channelUID, Command command) { Channel channel = thing.getChannel(channelUID); if (channel == null) { - logger.warn("Received command '{}' for unknown channel '{}'", command, channelUID); + logger.warn("{} received command '{}' for unknown channel '{}'", thing.getUID(), command, channelUID); return; } if (command == RefreshType.REFRESH) { @@ -486,12 +485,13 @@ public void handleCommand(ChannelUID channelUID, Command command) { } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect - logger.debug("Communication error '{}' sending command '{}' to '{}', reconnecting..", e.getMessage(), - command, channelUID); + logger.debug("{} communication error '{}' sending command '{}' to '{}', reconnecting..", thing.getUID(), + e.getMessage(), command, channelUID); scheduleConnectionAttempt(); } else { // other exception; log at warn and don't try to reconnect - logger.warn("Unexpected error '{}' sending command '{}' to '{}'", e.getMessage(), command, channelUID); + logger.warn("{} unexpected error '{}' sending command '{}' to '{}'", thing.getUID(), e.getMessage(), + command, channelUID); } logger.debug("Stack trace", e); } @@ -528,12 +528,13 @@ public void dispose() { ChannelTypeUID uid = channel.getChannelTypeUID(); ChannelType ct = channelTypeRegistry.getChannelType(uid); if (ct == null) { - logger.warn("Channel '{}' is missing a channel type", uid); + logger.warn("{} channel '{}' is missing a channel type", thing.getUID(), uid); return null; } StateDescription st = ct.getState(); if (st == null) { - logger.warn("Channel '{}' of type '{}' is missing a state description", uid, ct.getUID()); + logger.warn("{} channel '{}' of type '{}' is missing a state description", thing.getUID(), uid, + ct.getUID()); return null; } return st; @@ -688,7 +689,7 @@ private void lightModelFinalize(Accessory accessory, Map channe channels.put(uid.getId(), channel); // add to channels map logger.trace( "{} Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", - uid, channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), + thing.getUID(), channel.getAcceptedItemType(), channel.getDefaultTags(), channel.getDescription(), channel.getKind(), channel.getLabel(), channel.getProperties(), channel.getUID()); lightModelClientHSBTypeChannel = uid; } @@ -835,7 +836,7 @@ private synchronized void writeChannel(Channel channel, Command command) throws && serviceResponse.characteristics instanceof List characteristics) { for (Characteristic cxx : characteristics) { if (cxx.getStatusCode() instanceof StatusCode code && code != StatusCode.SUCCESS) { - logger.warn("Error writing to channel '{}': {}", channel.getUID(), code); + logger.warn("{} error writing to channel '{}': {}", thing.getUID(), channel.getUID(), code); } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index eb2ff06a00918..390a40422f00a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -146,7 +146,7 @@ public synchronized String call(Callable task) throws Exception { long delay = Duration.between(Instant.now(), next).toMillis(); if (delay > 0) { delay = Math.min(delay, MIN_INTERVAL.toMillis()); - logger.trace("Throttling call for {} ms to respect minimum interval", delay); + logger.trace("{} throttling call for {} ms to respect minimum interval", thing.getUID(), delay); Thread.sleep(delay); } return task.call(); @@ -211,11 +211,12 @@ private void fetchAccessories() { } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect - logger.debug("Communication error '{}' fetching accessories, reconnecting..", e.getMessage()); + logger.debug("{} communication error '{}' fetching accessories, reconnecting..", thing.getUID(), + e.getMessage()); scheduleConnectionAttempt(); } else { // other exception; log at warn and don't try to reconnect - logger.warn("Unexpected error '{}' fetching accessories", e.getMessage()); + logger.warn("{} unexpected error '{}' fetching accessories", thing.getUID(), e.getMessage()); } logger.debug("Stack trace", e); } @@ -252,7 +253,7 @@ private void processDependentThings() { } catch (NumberFormatException e) { } } - logger.debug("Missing or invalid accessory id"); + logger.debug("{} missing or invalid accessory id", thing.getUID()); return null; } @@ -299,7 +300,7 @@ private synchronized boolean verifyPairing() { // check if we have a stored key Ed25519PublicKeyParameters accessoryKey = keyStore.getAccessoryKey(macAddress); if (accessoryKey == null) { - logger.debug("No stored pairing credentials for {}", thing.getUID()); + logger.debug("{} no stored pairing credentials", thing.getUID()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); return false; @@ -312,7 +313,7 @@ private synchronized boolean verifyPairing() { // attempt to verify pairing try { - logger.debug("Starting Pair-Verify with existing key for {}", thing.getUID()); + logger.debug("{} starting Pair-Verify with existing key", thing.getUID()); PairVerifyClient client = new PairVerifyClient(getIpTransport(), keyStore.getControllerUUID(), keyStore.getControllerKey(), accessoryKey); @@ -320,13 +321,13 @@ private synchronized boolean verifyPairing() { rwService = new CharacteristicReadWriteClient(getIpTransport()); throttler.reset(); - logger.debug("Restored pairing was verified for {}", thing.getUID()); + logger.debug("{} restored pairing was verified", thing.getUID()); scheduler.schedule(this::fetchAccessories, MIN_CONNECTION_ATTEMPT_DELAY_SECONDS, TimeUnit.SECONDS); return true; // pairing restore succeeded => exit } catch (NoSuchAlgorithmException | NoSuchProviderException | IllegalAccessException | InvalidCipherTextException | IOException | InterruptedException | TimeoutException | ExecutionException | IllegalStateException e) { - logger.debug("Restored pairing was not verified for {}", thing.getUID(), e); + logger.debug("{} restored pairing was not verified", thing.getUID(), e); // pairing restore failed => exit and perhaps try again later updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.pairing-verification-failed", "Pairing / Verification failed", null)); @@ -447,7 +448,7 @@ public Collection> getServices() { return null; } if (!HOST_PATTERN.matcher(hostName).matches()) { - logger.warn("Host name '{}' does not match expected pattern; using anyway..", hostName); + logger.warn("{} host name '{}' does not match expected pattern; using anyway..", thing.getUID(), hostName); } return hostName.replace(" ", "\\032"); // escape mDNS spaces } @@ -468,7 +469,7 @@ public Collection> getServices() { this.ipTransport = ipTransport; return ipTransport; } catch (IOException e) { - logger.warn("Error '{}' creating transport", e.getMessage()); + logger.warn("{} error '{}' creating transport", thing.getUID(), e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); } @@ -485,12 +486,12 @@ public Collection> getServices() { */ public String pair(String code, boolean withExternalAuthentication) { if (isChildAccessory) { - logger.warn("Cannot pair child accessory '{}'", thing.getUID()); + logger.warn("{} forbidden to pair a child accessory", thing.getUID()); return ACTION_RESULT_ERROR_FORMAT.formatted("child accessory"); } if (!PAIRING_CODE_PATTERN.matcher(code).matches()) { - logger.debug("Pairing code must match XXX-XX-XXX or XXXX-XXXX or XXXXXXXX"); + logger.debug("{} pairing code must match XXX-XX-XXX or XXXX-XXXX or XXXXXXXX", thing.getUID()); return ACTION_RESULT_ERROR_FORMAT.formatted("code format"); } String pairingCode = normalizePairingCode(code); @@ -515,20 +516,20 @@ public String pair(String code, boolean withExternalAuthentication) { } try { - logger.debug("Starting Pair-Setup for {}", thing.getUID()); + logger.debug("{} starting Pair-Setup", thing.getUID()); PairSetupClient pairSetupClient = new PairSetupClient(getIpTransport(), keyStore.getControllerUUID(), keyStore.getControllerKey(), pairingCode, withExternalAuthentication); Ed25519PublicKeyParameters accessoryKey = pairSetupClient.pair(); keyStore.setAccessoryKey(macAddress, accessoryKey); - logger.debug("Pair-Setup completed; starting Pair-Verify for {}", thing.getUID()); + logger.debug("{} completed Pair-Setup; starting Pair-Verify", thing.getUID()); connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; // reset delay on manual pairing scheduleConnectionAttempt(); return ACTION_RESULT_OK; // pairing succeeded } catch (Exception e) { // catch all; log all exceptions - logger.warn("Pairing / verification failed '{}' for {}", e.getMessage(), thing.getUID()); + logger.warn("{} pairing / verification failed '{}'", thing.getUID(), e.getMessage()); logger.debug("Stack trace", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.pairing-verification-failed", "Pairing / Verification failed", null)); @@ -543,12 +544,12 @@ public String pair(String code, boolean withExternalAuthentication) { */ private String unpairInner() { if (isChildAccessory) { - logger.warn("Cannot unpair child accessory '{}'", thing.getUID()); + logger.warn("{} forbidden to unpair a child accessory", thing.getUID()); return ACTION_RESULT_ERROR_FORMAT.formatted("child accessory"); } if (!(getConfig().get(Thing.PROPERTY_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { - logger.warn("Cannot unpair accessory '{}' due to missing mac address configuration", thing.getUID()); + logger.warn("{} cannot unpair accessory due to missing mac address configuration", thing.getUID()); return ACTION_RESULT_ERROR_FORMAT.formatted("config error"); } @@ -563,7 +564,7 @@ private String unpairInner() { return ACTION_RESULT_OK; } catch (IOException | InterruptedException | TimeoutException | ExecutionException | IllegalAccessException | IllegalStateException e) { - logger.warn("Error '{}' unpairing accessory '{}'", e.getMessage(), thing.getUID()); + logger.warn("{} error '{}' unpairing accessory", thing.getUID(), e.getMessage()); return ACTION_RESULT_ERROR_FORMAT.formatted("unpairing error"); } } @@ -645,11 +646,12 @@ private void enableEvents(boolean enable) { } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect - logger.debug("Communication error '{}' subscribing to events, reconnecting..", e.getMessage()); + logger.debug("{} communication error '{}' subscribing to events, reconnecting..", thing.getUID(), + e.getMessage()); scheduleConnectionAttempt(); } else { // other exception; log at warn and don't try to reconnect - logger.warn("Unexpected error '{}' subscribing to events", e.getMessage()); + logger.warn("{} unexpected error '{}' subscribing to events", thing.getUID(), e.getMessage()); } logger.debug("Stack trace", e); } @@ -670,7 +672,7 @@ private void enableEvents(boolean enable) { */ private void enableEventsOrThrow(boolean enable) throws Exception { if (isChildAccessory) { - logger.warn("Forbidden to enable/disable events on child accessory '{}'", thing.getUID()); + logger.warn("{} forbidden to enable/disable events on child accessory", thing.getUID()); return; } Service service = new Service(); @@ -687,7 +689,8 @@ private void enableEventsOrThrow(boolean enable) throws Exception { throw new IllegalStateException("Read/write service not initialized"); } throttler.call(() -> rwService.writeCharacteristics(GSON.toJson(service))); - logger.debug("Eventing {}abled for {} channels", enable ? "en" : "dis", service.characteristics.size()); + logger.debug("{} eventing {}abled for {} channels", thing.getUID(), enable ? "en" : "dis", + service.characteristics.size()); } /** @@ -710,11 +713,12 @@ private synchronized void refresh() { } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect - logger.debug("Communication error '{}' polling accessories, reconnecting..", e.getMessage()); + logger.debug("{} communication error '{}' polling accessories, reconnecting..", thing.getUID(), + e.getMessage()); scheduleConnectionAttempt(); } else { // other exception; log at warn and don't try to reconnect - logger.warn("Unexpected error '{}' polling accessories", e.getMessage()); + logger.warn("{} unexpected error '{}' polling accessories", thing.getUID(), e.getMessage()); } logger.debug("Stack trace", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, @@ -787,7 +791,7 @@ private void startRootThingRefreshTask() { } } if (refreshTask == null) { - logger.warn("Invalid refresh interval configuration, polling disabled"); + logger.warn("{} invalid refresh interval configuration, polling disabled", thing.getUID()); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml index 097ec8e963df7..6a550ca717605 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml @@ -7,4 +7,16 @@ HomeKit Binding This is the binding for a HomeKit client. + + + mdns + + + mdnsServiceType + _hap._tcp.local. + + + + + diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java index 9dfa7358a704f..150260ef1cbe5 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java @@ -410,10 +410,11 @@ void testChannelDefinitions() { // Check that the channel group type and its UID and label are set ChannelGroupType channelGroupType = channelGroupTypes.stream() - .filter(cgt -> "channel-group-type-lightbulb".equals(cgt.getUID().getId())).findFirst().orElse(null); + .filter(cgt -> "channel-group-type-lightbulb-7-bridge1-accessory3".equals(cgt.getUID().getId())) + .findFirst().orElse(null); assertNotNull(channelGroupType); assertEquals("Light Bulb", channelGroupType.getLabel()); - assertEquals("channel-group-type-lightbulb", channelGroupType.getUID().getId()); + assertEquals("channel-group-type-lightbulb-7-bridge1-accessory3", channelGroupType.getUID().getId()); // There should be two channel definitions for the Light Bulb service: On and Brightness assertEquals(2, channelGroupType.getChannelDefinitions().size()); @@ -422,7 +423,7 @@ void testChannelDefinitions() { ChannelDefinition channelDefinition = channelGroupType.getChannelDefinitions().stream() .filter(cd -> "Brightness".equals(cd.getLabel())).findFirst().orElse(null); assertNotNull(channelDefinition); - assertEquals("channel-type-brightness-bridge1-accessory3", channelDefinition.getChannelTypeUID().getId()); + assertEquals("channel-type-brightness-9-bridge1-accessory3", channelDefinition.getChannelTypeUID().getId()); assertEquals("Brightness", channelDefinition.getLabel()); assertEquals("int", channelDefinition.getProperties().get("format")); @@ -433,7 +434,7 @@ void testChannelDefinitions() { ChannelType channelType = channelTypes.stream().filter(ct -> "Dimmer".equals(ct.getItemType())).findFirst() .orElse(null); assertNotNull(channelType); - assertEquals("channel-type-brightness-bridge1-accessory3", channelType.getUID().getId()); + assertEquals("channel-type-brightness-9-bridge1-accessory3", channelType.getUID().getId()); assertEquals("Brightness", channelType.getLabel()); assertEquals("Dimmer", channelType.getItemType()); assertEquals("light", channelType.getCategory()); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java new file mode 100644 index 0000000000000..0e4e1259283f9 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java @@ -0,0 +1,726 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.homekit.internal.dto.Accessories; +import org.openhab.binding.homekit.internal.dto.Accessory; +import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.type.ChannelDefinition; +import org.openhab.core.thing.type.ChannelGroupDefinition; +import org.openhab.core.thing.type.ChannelGroupType; +import org.openhab.core.thing.type.ChannelGroupTypeUID; +import org.openhab.core.thing.type.ChannelType; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.osgi.framework.Bundle; + +import com.google.gson.Gson; + +/** + * Test cases for loading channel creation data from JSON provided by Aqara presnce sensors. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestChannelCreationForAqaraJson { + + // Aqara JSON dump + private static final String TEST_JSON = """ + { + "accessories": [ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "3E", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 2, + "type": "14", + "format": "bool", + "perms": [ + "pw" + ] + }, + { + "iid": 3, + "type": "20", + "format": "string", + "value": "Aqara", + "perms": [ + "pr" + ], + "ev": false, + "enc": false + }, + { + "iid": 4, + "type": "21", + "format": "string", + "value": "PS-S02E", + "perms": [ + "pr" + ], + "ev": false, + "enc": false + }, + { + "iid": 5, + "type": "23", + "format": "string", + "value": "Presence-Sensor-FP2-DB0B", + "perms": [ + "pr" + ], + "ev": false, + "enc": false + }, + { + "iid": 6, + "type": "30", + "format": "string", + "value": "54EF447BDB0B", + "perms": [ + "pr" + ], + "ev": false, + "enc": false + }, + { + "iid": 7, + "type": "52", + "format": "string", + "value": "1.3.3", + "perms": [ + "pr" + ], + "ev": false, + "enc": false + }, + { + "iid": 8, + "type": "53", + "format": "string", + "value": "1.0.0", + "perms": [ + "pr" + ], + "ev": false, + "enc": false + }, + { + "iid": 9, + "type": "34AB8811-AC7F-4340-BAC3-FD6A85F9943B", + "format": "string", + "value": "6.1;6.1", + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false + }, + { + "iid": 10, + "type": "220", + "format": "data", + "value": "xDsGOzOmv1k=", + "perms": [ + "pr" + ], + "ev": false, + "enc": false, + "maxDataLen": 8 + } + ] + }, + { + "iid": 16, + "type": "A2", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 18, + "type": "37", + "format": "string", + "value": "1.1.0", + "perms": [ + "pr" + ], + "ev": false, + "enc": false + } + ] + }, + { + "iid": 64, + "type": "22A", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 66, + "type": "22B", + "format": "bool", + "value": 1, + "perms": [ + "pr" + ], + "ev": false, + "enc": false + }, + { + "iid": 67, + "type": "22C", + "format": "uint32", + "value": 9, + "perms": [ + "pr" + ], + "ev": false, + "enc": false, + "minValue": 0, + "maxValue": 15, + "minStep": 1 + }, + { + "iid": 68, + "type": "22D", + "format": "tlv8", + "value": "", + "perms": [ + "pr", + "pw", + "ev", + "tw", + "wr" + ], + "ev": false, + "enc": false + } + ] + }, + { + "iid": 2560, + "type": "239", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 2562, + "type": "23C", + "format": "data", + "value": "", + "perms": [ + "pr" + ], + "ev": false, + "enc": false, + "maxDataLen": 0 + } + ] + }, + { + "iid": 80, + "type": "9715BF53-AB63-4449-8DC7-2785D617390A", + "primary": false, + "hidden": true, + "characteristics": [ + { + "iid": 81, + "type": "7D943F6A-E052-4E96-A176-D17BF00E32CB", + "format": "int", + "value": -1, + "perms": [ + "pr", + "ev", + "hd" + ], + "ev": false, + "enc": false, + "description": "Firmware Update Status", + "minValue": -128, + "maxValue": 127, + "minStep": 1 + }, + { + "iid": 82, + "type": "A45EFD52-0DB5-4C1A-9727-513FBCD8185F", + "format": "string", + "perms": [ + "pw", + "hd" + ], + "description": "Firmware Update URL", + "maxLen": 256 + }, + { + "iid": 83, + "type": "40F0124A-579D-40E4-865E-0EF6740EA64B", + "format": "string", + "perms": [ + "pw", + "hd" + ], + "description": "Firmware Update Checksum" + }, + { + "iid": 85, + "type": "96BF5F20-2996-4DB6-8D65-0E36314BCB6D", + "format": "string", + "value": "1.3.3", + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false, + "description": "Firmware Version" + }, + { + "iid": 84, + "type": "36B7A28B-3200-4783-A3FB-6714F11B1417", + "format": "string", + "value": "lumi.motion.agl001", + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false, + "description": "Device Model" + }, + { + "iid": 86, + "type": "F5329CB1-A50B-4225-BA9B-331449E7F7A9", + "format": "uint8", + "value": 1, + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false, + "description": "Selected IoT Platform", + "minValue": 0, + "maxValue": 4, + "minStep": 1 + } + ] + }, + { + "iid": 96, + "type": "F49132D1-12DF-4119-87D3-A93E8D68531E", + "primary": false, + "hidden": true, + "characteristics": [ + { + "iid": 101, + "type": "23", + "format": "string", + "value": "AIOT", + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false, + "description": "Name" + }, + { + "iid": 97, + "type": "25D889CB-7135-4A29-B5B4-C1FFD6D2DD5C", + "format": "string", + "value": "", + "perms": [ + "pr", + "pw", + "hd" + ], + "ev": false, + "enc": false, + "description": "Country Domain" + }, + { + "iid": 98, + "type": "C7EECAA7-91D9-40EB-AD0C-FFDDE3143CB9", + "format": "string", + "value": "lumi1.54ef447bdb0b", + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false, + "description": "AIOT did" + }, + { + "iid": 99, + "type": "80FA747E-CB45-45A4-B7BE-AA7D9964859E", + "format": "string", + "perms": [ + "pw", + "hd" + ], + "description": "AIOT bindkey" + }, + { + "iid": 100, + "type": "C3B8A329-EF0C-4739-B773-E5B7AEA52C71", + "format": "bool", + "value": 1, + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false, + "description": "AIOT bindstate" + } + ] + }, + { + "iid": 2672, + "type": "84", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 2673, + "type": "23", + "format": "string", + "value": "Light Sensor", + "perms": [ + "pr" + ], + "ev": false, + "enc": false + }, + { + "iid": 2674, + "type": "6B", + "format": "float", + "value": 9, + "perms": [ + "pr", + "ev" + ], + "ev": false, + "enc": false, + "unit": "lux", + "minValue": 0, + "maxValue": 100000, + "minStep": 1 + } + ] + }, + { + "iid": 2688, + "type": "86", + "primary": true, + "hidden": false, + "characteristics": [ + { + "iid": 2689, + "type": "23", + "format": "string", + "value": "Presence Sensor 1", + "perms": [ + "pr" + ], + "ev": false, + "enc": false, + "maxLen": 20 + }, + { + "iid": 2690, + "type": "71", + "format": "uint8", + "value": 1, + "perms": [ + "pr", + "ev" + ], + "ev": false, + "enc": false, + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "iid": 2691, + "type": "C8622A33-826A-4DD3-9BE9-D496361F29BB", + "format": "uint8", + "value": 0, + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false, + "description": "Sensor Index", + "minValue": 0, + "maxValue": 30, + "minStep": 1 + } + ] + }, + { + "iid": 2692, + "type": "86", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 2693, + "type": "23", + "format": "string", + "value": "Presence Sensor 2", + "perms": [ + "pr" + ], + "ev": false, + "enc": false, + "maxLen": 20 + }, + { + "iid": 2694, + "type": "71", + "format": "uint8", + "value": 0, + "perms": [ + "pr", + "ev" + ], + "ev": false, + "enc": false, + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "iid": 2695, + "type": "C8622A33-826A-4DD3-9BE9-D496361F29BB", + "format": "uint8", + "value": 1, + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false, + "description": "Sensor Index", + "minValue": 0, + "maxValue": 30, + "minStep": 1 + } + ] + }, + { + "iid": 2696, + "type": "86", + "primary": false, + "hidden": false, + "characteristics": [ + { + "iid": 2697, + "type": "23", + "format": "string", + "value": "Presence Sensor 3", + "perms": [ + "pr" + ], + "ev": false, + "enc": false, + "maxLen": 20 + }, + { + "iid": 2698, + "type": "71", + "format": "uint8", + "value": 1, + "perms": [ + "pr", + "ev" + ], + "ev": false, + "enc": false, + "minValue": 0, + "maxValue": 1, + "minStep": 1 + }, + { + "iid": 2699, + "type": "C8622A33-826A-4DD3-9BE9-D496361F29BB", + "format": "uint8", + "value": 2, + "perms": [ + "pr", + "hd" + ], + "ev": false, + "enc": false, + "description": "Sensor Index", + "minValue": 0, + "maxValue": 30, + "minStep": 1 + } + ] + } + ] + } + ] + } + """; + + private static final Gson GSON = new Gson(); + + @Test + void testGenericJsonParsing() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + assertNotNull(accessories.accessories); + assertEquals(1, accessories.accessories.size()); + for (Accessory accessory : accessories.accessories) { + assertNotNull(accessory.aid); + assertNotNull(accessory.services); + assertEquals(10, accessory.services.size()); + for (var service : accessory.services) { + assertNotNull(service.type); + assertNotNull(service.iid); + assertNotNull(service.characteristics); + assertTrue(!service.characteristics.isEmpty()); + for (var characteristic : service.characteristics) { + assertNotNull(characteristic.type); + assertNotNull(characteristic.iid); + assertNotNull(characteristic.perms); + assertTrue(!characteristic.perms.isEmpty()); + assertNotNull(characteristic.format); + } + } + } + } + + @Test + void testChannelDefinitions() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + + HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + TranslationProvider i18nProvider = mock(TranslationProvider.class); + Bundle bundle = mock(Bundle.class); + + Map channelGroupTypes = new HashMap<>(); + Map channelTypes = new HashMap<>(); + + doAnswer(invocation -> { + ChannelGroupType arg = invocation.getArgument(0); + channelGroupTypes.put(arg.getUID(), arg); + return null; + }).when(typeProvider).putChannelGroupType(any(ChannelGroupType.class)); + + doAnswer(invocation -> { + ChannelType arg = invocation.getArgument(0); + channelTypes.put(arg.getUID(), arg); + return null; + }).when(typeProvider).putChannelType(any(ChannelType.class)); + + ThingUID thingUID = new ThingUID("hhh", "aaa", "1234567890abcdef"); + Accessory accessory = accessories.getAccessory(1L); + assertNotNull(accessory); + List channelGroupDefinitions = accessory + .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); + + assertNotNull(channelGroupDefinitions); + assertEquals(5, channelGroupDefinitions.size()); + + // Check that the channel group definition and its type UID and label are set + for (ChannelGroupDefinition groupDef : channelGroupDefinitions) { + assertNotNull(groupDef.getId()); + assertNotNull(groupDef.getTypeUID()); + assertNotNull(groupDef.getLabel()); + } + + // there should be 5 unique channel group types; 1 protocol info service, 1 light sensor, and 3 presence sensors + assertEquals(5, channelGroupTypes.size()); + + // there should be 4 unique channel types; 1 light sensor, and 3 presence sensors + assertEquals(4, channelTypes.size()); + + // check the first presence sensor + ChannelGroupTypeUID targetChannelGroupTypeUID = new ChannelGroupTypeUID( + "homekit:channel-group-type-sensor-occupancy-2688-1234567890abcdef-1"); + assertNotNull(targetChannelGroupTypeUID); + ChannelGroupType channelGroupType = channelGroupTypes.get(targetChannelGroupTypeUID); + assertNotNull(channelGroupType); + List channelDefinitions = channelGroupType.getChannelDefinitions(); + assertNotNull(channelDefinitions); + assertEquals(2, channelDefinitions.size()); + ChannelDefinition channelDefinition = channelDefinitions.get(1); + assertNotNull(channelDefinition); + ChannelTypeUID channelTypeUID = channelDefinition.getChannelTypeUID(); + assertNotNull(channelTypeUID); + ChannelType channelType = channelTypes.get(channelTypeUID); + assertNotNull(channelType); + assertEquals("channel-type-occupancy-detected-2690-1234567890abcdef-1", channelType.getUID().getId()); + + // check the second presence sensor + targetChannelGroupTypeUID = new ChannelGroupTypeUID( + "homekit:channel-group-type-sensor-occupancy-2692-1234567890abcdef-1"); + assertNotNull(targetChannelGroupTypeUID); + channelGroupType = channelGroupTypes.get(targetChannelGroupTypeUID); + assertNotNull(channelGroupType); + channelDefinitions = channelGroupType.getChannelDefinitions(); + assertNotNull(channelDefinitions); + assertEquals(2, channelDefinitions.size()); + channelDefinition = channelDefinitions.get(1); + assertNotNull(channelDefinition); + channelTypeUID = channelDefinition.getChannelTypeUID(); + assertNotNull(channelTypeUID); + channelType = channelTypes.get(channelTypeUID); + assertNotNull(channelType); + assertEquals("channel-type-occupancy-detected-2694-1234567890abcdef-1", channelType.getUID().getId()); + + // check the third presence sensor + targetChannelGroupTypeUID = new ChannelGroupTypeUID( + "homekit:channel-group-type-sensor-occupancy-2696-1234567890abcdef-1"); + assertNotNull(targetChannelGroupTypeUID); + channelGroupType = channelGroupTypes.get(targetChannelGroupTypeUID); + assertNotNull(channelGroupType); + channelDefinitions = channelGroupType.getChannelDefinitions(); + assertNotNull(channelDefinitions); + assertEquals(2, channelDefinitions.size()); + channelDefinition = channelDefinitions.get(1); + assertNotNull(channelDefinition); + channelTypeUID = channelDefinition.getChannelTypeUID(); + assertNotNull(channelTypeUID); + channelType = channelTypes.get(channelTypeUID); + assertNotNull(channelType); + assertEquals("channel-type-occupancy-detected-2698-1234567890abcdef-1", channelType.getUID().getId()); + } +} From ce8d9646840acc1068bc29a0f5ff94a991b931c2 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 21 Nov 2025 22:19:31 +0000 Subject: [PATCH 131/177] typos Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/transport/IpTransport.java | 6 +++--- .../homekit/internal/TestChannelCreationForAqaraJson.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 00a3ad3f640c0..87dc6f9a97718 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -71,7 +71,7 @@ public class IpTransport implements AutoCloseable { * Creates a new IpTransport instance on the given host. * * @param ipAddress the IP address and port of the HomeKit accessory - * @param hostName the fully qualified host name (e.g. 'foobar.local') of the HomeKit accessory + * @param hostName the fully qualified host name (e.g. 'foobar._hap._tcp.local') of the HomeKit accessory * @throws IOException */ public IpTransport(String ipAddress, String hostName, EventListener eventListener) throws IOException { @@ -80,7 +80,7 @@ public IpTransport(String ipAddress, String hostName, EventListener eventListene this.eventListener = eventListener; String[] parts = ipAddress.split(":"); socket = new Socket(); - socket.setKeepAlive(true); // keep-alive forbiddden for accessories but client should use it + socket.setKeepAlive(true); // keep-alive forbidden for accessories but client should use it socket.setTcpNoDelay(true); // disable Nagle algorithm to force immediate flushing of packets socket.connect(new InetSocketAddress(parts[0], Integer.parseInt(parts[1])), TIMEOUT_MILLI_SECONDS); logger.debug("Connected to {} alias {}", ipAddress, hostName); @@ -256,7 +256,7 @@ private boolean contentIsEmpty(String method) { } /** - * Reads a plain (non-secure) HTTP response from the input stream. + * Reads a plain (non secure) HTTP response from the input stream. * * @param trace if true, captures the raw data for debugging purposes. * diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java index 0e4e1259283f9..9d355a77541df 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java @@ -38,7 +38,7 @@ import com.google.gson.Gson; /** - * Test cases for loading channel creation data from JSON provided by Aqara presnce sensors. + * Test cases for loading channel creation data from JSON provided by Aqara presence sensors. * * @author Andrew Fiddian-Green - Initial contribution */ From 50b38fd257dad849ac7bdb39ff01da619b744c50 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 22 Nov 2025 16:26:51 +0000 Subject: [PATCH 132/177] improve volatile resource clean-up and usage Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 11 +- .../handler/HomekitBaseAccessoryHandler.java | 18 +- .../internal/session/HttpPayloadParser.java | 10 +- .../internal/session/SecureSession.java | 90 +++-- .../internal/transport/IpTransport.java | 92 +++-- .../internal/TestHttpChunkedParser.java | 157 ++++---- .../internal/TestHttpPayloadParser.java | 356 ++++++++++-------- 7 files changed, 415 insertions(+), 319 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 7635fbc1d483c..b176494360a62 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -108,8 +108,13 @@ public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { * Used to create a combined HSB channel and handle commands accordingly. * This is only initialized if the accessory has relevant light characteristics. */ - private @Nullable LightModel lightModel = null; - private @Nullable ChannelUID lightModelClientHSBTypeChannel = null; // special HSB combined channel + private volatile @Nullable LightModel lightModel = null; + private volatile @Nullable ChannelUID lightModelClientHSBTypeChannel = null; // special HSB combined channel + + /* + * Channel for the stop button (rollershutters) + */ + private volatile @Nullable Channel stopMoveChannel = null; /* * Internal record representing a link between an OH channel and a HomeKit characteristic type & iid. @@ -120,8 +125,6 @@ private record LightModelLink(Channel channel, CharacteristicType cxxType, Long private final List lightModelLinks = new ArrayList<>(); - private @Nullable Channel stopMoveChannel = null; // channel for the stop button (rollershutters) - public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, ChannelTypeRegistry channelTypeRegistry, ChannelGroupTypeRegistry channelGroupTypeRegistry, HomekitKeyStore keyStore, TranslationProvider i18nProvider, Bundle bundle) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 390a40422f00a..c1d4c2dcb7953 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -94,11 +94,11 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple private boolean isConfigured = false; private int connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; - private @Nullable ScheduledFuture connectionAttemptTask; - private @Nullable CharacteristicReadWriteClient rwService; - private @Nullable IpTransport ipTransport; - private @Nullable ScheduledFuture refreshTask; - private @Nullable Future manualRefreshTask; + private volatile @Nullable ScheduledFuture connectionAttemptTask; + private volatile @Nullable CharacteristicReadWriteClient rwService; + private volatile @Nullable IpTransport ipTransport; + private volatile @Nullable ScheduledFuture refreshTask; + private volatile @Nullable Future manualRefreshTask; private @NonNullByDefault({}) Long accessoryId; @@ -182,10 +182,10 @@ public void dispose() { if (connectionAttemptTask instanceof ScheduledFuture task) { task.cancel(true); } + connectionAttemptTask = null; if (ipTransport instanceof IpTransport transport) { transport.close(); } - connectionAttemptTask = null; ipTransport = null; super.dispose(); } @@ -233,8 +233,8 @@ private void processDependentThings() { try { Thread.sleep(100); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; // shutting down; exit immediately + Thread.currentThread().interrupt(); // shutting down; restore interrupt flag, and exit immediately + return; } } onRootThingAccessoriesLoaded(); @@ -642,7 +642,7 @@ private void enableEvents(boolean enable) { try { enableEventsOrThrow(enable); } catch (InterruptedException e) { - // shutting down; do nothing + Thread.currentThread().interrupt(); // shutting down; restore interrupt flag and do nothing } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java index 053f831c0367e..5b9fc356de450 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java @@ -13,6 +13,7 @@ package org.openhab.binding.homekit.internal.session; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -29,7 +30,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class HttpPayloadParser { +public class HttpPayloadParser implements AutoCloseable { private static final String NEWLINE_REGEX = "\\r?\\n"; private static final int MAX_CONTENT_LENGTH = 65536; @@ -315,4 +316,11 @@ public static byte[] readln(byte[] data, int start) throws IllegalStateException System.arraycopy(data, start, line, 0, line.length); return line; } + + @Override + public void close() throws IOException { + headerBuffer.close(); + contentBuffer.close(); + chunkDataBuffer.close(); + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index eec2e5afacfa5..8be22e7c7157d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -16,8 +16,9 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; +import java.io.EOFException; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; import java.nio.ByteBuffer; @@ -39,18 +40,16 @@ public class SecureSession { private static final int SLEEP_INTERVAL_MILLISECONDS = 50; - private final DataInputStream in; + private final InputStream in; private final OutputStream out; - private final byte[] writeKey; - private final byte[] readKey; + private final AsymmetricSessionKeys keys; private final AtomicInteger writeCounter = new AtomicInteger(0); private final AtomicInteger readCounter = new AtomicInteger(0); public SecureSession(Socket socket, AsymmetricSessionKeys keys) throws IOException { - in = new DataInputStream(socket.getInputStream()); + in = socket.getInputStream(); out = socket.getOutputStream(); - writeKey = keys.getWriteKey(); - readKey = keys.getReadKey(); + this.keys = keys; } /** @@ -62,11 +61,11 @@ public SecureSession(Socket socket, AsymmetricSessionKeys keys) throws IOExcepti * @throws InvalidCipherTextException */ public void send(byte[] plainText) throws IOException, InvalidCipherTextException { - ByteArrayInputStream plainTextStream = new ByteArrayInputStream(plainText); - while (plainTextStream.available() > 0) { - sendFrame(plainTextStream); + try (ByteArrayInputStream plainTextStream = new ByteArrayInputStream(plainText)) { + while (plainTextStream.available() > 0) { + sendFrame(plainTextStream); + } } - out.flush(); } /** @@ -81,11 +80,15 @@ public void send(byte[] plainText) throws IOException, InvalidCipherTextExceptio */ private void sendFrame(ByteArrayInputStream plainTextStream) throws IOException, InvalidCipherTextException { short frameLen = (short) Math.min(1024, plainTextStream.available()); - ByteBuffer frameAad = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(frameLen); - out.write(frameAad.array(), 0, frameAad.array().length); // send length prefix + byte[] frameAad = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(frameLen).array(); byte[] plainText = plainTextStream.readNBytes(frameLen); byte[] nonce64 = generateNonce64(writeCounter.getAndIncrement()); - out.write(encrypt(writeKey, nonce64, plainText, frameAad.array())); // AAD = lenBytes; outputs extra 16 byte tag + byte[] cipherText = encrypt(keys.getWriteKey(), nonce64, plainText, frameAad); + byte[] frame = new byte[frameAad.length + cipherText.length]; + System.arraycopy(frameAad, 0, frame, 0, frameAad.length); + System.arraycopy(cipherText, 0, frame, frameAad.length, cipherText.length); + out.write(frame); + out.flush(); } /** @@ -103,24 +106,26 @@ private void sendFrame(ByteArrayInputStream plainTextStream) throws IOException, * @throws IllegalStateException if the received data is malformed */ public byte[][] receive(boolean trace) throws IOException, InvalidCipherTextException, IllegalStateException { - HttpPayloadParser httpParser = new HttpPayloadParser(); - ByteArrayOutputStream traceStream = new ByteArrayOutputStream(); - do { - if (in.available() == 0) { - try { - Thread.sleep(SLEEP_INTERVAL_MILLISECONDS); // wait for data to arrive - } catch (InterruptedException e) { - // coninue - } - } else { - byte[] frame = receiveFrame(); - if (trace) { - traceStream.write(frame); + try (HttpPayloadParser httpParser = new HttpPayloadParser(); + ByteArrayOutputStream traceStream = new ByteArrayOutputStream()) { + do { + if (in.available() == 0) { + try { + Thread.sleep(SLEEP_INTERVAL_MILLISECONDS); // wait for data to arrive + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // restore interrupt flag + throw new IOException("Thread interrupted while waiting for data", e); + } + } else { + byte[] frame = receiveFrame(); + if (trace) { + traceStream.write(frame); + } + httpParser.accept(frame); } - httpParser.accept(frame); - } - } while (!httpParser.isComplete()); - return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), traceStream.toByteArray() }; + } while (!httpParser.isComplete()); + return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), traceStream.toByteArray() }; + } } /** @@ -136,14 +141,31 @@ public byte[][] receive(boolean trace) throws IOException, InvalidCipherTextExce */ private byte[] receiveFrame() throws IOException, InvalidCipherTextException, IllegalStateException { byte[] frameAad = new byte[2]; // AAD data length prefix - in.readFully(frameAad, 0, frameAad.length); + readFully(in, frameAad); short frameLen = ByteBuffer.wrap(frameAad).order(ByteOrder.LITTLE_ENDIAN).getShort(); if (frameLen < 0 || frameLen > 1024) { throw new IllegalStateException("Invalid frame length"); } byte[] cipherText = new byte[frameLen + 16]; // read 16 extra bytes for the auth tag - in.readFully(cipherText, 0, cipherText.length); + readFully(in, cipherText); byte[] nonce64 = generateNonce64(readCounter.getAndIncrement()); - return decrypt(readKey, nonce64, cipherText, frameAad); + return decrypt(keys.getReadKey(), nonce64, cipherText, frameAad); + } + + /** + * Reads bytes from the given input stream until the buffer is completely filled. + * + * @param buffer + * @throws IOException + */ + private void readFully(InputStream in, byte[] buffer) throws IOException { + int offset = 0; + while (offset < buffer.length) { + int read = in.read(buffer, offset, buffer.length - offset); + if (read == -1) { + throw new EOFException("Unexpected end of stream"); + } + offset += read; + } } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index 87dc6f9a97718..de891ce43c173 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -50,8 +50,8 @@ @NonNullByDefault public class IpTransport implements AutoCloseable { - private static final int TIMEOUT_MILLI_SECONDS = 10000; - private static final Duration MINIMUM_REQUEST_INTERVAL = Duration.ofMillis(200); + private static final int TIMEOUT_MILLI_SECONDS = 15000; // HomeKit spec expects "around 10 seconds" so be safe + private static final Duration MINIMUM_REQUEST_INTERVAL = Duration.ofMillis(250); private final Logger logger = LoggerFactory.getLogger(IpTransport.class); private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "homekit-io")); @@ -60,9 +60,9 @@ public class IpTransport implements AutoCloseable { private final String hostName; private final EventListener eventListener; - private @Nullable SecureSession secureSession = null; - private @Nullable Thread readThread = null; - private @Nullable CompletableFuture readHttpResponseFuture = null; + private volatile @Nullable SecureSession secureSession = null; + private volatile @Nullable Thread readThread = null; + private volatile @Nullable CompletableFuture readHttpResponseFuture = null; private boolean closing = false; private Instant earliestNextRequestTime = Instant.MIN; @@ -150,6 +150,7 @@ public byte[] put(String endpoint, String contentType, byte[] content) /** * Executes an HTTP request with the specified method, endpoint, content type, and content. + * Note: for thread safety only one request may be in flight at a time * * @param method the HTTP method (e.g., "GET", "POST", "PUT") * @param endpoint the endpoint to which the request is sent @@ -267,21 +268,22 @@ private boolean contentIsEmpty(String method) { * @throws IllegalStateException if the response is invalid. */ private byte[][] readPlainResponse(InputStream in, boolean trace) throws IOException, IllegalStateException { - HttpPayloadParser httpParser = new HttpPayloadParser(); - ByteArrayOutputStream raw = trace ? new ByteArrayOutputStream() : null; - byte[] buf = new byte[4096]; - do { - int read = in.read(buf, 0, buf.length); - if (read > 0) { - byte[] frame = Arrays.copyOf(buf, read); - if (raw != null) { - raw.write(frame); + try (HttpPayloadParser httpParser = new HttpPayloadParser(); + ByteArrayOutputStream raw = trace ? new ByteArrayOutputStream() : null) { + byte[] buf = new byte[4096]; + do { + int read = in.read(buf, 0, buf.length); + if (read > 0) { + byte[] frame = Arrays.copyOf(buf, read); + if (raw != null) { + raw.write(frame); + } + httpParser.accept(frame); } - httpParser.accept(frame); - } - } while (!httpParser.isComplete()); - return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), - raw != null ? raw.toByteArray() : new byte[0] }; + } while (!httpParser.isComplete()); + return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), + raw != null ? raw.toByteArray() : new byte[0] }; + } } /** @@ -303,14 +305,22 @@ public void close() { secureSession = null; try { socket.close(); - if (readThread instanceof Thread thread) { + } catch (IOException e) { + // shut down quietly + } + if (readThread instanceof Thread thread) { + try { thread.interrupt(); thread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // restore interrupt flag, and shut down quietly } - } catch (IOException | InterruptedException e) { - // shut down quietly } readThread = null; + if (readHttpResponseFuture instanceof CompletableFuture readFuture) { + readFuture.complete(new byte[3][0]); // complete with an empty response + } + executor.shutdown(); } /** @@ -340,29 +350,39 @@ private void handleResponse(byte[][] response) { * thread is interrupted, or an error occurs. */ private void readTask() { - Throwable cause = null; - do { - try { + try { + do { SecureSession session = secureSession; if (session == null) { throw new IllegalStateException("Secure session is null"); } byte[][] response = session.receive(logger.isTraceEnabled()); handleResponse(response); - } catch (Exception e) { - // catch all; capture cause and exit - cause = e; - break; + } while (!Thread.currentThread().isInterrupted()); + } catch (Exception e) { + // catch all; log the cause and log any residual data in the socket + if (!closing) { + logger.debug("Error '{}' while listening for HTTP responses", e.getMessage(), e); + try { + InputStream in = socket.getInputStream(); + int available = in.available(); + if (available > 0) { + byte[] leftover = new byte[available]; + int read = in.read(leftover); + if (read > 0) { + logger.debug("Unprocessed socket data ({} bytes):\n{}", read, + new String(leftover, 0, read, StandardCharsets.ISO_8859_1)); + } + } + } catch (IOException ioe) { + logger.debug("Unable to read leftover socket data: {}", ioe.getMessage(), ioe); + } } - } while (!Thread.currentThread().isInterrupted()); - - if (readHttpResponseFuture instanceof CompletableFuture future) { - readHttpResponseFuture = null; - future.completeExceptionally(cause != null ? cause : new InterruptedException("Listener interrupted")); } - if (cause != null && !closing) { - logger.debug("Error '{}' while listening for HTTP responses", cause.getMessage(), cause); + if (readHttpResponseFuture instanceof CompletableFuture readFuture) { + readHttpResponseFuture = null; + readFuture.completeExceptionally(new InterruptedException("Listener interrupted")); } } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java index 730cb72b77e63..54c53227e6cc1 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java @@ -14,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.*; +import java.io.IOException; import java.nio.charset.StandardCharsets; import org.eclipse.jdt.annotation.NonNullByDefault; @@ -42,94 +43,104 @@ class TestHttpChunkedParser { @Test void testValidChunkedPayload() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(s0.getBytes(StandardCharsets.UTF_8)); - parser.accept(s1.getBytes(StandardCharsets.UTF_8)); - parser.accept(s2.getBytes(StandardCharsets.UTF_8)); - parser.accept(s3.getBytes(StandardCharsets.UTF_8)); - parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); - parser.accept(s5.getBytes(StandardCharsets.UTF_8)); - parser.accept(s6.getBytes(StandardCharsets.UTF_8)); - parser.accept(s7.getBytes(StandardCharsets.UTF_8)); - parser.accept(s8.getBytes(StandardCharsets.UTF_8)); - parser.accept(s9.getBytes(StandardCharsets.UTF_8)); - parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); - assertTrue(parser.isComplete()); - assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s5.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept(s9.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + assertTrue(parser.isComplete()); + assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); + } catch (IllegalStateException | IOException e) { + } } @Test void testBadChunkedSizePayload() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(s0.getBytes(StandardCharsets.UTF_8)); - parser.accept(s1.getBytes(StandardCharsets.UTF_8)); - parser.accept(s2.getBytes(StandardCharsets.UTF_8)); - parser.accept(s3.getBytes(StandardCharsets.UTF_8)); - parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); - parser.accept(s5err.getBytes(StandardCharsets.UTF_8)); - parser.accept(s6.getBytes(StandardCharsets.UTF_8)); - parser.accept(s7.getBytes(StandardCharsets.UTF_8)); - parser.accept(s8.getBytes(StandardCharsets.UTF_8)); - parser.accept(s9.getBytes(StandardCharsets.UTF_8)); - assertThrows(IllegalStateException.class, () -> parser.accept(crlf.getBytes(StandardCharsets.UTF_8))); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s5err.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept(s9.getBytes(StandardCharsets.UTF_8)); + assertThrows(IllegalStateException.class, () -> parser.accept(crlf.getBytes(StandardCharsets.UTF_8))); + } catch (IllegalStateException | IOException e) { + } } @Test void testChunkedPayloadWithEmptyLines() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(s0.getBytes(StandardCharsets.UTF_8)); - parser.accept(s1.getBytes(StandardCharsets.UTF_8)); - parser.accept(s2.getBytes(StandardCharsets.UTF_8)); - parser.accept(s3.getBytes(StandardCharsets.UTF_8)); - parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); - parser.accept(s5.getBytes(StandardCharsets.UTF_8)); - parser.accept(s6.getBytes(StandardCharsets.UTF_8)); - parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); - parser.accept(s7.getBytes(StandardCharsets.UTF_8)); - parser.accept(s8.getBytes(StandardCharsets.UTF_8)); - parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); - parser.accept(s9.getBytes(StandardCharsets.UTF_8)); - parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); - assertTrue(parser.isComplete()); - assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s5.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s9.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + assertTrue(parser.isComplete()); + assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); + } catch (IllegalStateException | IOException e) { + } } @Test void testIncompleteChunkedPayload() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(s0.getBytes(StandardCharsets.UTF_8)); - parser.accept(s1.getBytes(StandardCharsets.UTF_8)); - parser.accept(s2.getBytes(StandardCharsets.UTF_8)); - parser.accept(s3.getBytes(StandardCharsets.UTF_8)); - parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); - parser.accept(s5.getBytes(StandardCharsets.UTF_8)); - parser.accept(s6.getBytes(StandardCharsets.UTF_8)); - parser.accept(s7.getBytes(StandardCharsets.UTF_8)); - parser.accept(s8.getBytes(StandardCharsets.UTF_8)); - parser.accept(s9.getBytes(StandardCharsets.UTF_8)); - assertFalse(parser.isComplete()); - assertEquals("", new String(parser.getContent(), StandardCharsets.UTF_8)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); + parser.accept(s5.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept(s9.getBytes(StandardCharsets.UTF_8)); + assertFalse(parser.isComplete()); + assertEquals("", new String(parser.getContent(), StandardCharsets.UTF_8)); + } catch (IllegalStateException | IOException e) { + } } @Test void testValidChunkedPayloadWitSplitFrames() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(s0.getBytes(StandardCharsets.UTF_8)); - parser.accept(s1.getBytes(StandardCharsets.UTF_8)); - parser.accept(s2.getBytes(StandardCharsets.UTF_8)); - parser.accept(s3.getBytes(StandardCharsets.UTF_8)); - parser.accept("\r".getBytes(StandardCharsets.UTF_8)); - parser.accept("\n".getBytes(StandardCharsets.UTF_8)); - parser.accept(s5.getBytes(StandardCharsets.UTF_8)); - parser.accept(s6.getBytes(StandardCharsets.UTF_8)); - parser.accept(s7.getBytes(StandardCharsets.UTF_8)); - parser.accept(s8.getBytes(StandardCharsets.UTF_8)); - parser.accept("0".getBytes(StandardCharsets.UTF_8)); - parser.accept("\r".getBytes(StandardCharsets.UTF_8)); - parser.accept("\n".getBytes(StandardCharsets.UTF_8)); - parser.accept("\r".getBytes(StandardCharsets.UTF_8)); - parser.accept("\n".getBytes(StandardCharsets.UTF_8)); - assertTrue(parser.isComplete()); - assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(s0.getBytes(StandardCharsets.UTF_8)); + parser.accept(s1.getBytes(StandardCharsets.UTF_8)); + parser.accept(s2.getBytes(StandardCharsets.UTF_8)); + parser.accept(s3.getBytes(StandardCharsets.UTF_8)); + parser.accept("\r".getBytes(StandardCharsets.UTF_8)); + parser.accept("\n".getBytes(StandardCharsets.UTF_8)); + parser.accept(s5.getBytes(StandardCharsets.UTF_8)); + parser.accept(s6.getBytes(StandardCharsets.UTF_8)); + parser.accept(s7.getBytes(StandardCharsets.UTF_8)); + parser.accept(s8.getBytes(StandardCharsets.UTF_8)); + parser.accept("0".getBytes(StandardCharsets.UTF_8)); + parser.accept("\r".getBytes(StandardCharsets.UTF_8)); + parser.accept("\n".getBytes(StandardCharsets.UTF_8)); + parser.accept("\r".getBytes(StandardCharsets.UTF_8)); + parser.accept("\n".getBytes(StandardCharsets.UTF_8)); + assertTrue(parser.isComplete()); + assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); + } catch (IllegalStateException | IOException e) { + } } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java index 07cb515f40d12..eb71559997351 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java @@ -14,6 +14,8 @@ import static org.junit.jupiter.api.Assertions.*; +import java.io.IOException; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; import org.openhab.binding.homekit.internal.session.HttpPayloadParser; @@ -46,223 +48,253 @@ class TestHttpPayloadParser { @Test void testHttpWithChunkedContentOk() { - HttpPayloadParser parser = new HttpPayloadParser(); - String h = HEADERS_A + HEADERS_C + HEADERS_Z; - String hc = h + CHUNK.formatted(100) + CONTENT + CRLF + CHUNK.formatted(0) + CRLF; - parser.accept(hc.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(100, content.length); - assertEquals(CONTENT, new String(content)); - byte[] headers = parser.getHeaders(); - assertEquals(h, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + String h = HEADERS_A + HEADERS_C + HEADERS_Z; + String hc = h + CHUNK.formatted(100) + CONTENT + CRLF + CHUNK.formatted(0) + CRLF; + parser.accept(hc.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + assertEquals(h, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithChunkedContentOkManyPartial() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(HEADERS_A.substring(0, 8).getBytes()); - parser.accept(HEADERS_A.substring(8).getBytes()); - parser.accept(HEADERS_C.substring(0, 14).getBytes()); - parser.accept(HEADERS_C.substring(14).getBytes()); - parser.accept(HEADERS_Z.substring(0, 19).getBytes()); - parser.accept(HEADERS_Z.substring(19).getBytes()); - parser.accept(CHUNK.formatted(100).getBytes()); - parser.accept(CONTENT.substring(0, 51).getBytes()); - parser.accept(CONTENT.substring(51).getBytes()); - parser.accept(CRLF.getBytes()); - parser.accept(CHUNK.formatted(0).getBytes()); - parser.accept(CRLF.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(100, content.length); - assertEquals(CONTENT, new String(content)); - byte[] headers = parser.getHeaders(); - String h = HEADERS_A + HEADERS_C + HEADERS_Z; - assertEquals(h, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(HEADERS_A.substring(0, 8).getBytes()); + parser.accept(HEADERS_A.substring(8).getBytes()); + parser.accept(HEADERS_C.substring(0, 14).getBytes()); + parser.accept(HEADERS_C.substring(14).getBytes()); + parser.accept(HEADERS_Z.substring(0, 19).getBytes()); + parser.accept(HEADERS_Z.substring(19).getBytes()); + parser.accept(CHUNK.formatted(100).getBytes()); + parser.accept(CONTENT.substring(0, 51).getBytes()); + parser.accept(CONTENT.substring(51).getBytes()); + parser.accept(CRLF.getBytes()); + parser.accept(CHUNK.formatted(0).getBytes()); + parser.accept(CRLF.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + String h = HEADERS_A + HEADERS_C + HEADERS_Z; + assertEquals(h, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithChunkedContentOkManyPartialAndSplitChunkHeader() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(HEADERS_A.substring(0, 8).getBytes()); - parser.accept(HEADERS_A.substring(8).getBytes()); - parser.accept(HEADERS_C.substring(0, 14).getBytes()); - parser.accept(HEADERS_C.substring(14).getBytes()); - parser.accept(HEADERS_Z.substring(0, 19).getBytes()); - parser.accept(HEADERS_Z.substring(19).getBytes()); - parser.accept(CHUNK_1.formatted(100).getBytes()); - parser.accept(CHUNK_2.getBytes()); - parser.accept(CONTENT.substring(0, 51).getBytes()); - parser.accept(CONTENT.substring(51).getBytes()); - parser.accept(CRLF.getBytes()); - parser.accept(CHUNK.formatted(0).getBytes()); - parser.accept(CRLF.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(100, content.length); - assertEquals(CONTENT, new String(content)); - byte[] headers = parser.getHeaders(); - String h = HEADERS_A + HEADERS_C + HEADERS_Z; - assertEquals(h, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(HEADERS_A.substring(0, 8).getBytes()); + parser.accept(HEADERS_A.substring(8).getBytes()); + parser.accept(HEADERS_C.substring(0, 14).getBytes()); + parser.accept(HEADERS_C.substring(14).getBytes()); + parser.accept(HEADERS_Z.substring(0, 19).getBytes()); + parser.accept(HEADERS_Z.substring(19).getBytes()); + parser.accept(CHUNK_1.formatted(100).getBytes()); + parser.accept(CHUNK_2.getBytes()); + parser.accept(CONTENT.substring(0, 51).getBytes()); + parser.accept(CONTENT.substring(51).getBytes()); + parser.accept(CRLF.getBytes()); + parser.accept(CHUNK.formatted(0).getBytes()); + parser.accept(CRLF.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + String h = HEADERS_A + HEADERS_C + HEADERS_Z; + assertEquals(h, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithContentDiscardExtra() { - HttpPayloadParser parser = new HttpPayloadParser(); - String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; - String hc = h + CONTENT + "EXTRA"; - parser.accept(hc.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(100, content.length); - assertEquals(CONTENT, new String(content)); - byte[] headers = parser.getHeaders(); - assertEquals(h, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; + String hc = h + CONTENT + "EXTRA"; + parser.accept(hc.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + assertEquals(h, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithContentManyPartialOk() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(HEADERS_A.substring(0, 11).getBytes()); - parser.accept(HEADERS_A.substring(11).getBytes()); - parser.accept(HEADERS_B.substring(0, 11).getBytes()); - parser.accept(HEADERS_B.substring(11).formatted(100).getBytes()); - parser.accept(HEADERS_Z.substring(0, 12).getBytes()); - parser.accept(HEADERS_Z.substring(12).getBytes()); - parser.accept(CONTENT.substring(0, 42).getBytes()); - parser.accept(CONTENT.substring(42).getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(100, content.length); - assertEquals(CONTENT, new String(content)); - byte[] headers = parser.getHeaders(); - String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; - assertEquals(h, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(HEADERS_A.substring(0, 11).getBytes()); + parser.accept(HEADERS_A.substring(11).getBytes()); + parser.accept(HEADERS_B.substring(0, 11).getBytes()); + parser.accept(HEADERS_B.substring(11).formatted(100).getBytes()); + parser.accept(HEADERS_Z.substring(0, 12).getBytes()); + parser.accept(HEADERS_Z.substring(12).getBytes()); + parser.accept(CONTENT.substring(0, 42).getBytes()); + parser.accept(CONTENT.substring(42).getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; + assertEquals(h, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithContentManyPartialOkAndSplitCRLF() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(HEADERS_A.substring(0, 11).getBytes()); - parser.accept(HEADERS_A.substring(11).getBytes()); - parser.accept(HEADERS_B.substring(0, 11).getBytes()); - parser.accept(HEADERS_B.substring(11).formatted(100).getBytes()); - parser.accept(HEADERS_Z1.getBytes()); - parser.accept(HEADERS_Z2.getBytes()); - parser.accept(CONTENT.substring(0, 42).getBytes()); - parser.accept(CONTENT.substring(42).getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(100, content.length); - assertEquals(CONTENT, new String(content)); - byte[] headers = parser.getHeaders(); - String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; - assertEquals(h, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(HEADERS_A.substring(0, 11).getBytes()); + parser.accept(HEADERS_A.substring(11).getBytes()); + parser.accept(HEADERS_B.substring(0, 11).getBytes()); + parser.accept(HEADERS_B.substring(11).formatted(100).getBytes()); + parser.accept(HEADERS_Z1.getBytes()); + parser.accept(HEADERS_Z2.getBytes()); + parser.accept(CONTENT.substring(0, 42).getBytes()); + parser.accept(CONTENT.substring(42).getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; + assertEquals(h, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithContentOk() { - HttpPayloadParser parser = new HttpPayloadParser(); - String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; - String hc = h + CONTENT; - parser.accept(hc.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(100, content.length); - assertEquals(CONTENT, new String(content)); - byte[] headers = parser.getHeaders(); - assertEquals(h, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(100, content.length); + assertEquals(CONTENT, new String(content)); + byte[] headers = parser.getHeaders(); + assertEquals(h, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithMultipleFrames() { - HttpPayloadParser parser = new HttpPayloadParser(); - String h = HEADERS_A + HEADERS_B.formatted(300) + HEADERS_Z; - String hc = h + CONTENT; - parser.accept(hc.getBytes()); - assertFalse(parser.isComplete()); - parser.accept(CONTENT.getBytes()); - parser.accept(CONTENT.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(300, content.length); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + String h = HEADERS_A + HEADERS_B.formatted(300) + HEADERS_Z; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertFalse(parser.isComplete()); + parser.accept(CONTENT.getBytes()); + parser.accept(CONTENT.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(300, content.length); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithNoContentLength() { - HttpPayloadParser parser = new HttpPayloadParser(); - String h = HEADERS_A + HEADERS_B; - String hc = h + CONTENT; - parser.accept(hc.getBytes()); - assertFalse(parser.isComplete()); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + String h = HEADERS_A + HEADERS_B; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertFalse(parser.isComplete()); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithWrongContentLength() { - HttpPayloadParser parser = new HttpPayloadParser(); - String h = HEADERS_A + HEADERS_B.formatted(200) + HEADERS_Z; - String hc = h + CONTENT; - parser.accept(hc.getBytes()); - assertFalse(parser.isComplete()); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + String h = HEADERS_A + HEADERS_B.formatted(200) + HEADERS_Z; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertFalse(parser.isComplete()); + } catch (IllegalStateException | IOException e) { + } } @Test void testHttpWithZeroContentLength() { - HttpPayloadParser parser = new HttpPayloadParser(); - String h = HEADERS_A + HEADERS_B.formatted(0) + HEADERS_Z; - String hc = h + CONTENT; - parser.accept(hc.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(0, content.length); - byte[] headers = parser.getHeaders(); - assertEquals(h, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + String h = HEADERS_A + HEADERS_B.formatted(0) + HEADERS_Z; + String hc = h + CONTENT; + parser.accept(hc.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(h, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testOk204() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(OK_204.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(0, content.length); - byte[] headers = parser.getHeaders(); - assertEquals(OK_204, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(OK_204.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(OK_204, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testError403() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(ERROR_403.getBytes()); - parser.accept(CHUNK.formatted(0).getBytes()); - parser.accept(CRLF.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(0, content.length); - byte[] headers = parser.getHeaders(); - assertEquals(ERROR_403, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(ERROR_403.getBytes()); + parser.accept(CHUNK.formatted(0).getBytes()); + parser.accept(CRLF.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(ERROR_403, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testError404() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(ERROR_404.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(0, content.length); - byte[] headers = parser.getHeaders(); - assertEquals(ERROR_404, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(ERROR_404.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(ERROR_404, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } @Test void testError500() { - HttpPayloadParser parser = new HttpPayloadParser(); - parser.accept(ERROR_500.getBytes()); - assertTrue(parser.isComplete()); - byte[] content = parser.getContent(); - assertEquals(0, content.length); - byte[] headers = parser.getHeaders(); - assertEquals(ERROR_500, new String(headers)); + try (HttpPayloadParser parser = new HttpPayloadParser()) { + parser.accept(ERROR_500.getBytes()); + assertTrue(parser.isComplete()); + byte[] content = parser.getContent(); + assertEquals(0, content.length); + byte[] headers = parser.getHeaders(); + assertEquals(ERROR_500, new String(headers)); + } catch (IllegalStateException | IOException e) { + } } } From 036553b941c91a23733f1478fc2d1ff308bf9e68 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 23 Nov 2025 16:18:38 +0000 Subject: [PATCH 133/177] differentiate root vs. child accessory thing type ids Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 4 +- .../HomekitChildDiscoveryService.java | 23 +++++------ .../HomekitMdnsDiscoveryParticipant.java | 7 ++-- .../factory/HomekitHandlerFactory.java | 5 ++- .../handler/HomekitBaseAccessoryHandler.java | 18 +++++---- .../resources/OH-INF/i18n/homekit.properties | 39 +++++++++++++------ .../resources/OH-INF/thing/thing-types.xml | 32 ++++++++------- 7 files changed, 77 insertions(+), 51 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 2cd61fb707213..ebb301a1c23b2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -30,7 +30,8 @@ public class HomekitBindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); - public static final ThingTypeUID THING_TYPE_ACCESSORY = new ThingTypeUID(BINDING_ID, "accessory"); + public static final ThingTypeUID THING_TYPE_ROOT_ACCESSORY = new ThingTypeUID(BINDING_ID, "root-accessory"); + public static final ThingTypeUID THING_TYPE_CHILD_ACCESSORY = new ThingTypeUID(BINDING_ID, "child-accessory"); // specific Channel Type UIDs public static final String FAKE_PROPERTY_CHANNEL = "property-fake-channel"; @@ -70,6 +71,7 @@ public class HomekitBindingConstants { // thing properties public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; + public static final String PROPERTY_REPRESENTATION = "representationProperty"; // channel properties public static final String PROPERTY_IID = "iid"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index de9d475922f43..ec881675694a2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -29,7 +29,9 @@ /** * Discovery service component that publishes newly discovered child accessories of a HomeKit bridge accessory. - * Discovered accessories are published with a ThingUID based on their accessory ID (aid) and service ID (iid). + * Discovered devices are published as Things of type + * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_CHILD_ACCESSORY} with a ThingUID + * based on their accessory ID (aid). * * @author Andrew Fiddian-Green - Initial Contribution */ @@ -40,7 +42,7 @@ public class HomekitChildDiscoveryService extends AbstractThingHandlerDiscoveryS private static final int TIMEOUT_SECONDS = 10; public HomekitChildDiscoveryService() { - super(HomekitBridgeHandler.class, Set.of(THING_TYPE_ACCESSORY), TIMEOUT_SECONDS); + super(HomekitBridgeHandler.class, Set.of(THING_TYPE_CHILD_ACCESSORY), TIMEOUT_SECONDS); } @Override @@ -63,19 +65,18 @@ public void startScan() { } private void discoverChildren(Thing bridge, Collection accessories) { + String representationPropertyPrefix = thingHandler.getThing().getConfiguration() + .get(Thing.PROPERTY_MAC_ADDRESS) instanceof String mac ? mac + "-" : ""; accessories.forEach(accessory -> { if (accessory.aid instanceof Long aid && aid != 1L && accessory.services != null) { - ThingUID uid = new ThingUID(THING_TYPE_ACCESSORY, bridge.getUID(), aid.toString()); - String thingLabel = "%s (%d)".formatted(accessory.getAccessoryInstanceLabel(), accessory.aid); + String aidString = aid.toString(); + ThingUID uid = new ThingUID(THING_TYPE_CHILD_ACCESSORY, bridge.getUID(), aidString); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // - .withLabel(THING_LABEL_FMT.formatted(thingLabel, bridge.getLabel())) // - .withProperty(CONFIG_HOST_NAME, "n/a") // - .withProperty(CONFIG_IP_ADDRESS, "n/a") // - .withProperty(Thing.PROPERTY_MAC_ADDRESS, "n/a") // - .withProperty(CONFIG_ACCESSORY_ID, aid.toString()) // - .withProperty(CONFIG_REFRESH_INTERVAL, "60") // - .withRepresentationProperty(CONFIG_ACCESSORY_ID).build()); + .withLabel(THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), bridge.getLabel())) + .withProperty(CONFIG_ACCESSORY_ID, aidString) + .withProperty(PROPERTY_REPRESENTATION, representationPropertyPrefix + aidString) + .withRepresentationProperty(PROPERTY_REPRESENTATION).build()); } }); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index baf73b98d7f99..1a04aea734ab7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -39,7 +39,7 @@ * The device category is also included, allowing differentiation between bridges and accessories. * The discovery participant creates a ThingUID based on the MAC address and device category. * Discovered devices are published as Things of type - * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_ACCESSORY} + * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_ROOT_ACCESSORY} * or {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGE}. * Discovered Things include properties such as model name, protocol version, and IP address. * This class does not perform active scanning; instead, it relies on the central mDNS discovery @@ -55,7 +55,7 @@ public class HomekitMdnsDiscoveryParticipant implements MDNSDiscoveryParticipant @Override public Set getSupportedThingTypeUIDs() { - return Set.of(THING_TYPE_ACCESSORY); + return Set.of(THING_TYPE_BRIDGE, THING_TYPE_ROOT_ACCESSORY); } @Override @@ -90,7 +90,6 @@ public String getServiceType() { .withProperty(CONFIG_IP_ADDRESS, ipAddress) // .withProperty(Thing.PROPERTY_MAC_ADDRESS, macAddress) // .withProperty(PROPERTY_ACCESSORY_CATEGORY, category.toString()) // - .withProperty(CONFIG_ACCESSORY_ID, "1".toString()) // .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); if (properties.get("md") instanceof String model) { @@ -123,7 +122,7 @@ public String getServiceType() { } if (mac != null && cat != null) { - return new ThingUID(AccessoryCategory.BRIDGE == cat ? THING_TYPE_BRIDGE : THING_TYPE_ACCESSORY, + return new ThingUID(AccessoryCategory.BRIDGE == cat ? THING_TYPE_BRIDGE : THING_TYPE_ROOT_ACCESSORY, mac.replace(":", "").toLowerCase()); // thing id example "a1b2c3d4e5f6" } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index f0920cac50bb4..9a5984a9ac1f7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -46,7 +46,8 @@ @Component(service = ThingHandlerFactory.class) public class HomekitHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_ACCESSORY); + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, + THING_TYPE_CHILD_ACCESSORY, THING_TYPE_ROOT_ACCESSORY); private final HomekitTypeProvider typeProvider; private final ChannelTypeRegistry channelTypeRegistry; @@ -78,7 +79,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { return new HomekitBridgeHandler((Bridge) thing, typeProvider, keyStore, i18nProvider, bundle); - } else if (THING_TYPE_ACCESSORY.equals(thingTypeUID)) { + } else if (THING_TYPE_CHILD_ACCESSORY.equals(thingTypeUID) || THING_TYPE_ROOT_ACCESSORY.equals(thingTypeUID)) { return new HomekitAccessoryHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry, keyStore, i18nProvider, bundle); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index c1d4c2dcb7953..e528776fb00ee 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -242,19 +242,23 @@ private void processDependentThings() { } /** - * Returns the accessory ID from the 'AccessoryID' configuration parameter. + * Returns the accessory ID. For bridges and root accessories this is always 1. Whereas for child + * accessories it comes from the thing's configuration parameter value. * * @return the accessory ID, or null if it cannot be determined */ protected @Nullable Long getAccessoryId() { - if (getConfig().get(CONFIG_ACCESSORY_ID) instanceof BigDecimal accessoryId) { - try { - return accessoryId.longValue(); - } catch (NumberFormatException e) { + if (THING_TYPE_CHILD_ACCESSORY.equals(thing.getThingTypeUID())) { + if (getConfig().get(CONFIG_ACCESSORY_ID) instanceof BigDecimal accessoryId) { + try { + return accessoryId.longValue(); + } catch (NumberFormatException e) { + } } + logger.debug("{} missing or invalid accessory id", thing.getUID()); + return null; } - logger.debug("{} missing or invalid accessory id", thing.getUID()); - return null; + return 1L; } @Override diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 1bc014cce828b..704d712e93b9d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -5,10 +5,33 @@ addon.homekit.description = This is the binding for a HomeKit client. # thing types -thing-type.homekit.accessory.label = HomeKit Device -thing-type.homekit.accessory.description = HomeKit Accessory Device thing-type.homekit.bridge.label = HomeKit Bridge -thing-type.homekit.bridge.description = HomeKit Accessory Bridge +thing-type.homekit.bridge.description = HomeKit Bridge +thing-type.homekit.child-accessory.label = HomeKit Device +thing-type.homekit.child-accessory.description = HomeKit Child Accessory Device +thing-type.homekit.root-accessory.label = HomeKit Device +thing-type.homekit.root-accessory.description = HomeKit Root Accessory Device + +# thing types config + +thing-type.config.homekit.bridge.hostName.label = Host Name +thing-type.config.homekit.bridge.hostName.description = The bridge fully qualified host name as discovered by mDNS. +thing-type.config.homekit.bridge.ipAddress.label = IP Address +thing-type.config.homekit.bridge.ipAddress.description = IP v4 address of the HomeKit bridge. +thing-type.config.homekit.bridge.macAddress.label = MAC Address +thing-type.config.homekit.bridge.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval +thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the bridge is polled in sec. +thing-type.config.homekit.child-accessory.accessoryID.label = Accessory ID +thing-type.config.homekit.child-accessory.accessoryID.description = ID of the accessory. +thing-type.config.homekit.root-accessory.hostName.label = Host Name +thing-type.config.homekit.root-accessory.hostName.description = The accessory fully qualified host name as discovered by mDNS. +thing-type.config.homekit.root-accessory.ipAddress.label = IP Address +thing-type.config.homekit.root-accessory.ipAddress.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.root-accessory.macAddress.label = MAC Address +thing-type.config.homekit.root-accessory.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.root-accessory.refreshInterval.label = Refresh Interval +thing-type.config.homekit.root-accessory.refreshInterval.description = Interval at which the accessory is polled in sec. # thing types config @@ -24,14 +47,6 @@ thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. thing-type.config.homekit.bridge.accessoryID.label = Accessory ID thing-type.config.homekit.bridge.accessoryID.description = ID of the accessory. -thing-type.config.homekit.bridge.hostName.label = Host Name -thing-type.config.homekit.bridge.hostName.description = The bridge fully qualified host name as discovered by mDNS. -thing-type.config.homekit.bridge.ipAddress.label = IP Address -thing-type.config.homekit.bridge.ipAddress.description = IP v4 address of the HomeKit bridge. -thing-type.config.homekit.bridge.macAddress.label = MAC Address -thing-type.config.homekit.bridge.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval -thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the bridge is polled in sec. # thing error state messages @@ -55,7 +70,7 @@ actions.pairing-auth.label = With External Authentication actions.pairing-auth.description = Set 'true' if pairing requires external authentication e.g. from an app (default false). actions.pairing-code.label = Pairing Code actions.pairing-code.description = The 8 digit pairing code of the HomeKit accessory or bridge e.g. XXX-XX-XXX or XXXX-XXXX. -actions.pairing-result.label = Pairing Result +actions.pairing-result.label = Pairing Result actions.pairing-result.description = The message describes the result of the pairing attempt. actions.unpairing-action.label = Unpair Accessory or Bridge actions.unpairing-action.description = Remove the pairing between this thing and the respective accessory or bridge. diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index c4e4289d81523..6e4eeaf6fa16c 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -4,9 +4,9 @@ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0" xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd"> - + - HomeKit Accessory Device + HomeKit Root Accessory Device network-address @@ -23,11 +23,6 @@ The accessory fully qualified host name as discovered by mDNS. true - - - ID of the accessory. - true - Interval at which the accessory is polled in sec. @@ -37,10 +32,9 @@ - - HomeKit Accessory Bridge + HomeKit Bridge network-address @@ -57,11 +51,6 @@ The bridge fully qualified host name as discovered by mDNS. true - - - ID of the accessory. - true - Interval at which the bridge is polled in sec. @@ -71,4 +60,19 @@ + + + + + + HomeKit Child Accessory Device + + + + ID of the accessory. + true + + + + From 605e41c44f91ad674e8e042ed557cb17d29a154b Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 23 Nov 2025 16:52:36 +0000 Subject: [PATCH 134/177] adjust readme Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 71 +++++++++---------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 81ce086d9333a..30b588974cf30 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -5,31 +5,30 @@ Do not confuse this with the other HomeKit **integration** (https://www.openhab. ## Supported Things -There are two types of Things supported: +There are three types of Things supported: -- `accessory`: This integrates a single HomeKit accessory, whereby its services appear as channel groups, and the respective characteristics appear as channels. -- `bridge`: This integrates a HomeKit bridge accessory containing multiple child `accessory` Things. - So Things of type `accessory` either represent a stand-alone accessories or a child of a `bridge` Thing. +- `root-accessory`: This integrates a single HomeKit accessory, whereby its services appear as channel groups, and the respective characteristics appear as channels. +- `child-accessory`: This has similar functionality to a `root-accessory`, except the communication is done via the `bridge` (see below). +- `bridge`: This integrates a HomeKit bridge accessory containing multiple `child-accessory` Things. -Things of type `bridge` and stand-alone `accessory` Things both communicate directly with their HomeKit device over the LAN. -Whereas child `accessory` Things communicate via their respective `bridge` Thing. +Things of type `bridge` and `root-accessory` both communicate directly with their HomeKit device over the LAN. +Whereas child `child-accessory` Things communicate via their respective `bridge` Thing. ## Discovery -Both `bridge` and stand-alone `accessory` Things will be auto discovered via mDNS. -Once a `bridge` Thing has been instantiated and paired, its child `accessory` Things will also be auto- discovered. +Both `root-accessory` and `bridge` Things will be auto- discovered via mDNS. +And once a `bridge` Thing has been instantiated and paired, its `child-accessory` Things will also be auto- discovered. -## Thing Configuration +## Configuration for Bridge and Root Accessory Things -The following table shows the thing configuration parameters. +The following table shows the thing configuration parameters for `bridge` and `root-accessory` Things. -| Name | Type | Description | Default | Required | Advanced | -|-------------------|---------|------------------------------------------------------|---------|-----------|-----------| -| `ipAddress` | text | IP v4 address of the HomeKit accessory. | N/A | see below | no | -| `hostName` | text | The fully qualified host name as discovered by mDNS. | N/A | see below | yes | -| `macAddress` | text | Unique accessory identifier. | N/A | see below | yes | -| `accessoryID` | integer | ID of the accessory. | N/A | see below | yes | -| `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | +| Name | Type | Description | Default | Required | Advanced | +|-------------------|---------|------------------------------------------------------|-----------|----------|----------| +| `ipAddress` | text | IP v4 address of the HomeKit accessory. | see below | yes | yes | +| `hostName` | text | The fully qualified host name as discovered by mDNS. | see below | yes | yes | +| `macAddress` | text | Unique accessory identifier. | see below | yes | yes | +| `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | NOTE: as a general rule, if you create the things via the Inbox, then all of the above configuration parameters will have their proper values already preset. @@ -39,31 +38,27 @@ It must match the format `123.123.123.123:4567` representing its IP v4 address a As a general rule, `hostName` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. -It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.:1234` ) as found manually via (say) an mDNS discovery app. +It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.` ) as found manually via (say) an mDNS discovery app. As a general rule, `macAddress` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. It must be the unique accessory identifier as found manually via (say) an mDNS discovery app. -As a general rule, `accessoryID` is set by the mDNS auto- discovery process, or child discovery process. -However you can configure it manually if you wish. -It must be the ID of the accessory within the bridge, or `1` if it is a root accessory. +### Configuration for Child Accessory Things -### Thing Configuration for Child Accessories of a Bridge +The following table shows the thing configuration parameters for `child-accessory` Things. -Child accessories are `accessory` things which are hosted by a bridge. -Such accessories do not have an own Internet connetion, so all communications are handled by the bridge. -Therefore the following preset values are applied (and changing these values has no impact): +| Name | Type | Description | Default | Required | Advanced | +|-------------------|---------|------------------------------------------------------|-----------|----------|----------| +| `accessoryID` | integer | ID of the accessory. | see below | yes | yes | -- The only *required* parameter is `accessoryID` so it MUST have the correct value. -- The `ipAddress` parameter is not used so it is preset to `n/a`. -- The `hostName` parameter is not used so it is preset to `n/a`. -- The `macAddress` parameter is not used so it is preset to `n/a`. -- The `refreshInterval` parameter is not used so it is preset to `60`. +As a general rule, `accessoryID` is set by the child auto- discovery process. +However you can configure it manually if you wish. +It must be the ID of the accessory within the `bridge`. ## Thing Pairing -The `bridge` and stand-alone `accessory` Things need to be paired with their respective HomeKit accessories. +The `bridge` and `root-accessory` Things need to be paired with their respective HomeKit accessories. This requires entering the HomeKit pairing code by means of a Thing Action. Note that HomeKit accessories can only be paired with one controller, so if it is already paired with something else, you will need to remove that pairing first. @@ -84,7 +79,7 @@ Whereas for case 2. above, must be `ON`. ## Channels -Channels are auto-created depending on the services and characteristics published by the HomeKit accessory. +For `root-accessory` and `child-accessory` Things, the Channels are auto-created depending on the services and characteristics published by the HomeKit accessory. As a general rule openHAB has one channel for each HomeKit charactersitic. Some HomeKit accessories have separate charactersitics for 'target' and 'current' states. @@ -105,20 +100,20 @@ So the thing creates one additional `HSBType` channel that amalgamates hue, satu ### Thing Configuration Things are automatically configured when they are discovered. -So for this reason it is extremely difficult to create Things via a '.things' file, and is therefore not recommended. +So for this reason it is difficult to create Things via a '.things' file, and therefore not recommended. ```java -Bridge homekit:bridge:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", accessoryID=1 ] { - Thing accessory 2 "VELUX Sensor" @ "Hallway" [ host="n/a", accessoryID=2 ] - Thing accessory 3 "VELUX Window" @ "Hallway" [ host="n/a", accessoryID=3 ] - Thing accessory 4 "VELUX Window" @ "Small bathroom" [ host="n/a", accessoryID=4 ] +Bridge homekit:bridge:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", hostName="foobar._hap._tcp.local.", refreshInterval=60 ] { + Thing accessory 2 "VELUX Sensor" @ "Hallway" [ accessoryID=2 ] + Thing accessory 3 "VELUX Window" @ "Hallway" [ accessoryID=3 ] + Thing accessory 4 "VELUX Window" @ "Small bathroom" [ accessoryID=4 ] } ``` ### Item Configuration ```java -Number:Temperature Color_Temperature "Color Temperature [%.1f mired]" [ColorTemperature, Setpoint] { channel="homekit:accessory:297b703df234:lightbulb#color-temperature", unit="mired" } +Number:Temperature Color_Temperature "Color Temperature [%.1f mired]" [ColorTemperature, Setpoint] { channel="homekit:root-accessory:297b703df234:lightbulb#color-temperature", unit="mired" } ``` ### Sitemap Configuration From 181328f929bfd089d0e1ec7d258ba548e3216352 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 23 Nov 2025 16:55:35 +0000 Subject: [PATCH 135/177] oops Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 30b588974cf30..7fc83558c4fbe 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -38,7 +38,7 @@ It must match the format `123.123.123.123:4567` representing its IP v4 address a As a general rule, `hostName` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. -It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.` ) as found manually via (say) an mDNS discovery app. +It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.:1234` ) as found manually via (say) an mDNS discovery app. As a general rule, `macAddress` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. From 509ab0687b58783201e5d616cf51aa6c339df21e Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 23 Nov 2025 18:42:53 +0000 Subject: [PATCH 136/177] simplify child check Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitBaseAccessoryHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index e528776fb00ee..e28d2b1491715 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -248,7 +248,7 @@ private void processDependentThings() { * @return the accessory ID, or null if it cannot be determined */ protected @Nullable Long getAccessoryId() { - if (THING_TYPE_CHILD_ACCESSORY.equals(thing.getThingTypeUID())) { + if (isChildAccessory) { if (getConfig().get(CONFIG_ACCESSORY_ID) instanceof BigDecimal accessoryId) { try { return accessoryId.longValue(); From bc2ebdc6fc148f09339216b686365603868ff03a Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 24 Nov 2025 10:48:05 +0000 Subject: [PATCH 137/177] rename thing type ids Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 36 ++++++----- .../internal/HomekitBindingConstants.java | 4 +- .../HomekitMdnsDiscoveryParticipant.java | 9 +-- .../factory/HomekitHandlerFactory.java | 8 +-- .../resources/OH-INF/i18n/homekit.properties | 59 +++++++------------ .../resources/OH-INF/thing/thing-types.xml | 18 +++--- 6 files changed, 62 insertions(+), 72 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 7fc83558c4fbe..c99a8caf59516 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -7,21 +7,24 @@ Do not confuse this with the other HomeKit **integration** (https://www.openhab. There are three types of Things supported: -- `root-accessory`: This integrates a single HomeKit accessory, whereby its services appear as channel groups, and the respective characteristics appear as channels. -- `child-accessory`: This has similar functionality to a `root-accessory`, except the communication is done via the `bridge` (see below). -- `bridge`: This integrates a HomeKit bridge accessory containing multiple `child-accessory` Things. +- `lan-accessory`: This integrates a single HomeKit accessory, which has its own LAN connection. + Its services appear as channel groups, and their respective characteristics appear as channels. +- `child-accessory`: This integrates a single HomeKit accessory, which does NOT have its own LAN connection. + It has the same functionality as a `lan-accessory`, except that its communication is done via a `bridge-accessory` (see below). +- `bridge-accessory`: This integrates a HomeKit bridge accessory, which has its own LAN connection. + It does not have any own channels. But instead it contains multiple `child-accessory` Things (see above). -Things of type `bridge` and `root-accessory` both communicate directly with their HomeKit device over the LAN. -Whereas child `child-accessory` Things communicate via their respective `bridge` Thing. +Things of type `bridge-accessory` and `lan-accessory` both communicate directly with their HomeKit accessory device via the LAN. +Whereas child `child-accessory` Things communicate via their respective `bridge-accessory` Thing. ## Discovery -Both `root-accessory` and `bridge` Things will be auto- discovered via mDNS. -And once a `bridge` Thing has been instantiated and paired, its `child-accessory` Things will also be auto- discovered. +Both `lan-accessory` and `bridge-accessory` Things will be auto- discovered via mDNS. +And once a `bridge-accessory` Thing has been instantiated and paired, its `child-accessory` Things will also be auto- discovered. ## Configuration for Bridge and Root Accessory Things -The following table shows the thing configuration parameters for `bridge` and `root-accessory` Things. +The following table shows the thing configuration parameters for `bridge-accessory` and `lan-accessory` Things. | Name | Type | Description | Default | Required | Advanced | |-------------------|---------|------------------------------------------------------|-----------|----------|----------| @@ -54,11 +57,11 @@ The following table shows the thing configuration parameters for `child-accessor As a general rule, `accessoryID` is set by the child auto- discovery process. However you can configure it manually if you wish. -It must be the ID of the accessory within the `bridge`. +It must be the ID of the accessory within the `bridge-accessory`. ## Thing Pairing -The `bridge` and `root-accessory` Things need to be paired with their respective HomeKit accessories. +The `bridge-accessory` and `lan-accessory` Things need to be paired with their respective HomeKit accessories. This requires entering the HomeKit pairing code by means of a Thing Action. Note that HomeKit accessories can only be paired with one controller, so if it is already paired with something else, you will need to remove that pairing first. @@ -79,7 +82,8 @@ Whereas for case 2. above, must be `ON`. ## Channels -For `root-accessory` and `child-accessory` Things, the Channels are auto-created depending on the services and characteristics published by the HomeKit accessory. +For `lan-accessory` and `child-accessory` Things, the Channels are auto-created depending on the services and characteristics published by the HomeKit accessory. +Things of type `bridge-accessory` do not have own channels. As a general rule openHAB has one channel for each HomeKit charactersitic. Some HomeKit accessories have separate charactersitics for 'target' and 'current' states. @@ -103,17 +107,17 @@ Things are automatically configured when they are discovered. So for this reason it is difficult to create Things via a '.things' file, and therefore not recommended. ```java -Bridge homekit:bridge:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", hostName="foobar._hap._tcp.local.", refreshInterval=60 ] { - Thing accessory 2 "VELUX Sensor" @ "Hallway" [ accessoryID=2 ] - Thing accessory 3 "VELUX Window" @ "Hallway" [ accessoryID=3 ] - Thing accessory 4 "VELUX Window" @ "Small bathroom" [ accessoryID=4 ] +Bridge homekit:bridge-accessory:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", hostName="foobar._hap._tcp.local.", refreshInterval=60 ] { + Thing accessory2 "VELUX Sensor" @ "Hallway" [ accessoryID=2 ] + Thing accessory3 "VELUX Window" @ "Hallway" [ accessoryID=3 ] + Thing accessory4 "VELUX Window" @ "Small bathroom" [ accessoryID=4 ] } ``` ### Item Configuration ```java -Number:Temperature Color_Temperature "Color Temperature [%.1f mired]" [ColorTemperature, Setpoint] { channel="homekit:root-accessory:297b703df234:lightbulb#color-temperature", unit="mired" } +Number:Temperature Color_Temperature "Color Temperature [%.1f mired]" [ColorTemperature, Setpoint] { channel="homekit:lan-accessory:297b703df234:lightbulb#color-temperature", unit="mired" } ``` ### Sitemap Configuration diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index ebb301a1c23b2..2dd33fccccbbb 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -29,9 +29,9 @@ public class HomekitBindingConstants { public static final String BINDING_ID = "homekit"; // List of all Thing Type UIDs - public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); - public static final ThingTypeUID THING_TYPE_ROOT_ACCESSORY = new ThingTypeUID(BINDING_ID, "root-accessory"); + public static final ThingTypeUID THING_TYPE_LAN_ACCESSORY = new ThingTypeUID(BINDING_ID, "lan-accessory"); public static final ThingTypeUID THING_TYPE_CHILD_ACCESSORY = new ThingTypeUID(BINDING_ID, "child-accessory"); + public static final ThingTypeUID THING_TYPE_BRIDGE_ACCESSORY = new ThingTypeUID(BINDING_ID, "bridge-accessory"); // specific Channel Type UIDs public static final String FAKE_PROPERTY_CHANNEL = "property-fake-channel"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 1a04aea734ab7..88fea2824e9a3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -39,8 +39,8 @@ * The device category is also included, allowing differentiation between bridges and accessories. * The discovery participant creates a ThingUID based on the MAC address and device category. * Discovered devices are published as Things of type - * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_ROOT_ACCESSORY} - * or {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGE}. + * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_LAN_ACCESSORY} + * or {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGE_ACCESSORY}. * Discovered Things include properties such as model name, protocol version, and IP address. * This class does not perform active scanning; instead, it relies on the central mDNS discovery * service to notify it of new services. @@ -55,7 +55,7 @@ public class HomekitMdnsDiscoveryParticipant implements MDNSDiscoveryParticipant @Override public Set getSupportedThingTypeUIDs() { - return Set.of(THING_TYPE_BRIDGE, THING_TYPE_ROOT_ACCESSORY); + return Set.of(THING_TYPE_BRIDGE_ACCESSORY, THING_TYPE_LAN_ACCESSORY); } @Override @@ -122,7 +122,8 @@ public String getServiceType() { } if (mac != null && cat != null) { - return new ThingUID(AccessoryCategory.BRIDGE == cat ? THING_TYPE_BRIDGE : THING_TYPE_ROOT_ACCESSORY, + return new ThingUID( + AccessoryCategory.BRIDGE == cat ? THING_TYPE_BRIDGE_ACCESSORY : THING_TYPE_LAN_ACCESSORY, mac.replace(":", "").toLowerCase()); // thing id example "a1b2c3d4e5f6" } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index 9a5984a9ac1f7..f4dae557eb1de 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -46,8 +46,8 @@ @Component(service = ThingHandlerFactory.class) public class HomekitHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, - THING_TYPE_CHILD_ACCESSORY, THING_TYPE_ROOT_ACCESSORY); + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE_ACCESSORY, + THING_TYPE_CHILD_ACCESSORY, THING_TYPE_LAN_ACCESSORY); private final HomekitTypeProvider typeProvider; private final ChannelTypeRegistry channelTypeRegistry; @@ -77,9 +77,9 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { @Override protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); - if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { + if (THING_TYPE_BRIDGE_ACCESSORY.equals(thingTypeUID)) { return new HomekitBridgeHandler((Bridge) thing, typeProvider, keyStore, i18nProvider, bundle); - } else if (THING_TYPE_CHILD_ACCESSORY.equals(thingTypeUID) || THING_TYPE_ROOT_ACCESSORY.equals(thingTypeUID)) { + } else if (THING_TYPE_CHILD_ACCESSORY.equals(thingTypeUID) || THING_TYPE_LAN_ACCESSORY.equals(thingTypeUID)) { return new HomekitAccessoryHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry, keyStore, i18nProvider, bundle); } diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 704d712e93b9d..9a965a29c82dc 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -5,48 +5,33 @@ addon.homekit.description = This is the binding for a HomeKit client. # thing types -thing-type.homekit.bridge.label = HomeKit Bridge -thing-type.homekit.bridge.description = HomeKit Bridge -thing-type.homekit.child-accessory.label = HomeKit Device -thing-type.homekit.child-accessory.description = HomeKit Child Accessory Device -thing-type.homekit.root-accessory.label = HomeKit Device -thing-type.homekit.root-accessory.description = HomeKit Root Accessory Device +thing-type.homekit.bridge-accessory.label = HomeKit LAN Bridge Accessory +thing-type.homekit.bridge-accessory.description = HomeKit accessory with LAN connection for support of child accessories +thing-type.homekit.child-accessory.label = HomeKit Child Accessory +thing-type.homekit.child-accessory.description = HomeKit child of a bridge accessory without own LAN connection +thing-type.homekit.lan-accessory.label = HomeKit LAN Accessory +thing-type.homekit.lan-accessory.description = HomeKit accessory with own LAN connection # thing types config -thing-type.config.homekit.bridge.hostName.label = Host Name -thing-type.config.homekit.bridge.hostName.description = The bridge fully qualified host name as discovered by mDNS. -thing-type.config.homekit.bridge.ipAddress.label = IP Address -thing-type.config.homekit.bridge.ipAddress.description = IP v4 address of the HomeKit bridge. -thing-type.config.homekit.bridge.macAddress.label = MAC Address -thing-type.config.homekit.bridge.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval -thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the bridge is polled in sec. +thing-type.config.homekit.bridge-accessory.hostName.label = Host Name +thing-type.config.homekit.bridge-accessory.hostName.description = The bridge fully qualified host name as discovered by mDNS. +thing-type.config.homekit.bridge-accessory.ipAddress.label = IP Address +thing-type.config.homekit.bridge-accessory.ipAddress.description = IP v4 address of the HomeKit bridge. +thing-type.config.homekit.bridge-accessory.macAddress.label = MAC Address +thing-type.config.homekit.bridge-accessory.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.bridge-accessory.refreshInterval.label = Refresh Interval +thing-type.config.homekit.bridge-accessory.refreshInterval.description = Interval at which the bridge is polled in sec. thing-type.config.homekit.child-accessory.accessoryID.label = Accessory ID thing-type.config.homekit.child-accessory.accessoryID.description = ID of the accessory. -thing-type.config.homekit.root-accessory.hostName.label = Host Name -thing-type.config.homekit.root-accessory.hostName.description = The accessory fully qualified host name as discovered by mDNS. -thing-type.config.homekit.root-accessory.ipAddress.label = IP Address -thing-type.config.homekit.root-accessory.ipAddress.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.root-accessory.macAddress.label = MAC Address -thing-type.config.homekit.root-accessory.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.root-accessory.refreshInterval.label = Refresh Interval -thing-type.config.homekit.root-accessory.refreshInterval.description = Interval at which the accessory is polled in sec. - -# thing types config - -thing-type.config.homekit.accessory.accessoryID.label = Accessory ID -thing-type.config.homekit.accessory.accessoryID.description = ID of the accessory. -thing-type.config.homekit.accessory.hostName.label = Host Name -thing-type.config.homekit.accessory.hostName.description = The accessory fully qualified host name as discovered by mDNS. -thing-type.config.homekit.accessory.ipAddress.label = IP Address -thing-type.config.homekit.accessory.ipAddress.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.accessory.macAddress.label = MAC Address -thing-type.config.homekit.accessory.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval -thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. -thing-type.config.homekit.bridge.accessoryID.label = Accessory ID -thing-type.config.homekit.bridge.accessoryID.description = ID of the accessory. +thing-type.config.homekit.lan-accessory.hostName.label = Host Name +thing-type.config.homekit.lan-accessory.hostName.description = The accessory fully qualified host name as discovered by mDNS. +thing-type.config.homekit.lan-accessory.ipAddress.label = IP Address +thing-type.config.homekit.lan-accessory.ipAddress.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.lan-accessory.macAddress.label = MAC Address +thing-type.config.homekit.lan-accessory.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.lan-accessory.refreshInterval.label = Refresh Interval +thing-type.config.homekit.lan-accessory.refreshInterval.description = Interval at which the accessory is polled in sec. # thing error state messages diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 6e4eeaf6fa16c..2b04ed239acb1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -4,9 +4,9 @@ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0" xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd"> - - - HomeKit Root Accessory Device + + + HomeKit accessory with own LAN connection network-address @@ -32,9 +32,9 @@ - - - HomeKit Bridge + + + HomeKit accessory with LAN connection for support of child accessories network-address @@ -62,10 +62,10 @@ - + - - HomeKit Child Accessory Device + + HomeKit child of a bridge accessory without own LAN connection From 03ad48870aac597f147603277a1c3c42fdfa0361 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 24 Nov 2025 13:16:09 +0000 Subject: [PATCH 138/177] minor tweaks to readme and thing xml Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 4 ++-- .../src/main/resources/OH-INF/thing/thing-types.xml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index c99a8caf59516..0879d5b5aa1b9 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -22,7 +22,7 @@ Whereas child `child-accessory` Things communicate via their respective `bridge- Both `lan-accessory` and `bridge-accessory` Things will be auto- discovered via mDNS. And once a `bridge-accessory` Thing has been instantiated and paired, its `child-accessory` Things will also be auto- discovered. -## Configuration for Bridge and Root Accessory Things +## Configuration for `bridge-accessory` and `lan-accessory` Things The following table shows the thing configuration parameters for `bridge-accessory` and `lan-accessory` Things. @@ -47,7 +47,7 @@ As a general rule, `macAddress` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. It must be the unique accessory identifier as found manually via (say) an mDNS discovery app. -### Configuration for Child Accessory Things +### Configuration for `child-accessory` Things The following table shows the thing configuration parameters for `child-accessory` Things. diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 2b04ed239acb1..9a096844c4a49 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -12,6 +12,7 @@ network-address IP v4 address of the HomeKit accessory. + true @@ -40,6 +41,7 @@ network-address IP v4 address of the HomeKit bridge. + true From d3f4384eb7e586f49ad4a069858739d83e5d2b6d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 24 Nov 2025 16:53:29 +0000 Subject: [PATCH 139/177] discovery creates unique thing labels Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 18 +++++++++++------- .../HomekitChildDiscoveryService.java | 18 +++++++++++------- .../HomekitMdnsDiscoveryParticipant.java | 2 +- .../homekit/internal/dto/Characteristic.java | 2 +- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 2dd33fccccbbb..3424d53f35381 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -52,15 +52,19 @@ public class HomekitBindingConstants { */ public static final String CHANNEL_TYPE_ID_FMT = "channel-type-%s-%d-%s-%s"; - /* - * format string for channel-definition IDs which are used to instantiate channels - * format: [characteristicIdentifier]-[characteristicIid] - * example: occupancy-detected-2694 + /** + * format string for channel-definition IDs like '[characteristicIdentifier]-[characteristicIid]' + * used to instantiate channels and labels like '[thingName]-[accessoryAid]' used to discover + * things; examples: + *

            + *
          • occupancy-detected-2694
          • + *
          • 11:22:33:44:55:66-1234
          • + *
          */ - public static final String CHANNEL_DEFINITION_ID_FMT = "%s-%d"; + public static final String STRING_AID_FMT = "%s-%d"; - // labels - public static final String THING_LABEL_FMT = "%s on %s"; + // labels for things e.g. 'Living Room Light (11:22:33:44:55:66-1234)' + public static final String THING_LABEL_FMT = "%s (%s)"; // configuration parameters public static final String CONFIG_HOST_NAME = "hostName"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index ec881675694a2..786847cf3d44e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -65,17 +65,21 @@ public void startScan() { } private void discoverChildren(Thing bridge, Collection accessories) { - String representationPropertyPrefix = thingHandler.getThing().getConfiguration() - .get(Thing.PROPERTY_MAC_ADDRESS) instanceof String mac ? mac + "-" : ""; + String bridgeMacAddress = thingHandler.getThing().getConfiguration() + .get(Thing.PROPERTY_MAC_ADDRESS) instanceof String mac ? mac : null; + if (bridgeMacAddress == null) { + return; + } accessories.forEach(accessory -> { if (accessory.aid instanceof Long aid && aid != 1L && accessory.services != null) { - String aidString = aid.toString(); - ThingUID uid = new ThingUID(THING_TYPE_CHILD_ACCESSORY, bridge.getUID(), aidString); + ThingUID uid = new ThingUID(THING_TYPE_CHILD_ACCESSORY, bridge.getUID(), aid.toString()); + String uniqueId = STRING_AID_FMT.formatted(bridgeMacAddress, aid); + String label = THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), uniqueId); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // - .withLabel(THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), bridge.getLabel())) - .withProperty(CONFIG_ACCESSORY_ID, aidString) - .withProperty(PROPERTY_REPRESENTATION, representationPropertyPrefix + aidString) + .withLabel(label) // + .withProperty(CONFIG_ACCESSORY_ID, aid.toString()) // + .withProperty(PROPERTY_REPRESENTATION, uniqueId) .withRepresentationProperty(PROPERTY_REPRESENTATION).build()); } }); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 88fea2824e9a3..d58a8bb65b2a3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -85,7 +85,7 @@ public String getServiceType() { if (ipAddress != null && macAddress != null && category != null) { DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); - builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), ipAddress)) // + builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), macAddress)) // .withProperty(CONFIG_HOST_NAME, getHostName(service)) // .withProperty(CONFIG_IP_ADDRESS, ipAddress) // .withProperty(Thing.PROPERTY_MAC_ADDRESS, macAddress) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 974615da2dba2..38e9081088465 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -910,7 +910,7 @@ public class Characteristic { Optional.ofNullable(format).ifPresent(s -> props.put(PROPERTY_FORMAT, s)); Optional.ofNullable(dataType).ifPresent(s -> props.put(PROPERTY_DATA_TYPE, s)); - String channelDefinitionIdentifier = CHANNEL_DEFINITION_ID_FMT.formatted(charactersticIdentifier, iid); + String channelDefinitionIdentifier = STRING_AID_FMT.formatted(charactersticIdentifier, iid); ChannelDefinitionBuilder channelDefBuilder = new ChannelDefinitionBuilder(channelDefinitionIdentifier, channelTypeUid).withLabel(getChannelLabel(characteristicType, i18nProvider, bundle)) From 59bc7a3e503ba9e0b3d49e1d9e4b938f2e423b29 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 25 Nov 2025 13:46:55 +0000 Subject: [PATCH 140/177] adopt reviewer suggestions Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 55 ++-- .../internal/HomekitBindingConstants.java | 6 +- .../action/HomekitPairingActions.java | 4 +- .../HomekitChildDiscoveryService.java | 6 +- .../HomekitMdnsDiscoveryParticipant.java | 9 +- .../homekit/internal/dto/Accessories.java | 4 +- .../homekit/internal/dto/Characteristic.java | 20 +- .../enums/AccessoryPairingFeature.java | 15 +- .../internal/enums/CharacteristicType.java | 271 +++++++++--------- .../homekit/internal/enums/ServiceType.java | 107 +++---- .../factory/HomekitHandlerFactory.java | 8 +- .../handler/HomekitAccessoryHandler.java | 2 +- .../resources/OH-INF/i18n/homekit.properties | 48 ++-- .../resources/OH-INF/thing/thing-types.xml | 20 +- 14 files changed, 305 insertions(+), 270 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 0879d5b5aa1b9..7d1212cd293e4 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -1,30 +1,31 @@ # HomeKit Binding This binding allows pairing with HomeKit accessories and **imports** their services as channel groups and their respective service- characteristics as channels. -Do not confuse this with the other HomeKit **integration** (https://www.openhab.org/addons/integrations/homekit/) which **exports** openHAB Items to a HomeKit controller. +Do not confuse this with the [HomeKit system integration](https://www.openhab.org/addons/integrations/homekit/) which **exports** openHAB Items to a HomeKit controller. ## Supported Things There are three types of Things supported: -- `lan-accessory`: This integrates a single HomeKit accessory, which has its own LAN connection. +- `accessory`: This integrates a single HomeKit accessory, which has its own LAN connection. Its services appear as channel groups, and their respective characteristics appear as channels. -- `child-accessory`: This integrates a single HomeKit accessory, which does NOT have its own LAN connection. - It has the same functionality as a `lan-accessory`, except that its communication is done via a `bridge-accessory` (see below). -- `bridge-accessory`: This integrates a HomeKit bridge accessory, which has its own LAN connection. - It does not have any own channels. But instead it contains multiple `child-accessory` Things (see above). +- `bridged-accessory`: This integrates a single HomeKit accessory, which does NOT have its own LAN connection. + It has the same functionality as an `accessory`, except that its communication is done via a `bridge` (see below). +- `bridge`: This integrates a HomeKit bridge accessory, which has its own LAN connection. + It does not have any own channels. + Instead it contains multiple `bridged-accessory` Things (see above). -Things of type `bridge-accessory` and `lan-accessory` both communicate directly with their HomeKit accessory device via the LAN. -Whereas child `child-accessory` Things communicate via their respective `bridge-accessory` Thing. +Things of type `bridge` and `accessory` both communicate directly with their HomeKit accessory device via the LAN. +Whereas child `bridged-accessory` Things communicate via their respective `bridge` Thing. ## Discovery -Both `lan-accessory` and `bridge-accessory` Things will be auto- discovered via mDNS. -And once a `bridge-accessory` Thing has been instantiated and paired, its `child-accessory` Things will also be auto- discovered. +Both `accessory` and `bridge` Things will be auto- discovered via mDNS. +And once a `bridge` Thing has been instantiated and paired, its `bridged-accessory` Things will also be auto- discovered. -## Configuration for `bridge-accessory` and `lan-accessory` Things +## Configuration for `bridge` and `accessory` Things -The following table shows the thing configuration parameters for `bridge-accessory` and `lan-accessory` Things. +The following table shows the thing configuration parameters for `bridge` and `accessory` Things. | Name | Type | Description | Default | Required | Advanced | |-------------------|---------|------------------------------------------------------|-----------|----------|----------| @@ -39,29 +40,29 @@ As a general rule `ipAddress` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. It must match the format `123.123.123.123:4567` representing its IP v4 address and port. -As a general rule, `hostName` is set by the mDNS auto- discovery process. +As a general rule `hostName` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. -It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.:1234` ) as found manually via (say) an mDNS discovery app. +It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.:1234`) as found manually via (say) an mDNS discovery app. -As a general rule, `macAddress` is set by the mDNS auto- discovery process. +As a general rule `macAddress` is set by the mDNS auto- discovery process. However you can configure it manually if you wish. It must be the unique accessory identifier as found manually via (say) an mDNS discovery app. -### Configuration for `child-accessory` Things +### Configuration for `bridged-accessory` Things -The following table shows the thing configuration parameters for `child-accessory` Things. +The following table shows the thing configuration parameters for `bridged-accessory` Things. | Name | Type | Description | Default | Required | Advanced | |-------------------|---------|------------------------------------------------------|-----------|----------|----------| | `accessoryID` | integer | ID of the accessory. | see below | yes | yes | -As a general rule, `accessoryID` is set by the child auto- discovery process. +As a general rule `accessoryID` is set by the child auto- discovery process. However you can configure it manually if you wish. -It must be the ID of the accessory within the `bridge-accessory`. +It must be the ID of the accessory within the `bridge`. ## Thing Pairing -The `bridge-accessory` and `lan-accessory` Things need to be paired with their respective HomeKit accessories. +The `bridge` and `accessory` Things need to be paired with their respective HomeKit accessories. This requires entering the HomeKit pairing code by means of a Thing Action. Note that HomeKit accessories can only be paired with one controller, so if it is already paired with something else, you will need to remove that pairing first. @@ -82,8 +83,8 @@ Whereas for case 2. above, must be `ON`. ## Channels -For `lan-accessory` and `child-accessory` Things, the Channels are auto-created depending on the services and characteristics published by the HomeKit accessory. -Things of type `bridge-accessory` do not have own channels. +For `accessory` and `bridged-accessory` Things, the Channels are auto-created depending on the services and characteristics published by the HomeKit accessory. +Things of type `bridge` do not have own channels. As a general rule openHAB has one channel for each HomeKit charactersitic. Some HomeKit accessories have separate charactersitics for 'target' and 'current' states. @@ -107,17 +108,17 @@ Things are automatically configured when they are discovered. So for this reason it is difficult to create Things via a '.things' file, and therefore not recommended. ```java -Bridge homekit:bridge-accessory:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", hostName="foobar._hap._tcp.local.", refreshInterval=60 ] { - Thing accessory2 "VELUX Sensor" @ "Hallway" [ accessoryID=2 ] - Thing accessory3 "VELUX Window" @ "Hallway" [ accessoryID=3 ] - Thing accessory4 "VELUX Window" @ "Small bathroom" [ accessoryID=4 ] +Bridge homekit:bridge:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", hostName="foobar._hap._tcp.local.", refreshInterval=60 ] { + Thing accessory 2 "VELUX Sensor" @ "Hallway" [ accessoryID=2 ] + Thing accessory 3 "VELUX Window" @ "Hallway" [ accessoryID=3 ] + Thing accessory 4 "VELUX Window" @ "Small bathroom" [ accessoryID=4 ] } ``` ### Item Configuration ```java -Number:Temperature Color_Temperature "Color Temperature [%.1f mired]" [ColorTemperature, Setpoint] { channel="homekit:lan-accessory:297b703df234:lightbulb#color-temperature", unit="mired" } +Number:Temperature Color_Temperature "Color Temperature [%.1f mired]" [ColorTemperature, Setpoint] { channel="homekit:accessory:297b703df234:lightbulb#color-temperature", unit="mired" } ``` ### Sitemap Configuration diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 3424d53f35381..83f73fa9a03e9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -29,9 +29,9 @@ public class HomekitBindingConstants { public static final String BINDING_ID = "homekit"; // List of all Thing Type UIDs - public static final ThingTypeUID THING_TYPE_LAN_ACCESSORY = new ThingTypeUID(BINDING_ID, "lan-accessory"); - public static final ThingTypeUID THING_TYPE_CHILD_ACCESSORY = new ThingTypeUID(BINDING_ID, "child-accessory"); - public static final ThingTypeUID THING_TYPE_BRIDGE_ACCESSORY = new ThingTypeUID(BINDING_ID, "bridge-accessory"); + public static final ThingTypeUID THING_TYPE_ACCESSORY = new ThingTypeUID(BINDING_ID, "accessory"); + public static final ThingTypeUID THING_TYPE_BRIDGED_ACCESSORY = new ThingTypeUID(BINDING_ID, "bridged-accessory"); + public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); // specific Channel Type UIDs public static final String FAKE_PROPERTY_CHANNEL = "property-fake-channel"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java index 10e182aeba92d..3100e65119936 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.java @@ -45,7 +45,7 @@ public static String pair(ThingActions actions, String code, boolean auth) { if (actions instanceof HomekitPairingActions accessoryActions) { return accessoryActions.pair(code, auth); } else { - throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitAccessoryActions"); + throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitPairingActions"); } } @@ -53,7 +53,7 @@ public static String unpair(ThingActions actions) { if (actions instanceof HomekitPairingActions accessoryActions) { return accessoryActions.unpair(); } else { - throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitAccessoryActions"); + throw new IllegalArgumentException("The 'actions' argument is not an instance of HomekitPairingActions"); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java index 786847cf3d44e..97875ba236318 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java @@ -30,7 +30,7 @@ /** * Discovery service component that publishes newly discovered child accessories of a HomeKit bridge accessory. * Discovered devices are published as Things of type - * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_CHILD_ACCESSORY} with a ThingUID + * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGED_ACCESSORY} with a ThingUID * based on their accessory ID (aid). * * @author Andrew Fiddian-Green - Initial Contribution @@ -42,7 +42,7 @@ public class HomekitChildDiscoveryService extends AbstractThingHandlerDiscoveryS private static final int TIMEOUT_SECONDS = 10; public HomekitChildDiscoveryService() { - super(HomekitBridgeHandler.class, Set.of(THING_TYPE_CHILD_ACCESSORY), TIMEOUT_SECONDS); + super(HomekitBridgeHandler.class, Set.of(THING_TYPE_BRIDGED_ACCESSORY), TIMEOUT_SECONDS); } @Override @@ -72,7 +72,7 @@ private void discoverChildren(Thing bridge, Collection accessories) { } accessories.forEach(accessory -> { if (accessory.aid instanceof Long aid && aid != 1L && accessory.services != null) { - ThingUID uid = new ThingUID(THING_TYPE_CHILD_ACCESSORY, bridge.getUID(), aid.toString()); + ThingUID uid = new ThingUID(THING_TYPE_BRIDGED_ACCESSORY, bridge.getUID(), aid.toString()); String uniqueId = STRING_AID_FMT.formatted(bridgeMacAddress, aid); String label = THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), uniqueId); thingDiscovered(DiscoveryResultBuilder.create(uid) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index d58a8bb65b2a3..28706781a6754 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -39,8 +39,8 @@ * The device category is also included, allowing differentiation between bridges and accessories. * The discovery participant creates a ThingUID based on the MAC address and device category. * Discovered devices are published as Things of type - * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_LAN_ACCESSORY} - * or {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGE_ACCESSORY}. + * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_ACCESSORY} + * or {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGE}. * Discovered Things include properties such as model name, protocol version, and IP address. * This class does not perform active scanning; instead, it relies on the central mDNS discovery * service to notify it of new services. @@ -55,7 +55,7 @@ public class HomekitMdnsDiscoveryParticipant implements MDNSDiscoveryParticipant @Override public Set getSupportedThingTypeUIDs() { - return Set.of(THING_TYPE_BRIDGE_ACCESSORY, THING_TYPE_LAN_ACCESSORY); + return Set.of(THING_TYPE_BRIDGE, THING_TYPE_ACCESSORY); } @Override @@ -122,8 +122,7 @@ public String getServiceType() { } if (mac != null && cat != null) { - return new ThingUID( - AccessoryCategory.BRIDGE == cat ? THING_TYPE_BRIDGE_ACCESSORY : THING_TYPE_LAN_ACCESSORY, + return new ThingUID(AccessoryCategory.BRIDGE == cat ? THING_TYPE_BRIDGE : THING_TYPE_ACCESSORY, mac.replace(":", "").toLowerCase()); // thing id example "a1b2c3d4e5f6" } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java index 5b20f8b3c601b..7e756f8373a6f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java @@ -14,6 +14,7 @@ import java.util.List; +import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; /** @@ -23,8 +24,9 @@ * * @author Andrew Fiddian-Green - Initial contribution */ +@NonNullByDefault public class Accessories { - public List accessories; + public @NonNullByDefault({}) List accessories; public @Nullable Accessory getAccessory(Long aid) { return accessories.stream().filter(a -> aid.equals(a.aid)).findFirst().orElse(null); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 38e9081088465..64e15db47c899 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -417,7 +417,11 @@ public class Characteristic { break; case IDENTIFY: - // TODO + /* + * The identify characteristic is used to trigger a physical identification action on the accessory, + * such as blinking an LED or making a sound. It does not represent a state or property that can be + * monitored or controlled, so we do not create a channel for it. + */ itemType = null; break; @@ -463,7 +467,11 @@ public class Characteristic { break; case LOCK_MANAGEMENT_CONTROL_POINT: - // TODO tlv8 + /* + * According to Apple specifications this Characteristic type returns data in a tlv8 format, however + * there is no way to represent this in openHAB at present, nor is there any documentation about the + * potential fields in such tlv, so we ignore it for now. + */ itemType = null; break; @@ -839,8 +847,14 @@ public class Characteristic { Optional.ofNullable(maxValue).map(v -> BigDecimal.valueOf(v)).ifPresent(b -> fragBldr.withMaximum(b)); Optional.ofNullable(minStep).map(v -> BigDecimal.valueOf(v)).ifPresent(b -> fragBldr.withStep(b)); - if (isPercentage) { + if (isPercentage || "%".equals(uom) || CoreItemFactory.DIMMER == itemType) { fragBldr.withPattern("%.0f %%"); + if (minValue == null) { + fragBldr.withMinimum(BigDecimal.ZERO); + } + if (maxValue == null) { + fragBldr.withMaximum(BigDecimal.valueOf(100)); + } } else if (uom != null) { fragBldr.withPattern("%.1f " + uom); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java index af48d6fd2d040..ade3bb15fcc2a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java @@ -21,9 +21,18 @@ */ @NonNullByDefault public enum AccessoryPairingFeature { - NO(0x00), // no support for HAP Pairing - YES(0x01), // supports pairing via software, or Apple authentication coprocessor - SECURE_HTTP_DEPRECATED(0x02); // supports pairing via secure HTTP (deprecated) + /** + * no support for HAP Pairing + */ + NO(0x00), + /** + * supports pairing via software, or Apple authentication coprocessor + */ + YES(0x01), + /** + * supports pairing via secure HTTP (deprecated) + */ + SECURE_HTTP_DEPRECATED(0x02); public final byte value; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java index 478018694304b..d5befab43756f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -24,140 +24,145 @@ */ @NonNullByDefault public enum CharacteristicType { + /* + * According to the Apple specifications the type fields are fully qualified strings such + * as "public.hap.characteristic.accessory-properties" however we do not need to use the + * "public.hap.characteristic." prefix in this binding so for brevity it has been removed. + */ //@formatter:off - ACCESSORY_PROPERTIES(0xA6, "public.hap.characteristic.accessory-properties"), - ACTIVE(0xB0, "public.hap.characteristic.active"), - ACTIVE_IDENTIFIER(0xE7, "public.hap.characteristic.active-identifier"), - ADMINISTRATOR_ONLY_ACCESS(0x01, "public.hap.characteristic.administrator-only-access"), - AIR_PARTICULATE_DENSITY(0x64, "public.hap.characteristic.air-particulate.density"), - AIR_PARTICULATE_SIZE(0x65, "public.hap.characteristic.air-particulate.size"), - AIR_PURIFIER_STATE_CURRENT(0xA9, "public.hap.characteristic.air-purifier.state.current"), - AIR_PURIFIER_STATE_TARGET(0xA8, "public.hap.characteristic.air-purifier.state.target"), - AIR_QUALITY(0x95, "public.hap.characteristic.air-quality"), - AUDIO_FEEDBACK(0x05, "public.hap.characteristic.audio-feedback"), - BATTERY_LEVEL(0x68, "public.hap.characteristic.battery-level"), - BRIGHTNESS(0x08, "public.hap.characteristic.brightness"), - BUTTON_EVENT(0x126, "public.hap.characteristic.button-event"), - CARBON_DIOXIDE_DETECTED(0x92, "public.hap.characteristic.carbon-dioxide.detected"), - CARBON_DIOXIDE_LEVEL(0x93, "public.hap.characteristic.carbon-dioxide.level"), - CARBON_DIOXIDE_PEAK_LEVEL(0x94, "public.hap.characteristic.carbon-dioxide.peak-level"), - CARBON_MONOXIDE_DETECTED(0x69, "public.hap.characteristic.carbon-monoxide.detected"), - CARBON_MONOXIDE_LEVEL(0x90, "public.hap.characteristic.carbon-monoxide.level"), - CARBON_MONOXIDE_PEAK_LEVEL(0x91, "public.hap.characteristic.carbon-monoxide.peak-level"), - CHARGING_STATE(0x8F, "public.hap.characteristic.charging-state"), - COLOR_TEMPERATURE(0xCE, "public.hap.characteristic.color-temperature"), - CONTACT_STATE(0x6A, "public.hap.characteristic.contact-state"), - DENSITY_NO2(0xC4, "public.hap.characteristic.density.no2"), - DENSITY_OZONE(0xC3, "public.hap.characteristic.density.ozone"), - DENSITY_PM10(0xC7, "public.hap.characteristic.density.pm10"), - DENSITY_PM2_5(0xC6, "public.hap.characteristic.density.pm2_5"), - DENSITY_SO2(0xC5, "public.hap.characteristic.density.so2"), - DENSITY_VOC(0xC8, "public.hap.characteristic.density.voc"), - DOOR_STATE_CURRENT(0x0E, "public.hap.characteristic.door-state.current"), - DOOR_STATE_TARGET(0x32, "public.hap.characteristic.door-state.target"), - FAN_STATE_CURRENT(0xAF, "public.hap.characteristic.fan.state.current"), - FAN_STATE_TARGET(0xBF, "public.hap.characteristic.fan.state.target"), - FILTER_CHANGE_INDICATION(0xAC, "public.hap.characteristic.filter.change-indication"), - FILTER_LIFE_LEVEL(0xAB, "public.hap.characteristic.filter.life-level"), - FILTER_RESET_INDICATION(0xAD, "public.hap.characteristic.filter.reset-indication"), - FIRMWARE_REVISION(0x52, "public.hap.characteristic.firmware.revision"), - HARDWARE_REVISION(0x53, "public.hap.characteristic.hardware.revision"), - HEATER_COOLER_STATE_CURRENT(0xB1, "public.hap.characteristic.heater-cooler.state.current"), - HEATER_COOLER_STATE_TARGET(0xB2, "public.hap.characteristic.heater-cooler.state.target"), - HEATING_COOLING_CURRENT(0x0F, "public.hap.characteristic.heating-cooling.current"), - HEATING_COOLING_TARGET(0x33, "public.hap.characteristic.heating-cooling.target"), - HORIZONTAL_TILT_CURRENT(0x6C, "public.hap.characteristic.horizontal-tilt.current"), - HORIZONTAL_TILT_TARGET(0x7B, "public.hap.characteristic.horizontal-tilt.target"), - HUE(0x13, "public.hap.characteristic.hue"), - HUMIDIFIER_DEHUMIDIFIER_STATE_CURRENT(0xB3, "public.hap.characteristic.humidifier-dehumidifier.state.current"), - HUMIDIFIER_DEHUMIDIFIER_STATE_TARGET(0xB4, "public.hap.characteristic.humidifier-dehumidifier.state.target"), - IDENTIFY(0x14, "public.hap.characteristic.identify"), - IMAGE_MIRROR(0x11F, "public.hap.characteristic.image-mirror"), - IMAGE_ROTATION(0x11E, "public.hap.characteristic.image-rotation"), - IN_USE(0xD2, "public.hap.characteristic.in-use"), - INPUT_EVENT(0x73, "public.hap.characteristic.input-event"), - IS_CONFIGURED(0xD6, "public.hap.characteristic.is-configured"), - LEAK_DETECTED(0x70, "public.hap.characteristic.leak-detected"), - LIGHT_LEVEL_CURRENT(0x6B, "public.hap.characteristic.light-level.current"), - LOCK_MANAGEMENT_AUTO_SECURE_TIMEOUT(0x1A, "public.hap.characteristic.lock-management.auto-secure-timeout"), - LOCK_MANAGEMENT_CONTROL_POINT(0x19, "public.hap.characteristic.lock-management.control-point"), - LOCK_MECHANISM_CURRENT_STATE(0x1D, "public.hap.characteristic.lock-mechanism.current-state"), - LOCK_MECHANISM_LAST_KNOWN_ACTION(0x1C, "public.hap.characteristic.lock-mechanism.last-known-action"), - LOCK_MECHANISM_TARGET_STATE(0x1E, "public.hap.characteristic.lock-mechanism.target-state"), - LOCK_PHYSICAL_CONTROLS(0xA7, "public.hap.characteristic.lock-physical-controls"), - LOGS(0x1F, "public.hap.characteristic.logs"), - MANUFACTURER(0x20, "public.hap.characteristic.manufacturer"), - MODEL(0x21, "public.hap.characteristic.model"), - MOTION_DETECTED(0x22, "public.hap.characteristic.motion-detected"), - MUTE(0x11A, "public.hap.characteristic.mute"), - NAME(0x23, "public.hap.characteristic.name"), - NIGHT_VISION(0x11B, "public.hap.characteristic.night-vision"), - OBSTRUCTION_DETECTED(0x24, "public.hap.characteristic.obstruction-detected"), - OCCUPANCY_DETECTED(0x71, "public.hap.characteristic.occupancy-detected"), - ON(0x25, "public.hap.characteristic.on"), - OUTLET_IN_USE(0x26, "public.hap.characteristic.outlet-in-use"), - PAIRING_FEATURES(0x4F, "public.hap.characteristic.pairing.features"), - PAIRING_PAIR_SETUP(0x4C, "public.hap.characteristic.pairing.pair-setup"), - PAIRING_PAIR_VERIFY(0x4E, "public.hap.characteristic.pairing.pair-verify"), - PAIRING_PAIRINGS(0x50, "public.hap.characteristic.pairing.pairings"), - POSITION_CURRENT(0x6D, "public.hap.characteristic.position.current"), - POSITION_HOLD(0x6F, "public.hap.characteristic.position.hold"), - POSITION_STATE(0x72, "public.hap.characteristic.position.state"), - POSITION_TARGET(0x7C, "public.hap.characteristic.position.target"), - PROGRAM_MODE(0xD1, "public.hap.characteristic.program-mode"), - RELATIVE_HUMIDITY_CURRENT(0x10, "public.hap.characteristic.relative-humidity.current"), - RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD(0xC9, "public.hap.characteristic.relative-humidity.dehumidifier-threshold"), - RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD(0xCA, "public.hap.characteristic.relative-humidity.humidifier-threshold"), - RELATIVE_HUMIDITY_TARGET(0x34, "public.hap.characteristic.relative-humidity.target"), - REMAINING_DURATION(0xD4, "public.hap.characteristic.remaining-duration"), - ROTATION_DIRECTION(0x28, "public.hap.characteristic.rotation.direction"), - ROTATION_SPEED(0x29, "public.hap.characteristic.rotation.speed"), - SATURATION(0x2F, "public.hap.characteristic.saturation"), - SECURITY_SYSTEM_ALARM_TYPE(0x8E, "public.hap.characteristic.security-system.alarm-type"), - SECURITY_SYSTEM_STATE_CURRENT(0x66, "public.hap.characteristic.security-system-state.current"), - SECURITY_SYSTEM_STATE_TARGET(0x67, "public.hap.characteristic.security-system-state.target"), - SELECTED_AUDIO_STREAM_CONFIGURATION(0x128, "public.hap.characteristic.selected-audio-stream-configuration"), - SELECTED_RTP_STREAM_CONFIGURATION(0x117, "public.hap.characteristic.selected-rtp-stream-configuration"), - SERIAL_NUMBER(0x30, "public.hap.characteristic.serial-number"), - SERVICE_LABEL_INDEX(0xCB, "public.hap.characteristic.service-label-index"), - SERVICE_LABEL_NAMESPACE(0xCD, "public.hap.characteristic.service-label-namespace"), - SET_DURATION(0xD3, "public.hap.characteristic.set-duration"), - SETUP_DATA_STREAM_TRANSPORT(0x131, "public.hap.characteristic.setup-data-stream-transport"), - SETUP_ENDPOINTS(0x118, "public.hap.characteristic.setup-endpoints"), - SIRI_INPUT_TYPE(0x132, "public.hap.characteristic.siri-input-type"), - SLAT_STATE_CURRENT(0xAA, "public.hap.characteristic.slat.state.current"), - SMOKE_DETECTED(0x76, "public.hap.characteristic.smoke-detected"), - STATUS_ACTIVE(0x75, "public.hap.characteristic.status-active"), - STATUS_FAULT(0x77, "public.hap.characteristic.status-fault"), - STATUS_JAMMED(0x78, "public.hap.characteristic.status-jammed"), - STATUS_LO_BATT(0x79, "public.hap.characteristic.status-lo-batt"), - STATUS_TAMPERED(0x7A, "public.hap.characteristic.status-tampered"), - STREAMING_STATUS(0x120, "public.hap.characteristic.streaming-status"), - SUPPORTED_AUDIO_CONFIGURATION(0x115, "public.hap.characteristic.supported-audio-configuration"), - SUPPORTED_DATA_STREAM_TRANSPORT_CONFIGURATION(0x130, "public.hap.characteristic.supported-data-stream-transport-configuration"), - SUPPORTED_RTP_CONFIGURATION(0x116, "public.hap.characteristic.supported-rtp-configuration"), - SUPPORTED_TARGET_CONFIGURATION(0x123, "public.hap.characteristic.supported-target-configuration"), - SUPPORTED_VIDEO_STREAM_CONFIGURATION(0x114, "public.hap.characteristic.supported-video-stream-configuration"), - SWING_MODE(0xB6, "public.hap.characteristic.swing-mode"), - TARGET_LIST(0x124, "public.hap.characteristic.target-list"), - TEMPERATURE_COOLING_THRESHOLD(0x0D, "public.hap.characteristic.temperature.cooling-threshold"), - TEMPERATURE_CURRENT(0x11, "public.hap.characteristic.temperature.current"), - TEMPERATURE_HEATING_THRESHOLD(0x12, "public.hap.characteristic.temperature.heating-threshold"), - TEMPERATURE_TARGET(0x35, "public.hap.characteristic.temperature.target"), - TEMPERATURE_UNITS(0x36, "public.hap.characteristic.temperature.units"), - TILT_CURRENT(0xC1, "public.hap.characteristic.tilt.current"), - TILT_TARGET(0xC2, "public.hap.characteristic.tilt.target"), - TYPE_SLAT(0xC0, "public.hap.characteristic.type.slat"), - VALVE_TYPE(0xD5, "public.hap.characteristic.valve-type"), - VERSION(0x37, "public.hap.characteristic.version"), - VERTICAL_TILT_CURRENT(0x6E, "public.hap.characteristic.vertical-tilt.current"), - VERTICAL_TILT_TARGET(0x7D, "public.hap.characteristic.vertical-tilt.target"), - VOLUME(0x119, "public.hap.characteristic.volume"), - WATER_LEVEL(0xB5, "public.hap.characteristic.water-level"), - ZOOM_DIGITAL(0x11D, "public.hap.characteristic.zoom-digital"), - ZOOM_OPTICAL(0x11C, "public.hap.characteristic.zoom-optical"), + ACCESSORY_PROPERTIES(0xA6, "accessory-properties"), + ACTIVE(0xB0, "active"), + ACTIVE_IDENTIFIER(0xE7, "active-identifier"), + ADMINISTRATOR_ONLY_ACCESS(0x01, "administrator-only-access"), + AIR_PARTICULATE_DENSITY(0x64, "air-particulate.density"), + AIR_PARTICULATE_SIZE(0x65, "air-particulate.size"), + AIR_PURIFIER_STATE_CURRENT(0xA9, "air-purifier.state.current"), + AIR_PURIFIER_STATE_TARGET(0xA8, "air-purifier.state.target"), + AIR_QUALITY(0x95, "air-quality"), + AUDIO_FEEDBACK(0x05, "audio-feedback"), + BATTERY_LEVEL(0x68, "battery-level"), + BRIGHTNESS(0x08, "brightness"), + BUTTON_EVENT(0x126, "button-event"), + CARBON_DIOXIDE_DETECTED(0x92, "carbon-dioxide.detected"), + CARBON_DIOXIDE_LEVEL(0x93, "carbon-dioxide.level"), + CARBON_DIOXIDE_PEAK_LEVEL(0x94, "carbon-dioxide.peak-level"), + CARBON_MONOXIDE_DETECTED(0x69, "carbon-monoxide.detected"), + CARBON_MONOXIDE_LEVEL(0x90, "carbon-monoxide.level"), + CARBON_MONOXIDE_PEAK_LEVEL(0x91, "carbon-monoxide.peak-level"), + CHARGING_STATE(0x8F, "charging-state"), + COLOR_TEMPERATURE(0xCE, "color-temperature"), + CONTACT_STATE(0x6A, "contact-state"), + DENSITY_NO2(0xC4, "density.no2"), + DENSITY_OZONE(0xC3, "density.ozone"), + DENSITY_PM10(0xC7, "density.pm10"), + DENSITY_PM2_5(0xC6, "density.pm2_5"), + DENSITY_SO2(0xC5, "density.so2"), + DENSITY_VOC(0xC8, "density.voc"), + DOOR_STATE_CURRENT(0x0E, "door-state.current"), + DOOR_STATE_TARGET(0x32, "door-state.target"), + FAN_STATE_CURRENT(0xAF, "fan.state.current"), + FAN_STATE_TARGET(0xBF, "fan.state.target"), + FILTER_CHANGE_INDICATION(0xAC, "filter.change-indication"), + FILTER_LIFE_LEVEL(0xAB, "filter.life-level"), + FILTER_RESET_INDICATION(0xAD, "filter.reset-indication"), + FIRMWARE_REVISION(0x52, "firmware.revision"), + HARDWARE_REVISION(0x53, "hardware.revision"), + HEATER_COOLER_STATE_CURRENT(0xB1, "heater-cooler.state.current"), + HEATER_COOLER_STATE_TARGET(0xB2, "heater-cooler.state.target"), + HEATING_COOLING_CURRENT(0x0F, "heating-cooling.current"), + HEATING_COOLING_TARGET(0x33, "heating-cooling.target"), + HORIZONTAL_TILT_CURRENT(0x6C, "horizontal-tilt.current"), + HORIZONTAL_TILT_TARGET(0x7B, "horizontal-tilt.target"), + HUE(0x13, "hue"), + HUMIDIFIER_DEHUMIDIFIER_STATE_CURRENT(0xB3, "humidifier-dehumidifier.state.current"), + HUMIDIFIER_DEHUMIDIFIER_STATE_TARGET(0xB4, "humidifier-dehumidifier.state.target"), + IDENTIFY(0x14, "identify"), + IMAGE_MIRROR(0x11F, "image-mirror"), + IMAGE_ROTATION(0x11E, "image-rotation"), + IN_USE(0xD2, "in-use"), + INPUT_EVENT(0x73, "input-event"), + IS_CONFIGURED(0xD6, "is-configured"), + LEAK_DETECTED(0x70, "leak-detected"), + LIGHT_LEVEL_CURRENT(0x6B, "light-level.current"), + LOCK_MANAGEMENT_AUTO_SECURE_TIMEOUT(0x1A, "lock-management.auto-secure-timeout"), + LOCK_MANAGEMENT_CONTROL_POINT(0x19, "lock-management.control-point"), + LOCK_MECHANISM_CURRENT_STATE(0x1D, "lock-mechanism.current-state"), + LOCK_MECHANISM_LAST_KNOWN_ACTION(0x1C, "lock-mechanism.last-known-action"), + LOCK_MECHANISM_TARGET_STATE(0x1E, "lock-mechanism.target-state"), + LOCK_PHYSICAL_CONTROLS(0xA7, "lock-physical-controls"), + LOGS(0x1F, "logs"), + MANUFACTURER(0x20, "manufacturer"), + MODEL(0x21, "model"), + MOTION_DETECTED(0x22, "motion-detected"), + MUTE(0x11A, "mute"), + NAME(0x23, "name"), + NIGHT_VISION(0x11B, "night-vision"), + OBSTRUCTION_DETECTED(0x24, "obstruction-detected"), + OCCUPANCY_DETECTED(0x71, "occupancy-detected"), + ON(0x25, "on"), + OUTLET_IN_USE(0x26, "outlet-in-use"), + PAIRING_FEATURES(0x4F, "pairing.features"), + PAIRING_PAIR_SETUP(0x4C, "pairing.pair-setup"), + PAIRING_PAIR_VERIFY(0x4E, "pairing.pair-verify"), + PAIRING_PAIRINGS(0x50, "pairing.pairings"), + POSITION_CURRENT(0x6D, "position.current"), + POSITION_HOLD(0x6F, "position.hold"), + POSITION_STATE(0x72, "position.state"), + POSITION_TARGET(0x7C, "position.target"), + PROGRAM_MODE(0xD1, "program-mode"), + RELATIVE_HUMIDITY_CURRENT(0x10, "relative-humidity.current"), + RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD(0xC9, "relative-humidity.dehumidifier-threshold"), + RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD(0xCA, "relative-humidity.humidifier-threshold"), + RELATIVE_HUMIDITY_TARGET(0x34, "relative-humidity.target"), + REMAINING_DURATION(0xD4, "remaining-duration"), + ROTATION_DIRECTION(0x28, "rotation.direction"), + ROTATION_SPEED(0x29, "rotation.speed"), + SATURATION(0x2F, "saturation"), + SECURITY_SYSTEM_ALARM_TYPE(0x8E, "security-system.alarm-type"), + SECURITY_SYSTEM_STATE_CURRENT(0x66, "security-system-state.current"), + SECURITY_SYSTEM_STATE_TARGET(0x67, "security-system-state.target"), + SELECTED_AUDIO_STREAM_CONFIGURATION(0x128, "selected-audio-stream-configuration"), + SELECTED_RTP_STREAM_CONFIGURATION(0x117, "selected-rtp-stream-configuration"), + SERIAL_NUMBER(0x30, "serial-number"), + SERVICE_LABEL_INDEX(0xCB, "service-label-index"), + SERVICE_LABEL_NAMESPACE(0xCD, "service-label-namespace"), + SET_DURATION(0xD3, "set-duration"), + SETUP_DATA_STREAM_TRANSPORT(0x131, "setup-data-stream-transport"), + SETUP_ENDPOINTS(0x118, "setup-endpoints"), + SIRI_INPUT_TYPE(0x132, "siri-input-type"), + SLAT_STATE_CURRENT(0xAA, "slat.state.current"), + SMOKE_DETECTED(0x76, "smoke-detected"), + STATUS_ACTIVE(0x75, "status-active"), + STATUS_FAULT(0x77, "status-fault"), + STATUS_JAMMED(0x78, "status-jammed"), + STATUS_LO_BATT(0x79, "status-lo-batt"), + STATUS_TAMPERED(0x7A, "status-tampered"), + STREAMING_STATUS(0x120, "streaming-status"), + SUPPORTED_AUDIO_CONFIGURATION(0x115, "supported-audio-configuration"), + SUPPORTED_DATA_STREAM_TRANSPORT_CONFIGURATION(0x130, "supported-data-stream-transport-configuration"), + SUPPORTED_RTP_CONFIGURATION(0x116, "supported-rtp-configuration"), + SUPPORTED_TARGET_CONFIGURATION(0x123, "supported-target-configuration"), + SUPPORTED_VIDEO_STREAM_CONFIGURATION(0x114, "supported-video-stream-configuration"), + SWING_MODE(0xB6, "swing-mode"), + TARGET_LIST(0x124, "target-list"), + TEMPERATURE_COOLING_THRESHOLD(0x0D, "temperature.cooling-threshold"), + TEMPERATURE_CURRENT(0x11, "temperature.current"), + TEMPERATURE_HEATING_THRESHOLD(0x12, "temperature.heating-threshold"), + TEMPERATURE_TARGET(0x35, "temperature.target"), + TEMPERATURE_UNITS(0x36, "temperature.units"), + TILT_CURRENT(0xC1, "tilt.current"), + TILT_TARGET(0xC2, "tilt.target"), + TYPE_SLAT(0xC0, "type.slat"), + VALVE_TYPE(0xD5, "valve-type"), + VERSION(0x37, "version"), + VERTICAL_TILT_CURRENT(0x6E, "vertical-tilt.current"), + VERTICAL_TILT_TARGET(0x7D, "vertical-tilt.target"), + VOLUME(0x119, "volume"), + WATER_LEVEL(0xB5, "water-level"), + ZOOM_DIGITAL(0x11D, "zoom-digital"), + ZOOM_OPTICAL(0x11C, "zoom-optical"), // placeholder for any custom or unsupported characteristic - CUSTOM_CXX(0xFF, "public.hap.characteristic.custom"); + CUSTOM_CXX(0xFF, "custom"); //@formatter:on private final int id; @@ -181,7 +186,7 @@ public static CharacteristicType from(int id) throws IllegalArgumentException { * Returns OH type id being a shortened version of the full Homekit type id. e.g. ZOOM_DIGITAL -> zoom-digital */ public String getOpenhabType() { - return type.substring(26).replace(".", "-"); // convert to OH channel type format + return type.replace(".", "-"); // convert to OH channel type format } /** diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index b47913f1a4b74..2772b93f0bb18 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -24,56 +24,61 @@ */ @NonNullByDefault public enum ServiceType { - ACCESSORY_INFORMATION(0x3E, "public.hap.service.accessory-information"), - AIR_PURIFIER(0xBB, "public.hap.service.air-purifier"), - AUDIO_STREAM_MANAGEMENT(0x127, "public.hap.service.audio-stream-management"), - BATTERY(0x96, "public.hap.service.battery"), - CAMERA_RTP_STREAM_MANAGEMENT(0x110, "public.hap.service.camera-rtp-stream-management"), - DATA_STREAM_TRANSPORT_MANAGEMENT(0x129, "public.hap.service.data-stream-transport-management"), - DOOR(0x81, "public.hap.service.door"), - DOORBELL(0x121, "public.hap.service.doorbell"), - FAN(0x40, "public.hap.service.fan"), - FANV2(0xB7, "public.hap.service.fanv2"), - FAUCET(0xD7, "public.hap.service.faucet"), - FILTER_MAINTENANCE(0xBA, "public.hap.service.filter-maintenance"), - GARAGE_DOOR_OPENER(0x41, "public.hap.service.garage-door-opener"), - HEATER_COOLER(0xBC, "public.hap.service.heater-cooler"), - HUMIDIFIER_DEHUMIDIFIER(0xBD, "public.hap.service.humidifier-dehumidifier"), - INPUT_SOURCE(0xD9, "public.hap.service.input-source"), - IRRIGATION_SYSTEM(0xCF, "public.hap.service.irrigation-system"), - LIGHT_BULB(0x43, "public.hap.service.lightbulb"), - LOCK_MANAGEMENT(0x44, "public.hap.service.lock-management"), - LOCK_MECHANISM(0x45, "public.hap.service.lock-mechanism"), - MICROPHONE(0x112, "public.hap.service.microphone"), - OUTLET(0x47, "public.hap.service.outlet"), - PAIRING(0x55, "public.hap.service.pairing"), - PROTOCOL_INFORMATION_SERVICE(0xA2, "public.hap.service.protocol.information.service"), - SECURITY_SYSTEM(0x7E, "public.hap.service.security-system"), - SENSOR_AIR_QUALITY(0x8D, "public.hap.service.sensor.air-quality"), - SENSOR_CARBON_DIOXIDE(0x97, "public.hap.service.sensor.carbon-dioxide"), - SENSOR_CARBON_MONOXIDE(0x7F, "public.hap.service.sensor.carbon-monoxide"), - SENSOR_CONTACT(0x80, "public.hap.service.sensor.contact"), - SENSOR_HUMIDITY(0x82, "public.hap.service.sensor.humidity"), - SENSOR_LEAK(0x83, "public.hap.service.sensor.leak"), - SENSOR_LIGHT(0x84, "public.hap.service.sensor.light"), - SENSOR_MOTION(0x85, "public.hap.service.sensor.motion"), - SENSOR_OCCUPANCY(0x86, "public.hap.service.sensor.occupancy"), - SENSOR_SMOKE(0x87, "public.hap.service.sensor.smoke"), - SENSOR_TEMPERATURE(0x8A, "public.hap.service.sensor.temperature"), - SERVICE_LABEL(0xCC, "public.hap.service.service-label"), - SIRI(0x133, "public.hap.service.siri"), - SMART_SPEAKER(0x228, "public.hap.service.smart-speaker"), - SPEAKER(0x113, "public.hap.service.speaker"), - STATELESS_PROGRAMMABLE_SWITCH(0x89, "public.hap.service.stateless-programmable-switch"), - SWITCH(0x49, "public.hap.service.switch"), - TARGET_CONTROL(0x125, "public.hap.service.target-control"), - TARGET_CONTROL_MANAGEMENT(0x122, "public.hap.service.target-control-management"), - TELEVISION(0xD8, "public.hap.service.television"), - THERMOSTAT(0x4A, "public.hap.service.thermostat"), - VALVE(0xD0, "public.hap.service.valve"), - VERTICAL_SLAT(0xB9, "public.hap.service.vertical-slat"), - WINDOW(0x8B, "public.hap.service.window"), - WINDOW_COVERING(0x8C, "public.hap.service.window-covering"); + /* + * According to the Apple specifications the type fields are fully qualified strings + * such as "public.hap.service.accessory-information" however we do not need to use + * the "public.hap.service." prefix in this binding so for brevity it has been removed. + */ + ACCESSORY_INFORMATION(0x3E, "accessory-information"), + AIR_PURIFIER(0xBB, "air-purifier"), + AUDIO_STREAM_MANAGEMENT(0x127, "audio-stream-management"), + BATTERY(0x96, "battery"), + CAMERA_RTP_STREAM_MANAGEMENT(0x110, "camera-rtp-stream-management"), + DATA_STREAM_TRANSPORT_MANAGEMENT(0x129, "data-stream-transport-management"), + DOOR(0x81, "door"), + DOORBELL(0x121, "doorbell"), + FAN(0x40, "fan"), + FANV2(0xB7, "fanv2"), + FAUCET(0xD7, "faucet"), + FILTER_MAINTENANCE(0xBA, "filter-maintenance"), + GARAGE_DOOR_OPENER(0x41, "garage-door-opener"), + HEATER_COOLER(0xBC, "heater-cooler"), + HUMIDIFIER_DEHUMIDIFIER(0xBD, "humidifier-dehumidifier"), + INPUT_SOURCE(0xD9, "input-source"), + IRRIGATION_SYSTEM(0xCF, "irrigation-system"), + LIGHT_BULB(0x43, "lightbulb"), + LOCK_MANAGEMENT(0x44, "lock-management"), + LOCK_MECHANISM(0x45, "lock-mechanism"), + MICROPHONE(0x112, "microphone"), + OUTLET(0x47, "outlet"), + PAIRING(0x55, "pairing"), + PROTOCOL_INFORMATION_SERVICE(0xA2, "protocol.information.service"), + SECURITY_SYSTEM(0x7E, "security-system"), + SENSOR_AIR_QUALITY(0x8D, "sensor.air-quality"), + SENSOR_CARBON_DIOXIDE(0x97, "sensor.carbon-dioxide"), + SENSOR_CARBON_MONOXIDE(0x7F, "sensor.carbon-monoxide"), + SENSOR_CONTACT(0x80, "sensor.contact"), + SENSOR_HUMIDITY(0x82, "sensor.humidity"), + SENSOR_LEAK(0x83, "sensor.leak"), + SENSOR_LIGHT(0x84, "sensor.light"), + SENSOR_MOTION(0x85, "sensor.motion"), + SENSOR_OCCUPANCY(0x86, "sensor.occupancy"), + SENSOR_SMOKE(0x87, "sensor.smoke"), + SENSOR_TEMPERATURE(0x8A, "sensor.temperature"), + SERVICE_LABEL(0xCC, "service-label"), + SIRI(0x133, "siri"), + SMART_SPEAKER(0x228, "smart-speaker"), + SPEAKER(0x113, "speaker"), + STATELESS_PROGRAMMABLE_SWITCH(0x89, "stateless-programmable-switch"), + SWITCH(0x49, "switch"), + TARGET_CONTROL(0x125, "target-control"), + TARGET_CONTROL_MANAGEMENT(0x122, "target-control-management"), + TELEVISION(0xD8, "television"), + THERMOSTAT(0x4A, "thermostat"), + VALVE(0xD0, "valve"), + VERTICAL_SLAT(0xB9, "vertical-slat"), + WINDOW(0x8B, "window"), + WINDOW_COVERING(0x8C, "window-covering"); private final int id; private final String type; @@ -93,7 +98,7 @@ public static ServiceType from(int type) throws IllegalArgumentException { } public String getOpenhabType() { - return type.substring(19).replace(".", "-"); // convert to OH channel type format + return type.replace(".", "-"); // convert to OH channel type format } public String getType() { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java index f4dae557eb1de..22d784ba61641 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -46,8 +46,8 @@ @Component(service = ThingHandlerFactory.class) public class HomekitHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE_ACCESSORY, - THING_TYPE_CHILD_ACCESSORY, THING_TYPE_LAN_ACCESSORY); + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, + THING_TYPE_BRIDGED_ACCESSORY, THING_TYPE_ACCESSORY); private final HomekitTypeProvider typeProvider; private final ChannelTypeRegistry channelTypeRegistry; @@ -77,9 +77,9 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { @Override protected @Nullable ThingHandler createHandler(Thing thing) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); - if (THING_TYPE_BRIDGE_ACCESSORY.equals(thingTypeUID)) { + if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { return new HomekitBridgeHandler((Bridge) thing, typeProvider, keyStore, i18nProvider, bundle); - } else if (THING_TYPE_CHILD_ACCESSORY.equals(thingTypeUID) || THING_TYPE_LAN_ACCESSORY.equals(thingTypeUID)) { + } else if (THING_TYPE_BRIDGED_ACCESSORY.equals(thingTypeUID) || THING_TYPE_ACCESSORY.equals(thingTypeUID)) { return new HomekitAccessoryHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry, keyStore, i18nProvider, bundle); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index b176494360a62..35d91802e6592 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -484,7 +484,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { } return; // success } catch (InterruptedException e) { - // shutting down; do nothing + Thread.currentThread().interrupt(); // shutting down; restore interrupt flag but otherwise do nothing } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 9a965a29c82dc..38b57d63539e4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -5,33 +5,33 @@ addon.homekit.description = This is the binding for a HomeKit client. # thing types -thing-type.homekit.bridge-accessory.label = HomeKit LAN Bridge Accessory -thing-type.homekit.bridge-accessory.description = HomeKit accessory with LAN connection for support of child accessories -thing-type.homekit.child-accessory.label = HomeKit Child Accessory -thing-type.homekit.child-accessory.description = HomeKit child of a bridge accessory without own LAN connection -thing-type.homekit.lan-accessory.label = HomeKit LAN Accessory -thing-type.homekit.lan-accessory.description = HomeKit accessory with own LAN connection +thing-type.homekit.accessory.label = HomeKit Accessory +thing-type.homekit.accessory.description = HomeKit accessory with its own LAN connection +thing-type.homekit.bridge.label = HomeKit Bridge +thing-type.homekit.bridge.description = HomeKit accessory with LAN connection that supports bridged accessories not having an own LAN connection +thing-type.homekit.bridged-accessory.label = HomeKit Bridged Accessory +thing-type.homekit.bridged-accessory.description = HomeKit without its own LAN connection and instead supported by a bridge # thing types config -thing-type.config.homekit.bridge-accessory.hostName.label = Host Name -thing-type.config.homekit.bridge-accessory.hostName.description = The bridge fully qualified host name as discovered by mDNS. -thing-type.config.homekit.bridge-accessory.ipAddress.label = IP Address -thing-type.config.homekit.bridge-accessory.ipAddress.description = IP v4 address of the HomeKit bridge. -thing-type.config.homekit.bridge-accessory.macAddress.label = MAC Address -thing-type.config.homekit.bridge-accessory.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.bridge-accessory.refreshInterval.label = Refresh Interval -thing-type.config.homekit.bridge-accessory.refreshInterval.description = Interval at which the bridge is polled in sec. -thing-type.config.homekit.child-accessory.accessoryID.label = Accessory ID -thing-type.config.homekit.child-accessory.accessoryID.description = ID of the accessory. -thing-type.config.homekit.lan-accessory.hostName.label = Host Name -thing-type.config.homekit.lan-accessory.hostName.description = The accessory fully qualified host name as discovered by mDNS. -thing-type.config.homekit.lan-accessory.ipAddress.label = IP Address -thing-type.config.homekit.lan-accessory.ipAddress.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.lan-accessory.macAddress.label = MAC Address -thing-type.config.homekit.lan-accessory.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.lan-accessory.refreshInterval.label = Refresh Interval -thing-type.config.homekit.lan-accessory.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.accessory.hostName.label = Host Name +thing-type.config.homekit.accessory.hostName.description = The accessory fully qualified host name as discovered by mDNS. +thing-type.config.homekit.accessory.ipAddress.label = IP Address +thing-type.config.homekit.accessory.ipAddress.description = IP v4 address of the HomeKit accessory. +thing-type.config.homekit.accessory.macAddress.label = MAC Address +thing-type.config.homekit.accessory.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval +thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. +thing-type.config.homekit.bridge.hostName.label = Host Name +thing-type.config.homekit.bridge.hostName.description = The bridge fully qualified host name as discovered by mDNS. +thing-type.config.homekit.bridge.ipAddress.label = IP Address +thing-type.config.homekit.bridge.ipAddress.description = IP v4 address of the HomeKit bridge. +thing-type.config.homekit.bridge.macAddress.label = MAC Address +thing-type.config.homekit.bridge.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval +thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the bridge is polled in sec. +thing-type.config.homekit.bridged-accessory.accessoryID.label = Accessory ID +thing-type.config.homekit.bridged-accessory.accessoryID.description = ID of the accessory. # thing error state messages diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index 9a096844c4a49..f7ef1b6c6af9f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -4,9 +4,9 @@ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0" xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd"> - - - HomeKit accessory with own LAN connection + + + HomeKit accessory with its own LAN connection network-address @@ -33,9 +33,9 @@ - - - HomeKit accessory with LAN connection for support of child accessories + + + HomeKit accessory with LAN connection that supports bridged accessories not having an own LAN connection network-address @@ -62,12 +62,12 @@ - + - + - - HomeKit child of a bridge accessory without own LAN connection + + HomeKit without its own LAN connection and instead supported by a bridge From fac3df61b30ce45acb82aba00ce1d218f5dfe9ca Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 25 Nov 2025 14:04:06 +0000 Subject: [PATCH 141/177] fix some (but not all) checkstyle warnings Signed-off-by: Andrew Fiddian-Green --- .../internal/handler/HomekitBaseAccessoryHandler.java | 8 ++++---- .../CharacteristicReadWriteClient.java | 2 +- .../{hap_services => hapservices}/PairRemoveClient.java | 2 +- .../{hap_services => hapservices}/PairSetupClient.java | 2 +- .../{hap_services => hapservices}/PairVerifyClient.java | 2 +- .../binding/homekit/internal/temporary/LightModel.java | 5 +---- .../openhab/binding/homekit/internal/TestPairSetup.java | 2 +- .../openhab/binding/homekit/internal/TestPairVerify.java | 2 +- 8 files changed, 11 insertions(+), 14 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{hap_services => hapservices}/CharacteristicReadWriteClient.java (97%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{hap_services => hapservices}/PairRemoveClient.java (98%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{hap_services => hapservices}/PairSetupClient.java (99%) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/{hap_services => hapservices}/PairVerifyClient.java (99%) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index e28d2b1491715..28ad57766a005 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -47,10 +47,10 @@ import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.dto.Service; import org.openhab.binding.homekit.internal.enums.ServiceType; -import org.openhab.binding.homekit.internal.hap_services.CharacteristicReadWriteClient; -import org.openhab.binding.homekit.internal.hap_services.PairRemoveClient; -import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; -import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; +import org.openhab.binding.homekit.internal.hapservices.CharacteristicReadWriteClient; +import org.openhab.binding.homekit.internal.hapservices.PairRemoveClient; +import org.openhab.binding.homekit.internal.hapservices.PairSetupClient; +import org.openhab.binding.homekit.internal.hapservices.PairVerifyClient; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.binding.homekit.internal.session.EventListener; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/CharacteristicReadWriteClient.java similarity index 97% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/CharacteristicReadWriteClient.java index dc5ebf43a4f7f..edb9fc3d296b3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/CharacteristicReadWriteClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/CharacteristicReadWriteClient.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.hap_services; +package org.openhab.binding.homekit.internal.hapservices; import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java similarity index 98% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java index 250163f8c5131..d7065b4de6a4a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.hap_services; +package org.openhab.binding.homekit.internal.hapservices; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.toHex; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairSetupClient.java similarity index 99% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairSetupClient.java index 63ac761a690c8..0eb11ca4ea4d3 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairSetupClient.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.hap_services; +package org.openhab.binding.homekit.internal.hapservices; import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.toHex; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairVerifyClient.java similarity index 99% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairVerifyClient.java index 228eab1d538ac..15973ce863db2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hap_services/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairVerifyClient.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.homekit.internal.hap_services; +package org.openhab.binding.homekit.internal.hapservices; import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import static org.openhab.binding.homekit.internal.crypto.CryptoConstants.*; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java index e039e5978e5d8..f0ab077b3604a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java @@ -727,7 +727,6 @@ public double[] getRGBx() throws IllegalStateException { * In white only mode the RGB values are all zero. */ if (LedOperatingMode.WHITE_ONLY == ledOperatingMode) { - /* * If the light has a single white led then its value is determined by the brightness only. */ @@ -757,7 +756,6 @@ public double[] getRGBx() throws IllegalStateException { * In RGB only mode the RGB values are determined by the HSB values and the white values are always zero. */ if (LedOperatingMode.RGB_ONLY == ledOperatingMode) { - /* * RGB only - convert HSB to RGB, then scale to [0..255] and pad with zeros for white values. */ @@ -775,7 +773,6 @@ public double[] getRGBx() throws IllegalStateException { * In combined mode the RGB and white values are all determined by the HSB values. */ if (LedOperatingMode.COMBINED == ledOperatingMode) { - /* * RGBCW - convert HSB to RGB, normalize it, then convert to RGBCW, then scale to [0..255] */ @@ -1306,7 +1303,7 @@ protected double[] getProfile() { * Internal: a class containing mathematical utility methods that convert between RGB and RGBCW color arrays * based on the RGB main values and the RGB sub- component values of the cool and warm white LEDs. * - * TODO it is intended to move this class to the {@link ColorUtil} utility class, but let's keep it here + * Note: it is intended to move this class to the {@link ColorUtil} utility class, but let's keep it here * for the time being in order to simplify testing and code review. */ public static class RgbcwMath { diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index ede5d2b370279..cd31f5f89d609 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -36,7 +36,7 @@ import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.hap_services.PairSetupClient; +import org.openhab.binding.homekit.internal.hapservices.PairSetupClient; import org.openhab.binding.homekit.internal.transport.IpTransport; /** diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index 35f30d3d5a969..7550b656b062e 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -35,7 +35,7 @@ import org.openhab.binding.homekit.internal.enums.PairingMethod; import org.openhab.binding.homekit.internal.enums.PairingState; import org.openhab.binding.homekit.internal.enums.TlvType; -import org.openhab.binding.homekit.internal.hap_services.PairVerifyClient; +import org.openhab.binding.homekit.internal.hapservices.PairVerifyClient; import org.openhab.binding.homekit.internal.transport.IpTransport; /** From e581ce9bff041e8aff9766a7a1b7c85709ba3819 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 25 Nov 2025 18:24:28 +0000 Subject: [PATCH 142/177] hardening and refactoring Signed-off-by: Andrew Fiddian-Green --- .../internal/transport/IpTransport.java | 56 ++++++++++++++----- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java index de891ce43c173..0b8e331dc872e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -54,7 +54,11 @@ public class IpTransport implements AutoCloseable { private static final Duration MINIMUM_REQUEST_INTERVAL = Duration.ofMillis(250); private final Logger logger = LoggerFactory.getLogger(IpTransport.class); - private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "homekit-io")); + private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "homekit-io"); + t.setDaemon(true); + return t; + }); private final Socket socket; private final String hostName; @@ -86,10 +90,25 @@ public IpTransport(String ipAddress, String hostName, EventListener eventListene logger.debug("Connected to {} alias {}", ipAddress, hostName); } - public void setSessionKeys(AsymmetricSessionKeys keys) throws IOException { + /** + * Sets the session keys for secure communication. + * This starts a read thread to listen for incoming responses. + * + * @param keys the asymmetric session keys for encryption/decryption + * @throws IOException + * @throws IllegalStateException if the secure session is already set or the read thread is already running + */ + public void setSessionKeys(AsymmetricSessionKeys keys) throws IOException, IllegalStateException { logger.trace("setSessionKeys()"); + if (secureSession != null) { + throw new IllegalStateException("Secure session already set"); + } + if (readThread != null) { + throw new IllegalStateException("Read thread already running"); + } secureSession = new SecureSession(socket, keys); Thread thread = new Thread(this::readTask, "homekit-read"); + thread.setDaemon(true); readThread = thread; thread.start(); logger.trace("setSessionKeys() {}", secureSession); @@ -181,31 +200,31 @@ private synchronized byte[] execute(String method, String endpoint, String conte earliestNextRequestTime = Instant.now().plus(MINIMUM_REQUEST_INTERVAL); // assume zero processing time if (secureSession instanceof SecureSession secureSession) { // before we write request, create CompletableFuture to read response (with a timeout) - CompletableFuture readHttpResponseFuture = new CompletableFuture<>(); - this.readHttpResponseFuture = readHttpResponseFuture; + CompletableFuture readFuture = new CompletableFuture<>(); + readHttpResponseFuture = readFuture; // create Future to write the request (with a timeout) - Future<@Nullable Void> writeTask = executor.submit(() -> { + Future<@Nullable Void> writeFuture = executor.submit(() -> { secureSession.send(request); return null; }); // now wait for both write and read to complete - writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); - response = readHttpResponseFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + writeFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + response = readFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); } else { OutputStream out = socket.getOutputStream(); InputStream in = socket.getInputStream(); // create Future to write the request (with a timeout) - Future<@Nullable Void> writeTask = executor.submit(() -> { + Future<@Nullable Void> writeFuture = executor.submit(() -> { out.write(request); out.flush(); return null; }); // wait for write to complete - writeTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + writeFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); // create Future to read the response (with a timeout) - Future readTask = executor.submit(() -> readPlainResponse(in, trace)); + Future readFuture = executor.submit(() -> readPlainResponse(in, trace)); // wait for read to complete - response = readTask.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + response = readFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); } earliestNextRequestTime = Instant.now().plus(MINIMUM_REQUEST_INTERVAL); // allow actual processing time @@ -300,7 +319,7 @@ private void checkHeaders(byte[] headers) throws IOException, IllegalStateExcept } @Override - public void close() { + public synchronized void close() { closing = true; secureSession = null; try { @@ -320,7 +339,14 @@ public void close() { if (readHttpResponseFuture instanceof CompletableFuture readFuture) { readFuture.complete(new byte[3][0]); // complete with an empty response } - executor.shutdown(); + executor.shutdownNow(); + try { + if (!executor.awaitTermination(500, TimeUnit.MILLISECONDS)) { + logger.debug("Executor did not terminate promptly"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } /** @@ -331,9 +357,9 @@ public void close() { private void handleResponse(byte[][] response) { String headers = new String(response[0], StandardCharsets.ISO_8859_1); if (headers.startsWith("HTTP")) { - if (readHttpResponseFuture instanceof CompletableFuture future) { + if (readHttpResponseFuture instanceof CompletableFuture readFuture) { readHttpResponseFuture = null; - future.complete(response); + readFuture.complete(response); } } else if (headers.startsWith("EVENT")) { logger.trace("HTTP event:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); From b50667b43332c731e5e10c2132ec593145c48229 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 26 Nov 2025 00:25:33 +0000 Subject: [PATCH 143/177] Update bundles/org.openhab.binding.homekit/README.md Co-authored-by: Jacob Laursen Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 7d1212cd293e4..1b04065135b3d 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -124,5 +124,6 @@ Number:Temperature Color_Temperature "Color Temperature [%.1f mired]" [C ### Sitemap Configuration ```perl -Slider item=Color_Temperature +Slider item=SkylightHallway_Position +Slider item=SkylightBathroom_Position ``` From e0c3fa3b376d9587b9f287088bfeccd2e35e8a39 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 26 Nov 2025 00:26:03 +0000 Subject: [PATCH 144/177] Update bundles/org.openhab.binding.homekit/README.md Co-authored-by: Jacob Laursen Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 1b04065135b3d..a9295a2cd28f6 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -118,7 +118,19 @@ Bridge homekit:bridge:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddr ### Item Configuration ```java -Number:Temperature Color_Temperature "Color Temperature [%.1f mired]" [ColorTemperature, Setpoint] { channel="homekit:accessory:297b703df234:lightbulb#color-temperature", unit="mired" } +Group VeluxSensorSwitch "Velux indoor climate sensor" (Hallway) ["Sensor"] + +Number:Dimensionless Velux_Hallway_CO2 "CO2 [%d ppm]" (VeluxSensorSwitch) ["Measurement", "CO2"] { channel="homekit:bridged-accessory:velux:sensor:sensor-carbon-dioxide#carbon-dioxide-level-17", unit="ppm" } +Number:Dimensionless Velux_Hallway_Humidity "Humidity [%.0f %%]" (VeluxSensorSwitch) ["Measurement", "Humidity"] { channel="homekit:bridged-accessory:velux:sensor:sensor-humidity#relative-humidity-current-13", unit="%" } +Number:Temperature Velux_Hallway_Temperature "Temperature" (VeluxSensorSwitch) ["Measurement", "Temperature"] { channel="homekit:bridged-accessory:velux:sensor:sensor-temperature#temperature-current-10", unit="°C" } + +Group SkylightHallway "Skylight window" (Hallway) ["Window"] + +Rollershutter SkylightHallway_Position "Position" (SkylightHallway) ["OpenState"] { channel="homekit:bridged-accessory:velux:skylight_hallway:window#position-target-11" } + +Group SkylightBathroom "Skylight window" (SmallBathroom) ["Window"] + +Rollershutter SkylightBathroom_Position "Position" (SkylightBathroom) ["OpenState"] { channel="homekit:bridged-accessory:velux:skylight_bathroom:window#position-target-11" } ``` ### Sitemap Configuration From f7df9edc8098d59d75e8f12c85fdec213af71241 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 26 Nov 2025 00:26:25 +0000 Subject: [PATCH 145/177] Update bundles/org.openhab.binding.homekit/README.md Co-authored-by: Jacob Laursen Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index a9295a2cd28f6..1fcc99d1cff2c 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -109,9 +109,9 @@ So for this reason it is difficult to create Things via a '.things' file, and th ```java Bridge homekit:bridge:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", hostName="foobar._hap._tcp.local.", refreshInterval=60 ] { - Thing accessory 2 "VELUX Sensor" @ "Hallway" [ accessoryID=2 ] - Thing accessory 3 "VELUX Window" @ "Hallway" [ accessoryID=3 ] - Thing accessory 4 "VELUX Window" @ "Small bathroom" [ accessoryID=4 ] + Thing bridged-accessory sensor "VELUX Sensor" @ "Hallway" [ accessoryID=2 ] + Thing bridged-accessory skylight_hallway "VELUX Window" @ "Hallway" [ accessoryID=3 ] + Thing bridged-accessory skylight_bathroom "VELUX Window" @ "Bathroom" [ accessoryID=4 ] } ``` From b859910a2ab28f40eca43107c2ad31859bfe5c46 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 26 Nov 2025 11:23:42 +0000 Subject: [PATCH 146/177] refactor names to match new thing-type id schema Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 6 +- .../internal/HomekitBindingConstants.java | 4 +- ...ekitBridgedAccessoryDiscoveryService.java} | 16 +-- .../homekit/internal/dto/Accessory.java | 8 +- .../handler/HomekitAccessoryHandler.java | 27 +++-- .../handler/HomekitBaseAccessoryHandler.java | 113 +++++++++--------- .../handler/HomekitBridgeHandler.java | 64 +++++----- 7 files changed, 119 insertions(+), 119 deletions(-) rename bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/{HomekitChildDiscoveryService.java => HomekitBridgedAccessoryDiscoveryService.java} (82%) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 1fcc99d1cff2c..0a6016a2a62db 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -16,7 +16,7 @@ There are three types of Things supported: Instead it contains multiple `bridged-accessory` Things (see above). Things of type `bridge` and `accessory` both communicate directly with their HomeKit accessory device via the LAN. -Whereas child `bridged-accessory` Things communicate via their respective `bridge` Thing. +Whereas `bridged-accessory` Things communicate via their respective `bridge` Thing. ## Discovery @@ -56,9 +56,9 @@ The following table shows the thing configuration parameters for `bridged-access |-------------------|---------|------------------------------------------------------|-----------|----------|----------| | `accessoryID` | integer | ID of the accessory. | see below | yes | yes | -As a general rule `accessoryID` is set by the child auto- discovery process. +As a general rule `accessoryID` is set by the auto- discovery process. However you can configure it manually if you wish. -It must be the ID of the accessory within the `bridge`. +It must be the ID of the `bridged-accessory` within the `bridge`. ## Thing Pairing diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 83f73fa9a03e9..8dae0a73e4431 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -40,14 +40,14 @@ public class HomekitBindingConstants { /* * format string for channel-group-type UIDs which represent services - * format: 'channel-group-type'-[serviceIdentifier]-[serviceIid]-[rootThingId]-[accessoryId] + * format: 'channel-group-type'-[serviceIdentifier]-[serviceIid]-[thingId]-[accessoryId] * example: channel-group-type-accessory-information-1-1234567890abcdef-1 */ public static final String CHANNEL_GROUP_TYPE_ID_FMT = "channel-group-type-%s-%d-%s-%s"; /* * format string for channel-type UIDs which represent characteristics - * format: 'channel-type'-[characteristicIdentifier]-[characteristicIid]-[rootThingId]-[accessoryId] + * format: 'channel-type'-[characteristicIdentifier]-[characteristicIid]-[thingId]-[accessoryId] * example: channel-type-occupancy-detected-2694-1234567890abcdef-1 */ public static final String CHANNEL_TYPE_ID_FMT = "channel-type-%s-%d-%s-%s"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java similarity index 82% rename from bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java rename to bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java index 97875ba236318..8c30f177bf37e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitChildDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java @@ -28,20 +28,20 @@ import org.osgi.service.component.annotations.Component; /** - * Discovery service component that publishes newly discovered child accessories of a HomeKit bridge accessory. - * Discovered devices are published as Things of type - * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGED_ACCESSORY} with a ThingUID - * based on their accessory ID (aid). + * Discovery service component that publishes newly discovered bridged accessories of a HomeKit bridge + * accessory. Discovered devices are published as Things with thingUID based on accessory ID (aid) of type + * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGED_ACCESSORY} . * * @author Andrew Fiddian-Green - Initial Contribution */ @NonNullByDefault @Component(service = DiscoveryService.class) -public class HomekitChildDiscoveryService extends AbstractThingHandlerDiscoveryService { +public class HomekitBridgedAccessoryDiscoveryService + extends AbstractThingHandlerDiscoveryService { private static final int TIMEOUT_SECONDS = 10; - public HomekitChildDiscoveryService() { + public HomekitBridgedAccessoryDiscoveryService() { super(HomekitBridgeHandler.class, Set.of(THING_TYPE_BRIDGED_ACCESSORY), TIMEOUT_SECONDS); } @@ -60,11 +60,11 @@ public void dispose() { @Override public void startScan() { if (thingHandler instanceof HomekitBridgeHandler handler) { - discoverChildren(handler.getThing(), handler.getAccessories().values()); + discoverBridgedAccessories(handler.getThing(), handler.getAccessories().values()); } } - private void discoverChildren(Thing bridge, Collection accessories) { + private void discoverBridgedAccessories(Thing bridge, Collection accessories) { String bridgeMacAddress = thingHandler.getThing().getConfiguration() .get(Thing.PROPERTY_MAC_ADDRESS) instanceof String mac ? mac : null; if (bridgeMacAddress == null) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 3601723501a97..afc31e91e0f93 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -51,10 +51,10 @@ public class Accessory { /** * Builds and registers channel group definitions for all services of this accessory. - * Each child service registers a ChannelGroupType and returns a ChannelGroupDefinition thereof. - * Each grandchild category registers a ChannelType and returns a ChannelDefinition thereof. - * Child services that do not map to a channel group definition are ignored. - * Grandchild categories that do not map to a channel definition are ignored. + * Each nested service registers a ChannelGroupType and returns a ChannelGroupDefinition thereof. + * Each sub-nested characteristic registers a ChannelType and returns a ChannelDefinition thereof. + * Nested services that do not map to a channel group definition are ignored. + * Sub-nested characteristics that do not map to a channel definition are ignored. * * @param thingUID the ThingUID to associate the ChannelGroupDefinitions with * @param typeProvider the HomeKit type provider used to look up channel group definitions. diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 35d91802e6592..a49a3efc0cb37 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -85,9 +85,12 @@ import com.google.gson.JsonPrimitive; /** - * Handles a single HomeKit accessory. - * It provides a polling mechanism to regularly update the state of the accessory. - * It also handles commands sent to the accessory's channels. + * Handler for a HomeKit accessory or bridged accessory. + * It creates channels based on the accessory's services and characteristics. + * It handles state updates from the remote device to update channel states. + * It handles commands sent to the accessory's channels. + * It also manages a light model for accessories with color capabilities, + * allowing combined control of hue, saturation, brightness, and color temperature. * * @author Andrew Fiddian-Green - Initial contribution */ @@ -320,7 +323,7 @@ private void createChannels() { return; } Accessory accessory = accessories.get(accessoryId); - if (accessory == null && !isChildAccessory && !accessories.isEmpty()) { + if (accessory == null && !isBridgedAccessory && !accessories.isEmpty()) { // fallback to the first accessory if the specific one is not found (should not normally happen) accessory = accessories.values().iterator().next(); } @@ -505,10 +508,10 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override public void initialize() { super.initialize(); - if (isChildAccessory) { + if (isBridgedAccessory) { if (getBridge() instanceof Bridge bridge && bridge.getStatus() == ThingStatus.ONLINE) { scheduler.submit(() -> { - onRootThingAccessoriesLoaded(); + onConnectedThingAccessoriesLoaded(); updateStatus(ThingStatus.ONLINE); }); } else { @@ -886,14 +889,14 @@ private void updateChannelsFromJson(String json) { } /** - * Override method to delegate to the bridge IP transport if we are a child accessory. + * Override method to delegate to the bridge IP transport if we are a bridged accessory. * - * @return own IpTransport service or bridge IpTransport service if we are a child. + * @return own IpTransport service or bridge IpTransport service if we are a bridged accessory. * @throws IllegalAccessException if access to the transport is denied. */ @Override protected IpTransport getIpTransport() throws IllegalAccessException { - if (isChildAccessory) { + if (isBridgedAccessory) { if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { return bridgeHandler.getIpTransport(); @@ -905,12 +908,12 @@ protected IpTransport getIpTransport() throws IllegalAccessException { } @Override - protected boolean dependentThingsInitialized() { - return ThingHandlerHelper.isHandlerInitialized(thing); // no children; return own status + protected boolean bridgedThingsInitialized() { + return ThingHandlerHelper.isHandlerInitialized(thing); // no bridged accessories; return own status } @Override - protected void onRootThingAccessoriesLoaded() { + protected void onConnectedThingAccessoriesLoaded() { createProperties(); createChannels(); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 28ad57766a005..8d55eb67da2dc 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -70,11 +70,10 @@ import com.google.gson.Gson; /** - * Handles I/O with HomeKit server devices -- either simple accessories or bridge accessories that - * contain child accessories. If the handler is for a HomeKit bridge or a stand alone HomeKit accessory - * device it performs the pairing and secure session setup. If the handler is for a HomeKit accessory - * that is part of a bridge, it uses the pairing and session from the bridge handler. - * Subclasses should override the handleCommand method to handle commands for specific channels. + * Handles I/O with HomeKit server devices -- either simply accessories, bridge accessories or bridged + * accessories. If the handler is for a HomeKit bridge or a HomeKit accessory it performs the pairing + * and secure session setup. If the handler is for a HomeKit bridged accessory, it depends upon the + * pairing and session of the bridge accessory handler. * * @author Andrew Fiddian-Green - Initial contribution */ @@ -116,7 +115,7 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected final TranslationProvider i18nProvider; protected final Bundle bundle; - protected boolean isChildAccessory = false; + protected boolean isBridgedAccessory = false; protected final Throttler throttler = new Throttler(); /** @@ -172,7 +171,7 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { cancelRefreshTasks(); - if (!isChildAccessory) { + if (!isBridgedAccessory) { try { enableEventsOrThrow(false); } catch (Exception e) { @@ -207,7 +206,7 @@ private void fetchAccessories() { .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } logger.debug("{} fetched {} accessories", thing.getUID(), accessories.size()); - scheduler.submit(this::processDependentThings); + scheduler.submit(this::processBridgedThings); } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect @@ -223,13 +222,13 @@ private void fetchAccessories() { } /** - * Waits for all dependent accessory things to be initialized, then processes them by calling the - * overloaded abstract 'onRootAccessoriesLoaded' methods, and finally calls the 'onRootThingOnline' - * methods (and its eventual overloaded implementations). + * Waits for all bridged accessory things to be initialized, then processes them by calling the + * overloaded abstract 'onConnectedThingAccessoriesLoaded' methods, and finally calls the + * 'onThingOnline' methods (and its eventual overloaded implementations). */ - private void processDependentThings() { + private void processBridgedThings() { Instant timeout = Instant.now().plus(HANDLER_INITIALIZATION_TIMEOUT); - while (!dependentThingsInitialized() && Instant.now().isBefore(timeout)) { + while (!bridgedThingsInitialized() && Instant.now().isBefore(timeout)) { try { Thread.sleep(100); } catch (InterruptedException e) { @@ -237,18 +236,18 @@ private void processDependentThings() { return; } } - onRootThingAccessoriesLoaded(); - onRootThingOnline(); + onConnectedThingAccessoriesLoaded(); + onThingOnline(); } /** - * Returns the accessory ID. For bridges and root accessories this is always 1. Whereas for child - * accessories it comes from the thing's configuration parameter value. + * Returns the accessory ID. For bridges and accessories this is always 1. Whereas for + * bridged accessories it comes from the thing's configuration parameter value. * * @return the accessory ID, or null if it cannot be determined */ protected @Nullable Long getAccessoryId() { - if (isChildAccessory) { + if (isBridgedAccessory) { if (getConfig().get(CONFIG_ACCESSORY_ID) instanceof BigDecimal accessoryId) { try { return accessoryId.longValue(); @@ -264,7 +263,7 @@ private void processDependentThings() { @Override public void handleRemoval() { cancelRefreshTasks(); - if (isChildAccessory) { + if (isBridgedAccessory) { updateStatus(ThingStatus.REMOVED); } else { scheduler.submit(() -> { @@ -279,8 +278,8 @@ public void handleRemoval() { public void initialize() { eventedCharacteristics.clear(); accessories.clear(); - isChildAccessory = getBridge() instanceof Bridge; - if (!isChildAccessory) { + isBridgedAccessory = getBridge() instanceof Bridge; + if (!isBridgedAccessory) { scheduleConnectionAttempt(); } updateStatus(ThingStatus.UNKNOWN); @@ -396,12 +395,12 @@ private synchronized void attemptConnect() { /** * Gets the IP transport. * - * @throws IllegalAccessException if this is a child accessory or if the transport is not initialized. + * @throws IllegalAccessException if this is a bridged accessory or if the transport is not initialized. * @return the IpTransport */ protected IpTransport getIpTransport() throws IllegalAccessException, IllegalStateException { - if (isChildAccessory) { - throw new IllegalAccessException("Child accessories must delegate to bridge IP transport"); + if (isBridgedAccessory) { + throw new IllegalAccessException("Bridged accessories must delegate to bridge IP transport"); } IpTransport ipTransport = this.ipTransport; if (ipTransport == null) { @@ -421,8 +420,8 @@ protected IpTransport getIpTransport() throws IllegalAccessException, IllegalSta @Override public Collection> getServices() { - // only non child accessories require pairing support - return thing.getBridgeUID() != null ? Set.of() : Set.of(HomekitPairingActions.class); + // only bridges and accessories require pairing support + return isBridgedAccessory ? Set.of() : Set.of(HomekitPairingActions.class); } private @Nullable String checkedIpAddress() { @@ -489,9 +488,9 @@ public Collection> getServices() { * @return OK or ERROR with reason */ public String pair(String code, boolean withExternalAuthentication) { - if (isChildAccessory) { - logger.warn("{} forbidden to pair a child accessory", thing.getUID()); - return ACTION_RESULT_ERROR_FORMAT.formatted("child accessory"); + if (isBridgedAccessory) { + logger.warn("{} forbidden to pair a bridged accessory", thing.getUID()); + return ACTION_RESULT_ERROR_FORMAT.formatted("bridged accessory"); } if (!PAIRING_CODE_PATTERN.matcher(code).matches()) { @@ -547,9 +546,9 @@ public String pair(String code, boolean withExternalAuthentication) { * @return OK or ERROR with reason */ private String unpairInner() { - if (isChildAccessory) { - logger.warn("{} forbidden to unpair a child accessory", thing.getUID()); - return ACTION_RESULT_ERROR_FORMAT.formatted("child accessory"); + if (isBridgedAccessory) { + logger.warn("{} forbidden to unpair a bridged accessory", thing.getUID()); + return ACTION_RESULT_ERROR_FORMAT.formatted("bridged accessory"); } if (!(getConfig().get(Thing.PROPERTY_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { @@ -638,7 +637,7 @@ protected void createProperties() { /** * Wrapper to enable or disable eventing for members of the eventedCharacteristics list of the - * accessory or its children, with exception handling. + * accessory or its bridged accessories, with exception handling. * * @param enable true to enable events, false to disable */ @@ -663,20 +662,20 @@ private void enableEvents(boolean enable) { /** * Inner method to enable or disable eventing for members of the eventedCharacteristics list of the - * accessory or its children. All exceptions are thrown upwards to the caller. + * accessory or its bridged accessories. All exceptions are thrown upwards to the caller. * * @param enable true to enable events, false to disable * @throws Exception the compiler requires us to handle any error; but it will actually be one of the following: - * IllegalStateException if this is a child accessory or if the read/write service is not initialized, - * IllegalAccessException if this is a child accessory, + * IllegalStateException if this is a bridged accessory or if the read/write service is not initialized, + * IllegalAccessException if this is a bridged accessory, * IOException if there is a communication error, * InterruptedException if the operation is interrupted, * TimeoutException if the operation times out, * ExecutionException if there is an execution error */ private void enableEventsOrThrow(boolean enable) throws Exception { - if (isChildAccessory) { - logger.warn("{} forbidden to enable/disable events on child accessory", thing.getUID()); + if (isBridgedAccessory) { + logger.warn("{} forbidden to enable/disable events on bridged accessories", thing.getUID()); return; } Service service = new Service(); @@ -698,7 +697,7 @@ private void enableEventsOrThrow(boolean enable) throws Exception { } /** - * Polls all characteristics in the polledCharacteristics list of the accessory or its children. + * Polls all characteristics in the polledCharacteristics list of the accessory or its bridged accessories. * Called periodically by the refresh task and on-demand when RefreshType.REFRESH is called. */ private synchronized void refresh() { @@ -731,19 +730,19 @@ private synchronized void refresh() { } /** - * Checks if all dependent accessory things have the reached status UNKNOWN, OFFLINE, or ONLINE. + * Checks if all bridged accessory things have the reached status UNKNOWN, OFFLINE, or ONLINE. * Subclasses MUST override this to perform the check. */ - protected abstract boolean dependentThingsInitialized(); + protected abstract boolean bridgedThingsInitialized(); /** - * Called when the root thing has finished loading the accessories. + * Called when the connected thing has finished loading the accessories. * Subclasses MUST override this to perform any extra processing required. */ - protected abstract void onRootThingAccessoriesLoaded(); + protected abstract void onConnectedThingAccessoriesLoaded(); /** - * Gets the evented characteristics list for this accessory or its children. + * Gets the evented characteristics list for this accessory or its bridged accessories. * Subclasses MUST override this to perform any extra processing required. * * @return map of channel UID to characteristic @@ -751,7 +750,7 @@ private synchronized void refresh() { protected abstract Map getEventedCharacteristics(); /** - * Gets the polled characteristics list for this accessory or its children. + * Gets the polled characteristics list for this accessory or its bridged accessories. * Subclasses MUST override this to perform any extra processing required. * * @return map of channel UID to characteristic @@ -762,24 +761,24 @@ private synchronized void refresh() { public abstract void onEvent(String json); /** - * Called when the root thing is fully online. Updates the thing status to ONLINE. And if the thing - * is not a child, enables eventing,and starts the refresh task. + * Called when the thing is fully online. Updates the thing status to ONLINE. And if the + * thing is not a bridged accessory, enables eventing,and starts the refresh task. * Subclasses MAY override this to perform any extra processing required. */ - protected void onRootThingOnline() { + protected void onThingOnline() { updateStatus(ThingStatus.ONLINE); - if (!isChildAccessory) { + if (!isBridgedAccessory) { enableEvents(true); - startRootThingRefreshTask(); + startConnectedThingRefreshTask(); } } /** - * Called when the root thing handler has been initialized, the pairing verified, the accessories - * loaded, and the channels and properties created. Sets up a scheduled task to periodically - * refresh the state of the accessory. + * Called when the connected thing handler has been initialized, the pairing verified, the accessories + * loaded, and the channels and properties created. Sets up a scheduled task to periodically refresh + * the state of the accessory. */ - private void startRootThingRefreshTask() { + private void startConnectedThingRefreshTask() { if (getConfig().get(CONFIG_REFRESH_INTERVAL) instanceof Object refreshInterval) { try { int refreshIntervalSeconds = Integer.parseInt(refreshInterval.toString()); @@ -815,7 +814,7 @@ private void cancelRefreshTasks() { /** * Requests a manual refresh by scheduling a refresh task after a short debounce delay. Defers to the - * bridge handler if this is a child accessory. And if a manual refresh task is already scheduled or + * bridge handler if this is a bridged accessory. And if a manual refresh task is already scheduled or * running, it does nothing more. */ protected void requestManualRefresh() { @@ -830,7 +829,7 @@ protected void requestManualRefresh() { } /** - * Reads characteristic(s) from the accessory. Defers to the bridge handler if this is a child accessory. + * Reads characteristic(s) from the accessory. Defers to the bridge handler if this is a bridged accessory. * * @param query a comma delimited HTTP query string e.g. "1.10,1.11" for aid 1 and iid 10 and 11 * @return JSON response as String @@ -852,7 +851,7 @@ protected String readCharacteristics(String query) throws Exception { } /** - * Writes characteristic(s) to the accessory. Defers to the bridge handler if this is a child accessory. + * Writes characteristic(s) to the accessory. Defers to the bridge handler if this is a bridged accessory. * * @param json the JSON to write * @return the JSON response diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 537937efa4b36..d4c8b1622be4d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -19,7 +19,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.homekit.internal.action.HomekitPairingActions; -import org.openhab.binding.homekit.internal.discovery.HomekitChildDiscoveryService; +import org.openhab.binding.homekit.internal.discovery.HomekitBridgedAccessoryDiscoveryService; import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.persistence.HomekitKeyStore; import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; @@ -37,19 +37,16 @@ import org.osgi.framework.Bundle; /** - * Handler for HomeKit bridge devices. - * It marshals the communications with multiple HomeKit child accessories within a HomeKit bridge server. - * It uses the /accessories endpoint to discover embedded accessories and their services. - * It notifies the {@link HomekitChildDiscoveryService} when accessories are discovered. - * It does not currently handle commands for channels, that is left to the child accessory handlers. - * It extends {@link HomekitBaseAccessoryHandler} to handle pairing and secure session setup. + * Handler for a HomeKit bridge accessory. + * It marshals the communications with multiple HomeKit bridged accessories within a HomeKit bridge. + * It notifies the {@link HomekitBridgedAccessoryDiscoveryService} when bridged accessories are discovered. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault public class HomekitBridgeHandler extends HomekitBaseAccessoryHandler implements BridgeHandler { - private @Nullable HomekitChildDiscoveryService childDiscoveryService = null; + private @Nullable HomekitBridgedAccessoryDiscoveryService bridgedAccessoryDiscoveryService = null; public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, TranslationProvider i18nProvider, Bundle bundle) { @@ -82,7 +79,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { @Override public Collection> getServices() { - return Set.of(HomekitChildDiscoveryService.class, HomekitPairingActions.class); + return Set.of(HomekitBridgedAccessoryDiscoveryService.class, HomekitPairingActions.class); } @Override @@ -96,58 +93,59 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { } @Override - protected boolean dependentThingsInitialized() { - return getThing().getThings().stream().allMatch(child -> ThingHandlerHelper.isHandlerInitialized(child)); + protected boolean bridgedThingsInitialized() { + return getThing().getThings().stream() + .allMatch(bridgedAccessory -> ThingHandlerHelper.isHandlerInitialized(bridgedAccessory)); } @Override - protected void onRootThingAccessoriesLoaded() { + protected void onConnectedThingAccessoriesLoaded() { createProperties(); - getThing().getThings().forEach(child -> { - if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { - childAccessoryHandler.onRootThingAccessoriesLoaded(); + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + accessoryHandler.onConnectedThingAccessoriesLoaded(); } }); } @Override public void onEvent(String jsonContent) { - getThing().getThings().forEach(child -> { - if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { - childAccessoryHandler.onEvent(jsonContent); + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + accessoryHandler.onEvent(jsonContent); } }); } @Override - protected void onRootThingOnline() { + protected void onThingOnline() { updateStatus(ThingStatus.ONLINE); - getThing().getThings().forEach(child -> { - if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { - childAccessoryHandler.onRootThingOnline(); + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + accessoryHandler.onThingOnline(); } }); - super.onRootThingOnline(); - HomekitChildDiscoveryService discoveryService = childDiscoveryService; + super.onThingOnline(); + HomekitBridgedAccessoryDiscoveryService discoveryService = bridgedAccessoryDiscoveryService; if (discoveryService != null) { discoveryService.startScan(); } } - public void registerDiscoveryService(HomekitChildDiscoveryService discoveryService) { - childDiscoveryService = discoveryService; + public void registerDiscoveryService(HomekitBridgedAccessoryDiscoveryService discoveryService) { + bridgedAccessoryDiscoveryService = discoveryService; } public void unregisterDiscoveryService() { - childDiscoveryService = null; + bridgedAccessoryDiscoveryService = null; } @Override protected Map getEventedCharacteristics() { eventedCharacteristics.clear(); - getThing().getThings().forEach(child -> { - if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { - eventedCharacteristics.putAll(childAccessoryHandler.getPolledCharacteristics()); + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + eventedCharacteristics.putAll(accessoryHandler.getPolledCharacteristics()); } }); return eventedCharacteristics; @@ -156,9 +154,9 @@ protected Map getEventedCharacteristics() { @Override protected Map getPolledCharacteristics() { polledCharacteristics.clear(); - getThing().getThings().forEach(child -> { - if (child.getHandler() instanceof HomekitAccessoryHandler childAccessoryHandler) { - polledCharacteristics.putAll(childAccessoryHandler.getPolledCharacteristics()); + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + polledCharacteristics.putAll(accessoryHandler.getPolledCharacteristics()); } }); return polledCharacteristics; From aadf1815ccd092e68a50afe6d175d62d86bed57c Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 26 Nov 2025 23:44:59 +0000 Subject: [PATCH 147/177] adopt reviewer suggestions - part 1 Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 48 ++++++----- .../internal/HomekitBindingConstants.java | 4 +- ...mekitBridgedAccessoryDiscoveryService.java | 2 +- .../HomekitMdnsDiscoveryParticipant.java | 7 +- .../homekit/internal/enums/ServiceType.java | 6 +- .../handler/HomekitBaseAccessoryHandler.java | 6 +- .../hapservices/PairRemoveClient.java | 11 +-- .../internal/hapservices/PairSetupClient.java | 86 +++++++++---------- .../hapservices/PairVerifyClient.java | 59 +++++++------ .../internal/session/HttpPayloadParser.java | 2 +- .../internal/session/SecureSession.java | 12 +-- .../main/resources/OH-INF/config/config.xml | 31 +++++++ .../resources/OH-INF/i18n/homekit.properties | 26 ++---- .../resources/OH-INF/thing/thing-types.xml | 52 +---------- .../internal/TestHttpChunkedParser.java | 15 ++-- .../internal/TestHttpPayloadParser.java | 45 ++++------ 16 files changed, 193 insertions(+), 219 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 0a6016a2a62db..d2ff0f460a861 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -8,11 +8,11 @@ Do not confuse this with the [HomeKit system integration](https://www.openhab.or There are three types of Things supported: - `accessory`: This integrates a single HomeKit accessory, which has its own LAN connection. - Its services appear as channel groups, and their respective characteristics appear as channels. + Its services appear as Channel Groups, and their respective characteristics appear as Channels. - `bridged-accessory`: This integrates a single HomeKit accessory, which does NOT have its own LAN connection. It has the same functionality as an `accessory`, except that its communication is done via a `bridge` (see below). - `bridge`: This integrates a HomeKit bridge accessory, which has its own LAN connection. - It does not have any own channels. + It does not have any own Channels. Instead it contains multiple `bridged-accessory` Things (see above). Things of type `bridge` and `accessory` both communicate directly with their HomeKit accessory device via the LAN. @@ -20,43 +20,44 @@ Whereas `bridged-accessory` Things communicate via their respective `bridge` Thi ## Discovery -Both `accessory` and `bridge` Things will be auto- discovered via mDNS. -And once a `bridge` Thing has been instantiated and paired, its `bridged-accessory` Things will also be auto- discovered. +Both `accessory` and `bridge` Things will be auto-discovered via mDNS. +And once a `bridge` Thing has been instantiated and paired, its `bridged-accessory` Things will also be auto-discovered. ## Configuration for `bridge` and `accessory` Things -The following table shows the thing configuration parameters for `bridge` and `accessory` Things. +The following table shows the Thing configuration parameters for `bridge` and `accessory` Things. | Name | Type | Description | Default | Required | Advanced | |-------------------|---------|------------------------------------------------------|-----------|----------|----------| | `ipAddress` | text | IP v4 address of the HomeKit accessory. | see below | yes | yes | -| `hostName` | text | The fully qualified host name as discovered by mDNS. | see below | yes | yes | +| `httpHostHeader` | text | The fully qualified host name as discovered by mDNS. | see below | yes | yes | | `macAddress` | text | Unique accessory identifier. | see below | yes | yes | | `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | -NOTE: as a general rule, if you create the things via the Inbox, then all of the above configuration parameters will have their proper values already preset. +NOTE: as a general rule, if you create the Things via the Inbox, then all of the above configuration parameters will have their proper values already preset. -As a general rule `ipAddress` is set by the mDNS auto- discovery process. +As a general rule `ipAddress` is set by the mDNS auto-discovery process. However you can configure it manually if you wish. It must match the format `123.123.123.123:4567` representing its IP v4 address and port. -As a general rule `hostName` is set by the mDNS auto- discovery process. +As a general rule `httpHostHeader` is set by the mDNS auto-discovery process. However you can configure it manually if you wish. +The `httpHostHeader` is required for the 'Host:' header of HTTP requests sent to the `accessory` or `bridge`. It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.:1234`) as found manually via (say) an mDNS discovery app. -As a general rule `macAddress` is set by the mDNS auto- discovery process. +As a general rule `macAddress` is set by the mDNS auto-discovery process. However you can configure it manually if you wish. It must be the unique accessory identifier as found manually via (say) an mDNS discovery app. ### Configuration for `bridged-accessory` Things -The following table shows the thing configuration parameters for `bridged-accessory` Things. +The following table shows the Thing configuration parameters for `bridged-accessory` Things. | Name | Type | Description | Default | Required | Advanced | |-------------------|---------|------------------------------------------------------|-----------|----------|----------| | `accessoryID` | integer | ID of the accessory. | see below | yes | yes | -As a general rule `accessoryID` is set by the auto- discovery process. +As a general rule `accessoryID` is set by the auto-discovery process. However you can configure it manually if you wish. It must be the ID of the `bridged-accessory` within the `bridge`. @@ -84,21 +85,28 @@ Whereas for case 2. above, must be `ON`. ## Channels For `accessory` and `bridged-accessory` Things, the Channels are auto-created depending on the services and characteristics published by the HomeKit accessory. -Things of type `bridge` do not have own channels. +Things of type `bridge` do not have own Channels. -As a general rule openHAB has one channel for each HomeKit charactersitic. -Some HomeKit accessories have separate charactersitics for 'target' and 'current' states. -The two charactersitics may have different values (e.g. for a thermostat). -In all such cases the thing has a channel for each characteristic so that both values can be accessed. +As a general rule openHAB has one Channel for each HomeKit characteristic. +Some HomeKit accessories have separate characteristics for 'target' and 'current' states. +The two characteristics may have different values (e.g. for a thermostat). +In all such cases the Thing has a Channel for each characteristic so that both values can be accessed. Some HomeKit characteristics represent fixed information e.g. model number, firmware version, etc. -Such values appear in openHAB as properties of the respectinve thing. +Such values appear in openHAB as properties of the respective Thing. ### Special Extra HSBType Channel -In openHAB the norm is that lighting objects shall be represented by a single `HSBType` channel which manages hue, saturation, brightness, and on-off states. +In openHAB the norm is that lighting objects shall be represented by a single `HSBType` Channel which manages hue, saturation, brightness, and on-off states. By contrast a HomeKit accessory has four separate characteristics for hue, saturation, brightness, and on-off. -So the thing creates one additional `HSBType` channel that amalgamates hue, saturation, brightness, and on-off characteristics, according to the openHAB norm. +So the Thing creates one additional `HSBType` Channel that amalgamates hue, saturation, brightness, and on-off characteristics, according to the openHAB norm. + +## Integration with Apple Home App / Ecosystem + +Many HomeKit accessories are able only to be paired with one client. +This means that if you want to pair such an accessory with this binding, you must first unpair it from the Apple Home app. + +If you want to integrate such an accessory with both this binding and with the Apple Home ecosystem, then you can use this binding to import the Channels as OpenHAB Items, and then use the OpenHAB system integration addon to re-export those Items to the Apple HomeKit eco system. ## File Based Configuration diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 8dae0a73e4431..5f033c4d0866f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -15,6 +15,7 @@ import java.util.regex.Pattern; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.type.ChannelTypeUID; @@ -67,10 +68,11 @@ public class HomekitBindingConstants { public static final String THING_LABEL_FMT = "%s (%s)"; // configuration parameters - public static final String CONFIG_HOST_NAME = "hostName"; + public static final String CONFIG_HTTP_HOST_HEADER = "httpHostHeader"; public static final String CONFIG_IP_ADDRESS = "ipAddress"; public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; public static final String CONFIG_ACCESSORY_ID = "accessoryID"; + public static final String CONFIG_MAC_ADDRESS = Thing.PROPERTY_MAC_ADDRESS; // thing properties public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java index 8c30f177bf37e..2e1299997c8ae 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java @@ -66,7 +66,7 @@ public void startScan() { private void discoverBridgedAccessories(Thing bridge, Collection accessories) { String bridgeMacAddress = thingHandler.getThing().getConfiguration() - .get(Thing.PROPERTY_MAC_ADDRESS) instanceof String mac ? mac : null; + .get(CONFIG_MAC_ADDRESS) instanceof String mac ? mac : null; if (bridgeMacAddress == null) { return; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index 28706781a6754..b900d902a195a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -86,11 +86,11 @@ public String getServiceType() { if (ipAddress != null && macAddress != null && category != null) { DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), macAddress)) // - .withProperty(CONFIG_HOST_NAME, getHostName(service)) // + .withProperty(CONFIG_HTTP_HOST_HEADER, getHostName(service)) // .withProperty(CONFIG_IP_ADDRESS, ipAddress) // - .withProperty(Thing.PROPERTY_MAC_ADDRESS, macAddress) // + .withProperty(CONFIG_MAC_ADDRESS, macAddress) // .withProperty(PROPERTY_ACCESSORY_CATEGORY, category.toString()) // - .withRepresentationProperty(Thing.PROPERTY_MAC_ADDRESS); + .withRepresentationProperty(CONFIG_MAC_ADDRESS); if (properties.get("md") instanceof String model) { builder.withProperty(Thing.PROPERTY_MODEL_ID, model); @@ -132,6 +132,7 @@ public String getServiceType() { /** * The JmDNS library getProperties() method has a bug whereby it fails to return any properties * in the case that the TXT record contains zero length parts. This is a drop in replacement. + * Fixed upstream by https://github.com/jmdns/jmdns/pull/355 */ private Map getProperties(ServiceInfo service) { Map map = new HashMap<>(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java index 2772b93f0bb18..18491d9cf1eaa 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -88,13 +88,13 @@ public enum ServiceType { this.type = type; } - public static ServiceType from(int type) throws IllegalArgumentException { + public static ServiceType from(int id) throws IllegalArgumentException { for (ServiceType value : values()) { - if (value.id == type) { + if (value.id == id) { return value; } } - throw new IllegalArgumentException("Unknown ID: " + type); + throw new IllegalArgumentException("Unknown ID: " + id); } public String getOpenhabType() { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 8d55eb67da2dc..b587427021c24 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -435,7 +435,7 @@ public Collection> getServices() { } private @Nullable String checkedMacAddress() { - if (!(getConfig().get(Thing.PROPERTY_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { + if (!(getConfig().get(CONFIG_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.missing-mac-address", "Missing MAC address", null)); return null; @@ -444,7 +444,7 @@ public Collection> getServices() { } private @Nullable String checkedHostName() { - Object obj = getConfig().get(CONFIG_HOST_NAME); + Object obj = getConfig().get(CONFIG_HTTP_HOST_HEADER); if (obj == null || !(obj instanceof String hostName)) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, "error.invalid-host-name", "Invalid fully qualified host name", null)); @@ -551,7 +551,7 @@ private String unpairInner() { return ACTION_RESULT_ERROR_FORMAT.formatted("bridged accessory"); } - if (!(getConfig().get(Thing.PROPERTY_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { + if (!(getConfig().get(CONFIG_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { logger.warn("{} cannot unpair accessory due to missing mac address configuration", thing.getUID()); return ACTION_RESULT_ERROR_FORMAT.formatted("config error"); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java index d7065b4de6a4a..4f50661144871 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java @@ -56,11 +56,12 @@ public PairRemoveClient(IpTransport ipTransport, byte[] controllerId) { /** * Removes an existing pairing with the accessory. * - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws IllegalStateException + * @throws ExecutionException if there is an error during the HTTP request + * @throws TimeoutException if the HTTP request times out + * @throws InterruptedException if the HTTP request is interrupted + * @throws IOException if there is an I/O error during the HTTP request + * @throws IllegalStateException if the state is invalid + * @throws SecurityException if required keys are missing or state is invalid */ public void remove() throws IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairSetupClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairSetupClient.java index 0eb11ca4ea4d3..55abc568b2cc2 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairSetupClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairSetupClient.java @@ -82,14 +82,14 @@ public PairSetupClient(IpTransport ipTransport, byte[] controllerId, Ed25519Priv * Executes the 6-step pairing process with the accessory. * * @return SessionKeys containing the derived session keys - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws InvalidCipherTextException - * @throws SecurityException - * @throws NoSuchAlgorithmException - * @throws IllegalStateException + * @throws ExecutionException if there is an error during the HTTP requests + * @throws TimeoutException if any HTTP request times out + * @throws InterruptedException if any HTTP request is interrupted + * @throws IOException if there is an I/O error during the HTTP requests + * @throws InvalidCipherTextException if there is an error in cryptographic operations + * @throws SecurityException if required keys are missing or state is invalid + * @throws NoSuchAlgorithmException if a required cryptographic algorithm is not available + * @throws IllegalStateException if the state is invalid */ public Ed25519PublicKeyParameters pair() throws NoSuchAlgorithmException, SecurityException, InvalidCipherTextException, IOException, @@ -102,14 +102,14 @@ public Ed25519PublicKeyParameters pair() * Executes step M1 of the pairing process: Start Pair-Setup. * * @return byte array containing the response from the accessory - * @throws ExecutionException - * @throws TimeoutException - * @throws IOException + * @throws ExecutionException if there is an error during the HTTP request + * @throws TimeoutException if the HTTP request times out + * @throws IOException if there is an I/O error during the HTTP request * @throws InterruptedException if the operation is interrupted - * @throws InvalidCipherTextException - * @throws SecurityException - * @throws NoSuchAlgorithmException - * @throws IllegalStateException + * @throws InvalidCipherTextException if there is an error in cryptographic operations + * @throws SecurityException if required keys are missing or state is invalid + * @throws NoSuchAlgorithmException if a required cryptographic algorithm is not available + * @throws IllegalStateException if the state is invalid */ private SRPclient m1Execute() throws IOException, InterruptedException, TimeoutException, ExecutionException, NoSuchAlgorithmException, SecurityException, InvalidCipherTextException, IllegalStateException { @@ -129,14 +129,14 @@ private SRPclient m1Execute() throws IOException, InterruptedException, TimeoutE * And initializes the SRP client with the received parameters. * * @param m1Response byte array containing the response from step M1 - * @throws NoSuchAlgorithmException - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws InvalidCipherTextException - * @throws SecurityException - * @throws IllegalStateException + * @throws NoSuchAlgorithmException if a required cryptographic algorithm is not available + * @throws ExecutionException if there is an error during the HTTP request + * @throws TimeoutException if the HTTP request times out + * @throws InterruptedException if the operation is interrupted + * @throws IOException if there is an I/O error during the HTTP request + * @throws InvalidCipherTextException if there is an error in cryptographic operations + * @throws SecurityException if required keys are missing or state is invalid + * @throws IllegalStateException if the state is invalid */ private SRPclient m2Execute(byte[] m1Response) throws NoSuchAlgorithmException, SecurityException, InvalidCipherTextException, IOException, @@ -156,13 +156,13 @@ private SRPclient m2Execute(byte[] m1Response) * Executes step M3 of the pairing process: Send client SRP public key & M1 proof. * * @return byte array containing the response from the accessory - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws SecurityException - * @throws InvalidCipherTextException - * @throws IllegalStateException + * @throws ExecutionException if there is an error during the HTTP request + * @throws TimeoutException if the HTTP request times out + * @throws InterruptedException if the operation is interrupted + * @throws IOException if there is an I/O error during the HTTP request + * @throws SecurityException if required keys are missing or state is invalid + * @throws InvalidCipherTextException if there is an error in cryptographic operations + * @throws IllegalStateException if the state is invalid */ private SRPclient m3Execute(SRPclient client) throws SecurityException, IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException, IllegalStateException { @@ -181,12 +181,12 @@ private SRPclient m3Execute(SRPclient client) throws SecurityException, IOExcept * Executes step M4 of the pairing process: Verify accessory SRP proof. * * @param m3Response byte array containing the response from step M3 - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws InvalidCipherTextException - * @throws IllegalStateException + * @throws ExecutionException if there is an error during the HTTP request + * @throws TimeoutException if the HTTP request times out + * @throws InterruptedException if the operation is interrupted + * @throws IOException if there is an I/O error during the HTTP request + * @throws InvalidCipherTextException if there is an error in cryptographic operations + * @throws IllegalStateException if the state is invalid */ private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { @@ -204,12 +204,12 @@ private SRPclient m4Execute(SRPclient client, byte[] m3Response) throws InvalidC * Sends the session key, pairing identifier, client LTPK, and signature to the accessory. * * @return byte array containing the response from the accessory - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws InvalidCipherTextException - * @throws IllegalStateException + * @throws ExecutionException if there is an error during the HTTP request + * @throws TimeoutException if the HTTP request times out + * @throws InterruptedException if the operation is interrupted + * @throws IOException if there is an I/O error during the HTTP request + * @throws InvalidCipherTextException if there is an error in cryptographic operations + * @throws IllegalStateException if the state is invalid */ private SRPclient m5Execute(SRPclient client) throws IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException, IllegalStateException { @@ -229,7 +229,7 @@ private SRPclient m5Execute(SRPclient client) throws IOException, InterruptedExc * Derives and returns the session keys. * * @param m5Response byte array containing the response from step M5 - * @throws InvalidCipherTextException + * @throws InvalidCipherTextException if there is an error in cryptographic operations */ private SRPclient m6Execute(SRPclient client, byte[] m5Response) throws InvalidCipherTextException { logger.debug("Pair-Setup M6: Read accessory id, LTPK, and signature; and verify it"); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairVerifyClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairVerifyClient.java index 15973ce863db2..d240077ed60d4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairVerifyClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairVerifyClient.java @@ -79,12 +79,13 @@ public PairVerifyClient(IpTransport ipTransport, byte[] controllerId, Ed25519Pri * Executes the 4-step pairing verification process with the accessory. * * @return SessionKeys containing the derived session keys - * @throws ExecutionException - * @throws TimeoutException - * @throws InterruptedException - * @throws IOException - * @throws InvalidCipherTextException - * @throws IllegalStateException + * @throws ExecutionException if there is an error during the HTTP request + * @throws TimeoutException if the HTTP request times out + * @throws InterruptedException if the operation is interrupted + * @throws IOException if there is an I/O error during the HTTP request + * @throws SecurityException if required keys are missing or state is invalid + * @throws InvalidCipherTextException if there is an error in cryptographic operations + * @throws IllegalStateException if the state is invalid */ public AsymmetricSessionKeys verify() throws IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException, IllegalStateException { @@ -95,12 +96,12 @@ public AsymmetricSessionKeys verify() throws IOException, InterruptedException, /** * M1 — Create new random client ephemeral X25519 public key and send it to server * - * @throws IOException - * @throws InterruptedException - * @throws TimeoutException - * @throws ExecutionException - * @throws InvalidCipherTextException - * @throws IllegalStateException + * @throws IOException if there is an I/O error during the HTTP request + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the HTTP request times out + * @throws ExecutionException if there is an error during the HTTP request + * @throws InvalidCipherTextException if there is an error in cryptographic operations + * @throws IllegalStateException if the state is invalid */ private void m1Execute() throws IOException, InterruptedException, TimeoutException, ExecutionException, InvalidCipherTextException, IllegalStateException { @@ -117,12 +118,13 @@ private void m1Execute() throws IOException, InterruptedException, TimeoutExcept /** * M2 — Receive server ephemeral X25519 public key and encrypted TLV * - * @param m1Response - * @throws InvalidCipherTextException - * @throws IOException - * @throws InterruptedException - * @throws TimeoutException - * @throws ExecutionException + * @param m1Response the response from step M1 + * + * @throws IOException if there is an I/O error during the HTTP request + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the HTTP request times out + * @throws ExecutionException if there is an error during the HTTP request + * @throws InvalidCipherTextException if there is an error in cryptographic operations */ private void m2Execute(byte[] m1Response) throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException { @@ -142,8 +144,11 @@ private void m2Execute(byte[] m1Response) Map subTlv = Tlv8Codec.decode(plainText); byte[] serverPairingId = subTlv.get(TlvType.IDENTIFIER.value); byte[] serverSignature = subTlv.get(TlvType.SIGNATURE.value); - if (serverPairingId == null || serverSignature == null) { - throw new SecurityException("Accessory identifier or signature missing"); + if (serverPairingId == null) { + throw new SecurityException("Accessory identifier missing"); + } + if (serverSignature == null) { + throw new SecurityException("Accessory signature missing"); } verifySignature(accessoryKey, serverSignature, concat(serverEphemeralPublicKey.getEncoded(), serverPairingId, @@ -155,12 +160,12 @@ private void m2Execute(byte[] m1Response) /** * M3 — Send encrypted controller identifier and signature * - * @throws InvalidCipherTextException - * @throws IOException - * @throws InterruptedException - * @throws TimeoutException - * @throws ExecutionException - * @throws IllegalStateException + * @throws InvalidCipherTextException if there is an error in cryptographic operations + * @throws IOException if there is an I/O error during the HTTP request + * @throws InterruptedException if the operation is interrupted + * @throws TimeoutException if the HTTP request times out + * @throws ExecutionException if there is an error during the HTTP request + * @throws IllegalStateException if the state is invalid */ private void m3Execute() throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, ExecutionException, IllegalStateException { @@ -189,7 +194,7 @@ private void m3Execute() throws InvalidCipherTextException, IOException, Interru /** * M4 — Final confirmation * - * @param m3Response + * @param m3Response the response from step M3 */ private void m4Execute(byte[] m3Response) { logger.debug("Pair-Verify M4: Confirm validation; derive session keys"); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java index 5b9fc356de450..437ae561ba868 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java @@ -223,7 +223,7 @@ private void parseChunkedBytes(byte[] block) throws IllegalStateException { * If the content exceeds the maximum allowed length, a SecurityException is thrown. * * @param data the byte array containing content data to be processed. - * @throws IllegalStateException + * @throws IllegalStateException if the content exceeds maximum allowed length. */ private void processContentBytes(byte[] data) throws IllegalStateException { if (isChunked && !finalChunkSeen) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java index 8be22e7c7157d..bc9ac188668ef 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -101,8 +101,8 @@ private void sendFrame(ByteArrayInputStream plainTextStream) throws IOException, * @param trace if true, captures the raw decrypted frames for debugging purposes. * @return a 3D byte array where the first element is the HTTP headers, the second element is the content, * and the third is the raw trace (if enabled). - * @throws IOException - * @throws InvalidCipherTextException + * @throws IOException if an I/O error occurs + * @throws InvalidCipherTextException if decryption fails * @throws IllegalStateException if the received data is malformed */ public byte[][] receive(boolean trace) throws IOException, InvalidCipherTextException, IllegalStateException { @@ -135,8 +135,8 @@ public byte[][] receive(boolean trace) throws IOException, InvalidCipherTextExce * incremented after reading the frame to ensure nonce uniqueness. * * @return the decrypted plaintext of the single frame. - * @throws IOException - * @throws InvalidCipherTextException + * @throws IOException if an I/O error occurs + * @throws InvalidCipherTextException if decryption fails * @throws IllegalStateException if the frame length is invalid */ private byte[] receiveFrame() throws IOException, InvalidCipherTextException, IllegalStateException { @@ -155,8 +155,8 @@ private byte[] receiveFrame() throws IOException, InvalidCipherTextException, Il /** * Reads bytes from the given input stream until the buffer is completely filled. * - * @param buffer - * @throws IOException + * @param buffer the buffer to fill + * @throws IOException if an I/O error occurs or end of stream is reached before filling the buffer */ private void readFully(InputStream in, byte[] buffer) throws IOException { int offset = 0; diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml new file mode 100644 index 0000000000000..dada73122d94e --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml @@ -0,0 +1,31 @@ + + + + + + network-address + + IP v4 address (and optional port) of the HomeKit device. + + + + Unique accessory identifier. + true + + + + The device fully qualified host name as discovered by mDNS (needed for HTTP Host headers). + true + + + + Interval at which the device is polled in sec. + 60 + true + + + + diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 38b57d63539e4..8f685ec0195dd 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -10,28 +10,20 @@ thing-type.homekit.accessory.description = HomeKit accessory with its own LAN co thing-type.homekit.bridge.label = HomeKit Bridge thing-type.homekit.bridge.description = HomeKit accessory with LAN connection that supports bridged accessories not having an own LAN connection thing-type.homekit.bridged-accessory.label = HomeKit Bridged Accessory -thing-type.homekit.bridged-accessory.description = HomeKit without its own LAN connection and instead supported by a bridge +thing-type.homekit.bridged-accessory.description = HomeKit accessory without its own LAN connection and instead supported by a bridge # thing types config -thing-type.config.homekit.accessory.hostName.label = Host Name -thing-type.config.homekit.accessory.hostName.description = The accessory fully qualified host name as discovered by mDNS. -thing-type.config.homekit.accessory.ipAddress.label = IP Address -thing-type.config.homekit.accessory.ipAddress.description = IP v4 address of the HomeKit accessory. -thing-type.config.homekit.accessory.macAddress.label = MAC Address -thing-type.config.homekit.accessory.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.accessory.refreshInterval.label = Refresh Interval -thing-type.config.homekit.accessory.refreshInterval.description = Interval at which the accessory is polled in sec. -thing-type.config.homekit.bridge.hostName.label = Host Name -thing-type.config.homekit.bridge.hostName.description = The bridge fully qualified host name as discovered by mDNS. -thing-type.config.homekit.bridge.ipAddress.label = IP Address -thing-type.config.homekit.bridge.ipAddress.description = IP v4 address of the HomeKit bridge. -thing-type.config.homekit.bridge.macAddress.label = MAC Address -thing-type.config.homekit.bridge.macAddress.description = Unique accessory identifier. -thing-type.config.homekit.bridge.refreshInterval.label = Refresh Interval -thing-type.config.homekit.bridge.refreshInterval.description = Interval at which the bridge is polled in sec. thing-type.config.homekit.bridged-accessory.accessoryID.label = Accessory ID thing-type.config.homekit.bridged-accessory.accessoryID.description = ID of the accessory. +thing-type.config.homekit.network.httpHostHeader.label = Host Name +thing-type.config.homekit.network.httpHostHeader.description = The device fully qualified host name as discovered by mDNS (needed for HTTP Host headers). +thing-type.config.homekit.network.ipAddress.label = IP Address +thing-type.config.homekit.network.ipAddress.description = IP v4 address (and optional port) of the HomeKit device. +thing-type.config.homekit.network.macAddress.label = MAC Address +thing-type.config.homekit.network.macAddress.description = Unique accessory identifier. +thing-type.config.homekit.network.refreshInterval.label = Refresh Interval +thing-type.config.homekit.network.refreshInterval.description = Interval at which the device is polled in sec. # thing error state messages diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index f7ef1b6c6af9f..fa2eabde72daa 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -7,59 +7,13 @@ HomeKit accessory with its own LAN connection - - - network-address - - IP v4 address of the HomeKit accessory. - true - - - - Unique accessory identifier. - true - - - - The accessory fully qualified host name as discovered by mDNS. - true - - - - Interval at which the accessory is polled in sec. - 60 - true - - + HomeKit accessory with LAN connection that supports bridged accessories not having an own LAN connection - - - network-address - - IP v4 address of the HomeKit bridge. - true - - - - Unique accessory identifier. - true - - - - The bridge fully qualified host name as discovered by mDNS. - true - - - - Interval at which the bridge is polled in sec. - 60 - true - - + @@ -67,7 +21,7 @@ - HomeKit without its own LAN connection and instead supported by a bridge + HomeKit accessory without its own LAN connection and instead supported by a bridge diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java index 54c53227e6cc1..d13d50c0e6bb7 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java @@ -42,7 +42,7 @@ class TestHttpChunkedParser { private final String s9 = "0\r\n"; @Test - void testValidChunkedPayload() { + void testValidChunkedPayload() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(s0.getBytes(StandardCharsets.UTF_8)); parser.accept(s1.getBytes(StandardCharsets.UTF_8)); @@ -57,12 +57,11 @@ void testValidChunkedPayload() { parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); assertTrue(parser.isComplete()); assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); - } catch (IllegalStateException | IOException e) { } } @Test - void testBadChunkedSizePayload() { + void testBadChunkedSizePayload() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(s0.getBytes(StandardCharsets.UTF_8)); parser.accept(s1.getBytes(StandardCharsets.UTF_8)); @@ -75,12 +74,11 @@ void testBadChunkedSizePayload() { parser.accept(s8.getBytes(StandardCharsets.UTF_8)); parser.accept(s9.getBytes(StandardCharsets.UTF_8)); assertThrows(IllegalStateException.class, () -> parser.accept(crlf.getBytes(StandardCharsets.UTF_8))); - } catch (IllegalStateException | IOException e) { } } @Test - void testChunkedPayloadWithEmptyLines() { + void testChunkedPayloadWithEmptyLines() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(s0.getBytes(StandardCharsets.UTF_8)); parser.accept(s1.getBytes(StandardCharsets.UTF_8)); @@ -97,12 +95,11 @@ void testChunkedPayloadWithEmptyLines() { parser.accept(crlf.getBytes(StandardCharsets.UTF_8)); assertTrue(parser.isComplete()); assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); - } catch (IllegalStateException | IOException e) { } } @Test - void testIncompleteChunkedPayload() { + void testIncompleteChunkedPayload() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(s0.getBytes(StandardCharsets.UTF_8)); parser.accept(s1.getBytes(StandardCharsets.UTF_8)); @@ -116,12 +113,11 @@ void testIncompleteChunkedPayload() { parser.accept(s9.getBytes(StandardCharsets.UTF_8)); assertFalse(parser.isComplete()); assertEquals("", new String(parser.getContent(), StandardCharsets.UTF_8)); - } catch (IllegalStateException | IOException e) { } } @Test - void testValidChunkedPayloadWitSplitFrames() { + void testValidChunkedPayloadWitSplitFrames() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(s0.getBytes(StandardCharsets.UTF_8)); parser.accept(s1.getBytes(StandardCharsets.UTF_8)); @@ -140,7 +136,6 @@ void testValidChunkedPayloadWitSplitFrames() { parser.accept("\n".getBytes(StandardCharsets.UTF_8)); assertTrue(parser.isComplete()); assertEquals("123456789123456789abcdef", new String(parser.getContent(), StandardCharsets.UTF_8)); - } catch (IllegalStateException | IOException e) { } } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java index eb71559997351..fbdef733d5f95 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java @@ -47,7 +47,7 @@ class TestHttpPayloadParser { private static final String CRLF = "\r\n"; @Test - void testHttpWithChunkedContentOk() { + void testHttpWithChunkedContentOk() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { String h = HEADERS_A + HEADERS_C + HEADERS_Z; String hc = h + CHUNK.formatted(100) + CONTENT + CRLF + CHUNK.formatted(0) + CRLF; @@ -58,12 +58,11 @@ void testHttpWithChunkedContentOk() { assertEquals(CONTENT, new String(content)); byte[] headers = parser.getHeaders(); assertEquals(h, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithChunkedContentOkManyPartial() { + void testHttpWithChunkedContentOkManyPartial() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(HEADERS_A.substring(0, 8).getBytes()); parser.accept(HEADERS_A.substring(8).getBytes()); @@ -84,12 +83,11 @@ void testHttpWithChunkedContentOkManyPartial() { byte[] headers = parser.getHeaders(); String h = HEADERS_A + HEADERS_C + HEADERS_Z; assertEquals(h, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithChunkedContentOkManyPartialAndSplitChunkHeader() { + void testHttpWithChunkedContentOkManyPartialAndSplitChunkHeader() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(HEADERS_A.substring(0, 8).getBytes()); parser.accept(HEADERS_A.substring(8).getBytes()); @@ -111,12 +109,11 @@ void testHttpWithChunkedContentOkManyPartialAndSplitChunkHeader() { byte[] headers = parser.getHeaders(); String h = HEADERS_A + HEADERS_C + HEADERS_Z; assertEquals(h, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithContentDiscardExtra() { + void testHttpWithContentDiscardExtra() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; String hc = h + CONTENT + "EXTRA"; @@ -127,12 +124,11 @@ void testHttpWithContentDiscardExtra() { assertEquals(CONTENT, new String(content)); byte[] headers = parser.getHeaders(); assertEquals(h, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithContentManyPartialOk() { + void testHttpWithContentManyPartialOk() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(HEADERS_A.substring(0, 11).getBytes()); parser.accept(HEADERS_A.substring(11).getBytes()); @@ -149,12 +145,11 @@ void testHttpWithContentManyPartialOk() { byte[] headers = parser.getHeaders(); String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; assertEquals(h, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithContentManyPartialOkAndSplitCRLF() { + void testHttpWithContentManyPartialOkAndSplitCRLF() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(HEADERS_A.substring(0, 11).getBytes()); parser.accept(HEADERS_A.substring(11).getBytes()); @@ -171,12 +166,11 @@ void testHttpWithContentManyPartialOkAndSplitCRLF() { byte[] headers = parser.getHeaders(); String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; assertEquals(h, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithContentOk() { + void testHttpWithContentOk() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { String h = HEADERS_A + HEADERS_B.formatted(100) + HEADERS_Z; String hc = h + CONTENT; @@ -187,12 +181,11 @@ void testHttpWithContentOk() { assertEquals(CONTENT, new String(content)); byte[] headers = parser.getHeaders(); assertEquals(h, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithMultipleFrames() { + void testHttpWithMultipleFrames() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { String h = HEADERS_A + HEADERS_B.formatted(300) + HEADERS_Z; String hc = h + CONTENT; @@ -203,34 +196,31 @@ void testHttpWithMultipleFrames() { assertTrue(parser.isComplete()); byte[] content = parser.getContent(); assertEquals(300, content.length); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithNoContentLength() { + void testHttpWithNoContentLength() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { String h = HEADERS_A + HEADERS_B; String hc = h + CONTENT; parser.accept(hc.getBytes()); assertFalse(parser.isComplete()); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithWrongContentLength() { + void testHttpWithWrongContentLength() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { String h = HEADERS_A + HEADERS_B.formatted(200) + HEADERS_Z; String hc = h + CONTENT; parser.accept(hc.getBytes()); assertFalse(parser.isComplete()); - } catch (IllegalStateException | IOException e) { } } @Test - void testHttpWithZeroContentLength() { + void testHttpWithZeroContentLength() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { String h = HEADERS_A + HEADERS_B.formatted(0) + HEADERS_Z; String hc = h + CONTENT; @@ -240,12 +230,11 @@ void testHttpWithZeroContentLength() { assertEquals(0, content.length); byte[] headers = parser.getHeaders(); assertEquals(h, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testOk204() { + void testOk204() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(OK_204.getBytes()); assertTrue(parser.isComplete()); @@ -253,12 +242,11 @@ void testOk204() { assertEquals(0, content.length); byte[] headers = parser.getHeaders(); assertEquals(OK_204, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testError403() { + void testError403() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(ERROR_403.getBytes()); parser.accept(CHUNK.formatted(0).getBytes()); @@ -268,12 +256,11 @@ void testError403() { assertEquals(0, content.length); byte[] headers = parser.getHeaders(); assertEquals(ERROR_403, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testError404() { + void testError404() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(ERROR_404.getBytes()); assertTrue(parser.isComplete()); @@ -281,12 +268,11 @@ void testError404() { assertEquals(0, content.length); byte[] headers = parser.getHeaders(); assertEquals(ERROR_404, new String(headers)); - } catch (IllegalStateException | IOException e) { } } @Test - void testError500() { + void testError500() throws IOException { try (HttpPayloadParser parser = new HttpPayloadParser()) { parser.accept(ERROR_500.getBytes()); assertTrue(parser.isComplete()); @@ -294,7 +280,6 @@ void testError500() { assertEquals(0, content.length); byte[] headers = parser.getHeaders(); assertEquals(ERROR_500, new String(headers)); - } catch (IllegalStateException | IOException e) { } } } From 4e20891a65379935918eac525b825eb4a23d57fc Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 26 Nov 2025 23:47:03 +0000 Subject: [PATCH 148/177] typo Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index d2ff0f460a861..1d11a789843e5 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -106,7 +106,7 @@ So the Thing creates one additional `HSBType` Channel that amalgamates hue, satu Many HomeKit accessories are able only to be paired with one client. This means that if you want to pair such an accessory with this binding, you must first unpair it from the Apple Home app. -If you want to integrate such an accessory with both this binding and with the Apple Home ecosystem, then you can use this binding to import the Channels as OpenHAB Items, and then use the OpenHAB system integration addon to re-export those Items to the Apple HomeKit eco system. +If you want to integrate such an accessory with both this binding and with the Apple Home ecosystem, then you can use this binding to import the Channels as openHAB Items, and then use the openHAB system integration addon to re-export those Items to the Apple HomeKit eco system. ## File Based Configuration From 3c0031c147c81ab21e188b2b48b9fa6541a36eac Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 27 Nov 2025 16:23:01 +0000 Subject: [PATCH 149/177] adopt reviewer suggestions - part 2 Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 15 +++--- .../homekit/internal/dto/Characteristic.java | 53 +++++++++++-------- .../handler/HomekitAccessoryHandler.java | 14 +++-- .../handler/HomekitBaseAccessoryHandler.java | 10 ++-- .../TestChannelCreationForAppleJson.java | 10 ++-- .../TestChannelCreationForVeluxJson.java | 10 ++-- 6 files changed, 56 insertions(+), 56 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 5f033c4d0866f..28013456881f4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -17,6 +17,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelTypeUID; /** @@ -34,19 +35,21 @@ public class HomekitBindingConstants { public static final ThingTypeUID THING_TYPE_BRIDGED_ACCESSORY = new ThingTypeUID(BINDING_ID, "bridged-accessory"); public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); - // specific Channel Type UIDs - public static final String FAKE_PROPERTY_CHANNEL = "property-fake-channel"; - public static final ChannelTypeUID FAKE_PROPERTY_CHANNEL_TYPE_UID = new ChannelTypeUID(BINDING_ID, - FAKE_PROPERTY_CHANNEL); + /** + * Some Characteristics have variable values and others remain static over time. The latter are produced with + * a {@link ChannelDefinition} with this channel-type uid. And when Things are created, rather than instantiating + * them as (dynamic data) Channels of the Thing, instead they are added as (static data) Properties of the Thing. + */ + public static final ChannelTypeUID CHANNEL_TYPE_STATIC = new ChannelTypeUID(BINDING_ID, "static-data"); - /* + /** * format string for channel-group-type UIDs which represent services * format: 'channel-group-type'-[serviceIdentifier]-[serviceIid]-[thingId]-[accessoryId] * example: channel-group-type-accessory-information-1-1234567890abcdef-1 */ public static final String CHANNEL_GROUP_TYPE_ID_FMT = "channel-group-type-%s-%d-%s-%s"; - /* + /** * format string for channel-type UIDs which represent characteristics * format: 'channel-type'-[characteristicIdentifier]-[characteristicIid]-[thingId]-[accessoryId] * example: channel-type-occupancy-detected-2694-1234567890abcdef-1 diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 64e15db47c899..ebacc1f820eee 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -35,6 +35,7 @@ import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelDefinitionBuilder; +import org.openhab.core.thing.type.ChannelType; import org.openhab.core.thing.type.ChannelTypeBuilder; import org.openhab.core.thing.type.ChannelTypeUID; import org.openhab.core.thing.type.StateChannelTypeBuilder; @@ -74,12 +75,16 @@ public class Characteristic { public @NonNullByDefault({}) Integer status; /** - * Builds a ChannelType and a ChannelDefinition based on the characteristic properties. - * Registers the ChannelType with the provided HomekitTypeProvider. - * Returns a ChannelDefinition that is specific instance of ChannelType. - * Returns null if the characteristic cannot be mapped to a channel definition. - * Examines characteristic type, data format, permissions, and other properties - * to determine appropriate channel type, item type, tags, category, and attributes. + * Builds a {@link ChannelType} and a {@link ChannelDefinition} based on the characteristic properties. Registers + * the ChannelType with the provided {@link HomekitTypeProvider}, and returns a ChannelDefinition referring to a + * specific instance of this ChannelType, or null if the characteristic cannot be mapped to a channel definition. + *

          + * Examines characteristic type, data format, permissions, and other properties to determine the appropriate channel + * type, item type, tags, category, and attributes. + *

          + * Some Characteristics have variable values and others remain static over time. The latter are produced with + * a special channel-type uid, so that when Things are being created, rather than adding them as (dynamic data) + * Channels of the Thing, instead they are added as (static data) Properties of the Thing. * * @param thingUID the ThingUID to associate the ChannelDefinition with * @param typeProvider the HomekitTypeProvider to register the channel type with @@ -104,6 +109,7 @@ public class Characteristic { boolean isStateChannel = true; boolean isPercentage = "percentage".equals(unit); boolean isEnumLike = false; + boolean isStaticValue = false; String uom = unit == null ? null : switch (unit) { case "celsius" -> "°C"; @@ -173,7 +179,7 @@ public class Characteristic { break; case AIR_PARTICULATE_SIZE: - itemType = FAKE_PROPERTY_CHANNEL; + isStaticValue = true; break; case AIR_PURIFIER_STATE_CURRENT: @@ -354,7 +360,7 @@ public class Characteristic { case FIRMWARE_REVISION: case HARDWARE_REVISION: - itemType = FAKE_PROPERTY_CHANNEL; + isStaticValue = true; break; case HEATER_COOLER_STATE_CURRENT: @@ -445,7 +451,7 @@ public class Characteristic { case IN_USE: case IS_CONFIGURED: - itemType = FAKE_PROPERTY_CHANNEL; + isStaticValue = true; break; case LEAK_DETECTED: @@ -509,7 +515,7 @@ public class Characteristic { case MANUFACTURER: case MODEL: - itemType = FAKE_PROPERTY_CHANNEL; + isStaticValue = true; break; case MOTION_DETECTED: @@ -525,7 +531,7 @@ public class Characteristic { break; case NAME: - itemType = FAKE_PROPERTY_CHANNEL; + isStaticValue = true; break; case NIGHT_VISION: @@ -550,7 +556,7 @@ public class Characteristic { break; case OUTLET_IN_USE: - itemType = FAKE_PROPERTY_CHANNEL; + propertyTag = Property.POWER; break; case PAIRING_FEATURES: @@ -651,7 +657,7 @@ public class Characteristic { break; case SERIAL_NUMBER: - itemType = FAKE_PROPERTY_CHANNEL; + isStaticValue = true; break; case SET_DURATION: @@ -661,7 +667,7 @@ public class Characteristic { break; case SIRI_INPUT_TYPE: - itemType = FAKE_PROPERTY_CHANNEL; + isStaticValue = true; break; case SLAT_STATE_CURRENT: @@ -747,7 +753,7 @@ public class Characteristic { break; case TEMPERATURE_UNITS: - itemType = FAKE_PROPERTY_CHANNEL; + isStaticValue = true; break; case TILT_CURRENT: @@ -760,7 +766,7 @@ public class Characteristic { case TYPE_SLAT: case VALVE_TYPE: case VERSION: - itemType = FAKE_PROPERTY_CHANNEL; + isStaticValue = true; break; case VERTICAL_TILT_CURRENT: @@ -800,17 +806,18 @@ public class Characteristic { } /* - * ================ CREATE FAKE PROPERTY CHANNEL ================= + * ================ CREATE SPECIAL STATIC CHANNEL DEFINITION ================= * - * create and return fake property channel for characteristics that - * are not mapped to a real channel + * If the Characteristic represents read only values that remain static over time, + * then we create a channel definition with a special channel-type uid, so that when + * Things are being created, no such channel gets added to the Thing's Channels, but + * rather the static value gets added to the Thing's Properties. * */ - if (FAKE_PROPERTY_CHANNEL.equals(itemType)) { + if (isStaticValue) { if (value != null && value.isJsonPrimitive()) { - // create fake property channels for characteristics that contain only static information - return new ChannelDefinitionBuilder(characteristicType.toCamelCase(), FAKE_PROPERTY_CHANNEL_TYPE_UID) - .withLabel(value.getAsString()).build(); + return new ChannelDefinitionBuilder("static", CHANNEL_TYPE_STATIC) + .withProperties(Map.of(characteristicType.toCamelCase(), value.getAsString())).build(); } return null; } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index a49a3efc0cb37..89bef8d93e6aa 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -359,15 +359,13 @@ private void createChannels() { chanDef.getChannelTypeUID(), chanDef.getAutoUpdatePolicy(), chanDef.getProperties()); - if (FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(chanDef.getChannelTypeUID())) { - // this is a property, not a channel - String name = chanDef.getId(); - if (chanDef.getLabel() instanceof String value) { - properties.put(name, value); - logger.trace("{} Property '{}:{}'", thing.getUID(), name, value); - } + if (CHANNEL_TYPE_STATIC.equals(chanDef.getChannelTypeUID())) { + // static ChannelDefinition: add as a Property (rather than a Channel) + Map channelProperties = chanDef.getProperties(); + properties.putAll(channelProperties); + logger.trace("{} Property {}", thing.getUID(), channelProperties); } else { - // this is a real channel + // variable ChannelDefinition: add as a Channel (rather than a Property) ChannelType channelType = channelTypeRegistry .getChannelType(chanDef.getChannelTypeUID()); if (channelType == null) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index b587427021c24..fe834a83b6215 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -618,21 +618,21 @@ protected void createProperties() { return; } // search for the accessory information service and collect its properties + Map thingProperties = thing.getProperties(); for (Service service : accessory.services) { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { for (Characteristic characteristic : service.characteristics) { ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thing.getUID(), typeProvider, i18nProvider, bundle); - if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { - String name = channelDef.getId(); - if (channelDef.getLabel() instanceof String value) { - thing.setProperty(name, value); - } + if (channelDef != null && CHANNEL_TYPE_STATIC.equals(channelDef.getChannelTypeUID())) { + // only static ChannelDefinitions contribute to the properties + thingProperties.putAll(channelDef.getProperties()); } } break; // only one accessory information service per accessory } } + thing.setProperties(thingProperties); } /** diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java index 150260ef1cbe5..43de2a3f83485 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java @@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.FAKE_PROPERTY_CHANNEL_TYPE_UID; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.CHANNEL_TYPE_STATIC; import java.math.BigDecimal; import java.util.ArrayList; @@ -458,12 +458,8 @@ void testChannelDefinitions() { for (Characteristic characteristic : service.characteristics) { ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thingUID, typeProvider, i18nProvider, bundle); - if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { - String name = channelDef.getId(); - String value = channelDef.getLabel(); - if (value != null) { - properties.put(name, value); - } + if (channelDef != null && CHANNEL_TYPE_STATIC.equals(channelDef.getChannelTypeUID())) { + properties.putAll(channelDef.getProperties()); } } break; diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java index ca8f95064817e..86fa04e0cdf2b 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.FAKE_PROPERTY_CHANNEL_TYPE_UID; +import static org.openhab.binding.homekit.internal.HomekitBindingConstants.CHANNEL_TYPE_STATIC; import java.math.BigDecimal; import java.util.ArrayList; @@ -1616,12 +1616,8 @@ void testBridge() { for (Characteristic characteristic : service.characteristics) { ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thingUID, typeProvider, i18nProvider, bundle); - if (channelDef != null && FAKE_PROPERTY_CHANNEL_TYPE_UID.equals(channelDef.getChannelTypeUID())) { - String name = channelDef.getId(); - String value = channelDef.getLabel(); - if (value != null) { - properties.put(name, value); - } + if (channelDef != null && CHANNEL_TYPE_STATIC.equals(channelDef.getChannelTypeUID())) { + properties.putAll(channelDef.getProperties()); } } break; From 8f9c9d25e776bfdf9a20809ef382169f994ed404 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 30 Nov 2025 11:34:16 +0000 Subject: [PATCH 150/177] adopt reviewer suggestions Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 18 ++--- .../handler/HomekitAccessoryHandler.java | 41 ++++++----- .../handler/HomekitBaseAccessoryHandler.java | 73 +++++++++---------- .../hapservices/PairRemoveClient.java | 1 - .../main/resources/OH-INF/config/config.xml | 2 +- .../resources/OH-INF/i18n/homekit.properties | 12 +-- 6 files changed, 67 insertions(+), 80 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 1d11a789843e5..f9d19929a0681 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -29,25 +29,19 @@ The following table shows the Thing configuration parameters for `bridge` and `a | Name | Type | Description | Default | Required | Advanced | |-------------------|---------|------------------------------------------------------|-----------|----------|----------| -| `ipAddress` | text | IP v4 address of the HomeKit accessory. | see below | yes | yes | +| `ipAddress` | text | IP v4 address of the HomeKit accessory. | see below | yes | no | | `httpHostHeader` | text | The fully qualified host name as discovered by mDNS. | see below | yes | yes | -| `macAddress` | text | Unique accessory identifier. | see below | yes | yes | +| `macAddress` | text | Unique accessory identifier as discovered by mDNS. | see below | yes | yes | | `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | -NOTE: as a general rule, if you create the Things via the Inbox, then all of the above configuration parameters will have their proper values already preset. +NOTE: as a general rule, if you create the Things via the Inbox from the mDNS discovery result, then all of the above configuration parameters will have their proper values already preset. -As a general rule `ipAddress` is set by the mDNS auto-discovery process. -However you can configure it manually if you wish. -It must match the format `123.123.123.123:4567` representing its IP v4 address and port. +`ipAddress` must match the format `123.123.123.123:4567` representing its IP v4 address and port. -As a general rule `httpHostHeader` is set by the mDNS auto-discovery process. -However you can configure it manually if you wish. -The `httpHostHeader` is required for the 'Host:' header of HTTP requests sent to the `accessory` or `bridge`. +`httpHostHeader` is required for the 'Host:' header of HTTP requests sent to the `accessory` or `bridge`. It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.:1234`) as found manually via (say) an mDNS discovery app. -As a general rule `macAddress` is set by the mDNS auto-discovery process. -However you can configure it manually if you wish. -It must be the unique accessory identifier as found manually via (say) an mDNS discovery app. +`macAddress` must be the unique accessory identifier as found manually via (say) an mDNS discovery app. ### Configuration for `bridged-accessory` Things diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 89bef8d93e6aa..af12cc2b65fc8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -14,6 +14,7 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.io.IOException; import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; @@ -22,6 +23,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ExecutionException; import javax.measure.Unit; import javax.measure.format.MeasurementParseException; @@ -490,17 +492,16 @@ public void handleCommand(ChannelUID channelUID, Command command) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect logger.debug("{} communication error '{}' sending command '{}' to '{}', reconnecting..", thing.getUID(), - e.getMessage(), command, channelUID); + e.getMessage(), command, channelUID, e); scheduleConnectionAttempt(); } else { // other exception; log at warn and don't try to reconnect logger.warn("{} unexpected error '{}' sending command '{}' to '{}'", thing.getUID(), e.getMessage(), - command, channelUID); + command, channelUID, e); } - logger.debug("Stack trace", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/error.error-sending-command:" + e.getMessage()); } - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - i18nProvider.getText(bundle, "error.error-sending-command", "Error sending command", null)); } @Override @@ -626,11 +627,11 @@ private boolean lightModelRefresh(Characteristic cxx) throws IllegalStateExcepti * @param hsbCommand the HSBType command containing hue, saturation, and brightness * @param writer the CharacteristicReadWriteClient to send the command * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: - * ExecutionException, - * TimeoutException, - * InterruptedException, - * IOException, - * IllegalStateException + * @throws ExecutionException if there is an execution error + * @throws TimeoutException if the operation times out + * @throws InterruptedException if the operation is interrupted + * @throws IOException if there is a communication error + * @throws IllegalStateException if the accessory ID or characteristic IID are not initialized */ private void lightModelHandleCommand(Command command) throws Exception { LightModel lightModel = this.lightModel; @@ -783,11 +784,11 @@ private void eventingPollingFinalize(Accessory accessory, Map c * @param channel the channel to read * @return the current state of the channel, or null if not found * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: - * ExecutionException, - * TimeoutException, - * InterruptedException, - * IOException, - * IllegalStateException + * @throws ExecutionException if there is an execution error + * @throws TimeoutException if the operation times out + * @throws InterruptedException if the operation is interrupted + * @throws IOException if there is a communication error + * @throws IllegalStateException if the read/write service is not initialized */ private synchronized @Nullable State readChannel(Channel channel) throws Exception { Long aid = getAccessoryId(); @@ -815,11 +816,11 @@ private void eventingPollingFinalize(Accessory accessory, Map c * @param command the command to send * @param writer the CharacteristicReadWriteClient to send the command * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: - * ExecutionException, - * TimeoutException, - * InterruptedException, - * IOException, - * IllegalStateException + * @throws ExecutionException if there is an execution error + * @throws TimeoutException if the operation times out + * @throws InterruptedException if the operation is interrupted + * @throws IOException if there is a communication error + * @throws IllegalStateException if the accessory ID or characteristic IID are not initialized */ private synchronized void writeChannel(Channel channel, Command command) throws Exception { Long aid = getAccessoryId(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index fe834a83b6215..61a37178c83d9 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -142,11 +142,12 @@ public synchronized String call(Callable task) throws Exception { if (next == null) { notBeforeInstant = next = Instant.now().plus(MIN_INTERVAL); } - long delay = Duration.between(Instant.now(), next).toMillis(); - if (delay > 0) { - delay = Math.min(delay, MIN_INTERVAL.toMillis()); - logger.trace("{} throttling call for {} ms to respect minimum interval", thing.getUID(), delay); - Thread.sleep(delay); + Duration delay = Duration.between(Instant.now(), next); + if (!delay.isNegative() && !delay.isZero()) { + Duration sleepDuration = delay.compareTo(MIN_INTERVAL) < 0 ? delay : MIN_INTERVAL; + logger.trace("{} throttling call for {} to respect minimum interval", thing.getUID(), + sleepDuration); + Thread.sleep(sleepDuration); } return task.call(); } finally { @@ -170,6 +171,8 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { + eventedCharacteristics.clear(); + accessories.clear(); cancelRefreshTasks(); if (!isBridgedAccessory) { try { @@ -276,8 +279,6 @@ public void handleRemoval() { @Override public void initialize() { - eventedCharacteristics.clear(); - accessories.clear(); isBridgedAccessory = getBridge() instanceof Bridge; if (!isBridgedAccessory) { scheduleConnectionAttempt(); @@ -304,8 +305,7 @@ private synchronized boolean verifyPairing() { Ed25519PublicKeyParameters accessoryKey = keyStore.getAccessoryKey(macAddress); if (accessoryKey == null) { logger.debug("{} no stored pairing credentials", thing.getUID()); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.not-paired"); return false; } @@ -332,8 +332,8 @@ private synchronized boolean verifyPairing() { | ExecutionException | IllegalStateException e) { logger.debug("{} restored pairing was not verified", thing.getUID(), e); // pairing restore failed => exit and perhaps try again later - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, - "error.pairing-verification-failed", "Pairing / Verification failed", null)); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/error.pairing-verification-failed:" + e.getMessage()); return false; } } @@ -427,8 +427,7 @@ public Collection> getServices() { private @Nullable String checkedIpAddress() { Object obj = getConfig().get(CONFIG_IP_ADDRESS); if (obj == null || !(obj instanceof String ipAddress) || !IPV4_PATTERN.matcher(ipAddress).matches()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.invalid-ip-address", "Invalid IP address", null)); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.invalid-ip-address"); return null; } return ipAddress; @@ -436,8 +435,7 @@ public Collection> getServices() { private @Nullable String checkedMacAddress() { if (!(getConfig().get(CONFIG_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.missing-mac-address", "Missing MAC address", null)); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.missing-mac-address"); return null; } return macAddress; @@ -446,8 +444,7 @@ public Collection> getServices() { private @Nullable String checkedHostName() { Object obj = getConfig().get(CONFIG_HTTP_HOST_HEADER); if (obj == null || !(obj instanceof String hostName)) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.invalid-host-name", "Invalid fully qualified host name", null)); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.invalid-host-name"); return null; } if (!HOST_PATTERN.matcher(hostName).matches()) { @@ -460,7 +457,7 @@ public Collection> getServices() { accessoryId = getAccessoryId(); if (accessoryId == null) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.invalid-accessory-id", "Invalid accessory ID", null)); + "@text/error.invalid-accessory-id"); return null; } return accessoryId; @@ -472,9 +469,8 @@ public Collection> getServices() { this.ipTransport = ipTransport; return ipTransport; } catch (IOException e) { - logger.warn("{} error '{}' creating transport", thing.getUID(), e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - i18nProvider.getText(bundle, "error.failed-to-connect", "Failed to connect", null)); + "@text/error.failed-to-connect:" + e.getMessage()); } return null; } @@ -532,10 +528,9 @@ public String pair(String code, boolean withExternalAuthentication) { return ACTION_RESULT_OK; // pairing succeeded } catch (Exception e) { // catch all; log all exceptions - logger.warn("{} pairing / verification failed '{}'", thing.getUID(), e.getMessage()); - logger.debug("Stack trace", e); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, i18nProvider.getText(bundle, - "error.pairing-verification-failed", "Pairing / Verification failed", null)); + logger.debug("{} pairing / verification failed '{}'", thing.getUID(), e.getMessage(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/error.pairing-verification-failed:" + e.getMessage()); return ACTION_RESULT_ERROR_FORMAT.formatted("pairing error"); } } @@ -580,8 +575,7 @@ private String unpairInner() { public String unpair() { String result = unpairInner(); if (result.startsWith(ACTION_RESULT_OK)) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - i18nProvider.getText(bundle, "error.not-paired", "Not paired", null)); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.not-paired"); } return result; } @@ -717,15 +711,14 @@ private synchronized void refresh() { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect logger.debug("{} communication error '{}' polling accessories, reconnecting..", thing.getUID(), - e.getMessage()); + e.getMessage(), e); scheduleConnectionAttempt(); } else { // other exception; log at warn and don't try to reconnect - logger.warn("{} unexpected error '{}' polling accessories", thing.getUID(), e.getMessage()); + logger.warn("{} unexpected error '{}' polling accessories", thing.getUID(), e.getMessage(), e); } - logger.debug("Stack trace", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - i18nProvider.getText(bundle, "error.polling-error", "Polling error", null)); + "@text/error.polling-error:" + e.getMessage()); } } @@ -834,11 +827,11 @@ protected void requestManualRefresh() { * @param query a comma delimited HTTP query string e.g. "1.10,1.11" for aid 1 and iid 10 and 11 * @return JSON response as String * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: - * ExecutionException, - * TimeoutException, - * InterruptedException, - * IOException, - * IllegalStateException + * @throws ExecutionException if there is an execution error + * @throws TimeoutException if the operation times out + * @throws InterruptedException if the operation is interrupted + * @throws IOException if there is a communication error + * @throws IllegalStateException if the read/write service is not initialized */ protected String readCharacteristics(String query) throws Exception { CharacteristicReadWriteClient rwService = getBridge() instanceof Bridge bridge @@ -856,11 +849,11 @@ protected String readCharacteristics(String query) throws Exception { * @param json the JSON to write * @return the JSON response * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: - * ExecutionException, - * TimeoutException, - * InterruptedException, - * IOException, - * IllegalStateException + * @throws ExecutionException if there is an execution error + * @throws TimeoutException if the operation times out + * @throws InterruptedException + * @throws IOException if there is a communication error + * @throws IllegalStateException if the read/write service is not initialized */ protected String writeCharacteristics(String json) throws Exception { CharacteristicReadWriteClient rwService = getBridge() instanceof Bridge bridge diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java index 4f50661144871..3cdc573fbf775 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java @@ -48,7 +48,6 @@ public class PairRemoveClient { private final byte[] controllerId; public PairRemoveClient(IpTransport ipTransport, byte[] controllerId) { - logger.debug("Created.."); this.ipTransport = ipTransport; this.controllerId = controllerId; } diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml index dada73122d94e..27290f23d2db7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml @@ -16,7 +16,7 @@ true - + The device fully qualified host name as discovered by mDNS (needed for HTTP Host headers). true diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index 8f685ec0195dd..be3bd5c3d2626 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -16,7 +16,7 @@ thing-type.homekit.bridged-accessory.description = HomeKit accessory without its thing-type.config.homekit.bridged-accessory.accessoryID.label = Accessory ID thing-type.config.homekit.bridged-accessory.accessoryID.description = ID of the accessory. -thing-type.config.homekit.network.httpHostHeader.label = Host Name +thing-type.config.homekit.network.httpHostHeader.label = HTTP Host Header thing-type.config.homekit.network.httpHostHeader.description = The device fully qualified host name as discovered by mDNS (needed for HTTP Host headers). thing-type.config.homekit.network.ipAddress.label = IP Address thing-type.config.homekit.network.ipAddress.description = IP v4 address (and optional port) of the HomeKit device. @@ -29,14 +29,14 @@ thing-type.config.homekit.network.refreshInterval.description = Interval at whic error.bridge-not-connected = Bridge not connected error.invalid-ip-address = Invalid IP address -error.failed-to-connect = Failed to connect +error.failed-to-connect = Failed to connect: ''{0}'' error.invalid-pairing-code = Invalid pairing code error.invalid-accessory-id = Invalid accessory ID -error.invalid-host-name = Invalid fully qualified host name +error.invalid-host-name = Invalid fully qualified HTTP host header error.missing-mac-address = Missing MAC address -error.pairing-verification-failed = Pairing / verification failed -error.polling-error = Polling error -error.error-sending-command = Error sending command +error.pairing-verification-failed = Pairing / verification failed: ''{0}'' +error.polling-error = Polling error: ''{0}'' +error.error-sending-command = Error sending command: ''{0}'' error.not-paired = Not paired # thing actions From e55701a421bb0c931933c07a358b38d6368d7375 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 30 Nov 2025 14:20:59 +0000 Subject: [PATCH 151/177] fix @text argument passing Signed-off-by: Andrew Fiddian-Green --- .../internal/handler/HomekitAccessoryHandler.java | 2 +- .../internal/handler/HomekitBaseAccessoryHandler.java | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index af12cc2b65fc8..300e9410e9cb4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -500,7 +500,7 @@ public void handleCommand(ChannelUID channelUID, Command command) { command, channelUID, e); } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "@text/error.error-sending-command:" + e.getMessage()); + THING_STATUS_FMT.formatted("error.error-sending-command", e.getMessage())); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 61a37178c83d9..ea0ebc3e12767 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -102,6 +102,7 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple private @NonNullByDefault({}) Long accessoryId; protected static final Gson GSON = new Gson(); + protected static final String THING_STATUS_FMT = "@text/%s [\"%s\"]"; /** * Maps of evented and polled Characteristics. @@ -143,7 +144,7 @@ public synchronized String call(Callable task) throws Exception { notBeforeInstant = next = Instant.now().plus(MIN_INTERVAL); } Duration delay = Duration.between(Instant.now(), next); - if (!delay.isNegative() && !delay.isZero()) { + if (!delay.isPositive()) { Duration sleepDuration = delay.compareTo(MIN_INTERVAL) < 0 ? delay : MIN_INTERVAL; logger.trace("{} throttling call for {} to respect minimum interval", thing.getUID(), sleepDuration); @@ -333,7 +334,7 @@ private synchronized boolean verifyPairing() { logger.debug("{} restored pairing was not verified", thing.getUID(), e); // pairing restore failed => exit and perhaps try again later updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "@text/error.pairing-verification-failed:" + e.getMessage()); + THING_STATUS_FMT.formatted("error.pairing-verification-failed", e.getMessage())); return false; } } @@ -470,7 +471,7 @@ public Collection> getServices() { return ipTransport; } catch (IOException e) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "@text/error.failed-to-connect:" + e.getMessage()); + THING_STATUS_FMT.formatted("error.failed-to-connect", e.getMessage())); } return null; } @@ -530,7 +531,7 @@ public String pair(String code, boolean withExternalAuthentication) { // catch all; log all exceptions logger.debug("{} pairing / verification failed '{}'", thing.getUID(), e.getMessage(), e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "@text/error.pairing-verification-failed:" + e.getMessage()); + THING_STATUS_FMT.formatted("error.pairing-verification-failed", e.getMessage())); return ACTION_RESULT_ERROR_FORMAT.formatted("pairing error"); } } @@ -718,7 +719,7 @@ private synchronized void refresh() { logger.warn("{} unexpected error '{}' polling accessories", thing.getUID(), e.getMessage(), e); } updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "@text/error.polling-error:" + e.getMessage()); + THING_STATUS_FMT.formatted("error.polling-error", e.getMessage())); } } From ae994a8032ba29bd666b62f3728251f01ee1f2f8 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 30 Nov 2025 14:50:19 +0000 Subject: [PATCH 152/177] fix writing to immutable map Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitBaseAccessoryHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index ea0ebc3e12767..a082aed130419 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -23,6 +23,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -613,7 +614,7 @@ protected void createProperties() { return; } // search for the accessory information service and collect its properties - Map thingProperties = thing.getProperties(); + Map thingProperties = new HashMap<>(thing.getProperties()); for (Service service : accessory.services) { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { for (Characteristic characteristic : service.characteristics) { From 2c5a98bc6d8d7b01e430a19fe47537f562cbef09 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 30 Nov 2025 23:28:11 +0000 Subject: [PATCH 153/177] Invert delay check for throttling logic Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitBaseAccessoryHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index a082aed130419..b8e45a0120362 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -145,7 +145,7 @@ public synchronized String call(Callable task) throws Exception { notBeforeInstant = next = Instant.now().plus(MIN_INTERVAL); } Duration delay = Duration.between(Instant.now(), next); - if (!delay.isPositive()) { + if (delay.isPositive()) { Duration sleepDuration = delay.compareTo(MIN_INTERVAL) < 0 ? delay : MIN_INTERVAL; logger.trace("{} throttling call for {} to respect minimum interval", thing.getUID(), sleepDuration); From f50242421ee165cd98f86d7ae9b39e958fa8afb7 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 1 Dec 2025 23:52:02 +0000 Subject: [PATCH 154/177] Update bundles/org.openhab.binding.homekit/README.md Co-authored-by: Jacob Laursen Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index f9d19929a0681..1ba16a236e302 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -110,7 +110,7 @@ Things are automatically configured when they are discovered. So for this reason it is difficult to create Things via a '.things' file, and therefore not recommended. ```java -Bridge homekit:bridge:velux "VELUX Gateway" [ host="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", hostName="foobar._hap._tcp.local.", refreshInterval=60 ] { +Bridge homekit:bridge:velux "VELUX Gateway" [ ipAddress="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", httpHostHeader="foobar._hap._tcp.local.", refreshInterval=60 ] { Thing bridged-accessory sensor "VELUX Sensor" @ "Hallway" [ accessoryID=2 ] Thing bridged-accessory skylight_hallway "VELUX Window" @ "Hallway" [ accessoryID=3 ] Thing bridged-accessory skylight_bathroom "VELUX Window" @ "Bathroom" [ accessoryID=4 ] From 564263aa2ce11c74f5a5df3d32acf11d213f3c63 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 2 Dec 2025 12:18:52 +0000 Subject: [PATCH 155/177] various - use new core HexUtils functions - add java doc about Alice and Bob notation - eliminate minor compiler warnings Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 5 +- .../internal/crypto/CryptoConstants.java | 14 ++++ .../homekit/internal/crypto/CryptoUtils.java | 27 -------- .../homekit/internal/crypto/SRPclient.java | 14 ++++ .../homekit/internal/dto/Characteristic.java | 6 +- .../binding/homekit/internal/SRPserver.java | 14 ++++ .../internal/TestAppleTestVectors.java | 68 ++++++++++++------- .../homekit/internal/TestPairSetup.java | 10 +-- .../homekit/internal/TestPairVerify.java | 5 +- 9 files changed, 99 insertions(+), 64 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 28013456881f4..93f39956263e6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -17,7 +17,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; -import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelTypeUID; /** @@ -37,8 +36,8 @@ public class HomekitBindingConstants { /** * Some Characteristics have variable values and others remain static over time. The latter are produced with - * a {@link ChannelDefinition} with this channel-type uid. And when Things are created, rather than instantiating - * them as (dynamic data) Channels of the Thing, instead they are added as (static data) Properties of the Thing. + * a ChannelDefinition with this channel-type uid. And when Things are created, rather than instantiating them + * as (dynamic data) Channels of the Thing, instead they are added as (static data) Properties of the Thing. */ public static final ChannelTypeUID CHANNEL_TYPE_STATIC = new ChannelTypeUID(BINDING_ID, "static-data"); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java index 0179ac691b266..0b297c49ab717 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java @@ -28,6 +28,20 @@ @NonNullByDefault public class CryptoConstants { + /* + * *************************************************************************************** + * + * DEVELOPER NOTE: + * + * Some of the field names in this class follow the Crytographic "Alice and Bob Notation" + * where for example 'A' (uppercase) is the conventional meaning for "Alice's Public Key" + * and 'a' (lowercase) is the conventional meaning for "Alice's Private Key". Such names + * are legal according to Java language syntax, but the openHAB style checker warns about + * some of them. => Please ignore such warnings. + * + * *************************************************************************************** + */ + public static final BigInteger N = new BigInteger(""" FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD EF9519B3 CD3A431B 302B0A6D F25F1437 diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java index 9450d321f25d4..eaf7ab72a06ea 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -130,33 +130,6 @@ public static byte[] signMessage(Ed25519PrivateKeyParameters secretKey, byte[] m return signer.generateSignature(); } - public static BigInteger toBigInteger(String hexBlock) throws IllegalArgumentException { - String plainHex = hexBlock.replaceAll("\\s+", ""); - if (plainHex.length() % 2 != 0) { - throw new IllegalArgumentException("Hex string must have even length"); - } - return new BigInteger(plainHex, 16); - } - - public static byte[] toBytes(String hexBlock) throws IllegalArgumentException { - String plainHex = hexBlock.replaceAll("\\s+", ""); - if (plainHex.length() % 2 != 0) { - throw new IllegalArgumentException("Hex string must have even length"); - } - int length = plainHex.length(); - byte[] result = new byte[length / 2]; - for (int i = 0; i < length; i += 2) { - int hi = Character.digit(plainHex.charAt(i), 16); - int lo = Character.digit(plainHex.charAt(i + 1), 16); - if (hi == -1 || lo == -1) { - throw new IllegalArgumentException( - "Invalid hex character: " + plainHex.charAt(i) + plainHex.charAt(i + 1)); - } - result[i / 2] = (byte) ((hi << 4) + lo); - } - return result; - } - public static String toHex(byte @Nullable [] bytes) { return bytes == null ? "null" : Hex.toHexString(bytes); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java index a2d7935cff9fe..62db5788df52a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -43,6 +43,20 @@ public class SRPclient { private final Logger logger = LoggerFactory.getLogger(SRPclient.class); + /* + * *************************************************************************************** + * + * DEVELOPER NOTE: + * + * Some of the field names in this class follow the Crytographic "Alice and Bob Notation" + * where for example 'A' (uppercase) is the conventional meaning for "Alice's Public Key" + * and 'a' (lowercase) is the conventional meaning for "Alice's Private Key". Such names + * are legal according to Java language syntax, but the openHAB style checker warns about + * some of them. => Please ignore such warnings. + * + * *************************************************************************************** + */ + public final BigInteger A; // client SRP public key public final BigInteger a; // client SRP private ephemeral public final BigInteger B; // server SRP public key diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index ebacc1f820eee..4221972a4e393 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -839,7 +839,8 @@ public class Characteristic { String channelTypeLabel = characteristicType.toString(); if (!isStateChannel) { - typeProvider.putChannelType(ChannelTypeBuilder.trigger(channelTypeUid, channelTypeLabel).build()); + ChannelType channelType = ChannelTypeBuilder.trigger(channelTypeUid, channelTypeLabel).build(); + typeProvider.putChannelType(channelType); } else { if (itemType == null) { @@ -916,7 +917,8 @@ public class Characteristic { } // persist the (state) channel TYPE - typeProvider.putChannelType(chanTypBldr.build()); + ChannelType channelType = chanTypBldr.build(); + typeProvider.putChannelType(channelType); } /* diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java index d1832459a5d5d..7cea3e80655d9 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -39,6 +39,20 @@ @NonNullByDefault public class SRPserver { + /* + * *************************************************************************************** + * + * DEVELOPER NOTE: + * + * Some of the field names in this class follow the Crytographic "Alice and Bob Notation" + * where for example 'A' (uppercase) is the conventional meaning for "Alice's Public Key" + * and 'a' (lowercase) is the conventional meaning for "Alice's Private Key". Such names + * are legal according to Java language syntax, but the openHAB style checker warns about + * some of them. => Please ignore such warnings. + * + * *************************************************************************************** + */ + // Session state public @NonNullByDefault({}) BigInteger A; // client public SRP key public final BigInteger b; // server private SRP key diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java index b61981ec4d5f0..673b00bd39186 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import org.openhab.binding.homekit.internal.crypto.CryptoUtils; import org.openhab.binding.homekit.internal.crypto.SRPclient; +import org.openhab.core.util.HexUtils; /** * Tests to validate the code against the test vectors in Apple HomeKit Accessory Protocol @@ -33,6 +34,21 @@ */ @NonNullByDefault class TestAppleTestVectors { + + /* + * *************************************************************************************** + * + * DEVELOPER NOTE: + * + * Some of the field names in this class follow the Crytographic "Alice and Bob Notation" + * where for example 'A' (uppercase) is the conventional meaning for "Alice's Public Key" + * and 'a' (lowercase) is the conventional meaning for "Alice's Private Key". Such names + * are legal according to Java language syntax, but the openHAB style checker warns about + * some of them. => Please ignore such warnings. + * + * *************************************************************************************** + */ + // Modulus N private static final String N_hex = """ FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 29024E08 8A67CC74 @@ -150,30 +166,30 @@ class TestAppleTestVectors { @Test void testBasicConversions() { - BigInteger N1 = CryptoUtils.toBigInteger(N_hex); + BigInteger N1 = HexUtils.hexBlockToBigInteger(N_hex); assertEquals(3072, N1.bitLength()); - BigInteger N2 = new BigInteger(1, CryptoUtils.toBytes(N_hex)); + BigInteger N2 = new BigInteger(1, HexUtils.hexBlockToBytes(N_hex)); assertEquals(3072, N2.bitLength()); assertEquals(N1, N2); byte[] nBytes = CryptoUtils.toUnsigned(N1, 384); assertEquals(384, nBytes.length); - assertArrayEquals(CryptoUtils.toBytes(N_hex), nBytes); + assertArrayEquals(HexUtils.hexBlockToBytes(N_hex), nBytes); - BigInteger g1 = new BigInteger(1, CryptoUtils.toBytes(g_hex)); + BigInteger g1 = new BigInteger(1, HexUtils.hexBlockToBytes(g_hex)); assertEquals(5, g1.intValue()); - assertEquals(g1, CryptoUtils.toBigInteger(g_hex)); + assertEquals(g1, HexUtils.hexBlockToBigInteger(g_hex)); } @Test void testClientKeyConversion() { - BigInteger N = CryptoUtils.toBigInteger(N_hex); - BigInteger g = CryptoUtils.toBigInteger(g_hex); + BigInteger N = HexUtils.hexBlockToBigInteger(N_hex); + BigInteger g = HexUtils.hexBlockToBigInteger(g_hex); - BigInteger a = CryptoUtils.toBigInteger(a_hex); - BigInteger A = CryptoUtils.toBigInteger(A_hex); + BigInteger a = HexUtils.hexBlockToBigInteger(a_hex); + BigInteger A = HexUtils.hexBlockToBigInteger(A_hex); BigInteger calcA = g.modPow(a, N); @@ -183,23 +199,23 @@ void testClientKeyConversion() { byte[] exp; act = CryptoUtils.toUnsigned(a, 32); - exp = CryptoUtils.toBytes(a_hex); + exp = HexUtils.hexBlockToBytes(a_hex); assertArrayEquals(exp, act); act = CryptoUtils.toUnsigned(A, 384); - exp = CryptoUtils.toBytes(A_hex); + exp = HexUtils.hexBlockToBytes(A_hex); assertArrayEquals(exp, act); } @Test void testClientVectors() { - byte[] a = CryptoUtils.toBytes(a_hex); - byte[] A = CryptoUtils.toBytes(A_hex); - byte[] B = CryptoUtils.toBytes(B_hex); - byte[] s = CryptoUtils.toBytes(s_hex); - byte[] u = CryptoUtils.toBytes(u_hex); - byte[] S = CryptoUtils.toBytes(S_hex); - byte[] K = CryptoUtils.toBytes(K_hex); + byte[] a = HexUtils.hexBlockToBytes(a_hex); + byte[] A = HexUtils.hexBlockToBytes(A_hex); + byte[] B = HexUtils.hexBlockToBytes(B_hex); + byte[] s = HexUtils.hexBlockToBytes(s_hex); + byte[] u = HexUtils.hexBlockToBytes(u_hex); + byte[] S = HexUtils.hexBlockToBytes(S_hex); + byte[] K = HexUtils.hexBlockToBytes(K_hex); AtomicReference clientRef = new AtomicReference<>(); @@ -216,14 +232,14 @@ void testClientVectors() { @Test void testServerVectors() { - byte[] b = CryptoUtils.toBytes(b_hex); - byte[] A = CryptoUtils.toBytes(A_hex); - byte[] B = CryptoUtils.toBytes(B_hex); - byte[] s = CryptoUtils.toBytes(s_hex); - byte[] u = CryptoUtils.toBytes(u_hex); - byte[] v = CryptoUtils.toBytes(v_hex); - byte[] S = CryptoUtils.toBytes(S_hex); - byte[] K = CryptoUtils.toBytes(K_hex); + byte[] b = HexUtils.hexBlockToBytes(b_hex); + byte[] A = HexUtils.hexBlockToBytes(A_hex); + byte[] B = HexUtils.hexBlockToBytes(B_hex); + byte[] s = HexUtils.hexBlockToBytes(s_hex); + byte[] u = HexUtils.hexBlockToBytes(u_hex); + byte[] v = HexUtils.hexBlockToBytes(v_hex); + byte[] S = HexUtils.hexBlockToBytes(S_hex); + byte[] K = HexUtils.hexBlockToBytes(K_hex); Ed25519PrivateKeyParameters dummyLTPK = new Ed25519PrivateKeyParameters(new SecureRandom()); byte[] dummyPID = "serverPairingId".getBytes(StandardCharsets.UTF_8); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java index cd31f5f89d609..f071112f9c9cf 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -38,6 +38,7 @@ import org.openhab.binding.homekit.internal.enums.TlvType; import org.openhab.binding.homekit.internal.hapservices.PairSetupClient; import org.openhab.binding.homekit.internal.transport.IpTransport; +import org.openhab.core.util.HexUtils; /** * Test cases for the {@link PairSetupClient} class. @@ -75,7 +76,8 @@ void testBareCrypto() throws InvalidCipherTextException { @Test void testSrpClient() throws InvalidCipherTextException, NoSuchAlgorithmException { byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); - SRPclient client = new SRPclient("password123", toBytes(SALT_HEX), toBytes(SERVER_PRIVATE_HEX)); + SRPclient client = new SRPclient("password123", HexUtils.hexBlockToBytes(SALT_HEX), + HexUtils.hexBlockToBytes(SERVER_PRIVATE_HEX)); byte[] sharedKey = generateHkdfKey(client.K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); byte[] cipherText = encrypt(sharedKey, PS_M5_NONCE, plainText0, new byte[0]); byte[] plainText1 = decrypt(sharedKey, PS_M5_NONCE, cipherText, new byte[0]); @@ -89,13 +91,13 @@ void testPairSetup() throws NoSuchAlgorithmException, SecurityException, Invalid String password = "password123"; byte[] iOSDeviceId = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; byte[] accessoryId = new byte[] { 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 }; - byte[] serverSalt = toBytes(SALT_HEX); + byte[] serverSalt = HexUtils.hexBlockToBytes(SALT_HEX); // initialize signing keys Ed25519PrivateKeyParameters controllerLongTermSecretKey = new Ed25519PrivateKeyParameters( - toBytes(CLIENT_PRIVATE_HEX)); + HexUtils.hexBlockToBytes(CLIENT_PRIVATE_HEX)); Ed25519PrivateKeyParameters accessoryLongTermSecretKey = new Ed25519PrivateKeyParameters( - toBytes(SERVER_PRIVATE_HEX)); + HexUtils.hexBlockToBytes(SERVER_PRIVATE_HEX)); // create mock IpTransport mockTransport = mock(IpTransport.class); diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java index 7550b656b062e..5f5fecc4bd0f8 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -37,6 +37,7 @@ import org.openhab.binding.homekit.internal.enums.TlvType; import org.openhab.binding.homekit.internal.hapservices.PairVerifyClient; import org.openhab.binding.homekit.internal.transport.IpTransport; +import org.openhab.core.util.HexUtils; /** * Test cases for the {@link PairVerifyClient} class. @@ -58,10 +59,10 @@ class TestPairVerify { byte[] accessoryId = new byte[] { 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 }; private final Ed25519PrivateKeyParameters controllerLongTermPrivateKey = new Ed25519PrivateKeyParameters( - toBytes(CLIENT_PRIVATE_HEX)); + HexUtils.hexBlockToBytes(CLIENT_PRIVATE_HEX)); private final Ed25519PrivateKeyParameters accessoryLongTermPrivateKey = new Ed25519PrivateKeyParameters( - toBytes(SERVER_PRIVATE_HEX)); + HexUtils.hexBlockToBytes(SERVER_PRIVATE_HEX)); private @NonNullByDefault({}) X25519PrivateKeyParameters accessoryEphemeralSecretKey; private @NonNullByDefault({}) X25519PublicKeyParameters controllerEphemeralPublicKey; From c602ca45d1ecd87a119682ecbbb11b377b61de5e Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 4 Dec 2025 18:45:43 +0000 Subject: [PATCH 156/177] sleeker start up process Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitBaseAccessoryHandler.java | 60 ++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index b8e45a0120362..53cd221764674 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -56,7 +56,11 @@ import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.binding.homekit.internal.session.EventListener; import org.openhab.binding.homekit.internal.transport.IpTransport; +import org.openhab.core.events.Event; +import org.openhab.core.events.EventSubscriber; +import org.openhab.core.events.system.StartlevelEvent; import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.service.StartLevelService; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; @@ -65,6 +69,7 @@ import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.thing.type.ChannelDefinition; import org.osgi.framework.Bundle; +import org.osgi.framework.ServiceRegistration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -79,14 +84,12 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler implements EventListener { +public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler implements EventListener, EventSubscriber { private static final int MIN_CONNECTION_ATTEMPT_DELAY_SECONDS = 2; private static final int MAX_CONNECTION_ATTEMPT_DELAY_SECONDS = 600; private static final int MANUAL_REFRESH_DELAY_SECONDS = 3; - private static final Duration HANDLER_INITIALIZATION_TIMEOUT = Duration.ofSeconds(10); - private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); private final Map accessories = new ConcurrentHashMap<>(); private final HomekitKeyStore keyStore; @@ -120,6 +123,8 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected boolean isBridgedAccessory = false; protected final Throttler throttler = new Throttler(); + private @Nullable ServiceRegistration eventSubscription; + /** * A helper class that runs a {@link Callable} and enforces a minimum delay between calls. * This is to avoid overwhelming accessories with too many requests in a short time. @@ -191,6 +196,10 @@ public void dispose() { transport.close(); } ipTransport = null; + if (eventSubscription instanceof ServiceRegistration registration) { + registration.unregister(); + } + eventSubscription = null; super.dispose(); } @@ -227,19 +236,13 @@ private void fetchAccessories() { } /** - * Waits for all bridged accessory things to be initialized, then processes them by calling the + * Called after all bridged accessory things are initialized, and processes them by calling the * overloaded abstract 'onConnectedThingAccessoriesLoaded' methods, and finally calls the * 'onThingOnline' methods (and its eventual overloaded implementations). */ private void processBridgedThings() { - Instant timeout = Instant.now().plus(HANDLER_INITIALIZATION_TIMEOUT); - while (!bridgedThingsInitialized() && Instant.now().isBefore(timeout)) { - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); // shutting down; restore interrupt flag, and exit immediately - return; - } + if (!bridgedThingsInitialized()) { + logger.warn("{} unexpected error: bridged Things not initialized.", thing.getUID()); } onConnectedThingAccessoriesLoaded(); onThingOnline(); @@ -283,11 +286,28 @@ public void handleRemoval() { public void initialize() { isBridgedAccessory = getBridge() instanceof Bridge; if (!isBridgedAccessory) { - scheduleConnectionAttempt(); + // delay connection attempt until Start Level notification via the receive() method below + eventSubscription = bundle.getBundleContext().registerService(EventSubscriber.class.getName(), this, null); } updateStatus(ThingStatus.UNKNOWN); } + /** + * STARTLEVEL_COMPLETE means Thing handlers instantiated, initialize() methods called, and all registries loaded, + * so everything is now finally ready for us to schedule a connection attempt. + */ + @Override + public void receive(Event event) { + if (event instanceof StartlevelEvent sle && sle.getStartlevel() >= StartLevelService.STARTLEVEL_COMPLETE) { + scheduleConnectionAttempt(); + } + } + + @Override + public Set getSubscribedEventTypes() { + return Set.of(StartlevelEvent.TYPE); + } + /** * Restores an existing pairing. * Updates the thing status accordingly. @@ -662,17 +682,15 @@ private void enableEvents(boolean enable) { * * @param enable true to enable events, false to disable * @throws Exception the compiler requires us to handle any error; but it will actually be one of the following: - * IllegalStateException if this is a bridged accessory or if the read/write service is not initialized, - * IllegalAccessException if this is a bridged accessory, - * IOException if there is a communication error, - * InterruptedException if the operation is interrupted, - * TimeoutException if the operation times out, - * ExecutionException if there is an execution error + * @throws IllegalStateException if this is a bridged accessory or if the read/write service is not initialized, + * @throws IOException if there is a communication error, + * @throws InterruptedException if the operation is interrupted, + * @throws TimeoutException if the operation times out, + * @throws ExecutionException if there is an execution error */ private void enableEventsOrThrow(boolean enable) throws Exception { if (isBridgedAccessory) { - logger.warn("{} forbidden to enable/disable events on bridged accessories", thing.getUID()); - return; + throw new IllegalStateException("Forbidden to enable/disable events on bridged accessory"); } Service service = new Service(); service.characteristics = new ArrayList<>(); From 6eafb2e536eb9e0debf9644925a94f7d12bc0105 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 5 Dec 2025 11:01:23 +0000 Subject: [PATCH 157/177] handle case of thing (re)enable after start-up Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitBaseAccessoryHandler.java | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 53cd221764674..7ef3c3c549196 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -69,6 +69,8 @@ import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.thing.type.ChannelDefinition; import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceReference; import org.osgi.framework.ServiceRegistration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -286,15 +288,43 @@ public void handleRemoval() { public void initialize() { isBridgedAccessory = getBridge() instanceof Bridge; if (!isBridgedAccessory) { - // delay connection attempt until Start Level notification via the receive() method below - eventSubscription = bundle.getBundleContext().registerService(EventSubscriber.class.getName(), this, null); + if (alreadyAtStartLevelComplete()) { + // schedule connection attempt immediately + scheduleConnectionAttempt(); + } else { + // delay connection attempt until STARTLEVEL_COMPLETE is signalled via receive() method below + BundleContext context = bundle.getBundleContext(); + eventSubscription = context.registerService(EventSubscriber.class.getName(), this, null); + } } updateStatus(ThingStatus.UNKNOWN); } /** - * STARTLEVEL_COMPLETE means Thing handlers instantiated, initialize() methods called, and all registries loaded, - * so everything is now finally ready for us to schedule a connection attempt. + * Return true if STARTLEVEL_COMPLETE has already been acheived. + *

          + * Note: STARTLEVEL_COMPLETE means all Thing handlers are instantiated and their initialize() methods have + * been called, and the registries for item, thing, and item-channel-links have all been loaded. + */ + private boolean alreadyAtStartLevelComplete() { + BundleContext context = bundle.getBundleContext(); + ServiceReference reference = context.getServiceReference(StartLevelService.class); + if (reference != null && context.getService(reference) instanceof StartLevelService service) { + try { + return service.getStartLevel() >= StartLevelService.STARTLEVEL_COMPLETE; + } finally { + context.ungetService(reference); + } + } + return false; + } + + /** + * When an event is received, checks if {@link StartLevelService#STARTLEVEL_COMPLETE} is reached, and if so + * schedules a connection attempt. + *

          + * Note: STARTLEVEL_COMPLETE means all Thing handlers are instantiated and their initialize() methods have + * been called, and the registries for item, thing, and item-channel-links have all been loaded. */ @Override public void receive(Event event) { From c5b7209bb2d6f64a6e04ef625162940b6c4e3974 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 5 Dec 2025 14:37:36 +0000 Subject: [PATCH 158/177] various - remove unnecessary handler initialized tests - resubscribe event if channel linked - refactoring Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 14 +++-- .../handler/HomekitBaseAccessoryHandler.java | 59 +++++++++---------- .../handler/HomekitBridgeHandler.java | 7 --- 3 files changed, 35 insertions(+), 45 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 300e9410e9cb4..4311b3e796561 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -70,7 +70,6 @@ import org.openhab.core.thing.type.ChannelType; import org.openhab.core.thing.type.ChannelTypeRegistry; import org.openhab.core.thing.type.ChannelTypeUID; -import org.openhab.core.thing.util.ThingHandlerHelper; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; import org.openhab.core.types.State; @@ -906,11 +905,6 @@ protected IpTransport getIpTransport() throws IllegalAccessException { return super.getIpTransport(); } - @Override - protected boolean bridgedThingsInitialized() { - return ThingHandlerHelper.isHandlerInitialized(thing); // no bridged accessories; return own status - } - @Override protected void onConnectedThingAccessoriesLoaded() { createProperties(); @@ -928,7 +922,11 @@ public void onEvent(String json) { */ @Override public void channelLinked(ChannelUID channelUID) { + boolean eventedCharacteristicsChanged = false; try { + if (!alreadyAtStartLevelComplete()) { + return; // item-channel-links not yet fully initialized + } final Channel channel = thing.getChannel(channelUID); if (channel == null) { return; // OH core ensures this does not happen @@ -969,6 +967,7 @@ public void channelLinked(ChannelUID channelUID) { entry.aid = aid; entry.iid = iid; eventedCharacteristics.put(AID_IID_FORMAT.formatted(entry.aid, entry.iid), entry); + eventedCharacteristicsChanged = true; } if (checkChannelLinkByIID) { return; // unique match found; return directly @@ -977,6 +976,9 @@ public void channelLinked(ChannelUID channelUID) { } } } finally { + if (eventedCharacteristicsChanged) { // if evented list changes (re-) enable eventing (using new list) + scheduler.submit(() -> enableEvents(true)); + } super.channelLinked(channelUID); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 7ef3c3c549196..d8593337d64e7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -243,9 +243,6 @@ private void fetchAccessories() { * 'onThingOnline' methods (and its eventual overloaded implementations). */ private void processBridgedThings() { - if (!bridgedThingsInitialized()) { - logger.warn("{} unexpected error: bridged Things not initialized.", thing.getUID()); - } onConnectedThingAccessoriesLoaded(); onThingOnline(); } @@ -291,10 +288,9 @@ public void initialize() { if (alreadyAtStartLevelComplete()) { // schedule connection attempt immediately scheduleConnectionAttempt(); - } else { + } else if (bundle.getBundleContext() instanceof BundleContext ctx) { // delay connection attempt until STARTLEVEL_COMPLETE is signalled via receive() method below - BundleContext context = bundle.getBundleContext(); - eventSubscription = context.registerService(EventSubscriber.class.getName(), this, null); + eventSubscription = ctx.registerService(EventSubscriber.class.getName(), this, null); } } updateStatus(ThingStatus.UNKNOWN); @@ -306,14 +302,16 @@ public void initialize() { * Note: STARTLEVEL_COMPLETE means all Thing handlers are instantiated and their initialize() methods have * been called, and the registries for item, thing, and item-channel-links have all been loaded. */ - private boolean alreadyAtStartLevelComplete() { - BundleContext context = bundle.getBundleContext(); - ServiceReference reference = context.getServiceReference(StartLevelService.class); - if (reference != null && context.getService(reference) instanceof StartLevelService service) { - try { - return service.getStartLevel() >= StartLevelService.STARTLEVEL_COMPLETE; - } finally { - context.ungetService(reference); + protected boolean alreadyAtStartLevelComplete() { + if (bundle.getBundleContext() instanceof BundleContext ctx) { + if (ctx.getServiceReference(StartLevelService.class) instanceof ServiceReference ref) { + if (ctx.getService(ref) instanceof StartLevelService svc) { + try { + return svc.getStartLevel() >= StartLevelService.STARTLEVEL_COMPLETE; + } finally { + ctx.ungetService(ref); + } + } } } return false; @@ -416,12 +414,11 @@ private String normalizePairingCode(String input) throws IllegalArgumentExceptio protected void scheduleConnectionAttempt() { if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { bridgeHandler.scheduleConnectionAttempt(); - } else { - ScheduledFuture task = connectionAttemptTask; - if (task == null || task.isDone() || task.isCancelled()) { - connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, - TimeUnit.SECONDS); - } + return; + } + ScheduledFuture task = connectionAttemptTask; + if (task == null || task.isDone() || task.isCancelled()) { + connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, TimeUnit.SECONDS); } } @@ -687,7 +684,11 @@ protected void createProperties() { * * @param enable true to enable events, false to disable */ - private void enableEvents(boolean enable) { + protected void enableEvents(boolean enable) { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + bridgeHandler.enableEvents(true); + return; + } try { enableEventsOrThrow(enable); } catch (InterruptedException e) { @@ -772,12 +773,6 @@ private synchronized void refresh() { } } - /** - * Checks if all bridged accessory things have the reached status UNKNOWN, OFFLINE, or ONLINE. - * Subclasses MUST override this to perform the check. - */ - protected abstract boolean bridgedThingsInitialized(); - /** * Called when the connected thing has finished loading the accessories. * Subclasses MUST override this to perform any extra processing required. @@ -863,11 +858,11 @@ private void cancelRefreshTasks() { protected void requestManualRefresh() { if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { bridgeHandler.requestManualRefresh(); - } else { - Future task = manualRefreshTask; - if (task == null || task.isDone() || task.isCancelled()) { - manualRefreshTask = scheduler.schedule(this::refresh, MANUAL_REFRESH_DELAY_SECONDS, TimeUnit.SECONDS); - } + return; + } + Future task = manualRefreshTask; + if (task == null || task.isDone() || task.isCancelled()) { + manualRefreshTask = scheduler.schedule(this::refresh, MANUAL_REFRESH_DELAY_SECONDS, TimeUnit.SECONDS); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index d4c8b1622be4d..487f5920096d1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -32,7 +32,6 @@ import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.thing.binding.builder.BridgeBuilder; -import org.openhab.core.thing.util.ThingHandlerHelper; import org.openhab.core.types.Command; import org.osgi.framework.Bundle; @@ -92,12 +91,6 @@ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { // do nothing } - @Override - protected boolean bridgedThingsInitialized() { - return getThing().getThings().stream() - .allMatch(bridgedAccessory -> ThingHandlerHelper.isHandlerInitialized(bridgedAccessory)); - } - @Override protected void onConnectedThingAccessoriesLoaded() { createProperties(); From 11209863fd65b11f953d665081c080c37756452a Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 5 Dec 2025 14:45:46 +0000 Subject: [PATCH 159/177] fix silly error Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitBaseAccessoryHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index d8593337d64e7..6eee24a3591da 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -686,7 +686,7 @@ protected void createProperties() { */ protected void enableEvents(boolean enable) { if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { - bridgeHandler.enableEvents(true); + bridgeHandler.enableEvents(enable); return; } try { From 5c1253969ef4aa54b0adc3749092218bc5eeafb0 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 7 Dec 2025 11:32:44 +0000 Subject: [PATCH 160/177] various - mdns only returns ipv4 address - change macAddress to uniqueId Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 5 +-- .../internal/HomekitBindingConstants.java | 3 +- ...mekitBridgedAccessoryDiscoveryService.java | 8 ++--- .../HomekitMdnsDiscoveryParticipant.java | 33 ++++++++++--------- .../handler/HomekitBaseAccessoryHandler.java | 30 ++++++++--------- .../main/resources/OH-INF/config/config.xml | 4 +-- .../resources/OH-INF/i18n/homekit.properties | 6 ++-- 7 files changed, 46 insertions(+), 43 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index 1ba16a236e302..fb96ee4c78d9c 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -31,7 +31,7 @@ The following table shows the Thing configuration parameters for `bridge` and `a |-------------------|---------|------------------------------------------------------|-----------|----------|----------| | `ipAddress` | text | IP v4 address of the HomeKit accessory. | see below | yes | no | | `httpHostHeader` | text | The fully qualified host name as discovered by mDNS. | see below | yes | yes | -| `macAddress` | text | Unique accessory identifier as discovered by mDNS. | see below | yes | yes | +| `uniqueId` | text | Unique accessory identifier as discovered by mDNS. | see below | yes | yes | | `refreshInterval` | integer | Interval at which the accessory is polled in sec. | 60 | no | yes | NOTE: as a general rule, if you create the Things via the Inbox from the mDNS discovery result, then all of the above configuration parameters will have their proper values already preset. @@ -41,7 +41,8 @@ NOTE: as a general rule, if you create the Things via the Inbox from the mDNS di `httpHostHeader` is required for the 'Host:' header of HTTP requests sent to the `accessory` or `bridge`. It must be the fully qualified host name (e.g. `foobar._hap._tcp.local.` or, if the port is not 0 or 80, `foobar._hap._tcp.local.:1234`) as found manually via (say) an mDNS discovery app. -`macAddress` must be the unique accessory identifier as found manually via (say) an mDNS discovery app. +`uniqueId` must be the unique accessory identifier as found manually via (say) an mDNS discovery app. +Typically it takes the form `00:1A:2B:3C:4D:5E` which is similar to (or the same as) a MAC address. ### Configuration for `bridged-accessory` Things diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 93f39956263e6..91b0a66ebcc74 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -15,7 +15,6 @@ import java.util.regex.Pattern; import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.type.ChannelTypeUID; @@ -74,7 +73,7 @@ public class HomekitBindingConstants { public static final String CONFIG_IP_ADDRESS = "ipAddress"; public static final String CONFIG_REFRESH_INTERVAL = "refreshInterval"; public static final String CONFIG_ACCESSORY_ID = "accessoryID"; - public static final String CONFIG_MAC_ADDRESS = Thing.PROPERTY_MAC_ADDRESS; + public static final String CONFIG_UNIQUE_ID = "uniqueId"; // thing properties public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java index 2e1299997c8ae..50428add87774 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java @@ -65,15 +65,15 @@ public void startScan() { } private void discoverBridgedAccessories(Thing bridge, Collection accessories) { - String bridgeMacAddress = thingHandler.getThing().getConfiguration() - .get(CONFIG_MAC_ADDRESS) instanceof String mac ? mac : null; - if (bridgeMacAddress == null) { + String bridgeUniqueId = thingHandler.getThing().getConfiguration() + .get(CONFIG_UNIQUE_ID) instanceof String uniqueId ? uniqueId : null; + if (bridgeUniqueId == null) { return; } accessories.forEach(accessory -> { if (accessory.aid instanceof Long aid && aid != 1L && accessory.services != null) { ThingUID uid = new ThingUID(THING_TYPE_BRIDGED_ACCESSORY, bridge.getUID(), aid.toString()); - String uniqueId = STRING_AID_FMT.formatted(bridgeMacAddress, aid); + String uniqueId = STRING_AID_FMT.formatted(bridgeUniqueId, aid); String label = THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), uniqueId); thingDiscovered(DiscoveryResultBuilder.create(uid) // .withBridge(bridge.getUID()) // diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java index b900d902a195a..10879fec8601a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -15,8 +15,10 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.Set; import javax.jmdns.ServiceInfo; @@ -35,9 +37,9 @@ /** * Discovers new HomeKit server devices. * HomeKit devices advertise themselves using mDNS with the service type "_hap._tcp.local.". - * Each device is identified by its MAC address, which is included in the mDNS properties. + * Each device is identified by its unique id, which is included in the mDNS properties. * The device category is also included, allowing differentiation between bridges and accessories. - * The discovery participant creates a ThingUID based on the MAC address and device category. + * The discovery participant creates a ThingUID based on the unique id and device category. * Discovered devices are published as Things of type * {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_ACCESSORY} * or {@link org.openhab.binding.homekit.internal.HomekitBindingConstants#THING_TYPE_BRIDGE}. @@ -68,8 +70,9 @@ public String getServiceType() { if (getThingUID(service) instanceof ThingUID uid) { Map properties = getProperties(service); - String macAddress = properties.get("id"); // MAC address - String ipAddress = service.getHostAddresses()[0]; // ipV4 address + String uniqueId = properties.get("id"); // unique id + String ipAddress = Arrays.stream(service.getInet4Addresses()).filter(Objects::nonNull) + .map(ipv4 -> ipv4.getHostAddress()).findFirst().orElse(null); int port = service.getPort(); if (port != 0) { ipAddress = ipAddress + ":" + port; @@ -83,14 +86,14 @@ public String getServiceType() { category = null; } - if (ipAddress != null && macAddress != null && category != null) { + if (ipAddress != null && uniqueId != null && category != null) { DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); - builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), macAddress)) // + builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), uniqueId)) // .withProperty(CONFIG_HTTP_HOST_HEADER, getHostName(service)) // .withProperty(CONFIG_IP_ADDRESS, ipAddress) // - .withProperty(CONFIG_MAC_ADDRESS, macAddress) // + .withProperty(CONFIG_UNIQUE_ID, uniqueId) // .withProperty(PROPERTY_ACCESSORY_CATEGORY, category.toString()) // - .withRepresentationProperty(CONFIG_MAC_ADDRESS); + .withRepresentationProperty(CONFIG_UNIQUE_ID); if (properties.get("md") instanceof String model) { builder.withProperty(Thing.PROPERTY_MODEL_ID, model); @@ -112,18 +115,18 @@ public String getServiceType() { public @Nullable ThingUID getThingUID(ServiceInfo service) { Map properties = getProperties(service); - String mac = properties.get("id"); // MAC address - AccessoryCategory cat; + String uniqueId = properties.get("id"); + AccessoryCategory category; try { String ci = properties.getOrDefault("ci", ""); - cat = AccessoryCategory.from(Integer.parseInt(ci)); + category = AccessoryCategory.from(Integer.parseInt(ci)); } catch (IllegalArgumentException e) { - cat = null; + category = null; } - if (mac != null && cat != null) { - return new ThingUID(AccessoryCategory.BRIDGE == cat ? THING_TYPE_BRIDGE : THING_TYPE_ACCESSORY, - mac.replace(":", "").toLowerCase()); // thing id example "a1b2c3d4e5f6" + if (uniqueId != null && category != null) { + return new ThingUID(AccessoryCategory.BRIDGE == category ? THING_TYPE_BRIDGE : THING_TYPE_ACCESSORY, + uniqueId.replace(":", "").toLowerCase()); // thing id example "a1b2c3d4e5f6" } return null; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 6eee24a3591da..1320b4d784a15 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -344,15 +344,15 @@ private synchronized boolean verifyPairing() { isConfigured = false; Long accessoryId = checkedAccessoryId(); String ipAddress = checkedIpAddress(); - String macAddress = checkedMacAddress(); + String uniqueId = checkedUniqueId(); String hostName = checkedHostName(); - if (accessoryId == null || ipAddress == null || macAddress == null || hostName == null) { + if (accessoryId == null || ipAddress == null || uniqueId == null || hostName == null) { return false; // configuration error } isConfigured = true; // check if we have a stored key - Ed25519PublicKeyParameters accessoryKey = keyStore.getAccessoryKey(macAddress); + Ed25519PublicKeyParameters accessoryKey = keyStore.getAccessoryKey(uniqueId); if (accessoryKey == null) { logger.debug("{} no stored pairing credentials", thing.getUID()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.not-paired"); @@ -482,12 +482,12 @@ public Collection> getServices() { return ipAddress; } - private @Nullable String checkedMacAddress() { - if (!(getConfig().get(CONFIG_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.missing-mac-address"); + private @Nullable String checkedUniqueId() { + if (!(getConfig().get(CONFIG_UNIQUE_ID) instanceof String uniqueId) || uniqueId.isBlank()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.missing-unique-id"); return null; } - return macAddress; + return uniqueId; } private @Nullable String checkedHostName() { @@ -547,14 +547,14 @@ public String pair(String code, boolean withExternalAuthentication) { isConfigured = false; Long accessoryId = checkedAccessoryId(); String ipAddress = checkedIpAddress(); - String macAddress = checkedMacAddress(); + String uniqueId = checkedUniqueId(); String hostName = checkedHostName(); - if (accessoryId == null || ipAddress == null || macAddress == null || hostName == null) { + if (accessoryId == null || ipAddress == null || uniqueId == null || hostName == null) { return ACTION_RESULT_ERROR_FORMAT.formatted("config error"); } isConfigured = true; - if (keyStore.getAccessoryKey(macAddress) != null) { + if (keyStore.getAccessoryKey(uniqueId) != null) { return ACTION_RESULT_OK_FORMAT.formatted("already paired"); // OK if already paired } @@ -569,7 +569,7 @@ public String pair(String code, boolean withExternalAuthentication) { keyStore.getControllerKey(), pairingCode, withExternalAuthentication); Ed25519PublicKeyParameters accessoryKey = pairSetupClient.pair(); - keyStore.setAccessoryKey(macAddress, accessoryKey); + keyStore.setAccessoryKey(uniqueId, accessoryKey); logger.debug("{} completed Pair-Setup; starting Pair-Verify", thing.getUID()); connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; // reset delay on manual pairing @@ -595,19 +595,19 @@ private String unpairInner() { return ACTION_RESULT_ERROR_FORMAT.formatted("bridged accessory"); } - if (!(getConfig().get(CONFIG_MAC_ADDRESS) instanceof String macAddress) || macAddress.isBlank()) { - logger.warn("{} cannot unpair accessory due to missing mac address configuration", thing.getUID()); + if (!(getConfig().get(CONFIG_UNIQUE_ID) instanceof String uid) || uid.isBlank()) { + logger.warn("{} cannot unpair accessory due to missing unique id configuration", thing.getUID()); return ACTION_RESULT_ERROR_FORMAT.formatted("config error"); } - if (keyStore.getAccessoryKey(macAddress) == null) { + if (keyStore.getAccessoryKey(uid) == null) { return ACTION_RESULT_ERROR_FORMAT.formatted("not paired"); } try { PairRemoveClient service = new PairRemoveClient(getIpTransport(), keyStore.getControllerUUID()); service.remove(); - keyStore.setAccessoryKey(macAddress, null); + keyStore.setAccessoryKey(uid, null); return ACTION_RESULT_OK; } catch (IOException | InterruptedException | TimeoutException | ExecutionException | IllegalAccessException | IllegalStateException e) { diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml index 27290f23d2db7..4c5967a752049 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/config/config.xml @@ -10,8 +10,8 @@ IP v4 address (and optional port) of the HomeKit device. - - + + Unique accessory identifier. true diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties index be3bd5c3d2626..aec376eee19ed 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -20,10 +20,10 @@ thing-type.config.homekit.network.httpHostHeader.label = HTTP Host Header thing-type.config.homekit.network.httpHostHeader.description = The device fully qualified host name as discovered by mDNS (needed for HTTP Host headers). thing-type.config.homekit.network.ipAddress.label = IP Address thing-type.config.homekit.network.ipAddress.description = IP v4 address (and optional port) of the HomeKit device. -thing-type.config.homekit.network.macAddress.label = MAC Address -thing-type.config.homekit.network.macAddress.description = Unique accessory identifier. thing-type.config.homekit.network.refreshInterval.label = Refresh Interval thing-type.config.homekit.network.refreshInterval.description = Interval at which the device is polled in sec. +thing-type.config.homekit.network.uniqueId.label = Unique ID +thing-type.config.homekit.network.uniqueId.description = Unique accessory identifier. # thing error state messages @@ -33,7 +33,7 @@ error.failed-to-connect = Failed to connect: ''{0}'' error.invalid-pairing-code = Invalid pairing code error.invalid-accessory-id = Invalid accessory ID error.invalid-host-name = Invalid fully qualified HTTP host header -error.missing-mac-address = Missing MAC address +error.missing-unique-id = Missing unique ID error.pairing-verification-failed = Pairing / verification failed: ''{0}'' error.polling-error = Polling error: ''{0}'' error.error-sending-command = Error sending command: ''{0}'' From 096b26180355c0e7656c25b43651f6ebb1e12d65 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 9 Dec 2025 12:35:55 +0000 Subject: [PATCH 161/177] third attempt; awaiting dependent things Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 33 +++++- .../handler/HomekitBaseAccessoryHandler.java | 104 ++++++------------ .../handler/HomekitBridgeHandler.java | 7 ++ 3 files changed, 71 insertions(+), 73 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 4311b3e796561..07d45252dcdfd 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -56,6 +56,7 @@ import org.openhab.core.library.types.UpDownType; import org.openhab.core.library.unit.Units; import org.openhab.core.semantics.SemanticTag; +import org.openhab.core.service.StartLevelService; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; @@ -79,6 +80,8 @@ import org.openhab.core.types.UnDefType; import org.openhab.core.types.util.UnitUtils; import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -513,7 +516,7 @@ public void initialize() { updateStatus(ThingStatus.ONLINE); }); } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED); } } } @@ -909,6 +912,7 @@ protected IpTransport getIpTransport() throws IllegalAccessException { protected void onConnectedThingAccessoriesLoaded() { createProperties(); createChannels(); + removeNotReadyThing(thing); } @Override @@ -992,4 +996,31 @@ protected Map getEventedCharacteristics() { protected Map getPolledCharacteristics() { return polledCharacteristics; } + + /** + * Return true if STARTLEVEL_COMPLETE has already been acheived. + *

          + * Note: STARTLEVEL_COMPLETE means all Thing handlers are instantiated and their initialize() methods have + * been called, and the registries for item, thing, and item-channel-links have all been loaded. + */ + private boolean alreadyAtStartLevelComplete() { + if (bundle.getBundleContext() instanceof BundleContext ctx) { + if (ctx.getServiceReference(StartLevelService.class) instanceof ServiceReference ref) { + if (ctx.getService(ref) instanceof StartLevelService svc) { + try { + return svc.getStartLevel() >= StartLevelService.STARTLEVEL_COMPLETE; + } finally { + ctx.ungetService(ref); + } + } + } + } + return false; + } + + @Override + protected void initializeNotReadyThings() { + notReadyThings.clear(); + notReadyThings.add(thing); // a self connected accessory requires only itself to be ready + } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 1320b4d784a15..a678c7dc18f75 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -56,11 +56,7 @@ import org.openhab.binding.homekit.internal.persistence.HomekitTypeProvider; import org.openhab.binding.homekit.internal.session.EventListener; import org.openhab.binding.homekit.internal.transport.IpTransport; -import org.openhab.core.events.Event; -import org.openhab.core.events.EventSubscriber; -import org.openhab.core.events.system.StartlevelEvent; import org.openhab.core.i18n.TranslationProvider; -import org.openhab.core.service.StartLevelService; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; @@ -69,9 +65,6 @@ import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.thing.type.ChannelDefinition; import org.osgi.framework.Bundle; -import org.osgi.framework.BundleContext; -import org.osgi.framework.ServiceReference; -import org.osgi.framework.ServiceRegistration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -86,7 +79,7 @@ * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler implements EventListener, EventSubscriber { +public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler implements EventListener { private static final int MIN_CONNECTION_ATTEMPT_DELAY_SECONDS = 2; private static final int MAX_CONNECTION_ATTEMPT_DELAY_SECONDS = 600; @@ -118,6 +111,9 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected final Map eventedCharacteristics = new ConcurrentHashMap<>(); protected final Map polledCharacteristics = new ConcurrentHashMap<>(); + // Set of things that depend on this thing and are not yet ready for processing + protected final Set notReadyThings = ConcurrentHashMap.newKeySet(); + protected final HomekitTypeProvider typeProvider; protected final TranslationProvider i18nProvider; protected final Bundle bundle; @@ -125,8 +121,6 @@ public abstract class HomekitBaseAccessoryHandler extends BaseThingHandler imple protected boolean isBridgedAccessory = false; protected final Throttler throttler = new Throttler(); - private @Nullable ServiceRegistration eventSubscription; - /** * A helper class that runs a {@link Callable} and enforces a minimum delay between calls. * This is to avoid overwhelming accessories with too many requests in a short time. @@ -180,6 +174,7 @@ public HomekitBaseAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider @Override public void dispose() { + notReadyThings.clear(); eventedCharacteristics.clear(); accessories.clear(); cancelRefreshTasks(); @@ -198,10 +193,6 @@ public void dispose() { transport.close(); } ipTransport = null; - if (eventSubscription instanceof ServiceRegistration registration) { - registration.unregister(); - } - eventSubscription = null; super.dispose(); } @@ -222,7 +213,7 @@ private void fetchAccessories() { .collect(Collectors.toMap(a -> a.aid, Function.identity()))); } logger.debug("{} fetched {} accessories", thing.getUID(), accessories.size()); - scheduler.submit(this::processBridgedThings); + scheduler.submit(this::onConnectedThingAccessoriesLoaded); } catch (Exception e) { if (isCommunicationException(e)) { // communication exception; log at debug and try to reconnect @@ -237,16 +228,6 @@ private void fetchAccessories() { } } - /** - * Called after all bridged accessory things are initialized, and processes them by calling the - * overloaded abstract 'onConnectedThingAccessoriesLoaded' methods, and finally calls the - * 'onThingOnline' methods (and its eventual overloaded implementations). - */ - private void processBridgedThings() { - onConnectedThingAccessoriesLoaded(); - onThingOnline(); - } - /** * Returns the accessory ID. For bridges and accessories this is always 1. Whereas for * bridged accessories it comes from the thing's configuration parameter value. @@ -285,55 +266,10 @@ public void handleRemoval() { public void initialize() { isBridgedAccessory = getBridge() instanceof Bridge; if (!isBridgedAccessory) { - if (alreadyAtStartLevelComplete()) { - // schedule connection attempt immediately - scheduleConnectionAttempt(); - } else if (bundle.getBundleContext() instanceof BundleContext ctx) { - // delay connection attempt until STARTLEVEL_COMPLETE is signalled via receive() method below - eventSubscription = ctx.registerService(EventSubscriber.class.getName(), this, null); - } - } - updateStatus(ThingStatus.UNKNOWN); - } - - /** - * Return true if STARTLEVEL_COMPLETE has already been acheived. - *

          - * Note: STARTLEVEL_COMPLETE means all Thing handlers are instantiated and their initialize() methods have - * been called, and the registries for item, thing, and item-channel-links have all been loaded. - */ - protected boolean alreadyAtStartLevelComplete() { - if (bundle.getBundleContext() instanceof BundleContext ctx) { - if (ctx.getServiceReference(StartLevelService.class) instanceof ServiceReference ref) { - if (ctx.getService(ref) instanceof StartLevelService svc) { - try { - return svc.getStartLevel() >= StartLevelService.STARTLEVEL_COMPLETE; - } finally { - ctx.ungetService(ref); - } - } - } - } - return false; - } - - /** - * When an event is received, checks if {@link StartLevelService#STARTLEVEL_COMPLETE} is reached, and if so - * schedules a connection attempt. - *

          - * Note: STARTLEVEL_COMPLETE means all Thing handlers are instantiated and their initialize() methods have - * been called, and the registries for item, thing, and item-channel-links have all been loaded. - */ - @Override - public void receive(Event event) { - if (event instanceof StartlevelEvent sle && sle.getStartlevel() >= StartLevelService.STARTLEVEL_COMPLETE) { + initializeNotReadyThings(); scheduleConnectionAttempt(); } - } - - @Override - public Set getSubscribedEventTypes() { - return Set.of(StartlevelEvent.TYPE); + updateStatus(ThingStatus.UNKNOWN); } /** @@ -798,12 +734,27 @@ private synchronized void refresh() { @Override public abstract void onEvent(String json); + /** + * Called by dependent things when they are finally ready to run. When all dependent things are indeed + * ready the connected thing can start polling, subscribe to events and indicate itself as online. + * + * @param readyThing the dependent thing that has become ready and shall be removed from the not-ready set + */ + protected void removeNotReadyThing(Thing readyThing) { + logger.trace("{} dependent thing {} is now ready", thing.getUID(), readyThing.getUID()); + notReadyThings.remove(readyThing); + if (notReadyThings.isEmpty()) { + onThingOnline(); + } + } + /** * Called when the thing is fully online. Updates the thing status to ONLINE. And if the * thing is not a bridged accessory, enables eventing,and starts the refresh task. * Subclasses MAY override this to perform any extra processing required. */ protected void onThingOnline() { + logger.trace("{} thing going online", thing.getUID()); updateStatus(ThingStatus.ONLINE); if (!isBridgedAccessory) { enableEvents(true); @@ -909,4 +860,13 @@ protected String writeCharacteristics(String json) throws Exception { } return throttler.call(() -> rwService.writeCharacteristics(json)); } + + /** + * Loads the set of things that depend on this accessory and which are not yet ready. This accessory + * cannot go online until all dependent things are ready. In other words, only when all dependent things + * have removed themselves from this set. + * + * Subclasses MUST override this to perform any extra processing required. + */ + protected abstract void initializeNotReadyThings(); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 487f5920096d1..ade4a3828fa44 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -99,6 +99,7 @@ protected void onConnectedThingAccessoriesLoaded() { accessoryHandler.onConnectedThingAccessoriesLoaded(); } }); + onThingOnline(); } @Override @@ -154,4 +155,10 @@ protected Map getPolledCharacteristics() { }); return polledCharacteristics; } + + @Override + protected void initializeNotReadyThings() { + notReadyThings.clear(); + notReadyThings.addAll(getThing().getThings()); // a bridge requires all bridged-accessories to be ready + } } From 2dc0120249859bcbd9a097a882ff3972c65c0c69 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Tue, 9 Dec 2025 22:54:02 +0000 Subject: [PATCH 162/177] adopt some reviewer suggestions Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 07d45252dcdfd..de5fe15fcf55f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -516,7 +516,7 @@ public void initialize() { updateStatus(ThingStatus.ONLINE); }); } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); } } } @@ -998,7 +998,7 @@ protected Map getPolledCharacteristics() { } /** - * Return true if STARTLEVEL_COMPLETE has already been acheived. + * Return true if STARTLEVEL_COMPLETE has already been achieved. *

          * Note: STARTLEVEL_COMPLETE means all Thing handlers are instantiated and their initialize() methods have * been called, and the registries for item, thing, and item-channel-links have all been loaded. From bae66afe25798695c024bfa372ffb0bff3728b5d Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 10 Dec 2025 11:31:16 +0000 Subject: [PATCH 163/177] adapt based on prior review comments - remove start level check - subscribe to evented channel on newly enabled things - remove excess logging - add thing enabled check Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 28 +------------------ .../handler/HomekitBaseAccessoryHandler.java | 2 -- .../handler/HomekitBridgeHandler.java | 3 +- 3 files changed, 3 insertions(+), 30 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index de5fe15fcf55f..910f29338624f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -56,7 +56,6 @@ import org.openhab.core.library.types.UpDownType; import org.openhab.core.library.unit.Units; import org.openhab.core.semantics.SemanticTag; -import org.openhab.core.service.StartLevelService; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; @@ -80,8 +79,6 @@ import org.openhab.core.types.UnDefType; import org.openhab.core.types.util.UnitUtils; import org.osgi.framework.Bundle; -import org.osgi.framework.BundleContext; -import org.osgi.framework.ServiceReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -513,6 +510,7 @@ public void initialize() { if (getBridge() instanceof Bridge bridge && bridge.getStatus() == ThingStatus.ONLINE) { scheduler.submit(() -> { onConnectedThingAccessoriesLoaded(); + enableEvents(true); updateStatus(ThingStatus.ONLINE); }); } else { @@ -928,9 +926,6 @@ public void onEvent(String json) { public void channelLinked(ChannelUID channelUID) { boolean eventedCharacteristicsChanged = false; try { - if (!alreadyAtStartLevelComplete()) { - return; // item-channel-links not yet fully initialized - } final Channel channel = thing.getChannel(channelUID); if (channel == null) { return; // OH core ensures this does not happen @@ -997,27 +992,6 @@ protected Map getPolledCharacteristics() { return polledCharacteristics; } - /** - * Return true if STARTLEVEL_COMPLETE has already been achieved. - *

          - * Note: STARTLEVEL_COMPLETE means all Thing handlers are instantiated and their initialize() methods have - * been called, and the registries for item, thing, and item-channel-links have all been loaded. - */ - private boolean alreadyAtStartLevelComplete() { - if (bundle.getBundleContext() instanceof BundleContext ctx) { - if (ctx.getServiceReference(StartLevelService.class) instanceof ServiceReference ref) { - if (ctx.getService(ref) instanceof StartLevelService svc) { - try { - return svc.getStartLevel() >= StartLevelService.STARTLEVEL_COMPLETE; - } finally { - ctx.ungetService(ref); - } - } - } - } - return false; - } - @Override protected void initializeNotReadyThings() { notReadyThings.clear(); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index a678c7dc18f75..832af2597db29 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -741,7 +741,6 @@ private synchronized void refresh() { * @param readyThing the dependent thing that has become ready and shall be removed from the not-ready set */ protected void removeNotReadyThing(Thing readyThing) { - logger.trace("{} dependent thing {} is now ready", thing.getUID(), readyThing.getUID()); notReadyThings.remove(readyThing); if (notReadyThings.isEmpty()) { onThingOnline(); @@ -754,7 +753,6 @@ protected void removeNotReadyThing(Thing readyThing) { * Subclasses MAY override this to perform any extra processing required. */ protected void onThingOnline() { - logger.trace("{} thing going online", thing.getUID()); updateStatus(ThingStatus.ONLINE); if (!isBridgedAccessory) { enableEvents(true); diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index ade4a3828fa44..621c7bd702ceb 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -159,6 +159,7 @@ protected Map getPolledCharacteristics() { @Override protected void initializeNotReadyThings() { notReadyThings.clear(); - notReadyThings.addAll(getThing().getThings()); // a bridge requires all bridged-accessories to be ready + // a bridge requires all enabled bridged-accessories to be ready + notReadyThings.addAll(getThing().getThings().stream().filter(thing -> thing.isEnabled()).toList()); } } From 22d983f9c8f607da7ea43e73822c121e353b3015 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 10 Dec 2025 19:16:40 +0000 Subject: [PATCH 164/177] adopt some reviewer suggestions Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/handler/HomekitAccessoryHandler.java | 2 +- .../internal/handler/HomekitBaseAccessoryHandler.java | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 910f29338624f..2047ec08d57b8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -910,7 +910,7 @@ protected IpTransport getIpTransport() throws IllegalAccessException { protected void onConnectedThingAccessoriesLoaded() { createProperties(); createChannels(); - removeNotReadyThing(thing); + markAsReady(thing); } @Override diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 832af2597db29..91a3c4841595d 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -740,7 +740,11 @@ private synchronized void refresh() { * * @param readyThing the dependent thing that has become ready and shall be removed from the not-ready set */ - protected void removeNotReadyThing(Thing readyThing) { + protected void markAsReady(Thing readyThing) { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + bridgeHandler.markAsReady(readyThing); + return; + } notReadyThings.remove(readyThing); if (notReadyThings.isEmpty()) { onThingOnline(); @@ -749,7 +753,7 @@ protected void removeNotReadyThing(Thing readyThing) { /** * Called when the thing is fully online. Updates the thing status to ONLINE. And if the - * thing is not a bridged accessory, enables eventing,and starts the refresh task. + * thing is not a bridged accessory, enables eventing, and starts the refresh task. * Subclasses MAY override this to perform any extra processing required. */ protected void onThingOnline() { From f9ebe1670bae192f34aab6f61781940c95b34640 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 10 Dec 2025 23:02:25 +0000 Subject: [PATCH 165/177] sacrifice javadoc clarity for fewer code style warnings Signed-off-by: Andrew Fiddian-Green --- .../internal/handler/HomekitAccessoryHandler.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 2047ec08d57b8..66d29c5615b2e 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -14,7 +14,6 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; -import java.io.IOException; import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; @@ -23,7 +22,6 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.ExecutionException; import javax.measure.Unit; import javax.measure.format.MeasurementParseException; @@ -627,11 +625,11 @@ private boolean lightModelRefresh(Characteristic cxx) throws IllegalStateExcepti * @param hsbCommand the HSBType command containing hue, saturation, and brightness * @param writer the CharacteristicReadWriteClient to send the command * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: - * @throws ExecutionException if there is an execution error * @throws TimeoutException if the operation times out * @throws InterruptedException if the operation is interrupted - * @throws IOException if there is a communication error * @throws IllegalStateException if the accessory ID or characteristic IID are not initialized + * plus ExecutionException if there is an execution error + * plus IOException if there is a communication error */ private void lightModelHandleCommand(Command command) throws Exception { LightModel lightModel = this.lightModel; @@ -784,11 +782,11 @@ private void eventingPollingFinalize(Accessory accessory, Map c * @param channel the channel to read * @return the current state of the channel, or null if not found * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: - * @throws ExecutionException if there is an execution error * @throws TimeoutException if the operation times out * @throws InterruptedException if the operation is interrupted - * @throws IOException if there is a communication error * @throws IllegalStateException if the read/write service is not initialized + * plus ExecutionException if there is an execution error + * plus IOException if there is a communication error */ private synchronized @Nullable State readChannel(Channel channel) throws Exception { Long aid = getAccessoryId(); @@ -816,11 +814,11 @@ private void eventingPollingFinalize(Accessory accessory, Map c * @param command the command to send * @param writer the CharacteristicReadWriteClient to send the command * @throws Exception compiler requires us to handle any exception; but actually will be one of the following: - * @throws ExecutionException if there is an execution error * @throws TimeoutException if the operation times out * @throws InterruptedException if the operation is interrupted - * @throws IOException if there is a communication error * @throws IllegalStateException if the accessory ID or characteristic IID are not initialized + * plus ExecutionException if there is an execution error + * plus IOException if there is a communication error */ private synchronized void writeChannel(Channel channel, Command command) throws Exception { Long aid = getAccessoryId(); From dac81d545b736462751fc5457698b2ad0ece6c14 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Wed, 10 Dec 2025 23:06:44 +0000 Subject: [PATCH 166/177] use fully qualified class names Signed-off-by: Andrew Fiddian-Green --- .../internal/handler/HomekitAccessoryHandler.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 66d29c5615b2e..201b31c996ad1 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -628,8 +628,8 @@ private boolean lightModelRefresh(Characteristic cxx) throws IllegalStateExcepti * @throws TimeoutException if the operation times out * @throws InterruptedException if the operation is interrupted * @throws IllegalStateException if the accessory ID or characteristic IID are not initialized - * plus ExecutionException if there is an execution error - * plus IOException if there is a communication error + * @throws java.util.concurrent.ExecutionException if there is an execution error + * @throws java.io.IOException if there is a communication error */ private void lightModelHandleCommand(Command command) throws Exception { LightModel lightModel = this.lightModel; @@ -785,8 +785,8 @@ private void eventingPollingFinalize(Accessory accessory, Map c * @throws TimeoutException if the operation times out * @throws InterruptedException if the operation is interrupted * @throws IllegalStateException if the read/write service is not initialized - * plus ExecutionException if there is an execution error - * plus IOException if there is a communication error + * @throws java.util.concurrent.ExecutionException if there is an execution error + * @throws java.io.IOException if there is a communication error */ private synchronized @Nullable State readChannel(Channel channel) throws Exception { Long aid = getAccessoryId(); @@ -817,8 +817,8 @@ private void eventingPollingFinalize(Accessory accessory, Map c * @throws TimeoutException if the operation times out * @throws InterruptedException if the operation is interrupted * @throws IllegalStateException if the accessory ID or characteristic IID are not initialized - * plus ExecutionException if there is an execution error - * plus IOException if there is a communication error + * @throws java.util.concurrent.ExecutionException if there is an execution error + * @throws java.io.IOException if there is a communication error */ private synchronized void writeChannel(Channel channel, Command command) throws Exception { Long aid = getAccessoryId(); From dfeb6b4d793d033c5d4b6f95bb501c2819a47ae2 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 11 Dec 2025 15:25:19 +0000 Subject: [PATCH 167/177] split signal paths for channel definitions and properties Signed-off-by: Andrew Fiddian-Green --- .../internal/HomekitBindingConstants.java | 10 +- ...mekitBridgedAccessoryDiscoveryService.java | 4 +- .../homekit/internal/dto/Accessory.java | 28 +++- .../homekit/internal/dto/Characteristic.java | 31 ++--- .../binding/homekit/internal/dto/Content.java | 29 ++++ .../binding/homekit/internal/dto/Service.java | 23 +++- .../handler/HomekitAccessoryHandler.java | 128 ++++++++---------- .../handler/HomekitBaseAccessoryHandler.java | 10 +- .../TestChannelCreationForAppleJson.java | 13 +- .../TestChannelCreationForAqaraJson.java | 41 ++++-- .../TestChannelCreationForVeluxJson.java | 17 +-- 11 files changed, 183 insertions(+), 151 deletions(-) create mode 100644 bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Content.java diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java index 91b0a66ebcc74..6e769f1f43212 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.java @@ -16,7 +16,6 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.ThingTypeUID; -import org.openhab.core.thing.type.ChannelTypeUID; /** * Defines common constants which are used across the whole HomeKit binding. @@ -33,13 +32,6 @@ public class HomekitBindingConstants { public static final ThingTypeUID THING_TYPE_BRIDGED_ACCESSORY = new ThingTypeUID(BINDING_ID, "bridged-accessory"); public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); - /** - * Some Characteristics have variable values and others remain static over time. The latter are produced with - * a ChannelDefinition with this channel-type uid. And when Things are created, rather than instantiating them - * as (dynamic data) Channels of the Thing, instead they are added as (static data) Properties of the Thing. - */ - public static final ChannelTypeUID CHANNEL_TYPE_STATIC = new ChannelTypeUID(BINDING_ID, "static-data"); - /** * format string for channel-group-type UIDs which represent services * format: 'channel-group-type'-[serviceIdentifier]-[serviceIid]-[thingId]-[accessoryId] @@ -78,7 +70,7 @@ public class HomekitBindingConstants { // thing properties public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; - public static final String PROPERTY_REPRESENTATION = "representationProperty"; + public static final String PROPERTY_UNIQUE_ID = CONFIG_UNIQUE_ID; // channel properties public static final String PROPERTY_IID = "iid"; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java index 50428add87774..70b76ccaeae39 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java @@ -79,8 +79,8 @@ private void discoverBridgedAccessories(Thing bridge, Collection acce .withBridge(bridge.getUID()) // .withLabel(label) // .withProperty(CONFIG_ACCESSORY_ID, aid.toString()) // - .withProperty(PROPERTY_REPRESENTATION, uniqueId) - .withRepresentationProperty(PROPERTY_REPRESENTATION).build()); + .withProperty(PROPERTY_UNIQUE_ID, uniqueId).withRepresentationProperty(PROPERTY_UNIQUE_ID) + .build()); } }); } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index afc31e91e0f93..2de26a2117170 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -12,8 +12,12 @@ */ package org.openhab.binding.homekit.internal.dto; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Map.Entry; import java.util.Objects; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -60,13 +64,29 @@ public class Accessory { * @param typeProvider the HomeKit type provider used to look up channel group definitions. * @return a list of channel group definition instances for the services of this accessory. */ - public List buildAndRegisterChannelGroupDefinitions(ThingUID thingUID, - HomekitTypeProvider typeProvider, TranslationProvider i18nProvider, Bundle bundle) { - return services.stream() - .map(s -> s.buildAndRegisterChannelGroupDefinition(thingUID, typeProvider, i18nProvider, bundle)) + public List getChannelGroupDefinitions(ThingUID thingUID, HomekitTypeProvider typeProvider, + TranslationProvider i18nProvider, Bundle bundle) { + return services.stream().map(s -> s.getChannelGroupDefinition(thingUID, typeProvider, i18nProvider, bundle)) .filter(Objects::nonNull).toList(); } + /** + * Returns a property map from all characteristics of all services. In which if multiple characteristics + * provide the same property name, their values are concatenated. This may for example occur if an accessory + * hosts multiple services each having a characteristic for e.g. a "name" property. + * + * DEVELOPER NOTE: strictly speaking merging "name" properties from multiple characteristics is somewhat + * dubious, since in reality each is the name of a channel-group and neither is the name of the thing. But + * we are ignoring this for the time being. + */ + public Map getProperties(ThingUID thingUID, HomekitTypeProvider typeProvider, + TranslationProvider i18nProvider, Bundle bundle) { + return services.stream() + .flatMap(s -> s.getProperties(thingUID, typeProvider, i18nProvider, bundle).entrySet().stream()) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue, + (v1, v2) -> v1.contains(v2) ? v1 : v1 + ", " + v2, LinkedHashMap::new)); + } + public AccessoryCategory getAccessoryType() { Integer category = this.category; if (category == null) { diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 4221972a4e393..69dbdff5cd73a 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -33,7 +33,6 @@ import org.openhab.core.semantics.model.DefaultSemanticTags.Point; import org.openhab.core.semantics.model.DefaultSemanticTags.Property; import org.openhab.core.thing.ThingUID; -import org.openhab.core.thing.type.ChannelDefinition; import org.openhab.core.thing.type.ChannelDefinitionBuilder; import org.openhab.core.thing.type.ChannelType; import org.openhab.core.thing.type.ChannelTypeBuilder; @@ -75,23 +74,18 @@ public class Characteristic { public @NonNullByDefault({}) Integer status; /** - * Builds a {@link ChannelType} and a {@link ChannelDefinition} based on the characteristic properties. Registers - * the ChannelType with the provided {@link HomekitTypeProvider}, and returns a ChannelDefinition referring to a - * specific instance of this ChannelType, or null if the characteristic cannot be mapped to a channel definition. - *

          - * Examines characteristic type, data format, permissions, and other properties to determine the appropriate channel - * type, item type, tags, category, and attributes. - *

          - * Some Characteristics have variable values and others remain static over time. The latter are produced with - * a special channel-type uid, so that when Things are being created, rather than adding them as (dynamic data) - * Channels of the Thing, instead they are added as (static data) Properties of the Thing. + * Returns the {@link Content} for this characteristic. Some Characteristics have variable values and others remain + * static over time. The latter return a 'Property' record and the latter return a 'ChannelDefinition' record. + * Examines the characteristic type, data format, permissions, and other properties to determine the appropriate + * Content type and, where relevant, the channel type, item type, tags, category, and attributes. In the case of a + * 'ChannelDefinition' the method also builds a ChannelType and registers it with the provided HomekitTypeProvider. * - * @param thingUID the ThingUID to associate the ChannelDefinition with - * @param typeProvider the HomekitTypeProvider to register the channel type with - * @return the ChannelDefinition or null if it cannot be mapped + * @param thingUID the ThingUID to associate the ChannelDefinition with. + * @param typeProvider the HomekitTypeProvider to register the channel type with. + * @return the {@link Content} or null if it cannot be mapped. */ - public @Nullable ChannelDefinition buildAndRegisterChannelDefinition(ThingUID thingUID, - HomekitTypeProvider typeProvider, TranslationProvider i18nProvider, Bundle bundle) { + public @Nullable Content getContent(ThingUID thingUID, HomekitTypeProvider typeProvider, + TranslationProvider i18nProvider, Bundle bundle) { CharacteristicType characteristicType = getCharacteristicType(); DataFormatType dataFormatType; try { @@ -816,8 +810,7 @@ public class Characteristic { */ if (isStaticValue) { if (value != null && value.isJsonPrimitive()) { - return new ChannelDefinitionBuilder("static", CHANNEL_TYPE_STATIC) - .withProperties(Map.of(characteristicType.toCamelCase(), value.getAsString())).build(); + return new Content.Property(characteristicType.toCamelCase(), value.getAsString()); } return null; } @@ -939,7 +932,7 @@ public class Characteristic { channelTypeUid).withLabel(getChannelLabel(characteristicType, i18nProvider, bundle)) .withProperties(props); Optional.ofNullable(getChannelDescription()).ifPresent(d -> channelDefBuilder.withDescription(d)); - return channelDefBuilder.build(); + return new Content.ChannelDefinition(channelDefBuilder.build()); } /* diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Content.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Content.java new file mode 100644 index 0000000000000..18f81e9cafc69 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Content.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.homekit.internal.dto; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Used to encapsulate different types Characteristic's contents. Either a Channel Definition or a Property. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public sealed interface Content { + record ChannelDefinition(org.openhab.core.thing.type.ChannelDefinition definition) implements Content { + } + + record Property(String name, String value) implements Content { + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java index 7f70517a9642e..f397ba23a3aa8 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -14,8 +14,11 @@ import static org.openhab.binding.homekit.internal.HomekitBindingConstants.*; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -58,7 +61,7 @@ public class Service { * @param typeProvider the HomekitStorageBasedTypeProvider to register the channel group type with * @return the created ChannelGroupDefinition or null if creation failed */ - public @Nullable ChannelGroupDefinition buildAndRegisterChannelGroupDefinition(ThingUID thingUID, + public @Nullable ChannelGroupDefinition getChannelGroupDefinition(ThingUID thingUID, HomekitTypeProvider typeProvider, TranslationProvider i18nProvider, Bundle bundle) { ServiceType serviceType = getServiceType(); if (serviceType == null || ServiceType.ACCESSORY_INFORMATION == serviceType) { @@ -66,8 +69,9 @@ public class Service { } List channelDefinitions = characteristics.stream() - .map(c -> c.buildAndRegisterChannelDefinition(thingUID, typeProvider, i18nProvider, bundle)) - .filter(Objects::nonNull).toList(); + .map(c -> c.getContent(thingUID, typeProvider, i18nProvider, bundle)) + .filter(Content.ChannelDefinition.class::isInstance).map(Content.ChannelDefinition.class::cast) + .map(Content.ChannelDefinition::definition).toList(); if (channelDefinitions.isEmpty()) { return null; @@ -92,6 +96,19 @@ public class Service { getChannelGroupInstanceLabel(), null); } + /** + * Returns a property map from all characteristics of this service. In which if multiple characteristics + * provide the same property name, their values are concatenated. However this should not normally happen + * as characteristic types within a service should be unique. + */ + public Map getProperties(ThingUID thingUID, HomekitTypeProvider typeProvider, + TranslationProvider i18nProvider, Bundle bundle) { + return characteristics.stream().map(c -> c.getContent(thingUID, typeProvider, i18nProvider, bundle)) + .filter(Content.Property.class::isInstance).map(Content.Property.class::cast) + .collect(Collectors.toMap(Content.Property::name, Content.Property::value, + (v1, v2) -> v1.contains(v2) ? v1 : v1 + ", " + v2, LinkedHashMap::new)); + } + /* * Returns the 'name' field if it is present. Otherwise searches for a characterstic of type * CharacteristicType.NAME and if present returns that value. Otherwise returns the service diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 201b31c996ad1..3416c49361f68 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -332,81 +332,64 @@ private void createChannels() { lightModelInitialize(accessory); - // create the channels and properties + // create the channels Map uniqueChannelsMap = new HashMap<>(); // use map to prevent duplicate Channel ID - Map properties = new HashMap<>(thing.getProperties()); // keep existing properties - accessory.buildAndRegisterChannelGroupDefinitions(thing.getUID(), typeProvider, i18nProvider, bundle) - .forEach(groupDef -> { - logger.trace("{} ChannelGroupDefinition id:{}, typeUID:{}, label:{}, description:{}", - thing.getUID(), groupDef.getId(), groupDef.getTypeUID(), groupDef.getLabel(), - groupDef.getDescription()); - - ChannelGroupType channelGroupType = channelGroupTypeRegistry - .getChannelGroupType(groupDef.getTypeUID()); - if (channelGroupType == null) { - logger.warn("{} fatal error ChannelGroupType '{}' is not registered", thing.getUID(), - groupDef.getTypeUID()); + accessory.getChannelGroupDefinitions(thing.getUID(), typeProvider, i18nProvider, bundle).forEach(groupDef -> { + logger.trace("{} ChannelGroupDefinition id:{}, typeUID:{}, label:{}, description:{}", thing.getUID(), + groupDef.getId(), groupDef.getTypeUID(), groupDef.getLabel(), groupDef.getDescription()); + + ChannelGroupType channelGroupType = channelGroupTypeRegistry.getChannelGroupType(groupDef.getTypeUID()); + if (channelGroupType == null) { + logger.warn("{} fatal error ChannelGroupType '{}' is not registered", thing.getUID(), + groupDef.getTypeUID()); + } else { + logger.trace("{} ChannelGroupType UID:{}, label:{}, category:{}, description:{}", thing.getUID(), + channelGroupType.getUID(), channelGroupType.getLabel(), channelGroupType.getCategory(), + channelGroupType.getDescription()); + + channelGroupType.getChannelDefinitions().forEach(chanDef -> { + logger.trace( + "{} ChannelDefinition id:{}, label:{}, description:{}, channelTypeUID:{}, autoUpdatePolicy:{}, properties:{}", + thing.getUID(), chanDef.getId(), chanDef.getLabel(), chanDef.getDescription(), + chanDef.getChannelTypeUID(), chanDef.getAutoUpdatePolicy(), chanDef.getProperties()); + + ChannelType channelType = channelTypeRegistry.getChannelType(chanDef.getChannelTypeUID()); + if (channelType == null) { + logger.warn("{} fatal error ChannelType '{}' is not registered", thing.getUID(), + chanDef.getChannelTypeUID()); } else { - logger.trace("{} ChannelGroupType UID:{}, label:{}, category:{}, description:{}", - thing.getUID(), channelGroupType.getUID(), channelGroupType.getLabel(), - channelGroupType.getCategory(), channelGroupType.getDescription()); + logger.trace( + "{} ChannelType category:{}, description:{}, itemType:{}, label:{}, autoUpdatePolicy:{}, itemType:{}, kind:{}, tags:{}, uid:{}, unitHint:{}", + thing.getUID(), channelType.getCategory(), channelType.getDescription(), + channelType.getItemType(), channelType.getLabel(), channelType.getAutoUpdatePolicy(), + channelType.getItemType(), channelType.getKind(), channelType.getTags(), + channelType.getUID(), channelType.getUnitHint()); + + String channelId = chanDef.getId(); + if (uniqueChannelsMap.containsKey(channelId)) { + logger.debug("{} Error duplicate channelId:{}", thing.getUID(), channelId); + } else { + ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), channelId); + ChannelBuilder builder = ChannelBuilder.create(channelUID) + .withAcceptedItemType(channelType.getItemType()) + .withAutoUpdatePolicy(channelType.getAutoUpdatePolicy()) + .withDefaultTags(channelType.getTags()).withKind(channelType.getKind()) + .withProperties(chanDef.getProperties()).withType(channelType.getUID()); + Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); + Optional.ofNullable(chanDef.getDescription()).ifPresent(builder::withDescription); + Channel channel = builder.build(); + uniqueChannelsMap.put(channelId, channel); - channelGroupType.getChannelDefinitions().forEach(chanDef -> { logger.trace( - "{} ChannelDefinition id:{}, label:{}, description:{}, channelTypeUID:{}, autoUpdatePolicy:{}, properties:{}", - thing.getUID(), chanDef.getId(), chanDef.getLabel(), chanDef.getDescription(), - chanDef.getChannelTypeUID(), chanDef.getAutoUpdatePolicy(), - chanDef.getProperties()); - - if (CHANNEL_TYPE_STATIC.equals(chanDef.getChannelTypeUID())) { - // static ChannelDefinition: add as a Property (rather than a Channel) - Map channelProperties = chanDef.getProperties(); - properties.putAll(channelProperties); - logger.trace("{} Property {}", thing.getUID(), channelProperties); - } else { - // variable ChannelDefinition: add as a Channel (rather than a Property) - ChannelType channelType = channelTypeRegistry - .getChannelType(chanDef.getChannelTypeUID()); - if (channelType == null) { - logger.warn("{} fatal error ChannelType '{}' is not registered", thing.getUID(), - chanDef.getChannelTypeUID()); - } else { - logger.trace( - "{} ChannelType category:{}, description:{}, itemType:{}, label:{}, autoUpdatePolicy:{}, itemType:{}, kind:{}, tags:{}, uid:{}, unitHint:{}", - thing.getUID(), channelType.getCategory(), channelType.getDescription(), - channelType.getItemType(), channelType.getLabel(), - channelType.getAutoUpdatePolicy(), channelType.getItemType(), - channelType.getKind(), channelType.getTags(), channelType.getUID(), - channelType.getUnitHint()); - - String channelId = chanDef.getId(); - if (uniqueChannelsMap.containsKey(channelId)) { - logger.debug("{} Error duplicate channelId:{}", thing.getUID(), channelId); - } else { - ChannelUID channelUID = new ChannelUID(thing.getUID(), groupDef.getId(), - channelId); - ChannelBuilder builder = ChannelBuilder.create(channelUID) - .withAcceptedItemType(channelType.getItemType()) - .withAutoUpdatePolicy(channelType.getAutoUpdatePolicy()) - .withDefaultTags(channelType.getTags()).withKind(channelType.getKind()) - .withProperties(chanDef.getProperties()).withType(channelType.getUID()); - Optional.ofNullable(chanDef.getLabel()).ifPresent(builder::withLabel); - Optional.ofNullable(chanDef.getDescription()) - .ifPresent(builder::withDescription); - Channel channel = builder.build(); - uniqueChannelsMap.put(channelId, channel); - - logger.trace( - "{} Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", - thing.getUID(), channel.getAcceptedItemType(), channel.getDefaultTags(), - channel.getDescription(), channel.getKind(), channel.getLabel(), - channel.getProperties(), channel.getUID()); - } - } - } - }); + "{} Channel acceptedItemType:{}, defaultTags:{}, description:{}, kind:{}, label:{}, properties:{}, uid:{}", + thing.getUID(), channel.getAcceptedItemType(), channel.getDefaultTags(), + channel.getDescription(), channel.getKind(), channel.getLabel(), + channel.getProperties(), channel.getUID()); + } } }); + } + }); lightModelFinalize(accessory, uniqueChannelsMap); stopMoveFinalize(accessory, uniqueChannelsMap); @@ -415,10 +398,11 @@ private void createChannels() { String oldLabel = thing.getLabel(); String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; List newChannels = !uniqueChannelsMap.isEmpty() ? uniqueChannelsMap.values().stream().toList() : null; - Map newProperties = !properties.isEmpty() ? properties : null; + Map newProperties = new HashMap<>(thing.getProperties()); // keep existing properties + newProperties.putAll(accessory.getProperties(thing.getUID(), typeProvider, i18nProvider, bundle)); SemanticTag newEquipmentTag = accessory.getSemanticEquipmentTag(); - if (newLabel != null || newChannels != null || newProperties != null || newEquipmentTag != null) { + if (newLabel != null || newChannels != null || newEquipmentTag != null || !newProperties.isEmpty()) { ThingBuilder builder = editThing(); Optional.ofNullable(newLabel).ifPresent(builder::withLabel); Optional.ofNullable(newChannels).ifPresent(builder::withChannels); @@ -429,7 +413,7 @@ private void createChannels() { logger.debug( "{} updated with {} channels (of which {} polled, {} evented), {} properties, label: '{}', equipment tag: '{}'", thing.getUID(), uniqueChannelsMap.size(), polledCharacteristics.size(), - eventedCharacteristics.size(), properties.size(), newLabel, newEquipmentTag); + eventedCharacteristics.size(), newProperties.size(), newLabel, newEquipmentTag); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 91a3c4841595d..27467b9f1a302 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -63,7 +63,6 @@ import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.thing.binding.BaseThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; -import org.openhab.core.thing.type.ChannelDefinition; import org.osgi.framework.Bundle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -600,14 +599,7 @@ protected void createProperties() { Map thingProperties = new HashMap<>(thing.getProperties()); for (Service service : accessory.services) { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { - for (Characteristic characteristic : service.characteristics) { - ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thing.getUID(), - typeProvider, i18nProvider, bundle); - if (channelDef != null && CHANNEL_TYPE_STATIC.equals(channelDef.getChannelTypeUID())) { - // only static ChannelDefinitions contribute to the properties - thingProperties.putAll(channelDef.getProperties()); - } - } + thingProperties.putAll(service.getProperties(thing.getUID(), typeProvider, i18nProvider, bundle)); break; // only one accessory information service per accessory } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java index 43de2a3f83485..129231dfb1aef 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java @@ -15,7 +15,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.CHANNEL_TYPE_STATIC; import java.math.BigDecimal; import java.util.ArrayList; @@ -391,8 +390,8 @@ void testChannelDefinitions() { ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory3"); Accessory accessory = accessories.getAccessory(3L); assertNotNull(accessory); - List channelGroupDefinitions = accessory - .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); + List channelGroupDefinitions = accessory.getChannelGroupDefinitions(thingUID, + typeProvider, i18nProvider, bundle); // There should be just one channel group definition for the Light Bulb service assertNotNull(channelGroupDefinitions); @@ -455,13 +454,7 @@ void testChannelDefinitions() { Map properties = new HashMap<>(); for (Service service : accessory.services) { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { - for (Characteristic characteristic : service.characteristics) { - ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thingUID, - typeProvider, i18nProvider, bundle); - if (channelDef != null && CHANNEL_TYPE_STATIC.equals(channelDef.getChannelTypeUID())) { - properties.putAll(channelDef.getProperties()); - } - } + properties.putAll(service.getProperties(thingUID, typeProvider, i18nProvider, bundle)); break; } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java index 9d355a77541df..9af8e87e9a77a 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java @@ -653,11 +653,11 @@ void testChannelDefinitions() { ThingUID thingUID = new ThingUID("hhh", "aaa", "1234567890abcdef"); Accessory accessory = accessories.getAccessory(1L); assertNotNull(accessory); - List channelGroupDefinitions = accessory - .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); + List channelGroupDefinitions = accessory.getChannelGroupDefinitions(thingUID, + typeProvider, i18nProvider, bundle); assertNotNull(channelGroupDefinitions); - assertEquals(5, channelGroupDefinitions.size()); + assertEquals(4, channelGroupDefinitions.size()); // Check that the channel group definition and its type UID and label are set for (ChannelGroupDefinition groupDef : channelGroupDefinitions) { @@ -666,8 +666,8 @@ void testChannelDefinitions() { assertNotNull(groupDef.getLabel()); } - // there should be 5 unique channel group types; 1 protocol info service, 1 light sensor, and 3 presence sensors - assertEquals(5, channelGroupTypes.size()); + // there should be 4 unique channel group types; 1 light sensor, and 3 presence sensors + assertEquals(4, channelGroupTypes.size()); // there should be 4 unique channel types; 1 light sensor, and 3 presence sensors assertEquals(4, channelTypes.size()); @@ -680,8 +680,8 @@ void testChannelDefinitions() { assertNotNull(channelGroupType); List channelDefinitions = channelGroupType.getChannelDefinitions(); assertNotNull(channelDefinitions); - assertEquals(2, channelDefinitions.size()); - ChannelDefinition channelDefinition = channelDefinitions.get(1); + assertEquals(1, channelDefinitions.size()); + ChannelDefinition channelDefinition = channelDefinitions.get(0); assertNotNull(channelDefinition); ChannelTypeUID channelTypeUID = channelDefinition.getChannelTypeUID(); assertNotNull(channelTypeUID); @@ -697,8 +697,8 @@ void testChannelDefinitions() { assertNotNull(channelGroupType); channelDefinitions = channelGroupType.getChannelDefinitions(); assertNotNull(channelDefinitions); - assertEquals(2, channelDefinitions.size()); - channelDefinition = channelDefinitions.get(1); + assertEquals(1, channelDefinitions.size()); + channelDefinition = channelDefinitions.get(0); assertNotNull(channelDefinition); channelTypeUID = channelDefinition.getChannelTypeUID(); assertNotNull(channelTypeUID); @@ -714,8 +714,8 @@ void testChannelDefinitions() { assertNotNull(channelGroupType); channelDefinitions = channelGroupType.getChannelDefinitions(); assertNotNull(channelDefinitions); - assertEquals(2, channelDefinitions.size()); - channelDefinition = channelDefinitions.get(1); + assertEquals(1, channelDefinitions.size()); + channelDefinition = channelDefinitions.get(0); assertNotNull(channelDefinition); channelTypeUID = channelDefinition.getChannelTypeUID(); assertNotNull(channelTypeUID); @@ -723,4 +723,23 @@ void testChannelDefinitions() { assertNotNull(channelType); assertEquals("channel-type-occupancy-detected-2698-1234567890abcdef-1", channelType.getUID().getId()); } + + @Test + void testProperties() { + Accessories accessories = GSON.fromJson(TEST_JSON, Accessories.class); + assertNotNull(accessories); + HomekitTypeProvider typeProvider = mock(HomekitTypeProvider.class); + TranslationProvider i18nProvider = mock(TranslationProvider.class); + Bundle bundle = mock(Bundle.class); + Accessory accessory = accessories.getAccessory(1L); + assertNotNull(accessory); + ThingUID thingUID = new ThingUID("hhh", "aaa", "1234567890abcdef"); + Map properties = accessory.getProperties(thingUID, typeProvider, i18nProvider, bundle); + assertNotNull(properties); + assertEquals(7, properties.size()); + String name = properties.get("name"); + assertNotNull(name); + String[] names = name.split(", "); + assertEquals(6, names.length); + } } diff --git a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java index 86fa04e0cdf2b..9118b6abd7aa4 100644 --- a/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -15,7 +15,6 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import static org.openhab.binding.homekit.internal.HomekitBindingConstants.CHANNEL_TYPE_STATIC; import java.math.BigDecimal; import java.util.ArrayList; @@ -1613,13 +1612,7 @@ void testBridge() { Map properties = new HashMap<>(); for (Service service : accessory.services) { if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { - for (Characteristic characteristic : service.characteristics) { - ChannelDefinition channelDef = characteristic.buildAndRegisterChannelDefinition(thingUID, - typeProvider, i18nProvider, bundle); - if (channelDef != null && CHANNEL_TYPE_STATIC.equals(channelDef.getChannelTypeUID())) { - properties.putAll(channelDef.getProperties()); - } - } + properties.putAll(service.getProperties(thingUID, typeProvider, i18nProvider, bundle)); break; } } @@ -1661,8 +1654,8 @@ void testSensors() { ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory2"); Accessory accessory = accessories.getAccessory(2L); assertNotNull(accessory); - List channelGroupDefinitions = accessory - .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); + List channelGroupDefinitions = accessory.getChannelGroupDefinitions(thingUID, + typeProvider, i18nProvider, bundle); // There should be three channel group definitions for the temperature, humidity and co2 sensors assertNotNull(channelGroupDefinitions); @@ -1820,8 +1813,8 @@ void testVenetianBlind() { ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory9"); Accessory accessory = accessories.getAccessory(9L); assertNotNull(accessory); - List channelGroupDefinitions = accessory - .buildAndRegisterChannelGroupDefinitions(thingUID, typeProvider, i18nProvider, bundle); + List channelGroupDefinitions = accessory.getChannelGroupDefinitions(thingUID, + typeProvider, i18nProvider, bundle); // There should be one channel group definition for the blind assertNotNull(channelGroupDefinitions); From 113b4ad3af9c9a7b97c78c98590f5ab45e5b0fd6 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 11 Dec 2025 22:20:04 +0000 Subject: [PATCH 168/177] Update bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java Co-authored-by: Jacob Laursen Signed-off-by: Andrew Fiddian-Green --- .../openhab/binding/homekit/internal/dto/Characteristic.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java index 69dbdff5cd73a..118958733d3e6 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -74,8 +74,9 @@ public class Characteristic { public @NonNullByDefault({}) Integer status; /** - * Returns the {@link Content} for this characteristic. Some Characteristics have variable values and others remain - * static over time. The latter return a 'Property' record and the latter return a 'ChannelDefinition' record. + * Returns the {@link Content} for this characteristic. Some characteristics have values that may change over time, + * while others remain static. Characteristics with static values return a {@code Property} record, + * whereas characteristics with dynamic values return a {@code ChannelDefinition} record. * Examines the characteristic type, data format, permissions, and other properties to determine the appropriate * Content type and, where relevant, the channel type, item type, tags, category, and attributes. In the case of a * 'ChannelDefinition' the method also builds a ChannelType and registers it with the provided HomekitTypeProvider. From bc09b2d1f691977558f83bb1c0d22dc3d25548bf Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Thu, 11 Dec 2025 23:23:47 +0000 Subject: [PATCH 169/177] provisional: adopt reviewer suggestions Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 16 ++++++++++++---- .../handler/HomekitBaseAccessoryHandler.java | 5 +++++ .../main/resources/OH-INF/thing/thing-types.xml | 3 +++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 3416c49361f68..652dab2b8465b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -398,11 +398,18 @@ private void createChannels() { String oldLabel = thing.getLabel(); String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; List newChannels = !uniqueChannelsMap.isEmpty() ? uniqueChannelsMap.values().stream().toList() : null; - Map newProperties = new HashMap<>(thing.getProperties()); // keep existing properties - newProperties.putAll(accessory.getProperties(thing.getUID(), typeProvider, i18nProvider, bundle)); + Map oldProperties = new HashMap<>(thing.getProperties()); + Map getProperties = accessory.getProperties(thing.getUID(), typeProvider, i18nProvider, bundle); + Map newProperties; + if (!getProperties.isEmpty()) { + newProperties = oldProperties; + newProperties.putAll(getProperties); + } else { + newProperties = null; + } SemanticTag newEquipmentTag = accessory.getSemanticEquipmentTag(); - if (newLabel != null || newChannels != null || newEquipmentTag != null || !newProperties.isEmpty()) { + if (newLabel != null || newChannels != null || newProperties != null || newEquipmentTag != null) { ThingBuilder builder = editThing(); Optional.ofNullable(newLabel).ifPresent(builder::withLabel); Optional.ofNullable(newChannels).ifPresent(builder::withChannels); @@ -413,7 +420,8 @@ private void createChannels() { logger.debug( "{} updated with {} channels (of which {} polled, {} evented), {} properties, label: '{}', equipment tag: '{}'", thing.getUID(), uniqueChannelsMap.size(), polledCharacteristics.size(), - eventedCharacteristics.size(), newProperties.size(), newLabel, newEquipmentTag); + eventedCharacteristics.size(), newProperties != null ? newProperties.size() : oldProperties.size(), + newLabel != null ? newLabel : oldLabel, newEquipmentTag); } } diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java index 27467b9f1a302..5fedbb5a65c81 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -603,6 +603,11 @@ protected void createProperties() { break; // only one accessory information service per accessory } } + // for bridged-accessories add the unique id i.e. representation property + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler + && bridgeHandler.getThing().getConfiguration().get(CONFIG_UNIQUE_ID) instanceof String bridgeUniqueId) { + thingProperties.put(PROPERTY_UNIQUE_ID, STRING_AID_FMT.formatted(bridgeUniqueId, accessoryId)); + } thing.setProperties(thingProperties); } diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index fa2eabde72daa..b0a77ea996f27 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -7,12 +7,14 @@ HomeKit accessory with its own LAN connection + uniqueId HomeKit accessory with LAN connection that supports bridged accessories not having an own LAN connection + uniqueId @@ -22,6 +24,7 @@ HomeKit accessory without its own LAN connection and instead supported by a bridge + uniqueId From 8336efe07d967ccf2463921dc810b333fc786504 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Dec 2025 10:48:12 +0000 Subject: [PATCH 170/177] add equipment tag Signed-off-by: Andrew Fiddian-Green --- .../src/main/resources/OH-INF/thing/thing-types.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml index b0a77ea996f27..9d5e9da150079 100644 --- a/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -14,6 +14,7 @@ HomeKit accessory with LAN connection that supports bridged accessories not having an own LAN connection + NetworkAppliance uniqueId From 45d0fd11afea4ab7deb855b9f0d1b3e8b4fb52f6 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Dec 2025 14:10:00 +0000 Subject: [PATCH 171/177] improve equipment tagging Signed-off-by: Andrew Fiddian-Green --- .../homekit/internal/dto/Accessory.java | 91 ++++++++++++++++++- .../internal/enums/AccessoryCategory.java | 9 ++ .../handler/HomekitAccessoryHandler.java | 18 +++- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index 2de26a2117170..fb612661b54c4 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -102,7 +102,11 @@ public AccessoryCategory getAccessoryType() { * @return the corresponding SemanticTag or null if none exists */ public @Nullable SemanticTag getSemanticEquipmentTag() { - switch (getAccessoryType()) { + return getSemanticEquipmentTag(getAccessoryType()); + } + + public @Nullable SemanticTag getSemanticEquipmentTag(AccessoryCategory accessoryCategory) { + switch (accessoryCategory) { case BRIDGE: return Equipment.NETWORK_APPLIANCE; case FAN: @@ -178,6 +182,91 @@ public AccessoryCategory getAccessoryType() { return null; } + /** + * Returns the SemanticTag of the accessory by parsing its services, or null if no match. + */ + public @Nullable SemanticTag getSemanticEquipmentTagFromServices() { + for (Service service : services) { + ServiceType serviceType = service.getServiceType(); + if (serviceType != null) { + switch (serviceType) { + case GARAGE_DOOR_OPENER: + return Equipment.GARAGE_DOOR; + case LIGHT_BULB: + return Equipment.LIGHT_SOURCE; + case LOCK_MANAGEMENT: + case LOCK_MECHANISM: + return Equipment.LOCK; + case OUTLET: + return Equipment.POWER_OUTLET; + case SWITCH: + return Equipment.CONTROL_DEVICE; + case THERMOSTAT: + return Equipment.THERMOSTAT; + case SENSOR_AIR_QUALITY: + case SENSOR_CARBON_DIOXIDE: + case SENSOR_CARBON_MONOXIDE: + case SENSOR_CONTACT: + case SENSOR_HUMIDITY: + case SENSOR_LEAK: + case SENSOR_LIGHT: + case SENSOR_MOTION: + case SENSOR_OCCUPANCY: + case SENSOR_SMOKE: + case SENSOR_TEMPERATURE: + return Equipment.SENSOR; + case SECURITY_SYSTEM: + return Equipment.ALARM_SYSTEM; + case DOOR: + return Equipment.DOOR; + case WINDOW: + return Equipment.WINDOW; + case WINDOW_COVERING: + return Equipment.WINDOW_COVERING; + case AIR_PURIFIER: + return Equipment.AIR_FILTER; + case HEATER_COOLER: + return Equipment.HVAC; + case HUMIDIFIER_DEHUMIDIFIER: + return Equipment.HUMIDIFIER; + case FAUCET: + return Equipment.HOT_WATER_FAUCET; + case SPEAKER: + case SMART_SPEAKER: + return Equipment.SPEAKER; + case TELEVISION: + return Equipment.TELEVISION; + case AUDIO_STREAM_MANAGEMENT: + return Equipment.AUDIO_VISUAL; + case BATTERY: + return Equipment.BATTERY; + case CAMERA_RTP_STREAM_MANAGEMENT: + return Equipment.CAMERA; + case DOORBELL: + return Equipment.DOORBELL; + case FAN: + case FANV2: + return Equipment.FAN; + case FILTER_MAINTENANCE: + return Equipment.AIR_FILTER; + case IRRIGATION_SYSTEM: + return Equipment.IRRIGATION; + case SIRI: + return Equipment.VOICE_ASSISTANT; + case STATELESS_PROGRAMMABLE_SWITCH: + return Equipment.CONTROL_DEVICE; + case VALVE: + return Equipment.VALVE; + case VERTICAL_SLAT: + return Equipment.WINDOW_COVERING; + default: + break; + } + } + } + return null; + } + /** * Gets the label for this accessory instance. If the accessory has a non-blank name, that is returned. Otherwise, * if the accessory has an Accessory Information service with a Name characteristic, that is returned. Otherwise, diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java index d5a61086e443c..a58498b8b8703 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java @@ -76,6 +76,15 @@ public static AccessoryCategory from(int id) throws IllegalArgumentException { return OTHER; } + public static AccessoryCategory from(String label) { + for (AccessoryCategory value : values()) { + if (label.equals(value.label)) { + return value; + } + } + return OTHER; + } + @Override public String toString() { return label; diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 652dab2b8465b..023dd946f7d2f 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -32,6 +32,7 @@ import org.openhab.binding.homekit.internal.dto.Accessory; import org.openhab.binding.homekit.internal.dto.Characteristic; import org.openhab.binding.homekit.internal.dto.Service; +import org.openhab.binding.homekit.internal.enums.AccessoryCategory; import org.openhab.binding.homekit.internal.enums.CharacteristicType; import org.openhab.binding.homekit.internal.enums.DataFormatType; import org.openhab.binding.homekit.internal.enums.StatusCode; @@ -398,6 +399,7 @@ private void createChannels() { String oldLabel = thing.getLabel(); String newLabel = oldLabel == null || oldLabel.isEmpty() ? accessory.getAccessoryInstanceLabel() : null; List newChannels = !uniqueChannelsMap.isEmpty() ? uniqueChannelsMap.values().stream().toList() : null; + Map oldProperties = new HashMap<>(thing.getProperties()); Map getProperties = accessory.getProperties(thing.getUID(), typeProvider, i18nProvider, bundle); Map newProperties; @@ -407,7 +409,20 @@ private void createChannels() { } else { newProperties = null; } + + String oldEquipmentTag = thing.getSemanticEquipmentTag(); SemanticTag newEquipmentTag = accessory.getSemanticEquipmentTag(); + if (newEquipmentTag == null && oldProperties.get(PROPERTY_ACCESSORY_CATEGORY) instanceof String catProperty + && AccessoryCategory.from(catProperty) instanceof AccessoryCategory category + && AccessoryCategory.OTHER != category) { + newEquipmentTag = accessory.getSemanticEquipmentTag(category); + } + if (newEquipmentTag == null) { + newEquipmentTag = accessory.getSemanticEquipmentTagFromServices(); + } + if (newEquipmentTag != null && newEquipmentTag.getName().equals(oldEquipmentTag)) { + newEquipmentTag = null; // do not change prior tag + } if (newLabel != null || newChannels != null || newProperties != null || newEquipmentTag != null) { ThingBuilder builder = editThing(); @@ -421,7 +436,8 @@ private void createChannels() { "{} updated with {} channels (of which {} polled, {} evented), {} properties, label: '{}', equipment tag: '{}'", thing.getUID(), uniqueChannelsMap.size(), polledCharacteristics.size(), eventedCharacteristics.size(), newProperties != null ? newProperties.size() : oldProperties.size(), - newLabel != null ? newLabel : oldLabel, newEquipmentTag); + newLabel != null ? newLabel : oldLabel, newEquipmentTag != null ? newEquipmentTag.getName() + : oldEquipmentTag != null ? oldEquipmentTag : "n/a"); } } From b333f548e4c0ae708b93cb460705047dcf3b7a67 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Dec 2025 16:15:12 +0000 Subject: [PATCH 172/177] fix misclassification of battery powered accessories Signed-off-by: Andrew Fiddian-Green --- .../org/openhab/binding/homekit/internal/dto/Accessory.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java index fb612661b54c4..5a7f5b610f50b 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -238,8 +238,6 @@ public AccessoryCategory getAccessoryType() { return Equipment.TELEVISION; case AUDIO_STREAM_MANAGEMENT: return Equipment.AUDIO_VISUAL; - case BATTERY: - return Equipment.BATTERY; case CAMERA_RTP_STREAM_MANAGEMENT: return Equipment.CAMERA; case DOORBELL: @@ -259,6 +257,8 @@ public AccessoryCategory getAccessoryType() { return Equipment.VALVE; case VERTICAL_SLAT: return Equipment.WINDOW_COVERING; + case BATTERY: + // Equipment.BATTERY risks to misclassify accessories that happen to be battery powered default: break; } From b1a951f2547637b8c0ce950a660a6a608c45b5af Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Dec 2025 18:35:23 +0000 Subject: [PATCH 173/177] tweak equipment tag code Signed-off-by: Andrew Fiddian-Green --- .../handler/HomekitAccessoryHandler.java | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java index 023dd946f7d2f..753c724de91f5 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -411,17 +411,19 @@ private void createChannels() { } String oldEquipmentTag = thing.getSemanticEquipmentTag(); - SemanticTag newEquipmentTag = accessory.getSemanticEquipmentTag(); - if (newEquipmentTag == null && oldProperties.get(PROPERTY_ACCESSORY_CATEGORY) instanceof String catProperty - && AccessoryCategory.from(catProperty) instanceof AccessoryCategory category - && AccessoryCategory.OTHER != category) { - newEquipmentTag = accessory.getSemanticEquipmentTag(category); - } - if (newEquipmentTag == null) { - newEquipmentTag = accessory.getSemanticEquipmentTagFromServices(); - } - if (newEquipmentTag != null && newEquipmentTag.getName().equals(oldEquipmentTag)) { - newEquipmentTag = null; // do not change prior tag + SemanticTag newEquipmentTag; + if (oldEquipmentTag != null && oldEquipmentTag.isEmpty()) { + newEquipmentTag = null; + } else { + newEquipmentTag = accessory.getSemanticEquipmentTag(); + if (newEquipmentTag == null && oldProperties.get(PROPERTY_ACCESSORY_CATEGORY) instanceof String catProperty + && AccessoryCategory.from(catProperty) instanceof AccessoryCategory category + && AccessoryCategory.OTHER != category) { + newEquipmentTag = accessory.getSemanticEquipmentTag(category); + } + if (newEquipmentTag == null) { + newEquipmentTag = accessory.getSemanticEquipmentTagFromServices(); + } } if (newLabel != null || newChannels != null || newProperties != null || newEquipmentTag != null) { From a9d0cc207d2cb4d5f48cca5052c196de7d5d03fd Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Dec 2025 23:08:35 +0000 Subject: [PATCH 174/177] Update bundles/org.openhab.binding.homekit/README.md Co-authored-by: Jacob Laursen Signed-off-by: Andrew Fiddian-Green --- bundles/org.openhab.binding.homekit/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/README.md b/bundles/org.openhab.binding.homekit/README.md index fb96ee4c78d9c..a7e033e18f9a2 100644 --- a/bundles/org.openhab.binding.homekit/README.md +++ b/bundles/org.openhab.binding.homekit/README.md @@ -111,7 +111,7 @@ Things are automatically configured when they are discovered. So for this reason it is difficult to create Things via a '.things' file, and therefore not recommended. ```java -Bridge homekit:bridge:velux "VELUX Gateway" [ ipAddress="192.168.0.235:5001", macAddress="XX:XX:XX:XX:XX:XX", httpHostHeader="foobar._hap._tcp.local.", refreshInterval=60 ] { +Bridge homekit:bridge:velux "VELUX Gateway" [ ipAddress="192.168.0.235:5001", uniqueId="XX:XX:XX:XX:XX:XX", httpHostHeader="foobar._hap._tcp.local.", refreshInterval=60 ] { Thing bridged-accessory sensor "VELUX Sensor" @ "Hallway" [ accessoryID=2 ] Thing bridged-accessory skylight_hallway "VELUX Window" @ "Hallway" [ accessoryID=3 ] Thing bridged-accessory skylight_bathroom "VELUX Window" @ "Bathroom" [ accessoryID=4 ] From 82320576871776d679e3c615008ad12b834ef4ab Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Dec 2025 23:08:56 +0000 Subject: [PATCH 175/177] Update bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java Co-authored-by: Jacob Laursen Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/handler/HomekitBridgeHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java index 621c7bd702ceb..5d8d8497c61e7 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -45,7 +45,7 @@ @NonNullByDefault public class HomekitBridgeHandler extends HomekitBaseAccessoryHandler implements BridgeHandler { - private @Nullable HomekitBridgedAccessoryDiscoveryService bridgedAccessoryDiscoveryService = null; + private @Nullable HomekitBridgedAccessoryDiscoveryService bridgedAccessoryDiscoveryService; public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, TranslationProvider i18nProvider, Bundle bundle) { From 178ee862344fb5ff8d128d40a37f700dc77b09de Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Fri, 12 Dec 2025 23:09:13 +0000 Subject: [PATCH 176/177] Update bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java Co-authored-by: Jacob Laursen Signed-off-by: Andrew Fiddian-Green --- .../binding/homekit/internal/enums/AccessoryCategory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java index a58498b8b8703..9ca697e314723 100644 --- a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java @@ -67,7 +67,7 @@ public enum AccessoryCategory { this.label = label; } - public static AccessoryCategory from(int id) throws IllegalArgumentException { + public static AccessoryCategory from(int id) { for (AccessoryCategory value : values()) { if (value.id == id) { return value; From 7b6b1d235359ae3f06c57fb78f88000c29b4ab86 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sat, 13 Dec 2025 10:32:43 +0000 Subject: [PATCH 177/177] fix bad github merge suggestion Signed-off-by: Andrew Fiddian-Green --- bom/openhab-addons/pom.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 3ef7cb85d17df..f0ffcc67c1f3b 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -814,6 +814,10 @@ org.openhab.addons.bundles org.openhab.binding.homekit + ${project.version} + + + org.openhab.addons.bundles org.openhab.binding.homie ${project.version}