2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.io.homekit.internal.accessories;
15 import java.lang.reflect.InvocationTargetException;
16 import java.math.BigDecimal;
17 import java.util.ArrayList;
18 import java.util.Collection;
19 import java.util.HashMap;
20 import java.util.List;
22 import java.util.Optional;
23 import java.util.concurrent.CompletableFuture;
24 import java.util.concurrent.ExecutionException;
26 import javax.json.Json;
27 import javax.json.JsonObjectBuilder;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.core.items.GenericItem;
32 import org.openhab.core.items.Item;
33 import org.openhab.core.library.types.OnOffType;
34 import org.openhab.core.library.types.OpenClosedType;
35 import org.openhab.core.types.State;
36 import org.openhab.io.homekit.internal.HomekitAccessoryUpdater;
37 import org.openhab.io.homekit.internal.HomekitCharacteristicType;
38 import org.openhab.io.homekit.internal.HomekitException;
39 import org.openhab.io.homekit.internal.HomekitSettings;
40 import org.openhab.io.homekit.internal.HomekitTaggedItem;
41 import org.slf4j.Logger;
42 import org.slf4j.LoggerFactory;
44 import io.github.hapjava.accessories.HomekitAccessory;
45 import io.github.hapjava.characteristics.Characteristic;
46 import io.github.hapjava.characteristics.CharacteristicEnum;
47 import io.github.hapjava.characteristics.HomekitCharacteristicChangeCallback;
48 import io.github.hapjava.characteristics.impl.accessoryinformation.FirmwareRevisionCharacteristic;
49 import io.github.hapjava.characteristics.impl.accessoryinformation.HardwareRevisionCharacteristic;
50 import io.github.hapjava.characteristics.impl.accessoryinformation.IdentifyCharacteristic;
51 import io.github.hapjava.characteristics.impl.accessoryinformation.ManufacturerCharacteristic;
52 import io.github.hapjava.characteristics.impl.accessoryinformation.ModelCharacteristic;
53 import io.github.hapjava.characteristics.impl.accessoryinformation.SerialNumberCharacteristic;
54 import io.github.hapjava.characteristics.impl.base.BaseCharacteristic;
55 import io.github.hapjava.characteristics.impl.common.NameCharacteristic;
56 import io.github.hapjava.services.Service;
57 import io.github.hapjava.services.impl.AccessoryInformationService;
60 * Abstract class for Homekit Accessory implementations, this provides the
61 * accessory metadata using information from the underlying Item.
63 * @author Andy Lintner - Initial contribution
65 public abstract class AbstractHomekitAccessoryImpl implements HomekitAccessory {
66 private final Logger logger = LoggerFactory.getLogger(AbstractHomekitAccessoryImpl.class);
67 private final List<HomekitTaggedItem> characteristics;
68 private final HomekitTaggedItem accessory;
69 private final HomekitAccessoryUpdater updater;
70 private final HomekitSettings settings;
71 private final List<Service> services;
72 private final Map<Class<? extends Characteristic>, Characteristic> rawCharacteristics;
73 private boolean isLinkedService = false;
75 public AbstractHomekitAccessoryImpl(HomekitTaggedItem accessory, List<HomekitTaggedItem> mandatoryCharacteristics,
76 List<Characteristic> mandatoryRawCharacteristics, HomekitAccessoryUpdater updater,
77 HomekitSettings settings) {
78 this.characteristics = mandatoryCharacteristics;
79 this.accessory = accessory;
80 this.updater = updater;
81 this.services = new ArrayList<>();
82 this.settings = settings;
83 this.rawCharacteristics = new HashMap<>();
84 // create raw characteristics for mandatory characteristics
85 characteristics.forEach(c -> {
86 var rawCharacteristic = HomekitCharacteristicFactory.createNullableCharacteristic(c, updater);
87 // not all mandatory characteristics are creatable via HomekitCharacteristicFactory (yet)
88 if (rawCharacteristic != null) {
89 rawCharacteristics.put(rawCharacteristic.getClass(), rawCharacteristic);
92 mandatoryRawCharacteristics.forEach(c -> {
93 if (rawCharacteristics.get(c.getClass()) != null) {
95 "Accessory {} already has a characteristic of type {}; ignoring additional definition from metadata.",
96 accessory.getName(), c.getClass().getSimpleName());
98 rawCharacteristics.put(c.getClass(), c);
104 * Gives an accessory an opportunity to populate additional characteristics after all optional
105 * charactericteristics have been added.
107 * @throws HomekitException
109 public void init() throws HomekitException {
110 // initialize the AccessoryInformation Service with defaults if not specified
111 if (!rawCharacteristics.containsKey(NameCharacteristic.class)) {
112 rawCharacteristics.put(NameCharacteristic.class, new NameCharacteristic(() -> {
113 return CompletableFuture.completedFuture(accessory.getItem().getLabel());
117 if (!isLinkedService()) {
118 if (!rawCharacteristics.containsKey(IdentifyCharacteristic.class)) {
119 rawCharacteristics.put(IdentifyCharacteristic.class, new IdentifyCharacteristic(v -> {
122 if (!rawCharacteristics.containsKey(ManufacturerCharacteristic.class)) {
123 rawCharacteristics.put(ManufacturerCharacteristic.class, new ManufacturerCharacteristic(() -> {
124 return CompletableFuture.completedFuture("none");
127 if (!rawCharacteristics.containsKey(ModelCharacteristic.class)) {
128 rawCharacteristics.put(ModelCharacteristic.class, new ModelCharacteristic(() -> {
129 return CompletableFuture.completedFuture("none");
132 if (!rawCharacteristics.containsKey(SerialNumberCharacteristic.class)) {
133 rawCharacteristics.put(SerialNumberCharacteristic.class, new SerialNumberCharacteristic(() -> {
134 return CompletableFuture.completedFuture(accessory.getItem().getName());
137 if (!rawCharacteristics.containsKey(FirmwareRevisionCharacteristic.class)) {
138 rawCharacteristics.put(FirmwareRevisionCharacteristic.class, new FirmwareRevisionCharacteristic(() -> {
139 return CompletableFuture.completedFuture("none");
143 var service = new AccessoryInformationService(getCharacteristic(IdentifyCharacteristic.class).get(),
144 getCharacteristic(ManufacturerCharacteristic.class).get(),
145 getCharacteristic(ModelCharacteristic.class).get(),
146 getCharacteristic(NameCharacteristic.class).get(),
147 getCharacteristic(SerialNumberCharacteristic.class).get(),
148 getCharacteristic(FirmwareRevisionCharacteristic.class).get());
150 getCharacteristic(HardwareRevisionCharacteristic.class)
151 .ifPresent(c -> service.addOptionalCharacteristic(c));
153 // make sure this is the first service
154 services.add(0, service);
159 * @param parentAccessory The primary service to link to.
160 * @return If this accessory should be nested as a linked service below a primary service,
161 * rather than as a sibling.
163 public boolean isLinkable(HomekitAccessory parentAccessory) {
168 * Sets if this accessory is being used as a linked service.
170 public void setIsLinkedService(boolean value) {
171 isLinkedService = value;
175 * @return If this accessory is being used as a linked service.
177 public boolean isLinkedService() {
178 return isLinkedService;
182 * @return If this accessory is only valid as a linked service, not as a standalone accessory.
184 public boolean isLinkedServiceOnly() {
189 public Optional<HomekitTaggedItem> getCharacteristic(HomekitCharacteristicType type) {
190 return characteristics.stream().filter(c -> c.getCharacteristicType() == type).findAny();
195 return accessory.getId();
199 public CompletableFuture<String> getName() {
200 return getCharacteristic(NameCharacteristic.class).get().getValue();
204 public CompletableFuture<String> getManufacturer() {
205 return getCharacteristic(ManufacturerCharacteristic.class).get().getValue();
209 public CompletableFuture<String> getModel() {
210 return getCharacteristic(ModelCharacteristic.class).get().getValue();
214 public CompletableFuture<String> getSerialNumber() {
215 return getCharacteristic(SerialNumberCharacteristic.class).get().getValue();
219 public CompletableFuture<String> getFirmwareRevision() {
220 return getCharacteristic(FirmwareRevisionCharacteristic.class).get().getValue();
224 public void identify() {
226 getCharacteristic(IdentifyCharacteristic.class).get().setValue(true);
227 } catch (Exception e) {
232 public HomekitTaggedItem getRootAccessory() {
237 public Collection<Service> getServices() {
238 return this.services;
241 public void addService(Service service) {
242 services.add(service);
244 var serviceClass = service.getClass();
245 rawCharacteristics.values().forEach(characteristic -> {
246 // belongs on the accessory information service
247 if (characteristic.getClass() == NameCharacteristic.class) {
251 // if the service supports adding this characteristic as optional, add it!
252 serviceClass.getMethod("addOptionalCharacteristic", characteristic.getClass()).invoke(service,
254 } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
255 // the service doesn't support this optional characteristic; ignore it
260 protected HomekitAccessoryUpdater getUpdater() {
264 protected HomekitSettings getSettings() {
269 protected void subscribe(HomekitCharacteristicType characteristicType,
270 HomekitCharacteristicChangeCallback callback) {
271 final Optional<HomekitTaggedItem> characteristic = getCharacteristic(characteristicType);
272 if (characteristic.isPresent()) {
273 getUpdater().subscribe((GenericItem) characteristic.get().getItem(), characteristicType.getTag(), callback);
275 logger.warn("Missing mandatory characteristic {}", characteristicType);
280 protected void unsubscribe(HomekitCharacteristicType characteristicType) {
281 final Optional<HomekitTaggedItem> characteristic = getCharacteristic(characteristicType);
282 if (characteristic.isPresent()) {
283 getUpdater().unsubscribe((GenericItem) characteristic.get().getItem(), characteristicType.getTag());
285 logger.warn("Missing mandatory characteristic {}", characteristicType);
289 protected @Nullable State getState(HomekitCharacteristicType characteristic) {
290 final Optional<HomekitTaggedItem> taggedItem = getCharacteristic(characteristic);
291 if (taggedItem.isPresent()) {
292 return taggedItem.get().getItem().getState();
294 logger.debug("State for characteristic {} at accessory {} cannot be retrieved.", characteristic,
295 accessory.getName());
299 protected @Nullable <T extends State> T getStateAs(HomekitCharacteristicType characteristic, Class<T> type) {
300 final State state = getState(characteristic);
302 return state.as(type);
307 protected @Nullable Double getStateAsTemperature(HomekitCharacteristicType characteristic) {
308 return HomekitCharacteristicFactory.stateAsTemperature(getState(characteristic));
312 protected <T extends Item> Optional<T> getItem(HomekitCharacteristicType characteristic, Class<T> type) {
313 final Optional<HomekitTaggedItem> taggedItem = getCharacteristic(characteristic);
314 if (taggedItem.isPresent()) {
315 final Item item = taggedItem.get().getItem();
316 if (type.isInstance(item)) {
317 return Optional.of((T) item);
319 logger.warn("Unsupported item type for characteristic {} at accessory {}. Expected {}, got {}",
320 characteristic, accessory.getItem().getName(), type, taggedItem.get().getItem().getClass());
323 logger.warn("Mandatory characteristic {} not found at accessory {}. ", characteristic,
324 accessory.getItem().getName());
326 return Optional.empty();
330 * return configuration attached to the root accessory, e.g. groupItem.
331 * Note: result will be casted to the type of the default value.
332 * The type for number is BigDecimal.
334 * @param key configuration key
335 * @param defaultValue default value
336 * @param <T> expected type
337 * @return configuration value
340 protected <T> T getAccessoryConfiguration(String key, T defaultValue) {
341 return accessory.getConfiguration(key, defaultValue);
345 * return configuration attached to the root accessory, e.g. groupItem.
347 * @param key configuration key
348 * @param defaultValue default value
349 * @return configuration value
352 protected boolean getAccessoryConfigurationAsBoolean(String key, boolean defaultValue) {
353 return accessory.getConfigurationAsBoolean(key, defaultValue);
357 * return configuration of the characteristic item, e.g. currentTemperature.
358 * Note: result will be casted to the type of the default value.
359 * The type for number is BigDecimal.
361 * @param characteristicType characteristic type
362 * @param key configuration key
363 * @param defaultValue default value
364 * @param <T> expected type
365 * @return configuration value
368 protected <T> T getAccessoryConfiguration(HomekitCharacteristicType characteristicType, String key,
370 return getCharacteristic(characteristicType)
371 .map(homekitTaggedItem -> homekitTaggedItem.getConfiguration(key, defaultValue)).orElse(defaultValue);
375 protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
376 HomekitCharacteristicType characteristicType, Class<T> klazz) {
377 return createMapping(characteristicType, klazz, null, false);
381 protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
382 HomekitCharacteristicType characteristicType, Class<T> klazz, boolean inverted) {
383 return createMapping(characteristicType, klazz, null, inverted);
387 protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
388 HomekitCharacteristicType characteristicType, Class<T> klazz, @Nullable List<T> customEnumList) {
389 return createMapping(characteristicType, klazz, customEnumList, false);
393 * create mapping with values from item configuration
395 * @param characteristicType to identify item; must be present
396 * @param customEnumList list to store custom state enumeration
397 * @param inverted if ON/OFF and OPEN/CLOSED should be inverted by default (inverted on the item will double-invert)
398 * @return mapping of enum values to custom string values
401 protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
402 HomekitCharacteristicType characteristicType, Class<T> klazz, @Nullable List<T> customEnumList,
404 HomekitTaggedItem item = getCharacteristic(characteristicType).get();
405 return HomekitCharacteristicFactory.createMapping(item, klazz, customEnumList, inverted);
409 * takes item state as value and retrieves the key for that value from mapping.
410 * e.g. used to map StringItem value to HomeKit Enum
412 * @param characteristicType characteristicType to identify item
413 * @param mapping mapping
414 * @param defaultValue default value if nothing found in mapping
415 * @param <T> type of the result derived from
416 * @return key for the value
419 public <T> T getKeyFromMapping(HomekitCharacteristicType characteristicType, Map<T, String> mapping,
421 final Optional<HomekitTaggedItem> c = getCharacteristic(characteristicType);
423 return HomekitCharacteristicFactory.getKeyFromMapping(c.get(), c.get().getItem().getState(), mapping,
430 protected void addCharacteristic(HomekitTaggedItem item, Characteristic characteristic)
431 throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
432 characteristics.add(item);
433 addCharacteristic(characteristic);
437 * If the primary service does not yet exist, it won't be added to it. It's the resposibility
438 * of the caller to add characteristics when the primary service is created.
440 * @param characteristic
441 * @throws NoSuchMethodException
442 * @throws IllegalAccessException
443 * @throws InvocationTargetException
446 public void addCharacteristic(Characteristic characteristic)
447 throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
448 if (rawCharacteristics.containsKey(characteristic.getClass())) {
449 logger.warn("Accessory {} already has a characteristic of type {}; ignoring additional definition.",
450 accessory.getName(), characteristic.getClass().getSimpleName());
453 rawCharacteristics.put(characteristic.getClass(), characteristic);
454 var service = getPrimaryService();
455 if (service != null) {
456 // find the corresponding add method at service and call it.
457 service.getClass().getMethod("addOptionalCharacteristic", characteristic.getClass()).invoke(service,
463 * Takes the NameCharacteristic that normally exists on the AccessoryInformationService,
464 * and puts it on the primary service.
466 public void promoteNameCharacteristic() {
467 var characteristic = getCharacteristic(NameCharacteristic.class);
468 if (!characteristic.isPresent()) {
472 var service = getPrimaryService();
473 if (service != null) {
475 // find the corresponding add method at service and call it.
476 service.getClass().getMethod("addOptionalCharacteristic", NameCharacteristic.class).invoke(service,
477 characteristic.get());
478 } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
479 // This should never happen; all services should support NameCharacteristic as an optional
481 // If HAP-Java defined a service that doesn't support addOptionalCharacteristic(NameCharacteristic),
482 // Then it's a bug there, and we're just going to ignore the exception here.
488 public <T> Optional<T> getCharacteristic(Class<? extends T> klazz) {
489 return Optional.ofNullable((T) rawCharacteristics.get(klazz));
493 * create boolean reader with ON state mapped to trueOnOffValue or trueOpenClosedValue depending of item type
495 * @param characteristicType characteristic id
496 * @param trueOnOffValue ON value for switch
497 * @param trueOpenClosedValue ON value for contact
498 * @return boolean read
499 * @throws IncompleteAccessoryException
502 protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType,
503 OnOffType trueOnOffValue, OpenClosedType trueOpenClosedValue) throws IncompleteAccessoryException {
504 return new BooleanItemReader(
505 getItem(characteristicType, GenericItem.class)
506 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType)),
507 trueOnOffValue, trueOpenClosedValue);
511 * create boolean reader for a number item with ON state mapped to the value of the
512 * item being above a given threshold
514 * @param characteristicType characteristic id
515 * @param trueThreshold threshold for true of number item
516 * @param invertThreshold result is true if item is less than threshold, instead of more
517 * @return boolean read
518 * @throws IncompleteAccessoryException
521 protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType,
522 BigDecimal trueThreshold, boolean invertThreshold) throws IncompleteAccessoryException {
523 final HomekitTaggedItem taggedItem = getCharacteristic(characteristicType)
524 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType));
525 return new BooleanItemReader(taggedItem.getItem(), OnOffType.from(!taggedItem.isInverted()),
526 taggedItem.isInverted() ? OpenClosedType.CLOSED : OpenClosedType.OPEN, trueThreshold, invertThreshold);
530 * create boolean reader with default ON/OFF mapping considering inverted flag
532 * @param characteristicType characteristic id
533 * @return boolean reader
534 * @throws IncompleteAccessoryException
537 protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType)
538 throws IncompleteAccessoryException {
539 final HomekitTaggedItem taggedItem = getCharacteristic(characteristicType)
540 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType));
541 return new BooleanItemReader(taggedItem.getItem(), OnOffType.from(!taggedItem.isInverted()),
542 taggedItem.isInverted() ? OpenClosedType.CLOSED : OpenClosedType.OPEN);
546 * Calculates a string as json of the configuration for this accessory, suitable for seeing
547 * if the structure has changed, and building a dummy accessory for it. It is _not_ suitable
548 * for actual publishing to by HAP-Java to iOS devices, since all the IIDs will be set to 0.
549 * The IIDs will get replaced by actual values by HAP-Java inside of DummyHomekitCharacteristic.
551 public String toJson() {
552 var builder = Json.createArrayBuilder();
553 getServices().forEach(s -> {
554 builder.add(serviceToJson(s));
556 return builder.build().toString();
559 private JsonObjectBuilder serviceToJson(Service service) {
560 var serviceBuilder = Json.createObjectBuilder();
561 serviceBuilder.add("type", service.getType());
562 var characteristics = Json.createArrayBuilder();
564 service.getCharacteristics().stream().sorted((l, r) -> l.getClass().getName().compareTo(r.getClass().getName()))
567 var cJson = c.toJson(0).get();
568 var cBuilder = Json.createObjectBuilder();
569 // Need to copy over everything except the current value, which we instead
570 // reach in and get the default value
571 cJson.forEach((k, v) -> {
572 if ("value".equals(k)) {
573 Object defaultValue = ((BaseCharacteristic) c).getDefault();
574 if (defaultValue instanceof Boolean) {
575 cBuilder.add("value", (boolean) defaultValue);
576 } else if (defaultValue instanceof Integer) {
577 cBuilder.add("value", (int) defaultValue);
578 } else if (defaultValue instanceof Double) {
579 cBuilder.add("value", (double) defaultValue);
581 cBuilder.add("value", defaultValue.toString());
587 characteristics.add(cBuilder.build());
588 } catch (InterruptedException | ExecutionException e) {
591 serviceBuilder.add("c", characteristics);
593 if (!service.getLinkedServices().isEmpty()) {
594 var linkedServices = Json.createArrayBuilder();
595 service.getLinkedServices().forEach(s -> linkedServices.add(serviceToJson(s)));
596 serviceBuilder.add("ls", linkedServices);
598 return serviceBuilder;