diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 54802e935..e19070fd2 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1961,6 +1961,11 @@
org.openhab.persistence.influxdb
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.persistence.inmemory
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.persistence.jdbc
diff --git a/bundles/org.openhab.persistence.inmemory/NOTICE b/bundles/org.openhab.persistence.inmemory/NOTICE
new file mode 100644
index 000000000..38d625e34
--- /dev/null
+++ b/bundles/org.openhab.persistence.inmemory/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.persistence.inmemory/README.md b/bundles/org.openhab.persistence.inmemory/README.md
new file mode 100644
index 000000000..f24ab44c8
--- /dev/null
+++ b/bundles/org.openhab.persistence.inmemory/README.md
@@ -0,0 +1,14 @@
+# InMemory Persistence
+
+The InMemory persistence service provides a volatile storage, i.e. it is cleared on shutdown.
+Because of that the `restoreOnStartup` strategy is not supported for this service.
+
+The main use-case is to store data that is needed during runtime, e.g. temporary storage of forecast data that is retrieved from a binding.
+
+Since all data is stored in memory only, there is no default strategy for this service.
+Unlike other persistence services, you MUST add a configuration, otherwise no data will be persisted.
+To avoid excessive memory usage, it is recommended to persist only a limited number of items and use a strategy that stores only data that is actually needed.
+
+The service has a global configuration option `maxEntries` to limit the number of datapoints per item, the default value is `512`.
+When the number of datapoints is reached and a new value is persisted, the oldest (by timestamp) value will be removed.
+A `maxEntries` value of `0` disables automatic purging.
diff --git a/bundles/org.openhab.persistence.inmemory/pom.xml b/bundles/org.openhab.persistence.inmemory/pom.xml
new file mode 100644
index 000000000..3df08eae2
--- /dev/null
+++ b/bundles/org.openhab.persistence.inmemory/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.0.0-SNAPSHOT
+
+
+ org.openhab.persistence.inmemory
+
+ openHAB Add-ons :: Bundles :: Persistence Service :: InMemory
+
+
diff --git a/bundles/org.openhab.persistence.inmemory/src/main/feature/feature.xml b/bundles/org.openhab.persistence.inmemory/src/main/feature/feature.xml
new file mode 100644
index 000000000..6ec74479c
--- /dev/null
+++ b/bundles/org.openhab.persistence.inmemory/src/main/feature/feature.xml
@@ -0,0 +1,10 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.persistence.inmemory/${project.version}
+
+
+
diff --git a/bundles/org.openhab.persistence.inmemory/src/main/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceService.java b/bundles/org.openhab.persistence.inmemory/src/main/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceService.java
new file mode 100644
index 000000000..cffb2ce41
--- /dev/null
+++ b/bundles/org.openhab.persistence.inmemory/src/main/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceService.java
@@ -0,0 +1,311 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.inmemory.internal;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.config.core.ConfigParser;
+import org.openhab.core.config.core.ConfigurableService;
+import org.openhab.core.items.Item;
+import org.openhab.core.persistence.FilterCriteria;
+import org.openhab.core.persistence.HistoricItem;
+import org.openhab.core.persistence.ModifiablePersistenceService;
+import org.openhab.core.persistence.PersistenceItemInfo;
+import org.openhab.core.persistence.PersistenceService;
+import org.openhab.core.persistence.strategy.PersistenceStrategy;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.osgi.framework.Constants;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Modified;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is the implementation of the volatile {@link PersistenceService}.
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = { PersistenceService.class,
+ ModifiablePersistenceService.class }, configurationPid = "org.openhab.inmemory", //
+ property = Constants.SERVICE_PID + "=org.openhab.inmemory")
+@ConfigurableService(category = "persistence", label = "InMemory Persistence Service", description_uri = InMemoryPersistenceService.CONFIG_URI)
+public class InMemoryPersistenceService implements ModifiablePersistenceService {
+
+ private static final String SERVICE_ID = "inmemory";
+ private static final String SERVICE_LABEL = "In Memory";
+
+ protected static final String CONFIG_URI = "persistence:inmemory";
+ private final String MAX_ENTRIES_CONFIG = "maxEntries";
+ private final long MAX_ENTRIES_DEFAULT = 512;
+
+ private final Logger logger = LoggerFactory.getLogger(InMemoryPersistenceService.class);
+
+ private final Map persistMap = new ConcurrentHashMap<>();
+ private long maxEntries = MAX_ENTRIES_DEFAULT;
+
+ @Activate
+ public void activate(Map config) {
+ modified(config);
+ logger.debug("InMemory persistence service is now activated.");
+ }
+
+ @Modified
+ public void modified(Map config) {
+ maxEntries = ConfigParser.valueAsOrElse(config.get(MAX_ENTRIES_CONFIG), Long.class, MAX_ENTRIES_DEFAULT);
+
+ persistMap.values().forEach(persistItem -> {
+ Lock lock = persistItem.lock();
+ lock.lock();
+ try {
+ while (persistItem.database().size() > maxEntries) {
+ persistItem.database().pollFirst();
+ }
+ } finally {
+ lock.unlock();
+ }
+ });
+ }
+
+ @Deactivate
+ public void deactivate() {
+ logger.debug("InMemory persistence service deactivated.");
+ }
+
+ @Override
+ public String getId() {
+ return SERVICE_ID;
+ }
+
+ @Override
+ public String getLabel(@Nullable Locale locale) {
+ return SERVICE_LABEL;
+ }
+
+ @Override
+ public Set getItemInfo() {
+ return persistMap.entrySet().stream().map(this::toItemInfo).collect(Collectors.toSet());
+ }
+
+ @Override
+ public void store(Item item) {
+ internalStore(item.getName(), ZonedDateTime.now(), item.getState());
+ }
+
+ @Override
+ public void store(Item item, @Nullable String alias) {
+ String finalName = Objects.requireNonNullElse(alias, item.getName());
+ internalStore(finalName, ZonedDateTime.now(), item.getState());
+ }
+
+ @Override
+ public void store(Item item, ZonedDateTime date, State state) {
+ internalStore(item.getName(), date, state);
+ }
+
+ @Override
+ public boolean remove(FilterCriteria filter) throws IllegalArgumentException {
+ String itemName = filter.getItemName();
+ if (itemName == null) {
+ return false;
+ }
+
+ PersistItem persistItem = persistMap.get(itemName);
+ if (persistItem == null) {
+ return false;
+ }
+
+ Lock lock = persistItem.lock();
+ lock.lock();
+ try {
+ List toRemove = persistItem.database().stream().filter(e -> applies(e, filter)).toList();
+ toRemove.forEach(persistItem.database()::remove);
+ } finally {
+ lock.unlock();
+ }
+ return true;
+ }
+
+ @Override
+ public Iterable query(FilterCriteria filter) {
+ String itemName = filter.getItemName();
+ if (itemName == null) {
+ return List.of();
+ }
+
+ PersistItem persistItem = persistMap.get(itemName);
+ if (persistItem == null) {
+ return List.of();
+ }
+
+ Lock lock = persistItem.lock();
+ lock.lock();
+ try {
+ return persistItem.database().stream().filter(e -> applies(e, filter)).map(e -> toHistoricItem(itemName, e))
+ .toList();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public List getDefaultStrategies() {
+ // persist nothing by default
+ return List.of();
+ }
+
+ private PersistenceItemInfo toItemInfo(Map.Entry itemEntry) {
+ Lock lock = itemEntry.getValue().lock();
+ lock.lock();
+ try {
+ String name = itemEntry.getKey();
+ Integer count = itemEntry.getValue().database().size();
+ Instant earliest = itemEntry.getValue().database().first().timestamp().toInstant();
+ Instant latest = itemEntry.getValue().database.last().timestamp.toInstant();
+ return new PersistenceItemInfo() {
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public @Nullable Integer getCount() {
+ return count;
+ }
+
+ @Override
+ public @Nullable Date getEarliest() {
+ return Date.from(earliest);
+ }
+
+ @Override
+ public @Nullable Date getLatest() {
+ return Date.from(latest);
+ }
+ };
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private HistoricItem toHistoricItem(String itemName, PersistEntry entry) {
+ return new HistoricItem() {
+ @Override
+ public ZonedDateTime getTimestamp() {
+ return entry.timestamp();
+ }
+
+ @Override
+ public State getState() {
+ return entry.state();
+ }
+
+ @Override
+ public String getName() {
+ return itemName;
+ }
+ };
+ }
+
+ private void internalStore(String itemName, ZonedDateTime timestamp, State state) {
+ if (state instanceof UnDefType) {
+ return;
+ }
+
+ PersistItem persistItem = Objects.requireNonNull(persistMap.computeIfAbsent(itemName,
+ k -> new PersistItem(new TreeSet<>(Comparator.comparing(PersistEntry::timestamp)),
+ new ReentrantLock())));
+
+ Lock lock = persistItem.lock();
+ lock.lock();
+ try {
+ persistItem.database().add(new PersistEntry(timestamp, state));
+
+ while (persistItem.database.size() > maxEntries) {
+ persistItem.database().pollFirst();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @SuppressWarnings({ "rawType", "unchecked" })
+ private boolean applies(PersistEntry entry, FilterCriteria filter) {
+ ZonedDateTime beginDate = filter.getBeginDate();
+ if (beginDate != null && entry.timestamp().isBefore(beginDate)) {
+ return false;
+ }
+ ZonedDateTime endDate = filter.getEndDate();
+ if (endDate != null && entry.timestamp().isAfter(endDate)) {
+ return false;
+ }
+
+ State refState = filter.getState();
+ FilterCriteria.Operator operator = filter.getOperator();
+ if (refState == null) {
+ // no state filter
+ return true;
+ }
+
+ if (operator == FilterCriteria.Operator.EQ) {
+ return entry.state().equals(refState);
+ }
+
+ if (operator == FilterCriteria.Operator.NEQ) {
+ return !entry.state().equals(refState);
+ }
+
+ if (entry.state() instanceof Comparable comparableState && entry.state.getClass().equals(refState.getClass())) {
+ if (operator == FilterCriteria.Operator.GT) {
+ return comparableState.compareTo(refState) > 0;
+ }
+ if (operator == FilterCriteria.Operator.GTE) {
+ return comparableState.compareTo(refState) >= 0;
+ }
+ if (operator == FilterCriteria.Operator.LT) {
+ return comparableState.compareTo(refState) < 0;
+ }
+ if (operator == FilterCriteria.Operator.LTE) {
+ return comparableState.compareTo(refState) <= 0;
+ }
+ } else {
+ logger.warn("Using operator {} but state {} is not comparable!", operator, refState);
+ }
+ return true;
+ }
+
+ private record PersistEntry(ZonedDateTime timestamp, State state) {
+ };
+
+ private record PersistItem(TreeSet database, Lock lock) {
+ };
+}
diff --git a/bundles/org.openhab.persistence.inmemory/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.persistence.inmemory/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 000000000..a87035935
--- /dev/null
+++ b/bundles/org.openhab.persistence.inmemory/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,21 @@
+
+
+
+ persistence
+ InMemory Persistence
+ A volatile persistence service to temporarily store data.
+ none
+
+ org.openhab.inmemory
+
+
+
+
+ The maximum number of values stored for each item (0 = infinite).
+ 512
+
+
+
+
diff --git a/bundles/org.openhab.persistence.inmemory/src/test/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceTests.java b/bundles/org.openhab.persistence.inmemory/src/test/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceTests.java
new file mode 100644
index 000000000..750e7f9b0
--- /dev/null
+++ b/bundles/org.openhab.persistence.inmemory/src/test/java/org/openhab/persistence/inmemory/internal/InMemoryPersistenceTests.java
@@ -0,0 +1,183 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.inmemory.internal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.closeTo;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.when;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Comparator;
+import java.util.TreeSet;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.items.GenericItem;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.persistence.FilterCriteria;
+import org.openhab.core.persistence.HistoricItem;
+import org.openhab.core.types.State;
+
+/**
+ * The {@link InMemoryPersistenceTests} contains tests for the {@link InMemoryPersistenceService}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@NonNullByDefault
+public class InMemoryPersistenceTests {
+ private static final String ITEM_NAME = "testItem";
+ private static final String ALIAS = "alias";
+
+ private @NonNullByDefault({}) InMemoryPersistenceService service;
+ private @NonNullByDefault({}) @Mock GenericItem item;
+
+ private @NonNullByDefault({}) FilterCriteria filterCriteria;
+
+ @BeforeEach
+ public void setup() {
+ when(item.getName()).thenReturn(ITEM_NAME);
+
+ filterCriteria = new FilterCriteria();
+ filterCriteria.setItemName(ITEM_NAME);
+
+ service = new InMemoryPersistenceService();
+ }
+
+ @Test
+ public void storeDirect() {
+ State state = new DecimalType(1);
+ when(item.getState()).thenReturn(state);
+
+ ZonedDateTime expectedTime = ZonedDateTime.now();
+ service.store(item);
+
+ TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp));
+ service.query(filterCriteria).forEach(storedStates::add);
+
+ assertThat(storedStates, hasSize(1));
+ assertThat(storedStates.first().getName(), is(ITEM_NAME));
+ assertThat(storedStates.first().getState(), is(state));
+ assertThat((double) storedStates.first().getTimestamp().toEpochSecond(),
+ is(closeTo(expectedTime.toEpochSecond(), 2)));
+ }
+
+ @Test
+ public void storeAlias() {
+ State state = new PercentType(1);
+ when(item.getState()).thenReturn(state);
+
+ ZonedDateTime expectedTime = ZonedDateTime.now();
+ service.store(item, ALIAS);
+
+ TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp));
+
+ // query with item name should return nothing
+ service.query(filterCriteria).forEach(storedStates::add);
+ assertThat(storedStates, is(empty()));
+
+ filterCriteria.setItemName(ALIAS);
+ service.query(filterCriteria).forEach(storedStates::add);
+
+ assertThat(storedStates.size(), is(1));
+ assertThat(storedStates.first().getName(), is(ALIAS));
+ assertThat(storedStates.first().getState(), is(state));
+ assertThat((double) storedStates.first().getTimestamp().toEpochSecond(),
+ is(closeTo(expectedTime.toEpochSecond(), 2)));
+ }
+
+ @Test
+ public void storeHistoric() {
+ State state = new HSBType("120,100,100");
+ when(item.getState()).thenReturn(state);
+
+ State historicState = new HSBType("40,50,50");
+ ZonedDateTime expectedTime = ZonedDateTime.of(2022, 05, 31, 10, 0, 0, 0, ZoneId.systemDefault());
+ service.store(item, expectedTime, historicState);
+
+ TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp));
+ service.query(filterCriteria).forEach(storedStates::add);
+
+ assertThat(storedStates, hasSize(1));
+ assertThat(storedStates.first().getName(), is(ITEM_NAME));
+ assertThat(storedStates.first().getState(), is(historicState));
+ assertThat(storedStates.first().getTimestamp(), is(expectedTime));
+ }
+
+ @Test
+ public void queryWithoutItemNameReturnsEmptyList() {
+ TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp));
+ service.query(new FilterCriteria()).forEach(storedStates::add);
+
+ assertThat(storedStates, is(empty()));
+ }
+
+ @Test
+ public void queryUnknownItemReturnsEmptyList() {
+ TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp));
+ service.query(filterCriteria).forEach(storedStates::add);
+
+ assertThat(storedStates, is(empty()));
+ }
+
+ @Test
+ public void removeBetweenTimes() {
+ State historicState1 = new StringType("value1");
+ State historicState2 = new StringType("value2");
+ State historicState3 = new StringType("value3");
+
+ ZonedDateTime expectedTime = ZonedDateTime.of(2022, 05, 31, 10, 0, 0, 0, ZoneId.systemDefault());
+ service.store(item, expectedTime, historicState1);
+ service.store(item, expectedTime.plusHours(2), historicState2);
+ service.store(item, expectedTime.plusHours(4), historicState3);
+
+ // ensure both are stored
+ TreeSet storedStates = new TreeSet<>(Comparator.comparing(HistoricItem::getTimestamp));
+ service.query(filterCriteria).forEach(storedStates::add);
+
+ assertThat(storedStates, hasSize(3));
+
+ filterCriteria.setBeginDate(expectedTime.plusHours(1));
+ filterCriteria.setEndDate(expectedTime.plusHours(3));
+ service.remove(filterCriteria);
+
+ filterCriteria = new FilterCriteria();
+ filterCriteria.setItemName(ITEM_NAME);
+ storedStates.clear();
+ service.query(filterCriteria).forEach(storedStates::add);
+
+ assertThat(storedStates, hasSize(2));
+
+ assertThat(storedStates.first().getName(), is(ITEM_NAME));
+ assertThat(storedStates.first().getState(), is(historicState1));
+ assertThat(storedStates.first().getTimestamp(), is(expectedTime));
+
+ assertThat(storedStates.last().getName(), is(ITEM_NAME));
+ assertThat(storedStates.last().getState(), is(historicState3));
+ assertThat(storedStates.last().getTimestamp(), is(expectedTime.plusHours(4)));
+ }
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 4020bcde0..5fb1ccee8 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -423,6 +423,7 @@
org.openhab.persistence.dynamodb
org.openhab.persistence.influxdb
+ org.openhab.persistence.inmemory
org.openhab.persistence.jdbc
org.openhab.persistence.jpa
org.openhab.persistence.mapdb