Technische Dokumentation Thingsboard Frost Adapter

Inhaltsverzeichnis

Willkommen in der technischen Dokumentation des Thingsboard Frost Adapter.

Einleitung

Der Thingsboard Frost Adapter ist ein FastAPI‑Microservice, der Gerätedaten aus ThingsBoard in einen FROST‑Server (Referenzimplementierung der OGC SensorThings API) überführt. Er bildet Geräte, Standorte, Geräteprofile und Telemetrie‑Felder aus ThingsBoard auf die entsprechenden SensorThings‑API‑Entitäten ab (siehe Datenmodell: ThingsBoard auf SensorThings abbilden) und legt für jede eingehende Telemetrie‑Nachricht eine Observation im FROST‑Server an.

Der Adapter ist mandantenfähig: Er bedient mehrere Mandanten gleichzeitig, und ein Mandant ist die Zusammenfassung eines ThingsBoard‑Mandanten mit genau einem FROST‑Server. Adressiert wird ein Mandant über seinen unveränderlichen Slug in der Ingest‑URL.

Einen Überblick über die beteiligten Komponenten und den Datenfluss liefert die Seite Architekturüberblick.

Aufbau dieser Dokumentation

Die technische Dokumentation gliedert sich in neun weitere Seiten. Sie stehen in Leserichtung: die ersten fünf beschreiben den Adapter, die letzten beiden richten sich an andere Leser.

Einstieg

Architektur

Daten und Regeln

Referenz und Betrieb

Wer die Software benutzt statt sie zu ändern, findet die schrittweisen Anleitungen in der Nutzerdokumentation.

Repository und Entwicklungsumgebung

Zweck

Diese Seite beantwortet die Fragen, die sich beim ersten Öffnen des Repositories stellen: Was liegt hier, wie bekomme ich es zum Laufen, wie lasse ich die Tests laufen, und welche Einstellung gehört in eine Umgebungsvariable statt in die Datenbank. Der innere Aufbau des Adapters steht in Architektur im Detail, die Abläufe des Betriebs in Betriebsanleitungen.

Was im Repository liegt

Pfad Inhalt

src/thingsboard_frost_adapter/

Der Adapter selbst — ein FastAPI‑Dienst, in vier Schichten gegliedert (Architektur im Detail: Modulschichten).

src/frontend/

Die Streamlit‑Verwaltungsoberfläche. Eine eigenständige Anwendung mit eigenem Container und eigenem Port (Frontend (Streamlit)).

tests/

unit/ und integration/, getrennt nach Abhängigkeit und nicht nach Umfang (siehe Tests).

docs/

Diese Dokumentation als Antora‑Komponente mit den beiden Modulen tech_doc und user_doc.

rulechain/

thingsboard_frost_adapter.json — die exportierte ThingsBoard Rule Chain, die Telemetrie an den Adapter schickt. Wird je Mandant von Hand importiert und angepasst (Rule Chain je Mandant verteilen).

initdb/

20-frost-b.sql — legt die Datenbank des zweiten FROST‑Servers an. Wird nur beim Erstellen des Volumes ausgeführt (siehe Die Entwicklungsumgebung).

keycloak-imports/

Der Realm‑Export, den Keycloak beim Start mit --import-realm einliest.

docker-compose.yml
docker-compose.app.yml

Infrastruktur bzw. Anwendung — zwei Dateien mit einem gemeinsamen Projektnamen (siehe Die Entwicklungsumgebung).

Dockerfile

Ein mehrstufiger Build mit den Zielen backend und frontend (siehe Container‑Images).

.env.example, .streamlit/secrets-example.toml

Vorlagen für die beiden Konfigurationsdateien, die nicht eingecheckt sind (siehe Konfiguration).

Unterhalb von src/ liegen damit zwei Pakete nebeneinander: der Adapter und das Frontend. Sie teilen sich das Poetry‑Projekt und eine Abhängigkeitsliste, laufen aber als zwei getrennte Prozesse und sprechen ausschließlich über die REST‑API des Adapters miteinander.

Die Entwicklungsumgebung

Die Umgebung ist auf zwei Compose‑Dateien verteilt, die denselben Projektnamen (name: frost_allinone) und damit dasselbe Netz benutzen:

Datei Dienste

docker-compose.yml

Die Infrastruktur: db_adapter (Postgres des Adapters, 5432), database (PostGIS für die FROST‑Server), frost_server (8080), frost_server_b (8081), keycloak_web (8180), db_keycloak, redis (6379), thingsboard (9090). Die Testmandanten darin legt die Testsuite an, kein eigener Dienst — siehe Das lokale ThingsBoard.

docker-compose.app.yml

Die Anwendung: thingsboard_frost_adapter (8000) und frontend (8501).

Für die tägliche Arbeit reicht die erste Datei — Adapter und Frontend laufen dann lokal aus dem Poetry‑Environment gegen die Container:

docker compose -f docker-compose.yml up

Zwei FROST‑Server sind Absicht

Die Entwicklungsumgebung enthält zwei FROST‑Instanzen, weil die Integrationstests die Mandantentrennung prüfen und dafür zwei Mandanten brauchen. frost_server_b ist bewusst dauerhaft und nicht optional: Ohne CI‑Job ist ein Teststapel, den man erst einschalten muss, ein Teststapel, den nichts jemals startet. Die Mehrkosten sind ein Container — er teilt sich den Datenbankserver (als zweite Datenbank) und den Keycloak‑Realm mit frost_server und braucht dort nur einen eigenen Client.

Beide Instanzen laufen auf demselben festgeschriebenen Image‑Tag (FROST_SERVER_TAG), damit sie nicht unbemerkt auf verschiedene FROST‑Versionen auseinanderlaufen (Image‑Tag).

Beim Hinzufügen eines FROST‑Servers muss das Volume neu erstellt werden. Die Datei initdb/20-frost-b.sql legt die Datenbank des zweiten Servers an, und Init‑Dateien führt Postgres ausschließlich dann aus, wenn das Datenverzeichnis leer ist. Eine später hinzugefügte Datenbank entsteht deshalb erst nach:

docker compose down -v
docker compose -f docker-compose.yml up

Die Datei ist zudem als einzelne Datei eingebunden und nie als Verzeichnis: Das Image bringt in demselben Verzeichnis sein eigenes 10_postgis.sh mit, das ein Verzeichnis‑Mount verdecken würde — womit auch der heute funktionierende FROST seine PostGIS‑Erweiterung verlöre.

Das lokale ThingsBoard

Der Stapel enthält eine eigene ThingsBoard‑Installation (thingsboard, Port 9090) — die Community Edition, weil es die Professional Edition nicht als frei beziehbares Image gibt. Genau das ist auch der Grund, aus dem es THINGSBOARD_EDITION überhaupt gibt: Die Community Edition kennt keine API‑Key‑Authentifizierung, der lokale Stapel läuft deshalb mit ce und Benutzername/Passwort.

Der Image‑Tag (THINGSBOARD_CE_TAG) ist aus einem härteren Grund festgeschrieben als bei FROST: ThingsBoard legt sein Schema einmal an, nämlich auf einem leeren Volume, und migriert beim Start nicht. Ein wandernder Tag lässt deshalb früher oder später ein neueres Image auf ein älteres Schema treffen — ein Fehler, der sich nicht von selbst meldet. Das Volume thingsboard_data bleibt erhalten, weil der erste Start die Schema‑Installation mitmacht und gut eine Minute dauert, während jeder weitere in Sekunden bereit ist.

docker compose up hinterlässt eine leere ThingsBoard‑Installation; die beiden Testmandanten legt die Testsuite an. Das Fixture thingsboard_provisioned in tests/integration/conftest.py ruft dazu einmal je Lauf provision() aus tests/integration/thingsboard_data.py auf. Ein eigener Bereitstellungs‑Container dafür wäre eine zweite Stelle, die dasselbe entscheidet: Die Bereitstellung ist ohnehin aus jedem Zustand heraus wiederholbar — Vorhandenes bleibt unangetastet, Fehlendes entsteht neu, und ein Mandantenadmin, dessen Passwort nicht mehr das aus .env ist, wird samt Mandant neu aufgebaut, weil ThingsBoard keinen Weg bietet, ein Passwort von der Sysadmin‑Seite aus zu setzen. Die Zugangsdaten in .env sind damit verbindlich und nicht bloß erhofft.

Der Healthcheck meldet sich als Sysadmin an — er tut also genau das, was die Bereitstellung als Erstes tut, und docker compose up -d --wait im Session‑Fixture macht daraus die Schranke, hinter der der Lauf beginnen darf. Alles Billigere wurde gemessen und lügt: Auf einem leeren Volume beantwortet die Installationsphase rund 35 Sekunden lang HTTP‑Anfragen, GET /login mit 200, ohne dass die API existiert; danach ist der Server für weitere rund 30 Sekunden ganz weg, während ThingsBoard in den eigentlichen Betrieb neu startet. Ein Port‑ oder /login‑Test meldete „bereit" deshalb zweimal falsch, und der erste Test liefe auf jedem frischen Rechner hinein — was wie eine unzuverlässige Testsuite aussähe und keine ist.

Der veröffentlichte Port 8000

Die Portfreigabe 8000:8000 des Dienstes thingsboard_frost_adapter in docker-compose.app.yml ist eine reine Entwicklungshilfe und darf in der echten Installation nicht vorhanden sein. Der Adapter authentifiziert niemanden; die Unerreichbarkeit über das Netz ist die Sicherheitsgrenze. Die Begründung und die Folgen stehen in Unerreichbarkeit ist die Sicherheitsgrenze.

Lokal starten

Abhängigkeiten installieren — das installiert auch das Projekt selbst und nicht nur seine Abhängigkeiten, was den src‑Layout‑Import überhaupt erst auflöst:

poetry install

Backend:

poetry run uvicorn thingsboard_frost_adapter.main:app --reload

Frontend — setzt ein laufendes Backend voraus:

poetry run streamlit run src/frontend/main.py

Beim Start legt bootstrap() das Schema an und füllt die Stammdaten. Es spricht dabei kein externes System an und prüft insbesondere keinen Mandanten (Architekturüberblick).

Tests

Die oberste Achse von tests/ ist die Abhängigkeit, nicht der Umfang.

Baum Voraussetzung

tests/unit/

Nichts. Läuft aus einem blanken Checkout in deutlich unter einer Sekunde und bleibt damit brauchbar, während der Compose‑Stapel unten ist.

tests/integration/

Der Compose‑Stapel, und sonst nichts. Das ThingsBoard, gegen das sie laufen, steht mit darin (Das lokale ThingsBoard); kein Test erreicht ein System außerhalb.

-m staging

Zusätzlich Netzzugang zum gemeinsamen Staging‑System und ein Lauf, der von außen auf pe konfiguriert ist — siehe Der Staging‑Lauf. Standardmäßig abgewählt.

poetry run pytest                    # alles, was die aktuelle Konfiguration erreichen kann
poetry run pytest -m integration     # nur die Integrationstests, nur gegen den Stapel

Kein Test setzt eine Umgebungsvariable und keiner verbiegt config. Gegen welches ThingsBoard die Suite läuft und mit welcher Edition, steht in .env und wird von außen gesetzt — es sind prozessweite Tatsachen, und ein Test, der sie verschöbe, würde über ein Deployment urteilen, bei dem sich die ihm gereichten Mandantenzeilen gar nicht anmelden könnten. Aus demselben Grund ist der Staging‑Lauf ein zweiter Aufruf und keine Verzweigung im Code.

--teardown führt am Ende docker compose down -v aus, und das -v nimmt das ThingsBoard‑Volume mit. Der nächste Lauf zahlt dann die Kaltstart‑Installation des Schemas: rund zwei Minuten, bevor der erste Test beginnt. Das ist kein Hänger. Ohne die Option wird nichts zerstört und eine warme Instanz ist in Sekunden bereit.

Der Staging‑Lauf

Zwei Tests, beide reiner Gutfall: Ein Gerät entsteht in FROST aus seinem ThingsBoard‑Datensatz, und Telemetrie landet als Observation an ihrem Datastream. Mehr nicht — alles andere ist lokal bewiesen, und es hier zu wiederholen kostete nur Schreibzugriffe auf ein System, das andere gerade benutzen.

Es ist die einzige Deckung, die der pe‑Zweig noch hat: Die Community Edition kennt keine API‑Key‑Authentifizierung, also sind Anmeldeverfahren, Client‑Klasse und Credential‑Form dort von der ersten bis zur letzten Zeile ein anderer Weg — und das Deployment, gegen das dieser Adapter tatsächlich läuft, ist PE.

Vorlage ist .env.staging.example; die ausgefüllte .env.staging steht in .gitignore.

set -a; source .env.staging; set +a
poetry run pytest -m staging

set -a exportiert, was die Datei setzt, und eine exportierte Variable gewinnt gegen .env: Der Adapter lädt diese mit load_dotenv(), das Vorhandenes nie überschreibt. Die lokalen Vorgaben in .env bleiben also unangetastet. Der Compose‑Stapel muss trotzdem laufen — die FROST‑Server, die diese Tests lesen, sind die lokalen; geprüft wird die ThingsBoard‑Hälfte.

Der Lauf schreibt die Registry‑Zeilen der beiden Testmandanten auf PE‑Credentials um: Sie tragen danach einen API‑Key statt Benutzername und Passwort. Das ist kein Schaden, sondern der dokumentierte Editionswechsel — der nächste lokale Lauf registriert sie ebenso selbstverständlich wieder zurück. Eine Zeile mit der Credential‑Form der jeweils anderen Edition wird dabei gelöscht und neu angelegt, weil die Registry jede Änderung an ihr zu Recht verweigert.

Welcher Test wohin gehört, regelt die Spiegelregel in Architektur im Detail; ihr verbindlicher Wortlaut steht im Docstring von tests/unit/init.py.

Konfiguration

Die zentrale Unterscheidung: Eine Umgebungsvariable ist ein Wert, den der Prozess nur einmal halten kann. Alles, was sich je Mandant unterscheidet — die FROST‑Server‑URL, deren Keycloak‑Client, die ThingsBoard‑Zugangsdaten — steht in der Tabelle tenant und nicht in der Konfiguration. Die ThingsBoard‑Edition ist genau deshalb Konfiguration: Sie ist eine Eigenschaft der einen Installation hinter THINGSBOARD_URL, nicht der einzelnen Mandantenzeile.

Der Keycloak‑Zugang ist die Ausnahme, die die Regel bestätigt: Es gibt einen globalen Adapter‑Benutzer, dem die Client‑Rollen des FROST‑Clients jedes Mandanten zugewiesen sind. Deshalb sind Realm, Benutzername und Passwort deployment‑weit, und nur keycloak_client_id ist mandantenspezifisch.

.env — der Adapter

Variable Bedeutung

EXTERNAL_IP

Die von den Containern aus erreichbare Adresse des Hosts. Fehlt KEYCLOAK_URL, wird daraus http://<EXTERNAL_IP>:8180/ gebildet.

KEYCLOAK_REALM_NAME_FROST_SERVER
KEYCLOAK_USERNAME_FROST_SERVER
KEYCLOAK_PASSWORD_FROST_SERVER

Realm und Zugangsdaten des einen globalen Adapter‑Benutzers.

THINGSBOARD_URL

Die eine ThingsBoard‑Installation. Ein Deployment, viele ThingsBoard‑Mandanten — welcher davon gelesen wird, entscheiden die Zugangsdaten aus der Mandantenzeile. Lokal ist das http://localhost:9090, also das ThingsBoard aus dem Stapel; der Bereitstellungs‑Container bekommt denselben Namen auf den Dienstnamen gesetzt, weil localhost dort er selbst wäre.

THINGSBOARD_EDITION

