- Phase 1 + 2 als DONE markiert - Tatsächliche Implementierung dokumentiert (parser_config JSON-Blob statt pro-Parser-Felder, _PARSER_SELECTION, Auto-Hint via onchange) - Direct-Session-Architektur (Strategie B) dokumentiert - E2E-Test-Ergebnisse Lasercutter ergänzt - Test-Baseline von 46 auf 87 aktualisiert - Neuen Abschnitt 'Neuen Parser integrieren' mit Schritt-für-Schritt-Checkliste hinzugefügt
18 KiB
Implementierungsplan: Dynamic Parser Registry
Stand: 2026-03-11
Status: Phase 1 + Phase 2 abgeschlossen · Phase 3 optional/offen
Feature Request: docs/FEATURE_REQUEST_DYNAMIC_PARSER_REGISTRY.md
Übersicht
Drei Phasen, strikt in dieser Reihenfolge – jede Phase ist eigenständig deploybar und testbar.
Phase 1: IoT Bridge ✅ DONE (commit fcca327 + b948523)
→ Parser-Registry + Pro-Gerät-Parser + GET /parsers
Phase 2: Odoo (Registry-driven JSON Config) ✅ DONE (commit b948523)
→ parser_config als JSON-Blob + _PARSER_SELECTION + Auto-Hint aus Bridge-Registry
Phase 3: Laufzeit-Validierung (optional) ⬜ OFFEN
→ Odoo prüft beim Speichern, ob parser_type in der aktiven Bridge bekannt ist
Phase 1 – IoT Bridge: Parser-Registry ✅
Umgesetzte Architektur
parsers/registry.py
Zentrale Registry: parser_type-String → Parser-Klasse + Schema.
Dies ist die einzige Quelle für Parser-Metadaten (Label, Beschreibung, Topic-Hint,
Einheit, Parameter-Schema mit Defaults).
PARSER_REGISTRY: dict[str, dict] = {
"shelly_pm": {
"class": ShellyParser,
"label": "Shelly PM (Power Meter)",
"description": "...",
"topic_hint": "<device-name>/status/pm1:0",
"data_unit": "W",
"parameters": [
{"name": "standby_threshold_w", "type": "float", "default": 20.0, ...},
{"name": "working_threshold_w", "type": "float", "default": 100.0, ...},
{"name": "start_debounce_s", "type": "float", "default": 3.0, ...},
{"name": "stop_debounce_s", "type": "float", "default": 15.0, ...},
],
},
"direct_session": {
"class": DirectSessionParser,
"label": "Direct Session (Lasercutter, …)",
"description": "...",
"topic_hint": "<device-name>/session",
"data_unit": "s",
"parameters": [
{"name": "freetime_s", "type": "integer", "default": 0, ...},
],
},
"dummy_generic": {
"class": DummyParser,
"label": "Dummy Puls-Zähler [nur Test]",
...
},
}
Public API:
get_parser(parser_type)→ neue Parser-Instanz (wirftKeyErrorbei Unbekannt)get_schema()→ vollständiges Schema ohneclass-Einträge (fürGET /parsers)list_parser_types()→ sortierte Liste aller Keys
api/server.py – GET /parsers
@app.get("/parsers")
def list_parsers():
from parsers.registry import get_schema
return get_schema()
core/device_manager.py – zwei Parser-Strategien
Der Parser wird pro Gerät über parser_type in der Device-Config gewählt:
Strategie A – SessionDetector-basiert (shelly_pm, dummy_generic):
Parser-Instanz wird an SessionDetector übergeben; dieser überwacht Messwerte und
generiert session_started / session_heartbeat / session_ended-Events.
Strategie B – Direct-Session (direct_session):
Kein SessionDetector. Der Parser liefert direkt message_type: "session_complete".
route_message() enqueued ein session_complete-Event direkt in die Event-Queue.
# route_message() – Dispatch nach message_type
if parsed and parsed.get("message_type") == "session_complete":
event = {
"event_uid": str(uuid.uuid4()),
"event_type": "session_complete",
"device_id": device_id,
"session_id": ...,
"timestamp": ...,
"payload": { "session_seconds": ..., "session_minutes": ..., ... },
}
self.event_queue.enqueue(event)
Definition of Done Phase 1 ✅
parsers/registry.pyerstellt und getestetDeviceManagernutzt pro-Gerät-Parser aus Registry (beide Strategien)GET /parsersgibt Schema zurück- 87/87 Tests grün (Stand 2026-03-11)
Phase 2 – Odoo: Registry-driven JSON Config ✅
Abweichung vom ursprünglichen Plan: Statt pro-Parser-Felder in Odoo und hardcoded
invisible-Gruppen wurde ein schlanker Registry-getriebener Ansatz gewählt. Odoo kennt keine parser-spezifischen Felder mehr – alles läuft über ein zentralesparser_config-JSON-Blob.
Umgesetzte Architektur
models/mqtt_device.py
_PARSER_SELECTION – einzige Stelle in Odoo, die neue Parser listet:
_PARSER_SELECTION = [
('shelly_pm', 'Shelly PM (Power Meter)'),
('direct_session', 'Direct Session (Lasercutter, …)'),
('dummy_generic', 'Dummy Puls-Zähler [nur Test]'),
]
parser_config – JSON-Blob mit allen Parser-Parametern:
parser_config = fields.Text(
string='Parser-Konfiguration (JSON)',
help='Wird automatisch mit Registry-Defaults befüllt wenn parser_type gewechselt wird.',
)
parser_topic_hint / parser_type_description – Anzeigefelder, read-only:
parser_topic_hint = fields.Char(string='Topic-Format', readonly=True, store=True)
parser_type_description = fields.Char(string='Parser-Beschreibung', readonly=True, store=True)
_onchange_parser_type() – ruft GET /parsers auf und befüllt alle drei Felder:
@api.onchange('parser_type')
def _onchange_parser_type(self):
# GET /parsers → registry_entry für self.parser_type
# parser_topic_hint ← registry_entry["topic_hint"]
# parser_type_description ← registry_entry["description"]
# parser_config ← {param["name"]: param["default"] for param in parameters}
get_parser_config_dict() – sicherer Accessor:
def get_parser_config_dict(self) -> dict:
"""Return parser_config as dict (safe fallback to empty dict)."""
_compute_push_payload() schreibt parser_config als parser_config-Key in den
Config-Push an die Bridge.
models/iot_event.py
event_type Selection enthält neu ('session_complete', 'Session Complete').
models/mqtt_session.py
end_reason Selection enthält neu ('direct_session', 'Direct Session').
controllers/iot_api.py – _process_event()
Neue Branch für session_complete:
elif event.event_type == 'session_complete':
# Device per external_device_id finden
# Direkt fertige Session anlegen: status='completed', end_reason='direct_session'
# device.state = 'idle'
views/mqtt_device_views.xml
Ein einziger generischer Hinweis-Div (kein pro-Parser-Hardcoding):
<div class="alert alert-info mb-0 mt-1" colspan="2" role="alert"
invisible="not parser_topic_hint">
<i class="fa fa-info-circle"/>
Erwartetes Topic-Format:
<code><field name="parser_topic_hint" nolabel="1" readonly="1"/></code><br/>
<small class="text-muted">
<field name="parser_type_description" nolabel="1" readonly="1"/>
</small>
</div>
Definition of Done Phase 2 ✅
_PARSER_SELECTIONenthält alle bekannten parser_typesparser_configJSON-Blob ersetzt alle pro-Parser-Felder_onchange_parser_typebefülltparser_configmit Registry-Defaults- View zeigt Topic-Hint und Beschreibung generisch an
session_completePipeline end-to-end getestet (MQTT → Bridge → Odoo → DB)
Phase 3 – Laufzeit-Validierung (optional) ⬜
Odoo prüft beim Speichern eines Geräts, ob der gewählte parser_type von der
aktuell konfigurierten Bridge unterstützt wird.
@api.constrains('parser_type', 'bridge_id')
def _check_parser_type_supported(self):
for record in self:
supported = record.bridge_id._fetch_supported_parsers()
if record.parser_type not in supported:
raise ValidationError(
f"Parser '{record.parser_type}' wird von der Bridge nicht unterstützt. "
f"Verfügbar: {supported}"
)
_fetch_supported_parsers() ruft GET /parsers auf der konfigurierten Bridge-URL ab.
Diese Phase ist optional. Sie erhöht die Robustheit, fügt aber eine Netzwerkabhängigkeit
beim Speichern hinzu. Empfehlung: als weiche Warnung statt hartem Fehler umsetzen.
Definition of Done Phase 3
_fetch_supported_parsers()implementiert und gecacht (kein Call pro Feldänderung)- Warnung bei unbekanntem
parser_type(kein hard block) - Graceful degradation wenn Bridge nicht erreichbar
Neuen Parser integrieren
Minimale Änderungen an zwei Stellen (Bridge + Odoo). Alles andere – UI-Hint,
parser_config-Defaults, Bridge-API – wird automatisch abgeleitet.
Schritt 1 – Bridge: Parser-Datei anlegen
Neue Datei iot_bridge/parsers/<name>_parser.py:
class MyParser:
def parse_message(self, topic: str, payload: dict) -> dict | None:
"""
Gibt dict mit message_type zurück, oder None wenn Payload ungültig/irrelevant.
Verwendbare message_types:
"session_complete" → direkte fertige Session (kein SessionDetector nötig)
"apower" → Leistungsmesswert → SessionDetector übernimmt
"""
value = payload.get("my_field")
if value is None:
return None
return {
"message_type": "session_complete", # oder "apower"
"device_id": topic.split("/")[0],
"timestamp": ...,
# weitere Felder je nach message_type
}
Welchen message_type wählen?
| Gerät erkennt Session selbst | → "session_complete" |
Strategie B (Direct Session) |
|---|---|---|
| Gerät liefert Messwerte | → "apower" |
Strategie A (SessionDetector) |
Schritt 2 – Bridge: Registry-Eintrag
In iot_bridge/parsers/registry.py einen neuen Key in PARSER_REGISTRY eintragen:
"my_parser": {
"class": MyParser,
"label": "Mein Gerät (Kurzbeschreibung)",
"description": "Vollständige Beschreibung für die Odoo-UI.",
"topic_hint": "<device-name>/data", # MQTT-Topic-Muster (nur informativ)
"data_unit": "s", # Einheit der Messgröße
"parameters": [
{
"name": "my_param", # Key in parser_config JSON
"label": "Mein Parameter",
"type": "integer", # integer | float | string | boolean
"default": 42,
"description": "Erklärung des Parameters.",
},
],
},
Nach diesem Schritt liefert
GET /parsersden neuen Eintrag automatisch. Odoo befülltparser_configund die UI-Hints beim Typwechsel ohne weitere Änderungen.
Schritt 3 – Odoo: Selection ergänzen
In extra-addons/.../models/mqtt_device.py eine Zeile in _PARSER_SELECTION:
_PARSER_SELECTION = [
('shelly_pm', 'Shelly PM (Power Meter)'),
('direct_session', 'Direct Session (Lasercutter, …)'),
('my_parser', 'Mein Gerät (Kurzbeschreibung)'), # ← neu
]
Label muss mit registry.py übereinstimmen (Konsistenz, kein technisches Muss).
Schritt 4 – Odoo: DB-Migration
# Im Docker-Host-Verzeichnis odoo/
docker compose -f docker-compose.dev.yaml exec odoo \
odoo -u open_workshop_mqtt --stop-after-init
Danach Odoo neu starten. Das neue parser_type-Dropdown erscheint in der Gerät-Form.
Schritt 5 – route_message() prüfen (nur bei neuen message_types)
Wenn der neue Parser einen bisher unbekannten message_type zurückgibt, muss
iot_bridge/core/device_manager.py → route_message() um eine neue Branch erweitert werden.
Für "session_complete" und "apower" ist das nicht nötig – diese werden bereits behandelt.
Schritt 6 – _process_event() prüfen (nur bei neuen event_types)
Analog: Wenn ein neuer event_type nach Odoo gemeldet wird (z. B. "pulse_counted"),
muss iot_api.py → _process_event() und iot_event.py → event_type-Selection erweitert werden.
Für "session_complete" ist das nicht nötig – bereits implementiert.
Checkliste: Neuer Parser
Bridge:
[ ] parsers/<name>_parser.py → parse_message() implementiert
[ ] parsers/registry.py → PARSER_REGISTRY Eintrag ergänzt
[ ] tests/unit/test_<name>_parser.py → Unit-Tests geschrieben
[ ] route_message() erweitert (nur bei neuem message_type)
Odoo:
[ ] mqtt_device.py: _PARSER_SELECTION → 1 Zeile ergänzt
[ ] iot_event.py: event_type Selection → nur wenn neuer event_type
[ ] mqtt_session.py: end_reason → nur wenn neuer end_reason
[ ] iot_api.py: _process_event() → nur wenn neuer event_type
Deploy:
[ ] odoo -u open_workshop_mqtt
[ ] Bridge-Container neu starten
[ ] E2E-Test: MQTT-Payload → Odoo-Session überprüfen
Testumgebung
Laufende Services (Dev-Stack)
Alle Services laufen als Docker-Container (docker-compose.dev.yaml):
| Container | Port | Funktion |
|---|---|---|
hobbyhimmel_odoo_18-dev_iot_bridge |
8080 | IoT Bridge (FastAPI) |
hobbyhimmel_odoo_18-dev |
9018 | Odoo 18 (DB: odoo, Admin: admin/admin) |
hobbyhimmel_odoo_18-dev_mosquitto |
1883 | MQTT Broker (kein TLS, kein Auth) |
hobbyhimmel_odoo_18-dev_db |
5432 | PostgreSQL |
hobbyhimmel_odoo_18-dev_pgadmin |
5050 | pgAdmin |
Unit-Tests
Tests laufen im Bridge-Container:
docker exec hobbyhimmel_odoo_18-dev_iot_bridge python -m pytest tests/unit/ --no-cov -q
Stand 2026-03-11: 87/87 Tests bestanden
tests/unit/test_bootstrap.py 3 passed
tests/unit/test_config.py 3 passed
tests/unit/test_config_schema.py 3 passed
tests/unit/test_dependencies.py 2 passed
tests/unit/test_device_manager.py 3 passed
tests/unit/test_direct_session_parser.py 24 passed ← neu (commit fcca327)
tests/unit/test_event_queue.py 13 passed ← reale Wartezeiten (~30s), kein Hänger
tests/unit/test_exceptions.py 5 passed
tests/unit/test_odoo_client_error_handling.py 2 passed
tests/unit/test_parser_registry.py 16 passed ← neu
tests/unit/test_service_manager.py 3 passed
tests/unit/test_session_detector.py 9 passed
Shelly-Simulator (manueller E2E-Test)
# Im iot_bridge/-Verzeichnis, lokal (nicht im Container):
python3 tests/tools/shelly_simulator.py \
--broker mqtt.majufilo.eu --port 8883 \
--username mosquitto --password <siehe config.yaml> \
--topic-prefix testshelly \
--scenario online_test --duration 30
Verfügbare Szenarien:
| Szenario | Beschreibung |
|---|---|
standby |
20–100W Dauerbetrieb (--duration Sekunden) |
working |
>100W Dauerbetrieb (--duration Sekunden) |
full_session |
Kompletter Zyklus OFF→IDLE→STANDBY→WORKING→OFF (~10 Minuten) |
online_test |
Kontinuierliche Nachrichten zum Prüfen des Online-Status |
offline_test |
Online → Pause → Recovery (testet Timeout-Erkennung) |
Direct-Session E2E-Test (Lasercutter-Simulator)
import paho.mqtt.publish as publish, ssl
publish.single(
topic='lasercutter/session',
payload='{"session_id":1,"session_minutes":2,"session_seconds":112,'
'"session_start_time":"2026-03-11T14:04:00","freetime_s":10,"ip":"192.168.2.65"}',
hostname='mqtt.majufilo.eu', port=8883,
auth={'username': 'mosquitto', 'password': '<siehe config.yaml>'},
tls={'cert_reqs': ssl.CERT_NONE},
)
E2E-Test Ergebnis (2026-03-11):
| Schritt | Status | Beobachtung |
|---|---|---|
| Payload → Broker | ✅ | mqtt.majufilo.eu:8883 (TLS, self-signed) |
| Bridge empfängt + parst | ✅ | DirectSessionParser → session_complete |
| Bridge → Odoo Event-Queue | ✅ | session_complete-Event enqueued |
Odoo _process_event |
✅ | Session erstellt, device.state='idle' |
| DB-Eintrag | ✅ | ows_mqtt_session: working_duration_s=112, end_reason=direct_session, device_id=5 |
Reihenfolge und Abhängigkeiten
Phase 1 (Bridge) ──────────────────────▶ Phase 2 (Odoo)
│
▼
Phase 3 (optional)
Phase 2 kann erst beginnen, wenn GET /parsers aus Phase 1 stabil ist.
Phase 3 ist unabhängig von Phase 2, setzt aber Phase 1 voraus.
Architektur-Klarstellungen
Wo werden Parameter gespeichert?
Entscheidung: Parameter-Werte in Odoo (parser_config), Struktur in der Bridge-Registry.
Die Architektur folgt dem Push-Modell: Odoo ist Config-Master und schreibt
Konfiguration aktiv zur Bridge (POST /config). Die Bridge ist bewusst zustandslos bezüglich
Konfiguration – sie empfängt und arbeitet.
Odoo (Config-Master) ──POST /config──▶ IoT Bridge (zustandslos)
parser_config: {"freetime_s": 10} Registry: Schema (Typ, Label, Default)
- Odoo kennt die Werte (gespeichert in
parser_configJSON-Blob) - Bridge kennt die Struktur (Schema via
GET /parsers)
Wann entscheidet das Topic über den Parser?
Nie – das Topic entscheidet nicht über den Parser.
Der Parser wird über parser_type in der Device-Config gewählt (einmalig beim Anlegen).
Das Topic wird danach dem Parser nur als Kontext übergeben, damit er intern filtern kann.
Gerät-Config: parser_type="shelly_pm" → ShellyParser-Instanz
└── ShellyParser.parse_message(topic, payload)
└── if "/status/pm1:0" in topic: ... ← internes Filter, kein Dispatch
SessionDetector vs. Direct Session
| Merkmal | Strategie A (SessionDetector) | Strategie B (Direct Session) |
|---|---|---|
| Parser-Typen | shelly_pm, dummy_generic |
direct_session |
| message_type | "apower" |
"session_complete" |
| Bridge-Logik | SessionDetector überwacht Messwerte |
route_message() enqueued direkt |
| Odoo event_type | session_started / session_heartbeat / session_ended |
session_complete |
| Session-Erkennung | Bridge (Threshold-Logik) | Gerät selbst |