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.Map.Entry;
23 import java.util.Optional;
24 import java.util.concurrent.CompletableFuture;
25 import java.util.concurrent.ExecutionException;
27 import javax.json.Json;
28 import javax.json.JsonObjectBuilder;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.core.items.GenericItem;
33 import org.openhab.core.items.Item;
34 import org.openhab.core.library.types.OnOffType;
35 import org.openhab.core.library.types.OpenClosedType;
36 import org.openhab.core.library.types.StringType;
37 import org.openhab.core.types.State;
38 import org.openhab.io.homekit.internal.HomekitAccessoryUpdater;
39 import org.openhab.io.homekit.internal.HomekitCharacteristicType;
40 import org.openhab.io.homekit.internal.HomekitSettings;
41 import org.openhab.io.homekit.internal.HomekitTaggedItem;
42 import org.slf4j.Logger;
43 import org.slf4j.LoggerFactory;
45 import io.github.hapjava.accessories.HomekitAccessory;
46 import io.github.hapjava.characteristics.Characteristic;
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<>();
77 * @param parentAccessory The primary service to link to.
78 * @return If this accessory should be nested as a linked service below a primary service,
79 * rather than as a sibling.
81 public boolean isLinkable(HomekitAccessory parentAccessory) {
86 * @return If this accessory is only valid as a linked service, not as a standalone accessory.
88 public boolean isLinkedServiceOnly() {
93 public Optional<HomekitTaggedItem> getCharacteristic(HomekitCharacteristicType type) {
94 return characteristics.stream().filter(c -> c.getCharacteristicType() == type).findAny();
99 return accessory.getId();
103 public CompletableFuture<String> getName() {
104 return CompletableFuture.completedFuture(accessory.getItem().getLabel());
108 public CompletableFuture<String> getManufacturer() {
109 return CompletableFuture.completedFuture("none");
113 public CompletableFuture<String> getModel() {
114 return CompletableFuture.completedFuture("none");
118 public CompletableFuture<String> getSerialNumber() {
119 return CompletableFuture.completedFuture(accessory.getItem().getName());
123 public CompletableFuture<String> getFirmwareRevision() {
124 return CompletableFuture.completedFuture("none");
128 public void identify() {
129 // We're not going to support this for now
132 public HomekitTaggedItem getRootAccessory() {
137 public Collection<Service> getServices() {
138 return this.services;
141 protected HomekitAccessoryUpdater getUpdater() {
145 protected HomekitSettings getSettings() {
150 protected void subscribe(HomekitCharacteristicType characteristicType,
151 HomekitCharacteristicChangeCallback callback) {
152 final Optional<HomekitTaggedItem> characteristic = getCharacteristic(characteristicType);
153 if (characteristic.isPresent()) {
154 getUpdater().subscribe((GenericItem) characteristic.get().getItem(), characteristicType.getTag(), callback);
156 logger.warn("Missing mandatory characteristic {}", characteristicType);
161 protected void unsubscribe(HomekitCharacteristicType characteristicType) {
162 final Optional<HomekitTaggedItem> characteristic = getCharacteristic(characteristicType);
163 if (characteristic.isPresent()) {
164 getUpdater().unsubscribe((GenericItem) characteristic.get().getItem(), characteristicType.getTag());
166 logger.warn("Missing mandatory characteristic {}", characteristicType);
170 protected @Nullable State getState(HomekitCharacteristicType characteristic) {
171 final Optional<HomekitTaggedItem> taggedItem = getCharacteristic(characteristic);
172 if (taggedItem.isPresent()) {
173 return taggedItem.get().getItem().getState();
175 logger.debug("State for characteristic {} at accessory {} cannot be retrieved.", characteristic,
176 accessory.getName());
180 protected @Nullable <T extends State> T getStateAs(HomekitCharacteristicType characteristic, Class<T> type) {
181 final State state = getState(characteristic);
183 return state.as(type);
188 protected @Nullable Double getStateAsTemperature(HomekitCharacteristicType characteristic) {
189 return HomekitCharacteristicFactory.stateAsTemperature(getState(characteristic));
193 protected <T extends Item> Optional<T> getItem(HomekitCharacteristicType characteristic, Class<T> type) {
194 final Optional<HomekitTaggedItem> taggedItem = getCharacteristic(characteristic);
195 if (taggedItem.isPresent()) {
196 final Item item = taggedItem.get().getItem();
197 if (type.isInstance(item)) {
198 return Optional.of((T) item);
200 logger.warn("Unsupported item type for characteristic {} at accessory {}. Expected {}, got {}",
201 characteristic, accessory.getItem().getName(), type, taggedItem.get().getItem().getClass());
204 logger.warn("Mandatory characteristic {} not found at accessory {}. ", characteristic,
205 accessory.getItem().getName());
208 return Optional.empty();
212 * return configuration attached to the root accessory, e.g. groupItem.
213 * Note: result will be casted to the type of the default value.
214 * The type for number is BigDecimal.
216 * @param key configuration key
217 * @param defaultValue default value
218 * @param <T> expected type
219 * @return configuration value
222 protected <T> T getAccessoryConfiguration(String key, T defaultValue) {
223 return accessory.getConfiguration(key, defaultValue);
227 * return configuration attached to the root accessory, e.g. groupItem.
229 * @param key configuration key
230 * @param defaultValue default value
231 * @return configuration value
234 protected boolean getAccessoryConfigurationAsBoolean(String key, boolean defaultValue) {
235 return accessory.getConfigurationAsBoolean(key, defaultValue);
239 * return configuration of the characteristic item, e.g. currentTemperature.
240 * Note: result will be casted to the type of the default value.
241 * The type for number is BigDecimal.
243 * @param characteristicType characteristic type
244 * @param key configuration key
245 * @param defaultValue default value
246 * @param <T> expected type
247 * @return configuration value
250 protected <T> T getAccessoryConfiguration(HomekitCharacteristicType characteristicType, String key,
252 return getCharacteristic(characteristicType)
253 .map(homekitTaggedItem -> homekitTaggedItem.getConfiguration(key, defaultValue)).orElse(defaultValue);
257 * update mapping with values from item configuration.
258 * it checks for all keys from the mapping whether there is configuration at item with the same key and if yes,
261 * @param characteristicType characteristicType to identify item
262 * @param map mapping to update
263 * @param customEnumList list to store custom state enumeration
266 protected <T> void updateMapping(HomekitCharacteristicType characteristicType, Map<T, String> map,
267 @Nullable List<T> customEnumList) {
268 getCharacteristic(characteristicType).ifPresent(c -> {
269 final Map<String, Object> configuration = c.getConfiguration();
270 if (configuration != null) {
271 map.forEach((k, current_value) -> {
272 final Object new_value = configuration.get(k.toString());
273 if (new_value instanceof String) {
274 map.put(k, (String) new_value);
275 if (customEnumList != null) {
276 customEnumList.add(k);
285 protected <T> void updateMapping(HomekitCharacteristicType characteristicType, Map<T, String> map) {
286 updateMapping(characteristicType, map, null);
290 * takes item state as value and retrieves the key for that value from mapping.
291 * e.g. used to map StringItem value to HomeKit Enum
293 * @param characteristicType characteristicType to identify item
294 * @param mapping mapping
295 * @param defaultValue default value if nothing found in mapping
296 * @param <T> type of the result derived from
297 * @return key for the value
300 protected <T> T getKeyFromMapping(HomekitCharacteristicType characteristicType, Map<T, String> mapping,
302 final Optional<HomekitTaggedItem> c = getCharacteristic(characteristicType);
304 final State state = c.get().getItem().getState();
305 logger.trace("getKeyFromMapping: characteristic {}, state {}, mapping {}", characteristicType.getTag(),
307 if (state instanceof StringType) {
308 return mapping.entrySet().stream().filter(entry -> state.toString().equalsIgnoreCase(entry.getValue()))
309 .findAny().map(Entry::getKey).orElseGet(() -> {
311 "Wrong value {} for {} characteristic of the item {}. Expected one of following {}. Returning {}.",
312 state.toString(), characteristicType.getTag(), c.get().getName(), mapping.values(),
322 protected void addCharacteristic(HomekitTaggedItem item, Characteristic characteristic)
323 throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
324 characteristics.add(item);
325 addCharacteristic(characteristic);
330 * @param characteristic
333 public void addCharacteristic(Characteristic characteristic)
334 throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
335 if (rawCharacteristics.containsKey(characteristic.getClass())) {
336 logger.warn("Accessory {} already has a characteristic of type {}; ignoring additional definition.",
337 accessory.getName(), characteristic.getClass().getSimpleName());
340 rawCharacteristics.put(characteristic.getClass(), characteristic);
341 var service = getPrimaryService();
342 // find the corresponding add method at service and call it.
343 service.getClass().getMethod("addOptionalCharacteristic", characteristic.getClass()).invoke(service,
348 public <T> Optional<T> getCharacteristic(Class<? extends T> klazz) {
349 return Optional.ofNullable((T) rawCharacteristics.get(klazz));
353 * create boolean reader with ON state mapped to trueOnOffValue or trueOpenClosedValue depending of item type
355 * @param characteristicType characteristic id
356 * @param trueOnOffValue ON value for switch
357 * @param trueOpenClosedValue ON value for contact
358 * @return boolean read
359 * @throws IncompleteAccessoryException
362 protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType,
363 OnOffType trueOnOffValue, OpenClosedType trueOpenClosedValue) throws IncompleteAccessoryException {
364 return new BooleanItemReader(
365 getItem(characteristicType, GenericItem.class)
366 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType)),
367 trueOnOffValue, trueOpenClosedValue);
371 * create boolean reader for a number item with ON state mapped to the value of the
372 * item being above a given threshold
374 * @param characteristicType characteristic id
375 * @param trueThreshold threshold for true of number item
376 * @param invertThreshold result is true if item is less than threshold, instead of more
377 * @return boolean read
378 * @throws IncompleteAccessoryException
381 protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType,
382 BigDecimal trueThreshold, boolean invertThreshold) throws IncompleteAccessoryException {
383 final HomekitTaggedItem taggedItem = getCharacteristic(characteristicType)
384 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType));
385 return new BooleanItemReader(taggedItem.getItem(), taggedItem.isInverted() ? OnOffType.OFF : OnOffType.ON,
386 taggedItem.isInverted() ? OpenClosedType.CLOSED : OpenClosedType.OPEN, trueThreshold, invertThreshold);
390 * create boolean reader with default ON/OFF mapping considering inverted flag
392 * @param characteristicType characteristic id
393 * @return boolean reader
394 * @throws IncompleteAccessoryException
397 protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType)
398 throws IncompleteAccessoryException {
399 final HomekitTaggedItem taggedItem = getCharacteristic(characteristicType)
400 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType));
401 return new BooleanItemReader(taggedItem.getItem(), taggedItem.isInverted() ? OnOffType.OFF : OnOffType.ON,
402 taggedItem.isInverted() ? OpenClosedType.CLOSED : OpenClosedType.OPEN);
406 * Calculates a string as json of the configuration for this accessory, suitable for seeing
407 * if the structure has changed, and building a dummy accessory for it. It is _not_ suitable
408 * for actual publishing to by HAP-Java to iOS devices, since all the IIDs will be set to 0.
409 * The IIDs will get replaced by actual values by HAP-Java inside of DummyHomekitCharacteristic.
411 public String toJson() {
412 var builder = Json.createArrayBuilder();
413 getServices().forEach(s -> {
414 builder.add(serviceToJson(s));
416 return builder.build().toString();
419 private JsonObjectBuilder serviceToJson(Service service) {
420 var serviceBuilder = Json.createObjectBuilder();
421 serviceBuilder.add("type", service.getType());
422 var characteristics = Json.createArrayBuilder();
424 service.getCharacteristics().stream().sorted((l, r) -> l.getClass().getName().compareTo(r.getClass().getName()))
427 var cJson = c.toJson(0).get();
428 var cBuilder = Json.createObjectBuilder();
429 // Need to copy over everything except the current value, which we instead
430 // reach in and get the default value
431 cJson.forEach((k, v) -> {
432 if (k.equals("value")) {
433 Object defaultValue = ((BaseCharacteristic) c).getDefault();
434 if (defaultValue instanceof Boolean) {
435 cBuilder.add("value", (boolean) defaultValue);
436 } else if (defaultValue instanceof Integer) {
437 cBuilder.add("value", (int) defaultValue);
438 } else if (defaultValue instanceof Double) {
439 cBuilder.add("value", (double) defaultValue);
441 cBuilder.add("value", defaultValue.toString());
447 characteristics.add(cBuilder.build());
448 } catch (InterruptedException | ExecutionException e) {
451 serviceBuilder.add("c", characteristics);
453 if (!service.getLinkedServices().isEmpty()) {
454 var linkedServices = Json.createArrayBuilder();
455 service.getLinkedServices().forEach(s -> linkedServices.add(serviceToJson(s)));
456 serviceBuilder.add("ls", linkedServices);
458 return serviceBuilder;