]> git.basschouten.com Git - openhab-addons.git/blob
0ee3d2bc692a0328d8b5746b719f64b2a6abfde2
[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.persistence.dynamodb.internal;
14
15 import java.math.BigDecimal;
16 import java.time.Duration;
17 import java.time.Instant;
18 import java.time.ZoneId;
19 import java.time.ZonedDateTime;
20 import java.time.format.DateTimeFormatter;
21 import java.time.format.DateTimeParseException;
22 import java.util.HashMap;
23 import java.util.Map;
24
25 import javax.measure.Quantity;
26 import javax.measure.Unit;
27
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.openhab.core.items.Item;
31 import org.openhab.core.library.items.CallItem;
32 import org.openhab.core.library.items.ColorItem;
33 import org.openhab.core.library.items.ContactItem;
34 import org.openhab.core.library.items.DateTimeItem;
35 import org.openhab.core.library.items.DimmerItem;
36 import org.openhab.core.library.items.ImageItem;
37 import org.openhab.core.library.items.LocationItem;
38 import org.openhab.core.library.items.NumberItem;
39 import org.openhab.core.library.items.PlayerItem;
40 import org.openhab.core.library.items.RollershutterItem;
41 import org.openhab.core.library.items.StringItem;
42 import org.openhab.core.library.items.SwitchItem;
43 import org.openhab.core.library.types.DateTimeType;
44 import org.openhab.core.library.types.DecimalType;
45 import org.openhab.core.library.types.HSBType;
46 import org.openhab.core.library.types.OnOffType;
47 import org.openhab.core.library.types.OpenClosedType;
48 import org.openhab.core.library.types.PercentType;
49 import org.openhab.core.library.types.PlayPauseType;
50 import org.openhab.core.library.types.PointType;
51 import org.openhab.core.library.types.QuantityType;
52 import org.openhab.core.library.types.RewindFastforwardType;
53 import org.openhab.core.library.types.StringListType;
54 import org.openhab.core.library.types.StringType;
55 import org.openhab.core.persistence.HistoricItem;
56 import org.openhab.core.types.State;
57 import org.openhab.core.types.UnDefType;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60
61 import software.amazon.awssdk.enhanced.dynamodb.AttributeConverter;
62 import software.amazon.awssdk.enhanced.dynamodb.AttributeValueType;
63 import software.amazon.awssdk.enhanced.dynamodb.EnhancedType;
64 import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
65 import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticAttributeTags;
66 import software.amazon.awssdk.enhanced.dynamodb.mapper.StaticTableSchema.Builder;
67 import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
68
69 /**
70  * Base class for all DynamoDBItem. Represents openHAB Item serialized in a suitable format for the database
71  *
72  * @param <T> Type of the state as accepted by the AWS SDK.
73  *
74  * @author Sami Salonen - Initial contribution
75  */
76 @NonNullByDefault
77 public abstract class AbstractDynamoDBItem<T> implements DynamoDBItem<T> {
78
79     private static final BigDecimal REWIND_BIGDECIMAL = new BigDecimal("-1");
80     private static final BigDecimal PAUSE_BIGDECIMAL = new BigDecimal("0");
81     private static final BigDecimal PLAY_BIGDECIMAL = new BigDecimal("1");
82     private static final BigDecimal FAST_FORWARD_BIGDECIMAL = new BigDecimal("2");
83
84     private static final ZoneId UTC = ZoneId.of("UTC");
85     public static final ZonedDateTimeStringConverter ZONED_DATE_TIME_CONVERTER_STRING = new ZonedDateTimeStringConverter();
86     public static final ZonedDateTimeMilliEpochConverter ZONED_DATE_TIME_CONVERTER_MILLIEPOCH = new ZonedDateTimeMilliEpochConverter();
87     public static final DateTimeFormatter DATEFORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT).withZone(UTC);
88     protected static final Class<@Nullable Long> NULLABLE_LONG = (Class<@Nullable Long>) Long.class;
89
90     public static AttributeConverter<ZonedDateTime> getTimestampConverter(boolean legacy) {
91         return legacy ? ZONED_DATE_TIME_CONVERTER_STRING : ZONED_DATE_TIME_CONVERTER_MILLIEPOCH;
92     }
93
94     protected static <C extends AbstractDynamoDBItem<?>> Builder<C> getBaseSchemaBuilder(Class<C> clz, boolean legacy) {
95         return TableSchema.builder(clz).addAttribute(String.class,
96                 a -> a.name(legacy ? DynamoDBItem.ATTRIBUTE_NAME_ITEMNAME_LEGACY : DynamoDBItem.ATTRIBUTE_NAME_ITEMNAME)
97                         .getter(AbstractDynamoDBItem::getName).setter(AbstractDynamoDBItem::setName)
98                         .tags(StaticAttributeTags.primaryPartitionKey()))
99                 .addAttribute(ZonedDateTime.class, a -> a
100                         .name(legacy ? DynamoDBItem.ATTRIBUTE_NAME_TIMEUTC_LEGACY : DynamoDBItem.ATTRIBUTE_NAME_TIMEUTC)
101                         .getter(AbstractDynamoDBItem::getTime).setter(AbstractDynamoDBItem::setTime)
102                         .tags(StaticAttributeTags.primarySortKey()).attributeConverter(getTimestampConverter(legacy)));
103     }
104
105     private static final Map<Class<? extends Item>, Class<? extends DynamoDBItem<?>>> ITEM_CLASS_MAP_LEGACY = new HashMap<>();
106
107     static {
108         ITEM_CLASS_MAP_LEGACY.put(CallItem.class, DynamoDBStringItem.class);
109         ITEM_CLASS_MAP_LEGACY.put(ContactItem.class, DynamoDBBigDecimalItem.class);
110         ITEM_CLASS_MAP_LEGACY.put(DateTimeItem.class, DynamoDBStringItem.class);
111         ITEM_CLASS_MAP_LEGACY.put(LocationItem.class, DynamoDBStringItem.class);
112         ITEM_CLASS_MAP_LEGACY.put(NumberItem.class, DynamoDBBigDecimalItem.class);
113         ITEM_CLASS_MAP_LEGACY.put(RollershutterItem.class, DynamoDBBigDecimalItem.class);
114         ITEM_CLASS_MAP_LEGACY.put(StringItem.class, DynamoDBStringItem.class);
115         ITEM_CLASS_MAP_LEGACY.put(SwitchItem.class, DynamoDBBigDecimalItem.class);
116         ITEM_CLASS_MAP_LEGACY.put(DimmerItem.class, DynamoDBBigDecimalItem.class);
117         ITEM_CLASS_MAP_LEGACY.put(ColorItem.class, DynamoDBStringItem.class);
118         ITEM_CLASS_MAP_LEGACY.put(PlayerItem.class, DynamoDBStringItem.class);
119     }
120
121     private static final Map<Class<? extends Item>, Class<? extends DynamoDBItem<?>>> ITEM_CLASS_MAP_NEW = new HashMap<>();
122
123     static {
124         ITEM_CLASS_MAP_NEW.put(CallItem.class, DynamoDBStringItem.class);
125         ITEM_CLASS_MAP_NEW.put(ContactItem.class, DynamoDBBigDecimalItem.class);
126         ITEM_CLASS_MAP_NEW.put(DateTimeItem.class, DynamoDBStringItem.class);
127         ITEM_CLASS_MAP_NEW.put(LocationItem.class, DynamoDBStringItem.class);
128         ITEM_CLASS_MAP_NEW.put(NumberItem.class, DynamoDBBigDecimalItem.class);
129         ITEM_CLASS_MAP_NEW.put(RollershutterItem.class, DynamoDBBigDecimalItem.class);
130         ITEM_CLASS_MAP_NEW.put(StringItem.class, DynamoDBStringItem.class);
131         ITEM_CLASS_MAP_NEW.put(SwitchItem.class, DynamoDBBigDecimalItem.class);
132         ITEM_CLASS_MAP_NEW.put(DimmerItem.class, DynamoDBBigDecimalItem.class);
133         ITEM_CLASS_MAP_NEW.put(ColorItem.class, DynamoDBStringItem.class);
134         ITEM_CLASS_MAP_NEW.put(PlayerItem.class, DynamoDBBigDecimalItem.class); // Different from LEGACY
135     }
136
137     public static Class<? extends DynamoDBItem<?>> getDynamoItemClass(Class<? extends Item> itemClass, boolean legacy)
138             throws NullPointerException {
139         Class<? extends DynamoDBItem<?>> dtoclass = (legacy ? ITEM_CLASS_MAP_LEGACY : ITEM_CLASS_MAP_NEW)
140                 .get(itemClass);
141         if (dtoclass == null) {
142             throw new IllegalArgumentException(String.format("Unknown item class %s", itemClass));
143         }
144         return dtoclass;
145     }
146
147     /**
148      * Custom converter for serialization/deserialization of ZonedDateTime.
149      *
150      * Serialization: ZonedDateTime is first converted to UTC and then stored with format yyyy-MM-dd'T'HH:mm:ss.SSS'Z'
151      * This allows easy sorting of values since all timestamps are in UTC and string ordering can be used.
152      *
153      * @author Sami Salonen - Initial contribution
154      *
155      */
156     public static final class ZonedDateTimeStringConverter implements AttributeConverter<ZonedDateTime> {
157
158         @Override
159         public AttributeValue transformFrom(ZonedDateTime time) {
160             return AttributeValue.builder().s(toString(time)).build();
161         }
162
163         @Override
164         public ZonedDateTime transformTo(@NonNullByDefault({}) AttributeValue serialized) {
165             return transformTo(serialized.s());
166         }
167
168         @Override
169         public EnhancedType<ZonedDateTime> type() {
170             return EnhancedType.<ZonedDateTime> of(ZonedDateTime.class);
171         }
172
173         @Override
174         public AttributeValueType attributeValueType() {
175             return AttributeValueType.S;
176         }
177
178         public String toString(ZonedDateTime time) {
179             return DATEFORMATTER.format(time.withZoneSameInstant(UTC));
180         }
181
182         public ZonedDateTime transformTo(String serialized) {
183             return ZonedDateTime.parse(serialized, DATEFORMATTER);
184         }
185     }
186
187     /**
188      * Custom converter for serialization/deserialization of ZonedDateTime.
189      *
190      * Serialization: ZonedDateTime is first converted to UTC and then stored as milliepochs
191      *
192      * @author Sami Salonen - Initial contribution
193      *
194      */
195     public static final class ZonedDateTimeMilliEpochConverter implements AttributeConverter<ZonedDateTime> {
196
197         @Override
198         public AttributeValue transformFrom(ZonedDateTime time) {
199             return AttributeValue.builder().n(toEpochMilliString(time)).build();
200         }
201
202         @Override
203         public ZonedDateTime transformTo(@NonNullByDefault({}) AttributeValue serialized) {
204             return transformTo(serialized.n());
205         }
206
207         @Override
208         public EnhancedType<ZonedDateTime> type() {
209             return EnhancedType.<ZonedDateTime> of(ZonedDateTime.class);
210         }
211
212         @Override
213         public AttributeValueType attributeValueType() {
214             return AttributeValueType.N;
215         }
216
217         public static String toEpochMilliString(ZonedDateTime time) {
218             return String.valueOf(time.toInstant().toEpochMilli());
219         }
220
221         public static BigDecimal toBigDecimal(ZonedDateTime time) {
222             return new BigDecimal(toEpochMilliString(time));
223         }
224
225         public ZonedDateTime transformTo(String serialized) {
226             return transformTo(Long.valueOf(serialized));
227         }
228
229         public ZonedDateTime transformTo(Long epochMillis) {
230             return Instant.ofEpochMilli(epochMillis).atZone(UTC);
231         }
232     }
233
234     private final Logger logger = LoggerFactory.getLogger(AbstractDynamoDBItem.class);
235
236     protected String name;
237     protected @Nullable T state;
238     protected ZonedDateTime time;
239     private @Nullable Integer expireDays;
240     private @Nullable Long expiry;
241
242     public AbstractDynamoDBItem(String name, @Nullable T state, ZonedDateTime time, @Nullable Integer expireDays) {
243         this.name = name;
244         this.state = state;
245         this.time = time;
246         if (expireDays != null && expireDays <= 0) {
247             throw new IllegalArgumentException();
248         }
249         this.expireDays = expireDays;
250         this.expiry = expireDays == null ? null : time.toInstant().plus(Duration.ofDays(expireDays)).getEpochSecond();
251     }
252
253     /**
254      * Convert given state to target state.
255      *
256      * If conversion fails, IllegalStateException is raised.
257      * Use this method you do not expect conversion to fail.
258      *
259      * @param <T> state type to convert to
260      * @param state state to convert
261      * @param clz class of the resulting state
262      * @return state as type T
263      * @throws IllegalStateException on failing conversion
264      */
265     private static <T extends State> T convert(State state, Class<T> clz) {
266         @Nullable
267         T converted = state.as(clz);
268         if (converted == null) {
269             throw new IllegalStateException(String.format("Could not convert %s '%s' into %s",
270                     state.getClass().getSimpleName(), state, clz.getClass().getSimpleName()));
271         }
272         return converted;
273     }
274
275     public static DynamoDBItem<?> fromStateLegacy(Item item, ZonedDateTime time) {
276         String name = item.getName();
277         State state = item.getState();
278         if (item instanceof PlayerItem) {
279             return new DynamoDBStringItem(name, state.toFullString(), time, null);
280         } else {
281             // Apart from PlayerItem, the values are serialized to dynamodb number/strings in the same way in legacy
282             // delegate to fromStateNew
283             return fromStateNew(item, time, null);
284         }
285     }
286
287     public static DynamoDBItem<?> fromStateNew(Item item, ZonedDateTime time, @Nullable Integer expireDays) {
288         String name = item.getName();
289         State state = item.getState();
290         if (item instanceof CallItem) {
291             return new DynamoDBStringItem(name, convert(state, StringListType.class).toFullString(), time, expireDays);
292         } else if (item instanceof ContactItem) {
293             return new DynamoDBBigDecimalItem(name, convert(state, DecimalType.class).toBigDecimal(), time, expireDays);
294         } else if (item instanceof DateTimeItem) {
295             return new DynamoDBStringItem(name,
296                     ZONED_DATE_TIME_CONVERTER_STRING.toString(((DateTimeType) state).getZonedDateTime()), time,
297                     expireDays);
298         } else if (item instanceof ImageItem) {
299             throw new IllegalArgumentException("Unsupported item " + item.getClass().getSimpleName());
300         } else if (item instanceof LocationItem) {
301             return new DynamoDBStringItem(name, state.toFullString(), time, expireDays);
302         } else if (item instanceof NumberItem) {
303             return new DynamoDBBigDecimalItem(name, convert(state, DecimalType.class).toBigDecimal(), time, expireDays);
304         } else if (item instanceof PlayerItem) {
305             if (state instanceof PlayPauseType pauseType) {
306                 switch (pauseType) {
307                     case PLAY:
308                         return new DynamoDBBigDecimalItem(name, PLAY_BIGDECIMAL, time, expireDays);
309                     case PAUSE:
310                         return new DynamoDBBigDecimalItem(name, PAUSE_BIGDECIMAL, time, expireDays);
311                     default:
312                         throw new IllegalArgumentException("Unexpected enum with PlayPauseType: " + state.toString());
313                 }
314             } else if (state instanceof RewindFastforwardType rewindType) {
315                 switch (rewindType) {
316                     case FASTFORWARD:
317                         return new DynamoDBBigDecimalItem(name, FAST_FORWARD_BIGDECIMAL, time, expireDays);
318                     case REWIND:
319                         return new DynamoDBBigDecimalItem(name, REWIND_BIGDECIMAL, time, expireDays);
320                     default:
321                         throw new IllegalArgumentException(
322                                 "Unexpected enum with RewindFastforwardType: " + state.toString());
323                 }
324             } else {
325                 throw new IllegalStateException(
326                         String.format("Unexpected state type %s with PlayerItem", state.getClass().getSimpleName()));
327             }
328         } else if (item instanceof RollershutterItem) {
329             // Normalize UP/DOWN to %
330             return new DynamoDBBigDecimalItem(name, convert(state, PercentType.class).toBigDecimal(), time, expireDays);
331         } else if (item instanceof StringItem) {
332             if (state instanceof StringType stringType) {
333                 return new DynamoDBStringItem(name, stringType.toString(), time, expireDays);
334             } else if (state instanceof DateTimeType dateType) {
335                 return new DynamoDBStringItem(name,
336                         ZONED_DATE_TIME_CONVERTER_STRING.toString(dateType.getZonedDateTime()), time, expireDays);
337             } else {
338                 throw new IllegalStateException(
339                         String.format("Unexpected state type %s with StringItem", state.getClass().getSimpleName()));
340             }
341         } else if (item instanceof ColorItem) { // Note: needs to be before parent class DimmerItem
342             return new DynamoDBStringItem(name, convert(state, HSBType.class).toFullString(), time, expireDays);
343         } else if (item instanceof DimmerItem) {// Note: needs to be before parent class SwitchItem
344             // Normalize ON/OFF to %
345             return new DynamoDBBigDecimalItem(name, convert(state, PercentType.class).toBigDecimal(), time, expireDays);
346         } else if (item instanceof SwitchItem) {
347             // Normalize ON/OFF to 1/0
348             return new DynamoDBBigDecimalItem(name, convert(state, DecimalType.class).toBigDecimal(), time, expireDays);
349         } else {
350             throw new IllegalArgumentException("Unsupported item " + item.getClass().getSimpleName());
351         }
352     }
353
354     @Override
355     public @Nullable HistoricItem asHistoricItem(final Item item) {
356         return asHistoricItem(item, null);
357     }
358
359     @Override
360     public @Nullable HistoricItem asHistoricItem(final Item item, @Nullable Unit<?> targetUnit) {
361         final State deserializedState;
362         if (this.getState() == null) {
363             return null;
364         }
365         try {
366             deserializedState = accept(new DynamoDBItemVisitor<@Nullable State>() {
367
368                 @Override
369                 public @Nullable State visit(DynamoDBStringItem dynamoStringItem) {
370                     String stringState = dynamoStringItem.getState();
371                     if (stringState == null) {
372                         return null;
373                     }
374                     if (item instanceof ColorItem) {
375                         return new HSBType(stringState);
376                     } else if (item instanceof LocationItem) {
377                         return new PointType(stringState);
378                     } else if (item instanceof PlayerItem) {
379                         // Backwards-compatibility with legacy schema. New schema uses DynamoDBBigDecimalItem
380                         try {
381                             return PlayPauseType.valueOf(stringState);
382                         } catch (IllegalArgumentException e) {
383                             return RewindFastforwardType.valueOf(stringState);
384                         }
385                     } else if (item instanceof DateTimeItem) {
386                         try {
387                             // Parse ZoneDateTime from string. DATEFORMATTER assumes UTC in case it is not clear
388                             // from the string (should be).
389                             // We convert to default/local timezone for user convenience (e.g. display)
390                             return new DateTimeType(ZONED_DATE_TIME_CONVERTER_STRING.transformTo(stringState)
391                                     .withZoneSameInstant(ZoneId.systemDefault()));
392                         } catch (DateTimeParseException e) {
393                             logger.warn("Failed to parse {} as date. Outputting UNDEF instead", stringState);
394                             return UnDefType.UNDEF;
395                         }
396                     } else if (item instanceof CallItem) {
397                         String parts = stringState;
398                         String[] strings = parts.split(",");
399                         String orig = strings[0];
400                         String dest = strings[1];
401                         return new StringListType(orig, dest);
402                     } else {
403                         return new StringType(dynamoStringItem.getState());
404                     }
405                 }
406
407                 @Override
408                 public @Nullable State visit(DynamoDBBigDecimalItem dynamoBigDecimalItem) {
409                     BigDecimal numberState = dynamoBigDecimalItem.getState();
410                     if (numberState == null) {
411                         return null;
412                     }
413                     if (item instanceof NumberItem numberItem) {
414                         Unit<? extends Quantity<?>> unit = targetUnit == null ? numberItem.getUnit() : targetUnit;
415                         if (unit != null) {
416                             return new QuantityType<>(numberState, unit);
417                         } else {
418                             return new DecimalType(numberState);
419                         }
420                     } else if (item instanceof DimmerItem) {
421                         // % values have been stored as-is
422                         return new PercentType(numberState);
423                     } else if (item instanceof SwitchItem) {
424                         return OnOffType.from(numberState.compareTo(BigDecimal.ZERO) != 0);
425                     } else if (item instanceof ContactItem) {
426                         return numberState.compareTo(BigDecimal.ZERO) != 0 ? OpenClosedType.OPEN
427                                 : OpenClosedType.CLOSED;
428                     } else if (item instanceof RollershutterItem) {
429                         // Percents and UP/DOWN have been stored % values (not fractional)
430                         return new PercentType(numberState);
431                     } else if (item instanceof PlayerItem) {
432                         if (numberState.equals(PLAY_BIGDECIMAL)) {
433                             return PlayPauseType.PLAY;
434                         } else if (numberState.equals(PAUSE_BIGDECIMAL)) {
435                             return PlayPauseType.PAUSE;
436                         } else if (numberState.equals(FAST_FORWARD_BIGDECIMAL)) {
437                             return RewindFastforwardType.FASTFORWARD;
438                         } else if (numberState.equals(REWIND_BIGDECIMAL)) {
439                             return RewindFastforwardType.REWIND;
440                         } else {
441                             throw new IllegalArgumentException("Unknown serialized value");
442                         }
443                     } else {
444                         logger.warn(
445                                 "Not sure how to convert big decimal item {} to type {}. Using StringType as fallback",
446                                 dynamoBigDecimalItem.getName(), item.getClass());
447                         return new StringType(numberState.toString());
448                     }
449                 }
450             });
451             if (deserializedState == null) {
452                 return null;
453             }
454             return new DynamoDBHistoricItem(getName(), deserializedState, getTime());
455         } catch (Exception e) {
456             logger.trace("Failed to convert state '{}' to item {} {}: {} {}. Data persisted with incompatible item.",
457                     this.state, item.getClass().getSimpleName(), item.getName(), e.getClass().getSimpleName(),
458                     e.getMessage());
459             return null;
460         }
461     }
462
463     /**
464      * We define all getter and setters in the child class implement those. Having the getter
465      * and setter implementations here in the parent class does not work with introspection done by AWS SDK (1.11.56).
466      */
467
468     /*
469      * (non-Javadoc)
470      *
471      * @see org.openhab.persistence.dynamodb.internal.DynamoItem#accept(org.openhab.persistence.dynamodb.internal.
472      * DynamoItemVisitor)
473      */
474     @Override
475     public abstract <R> R accept(DynamoDBItemVisitor<R> visitor);
476
477     @Override
478     public String toString() {
479         @Nullable
480         T localState = state;
481         return DATEFORMATTER.format(time) + ": " + name + " -> "
482                 + (localState == null ? "<null>" : localState.toString());
483     }
484
485     @Override
486     public String getName() {
487         return name;
488     }
489
490     @Override
491     public void setName(String name) {
492         this.name = name;
493     }
494
495     @Override
496     public ZonedDateTime getTime() {
497         return time;
498     }
499
500     @Override
501     @Nullable
502     public Long getExpiryDate() {
503         return expiry;
504     }
505
506     @Override
507     public void setTime(ZonedDateTime time) {
508         this.time = time;
509     }
510
511     @Override
512     public @Nullable Integer getExpireDays() {
513         return expireDays;
514     }
515
516     @Override
517     public void setExpireDays(@Nullable Integer expireDays) {
518         this.expireDays = expireDays;
519     }
520
521     public void setExpiry(@Nullable Long expiry) {
522         this.expiry = expiry;
523     }
524 }