# TimescaleDB Persistence

This service persists and queries openHAB item states using TimescaleDB (opens new window), a time-series database built on PostgreSQL.

Unlike the generic JDBC persistence (which can also connect to TimescaleDB via the PostgreSQL driver), this service is purpose-built for TimescaleDB and leverages its native time-series features:

  • Hypertables for automatic time-based partitioning and fast range queries
  • In-place downsampling — raw data is aggregated and replaced in the hypertable directly, so openHAB reads aggregated data transparently without any schema changes
  • Per-item downsampling config via item metadata (namespace timescaledb)
  • Compression Policies to automatically compress older data and reduce storage
  • Retention Policies to automatically drop data older than a configured threshold

# Prerequisites

  • TimescaleDB 2.x installed and running (as a PostgreSQL extension)
  • A database and user created for openHAB
CREATE DATABASE openhab;
CREATE USER openhab WITH PASSWORD 'openhab';
GRANT ALL PRIVILEGES ON DATABASE openhab TO openhab;

-- Connect to openhab database, then:
CREATE EXTENSION IF NOT EXISTS timescaledb;

# Database Schema

The service creates all tables automatically on startup — no manual DDL required. 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).

# State Type Mapping

openHAB Type value column string column unit column
DecimalType numeric value
QuantityType numeric value (stripped) unit string, e.g. °C
OnOffType 1.0 (ON) / 0.0 (OFF)
OpenClosedType 1.0 (OPEN) / 0.0 (CLOSED)
PercentType 0.0100.0
UpDownType 0.0 (UP) / 1.0 (DOWN)
HSBType H,S,B
DateTimeType ISO-8601
StringType raw string

# Configuration

Configure via $OPENHAB_CONF/services/timescaledb.cfg or in the UI under Settings → Add-ons → TimescaleDB → Configure.

Property Default Required Description
url Yes JDBC URL, e.g. jdbc:postgresql://localhost:5432/openhab
user openhab No Database user
password Yes Database password
chunkInterval 7 days No TimescaleDB chunk interval for the hypertable
retentionDays 0 No Drop data older than N days. 0 = disabled
compressionAfterDays 0 No Compress chunks older than N days. 0 = disabled
maxConnections 5 No Maximum DB connections in the pool
connectTimeout 5000 No Connection timeout in milliseconds

# Persistence Configuration

All item- and event-related configuration is defined in persistence/timescaledb.persist:

Strategies {
    everyMinute : "0 * * * * ?"
    everyHour   : "0 0 * * * ?"
    everyDay    : "0 0 0 * * ?"
    default = everyChange
}

Items {
    *             : strategy = everyChange, restoreOnStartup
    Temperature_* : strategy = everyMinute
    Energy_*      : strategy = everyHour
}

# Per-Item Downsampling and Metadata Tags

Per-item behaviour is configured via item metadata in the timescaledb namespace.

# Metadata format

timescaledb="<label>" [ aggregation="<fn>", downsampleInterval="<interval>",
    retainRawDays="<n>", retentionDays="<n>", <custom-tag>="<value>", ... ]
Metadata key Values / default Description
value (main) any string User-defined label stored in item_meta.value. Leave blank (single space " ") if only retention is needed.
aggregation AVG, MAX, MIN, SUM Downsampling aggregation function. Omit if no downsampling is needed.
downsampleInterval e.g. 1h, 15m, 1d Time bucket size for aggregation. Required when aggregation is set.
retainRawDays integer, default 5 Keep raw data for N days before replacing with aggregated rows.
retentionDays integer, default 0 Drop all data older than N days. 0 = disabled.
custom tags any key=value pairs Stored unfiltered as JSONB in item_meta.metadata. Queryable via Grafana/SQL JSONB operators.

The entire config map (all keys including aggregation, downsampleInterval, etc.) is stored as JSONB in item_meta.metadata, enabling flexible SQL/Grafana filtering.

# Configuration in .items files

// Downsampling + custom tags for Grafana filtering
Number:Temperature Sensor_Temperature_Living "Living Room [%.1f °C]" {
    timescaledb="sensor.temperature" [ aggregation="AVG", downsampleInterval="1h",
        retainRawDays="5", location="living_room", kind="temperature" ]
}

Number:Power Meter_Power_House "House Power [%.1f W]" {
    timescaledb="meter.power" [ aggregation="AVG", downsampleInterval="15m",
        retainRawDays="3", retentionDays="365" ]
}

Number:Energy Meter_Energy_House "House Energy [%.3f kWh]" {
    timescaledb="meter.energy" [ aggregation="SUM", downsampleInterval="1h", retainRawDays="7" ]
}

// Retention-only: no downsampling, just drop data older than 30 days.
// The value must be a single space " " — openHAB rejects a truly empty string.
Number:Temperature Sensor_Temp_Outdoor {
    timescaledb=" " [ retentionDays="30" ]
}

