odoo_mqtt/docs/IMPLEMENTATION_PLAN_DYNAMIC_PARSER_REGISTRY.md
matthias.lotz 23e46efb41 docs: IMPLEMENTATION_PLAN aktualisiert auf Stand 2026-03-11
- 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
2026-03-11 15:22:21 +01:00

18 KiB
Raw Blame History

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 (wirft KeyError bei Unbekannt)
  • get_schema() → vollständiges Schema ohne class-Einträge (für GET /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.py erstellt und getestet
  • DeviceManager nutzt pro-Gerät-Parser aus Registry (beide Strategien)
  • GET /parsers gibt 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 zentrales parser_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_SELECTION enthält alle bekannten parser_types
  • parser_config JSON-Blob ersetzt alle pro-Parser-Felder
  • _onchange_parser_type befüllt parser_config mit Registry-Defaults
  • View zeigt Topic-Hint und Beschreibung generisch an
  • session_complete Pipeline 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 /parsers den neuen Eintrag automatisch. Odoo befüllt parser_config und 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.pyroute_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.pyevent_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 20100W 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 DirectSessionParsersession_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_config JSON-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