- 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
13 KiB
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:
SessionDetectorist aktuell hart auf Power-Threshold-Logik gebaut. Für Parser, die selbston/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
SessionDetectorFü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")→ gibtShellyParser-Instanz zurück - Unit-Test:
get_parser("unknown")→ wirftValueErrormit sprechender Meldung - Integration-Test:
GET /parsers→ HTTP 200, enthält"shelly_pm"
Definition of Done Phase 1
parsers/registry.pyerstellt und getestetDeviceManagernutzt pro-Gerät-Parser aus RegistryGET /parsersgibt 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
invisibleverknüpft _compute_strategy_configkorrekt aufgeteilt nachparser_type- Manuelle Test: Wechsel des
parser_typeblendet 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 |
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) |
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
- 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? - 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