# Configuration in mainUI

Downsampling + Retention + Tags:

Item → Metadata → Add Metadata → Enter namespace "timescaledb":

  • Value: sensor.temperature (or any descriptive label)
  • Additional config: aggregation=AVG, downsampleInterval=1h, retainRawDays=5, retentionDays=365, location=living_room

Retention-only (no downsampling):

Item → Metadata → Add Metadata → Enter namespace "timescaledb":

  • Value: (a single space — openHAB rejects an empty value)
  • Additional config: retentionDays=30

# How in-place downsampling works

The downsampling runs as a scheduled job (daily, at midnight):

For each item with timescaledb metadata:
  1. Parse operation + downsampleInterval from metadata
  2. Compute cutoff = NOW() - retainRawDays
  3. SELECT time_bucket(interval, time), agg_fn(value), MAX(unit)
       FROM items
       WHERE item_id = ? AND downsampled = FALSE AND time < cutoff
       GROUP BY bucket
  4. INSERT aggregated rows with downsampled = TRUE
  5. DELETE original rows (downsampled = FALSE, time < cutoff)

This keeps the hypertable as the single source of truth. openHAB reads aggregated and raw data from the same table — no query changes needed.

# Supported intervals

Metadata value SQL interval
1m 1 minute
5m 5 minutes
15m 15 minutes
30m 30 minutes
1h 1 hour
6h 6 hours
1d 1 day

# Querying from openHAB

The service implements the full QueryablePersistenceService interface:

openHAB Query TimescaleDB Implementation
historicState(item, timestamp) SELECT … ORDER BY time DESC LIMIT 1
averageSince(item, timestamp) AVG(value) WHERE time >= ?
sumSince(item, timestamp) SUM(value) WHERE time >= ?
minSince(item, timestamp) MIN(value) WHERE time >= ?
maxSince(item, timestamp) MAX(value) WHERE time >= ?
countSince(item, timestamp) COUNT(*) WHERE time >= ?
getAllStatesBetween(item, begin, end) Range scan (raw + downsampled)
removeAllStatesBetween(item, begin, end) DELETE WHERE time BETWEEN ? AND ?

# Compression

When compressionAfterDays > 0, the service configures automatic chunk compression:

ALTER TABLE items SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'item_id',
    timescaledb.compress_orderby   = 'time DESC'
);
SELECT add_compression_policy('items', INTERVAL '30 days');

# Retention

When retentionDays > 0 (global config), a TimescaleDB retention policy is added:

SELECT add_retention_policy('items', INTERVAL '365 days');

Per-item retention (via metadata retentionDays) is applied by the daily downsampling job using a targeted DELETE. This works independently of downsampling: an item can have retentionDays set without any aggregation function (use a single space " " as the metadata value in that case).

# Grafana Integration

TimescaleDB works natively with the Grafana PostgreSQL data source.

# Query by item name

-- Raw + downsampled data for a sensor (last 24 h)
SELECT
  time_bucket('5 minutes', time) AS time,
  AVG(value)                     AS temperature,
  MAX(unit)                      AS unit
FROM items
JOIN item_meta ON items.item_id = item_meta.id
WHERE item_meta.name = 'Sensor_Temperature_Living'
  AND time > NOW() - INTERVAL '24 hours'
GROUP BY 1
ORDER BY 1;

# Filter by label (item_meta.value)

-- All items labelled "sensor.temperature" (last 24 h)
SELECT
  time_bucket('5 minutes', time) AS time,
  item_meta.name                 AS sensor,
  AVG(value)                     AS temperature
FROM items
JOIN item_meta ON items.item_id = item_meta.id
WHERE item_meta.value = 'sensor.temperature'
  AND time > NOW() - INTERVAL '24 hours'
GROUP BY 1, 2
ORDER BY 1;

# Filter by custom tag (item_meta.metadata JSONB)

-- All temperature sensors in the living room
SELECT
  time_bucket('5 minutes', time) AS time,
  item_meta.name                 AS sensor,
  AVG(value)                     AS temperature
FROM items
JOIN item_meta ON items.item_id = item_meta.id
WHERE item_meta.metadata->>'location' = 'living_room'
  AND item_meta.metadata->>'kind'     = 'temperature'
  AND time > NOW() - INTERVAL '24 hours'
GROUP BY 1, 2
ORDER BY 1;

# Differences from JDBC Persistence

Feature JDBC Persistence TimescaleDB Persistence
TimescaleDB hypertables No (plain tables) Yes
In-place downsampling No Yes
Per-item aggregation config No Yes (item metadata)
Automatic compression No Yes
Retention policies No Yes (global + per-item)
Unit stored per measurement No Yes
Multiple DB backends Yes No (TimescaleDB only)
Schema (one table per item) Yes No (single hypertable)