Welche ThingsBoard‑Edition hinter THINGSBOARD_URL steht: pe (Standard) oder ce. Sie entscheidet, womit sich jede Mandantenzeile anmeldet — pe mit einem API‑Key, ce mit Benutzername und Passwort, weil die Community Edition keine API‑Key‑Authentifizierung kennt. Konfiguriert wird die Edition und nicht das Anmeldeverfahren: So lässt sich die Kombination, die nicht funktionieren kann (ce plus API‑Key), gar nicht erst hinschreiben. Zum Wechsel siehe Wechsel der ThingsBoard‑Edition.

TENANT_SECRET_KEY

Der Fernet‑Schlüssel, mit dem jedes ThingsBoard‑Geheimnis eines Mandanten — API‑Key unter PE, Passwort unter CE — in der Datenbank verschlüsselt ist. Pflicht, ohne Standardwert und ohne Klartext‑Rückfallweg, damit die Verschlüsselung sich nicht still abschalten kann. Zum Verlustfall siehe Verlust des Verschlüsselungsschlüssels.

THINGSBOARD_FROST_ADAPTER_DB_*

Name, Benutzer, Passwort, Host und Port der Adapter‑Datenbank.

REDIS_URL, LOG_LEVEL

Redis‑Verbindung für die verteilten Sperren; Log‑Stufe.

FROST_SERVER_TAG

Das Image‑Tag aller FROST‑Instanzen. Niemals latest.

THINGSBOARD_CE_TAG

Das Image‑Tag des lokalen ThingsBoard. Niemals latest, und zwar zwingender als bei FROST — siehe Das lokale ThingsBoard.

THINGSBOARD_TEST_USERNAME_A
THINGSBOARD_TEST_PASSWORD_A
THINGSBOARD_TEST_USERNAME_B
THINGSBOARD_TEST_PASSWORD_B

Nur für die Tests: die Mandantenadmins der beiden Mandanten im lokalen ThingsBoard. Sie sind nirgends nachzuschlagen — die Bereitstellung legt die Benutzer mit genau diesen Werten an (siehe Das lokale ThingsBoard).

.env.staging — nur der Staging‑Lauf

Wird ausschließlich für pytest -m staging in die Shell geladen und sonst von nichts gelesen (Der Staging‑Lauf). Sie nennt THINGSBOARD_URL und THINGSBOARD_EDITION="pe" für das gemeinsame System sowie THINGSBOARD_TEST_API_KEY_A und THINGSBOARD_TEST_API_KEY_B — je einen schreibberechtigten Schlüssel aus zwei verschiedenen ThingsBoard‑Mandanten. Landen beide im selben Mandanten, verweigert die Suite den Dienst: Jede Isolationseigenschaft, die sie prüft, wäre dann nicht mehr widerlegbar. Der Adapter selbst liest die Schlüssel nie.

Einen neuen Schlüssel erzeugt:

python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

.streamlit/secrets.toml — das Frontend

Das Frontend liest seine eigene Datei, nicht .env. Entscheidend ist frost_adapter_backend_base_url — lokal http://localhost:8000, im Container http://thingsboard_frost_adapter:8000 — sowie der [auth]‑Block für die Keycloak‑Anmeldung. Vorlage ist .streamlit/secrets-example.toml; die Details stehen in Frontend (Streamlit).

Weder .env noch .streamlit/secrets.toml sind eingecheckt; beide stehen in .gitignore. Eingecheckt sind nur die beiden Beispieldateien.

Container‑Images

Der Dockerfile ist mehrstufig und endet in drei Zielen, die sich eine gemeinsame runtime‑Stufe teilen:

Ziel Ergebnis

backend

uvicorn thingsboard_frost_adapter.main:app auf Port 8000.

frontend

streamlit run src/frontend/main.py auf Port 8501.

Die CI baut beide getrennt (build_and_publish_backend_docker_image, build_and_publish_frontend_docker_image) und veröffentlicht anschließend diese Dokumentation als GitLab Pages.

Architekturüberblick

Komponenten im Überblick

                    ┌──────────────┐
                    │   Keycloak   │  Authentifizierung für alle Pfeile
                    └──────────────┘

  ┌──────────────┐   Rule Chain    ┌───────────────────────┐
  │  ThingsBoard │ ──────────────► │                       │
  │  (Mandant A) │   REST /ingest  │                       │       ┌──────────────┐
  └──────────────┘                 │                       │ ────► │ FROST-Server │
                                   │  Thingsboard Frost    │       │  (Mandant A) │
  ┌──────────────┐                 │       Adapter         │       └──────────────┘
  │  ThingsBoard │ ──────────────► │                       │       ┌──────────────┐
  │  (Mandant B) │                 │                       │ ────► │ FROST-Server │
  └──────────────┘                 └───────────────────────┘       │  (Mandant B) │
                                     │        │        │           └──────────────┘
  ┌──────────────┐   REST            │        │        │
  │  Frontend    │ ──────────────────┘        │        │
  │ (Streamlit)  │                            ▼        ▼
  └──────────────┘                     ┌───────────┐ ┌───────┐
                                       │ Postgres  │ │ Redis │
                                       │ (Adapter) │ │Sperren│
                                       └───────────┘ └───────┘
Komponente Aufgabe

ThingsBoard

Quelle der Gerätedaten. Eine Rule Chain leitet Telemetrie‑ sowie Geräte‑/Asset‑Änderungsereignisse per REST an den Adapter weiter, sobald ein Gerät der Gruppe publish-to-frost hinzugefügt wurde.

Thingsboard Frost Adapter

Nimmt die Ereignisse aus ThingsBoard entgegen, legt bei Bedarf die zugehörigen SensorThings‑Entitäten im FROST‑Server an bzw. aktualisiert sie, und wendet dabei die Geschäftsregeln auf die Stammdaten an.

Adapter‑Datenbank (PostgreSQL)

Persistiert die lokal verwalteten Stammdaten UnitOfMeasurement, UnitOfMeasurementKey und TelemetryKey, welche die Zuordnung von ThingsBoard‑Telemetrie zu SensorThings‑Begriffen definieren. Dazu die kanonischen ObservedProperty‑Zeilen und die Mandanten‑Registry — alle sechs Tabellen stehen in Datenbankschema des Adapters.

Frontend (Streamlit)

Weboberfläche zur Pflege der Stammdaten über die REST‑API des Adapters (siehe Zuordnungen pflegen: ThingsBoard‑Telemetrie → SensorThings API). Der Aufbau der Anwendung steht in Frontend (Streamlit).

FROST‑Server

Referenzimplementierung der OGC SensorThings API; persistiert Things, Locations, Sensors, ObservedProperties, Datastreams und Observations in einer eigenen PostGIS‑Datenbank (siehe Schnittstellenbeschreibung SensorThings API (FROST‑Server)).

Redis

Wird für verteilte Sperren (redis_lock) verwendet, um beim gleichzeitigen Eintreffen mehrerer Telemetrie‑Nachrichten eines Geräts eine doppelte Anlage derselben Entität in FROST zu verhindern.

Keycloak

Stellt die Authentifizierung sowohl für den Zugriff auf den FROST‑Server als auch für Frontend und Adapter bereit.

Aufbau des Adapters

src/thingsboard_frost_adapter/ ist in vier Schichten gegliedert. Der Pfad eines Moduls sagt, was es tut, bevor man die Datei öffnet:

Schicht Aufgabe

routers/

Die HTTP‑Oberfläche: Pfade, Statuscodes, Response‑Modelle. Eine Route ist ein Service‑Aufruf.

services/

Die Entscheidungen: Geschäftsregeln, Reihenfolgen, Fehlerpolitik – und der Ort, an dem sich zwei Repositories treffen.

repositories/db/, repositories/external/

Genau ein Zugriffsweg auf genau ein System: je Modul eine Postgres‑Tabelle (db/) bzw. ein Fremdsystem (external/: FROST, ThingsBoard).

clients/

Der Aufbau einer Verbindung zu System X für Mandant Y, samt Authentifizierung und deren Zwischenspeicherung.

Importe zeigen ausschließlich nach unten: routers/services/repositories/db/, repositories/external/ und clients/; dazu repositories/external/clients/. Unterhalb von services/ importiert nichts eine Schicht über sich, und ein Repository importiert kein anderes Repository. db/, redis/, model/, exceptions/, configuration/ und bootstrap/ sind keine Schichten, sondern Querschnittspakete und dürfen von überall importiert werden.

Die Regel wird ausschließlich in Prosa durchgesetzt – es gibt kein Lint‑ oder CI‑Werkzeug dafür. Die Begründungen, die Verträge zwischen den Schichten und die beiden benannten Ausnahmen stehen in Architektur im Detail.

Datenfluss einer Telemetrie‑Nachricht

  Rule Chain
      │  POST /ingest/{slug}/telemetry
      ▼
  Slug ──► Mandantenzeile ──► Clients dieses Mandanten
      │
      ▼
  Thing vorhanden?
      │ nein ──► Thing, Location, Sensor, Datastreams anlegen
      │            (Gerätedaten aus ThingsBoard + Zuordnungen aus Postgres)
      ▼ ja
  je Telemetrie-Schlüssel mit Zuordnung ──► Observation im Datastream
  1. ThingsBoard sendet über die Rule Chain eine Telemetrie‑Nachricht (Nutzdaten + Metadaten wie deviceName und Zeitstempel) an den Endpunkt POST /ingest/{slug}/telemetry des Adapters. Der slug ist der in der Tenant‑Registry hinterlegte Bezeichner des Mandanten und bestimmt, in welchen FROST‑Server geschrieben wird; einen mandantenfreien Endpunkt gibt es nicht.

  2. Der Adapter prüft, ob für das Gerät bereits ein Thing in FROST existiert; falls nicht, werden Thing, Location, Sensor und die passenden Datastreams anhand der in ThingsBoard gepflegten Gerätedaten und der lokal verwalteten Telemetrie‑Zuordnungen angelegt.

  3. Für jeden in der Nachricht enthaltenen Telemetrie‑Schlüssel, für den eine Zuordnung existiert, wird eine Observation im zugehörigen Datastream angelegt.

Änderungen oder das Löschen von Geräten bzw. Standort‑Assets in ThingsBoard werden über die Endpunkte POST /ingest/{slug}/device/update, POST /ingest/{slug}/device/delete und POST /ingest/{slug}/asset/update entsprechend in FROST nachgezogen.

Erreichbarkeit eines Mandanten

Ob die Abhängigkeiten eines Mandanten funktionieren, wird nicht persistiert, sondern auf Anfrage in Echtzeit überprüft.

Was geprüft wird

Eine Prüfung besteht aus drei Aufrufen — ein Keycloak‑Token mit dem keycloak_client_id des Mandanten, ein GET auf die FROST‑Service‑Root und eine authentifizierte Abfrage gegen ThingsBoard mit den entschlüsselten Zugangsdaten des Mandanten sowie einer OData‑Abfrage Observations?$orderby=phenomenonTime desc&$top=1 gegen den FROST des Mandanten.

Wer sie auslöst

Diese Prüfung hat drei Aufrufer:

  • POST /tenant und PATCH /tenant/{id} führen sie vor dem Schreiben aus und antworten bei einem Fehlschlag mit 400 unter Nennung der fehlgeschlagenen Prüfung, ohne eine Zeile anzulegen oder zu ändern. Ein fehlerhafter Zugangsdatensatz fällt damit in dem einen Moment auf, in dem ein Mensch auf das Formular schaut und nicht bei der ersten Telemetrie‑Nachricht, deren Fehlschlag die Rule Chain still verwirft. Die Folge ist: ein Mandant lässt sich nicht registrieren, solange sein FROST kurzzeitig nicht erreichbar ist.

  • GET /tenant/{id}/health und GET /tenant/health liefern das Ergebnis als TenantHealth.

  • Die Tenants‑Seite des Frontends ruft sie pro Zeile über Test connection bzw. für alle Zeilen über Check all auf. Beim Rendern wird nicht geprüft, um die Ladezeit der Seite nicht zu verlängern.

Beim Start wird nichts geprüft

Der Start des Adapters prüft nichts davon: bootstrap() legt das Schema an, füllt die Stammdaten in Postgres, protokolliert die registrierten Slugs und spricht kein externes System an. Die Registry beim Start durchzugehen, würde den Start von N FROST‑Servern abhängig machen.

Fehler mitten in einer Nachricht

Was die Prüfung nicht sehen kann

Fehler, bei denen die Zugangsdaten gültig bleiben — ein FROST, der den Login akzeptiert, aber ein POST Observations ablehnt, oder ein Gerät, das zwischen Rule Chain und Adapter in ThingsBoard verschwindet — sind für die Prüfung konstruktionsbedingt unsichtbar. Da nichts mitgeschrieben wird, ist das Log der einzige Nachweis, und daher gilt: keine Ausnahme verlässt den Telemetrie‑ oder Gerätepfad ohne eine Logzeile mit Mandanten‑Slug und Entitätsname. Ein nicht erreichbares Zielsystem bleibt dabei ein 502, jeder andere Fehler mitten in der Nachricht wird zu 500.

Alarmiert wird in ThingsBoard

Alarmiert wird in ThingsBoard, nicht im Adapter: die vier TbRestApiCallNode der Rule Chain sind über ihre Failure‑Relation mit je einem TbCreateAlarmNode verbunden, sodass ein 500 oder 502 einen Alarm auf dem auslösenden Gerät im ThingsBoard des jeweiligen Mandanten erzeugt. Der Adapter selbst versendet keine E‑Mails, Webhooks oder Benachrichtigungen — er ist absichtlich von außerhalb des Docker‑Netzes nicht erreichbar.

Architektur im Detail: Modulschichten

Zweck

Dieser Abschnitt beschreibt den inneren Aufbau von src/thingsboard_frost_adapter/: die vier Schichten, die Regel, in welche Richtung Importe zeigen, und die Verträge, die die Schichten einander geben. Der Architekturüberblick beschreibt die Komponenten um den Adapter herum; hier geht es um seinen Inhalt.

Das Schichtschema gilt für src/thingsboard_frost_adapter/ und nur dort. Daneben liegt unter src/frontend/ eine zweite, eigenständige Anwendung mit eigener, flacherer Gliederung; sie greift ausschließlich über die REST‑API auf den Adapter zu und ist in Frontend (Streamlit) beschrieben.

Die vier Schichten

Schicht Besitzt Darf importieren

routers/

Die HTTP-Oberfläche: Pfade, Statuscodes, Response-Modelle, das Auslesen des Request-Bodys. Eine Route ist ein Service-Aufruf.

von den Schichten nur services/; dazu model/, exceptions/ und Geschwister aus routers/ (base_routes, errors)

services/

Die Entscheidungen: Geschäftsregeln, Reihenfolgen, Fehlerpolitik — und der Ort, an dem sich zwei Repositories treffen.

repositories/db/, repositories/external/, clients/ sowie andere Module aus services/

repositories/db/
repositories/external/

Genau einen Zugriffsweg auf genau ein System: je Modul eine Postgres-Tabelle (db/) bzw. ein Fremdsystem (external/: FROST, ThingsBoard).

clients/, db/, redis/, model/, exceptions/; in db/ zusätzlich crud

clients/

Den Aufbau einer Verbindung zu System X für Mandant Y, samt Authentifizierung und deren Zwischenspeicherung.

configuration/, model/ und Geschwister aus clients/ — sonst nichts aus dem Projekt

Der Zugehörigkeitstest, wenn bei einem neuen Modul unklar ist, wohin es gehört:

  • Brauche ich Request, einen Statuscode oder ein Response-Modell? → routers/

  • Wird hier etwas entschieden, oder treffen sich hier zwei Repositories? → services/

  • Ist das eine Anfrage an ein System, deren Antwort ohne Regelwissen zustande kommt? → repositories/

  • Ist das der Aufbau einer Verbindung oder einer Authentifizierung? → clients/

db/, redis/, model/, exceptions/, configuration/ und bootstrap/ sind keine Schichten, sondern Querschnittspakete. Sie dürfen von überall importiert werden.

