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:
parent
0111c5db24
commit
d0ca48b3ef
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user