odoo_mqtt/docs/IMPLEMENTATION_PLAN_DYNAMIC_PARSER_REGISTRY.md
matthias.lotz 4de3401416 fix: odoo/ als normales Verzeichnis tracken + Bugfix db_name + Feature-Docs
- git rm --cached odoo: Gitlink-Eintrag entfernt, odoo/ wird jetzt korrekt versioniert
- odoo/src/odoo-dev.conf: db_name = odoo gesetzt (behebt 404 bei mehreren DBs)
- docs/FEATURE_REQUEST_DYNAMIC_PARSER_REGISTRY.md: Feature Request inkl. ADR
- docs/IMPLEMENTATION_PLAN_DYNAMIC_PARSER_REGISTRY.md: 3-Phasen-Implementierungsplan
- iot_bridge/README.md: Doku aktualisiert
2026-03-10 17:17:59 +01:00

13 KiB
Raw Blame History

Implementierungsplan: Dynamic Parser Registry

Stand: 2026-03-10
Status: In Planung
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
  → Parser-Registry + Pro-Gerät-Parser + GET /parsers

Phase 2: Odoo (statische Ableitung)
  → Modell-Felder + View-invisible + Selection aus Bridge-Schema ableiten

Phase 3: Laufzeit-Sync (optional, nur Selection-Validierung)
  → Odoo prüft beim Speichern, ob parser_type in der aktiven Bridge bekannt ist

Phase 1 IoT Bridge: Parser-Registry

1.1 parsers/registry.py neue Datei

Zentrale Registry: parser_type-String → Parser-Klasse + Schema.

# parsers/registry.py

from parsers.shelly_parser import ShellyParser

PARSER_REGISTRY: dict[str, dict] = {
    "shelly_pm": {
        "class": ShellyParser,
        "display_name": "Shelly PM Mini G3",
        "parameters": [
            {
                "key": "power_on_threshold_w",
                "odoo_field": "power_on_threshold_w",
                "type": "integer",
                "unit": "W",
                "label": "Einschalt-Schwelle",
                "default": 20,
                "required": True,
                "group": "session_detection",
                "help": "Ab welcher Leistung gilt das Gerät als eingeschaltet?",
                "parser_types": ["shelly_pm"],
            },
            # ... weitere Parameter
        ],
    }
}

def get_parser(parser_type: str):
    entry = PARSER_REGISTRY.get(parser_type)
    if entry is None:
        raise ValueError(f"Unknown parser_type: '{parser_type}'. "
                         f"Available: {list(PARSER_REGISTRY.keys())}")
    return entry["class"]()

def get_schema() -> dict:
    """Liefert das vollständige Schema für GET /parsers."""
    return {
        key: {
            "display_name": val["display_name"],
            "parameters": val["parameters"],
        }
        for key, val in PARSER_REGISTRY.items()
    }

1.2 core/device_manager.py Umbau auf Pro-Gerät-Parser

Auswahl-Logik: Der Parser wird pro Gerät bestimmt durch parser_type in der Device-Config, nicht durch das MQTT-Topic. Das Topic wird dem Parser nur als Kontext übergeben, damit er intern entscheiden kann, welche Nachrichten er verarbeitet (z. B. filtert ShellyParser auf /status/pm1:0).

Vorher:

self.parser = parser or ShellyParser()  # global, für alle Geräte
# ...
parsed = self.parser.parse_message(topic, payload)  # immer derselbe Parser

Nachher:

# self.parser entfällt als globales Attribut
# Jedes Gerät bekommt beim Anlegen seine eigene Parser-Instanz

In _add_device():

from parsers.registry import get_parser
parser = get_parser(device.parser_type)  # Lookup: parser_type → Parser-Klasse
detector = SessionDetector(..., parser=parser)

SessionDetector erhält damit seinen Parser direkt keine Abhängigkeit mehr vom DeviceManager.

Achtung offenes Architekturproblem: SessionDetector ist aktuell hart auf Power-Threshold-Logik gebaut. Für Parser, die selbst on/off-Events liefern (z. B. Laser-Cutter), ist kein externer Detector notwendig das Gerät meldet seinen Zustand direkt. Zwei mögliche Lösungsrichtungen:

  • No-Op-Detector: reicht Events 1:1 durch, ohne Threshold-Logik
  • SessionStrategy als eigenes Konzept: wird pro Parser aus der Registry mitgeliefert und ersetzt den fixen SessionDetector

Für Phase 1 (nur shelly_pm) ist der bestehende Detector ausreichend. Die Entscheidung fällt, wenn der erste alternative Parser implementiert wird.

1.3 api/server.py neuer Endpunkt GET /parsers

@app.get("/parsers")
def list_parsers():
    from parsers.registry import get_schema
    return get_schema()

1.4 Tests

  • Unit-Test: get_parser("shelly_pm") → gibt ShellyParser-Instanz zurück
  • Unit-Test: get_parser("unknown") → wirft ValueError mit sprechender Meldung
  • Integration-Test: GET /parsers → HTTP 200, enthält "shelly_pm"

Definition of Done Phase 1

  • parsers/registry.py erstellt und getestet
  • DeviceManager nutzt pro-Gerät-Parser aus Registry
  • GET /parsers gibt Schema zurück
  • Alle bestehenden Tests weiterhin grün

