]> git.basschouten.com Git - openhab-addons.git/blob
36836a01b6c023d732fdab9a084ad08947d07d09
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.io.homekit.internal.accessories;
14
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;
21 import java.util.Map;
22 import java.util.Optional;
23 import java.util.concurrent.CompletableFuture;
24 import java.util.concurrent.ExecutionException;
25
26 import javax.json.Json;
27 import javax.json.JsonObjectBuilder;
28
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;
43
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;
58
59 /**
60  * Abstract class for Homekit Accessory implementations, this provides the
61  * accessory metadata using information from the underlying Item.
62  *
63  * @author Andy Lintner - Initial contribution
64  */
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;
74
75     public AbstractHomekitAccessoryImpl(HomekitTaggedItem accessory, List<HomekitTaggedItem> characteristics,
76             HomekitAccessoryUpdater updater, HomekitSettings settings) {
77         this.characteristics = characteristics;
78         this.accessory = accessory;
79         this.updater = updater;
80         this.services = new ArrayList<>();
81         this.settings = settings;
82         this.rawCharacteristics = new HashMap<>();
83         // create raw characteristics for mandatory characteristics
84         characteristics.forEach(c -> {
85             var rawCharacteristic = HomekitCharacteristicFactory.createNullableCharacteristic(c, updater);
86             // not all mandatory characteristics are creatable via HomekitCharacteristicFactory (yet)
87             if (rawCharacteristic != null) {
88                 rawCharacteristics.put(rawCharacteristic.getClass(), rawCharacteristic);
89             }
90         });
91     }
92
93     /**
94      * Gives an accessory an opportunity to populate additional characteristics after all optional
95      * charactericteristics have been added.
96      * 
97      * @throws HomekitException
98      */
99     public void init() throws HomekitException {
100         // initialize the AccessoryInformation Service with defaults if not specified
101         if (!rawCharacteristics.containsKey(NameCharacteristic.class)) {
102             rawCharacteristics.put(NameCharacteristic.class, new NameCharacteristic(() -> {
103                 return CompletableFuture.completedFuture(accessory.getItem().getLabel());
104             }));
105         }
106
107         if (!isLinkedService()) {
108             if (!rawCharacteristics.containsKey(IdentifyCharacteristic.class)) {
109                 rawCharacteristics.put(IdentifyCharacteristic.class, new IdentifyCharacteristic(v -> {
110                 }));
111             }
112             if (!rawCharacteristics.containsKey(ManufacturerCharacteristic.class)) {
113                 rawCharacteristics.put(ManufacturerCharacteristic.class, new ManufacturerCharacteristic(() -> {
114                     return CompletableFuture.completedFuture("none");
115                 }));
116             }
117             if (!rawCharacteristics.containsKey(ModelCharacteristic.class)) {
118                 rawCharacteristics.put(ModelCharacteristic.class, new ModelCharacteristic(() -> {
119                     return CompletableFuture.completedFuture("none");
120                 }));
121             }
122             if (!rawCharacteristics.containsKey(SerialNumberCharacteristic.class)) {
123                 rawCharacteristics.put(SerialNumberCharacteristic.class, new SerialNumberCharacteristic(() -> {
124                     return CompletableFuture.completedFuture(accessory.getItem().getName());
125                 }));
126             }
127             if (!rawCharacteristics.containsKey(FirmwareRevisionCharacteristic.class)) {
128                 rawCharacteristics.put(FirmwareRevisionCharacteristic.class, new FirmwareRevisionCharacteristic(() -> {
129                     return CompletableFuture.completedFuture("none");
130                 }));
131             }
132
133             var service = new AccessoryInformationService(getCharacteristic(IdentifyCharacteristic.class).get(),
134                     getCharacteristic(ManufacturerCharacteristic.class).get(),
135                     getCharacteristic(ModelCharacteristic.class).get(),
136                     getCharacteristic(NameCharacteristic.class).get(),
137                     getCharacteristic(SerialNumberCharacteristic.class).get(),
138                     getCharacteristic(FirmwareRevisionCharacteristic.class).get());
139
140             getCharacteristic(HardwareRevisionCharacteristic.class)
141                     .ifPresent(c -> service.addOptionalCharacteristic(c));
142
143             // make sure this is the first service
144             services.add(0, service);
145         }
146     }
147
148     /**
149      * @param parentAccessory The primary service to link to.
150      * @return If this accessory should be nested as a linked service below a primary service,
151      *         rather than as a sibling.
152      */
153     public boolean isLinkable(HomekitAccessory parentAccessory) {
154         return false;
155     }
156
157     /**
158      * Sets if this accessory is being used as a linked service.
159      */
160     public void setIsLinkedService(boolean value) {
161         isLinkedService = value;
162     }
163
164     /**
165      * @return If this accessory is being used as a linked service.
166      */
167     public boolean isLinkedService() {
168         return isLinkedService;
169     }
170
171     /**
172      * @return If this accessory is only valid as a linked service, not as a standalone accessory.
173      */
174     public boolean isLinkedServiceOnly() {
175         return false;
176     }
177
178     @NonNullByDefault
179     public Optional<HomekitTaggedItem> getCharacteristic(HomekitCharacteristicType type) {
180         return characteristics.stream().filter(c -> c.getCharacteristicType() == type).findAny();
181     }
182
183     @Override
184     public int getId() {
185         return accessory.getId();
186     }
187
188     @Override
189     public CompletableFuture<String> getName() {
190         return getCharacteristic(NameCharacteristic.class).get().getValue();
191     }
192
193     @Override
194     public CompletableFuture<String> getManufacturer() {
195         return getCharacteristic(ManufacturerCharacteristic.class).get().getValue();
196     }
197
198     @Override
199     public CompletableFuture<String> getModel() {
200         return getCharacteristic(ModelCharacteristic.class).get().getValue();
201     }
202
203     @Override
204     public CompletableFuture<String> getSerialNumber() {
205         return getCharacteristic(SerialNumberCharacteristic.class).get().getValue();
206     }
207
208     @Override
209     public CompletableFuture<String> getFirmwareRevision() {
210         return getCharacteristic(FirmwareRevisionCharacteristic.class).get().getValue();
211     }
212
213     @Override
214     public void identify() {
215         try {
216             getCharacteristic(IdentifyCharacteristic.class).get().setValue(true);
217         } catch (Exception e) {
218             // ignore
219         }
220     }
221
222     public HomekitTaggedItem getRootAccessory() {
223         return accessory;
224     }
225
226     @Override
227     public Collection<Service> getServices() {
228         return this.services;
229     }
230
231     public void addService(Service service) {
232         services.add(service);
233
234         var serviceClass = service.getClass();
235         rawCharacteristics.values().forEach(characteristic -> {
236             // belongs on the accessory information service
237             if (characteristic.getClass() == NameCharacteristic.class) {
238                 return;
239             }
240             try {
241                 // if the service supports adding this characteristic as optional, add it!
242                 serviceClass.getMethod("addOptionalCharacteristic", characteristic.getClass()).invoke(service,
243                         characteristic);
244             } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
245                 // the service doesn't support this optional characteristic; ignore it
246             }
247         });
248     }
249
250     protected HomekitAccessoryUpdater getUpdater() {
251         return updater;
252     }
253
254     protected HomekitSettings getSettings() {
255         return settings;
256     }
257
258     @NonNullByDefault
259     protected void subscribe(HomekitCharacteristicType characteristicType,
260             HomekitCharacteristicChangeCallback callback) {
261         final Optional<HomekitTaggedItem> characteristic = getCharacteristic(characteristicType);
262         if (characteristic.isPresent()) {
263             getUpdater().subscribe((GenericItem) characteristic.get().getItem(), characteristicType.getTag(), callback);
264         } else {
265             logger.warn("Missing mandatory characteristic {}", characteristicType);
266         }
267     }
268
269     @NonNullByDefault
270     protected void unsubscribe(HomekitCharacteristicType characteristicType) {
271         final Optional<HomekitTaggedItem> characteristic = getCharacteristic(characteristicType);
272         if (characteristic.isPresent()) {
273             getUpdater().unsubscribe((GenericItem) characteristic.get().getItem(), characteristicType.getTag());
274         } else {
275             logger.warn("Missing mandatory characteristic {}", characteristicType);
276         }
277     }
278
279     protected @Nullable State getState(HomekitCharacteristicType characteristic) {
280         final Optional<HomekitTaggedItem> taggedItem = getCharacteristic(characteristic);
281         if (taggedItem.isPresent()) {
282             return taggedItem.get().getItem().getState();
283         }
284         logger.debug("State for characteristic {} at accessory {} cannot be retrieved.", characteristic,
285                 accessory.getName());
286         return null;
287     }
288
289     protected @Nullable <T extends State> T getStateAs(HomekitCharacteristicType characteristic, Class<T> type) {
290         final State state = getState(characteristic);
291         if (state != null) {
292             return state.as(type);
293         }
294         return null;
295     }
296
297     protected @Nullable Double getStateAsTemperature(HomekitCharacteristicType characteristic) {
298         return HomekitCharacteristicFactory.stateAsTemperature(getState(characteristic));
299     }
300
301     @NonNullByDefault
302     protected <T extends Item> Optional<T> getItem(HomekitCharacteristicType characteristic, Class<T> type) {
303         final Optional<HomekitTaggedItem> taggedItem = getCharacteristic(characteristic);
304         if (taggedItem.isPresent()) {
305             final Item item = taggedItem.get().getItem();
306             if (type.isInstance(item)) {
307                 return Optional.of((T) item);
308             } else {
309                 logger.warn("Unsupported item type for characteristic {} at accessory {}. Expected {}, got {}",
310                         characteristic, accessory.getItem().getName(), type, taggedItem.get().getItem().getClass());
311             }
312         } else {
313             logger.warn("Mandatory characteristic {} not found at accessory {}. ", characteristic,
314                     accessory.getItem().getName());
315         }
316         return Optional.empty();
317     }
318
319     /**
320      * return configuration attached to the root accessory, e.g. groupItem.
321      * Note: result will be casted to the type of the default value.
322      * The type for number is BigDecimal.
323      *
324      * @param key configuration key
325      * @param defaultValue default value
326      * @param <T> expected type
327      * @return configuration value
328      */
329     @NonNullByDefault
330     protected <T> T getAccessoryConfiguration(String key, T defaultValue) {
331         return accessory.getConfiguration(key, defaultValue);
332     }
333
334     /**
335      * return configuration attached to the root accessory, e.g. groupItem.
336      *
337      * @param key configuration key
338      * @param defaultValue default value
339      * @return configuration value
340      */
341     @NonNullByDefault
342     protected boolean getAccessoryConfigurationAsBoolean(String key, boolean defaultValue) {
343         return accessory.getConfigurationAsBoolean(key, defaultValue);
344     }
345
346     /**
347      * return configuration of the characteristic item, e.g. currentTemperature.
348      * Note: result will be casted to the type of the default value.
349      * The type for number is BigDecimal.
350      *
351      * @param characteristicType characteristic type
352      * @param key configuration key
353      * @param defaultValue default value
354      * @param <T> expected type
355      * @return configuration value
356      */
357     @NonNullByDefault
358     protected <T> T getAccessoryConfiguration(HomekitCharacteristicType characteristicType, String key,
359             T defaultValue) {
360         return getCharacteristic(characteristicType)
361                 .map(homekitTaggedItem -> homekitTaggedItem.getConfiguration(key, defaultValue)).orElse(defaultValue);
362     }
363
364     @NonNullByDefault
365     protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
366             HomekitCharacteristicType characteristicType, Class<T> klazz) {
367         return createMapping(characteristicType, klazz, null, false);
368     }
369
370     @NonNullByDefault
371     protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
372             HomekitCharacteristicType characteristicType, Class<T> klazz, boolean inverted) {
373         return createMapping(characteristicType, klazz, null, inverted);
374     }
375
376     @NonNullByDefault
377     protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
378             HomekitCharacteristicType characteristicType, Class<T> klazz, @Nullable List<T> customEnumList) {
379         return createMapping(characteristicType, klazz, customEnumList, false);
380     }
381
382     /**
383      * create mapping with values from item configuration
384      * 
385      * @param characteristicType to identify item; must be present
386      * @param customEnumList list to store custom state enumeration
387      * @param inverted if ON/OFF and OPEN/CLOSED should be inverted by default (inverted on the item will double-invert)
388      * @return mapping of enum values to custom string values
389      */
390     @NonNullByDefault
391     protected <T extends Enum<T> & CharacteristicEnum> Map<T, String> createMapping(
392             HomekitCharacteristicType characteristicType, Class<T> klazz, @Nullable List<T> customEnumList,
393             boolean inverted) {
394         HomekitTaggedItem item = getCharacteristic(characteristicType).get();
395         return HomekitCharacteristicFactory.createMapping(item, klazz, customEnumList, inverted);
396     }
397
398     /**
399      * takes item state as value and retrieves the key for that value from mapping.
400      * e.g. used to map StringItem value to HomeKit Enum
401      *
402      * @param characteristicType characteristicType to identify item
403      * @param mapping mapping
404      * @param defaultValue default value if nothing found in mapping
405      * @param <T> type of the result derived from
406      * @return key for the value
407      */
408     @NonNullByDefault
409     public <T> T getKeyFromMapping(HomekitCharacteristicType characteristicType, Map<T, String> mapping,
410             T defaultValue) {
411         final Optional<HomekitTaggedItem> c = getCharacteristic(characteristicType);
412         if (c.isPresent()) {
413             return HomekitCharacteristicFactory.getKeyFromMapping(c.get(), mapping, defaultValue);
414         }
415         return defaultValue;
416     }
417
418     @NonNullByDefault
419     protected void addCharacteristic(HomekitTaggedItem item, Characteristic characteristic)
420             throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
421         characteristics.add(item);
422         addCharacteristic(characteristic);
423     }
424
425     /**
426      * If the primary service does not yet exist, it won't be added to it. It's the resposibility
427      * of the caller to add characteristics when the primary service is created.
428      *
429      * @param characteristic
430      * @throws NoSuchMethodException
431      * @throws IllegalAccessException
432      * @throws InvocationTargetException
433      */
434     @NonNullByDefault
435     public void addCharacteristic(Characteristic characteristic)
436             throws NoSuchMethodException, IllegalAccessException, InvocationTargetException {
437         if (rawCharacteristics.containsKey(characteristic.getClass())) {
438             logger.warn("Accessory {} already has a characteristic of type {}; ignoring additional definition.",
439                     accessory.getName(), characteristic.getClass().getSimpleName());
440             return;
441         }
442         rawCharacteristics.put(characteristic.getClass(), characteristic);
443         var service = getPrimaryService();
444         if (service != null) {
445             // find the corresponding add method at service and call it.
446             service.getClass().getMethod("addOptionalCharacteristic", characteristic.getClass()).invoke(service,
447                     characteristic);
448         }
449     }
450
451     /**
452      * Takes the NameCharacteristic that normally exists on the AccessoryInformationService,
453      * and puts it on the primary service.
454      */
455     public void promoteNameCharacteristic() {
456         var characteristic = getCharacteristic(NameCharacteristic.class);
457         if (!characteristic.isPresent()) {
458             return;
459         }
460
461         var service = getPrimaryService();
462         if (service != null) {
463             try {
464                 // find the corresponding add method at service and call it.
465                 service.getClass().getMethod("addOptionalCharacteristic", NameCharacteristic.class).invoke(service,
466                         characteristic.get());
467             } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
468                 // This should never happen; all services should support NameCharacteristic as an optional
469                 // Characteristic.
470                 // If HAP-Java defined a service that doesn't support addOptionalCharacteristic(NameCharacteristic),
471                 // Then it's a bug there, and we're just going to ignore the exception here.
472             }
473         }
474     }
475
476     @NonNullByDefault
477     public <T> Optional<T> getCharacteristic(Class<? extends T> klazz) {
478         return Optional.ofNullable((T) rawCharacteristics.get(klazz));
479     }
480
481     /**
482      * create boolean reader with ON state mapped to trueOnOffValue or trueOpenClosedValue depending of item type
483      *
484      * @param characteristicType characteristic id
485      * @param trueOnOffValue ON value for switch
486      * @param trueOpenClosedValue ON value for contact
487      * @return boolean read
488      * @throws IncompleteAccessoryException
489      */
490     @NonNullByDefault
491     protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType,
492             OnOffType trueOnOffValue, OpenClosedType trueOpenClosedValue) throws IncompleteAccessoryException {
493         return new BooleanItemReader(
494                 getItem(characteristicType, GenericItem.class)
495                         .orElseThrow(() -> new IncompleteAccessoryException(characteristicType)),
496                 trueOnOffValue, trueOpenClosedValue);
497     }
498
499     /**
500      * create boolean reader for a number item with ON state mapped to the value of the
501      * item being above a given threshold
502      *
503      * @param characteristicType characteristic id
504      * @param trueThreshold threshold for true of number item
505      * @param invertThreshold result is true if item is less than threshold, instead of more
506      * @return boolean read
507      * @throws IncompleteAccessoryException
508      */
509     @NonNullByDefault
510     protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType,
511             BigDecimal trueThreshold, boolean invertThreshold) throws IncompleteAccessoryException {
512         final HomekitTaggedItem taggedItem = getCharacteristic(characteristicType)
513                 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType));
514         return new BooleanItemReader(taggedItem.getItem(), OnOffType.from(!taggedItem.isInverted()),
515                 taggedItem.isInverted() ? OpenClosedType.CLOSED : OpenClosedType.OPEN, trueThreshold, invertThreshold);
516     }
517
518     /**
519      * create boolean reader with default ON/OFF mapping considering inverted flag
520      *
521      * @param characteristicType characteristic id
522      * @return boolean reader
523      * @throws IncompleteAccessoryException
524      */
525     @NonNullByDefault
526     protected BooleanItemReader createBooleanReader(HomekitCharacteristicType characteristicType)
527             throws IncompleteAccessoryException {
528         final HomekitTaggedItem taggedItem = getCharacteristic(characteristicType)
529                 .orElseThrow(() -> new IncompleteAccessoryException(characteristicType));
530         return new BooleanItemReader(taggedItem.getItem(), OnOffType.from(!taggedItem.isInverted()),
531                 taggedItem.isInverted() ? OpenClosedType.CLOSED : OpenClosedType.OPEN);
532     }
533
534     /**
535      * Calculates a string as json of the configuration for this accessory, suitable for seeing
536      * if the structure has changed, and building a dummy accessory for it. It is _not_ suitable
537      * for actual publishing to by HAP-Java to iOS devices, since all the IIDs will be set to 0.
538      * The IIDs will get replaced by actual values by HAP-Java inside of DummyHomekitCharacteristic.
539      */
540     public String toJson() {
541         var builder = Json.createArrayBuilder();
542         getServices().forEach(s -> {
543             builder.add(serviceToJson(s));
544         });
545         return builder.build().toString();
546     }
547
548     private JsonObjectBuilder serviceToJson(Service service) {
549         var serviceBuilder = Json.createObjectBuilder();
550         serviceBuilder.add("type", service.getType());
551         var characteristics = Json.createArrayBuilder();
552
553         service.getCharacteristics().stream().sorted((l, r) -> l.getClass().getName().compareTo(r.getClass().getName()))
554                 .forEach(c -> {
555                     try {
556                         var cJson = c.toJson(0).get();
557                         var cBuilder = Json.createObjectBuilder();
558                         // Need to copy over everything except the current value, which we instead
559                         // reach in and get the default value
560                         cJson.forEach((k, v) -> {
561                             if ("value".equals(k)) {
562                                 Object defaultValue = ((BaseCharacteristic) c).getDefault();
563                                 if (defaultValue instanceof Boolean) {
564                                     cBuilder.add("value", (boolean) defaultValue);
565                                 } else if (defaultValue instanceof Integer) {
566                                     cBuilder.add("value", (int) defaultValue);
567                                 } else if (defaultValue instanceof Double) {
568                                     cBuilder.add("value", (double) defaultValue);
569                                 } else {
570                                     cBuilder.add("value", defaultValue.toString());
571                                 }
572                             } else {
573                                 cBuilder.add(k, v);
574                             }
575                         });
576                         characteristics.add(cBuilder.build());
577                     } catch (InterruptedException | ExecutionException e) {
578                     }
579                 });
580         serviceBuilder.add("c", characteristics);
581
582         if (!service.getLinkedServices().isEmpty()) {
583             var linkedServices = Json.createArrayBuilder();
584             service.getLinkedServices().forEach(s -> linkedServices.add(serviceToJson(s)));
585             serviceBuilder.add("ls", linkedServices);
586         }
587         return serviceBuilder;
588     }
589 }