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