Compare commits
3 Commits
58155dc737
...
b2a1056255
| Author | SHA1 | Date | |
|---|---|---|---|
| b2a1056255 | |||
| d530f0f3e5 | |||
| 2d0ac1bc53 |
+600
@@ -0,0 +1,600 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- GebOS – Schema V1
|
||||||
|
-- Target: Postgres 17 + TimescaleDB
|
||||||
|
--
|
||||||
|
-- Scope: UVI pilot per § 6a HeizkostenV, methodology per UBA-Leitfaden
|
||||||
|
-- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate
|
||||||
|
-- epic and deliberately not modeled here.
|
||||||
|
--
|
||||||
|
-- Requirement IDs (UVI-DAT-*, UVI-FUN-*, UVI-NFR-*, UVI-MET-*) refer to the
|
||||||
|
-- Notion page "UVI – Anforderungen (Pilot)", which is the source of truth.
|
||||||
|
--
|
||||||
|
-- Conventions
|
||||||
|
-- * lowercase snake_case everywhere — no quoted identifiers, ever.
|
||||||
|
-- * Surrogate UUID primary keys, except time-series tables where the
|
||||||
|
-- composite natural key carries meaning (see measurement).
|
||||||
|
-- * timestamptz for all instants; date for civil dates (tenancy, prices).
|
||||||
|
-- * jsonb, never json (binary, indexable, deduplicated keys).
|
||||||
|
-- * created_at on every table for ops forensics.
|
||||||
|
-- * ON DELETE RESTRICT as default policy: telemetry and tenancy data must
|
||||||
|
-- never disappear as a side effect of deleting a parent row. Deletions
|
||||||
|
-- are rare, deliberate, admin-level operations.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
|
||||||
|
CREATE EXTENSION IF NOT EXISTS btree_gist; -- exclusion constraint on occupancy
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Enums
|
||||||
|
--
|
||||||
|
-- Enums are used where the value set is fixed by law or by the product
|
||||||
|
-- itself and changes require a deliberate migration (which is a feature:
|
||||||
|
-- a new fuel type SHOULD force someone to also add its CO2 factor and
|
||||||
|
-- price rows). Where the value set is operational content that admins
|
||||||
|
-- curate at runtime (saving tips), a table is used instead of an enum.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Drives CO2 emission factors (GEG Anlage 9) and cost estimation (Module 5).
|
||||||
|
-- One fuel per building is the pilot assumption. District heating fuel-mix
|
||||||
|
-- disclosure (§ 6a Abs. 3) belongs to annual billing; if it later requires
|
||||||
|
-- per-network fuel compositions, promote this enum to a table with a
|
||||||
|
-- composition child table — the FK sites below are the only touch points.
|
||||||
|
CREATE TYPE fuel_type AS ENUM (
|
||||||
|
'natural_gas',
|
||||||
|
'heating_oil',
|
||||||
|
'district_heating',
|
||||||
|
'heat_pump_electricity',
|
||||||
|
'wood_pellets',
|
||||||
|
'other'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The SEMANTIC kind of a reading. This is deliberately separate from the
|
||||||
|
-- physical unit: heating energy and hot-water energy are both kWh, yet the
|
||||||
|
-- UVI must never mix them (Modules 1 and 2 render them separately, and
|
||||||
|
-- UVI-FUN-14 treats them differently outside the heating period). The unit
|
||||||
|
-- says how a number is measured; the kind says what it means.
|
||||||
|
--
|
||||||
|
-- Diagnostic kinds (temperatures, error flags) are included because OMS
|
||||||
|
-- telegrams carry them anyway and they are the raw material for device
|
||||||
|
-- health monitoring — cheap to store now, expensive to backfill later.
|
||||||
|
CREATE TYPE measurement_kind AS ENUM (
|
||||||
|
'heating_energy', -- kWh, heat cost allocator / heat meter
|
||||||
|
'hot_water_energy', -- kWh
|
||||||
|
'hot_water_volume', -- m3; some installations meter volume and
|
||||||
|
-- convert to energy downstream
|
||||||
|
'cold_water_volume', -- m3; not needed for the UVI but commonly on
|
||||||
|
-- the same radio and trivially captured
|
||||||
|
'flow_temperature', -- °C, diagnostics
|
||||||
|
'return_temperature', -- °C, diagnostics
|
||||||
|
'error_flags', -- OMS status/error register
|
||||||
|
'other'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Two roles are enough for the pilot: tenants see exactly one apartment
|
||||||
|
-- dashboard (UVI-NFR-03), admins see the read-only overview of everything
|
||||||
|
-- (UVI-FUN-17). A role enum rather than a boolean because the role set is
|
||||||
|
-- known to grow (landlord read-only view is an announced post-pilot epic)
|
||||||
|
-- and because UVI-NFR-07 already treats "admin" as a first-class concept
|
||||||
|
-- with its own audit requirements.
|
||||||
|
CREATE TYPE user_role AS ENUM ('tenant', 'admin');
|
||||||
|
|
||||||
|
CREATE TYPE saving_tip_category AS ENUM ('heating', 'hot_water');
|
||||||
|
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 1. BUILDING & TENANCY
|
||||||
|
--
|
||||||
|
-- The physical and legal world: buildings contain apartments, apartments
|
||||||
|
-- are occupied by tenants over time periods. Everything the UVI displays
|
||||||
|
-- is ultimately scoped by this layer — a measurement only becomes a
|
||||||
|
-- "tenant's consumption" through sensor -> apartment -> occupancy.
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
CREATE TABLE building (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Address is structured (not one text blob) because the admin
|
||||||
|
-- dashboard groups by building address (UVI-FUN-17) and because
|
||||||
|
-- postal_code is the likely input for automatic weather-station
|
||||||
|
-- assignment if that open decision lands on "auto".
|
||||||
|
street text NOT NULL,
|
||||||
|
house_number text NOT NULL,
|
||||||
|
postal_code text NOT NULL,
|
||||||
|
city text NOT NULL,
|
||||||
|
|
||||||
|
-- Coordinates exist for exactly one consumer: nearest-weather-station
|
||||||
|
-- resolution. They are NOT used to locate devices — device placement
|
||||||
|
-- is human-readable installer text on the device rows themselves.
|
||||||
|
lat numeric(9,6),
|
||||||
|
lng numeric(9,6),
|
||||||
|
|
||||||
|
-- The building's energy carrier. Chosen at building level (not per
|
||||||
|
-- apartment, not per measurement) because the boiler / district
|
||||||
|
-- heating connection is a building-level fact, and both the CO2
|
||||||
|
-- module and the cost module resolve their factors through it.
|
||||||
|
fuel_type fuel_type NOT NULL,
|
||||||
|
|
||||||
|
-- UVI-MET-03. The UBA/GEG comparison metrics (Bandtacho thresholds,
|
||||||
|
-- in-house kWh/m²) are defined against Gebäudenutzfläche (AN), a
|
||||||
|
-- technical energy-balance quantity nobody in this business has on
|
||||||
|
-- file. The sanctioned approximation is AN ≈ Wohnfläche × 1.2.
|
||||||
|
-- Stored per building, not hardcoded, so that a building with a
|
||||||
|
-- known real AN (e.g. from an energy certificate) can override it:
|
||||||
|
-- set area_factor = real_AN / sum(living_area_m2). Every specific
|
||||||
|
-- consumption in the app divides by (living_area_m2 * area_factor);
|
||||||
|
-- omitting the factor would inflate every building's apparent
|
||||||
|
-- consumption ~20% and shift Bandtacho classes.
|
||||||
|
area_factor numeric(4,2) NOT NULL DEFAULT 1.20
|
||||||
|
CHECK (area_factor > 0),
|
||||||
|
|
||||||
|
-- Which DWD station's heating degree days contextualize this
|
||||||
|
-- building's consumption (Module 1 footnote, UVI-FUN-07). Nullable
|
||||||
|
-- and manually assigned in the pilot: the auto-vs-manual assignment
|
||||||
|
-- question is an explicitly open decision, and the schema must not
|
||||||
|
-- presume its answer. A NULL here means the weather footnote renders
|
||||||
|
-- as "not available" (UVI-FUN-15) — never as a silent default station.
|
||||||
|
weather_station_id uuid, -- FK added after weather_station is defined
|
||||||
|
|
||||||
|
-- Building-level operational remarks: key-box code, basement access,
|
||||||
|
-- contact person. Device mounting locations do NOT belong here —
|
||||||
|
-- they live on the device rows (see gateway/sensor.installation_note).
|
||||||
|
note text,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE apartment (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
building_id uuid NOT NULL REFERENCES building(id),
|
||||||
|
|
||||||
|
-- Human-readable unit designation ("2. OG links", "WE 04"). Unique
|
||||||
|
-- within a building so admin views and installer workflows have an
|
||||||
|
-- unambiguous handle.
|
||||||
|
label text NOT NULL,
|
||||||
|
|
||||||
|
-- UVI-DAT-01, and NOT NULL on purpose: the in-house comparison
|
||||||
|
-- (Module 2) is kWh per m² — an apartment without an area cannot
|
||||||
|
-- participate in the product's core comparison at all, so it is
|
||||||
|
-- invalid data, not missing data.
|
||||||
|
living_area_m2 numeric(7,2) NOT NULL CHECK (living_area_m2 > 0),
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (building_id, label)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Application-level user profile. Identity — email address, magic-link
|
||||||
|
-- issuance, session state — is owned entirely by Supabase Auth (GoTrue)
|
||||||
|
-- in the auth schema (UVI-NFR-08: passwordless only). This table stores
|
||||||
|
-- only what the application adds on top. The PK IS the auth.users id
|
||||||
|
-- (same uuid, no separate surrogate) so that auth.uid() in RLS policies
|
||||||
|
-- joins directly without a mapping table.
|
||||||
|
CREATE TABLE app_user (
|
||||||
|
id uuid PRIMARY KEY, -- = auth.users.id
|
||||||
|
role user_role NOT NULL DEFAULT 'tenant',
|
||||||
|
display_name text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tenancy periods (UVI-DAT-04). This table is the legal heart of the
|
||||||
|
-- data model, for two reasons:
|
||||||
|
--
|
||||||
|
-- 1. Mieterwechsel correctness: § 6a comparisons to the previous month /
|
||||||
|
-- previous-year month are only permitted against the SAME tenant's
|
||||||
|
-- consumption. The dashboard decides whether to render a comparison
|
||||||
|
-- by checking that both months fall inside one occupancy row. Without
|
||||||
|
-- periods, a new tenant would be shown (and judged against) their
|
||||||
|
-- predecessor's behavior.
|
||||||
|
--
|
||||||
|
-- 2. Privacy scope: a tenant may only ever see measurements taken during
|
||||||
|
-- their own tenancy (UVI-NFR-03). The RLS sketch at the bottom of this
|
||||||
|
-- file enforces that by joining measurement timestamps against this
|
||||||
|
-- table's date range — history before move-in is structurally
|
||||||
|
-- invisible, not just filtered in application code.
|
||||||
|
--
|
||||||
|
-- valid_to IS NULL means "current tenant". The exclusion constraint makes
|
||||||
|
-- overlapping tenancies for one apartment a constraint violation rather
|
||||||
|
-- than a bug class: the database itself guarantees that any timestamp
|
||||||
|
-- maps to at most one responsible tenant.
|
||||||
|
CREATE TABLE occupancy (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
apartment_id uuid NOT NULL REFERENCES apartment(id),
|
||||||
|
user_id uuid NOT NULL REFERENCES app_user(id),
|
||||||
|
valid_from date NOT NULL,
|
||||||
|
valid_to date,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (valid_to IS NULL OR valid_to > valid_from),
|
||||||
|
EXCLUDE USING gist (
|
||||||
|
apartment_id WITH =,
|
||||||
|
daterange(valid_from, COALESCE(valid_to, 'infinity'::date), '[)') WITH &&
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 2. DEVICES & INGEST
|
||||||
|
--
|
||||||
|
-- The radio world: gateways listen, meters broadcast, frames arrive.
|
||||||
|
-- Design stance: wM-Bus at 868 MHz is a BROADCAST medium. A gateway does
|
||||||
|
-- not "own" the sensors it hears — two gateways in one building may both
|
||||||
|
-- receive the same meter, and every gateway will receive meters that are
|
||||||
|
-- not ours (neighbors, unprovisioned devices). The schema therefore:
|
||||||
|
-- * ties gateways to buildings (where they are installed), never to
|
||||||
|
-- sensors;
|
||||||
|
-- * records which gateway heard a frame on the frame itself, because
|
||||||
|
-- that is a fact about the reception event, not about the meter;
|
||||||
|
-- * accepts and stores frames from unknown senders instead of
|
||||||
|
-- dropping them at the door.
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
CREATE TABLE gateway (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- IMEI identifies the cellular modem and is unique per device, but it
|
||||||
|
-- is a hardware serial, not an identity: a defective gateway swapped
|
||||||
|
-- in the field is, to the business, "the same listening post" with a
|
||||||
|
-- new IMEI. A surrogate PK keeps history intact across swaps; the
|
||||||
|
-- unique index still supports lookup-by-IMEI at ingest time.
|
||||||
|
imei varchar(15) NOT NULL UNIQUE,
|
||||||
|
imsi varchar(15), -- SIM identity; changes with
|
||||||
|
-- provider swaps, so informative
|
||||||
|
-- only, never a key
|
||||||
|
|
||||||
|
building_id uuid NOT NULL REFERENCES building(id),
|
||||||
|
|
||||||
|
label text, -- short admin handle, "GW Keller"
|
||||||
|
|
||||||
|
-- Free-text installer note answering "where exactly is this thing?":
|
||||||
|
-- e.g. "mounted above the first ceiling panel on level 1". Kept per
|
||||||
|
-- device (not on a shared location entity) so that editing one
|
||||||
|
-- device's note can never silently rewrite another's.
|
||||||
|
installation_note text,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A device profile: one row per (manufacturer, model) of meter we know
|
||||||
|
-- how to decode. Exists so that parsing knowledge attaches to the KIND
|
||||||
|
-- of device rather than to each physical unit — provisioning meter #500
|
||||||
|
-- of a known model requires zero new parser configuration. This is the
|
||||||
|
-- schema-level expression of the product strategy "works with any open
|
||||||
|
-- OMS sensor": supporting a new vendor means inserting rows here and in
|
||||||
|
-- value_parser, not shipping code.
|
||||||
|
CREATE TABLE device_type (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
manufacturer text NOT NULL, -- OMS M-field, e.g. 'QDS'
|
||||||
|
model text NOT NULL,
|
||||||
|
medium text, -- OMS medium byte, informative
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (manufacturer, model)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Extraction rules: how to pull ONE value out of a decoded telegram of a
|
||||||
|
-- given device type. A telegram routinely carries several values we care
|
||||||
|
-- about (energy total, volume, temperatures, error register), hence
|
||||||
|
-- 1 device_type : N parsers. Each parser declares:
|
||||||
|
-- * where the value sits (json_path into the decoded telegram),
|
||||||
|
-- * how to normalize it (parser_instructions: scaling, offsets),
|
||||||
|
-- * what it MEANS (kind) and in what unit it is expressed.
|
||||||
|
-- The kind declared here is what stamps every measurement row with its
|
||||||
|
-- heating/hot-water semantics — the parser is the single place where
|
||||||
|
-- raw vendor bytes acquire domain meaning.
|
||||||
|
--
|
||||||
|
-- unit is plain text ('kWh', 'm3', '°C') rather than a lookup table:
|
||||||
|
-- a three-row table adds a join and an id without adding information.
|
||||||
|
-- Reintroduce a unit table only if units ever need behavior or metadata
|
||||||
|
-- (conversion factors, display localization).
|
||||||
|
CREATE TABLE value_parser (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
device_type_id uuid NOT NULL REFERENCES device_type(id),
|
||||||
|
json_path text NOT NULL,
|
||||||
|
parser_instructions jsonb,
|
||||||
|
kind measurement_kind NOT NULL,
|
||||||
|
unit text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (device_type_id, kind, json_path)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sensor (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- The meter's own identity as broadcast in its telegrams (OMS
|
||||||
|
-- secondary address / serial). This is the value ingest matches on
|
||||||
|
-- to attribute an incoming frame to a known sensor.
|
||||||
|
oms_id text NOT NULL UNIQUE,
|
||||||
|
|
||||||
|
device_type_id uuid NOT NULL REFERENCES device_type(id),
|
||||||
|
|
||||||
|
-- The device-to-apartment assignment (UVI-FUN-02). Direction matters:
|
||||||
|
-- an apartment has MANY devices (a heat cost allocator per radiator
|
||||||
|
-- plus a hot-water meter is the normal case), a device sits in at
|
||||||
|
-- most one apartment. NULLable by design — in the pilot this
|
||||||
|
-- assignment is performed manually in the database after physical
|
||||||
|
-- installation, so a sensor legitimately exists in a provisioned-
|
||||||
|
-- but-unassigned state. Its measurements are stored regardless and
|
||||||
|
-- become tenant-visible once the assignment is made.
|
||||||
|
apartment_id uuid REFERENCES apartment(id),
|
||||||
|
|
||||||
|
-- AES-128 key for OMS telegram decryption. Plaintext here is a
|
||||||
|
-- pilot-grade decision to keep moving; before production, move key
|
||||||
|
-- material to a dedicated mechanism (pgsodium / Vault) — the column
|
||||||
|
-- is the interface, the storage hardening is an ops task.
|
||||||
|
decrypt_key text,
|
||||||
|
|
||||||
|
installed_at date,
|
||||||
|
|
||||||
|
-- Same rationale as gateway.installation_note: "which radiator,
|
||||||
|
-- which room, how to reach it" is per-device installer knowledge.
|
||||||
|
installation_note text,
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Note what sensor deliberately does NOT have: a gateway reference (the
|
||||||
|
-- radio is broadcast; reception routing lives on received_payload) and a
|
||||||
|
-- location/building reference (derived via apartment -> building; a
|
||||||
|
-- second, independent path would allow the two to contradict each other).
|
||||||
|
|
||||||
|
-- Raw reception log: every frame any of our gateways heard, verbatim.
|
||||||
|
-- This table exists for auditability and reprocessing — if a parser had
|
||||||
|
-- a bug, or a new device_type is added for meters we were already
|
||||||
|
-- hearing, history can be re-parsed from here. Storage is the price of
|
||||||
|
-- never losing data to a software mistake.
|
||||||
|
CREATE TABLE received_payload (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
gateway_id uuid NOT NULL REFERENCES gateway(id),
|
||||||
|
|
||||||
|
-- Attribution to a known sensor happens at parse time and is
|
||||||
|
-- NULLABLE: on a shared radio band we routinely receive frames from
|
||||||
|
-- meters that are not ours. Those are stored (they may become ours
|
||||||
|
-- — e.g. a meter provisioned after installation) and flagged via
|
||||||
|
-- parse_status rather than rejected at ingest.
|
||||||
|
sensor_id uuid REFERENCES sensor(id),
|
||||||
|
|
||||||
|
-- Both ends of the transport hop. sent = gateway clock at upload,
|
||||||
|
-- received = server clock at arrival; their divergence is the
|
||||||
|
-- cheapest possible gateway-health signal (clock drift, buffering
|
||||||
|
-- backlog after connectivity loss).
|
||||||
|
timestamp_sent timestamptz,
|
||||||
|
timestamp_received timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
raw_payload jsonb NOT NULL,
|
||||||
|
|
||||||
|
-- Ingest pipeline state machine. 'unknown_sensor' is a normal,
|
||||||
|
-- expected state, not an error — see attribution note above.
|
||||||
|
parse_status text NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (parse_status IN ('pending','parsed','unknown_sensor','error')),
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ON received_payload (sensor_id, timestamp_received DESC);
|
||||||
|
-- Partial index: the parser worker's queue query ("give me unprocessed
|
||||||
|
-- frames") stays fast no matter how large the historical log grows.
|
||||||
|
CREATE INDEX ON received_payload (parse_status)
|
||||||
|
WHERE parse_status IN ('pending','error');
|
||||||
|
|
||||||
|
-- Raw-frame retention is an open decision. When made, implement it as a
|
||||||
|
-- scheduled purge (or convert this table to a hypertable and attach a
|
||||||
|
-- Timescale retention policy) — do NOT cascade-delete from measurement;
|
||||||
|
-- measurement.raw_payload_id is nullable precisely so lineage can be
|
||||||
|
-- severed by retention without touching the measurements themselves.
|
||||||
|
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 3. MEASUREMENT (time series)
|
||||||
|
--
|
||||||
|
-- The product's ground truth: one row per (meter, kind, instant) reading.
|
||||||
|
-- Everything the UVI shows is computed from these rows at query time —
|
||||||
|
-- no persisted aggregates (UVI-DAT-06). That rule keeps a single source
|
||||||
|
-- of truth and makes bug fixes retroactive for free: correct a parser,
|
||||||
|
-- re-parse, and every dashboard number is correct.
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
CREATE TABLE measurement (
|
||||||
|
sensor_id uuid NOT NULL REFERENCES sensor(id),
|
||||||
|
|
||||||
|
-- The METER's own reading time, extracted from the telegram — not
|
||||||
|
-- the reception time. wM-Bus meters repeat frames and gateways
|
||||||
|
-- buffer during connectivity loss, so arrival time can trail reading
|
||||||
|
-- time by minutes to days; all consumption math (monthly windows,
|
||||||
|
-- year-over-year) must bind to when the meter measured.
|
||||||
|
measured_at timestamptz NOT NULL,
|
||||||
|
|
||||||
|
kind measurement_kind NOT NULL,
|
||||||
|
|
||||||
|
-- NOT NULL: a reading without a value is not a reading. Missing data
|
||||||
|
-- is represented by the ABSENCE of rows for a period, and the UI
|
||||||
|
-- renders that as "not available" (UVI-FUN-15) — never by storing 0
|
||||||
|
-- or NULL, both of which would poison aggregations silently.
|
||||||
|
value numeric NOT NULL,
|
||||||
|
|
||||||
|
-- Denormalized from value_parser at write time so a row is
|
||||||
|
-- self-describing even if parser configuration later changes.
|
||||||
|
unit text NOT NULL,
|
||||||
|
|
||||||
|
-- Lineage to the exact raw frame this value was parsed from.
|
||||||
|
-- Nullable so raw-frame retention can delete old frames without
|
||||||
|
-- invalidating measurements (see received_payload).
|
||||||
|
raw_payload_id uuid REFERENCES received_payload(id),
|
||||||
|
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- Composite natural key, doing three jobs at once:
|
||||||
|
-- 1. TimescaleDB requires the partitioning column in any unique
|
||||||
|
-- constraint — a bare uuid PK is incompatible with a hypertable.
|
||||||
|
-- 2. Dedup: meters broadcast the same reading repeatedly by design.
|
||||||
|
-- Ingest uses INSERT .. ON CONFLICT DO NOTHING against this key,
|
||||||
|
-- so repeats collapse to no-ops instead of duplicate rows that
|
||||||
|
-- would double-count consumption.
|
||||||
|
-- 3. It IS the dominant access path (this sensor, this kind, this
|
||||||
|
-- time range), so the PK index serves the dashboard queries and
|
||||||
|
-- no secondary index is needed for the pilot.
|
||||||
|
PRIMARY KEY (sensor_id, kind, measured_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Monthly chunks match the product's natural query grain (the UVI is a
|
||||||
|
-- monthly report over a 13-month window).
|
||||||
|
SELECT create_hypertable('measurement', by_range('measured_at', INTERVAL '1 month'));
|
||||||
|
|
||||||
|
-- If the 13-month dashboard aggregations ever get slow at scale, the
|
||||||
|
-- sanctioned escape hatch is a Timescale continuous aggregate: it is
|
||||||
|
-- machine-maintained derived state over this table, which arguably
|
||||||
|
-- honors the intent of "no persisted aggregates" (no hand-maintained
|
||||||
|
-- second truth). Adopt it when measured, not preemptively.
|
||||||
|
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 4. REFERENCE DATA
|
||||||
|
--
|
||||||
|
-- External regulatory and environmental facts the UVI computes against
|
||||||
|
-- (UVI-DAT-07..11). Two shared design rules:
|
||||||
|
--
|
||||||
|
-- * Versioning by validity range wherever the source can change
|
||||||
|
-- (CO2 factors, prices): the question "what number was shown to the
|
||||||
|
-- tenant in March?" must remain answerable after an update, both for
|
||||||
|
-- tenant trust and for the § 7 Kürzungsrecht audit scenario.
|
||||||
|
-- * source columns record provenance, because every one of these
|
||||||
|
-- numbers is a claim about a legal document or an external dataset.
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
-- Bandtacho class boundaries (Module 3). GEG Anlage 10 defines ANNUAL
|
||||||
|
-- kWh/m² class limits; the UBA-Leitfaden (Tabelle 1) splits them into
|
||||||
|
-- per-calendar-month thresholds so a single month's consumption can be
|
||||||
|
-- classified. Hence one row per (class, month): 9 classes × 12 months.
|
||||||
|
CREATE TABLE efficiency_class_threshold (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
class text NOT NULL CHECK (class IN
|
||||||
|
('A+','A','B','C','D','E','F','G','H')),
|
||||||
|
month smallint NOT NULL CHECK (month BETWEEN 1 AND 12),
|
||||||
|
-- Upper bound of the class for that month. 'H' is open-ended in the
|
||||||
|
-- source; store a sentinel high value rather than NULL so that
|
||||||
|
-- classification is a simple "first class whose bound >= value"
|
||||||
|
-- scan with no NULL special-casing.
|
||||||
|
max_kwh_per_m2 numeric(8,3) NOT NULL,
|
||||||
|
source text NOT NULL DEFAULT 'UBA CC 69/2021 Tab. 1 / GEG Anl. 10',
|
||||||
|
UNIQUE (class, month)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CO2 factors per fuel (Module 6), GEG Anlage 9. Versioned because the
|
||||||
|
-- annex gets amended; emissions shown for a given month use the factor
|
||||||
|
-- valid in that month.
|
||||||
|
CREATE TABLE co2_emission_factor (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
fuel_type fuel_type NOT NULL,
|
||||||
|
g_co2_per_kwh numeric(8,2) NOT NULL,
|
||||||
|
valid_from date NOT NULL,
|
||||||
|
valid_to date, -- NULL = currently valid
|
||||||
|
source text NOT NULL DEFAULT 'GEG Anlage 9',
|
||||||
|
UNIQUE (fuel_type, valid_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Energy prices per fuel (Module 5: cost estimate = kWh × price).
|
||||||
|
-- Versioned for the same auditability reason, plus created_by: price
|
||||||
|
-- maintenance is a manual admin process (owner and cadence still an
|
||||||
|
-- open decision), and attributing each price row makes that process
|
||||||
|
-- accountable from day one regardless of how the decision lands.
|
||||||
|
CREATE TABLE energy_price (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
fuel_type fuel_type NOT NULL,
|
||||||
|
ct_per_kwh numeric(8,3) NOT NULL,
|
||||||
|
valid_from date NOT NULL,
|
||||||
|
valid_to date,
|
||||||
|
source text,
|
||||||
|
created_by uuid REFERENCES app_user(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (fuel_type, valid_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- DWD stations we import heating-degree-day data for. Modeled as its own
|
||||||
|
-- entity (not columns on building) because many buildings share a
|
||||||
|
-- station and the HDD import job iterates stations, not buildings.
|
||||||
|
CREATE TABLE weather_station (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
dwd_id text NOT NULL UNIQUE, -- DWD's station identifier
|
||||||
|
name text NOT NULL,
|
||||||
|
lat numeric(9,6),
|
||||||
|
lng numeric(9,6),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE building
|
||||||
|
ADD CONSTRAINT building_weather_station_fk
|
||||||
|
FOREIGN KEY (weather_station_id) REFERENCES weather_station(id);
|
||||||
|
|
||||||
|
-- Monthly heating degree days per station (VDI 3807, DWD open data).
|
||||||
|
-- Consumed ONLY as display context — the Module 1 footnote "this month
|
||||||
|
-- was N% colder/warmer than the same month last year". Actual weather
|
||||||
|
-- normalization of consumption values is explicitly out of scope for
|
||||||
|
-- the UVI (it belongs to annual billing) and measurement values are
|
||||||
|
-- never adjusted by this data.
|
||||||
|
-- Operational note: a monthly DWD import fills this table; if a month
|
||||||
|
-- is missing, the footnote renders "not available" (UVI-FUN-15). The
|
||||||
|
-- import's timing sits inside the NFR-05 chain (data visible by the
|
||||||
|
-- 5th working day).
|
||||||
|
CREATE TABLE heating_degree_days (
|
||||||
|
station_id uuid NOT NULL REFERENCES weather_station(id),
|
||||||
|
year smallint NOT NULL,
|
||||||
|
month smallint NOT NULL CHECK (month BETWEEN 1 AND 12),
|
||||||
|
hdd numeric(7,1) NOT NULL,
|
||||||
|
PRIMARY KEY (station_id, year, month)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Savings tips library (Module 4, UVI-FUN-11). A table, not content in
|
||||||
|
-- code, because tips are editorial material admins curate. The category
|
||||||
|
-- drives seasonal selection (UVI-FUN-14): heating tips during the
|
||||||
|
-- heating period, hot-water tips outside it. active supports retiring
|
||||||
|
-- tips without deleting them (a tip already shown in a past month
|
||||||
|
-- should remain resolvable).
|
||||||
|
CREATE TABLE saving_tip (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
category saving_tip_category NOT NULL,
|
||||||
|
title text NOT NULL,
|
||||||
|
body text NOT NULL,
|
||||||
|
link_url text, -- deep link to advisory content
|
||||||
|
-- (verbraucherzentrale, co2online)
|
||||||
|
active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- 5. TENANT ISOLATION (UVI-NFR-03) — enforcement sketch
|
||||||
|
--
|
||||||
|
-- Isolation is enforced IN the database via row-level security, not only
|
||||||
|
-- in the Go backend: with Supabase Auth issuing JWTs, auth.uid() is
|
||||||
|
-- available inside policies, and a backend bug can then never leak a
|
||||||
|
-- neighbor's data — the worst case becomes an empty result.
|
||||||
|
--
|
||||||
|
-- Example policy shape for measurement (enable analogously per table):
|
||||||
|
--
|
||||||
|
-- ALTER TABLE measurement ENABLE ROW LEVEL SECURITY;
|
||||||
|
-- CREATE POLICY tenant_reads_own ON measurement FOR SELECT
|
||||||
|
-- USING (
|
||||||
|
-- sensor_id IN (
|
||||||
|
-- SELECT s.id
|
||||||
|
-- FROM sensor s
|
||||||
|
-- JOIN occupancy o ON o.apartment_id = s.apartment_id
|
||||||
|
-- WHERE o.user_id = auth.uid()
|
||||||
|
-- AND daterange(o.valid_from,
|
||||||
|
-- COALESCE(o.valid_to, 'infinity'::date),
|
||||||
|
-- '[)') @> measured_at::date
|
||||||
|
-- )
|
||||||
|
-- );
|
||||||
|
--
|
||||||
|
-- The occupancy-window predicate is the important part: a tenant sees
|
||||||
|
-- only measurements from their own tenancy period, so pre-move-in
|
||||||
|
-- history is structurally invisible (the Mieterwechsel rule enforced at
|
||||||
|
-- the storage layer). Admins get a separate permissive policy scoped to
|
||||||
|
-- role = 'admin'; admin reads are additionally logged at the application
|
||||||
|
-- layer (UVI-NFR-07).
|
||||||
|
--
|
||||||
|
-- Deliberately NOT in the schema:
|
||||||
|
-- * The small-building anonymity rule (UVI-FUN-09: with ≤ 4 units,
|
||||||
|
-- show the mean of the three thriftiest households instead of the
|
||||||
|
-- spectrum) — that is presentation logic in the in-house comparison
|
||||||
|
-- query, driven by count(apartment) per building at query time.
|
||||||
|
-- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display
|
||||||
|
-- logic; the data model is season-agnostic.
|
||||||
|
-- ===========================================================================
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Gateway MQTT connectivity — debugging findings (July 2026)
|
||||||
|
|
||||||
|
Why the ACRIOS `ACR_CV_101N_W_D2` gateway (SIM7022 NB-IoT modem) could not
|
||||||
|
connect to `ingest.gebos.online:8883`, how we proved it, and what to do next.
|
||||||
|
|
||||||
|
**TL;DR: the bundled SIM lives in a private ACRIOS APN (`acrios.iot`) with no
|
||||||
|
public-internet breakout. The broker, TLS setup, and gateway firmware are all
|
||||||
|
fine.** No packet from the gateway ever reached our host; every failure
|
||||||
|
happened inside the carrier/APN network.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
The modem's own PDP-context diagnostics (see step 6) show:
|
||||||
|
|
||||||
|
```
|
||||||
|
+CGDCONT: 0,"IP","acrios.iot","10.10.111.184"
|
||||||
|
+CGPADDR: 0,"10.10.111.184"
|
||||||
|
+CGCONTRDP: 0,5,"acrios.iot","10.10.111.184.255.255.255.0",,"10.65.0.1","10.65.0.2",,,,,1430
|
||||||
|
```
|
||||||
|
|
||||||
|
- APN `acrios.iot` — a private APN; the SIM (IMSI prefix 901-28, a global
|
||||||
|
IoT-roaming range) is provisioned for ACRIOS's walled garden only.
|
||||||
|
- Private device IP (`10.10.111.184`) and private DNS servers (`10.65.0.x`).
|
||||||
|
- Result: DNS for public names fails (`+CMQTTCONNECT: 0,25`) and raw TCP to a
|
||||||
|
public IP fails (`+CMQTTCONNECT: 0,3`). Signalling-plane features (network
|
||||||
|
registration, NITZ time sync, CSQ) all work, which made the SIM *look* fine.
|
||||||
|
- `APN = "auto"` in the Lua config is not the bug: `acrios.iot` is almost
|
||||||
|
certainly the only APN this SIM subscription allows, so overriding the APN
|
||||||
|
string alone cannot fix it.
|
||||||
|
|
||||||
|
This also explains the firmware's `acrios/` topic prefix — these gateways are
|
||||||
|
normally sold talking to ACRIOS's own MQTT cloud inside that APN.
|
||||||
|
|
||||||
|
## Debugging steps (in order, with what each eliminated)
|
||||||
|
|
||||||
|
1. **Verified the broker from the outside.** From a dev machine:
|
||||||
|
`openssl s_client -connect ingest.gebos.online:8883` (valid Let's Encrypt
|
||||||
|
RSA-2048 chain, TLS 1.2 + 1.3 OK) and
|
||||||
|
`mosquitto_pub ... -u bender -P <device pw>` → `CONNACK 0`, publish
|
||||||
|
accepted. ⇒ EMQX, its ACME cert, and the device credentials all work; the
|
||||||
|
problem is specific to the gateway's path.
|
||||||
|
|
||||||
|
2. **Read the serial log symptom precisely.** `AT+CMQTTCONNECT` returned `OK`
|
||||||
|
(command accepted) but the result URC never arrived before the script's
|
||||||
|
20 s timeout — indistinguishable from a TLS stall at that point.
|
||||||
|
|
||||||
|
3. **Checked EMQX for handshake errors.** `docker logs` showed nothing — but a
|
||||||
|
deliberately failed cipher probe from the dev machine *also* logged nothing,
|
||||||
|
proving EMQX's default log level swallows TLS failures. Inconclusive, not
|
||||||
|
exonerating. (`emqx ctl listeners` shutdown counters were similarly
|
||||||
|
ambiguous; `emqx ctl log set-level debug` enables visibility.)
|
||||||
|
|
||||||
|
4. **tcpdump on the ingest host** (`tcpdump -i any 'tcp port 8883'`) while the
|
||||||
|
gateway retried: zero packets from the gateway, ever. ⇒ nothing above the
|
||||||
|
IP layer (TLS version, ciphers, cert chain, EMQX config) could be the cause.
|
||||||
|
|
||||||
|
5. **Script changes to force better diagnostics** (v1.8 → v1.10, see below):
|
||||||
|
- `mqtt_timeout` 20 s → 60 s: the modem finally had time to report real
|
||||||
|
result codes instead of being abandoned mid-attempt.
|
||||||
|
⇒ surfaced `+CMQTTCONNECT: 0,25` = **DNS error**.
|
||||||
|
- `mqtt_server` hostname → IP literal `178.104.178.108`: bypassed DNS.
|
||||||
|
⇒ error changed to `+CMQTTCONNECT: 0,3` = **socket connect fail**.
|
||||||
|
DNS *and* TCP both dead, registration fine → no data breakout.
|
||||||
|
- `authmode 1 → 0` (skip server-cert verification): ruled out the modem
|
||||||
|
rejecting our CA chain; made no difference (never reached TLS). Note the
|
||||||
|
stock ACRIOS script enables cert verification whenever an MQTT username
|
||||||
|
is set, yet configures no CA — with this SIM it never got far enough to
|
||||||
|
matter, but it could never have verified successfully anyway.
|
||||||
|
- Added PDP-context dump (`AT+CGDCONT?`, `AT+CGPADDR`, `AT+CGCONTRDP`)
|
||||||
|
before every connect attempt. ⇒ produced the root-cause evidence above.
|
||||||
|
|
||||||
|
6. **SIMCom CMQTT result codes seen** (for future reference):
|
||||||
|
`+CMQTTCONNECT: 0,25` = DNS error · `0,3` = socket connect fail ·
|
||||||
|
`+CMQTTPUB: 0,26` = socket closed / not connected (follow-on noise, not a
|
||||||
|
distinct fault). No URC before timeout usually means the command was still
|
||||||
|
waiting — raise the wait, don't guess.
|
||||||
|
|
||||||
|
## Current state of `mbus_mqtt_gebos.lua` (v1.10) — temporary changes to revert
|
||||||
|
|
||||||
|
| Change | Why it's there | Revert when |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `mqtt_server = "178.104.178.108"` | bypass private-APN DNS | breakout exists **and** public DNS resolves |
|
||||||
|
| `AT+CSSLCFG="authmode",0,0` | no CA provisioned on modem | ISRG Root X1 loaded (`AT+CCERTDOWN`) + `cacert` configured → set authmode 1 |
|
||||||
|
| `mqtt_timeout = 60000` | see real modem result codes | after measuring real handshake time on a working network |
|
||||||
|
| PDP-context dump in `MQTT_SSL_Start()` | root-cause evidence | once connectivity is stable (costs battery/airtime per connect) |
|
||||||
|
|
||||||
|
## Options to get data flowing
|
||||||
|
|
||||||
|
1. **Preferred: ask ACRIOS** (SIM contract holder) to enable internet breakout
|
||||||
|
on the APN, or whitelist `ingest.gebos.online` / `178.104.178.108:8883`
|
||||||
|
for our SIMs. Hardware untouched.
|
||||||
|
2. **Own SIM:** any IoT SIM with public breakout (1NCE, EMnify, local carrier
|
||||||
|
NB-IoT, …); set `APN = "<provider apn>"` explicitly in the Lua config.
|
||||||
|
Check provider NB-IoT coverage on bands 3/5/8/20 (what the firmware
|
||||||
|
configures).
|
||||||
|
3. Fallback: ACRIOS cloud bridge/forwarding — reintroduces the vendor
|
||||||
|
dependency the self-hosted ingest stack exists to avoid.
|
||||||
|
|
||||||
|
## Verified-good along the way
|
||||||
|
|
||||||
|
- EMQX 5.8.6 (oci-container, host network) serving MQTTS :8883 with the ACME
|
||||||
|
(HTTP-01) RSA-2048 cert; hot-reloads renewed PEMs.
|
||||||
|
- Broker auth: `bender` device login + ACL, external end-to-end publish test.
|
||||||
|
- Note for MQTTX/dashboard tests: EMQX default log level logs neither TLS
|
||||||
|
handshake failures nor connects; use `emqx ctl log set-level debug`
|
||||||
|
temporarily, and `emqx ctl listeners` for connection/shutdown counters.
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user