Technische Dokumentation Thingsboard Frost Adapter
- Einleitung
- Repository und Entwicklungsumgebung
- Architekturüberblick
- Architektur im Detail: Modulschichten
- Frontend (Streamlit)
- Datenbankschema des Adapters
- Datenmodell: ThingsBoard auf SensorThings abbilden
- Geschäftsregeln für Stammdaten (Business Rules)
- Schnittstellenbeschreibung SensorThings API (FROST‑Server)
- Betriebsanleitungen (Runbooks)
- Vorbemerkung: Unerreichbarkeit ist die Sicherheitsgrenze
- FROST‑Instanz für einen Mandanten bereitstellen
- Rule Chain je Mandant verteilen
- Mandant stilllegen
- Verlust des Verschlüsselungsschlüssels
- Wechsel der ThingsBoard‑Edition
- Umstellung auf den mandantenfähigen Adapter
- Bewusst akzeptierte Einschränkungen
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
-
Repository und Entwicklungsumgebung – Was im Repository liegt, wie die Entwicklungsumgebung hochkommt, wie die Tests laufen und welche Einstellung wohin gehört.
Architektur
-
Architekturüberblick – Überblick über die beteiligten Komponenten und den Datenfluss.
-
Architektur im Detail: Modulschichten – Der innere Aufbau des Adapters: die vier Schichten, die Abhängigkeitsregel und die Verträge zwischen den Schichten.
-
Frontend (Streamlit) – Die Streamlit‑Verwaltungsoberfläche als eigenständige zweite Anwendung.
Daten und Regeln
-
Datenbankschema des Adapters – Die sechs Tabellen der Adapter‑Datenbank: Mandanten‑Registry und Stammdaten.
-
Datenmodell: ThingsBoard auf SensorThings abbilden – Woher jedes Feld der angelegten SensorThings‑Entitäten stammt.
-
Geschäftsregeln für Stammdaten (Business Rules) – Geschäftsregeln zum Schutz der Stammdaten vor inkonsistenten Zuständen und der Lebenszyklus eines Mandanten.
Referenz und Betrieb
-
Schnittstellenbeschreibung SensorThings API (FROST‑Server) – Kurzreferenz für den lesenden Zugriff auf die SensorThings API eines FROST‑Servers.
-
Betriebsanleitungen (Runbooks) – Die Abläufe, die ein Betreiber von Hand ausführt: Bereitstellen, Verteilen der Rule Chain, Stilllegen, Wiederherstellen.
|
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 |
|---|---|
|
Der Adapter selbst — ein FastAPI‑Dienst, in vier Schichten gegliedert (Architektur im Detail: Modulschichten). |
|
Die Streamlit‑Verwaltungsoberfläche. Eine eigenständige Anwendung mit eigenem Container und eigenem Port (Frontend (Streamlit)). |
|
|
|
Diese Dokumentation als Antora‑Komponente mit den beiden Modulen |
|
|
|
|
|
Der Realm‑Export, den Keycloak beim Start mit |
|
Infrastruktur bzw. Anwendung — zwei Dateien mit einem gemeinsamen Projektnamen (siehe Die Entwicklungsumgebung). |
|
Ein mehrstufiger Build mit den Zielen |
|
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 |
|---|---|
|
Die Infrastruktur: |
|
Die Anwendung: |
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
Die Datei ist zudem als einzelne Datei eingebunden und nie als Verzeichnis: Das Image bringt in demselben Verzeichnis sein eigenes |
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 |
Der veröffentlichte Port 8000
|
Die Portfreigabe |
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 |
|---|---|
|
Nichts. Läuft aus einem blanken Checkout in deutlich unter einer Sekunde und bleibt damit brauchbar, während der Compose‑Stapel unten ist. |
|
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. |
|
Zusätzlich Netzzugang zum gemeinsamen Staging‑System und ein Lauf, der von außen auf |
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 |
|
|
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 |
|---|---|
|
Die von den Containern aus erreichbare Adresse des Hosts. Fehlt |
|
Realm und Zugangsdaten des einen globalen Adapter‑Benutzers. |
|
Die eine ThingsBoard‑Installation. Ein Deployment, viele ThingsBoard‑Mandanten — welcher davon gelesen wird, entscheiden die Zugangsdaten aus der Mandantenzeile. Lokal ist das |
|
Welche ThingsBoard‑Edition hinter |
|
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. |
|
Name, Benutzer, Passwort, Host und Port der Adapter‑Datenbank. |
|
Redis‑Verbindung für die verteilten Sperren; Log‑Stufe. |
|
Das Image‑Tag aller FROST‑Instanzen. Niemals |
|
Das Image‑Tag des lokalen ThingsBoard. Niemals |
|
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 |
Container‑Images
Der Dockerfile ist mehrstufig und endet in drei Zielen, die sich eine gemeinsame runtime‑Stufe teilen:
| Ziel | Ergebnis |
|---|---|
|
|
|
|
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 |
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 |
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 ( |
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 |
|---|---|
|
Die HTTP‑Oberfläche: Pfade, Statuscodes, Response‑Modelle. Eine Route ist ein Service‑Aufruf. |
|
Die Entscheidungen: Geschäftsregeln, Reihenfolgen, Fehlerpolitik – und der Ort, an dem sich zwei Repositories treffen. |
|
Genau ein Zugriffsweg auf genau ein System: je Modul eine Postgres‑Tabelle ( |
|
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
-
ThingsBoard sendet über die Rule Chain eine Telemetrie‑Nachricht (Nutzdaten + Metadaten wie
deviceNameund Zeitstempel) an den EndpunktPOST /ingest/{slug}/telemetrydes Adapters. Derslugist der in der Tenant‑Registry hinterlegte Bezeichner des Mandanten und bestimmt, in welchen FROST‑Server geschrieben wird; einen mandantenfreien Endpunkt gibt es nicht. -
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.
-
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 /tenantundPATCH /tenant/{id}führen sie vor dem Schreiben aus und antworten bei einem Fehlschlag mit400unter 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}/healthundGET /tenant/healthliefern das Ergebnis alsTenantHealth. -
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 |
Die vier Schichten
| Schicht | Besitzt | Darf importieren |
|---|---|---|
|
Die HTTP-Oberfläche: Pfade, Statuscodes, Response-Modelle, das Auslesen des Request-Bodys. Eine Route ist ein Service-Aufruf. |
von den Schichten nur |
|
Die Entscheidungen: Geschäftsregeln, Reihenfolgen, Fehlerpolitik — und der Ort, an dem sich zwei Repositories treffen. |
|
|
Genau einen Zugriffsweg auf genau ein System: je Modul eine Postgres-Tabelle ( |
|
|
Den Aufbau einer Verbindung zu System X für Mandant Y, samt Authentifizierung und deren Zwischenspeicherung. |
|
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 nurservices/. -
services/importiertrepositories/db/,repositories/external/undclients/. -
repositories/external/importiertclients/—TenantFrostRepositorywird ausTenantClientskonstruiert und braucht den Typ für die eigene Annotation. -
Unterhalb von
services/importiert nichts eine Schicht über sich. Kein Repository und kein Client importiert jemalsservices/. -
Ein Repository importiert kein anderes Repository.
crudist 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 |
|---|---|
|
|
|
|
|
|
|
Fünf generische Funktionen ( |
|
|
|
Die ThingsBoard-Geräte eines Mandanten, deren Assets und deren Messwerte in den FROST dieses
Mandanten zu schieben. Der heiße Pfad ist |
|
Die Kopien der globalen Observed Properties im FROST eines Mandanten — auflösen, anlegen,
aktualisieren. Nicht zu verwechseln mit |
|
Die kanonische Observed-Property-Zeile in Postgres und die Schleife über alle Mandanten. |
|
Die beiden mandantenübergreifenden Regeln: |
|
Mandantenzeile und Lebenszyklus in einem Modul: |
|
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 Client — SensorThingsService, 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 |
|---|---|
|
|
|
|
|
|
|
|
|
Werden zwei Sperren gleichzeitig gehalten, ist |
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 |
|---|---|---|
|
|
400 mit Nennung der fehlgeschlagenen Prüfung |
|
|
409 |
|
|
404 — siehe Die benannten Ausnahmen |
Stammdaten- und Fan-out-Wächter in |
|
422 über den Handler in |
|
|
500 über den Handler in |
unbekannte Id |
|
404, im Router |
Zielsystem nicht erreichbar |
|
502 über den Handler in |
Die benannten Ausnahmen
Vier Stellen brechen eine der obigen Regeln. Alle vier sind bewusst und abschließend:
| Stelle | Ausnahme |
|---|---|
|
wirft |
|
macht als einziges Modul in |
|
nehmen eine |
|
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:
-
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.
-
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 |
|---|---|
|
die |
|
den rohen |
|
ein |
|
einen |
|
einen |
Durchsetzung
|
Die Schichtregel wird ausschließlich in Prosa durchgesetzt. Es gibt keine Werkzeugkette dafür —
kein |
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 |
„Was das Repository kapselt, ist der Client" — der Query-Builder und die OData-Filter bleiben drin. |
kein |
die obere Grenze der FROST-Entitäten; der Grund, warum |
kein rohes |
die eine begründete Ausnahme bleibt die eine, siehe Die benannten Ausnahmen. |
|
Die erste Form ist als Lesehilfe formuliert und nicht als Grep: |
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 |
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 |
|---|---|
|
|
|
|
|
|
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 |
|---|---|---|
|
5 s |
Zugriffe, die nur die Postgres‑Datenbank des Adapters lesen oder schreiben — die sind entweder schnell oder kaputt. |
|
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
ObservedPropertyhat 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
peder API‑Key, untercedas 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 /configurationvom 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
-
Eine Funktion je Operation in
api/frost_adapter_api_service.pyergänzen, mit dem Basispfad ausbase_routesund dem passenden der beiden Zeitlimits. -
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(…). -
Die Seite in
main.pyalsst.Pageeintragen und in die Liste vonst.navigationaufnehmen. -
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 |
|---|---|
|
Basis‑URL der Adapter‑API. Lokal |
|
Der OIDC‑Block für |
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 |
|---|---|---|
|
PK |
Fortlaufende Ganzzahl. |
|
|
Der Bezeichner in der Ingest‑URL. Unveränderlich — er steht in der Rule‑Chain‑URL eines fremden Systems, weshalb |
|
Anzeigename. Darf von |
|
|
|
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. |
|
Der Keycloak‑Client dieses FROST‑Servers. Der Benutzer dahinter ist deployment‑weit, nur der Client ist mandantenspezifisch. |
|
|
nullable |
Nur bei |
|
nullable |
Nur bei |
|
nullable |
Nur bei |
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
PATCHin 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: |
tenantobservedproperty
Die Zuordnungstabelle. Sie beantwortet: Welche FROST‑Entität in welchem Mandanten ist diese kanonische Observed Property?
| Spalte | Constraint | Bedeutung |
|---|---|---|
|
PK, FK → |
Der Mandant. Das Löschen einer Mandantenzeile räumt seine Zuordnungen mit ab. |
|
PK, FK → |
Die kanonische Zeile. |
|
|
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 |
|---|---|---|
|
|
Der Telemetrie‑Schlüssel aus ThingsBoard. Er ist zugleich der Name des zugehörigen Datastreams in FROST. |
|
|
Die gemessene Größe. |
|
FK → |
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 |
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 |
|---|---|
|
|
|
|
|
|
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 |
|---|---|---|
|
Gerätename |
Muss eindeutig sein und ist der Schlüssel, über den der Adapter das Thing wiederfindet. |
|
|
Fällt auf den Gerätenamen zurück, wenn keine Beschreibung gepflegt ist. |
|
Alle Attribute des Geräts als Objekt |
Hier landen auch die Attribute, die der Adapter selbst auswertet: |
|
Das verknüpfte |
Wird im selben |
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 des Assets |
Der Name identifiziert den Standort; mehrere Geräte im selben Gebäude teilen sich eine Location. |
|
|
Fällt auf den Asset‑Namen zurück. |
|
Die Attribute |
Als GeoJSON |
|
fest |
|
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 des Geräteprofils |
|
|
Beschreibung des Geräteprofils |
|
|
Geräteattribut |
Üblicherweise ein Link auf ein Datenblatt. Fehlt das Attribut, steht dort |
|
fest |
|
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 |
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 |
|---|---|---|
|
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. |
|
erzeugt |
|
|
fest |
|
|
Ein eingebettetes Objekt aus |
|
|
die oben angelegten Entitäten |
Wie die Maßeinheit bestimmt wird
Zwei Stufen, in dieser Reihenfolge:
-
Gerätespezifisch. Der Adapter liest das Geräteattribut
<telemetry_key>_uomaus denpropertiesdes Things und schlägt dessen Wert in der Tabelleunitofmeasurementkeynach. Ein Gerät, dessen Telemetrietemperaturein Fahrenheit liefert, bekommt damit über ein Attributtemperature_uomeine andere Einheit als alle anderen. -
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 |
|---|---|---|
|
Der Wert des Telemetrie‑Felds |
|
|
|
Millisekunden seit Epoch, umgerechnet nach |
|
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.
{
"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"
}
}
{
"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]
}
}
{
"name": "KSPB Umweltsensor",
"description": "Geräteprofil der KSPB-Messstationen",
"encodingType": "application/pdf",
"metadata": "https://example.org/datasheets/kspb.pdf"
}
{
"name": "CO2-Konzentration",
"definition": "https://en.wikipedia.org/wiki/Carbon_dioxide#Concentration",
"description": "Kohlenstoffdioxid-Konzentration in ppm"
}
{
"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 }
}
{
"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 |
|---|---|---|
|
noch ein |
|
|
noch ein |
|
|
in irgendeinem Mandanten ein FROST‑Datastream existiert, dessen Name dem Telemetry Key entspricht, oder ein Mandant nicht erreichbar ist |
|
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, derenunitOfMeasurement.nameeiner lokalen Maßeinheit entspricht -
find_datastreams_by_observed_property_id– Datastreams, die die dortige Kopie einerObservedPropertyreferenzieren (über diefrost_idaus 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_id → frost_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 mit404, 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_tenantsder 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
502unter 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.
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:
-
Prüfen der Zugangsdaten – fail‑closed, in zwei Stufen. Zuerst die Form: Zugangsdaten, die nicht zur konfigurierten ThingsBoard‑Edition passen, werden mit
422abgelehnt, 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 mit400. In beiden Fällen wird keine Zeile angelegt. -
Schreiben der Mandantenzeile.
-
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: |
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 |
|---|---|
|
|
|
|
|
|
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.
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 |
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). |
|
Location |
Geografischer Standort eines Things. |
|
Sensor |
Beschreibt das Messinstrument oder den Gerätetyp. |
|
ObservedProperty |
Die gemessene Eigenschaft (z. B. Temperatur, CO₂‑Konzentration). |
|
Datastream |
Verbindet Thing, Sensor und ObservedProperty zu einem Messdatenkanal. |
|
Observation |
Einzelner Messwert innerhalb eines Datastreams. |
|
FeatureOfInterest |
Räumliches Objekt, auf das sich eine Observation bezieht. |
|
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:
Adressierung
Jeder Entitätstyp wird über einen eigenen Pfad angesprochen. Die folgende Konvention gilt durchgängig für alle sieben:
| Muster | Bedeutung |
|---|---|
|
Alle Entitäten dieses Typs abrufen (Collection). |
|
Eine einzelne Entität anhand ihrer ID abrufen. |
|
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 |
|---|---|---|
|
Begrenzt die Anzahl der zurückgegebenen Entitäten. |
|
|
Überspringt die angegebene Anzahl an Entitäten (für Pagination). |
|
|
Gibt die Gesamtzahl der Entitäten im Ergebnis mit zurück. |
|
|
Sortiert die Ergebnisse nach einem oder mehreren Feldern. |
|
|
Beschränkt die zurückgegebenen Eigenschaften auf die genannten Felder. |
|
|
Lädt verknüpfte Entitäten inline mit (reduziert Anzahl der Anfragen). |
|
|
Filtert Ergebnisse anhand logischer Ausdrücke. |
|
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 |
|---|---|---|
|
Gleichheit |
|
|
Ungleichheit |
|
|
Größer als / Größer oder gleich |
|
|
Kleiner als / Kleiner oder gleich |
|
|
Logische Verknüpfung |
|
|
Teilzeichenkette prüfen |
|
|
Anfangszeichenkette prüfen |
|
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). |
|
Service‑Root – listet alle verfügbaren Entity‑Sets auf (JSON). Dies ist der Endpunkt, den die Erreichbarkeitsprüfung des Adapters abfragt. |
|
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 Bleibt die Freigabe stehen, genügt ein |
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 |
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:
-
frost_server_url=http://<host>:<port>/FROST-Server/v1.1 -
keycloak_client_id=frost-server-<slug>
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 |
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 |
Ablauf je weiterem Mandant:
-
rulechain/thingsboard_frost_adapter.jsonin den ThingsBoard‑Mandanten importieren. -
In allen vier
TbRestApiCallNodedie 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/updateDer Slug muss zeichengenau dem Slug der Mandantenzeile entsprechen. Ein falscher Slug ergibt einen
404mit dem Namen der abgelehnten Entität im Log des Adapters — geschrieben wird nichts. -
Prüfen, dass jeder der vier REST‑Knoten seine
Failure‑Relation auf einenTbCreateAlarmNodeverdrahtet 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. -
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 |
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.
-
Die Geräte des Mandanten in ThingsBoard aus der Veröffentlichungsgruppe entfernen. Jedes
/device/deleteentfernt das zugehörigeThingaus FROST und kaskadierend dessen Datastreams und Observations. -
Den Mandanten auf der Seite Tenants der Verwaltungsoberfläche löschen — alternativ
DELETE /tenant/{id}, wobei dieidausGET /tenantstammt, da der Ablauf hier nur den Slug kennt. Ein409bedeutet: Schritt 1 ist unvollständig, oder der FROST‑Server antwortet nicht. -
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:
-
GET {frost_server_url}/v1.1/ObservedProperties?$expand=Datastreams($top=1)— die verwaisten sind die ohne Datastreams. -
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.
-
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 |
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 |
|---|---|
|
|
|
|
|
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 |
Der Ablauf auf dem Entwicklungssystem, auf dem der Adapter läuft:
-
THINGSBOARD_EDITIONin.envsetzen und den Adapter neu starten. -
Jeden Mandanten löschen und neu anlegen. Löschen setzt voraus, dass sein FROST‑Server keine
Thingsmehr hält (siehe Mandant stilllegen) — die Alternative ist eindocker compose down -vund ein Neuaufbau, was auf einem Entwicklungssystem der kürzere Weg ist. -
Ende zu Ende prüfen:
Frost Adapter Test Deviceauslösen und im FROST des Mandanten eineObservationmit frischemphenomenonTimebestä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.
-
Stoppen und zerstören.
docker compose down -v. Die Datenbank des FROST‑Servers, die Postgres‑Datenbank des Adapters und Redis sind danach weg. -
Mandantenfähigen Adapter aufbauen.
bootstrap()legt das Schema an und füllt die Stammdaten inklusive der kanonischenObservedProperty‑Zeilen. Es existiert noch kein Mandant, also wird kein FROST‑Server angesprochen.TENANT_SECRET_KEYmuss vor diesem Schritt erzeugt und gesetzt sein, sonst startet die Anwendung nicht. -
Ersten Mandanten über die Verwaltungsoberfläche registrieren.
slug= slugifizierter Name des ThingsBoard‑Mandanten;frost_server_urlundkeycloak_client_idaus der Bereitstellung (FROST‑Instanz für einen Mandanten bereitstellen); die ThingsBoard‑Zugangsdaten je nach konfigurierter Edition — unterpederthingsboard_api_keyaus dem bisherigen.env, unterceBenutzername 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. -
Gepflegte Stammdaten von Hand nacherfassen — alle
TelemetryKey‑,UnitOfMeasurement‑ undUnitOfMeasurementKey‑Zeilen über die Standardwerte hinaus. -
Rule Chain importieren. Die alte Chain löschen und die aktualisierte Datei importieren (Rule Chain je Mandant verteilen).
-
Ende zu Ende prüfen.
Frost Adapter Test Deviceauslösen — der TBEL‑Filterknoten der Chain ist bereits auf diesen Gerätenamen festgelegt — und im FROST‑Server dieses Mandanten eineObservationmit frischemphenomenonTimebestä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.
-
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. -
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.
-
Zugangsdaten werden bei der Registrierung bewiesen und danach nie wieder — vom Adapter aus von selbst jedenfalls nicht. Ein rotierter ThingsBoard‑Key zeigt sich als
502und als rotes Test connection, aber nur, wenn jemand hinschaut oder ein Alarm auslöst. -
auto_renewalrettet vor einem toten Prozess, nicht vor einem hängenden. Ein Worker, der ewig in einem FROST‑Aufruf blockiert, erneuert seine Sperre weiter. -
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.
-
Stille ist unsichtbar. Ein untätiger Mandant und eine gelöschte Rule Chain sehen gleich aus: alle Signale grün.
-
Zwei Entwickler, die gleichzeitig die Testsuite laufen lassen, stören sich gegenseitig.
-
Wer eine Shell auf dem Docker‑Host hat, liest die Konfiguration jedes Mandanten.