Phase 2 Odoo: Statische Ableitung aus Bridge-Schema

Vorgehen: Das Bridge-Schema (GET /parsers) wird einmalig bei Implementierung eines
neuen Parsers konsultiert. Daraus werden Modell-Felder und View-XML manuell (oder per
Hilfs-Script) abgeleitet. Odoo bleibt vollständig statisch.

2.1 Modell mqtt_device.py

Selection-Feld nur echte Parser aus der Registry:

parser_type = fields.Selection([
    ('shelly_pm', 'Shelly PM Mini G3'),
    # Neue Einträge hier  direkt aus registry.py abgeleitet
], string='Parser Type', required=True, default='shelly_pm')

Parameter-Felder gruppiert nach Zugehörigkeit, keine Änderung an bestehenden:

# Gruppe: session_detection (shelly_pm)
power_on_threshold_w = fields.Integer(...)
active_work_threshold_w = fields.Integer(...)
# ...

# Gruppe: on_off_detection (zukünftige Parser)
# Felder kommen hier, wenn der Parser implementiert wird

2.2 View-XML invisible-Logik

Pro Parameter-Gruppe ein <group>-Element mit invisible-Bedingung:

<group string="Session Detection" name="session_detection"
       invisible="parser_type not in ['shelly_pm']">
    <field name="power_on_threshold_w"/>
    <field name="active_work_threshold_w"/>
    <!-- ... -->
</group>

<!-- Neue Gruppe für zukünftige Parser: -->
<!-- <group string="On/Off Detection" name="on_off_detection"
       invisible="parser_type not in ['laser_onoff']"> -->

2.3 _compute_strategy_config parser-abhängiges JSON

Die bestehende Compute-Methode generiert das Konfigurations-JSON für die Bridge.
Sie wird um eine Fallunterscheidung nach parser_type erweitert:

@api.depends('parser_type', 'power_on_threshold_w', ...)
def _compute_strategy_config(self):
    for record in self:
        if record.parser_type == 'shelly_pm':
            config = { ... }  # bestehende Logik
        # elif record.parser_type == 'laser_onoff':
        #     config = { ... }
        record.strategy_config = json.dumps(config, indent=2)

Definition of Done Phase 2

  • Selection enthält nur bekannte parser_types aus Bridge-Registry
  • Alle Parameter-Felder pro Gruppe korrekt mit invisible verknüpft
  • _compute_strategy_config korrekt aufgeteilt nach parser_type
  • Manuelle Test: Wechsel des parser_type blendet Felder korrekt um

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

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

Baseline (2026-03-10): 46/46 Tests bestanden in 43.94s
Hinweis: test_event_queue.py enthält reale Wartezeiten (~30s) das ist kein Hängeproblem.

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_event_queue.py        13 passed  ← langsam, aber korrekt
tests/unit/test_exceptions.py          5 passed
tests/unit/test_odoo_client_error_handling.py  2 passed
tests/unit/test_service_manager.py     3 passed
tests/unit/test_session_detector.py    9 passed

Shelly-Simulator (manueller E2E-Test)

Der Simulator sendet MQTT-Nachrichten an das testshelly-simulator-Gerät.
Die Bridge verbindet sich mit dem externen Broker mqtt.majufilo.eu:8883 (TLS, Auth).
Der lokale Mosquitto-Container wird von der Bridge nicht verwendet.

# 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)

E2E-Test Ergebnis (2026-03-10, Szenario online_test, 15s):

Schritt Status Beobachtung
Simulator → Broker Verbindung zu mqtt.majufilo.eu:8883 erfolgreich
Bridge empfängt Nachrichten shelly_parsed_status mit korrekten apower-Werten
State Machine idle → starting → standby, session_started generiert
Bridge → Odoo 404 NOT FOUND auf http://odoo-dev:8069/ows/iot/event

Bekanntes Problem (pre-existing): Die Bridge liefert Events an http://odoo-dev:8069/ows/iot/event
dieser Endpunkt antwortet mit 404. Das ist ein bestehendes Problem unabhängig von diesem
Feature-Branch und blockiert die Parser-Registry-Implementierung nicht.


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.


Offene Fragen

  1. Hilfs-Script für Ableitung? Soll es ein Script geben, das aus GET /parsers
    Python-Snippets und XML-Snippets für Odoo generiert als Entwickler-Hilfe,
    nicht als Laufzeit-Komponente?
  2. Gruppen-Namen standardisieren? Die group-Felder im Schema sollten über
    alle Parser hinweg konsistent benannt sein. Wo wird diese Liste gepflegt?

Architektur-Klarstellungen

Wo werden Parameter gespeichert?

Entscheidung: Parameter bleiben in der Odoo-Datenbank.

Die bestehende 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)
   Datenbank: Parameter-Werte              Registry: Parameter-Struktur (Schema)

Die Aufgaben sind damit klar getrennt:

  • Odoo kennt die Werte (z. B. power_on_threshold_w = 20)
  • Bridge kennt die Struktur (Schema: Typ, Label, Gruppe, Default via GET /parsers)

Parameter in der Bridge zu speichern würde das Push-Modell umkehren (Bridge wird stateful, Odoo muss pullen) und widerspricht der bestehenden Architektur ohne Mehrwert.

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