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