diff --git a/CODEOWNERS b/CODEOWNERS index 73f91493c769b..155e78b5661a8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -165,6 +165,7 @@ /bundles/org.openhab.binding.herzborg/ @Sonic-Amiga /bundles/org.openhab.binding.homeassistant/ @antroids @ccutrer /bundles/org.openhab.binding.homeconnect/ @bruestel +/bundles/org.openhab.binding.homekit/ @andrewfg /bundles/org.openhab.binding.homie/ @ccutrer /bundles/org.openhab.binding.homematic/ @FStolte @gerrieg @mdicke2s /bundles/org.openhab.binding.homewizard/ @Daniel-42 diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 053055634cec2..f0ffcc67c1f3b 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.homie 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..a7e033e18f9a2 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/README.md @@ -0,0 +1,144 @@ +# 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 [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: + +- `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. +- `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` and `accessory` both communicate directly with their HomeKit accessory device via the LAN. +Whereas `bridged-accessory` Things communicate via their respective `bridge` Thing. + +## 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. + +## Configuration 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 | no | +| `httpHostHeader` | text | The fully qualified host name 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. + +`ipAddress` must match the format `123.123.123.123:4567` representing its IP v4 address and port. + +`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. + +`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 + +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. +However you can configure it manually if you wish. +It must be the ID of the `bridged-accessory` within the `bridge`. + +## Thing Pairing + +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. +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`. + +## 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 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 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. +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. + +## 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 + +### Thing Configuration + +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", 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 ] +} +``` + +### Item Configuration + +```java +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 + +```perl +Slider item=SkylightHallway_Position +Slider item=SkylightBathroom_Position +``` diff --git a/bundles/org.openhab.binding.homekit/pom.xml b/bundles/org.openhab.binding.homekit/pom.xml new file mode 100644 index 0000000000000..afd612ddae0e4 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/pom.xml @@ -0,0 +1,25 @@ + + + + 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 Client Binding + + + + org.bouncycastle + bcprov-jdk18on + 1.81 + + + + 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..6e769f1f43212 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/HomekitBindingConstants.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.util.regex.Pattern; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * Defines common constants which are used across the whole HomeKit binding. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class HomekitBindingConstants { + + public static final String BINDING_ID = "homekit"; + + // List of all Thing Type UIDs + 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"); + + /** + * 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 + */ + public static final String CHANNEL_TYPE_ID_FMT = "channel-type-%s-%d-%s-%s"; + + /** + * 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 STRING_AID_FMT = "%s-%d"; + + // 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_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_UNIQUE_ID = "uniqueId"; + + // thing properties + public static final String PROPERTY_PROTOCOL_VERSION = "protocolVersion"; + public static final String PROPERTY_ACCESSORY_CATEGORY = "accessoryCategory"; + public static final String PROPERTY_UNIQUE_ID = CONFIG_UNIQUE_ID; + + // channel properties + public static final String PROPERTY_IID = "iid"; + public static final String PROPERTY_FORMAT = "format"; + public static final String PROPERTY_DATA_TYPE = "dataType"; + + // HomeKit HTTP URI endpoints and content types + 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"; + + // 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 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._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]+)\\._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 new file mode 100644 index 0000000000000..3100e65119936 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/action/HomekitPairingActions.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.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; +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; +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 for pairing. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@Component(scope = ServiceScope.PROTOTYPE, service = HomekitPairingActions.class) +@ThingActionsScope(name = "homekit-pairing") +@NonNullByDefault +public class HomekitPairingActions implements ThingActions { + + private final Logger logger = LoggerFactory.getLogger(HomekitPairingActions.class); + private @Nullable HomekitBaseAccessoryHandler handler; + + 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 HomekitPairingActions"); + } + } + + 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 HomekitPairingActions"); + } + } + + @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-action.label", description = "@text/actions.pairing-action.description") + 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; + if (handler != null) { + return handler.pair(code, auth); + } else { + logger.warn("ThingHandler is null."); + return ACTION_RESULT_ERROR_FORMAT.formatted("handler"); + } + } + + @RuleAction(label = "@text/actions.unpairing-action.label", description = "@text/actions.unpairing-action.description") + 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"); + } + } +} 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..0b297c49ab717 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoConstants.java @@ -0,0 +1,102 @@ +/* + * 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 java.security.NoSuchAlgorithmException; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Constants for cryptographic operations used in HomeKit communication. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@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 + 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 = 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); + + 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); + public static final byte[] CONTROL_WRITE_ENCRYPTION_KEY = "Control-Write-Encryption-Key".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 = 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(concat(paddedN, paddedG)); + return new BigInteger(1, hash); + } catch (NoSuchAlgorithmException e) { + throw new SecurityException("Failed to compute k", e); + } + } + + private static byte[] nonce(String input) { + 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 new file mode 100644 index 0000000000000..eaf7ab72a06ea --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/CryptoUtils.java @@ -0,0 +1,191 @@ +/* + * 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.ByteBuffer; +import java.nio.ByteOrder; +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; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.modes.ChaCha20Poly1305; +import org.bouncycastle.crypto.params.AEADParameters; +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.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; + +/** + * Utility class for cryptographic operations used in HomeKit communication. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class CryptoUtils { + + 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[] nonce64, byte[] cipherText, byte[] aad) + throws InvalidCipherTextException { + byte[] nonce96 = new byte[12]; // 96 bit nonce + 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); + 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[] nonce64, byte[] plainText, byte[] aad) + throws InvalidCipherTextException { + byte[] nonce96 = new byte[12]; // 96 bit nonce + 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); + byte[] cipherText = new byte[cipher.getOutputSize(plainText.length)]; + int offset = cipher.processBytes(plainText, 0, plainText.length, cipherText, 0); + cipher.doFinal(cipherText, offset); + return cipherText; + } + + // 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; + } + + /** + * Generates a 64 bit nonce using the given counter. + * + * @param counter The counter value. + * @return The generated nonce. + */ + public static byte[] generateNonce64(int counter) { + return ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(counter).array(); + } + + // Compute shared secret using ECDH + public static byte[] generateSharedSecret(X25519PrivateKeyParameters clientEphemeralSecretKey, + X25519PublicKeyParameters serverEphemeralPublicKey) { + byte[] secret = new byte[32]; + clientEphemeralSecretKey.generateSecret(serverEphemeralPublicKey, 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 NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-512"); + return md.digest(data); + } + + // Sign message with Ed25519 + public static byte[] signMessage(Ed25519PrivateKeyParameters secretKey, byte[] message) { + Ed25519Signer signer = new Ed25519Signer(); + signer.init(true, secretKey); + signer.update(message, 0, message.length); + return signer.generateSignature(); + } + + public static String toHex(byte @Nullable [] bytes) { + return bytes == null ? "null" : Hex.toHexString(bytes); + } + + /** + * 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. + */ + public static byte[] toUnsigned(BigInteger bigInteger, int length) { + byte[] raw = bigInteger.toByteArray(); + if (raw.length == length && raw[0] != 0) { + return raw; + } + + 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; + } + + // pad to fixed length + byte[] padded = new byte[length]; + System.arraycopy(unsigned, 0, padded, length - unsigned.length, unsigned.length); + return padded; + } + + public static void verifySignature(Ed25519PublicKeyParameters publicKey, byte[] signature, byte[] payload) + throws SecurityException { + Ed25519Signer verifier = new Ed25519Signer(); + verifier.init(false, publicKey); + verifier.update(payload, 0, payload.length); + if (!verifier.verifySignature(signature)) { + throw new SecurityException("Signature verification failed"); + } + } + + public static byte[] xor(byte[] a, byte[] b) throws IllegalArgumentException { + 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; + } +} 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..62db5788df52a --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/SRPclient.java @@ -0,0 +1,253 @@ +/* + * 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.CryptoConstants.*; +import static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; + +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; +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. + * 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 { + + 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 + 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 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 accessory server proof + + private @Nullable Ed25519PublicKeyParameters accessoryLongTermPublicKey = null; + + /** + * M1 - Simplified constructor when user and client private key are not provided. + * + * @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 NoSuchAlgorithmException + */ + public SRPclient(String password_P, byte[] serverSalt, byte[] serverEphemeralPublicKey) + throws NoSuchAlgorithmException { + 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 serverSalt the salt (s) provided by the server. + * @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 NoSuchAlgorithmException + */ + public SRPclient(String password_P, byte[] serverSalt, byte[] accessoryEphemeralPublicKey, @Nullable String user_I, + byte @Nullable [] clientEphemeralSecretKey) throws NoSuchAlgorithmException { + // set username, salt and server public key + s = serverSalt; + 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 + byte[] client_a = clientEphemeralSecretKey; + if (client_a == null) { + client_a = new byte[32]; + new SecureRandom().nextBytes(client_a); + } + a = new BigInteger(1, client_a); + A = g.modPow(a, N); + + // Compute hash x = H(salt || H(username || ":" || password)) + byte[] hIP = sha512((I + ":" + password_P).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, 384), toUnsigned(B, 384))); + u = new BigInteger(1, uHash); + if (u.equals(BigInteger.ZERO)) { + throw new SecurityException("Invalid scrambling parameter"); + } + + // 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 = toUnsigned(base.modPow(exp, N), 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)); + 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, 384), toUnsigned(B, 384), K)); + + // 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 getAccessoryLongTermPublicKey() throws IllegalStateException { + Ed25519PublicKeyParameters accessoryLTPK = this.accessoryLongTermPublicKey; + if (accessoryLTPK == null) { + throw new IllegalStateException("Accessory long-term public key not yet available"); + } + return accessoryLTPK; + } + + 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)); + } + if (!Arrays.equals(M2, accessoryProof)) { + throw new SecurityException("SRP server proof mismatch"); + } + } + + /** + * 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 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 InvalidCipherTextException + */ + public byte[] m5EncodeControllerInfoAndSign(byte[] iOSDeviceId, + 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); + byte[] iOSDeviceSignature = signMessage(iOSDeviceLongTermPrivateKey, iOSDeviceInfo); + + Map subTlv = new LinkedHashMap<>(); + 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[] 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; + } + + /** + * 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 InvalidCipherTextException + */ + 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), + toHex(decryptKey)); + } + byte[] plainText = decrypt(decryptKey, PS_M6_NONCE, cipherText, new byte[0]); + + Map subTlv = Tlv8Codec.decode(plainText); + byte[] accessoryPairingId = subTlv.get(TlvType.IDENTIFIER.value); + byte[] accessoryLTPK = subTlv.get(TlvType.PUBLIC_KEY.value); + byte[] accessorySignature = subTlv.get(TlvType.SIGNATURE.value); + + if (accessoryPairingId == null || accessoryLTPK == null || accessorySignature == null) { + throw new SecurityException("Missing accessory credentials in M6"); + } + + 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); + + 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)); + } + verifySignature(accessoryLongTermPublicKey, accessorySignature, accessoryInfo); + this.accessoryLongTermPublicKey = accessoryLongTermPublicKey; + } +} 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 new file mode 100644 index 0000000000000..1e7d0ee55ce41 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/crypto/Tlv8Codec.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.crypto; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +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. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +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 SecurityException("Invalid TLV8 length"); + } + + byte[] chunk = Arrays.copyOfRange(data, index, index + length); + index += length; + + ByteArrayOutputStream stream = tempMap.computeIfAbsent(type, k -> new ByteArrayOutputStream()); + if (stream != null) { + stream.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/discovery/HomekitBridgedAccessoryDiscoveryService.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java new file mode 100644 index 0000000000000..70b76ccaeae39 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitBridgedAccessoryDiscoveryService.java @@ -0,0 +1,87 @@ +/* + * 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.*; + +import java.util.Collection; +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.AbstractThingHandlerDiscoveryService; +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 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 HomekitBridgedAccessoryDiscoveryService + extends AbstractThingHandlerDiscoveryService { + + private static final int TIMEOUT_SECONDS = 10; + + public HomekitBridgedAccessoryDiscoveryService() { + super(HomekitBridgeHandler.class, Set.of(THING_TYPE_BRIDGED_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) { + discoverBridgedAccessories(handler.getThing(), handler.getAccessories().values()); + } + } + + private void discoverBridgedAccessories(Thing bridge, Collection accessories) { + 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(bridgeUniqueId, aid); + String label = THING_LABEL_FMT.formatted(accessory.getAccessoryInstanceLabel(), uniqueId); + thingDiscovered(DiscoveryResultBuilder.create(uid) // + .withBridge(bridge.getUID()) // + .withLabel(label) // + .withProperty(CONFIG_ACCESSORY_ID, aid.toString()) // + .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/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..10879fec8601a --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/discovery/HomekitMdnsDiscoveryParticipant.java @@ -0,0 +1,171 @@ +/* + * 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.*; + +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; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +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; +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; + +/** + * Discovers new HomeKit server devices. + * HomeKit devices advertise themselves using mDNS with the service type "_hap._tcp.local.". + * 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 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}. + * 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 + */ +@NonNullByDefault +@Component(service = MDNSDiscoveryParticipant.class, immediate = true) +public class HomekitMdnsDiscoveryParticipant implements MDNSDiscoveryParticipant { + + private static final String SERVICE_TYPE = "_hap._tcp.local."; + + @Override + public Set getSupportedThingTypeUIDs() { + return Set.of(THING_TYPE_BRIDGE, THING_TYPE_ACCESSORY); + } + + @Override + public String getServiceType() { + return SERVICE_TYPE; + } + + @Override + public @Nullable DiscoveryResult createResult(ServiceInfo service) { + if (getThingUID(service) instanceof ThingUID uid) { + Map properties = getProperties(service); + + 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; + } + + AccessoryCategory category; + try { + String ci = properties.getOrDefault("ci", ""); // accessory category + category = AccessoryCategory.from(Integer.parseInt(ci)); + } catch (IllegalArgumentException e) { + category = null; + } + + if (ipAddress != null && uniqueId != null && category != null) { + DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(uid); + builder.withLabel(THING_LABEL_FMT.formatted(service.getName(), uniqueId)) // + .withProperty(CONFIG_HTTP_HOST_HEADER, getHostName(service)) // + .withProperty(CONFIG_IP_ADDRESS, ipAddress) // + .withProperty(CONFIG_UNIQUE_ID, uniqueId) // + .withProperty(PROPERTY_ACCESSORY_CATEGORY, category.toString()) // + .withRepresentationProperty(CONFIG_UNIQUE_ID); + + if (properties.get("md") instanceof String model) { + builder.withProperty(Thing.PROPERTY_MODEL_ID, model); + } + if (properties.get("s#") instanceof String serial) { + builder.withProperty(Thing.PROPERTY_SERIAL_NUMBER, serial); + } + if (properties.get("pv") instanceof String protocolVersion) { + builder.withProperty(PROPERTY_PROTOCOL_VERSION, protocolVersion); + } + + return builder.build(); + } + } + return null; + } + + @Override + public @Nullable ThingUID getThingUID(ServiceInfo service) { + Map properties = getProperties(service); + + String uniqueId = properties.get("id"); + AccessoryCategory category; + try { + String ci = properties.getOrDefault("ci", ""); + category = AccessoryCategory.from(Integer.parseInt(ci)); + } catch (IllegalArgumentException e) { + category = null; + } + + 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; + } + + /** + * 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<>(); + byte[] bytes = service.getTextBytes(); + int i = 0; + while (i < bytes.length) { + int len = bytes[i++] & 0xFF; + 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 < 2 ? "" : parts[1].replaceFirst("\\u0000$", "")); // strip zero endings + i += len; + } + return map; + } + + /** + * 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.getQualifiedName(); + 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/dto/Accessories.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.java new file mode 100644 index 0000000000000..7e756f8373a6f --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessories.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.dto; + +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. + * Contains a list of HomeKitAccessory objects. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class 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/Accessory.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java new file mode 100644 index 0000000000000..5a7f5b610f50b --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Accessory.java @@ -0,0 +1,306 @@ +/* + * 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.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; +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; +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; + +/** + * 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 + */ +@NonNullByDefault +public class Accessory { + public @NonNullByDefault({}) Long 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. + * 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. + * @return a list of channel group definition instances for the services of this accessory. + */ + 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) { + return AccessoryCategory.OTHER; + } + return AccessoryCategory.from(category); + } + + /** + * 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() { + return getSemanticEquipmentTag(getAccessoryType()); + } + + public @Nullable SemanticTag getSemanticEquipmentTag(AccessoryCategory accessoryCategory) { + switch (accessoryCategory) { + 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 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: + break; + } + 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 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; + case BATTERY: + // Equipment.BATTERY risks to misclassify accessories that happen to be battery powered + 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, + * 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(Long 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 new file mode 100644 index 0000000000000..118958733d3e6 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Characteristic.java @@ -0,0 +1,979 @@ +/* + * 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.*; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +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.enums.StatusCode; +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; +import org.openhab.core.semantics.model.DefaultSemanticTags.Property; +import org.openhab.core.thing.ThingUID; +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 org.osgi.framework.Bundle; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +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 @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({}) 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 + 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 (events requested) + 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; + + /** + * 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. + * + * @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 Content getContent(ThingUID thingUID, HomekitTypeProvider typeProvider, + TranslationProvider i18nProvider, Bundle bundle) { + CharacteristicType characteristicType = getCharacteristicType(); + DataFormatType dataFormatType; + try { + dataFormatType = DataFormatType.from(format); + } catch (IllegalArgumentException e) { + return null; + } + + // 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 isNumberWithSuffix = false; + boolean isStateChannel = true; + boolean isPercentage = "percentage".equals(unit); + boolean isEnumLike = false; + boolean isStaticValue = false; + + 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 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()) { + dataType = "number"; + } + if (prim.isString()) { + dataType = "string"; + } + } + + String itemType = null; + String category = null; + String numberSuffix = null; + SemanticTag pointTag = null; + SemanticTag propertyTag = null; + + if (isReadOnly) { + if (isBoolean) { + itemType = CoreItemFactory.CONTACT; + pointTag = Point.STATUS; + category = "switch"; + } else if (isNumber) { + itemType = isPercentage ? CoreItemFactory.DIMMER : CoreItemFactory.NUMBER; + pointTag = isPercentage ? Point.STATUS : 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 = isPercentage ? CoreItemFactory.DIMMER : CoreItemFactory.NUMBER; + pointTag = isPercentage ? Point.CONTROL : 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: + itemType = null; + break; + + case AIR_PARTICULATE_DENSITY: + uom = "µg/m³"; + numberSuffix = "Density"; + propertyTag = Property.PARTICULATE_MATTER; + break; + + case AIR_PARTICULATE_SIZE: + isStaticValue = true; + break; + + case AIR_PURIFIER_STATE_CURRENT: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; + propertyTag = Property.MODE; + 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; + + 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: + itemType = CoreItemFactory.CONTACT; + dataType = "number"; + pointTag = Point.ALARM; + propertyTag = Property.CO2; + category = "co2"; + break; + + case CARBON_DIOXIDE_LEVEL: + case CARBON_DIOXIDE_PEAK_LEVEL: + uom = "ppm"; + numberSuffix = "Dimensionless"; + propertyTag = Property.CO2; + category = "co2"; + break; + + case CARBON_MONOXIDE_DETECTED: + itemType = CoreItemFactory.CONTACT; + dataType = "number"; + pointTag = Point.ALARM; + propertyTag = Property.CO; + category = "alarm"; + break; + + case CARBON_MONOXIDE_LEVEL: + case CARBON_MONOXIDE_PEAK_LEVEL: + uom = "ppm"; + numberSuffix = "Dimensionless"; + propertyTag = Property.CO; + break; + + case CHARGING_STATE: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + propertyTag = Property.MODE; + category = "battery"; + break; + + case COLOR_TEMPERATURE: + uom = "mired"; + numberSuffix = "Temperature"; + propertyTag = Property.COLOR_TEMPERATURE; + category = "light"; + break; + + case CONTACT_STATE: + itemType = CoreItemFactory.CONTACT; + dataType = "number"; + 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: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; + propertyTag = Property.OPEN_STATE; + category = "door"; + break; + + case DOOR_STATE_TARGET: + itemType = CoreItemFactory.SWITCH; + dataType = "number"; + pointTag = Point.CONTROL; + propertyTag = Property.OPEN_STATE; + category = "door"; + break; + + case FAN_STATE_CURRENT: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; + propertyTag = Property.MODE; + category = "fan"; + break; + + case FAN_STATE_TARGET: + itemType = CoreItemFactory.SWITCH; + dataType = "number"; + pointTag = Point.CONTROL; + propertyTag = Property.MODE; + category = "fan"; + break; + + case FILTER_CHANGE_INDICATION: + itemType = CoreItemFactory.CONTACT; + dataType = "number"; + break; + + case FILTER_LIFE_LEVEL: + itemType = CoreItemFactory.NUMBER; + uom = "%"; + numberSuffix = "Dimensionless"; + break; + + case FILTER_RESET_INDICATION: + itemType = CoreItemFactory.SWITCH; + dataType = "number"; + break; + + case FIRMWARE_REVISION: + case HARDWARE_REVISION: + isStaticValue = true; + break; + + case HEATER_COOLER_STATE_CURRENT: + case HEATER_COOLER_STATE_TARGET: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = isReadOnly ? Point.STATUS : Point.CONTROL; + propertyTag = Property.MODE; + category = "heating"; + break; + + case HEATING_COOLING_CURRENT: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; + propertyTag = Property.MODE; + category = "heating"; + break; + + case HEATING_COOLING_TARGET: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = Point.CONTROL; + propertyTag = Property.MODE; + category = "heating"; + break; + + case HORIZONTAL_TILT_CURRENT: + case HORIZONTAL_TILT_TARGET: + numberSuffix = "Angle"; + propertyTag = Property.TILT; + category = "rollershutter"; + break; + + case HUE: + numberSuffix = "Angle"; + propertyTag = Property.COLOR; + category = "color"; + break; + + case HUMIDIFIER_DEHUMIDIFIER_STATE_CURRENT: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; + propertyTag = Property.MODE; + category = "humidity"; + break; + + case HUMIDIFIER_DEHUMIDIFIER_STATE_TARGET: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = Point.CONTROL; + propertyTag = Property.MODE; + category = "humidity"; + break; + + case IDENTIFY: + /* + * 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; + + case IMAGE_MIRROR: + itemType = CoreItemFactory.SWITCH; + category = "image"; + break; + + case IMAGE_ROTATION: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = isReadOnly ? Point.STATUS : Point.CONTROL; + propertyTag = Property.MODE; + category = "image"; + break; + + case INPUT_EVENT: + isStateChannel = false; + break; + + case IN_USE: + case IS_CONFIGURED: + isStaticValue = true; + break; + + case LEAK_DETECTED: + itemType = CoreItemFactory.CONTACT; + dataType = "number"; + 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: + numberSuffix = "Duration"; + category = "lock"; + break; + + case LOCK_MANAGEMENT_CONTROL_POINT: + /* + * 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; + + case LOCK_MECHANISM_LAST_KNOWN_ACTION: + itemType = CoreItemFactory.STRING; + pointTag = Point.STATUS; + dataType = "number"; + isEnumLike = true; + category = "lock"; + break; + + case LOCK_PHYSICAL_CONTROLS: + itemType = CoreItemFactory.SWITCH; + propertyTag = Property.ENABLED; + category = "lock"; + break; + + case LOCK_MECHANISM_CURRENT_STATE: + itemType = CoreItemFactory.SWITCH; + propertyTag = Property.LOCK_STATE; + category = "lock"; + break; + + case LOCK_MECHANISM_TARGET_STATE: + itemType = CoreItemFactory.SWITCH; + dataType = "number"; + pointTag = Point.CONTROL; + propertyTag = Property.LOCK_STATE; + category = "lock"; + break; + + case LOGS: + itemType = null; + break; + + case MANUFACTURER: + case MODEL: + isStaticValue = true; + 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; + + case NAME: + isStaticValue = true; + break; + + case NIGHT_VISION: + itemType = CoreItemFactory.SWITCH; + propertyTag = Property.ENABLED; + break; + + case OBSTRUCTION_DETECTED: + itemType = CoreItemFactory.CONTACT; + break; + + case OCCUPANCY_DETECTED: + itemType = CoreItemFactory.CONTACT; + dataType = "number"; + pointTag = Point.STATUS; + propertyTag = Property.PRESENCE; + break; + + case ON: + propertyTag = Property.POWER; + category = "switch"; + break; + + case OUTLET_IN_USE: + propertyTag = Property.POWER; + break; + + case PAIRING_FEATURES: + case PAIRING_PAIRINGS: + case PAIRING_PAIR_SETUP: + case PAIRING_PAIR_VERIFY: + itemType = null; + break; + + case POSITION_HOLD: + itemType = CoreItemFactory.SWITCH; + propertyTag = Property.OPENING; + break; + + case POSITION_CURRENT: + itemType = CoreItemFactory.ROLLERSHUTTER; + propertyTag = Property.OPENING; + break; + + case POSITION_STATE: + itemType = CoreItemFactory.STRING; + dataType = "number"; + isEnumLike = true; + pointTag = Point.STATUS; + propertyTag = Property.OPENING; + break; + + case POSITION_TARGET: + itemType = CoreItemFactory.ROLLERSHUTTER; + propertyTag = Property.OPENING; + break; + + case PROGRAM_MODE: + itemType = CoreItemFactory.STRING; + pointTag = isReadOnly ? Point.STATUS : Point.CONTROL; + dataType = "number"; + isEnumLike = true; + propertyTag = Property.MODE; + break; + + case RELATIVE_HUMIDITY_DEHUMIDIFIER_THRESHOLD: + case RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD: + case RELATIVE_HUMIDITY_CURRENT: + case RELATIVE_HUMIDITY_TARGET: + itemType = CoreItemFactory.NUMBER; + numberSuffix = "Dimensionless"; + pointTag = isReadOnly ? Point.MEASUREMENT : Point.SETPOINT; + propertyTag = Property.HUMIDITY; + category = "humidity"; + break; + + case REMAINING_DURATION: + uom = "s"; + numberSuffix = "Duration"; + propertyTag = Property.DURATION; + category = "time"; + break; + + case ROTATION_DIRECTION: + itemType = CoreItemFactory.SWITCH; + dataType = "number"; + propertyTag = Property.MODE; + break; + + case ROTATION_SPEED: + itemType = CoreItemFactory.DIMMER; + propertyTag = Property.SPEED; + break; + + case SATURATION: + itemType = CoreItemFactory.DIMMER; + propertyTag = Property.COLOR; + category = "color"; + break; + + case SECURITY_SYSTEM_ALARM_TYPE: + itemType = CoreItemFactory.CONTACT; + dataType = "number"; + pointTag = Point.ALARM; + break; + + 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; + + case SELECTED_AUDIO_STREAM_CONFIGURATION: + case SELECTED_RTP_STREAM_CONFIGURATION: + case SERVICE_LABEL_INDEX: + case SERVICE_LABEL_NAMESPACE: + case SETUP_DATA_STREAM_TRANSPORT: + case SETUP_ENDPOINTS: + itemType = null; + break; + + case SERIAL_NUMBER: + isStaticValue = true; + break; + + case SET_DURATION: + uom = "s"; + numberSuffix = "Duration"; + propertyTag = Property.DURATION; + break; + + case SIRI_INPUT_TYPE: + isStaticValue = true; + break; + + case SLAT_STATE_CURRENT: + itemType = CoreItemFactory.STRING; + pointTag = Point.STATUS; + dataType = "number"; + isEnumLike = true; + propertyTag = Property.TILT; + break; + + case SMOKE_DETECTED: + itemType = CoreItemFactory.CONTACT; + dataType = "number"; + pointTag = Point.ALARM; + propertyTag = Property.SMOKE; + category = "smoke"; + break; + + case STATUS_ACTIVE: + itemType = CoreItemFactory.CONTACT; + pointTag = Point.STATUS; + propertyTag = Property.MODE; + break; + + 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"; + break; + + case STATUS_TAMPERED: + itemType = CoreItemFactory.CONTACT; + dataType = "number"; + pointTag = Point.ALARM; + propertyTag = Property.TAMPERED; + break; + + case STREAMING_STATUS: + itemType = null; + break; + + case SUPPORTED_AUDIO_CONFIGURATION: + case SUPPORTED_DATA_STREAM_TRANSPORT_CONFIGURATION: + case SUPPORTED_RTP_CONFIGURATION: + case SUPPORTED_TARGET_CONFIGURATION: + case SUPPORTED_VIDEO_STREAM_CONFIGURATION: + itemType = null; + break; + + case SWING_MODE: + itemType = CoreItemFactory.SWITCH; + dataType = "number"; + pointTag = isReadOnly ? Point.STATUS : Point.CONTROL; + propertyTag = Property.AIRFLOW; + break; + + case TARGET_LIST: + itemType = null; + break; + + case TEMPERATURE_COOLING_THRESHOLD: + case TEMPERATURE_CURRENT: + case TEMPERATURE_HEATING_THRESHOLD: + case TEMPERATURE_TARGET: + propertyTag = Property.TEMPERATURE; + numberSuffix = "Temperature"; + category = "temperature"; + break; + + case TEMPERATURE_UNITS: + isStaticValue = true; + break; + + case TILT_CURRENT: + case TILT_TARGET: + numberSuffix = "Angle"; + propertyTag = Property.TILT; + category = "rollershutter"; + break; + + case TYPE_SLAT: + case VALVE_TYPE: + case VERSION: + isStaticValue = true; + break; + + case VERTICAL_TILT_CURRENT: + case VERTICAL_TILT_TARGET: + numberSuffix = "Angle"; + propertyTag = Property.TILT; + category = "rollershutter"; + break; + + case VOLUME: + itemType = CoreItemFactory.DIMMER; + propertyTag = Property.SOUND_VOLUME; + category = "sound"; + break; + + case WATER_LEVEL: + numberSuffix = "Dimensionless"; + propertyTag = Property.WATER; + category = "water"; + break; + + case ZOOM_DIGITAL: + case ZOOM_OPTICAL: + itemType = null; + break; + + case CUSTOM_CXX: + // custom or unknown characteristic; fall through to default + + default: + return null; + } + + if (CoreItemFactory.NUMBER.equals(itemType) && numberSuffix != null) { + itemType = itemType + ":" + numberSuffix; + isNumberWithSuffix = true; + } + + /* + * ================ CREATE SPECIAL STATIC CHANNEL DEFINITION ================= + * + * 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 (isStaticValue) { + if (value != null && value.isJsonPrimitive()) { + return new Content.Property(characteristicType.toCamelCase(), value.getAsString()); + } + 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 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) { + ChannelType channelType = ChannelTypeBuilder.trigger(channelTypeUid, channelTypeLabel).build(); + typeProvider.putChannelType(channelType); + + } else { + if (itemType == null) { + return null; + } + + // 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 || "%".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); + } + + // use valid values to build options for enum-like characteristics + List options = new ArrayList<>(); + if (validValues != null && !validValues.isEmpty()) { + 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; + for (int i = min; i <= max; i += step) { + options.add(Integer.toString(i)); + } + } else + // 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) { + for (int i = min.intValue(); i <= max.intValue(); i += step.intValue()) { + options.add(Integer.toString(i)); + } + } + + if (!options.isEmpty()) { + String translationKey = "characteristic.%s.".formatted(characteristicType.getOpenhabType()); + fragBldr.withOptions(options.stream().map(o -> { + 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(o, optionLabel); + }).toList()); + } + } + 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) { + chanTypBldr.withTags(pointTag, propertyTag); + } else { + chanTypBldr.withTags(pointTag); + } + } + + // persist the (state) channel TYPE + ChannelType channelType = chanTypBldr.build(); + typeProvider.putChannelType(channelType); + } + + /* + * ================ 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 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(dataType).ifPresent(s -> props.put(PROPERTY_DATA_TYPE, s)); + + String channelDefinitionIdentifier = STRING_AID_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)); + return new Content.ChannelDefinition(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 null. + */ + private @Nullable String getChannelDescription() { + return description != null && !description.isBlank() ? description : null; + } + + public CharacteristicType getCharacteristicType() { + return getCharacteristicType(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 CharacteristicType.CUSTOM_CXX; + } + } + + @Override + 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/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 new file mode 100644 index 0000000000000..f397ba23a3aa8 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/dto/Service.java @@ -0,0 +1,151 @@ +/* + * 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.*; + +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; +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; + +/** + * 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 @NonNullByDefault({}) String type; // e.g. '96' => 'public.hap.service.battery' + public @NonNullByDefault({}) Long 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. + * 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 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 getChannelGroupDefinition(ThingUID thingUID, + 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.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; + } + + 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(); + + 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(channelGroupType); + return new ChannelGroupDefinition(serviceType.getOpenhabType(), channelGroupTypeUID, + 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 + * type in Title Case.. + */ + public String getChannelGroupInstanceLabel() { + 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 { + // 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; + } + } + + public @Nullable Characteristic getCharacteristic(Long 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"; + } +} 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 new file mode 100644 index 0000000000000..9ca697e314723 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryCategory.java @@ -0,0 +1,92 @@ +/* + * 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 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 AccessoryCategory { + OTHER(1, "Other Accessory"), + BRIDGE(2, "Bridge"), + FAN(3, "Fan"), + GARAGE_DOOR(4, "Garage Door"), + LIGHTING(5, "Lighting"), + 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"), + 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"), + 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; + + AccessoryCategory(int category, String label) { + this.id = category; + this.label = label; + } + + public static AccessoryCategory from(int id) { + for (AccessoryCategory value : values()) { + if (value.id == id) { + return value; + } + } + 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/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..ade3bb15fcc2a --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/AccessoryPairingFeature.java @@ -0,0 +1,51 @@ +/* + * 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 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; + + AccessoryPairingFeature(int value) { + this.value = (byte) value; + } + + public static AccessoryPairingFeature from(int value) throws IllegalArgumentException { + 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..c6cdddf19fcb2 --- /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) throws IllegalArgumentException { + for (AccessoryPairingStatus 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/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..d5befab43756f --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/CharacteristicType.java @@ -0,0 +1,223 @@ +/* + * 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 java.util.Arrays; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of HomeKit characteristic types. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@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, "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, "custom"); + //@formatter:on + + private final int id; + private final String type; + + CharacteristicType(int id, String type) { + this.id = id; + 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); + } + + /** + * 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.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. e.g. ZOOM_DIGITAL -> Zoom Digital + */ + @Override + public String toString() { + return Arrays.stream(name().split("_")).map( + word -> word.isEmpty() ? word : Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase()) + .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()); + 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/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..cb5e63051ffed --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/DataFormatType.java @@ -0,0 +1,38 @@ +/* + * 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 data types. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +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/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..e7bc8b94fce4a --- /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 value) throws IllegalArgumentException { + for (ErrorCode state : values()) { + if (state.value == value) { + return state; + } + } + 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 new file mode 100644 index 0000000000000..38ec48fd908b5 --- /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 value) throws IllegalArgumentException { + for (PairingMethod state : values()) { + if (state.value == value) { + return state; + } + } + 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 new file mode 100644 index 0000000000000..a2ff0338778c4 --- /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 value) throws IllegalArgumentException { + for (PairingState state : values()) { + if (state.value == value) { + return state; + } + } + throw new IllegalArgumentException("Unknown pairing state: " + value); + } +} 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..18491d9cf1eaa --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/enums/ServiceType.java @@ -0,0 +1,117 @@ +/* + * 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 java.util.Arrays; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Enumeration of HomeKit service types. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum ServiceType { + /* + * 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; + + ServiceType(int id, String type) { + this.id = id; + 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 getOpenhabType() { + return type.replace(".", "-"); // convert to OH channel type format + } + + public String getType() { + return type; + } + + /** + * Returns the name of the enum constant in Title Case. + */ + @Override + public String 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/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/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..4c1f7b38e5a8b --- /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), + SEPARATOR((byte) 0xFF); + + public final int value; + + TlvType(int value) { + this.value = value; + } +} 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 new file mode 100644 index 0000000000000..22d784ba61641 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/factory/HomekitHandlerFactory.java @@ -0,0 +1,88 @@ +/* + * 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.factory; + +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.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.i18n.TranslationProvider; +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.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; + +/** + * Creates things and thing handlers. Supports HomeKit bridges and accessories. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +@Component(service = ThingHandlerFactory.class) +public class HomekitHandlerFactory extends BaseThingHandlerFactory { + + 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; + 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 TranslationProvider translationProvider) { + this.typeProvider = typeProvider; + this.channelTypeRegistry = channelTypeRegistry; + this.channelGroupTypeRegistry = channelGroupTypeRegistry; + this.keyStore = keyStore; + this.i18nProvider = translationProvider; + this.bundle = FrameworkUtil.getBundle(getClass()); + } + + @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_BRIDGE.equals(thingTypeUID)) { + return new HomekitBridgeHandler((Bridge) thing, typeProvider, keyStore, i18nProvider, bundle); + } else if (THING_TYPE_BRIDGED_ACCESSORY.equals(thingTypeUID) || THING_TYPE_ACCESSORY.equals(thingTypeUID)) { + return new HomekitAccessoryHandler(thing, typeProvider, channelTypeRegistry, channelGroupTypeRegistry, + 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 new file mode 100644 index 0000000000000..753c724de91f5 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitAccessoryHandler.java @@ -0,0 +1,1008 @@ +/* + * 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 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; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +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; +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; +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.binding.homekit.internal.transport.IpTransport; +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; +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.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.Bridge; +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.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; +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; +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.osgi.framework.Bundle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; + +/** + * 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 + */ +@NonNullByDefault +public class HomekitAccessoryHandler extends HomekitBaseAccessoryHandler { + + // 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; + + /* + * 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 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. + * Used for light model management. + */ + private record LightModelLink(Channel channel, CharacteristicType cxxType, Long cxxIid) { + } + + private final List lightModelLinks = new ArrayList<>(); + + public HomekitAccessoryHandler(Thing thing, HomekitTypeProvider typeProvider, + ChannelTypeRegistry channelTypeRegistry, ChannelGroupTypeRegistry channelGroupTypeRegistry, + HomekitKeyStore keyStore, TranslationProvider i18nProvider, Bundle bundle) { + super(thing, typeProvider, keyStore, i18nProvider, bundle); + this.channelTypeRegistry = channelTypeRegistry; + this.channelGroupTypeRegistry = channelGroupTypeRegistry; + } + + /** + * 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 JsonPrimitive commandToJsonPrimitive(Command command, Channel channel) { + Object object = command; + 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.HUNDRED : PercentType.ZERO; + } else if (object instanceof OpenClosedType openClosed) { + object = openClosed == OpenClosedType.OPEN ? PercentType.HUNDRED : PercentType.ZERO; + } else if (object instanceof UpDownType upDown) { + object = upDown == UpDownType.UP ? PercentType.HUNDRED : PercentType.ZERO; + } + } + + // convert QuantityTypes to the characteristic's unit + if (object instanceof QuantityType quantity) { + if (stateDescription != null + && UnitUtils.parseUnit(stateDescription.getPattern()) instanceof Unit channelUnit) { + try { + QuantityType temp = quantity.toUnit(channelUnit); + object = temp != null ? temp : quantity; + } catch (MeasurementParseException e) { + logger.warn("{} unexpected unit '{}' for channel '{}'", thing.getUID(), channelUnit, + channel.getUID()); + } + } + } + + // 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) { + String optionValue = option.getValue(); + if (commandString.equalsIgnoreCase(optionValue)) { + try { + object = Integer.parseInt(optionValue); + break; + } catch (NumberFormatException e) { + logger.warn("{} unexpected state option value '{}' for channel '{}'", thing.getUID(), + optionValue, channel.getUID()); + } + } + } + } + } + + if (object instanceof Number number) { + // clamp numbers to characteristic's min/max limits + if (stateDescription != null && stateDescription.getMinimum() instanceof BigDecimal min + && min.doubleValue() > number.doubleValue()) { + object = min; + } + if (stateDescription != null && stateDescription.getMaximum() instanceof BigDecimal max + && max.doubleValue() < number.doubleValue()) { + object = max; + } + + // comply with characteristic's data format + 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()); + case STRING -> String.valueOf(number); + case BOOL -> Boolean.valueOf(number.intValue() != 0); + default -> object; + }; + } + } + + // 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(); + } + + // comply with the characteristic's data type + if (object instanceof Boolean bool + && 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"; + } + } + + return object instanceof Number num ? new JsonPrimitive(num) + : object instanceof Boolean bool ? new JsonPrimitive(bool) : new JsonPrimitive(object.toString()); + } + + /** + * 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("{} channel {} wrong item type 'COLOR'", thing.getUID(), 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()); + // 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)) { + 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()); + } + yield StringType.valueOf(value.getAsString()); + } + }; + } + 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() { + Map accessories = getAccessories(); + if (accessories.isEmpty()) { + return; + } + Long accessoryId = getAccessoryId(); + if (accessoryId == null) { + return; + } + Accessory accessory = accessories.get(accessoryId); + 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(); + } + if (accessory == null) { + return; + } + + lightModelInitialize(accessory); + + // create the channels + Map uniqueChannelsMap = new HashMap<>(); // use map to prevent duplicate Channel ID + 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( + "{} 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()); + } + } + }); + } + }); + + lightModelFinalize(accessory, uniqueChannelsMap); + stopMoveFinalize(accessory, uniqueChannelsMap); + eventingPollingFinalize(accessory, uniqueChannelsMap); + + 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; + if (!getProperties.isEmpty()) { + newProperties = oldProperties; + newProperties.putAll(getProperties); + } else { + newProperties = null; + } + + String oldEquipmentTag = thing.getSemanticEquipmentTag(); + 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) { + ThingBuilder builder = editThing(); + Optional.ofNullable(newLabel).ifPresent(builder::withLabel); + Optional.ofNullable(newChannels).ifPresent(builder::withChannels); + Optional.ofNullable(newProperties).ifPresent(builder::withProperties); + Optional.ofNullable(newEquipmentTag).ifPresent(builder::withSemanticEquipmentTag); + + updateThing(builder.build()); + logger.debug( + "{} 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 != null ? newEquipmentTag.getName() + : oldEquipmentTag != null ? oldEquipmentTag : "n/a"); + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + Channel channel = thing.getChannel(channelUID); + if (channel == null) { + logger.warn("{} received command '{}' for unknown channel '{}'", thing.getUID(), command, channelUID); + return; + } + if (command == RefreshType.REFRESH) { + requestManualRefresh(); + return; + } + try { + if (command instanceof StopMoveType stopMoveType && StopMoveType.STOP == stopMoveType) { + if (stopMoveChannel instanceof Channel stopMoveChannel) { + writeChannel(stopMoveChannel, OnOffType.ON); + } else if (readChannel(channel) instanceof Command actualPosition) { + writeChannel(channel, actualPosition); + } + } else if (channelUID.equals(lightModelClientHSBTypeChannel)) { + lightModelHandleCommand(command); + if (lightModel instanceof LightModel lightModel) { + 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); + } + return; // success + } catch (InterruptedException e) { + 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 + logger.debug("{} communication error '{}' sending command '{}' to '{}', reconnecting..", thing.getUID(), + 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, e); + } + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + THING_STATUS_FMT.formatted("error.error-sending-command", e.getMessage())); + } + } + + @Override + public void initialize() { + super.initialize(); + if (isBridgedAccessory) { + if (getBridge() instanceof Bridge bridge && bridge.getStatus() == ThingStatus.ONLINE) { + scheduler.submit(() -> { + onConnectedThingAccessoriesLoaded(); + enableEvents(true); + updateStatus(ThingStatus.ONLINE); + }); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + } + } + } + + @Override + public void dispose() { + lightModel = null; + lightModelLinks.clear(); + lightModelClientHSBTypeChannel = null; + eventedCharacteristics.clear(); + polledCharacteristics.clear(); + super.dispose(); + } + + 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", thing.getUID(), uid); + return null; + } + StateDescription st = ct.getState(); + if (st == null) { + logger.warn("{} channel '{}' of type '{}' is missing a state description", thing.getUID(), uid, + ct.getUID()); + return null; + } + 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; + } + + /** + * 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) throws IllegalStateException { + LightModel lightModel = this.lightModel; + if (lightModel == null) { + throw new IllegalStateException("Light model is not initialized"); + } + boolean changed = false; + 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) { + 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 { + switch (cxxType) { + case ON -> lightModel.setOnOff(primitiveValue.getAsBoolean()); // string, boolean + 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 compiler requires us to handle any exception; but actually will be one of the following: + * @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 + * @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; + if (lightModel == null) { + throw new IllegalStateException("Light model is not initialized"); + } + lightModel.handleCommand(command); + 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); + } + 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); + } + link = lightModelLinks.stream().filter(e -> CharacteristicType.BRIGHTNESS == e.cxxType).findFirst(); + if (link.isPresent() && lightModel.getBrightness(true) instanceof PercentType brightness) { + 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); + } + } + + /** + * 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 + * @param channels the list of channels to finalize + */ + 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.values()) { + 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 cxxType = cxx.getCharacteristicType(); + if (LIGHT_MODEL_RELEVANT_TYPES.contains(cxxType)) { + lightModelLinks.add(new LightModelLink(channel, cxxType, cxx.iid)); + } + } + } + } + } + } + // 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.put(uid.getId(), channel); // add to channels map + 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()); + lightModelClientHSBTypeChannel = 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, 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) { + if (iid.equals(String.valueOf(cxx.iid)) + && CharacteristicType.POSITION_HOLD == cxx.getCharacteristicType()) { + stopMoveChannel = channel; + return; + } + } + } + } + } + } + + /** + * 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. 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 + */ + private void eventingPollingFinalize(Accessory accessory, Map channels) { + eventedCharacteristics.clear(); + polledCharacteristics.clear(); + + final Long aid = getAccessoryId(); + if (aid == null) { + return; + } + + for (Channel channel : channels.values()) { + final ChannelUID channelUID = channel.getUID(); + 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 ((checkChannelLinkByIID && iid.equals(characteristic.iid)) + || LIGHT_MODEL_RELEVANT_TYPES.contains(characteristic.getCharacteristicType())) { + Characteristic entry = new Characteristic(); + entry.aid = aid; + 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 = characteristic.iid; + eventedCharacteristics.put(AID_IID_FORMAT.formatted(entry.aid, entry.iid), entry); + } + if (checkChannelLinkByIID) { + break nestedLoops; // unique match found; continue to next channel + } + } + } + } + } + } + } + + /** + * 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 compiler requires us to handle any exception; but actually will be one of the following: + * @throws TimeoutException if the operation times out + * @throws InterruptedException if the operation is interrupted + * @throws IllegalStateException if the read/write service is not initialized + * @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(); + 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 = 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) { + 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. + * + * @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 compiler requires us to handle any exception; but actually will be one of the following: + * @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 + * @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(); + 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 = Long.parseLong(iid); + characteristic.value = commandToJsonPrimitive(command, channel); + service.characteristics = List.of(characteristic); + String response = writeCharacteristics(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 '{}': {}", thing.getUID(), channel.getUID(), code); + } + } + } + } + + /** + * Updates the channels based on the provided JSON content. + * + * @param json the JSON content containing characteristic values + */ + private void updateChannelsFromJson(String json) { + Long aid = getAccessoryId(); + ChannelUID hsbChannelUID = null; + Service service = GSON.fromJson(json, Service.class); + if (service != null && service.characteristics instanceof List characteristics) { + for (Channel channel : thing.getChannels()) { + ChannelUID channelUID = channel.getUID(); + if (channelUID.equals(lightModelClientHSBTypeChannel)) { + for (Characteristic cxx : characteristics) { + 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 (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); + case TRIGGER -> triggerChannel(channelUID, state.toFullString()); + } + } + } + } + } + } + if (thing.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + if (hsbChannelUID != null) { + updateState(hsbChannelUID, Objects.requireNonNull(lightModel).getHsb()); + } + } + + /** + * 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 bridged accessory. + * @throws IllegalAccessException if access to the transport is denied. + */ + @Override + protected IpTransport getIpTransport() throws IllegalAccessException { + if (isBridgedAccessory) { + 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 + protected void onConnectedThingAccessoriesLoaded() { + createProperties(); + createChannels(); + markAsReady(thing); + } + + @Override + public void onEvent(String json) { + updateChannelsFromJson(json); + } + + /** + * 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 + public void channelLinked(ChannelUID channelUID) { + boolean eventedCharacteristicsChanged = false; + try { + 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 + } + + 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 ((checkChannelLinkByIID && iid.equals(characteristic.iid)) + || LIGHT_MODEL_RELEVANT_TYPES.contains(characteristic.getCharacteristicType())) { + Characteristic entry = new Characteristic(); + entry.aid = aid; + entry.iid = 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(AID_IID_FORMAT.formatted(entry.aid, entry.iid), entry); + eventedCharacteristicsChanged = true; + } + if (checkChannelLinkByIID) { + return; // unique match found; return directly + } + } + } + } + } finally { + if (eventedCharacteristicsChanged) { // if evented list changes (re-) enable eventing (using new list) + scheduler.submit(() -> enableEvents(true)); + } + super.channelLinked(channelUID); + } + } + + @Override + protected Map getEventedCharacteristics() { + return eventedCharacteristics; + } + + @Override + protected Map getPolledCharacteristics() { + return polledCharacteristics; + } + + @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 new file mode 100644 index 0000000000000..5fedbb5a65c81 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBaseAccessoryHandler.java @@ -0,0 +1,871 @@ +/* + * 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 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; +import java.time.Duration; +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; +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; +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; +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.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; +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.binding.BaseThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.osgi.framework.Bundle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * 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 + */ +@NonNullByDefault +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; + private static final int MANUAL_REFRESH_DELAY_SECONDS = 3; + + private final Logger logger = LoggerFactory.getLogger(HomekitBaseAccessoryHandler.class); + private final Map accessories = new ConcurrentHashMap<>(); + private final HomekitKeyStore keyStore; + + private boolean isConfigured = false; + private int connectionAttemptDelay = MIN_CONNECTION_ATTEMPT_DELAY_SECONDS; + + 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; + + protected static final Gson GSON = new Gson(); + protected static final String THING_STATUS_FMT = "@text/%s [\"%s\"]"; + + /** + * 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<>(); + + // 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; + + protected boolean isBridgedAccessory = 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); + } + Duration delay = Duration.between(Instant.now(), next); + 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); + Thread.sleep(sleepDuration); + } + 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) { + super(thing); + this.typeProvider = typeProvider; + this.keyStore = keyStore; + this.i18nProvider = translationProvider; + this.bundle = bundle; + } + + @Override + public void dispose() { + notReadyThings.clear(); + eventedCharacteristics.clear(); + accessories.clear(); + cancelRefreshTasks(); + if (!isBridgedAccessory) { + try { + enableEventsOrThrow(false); + } catch (Exception e) { + // closing; ignore + } + } + if (connectionAttemptTask instanceof ScheduledFuture task) { + task.cancel(true); + } + connectionAttemptTask = null; + if (ipTransport instanceof IpTransport transport) { + transport.close(); + } + ipTransport = null; + super.dispose(); + } + + /** + * Get information about embedded accessories and their respective channels from the /accessories endpoint. + * + * @return list of accessories (may be empty) + * @see HomeKit HTTP + */ + private void fetchAccessories() { + try { + accessories.clear(); + 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", thing.getUID(), accessories.size()); + scheduler.submit(this::onConnectedThingAccessoriesLoaded); + } catch (Exception e) { + if (isCommunicationException(e)) { + // communication exception; log at debug and try to reconnect + 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", thing.getUID(), e.getMessage()); + } + logger.debug("Stack trace", e); + } + } + + /** + * 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 (isBridgedAccessory) { + 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; + } + return 1L; + } + + @Override + public void handleRemoval() { + cancelRefreshTasks(); + if (isBridgedAccessory) { + updateStatus(ThingStatus.REMOVED); + } else { + scheduler.submit(() -> { + if (unpairInner().startsWith(ACTION_RESULT_OK)) { + updateStatus(ThingStatus.REMOVED); + } + }); + } + } + + @Override + public void initialize() { + isBridgedAccessory = getBridge() instanceof Bridge; + if (!isBridgedAccessory) { + initializeNotReadyThings(); + scheduleConnectionAttempt(); + } + updateStatus(ThingStatus.UNKNOWN); + } + + /** + * Restores an existing pairing. + * Updates the thing status accordingly. + */ + private synchronized boolean verifyPairing() { + isConfigured = false; + Long accessoryId = checkedAccessoryId(); + String ipAddress = checkedIpAddress(); + String uniqueId = checkedUniqueId(); + String hostName = checkedHostName(); + 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(uniqueId); + if (accessoryKey == null) { + logger.debug("{} no stored pairing credentials", thing.getUID()); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.not-paired"); + return false; + } + + // create new transport + if (checkedCreateIpTransport(ipAddress, hostName) == null) { + return false; // transport creation failed + } + + // attempt to verify pairing + try { + logger.debug("{} starting Pair-Verify with existing key", thing.getUID()); + PairVerifyClient client = new PairVerifyClient(getIpTransport(), keyStore.getControllerUUID(), + keyStore.getControllerKey(), accessoryKey); + + getIpTransport().setSessionKeys(client.verify()); + rwService = new CharacteristicReadWriteClient(getIpTransport()); + throttler.reset(); + + 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", thing.getUID(), e); + // pairing restore failed => exit and perhaps try again later + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + THING_STATUS_FMT.formatted("error.pairing-verification-failed", e.getMessage())); + return false; + } + } + + public Map getAccessories() { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + return bridgeHandler.getAccessories(); + } + return accessories; + } + + /** + * Normalize XXX-XX-XXX or XXXX-XXXX or XXXXXXXX to XXX-XX-XXX + */ + private String normalizePairingCode(String input) throws IllegalArgumentException { + // 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)); + } + + /** + * Schedules a connection attempt. + */ + protected void scheduleConnectionAttempt() { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + bridgeHandler.scheduleConnectionAttempt(); + return; + } + ScheduledFuture task = connectionAttemptTask; + if (task == null || task.isDone() || task.isCancelled()) { + connectionAttemptTask = scheduler.schedule(this::attemptConnect, connectionAttemptDelay, TimeUnit.SECONDS); + } + } + + /** + * 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 synchronized void attemptConnect() { + if (ipTransport instanceof IpTransport transport) { // close prior transport (if any) + transport.close(); + ipTransport = null; + } + 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); + } + } + + /** + * Gets the IP transport. + * + * @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 (isBridgedAccessory) { + throw new IllegalAccessException("Bridged accessories must delegate to bridge IP transport"); + } + IpTransport ipTransport = this.ipTransport; + if (ipTransport == null) { + throw new IllegalStateException("IP transport not initialized"); + } + return ipTransport; + } + + /** + * Gets the read/write service. + * + * @return the CharacteristicReadWriteClient + */ + protected @Nullable CharacteristicReadWriteClient getRwService() { + return rwService; + } + + @Override + public Collection> getServices() { + // only bridges and accessories require pairing support + return isBridgedAccessory ? Set.of() : Set.of(HomekitPairingActions.class); + } + + 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, "@text/error.invalid-ip-address"); + return null; + } + return ipAddress; + } + + 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 uniqueId; + } + + 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, "@text/error.invalid-host-name"); + return null; + } + if (!HOST_PATTERN.matcher(hostName).matches()) { + logger.warn("{} host name '{}' does not match expected pattern; using anyway..", thing.getUID(), hostName); + } + return hostName.replace(" ", "\\032"); // escape mDNS spaces + } + + private @Nullable Long checkedAccessoryId() { + accessoryId = getAccessoryId(); + if (accessoryId == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/error.invalid-accessory-id"); + return null; + } + return accessoryId; + } + + private @Nullable IpTransport checkedCreateIpTransport(String ipAddress, String hostName) { + try { + IpTransport ipTransport = new IpTransport(ipAddress, hostName, this); + this.ipTransport = ipTransport; + return ipTransport; + } catch (IOException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + THING_STATUS_FMT.formatted("error.failed-to-connect", e.getMessage())); + } + return null; + } + + /** + * 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 + * + * @return OK or ERROR with reason + */ + public String pair(String code, boolean withExternalAuthentication) { + 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()) { + 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); + + isConfigured = false; + Long accessoryId = checkedAccessoryId(); + String ipAddress = checkedIpAddress(); + String uniqueId = checkedUniqueId(); + String hostName = checkedHostName(); + if (accessoryId == null || ipAddress == null || uniqueId == null || hostName == null) { + return ACTION_RESULT_ERROR_FORMAT.formatted("config error"); + } + isConfigured = true; + + if (keyStore.getAccessoryKey(uniqueId) != null) { + return ACTION_RESULT_OK_FORMAT.formatted("already paired"); // OK if already paired + } + + // create new transport + if (checkedCreateIpTransport(ipAddress, hostName) == null) { + return ACTION_RESULT_ERROR_FORMAT.formatted("no transport"); + } + + try { + logger.debug("{} starting Pair-Setup", thing.getUID()); + PairSetupClient pairSetupClient = new PairSetupClient(getIpTransport(), keyStore.getControllerUUID(), + keyStore.getControllerKey(), pairingCode, withExternalAuthentication); + + Ed25519PublicKeyParameters accessoryKey = pairSetupClient.pair(); + 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 + scheduleConnectionAttempt(); + return ACTION_RESULT_OK; // pairing succeeded + } catch (Exception e) { + // catch all; log all exceptions + logger.debug("{} pairing / verification failed '{}'", thing.getUID(), e.getMessage(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + THING_STATUS_FMT.formatted("error.pairing-verification-failed", e.getMessage())); + return ACTION_RESULT_ERROR_FORMAT.formatted("pairing error"); + } + } + + /** + * Inner method to unpair and clear stored key. + * + * @return OK or ERROR with reason + */ + private String unpairInner() { + if (isBridgedAccessory) { + logger.warn("{} forbidden to unpair a bridged accessory", thing.getUID()); + return ACTION_RESULT_ERROR_FORMAT.formatted("bridged accessory"); + } + + 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(uid) == null) { + return ACTION_RESULT_ERROR_FORMAT.formatted("not paired"); + } + + try { + PairRemoveClient service = new PairRemoveClient(getIpTransport(), keyStore.getControllerUUID()); + service.remove(); + keyStore.setAccessoryKey(uid, null); + return ACTION_RESULT_OK; + } catch (IOException | InterruptedException | TimeoutException | ExecutionException | IllegalAccessException + | IllegalStateException e) { + logger.warn("{} error '{}' unpairing accessory", thing.getUID(), e.getMessage()); + return ACTION_RESULT_ERROR_FORMAT.formatted("unpairing error"); + } + } + + /** + * Thing Action that unpairs the accessory. + * + * @return OK or ERROR with reason + */ + public String unpair() { + String result = unpairInner(); + if (result.startsWith(ACTION_RESULT_OK)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/error.not-paired"); + } + return result; + } + + /** + * 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; + } + + /** + * 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; + } + Long 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 + Map thingProperties = new HashMap<>(thing.getProperties()); + for (Service service : accessory.services) { + if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { + thingProperties.putAll(service.getProperties(thing.getUID(), typeProvider, i18nProvider, bundle)); + 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); + } + + /** + * Wrapper to enable or disable eventing for members of the eventedCharacteristics list of the + * accessory or its bridged accessories, with exception handling. + * + * @param enable true to enable events, false to disable + */ + protected void enableEvents(boolean enable) { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + bridgeHandler.enableEvents(enable); + return; + } + try { + enableEventsOrThrow(enable); + } catch (InterruptedException e) { + 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 + 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", thing.getUID(), e.getMessage()); + } + logger.debug("Stack trace", e); + } + } + + /** + * Inner method to enable or disable eventing for members of the eventedCharacteristics list of the + * 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: + * @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) { + throw new IllegalStateException("Forbidden to enable/disable events on bridged accessory"); + } + Service service = new Service(); + service.characteristics = new ArrayList<>(); + service.characteristics.addAll(getEventedCharacteristics().values().stream().map(cxx -> { + cxx.ev = enable; + return cxx; + }).toList()); + 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", thing.getUID(), enable ? "en" : "dis", + service.characteristics.size()); + } + + /** + * 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() { + 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..", thing.getUID(), + 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(), e); + } + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + THING_STATUS_FMT.formatted("error.polling-error", e.getMessage())); + } + } + + /** + * Called when the connected thing has finished loading the accessories. + * Subclasses MUST override this to perform any extra processing required. + */ + protected abstract void onConnectedThingAccessoriesLoaded(); + + /** + * 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 + */ + protected abstract Map getEventedCharacteristics(); + + /** + * 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 + */ + protected abstract Map getPolledCharacteristics(); + + @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 markAsReady(Thing readyThing) { + if (getBridge() instanceof Bridge bridge && bridge.getHandler() instanceof HomekitBridgeHandler bridgeHandler) { + bridgeHandler.markAsReady(readyThing); + return; + } + 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() { + updateStatus(ThingStatus.ONLINE); + if (!isBridgedAccessory) { + enableEvents(true); + startConnectedThingRefreshTask(); + } + } + + /** + * 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 startConnectedThingRefreshTask() { + 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", thing.getUID()); + } + } + + /** + * 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 bridged 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(); + return; + } + 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 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 + * @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 + */ + 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 bridged 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: + * @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 + && 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)); + } + + /** + * 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 new file mode 100644 index 0000000000000..5d8d8497c61e7 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/handler/HomekitBridgeHandler.java @@ -0,0 +1,165 @@ +/* + * 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.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.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; +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; +import org.openhab.core.thing.binding.builder.BridgeBuilder; +import org.openhab.core.types.Command; +import org.osgi.framework.Bundle; + +/** + * 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 HomekitBridgedAccessoryDiscoveryService bridgedAccessoryDiscoveryService; + + public HomekitBridgeHandler(Bridge bridge, HomekitTypeProvider typeProvider, HomekitKeyStore keyStore, + TranslationProvider i18nProvider, Bundle bundle) { + super(bridge, typeProvider, keyStore, i18nProvider, bundle); + } + + @Override + public Bridge getThing() { + return (Bridge) super.getThing(); + } + + /** + * 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 + */ + @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 handleCommand(ChannelUID channelUID, Command command) { + // do nothing + } + + @Override + public Collection> getServices() { + return Set.of(HomekitBridgedAccessoryDiscoveryService.class, HomekitPairingActions.class); + } + + @Override + public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) { + // do nothing + } + + @Override + public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { + // do nothing + } + + @Override + protected void onConnectedThingAccessoriesLoaded() { + createProperties(); + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + accessoryHandler.onConnectedThingAccessoriesLoaded(); + } + }); + onThingOnline(); + } + + @Override + public void onEvent(String jsonContent) { + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + accessoryHandler.onEvent(jsonContent); + } + }); + } + + @Override + protected void onThingOnline() { + updateStatus(ThingStatus.ONLINE); + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + accessoryHandler.onThingOnline(); + } + }); + super.onThingOnline(); + HomekitBridgedAccessoryDiscoveryService discoveryService = bridgedAccessoryDiscoveryService; + if (discoveryService != null) { + discoveryService.startScan(); + } + } + + public void registerDiscoveryService(HomekitBridgedAccessoryDiscoveryService discoveryService) { + bridgedAccessoryDiscoveryService = discoveryService; + } + + public void unregisterDiscoveryService() { + bridgedAccessoryDiscoveryService = null; + } + + @Override + protected Map getEventedCharacteristics() { + eventedCharacteristics.clear(); + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + eventedCharacteristics.putAll(accessoryHandler.getPolledCharacteristics()); + } + }); + return eventedCharacteristics; + } + + @Override + protected Map getPolledCharacteristics() { + polledCharacteristics.clear(); + getThing().getThings().forEach(bridgedThing -> { + if (bridgedThing.getHandler() instanceof HomekitAccessoryHandler accessoryHandler) { + polledCharacteristics.putAll(accessoryHandler.getPolledCharacteristics()); + } + }); + return polledCharacteristics; + } + + @Override + protected void initializeNotReadyThings() { + notReadyThings.clear(); + // a bridge requires all enabled bridged-accessories to be ready + notReadyThings.addAll(getThing().getThings().stream().filter(thing -> thing.isEnabled()).toList()); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/CharacteristicReadWriteClient.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/CharacteristicReadWriteClient.java new file mode 100644 index 0000000000000..edb9fc3d296b3 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/CharacteristicReadWriteClient.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.hapservices; + +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; + +/** + * 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 CharacteristicReadWriteClient { + + private final IpTransport ipTransport; + + public CharacteristicReadWriteClient(IpTransport ipTransport) { + this.ipTransport = ipTransport; + } + + /** + * Reads characteristic(s) from the accessory. + * + * @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 ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws IllegalStateException + */ + 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); + return new String(result, StandardCharsets.UTF_8); + } + + /** + * Writes characteristic(s) to the accessory. + * + * @param json the JSON string to write. + * @return + * @throws ExecutionException + * @throws TimeoutException + * @throws InterruptedException + * @throws IOException + * @throws IllegalStateException + */ + 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)); + return new String(result, StandardCharsets.UTF_8); + } +} 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 new file mode 100644 index 0000000000000..3cdc573fbf775 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairRemoveClient.java @@ -0,0 +1,136 @@ +/* + * 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.hapservices; + +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; +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.transport.IpTransport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Service to remove an existing pairing with a HomeKit accessory. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class PairRemoveClient { + + private static final String ENDPOINT_PAIR_REMOVE = "/pairings"; + private static final String CONTENT_TYPE = "application/pairing+tlv8"; + + private final Logger logger = LoggerFactory.getLogger(PairRemoveClient.class); + + private final IpTransport ipTransport; + private final byte[] controllerId; + + public PairRemoveClient(IpTransport ipTransport, byte[] controllerId) { + this.ipTransport = ipTransport; + this.controllerId = controllerId; + } + + /** + * Removes an existing pairing with the accessory. + * + * @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 { + logger.debug("Pair-Remove: starting removal"); + 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, 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 {%d}", entry.getKey(), toHex(entry.getValue()), + entry.getValue().length)); + } + logger.trace("{}", sb.toString()); + } + } + + /** + * Helper class 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.value, TlvType.METHOD.value, TlvType.IDENTIFIER.value), // + PairingState.M2, Set.of(TlvType.STATE.value)); + + /** + * 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.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.value); + if (state == null || state.length != 1) { + throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); + } + + 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(), 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(), pairingState.name(), key)); + } + } + } + } +} 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 new file mode 100644 index 0000000000000..55abc568b2cc2 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairSetupClient.java @@ -0,0 +1,302 @@ +/* + * 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.hapservices; + +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; +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; +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.IpTransport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 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 final Logger logger = LoggerFactory.getLogger(PairSetupClient.class); + + private final IpTransport ipTransport; + 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, boolean withExternalAuthentication) { + logger.debug("Created with pairing code: {}", pairingCode); + this.ipTransport = ipTransport; + this.password = pairingCode; + this.controllerId = controllerId; + this.controllerKey = controllerKey; + this.withExternalAuthentication = withExternalAuthentication; + } + + /** + * Executes the 6-step pairing process with the accessory. + * + * @return SessionKeys containing the derived session keys + * @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, + InterruptedException, TimeoutException, ExecutionException, IllegalStateException { + SRPclient client = m1Execute(); + return client.getAccessoryLongTermPublicKey(); + } + + /** + * Executes step M1 of the pairing process: Start Pair-Setup. + * + * @return byte array containing the response from the accessory + * @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 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 { + 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[] { 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)); + 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 m1Response byte array containing the response from step M1 + * @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, + 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); + Validator.validate(PairingMethod.SETUP, tlv); + byte[] serverSalt = tlv.get(TlvType.SALT.value); + byte[] serverPublicKey = tlv.get(TlvType.PUBLIC_KEY.value); + 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 & M1 proof. + * + * @return byte array containing the response from the accessory + * @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 { + 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); + loggerTraceTlv(tlv); + Validator.validate(PairingMethod.SETUP, tlv); + 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 m3Response byte array containing the response from step M3 + * @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 { + 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)); + 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 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 { + logger.debug("Pair-Setup M5: Send controller id, LTPK, and signature to accessory"); + 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); + loggerTraceTlv(tlv); + Validator.validate(PairingMethod.SETUP, tlv); + 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 m5Response byte array containing the response from step M5 + * @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"); + 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 {%d}", entry.getKey(), toHex(entry.getValue()), + entry.getValue().length)); + } + logger.trace("{}", sb.toString()); + } + } + + /** + * Helper class 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.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. + * + * @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.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.value); + if (state == null || state.length != 1) { + throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); + } + + 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(), 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(), pairingState.name(), key)); + } + } + } + } +} 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 new file mode 100644 index 0000000000000..d240077ed60d4 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/hapservices/PairVerifyClient.java @@ -0,0 +1,264 @@ +/* + * 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.hapservices; + +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.*; + +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; +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.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.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. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class PairVerifyClient { + + private final Logger logger = LoggerFactory.getLogger(PairVerifyClient.class); + + private final IpTransport ipTransport; + private final byte[] clientPairingId; + private final Ed25519PrivateKeyParameters controllerKey; + private final Ed25519PublicKeyParameters accessoryKey; + private final X25519PrivateKeyParameters controllerEphemeralSecretKey; + + private @NonNullByDefault({}) X25519PublicKeyParameters serverEphemeralPublicKey; + private @NonNullByDefault({}) byte[] sharedSecret; + private @NonNullByDefault({}) byte[] sharedKey; + private @NonNullByDefault({}) byte[] readKey; + private @NonNullByDefault({}) byte[] writeKey; + + public PairVerifyClient(IpTransport ipTransport, byte[] controllerId, Ed25519PrivateKeyParameters controllerKey, + Ed25519PublicKeyParameters accessoryKey) throws NoSuchAlgorithmException, NoSuchProviderException { + logger.debug("Created.."); + this.ipTransport = ipTransport; + this.clientPairingId = controllerId; + this.controllerKey = controllerKey; + this.accessoryKey = accessoryKey; + this.controllerEphemeralSecretKey = CryptoUtils.generateX25519KeyPair(); + } + + /** + * Executes the 4-step pairing verification process with the accessory. + * + * @return SessionKeys containing the derived session keys + * @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 { + m1Execute(); + return new AsymmetricSessionKeys(readKey, writeKey); + } + + /** + * M1 — Create new random client ephemeral X25519 public key and send it to server + * + * @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 { + 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, 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); + } + + /** + * M2 — Receive server ephemeral X25519 public key and encrypted TLV + * + * @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 { + 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); + sharedSecret = generateSharedSecret(controllerEphemeralSecretKey, serverEphemeralPublicKey); + sharedKey = generateHkdfKey(sharedSecret, PAIR_VERIFY_ENCRYPT_SALT, PAIR_VERIFY_ENCRYPT_INFO); + + 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.value); + byte[] serverSignature = subTlv.get(TlvType.SIGNATURE.value); + 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, + controllerEphemeralSecretKey.generatePublicKey().getEncoded())); + + m3Execute(); + } + + /** + * M3 — Send encrypted controller identifier and signature + * + * @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 { + logger.debug("Pair-Verify M3: Send encrypted controller id with signature"); + byte[] clientSignature = signMessage(controllerKey, + concat(controllerEphemeralSecretKey.generatePublicKey().getEncoded(), clientPairingId, + serverEphemeralPublicKey.getEncoded())); + + 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 = 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)); + m4Execute(m3Response); + } + + /** + * M4 — Final confirmation + * + * @param m3Response the response from step M3 + */ + private void m4Execute(byte[] m3Response) { + 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 {%d}", entry.getKey(), toHex(entry.getValue()), + entry.getValue().length)); + } + logger.trace("{}", sb.toString()); + } + } + + /** + * Helper class 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.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. + * + * @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.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.value); + if (state == null || state.length != 1) { + throw new SecurityException("Missing or invalid 'STATE' TLV (0x06)"); + } + + 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(), 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(), pairingState.name(), key)); + } + } + } + } +} 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..94d1f3642f19e --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitKeyStore.java @@ -0,0 +1,86 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.UUID; + +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_UUID = "controllerUUID"; + 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 byte[] getControllerUUID() { + if (storage.get(CONTROLLER_UUID) instanceof String controllerUUID) { + return decode(controllerUUID); + } + byte[] newControllerUUID = UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8); + storage.put(CONTROLLER_UUID, encode(newControllerUUID)); + return newControllerUUID; + } + + public Ed25519PrivateKeyParameters getControllerKey() { + if (storage.get(CONTROLLER_KEY_ID) instanceof String controllerKey) { + return new Ed25519PrivateKeyParameters(decode(controllerKey), 0); + } + 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/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 new file mode 100644 index 0000000000000..4580e56068d68 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/persistence/HomekitTypeProvider.java @@ -0,0 +1,38 @@ +/* + * 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 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.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 + * channel group types. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +@Component(service = { ChannelTypeProvider.class, ChannelGroupTypeProvider.class, HomekitTypeProvider.class }) +public class HomekitTypeProvider extends AbstractStorageBasedTypeProvider { + + @Activate + public HomekitTypeProvider(@Reference StorageService storageService) { + super(storageService); + } +} diff --git a/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/AsymmetricSessionKeys.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/AsymmetricSessionKeys.java new file mode 100644 index 0000000000000..4cb1360f6a69f --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/AsymmetricSessionKeys.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 AsymmetricSessionKeys { + private final byte[] readKey; + private final byte[] writeKey; + + public AsymmetricSessionKeys(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/session/EventListener.java b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventListener.java new file mode 100644 index 0000000000000..7121945689179 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/EventListener.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.session; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Callback interface for handling HTTP 'EVENT' message contents. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public interface EventListener { + + /* + * Method invoked when an event occurs. + * + * @param jsonContent string containing the HTTP json content. + */ + public void onEvent(String jsonContent); +} 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..437ae561ba868 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/HttpPayloadParser.java @@ -0,0 +1,326 @@ +/* + * 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.io.IOException; +import java.nio.charset.StandardCharsets; +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 implements AutoCloseable { + + 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 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(); + private final ByteArrayOutputStream chunkDataBuffer = new ByteArrayOutputStream(); + + private boolean headersDone = false; + private int contentLength = -1; + private int headersLength = -1; + private boolean isChunked = false; + 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 IllegalStateException + */ + public void accept(byte[] frame) throws IllegalStateException { + 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; + } + // no content-length and not chunked: check status code + 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; + } + + /** + * 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 IllegalStateException if the status line is missing or malformed. + */ + 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 IllegalStateException("Malformed HTTP status code: " + matcher.group(1)); + } + } + throw new IllegalStateException("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. + * 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 IllegalStateException if the chunked data is malformed. + */ + 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; + } + if (sizeBuffer.length == 0) { + continue; // some implementations insert empty lines, so skip them + } + int size; + try { + size = Integer.parseInt(new String(sizeBuffer, StandardCharsets.ISO_8859_1).trim(), 16); + } catch (NumberFormatException e) { + throw new IllegalStateException("Invalid chunk size: " + sizeBuffer); + } + contentBuffer.write(chunkBuffer, pos, size); + if ((pos += size) >= max) { // move past data; exit on overrun + break; + } + 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; + } + } + } + } + + /** + * 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 IllegalStateException if the content exceeds maximum allowed length. + */ + private void processContentBytes(byte[] data) throws IllegalStateException { + if (isChunked && !finalChunkSeen) { + 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 IllegalStateException("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; + } + + /** + * 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; + } + + @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 new file mode 100644 index 0000000000000..bc9ac188668ef --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/session/SecureSession.java @@ -0,0 +1,171 @@ +/* + * 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 static org.openhab.binding.homekit.internal.crypto.CryptoUtils.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +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.util.concurrent.atomic.AtomicInteger; + +import org.bouncycastle.crypto.InvalidCipherTextException; +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. + * It maintains separate counters for read and write operations to ensure nonce uniqueness. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class SecureSession { + + private static final int SLEEP_INTERVAL_MILLISECONDS = 50; + + private final InputStream in; + private final OutputStream out; + 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 = socket.getInputStream(); + out = socket.getOutputStream(); + this.keys = keys; + } + + /** + * 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 complete plaintext message to be sent. + * @throws IOException + * @throws InvalidCipherTextException + */ + public void send(byte[] plainText) throws IOException, InvalidCipherTextException { + try (ByteArrayInputStream plainTextStream = new ByteArrayInputStream(plainText)) { + while (plainTextStream.available() > 0) { + sendFrame(plainTextStream); + } + } + } + + /** + * 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. + * + * @param plainTextStream the input stream containing the plaintext to be sent. + * @throws IOException + * @throws InvalidCipherTextException + */ + private void sendFrame(ByteArrayInputStream plainTextStream) throws IOException, InvalidCipherTextException { + short frameLen = (short) Math.min(1024, plainTextStream.available()); + byte[] frameAad = ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(frameLen).array(); + byte[] plainText = plainTextStream.readNBytes(frameLen); + byte[] nonce64 = generateNonce64(writeCounter.getAndIncrement()); + 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(); + } + + /** + * Reads multiple data frames from the input stream until a complete HTTP message is reconstructed. + * 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 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 { + 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); + } + } while (!httpParser.isComplete()); + return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), traceStream.toByteArray() }; + } + } + + /** + * 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 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 { + byte[] frameAad = new byte[2]; // AAD data length prefix + 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 + readFully(in, cipherText); + byte[] nonce64 = generateNonce64(readCounter.getAndIncrement()); + return decrypt(keys.getReadKey(), nonce64, cipherText, frameAad); + } + + /** + * Reads bytes from the given input stream until the buffer is completely filled. + * + * @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; + 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/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..f0ab077b3604a --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/temporary/LightModel.java @@ -0,0 +1,1473 @@ +/* + * 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 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. + * + * @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 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 + * 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. + * + * 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 { + + // 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); + } + } +} 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..0b8e331dc872e --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/java/org/openhab/binding/homekit/internal/transport/IpTransport.java @@ -0,0 +1,414 @@ +/* + * 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.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.time.Instant; +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; +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; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.homekit.internal.session.AsymmetricSessionKeys; +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; +import org.slf4j.LoggerFactory; + +/** + * 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. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class IpTransport implements AutoCloseable { + + 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 -> { + Thread t = new Thread(r, "homekit-io"); + t.setDaemon(true); + return t; + }); + + private final Socket socket; + private final String hostName; + private final EventListener eventListener; + + 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; + + /** + * 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._hap._tcp.local') of the HomeKit accessory + * @throws IOException + */ + 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 = ipAddress.split(":"); + socket = new Socket(); + 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); + } + + /** + * 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); + } + + /** + * 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, 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, 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, ExecutionException, IllegalStateException, TimeoutException { + return execute("PUT", endpoint, contentType, 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 + * @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, 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("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<>(); + readHttpResponseFuture = readFuture; + // create Future to write the request (with a timeout) + Future<@Nullable Void> writeFuture = executor.submit(() -> { + secureSession.send(request); + return null; + }); + // now wait for both write and read to complete + 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> writeFuture = executor.submit(() -> { + out.write(request); + out.flush(); + return null; + }); + // wait for write to complete + writeFuture.get(TIMEOUT_MILLI_SECONDS, TimeUnit.MILLISECONDS); + // create Future to read the response (with a timeout) + Future readFuture = executor.submit(() -> readPlainResponse(in, trace)); + // wait for read to complete + response = readFuture.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("HTTP response:\n{}", new String(response[2], StandardCharsets.ISO_8859_1)); + } + + checkHeaders(response[0]); + return response[1]; + } + + /** + * 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(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"); + } + sb.append("\r\n"); + + byte[] headerBytes = sb.toString().getBytes(StandardCharsets.UTF_8); + if (contentIsEmpty(method)) { + return headerBytes; + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(headerBytes); + out.write(content); + return out.toByteArray(); + } + + 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). + * + * @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, IllegalStateException { + 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); + } + } while (!httpParser.isComplete()); + return new byte[][] { httpParser.getHeaders(), httpParser.getContent(), + raw != null ? raw.toByteArray() : new byte[0] }; + } + } + + /** + * 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, IllegalStateException { + int httpStatusCode = HttpPayloadParser.getHttpStatusCode(headers); + if (httpStatusCode >= 300) { + throw new IOException("HTTP " + httpStatusCode); + } + } + + @Override + public synchronized void close() { + closing = true; + secureSession = null; + try { + socket.close(); + } 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 + } + } + readThread = null; + if (readHttpResponseFuture instanceof CompletableFuture readFuture) { + readFuture.complete(new byte[3][0]); // complete with an empty response + } + executor.shutdownNow(); + try { + if (!executor.awaitTermination(500, TimeUnit.MILLISECONDS)) { + logger.debug("Executor did not terminate promptly"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * 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 handleResponse(byte[][] response) { + String headers = new String(response[0], StandardCharsets.ISO_8859_1); + if (headers.startsWith("HTTP")) { + if (readHttpResponseFuture instanceof CompletableFuture readFuture) { + readHttpResponseFuture = null; + readFuture.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); + eventListener.onEvent(jsonContent); + } else { + logger.warn("Unexpected response headers:\n{}", headers); + } + } + + /** + * 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() { + try { + do { + SecureSession session = secureSession; + if (session == null) { + throw new IllegalStateException("Secure session is null"); + } + byte[][] response = session.receive(logger.isTraceEnabled()); + handleResponse(response); + } 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); + } + } + } + + if (readHttpResponseFuture instanceof CompletableFuture readFuture) { + readHttpResponseFuture = null; + readFuture.completeExceptionally(new InterruptedException("Listener interrupted")); + } + } +} 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..6a550ca717605 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,22 @@ + + + + binding + HomeKit Binding + This is the binding for a HomeKit client. + + + + mdns + + + mdnsServiceType + _hap._tcp.local. + + + + + + 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..4c5967a752049 --- /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 new file mode 100644 index 0000000000000..aec376eee19ed --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/i18n/homekit.properties @@ -0,0 +1,267 @@ +# add-on + +addon.homekit.name = HomeKit Binding +addon.homekit.description = This is the binding for a HomeKit client. + +# thing types + +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 accessory without its own LAN connection and instead supported by a bridge + +# thing types config + +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 = 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.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 + +error.bridge-not-connected = Bridge not connected +error.invalid-ip-address = Invalid IP address +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-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}'' +error.not-paired = Not paired + +# thing actions + +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.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-result.label = Unpairing Result +actions.unpairing-result.description = The message describes the result of the unpairing attempt. + +# characteristic texts + +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 = Closing +characteristic.position-state.1 = Opening +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 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..9d5e9da150079 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,38 @@ + + + + + + HomeKit accessory with its own LAN connection + uniqueId + + + + + + HomeKit accessory with LAN connection that supports bridged accessories not having an own LAN connection + NetworkAppliance + uniqueId + + + + + + + + + HomeKit accessory without its own LAN connection and instead supported by a bridge + uniqueId + + + + ID of the accessory. + true + + + + + 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..7cea3e80655d9 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/SRPserver.java @@ -0,0 +1,180 @@ +/* + * 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.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; +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 { + + /* + * *************************************************************************************** + * + * 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 + public final BigInteger B; // server public SRP key + 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[] accessoryId; + private final Ed25519PrivateKeyParameters accessoryKey; + + /** + * Create a SRP server instance with the given parameters. + * + * @param password the password to use + * @param serverSalt the salt to use + * @param accessoryId the pairing ID 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 NoSuchAlgorithmException + * + */ + public SRPserver(String password, byte[] serverSalt, byte[] accessoryId, Ed25519PrivateKeyParameters accessoryKey, + @Nullable String username, byte @Nullable [] accessoryPrivateKey) throws NoSuchAlgorithmException { + this.accessoryId = accessoryId; + this.accessoryKey = accessoryKey; + I = username != null ? username : PAIR_SETUP; + s = serverSalt; + + // 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); + + // Apply or create ephemeral b and compute public B + byte[] serverKey = accessoryPrivateKey; + 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); + } + + 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"); + } + A = clientPublicA; + + // u = H(PAD(A) || PAD(B)) + 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"); + } + + // S = (A * v^u)^b mod N (384 bytes) + BigInteger vu = v.modPow(u, N); + BigInteger base = A.multiply(vu).mod(N); + 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)); + 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, 384), toUnsigned(B, 384), K)); + + // Compute M2 = H(A || M1 || K) + return sha512(concat(toUnsigned(clientPublicA, 384), M1, K)); + } + + 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"); + } + + byte[] decryptKey = generateHkdfKey(K, PAIR_SETUP_ENCRYPT_SALT, PAIR_SETUP_ENCRYPT_INFO); + byte[] plainText = CryptoUtils.decrypt(decryptKey, PS_M5_NONCE, cipherText, 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[] 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); + byte[] accessorySignature = signMessage(accessoryKey, 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 new file mode 100644 index 0000000000000..673b00bd39186 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestAppleTestVectors.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; + +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; +import org.openhab.core.util.HexUtils; + +/** + * 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 { + + /* + * *************************************************************************************** + * + * 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 + 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 = HexUtils.hexBlockToBigInteger(N_hex); + assertEquals(3072, N1.bitLength()); + + 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(HexUtils.hexBlockToBytes(N_hex), nBytes); + + BigInteger g1 = new BigInteger(1, HexUtils.hexBlockToBytes(g_hex)); + assertEquals(5, g1.intValue()); + assertEquals(g1, HexUtils.hexBlockToBigInteger(g_hex)); + } + + @Test + void testClientKeyConversion() { + BigInteger N = HexUtils.hexBlockToBigInteger(N_hex); + BigInteger g = HexUtils.hexBlockToBigInteger(g_hex); + + BigInteger a = HexUtils.hexBlockToBigInteger(a_hex); + BigInteger A = HexUtils.hexBlockToBigInteger(A_hex); + + BigInteger calcA = g.modPow(a, N); + + assertEquals(A, calcA); + + byte[] act; + byte[] exp; + + act = CryptoUtils.toUnsigned(a, 32); + exp = HexUtils.hexBlockToBytes(a_hex); + assertArrayEquals(exp, act); + + act = CryptoUtils.toUnsigned(A, 384); + exp = HexUtils.hexBlockToBytes(A_hex); + assertArrayEquals(exp, act); + } + + @Test + void testClientVectors() { + 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<>(); + + 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, client.S); + assertArrayEquals(K, client.K); + } + + @Test + void testServerVectors() { + 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); + + 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.m3CreateServerProof(A)); + assertArrayEquals(u, CryptoUtils.toUnsigned(server.u, 64)); + assertArrayEquals(S, server.S); + assertArrayEquals(K, server.K); + } +} 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 new file mode 100644 index 0000000000000..129231dfb1aef --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAppleJson.java @@ -0,0 +1,470 @@ +/* + * 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.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; +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.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; + +/** + * Test cases for loading channel creation data from JSON provided in the Apple specification. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestChannelCreationForAppleJson { + + // Apple HomeKit Specification 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.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(1L); + assertNotNull(accessory); + assertEquals(1, accessory.aid); + assertEquals(2, accessory.services.size()); + Service service = accessory.getService(1L); + assertNotNull(service); + assertEquals("3E", service.type); + assertEquals(6, service.characteristics.size()); + Characteristic characteristic = service.getCharacteristic(2L); + 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); + TranslationProvider i18nProvider = mock(TranslationProvider.class); + Bundle bundle = mock(Bundle.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 + */ + ThingUID thingUID = new ThingUID("hhh", "aaa", "bridge1", "accessory3"); + Accessory accessory = accessories.getAccessory(3L); + assertNotNull(accessory); + List channelGroupDefinitions = accessory.getChannelGroupDefinitions(thingUID, + typeProvider, i18nProvider, bundle); + + // There should be just 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 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 channelGroupType = channelGroupTypes.stream() + .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-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()); + + // 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("channel-type-brightness-9-bridge1-accessory3", channelDefinition.getChannelTypeUID().getId()); + assertEquals("Brightness", channelDefinition.getLabel()); + assertEquals("int", channelDefinition.getProperties().get("format")); + + // 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("channel-type-brightness-9-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(1L); + assertNotNull(accessory); + Map properties = new HashMap<>(); + for (Service service : accessory.services) { + if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { + properties.putAll(service.getProperties(thingUID, typeProvider, i18nProvider, bundle)); + 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/TestChannelCreationForAqaraJson.java b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java new file mode 100644 index 0000000000000..9af8e87e9a77a --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForAqaraJson.java @@ -0,0 +1,745 @@ +/* + * 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 presence 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.getChannelGroupDefinitions(thingUID, + typeProvider, i18nProvider, bundle); + + assertNotNull(channelGroupDefinitions); + assertEquals(4, 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 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()); + + // 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(1, channelDefinitions.size()); + ChannelDefinition channelDefinition = channelDefinitions.get(0); + 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(1, channelDefinitions.size()); + channelDefinition = channelDefinitions.get(0); + 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(1, channelDefinitions.size()); + channelDefinition = channelDefinitions.get(0); + assertNotNull(channelDefinition); + channelTypeUID = channelDefinition.getChannelTypeUID(); + assertNotNull(channelTypeUID); + channelType = channelTypes.get(channelTypeUID); + 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 new file mode 100644 index 0000000000000..9118b6abd7aa4 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestChannelCreationForVeluxJson.java @@ -0,0 +1,1973 @@ +/* + * 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.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; +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.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.ChannelKind; +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; + +/** + * 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(1L); + assertNotNull(accessory); + assertEquals(1, accessory.aid); + assertEquals(3, accessory.services.size()); + Service service = accessory.getService(1L); + assertNotNull(service); + assertEquals("3E", service.type); + assertEquals(7, service.characteristics.size()); + Characteristic characteristic = service.getCharacteristic(2L); + 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 testBridge() { + 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); + + 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)); + + // 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(1L); + assertNotNull(accessory); + Map properties = new HashMap<>(); + for (Service service : accessory.services) { + if (ServiceType.ACCESSORY_INFORMATION == service.getServiceType()) { + properties.putAll(service.getProperties(thingUID, typeProvider, i18nProvider, bundle)); + 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); + TranslationProvider i18nProvider = mock(TranslationProvider.class); + Bundle bundle = mock(Bundle.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(2L); + assertNotNull(accessory); + List channelGroupDefinitions = accessory.getChannelGroupDefinitions(thingUID, + typeProvider, i18nProvider, bundle); + + // 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); + TranslationProvider i18nProvider = mock(TranslationProvider.class); + Bundle bundle = mock(Bundle.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(9L); + assertNotNull(accessory); + List channelGroupDefinitions = accessory.getChannelGroupDefinitions(thingUID, + typeProvider, i18nProvider, bundle); + + // 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()); + + // 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()); + + 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("Position State #2", options.get(2).getLabel()); + assertEquals("2", options.get(2).getValue()); + } +} 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..d13d50c0e6bb7 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpChunkedParser.java @@ -0,0 +1,141 @@ +/* + * 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.io.IOException; +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() throws IOException { + 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)); + } + } + + @Test + void testBadChunkedSizePayload() throws IOException { + 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))); + } + } + + @Test + void testChunkedPayloadWithEmptyLines() throws IOException { + 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)); + } + } + + @Test + void testIncompleteChunkedPayload() throws IOException { + 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)); + } + } + + @Test + void testValidChunkedPayloadWitSplitFrames() throws IOException { + 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)); + } + } +} 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..fbdef733d5f95 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestHttpPayloadParser.java @@ -0,0 +1,285 @@ +/* + * 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.io.IOException; + +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 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"; + private static final String CHUNK = CHUNK_1 + CHUNK_2; + private static final String CRLF = "\r\n"; + + @Test + 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; + 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() throws IOException { + 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)); + } + } + + @Test + void testHttpWithChunkedContentOkManyPartialAndSplitChunkHeader() throws IOException { + 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)); + } + } + + @Test + void testHttpWithContentDiscardExtra() throws IOException { + 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)); + } + } + + @Test + void testHttpWithContentManyPartialOk() throws IOException { + 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)); + } + } + + @Test + void testHttpWithContentManyPartialOkAndSplitCRLF() throws IOException { + 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)); + } + } + + @Test + void testHttpWithContentOk() throws IOException { + 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)); + } + } + + @Test + void testHttpWithMultipleFrames() throws IOException { + 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); + } + } + + @Test + 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()); + } + } + + @Test + 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()); + } + } + + @Test + void testHttpWithZeroContentLength() throws IOException { + 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)); + } + } + + @Test + void testOk204() throws IOException { + 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)); + } + } + + @Test + void testError403() throws IOException { + 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)); + } + } + + @Test + void testError404() throws IOException { + 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)); + } + } + + @Test + void testError500() throws IOException { + 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)); + } + } +} 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..f071112f9c9cf --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairSetup.java @@ -0,0 +1,168 @@ +/* + * 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.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.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; +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.hapservices.PairSetupClient; +import org.openhab.binding.homekit.internal.transport.IpTransport; +import org.openhab.core.util.HexUtils; + +/** + * 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 + """; + + 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[] nonce64 = generateNonce64(123); + new SecureRandom().nextBytes(key); + byte[] cipherText = encrypt(key, nonce64, plainText0, new byte[0]); + byte[] plainText1 = decrypt(key, nonce64, cipherText, new byte[0]); + assertArrayEquals(plainText0, plainText1); + } + + @Test + void testSrpClient() throws InvalidCipherTextException, NoSuchAlgorithmException { + byte[] plainText0 = "the quick brown dog".getBytes(StandardCharsets.UTF_8); + 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]); + assertArrayEquals(plainText0, plainText1); + } + + @Test + 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 }; + byte[] accessoryId = new byte[] { 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 }; + byte[] serverSalt = HexUtils.hexBlockToBytes(SALT_HEX); + + // initialize signing keys + Ed25519PrivateKeyParameters controllerLongTermSecretKey = new Ed25519PrivateKeyParameters( + HexUtils.hexBlockToBytes(CLIENT_PRIVATE_HEX)); + Ed25519PrivateKeyParameters accessoryLongTermSecretKey = new Ed25519PrivateKeyParameters( + HexUtils.hexBlockToBytes(SERVER_PRIVATE_HEX)); + + // create mock + IpTransport mockTransport = mock(IpTransport.class); + + // create SRP client and server + SRPserver server = new SRPserver(password, serverSalt, accessoryId, accessoryLongTermSecretKey, null, null); + PairSetupClient client = new PairSetupClient(mockTransport, iOSDeviceId, controllerLongTermSecretKey, password, + false); + + // mock the HTTP transport to simulate the SRP exchange + doAnswer(invocation -> { + byte[] arg = invocation.getArgument(2); + + // decode and validate the incoming TLV + Map tlv = Tlv8Codec.decode(arg); + PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv); + byte[] state = tlv.get(TlvType.STATE.value); + 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 -> m1GetAccessoryResponse(server, serverSalt); + case 3 -> m3GetAccessoryResponse(server, tlv, client); + case 5 -> m5GetAccessoryResponse(server, tlv); + default -> throw new IllegalArgumentException("Unexpected state"); + }; + + }).when(mockTransport).post(anyString(), anyString(), any(byte[].class)); + + // execute the pairing setup + client.pair(); + } + + private byte[] m1GetAccessoryResponse(SRPserver server, byte[] serverSalt) { + Map tlv = Map.of( // + 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); + } + + private byte[] m3GetAccessoryResponse(SRPserver server, Map tlv2, PairSetupClient client) + throws NoSuchAlgorithmException { + clientPublicKey = tlv2.get(TlvType.PUBLIC_KEY.value); + byte[] serverProof = server.m3CreateServerProof(Objects.requireNonNull(clientPublicKey)); + Map tlv3 = Map.of( // + TlvType.STATE.value, new byte[] { PairingState.M4.value }, // + TlvType.PROOF.value, serverProof // server proof + ); + PairSetupClient.Validator.validate(PairingMethod.SETUP, tlv3); + return Tlv8Codec.encode(tlv3); + } + + private byte[] m5GetAccessoryResponse(SRPserver server, Map tlv5) + throws InvalidCipherTextException { + 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, 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 new file mode 100644 index 0000000000000..5f5fecc4bd0f8 --- /dev/null +++ b/bundles/org.openhab.binding.homekit/src/test/java/org/openhab/binding/homekit/internal/TestPairVerify.java @@ -0,0 +1,164 @@ +/* + * 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 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; +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.hapservices.PairVerifyClient; +import org.openhab.binding.homekit.internal.transport.IpTransport; +import org.openhab.core.util.HexUtils; + +/** + * Test cases for the {@link PairVerifyClient} class. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +class TestPairVerify { + + 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 + """; + + 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( + HexUtils.hexBlockToBytes(CLIENT_PRIVATE_HEX)); + + private final Ed25519PrivateKeyParameters accessoryLongTermPrivateKey = new Ed25519PrivateKeyParameters( + HexUtils.hexBlockToBytes(SERVER_PRIVATE_HEX)); + + private @NonNullByDefault({}) X25519PrivateKeyParameters accessoryEphemeralSecretKey; + private @NonNullByDefault({}) X25519PublicKeyParameters controllerEphemeralPublicKey; + private @NonNullByDefault({}) byte[] cryptoKey; + + @Test + void testPairVerify() throws InvalidCipherTextException, IOException, InterruptedException, TimeoutException, + ExecutionException, NoSuchAlgorithmException, NoSuchProviderException, IllegalArgumentException { + accessoryEphemeralSecretKey = generateX25519KeyPair(); + + // create mock + IpTransport mockTransport = mock(IpTransport.class); + + // create SRP client and server + PairVerifyClient client = new PairVerifyClient(mockTransport, controllerId, controllerLongTermPrivateKey, + accessoryLongTermPrivateKey.generatePublicKey()); + + // mock the HTTP transport to simulate the SRP exchange + doAnswer(invocation -> { + byte[] arg = invocation.getArgument(2); + + // decode and validate the incoming TLV + Map tlv = Tlv8Codec.decode(arg); + PairVerifyClient.Validator.validate(PairingMethod.VERIFY, tlv); + byte[] state = tlv.get(TlvType.STATE.value); + 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 -> m1GetAccessoryResponse(tlv); + case 3 -> m3GetAccessoryResponse(tlv); + default -> throw new IllegalArgumentException("Unexpected state"); + }; + + }).when(mockTransport).post(anyString(), anyString(), any(byte[].class)); + + // execute the pairing verification process + client.verify(); + } + + private byte[] m1GetAccessoryResponse(Map tlv) throws InvalidCipherTextException { + byte[] controllerEphemeralPublicKey = tlv.get(TlvType.PUBLIC_KEY.value); + byte[] accessoryEphemeralPublicKey = accessoryEphemeralSecretKey.generatePublicKey().getEncoded(); + if (controllerEphemeralPublicKey == null) { + throw new SecurityException("Client public key missing"); + } + byte[] accessorySignature = signMessage(accessoryLongTermPrivateKey, + concat(accessoryEphemeralPublicKey, accessoryId, controllerEphemeralPublicKey)); + + Map tlvInner = Map.of( // + TlvType.IDENTIFIER.value, accessoryId, // + TlvType.SIGNATURE.value, accessorySignature); + + this.controllerEphemeralPublicKey = new X25519PublicKeyParameters(controllerEphemeralPublicKey); + + 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(cryptoKey, PV_M2_NONCE, plainText, new byte[0]); + + Map tlvOut = Map.of( // + TlvType.STATE.value, new byte[] { PairingState.M2.value }, // + TlvType.PUBLIC_KEY.value, accessoryEphemeralPublicKey, // + TlvType.ENCRYPTED_DATA.value, cipherText); + + return Tlv8Codec.encode(tlvOut); + } + + private byte[] m3GetAccessoryResponse(Map tlv) throws InvalidCipherTextException { + 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(cryptoKey, PV_M3_NONCE, Objects.requireNonNull(cipherText), new byte[0]); + + Map subTlv = Tlv8Codec.decode(plainText); + 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"); + } + + 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); + + // no further messages from server + return Tlv8Codec.encode(tlvOut); + } +} diff --git a/bundles/pom.xml b/bundles/pom.xml index 11aa91e1c5131..4445a8b4431d1 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -198,6 +198,7 @@ org.openhab.binding.herzborg org.openhab.binding.homeassistant org.openhab.binding.homeconnect + org.openhab.binding.homekit org.openhab.binding.homie org.openhab.binding.homematic org.openhab.binding.homewizard