]> git.basschouten.com Git - openhab-addons.git/blob
74676dda213300d3c2b0b813c0a05e824ee7556d
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.binding.hue.internal.api.dto.clip2;
14
15 import java.math.BigDecimal;
16 import java.math.MathContext;
17 import java.math.RoundingMode;
18 import java.time.Duration;
19 import java.time.Instant;
20 import java.time.ZoneId;
21 import java.time.ZonedDateTime;
22 import java.util.List;
23 import java.util.Map;
24 import java.util.Objects;
25 import java.util.Optional;
26
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.openhab.binding.hue.internal.api.dto.clip2.enums.ActionType;
30 import org.openhab.binding.hue.internal.api.dto.clip2.enums.ButtonEventType;
31 import org.openhab.binding.hue.internal.api.dto.clip2.enums.ContactStateType;
32 import org.openhab.binding.hue.internal.api.dto.clip2.enums.EffectType;
33 import org.openhab.binding.hue.internal.api.dto.clip2.enums.ResourceType;
34 import org.openhab.binding.hue.internal.api.dto.clip2.enums.SceneRecallAction;
35 import org.openhab.binding.hue.internal.api.dto.clip2.enums.SmartSceneRecallAction;
36 import org.openhab.binding.hue.internal.api.dto.clip2.enums.SmartSceneState;
37 import org.openhab.binding.hue.internal.api.dto.clip2.enums.TamperStateType;
38 import org.openhab.binding.hue.internal.api.dto.clip2.enums.ZigbeeStatus;
39 import org.openhab.binding.hue.internal.exceptions.DTOPresentButEmptyException;
40 import org.openhab.core.library.types.DateTimeType;
41 import org.openhab.core.library.types.DecimalType;
42 import org.openhab.core.library.types.HSBType;
43 import org.openhab.core.library.types.OnOffType;
44 import org.openhab.core.library.types.OpenClosedType;
45 import org.openhab.core.library.types.PercentType;
46 import org.openhab.core.library.types.QuantityType;
47 import org.openhab.core.library.types.StringType;
48 import org.openhab.core.library.unit.SIUnits;
49 import org.openhab.core.library.unit.Units;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.State;
52 import org.openhab.core.types.UnDefType;
53 import org.openhab.core.util.ColorUtil;
54 import org.openhab.core.util.ColorUtil.Gamut;
55
56 import com.google.gson.JsonElement;
57 import com.google.gson.JsonObject;
58 import com.google.gson.annotations.SerializedName;
59
60 /**
61  * Complete Resource information DTO for CLIP 2.
62  *
63  * Note: all fields are @Nullable because some cases do not (must not) use them.
64  *
65  * @author Andrew Fiddian-Green - Initial contribution
66  */
67 @NonNullByDefault
68 public class Resource {
69
70     public static final double PERCENT_DELTA = 30f;
71     public static final MathContext PERCENT_MATH_CONTEXT = new MathContext(4, RoundingMode.HALF_UP);
72
73     /**
74      * The SSE event mechanism sends resources in a sparse (skeleton) format that only includes state fields whose
75      * values have changed. A sparse resource does not contain the full state of the resource. And the absence of any
76      * field from such a resource does not indicate that the field value is UNDEF, but rather that the value is the same
77      * as what it was previously set to by the last non-sparse resource.
78      */
79     private transient boolean hasSparseData;
80
81     private @Nullable String type;
82     private @Nullable String id;
83     private @Nullable @SerializedName("bridge_id") String bridgeId;
84     private @Nullable @SerializedName("id_v1") String idV1;
85     private @Nullable ResourceReference owner;
86     private @Nullable MetaData metadata;
87     private @Nullable @SerializedName("product_data") ProductData productData;
88     private @Nullable List<ResourceReference> services;
89     private @Nullable OnState on;
90     private @Nullable Dimming dimming;
91     private @Nullable @SerializedName("color_temperature") ColorTemperature colorTemperature;
92     private @Nullable ColorXy color;
93     private @Nullable Alerts alert;
94     private @Nullable Effects effects;
95     private @Nullable @SerializedName("timed_effects") TimedEffects timedEffects;
96     private @Nullable ResourceReference group;
97     private @Nullable List<ActionEntry> actions;
98     private @Nullable Recall recall;
99     private @Nullable Boolean enabled;
100     private @Nullable LightLevel light;
101     private @Nullable Button button;
102     private @Nullable Temperature temperature;
103     private @Nullable Motion motion;
104     private @Nullable @SerializedName("power_state") Power powerState;
105     private @Nullable @SerializedName("relative_rotary") RelativeRotary relativeRotary;
106     private @Nullable List<ResourceReference> children;
107     private @Nullable JsonElement status;
108     private @Nullable Dynamics dynamics;
109     private @Nullable @SerializedName("contact_report") ContactReport contactReport;
110     private @Nullable @SerializedName("tamper_reports") List<TamperReport> tamperReports;
111     private @Nullable String state;
112
113     /**
114      * Constructor
115      *
116      * @param resourceType
117      */
118     public Resource(@Nullable ResourceType resourceType) {
119         if (Objects.nonNull(resourceType)) {
120             setType(resourceType);
121         }
122     }
123
124     /**
125      * Check if <code>light</code> or <code>grouped_light</code> resource contains any
126      * relevant fields to process according to its type.
127      * 
128      * As an example, {@link #colorTemperature} is relevant for a <code>light</code>
129      * resource because it's needed for updating the color-temperature channels.
130      *
131      * @return true is resource contains any relevant field
132      */
133     public boolean hasAnyRelevantField() {
134         return switch (getType()) {
135             // https://developers.meethue.com/develop/hue-api-v2/api-reference/#resource_light_get
136             case LIGHT -> hasHSBField() || colorTemperature != null || dynamics != null || effects != null
137                     || timedEffects != null;
138             // https://developers.meethue.com/develop/hue-api-v2/api-reference/#resource_grouped_light_get
139             case GROUPED_LIGHT -> on != null || dimming != null || alert != null;
140             default -> throw new IllegalStateException(type + " is not supported by hasAnyRelevantField()");
141         };
142     }
143
144     /**
145      * Check if resource contains any field which is needed to represent an HSB value
146      * (<code>on</code>, <code>dimming</code> or <code>color</code>).
147      *
148      * @return true if resource has any HSB field
149      */
150     public boolean hasHSBField() {
151         return on != null || dimming != null || color != null;
152     }
153
154     public @Nullable List<ActionEntry> getActions() {
155         return actions;
156     }
157
158     public @Nullable Alerts getAlerts() {
159         return alert;
160     }
161
162     public State getAlertState() {
163         Alerts alerts = this.alert;
164         if (Objects.nonNull(alerts)) {
165             if (!alerts.getActionValues().isEmpty()) {
166                 ActionType alertType = alerts.getAction();
167                 if (Objects.nonNull(alertType)) {
168                     return new StringType(alertType.name());
169                 }
170                 return new StringType(ActionType.NO_ACTION.name());
171             }
172         }
173         return UnDefType.NULL;
174     }
175
176     public String getArchetype() {
177         MetaData metaData = getMetaData();
178         if (Objects.nonNull(metaData)) {
179             return metaData.getArchetype().toString();
180         }
181         return getType().toString();
182     }
183
184     public State getBatteryLevelState() {
185         Power powerState = this.powerState;
186         return Objects.nonNull(powerState) ? powerState.getBatteryLevelState() : UnDefType.NULL;
187     }
188
189     public State getBatteryLowState() {
190         Power powerState = this.powerState;
191         return Objects.nonNull(powerState) ? powerState.getBatteryLowState() : UnDefType.NULL;
192     }
193
194     public @Nullable String getBridgeId() {
195         String bridgeId = this.bridgeId;
196         return Objects.isNull(bridgeId) || bridgeId.isBlank() ? null : bridgeId;
197     }
198
199     /**
200      * Get the brightness as a PercentType. If off the brightness is 0, otherwise use dimming value.
201      *
202      * @return a PercentType with the dimming state, or UNDEF, or NULL
203      */
204     public State getBrightnessState() {
205         Dimming dimming = this.dimming;
206         if (Objects.nonNull(dimming)) {
207             try {
208                 // if off the brightness is 0, otherwise it is the larger of dimming value or minimum dimming level
209                 OnState on = this.on;
210                 double brightness;
211                 if (Objects.nonNull(on) && !on.isOn()) {
212                     brightness = 0f;
213                 } else {
214                     Double minimumDimmingLevel = dimming.getMinimumDimmingLevel();
215                     brightness = Math.max(Objects.nonNull(minimumDimmingLevel) ? minimumDimmingLevel
216                             : Dimming.DEFAULT_MINIMUM_DIMMIMG_LEVEL, Math.min(100f, dimming.getBrightness()));
217                 }
218                 return new PercentType(new BigDecimal(brightness, PERCENT_MATH_CONTEXT));
219             } catch (DTOPresentButEmptyException e) {
220                 return UnDefType.UNDEF; // indicates the DTO is present but its inner fields are missing
221             }
222         }
223         return UnDefType.NULL;
224     }
225
226     public @Nullable Button getButton() {
227         return button;
228     }
229
230     /**
231      * Get the state corresponding to a button's last event value multiplied by the controlId found for it in the given
232      * controlIds map. States are decimal values formatted like '1002' where the first digit is the button's controlId
233      * and the last digit is the ordinal value of the button's last event.
234      *
235      * @param controlIds the map of control ids to be referenced.
236      * @return the state.
237      */
238     public State getButtonEventState(Map<String, Integer> controlIds) {
239         Button button = this.button;
240         if (button == null) {
241             return UnDefType.NULL;
242         }
243         ButtonEventType event;
244         ButtonReport buttonReport = button.getButtonReport();
245         if (buttonReport == null) {
246             event = button.getLastEvent();
247         } else {
248             event = buttonReport.getLastEvent();
249         }
250         if (event == null) {
251             return UnDefType.NULL;
252         }
253         return new DecimalType((controlIds.getOrDefault(getId(), 0).intValue() * 1000) + event.ordinal());
254     }
255
256     public State getButtonLastUpdatedState(ZoneId zoneId) {
257         Button button = this.button;
258         if (button == null) {
259             return UnDefType.NULL;
260         }
261         ButtonReport buttonReport = button.getButtonReport();
262         if (buttonReport == null) {
263             return UnDefType.UNDEF;
264         }
265         Instant lastChanged = buttonReport.getLastChanged();
266         if (Instant.EPOCH.equals(lastChanged)) {
267             return UnDefType.UNDEF;
268         }
269         return new DateTimeType(ZonedDateTime.ofInstant(lastChanged, zoneId));
270     }
271
272     public List<ResourceReference> getChildren() {
273         List<ResourceReference> children = this.children;
274         return Objects.nonNull(children) ? children : List.of();
275     }
276
277     /**
278      * Get the color as an HSBType. This returns an HSB that is based on an amalgamation of the color xy, dimming, and
279      * on/off JSON elements. It takes its 'H' and 'S' parts from the 'ColorXy' JSON element, and its 'B' part from the
280      * on/off resp. dimming JSON elements. If off the B part is 0, otherwise it is the dimming element value. Note: this
281      * method is only to be used on cached state DTOs which already have a defined color gamut.
282      *
283      * @return an HSBType containing the current color and brightness level, or UNDEF or NULL.
284      */
285     public State getColorState() {
286         ColorXy color = this.color;
287         if (Objects.nonNull(color)) {
288             try {
289                 Gamut gamut = color.getGamut();
290                 gamut = Objects.nonNull(gamut) ? gamut : ColorUtil.DEFAULT_GAMUT;
291                 HSBType hsb = ColorUtil.xyToHsb(color.getXY(), gamut);
292                 OnState on = this.on;
293                 Dimming dimming = this.dimming;
294                 double brightness = Objects.nonNull(on) && !on.isOn() ? 0
295                         : Objects.nonNull(dimming) ? Math.max(0, Math.min(100, dimming.getBrightness())) : 50;
296                 return new HSBType(hsb.getHue(), hsb.getSaturation(),
297                         new PercentType(new BigDecimal(brightness, PERCENT_MATH_CONTEXT)));
298             } catch (DTOPresentButEmptyException e) {
299                 return UnDefType.UNDEF; // indicates the DTO is present but its inner fields are missing
300             }
301         }
302         return UnDefType.NULL;
303     }
304
305     public @Nullable ColorTemperature getColorTemperature() {
306         return colorTemperature;
307     }
308
309     public State getColorTemperatureAbsoluteState() {
310         ColorTemperature colorTemp = colorTemperature;
311         if (Objects.nonNull(colorTemp)) {
312             try {
313                 QuantityType<?> colorTemperature = colorTemp.getAbsolute();
314                 if (Objects.nonNull(colorTemperature)) {
315                     return colorTemperature;
316                 }
317             } catch (DTOPresentButEmptyException e) {
318                 return UnDefType.UNDEF; // indicates the DTO is present but its inner fields are missing
319             }
320         }
321         return UnDefType.NULL;
322     }
323
324     /**
325      * Get the colour temperature in percent. Note: this method is only to be used on cached state DTOs which already
326      * have a defined mirek schema.
327      *
328      * @return a PercentType with the colour temperature percentage.
329      */
330     public State getColorTemperaturePercentState() {
331         ColorTemperature colorTemperature = this.colorTemperature;
332         if (Objects.nonNull(colorTemperature)) {
333             try {
334                 Double percent = colorTemperature.getPercent();
335                 if (Objects.nonNull(percent)) {
336                     return new PercentType(new BigDecimal(percent, PERCENT_MATH_CONTEXT));
337                 }
338             } catch (DTOPresentButEmptyException e) {
339                 return UnDefType.UNDEF; // indicates the DTO is present but its inner fields are missing
340             }
341         }
342         return UnDefType.NULL;
343     }
344
345     public @Nullable ColorXy getColorXy() {
346         return color;
347     }
348
349     /**
350      * Return an HSB where the HS part is derived from the color xy JSON element (only), so the B part is 100%
351      *
352      * @return an HSBType.
353      */
354     public State getColorXyState() {
355         ColorXy color = this.color;
356         if (Objects.nonNull(color)) {
357             try {
358                 Gamut gamut = color.getGamut();
359                 gamut = Objects.nonNull(gamut) ? gamut : ColorUtil.DEFAULT_GAMUT;
360                 HSBType hsb = ColorUtil.xyToHsb(color.getXY(), gamut);
361                 return new HSBType(hsb.getHue(), hsb.getSaturation(), PercentType.HUNDRED);
362             } catch (DTOPresentButEmptyException e) {
363                 return UnDefType.UNDEF; // indicates the DTO is present but its inner fields are missing
364             }
365         }
366         return UnDefType.NULL;
367     }
368
369     public State getContactLastUpdatedState(ZoneId zoneId) {
370         ContactReport contactReport = this.contactReport;
371         return Objects.nonNull(contactReport)
372                 ? new DateTimeType(ZonedDateTime.ofInstant(contactReport.getLastChanged(), zoneId))
373                 : UnDefType.NULL;
374     }
375
376     public State getContactState() {
377         ContactReport contactReport = this.contactReport;
378         return Objects.isNull(contactReport) ? UnDefType.NULL
379                 : ContactStateType.CONTACT == contactReport.getContactState() ? OpenClosedType.CLOSED
380                         : OpenClosedType.OPEN;
381     }
382
383     public int getControlId() {
384         MetaData metadata = this.metadata;
385         return Objects.nonNull(metadata) ? metadata.getControlId() : 0;
386     }
387
388     public @Nullable Dimming getDimming() {
389         return dimming;
390     }
391
392     /**
393      * Return a PercentType which is derived from the dimming JSON element (only).
394      *
395      * @return a PercentType.
396      */
397     public State getDimmingState() {
398         Dimming dimming = this.dimming;
399         if (Objects.nonNull(dimming)) {
400             try {
401                 double dimmingValue = Math.max(0f, Math.min(100f, dimming.getBrightness()));
402                 return new PercentType(new BigDecimal(dimmingValue, PERCENT_MATH_CONTEXT));
403             } catch (DTOPresentButEmptyException e) {
404                 return UnDefType.UNDEF; // indicates the DTO is present but its inner fields are missing
405             }
406         }
407         return UnDefType.NULL;
408     }
409
410     public @Nullable Effects getFixedEffects() {
411         return effects;
412     }
413
414     /**
415      * Get the amalgamated effect state. The result may be either from an 'effects' field or from a 'timedEffects'
416      * field. If both fields are missing it returns UnDefType.NULL, otherwise if either field is present and has an
417      * active value (other than EffectType.NO_EFFECT) it returns a StringType of the name of the respective active
418      * effect; and if none of the above apply, it returns a StringType of 'NO_EFFECT'.
419      *
420      * @return either a StringType value or UnDefType.NULL
421      */
422     public State getEffectState() {
423         Effects effects = this.effects;
424         TimedEffects timedEffects = this.timedEffects;
425         if (Objects.isNull(effects) && Objects.isNull(timedEffects)) {
426             return UnDefType.NULL;
427         }
428         EffectType effect = Objects.nonNull(effects) ? effects.getStatus() : null;
429         if (Objects.nonNull(effect) && effect != EffectType.NO_EFFECT) {
430             return new StringType(effect.name());
431         }
432         EffectType timedEffect = Objects.nonNull(timedEffects) ? timedEffects.getStatus() : null;
433         if (Objects.nonNull(timedEffect) && timedEffect != EffectType.NO_EFFECT) {
434             return new StringType(timedEffect.name());
435         }
436         return new StringType(EffectType.NO_EFFECT.name());
437     }
438
439     public @Nullable Boolean getEnabled() {
440         return enabled;
441     }
442
443     public State getEnabledState() {
444         Boolean enabled = this.enabled;
445         return Objects.nonNull(enabled) ? OnOffType.from(enabled.booleanValue()) : UnDefType.NULL;
446     }
447
448     public @Nullable Gamut getGamut() {
449         ColorXy color = this.color;
450         return Objects.nonNull(color) ? color.getGamut() : null;
451     }
452
453     public @Nullable ResourceReference getGroup() {
454         return group;
455     }
456
457     public String getId() {
458         String id = this.id;
459         return Objects.nonNull(id) ? id : "";
460     }
461
462     public String getIdV1() {
463         String idV1 = this.idV1;
464         return Objects.nonNull(idV1) ? idV1 : "";
465     }
466
467     public @Nullable LightLevel getLightLevel() {
468         return light;
469     }
470
471     public State getLightLevelState() {
472         LightLevel lightLevel = this.light;
473         if (lightLevel == null) {
474             return UnDefType.NULL;
475         }
476         LightLevelReport lightLevelReport = lightLevel.getLightLevelReport();
477         if (lightLevelReport == null) {
478             return lightLevel.getLightLevelState();
479         }
480         return new QuantityType<>(Math.pow(10f, (double) lightLevelReport.getLightLevel() / 10000f) - 1f, Units.LUX);
481     }
482
483     public State getLightLevelLastUpdatedState(ZoneId zoneId) {
484         LightLevel lightLevel = this.light;
485         if (lightLevel == null) {
486             return UnDefType.NULL;
487         }
488         LightLevelReport lightLevelReport = lightLevel.getLightLevelReport();
489         if (lightLevelReport == null) {
490             return UnDefType.UNDEF;
491         }
492         Instant lastChanged = lightLevelReport.getLastChanged();
493         if (Instant.EPOCH.equals(lastChanged)) {
494             return UnDefType.UNDEF;
495         }
496         return new DateTimeType(ZonedDateTime.ofInstant(lastChanged, zoneId));
497     }
498
499     public @Nullable MetaData getMetaData() {
500         return metadata;
501     }
502
503     public @Nullable Double getMinimumDimmingLevel() {
504         Dimming dimming = this.dimming;
505         return Objects.nonNull(dimming) ? dimming.getMinimumDimmingLevel() : null;
506     }
507
508     public @Nullable MirekSchema getMirekSchema() {
509         ColorTemperature colorTemp = this.colorTemperature;
510         return Objects.nonNull(colorTemp) ? colorTemp.getMirekSchema() : null;
511     }
512
513     public @Nullable Motion getMotion() {
514         return motion;
515     }
516
517     public State getMotionState() {
518         Motion motion = this.motion;
519         if (motion == null) {
520             return UnDefType.NULL;
521         }
522         MotionReport motionReport = motion.getMotionReport();
523         if (motionReport == null) {
524             return motion.getMotionState();
525         }
526         return OnOffType.from(motionReport.isMotion());
527     }
528
529     public State getMotionLastUpdatedState(ZoneId zoneId) {
530         Motion motion = this.motion;
531         if (motion == null) {
532             return UnDefType.NULL;
533         }
534         MotionReport motionReport = motion.getMotionReport();
535         if (motionReport == null) {
536             return UnDefType.UNDEF;
537         }
538         Instant lastChanged = motionReport.getLastChanged();
539         if (Instant.EPOCH.equals(lastChanged)) {
540             return UnDefType.UNDEF;
541         }
542         return new DateTimeType(ZonedDateTime.ofInstant(lastChanged, zoneId));
543     }
544
545     public State getMotionValidState() {
546         Motion motion = this.motion;
547         return Objects.nonNull(motion) ? motion.getMotionValidState() : UnDefType.NULL;
548     }
549
550     public String getName() {
551         MetaData metaData = getMetaData();
552         if (Objects.nonNull(metaData)) {
553             String name = metaData.getName();
554             if (Objects.nonNull(name)) {
555                 return name;
556             }
557         }
558         return getType().toString();
559     }
560
561     /**
562      * Return the state of the On/Off element (only).
563      */
564     public State getOnOffState() {
565         try {
566             OnState on = this.on;
567             return Objects.nonNull(on) ? OnOffType.from(on.isOn()) : UnDefType.NULL;
568         } catch (DTOPresentButEmptyException e) {
569             return UnDefType.UNDEF; // indicates the DTO is present but its inner fields are missing
570         }
571     }
572
573     public @Nullable OnState getOnState() {
574         return on;
575     }
576
577     public @Nullable ResourceReference getOwner() {
578         return owner;
579     }
580
581     public @Nullable Power getPowerState() {
582         return powerState;
583     }
584
585     public @Nullable ProductData getProductData() {
586         return productData;
587     }
588
589     public String getProductName() {
590         ProductData productData = getProductData();
591         if (Objects.nonNull(productData)) {
592             return productData.getProductName();
593         }
594         return getType().toString();
595     }
596
597     public @Nullable Recall getRecall() {
598         return recall;
599     }
600
601     public @Nullable RelativeRotary getRelativeRotary() {
602         return relativeRotary;
603     }
604
605     public State getRotaryStepsState() {
606         RelativeRotary relativeRotary = this.relativeRotary;
607         if (relativeRotary == null) {
608             return UnDefType.NULL;
609         }
610         RotaryReport rotaryReport = relativeRotary.getRotaryReport();
611         if (rotaryReport == null) {
612             return relativeRotary.getStepsState();
613         }
614         Rotation rotation = rotaryReport.getRotation();
615         if (rotation == null) {
616             return UnDefType.NULL;
617         }
618         return rotation.getStepsState();
619     }
620
621     public State getRotaryStepsLastUpdatedState(ZoneId zoneId) {
622         RelativeRotary relativeRotary = this.relativeRotary;
623         if (relativeRotary == null) {
624             return UnDefType.NULL;
625         }
626         RotaryReport rotaryReport = relativeRotary.getRotaryReport();
627         if (rotaryReport == null) {
628             return UnDefType.UNDEF;
629         }
630         Instant lastChanged = rotaryReport.getLastChanged();
631         if (Instant.EPOCH.equals(lastChanged)) {
632             return UnDefType.UNDEF;
633         }
634         return new DateTimeType(ZonedDateTime.ofInstant(lastChanged, zoneId));
635     }
636
637     /**
638      * Check if the scene resource contains a 'status.active' element. If such an element is present, returns a Boolean
639      * Optional whose value depends on the value of that element, or an empty Optional if it is not.
640      *
641      * @return true, false, or empty.
642      */
643     public Optional<Boolean> getSceneActive() {
644         if (ResourceType.SCENE == getType()) {
645             JsonElement status = this.status;
646             if (Objects.nonNull(status) && status.isJsonObject()) {
647                 JsonElement active = ((JsonObject) status).get("active");
648                 if (Objects.nonNull(active) && active.isJsonPrimitive()) {
649                     return Optional.of(!"inactive".equalsIgnoreCase(active.getAsString()));
650                 }
651             }
652         }
653         return Optional.empty();
654     }
655
656     /**
657      * If the getSceneActive() optional result is empty return 'UnDefType.NULL'. Otherwise if the optional result is
658      * present and 'true' (i.e. the scene is active) return the scene name. Or finally (the optional result is present
659      * and 'false') return 'UnDefType.UNDEF'.
660      *
661      * @return either 'UnDefType.NULL', a StringType containing the (active) scene name, or 'UnDefType.UNDEF'.
662      */
663     public State getSceneState() {
664         return getSceneActive().map(a -> a ? new StringType(getName()) : UnDefType.UNDEF).orElse(UnDefType.NULL);
665     }
666
667     /**
668      * Check if the smart scene resource contains a 'state' element. If such an element is present, returns a Boolean
669      * Optional whose value depends on the value of that element, or an empty Optional if it is not.
670      *
671      * @return true, false, or empty.
672      */
673     public Optional<Boolean> getSmartSceneActive() {
674         if (ResourceType.SMART_SCENE == getType()) {
675             String state = this.state;
676             if (Objects.nonNull(state)) {
677                 return Optional.of(SmartSceneState.ACTIVE == SmartSceneState.of(state));
678             }
679         }
680         return Optional.empty();
681     }
682
683     /**
684      * If the getSmartSceneActive() optional result is empty return 'UnDefType.NULL'. Otherwise if the optional result
685      * is present and 'true' (i.e. the scene is active) return the smart scene name. Or finally (the optional result is
686      * present and 'false') return 'UnDefType.UNDEF'.
687      *
688      * @return either 'UnDefType.NULL', a StringType containing the (active) scene name, or 'UnDefType.UNDEF'.
689      */
690     public State getSmartSceneState() {
691         return getSmartSceneActive().map(a -> a ? new StringType(getName()) : UnDefType.UNDEF).orElse(UnDefType.NULL);
692     }
693
694     public List<ResourceReference> getServiceReferences() {
695         List<ResourceReference> services = this.services;
696         return Objects.nonNull(services) ? services : List.of();
697     }
698
699     public JsonObject getStatus() {
700         JsonElement status = this.status;
701         if (Objects.nonNull(status) && status.isJsonObject()) {
702             return status.getAsJsonObject();
703         }
704         return new JsonObject();
705     }
706
707     public State getTamperLastUpdatedState(ZoneId zoneId) {
708         TamperReport report = getTamperReportsLatest();
709         return Objects.nonNull(report) ? new DateTimeType(ZonedDateTime.ofInstant(report.getLastChanged(), zoneId))
710                 : UnDefType.NULL;
711     }
712
713     /**
714      * The the Hue bridge could return its raw list of tamper reports in any order, so sort the list (latest entry
715      * first) according to the respective 'changed' instant and return the first entry i.e. the latest changed entry.
716      *
717      * @return the latest changed tamper report
718      */
719     private @Nullable TamperReport getTamperReportsLatest() {
720         List<TamperReport> reports = this.tamperReports;
721         return Objects.nonNull(reports)
722                 ? reports.stream().sorted((e1, e2) -> e2.getLastChanged().compareTo(e1.getLastChanged())).findFirst()
723                         .orElse(null)
724                 : null;
725     }
726
727     public State getTamperState() {
728         TamperReport report = getTamperReportsLatest();
729         return Objects.nonNull(report)
730                 ? TamperStateType.TAMPERED == report.getTamperState() ? OpenClosedType.OPEN : OpenClosedType.CLOSED
731                 : UnDefType.NULL;
732     }
733
734     public @Nullable Temperature getTemperature() {
735         return temperature;
736     }
737
738     public State getTemperatureState() {
739         Temperature temperature = this.temperature;
740         if (temperature == null) {
741             return UnDefType.NULL;
742         }
743         TemperatureReport temperatureReport = temperature.getTemperatureReport();
744         if (temperatureReport == null) {
745             return temperature.getTemperatureState();
746         }
747         return new QuantityType<>(temperatureReport.getTemperature(), SIUnits.CELSIUS);
748     }
749
750     public State getTemperatureLastUpdatedState(ZoneId zoneId) {
751         Temperature temperature = this.temperature;
752         if (temperature == null) {
753             return UnDefType.NULL;
754         }
755         TemperatureReport temperatureReport = temperature.getTemperatureReport();
756         if (temperatureReport == null) {
757             return UnDefType.UNDEF;
758         }
759         Instant lastChanged = temperatureReport.getLastChanged();
760         if (Instant.EPOCH.equals(lastChanged)) {
761             return UnDefType.UNDEF;
762         }
763         return new DateTimeType(ZonedDateTime.ofInstant(lastChanged, zoneId));
764     }
765
766     public State getTemperatureValidState() {
767         Temperature temperature = this.temperature;
768         return Objects.nonNull(temperature) ? temperature.getTemperatureValidState() : UnDefType.NULL;
769     }
770
771     public @Nullable TimedEffects getTimedEffects() {
772         return timedEffects;
773     }
774
775     public ResourceType getType() {
776         return ResourceType.of(type);
777     }
778
779     public State getZigbeeState() {
780         ZigbeeStatus zigbeeStatus = getZigbeeStatus();
781         return Objects.nonNull(zigbeeStatus) ? new StringType(zigbeeStatus.toString()) : UnDefType.NULL;
782     }
783
784     public @Nullable ZigbeeStatus getZigbeeStatus() {
785         JsonElement status = this.status;
786         if (Objects.nonNull(status) && status.isJsonPrimitive()) {
787             return ZigbeeStatus.of(status.getAsString());
788         }
789         return null;
790     }
791
792     public boolean hasFullState() {
793         return !hasSparseData;
794     }
795
796     /**
797      * Mark that the resource has sparse data.
798      *
799      * @return this instance.
800      */
801     public Resource markAsSparse() {
802         hasSparseData = true;
803         return this;
804     }
805
806     public Resource setAlerts(Alerts alert) {
807         this.alert = alert;
808         return this;
809     }
810
811     public Resource setColorTemperature(ColorTemperature colorTemperature) {
812         this.colorTemperature = colorTemperature;
813         return this;
814     }
815
816     public Resource setColorXy(@Nullable ColorXy color) {
817         this.color = color;
818         return this;
819     }
820
821     public Resource setContactReport(ContactReport contactReport) {
822         this.contactReport = contactReport;
823         return this;
824     }
825
826     public Resource setDimming(@Nullable Dimming dimming) {
827         this.dimming = dimming;
828         return this;
829     }
830
831     public Resource setDynamicsDuration(Duration duration) {
832         dynamics = new Dynamics().setDuration(duration);
833         return this;
834     }
835
836     public Resource setFixedEffects(Effects effect) {
837         this.effects = effect;
838         return this;
839     }
840
841     public Resource setEnabled(Command command) {
842         if (command instanceof OnOffType) {
843             this.enabled = ((OnOffType) command) == OnOffType.ON;
844         }
845         return this;
846     }
847
848     public Resource setId(String id) {
849         this.id = id;
850         return this;
851     }
852
853     public Resource setMetadata(MetaData metadata) {
854         this.metadata = metadata;
855         return this;
856     }
857
858     public Resource setMirekSchema(@Nullable MirekSchema schema) {
859         ColorTemperature colorTemperature = this.colorTemperature;
860         if (Objects.nonNull(colorTemperature)) {
861             colorTemperature.setMirekSchema(schema);
862         }
863         return this;
864     }
865
866     /**
867      * Set the on/off JSON element (only).
868      *
869      * @param command an OnOffTypee command value.
870      * @return this resource instance.
871      */
872     public Resource setOnOff(Command command) {
873         if (command instanceof OnOffType) {
874             OnOffType onOff = (OnOffType) command;
875             OnState on = this.on;
876             on = Objects.nonNull(on) ? on : new OnState();
877             on.setOn(OnOffType.ON.equals(onOff));
878             this.on = on;
879         }
880         return this;
881     }
882
883     public Resource setOnState(@Nullable OnState on) {
884         this.on = on;
885         return this;
886     }
887
888     public Resource setRecallAction(SceneRecallAction recallAction) {
889         Recall recall = this.recall;
890         this.recall = ((Objects.nonNull(recall) ? recall : new Recall())).setAction(recallAction);
891         return this;
892     }
893
894     public Resource setRecallAction(SmartSceneRecallAction recallAction) {
895         Recall recall = this.recall;
896         this.recall = ((Objects.nonNull(recall) ? recall : new Recall())).setAction(recallAction);
897         return this;
898     }
899
900     public Resource setRecallDuration(Duration recallDuration) {
901         Recall recall = this.recall;
902         this.recall = ((Objects.nonNull(recall) ? recall : new Recall())).setDuration(recallDuration);
903         return this;
904     }
905
906     public Resource setTamperReports(List<TamperReport> tamperReports) {
907         this.tamperReports = tamperReports;
908         return this;
909     }
910
911     public Resource setTimedEffects(TimedEffects timedEffects) {
912         this.timedEffects = timedEffects;
913         return this;
914     }
915
916     public Resource setTimedEffectsDuration(Duration dynamicsDuration) {
917         TimedEffects timedEffects = this.timedEffects;
918         if (Objects.nonNull(timedEffects)) {
919             timedEffects.setDuration(dynamicsDuration);
920         }
921         return this;
922     }
923
924     public Resource setType(ResourceType resourceType) {
925         this.type = resourceType.name().toLowerCase();
926         return this;
927     }
928
929     @Override
930     public String toString() {
931         String id = this.id;
932         return String.format("id:%s, type:%s", Objects.nonNull(id) ? id : "?" + " ".repeat(35),
933                 getType().name().toLowerCase());
934     }
935 }