Die Abhängigkeitsregel

Importe zeigen ausschließlich nach unten.

                 routers/
                    │  nur services/
                    ▼
                services/
             ┌──────┴───────┐
             ▼              │
      repositories/         │
       db/  external/       │
             │              │
             ▼              ▼
                clients/
  • routers/ importiert von den Schichten nur services/.

  • services/ importiert repositories/db/, repositories/external/ und clients/.

  • repositories/external/ importiert clients/TenantFrostRepository wird aus TenantClients konstruiert und braucht den Typ für die eigene Annotation.

  • Unterhalb von services/ importiert nichts eine Schicht über sich. Kein Repository und kein Client importiert jemals services/.

  • Ein Repository importiert kein anderes Repository. crud ist kein Repository, sondern gemeinsame Mechanik, und deshalb erlaubt.

  • Innerhalb von services/ sind Importe erlaubt und zyklenfrei; kein Modul im Baum braucht einen verzögerten Import.

bootstrap/ ist kein Router und importiert repositories/db/ direkt.

Der Paketbaum

src/thingsboard_frost_adapter/
  clients/
    frost_client.py  thingsboard_client.py  keycloak_client.py  tenant_clients.py
  repositories/
    db/
      crud.py
      observed_property_repository.py       telemetry_key_repository.py
      unit_of_measurement_repository.py     unit_of_measurement_key_repository.py
      tenant_repository.py                  tenant_observed_property_repository.py
    external/
      frost_repository.py  thingsboard_repository.py
  services/
    observed_property_service.py            telemetry_key_service.py
    unit_of_measurement_service.py          unit_of_measurement_key_service.py
    tenant_service.py                       tenant_observed_property_service.py
    device_export_service.py                tenant_healthcheck_service.py
    cross_tenant.py
  routers/          bootstrap/   main.py
  db/  redis/  model/  exceptions/  configuration/          (Querschnitt)

Wo liegt was

Die Module, deren Zuschnitt sich nicht aus dem Namen allein ergibt:

Modul Subjekt

clients/tenant_clients.py

TenantClients und clients_for(tenant) — der Einstieg zu allen Verbindungen eines Mandanten. Der Cache ist über einen Fingerprint der Mandantenzeile geschlüsselt, damit ein PATCH in allen Workern greift und nicht nur in dem, der ihn bedient hat. Er prüft zusätzlich, ob die ThingsBoard‑Sitzung des Paares abläuft, und baut es dann neu — beides dieselbe Frage („ist dieses Paar noch brauchbar?“), deshalb dieselbe Stelle.

clients/thingsboard_client.py

build_thingsboard_client(tenant) — der einzige Ort, an dem die ThingsBoard‑Edition eine Rolle spielt: Sie entscheidet Client‑Klasse und Zugangsdaten zugleich. Alles darüber sieht nur RestClientBase, und das ist die ehrliche Angabe: Jede vom Adapter benutzte Methode steht auf der Basisklasse, PE und CE deklarieren die wenigen, die sie wiederholen, unverändert.

clients/frost_client.py

build_frost_client(tenant) und PooledSensorThingsService. Die Unterklasse existiert für eine Sache: SensorThingsService.execute ruft das modulweite requests.request auf, das pro Aufruf eine Session samt TCP‑Verbindung öffnet und wieder schließt — bei vier FROST‑Aufrufen je Telemetriemeldung vier Verbindungen pro Meldung. Die Unterklasse schickt dieselben Aufrufe über eine langlebige Session je Mandant. Auth‑Header und Proxies bleiben dabei pro Anfrage, weil der Keycloak‑Handler sein Token bei Ablauf erneuert; langlebig ist allein der Verbindungspool.

repositories/db/crud.py

Fünf generische Funktionen (get_all, get_by_id, create, update, delete_by_id), die die sechs Repositories typisiert weiterreichen. Kein Service importiert crud direkt — wer das täte, griffe an seinem Repository vorbei.

repositories/external/frost_repository.py

TenantFrostRepository — der einzige Ort, an dem der FROST-Client, sein Query-Builder und seine OData-Filter vorkommen. Konstruiert über frost_repository_for(tenant).

services/device_export_service.py

Die ThingsBoard-Geräte eines Mandanten, deren Assets und deren Messwerte in den FROST dieses Mandanten zu schieben. Der heiße Pfad ist export_telemetry.

services/tenant_observed_property_service.py

Die Kopien der globalen Observed Properties im FROST eines Mandanten — auflösen, anlegen, aktualisieren. Nicht zu verwechseln mit tenant_observed_property_repository, das die Zuordnungstabelle in Postgres hält.

services/observed_property_service.py

Die kanonische Observed-Property-Zeile in Postgres und die Schleife über alle Mandanten.

services/cross_tenant.py

Die beiden mandantenübergreifenden Regeln: push_to_every_tenant verteilt best-effort, reject_if_any_tenant_uses beweist fehl-geschlossen. Importiert nur tenant_repository.

services/tenant_service.py

Mandantenzeile und Lebenszyklus in einem Modul: onboard, amend, offboard neben encrypt_and_create und den Weiterleitungen auf tenant_repository.

services/tenant_healthcheck_service.py

Die Erreichbarkeitsprüfung eines Mandanten. Kein Repository, sondern Konfigurationsvalidierung — sie gattert einen Schreibvorgang, bevor überhaupt eine Mandantenzeile entsteht.

Namenskonventionen

Jedes Modul, das ein Subjekt besitzt, trägt seine Schicht als Suffix_client, _repository, _service, _router —, auch wenn das Paket sie bereits nennt. Dieselben Substantive (tenant, frost, observed_property) wiederholen sich über alle vier Schichten; ohne Suffix hätte man drei Editor-Tabs namens tenant.py.

Ein Modul ohne eigenes Subjekt trägt kein Suffix. Davon gibt es genau zwei: crud.py, benannt nach einem Mechanismus, und cross_tenant.py, benannt nach einer Beziehung. Das fehlende Suffix sagt: Das ist ein Helfer, kein Einstiegspunkt.

Bindung: an einen Mandanten gebunden → Objekt, konstruiert aus TenantClients. Global → Modul von Funktionen. Postgres ist eine Engine für den ganzen Prozess, also ist repositories/db/ ein Modul von Funktionen; FROST und ThingsBoard gibt es je Mandant, also ist repositories/external/ objektbasiert. Innerhalb von services/ und repositories/db/ gibt es keine Klassen und keine Modul-Singletons.

Zu jeder mandantengebundenen Klasse steht eine Fabrik <name>_for(tenant) neben ihr. Es gibt drei:

frost_repository_for(tenant)                  # repositories/external/frost_repository.py
device_export_service_for(tenant)             # services/device_export_service.py
tenant_observed_property_service_for(tenant)  # services/tenant_observed_property_service.py

Die Verträge zwischen den Schichten

Ein Repository, eine Tabelle

Ein Repository setzt select() nur gegen seine eigene Tabelle ab und schreibt nur seine eigene Tabelle. Einer Relationship zum Lesen zu folgen ist erlaubt — das ist das Lesen des Modells. Die Zuweisung an eine Relationship-Collection ist ein Schreibzugriff auf die andere Tabelle und verboten.

Ein tabellenübergreifender Lesezugriff liegt im Repository der referenzierenden Tabelle, etwa telemetry_key_repository.find_by_observed_property_id. Wer die Abfrage aufruft, ist keine Eigenschaft der Abfrage.

Die Session gehört dem Repository

Jede Repository-Funktion öffnet ihre eigene Session, committet und schließt sie. services/ öffnet nie eine Session und bekommt nie eine — kein Session-Parameter oberhalb von repositories/.

Ein Repository gibt ein vollständig geladenes Objekt zurück, weil die Zeile nach dem Block abgelöst ist. Zwei Wege dahin: lazy=False auf TelemetryKey.default_uom, TelemetryKey.observed_property und UnitOfMeasurementKey.uom, sowie das Berühren des Attributs innerhalb der Session für die beiden Lazy-Collections (_ = uom.telemetry_keys). Die Tabellen und Beziehungen dahinter stehen in Datenbankschema des Adapters.

Kein fsc-Typ oberhalb von services/

fsc-Entitäten (Thing, Datastream, Sensor, …) überqueren die Grenze zwischen repositories/external/ und services/ frei in beide Richtungen. Was das Repository kapselt, ist der ClientSensorThingsService, sein Query-Builder, seine OData-Filter und seine 404er.

Oberhalb von services/ erscheint kein fsc-Typ. Deshalb ist /telemetry ein einziger export_telemetry-Aufruf: Die Zweischritt-Form hielte ein fsc.Thing in einer Routenvariablen.

Abwesenheit an der FROST-Grenze

find_ liefert None, wenn die Entität fehlt, und wirft, wenn das Fremdsystem krank ist.* _is_not_found bleibt privat im FROST-Repository und verlässt das Modul nie. Die Heil-Politik liest sich dadurch als Politik (if copy is None: heal) statt als Exception-Klempnerei.

Sperr-Reihenfolge

Sperren werden in beiden Schichten genommen:

Sperre genommen in

THING

services/device_export_service.py (get_or_create_device, create_observation, update_device) und repositories/external/frost_repository.py (_rewrite_datastreams)

LOCATION

repositories/external/frost_repository.py (get_or_create_location) und services/device_export_service.py (update_location)

SENSOR

repositories/external/frost_repository.py (get_or_create_sensor)

OBSERVED_PROPERTY

services/tenant_observed_property_service.py (_create_and_link)

Werden zwei Sperren gleichzeitig gehalten, ist THING immer die äußere. LOCATION, SENSOR und OBSERVED_PROPERTY liegen dann innerhalb von THING, niemals umgekehrt. Jede darf für sich allein auch ohne THING genommen werden — update_location tut genau das —; verboten ist nur die umgekehrte Verschachtelung. Die Sperre in _create_and_link ist über die kanonische Id geschlüsselt, nicht über den Namen.

Die Fehlergrenze

„Nur routers/ spricht HTTP" gilt hier nicht: HTTPException ist in tenant_ingest_router.reported() tragendes Transportmittel — fangen, .status_code lesen, mit angereichertem Detail erneut werfen —, und from fastapi import HTTPException zeigt seitwärts auf das Framework, nicht nach oben.

Ursprung wirft wird zu

services/tenant_service.py — Anmeldedatenprüfung fehlgeschlagen

HTTPException(400)

400 mit Nennung der fehlgeschlagenen Prüfung

services/tenant_service.py — FROST hält noch Things / ist nicht erreichbar

HTTPException(409)

409

repositories/external/thingsboard_repository.py — kein Asset / kein building-Asset

HTTPException(404)

404 — siehe Die benannten Ausnahmen

Stammdaten- und Fan-out-Wächter in services/

BusinessRuleViolationError

422 über den Handler in main.py

repositories/external/frost_repository.py — teilweise Umbenennung

PartialPushError

500 über den Handler in main.py

unbekannte Id

Nonerouters/errors.or_404

404, im Router

Zielsystem nicht erreichbar

requests.RequestException

502 über den Handler in main.py

Die benannten Ausnahmen

Vier Stellen brechen eine der obigen Regeln. Alle vier sind bewusst und abschließend:

Stelle Ausnahme

repositories/external/thingsboard_repository.py

wirft HTTPException(404) und wählt damit als einziges Repository das Urteil seines Aufrufers. Geduldet, nicht gebilligt — jeder heutige Aufrufer ist eine HTTP-Route und will genau diese 404. Neu zu bewerten, sobald ein Aufrufer ein nicht zugeordnetes Gerät überspringen statt ablehnen will.

services/tenant_healthcheck_service.py

macht als einziges Modul in services/ rohe requests-Aufrufe: Es umgeht die Client-Abstraktion, weil es die Abstraktionen prüft. Es kann außerdem kein aus TenantClients konstruiertes Objekt sein, weil es auf einem Kandidaten-Tenant läuft, bevor eine Zeile existiert.

