refactor(odoo): Registry-driven JSON Config (Phase 3)

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
This commit is contained in:
Matthias Lotz 2026-03-11 10:56:32 +01:00
parent 0111c5db24
commit d0ca48b3ef
5 changed files with 174 additions and 433 deletions

View File

@ -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/<type> <-- 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`

View File

@ -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 = {

View File

@ -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 = "<device-name>/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 = "<device-name>/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-name>', 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):

View File

@ -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:

View File

@ -34,16 +34,6 @@
<field name="arch" type="xml">
<form>
<header>
<button name="action_start_manual_session"
string="Start Session"
type="object"
class="btn-primary"
invisible="session_strategy != 'manual'"/>
<button name="action_stop_manual_session"
string="Stop Session"
type="object"
class="btn-secondary"
invisible="session_strategy != 'manual'"/>
<field name="state" widget="statusbar"/>
</header>
@ -109,100 +99,28 @@
</group>
</group>
<group string="Session Detection Strategy">
<group>
<field name="session_strategy"/>
<div class="text-muted mb-3" colspan="2">
Konfigurieren Sie, wie Sessions erkannt und abgerechnet werden
</div>
</group>
</group>
<group string="⚡ Leistungs-Schwellenwerte" invisible="parser_type not in ['shelly_pm']">
<group>
<field name="power_on_threshold_w" widget="integer"/>
<div class="text-muted small" colspan="2">
<i class="fa fa-info-circle"/>
Unter diesem Wert gilt das Gerät als ausgeschaltet
</div>
</group>
<group>
<field name="active_work_threshold_w" widget="integer"/>
<div class="text-muted small" colspan="2">
<i class="fa fa-cogs"/>
Über diesem Wert gilt das Gerät als aktiv arbeitend
</div>
</group>
</group>
<!-- Zeitsteuerung: nur für Parser mit Debounce-Parametern -->
<group string="⏱️ Zeitsteuerung"
invisible="parser_type not in ['shelly_pm']">
<group>
<field name="startup_delay_s" widget="integer"/>
<field name="shutdown_delay_s" widget="integer"/>
</group>
<group>
<field name="message_timeout_s" widget="integer"/>
<div class="text-muted small" colspan="2">
<i class="fa fa-exclamation-triangle"/>
Sicherheits-Timeout bei Verbindungsabbruch
</div>
</group>
</group>
<!-- Puls-Konfiguration: nur für dummy_generic -->
<group string="🔢 Puls-Konfiguration" invisible="parser_type != 'dummy_generic'">
<group>
<field name="dummy_pulse_count" widget="integer"/>
<div class="text-muted small" colspan="2">
<i class="fa fa-bolt"/>
Impulse pro Abrechnungseinheit (Registry: pulse_count)
</div>
</group>
<group>
<field name="dummy_pulse_debounce_ms" widget="integer"/>
<div class="text-muted small" colspan="2">
<i class="fa fa-filter"/>
Rauschfilter kurze Impulse werden ignoriert
</div>
</group>
<group>
<field name="dummy_reset_interval_min" widget="integer"/>
<div class="text-muted small" colspan="2">
<i class="fa fa-undo"/>
Automatischer Zähler-Reset nach Inaktivität
</div>
</group>
<group>
<field name="message_timeout_s" widget="integer"/>
</group>
</group>
<group string="💰 Abrechnung">
<group string="⚙️ Konfiguration">
<group>
<field name="billing_interval_min" widget="integer"/>
<div class="alert alert-info" colspan="2" role="alert">
<i class="fa fa-calculator"/>
<strong>Abrechnungs-Intervall = Event-Frequenz</strong><br/>
Die Bridge sendet alle <field name="billing_interval_min" class="oe_inline" readonly="1"/> Minuten
ein aggregiertes Event an Odoo.<br/>
<small class="text-muted">Empfohlung: 5-15 Minuten für normale Werkstatt-Abrechnung</small>
</div>
</group>
<group>
<label for="strategy_config" string="Generierte Konfiguration"/>
<field name="strategy_config"
widget="ace"
options="{'mode': 'json'}"
readonly="1"
nolabel="1"/>
<div class="text-muted small" colspan="2">
<i class="fa fa-code"/>
Automatisch generiert aus obigen Feldern (nur zur Ansicht)
</div>
</group>
<group/>
</group>
<group string="📋 Parser-Konfiguration">
<div class="alert alert-info" colspan="2" role="alert">
<i class="fa fa-info-circle"/>
Beim Wechsel des Parser-Typs werden die Felder automatisch mit
den <strong>Defaults aus der Bridge-Registry</strong> befüllt
(erfordert laufende Bridge).
Danach können die Werte frei editiert werden.
</div>
<field name="parser_config"
widget="ace"
options="{'mode': 'json'}"
nolabel="1"
colspan="2"/>
</group>
<group string="Live Status" invisible="state == 'offline'">
<group>
@ -330,7 +248,6 @@
<separator/>
<group expand="0" string="Group By">
<filter name="group_parser" string="Parser" context="{'group_by': 'parser_type'}"/>
<filter name="group_strategy" string="Strategy" context="{'group_by': 'session_strategy'}"/>
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
</group>
</search>