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