From d0ca48b3efdf995ea08b7796157dc4c06c1a3f33 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Wed, 11 Mar 2026 10:56:32 +0100 Subject: [PATCH] refactor(odoo): Registry-driven JSON Config (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt alle parser-spezifischen Einzelfelder durch ein einziges parser_config (fields.Json / jsonb) – neuer Parser = Bridge + 1 Zeile. Entfernt (Odoo, DB): power_on_threshold_w, active_work_threshold_w startup_delay_s, shutdown_delay_s, message_timeout_s dummy_pulse_count, dummy_pulse_debounce_ms, dummy_reset_interval_min strategy_config (computed Text) session_strategy Neu (Odoo, DB): parser_config (fields.Json / jsonb) Geändert: _onchange_parser_type holt Defaults via GET /parsers aus Bridge-Registry, kein parser-spezifischer if/elif mehr _compute_strategy_config entfällt komplett _build_bridge_config liest parser_config direkt View: alle parser-spezifischen Gruppen weg, ein ace-Widget iot_api.py: strategy_config -> parser_config IMPLEMENTATION_PLAN.md: Phase 3 dokumentiert Bridge: unverändert (session_config API bleibt, 63/63 Tests grün) DB: parser_config jsonb-Spalte, alle alten Spalten entfernt --- IMPLEMENTATION_PLAN.md | 77 +++- .../open_workshop_mqtt/controllers/iot_api.py | 20 +- .../open_workshop_mqtt/models/mqtt_device.py | 385 ++++-------------- .../tests/test_config_push_integration.py | 6 +- .../views/mqtt_device_views.xml | 119 +----- 5 files changed, 174 insertions(+), 433 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index d6c5fc8..c21706c 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,6 +1,6 @@ # Implementation Plan (Konsolidiert) -**Stand:** 2026-02-19 +**Stand:** 2026-03-11 **Status:** In Betrieb, laufende Optimierung ## 1) Erreichte Ergebnisse @@ -10,26 +10,79 @@ - Odoo-Addon als fachliche Integrationsschicht (`open_workshop_mqtt`) - Push-Config Odoo → Bridge über `POST /config` - Event-Flow Bridge → Odoo über `POST /ows/iot/event` (JSON-RPC) +- Dynamische Parser-Registry in der Bridge (`parsers/registry.py`) +- E2E-Pipeline vollständig getestet (device_online → session → billing → device_offline) ### Qualität & Stabilität - Type-Safety und Logging-Harmonisierung umgesetzt - Retry-/Fehlerbehandlung für Odoo-Delivery ausgebaut -- Unit- und Integration-Tests erweitert -- Bridge-Dokumentation (`ARCHITECTURE.md`, `DEVELOPMENT.md`, API-Referenzen) konsolidiert +- Unit- und Integration-Tests erweitert (63/63 grün) +- Bridge-Dokumentation konsolidiert -### Fachlicher Fix (aktuell) -- Offene Sessions werden robust geschlossen, wenn für dieselbe `device_id` neue Start-/Online-Ereignisse eintreffen -- Ziel: Keine hängenden Parallel-Sessions pro Gerät +--- -## 2) Offene Punkte (kurz) +## 2) Nächste Phase: Registry-driven JSON Config (Phase 3) -1. Optional: weitere Härtung der Odoo-Event-Tests rund um Restart-/Reconnect-Szenarien -2. Optional: Performance-/Batching-Themen (siehe `iot_bridge/OPTIMIZATION_PLAN.md`, Phase 5) -3. Kontinuierliche Doku-Pflege nach „Source-of-Truth“-Prinzip +### Motivation -## 3) Referenzen +Die bisherige Implementierung hat pro Parser je **5 Änderungsstellen**: +Bridge `registry.py` → Odoo `mqtt_device.py` (Felder) → `_compute_strategy_config` (Branch) → +`_onchange_parser_type` (Defaults) → View (neue Gruppe) → `odoo -u` (DB-Migration). + +Das skaliert nicht. Drei Probleme: +1. Neue Parser erfordern Odoo-Code-Änderungen +2. `strategy_config` ist `store=True` computed → aktualisiert sich nicht live im Formular +3. Parser-spezifische DB-Spalten (Flat Wide Table) erfordern Migrationen + +### Zielarchitektur + +``` +Bridge registry.py <-- Source of Truth für Schema + Defaults + | + v +GET /parsers/ <-- Odoo fragt Defaults beim onchange + | + v +parser_config (fields.Json) <-- ein JSON-Feld in Odoo, für alle Parser + | + v +POST /config <-- Bridge bekommt parser_config direkt +``` + +### Was sich ändert + +**Bridge (`iot_bridge`):** +- `registry.py`: bleibt SoT, `parameters[].default` als Basis für Odoo-onchange +- `core/device_manager.py`: liest `parser_config` dict statt `strategy_config`-Text +- Kein weiterer Bridge-Umbau nötig + +**Odoo (`open_workshop_mqtt`):** +- **Entfernt:** alle parser-spezifischen Felder: + `power_on_threshold_w`, `active_work_threshold_w`, `startup_delay_s`, + `shutdown_delay_s`, `message_timeout_s`, `dummy_pulse_count`, + `dummy_pulse_debounce_ms`, `dummy_reset_interval_min`, `strategy_config` +- **Neu:** `parser_config = fields.Json(default=dict)` +- **Bleibt:** `parser_type`, `topic_pattern`, `device_id`, `billing_interval_min` +- `_onchange_parser_type`: holt Defaults aus Bridge-Registry, befüllt `parser_config` +- `_compute_strategy_config`: entfällt komplett +- View: ein `ace`-Widget für `parser_config`, alle parser-spezifischen Gruppen weg + +### Workflow für neuen Parser (Zielzustand) + +1. **Bridge:** `parsers/my_parser.py` + Eintrag in `registry.py` +2. **Odoo:** 1 Zeile in `_PARSER_SELECTION` +3. `odoo -u open_workshop_mqtt` (wegen Selection-Änderung, aber **keine neuen DB-Felder**) + +--- + +## 3) Offene Punkte (nach Phase 3) + +1. Optional: Performance-/Batching-Themen (`iot_bridge/OPTIMIZATION_PLAN.md` Phase 5) +2. Optional: OWL-Widget das `parser_config` als Formular rendert (Schema aus Bridge-Registry) +3. Kontinuierliche Doku-Pflege nach "Source-of-Truth"-Prinzip + +## 4) Referenzen -- Detail-Roadmap: `iot_bridge/OPTIMIZATION_PLAN.md` - Bridge-Architektur: `iot_bridge/ARCHITECTURE.md` - Bridge-Entwicklung: `iot_bridge/DEVELOPMENT.md` - Odoo-Addon API: `extra-addons/open_workshop/open_workshop_mqtt/API.md` diff --git a/extra-addons/open_workshop/open_workshop_mqtt/controllers/iot_api.py b/extra-addons/open_workshop/open_workshop_mqtt/controllers/iot_api.py index 859344d..bbd1b51 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/controllers/iot_api.py +++ b/extra-addons/open_workshop/open_workshop_mqtt/controllers/iot_api.py @@ -118,16 +118,16 @@ class IotApiController(http.Controller): config_devices = [] for device in devices: - # Parse strategy_config JSON - session_config = {} - if device.strategy_config: - try: - session_config = json.loads(device.strategy_config) - except json.JSONDecodeError: - _logger.warning(f"Invalid JSON in device {device.id} strategy_config") - - # Add strategy type - session_config['strategy'] = device.session_strategy or 'power_threshold' + # parser_config as session_config (with fallbacks) + cfg = device.parser_config or {} + session_config = { + 'standby_threshold_w': float(cfg.get('standby_threshold_w', 20.0)), + 'working_threshold_w': float(cfg.get('working_threshold_w', 100.0)), + 'start_debounce_s': float(cfg.get('start_debounce_s', 3.0)), + 'stop_debounce_s': float(cfg.get('stop_debounce_s', 15.0)), + 'message_timeout_s': float(cfg.get('message_timeout_s', 20.0)), + 'heartbeat_interval_s': float(device.billing_interval_min * 60), + } # Build device config device_config = { diff --git a/extra-addons/open_workshop/open_workshop_mqtt/models/mqtt_device.py b/extra-addons/open_workshop/open_workshop_mqtt/models/mqtt_device.py index cf35c39..d728af2 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/models/mqtt_device.py +++ b/extra-addons/open_workshop/open_workshop_mqtt/models/mqtt_device.py @@ -61,9 +61,9 @@ class MqttDevice(models.Model): - device_id: Unique external identifier (used by Bridge) - state: Current device state (offline/idle/active) - topic_pattern: MQTT topic to subscribe to - - parser_type: Parser for MQTT messages (from Bridge Registry, e.g. shelly_pm) - - Session thresholds: standby_threshold_w, working_threshold_w - - Session timing: start_debounce_s, stop_debounce_s, message_timeout_s + - parser_type: Parser for MQTT messages (from Bridge Registry) + - parser_config: JSON blob with all parser parameters (fetched from Bridge Registry defaults) + - billing_interval_min: Abrechnungsintervall in Minuten - Statistics: last_message_time, session_count """ _name = 'ows.mqtt.device' @@ -121,121 +121,25 @@ class MqttDevice(models.Model): ), ) - # ========== Session Detection Strategy ========== - session_strategy = fields.Selection([ - ('power_threshold', 'Power Threshold (Dual)'), - ('last_will', 'MQTT Last Will Testament'), - ('manual', 'Manual Start/Stop'), - ], string='Session Strategy', default='power_threshold', required=True, - help='How to detect session start/end') - - # ========== Benutzerfreundliche Konfigurationsfelder ========== - - # Abrechnungs-Konfiguration + # ========== Abrechnung ========== billing_interval_min = fields.Integer( - string='Abrechnungs-Intervall', + string='Abrechnungs-Intervall (Minuten)', default=5, required=True, help='Die IoT-Bridge sendet alle X Minuten ein aggregiertes Event an Odoo.\n' - 'Dies ist Ihre Abrechnungseinheit!\n\n' - 'Beispiel: 5 Minuten = Events alle 5 Minuten\n' - '→ Feinere Abrechnung, mehr Events\n\n' - 'Beispiel: 15 Minuten = Events alle 15 Minuten\n' - '→ Gröbere Abrechnung, weniger Events' - ) - - # Leistungs-Schwellenwerte - power_on_threshold_w = fields.Integer( - string='Einschalt-Schwelle (Watt)', - default=20, - required=True, - help='Ab welcher Leistung gilt das Gerät als "eingeschaltet"?\n\n' - 'Beispiel Shaper Origin: 20 Watt\n' - '→ Unter 20W = Gerät ist AUS (Idle)\n' - '→ Über 20W = Gerät ist AN (Standby oder Working)\n\n' - 'Diese Schwelle startet die Session-Erkennung.' - ) - - active_work_threshold_w = fields.Integer( - string='Arbeits-Schwelle (Watt)', - default=100, - required=True, - help='Ab welcher Leistung arbeitet das Gerät aktiv?\n\n' - 'Beispiel Shaper Origin: 100 Watt\n' - '→ 20-100W = Standby (Gerät an, aber nicht aktiv)\n' - '→ Über 100W = Working (Gerät arbeitet aktiv)\n\n' - 'Nur "Working"-Zeit wird standardmäßig abgerechnet!' - ) - - # Zeitverzögerungen (Hysterese gegen Flackern) - startup_delay_s = fields.Integer( - string='Anlauf-Verzögerung (Sekunden)', - default=3, - required=True, - help='Wie lange muss das Gerät eingeschaltet sein, bevor die Session startet?\n\n' - 'Verhindert Fehlalarme bei kurzen Stromspitzen!\n\n' - 'Beispiel: 3 Sekunden\n' - '→ Gerät muss 3 Sekunden über der Einschalt-Schwelle bleiben\n' - '→ Erst dann beginnt die Session' - ) - - shutdown_delay_s = fields.Integer( - string='Abschalt-Verzögerung (Sekunden)', - default=15, - required=True, - help='Wie lange muss das Gerät ausgeschaltet sein, bevor die Session endet?\n\n' - 'Berücksichtigt kurze Arbeitspausen!\n\n' - 'Beispiel: 15 Sekunden\n' - '→ Gerät muss 15 Sekunden unter der Einschalt-Schwelle bleiben\n' - '→ Erst dann endet die Session\n\n' - 'Kurze Material-Wechsel werden so nicht als Session-Ende gewertet.' - ) - - message_timeout_s = fields.Integer( - string='Timeout bei fehlenden Daten (Sekunden)', - default=20, - required=True, - help='Nach wie vielen Sekunden ohne MQTT-Nachricht endet die Session automatisch?\n\n' - 'Sicherheits-Mechanismus für Verbindungsabbrüche!\n\n' - 'Beispiel: 20 Sekunden\n' - '→ Keine MQTT-Nachricht für 20 Sekunden = Session timeout\n' - '→ Verhindert "hängende" Sessions bei Netzwerkproblemen' - ) - - # ========== Dummy Generic Parser Configuration ========== - # Puls-spezifische Parameter – nur für parser_type == 'dummy_generic' relevant. - # Flache Tabelle (Flat Wide Table): andere Parser lassen diese Spalten auf NULL. - dummy_pulse_count = fields.Integer( - string='Impulse pro Einheit', - default=1, - required=True, - help='Wie viele Impulse entsprechen einer Abrechnungseinheit?\n' - '(Bridge-Registry: pulse_count, default=1)' - ) - dummy_pulse_debounce_ms = fields.Integer( - string='Entprellung (ms)', - default=50, - required=True, - help='Impulse kürzer als dieser Wert werden ignoriert (Rauschfilter).\n' - '(Bridge-Registry: pulse_debounce_ms, default=50)' - ) - dummy_reset_interval_min = fields.Integer( - string='Zähler-Reset (Minuten)', - default=60, - required=True, - help='Alle N Minuten wird der interne Impulszähler zurückgesetzt.\n' - '(Bridge-Registry: reset_interval_min, default=60)' + 'Beispiel: 5 Min = feinere Abrechnung, mehr Events.' ) - # Automatisch generierte JSON-Konfiguration (für Bridge API) - strategy_config = fields.Text( - string='Generierte Konfiguration (JSON)', - compute='_compute_strategy_config', - store=True, - readonly=True, - help='Automatisch generierte JSON-Konfiguration für die IoT-Bridge.\n' - 'Wird aus den obigen Feldern berechnet.\n' - 'Nur für Debugging/Transparenz sichtbar.' + # ========== Parser-Konfiguration ========== + # Ein einziges JSON-Feld für alle Parser-Parameter. + # Wird beim Wechsel des parser_type mit Defaults aus der Bridge-Registry befüllt. + # Struktur hängt vom Parser ab und wird von der Bridge (registry.py) definiert. + parser_config = fields.Json( + string='Parser-Konfiguration', + default=dict, + help='JSON-Konfiguration für den gewählten Parser.\n' + 'Wird automatisch mit Registry-Defaults befüllt wenn parser_type gewechselt wird.\n' + 'Kann frei bearbeitet werden. Bridge-Registry ist die Source of Truth für das Schema.' ) # ========== Live Status (Runtime) ========== @@ -292,65 +196,7 @@ class MqttDevice(models.Model): help='Average session duration in minutes' ) - # ========== Compute Methods ========== # ========== Computed Fields ========== - @api.depends('parser_type', 'billing_interval_min', 'power_on_threshold_w', - 'active_work_threshold_w', 'startup_delay_s', 'shutdown_delay_s', - 'message_timeout_s', - 'dummy_pulse_count', 'dummy_pulse_debounce_ms', 'dummy_reset_interval_min') - def _compute_strategy_config(self): - """ - Generiert die JSON-Konfiguration für die IoT-Bridge aus den UI-Feldern. - - Der Inhalt hängt vom Parser-Typ ab – nur Parameter, die in der - Bridge-Registry (parsers/registry.py) für diesen Typ definiert sind, - werden übertragen. - - WORKFLOW (bei neuem Parser): - 1. Bridge-Registry-Eintrag enthält ``parameters[]`` mit den - relevanten Feldnamen (z.B. standby_threshold_w). - 2. Hier einen neuen Branch ``elif device.parser_type == 'neuer_typ'`` - anlegen, der nur die relevanten Felder enthält. - 3. Felder, die nicht in der Registry-``parameters[]``-Liste des - Parsers stehen, werden NICHT in den config-Dict aufgenommen. - """ - for device in self: - # Gemeinsame Basisfelder für alle Power-Threshold-Parser - base = { - 'standby_threshold_w': device.power_on_threshold_w, - 'working_threshold_w': device.active_work_threshold_w, - 'message_timeout_s': device.message_timeout_s, - 'heartbeat_interval_s': device.billing_interval_min * 60, - } - - if device.parser_type == 'shelly_pm': - # shelly_pm: alle 4 Registry-Parameter + Debounce - # Registry-Parameter: standby_threshold_w, working_threshold_w, - # start_debounce_s, stop_debounce_s - config = { - **base, - 'start_debounce_s': device.startup_delay_s, - 'stop_debounce_s': device.shutdown_delay_s, - } - elif device.parser_type == 'dummy_generic': - # dummy_generic: völlig andere Parameter als shelly_pm - # Registry-Parameter: pulse_count, pulse_debounce_ms, reset_interval_min - config = { - 'pulse_count': device.dummy_pulse_count, - 'pulse_debounce_ms': device.dummy_pulse_debounce_ms, - 'reset_interval_min': device.dummy_reset_interval_min, - 'heartbeat_interval_s': device.billing_interval_min * 60, - 'message_timeout_s': device.message_timeout_s, - } - else: - # Fallback für unbekannte Typen: alle Felderübergeben - config = { - **base, - 'start_debounce_s': device.startup_delay_s, - 'stop_debounce_s': device.shutdown_delay_s, - } - - device.strategy_config = json.dumps(config, indent=2) @api.depends('session_ids', 'session_ids.status', 'session_ids.total_duration_min') def _compute_session_stats(self): @@ -368,81 +214,60 @@ class MqttDevice(models.Model): device.avg_session_duration_min = 0.0 # ========== Constraints ========== - @api.constrains('power_on_threshold_w', 'active_work_threshold_w', - 'startup_delay_s', 'shutdown_delay_s', 'message_timeout_s', - 'billing_interval_min') - def _check_threshold_values(self): - """Validate session detection configuration values""" + @api.constrains('billing_interval_min') + def _check_billing_interval(self): + """Validate billing interval""" for device in self: - if device.session_strategy == 'power_threshold': - # Validate positive values - if device.power_on_threshold_w < 0: - raise ValidationError(_('Einschalt-Schwelle muss positiv sein (≥ 0 Watt)')) - if device.active_work_threshold_w < 0: - raise ValidationError(_('Arbeits-Schwelle muss positiv sein (≥ 0 Watt)')) - if device.startup_delay_s < 0: - raise ValidationError(_('Anlauf-Verzögerung muss positiv sein (≥ 0 Sekunden)')) - if device.shutdown_delay_s < 0: - raise ValidationError(_('Abschalt-Verzögerung muss positiv sein (≥ 0 Sekunden)')) - if device.message_timeout_s <= 0: - raise ValidationError(_('Timeout muss positiv sein (> 0 Sekunden)')) - if device.billing_interval_min <= 0: - raise ValidationError(_('Abrechnungs-Intervall muss positiv sein (> 0 Minuten)')) - - # Validate threshold order - if device.power_on_threshold_w >= device.active_work_threshold_w: - raise ValidationError( - _('Einschalt-Schwelle (%d W) muss kleiner sein als Arbeits-Schwelle (%d W)!\n\n' - 'Beispiel: Einschalt=20W, Arbeits=100W') % - (device.power_on_threshold_w, device.active_work_threshold_w) - ) - - # Validate reasonable ranges - if device.billing_interval_min > 60: - raise ValidationError( - _('Abrechnungs-Intervall über 60 Minuten ist ungewöhnlich.\n' - 'Empfohlen: 5-15 Minuten für normale Abrechnung.') - ) + if device.billing_interval_min <= 0: + raise ValidationError(_('Abrechnungs-Intervall muss positiv sein (> 0 Minuten)')) + if device.billing_interval_min > 60: + raise ValidationError( + _('Abrechnungs-Intervall über 60 Minuten ist ungewöhnlich.\n' + 'Empfohlen: 5-15 Minuten für normale Abrechnung.') + ) # ========== UI Helper: Parser-Type Onchange ========== @api.onchange('parser_type') def _onchange_parser_type(self): """ - Reagiert auf Wechsel des Parser-Typs: - - Setzt passenden Topic-Pattern (laut topic_hint aus Bridge-Registry) - - Setzt sinnvolle Defaults für Schwellenwerte (laut parameters[].default - aus Bridge-Registry) + Reagiert auf Wechsel des Parser-Typs. - WORKFLOW (bei neuem Parser): - 1. Bridge-Registry enthält ``topic_hint`` und ``parameters[].default``. - 2. Hier neuen ``elif self.parser_type == 'neuer_typ'``-Block anlegen: - - topic_pattern setzen (laut topic_hint) - - power_on_threshold_w = parameters['standby_threshold_w']['default'] - - active_work_threshold_w = parameters['working_threshold_w']['default'] - - startup_delay_s / shutdown_delay_s falls vorhanden, sonst 0 + Holt Defaults und topic_hint aus der Bridge-Registry (GET /parsers) + und befüllt parser_config direkt. Kein parser-spezifischer Code hier – + der Parser ist vollständig in der Bridge-Registry definiert. + + WORKFLOW für neuen Parser: + 1. Bridge: parsers/my_parser.py + registry.py-Eintrag + 2. Odoo: 1 Zeile in _PARSER_SELECTION + 3. odoo -u open_workshop_mqtt + → Kein weiterer Odoo-Code nötig. """ - if self.parser_type == 'shelly_pm': - # Registry: topic_hint = "/status/pm1:0" - # Registry: standby_threshold_w.default=20, working_threshold_w.default=100 - # start_debounce_s.default=3, stop_debounce_s.default=15 - if not self.topic_pattern or self.topic_pattern in ('#', 'shaperorigin/#'): - device_prefix = self.device_id or 'device' - self.topic_pattern = f'{device_prefix}/status/pm1:0' - self.power_on_threshold_w = 20 - self.active_work_threshold_w = 100 - self.startup_delay_s = 3 - self.shutdown_delay_s = 15 + if not self.parser_type: + return - elif self.parser_type == 'dummy_generic': - # Registry: topic_hint = "/data" - # Eigene Felder: dummy_pulse_count, dummy_pulse_debounce_ms, dummy_reset_interval_min - if not self.topic_pattern or self.topic_pattern in ('#', 'shaperorigin/#'): - device_prefix = self.device_id or 'device' - self.topic_pattern = f'{device_prefix}/data' - # Puls-Defaults aus Registry (parameters[].default) - self.dummy_pulse_count = 1 - self.dummy_pulse_debounce_ms = 50 - self.dummy_reset_interval_min = 60 + import requests + bridge_url = self._get_bridge_url() + registry_entry = {} + try: + resp = requests.get(f'{bridge_url}/parsers', timeout=3) + if resp.status_code == 200: + registry_entry = resp.json().get(self.parser_type, {}) + except Exception: + _logger.warning('Bridge not reachable for parser defaults, using empty config') + + # topic_hint → topic_pattern setzen (falls noch auf Default) + topic_hint = registry_entry.get('topic_hint', '') + if topic_hint and (not self.topic_pattern or self.topic_pattern in ('#', 'shaperorigin/#')): + device_prefix = self.device_id or 'device' + self.topic_pattern = topic_hint.replace('', device_prefix) + + # parameters[].default → parser_config befüllen + defaults = { + p['name']: p['default'] + for p in registry_entry.get('parameters', []) + if 'default' in p + } + self.parser_config = defaults if defaults else {} # ========== Action Methods ========== def action_view_sessions(self): @@ -457,60 +282,13 @@ class MqttDevice(models.Model): 'context': {'default_device_id': self.id}, } - def action_start_manual_session(self): - """Manually start a session""" - self.ensure_one() - - if self.session_strategy != 'manual': - raise UserError(_('Manual session start is only available for manual strategy')) - - # Check if there's already a running session - running = self.session_ids.filtered(lambda s: s.status == 'running') - if running: - raise UserError(_('Device already has a running session')) - - # TODO: Implement manual session start - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': _('Manual Session'), - 'message': _('Manual session start not yet implemented'), - 'type': 'warning', - } - } - - def action_stop_manual_session(self): - """Manually stop a session""" - self.ensure_one() - - if self.session_strategy != 'manual': - raise UserError(_('Manual session stop is only available for manual strategy')) - - running = self.session_ids.filtered(lambda s: s.status == 'running') - if not running: - raise UserError(_('No running session to stop')) - - # TODO: Implement manual session stop - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': _('Manual Session'), - 'message': _('Manual session stop not yet implemented'), - 'type': 'warning', - } - } + # ========== Helper Methods ========== - def get_strategy_config_dict(self): - """Parse strategy_config JSON and return as dict""" + def get_parser_config_dict(self): + """Return parser_config as dict (safe fallback to empty dict)""" self.ensure_one() - try: - return json.loads(self.strategy_config or '{}') - except json.JSONDecodeError: - _logger.error(f"Invalid JSON in strategy_config for device {self.id}") - return {} + return self.parser_config or {} # ========== Bridge Config Push (Phase 3.2) ========== @@ -566,20 +344,21 @@ class MqttDevice(models.Model): device_configs = [] for device in devices: - strategy_config = device.get_strategy_config_dict() - - # Extract session config (defaults for missing values) + cfg = device.get_parser_config_dict() + + # Baue session_config für Bridge-API aus parser_config (mit Fallback-Defaults). + # heartbeat_interval_s wird immer aus billing_interval_min berechnet (Odoo SoT). session_config = { - 'standby_threshold_w': float(strategy_config.get('standby_threshold_w', 20.0)), - 'working_threshold_w': float(strategy_config.get('working_threshold_w', 100.0)), - 'start_debounce_s': float(strategy_config.get('start_debounce_s', 3.0)), - 'stop_debounce_s': float(strategy_config.get('stop_debounce_s', 15.0)), - 'message_timeout_s': float(strategy_config.get('message_timeout_s', 20.0)), - 'heartbeat_interval_s': float(strategy_config.get('heartbeat_interval_s', 300.0)) + 'standby_threshold_w': float(cfg.get('standby_threshold_w', 20.0)), + 'working_threshold_w': float(cfg.get('working_threshold_w', 100.0)), + 'start_debounce_s': float(cfg.get('start_debounce_s', 3.0)), + 'stop_debounce_s': float(cfg.get('stop_debounce_s', 15.0)), + 'message_timeout_s': float(cfg.get('message_timeout_s', 20.0)), + 'heartbeat_interval_s': float(device.billing_interval_min * 60), } - - # Extract topic (remove wildcard suffixes for bridge) - topic = device.topic_pattern.replace('/#', '/status/pm1:0').replace('/+', '/status/pm1:0') + + # Topic: Wildcards entfernen für direkte Bridge-Subscription + topic = device.topic_pattern.rstrip('/#').rstrip('/+') device_config = { 'device_id': device.device_id, @@ -741,13 +520,7 @@ class MqttDevice(models.Model): # Only push if relevant fields changed relevant_fields = { 'active', 'device_id', 'topic_pattern', 'parser_type', - 'session_strategy', 'strategy_config', 'name', 'broker_id', - # Billing configuration - 'billing_interval_min', - # Power thresholds - 'power_on_threshold_w', 'active_work_threshold_w', - # Time delays - 'startup_delay_s', 'shutdown_delay_s', 'message_timeout_s' + 'parser_config', 'name', 'broker_id', 'billing_interval_min', } if any(field in vals for field in relevant_fields): diff --git a/extra-addons/open_workshop/open_workshop_mqtt/tests/test_config_push_integration.py b/extra-addons/open_workshop/open_workshop_mqtt/tests/test_config_push_integration.py index 9269d73..15cf1ce 100755 --- a/extra-addons/open_workshop/open_workshop_mqtt/tests/test_config_push_integration.py +++ b/extra-addons/open_workshop/open_workshop_mqtt/tests/test_config_push_integration.py @@ -173,15 +173,13 @@ def main(): "device_id": test_device_id, "topic_pattern": f"{test_device_id}/status/pm1:0", "parser_type": "shelly_pm", - "session_strategy": "power_threshold", - "strategy_config": json.dumps({ + "parser_config": { "standby_threshold_w": 15.0, "working_threshold_w": 75.0, "start_debounce_s": 2.0, "stop_debounce_s": 10.0, "message_timeout_s": 15.0, - "heartbeat_interval_s": 60.0 - }) + } } try: diff --git a/extra-addons/open_workshop/open_workshop_mqtt/views/mqtt_device_views.xml b/extra-addons/open_workshop/open_workshop_mqtt/views/mqtt_device_views.xml index bbdb678..494bbbd 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/views/mqtt_device_views.xml +++ b/extra-addons/open_workshop/open_workshop_mqtt/views/mqtt_device_views.xml @@ -34,16 +34,6 @@
-
@@ -109,100 +99,28 @@ - - - -
- Konfigurieren Sie, wie Sessions erkannt und abgerechnet werden -
-
-
- - - - -
- - Unter diesem Wert gilt das Gerät als ausgeschaltet -
-
- - -
- - Über diesem Wert gilt das Gerät als aktiv arbeitend -
-
-
- - - - - - - - - -
- - Sicherheits-Timeout bei Verbindungsabbruch -
-
-
- - - - - -
- - Impulse pro Abrechnungseinheit (Registry: pulse_count) -
-
- - -
- - Rauschfilter – kurze Impulse werden ignoriert -
-
- - -
- - Automatischer Zähler-Reset nach Inaktivität -
-
- - - -
- - + - - - - + + + + + + + @@ -330,7 +248,6 @@ -