repositories/db/*.get_or_create (vier Seeder)

nehmen eine Session vom Aufrufer entgegen und committen nicht, weil bootstrap/ alle Vorgaben in einer Transaktion schreibt. Diese Form ist ausschließlich für bootstrap/.

services/unit_of_measurement_key_service.py

besteht aus fünf einzeiligen Weiterleitungen und sonst nichts. Es existiert trotzdem, damit „Router importieren nur `services/`" ein Satz ohne Ausnahme bleibt.

Die Teststruktur

Die oberste Achse von tests/ ist die Abhängigkeit (unit / integration), das Schichtschema ist der Baum darunter.

tests/
  unit/          clients/  configuration/  model/  redis/
  integration/   bootstrap/  clients/  routers/  services/
                 test_tenancy.py  test_thingsboard_fixture.py
                 test_thingsboard_provisioning.py  test_staging_happy_path.py
                 test_frost_housekeeping.py
                 conftest.py  rows.py  tenants.py
                 tenant_registry_data.py  thingsboard_data.py
                 frost_housekeeping.py

Die Spiegelregel, die der Docstring in tests/unit/init.py hält:

  1. Ein Test mit einem Modul unter Test liegt in dessen Schicht und ist nach ihm benannt — aufgelöst über das Modul unter Test, nicht über den obersten Import in der Datei.

  2. Ein Test, dessen Subjekt eine Eigenschaft des Gesamtsystems ist, bleibt ungespiegelt in der Wurzel von tests/integration/.

repositories/ erscheint in keinem der beiden Bäume: Nichts ruft ein Repository als seinen Einstiegspunkt auf. Die Repository-Primitive sind durch den Service abgedeckt.

tests/integration/conftest.py gibt je Mandant ein Fixture pro Subjekt heraus, statt eines Bündels, das alle Handles auf einmal trägt:

Fixture liefert

tenant_a / tenant_b

die Tenant-Zeile

tenant_a_frost / tenant_b_frost

den rohen fsc-Client, für Assertions und Teardown

tenant_a_frost_repo / tenant_b_frost_repo

ein TenantFrostRepository

tenant_a_export / tenant_b_export

einen DeviceExportService

tenant_a_observed_properties / tenant_b_observed_properties

einen TenantObservedPropertyService

Durchsetzung

Die Schichtregel wird ausschließlich in Prosa durchgesetzt. Es gibt keine Werkzeugkette dafür — kein import-linter, keine Tidy-Imports-Regel, keine Lint- oder Pre-Commit-Stufe — und es ist auch keine gewünscht. Prosa kann man widersprechen, einem Grep nur genügen. Das Thema kehrt als eigenes Vorhaben zurück, falls sich zeigt, dass die Regel verrottet ist.

Drei prüfbare Formen dienen als Leitfaden für das Code-Review — das, worauf man beim Lesen eines Diffs achtet:

prüfbare Form was sie schützt

kein .query(), kein .filter( und kein blankes self.frost außerhalb von repositories/external/frost_repository.py

„Was das Repository kapselt, ist der Client" — der Query-Builder und die OData-Filter bleiben drin.

kein fsc-Typ oberhalb von services/

die obere Grenze der FROST-Entitäten; der Grund, warum /telemetry ein einziger Aufruf ist.

kein rohes requests. in services/ außer in tenant_healthcheck_service.py

die eine begründete Ausnahme bleibt die eine, siehe Die benannten Ausnahmen.

Die erste Form ist als Lesehilfe formuliert und nicht als Grep: self.frost benennt eine Schicht tiefer den rohen SensorThingsService und in DeviceExportService das TenantFrostRepository. Geprüft wird der Typ hinter dem Attribut — greift dieser Zugriff am Repository vorbei auf den Client?

Alle drei binden src/ und nicht tests/. Das ist Absicht: Tests lesen FROST bewusst roh, damit sie einen Schreibvorgang unabhängig von dem Code prüfen können, der ihn ausgelöst hat. Ein Test, der dieselbe Abstraktion benutzt wie der Code unter Test, prüft sie nicht.

Frontend (Streamlit)

Zweck

src/frontend/ ist die Verwaltungsoberfläche für die Stammdaten und die Mandanten‑Registry. Sie ist eine eigenständige Anwendung neben dem Adapter: eigener Prozess, eigener Container, eigener Port (8501), eigene Konfigurationsdatei. Sie greift nie auf die Datenbank zu, sondern ausschließlich auf die REST‑API des Adapters.

Was die Oberfläche tut, beschreibt die Nutzerdokumentation — Zuordnungen pflegen und Mandanten verwalten. Hier steht, wie sie gebaut ist.

Aufbau

src/frontend/
  main.py                  Anmeldung und Navigation
  pages/
    tenant_index_page.py             uom_index_page.py
    observed_property_index_page.py  uom_key_index_page.py
    telemetry_key_index_page.py
  api/
    frost_adapter_api_service.py     der einzige Ort mit `requests`
  unreached_tenants.py               die eine gemeinsame Warnung

Die Schichtung ist flach und hat nur eine Regel:

  pages/  ──►  api/  ──►  REST-API des Adapters

Keine Seite setzt einen HTTP‑Aufruf ab. requests kommt ausschließlich in api/frost_adapter_api_service.py vor; eine Seite kennt nur benannte Funktionen wie get_all_uoms() oder sync_observed_properties(id).

Das Frontend importiert aus dem Adapter genau eine Sache: die Routenkonstanten aus thingsboard_frost_adapter.routers.base_routes. Das ist die einzige Code‑Kopplung zwischen beiden Anwendungen und hält die Pfade in einer Datei — geändert wird ein Basispfad dort, nicht an zwei Stellen.

Anmeldung und Navigation

main.py ist der Einstiegspunkt und tut zwei Dinge. Ohne Anmeldung zeigt es ausschließlich eine Login‑Seite und ruft st.login(); die Anmeldung selbst läuft über den OIDC‑Block [auth] in .streamlit/secrets.toml gegen Keycloak. Nach der Anmeldung baut es die Navigation aus fünf st.Page‑Objekten und stellt einen Logout‑Knopf in die Seitenleiste.

Die Reihenfolge in der Navigation ist gesetzt: Tenants steht vorn, danach die vier Stammdatentabellen.

Das Tabellenmuster

Vier der fünf Seiten sind dieselbe Seite mit anderen Spalten: ein st.data_editor über einen DataFrame, darüber ein Save‑Knopf. Beim Speichern liest submit() die drei Listen, die Streamlit im Session State führt, und übersetzt jede in einen API‑Aufruf:

Liste Aufruf

edited_rows

update_*(id, body) — der Zeilenindex führt über den DataFrame auf die id.

added_rows

create_*(body)

deleted_rows

delete_*_by_id(id)

Der Remount‑Schlüssel

Jede Seite führt einen Zähler im Session State und baut daraus den Schlüssel des Editors:

data_editor_gen_key = "uom_edit_gen"
data_editor_key = f"uom_edit_{st.session_state[data_editor_gen_key]}"

Nach jedem Speichern wird der Zähler erhöht. Grund: Ein fehlgeschlagener Schreibvorgang ändert die zugrunde liegenden Daten nicht, und ohne neuen Schlüssel bliebe der Editor mit den alten Änderungen und Löschmarkierungen stehen. Der neue Schlüssel erzwingt ein Neu‑Einhängen.

Namen in der Oberfläche, Ids in der API

Wo eine Spalte eine Beziehung zeigt, steht in der Tabelle der Name und in der API die Id. telemetry_key_index_page.py baut dafür zwei Abbildungen (uom_name_to_id, observed_propert_name_to_id) und benutzt dieselben Namen zugleich als options der SelectboxColumn — der Editor kann damit keinen Namen anbieten, der sich nicht auflösen lässt.

Zwei Zeitlimits

api/frost_adapter_api_service.py kennt genau zwei Werte, und die Unterscheidung ist inhaltlich:

Konstante Wert Gilt für

CRUD_TIMEOUT_SECONDS

5 s

Zugriffe, die nur die Postgres‑Datenbank des Adapters lesen oder schreiben — die sind entweder schnell oder kaputt.

EXTERNAL_SYSTEM_TIMEOUT_SECONDS

60 s

Jeder Aufruf, der über den Adapter hinaus nach Keycloak, ThingsBoard oder in den FROST eines Mandanten greift: die Erreichbarkeitsprüfungen, der Abgleich, die Mandanten‑Schreibvorgänge und alle Stammdaten‑Schreibvorgänge, die fächern.

Die zweite Gruppe braucht das längere Limit, weil eine Seite, die vorher aufgibt, eine funktionierende Prüfung oder einen bereits committeten Schreibvorgang als Fehlschlag meldet.

DataFrame oder JSON

Die meisten Funktionen geben einen DataFrame zurück, und ein leerer DataFrame ist zugleich das, womit ein fehlgeschlagener Aufruf antwortet — für eine Tabelle heißt beides „nichts anzuzeigen“.

Die Mandanten‑Schreibvorgänge sind die Ausnahme und lesen rohes JSON. create_tenant und update_tenant antworten mit dem verschachtelten {tenant, sync}, und die Seite muss den Bericht daraus lesen. Ein DataFrame könnte hier den entscheidenden Unterschied nicht ausdrücken: Schreibvorgang fehlgeschlagen (None) ist etwas anderes als Mandant angelegt, Replikation nicht durchgelaufen (sync: null).

Die Warnung über nicht erreichte Mandanten

unreached_tenants.py enthält die eine Warnung, die jede Stammdatenseite nach einem Speichern zeigt, wenn failed_tenants gefüllt ist. Der Schreibvorgang hat Postgres erreicht — und Postgres ist kanonisch —, aber nicht den FROST jedes Mandanten. Das ist eine Verzögerung und keine verlorene Änderung, deshalb eine Warnung mit einer Handlungsanweisung statt eines Fehlers.

Der Wiederholungsweg ist der einzige Grund, warum der Text ein Parameter ist:

  • Eine ObservedProperty hat eine Zuordnungszeile und damit den Knopf Sync observed properties.

  • Ein Telemetry Key oder eine Maßeinheit existiert nur in den Datastreams, die sie tragen. Es gibt nichts erneut zu übertragen — der Text verweist deshalb auf das Log und auf Handarbeit.

Die Tenants‑Seite

Sie folgt demselben Tabellenmuster, hat aber drei Eigenheiten:

Beim Rendern wird nichts geprüft.

Die Registry muss sich anzeigen lassen, ohne ein einziges Fremdsystem anzufassen. Ein Mandant hat deshalb keinen Status, bis jemand Test connection oder Check all connections klickt. Die Ergebnisse liegen im Session State und nicht in den Zeilendaten.

Der Slug wird abgefangen, nicht angeboten.

Wird er im Editor geändert, meldet die Seite, dass umbenannt nicht geht und der Mandant zu löschen und neu anzulegen ist. Die API weist es ebenfalls zurück — die Prüfung hier existiert, damit die Meldung erklärt statt nur abzulehnen.

Das ThingsBoard‑Geheimnis ist immer leer.

Die API gibt es nie zurück. Leer beim Speichern bedeutet „gespeichertes behalten“, ein eingetippter Wert ersetzt es. Welches Feld das ist, entscheidet die Edition: unter pe der API‑Key, unter ce das Passwort. Der CE‑Benutzername ist kein Geheimnis und verhält sich wie jedes andere Feld.

Die Edition wird erfragt, nicht konfiguriert.

Die Seite holt sie über GET /configuration vom Adapter — als einziger Aufruf dieses Moduls zwischengespeichert (60 Sekunden), weil Streamlit das Seitenskript bei jeder Interaktion neu ausführt und sich dieser Wert nur bei einem Neustart des Adapters ändern kann. Ein fehlgeschlagener Abruf wird nicht zwischengespeichert. Der Frontend‑Container hat keine .env, und die Variable dorthin zu duplizieren hieße, zwei Quellen für einen Wert zu führen, der übereinstimmen muss — ein Formular gegen die falsche Edition sammelt Zugangsdaten, die der Adapter anschließend bei jedem Speichern ablehnt. Antwortet der Adapter nicht, sagt die Seite das und rendert die Tabelle nicht, statt Felder zu raten.

Im Session State stehen zwei Dinge, und beide halten bewusst nur das Handlungsbedürftige: Die Prüfergebnisse pro Slug, und aus einem Abgleichbericht ausschließlich die Liste unmanaged_in_use. Ein sauberer Abgleich ist ein Toast und hinterlässt nichts — was im Zustand steht, bedeutet damit „braucht Aufmerksamkeit“ und nicht „das ist zuletzt passiert“.

Eine neue Seite hinzufügen

  1. Eine Funktion je Operation in api/frost_adapter_api_service.py ergänzen, mit dem Basispfad aus base_routes und dem passenden der beiden Zeitlimits.

  2. Ein Modul in pages/ anlegen, das dem Tabellenmuster folgt: Zähler und Editor‑Schlüssel, submit() über die drei Listen, st.title(…​), st.data_editor(…​).

  3. Die Seite in main.py als st.Page eintragen und in die Liste von st.navigation aufnehmen.

  4. Fächert ein Schreibvorgang über alle Mandanten, das Ergebnis durch warn_about_unreached_tenants(…​) schicken und einen Wiederholungstext angeben, der zu dieser Entität tatsächlich passt.

Konfiguration

Das Frontend liest .streamlit/secrets.toml und nicht .env. Vorlage ist .streamlit/secrets-example.toml; keine der beiden echten Dateien ist eingecheckt.

Schlüssel Bedeutung

frost_adapter_backend_base_url

Basis‑URL der Adapter‑API. Lokal http://localhost:8000, im Container http://thingsboard_frost_adapter:8000.

[auth]

Der OIDC‑Block für st.login(): redirect_uri, cookie_secret, client_id, client_secret und server_metadata_url des Keycloak‑Realms.

Im Compose‑Betrieb wird die Container‑Fassung als Datei eingehängt:

volumes:
  - ./.streamlit/secrets-docker.toml:/app/.streamlit/secrets.toml:ro

Datenbankschema des Adapters

Zweck

Der Adapter hält eine eigene PostgreSQL‑Datenbank mit sechs Tabellen. Sie enthält keine Messwerte — die stehen ausschließlich im FROST‑Server jedes Mandanten. Was hier liegt, ist die Mandanten‑Registry und die Stammdaten, die eine ThingsBoard‑Telemetrie in einen SensorThings‑Begriff übersetzen.

Diese Seite beschreibt die Tabellen und ihre Beziehungen. Welche Regeln beim Schreiben und Löschen gelten, steht in Geschäftsregeln; wie diese Zeilen auf SensorThings‑Entitäten abgebildet werden, in Datenmodell. Alle Modelle stehen in model/sql/models.py; das Schema wird beim Start von bootstrap() angelegt, es gibt keine Migrationswerkzeuge.

Überblick

                       tenant
                         │ 1
                         │            (tenant_id, observed_property_id) = PK
                         │            UNIQUE (tenant_id, frost_id)
                         ▼ n
              tenantobservedproperty ──────┐
                                           │ n
                                           ▼ 1
   unitofmeasurementkey            observedproperty
             │ n                           ▲ 1
             │                             │
             ▼ 1                           │ n
     unitofmeasurement ◄──── n ───── telemetrykey
                            default_uom_id  │
                                            └── observed_property_id (RESTRICT)

Zwei Gruppen: tenant und tenantobservedproperty bilden die Registry, die übrigen vier die mandantenübergreifenden Stammdaten. Die einzige Verbindung zwischen beiden Gruppen ist tenantobservedproperty — die Tabelle, die festhält, welche FROST‑Entität in welchem Mandanten die Kopie einer ObservedProperty ist.

Die Registry

tenant

Ein Mandant ist die Zusammenfassung eines ThingsBoard‑Mandanten mit genau einem FROST‑Server.

Spalte Constraint Bedeutung

id

PK

Fortlaufende Ganzzahl.

slug

UNIQUE

Der Bezeichner in der Ingest‑URL. Unveränderlich — er steht in der Rule‑Chain‑URL eines fremden Systems, weshalb TenantUpdate das Feld nicht kennt und mit extra="forbid" einen Änderungsversuch zurückweist statt ihn still zu verwerfen.

name

Anzeigename. Darf von slug abweichen und tut das nach einer Umbenennung in ThingsBoard auch.

frost_server_url

UNIQUE

Die Service‑Root des FROST‑Servers. Die Eindeutigkeit ist es, die die strikte 1:1‑Topologie erzwingt — zwei Mandanten können nicht in denselben Server schreiben.

keycloak_client_id

Der Keycloak‑Client dieses FROST‑Servers. Der Benutzer dahinter ist deployment‑weit, nur der Client ist mandantenspezifisch.

thingsboard_api_key

nullable

Nur bei THINGSBOARD_EDITION=pe belegt. Fernet‑Chiffrat, niemals Klartext.

thingsboard_username

nullable

Nur bei THINGSBOARD_EDITION=ce belegt. Kein Geheimnis — der ThingsBoard‑Benutzer, als der die Geräte dieses Mandanten gelesen werden, und deshalb das einzige Zugangsdatenfeld, das TenantPublic mitliefert.

thingsboard_password

nullable

Nur bei THINGSBOARD_EDITION=ce belegt. Fernet‑Chiffrat, niemals Klartext.

Zwei der drei Zugangsdatenspalten sind immer NULL, und welche zwei entscheidet nicht die Zeile, sondern die Konfiguration: Es gibt genau eine THINGSBOARD_URL, also genau ein Deployment, also genau eine Edition. Alle Zeilen eines Deployments haben damit zu jedem Zeitpunkt dieselbe Form. Eine Aufteilung in zwei Tabellen oder zwei Modelle würde eine Variation abbilden, die nicht vorkommen kann — und dafür eine Diskriminatorspalte, zwei CRUD‑Pfade und Polymorphie auf einer Tabelle kosten, in die tenantobservedproperty einen Fremdschlüssel hält. Erzwungen wird die Form in services/tenant_service.py, nicht im Schema.

Die Geheimnisse sind über die API nur schreibbar: TenantPublic erbt sie nicht und trägt stattdessen has_thingsboard_credentials.

Drei abgeleitete Eigenschaften stehen am Modell und nicht in der Tabelle:

decrypted_api_key / decrypted_password

entschlüsselt mit TENANT_SECRET_KEY. Schlägt fehl, sobald der Schlüssel gewechselt hat — pro Mandant, beim Bau seiner Clients. Ist die Spalte leer, nennt der Fehler die fehlende Spalte, statt in der Chiffre aufzulaufen.

has_thingsboard_credentials

ob die Zeile sich überhaupt anmelden kann. Ein Benutzername ohne Passwort ist keine halbe Anmeldung, sondern keine.

fingerprint

ein SHA‑256 über genau die Felder, aus denen die Clients eines Mandanten gebaut werden — alle drei Zugangsdatenspalten inbegriffen, nicht nur die der konfigurierten Edition. Der Client‑Cache ist danach geschlüsselt, damit ein PATCH in allen Workern greift und nicht nur in dem, der ihn bedient hat; ein rotiertes Passwort, das den Fingerabdruck nicht bewegt, ließe jeden Worker unbegrenzt mit dem alten anmelden.

Ein leeres Geheimnis ist unrepräsentierbar: TenantCreate und TenantUpdate verlangen min_length=1, und in TenantUpdate bedeutet ein fehlendes Feld „Chiffrat behalten“, während der Leerstring zurückgewiesen wird. Es gibt keinen Weg, Zugangsdaten zu entfernen — ein Mandant ohne sie wäre unbrauchbar.

tenantobservedproperty

Die Zuordnungstabelle. Sie beantwortet: Welche FROST‑Entität in welchem Mandanten ist diese kanonische Observed Property?

Spalte Constraint Bedeutung

tenant_id

PK, FK → tenant.id, ON DELETE CASCADE

Der Mandant. Das Löschen einer Mandantenzeile räumt seine Zuordnungen mit ab.

observed_property_id

PK, FK → observedproperty.id, ON DELETE CASCADE

Die kanonische Zeile.

frost_id

UNIQUE (tenant_id, frost_id)

Die ID der Kopie im FROST dieses Mandanten. Die Eindeutigkeit verhindert, dass zwei kanonische Zeilen auf dieselbe FROST‑Entität zeigen.

Identität wird hier festgehalten, niemals aus dem Namen erschlossen. Eine fehlende Zeile bedeutet: die Übertragung ist nie angekommen — Reparatur ist Anlegen. Eine vorhandene Zeile bedeutet: die Kopie existiert und darf nie ein zweites Mal entstehen — Reparatur ist das Überschreiben der Attribute. Eine namensbasierte Auflösung kann diese beiden Fälle nicht unterscheiden und erzeugt im zweiten Fall ein Duplikat, das eine Messgröße auf zwei Entitäten aufteilt.

Die Stammdaten

observedproperty

Die kanonische Zeile: name (eindeutig), definition, description. Jeder Mandant hält in seinem FROST eine abgeleitete Kopie, adressiert über tenantobservedproperty. Lesende Zugriffe sind reine Postgres‑Selects und sprechen keinen FROST‑Server an.

Es gibt bewusst keine Beziehung observed_property.telemetry_keys. Eine solche Sammlung wäre ein Lazy Load, der auf jede abgelöste kanonische Zeile wartet, die die Schreibpfade zurückgeben.

telemetrykey

Die Übersetzung eines ThingsBoard‑Telemetrie‑Schlüssels in einen SensorThings‑Begriff.

Spalte Constraint Bedeutung

key

UNIQUE

Der Telemetrie‑Schlüssel aus ThingsBoard. Er ist zugleich der Name des zugehörigen Datastreams in FROST.

observed_property_id

NOT NULL, FK → observedproperty.id, ON DELETE RESTRICT

Die gemessene Größe.

default_uom_id

FK → unitofmeasurement.id, nullable

Die Standard‑Maßeinheit.

Die Spalte trägt die Id und nicht mehr den Namen der Observed Property. Damit ist eine Umbenennung ein Schreibvorgang auf eine Zeile, und es bleibt kein Übertragungsschritt übrig, der halb greifen könnte. RESTRICT macht „zeigt auf eine Observed Property, die es nicht mehr gibt“ unrepräsentierbar — der Wächter darüber ist das, was daraus eine Meldung mit den betroffenen Schlüsseln macht statt eines nackten IntegrityError.

unitofmeasurement und unitofmeasurementkey

unitofmeasurement trägt name (eindeutig), symbol und definition — genau die drei Felder, die in FROST als eingebettetes unitOfMeasurement‑Objekt eines Datastreams landen.

unitofmeasurementkey bildet ein Suffix eines Telemetrie‑Schlüssels auf eine Maßeinheit ab: key (eindeutig) und ein nullables uom_id. Auf diese Tabelle verweist nichts zurück, weshalb sie als einzige keine Löschsperre hat.

Eine Maßeinheit hat keine eigene Identität in FROST. Sie steht als eingebetteter Wert in jedem Datastream, der sie benutzt — nicht als verknüpfte Entität. Dasselbe gilt für einen Telemetry Key, der nur als Name eines Datastreams existiert. Genau das ist der Grund, warum es für diese beiden keinen Wiederholungslauf gibt, für ObservedProperty mit ihrer Zuordnungszeile aber schon.

Ladeverhalten

Ein Repository gibt ein vollständig geladenes Objekt zurück, weil die Zeile nach dem Session‑Block abgelöst ist. Drei Beziehungen sind deshalb auf lazy=False gesetzt:

TelemetryKey.default_uom
TelemetryKey.observed_property
UnitOfMeasurementKey.uom

Die beiden Sammlungen auf UnitOfMeasurement (telemetry_keys, uom_keys) bleiben lazy und werden dort, wo sie gebraucht werden, innerhalb der Session berührt. Der Vertrag dazu steht in Architektur im Detail.

Die Antwortmodelle

Im selben Modul liegen die Modelle, die die REST‑API nach außen gibt. Zwei Formen fallen auf, weil sie eine Eigenschaft des Systems tragen und nicht nur eine Zeile:

Modell Trägt zusätzlich

TelemetryKeyWriteResult
UnitOfMeasurementWriteResult
ObservedPropertyWriteResult

failed_tenants — die Mandanten, die ein fächernder Schreibvorgang nicht erreicht hat. Postgres ist kanonisch und in jedem Fall committet; ein fehlgeschlagener Push ist eine Verzögerung und kein Defekt — aber nur, wenn jemand davon erfährt. Diese Liste ist dieses Erfahren.

TenantCreateResponse
TenantUpdateResponse

{tenant, sync} statt einer flachen Zeile, weil die beiden verschiedenen Regeln folgen: Der Mandant ist die committete Tatsache — ohne bestandene Prüfung gäbe es die Zeile nicht —, während sync best‑effort ist und fehlen darf, ohne dass der Schreibvorgang weniger erledigt wäre.

ObservedPropertySyncReport

unmanaged_in_use — Kopien, die keine Zuordnungszeile beansprucht und an denen Datastreams hängen. Leer ist die normale Antwort. Zählwerte von Angelegtem und Aktualisiertem fehlen bewusst: Niemand handelt danach, sie gehören in die Logzeile.

Datenmodell: ThingsBoard auf SensorThings abbilden

Zweck

Diese Seite beantwortet für jede SensorThings‑Entität, die der Adapter anlegt, woher jedes ihrer Felder kommt. Die Entitäten selbst und ihre Abfragesyntax beschreibt Schnittstellenbeschreibung SensorThings API; die Tabellen, aus denen die Zuordnungen stammen, Datenbankschema des Adapters.

Alles hier Beschriebene wird in den FROST‑Server eines Mandanten geschrieben — den, dessen Slug in der Ingest‑URL stand.

Überblick

  ThingsBoard                                    FROST (SensorThings)

  Gerät                          ───────────►    Thing
   └─ Relation "Contains"
       └─ Asset (Typ `building`) ───────────►    Location
   └─ Geräteprofil               ───────────►    Sensor
   └─ Telemetrie-Schlüssel       ───────────►    Datastream
       └─ Wert + Zeitstempel     ───────────►    Observation

  Adapter-Datenbank
   └─ observedproperty           ───────────►    ObservedProperty (Kopie je Mandant)
   └─ telemetrykey                    ⌐ Name des Datastreams
   └─ unitofmeasurement(key)          └ eingebettetes unitOfMeasurement

Zwei Quellen also: die Gerätedaten kommen aus ThingsBoard, die Bedeutung der Telemetrie aus den Stammdaten des Adapters.

Thing

Ein Thing entspricht genau einem Gerät in ThingsBoard.

Feld Quelle Anmerkung

name

Gerätename

Muss eindeutig sein und ist der Schlüssel, über den der Adapter das Thing wiederfindet.

description

additional_info.description des Geräts

Fällt auf den Gerätenamen zurück, wenn keine Beschreibung gepflegt ist.

properties

Alle Attribute des Geräts als Objekt

Hier landen auch die Attribute, die der Adapter selbst auswertet: device_definition und die <telemetry_key>_uom‑Einträge.

Locations

Das verknüpfte building‑Asset

Wird im selben POST mitgegeben und nicht nachträglich verknüpft — ein Thing, das für einen Moment ohne Standort existiert, ist ein Zustand, den ein gleichzeitiger Leser sehen könnte.

Location

Der Standort stammt aus dem Asset, das über eine Contains‑Relation mit dem Gerät verknüpft ist und den Typ building trägt. Findet der Adapter kein solches Asset, lehnt er mit 404 ab und schreibt nichts.

Feld Quelle Anmerkung

name

Name des Assets

Der Name identifiziert den Standort; mehrere Geräte im selben Gebäude teilen sich eine Location.

description

additional_info.description des Assets

Fällt auf den Asset‑Namen zurück.

location

Die Attribute lat und lon des Assets

Als GeoJSON Point in der Reihenfolge (lon, lat).

encodingType

fest

application/geo+json.

Sensor

Ein Sensor entspricht einem Geräteprofil in ThingsBoard — nicht einem einzelnen Gerät. Alle Geräte desselben Profils teilen sich einen Sensor.

Feld Quelle Anmerkung

name

Name des Geräteprofils

description

Beschreibung des Geräteprofils

metadata

Geräteattribut device_definition

Üblicherweise ein Link auf ein Datenblatt. Fehlt das Attribut, steht dort no device definition maintained.

encodingType

fest

application/pdf.

ObservedProperty

Die einzige Entität, die nicht aus ThingsBoard stammt. Ihre Wahrheit liegt in der Tabelle observedproperty des Adapters; jeder Mandant hält in seinem FROST eine abgeleitete Kopie mit name, definition und description aus der kanonischen Zeile.

Die Kopie wird ausschließlich über die in tenantobservedproperty hinterlegte frost_id aufgelöst, niemals über den Namen. Die Begründung steht in Geschäftsregeln.

Welche Observed Property zu einer Telemetrie gehört, entscheidet die Tabelle telemetrykey: Ein Schlüssel ohne Zuordnung erzeugt keinen Datastream und damit auch keine Observation — er wird still übergangen und protokolliert.

Datastream

Ein Datastream je Telemetrie‑Schlüssel und Gerät. Er verbindet Thing, Sensor und ObservedProperty.

Feld Quelle Anmerkung

name

Der Telemetrie‑Schlüssel

Der Name ist der Schlüssel. Ein Telemetry Key hat keine eigene Identität in FROST — er existiert nur als dieser Name, weshalb eine Umbenennung die Datastreams direkt umschreiben muss.

description

erzeugt

Datastream for <key> measurements of <Gerätename>.

observationType

fest

OM_Measurement.

unitOfMeasurement

siehe Wie die Maßeinheit bestimmt wird

Ein eingebettetes Objekt aus name, symbol und definition — keine verknüpfte Entität.

Thing, Sensor, ObservedProperty

die oben angelegten Entitäten

Wie die Maßeinheit bestimmt wird

Zwei Stufen, in dieser Reihenfolge:

  1. Gerätespezifisch. Der Adapter liest das Geräteattribut <telemetry_key>_uom aus den properties des Things und schlägt dessen Wert in der Tabelle unitofmeasurementkey nach. Ein Gerät, dessen Telemetrie temperature in Fahrenheit liefert, bekommt damit über ein Attribut temperature_uom eine andere Einheit als alle anderen.

  2. Standard. Findet sich darüber nichts, gilt telemetrykey.default_uom.

Die Maßeinheit wird nicht aus einem Suffix des Telemetrie‑Schlüssels abgeleitet. Sie kommt entweder aus dem Geräteattribut oder aus der Standardzuordnung des Schlüssels — beides gepflegt über die Verwaltungsoberfläche.

Observation

Für jeden Telemetrie‑Schlüssel einer eingehenden Nachricht, der einen Datastream hat, entsteht eine Observation.

Feld Quelle Anmerkung

result

Der Wert des Telemetrie‑Felds

phenomenonTime

ts aus den Metadaten der Nachricht

Millisekunden seit Epoch, umgerechnet nach Europe/Berlin und als ISO‑Zeitstempel geschrieben. FROST zeigt ihn ohnehin in UTC an. Fehlt ts, bleibt das Feld leer.

Datastream

Der Datastream dieses Schlüssels an diesem Thing

Fehlt er und ist der Schlüssel zugeordnet, wird er zuvor angelegt.

FeatureOfInterest

Wird nicht verwendet. Der Adapter legt keine FeaturesOfInterest an; FROST erzeugt sie bei Bedarf selbst aus der Location des Things.

Beispiele

Die folgenden Ausschnitte zeigen die Entitäten eines Luftqualitätssensors, wie der Adapter sie anlegt.

Thing
{
  "name": "KSPB-0507",
  "description": "Luftqualitätssensor an der Schule an der Wakenitz",
  "properties": {
    "areaServed": "Schule an der Wakenitz",
    "device_definition": "https://example.org/datasheets/kspb.pdf",
    "co2_uom": "ppm"
  }
}
Location
{
  "name": "Schule an der Wakenitz",
  "description": "Geo-Position des Geräts KSPB-0507",
  "encodingType": "application/geo+json",
  "location": {
    "type": "Point",
    "coordinates": [10.7383340505368, 53.855998806145]
  }
}
Sensor
{
  "name": "KSPB Umweltsensor",
  "description": "Geräteprofil der KSPB-Messstationen",
  "encodingType": "application/pdf",
  "metadata": "https://example.org/datasheets/kspb.pdf"
}
ObservedProperty
{
  "name": "CO2-Konzentration",
  "definition": "https://en.wikipedia.org/wiki/Carbon_dioxide#Concentration",
  "description": "Kohlenstoffdioxid-Konzentration in ppm"
}
Datastream
{
  "name": "co2",
  "description": "Datastream for co2 measurements of KSPB-0507",
  "observationType": "OM_Measurement",
  "unitOfMeasurement": {
    "name": "Parts per million",
    "symbol": "ppm",
    "definition": "https://unitsofmeasure.org"
  },
  "Thing": { "@iot.id": 1 },
  "Sensor": { "@iot.id": 1 },
  "ObservedProperty": { "@iot.id": 1 }
}
Observation
{
  "result": 487.2485,
  "phenomenonTime": "2024-06-20T06:58:06.594Z",
  "Datastream": { "@iot.id": 1 }
}

Geschäftsregeln für Stammdaten (Business Rules)

Einleitung

Die Stammdaten des Adapters – UnitOfMeasurement, UnitOfMeasurementKey, TelemetryKey und ObservedProperty – liegen in der Adapter‑Datenbank und referenzieren sich gegenseitig; ObservedProperty wird zusätzlich als Kopie in den FROST‑Server jedes Mandanten repliziert (siehe unten). Damit über die REST‑API bzw. die Verwaltungsoberfläche keine inkonsistenten Zustände entstehen können, prüfen die jeweiligen Module unter src/thingsboard_frost_adapter/services/ vor Lösch‑ und Umbenennungs‑Operationen die Referenzen auf verwandte Entitäten. Die Regeln liegen dort und nirgends sonst: Die Router setzen einen Aufruf ab, die Repositories entscheiden nichts (siehe Architektur im Detail).

Alle Regelverletzungen werden über die Exception BusinessRuleViolationError (thingsboard_frost_adapter/exceptions/business_rule_violation_error.py) signalisiert. Ein globaler FastAPI‑Exception‑Handler in main.py wandelt diese Exception in eine HTTP‑Antwort mit Status 422 Unprocessable Entity und einer sprechenden Fehlermeldung im Body um.

Referenzprüfung beim Löschen

Entität Löschen wird verhindert, wenn…​ Implementiert in

UnitOfMeasurement

noch ein TelemetryKey (default_uom_id) oder UnitOfMeasurementKey (uom_id) auf die Maßeinheit verweist, oder in irgendeinem Mandanten ein FROST‑Datastream sie referenziert (Abgleich über den Namen), oder ein Mandant nicht erreichbar ist

services/unit_of_measurement_service.py::delete_by_id

ObservedProperty

noch ein TelemetryKey (observed_property_id) auf sie verweist, oder in irgendeinem Mandanten ein FROST‑Datastream ihre dortige Kopie referenziert, oder ein Mandant nicht erreichbar ist. Ein nicht erreichbarer Mandant gilt als fehlgeschlagene Prüfung, nicht als Freigabe – „nicht erreichbar“ ist kein Beleg für „nicht in Benutzung“. Ein Force‑Flag gibt es bewusst nicht: der Ausweg für einen stillgelegten Mandanten ist das Löschen der Mandantenzeile, was dessen Zuordnungen kaskadierend entfernt

services/observed_property_service.py::delete_by_id

TelemetryKey

in irgendeinem Mandanten ein FROST‑Datastream existiert, dessen Name dem Telemetry Key entspricht, oder ein Mandant nicht erreichbar ist

services/telemetry_key_service.py::delete_by_id

Die Stammdatentabellen sind global – ihre Routen führen keinen Mandanten‑Slug –, ihre FROST‑Prüfungen laufen deshalb über alle Mandanten. Jede Prüfung fragt pro Mandant dessen eigenen FROST‑Server über das TenantFrostRepository dieses Mandanten (repositories/external/frost_repository.py, konstruiert über frost_repository_for(tenant)):

  • find_datastreams_by_name – Datastreams, deren Name einem Telemetry Key entspricht

  • find_datastreams_by_uom_name – Datastreams, deren unitOfMeasurement.name einer lokalen Maßeinheit entspricht

  • find_datastreams_by_observed_property_id – Datastreams, die die dortige Kopie einer ObservedProperty referenzieren (über die frost_id aus der Zuordnungstabelle)

Alle drei Prüfungen sind fail‑closed (services/cross_tenant.py::reject_if_any_tenant_uses): Sie müssen von jedem Mandanten ein „nicht in Benutzung“ hören. Ein Mandant, der nicht antworten kann, verhindert das Löschen genauso wie einer, der die Daten noch benutzt – „nicht erreichbar“ ist kein Beleg für „nicht in Benutzung“. Die Fehlermeldung nennt in beiden Fällen den Mandanten.

Eine tote Mandantenzeile, deren FROST‑Server bereits abgebaut wurde, lässt damit jede Stammdatenlöschung dauerhaft scheitern, weil die Prüfung nie eine Antwort bekommt. Ein Force‑Flag gibt es bewusst nicht; der Ausweg ist das Löschen der Mandantenzeile.

UnitOfMeasurementKey selbst hat keine eigene Löschsperre, da keine Entität darauf zurückverweist.

Validierung beim Bearbeiten

telemetry_key_service.update prüft beim Setzen von observed_property_id, dass unter dieser Id tatsächlich eine ObservedProperty existiert – ein Select gegen die kanonische Postgres‑Tabelle (observed_property_repository.get_by_id), nicht gegen FROST. Existiert sie nicht, wird die Änderung mit einer BusinessRuleViolationError abgelehnt, statt einen Telemetry Key mit einer nicht existenten Zuordnung zu speichern.

Die Spalte trägt seit dem Umbau auf einen echten Fremdschlüssel (ondelete="RESTRICT") die Id und nicht mehr den Namen der Observed Property. Der Wächter bleibt trotzdem: RESTRICT verweigert mit einem IntegrityError, der weder sagt, welches Feld falsch war, noch als 422 beim Aufrufer ankommt. Die Prüfung läuft nur auf dem PATCH‑Pfad, weil dort der Wert steht, den ein Mensch in der Oberfläche ausgewählt hat; eine unsinnige Id an POST fängt die Bedingung ab.

ObservedProperty: kanonisch in Postgres, kopiert je Mandant

ObservedProperty ist die einzige Stammdatenentität mit einer eigenen physischen FROST‑Identität. Die Wahrheit liegt in der Postgres‑Tabelle observedproperty; jeder Mandant hält in seinem FROST eine abgeleitete Kopie. Welche FROST‑Entität die Kopie ist, steht in tenantobservedproperty (tenant_id, observed_property_idfrost_id); die Spalten dieser Tabelle stehen in Datenbankschema des Adapters.

Lesen, Auflösen, Schreiben

  • Lesen (get_all, get_by_id, get_by_name) sind reine Postgres‑Selects und sprechen keinen FROST‑Server an.

  • Auflösen einer Kopie erfolgt ausschließlich über die hinterlegte frost_id, niemals über den Namen. Fehlt die Zuordnung, ist die Kopie nie angekommen und wird angelegt; antwortet FROST auf die hinterlegte ID mit 404, wurde sie außerhalb des Adapters gelöscht und wird neu angelegt. Eine namensbasierte Auflösung kann diese beiden Fälle nicht unterscheiden und würde im zweiten Fall ein Duplikat erzeugen.

  • Schreiben (Anlegen, Ändern, Löschen der Kopien) läuft best‑effort über alle Mandanten. Postgres wird in jedem Fall committet; nicht erreichte Mandanten stehen im Feld failed_tenants der Antwort und werden protokolliert.

Der Abgleich

  • Abgleich: POST /tenant/{id}/sync-observed-properties (in der Oberfläche der Button „Sync observed properties“ je Mandant) stellt idempotent sicher, dass jeder kanonische Eintrag im FROST des Mandanten vorhanden und aktuell ist. Ein und derselbe Code‑Pfad dient als Wiederholung einer fehlgeschlagenen Übertragung, als Onboarding‑Schritt für einen neuen Mandanten und als Cutover‑Schritt.

  • Der Abgleich ist rein ergänzend: er löscht nichts im FROST des Mandanten. Kopien ohne Zuordnungszeile werden nicht angetastet — weder die unbenutzten, die durch die geschlossene Löschprüfung nachweislich inert sind und deshalb auch nicht gemeldet werden, noch die benutzten, deren Löschen in SensorThings kaskadierend Datastreams und Observations vernichten würde. Benutzte, nicht verwaltete Kopien werden ausschließlich gemeldet (unmanaged_in_use): dieser Zustand — eine Größe auf zwei Entitäten verteilt — lässt sich nicht automatisch reparieren und erfordert eine menschliche Entscheidung.

  • Der Abgleich bricht beim ersten Fehler ab und die Route antwortet mit 502 unter Nennung des Mandanten. Der realistische Fehlerfall ist nicht eine einzelne Eigenschaft, sondern ein nicht erreichbarer FROST, der jede Übertragung gleichermaßen scheitern lässt. Da der Abgleich idempotent ist, ist ein Abbruch unkritisch: die Wiederholung erledigt den Rest.

  • Wie viele Einträge ein Abgleich übertragen hat, steht in der Logzeile, nicht in der Antwort; die Namen neu angelegter Kopien protokolliert der Adapter beim Anlegen selbst. Eine leere Antwort bedeutet: alles in Ordnung.

Beim Start

Beim Start des Adapters werden die Standard‑Observed‑Properties nur in Postgres angelegt; es wird kein FROST‑Server kontaktiert.

Aufnahme und Stilllegung eines Mandanten

Die Aufnahme eines Mandanten ist eine Abfolge, keine eigene Operation: es gibt weder einen onboard‑Endpunkt noch eine Spalte onboarded_at. POST /tenant (services/tenant_service.py::onboard) erledigt beides in einem Aufruf und ohne Neustart:

  1. Prüfen der Zugangsdaten – fail‑closed, in zwei Stufen. Zuerst die Form: Zugangsdaten, die nicht zur konfigurierten ThingsBoard‑Edition passen, werden mit 422 abgelehnt, bevor irgendein Fremdsystem angesprochen wird — ein API‑Key gegen eine Community Edition ist kein Zugang, der vielleicht funktioniert, sondern einer, der es nie kann. Danach die Erreichbarkeit: Schlägt eine der drei Prüfungen fehl, antwortet die Route mit 400. In beiden Fällen wird keine Zeile angelegt.

  2. Schreiben der Mandantenzeile.

  3. Replizieren der kanonischen Observed Properties in den FROST des Mandanten – best‑effort, über denselben Abgleich wie der Button „Sync observed properties“.

Die beiden letzten Schritte folgen bewusst nicht derselben Regel: Ein fehlgeschlagener Abgleich rollt die Zeile nicht zurück. Die Kopien entstehen spätestens beim ersten Telemetriezugriff, ein fehlgeschlagener Abgleich ist also eine Verzögerung und kein Defekt – eine geprüfte Zeile dafür zu verwerfen wäre schlechter. Die Antwort trägt deshalb beides: {tenant, sync}, wobei sync: null bedeutet, dass die Replikation nicht durchgelaufen ist und über den Sync‑Button wiederholt werden sollte.

Die Zugangsdaten werden ausschließlich beim Schreiben geprüft und danach nie wieder; ein rotiertes ThingsBoard‑Zugangsdatum fällt erst durch Telemetrieverkehr auf und wird über GET /tenant/{id}/health sichtbar. Was der Adapter von selbst überbrückt, ist nur die ablaufende Sitzung: Der Client‑Cache baut das Paar eines Mandanten neu, bevor dessen ThingsBoard‑Token abläuft. Nötig ist das unter der Community Edition, weil deren Token nach 150 Minuten stirbt, der Cache aber die Lebensdauer des Prozesses hat. Der Adapter schreibt dabei zu keinem Zeitpunkt in ThingsBoard – die Rule Chain des Mandanten wird von Hand angelegt und bleibt in der Hand des Betreibers.

PATCH mit geändertem frost_server_url

Ändert ein PATCH den FROST‑Server, werden alle Zuordnungszeilen dieses Mandanten gelöscht und der Abgleich läuft erneut. Grund: Jede hinterlegte frost_id ist eine ID im alten Server. FROST vergibt kleine fortlaufende Ganzzahlen, ein frisch bereitgestellter Server antwortet auf ID 3 also durchaus mit irgendeiner Entität – die Auflösung würde einer bestehenden Zuordnung vertrauen und neue Datastreams still an fremde Entitäten hängen. Ein PATCH, der die URL nicht anfasst, löst keinen Abgleich aus; die Zuordnungen sind weiterhin gültig.

Der FROST‑Server bleibt änderbar (im Gegensatz zum slug): Da Löschen der einzige andere Ausweg und das ThingsBoard‑Geheimnis nur schreibbar ist, würde „löschen und neu anlegen“ bedeuten, für einen Tippfehler in der URL die ThingsBoard‑Zugangsdaten erneut eingeben zu müssen.

DELETE /tenant/{id} ist auf Things abgesichert

Die Route fragt den FROST des Mandanten und lehnt mit 409 ab, wenn dort noch ein Thing existiert – oder wenn der Server nicht erreichbar ist. Fail‑closed wie die Stammdatenprüfungen: „nicht erreichbar“ ist kein Beleg für „leer“. Geprüft werden Things und nicht Datastreams, weil ein Datastream in SensorThings ohne Thing nicht existieren kann – „keine Things“ ist die stärkere Bedingung und „dieser Mandant hat noch Geräte registriert“ die bessere Meldung.

Reihenfolge der Stilllegung: Geräte des Mandanten in ThingsBoard aus der Publish‑to‑FROST‑Gruppe entfernen → Mandanten im Adapter löschen → danach die FROST‑Instanz abbauen. Der erste Schritt ist keine für die Prüfung erfundene Zusatzarbeit: delete_device löscht das Thing samt seiner Datastreams und Observations.

Ein erfolgreiches Löschen entfernt die Zeile und kaskadierend ihre TenantObservedProperty‑Zuordnungen; der FROST‑Server des Mandanten und seine Daten bleiben unangetastet. Ein Force‑Flag gibt es bewusst nicht: Bei zwei bis drei Mandanten ist das Geraderücken einer falsch sortierten Stilllegung von Hand vertretbar, während das Flag eine dauerhafte Möglichkeit wäre, genau den Zustand herzustellen, den die Zuordnungstabelle verhindert. Ein Mandant, der nur pausieren soll, braucht davon nichts – eine Zeile ohne Verkehr kostet nichts.

Daraus folgt die Invariante, auf der die Selbstheilung beruht: Der Adapter registriert einen Mandanten nur gegen einen FROST ohne Datastreams. Löschen und neu anlegen konvergiert deshalb – keine Zuordnungszeilen, der Abgleich legt frische Kopien an, und die alten Kopien bleiben unbenutzt und inert liegen (siehe oben: der Abgleich löscht nichts).

Propagation von Umbenennungen nach FROST

Wird der Name bzw. das Symbol/die Definition einer lokal verwalteten Entität geändert, überträgt der jeweilige Service diese Änderung best‑effort auf die zugehörigen FROST‑Datastreams:

Auslöser Wirkung auf FROST

unit_of_measurement_service.update – Name, Symbol oder Definition ändert sich

TenantFrostRepository.rename_unit_of_measurement je Mandant: für jeden per find_datastreams_by_uom_name gefundenen Datastream wird unitOfMeasurement mit den neuen Werten überschrieben

telemetry_key_service.updatekey ändert sich

TenantFrostRepository.rename_datastreams je Mandant: für jeden per find_datastreams_by_name gefundenen Datastream wird name auf den neuen Key gesetzt

observed_property_service.updatename ändert sich

TenantObservedPropertyService.push_observed_property je Mandant: die Kopie im FROST des Mandanten wird über ihre hinterlegte frost_id überschrieben – dieselbe Entität, kein zweiter Datensatz. Lokal ist nichts nachzuziehen: TelemetryKey verweist über observed_property_id und nicht über den Namen, eine Umbenennung geht also an der Zuordnung vorbei.

Alle drei Übertragungen laufen über alle Mandanten (services/cross_tenant.py::push_to_every_tenant) und sind best‑effort: Schlägt die Aktualisierung eines einzelnen Datastreams fehl, wird der Fehler protokolliert (logger.exception) und die Verarbeitung der übrigen Datastreams fortgesetzt; schlägt ein ganzer Mandant fehl, werden die übrigen trotzdem bedient. Die lokale Änderung wird in jedem Fall übernommen; es erfolgt kein Rollback. Nicht erreichte Mandanten stehen im Feld failed_tenants der Antwort, das die Oberfläche als Hinweis anzeigt.

Jeder Schreibzugriff auf einen Datastream läuft dabei unter dem Thing‑Lock dieses Mandanten (entity_lock(slug, LockedEntity.THING, …)), damit eine Umbenennung nicht mit dem Telemetriepfad kollidiert und Mandanten sich nicht gegenseitig blockieren, wenn sie zufällig ein gleichnamiges Gerät haben.

Für ObservedProperty ist die Wiederholung der Abgleich („Sync observed properties“). Für Telemetry Keys und Maßeinheiten gibt es sie nicht: Diese Entitäten haben keine eigene FROST‑Identität, sondern stehen nur als Name bzw. eingebetteter Wert in den Datastreams. Sobald die kanonische Zeile den neuen Namen trägt, findet keine Abfrage die nicht umbenannten Datastreams mehr – sie müssen im FROST des betroffenen Mandanten von Hand korrigiert werden.

Fehlerantwort im Beispiel

{
  "detail": "Cannot delete unit of measurement 'Grad Celsius': still referenced by 2 telemetry key(s) and 0 unit-of-measurement key(s)."
}

Diese Antwort wird mit HTTP‑Status 422 zurückgegeben, sobald eine der oben beschriebenen Regeln verletzt wird.

Schnittstellenbeschreibung SensorThings API (FROST‑Server)

Einleitung

Der FROST‑Server ist die Referenzimplementierung des OGC SensorThings API Standards (Version 1.1) und die Schnittstelle, über die die vom Adapter exportierten Daten gelesen werden. Diese Seite ist eine Kurzreferenz für den Zugriff darauf — der vollständige Standard und die vollständige Serverdokumentation stehen unter Weiterführende Referenzen.

Es gibt keine gemeinsame Basis‑URL. Jeder Mandant hat einen eigenen FROST‑Server, und dessen Service‑Root steht als frost_server_url in seiner Zeile der Mandanten‑Registry — abrufbar über GET /tenant oder ablesbar auf der Seite Tenants der Verwaltungsoberfläche. Die Beispiele auf dieser Seite schreiben dafür den Platzhalter {frost_server_url}; er endet auf /v1.1.

Entitäten – Übersicht

Die SensorThings API modelliert IoT‑Daten anhand von sieben Kern‑Entitäten.

Entität Zweck Endpunkt

Thing

Repräsentiert ein physisches oder virtuelles Objekt (z. B. eine Messstation).

/Things

Location

Geografischer Standort eines Things.

/Locations

Sensor

Beschreibt das Messinstrument oder den Gerätetyp.

/Sensors

ObservedProperty

Die gemessene Eigenschaft (z. B. Temperatur, CO₂‑Konzentration).

/ObservedProperties

Datastream

Verbindet Thing, Sensor und ObservedProperty zu einem Messdatenkanal.

/Datastreams

Observation

Einzelner Messwert innerhalb eines Datastreams.

/Observations

FeatureOfInterest

Räumliches Objekt, auf das sich eine Observation bezieht.

/FeaturesOfInterest

Der Adapter legt sechs davon selbst an; FeaturesOfInterest erzeugt FROST bei Bedarf selbst. Womit der Adapter die Felder jeder Entität füllt, steht in Datenmodell.

Die Beziehungen zwischen den Entitäten folgen dem UML‑Modell des OGC SensorThings Standards:

SensorThings API – Entitätenmodell

Adressierung

Jeder Entitätstyp wird über einen eigenen Pfad angesprochen. Die folgende Konvention gilt durchgängig für alle sieben:

Muster Bedeutung

/{EntitySet}

Alle Entitäten dieses Typs abrufen (Collection).

/{EntitySet}({id})

Eine einzelne Entität anhand ihrer ID abrufen.

/{EntitySet}({id})/{NavigationProperty}

Verknüpfte Entitäten über eine Beziehung navigieren.

curl -s "{frost_server_url}/Things"
curl -s "{frost_server_url}/Things(1)"
curl -s "{frost_server_url}/Things(1)/Datastreams"
curl -s "{frost_server_url}/Datastreams(1)/Observations"

Abfrageparameter und Filterung

Die SensorThings API unterstützt standardisierte Query‑Optionen, die als URL‑Parameter übergeben werden.

Parameter Beschreibung Beispiel

$top

Begrenzt die Anzahl der zurückgegebenen Entitäten.

$top=10

$skip

Überspringt die angegebene Anzahl an Entitäten (für Pagination).

$skip=20

$count

Gibt die Gesamtzahl der Entitäten im Ergebnis mit zurück.

$count=true

$orderby

Sortiert die Ergebnisse nach einem oder mehreren Feldern.

$orderby=phenomenonTime desc

$select

Beschränkt die zurückgegebenen Eigenschaften auf die genannten Felder.

$select=name,description

$expand

Lädt verknüpfte Entitäten inline mit (reduziert Anzahl der Anfragen).

$expand=Datastreams

$filter

Filtert Ergebnisse anhand logischer Ausdrücke.

$filter=result gt 30

Pagination

Der FROST‑Server liefert standardmäßig eine begrenzte Anzahl von Entitäten pro Anfrage (serverseitige Pagination). Enthält die Antwort mehr Daten als das Limit erlaubt, wird im JSON ein Feld @iot.nextLink ausgegeben, das die URL für die nächste Seite enthält.

# Erste Seite mit 100 Einträgen
curl -s "{frost_server_url}/Observations?\$top=100&\$count=true"

# Nächste Seite manuell abrufen
curl -s "{frost_server_url}/Observations?\$top=100&\$skip=100"
Programmatische Clients sollten das @iot.nextLink‑Feld auswerten und iterativ abrufen, bis kein weiterer Link mehr vorhanden ist.

Filter‑Ausdrücke

Operator Bedeutung Beispiel

eq

Gleichheit

$filter=name eq 'Temperatur'

ne

Ungleichheit

$filter=name ne 'Temperatur'

gt / ge

Größer als / Größer oder gleich

$filter=result gt 25

lt / le

Kleiner als / Kleiner oder gleich

$filter=result le 100

and / or

Logische Verknüpfung

$filter=result gt 20 and result lt 30

substringof

Teilzeichenkette prüfen

$filter=substringof('Temperatur', name)

startswith

Anfangszeichenkette prüfen

$filter=startswith(name, 'CO2')

Expand mit verschachtelter Abfrage

$expand kann mit Unter‑Query‑Optionen kombiniert werden, um gezielt verknüpfte Daten einzugrenzen.

# Datastream mit den letzten 5 Observations
curl -s "{frost_server_url}/Datastreams(1)?\$expand=Observations(\$top=5;\$orderby=phenomenonTime desc)"

# Things mit expandierten Datastreams und deren ObservedProperty
curl -s "{frost_server_url}/Things?\$expand=Datastreams(\$expand=ObservedProperty)"
Für komplexe und tief verschachtelte Abfragen konsultieren Sie die offizielle Dokumentation unter https://fraunhoferiosb.github.io/FROST-Server/settings/queryDefaults.html.

Praxisbeispiele

# Alle Things mit ihren Standorten und der Anzahl
curl -s "{frost_server_url}/Things?\$expand=Locations&\$count=true"

# Observations eines Datastreams in einem Zeitfenster, absteigend, auf 50 begrenzt
curl -s "{frost_server_url}/Datastreams(1)/Observations?\$filter=phenomenonTime ge 2024-06-01T00:00:00Z and phenomenonTime le 2024-06-30T23:59:59Z&\$orderby=phenomenonTime desc&\$top=50&\$count=true"

# Nur Name und Beschreibung aller Sensors
curl -s "{frost_server_url}/Sensors?\$select=name,description"

# Things, deren Name mit 'KSPB' beginnt
curl -s "{frost_server_url}/Things?\$filter=startswith(name,'KSPB')"

# Die jüngste Observation überhaupt — dieselbe Abfrage, die die Erreichbarkeitsprüfung stellt
curl -s "{frost_server_url}/Observations?\$orderby=phenomenonTime desc&\$top=1"

System‑Endpunkte

Neben den Entity‑Endpunkten stellt der FROST‑Server System‑Endpunkte bereit, die für Betrieb und Monitoring relevant sind. Sie liegen unterhalb der Service‑Root, also nicht unter /v1.1.

Endpunkt Beschreibung

/

Willkommensseite des FROST‑Servers (HTML).

/v1.1

Service‑Root – listet alle verfügbaren Entity‑Sets auf (JSON). Dies ist der Endpunkt, den die Erreichbarkeitsprüfung des Adapters abfragt.

/DatabaseStatus

Status der Datenbankverbindung. Zugleich die Admin‑Seite, über die eine neue Instanz ihr Schema initialisiert (FROST‑Instanz bereitstellen).

In Kubernetes‑Umgebungen eignet sich /DatabaseStatus als Liveness‑ oder Readiness‑Probe für den FROST‑Server‑Pod.

Betriebsanleitungen (Runbooks)

Dieses Kapitel beschreibt die Abläufe, die ein Betreiber von Hand ausführt: das Bereitstellen eines FROST‑Servers für einen neuen Mandanten, das Verteilen der Rule Chain, das Stilllegen eines Mandanten und die Wiederherstellung nach einem Verlust des Verschlüsselungsschlüssels. Keiner dieser Abläufe ist automatisiert — bei zwei bis drei Mandanten und einer Handvoll Ausführungen pro Jahr ist die Automatisierung teurer als der Ablauf.

Der Adapter schreibt an keiner Stelle nach ThingsBoard und legt an keiner Stelle einen FROST‑Server an. Alles in diesem Kapitel passiert neben dem Adapter; er erfährt vom Ergebnis nur über die zwei Werte, die am Ende in die Mandantenzeile eingetragen werden.

Die Umgebung, gegen die diese Abläufe stattfinden — Compose‑Dateien, Dienste und Umgebungsvariablen — beschreibt Repository und Entwicklungsumgebung.

Vorbemerkung: Unerreichbarkeit ist die Sicherheitsgrenze

Der Adapter authentifiziert niemanden. Er hat keine API‑Keys, keine Tokens, keine Benutzer — jeder, der ihn über das Netz erreicht, kann GET /tenant aufrufen und die Konfiguration aller Mandanten lesen sowie über POST /ingest/{slug}/…​ in jeden FROST‑Server schreiben. Der einzige Schutz ist, dass er ausschließlich innerhalb des Docker‑Netzes erreichbar ist.

Deshalb gilt für die echte Installation:

Die Portfreigabe 8000:8000 des Services thingsboard_frost_adapter in docker-compose.app.yml ist eine reine Entwicklungshilfe und darf in der echten Installation nicht vorhanden sein. ThingsBoard erreicht den Adapter über das Docker‑Netz als thingsboard-frost-adapter:8000 und benötigt keinen veröffentlichten Port.

Bleibt die Freigabe stehen, genügt ein curl host:8000/tenant vom Docker‑Host aus, um die Registry aller Mandanten zu lesen. Das ThingsBoard‑Geheimnis ist in der Antwort zwar nicht enthalten (API‑Key und Passwort werden nie zurückgeliefert), alles andere schon — unter der Community Edition auch der Benutzername — und schreibend ist der Adapter ohnehin vollständig offen.

Wer eine Shell auf dem Docker‑Host hat, liest ohnehin jede Mandantenkonfiguration; das ist eine bewusst akzeptierte Einschränkung (siehe Bewusst akzeptierte Einschränkungen).

FROST‑Instanz für einen Mandanten bereitstellen

Wird vom Betreiber ausgeführt, bevor die Mandantenzeile angelegt wird — die Registrierung prüft fail‑closed gegen die realen Systeme und schlägt fehl, solange der FROST‑Server nicht steht. Dauer etwa eine Stunde.

Jeder Mandant bekommt eine eigene FROST‑Instanz mit eigener Datenbank und eigenem Keycloak‑Client. Das ist die Topologie, aus der sich alles Weitere ergibt: getrennte Datenhaltung, getrennte Leserechte und ein frost_server_url, der in der Registry eindeutig ist.

1. Datenbank anlegen

Im bestehenden database‑Container (PostGIS):

CREATE DATABASE sensorthings_<slug>;
CREATE USER sensorthings_<slug> WITH PASSWORD '<generiert>';
GRANT ALL PRIVILEGES ON DATABASE sensorthings_<slug> TO sensorthings_<slug>;

Kein Schema anlegen — persistence_autoUpdateDatabase=true baut es beim ersten Start selbst auf.

2. Keycloak‑Client anlegen

Im bestehenden Realm (in der Entwicklungsumgebung FROST-Test) einen Client frost-server-<slug> nach dem Vorbild des bestehenden Clients frost-server anlegen und dessen Secret (auth_keycloakConfigSecret) notieren.

Anschließend dem bestehenden globalen Adapter‑Benutzer die Client‑Rollen dieses neuen Clients zuweisen.

Es wird kein Benutzer pro Mandant angelegt. FROST wertet Client‑Rollen aus, keine Realm‑Rollen — ein einziger Benutzer mit Client‑Rollen an N Clients ist der gesamte Mechanismus. Benutzername und Passwort dieses Kontos stehen deployment‑weit in KEYCLOAK_USERNAME_FROST_SERVER und KEYCLOAK_PASSWORD_FROST_SERVER; pro Mandant unterscheidet sich nur der Client.

3. Compose‑Service ergänzen

Den Block frost_server kopieren und fünf Werte ändern: Port, Service‑Root‑URL, Datenbank, Datenbankbenutzer/‑passwort und Keycloak‑Client. Das Image‑Tag bleibt überall identisch (siehe Image‑Tag).

  frost_server_<slug>:
    image: fraunhoferiosb/frost-server:${FROST_SERVER_TAG:-2.7.3}
    environment:
      - serviceRootUrl=http://${EXTERNAL_IP}:<port>/FROST-Server
      - plugins_modelLoader_enable=true
      - plugins_projects_enable=false             # bewusst aus
      - plugins_multiDatastream_enable=false
      - plugins_actuation_enable=false
      - http_cors_enable=true
      - http_cors_allowed_origins=*
      - persistence_db_driver=org.postgresql.Driver
      - persistence_db_url=jdbc:postgresql://database:5432/sensorthings_<slug>
      - persistence_db_username=sensorthings_<slug>
      - persistence_db_password=<generiert>
      - persistence_autoUpdateDatabase=true
      - auth_provider=de.fraunhofer.iosb.ilt.frostserver.auth.keycloak.KeycloakAuthProvider
      - auth_keycloakConfigUrl=http://${EXTERNAL_IP}:8180/realms/FROST-Test/clients-registrations/install/frost-server-<slug>
      - auth_keycloakConfigSecret=<Client Secret>
      - auth_allowAnonymousRead=true              # false, wenn dieser Mandant authentifizierte Lesezugriffe will
    ports:
      - <port>:8080
    depends_on:
      database: { condition: service_healthy }
      keycloak_web: { condition: service_healthy }
    healthcheck:
      test: [ "CMD-SHELL", "wget --spider -q http://localhost:8080/FROST-Server/v1.1 || exit 1" ]
      interval: 30s
      timeout: 10s
      retries: 10
      start_period: 60s

auth_allowAnonymousRead ist die Stelle, an der die Anforderung „manche Kunden wollen ihre Daten hinter einer Authentifizierung“ tatsächlich zu einem Schalter wird — pro Instanz, und genau das ist der Gegenwert der Topologie ein FROST‑Server je Mandant.

4. Starten und Datenbank initialisieren

Den Service hochfahren und die Initialisierung über die Admin‑Seite DatabaseStatus des FROST‑Servers anklicken. Dieser Schritt ist manuell und hat kein API‑Äquivalent.

5. Zwei Werte zurückgeben

An die Registry gehen genau zwei Werte:

Nichts anderes aus diesem Ablauf landet in der Mandantenzeile. Datenbankpasswort und Client‑Secret kennt der Adapter nie.

Danach wird der Mandant über die Verwaltungsoberfläche registriert; die Registrierung überträgt anschließend alle Observed Properties in den neuen, leeren FROST‑Server.

Nach einem Umzug auf Kubernetes wird aus diesem Ablauf ein helm install frost-<slug> gegen das offizielle Chart von Fraunhofer (je Instanz etwa 1 GiB Speicher und 500 m CPU angefordert), mit Wildcard‑DNS plus Ingress für das Routing. Diese Automatisierung ist bewusst nicht gebaut.

Image‑Tag

Alle FROST‑Instanzen laufen auf demselben festgeschriebenen Tag (FROST_SERVER_TAG, Standard 2.7.3), niemals auf latest. Zwei Instanzen auf unterschiedlichen Versionen unterscheiden sich im Verhalten der SensorThings‑API, und ein docker compose pull würde diesen Unterschied unbemerkt erzeugen. Eine Versionsanhebung ist eine Änderung an .env und betrifft dann alle Mandanten gleichzeitig.

Rule Chain je Mandant verteilen

rulechain/thingsboard_frost_adapter.json ist eine exportierte Rule Chain mit vier TbRestApiCallNode und einem TBEL‑Filterknoten, der auf den Gerätenamen Frost Adapter Test Device festgelegt ist.

Die Datei im Repository trägt den Slug fest verdrahtet in der URL, nicht als Templating‑Stufe ({{SLUG}}) — eine solche Datei ließe sich ohne einen Substitutionsschritt gar nicht erst in ThingsBoard importieren, und ein solcher Schritt existiert nicht. Angestrebt ist der Slug des ersten Mandanten: nur so dokumentiert die eingecheckte Chain eine Endpunkt‑Oberfläche, die es wirklich gibt.

Zurzeit steht dort der Platzhalter replace-me-with-tenant-1-slug, weil der Slug des ersten Mandanten bei der Umstellung noch nicht feststand. Solange er darin steht, ist die Datei nicht importierbereit: der Import ergibt vier unsichtbare 404er. Er ist beim ersten Import durch den echten Slug zu ersetzen — und zwar in der Datei im Repository, nicht nur in der ThingsBoard‑Oberfläche.

Ablauf je weiterem Mandant:

  1. rulechain/thingsboard_frost_adapter.json in den ThingsBoard‑Mandanten importieren.

  2. In allen vier TbRestApiCallNode die URL anpassen und den dort stehenden Slug durch den eigenen ersetzen:

    http://thingsboard-frost-adapter:8000/ingest/<slug>/telemetry
    http://thingsboard-frost-adapter:8000/ingest/<slug>/device/update
    http://thingsboard-frost-adapter:8000/ingest/<slug>/device/delete
    http://thingsboard-frost-adapter:8000/ingest/<slug>/asset/update

    Der Slug muss zeichengenau dem Slug der Mandantenzeile entsprechen. Ein falscher Slug ergibt einen 404 mit dem Namen der abgelehnten Entität im Log des Adapters — geschrieben wird nichts.

  3. Prüfen, dass jeder der vier REST‑Knoten seine Failure‑Relation auf einen TbCreateAlarmNode verdrahtet hat. Die ausgelieferte Datei bringt diese Verdrahtung mit; eine von Hand gebaute Chain muss sie ergänzen. Ohne sie verwirft die Rule Chain jeden Fehler des Adapters stillschweigend.

  4. Die Bedingung des TBEL‑Filterknotens anpassen, falls dieser Mandant nach anderen Kriterien veröffentlicht.

Kommt jemals ein fünfter REST‑Knoten oder ein fünfter Endpunkt hinzu, gelten beide Schritte — die URL‑Anpassung und die Failure‑Verdrahtung — auch für ihn.

Mandant stilllegen

Die Reihenfolge ist verbindlich; der 409 beim Löschen erzwingt sie. Die Schritte in der Oberfläche sind unter Einen Mandanten stilllegen beschrieben; hier stehen sie in der Fassung, die der Betrieb braucht.

  1. Die Geräte des Mandanten in ThingsBoard aus der Veröffentlichungsgruppe entfernen. Jedes /device/delete entfernt das zugehörige Thing aus FROST und kaskadierend dessen Datastreams und Observations.

  2. Den Mandanten auf der Seite Tenants der Verwaltungsoberfläche löschen — alternativ DELETE /tenant/{id}, wobei die id aus GET /tenant stammt, da der Ablauf hier nur den Slug kennt. Ein 409 bedeutet: Schritt 1 ist unvollständig, oder der FROST‑Server antwortet nicht.

  3. Erst jetzt die FROST‑Instanz, ihre Datenbank und ihren Keycloak‑Client abbauen.

Wiederherstellung nach falscher Reihenfolge

Wurde der FROST‑Server zuerst abgebaut, lässt sich der Mandant über die API nie mehr löschen: die Löschprüfung fragt seinen FROST‑Server, ob dort noch Things hängen, und bekommt keine Antwort mehr — „nicht erreichbar“ gilt fail‑closed als „nicht freigegeben“.

Die Folge trifft nicht nur diesen Mandanten. Die tote Zeile lässt jede Löschung einer globalen ObservedProperty, eines TelemetryKey und einer UnitOfMeasurement dauerhaft und für alle scheitern, weil auch diese Prüfungen über alle Mandanten laufen und fail‑closed sind. Ein Force‑Flag gibt es bewusst nicht.

Der einzige Ausweg ist eine von Hand ausgeführte Anweisung gegen die Adapter‑Datenbank:

DELETE FROM tenant WHERE slug = '<slug>';   -- tenantobservedproperty wird kaskadierend entfernt

Umbenennen

Ein Mandant wird nicht umbenannt, sondern gelöscht und neu angelegt — der Slug ist unveränderlich, weil er in der Rule‑Chain‑URL eines fremden Systems steht. Praktisch sind das die Schritte 1 und 2 oben, danach eine neue Registrierung gegen denselben frost_server_url, ohne Schritt 3.

Dabei bleiben die Observed‑Property‑Kopien des alten Slugs im FROST‑Server des Mandanten liegen; sie sind nachweislich inert, tauchen aber in der öffentlichen SensorThings‑Sammlung doppelt auf, und jedes weitere Umbenennen legt einen weiteren Satz an. Sie sind deshalb von Hand zu entfernen, solange noch klar ist, welcher Satz der aktuelle ist:

  1. GET {frost_server_url}/v1.1/ObservedProperties?$expand=Datastreams($top=1) — die verwaisten sind die ohne Datastreams.

  2. Vor dem Löschen gegen den eigenen Datenbestand des Adapters gegenprüfen:

    SELECT frost_id FROM tenantobservedproperty WHERE tenant_id = <id>;

    Diese IDs sind der aktuelle Satz und dürfen nicht angefasst werden.

  3. DELETE {frost_server_url}/v1.1/ObservedProperties(<id>) für alle übrigen.

Niemals eine Kopie löschen, an der Datastreams hängen — in SensorThings kaskadiert das auf deren Observations. Hat ein gelisteter Eintrag Datastreams und steht nicht in der Zuordnungstabelle, ist das nicht Umbenennungs‑Rest, sondern der Fall unmanaged_in_use, den der Abgleich meldet: eine echte Anomalie, die eine menschliche Entscheidung braucht.

Verlust des Verschlüsselungsschlüssels

TENANT_SECRET_KEY ist der Fernet‑Schlüssel, mit dem das ThingsBoard‑Geheimnis jedes Mandanten in der Datenbank verschlüsselt ist — der API‑Key unter PE, das Passwort unter CE. Er ist Pflicht, hat keinen Standardwert, keinen Klartext‑Rückfallweg — und keinen Rotationsmechanismus.

Geht er verloren oder wird er neu erzeugt, ist jedes gespeicherte Geheimnis nicht mehr entschlüsselbar. Der Adapter startet weiterhin; der Fehler zeigt sich pro Mandant beim Bau seiner Clients mit einer expliziten Meldung, und andere Mandanten arbeiten weiter — allerdings nur, sofern sie unter einem noch gültigen Schlüssel verschlüsselt wurden, was nach einem vollständigen Schlüsselverlust auf keinen zutrifft.

Die Wiederherstellung besteht darin, den neuen Schlüssel zu setzen und anschließend für jeden Mandanten das ThingsBoard‑Geheimnis über die Verwaltungsoberfläche neu einzugeben. Bei zwei bis drei Mandanten sind das Minuten. Derselbe Ablauf gilt, wenn ein Datenbank‑Dump in eine Installation mit einem anderen Schlüssel eingespielt wird.

Ein neuer Schlüssel wird erzeugt mit:

python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

Wechsel der ThingsBoard‑Edition

THINGSBOARD_EDITION sagt, welche ThingsBoard‑Installation hinter THINGSBOARD_URL steht: pe (Standard) oder ce. Sie wird nie ermittelt, sondern konfiguriert — ein Client zu bauen hieße sonst, ein Fremdsystem zu fragen, was es ist, und das Urteil darüber zwischenzuspeichern.

Die Edition entscheidet beides zugleich, Client‑Klasse und Zugangsdaten:

Edition Zugangsdaten je Mandant

pe

thingsboard_api_key

ce

thingsboard_username + thingsboard_password, denn die Community Edition kennt keine API‑Key‑Authentifizierung: X-Authorization: ApiKey beantwortet sie mit 401, und es gibt keinen Endpunkt, der einen Schlüssel ausstellt.

Ein Wechsel der Edition entwertet jede bestehende Mandantenzeile. Alle tragen danach die Zugangsdaten der jeweils anderen Edition, es gibt keine Reparatur an Ort und Stelle — die Zeilen müssen neu registriert werden.

Bis dahin lehnt die Registry jedes PATCH auf diese Zeilen mit 422 ab und nennt die konfigurierte Edition sowie die fehlenden Felder. Das ist beabsichtigt und sieht nur dann wie ein Fehler aus, wenn man es nicht erwartet hat.

Der Ablauf auf dem Entwicklungssystem, auf dem der Adapter läuft:

  1. THINGSBOARD_EDITION in .env setzen und den Adapter neu starten.

  2. Jeden Mandanten löschen und neu anlegen. Löschen setzt voraus, dass sein FROST‑Server keine Things mehr hält (siehe Mandant stilllegen) — die Alternative ist ein docker compose down -v und ein Neuaufbau, was auf einem Entwicklungssystem der kürzere Weg ist.

  3. Ende zu Ende prüfen: Frost Adapter Test Device auslösen und im FROST des Mandanten eine Observation mit frischem phenomenonTime bestätigen.

Was mit ablaufenden Tokens passiert

Unter PE trägt der API‑Key jede Anfrage und läuft nicht ab. Unter CE hält der Client ein JWT mit 150 Minuten Lebensdauer, während der Client‑Cache ihn für die Lebensdauer des Prozesses behält — ohne Gegenmaßnahme wäre nach 2,5 Stunden jeder ThingsBoard‑Zugriff dieses Mandanten kaputt, bis jemand den Adapter neu startet.

Die Gegenmaßnahme steht im Cache selbst (clients/tenant_clients.py): Er beantwortet zwei Fragen, und beide lauten „ist dieses Client‑Paar noch brauchbar?“ — hat sich die Zeile geändert (Fingerprint), und läuft die Sitzung ab (exp des Tokens, mit fünf Minuten Sicherheitsabstand). Trifft eines von beidem zu, wird das Paar neu gebaut. Unter PE ist exp schlicht 0, weil ein API‑Key gar kein Token hinterlegt; dort läuft nie etwas ab.

Nicht abgedeckt ist eine Sitzung, die ThingsBoard vorzeitig beendet — ein dort rotiertes Passwort oder eine von einem Administrator beendete Sitzung. Ein rotiertes Passwort muss ohnehin in die Mandantenzeile eingetragen werden, damit der Adapter es benutzen kann, und bewegt damit den Fingerprint; der zweite Fall ist selten und heilt spätestens mit exp. Bis dahin zeigt sich beides als fehlgeschlagene Anfrage und rotes Test connection.

Umstellung auf den mandantenfähigen Adapter

Es gibt keine Migration. Die Umstellung ist docker compose down -v und ein Neuaufbau — der Adapter läuft nur auf dem Entwicklungssystem des Teams, und alles, was er bisher geschrieben hat, ist entbehrlich. Diese eine Tatsache lässt Datenübernahme, Wartungsfenster und verwaiste Redis‑Sperrschlüssel zu einem einzigen zerstörenden Schritt zusammenfallen.

  1. Stoppen und zerstören. docker compose down -v. Die Datenbank des FROST‑Servers, die Postgres‑Datenbank des Adapters und Redis sind danach weg.

  2. Mandantenfähigen Adapter aufbauen. bootstrap() legt das Schema an und füllt die Stammdaten inklusive der kanonischen ObservedProperty‑Zeilen. Es existiert noch kein Mandant, also wird kein FROST‑Server angesprochen. TENANT_SECRET_KEY muss vor diesem Schritt erzeugt und gesetzt sein, sonst startet die Anwendung nicht.

  3. Ersten Mandanten über die Verwaltungsoberfläche registrieren. slug = slugifizierter Name des ThingsBoard‑Mandanten; frost_server_url und keycloak_client_id aus der Bereitstellung (FROST‑Instanz für einen Mandanten bereitstellen); die ThingsBoard‑Zugangsdaten je nach konfigurierter Edition — unter pe der thingsboard_api_key aus dem bisherigen .env, unter ce Benutzername und Passwort eines ThingsBoard‑Benutzers dieses Mandanten (Wechsel der ThingsBoard‑Edition). Welche Felder das Formular anbietet, entscheidet die Oberfläche anhand der Edition, die sie beim Adapter erfragt. Die Registrierung prüft fail‑closed gegen die realen Systeme und überträgt danach alle kanonischen Observed Properties in den leeren FROST‑Server.

  4. Gepflegte Stammdaten von Hand nacherfassen — alle TelemetryKey‑, UnitOfMeasurement‑ und UnitOfMeasurementKey‑Zeilen über die Standardwerte hinaus.

  5. Rule Chain importieren. Die alte Chain löschen und die aktualisierte Datei importieren (Rule Chain je Mandant verteilen).

  6. Ende zu Ende prüfen. Frost Adapter Test Device auslösen — der TBEL‑Filterknoten der Chain ist bereits auf diesen Gerätenamen festgelegt — und im FROST‑Server dieses Mandanten eine Observation mit frischem phenomenonTime bestätigen.

Warum Schritt 6 unersetzlich ist

Schritt 6 ist die einzige Prüfung, die Slug‑Routing, Mandantenauflösung, Client‑Auswahl, Sperrschlüssel und den Schreibvorgang gemeinsam ausübt. Ein 201 bei der Registrierung und ein sauberer Abgleichbericht sind seine Voraussetzung, kein Ersatz: die Rule Chain verwirft Fehler stillschweigend, weshalb „der Adapter ist gesund“ und „Telemetrie kommt an“ nie gleichgesetzt werden dürfen. Schlägt Schritt 6 fehl, ist das Debug‑Fenster der Rule Chain die richtige Stelle — eine falsche URL nach dem Neuimport ist die wahrscheinlichste Ursache.

Der Rückweg

Der Rückweg ist derselbe Wisch in die andere Richtung: vorheriges Image aufbauen, alte Chain importieren, einen leeren FROST‑Server hinnehmen.

Warum die Mandantenzeile getippt wird

Die Mandantenzeile wird getippt, nicht geseedet. Ein Übernahmeskript oder ein Auto‑Seed aus den alten Umgebungsvariablen wurde verworfen: er würde genau die Mandanten‑Kenntnis in bootstrap() zurückholen, die entfernt wurde, die alten Umgebungsvariablen über ihre Nützlichkeit hinaus in Config am Leben halten und ab dem Tag nach der Umstellung toten Code hinterlassen.

Warum der Slug nicht dev heißt

Der Slug ist der slugifizierte Name des ThingsBoard‑Mandanten, nicht dev. Er steht an drei Stellen, die übereinstimmen müssen — in den Rule‑Chain‑URLs, im Namensraum der Redis‑Sperren und in einer unveränderlichen Registry‑Zeile. dev benennt eine Umgebung, und der erste echte Mandant würde diesen Namen für immer tragen. Späteres Auseinanderlaufen, wenn jemand den Anzeigenamen in ThingsBoard ändert, ist harmlos: slug und name sind getrennte Felder.

Bewusst akzeptierte Einschränkungen

Die folgenden acht Punkte sind ausgewählt, nicht übersehen worden. Jeder ist bei zwei bis drei Mandanten günstig und wäre bei zehn eine Überprüfung wert.

  1. Einen Mandanten zu pausieren kostet sein ThingsBoard‑Geheimnis. Es gibt kein enabled‑Feld; Löschen ist der einzige Ausschalter, und da API‑Key bzw. Passwort nur schreibend über die API gehen, bedeutet Wiedereinschalten, sie neu einzugeben.

  2. Fail‑closed‑Prüfung plus hartes Löschen bedeutet: wer einen Mandanten gelöscht hat, während dessen FROST‑Server unten ist, kann ihn nicht wieder registrieren.

  3. Zugangsdaten werden bei der Registrierung bewiesen und danach nie wieder — vom Adapter aus von selbst jedenfalls nicht. Ein rotierter ThingsBoard‑Key zeigt sich als 502 und als rotes Test connection, aber nur, wenn jemand hinschaut oder ein Alarm auslöst.

  4. auto_renewal rettet vor einem toten Prozess, nicht vor einem hängenden. Ein Worker, der ewig in einem FROST‑Aufruf blockiert, erneuert seine Sperre weiter.

  5. Eine einzige öffentliche SensorThings‑URL über alle Mandanten hinweg gibt es nicht mehr, seit jeder Mandant einen eigenen FROST‑Server hat. Nachrüstbar wäre sie später über einen aggregierenden Katalog oder einen Harvester.

  6. Stille ist unsichtbar. Ein untätiger Mandant und eine gelöschte Rule Chain sehen gleich aus: alle Signale grün.

  7. Zwei Entwickler, die gleichzeitig die Testsuite laufen lassen, stören sich gegenseitig.

  8. Wer eine Shell auf dem Docker‑Host hat, liest die Konfiguration jedes Mandanten.