2 * Copyright (c) 2010-2023 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.base.BaseCharacteristic;
49 import io.github.hapjava.services.Service;
52 * Abstract class for Homekit Accessory implementations, this provides the
53 * accessory metadata using information from the underlying Item.
55 * @author Andy Lintner - Initial contribution
57 public abstract class AbstractHomekitAccessoryImpl implements HomekitAccessory {
58 private final Logger logger = LoggerFactory.getLogger(AbstractHomekitAccessoryImpl.class);
59 private final List<HomekitTaggedItem> characteristics;
60 private final HomekitTaggedItem accessory;
61 private final HomekitAccessoryUpdater updater;
62 private final HomekitSettings settings;
63 private final List<Service> services;
64 private final Map<Class<? extends Characteristic>, Characteristic> rawCharacteristics;
66 public AbstractHomekitAccessoryImpl(HomekitTaggedItem accessory, List<HomekitTaggedItem> characteristics,
67 HomekitAccessoryUpdater updater, HomekitSettings settings) {
68 this.characteristics = characteristics;
69 this.accessory = accessory;
70 this.updater = updater;
71 this.services = new ArrayList<>();
72 this.settings = settings;
73 this.rawCharacteristics = new HashMap<>();
74 // create raw characteristics for mandatory characteristics
75 characteristics.forEach(c -> {
76 var rawCharacteristic = HomekitCharacteristicFactory.createNullableCharacteristic(c, updater);
77 // not all mandatory characteristics are creatable via HomekitCharacteristicFactory (yet)
78 if (rawCharacteristic != null) {
79 rawCharacteristics.put(rawCharacteristic.getClass(), rawCharacteristic);
85 * Gives an accessory an opportunity to populate additional characteristics after all optional
86 * charactericteristics have been added.
88 * @throws HomekitException
90 public void init() throws HomekitException {
94 * @param parentAccessory The primary service to link to.
95 * @return If this accessory should be nested as a linked service below a primary service,
96 * rather than as a sibling.
98 public boolean isLinkable(HomekitAccessory parentAccessory) {
103 * @return If this accessory is only valid as a linked service, not as a standalone accessory.
105 public boolean isLinkedServiceOnly() {
110 public Optional<HomekitTaggedItem> getCharacteristic(HomekitCharacteristicType type) {
111 return characteristics.stream().filter(c -> c.getCharacteristicType() == type).findAny();
116 return accessory.getId();
120 public CompletableFuture<String> getName() {
121 return CompletableFuture.completedFuture(accessory.getItem().getLabel());
125 public CompletableFuture<String> getManufacturer() {
126 return CompletableFuture.completedFuture("none");
130 public CompletableFuture<String> getModel() {
131 return CompletableFuture.completedFuture("none");
135 public CompletableFuture<String> getSerialNumber() {
136 return CompletableFuture.completedFuture(accessory.getItem().getName());
140 public CompletableFuture<String> getFirmwareRevision() {
141 return CompletableFuture.completedFuture("none");
145 public void identify() {
146 // We're not going to support this for now
149 public HomekitTaggedItem getRootAccessory() {
154 public Collection<Service> getServices() {
155 return this.services;
158 protected HomekitAccessoryUpdater getUpdater() {
162 protected HomekitSettings getSettings() {
167 protected void subscribe(HomekitCharacteristicType characteristicType,
168 HomekitCharacteristicChangeCallback callback) {
169 final Optional<HomekitTaggedItem> characteristic = getCharacteristic(characteristicType);
170 if (characteristic.isPresent()) {
171 getUpdater().subscribe((GenericItem) characteristic.get().getItem(), characteristicType.getTag(), callback);
173 logger.warn("Missing mandatory characteristic {}", characteristicType);
178 protected void unsubscribe(HomekitCharacteristicType characteristicType) {
179 final Optional<HomekitTaggedItem> characteristic = getCharacteristic(characteristicType);
180 if (characteristic.isPresent()) {
181 getUpdater().unsubscribe((GenericItem) characteristic.get().getItem(), characteristicType.getTag());
183 logger.warn("Missing mandatory characteristic {}", characteristicType);
187 protected @Nullable State getState(HomekitCharacteristicType characteristic) {
188 final Optional<HomekitTaggedItem> taggedItem = getCharacteristic(characteristic);
189 if (taggedItem.isPresent()) {
190 return taggedItem.get().getItem().getState();
192 logger.debug("State for characteristic {} at accessory {} cannot be retrieved.", characteristic,
193 accessory.getName());
197 protected @Nullable <T extends State> T getStateAs(HomekitCharacteristicType characteristic, Class<T> type) {
198 final State state = getState(characteristic);
200 return state.as(type);
205 protected @Nullable Double getStateAsTemperature(HomekitCharacteristicType characteristic) {
206 return HomekitCharacteristicFactory.stateAsTemperature(getState(characteristic));
210 protected <T extends Item> Optional<T> getItem(HomekitCharacteristicType characteristic, Class<T> type) {
211 final Optional<HomekitTaggedItem> taggedItem = getCharacteristic(characteristic);
212 if (taggedItem.isPresent()) {
213 final Item item = taggedItem.get().getItem();
214 if (type.isInstance(item)) {
215 return Optional.of((T) item);
217 logger.warn("Unsupported item type for characteristic {} at accessory {}. Expected {}, got {}",
218 characteristic, accessory.getItem().getName(), type, taggedItem.get().getItem().getClass());
221 logger.warn("Mandatory characteristic {} not found at accessory {}. ", characteristic,
222 accessory.getItem().getName());
224 return Optional.empty();
228 * return configuration attached to the root accessory, e.g. groupItem.
229 * Note: result will be casted to the type of the default value.
230 * The type for number is BigDecimal.
232 * @param key configuration key
233 * @param defaultValue default value
234 * @param <T> expected type
235 * @return configuration value
238 protected <T> T getAccessoryConfiguration(String key, T defaultValue) {
239 return accessory.getConfiguration(key, defaultValue);
243 * return configuration attached to the root accessory, e.g. groupItem.
245 * @param key configuration key
246 * @param defaultValue default value
247 * @return configuration value
250 protected boolean getAccessoryConfigurationAsBoolean(String key, boolean defaultValue) {
251 return accessory.getConfigurationAsBoolean(key, defaultValue);
255 * return configuration of the characteristic item, e.g. currentTemperature.
256 * Note: result will be casted to the type of the default value.
257 * The type for number is BigDecimal.
259 * @param characteristicType characteristic type
260 * @param key configuration key
261 * @param defaultValue default value
262 * @param <T> expected type
263 * @return configuration value
266 protected <T> T getAccessoryConfiguration(HomekitCharacteristicType characteristicType, String key,
268 return getCharacteristic(characteristicType)
269 .map(homekitTaggedItem -> homekitTaggedItem.getConfiguration(key, defaultValue)).orElse(defaultValue);
273 protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
274 HomekitCharacteristicType characteristicType, Class<T> klazz) {
275 return createMapping(characteristicType, klazz, null, false);
279 protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
280 HomekitCharacteristicType characteristicType, Class<T> klazz, boolean inverted) {
281 return createMapping(characteristicType, klazz, null, inverted);
285 protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
286 HomekitCharacteristicType characteristicType, Class<T> klazz, @Nullable List<T> customEnumList) {
287 return createMapping(characteristicType, klazz, customEnumList, false);
291 * create mapping with values from item configuration
293 * @param characteristicType to identify item; must be present
294 * @param customEnumList list to store custom state enumeration
295 * @param inverted if ON/OFF and OPEN/CLOSED should be inverted by default (inverted on the item will double-invert)
296 * @return mapping of enum values to custom string values
299 protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
300 HomekitCharacteristicType characteristicType, Class<T> klazz, @Nullable List<T> customEnumList,
302 HomekitTaggedItem item = getCharacteristic(characteristicType).get();
303 return HomekitCharacteristicFactory.createMapping(item, klazz, customEnumList, inverted);
307 * takes item state as value and retrieves the key for that value from mapping.
308 * e.g. used to map StringItem value to HomeKit Enum
310 * @param characteristicType characteristicType to identify item
311 * @param mapping mapping
312 * @param defaultValue default value if nothing found in mapping
313 * @param <T> type of the result derived from
314 * @return key for the value
317 public <T> T getKeyFromMapping(HomekitCharacteristicType characteristicType, Map<T, String> mapping,
319 final Optional<HomekitTaggedItem> c = getCharacteristic(characteristicType);
321 return HomekitCharacteristicFactory.getKeyFromMapping(c.get(), mapping, defaultValue);
327 protected void addCharacteristic(HomekitTaggedItem item, Characteristic characteristic)
328 throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
329 characteristics.add(item);
330 addCharacteristic(characteristic);
334 * If the primary service does not yet exist, it won't be added to it. It's the resposibility
335 * of the caller to add characteristics when the primary service is created.
337 * @param characteristic
338 * @throws NoSuchMethodException
339 * @throws IllegalAccessException
340 * @throws InvocationTargetException
343 public void addCharacteristic(Characteristic characteristic)
344 throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
345 if (rawCharacteristics.containsKey(characteristic.getClass())) {
346 logger.warn("Accessory {} already has a characteristic of type {}; ignoring additional definition.",
347 accessory.getName(), characteristic.getClass().getSimpleName());
350 rawCharacteristics.put(characteristic.getClass(), characteristic);
351 var service = getPrimaryService();
352 if (service != null) {
353 // find the corresponding add method at service and call it.
354 service.getClass().getMethod("addOptionalCharacteristic", characteristic.getClass()).invoke(service,
360 public <T> Optional<T> getCharacteristic(Class<? extends T> klazz) {
361 return Optional.ofNullable((T) rawCharacteristics.get(klazz));
365 * create boolean reader with ON state mapped to trueOnOffValue or trueOpenClosedValue depending of item type
367 * @param characteristicType characteristic id
368 * @param trueOnOffValue ON value for switch
369 * @param trueOpenClosedValue ON value for contact
370 * @return boolean read
371 * @throws IncompleteAccessoryException
374 protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType,
375 OnOffType trueOnOffValue, OpenClosedType trueOpenClosedValue) throws IncompleteAccessoryException {
376 return new BooleanItemReader(
377 getItem(characteristicType, GenericItem.class)
378 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType)),
379 trueOnOffValue, trueOpenClosedValue);
383 * create boolean reader for a number item with ON state mapped to the value of the
384 * item being above a given threshold
386 * @param characteristicType characteristic id
387 * @param trueThreshold threshold for true of number item
388 * @param invertThreshold result is true if item is less than threshold, instead of more
389 * @return boolean read
390 * @throws IncompleteAccessoryException
393 protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType,
394 BigDecimal trueThreshold, boolean invertThreshold) throws IncompleteAccessoryException {
395 final HomekitTaggedItem taggedItem = getCharacteristic(characteristicType)
396 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType));
397 return new BooleanItemReader(taggedItem.getItem(), taggedItem.isInverted() ? OnOffType.OFF : OnOffType.ON,
398 taggedItem.isInverted() ? OpenClosedType.CLOSED : OpenClosedType.OPEN, trueThreshold, invertThreshold);
402 * create boolean reader with default ON/OFF mapping considering inverted flag
404 * @param characteristicType characteristic id
405 * @return boolean reader
406 * @throws IncompleteAccessoryException
409 protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType)
410 throws IncompleteAccessoryException {
411 final HomekitTaggedItem taggedItem = getCharacteristic(characteristicType)
412 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType));
413 return new BooleanItemReader(taggedItem.getItem(), taggedItem.isInverted() ? OnOffType.OFF : OnOffType.ON,
414 taggedItem.isInverted() ? OpenClosedType.CLOSED : OpenClosedType.OPEN);
418 * Calculates a string as json of the configuration for this accessory, suitable for seeing
419 * if the structure has changed, and building a dummy accessory for it. It is _not_ suitable
420 * for actual publishing to by HAP-Java to iOS devices, since all the IIDs will be set to 0.
421 * The IIDs will get replaced by actual values by HAP-Java inside of DummyHomekitCharacteristic.
423 public String toJson() {
424 var builder = Json.createArrayBuilder();
425 getServices().forEach(s -> {
426 builder.add(serviceToJson(s));
428 return builder.build().toString();
431 private JsonObjectBuilder serviceToJson(Service service) {
432 var serviceBuilder = Json.createObjectBuilder();
433 serviceBuilder.add("type", service.getType());
434 var characteristics = Json.createArrayBuilder();
436 service.getCharacteristics().stream().sorted((l, r) -> l.getClass().getName().compareTo(r.getClass().getName()))
439 var cJson = c.toJson(0).get();
440 var cBuilder = Json.createObjectBuilder();
441 // Need to copy over everything except the current value, which we instead
442 // reach in and get the default value
443 cJson.forEach((k, v) -> {
444 if ("value".equals(k)) {
445 Object defaultValue = ((BaseCharacteristic) c).getDefault();
446 if (defaultValue instanceof Boolean) {
447 cBuilder.add("value", (boolean) defaultValue);
448 } else if (defaultValue instanceof Integer) {
449 cBuilder.add("value", (int) defaultValue);
450 } else if (defaultValue instanceof Double) {
451 cBuilder.add("value", (double) defaultValue);
453 cBuilder.add("value", defaultValue.toString());
459 characteristics.add(cBuilder.build());
460 } catch (InterruptedException | ExecutionException e) {
463 serviceBuilder.add("c", characteristics);
465 if (!service.getLinkedServices().isEmpty()) {
466 var linkedServices = Json.createArrayBuilder();
467 service.getLinkedServices().forEach(s -> linkedServices.add(serviceToJson(s)));
468 serviceBuilder.add("ls", linkedServices);
470 return serviceBuilder;