2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.persistence.dynamodb.internal;
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;
25 import javax.measure.Quantity;
26 import javax.measure.Unit;
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;
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;
70 * Base class for all DynamoDBItem. Represents openHAB Item serialized in a suitable format for the database
72 * @param <T> Type of the state as accepted by the AWS SDK.
74 * @author Sami Salonen - Initial contribution
77 public abstract class AbstractDynamoDBItem<T> implements DynamoDBItem<T> {
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");
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;
90 public static AttributeConverter<ZonedDateTime> getTimestampConverter(boolean legacy) {
91 return legacy ? ZONED_DATE_TIME_CONVERTER_STRING : ZONED_DATE_TIME_CONVERTER_MILLIEPOCH;
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)));
105 private static final Map<Class<? extends Item>, Class<? extends DynamoDBItem<?>>> ITEM_CLASS_MAP_LEGACY = new HashMap<>();
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);
121 private static final Map<Class<? extends Item>, Class<? extends DynamoDBItem<?>>> ITEM_CLASS_MAP_NEW = new HashMap<>();
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
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)
141 if (dtoclass == null) {
142 throw new IllegalArgumentException(String.format("Unknown item class %s", itemClass));
148 * Custom converter for serialization/deserialization of ZonedDateTime.
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.
153 * @author Sami Salonen - Initial contribution
156 public static final class ZonedDateTimeStringConverter implements AttributeConverter<ZonedDateTime> {
159 public AttributeValue transformFrom(ZonedDateTime time) {
160 return AttributeValue.builder().s(toString(time)).build();
164 public ZonedDateTime transformTo(@NonNullByDefault({}) AttributeValue serialized) {
165 return transformTo(serialized.s());
169 public EnhancedType<ZonedDateTime> type() {
170 return EnhancedType.<ZonedDateTime> of(ZonedDateTime.class);
174 public AttributeValueType attributeValueType() {
175 return AttributeValueType.S;
178 public String toString(ZonedDateTime time) {
179 return DATEFORMATTER.format(time.withZoneSameInstant(UTC));
182 public ZonedDateTime transformTo(String serialized) {
183 return ZonedDateTime.parse(serialized, DATEFORMATTER);
188 * Custom converter for serialization/deserialization of ZonedDateTime.
190 * Serialization: ZonedDateTime is first converted to UTC and then stored as milliepochs
192 * @author Sami Salonen - Initial contribution
195 public static final class ZonedDateTimeMilliEpochConverter implements AttributeConverter<ZonedDateTime> {
198 public AttributeValue transformFrom(ZonedDateTime time) {
199 return AttributeValue.builder().n(toEpochMilliString(time)).build();
203 public ZonedDateTime transformTo(@NonNullByDefault({}) AttributeValue serialized) {
204 return transformTo(serialized.n());
208 public EnhancedType<ZonedDateTime> type() {
209 return EnhancedType.<ZonedDateTime> of(ZonedDateTime.class);
213 public AttributeValueType attributeValueType() {
214 return AttributeValueType.N;
217 public static String toEpochMilliString(ZonedDateTime time) {
218 return String.valueOf(time.toInstant().toEpochMilli());
221 public static BigDecimal toBigDecimal(ZonedDateTime time) {
222 return new BigDecimal(toEpochMilliString(time));
225 public ZonedDateTime transformTo(String serialized) {
226 return transformTo(Long.valueOf(serialized));
229 public ZonedDateTime transformTo(Long epochMillis) {
230 return Instant.ofEpochMilli(epochMillis).atZone(UTC);
234 private final Logger logger = LoggerFactory.getLogger(AbstractDynamoDBItem.class);
236 protected String name;
237 protected @Nullable T state;
238 protected ZonedDateTime time;
239 private @Nullable Integer expireDays;
240 private @Nullable Long expiry;
242 public AbstractDynamoDBItem(String name, @Nullable T state, ZonedDateTime time, @Nullable Integer expireDays) {
246 if (expireDays != null && expireDays <= 0) {
247 throw new IllegalArgumentException();
249 this.expireDays = expireDays;
250 this.expiry = expireDays == null ? null : time.toInstant().plus(Duration.ofDays(expireDays)).getEpochSecond();
254 * Convert given state to target state.
256 * If conversion fails, IllegalStateException is raised.
257 * Use this method you do not expect conversion to fail.
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
265 private static <T extends State> T convert(State state, Class<T> clz) {
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()));
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);
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);
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,
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) {
308 return new DynamoDBBigDecimalItem(name, PLAY_BIGDECIMAL, time, expireDays);
310 return new DynamoDBBigDecimalItem(name, PAUSE_BIGDECIMAL, time, expireDays);
312 throw new IllegalArgumentException("Unexpected enum with PlayPauseType: " + state.toString());
314 } else if (state instanceof RewindFastforwardType rewindType) {
315 switch (rewindType) {
317 return new DynamoDBBigDecimalItem(name, FAST_FORWARD_BIGDECIMAL, time, expireDays);
319 return new DynamoDBBigDecimalItem(name, REWIND_BIGDECIMAL, time, expireDays);
321 throw new IllegalArgumentException(
322 "Unexpected enum with RewindFastforwardType: " + state.toString());
325 throw new IllegalStateException(
326 String.format("Unexpected state type %s with PlayerItem", state.getClass().getSimpleName()));
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);
338 throw new IllegalStateException(
339 String.format("Unexpected state type %s with StringItem", state.getClass().getSimpleName()));
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);
350 throw new IllegalArgumentException("Unsupported item " + item.getClass().getSimpleName());
355 public @Nullable HistoricItem asHistoricItem(final Item item) {
356 return asHistoricItem(item, null);
360 public @Nullable HistoricItem asHistoricItem(final Item item, @Nullable Unit<?> targetUnit) {
361 final State deserializedState;
362 if (this.getState() == null) {
366 deserializedState = accept(new DynamoDBItemVisitor<@Nullable State>() {
369 public @Nullable State visit(DynamoDBStringItem dynamoStringItem) {
370 String stringState = dynamoStringItem.getState();
371 if (stringState == null) {
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
381 return PlayPauseType.valueOf(stringState);
382 } catch (IllegalArgumentException e) {
383 return RewindFastforwardType.valueOf(stringState);
385 } else if (item instanceof DateTimeItem) {
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;
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);
403 return new StringType(dynamoStringItem.getState());
408 public @Nullable State visit(DynamoDBBigDecimalItem dynamoBigDecimalItem) {
409 BigDecimal numberState = dynamoBigDecimalItem.getState();
410 if (numberState == null) {
413 if (item instanceof NumberItem numberItem) {
414 Unit<? extends Quantity<?>> unit = targetUnit == null ? numberItem.getUnit() : targetUnit;
416 return new QuantityType<>(numberState, unit);
418 return new DecimalType(numberState);
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;
441 throw new IllegalArgumentException("Unknown serialized value");
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());
451 if (deserializedState == null) {
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(),
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).
471 * @see org.openhab.persistence.dynamodb.internal.DynamoItem#accept(org.openhab.persistence.dynamodb.internal.
475 public abstract <R> R accept(DynamoDBItemVisitor<R> visitor);
478 public String toString() {
480 T localState = state;
481 return DATEFORMATTER.format(time) + ": " + name + " -> "
482 + (localState == null ? "<null>" : localState.toString());
486 public String getName() {
491 public void setName(String name) {
496 public ZonedDateTime getTime() {
502 public Long getExpiryDate() {
507 public void setTime(ZonedDateTime time) {
512 public @Nullable Integer getExpireDays() {
517 public void setExpireDays(@Nullable Integer expireDays) {
518 this.expireDays = expireDays;
521 public void setExpiry(@Nullable Long expiry) {
522 this.expiry = expiry;