Skip to content

Commit b4846f5

Browse files
authored
[timescaledb] Add metadata tag support and JSONB config storage (fixes openhab#20460) (openhab#20464)
* feat: Enhance TimescaleDB schema with value and metadata columns Signed-off-by: René Ulbricht <rene_ulbricht@outlook.com>
1 parent 98c5650 commit b4846f5

14 files changed

Lines changed: 1018 additions & 155 deletions

File tree

bundles/org.openhab.persistence.timescaledb/AGENTS.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ CREATE TABLE item_meta (
4949
id SERIAL PRIMARY KEY,
5050
name TEXT NOT NULL UNIQUE,
5151
label TEXT,
52+
value TEXT,
53+
metadata JSONB,
5254
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
5355
);
5456

@@ -65,6 +67,36 @@ SELECT create_hypertable('items', 'time');
6567
CREATE INDEX ON items (item_id, time DESC);
6668
```
6769

70+
### `item_meta.value` and `item_meta.metadata`
71+
72+
`value TEXT` stores `metadata.getValue()` — a free user-defined string (measurement label, filter tag, etc.).
73+
`metadata JSONB` stores the **complete** `metadata.getConfiguration()` map serialized as JSON — unfiltered,
74+
including reserved keys (`aggregation`, `downsampleInterval`, `retainRawDays`, `retentionDays`) and any
75+
user-defined tags (`location`, `kind`, etc.).
76+
77+
```
78+
Number:Temperature MySensor {
79+
timescaledb="sensor.temperature" [ aggregation="AVG", downsampleInterval="1h",
80+
location="living_room", kind="sensor" ]
81+
}
82+
-- item_meta.value = 'sensor.temperature'
83+
-- item_meta.metadata = '{"aggregation":"AVG","downsampleInterval":"1h","location":"living_room","kind":"sensor"}'
84+
```
85+
86+
Grafana can filter via JSONB operators: `WHERE metadata->>'location' = 'living_room'`
87+
88+
When no value/config is set, both columns are `NULL`.
89+
90+
**Migration:** On startup `TimescaleDBSchema.initialize()` runs a single DO-block that adds both columns atomically:
91+
92+
```sql
93+
ALTER TABLE item_meta
94+
ADD COLUMN IF NOT EXISTS value TEXT,
95+
ADD COLUMN IF NOT EXISTS metadata JSONB;
96+
```
97+
98+
`IF NOT EXISTS` makes the statement idempotent. A `lock_timeout` of 5 s prevents blocking `@Activate` indefinitely; on timeout a WARNING is logged and the migration is retried on the next startup.
99+
68100
### Why `unit` is per row, not in `item_meta`
69101

70102
A `QuantityType` unit can change over time (sensor reconfiguration, firmware update, etc.). Storing it in `item_meta` would corrupt historical reads. The unit is stored with each measurement and read back from the row when reconstructing `QuantityType` states.
@@ -134,17 +166,22 @@ private Optional<Metadata> getItemMetadata(String itemName) {
134166
```
135167

136168
`Metadata` has:
137-
- `getValue()`main value string, e.g. `"AVG"`, `"MAX"`, `"MIN"`, `"SUM"`, or `""` (no aggregation)
138-
- `getConfiguration()``Map<String, Object>` with keys like `"downsampleInterval"`, `"retainRawDays"`, `"retentionDays"`
169+
- `getValue()`user-defined string (e.g. `"sensor.temperature"`), stored in `item_meta.value`
170+
- `getConfiguration()``Map<String, Object>` with keys like `"aggregation"`, `"downsampleInterval"`, `"retainRawDays"`, `"retentionDays"`, plus user-defined tags
139171

140172
### Metadata format (configured by users in .items files)
141173

142174
```java
143175
Number:Temperature MySensor {
144-
timescaledb="AVG" [ downsampleInterval="1h", retainRawDays="5", retentionDays="365" ]
176+
timescaledb="sensor.temperature" [ aggregation="AVG", downsampleInterval="1h",
177+
retainRawDays="5", retentionDays="365", location="living_room" ]
145178
}
146179
```
147180

181+
- `getValue()` = user-defined string stored in `item_meta.value` (e.g. measurement label for Grafana)
182+
- `aggregation` in config = downsampling function (replaces the old `getValue()` = `"AVG"` pattern)
183+
- All config keys are stored unfiltered as JSONB in `item_meta.metadata`
184+
148185
### Parsing the metadata
149186

150187
```java
@@ -249,7 +286,7 @@ Location: `src/test/java/org/openhab/persistence/timescaledb/internal/`
249286
- `TimescaleDBMetadataServiceTest` — parsing of metadata values and config keys
250287
- `TimescaleDBDownsampleJobTest` — SQL generation for aggregation/delete, interval allowlist validation
251288

252-
Run with `mvn test` — last result: **183 tests, 0 failures** (2026-03-13).
289+
Run with `mvn test` — last result: **228 tests, 0 failures** (2026-03-28).
253290

254291
### Integration Tests (requires Docker + TimescaleDB)
255292

bundles/org.openhab.persistence.timescaledb/README.md

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ CREATE EXTENSION IF NOT EXISTS timescaledb;
2727
## Database Schema
2828

2929
The service **creates all tables automatically on startup** — no manual DDL required.
30-
Item states are stored in a single hypertable `items` (columns: `time`, `item_id`, `value`, `string`, `unit`, `downsampled`) and a name-lookup table `item_meta`.
30+
Item states are stored in a single hypertable `items` (columns: `time`, `item_id`, `value`, `string`, `unit`, `downsampled`) and a name-lookup table `item_meta` (columns: `id`, `name`, `label`, `value`, `metadata`).
3131

3232
## State Type Mapping
3333

@@ -77,36 +77,44 @@ Items {
7777
}
7878
```
7979

80-
## Per-Item Downsampling
80+
## Per-Item Downsampling and Metadata Tags
8181

82-
Downsampling is configured **per item** via item metadata in the `timescaledb` namespace.
82+
Per-item behaviour is configured via item metadata in the `timescaledb` namespace.
8383

8484
### Metadata format
8585

8686
```text
87-
timescaledb="<operation>" [downsampleInterval="<interval>", retainRawDays="<n>", retentionDays="<n>"]
87+
timescaledb="<label>" [ aggregation="<fn>", downsampleInterval="<interval>",
88+
retainRawDays="<n>", retentionDays="<n>", <custom-tag>="<value>", ... ]
8889
```
8990

90-
| Metadata key | Values | Description |
91-
|----------------------|--------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
92-
| value (main) | `AVG`, `MAX`, `MIN`, `SUM`, or `" "` | Aggregation function. Use a single space `" "` for retention-only (no downsampling). openHAB rejects a truly empty value, so a space is required. |
93-
| `downsampleInterval` | e.g. `1h`, `15m`, `1d` | Time bucket size for aggregation. Required when value is an aggregation function. |
94-
| `retainRawDays` | integer, default `5` | Keep raw data for N days before replacing with aggregated rows. |
95-
| `retentionDays` | integer, default `0` | Drop all data (raw + downsampled) older than N days. `0` = off. |
91+
| Metadata key | Values / default | Description |
92+
|----------------------|------------------------------|---------------------------------------------------------------------------------------------------------------|
93+
| value (main) | any string | User-defined label stored in `item_meta.value`. Leave blank (single space `" "`) if only retention is needed. |
94+
| `aggregation` | `AVG`, `MAX`, `MIN`, `SUM` | Downsampling aggregation function. Omit if no downsampling is needed. |
95+
| `downsampleInterval` | e.g. `1h`, `15m`, `1d` | Time bucket size for aggregation. Required when `aggregation` is set. |
96+
| `retainRawDays` | integer, default `5` | Keep raw data for N days before replacing with aggregated rows. |
97+
| `retentionDays` | integer, default `0` | Drop all data older than N days. `0` = disabled. |
98+
| custom tags | any key=value pairs | Stored unfiltered as JSONB in `item_meta.metadata`. Queryable via Grafana/SQL JSONB operators. |
99+
100+
The **entire config map** (all keys including `aggregation`, `downsampleInterval`, etc.) is stored as JSONB in `item_meta.metadata`, enabling flexible SQL/Grafana filtering.
96101

97102
### Configuration in `.items` files
98103

99104
```java
105+
// Downsampling + custom tags for Grafana filtering
100106
Number:Temperature Sensor_Temperature_Living "Living Room [%.1f °C]" {
101-
timescaledb="AVG" [ downsampleInterval="1h", retainRawDays="5" ]
107+
timescaledb="sensor.temperature" [ aggregation="AVG", downsampleInterval="1h",
108+
retainRawDays="5", location="living_room", kind="temperature" ]
102109
}
103110

104111
Number:Power Meter_Power_House "House Power [%.1f W]" {
105-
timescaledb="AVG" [ downsampleInterval="15m", retainRawDays="3", retentionDays="365" ]
112+
timescaledb="meter.power" [ aggregation="AVG", downsampleInterval="15m",
113+
retainRawDays="3", retentionDays="365" ]
106114
}
107115

108116
Number:Energy Meter_Energy_House "House Energy [%.3f kWh]" {
109-
timescaledb="SUM" [ downsampleInterval="1h", retainRawDays="7" ]
117+
timescaledb="meter.energy" [ aggregation="SUM", downsampleInterval="1h", retainRawDays="7" ]
110118
}
111119

112120
// Retention-only: no downsampling, just drop data older than 30 days.
@@ -118,12 +126,12 @@ Number:Temperature Sensor_Temp_Outdoor {
118126

119127
### Configuration in mainUI
120128

121-
**Downsampling + Retention:**
129+
**Downsampling + Retention + Tags:**
122130

123131
`Item → Metadata → Add Metadata → Enter namespace "timescaledb"`:
124132

125-
- Value: `AVG`
126-
- Additional config: `downsampleInterval=1h`, `retainRawDays=5`, `retentionDays=365`
133+
- Value: `sensor.temperature` (or any descriptive label)
134+
- Additional config: `aggregation=AVG`, `downsampleInterval=1h`, `retainRawDays=5`, `retentionDays=365`, `location=living_room`
127135

128136
**Retention-only (no downsampling):**
129137

@@ -204,7 +212,9 @@ This works independently of downsampling: an item can have `retentionDays` set w
204212

205213
## Grafana Integration
206214

207-
TimescaleDB works natively with the Grafana PostgreSQL data source:
215+
TimescaleDB works natively with the Grafana PostgreSQL data source.
216+
217+
### Query by item name
208218

209219
```sql
210220
-- Raw + downsampled data for a sensor (last 24 h)
@@ -220,6 +230,39 @@ GROUP BY 1
220230
ORDER BY 1;
221231
```
222232

233+
### Filter by label (`item_meta.value`)
234+
235+
```sql
236+
-- All items labelled "sensor.temperature" (last 24 h)
237+
SELECT
238+
time_bucket('5 minutes', time) AS time,
239+
item_meta.name AS sensor,
240+
AVG(value) AS temperature
241+
FROM items
242+
JOIN item_meta ON items.item_id = item_meta.id
243+
WHERE item_meta.value = 'sensor.temperature'
244+
AND time > NOW() - INTERVAL '24 hours'
245+
GROUP BY 1, 2
246+
ORDER BY 1;
247+
```
248+
249+
### Filter by custom tag (`item_meta.metadata` JSONB)
250+
251+
```sql
252+
-- All temperature sensors in the living room
253+
SELECT
254+
time_bucket('5 minutes', time) AS time,
255+
item_meta.name AS sensor,
256+
AVG(value) AS temperature
257+
FROM items
258+
JOIN item_meta ON items.item_id = item_meta.id
259+
WHERE item_meta.metadata->>'location' = 'living_room'
260+
AND item_meta.metadata->>'kind' = 'temperature'
261+
AND time > NOW() - INTERVAL '24 hours'
262+
GROUP BY 1, 2
263+
ORDER BY 1;
264+
```
265+
223266
## Differences from JDBC Persistence
224267

225268
| Feature | JDBC Persistence | TimescaleDB Persistence |

bundles/org.openhab.persistence.timescaledb/pom.xml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
<name>openHAB Add-ons :: Bundles :: Persistence Service :: TimescaleDB</name>
1616

1717
<properties>
18-
<bnd.importpackage>!com.codahale.metrics.*,!io.prometheus.*,!org.checkerframework.*,!org.jetbrains.annotations.*,!org.hibernate.*,!waffle.windows.auth.*,!org.osgi.service.jdbc.*,!com.sun.jna.*,!javassist.*</bnd.importpackage>
18+
<bnd.importpackage>!com.codahale.metrics.*,!io.prometheus.*,!org.checkerframework.*,!org.jetbrains.annotations.*,!org.hibernate.*,!waffle.windows.auth.*,!org.osgi.service.jdbc.*,!com.sun.jna.*,!javassist.*,!com.google.errorprone.annotations.*,!sun.misc.*</bnd.importpackage>
1919
<postgresql.version>42.7.9</postgresql.version>
2020
<hikari.version>5.1.0</hikari.version>
2121
</properties>
@@ -34,6 +34,13 @@
3434
<scope>compile</scope>
3535
</dependency>
3636

37+
<dependency>
38+
<groupId>com.google.code.gson</groupId>
39+
<artifactId>gson</artifactId>
40+
<version>${gson.version}</version>
41+
<scope>compile</scope>
42+
</dependency>
43+
3744
<!-- Test dependencies -->
3845
<dependency>
3946
<groupId>org.testcontainers</groupId>

bundles/org.openhab.persistence.timescaledb/src/main/java/org/openhab/persistence/timescaledb/internal/TimescaleDBMetadataService.java

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import java.util.ArrayList;
1616
import java.util.List;
17+
import java.util.Map;
1718
import java.util.Optional;
1819

1920
import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -27,26 +28,36 @@
2728
import org.slf4j.Logger;
2829
import org.slf4j.LoggerFactory;
2930

31+
import com.google.gson.Gson;
32+
3033
/**
31-
* Reads and parses per-item downsampling configuration from the {@link MetadataRegistry}
32-
* using the {@code timescaledb} namespace.
34+
* Reads per-item configuration from the {@link MetadataRegistry} using the {@code timescaledb} namespace.
3335
*
3436
* <p>
3537
* Example item metadata:
36-
*
38+
*
3739
* <pre>
3840
* Number:Temperature MySensor {
39-
* timescaledb="AVG" [ downsampleInterval="1h", retainRawDays="5", retentionDays="365" ]
41+
* timescaledb="sensor.temperature" [ aggregation="AVG", downsampleInterval="1h", retainRawDays="5",
42+
* retentionDays="365", kind="sensor", location="living_room" ]
4043
* }
4144
* </pre>
4245
*
46+
* <ul>
47+
* <li>{@code getValue()} — user-defined string (measurement label / filter tag), stored in
48+
* {@code item_meta.value}</li>
49+
* <li>{@code getConfiguration()} — full config map stored as JSONB in {@code item_meta.metadata}; reserved keys:
50+
* {@code aggregation}, {@code downsampleInterval}, {@code retainRawDays}, {@code retentionDays}</li>
51+
* </ul>
52+
*
4353
* @author René Ulbricht - Initial contribution
4454
*/
4555
@NonNullByDefault
4656
@Component(service = TimescaleDBMetadataService.class)
4757
public class TimescaleDBMetadataService {
4858

4959
private static final Logger LOGGER = LoggerFactory.getLogger(TimescaleDBMetadataService.class);
60+
private static final Gson GSON = new Gson();
5061

5162
/** The metadata namespace used by this persistence service. */
5263
public static final String METADATA_NAMESPACE = "timescaledb";
@@ -95,13 +106,61 @@ public List<String> getConfiguredItemNames() {
95106
return result;
96107
}
97108

109+
/**
110+
* Returns the user-defined value string from {@code metadata.getValue()}, stored verbatim in
111+
* {@code item_meta.value}. Returns {@code null} if no metadata is configured or the value is blank.
112+
*
113+
* <p>
114+
* Example: {@code timescaledb="sensor.temperature" [...]} → returns {@code "sensor.temperature"}.
115+
*
116+
* @param itemName The item name.
117+
* @return The value string, or {@code null}.
118+
*/
119+
public @Nullable String getMetadataValueString(String itemName) {
120+
MetadataKey key = new MetadataKey(METADATA_NAMESPACE, itemName);
121+
@Nullable
122+
Metadata metadata = metadataRegistry.get(key);
123+
if (metadata == null) {
124+
return null;
125+
}
126+
String v = metadata.getValue();
127+
return v.isBlank() ? null : v;
128+
}
129+
130+
/**
131+
* Returns the full {@code getConfiguration()} map serialized as a JSON string, suitable for storage
132+
* in {@code item_meta.metadata} (JSONB column). Returns {@code null} if no metadata is configured
133+
* or the config map is empty.
134+
*
135+
* <p>
136+
* All config keys are stored unfiltered, including reserved keys ({@code aggregation},
137+
* {@code downsampleInterval}, {@code retainRawDays}, {@code retentionDays}) and any user-defined tags.
138+
*
139+
* @param itemName The item name.
140+
* @return JSON string of the config map, or {@code null}.
141+
*/
142+
public @Nullable String getMetadataConfigJson(String itemName) {
143+
MetadataKey key = new MetadataKey(METADATA_NAMESPACE, itemName);
144+
@Nullable
145+
Metadata metadata = metadataRegistry.get(key);
146+
if (metadata == null) {
147+
return null;
148+
}
149+
Map<String, Object> config = metadata.getConfiguration();
150+
if (config.isEmpty()) {
151+
return null;
152+
}
153+
return GSON.toJson(config);
154+
}
155+
98156
private Optional<DownsampleConfig> parseConfig(String itemName, Metadata metadata) {
99-
String functionStr = metadata.getValue();
157+
var config = metadata.getConfiguration();
158+
Object aggObj = config.get("aggregation");
159+
String functionStr = aggObj != null ? aggObj.toString().trim() : "";
160+
100161
if (functionStr.isBlank()) {
101-
// No aggregation function — check for retention-only config.
102-
// Note: openHAB requires a non-empty metadata value, so use a single space (" ")
103-
// in item files and the UI when you only want retention without downsampling.
104-
int retentionDays = getInt(metadata.getConfiguration(), "retentionDays", DEFAULT_RETENTION_DAYS);
162+
// No aggregation function — retention-only config (retentionDays without downsampling).
163+
int retentionDays = getInt(config, "retentionDays", DEFAULT_RETENTION_DAYS);
105164
if (retentionDays < 0) {
106165
LOGGER.warn("Item '{}': retentionDays must be >= 0, ignoring negative value {}", itemName,
107166
retentionDays);
@@ -123,11 +182,9 @@ private Optional<DownsampleConfig> parseConfig(String itemName, Metadata metadat
123182
return Optional.empty();
124183
}
125184

126-
var config = metadata.getConfiguration();
127-
128185
String intervalStr = getString(config, "downsampleInterval", null);
129186
if (intervalStr == null || intervalStr.isBlank()) {
130-
LOGGER.warn("Item '{}': timescaledb metadata has function '{}' but no downsampleInterval — skipping",
187+
LOGGER.warn("Item '{}': timescaledb metadata has aggregation '{}' but no downsampleInterval — skipping",
131188
itemName, functionStr);
132189
return Optional.empty();
133190
}
@@ -156,13 +213,12 @@ private Optional<DownsampleConfig> parseConfig(String itemName, Metadata metadat
156213
return Optional.of(result);
157214
}
158215

159-
private static @Nullable String getString(java.util.Map<String, Object> config, String key,
160-
@Nullable String defaultValue) {
216+
private static @Nullable String getString(Map<String, Object> config, String key, @Nullable String defaultValue) {
161217
Object val = config.get(key);
162218
return val != null ? val.toString() : defaultValue;
163219
}
164220

165-
private static int getInt(java.util.Map<String, Object> config, String key, int defaultValue) {
221+
private static int getInt(Map<String, Object> config, String key, int defaultValue) {
166222
Object val = config.get(key);
167223
if (val == null) {
168224
return defaultValue;

0 commit comments

Comments
 (0)