diff --git a/extra-addons/open_workshop/open_workshop_mqtt/API.md b/extra-addons/open_workshop/open_workshop_mqtt/API.md index 26451a3..abb729e 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/API.md +++ b/extra-addons/open_workshop/open_workshop_mqtt/API.md @@ -1,638 +1,508 @@ -# Odoo IoT Event API Reference +# Open Workshop MQTT - API Reference -**Base URL:** `http://localhost:9018` (development) / `https://your-odoo-instance.com` (production) -**Endpoint:** `POST /ows/iot/event` -**Protocol:** JSON-RPC 2.0 -**Module:** `open_workshop_mqtt` -**Version:** 1.0.0 +**Auto-generated from Python docstrings** +Last Updated: 2026-02-15 13:39:24 --- -## Overview +## Table of Contents -This API receives IoT events from the Bridge service and processes them to: -- Track device online/offline status -- Detect work sessions -- Record device state transitions -- Calculate session statistics +### Controllers +- [iot_api.py](#controller-iot_api) -**Auto-called by:** IoT Bridge when MQTT messages are received and processed +### Models +- [iot_event.py](#model-iot_event) +- [mqtt_device.py](#model-mqtt_device) +- [mqtt_session.py](#model-mqtt_session) --- -## Authentication +## Controllers -**Method:** Odoo Session Cookie or API Key (optional) +### 📄 iot_api.py -Currently configured with `auth='none'` for Bridge-to-Odoo communication. For production: +**File:** `controllers/iot_api.py` -```python -# Option 1: API Key (recommended) -headers = { - 'Content-Type': 'application/json', - 'X-Openerp-Session-Id': 'your-api-key-here' +#### Class: `IotApiController` + +REST API for IoT Bridge integration + +##### Method: `get_iot_config()` + +Get IoT device configuration for Bridge + +Returns JSON with all active devices and their session configs + +Example Response: +{ +``` +"devices": [ + { + "device_id": "shellypmminig3-48f6eeb73a1c", + "mqtt_topic": "shaperorigin/status/pm1:0", + "parser_type": "shelly_pm_mini_g3", + "machine_name": "Shaper Origin", + "session_config": { + "strategy": "power_threshold", + "standby_threshold_w": 20, + "working_threshold_w": 100, + "start_debounce_s": 3, + "stop_debounce_s": 15, + "message_timeout_s": 20, + "heartbeat_interval_s": 300 + } + } +] +``` } -# Option 2: Session-based (after login) -# Odoo automatically validates session cookie +##### Method: `receive_iot_event()` + +Receive IoT event from Bridge and process device/session state changes. + +This is the main API endpoint that receives events from the IoT Bridge microservice. +All events are idempotent via event_uid to prevent duplicate processing. + +Protocol: JSON-RPC 2.0 (params must be wrapped in "params" object) + +Args: +``` +event_uid (str): Unique event identifier (UUID format, required) + Used for idempotency - duplicate submissions return 409 Conflict +event_type (str): Type of event (required), one of: + - 'device_online': Device came online → sets state='idle' + - 'device_offline': Device went offline → sets state='offline' + - 'session_started': Work session started → sets state='active', creates ows.mqtt.session + - 'session_stopped': Session ended → sets state='idle', finalizes ows.mqtt.session + - 'session_heartbeat': Periodic update during session (every 300s default) + - 'session_timeout': Session terminated due to message timeout +device_id (str): Device identifier, must exist in ows.mqtt.device (required) +session_id (str): Session identifier (required for session_* events, optional otherwise) +timestamp (str): ISO 8601 timestamp in UTC (required) +payload (dict): Device-specific data (optional), may contain: + - power_w (float): Current power consumption in watts + - state (str): Device state from Bridge + - avg_power_w (float): Average power for session + - peak_power_w (float): Peak power for session + - total_energy_wh (float): Total energy consumed + - total_duration_s (int): Session duration in seconds +**kw: Additional JSON-RPC parameters (ignored) ``` ---- +Returns: +``` +dict: Response dictionary with keys: + - status (str): 'success', 'duplicate', or 'error' + - event_id (int): Database ID of created/existing event + - event_uid (str): Echo of input event_uid + - timestamp (str): Processing timestamp + - message (str): Additional info (for duplicates/errors) +``` -## Request Format (JSON-RPC 2.0) +HTTP Status Codes: +``` +- 201 Created: Event successfully created and processed +- 409 Conflict: Duplicate event_uid (idempotent, no changes made) +- 400 Bad Request: Missing required fields or validation error +- 404 Not Found: device_id does not exist in ows.mqtt.device +- 500 Internal Server Error: Unexpected processing error +``` -**Critical:** Use JSON-RPC 2.0 format with `params` object! +Side Effects: +``` +- Creates iot.event record +- May update ows.mqtt.device.state (offline/idle/active) +- May create/update ows.mqtt.session record +- Updates ows.mqtt.device.last_message_time +``` -```json +Example Request (JSON-RPC 2.0): +``` { - "jsonrpc": "2.0", - "params": { + "jsonrpc": "2.0", + "params": { + "event_uid": "550e8400-e29b-41d4-a716-446655440000", + "event_type": "session_started", + "device_id": "shellypmminig3-48f6eeb73a1c", + "session_id": "session-abc123", + "timestamp": "2026-02-15T10:30:45.123456Z", + "payload": {"power_w": 125.5, "state": "working"} + } +} +``` + +Example Response (Success): +``` +{ + "status": "success", + "event_id": 42, "event_uid": "550e8400-e29b-41d4-a716-446655440000", - "event_type": "session_started", - "device_id": "shellypmminig3-48f6eeb73a1c", - "session_id": "abc-123-def-456", - "timestamp": "2026-02-15T10:30:45.123456Z", - "payload": { - "power_w": 125.5, - "state": "working" - } - } + "timestamp": "2026-02-15T10:30:45.234567" } ``` -### Parameters - -| Field | Type | Required | Constraints | Description | -|-------|------|----------|-------------|-------------| -| `jsonrpc` | String | ✅ Yes | Must be "2.0" | JSON-RPC protocol version | -| `params` | Object | ✅ Yes | - | Parameter container (Odoo convention) | -| `params.event_uid` | String (UUID) | ✅ Yes | Valid UUID format | Unique event identifier for idempotency | -| `params.event_type` | String (Enum) | ✅ Yes | See Event Types below | Type of event | -| `params.device_id` | String | ✅ Yes | Must exist in `mqtt.device` | Device identifier | -| `params.session_id` | String | ⚠️ Conditional | Required for session events | Session identifier | -| `params.timestamp` | String (ISO 8601) | ✅ Yes | Valid ISO 8601 format | Event timestamp (UTC) | -| `params.payload` | Object | ❌ Optional | Valid JSON | Device-specific data | - ---- - -## Event Types - -### 1. `device_online` - -**Purpose:** Notify Odoo that a device came online (MQTT connection established) - -**Effect:** -- Sets `mqtt.device.state = 'idle'` -- Updates `mqtt.device.last_message_time` -- Creates `iot.event` record (type: device_online) - -**Example Request:** -```json +Example Response (Duplicate): +``` { - "jsonrpc": "2.0", - "params": { - "event_uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "event_type": "device_online", - "device_id": "shellypmminig3-48f6eeb73a1c", - "timestamp": "2026-02-15T10:00:00.000000Z", - "payload": {} - } + "status": "duplicate", + "event_id": 42, + "event_uid": "550e8400-e29b-41d4-a716-446655440000", + "message": "Event already processed" } ``` -**Example Response (201 Created):** -```json -{ - "status": "success", - "event_id": 42, - "event_uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "event_type": "device_online", - "timestamp": "2026-02-15T10:00:00.123456" -} +See Also: +``` +- API.md: Complete API documentation with all event types +- models/mqtt_device.py: Device model with state field +- models/mqtt_session.py: Session model +- _process_event(): Internal event processing logic ``` -**Use Case:** Bridge detects first MQTT message from device after startup or reconnection +##### Method: `_process_event()` ---- +Process IoT event: create or update mqtt.session -### 2. `device_offline` - -**Purpose:** Notify Odoo that a device went offline (MQTT timeout or disconnect) - -**Effect:** -- Sets `mqtt.device.state = 'offline'` -- Updates `mqtt.device.last_message_time` -- Creates `iot.event` record (type: device_offline) -- If session active: Auto-stops session (calls session_stopped logic) - -**Example Request:** -```json -{ - "jsonrpc": "2.0", - "params": { - "event_uid": "a8d3c4f1-9b2e-4d7c-8a1e-2f3b4c5d6e7f", - "event_type": "device_offline", - "device_id": "shellypmminig3-48f6eeb73a1c", - "timestamp": "2026-02-15T12:00:00.000000Z", - "payload": {} - } -} +Args: ``` - -**Example Response (201 Created):** -```json -{ - "status": "success", - "event_id": 43, - "event_uid": "a8d3c4f1-9b2e-4d7c-8a1e-2f3b4c5d6e7f", - "event_type": "device_offline", - "timestamp": "2026-02-15T12:00:00.234567" -} -``` - -**Use Case:** Bridge detected message timeout (20s default) or MQTT disconnect - ---- - -### 3. `session_started` - -**Purpose:** Notify Odoo that a work session started (power crossed working threshold) - -**Effect:** -- Sets `mqtt.device.state = 'active'` -- Updates `mqtt.device.last_message_time` -- Creates `mqtt.session` record with: - - `session_id` (external identifier from Bridge) - - `start_time` (from event timestamp) - - `device_id` (link to mqtt.device) -- Creates `iot.event` record (type: session_started) - -**Example Request:** -```json -{ - "jsonrpc": "2.0", - "params": { - "event_uid": "b9e4d5f2-0c3f-5e8d-9b2f-3a4c5d6e7f8g", - "event_type": "session_started", - "device_id": "shellypmminig3-48f6eeb73a1c", - "session_id": "session-1739624845-abc123", - "timestamp": "2026-02-15T10:15:30.000000Z", - "payload": { - "power_w": 125.5, - "state": "working", - "voltage": 230.2 - } - } -} -``` - -**Example Response (201 Created):** -```json -{ - "status": "success", - "event_id": 44, - "event_uid": "b9e4d5f2-0c3f-5e8d-9b2f-3a4c5d6e7f8g", - "event_type": "session_started", - "session_id": "session-1739624845-abc123", - "timestamp": "2026-02-15T10:15:30.345678" -} -``` - -**Validation:** -- `session_id` must be provided -- Device must exist and be online/idle (not already in session) - -**Use Case:** Bridge's session detector confirms power > working_threshold_w for start_debounce_s duration - ---- - -### 4. `session_stopped` - -**Purpose:** Notify Odoo that a work session ended (power dropped below standby threshold) - -**Effect:** -- Sets `mqtt.device.state = 'idle'` -- Updates `mqtt.device.last_message_time` -- Updates `mqtt.session` record: - - Sets `end_time` (from event timestamp) - - Calculates `duration` (end_time - start_time) - - Updates statistics from payload (avg_power_w, peak_power_w, etc.) -- Creates `iot.event` record (type: session_stopped) - -**Example Request:** -```json -{ - "jsonrpc": "2.0", - "params": { - "event_uid": "c0f5e6g3-1d4g-6f9e-0c3g-4b5d6e7f8g9h", - "event_type": "session_stopped", - "device_id": "shellypmminig3-48f6eeb73a1c", - "session_id": "session-1739624845-abc123", - "timestamp": "2026-02-15T11:45:30.000000Z", - "payload": { - "power_w": 3.2, - "state": "idle", - "total_duration_s": 5400, - "avg_power_w": 118.3, - "peak_power_w": 145.7, - "total_energy_wh": 177.45 - } - } -} -``` - -**Example Response (201 Created):** -```json -{ - "status": "success", - "event_id": 45, - "event_uid": "c0f5e6g3-1d4g-6f9e-0c3g-4b5d6e7f8g9h", - "event_type": "session_stopped", - "session_id": "session-1739624845-abc123", - "timestamp": "2026-02-15T11:45:30.456789" -} -``` - -**Validation:** -- `session_id` must be provided -- Session must exist in `mqtt.session` with matching `session_id` -- Session must not already be stopped (end_time = False) - -**Use Case:** Bridge's session detector confirms power < standby_threshold_w for stop_debounce_s duration - ---- - -### 5. `session_heartbeat` - -**Purpose:** Periodic update during active session (aggregated power statistics) - -**Effect:** -- Updates `mqtt.device.last_message_time` -- Updates `mqtt.session` statistics: - - `avg_power_w` (running average) - - `peak_power_w` (maximum seen) - - `total_energy_wh` (accumulated) - - `message_count` (number of MQTT messages) -- Creates `iot.event` record (type: session_heartbeat) - -**Example Request:** -```json -{ - "jsonrpc": "2.0", - "params": { - "event_uid": "d1g6f7h4-2e5h-7g0f-1d4h-5c6e7f8g9h0i", - "event_type": "session_heartbeat", - "device_id": "shellypmminig3-48f6eeb73a1c", - "session_id": "session-1739624845-abc123", - "timestamp": "2026-02-15T10:20:30.000000Z", - "payload": { - "power_w": 110.3, - "state": "working", - "avg_power_w": 115.2, - "peak_power_w": 130.1, - "total_energy_wh": 9.6, - "message_count": 60 - } - } -} -``` - -**Example Response (201 Created):** -```json -{ - "status": "success", - "event_id": 46, - "event_uid": "d1g6f7h4-2e5h-7g0f-1d4h-5c6e7f8g9h0i", - "event_type": "session_heartbeat", - "session_id": "session-1739624845-abc123", - "timestamp": "2026-02-15T10:20:30.567890" -} -``` - -**Frequency:** Configured via `session_config.heartbeat_interval_s` (default: 300s) - -**Use Case:** Bridge sends periodic updates to track long-running sessions without creating too many events - ---- - -### 6. `session_timeout` - -**Purpose:** Session terminated due to MQTT message timeout (device went silent) - -**Effect:** Same as `session_stopped` but indicates abnormal termination - -**Example Request:** -```json -{ - "jsonrpc": "2.0", - "params": { - "event_uid": "e2h7g8i5-3f6i-8h1g-2e5i-6d7f8g9h0i1j", - "event_type": "session_timeout", - "device_id": "shellypmminig3-48f6eeb73a1c", - "session_id": "session-1739624845-abc123", - "timestamp": "2026-02-15T11:30:00.000000Z", - "payload": { - "timeout_duration_s": 20, - "last_power_w": 95.3 - } - } -} -``` - -**Use Case:** No MQTT messages received for `message_timeout_s` duration (default: 20s) during active session - ---- - -## Error Responses - -### 400 Bad Request - Missing Required Fields - -```json -{ - "status": "error", - "error": "Missing required fields: event_uid, device_id", - "code": 400 -} -``` - -**Cause:** One or more required parameters missing from `params` object - ---- - -### 404 Not Found - Device Not Found - -```json -{ - "status": "error", - "error": "Device not found: unknown-device-123", - "code": 404 -} -``` - -**Cause:** `device_id` does not exist in `mqtt.device` table - -**Solution:** Create device in Odoo first, config will be pushed to Bridge - ---- - -### 409 Conflict - Duplicate Event - -```json -{ - "status": "duplicate", - "event_id": 42, - "event_uid": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "message": "Event already processed" -} -``` - -**Cause:** `event_uid` already exists in `iot.event` table - -**Behavior:** Idempotent - original event_id returned, no changes made - -**Use Case:** Bridge retry after network failure - ---- - -### 500 Internal Server Error - Processing Failed - -```json -{ - "status": "error", - "error": "Exception message here", - "code": 500 -} -``` - -**Cause:** Unexpected error during event processing (check Odoo logs) - -**Debugging:** -```bash -docker logs odoo-dev | grep "iot_api.py" | tail -20 +event: ows.iot.event record ``` --- -## Idempotency +## Models -The API is **idempotent via `event_uid`**: +### 📄 iot_event.py -✅ **Same `event_uid` submitted twice:** -- First request: 201 Created (event processed) -- Second request: 409 Conflict (duplicate detected, original event_id returned) -- No side effects on second request (no state changes) +**File:** `models/iot_event.py` -**Why it matters:** -- Bridge can safely retry failed requests (network errors, timeouts) -- No duplicate events in database -- No double-counting of sessions/statistics +#### Class: `IotEvent` -**Implementation:** -```python -# In controllers/iot_api.py -existing_event = request.env['iot.event'].sudo().search([ - ('event_uid', '=', event_uid) -], limit=1) +IoT Event Log - Records all events received from IoT Bridge. -if existing_event: - return { - 'status': 'duplicate', - 'event_id': existing_event.id, - 'event_uid': event_uid, - 'message': 'Event already processed' - } +This model stores every event submitted via POST /ows/iot/event endpoint. +Events are immutable (readonly=True) and serve as audit trail for device +activity and session detection. + +Idempotency: +``` +All events are uniquely identified by event_uid (UUID). Duplicate +submissions with same event_uid are rejected with 409 Conflict, +ensuring Bridge can safely retry failed requests. +``` + +Event Types: +``` +- device_online: Device connected to MQTT → state='idle' +- device_offline: Device disconnected → state='offline' +- session_started: Work session detected → state='active', creates ows.mqtt.session +- session_stopped: Session ended → state='idle', finalizes ows.mqtt.session +- session_heartbeat: Periodic update (300s) → updates session stats +- session_timeout: Session lost due to message timeout → abnormal end +``` + +Event Flow: +``` +1. Bridge detects MQTT message pattern +2. Bridge runs session detection state machine +3. Bridge sends event via POST /ows/iot/event (JSON-RPC 2.0) +4. Odoo creates iot.event record (this model) +5. Odoo processes event (_process_event in controllers/iot_api.py) +6. Odoo updates ows.mqtt.device and/or ows.mqtt.session +``` + +Payload Structure: +``` +The payload field (JSON) contains device-specific data: +- power_w: Current power consumption (watts) +- voltage: Supply voltage +- state: Bridge state (idle/starting/working/stopping) +- avg_power_w: Average power for session +- peak_power_w: Peak power for session +- total_energy_wh: Accumulated energy (watt-hours) +- total_duration_s: Session duration (seconds) +- message_count: Number of MQTT messages +``` + +Related Models: +``` +- ows.mqtt.device: Device that generated this event +- ows.mqtt.session: Session associated with this event (for session events) +``` + +Example: +``` +# Query recent events for device +events = env['ows.iot.event'].search([ + ('device_id', '=', 'shellypmminig3-xxx'), + ('timestamp', '>', '2026-02-15 00:00:00') +], order='timestamp desc') +``` + +``` +# Check event payload +payload = events[0].get_payload_dict() +print(f"Power: {payload.get('power_w')}W") +``` + +SQL Constraints: +``` +- event_uid_unique: Prevents duplicate events (enforces idempotency) +``` + +Fields Overview: +``` +- event_uid: Unique UUID from Bridge (indexed for fast lookup) +- event_type: Type of event (session_started, device_online, etc.) +- device_id: Device identifier (string, not Many2one for flexibility) +- session_id: Session identifier (optional, for session events) +- timestamp: Event timestamp from Bridge (UTC) +- payload: JSON data with device-specific measurements +- create_date: Odoo record creation time +``` + +##### Method: `create()` + +Create event and auto-link to ows.mqtt.device and ows.mqtt.session + +##### Method: `get_payload_dict()` + +Return payload as Python dict + +##### Method: `mark_processed()` + +Mark event as processed (or failed) + +--- + +### 📄 mqtt_device.py + +**File:** `models/mqtt_device.py` + +#### Class: `MqttDevice` + +MQTT IoT Device Model - Represents a physical device monitored via MQTT. + +This model stores device configuration and current state for IoT devices +monitored through the Bridge microservice. It supports automatic config +push to the Bridge when devices are created, updated, or deleted. + +State Machine: +``` +offline → idle (device_online event) +idle → active (session_started event) +active → idle (session_stopped event) +idle → offline (device_offline event) +``` + +Config Push Architecture: +``` +- CREATE: Automatically pushes new device config to Bridge +- WRITE: Pushes updated config when relevant fields change +- UNLINK: Removes device from Bridge config +- Manual: User can click "Push Config to Bridge" button +``` + +Related Models: +``` +- mqtt.session: Work sessions detected by this device +- iot.event: Event history for this device +``` + +Bridge Integration: +``` +Configuration is pushed to Bridge via POST /config endpoint. +Bridge URL is configured in system parameter: open_workshop_mqtt.bridge_url +``` + +Example: +``` +device = env['mqtt.device'].create({ + 'name': 'CNC Mill Workshop 1', + 'device_id': 'shellypmminig3-48f6eeb73a1c', + 'topic_pattern': 'shellypmminig3-48f6eeb73a1c/status/pm1:0', + 'parser_type': 'shelly_pm', + 'standby_threshold_w': 5.0, + 'working_threshold_w': 50.0, +}) +# Config automatically pushed to Bridge +``` + +Fields Overview: +``` +- name: Human-readable device name +- 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 (shelly_pm, tasmota, etc.) +- Session thresholds: standby_threshold_w, working_threshold_w +- Session timing: start_debounce_s, stop_debounce_s, message_timeout_s +- Statistics: last_message_time, session_count +``` + +##### Method: `write()` + +Auto-subscribe when device is added to running connection +or when topic_pattern changes + +DEPRECATED: Auto-subscribe disabled - use standalone IoT Bridge container + +##### Method: `action_view_sessions()` + +Open sessions view for this device + +##### Method: `action_start_manual_session()` + +Manually start a session + +##### Method: `action_stop_manual_session()` + +Manually stop a session + +##### Method: `get_strategy_config_dict()` + +Parse strategy_config JSON and return as dict + +##### Method: `create()` + +Override create to push config to bridge after device creation + +##### Method: `write()` + +Override write to push config to bridge after device update + +##### Method: `unlink()` + +Override unlink to push config to bridge after device deletion + +##### Method: `action_push_config_to_bridge()` + +Manual action to push config to bridge. +Called from UI button. + +--- + +### 📄 mqtt_session.py + +**File:** `models/mqtt_session.py` + +#### Class: `MqttSession` + +MQTT Device Work Session - Represents a detected work session from IoT device. + +Sessions are automatically created by the IoT Bridge when power consumption +crosses the working threshold and are finalized when power drops below +standby threshold. The Bridge implements a 5-state state machine with +debounce timers to avoid false triggers. + +Session Lifecycle: +``` +1. IDLE: Device idle, power < standby_threshold_w +2. STARTING: Power rising, start_debounce_s timer active +3. WORKING: Power > working_threshold_w, session active +4. STOPPING: Power falling, stop_debounce_s timer active +5. IDLE: Session ended, record finalized +``` + +Events that create/update sessions: +``` +- session_started: Creates new session, sets start_time +- session_heartbeat: Updates statistics during session (every 300s) +- session_stopped: Finalizes session, sets end_time and duration +- session_timeout: Abnormal termination (no MQTT messages) +``` + +Statistics Tracked: +``` +- Duration: Total session time (end_time - start_time) +- Energy: Total energy consumed in Wh (avg_power_w * duration) +- Power: Average and peak power consumption in watts +- Messages: Number of MQTT messages received during session +``` + +Related Models: +``` +- mqtt.device: Parent device that generated this session +- iot.event: Individual events during this session +``` + +Example: +``` +# Created automatically by Bridge via session_started event +session = env['mqtt.session'].search([ + ('device_id.device_id', '=', 'shellypmminig3-xxx'), + ('end_time', '=', False) # Active session +]) +``` + +``` +# Statistics available +print(f"Duration: {session.duration_hours}h") +print(f"Energy: {session.total_energy_wh}Wh") +print(f"Avg Power: {session.avg_power_w}W") +``` + +Fields Overview: +``` +- session_id: Unique identifier from Bridge +- device_id: Link to mqtt.device +- start_time/end_time: Session timestamps +- duration: Calculated in seconds/minutes/hours +- start_power_w/end_power_w: Power at start/end +- avg_power_w/peak_power_w: Power statistics +- total_energy_wh: Accumulated energy +- status: running/completed/timeout +- message_count: Number of MQTT messages +``` + +##### Method: `get_metadata_dict()` + +Parse metadata JSON and return as dict + +##### Method: `create_or_update_session()` + +Create or update session from MQTT event + +Args: +``` +session_data: Dict with session information + - session_id: str (required) + - device_id: int (required, Odoo ID) + - event_type: 'session_start' | 'session_end' + - start_time: datetime + - end_time: datetime (for session_end) + - total_duration_s: int + - standby_duration_s: int + - working_duration_s: int + - start_power_w: float + - end_power_w: float + - end_reason: str + - metadata: dict (optional) +``` + +Returns: +``` +mqtt.session record +``` + +##### Method: `get_running_sessions()` + +Get all running sessions, optionally filtered by device + +Args: +``` +device_id: int (optional) - Filter by device ID +``` + +Returns: +``` +List of session dicts for state recovery ``` --- -## State Machine: Device States - -``` -offline ──device_online──> idle ──session_started──> active - ▲ ▲ │ - │ │ │ - └────device_offline──────┴───────session_stopped────┘ -``` - -| State | Description | Triggered By | -|-------|-------------|--------------| -| `offline` | Device not connected to MQTT | Initial state, device_offline event | -| `idle` | Device online but not working | device_online, session_stopped events | -| `active` | Device online and session active | session_started event | - -**Note:** session_timeout also transitions active → idle (same as session_stopped) - --- -## Testing +*Generated from source code docstrings* -### Manual Test (curl) - -```bash -# 1. Device comes online -curl -X POST http://localhost:9018/ows/iot/event \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "params": { - "event_uid": "550e8400-e29b-41d4-a716-446655440001", - "event_type": "device_online", - "device_id": "testshelly-simulator", - "timestamp": "2026-02-15T10:00:00Z", - "payload": {} - } - }' - -# 2. Session starts -curl -X POST http://localhost:9018/ows/iot/event \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "params": { - "event_uid": "550e8400-e29b-41d4-a716-446655440002", - "event_type": "session_started", - "device_id": "testshelly-simulator", - "session_id": "test-session-001", - "timestamp": "2026-02-15T10:05:00Z", - "payload": {"power_w": 120.0} - } - }' - -# 3. Heartbeat -curl -X POST http://localhost:9018/ows/iot/event \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "params": { - "event_uid": "550e8400-e29b-41d4-a716-446655440003", - "event_type": "session_heartbeat", - "device_id": "testshelly-simulator", - "session_id": "test-session-001", - "timestamp": "2026-02-15T10:10:00Z", - "payload": {"power_w": 125.5, "avg_power_w": 122.8} - } - }' - -# 4. Session stops -curl -X POST http://localhost:9018/ows/iot/event \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "params": { - "event_uid": "550e8400-e29b-41d4-a716-446655440004", - "event_type": "session_stopped", - "device_id": "testshelly-simulator", - "session_id": "test-session-001", - "timestamp": "2026-02-15T11:00:00Z", - "payload": {"power_w": 3.5, "total_duration_s": 3300} - } - }' -``` - -### Automated Tests - -```bash -cd iot_bridge/tests -python3 test_e2e_push_architecture.py - -# Expected output: -# Device Status Monitoring.......................... ✓ PASSED -``` - -**Test Coverage:** -- ✅ JSON-RPC 2.0 format validation -- ✅ event_uid requirement -- ✅ All event types (online, offline, session_started, stopped, heartbeat) -- ✅ State transitions (offline → idle → active → idle → offline) -- ✅ Idempotency (duplicate event_uid returns 409) - ---- - -## Monitoring & Observability - -### Odoo Logs - -```bash -# View all IoT API calls -docker logs odoo-dev | grep "iot_api.py" - -# View only errors -docker logs odoo-dev | grep "ERROR.*iot_api" - -# View specific device events -docker logs odoo-dev | grep "shellypmminig3-48f6eeb73a1c" -``` - -### Database Queries - -```sql --- Latest events -SELECT - id, event_uid, event_type, device_id, - timestamp, create_date -FROM iot_event -ORDER BY timestamp DESC -LIMIT 10; - --- Device states -SELECT - device_id, machine_name, state, - last_message_time -FROM mqtt_device; - --- Active sessions -SELECT - id, session_id, device_id, - start_time, end_time, duration, - avg_power_w, peak_power_w -FROM mqtt_session -WHERE end_time IS NULL; - --- Recent sessions -SELECT - id, session_id, device_id, - start_time, end_time, duration, - avg_power_w, total_energy_wh -FROM mqtt_session -ORDER BY start_time DESC -LIMIT 10; -``` - -### Odoo UI - -Navigate to: **IoT Bridge → Events** / **Sessions** / **Devices** - ---- - -## Rate Limiting - -**Current:** No rate limiting implemented - -**Recommendation for Production:** -- Implement rate limiting in nginx/reverse proxy -- Limit: 100 events/second per device_id -- Burst: 200 events/second -- Block excessive retry storms from misconfigured Bridge - ---- - -## Changelog - -| Version | Date | Changes | -|---------|------|---------| -| 1.0.0 | 2026-02-15 | Initial implementation with JSON-RPC 2.0, event_uid for idempotency, state transitions | - ---- - -## Related Documentation - -- [Bridge API Documentation](../../../iot_bridge/API.md) - Config push endpoint (Odoo → Bridge) -- [DOCUMENTATION_STRATEGY.md](../../../DOCUMENTATION_STRATEGY.md) - Overall API documentation approach -- [IMPLEMENTATION_PLAN.md](../../../IMPLEMENTATION_PLAN.md) - Project architecture and design decisions -- [README.md](../../../iot_bridge/README.md) - Bridge deployment and testing - ---- - -**Maintainer:** Open Workshop Team -**Contact:** lotz.matthias@gmail.com -**Last Updated:** 2026-02-15 +*Run `./build_docs.sh` to regenerate after code changes* \ No newline at end of file diff --git a/extra-addons/open_workshop/open_workshop_mqtt/README.md b/extra-addons/open_workshop/open_workshop_mqtt/README.md new file mode 100644 index 0000000..fbe5615 --- /dev/null +++ b/extra-addons/open_workshop/open_workshop_mqtt/README.md @@ -0,0 +1,151 @@ +# Open Workshop MQTT + +**IoT Device Integration with MQTT** + +## Overview + +This module provides seamless integration between Odoo 18 and IoT devices via MQTT protocol. It enables real-time monitoring of industrial equipment with intelligent work session detection based on power consumption patterns. + +## Key Features + +### Real-time Device Monitoring +Track IoT devices (Shelly PM, Tasmota) via MQTT with automatic status updates. + +### Intelligent Session Detection +Automatic work session detection using configurable power thresholds and debounce timers. Session Detection is implemented in the IoT Bridge microservice, which sends events to Odoo when sessions start/stop. + +### Device State Management +Track device states: offline → idle → active → idle → offline + +### REST API +JSON-RPC 2.0 API for event submission from IoT Bridge with idempotency support. + +### Automatic Config Push +Bridge receives configuration updates instantly when devices are created/modified. + +### Session Analytics +Track average power, peak power, duration, and total energy for each work session. + +## Architecture + +The module works in conjunction with the IoT Bridge microservice: + +1. **IoT Device** publishes MQTT messages (power, voltage, state) +2. **IoT Bridge** subscribes to topics, detects sessions, sends events to Odoo +3. **Odoo Module** receives events via REST API, tracks devices/sessions +4. **Automatic Config Push** updates Bridge when devices change + +## Installation + +1. Copy the module to your Odoo addons directory +2. Update the apps list +3. Install `Open Workshop MQTT` module +4. Configure system parameter: `open_workshop_mqtt.bridge_url` (e.g., http://iot-bridge:8080) +5. Deploy IoT Bridge microservice (Docker Compose recommended) + +### Command Line Installation + +```bash +# Install via command line +./odoo-bin -d DATABASE -i open_workshop_mqtt --stop-after-init + +# Or via Odoo Apps UI +Settings → Apps → Update Apps List → Search "mqtt" → Install +``` + +## Configuration + +### 1. Bridge Connection + +Set system parameter for Bridge URL: + +``` +Key: open_workshop_mqtt.bridge_url +Value: http://iot-bridge:8080 +``` + +### 2. Create Device + +Add IoT device: +- Device ID: `shellypmminig3-xxx` +- MQTT Topic: `shellypmminig3-xxx/status/pm1:0` +- Parser: `shelly_pm` +- Thresholds: Standby 5W, Working 50W + +> **⚠️ Important:** Config is automatically pushed to Bridge on device create/update/delete. No manual sync required! + +## Usage + +### Device Management +Navigate to **IoT Bridge → Devices** + +- View list of configured devices +- Check current state (offline/idle/active) +- See last message timestamp +- Manual config push button + +### Session Monitoring +Navigate to **IoT Bridge → Sessions** + +- View work sessions with start/end times +- See duration and energy consumption +- Track average and peak power +- Filter by device or date range + +### Event History +Navigate to **IoT Bridge → Events** + +- View all received events (device_online, session_started, etc.) +- Inspect event payloads (JSON) +- Check event processing status + +## API Reference + +### POST /ows/iot/event + +Submit IoT event from Bridge (JSON-RPC 2.0) + +```json +{ + "jsonrpc": "2.0", + "params": { + "event_uid": "550e8400-e29b-41d4-a716-446655440000", + "event_type": "session_started", + "device_id": "shelly-pm-001", + "session_id": "abc-123", + "timestamp": "2026-02-15T10:30:45Z", + "payload": {"power_w": 125.5} + } +} +``` + +**Event Types:** device_online, device_offline, session_started, session_stopped, session_heartbeat, session_timeout + +For complete API documentation, see: **[API.md](API.md)** (auto-generated from source code) + +## Technical Details + +### Dependencies +- Odoo 18.0 +- Python 3.10+ +- IoT Bridge microservice + +### Database Models +- `ows.mqtt.device` - Device registry +- `ows.mqtt.session` - Work sessions +- `ows.iot.event` - Event history + +## Support & Documentation + +- **Module Documentation:** `README.md` +- **API Reference:** [API.md](API.md) (auto-generated from source code docstrings) +- **Implementation Plan:** `IMPLEMENTATION_PLAN.md` +- **Bridge Documentation:** `iot_bridge/README.md` + +*Note: To update documentation after code changes, run: `./build_docs.sh`* + +For issues and feature requests, please contact the development team. + +## License + +This module is licensed under MIT License. diff --git a/extra-addons/open_workshop/open_workshop_mqtt/build_docs.py b/extra-addons/open_workshop/open_workshop_mqtt/build_docs.py old mode 100644 new mode 100755 index bd4b14c..938823a --- a/extra-addons/open_workshop/open_workshop_mqtt/build_docs.py +++ b/extra-addons/open_workshop/open_workshop_mqtt/build_docs.py @@ -1,7 +1,19 @@ #!/usr/bin/env python3 """ Build Documentation Script -Generates HTML documentation from Python docstrings without importing modules + +Generates documentation for Odoo module: +1. Converts README.md → index.html (module overview for Odoo Apps UI) +2. Extracts Python docstrings → API.md (API reference in Markdown) + +No external dependencies - uses only Python stdlib. + +Usage: + python3 build_docs.py + +Output: + static/description/index.html (from README.md) + API.md (from Python docstrings) """ import os import sys @@ -12,12 +24,187 @@ from datetime import datetime # Paths MODULE_DIR = Path(__file__).parent -DOCS_OUTPUT = MODULE_DIR / "static" / "description" / "api_reference.html" +README_FILE = MODULE_DIR / "README.md" +INDEX_OUTPUT = MODULE_DIR / "static" / "description" / "index.html" +API_OUTPUT = MODULE_DIR / "API.md" + + +# ============================================================================ +# MARKDOWN → HTML CONVERTER (for index.html from README.md) +# ============================================================================ + +def markdown_to_html(md_content): + """Convert Markdown to HTML with Odoo styling.""" + lines = md_content.split('\n') + html_lines = [] + in_code_block = False + in_list = False + code_lang = "" + + for i, line in enumerate(lines): + # Code blocks + if line.startswith('```'): + if not in_code_block: + code_lang = line[3:].strip() + in_code_block = True + html_lines.append('
')
+            else:
+                in_code_block = False
+                html_lines.append('
') + continue + + if in_code_block: + html_lines.append(line.replace('&', '&').replace('<', '<').replace('>', '>')) + continue + + # Empty lines + if not line.strip(): + if in_list: + html_lines.append('' if isinstance(in_list, str) and in_list == 'ul' else '') + in_list = False + html_lines.append('') + continue + + # Headers + if line.startswith('# '): + html_lines.append(f'

{line[2:]}

') + elif line.startswith('## '): + html_lines.append(f'

{line[3:]}

') + elif line.startswith('### '): + html_lines.append(f'

{line[4:]}

') + elif line.startswith('#### '): + html_lines.append(f'

{line[5:]}

') + + # Ordered lists + elif re.match(r'^\d+\.\s', line): + if not in_list or in_list == 'ul': + if in_list: + html_lines.append('') + html_lines.append('
    ') + in_list = 'ol' + match = re.match(r'^\d+\.\s(.+)$', line) + html_lines.append(f'
  1. {format_inline(match.group(1))}
  2. ') + + # Unordered lists + elif line.startswith('- '): + if not in_list or in_list == 'ol': + if in_list: + html_lines.append('
') + html_lines.append('' if in_list == 'ul' else '') + in_list = False + html_lines.append(f'

{format_inline(line)}

') + + # Close open lists + if in_list: + html_lines.append('' if in_list == 'ul' else '') + + return '\n'.join(html_lines) + + +def format_inline(text): + """Format inline Markdown (bold, italic, code, links).""" + # Links [text](url) + text = re.sub(r'\[([^\]]+)\]\(([^\)]+)\)', r'\1', text) + + # Bold **text** + text = re.sub(r'\*\*([^\*]+)\*\*', r'\1', text) + + # Italic *text* + text = re.sub(r'\*([^\*]+)\*', r'\1', text) + + # Inline code `code` + text = re.sub(r'`([^`]+)`', r'\1', text) + + # Arrows → + text = text.replace('→', '→') + + return text + + +def build_index_html(): + """Convert README.md to index.html for Odoo Apps UI.""" + print("📄 Converting README.md to index.html...") + + if not README_FILE.exists(): + print(f"⚠️ README.md not found at {README_FILE}") + return False + + md_content = README_FILE.read_text(encoding='utf-8') + body_html = markdown_to_html(md_content) + + # Wrap in Odoo-styled HTML template + html = f""" + + + + Open Workshop MQTT - Odoo Module + + + +
+{body_html} +
+ +
+

+ Generated from README.md | Run ./build_docs.sh to update after changes +

+ + +""" + + INDEX_OUTPUT.parent.mkdir(parents=True, exist_ok=True) + INDEX_OUTPUT.write_text(html, encoding='utf-8') + + print(f"✅ index.html written: {INDEX_OUTPUT}") + print(f" File size: {INDEX_OUTPUT.stat().st_size / 1024:.1f} KB") + return True + + +# ============================================================================ +# PYTHON DOCSTRINGS → MARKDOWN (API.md) +# ============================================================================ def extract_docstring(node): """Extract docstring from AST node.""" return ast.get_docstring(node) or "" + def parse_python_file(file_path): """Parse Python file and extract class/function docstrings.""" try: @@ -62,31 +249,47 @@ def parse_python_file(file_path): return items -def docstring_to_html(docstring): - """Convert docstring to HTML with basic formatting.""" - # Escape HTML - html = docstring.replace('&', '&').replace('<', '<').replace('>', '>') - - # Code blocks (indented) - html = re.sub(r' (.+?)(?=\n(?! )|$)', r'
\1
', html, flags=re.DOTALL) - - # Inline code (backticks) - html = re.sub(r'`([^`]+)`', r'\1', html) - - # Bold (Args:, Returns:, etc.) - html = re.sub(r'^(Args?:|Returns?:|Raises?:|Example?:|Note?:|See Also?:|Side Effects?:|HTTP Status Codes?:|Protocol?:|Event Types?:|State Machine?:|Session Lifecycle?:|Statistics Tracked?:|Related Models?:|Bridge Integration?:|Config Push Architecture?:|Payload Structure?:|Event Flow?:|Idempotency?:|SQL Constraints?:|Fields Overview?:)(.*)$', - r'\1\2', html, flags=re.MULTILINE) - - # Paragraphs - html = '

' + html.replace('\n\n', '

') + '

' - - # Line breaks - html = html.replace('\n', '
') - - return html -def main(): - print("🔨 Building API documentation from docstrings...") +def docstring_to_markdown(docstring, indent_level=0): + """Convert Python docstring to clean Markdown.""" + lines = docstring.split('\n') + md_lines = [] + indent = ' ' * indent_level + in_code_block = False + + for i, line in enumerate(lines): + # Detect code blocks (indented 4 spaces in docstring) + is_code_line = line.startswith(' ') and line.strip() + + if is_code_line: + # Start code block if not already in one + if not in_code_block: + md_lines.append('```') + in_code_block = True + # Add code line without the 4-space indent + md_lines.append(line[4:]) + else: + # Close code block if we were in one + if in_code_block: + md_lines.append('```') + in_code_block = False + + # Regular text line + if line.strip(): + md_lines.append(indent + line.strip()) + else: + md_lines.append('') + + # Close code block if still open at end + if in_code_block: + md_lines.append('```') + + return '\n'.join(md_lines) + + +def build_api_markdown(): + """Extract docstrings from Python files and generate API.md.""" + print("📚 Building API.md from Python docstrings...") # Find Python files controllers_files = list((MODULE_DIR / "controllers").glob("*.py")) @@ -95,7 +298,6 @@ def main(): controllers_files = [f for f in controllers_files if f.name != '__init__.py'] models_files = [f for f in models_files if f.name != '__init__.py'] - print(f"📚 Parsing Python files...") print(f" Controllers: {len(controllers_files)} files") print(f" Models: {len(models_files)} files") @@ -115,148 +317,151 @@ def main(): if items: all_items['models'][file_path.stem] = items - # Build HTML - print("📝 Generating HTML...") - - html_parts = [] + # Build Markdown + md_lines = [] # Header - html_parts.append(f""" - - - - Open Workshop MQTT - API Reference - - - -
- ← Back to Module Overview -

API Reference

-

Auto-generated from Python docstrings
Last Updated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}

- -
-

Table of Contents

- -
- -

Controllers

""") + # Controllers + md_lines.append("## Controllers") + md_lines.append("") - # Controllers content for file_name in sorted(all_items['controllers'].keys()): items = all_items['controllers'][file_name] - html_parts.append(f'
') - html_parts.append(f'

📄 {file_name}.py (controllers/{file_name}.py)

') + md_lines.append(f"### 📄 {file_name}.py") + md_lines.append("") + md_lines.append(f"**File:** `controllers/{file_name}.py`") + md_lines.append("") for item in items: if item['type'] == 'class': - html_parts.append(f"

Class: {item['name']}

") - html_parts.append(f'
{docstring_to_html(item["docstring"])}
') + md_lines.append(f"#### Class: `{item['name']}`") + md_lines.append("") + md_lines.append(docstring_to_markdown(item['docstring'])) + md_lines.append("") if item['methods']: for method in item['methods']: - html_parts.append(f'
') - html_parts.append(f"Method: {method['name']}()") - html_parts.append(f'
{docstring_to_html(method["docstring"])}
') - html_parts.append('
') + md_lines.append(f"##### Method: `{method['name']}()`") + md_lines.append("") + md_lines.append(docstring_to_markdown(method['docstring'], indent_level=0)) + md_lines.append("") elif item['type'] == 'function': - html_parts.append(f"

Function: {item['name']}()

") - html_parts.append(f'
{docstring_to_html(item["docstring"])}
') + md_lines.append(f"#### Function: `{item['name']}()`") + md_lines.append("") + md_lines.append(docstring_to_markdown(item['docstring'])) + md_lines.append("") - html_parts.append('
') + md_lines.append("---") + md_lines.append("") - html_parts.append('

Models

') + # Models + md_lines.append("## Models") + md_lines.append("") - # Models content for file_name in sorted(all_items['models'].keys()): items = all_items['models'][file_name] - html_parts.append(f'
') - html_parts.append(f'

📄 {file_name}.py (models/{file_name}.py)

') + md_lines.append(f"### 📄 {file_name}.py") + md_lines.append("") + md_lines.append(f"**File:** `models/{file_name}.py`") + md_lines.append("") for item in items: if item['type'] == 'class': - html_parts.append(f"

Class: {item['name']}

") - html_parts.append(f'
{docstring_to_html(item["docstring"])}
') + md_lines.append(f"#### Class: `{item['name']}`") + md_lines.append("") + md_lines.append(docstring_to_markdown(item['docstring'])) + md_lines.append("") if item['methods']: for method in item['methods']: - if not method['name'].startswith('_'): # Skip private methods for brevity - html_parts.append(f'
') - html_parts.append(f"Method: {method['name']}()") - html_parts.append(f'
{docstring_to_html(method["docstring"])}
') - html_parts.append('
') + if not method['name'].startswith('_'): # Skip private methods + md_lines.append(f"##### Method: `{method['name']}()`") + md_lines.append("") + md_lines.append(docstring_to_markdown(method['docstring'], indent_level=0)) + md_lines.append("") elif item['type'] == 'function': - html_parts.append(f"

Function: {item['name']}()

") - html_parts.append(f'
{docstring_to_html(item["docstring"])}
') + md_lines.append(f"#### Function: `{item['name']}()`") + md_lines.append("") + md_lines.append(docstring_to_markdown(item['docstring'])) + md_lines.append("") - html_parts.append('
') + md_lines.append("---") + md_lines.append("") # Footer - html_parts.append(""" -
-

- - Generated from source code docstrings
- Run ./build_docs.sh to regenerate after code changes -
-

-
- - -""") + md_lines.append("---") + md_lines.append("") + md_lines.append("*Generated from source code docstrings*") + md_lines.append("") + md_lines.append("*Run `./build_docs.sh` to regenerate after code changes*") - # Write HTML - DOCS_OUTPUT.parent.mkdir(parents=True, exist_ok=True) - DOCS_OUTPUT.write_text('\n'.join(html_parts), encoding='utf-8') + # Write Markdown + API_OUTPUT.write_text('\n'.join(md_lines), encoding='utf-8') - print(f"✅ Documentation written to: {DOCS_OUTPUT}") - print(f" File size: {DOCS_OUTPUT.stat().st_size / 1024:.1f} KB") + print(f"✅ API.md written: {API_OUTPUT}") + print(f" File size: {API_OUTPUT.stat().st_size / 1024:.1f} KB") + return True + + +# ============================================================================ +# MAIN +# ============================================================================ + +def main(): + print("🔨 Building Documentation...") + print("=" * 50) - print("\n✨ Done! Documentation is now available in Odoo:") - print(" Settings → Apps → 'Open Workshop MQTT' → Click 'API Reference' link") + success = True - return 0 + # 1. Convert README.md → index.html + if not build_index_html(): + success = False + + print() + + # 2. Extract docstrings → API.md + if not build_api_markdown(): + success = False + + print() + print("=" * 50) + if success: + print("✨ Done! Documentation is now available:") + print(" • Odoo UI: Settings → Apps → 'Open Workshop MQTT'") + print(" • Developer: API.md in module root") + print() + print("📖 Files generated:") + print(f" • {INDEX_OUTPUT.relative_to(MODULE_DIR)} (from README.md)") + print(f" • {API_OUTPUT.relative_to(MODULE_DIR)} (from Python docstrings)") + return 0 + else: + print("⚠️ Some documentation could not be generated") + return 1 + if __name__ == "__main__": sys.exit(main()) diff --git a/extra-addons/open_workshop/open_workshop_mqtt/build_docs.sh b/extra-addons/open_workshop/open_workshop_mqtt/build_docs.sh index 936628b..f0c8091 100755 --- a/extra-addons/open_workshop/open_workshop_mqtt/build_docs.sh +++ b/extra-addons/open_workshop/open_workshop_mqtt/build_docs.sh @@ -1,10 +1,10 @@ #!/bin/bash # Build Documentation Wrapper Script -# Generates API documentation from Python docstrings +# Generates documentation from README.md and Python docstrings cd "$(dirname "$0")" -echo "🔨 Building API Documentation..." +echo "🔨 Building Documentation..." echo "==================================" # Run Python build script (no external dependencies needed) @@ -12,11 +12,7 @@ python3 build_docs.py if [ $? -eq 0 ]; then echo "" - echo "✅ Success! Documentation generated at:" - echo " static/description/api_reference.html" - echo "" - echo "📖 View in Odoo:" - echo " Settings → Apps → Search 'mqtt' → Open Workshop MQTT → API Reference" + echo "✅ Success!" else echo "❌ Build failed!" exit 1 diff --git a/extra-addons/open_workshop/open_workshop_mqtt/check_routes.py b/extra-addons/open_workshop/open_workshop_mqtt/check_routes.py deleted file mode 100644 index 907a144..0000000 --- a/extra-addons/open_workshop/open_workshop_mqtt/check_routes.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -import sys -import odoo -from odoo import api, SUPERUSER_ID - -odoo.tools.config.parse_config(['--database=odoo']) -with api.Environment.manage(): - with odoo.registry('odoo').cursor() as cr: - env = api.Environment(cr, SUPERUSER_ID, {}) - - # Get all routes - from odoo.http import root - - print("All registered routes containing 'iot' or 'ows':") - count = 0 - for rule in root.get_db_router('odoo').url_map.iter_rules(): - path = str(rule.rule) - if 'iot' in path.lower() or 'ows' in path.lower(): - print(f" {rule.methods} {path}") - count += 1 - - print(f"\nTotal: {count} routes found") - - if count == 0: - print("\n❌ No IoT routes found - controller not loaded!") - print("\nChecking if controllers module can be imported:") - try: - from odoo.addons.open_workshop_mqtt import controllers - print("✅ controllers module imported successfully") - from odoo.addons.open_workshop_mqtt.controllers import iot_api - print("✅ iot_api module imported successfully") - print(f"✅ Controller class: {iot_api.IotApiController}") - except Exception as e: - print(f"❌ Import failed: {e}") 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 16af8d1..3caa69e 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 @@ -58,7 +58,7 @@ class IotApiController(http.Controller): http.db_monodb = lambda force=False: db_name # Temporary override for this request # Get all active MQTT devices - devices = request.env['mqtt.device'].sudo().search([ + devices = request.env['ows.mqtt.device'].sudo().search([ ('active', '=', True) ]) @@ -128,11 +128,11 @@ class IotApiController(http.Controller): event_type (str): Type of event (required), one of: - 'device_online': Device came online → sets state='idle' - 'device_offline': Device went offline → sets state='offline' - - 'session_started': Work session started → sets state='active', creates mqtt.session - - 'session_stopped': Session ended → sets state='idle', finalizes mqtt.session + - 'session_started': Work session started → sets state='active', creates ows.mqtt.session + - 'session_stopped': Session ended → sets state='idle', finalizes ows.mqtt.session - 'session_heartbeat': Periodic update during session (every 300s default) - 'session_timeout': Session terminated due to message timeout - device_id (str): Device identifier, must exist in mqtt.device (required) + device_id (str): Device identifier, must exist in ows.mqtt.device (required) session_id (str): Session identifier (required for session_* events, optional otherwise) timestamp (str): ISO 8601 timestamp in UTC (required) payload (dict): Device-specific data (optional), may contain: @@ -156,14 +156,14 @@ class IotApiController(http.Controller): - 201 Created: Event successfully created and processed - 409 Conflict: Duplicate event_uid (idempotent, no changes made) - 400 Bad Request: Missing required fields or validation error - - 404 Not Found: device_id does not exist in mqtt.device + - 404 Not Found: device_id does not exist in ows.mqtt.device - 500 Internal Server Error: Unexpected processing error Side Effects: - Creates iot.event record - - May update mqtt.device.state (offline/idle/active) - - May create/update mqtt.session record - - Updates mqtt.device.last_message_time + - May update ows.mqtt.device.state (offline/idle/active) + - May create/update ows.mqtt.session record + - Updates ows.mqtt.device.last_message_time Example Request (JSON-RPC 2.0): { @@ -280,10 +280,10 @@ class IotApiController(http.Controller): try: payload = event.get_payload_dict() - # Find or create mqtt.session + # Find or create ows.mqtt.session session = None if event.session_id: - session = request.env['mqtt.session'].sudo().search([ + session = request.env['ows.mqtt.session'].sudo().search([ ('session_id', '=', event.session_id) ], limit=1) @@ -291,12 +291,12 @@ class IotApiController(http.Controller): if event.event_type == 'session_started': if not session: # Create new session - device = request.env['mqtt.device'].sudo().search([ + device = request.env['ows.mqtt.device'].sudo().search([ ('device_id', '=', event.device_id) ], limit=1) if device: - session = request.env['mqtt.session'].sudo().create({ + session = request.env['ows.mqtt.session'].sudo().create({ 'session_id': event.session_id, 'device_id': device.id, 'start_time': event.timestamp, @@ -372,7 +372,7 @@ class IotApiController(http.Controller): session.write(end_vals) # Update device state back to idle (online but no active session) - device = request.env['mqtt.device'].sudo().search([ + device = request.env['ows.mqtt.device'].sudo().search([ ('device_id', '=', event.device_id) ], limit=1) if device: @@ -382,7 +382,7 @@ class IotApiController(http.Controller): elif event.event_type == 'device_online': # Device came online - update device status - device = request.env['mqtt.device'].sudo().search([ + device = request.env['ows.mqtt.device'].sudo().search([ ('device_id', '=', event.device_id) ], limit=1) @@ -397,7 +397,7 @@ class IotApiController(http.Controller): elif event.event_type == 'device_offline': # Device went offline - update device status - device = request.env['mqtt.device'].sudo().search([ + device = request.env['ows.mqtt.device'].sudo().search([ ('device_id', '=', event.device_id) ], limit=1) diff --git a/extra-addons/open_workshop/open_workshop_mqtt/models/iot_event.py b/extra-addons/open_workshop/open_workshop_mqtt/models/iot_event.py index d91cb04..2400815 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/models/iot_event.py +++ b/extra-addons/open_workshop/open_workshop_mqtt/models/iot_event.py @@ -28,8 +28,8 @@ class IotEvent(models.Model): Event Types: - device_online: Device connected to MQTT → state='idle' - device_offline: Device disconnected → state='offline' - - session_started: Work session detected → state='active', creates mqtt.session - - session_stopped: Session ended → state='idle', finalizes mqtt.session + - session_started: Work session detected → state='active', creates ows.mqtt.session + - session_stopped: Session ended → state='idle', finalizes ows.mqtt.session - session_heartbeat: Periodic update (300s) → updates session stats - session_timeout: Session lost due to message timeout → abnormal end @@ -39,7 +39,7 @@ class IotEvent(models.Model): 3. Bridge sends event via POST /ows/iot/event (JSON-RPC 2.0) 4. Odoo creates iot.event record (this model) 5. Odoo processes event (_process_event in controllers/iot_api.py) - 6. Odoo updates mqtt.device and/or mqtt.session + 6. Odoo updates ows.mqtt.device and/or ows.mqtt.session Payload Structure: The payload field (JSON) contains device-specific data: @@ -53,8 +53,8 @@ class IotEvent(models.Model): - message_count: Number of MQTT messages Related Models: - - mqtt.device: Device that generated this event - - mqtt.session: Session associated with this event (for session events) + - ows.mqtt.device: Device that generated this event + - ows.mqtt.session: Session associated with this event (for session events) Example: # Query recent events for device @@ -135,7 +135,7 @@ class IotEvent(models.Model): help='External device identifier from IoT Bridge' ) mqtt_device_id = fields.Many2one( - 'mqtt.device', + 'ows.mqtt.device', string='MQTT Device', readonly=True, index=True, @@ -150,7 +150,7 @@ class IotEvent(models.Model): help='Session identifier from IoT Bridge' ) mqtt_session_id = fields.Many2one( - 'mqtt.session', + 'ows.mqtt.session', string='MQTT Session', readonly=True, index=True, @@ -193,13 +193,13 @@ class IotEvent(models.Model): # ========== Compute Methods ========== def create(self, vals): - """Create event and auto-link to mqtt.device and mqtt.session""" + """Create event and auto-link to ows.mqtt.device and ows.mqtt.session""" event = super().create(vals) - # Try to link to mqtt.device by device_id + # Try to link to ows.mqtt.device by device_id if event.device_id and not event.mqtt_device_id: - # Look for mqtt.device with matching device_id in strategy_config - devices = self.env['mqtt.device'].search([]) + # Look for ows.mqtt.device with matching device_id in strategy_config + devices = self.env['ows.mqtt.device'].search([]) for device in devices: if device.strategy_config: try: @@ -210,9 +210,9 @@ class IotEvent(models.Model): except: pass - # Try to link to mqtt.session by session_id + # Try to link to ows.mqtt.session by session_id if event.session_id and not event.mqtt_session_id: - session = self.env['mqtt.session'].search([ + session = self.env['ows.mqtt.session'].search([ ('session_id', '=', event.session_id) ], limit=1) if session: 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 22c1b60..e86a5d5 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 @@ -57,7 +57,7 @@ class MqttDevice(models.Model): - Session timing: start_debounce_s, stop_debounce_s, message_timeout_s - Statistics: last_message_time, session_count """ - _name = 'mqtt.device' + _name = 'ows.mqtt.device' _description = 'MQTT Device' _rec_name = 'name' _order = 'name' @@ -143,7 +143,7 @@ class MqttDevice(models.Model): # ========== Relations ========== session_ids = fields.One2many( - 'mqtt.session', + 'ows.mqtt.session', 'device_id', string='Sessions', help='Runtime sessions for this device' diff --git a/extra-addons/open_workshop/open_workshop_mqtt/models/mqtt_session.py b/extra-addons/open_workshop/open_workshop_mqtt/models/mqtt_session.py index 08bd561..3c29ae3 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/models/mqtt_session.py +++ b/extra-addons/open_workshop/open_workshop_mqtt/models/mqtt_session.py @@ -65,7 +65,7 @@ class MqttSession(models.Model): - status: running/completed/timeout - message_count: Number of MQTT messages """ - _name = 'mqtt.session' + _name = 'ows.mqtt.session' _description = 'MQTT Device Session' _rec_name = 'session_id' _order = 'start_time desc' @@ -80,7 +80,7 @@ class MqttSession(models.Model): help='Unique session identifier' ) device_id = fields.Many2one( - 'mqtt.device', + 'ows.mqtt.device', string='Device', required=True, readonly=True, diff --git a/extra-addons/open_workshop/open_workshop_mqtt/security/ir.model.access.csv b/extra-addons/open_workshop/open_workshop_mqtt/security/ir.model.access.csv index 020ac1c..1d2c2d1 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/security/ir.model.access.csv +++ b/extra-addons/open_workshop/open_workshop_mqtt/security/ir.model.access.csv @@ -1,7 +1,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_mqtt_device_user,mqtt.device.user,model_mqtt_device,base.group_user,1,1,1,1 -access_mqtt_session_user,mqtt.session.user,model_mqtt_session,base.group_user,1,0,0,0 +access_mqtt_device_user,ows.mqtt.device.user,model_ows_mqtt_device,base.group_user,1,1,1,1 +access_mqtt_session_user,ows.mqtt.session.user,model_ows_mqtt_session,base.group_user,1,0,0,0 access_iot_event_user,ows.iot.event.user,model_ows_iot_event,base.group_user,1,0,0,0 -access_mqtt_device_system,mqtt.device.system,model_mqtt_device,base.group_system,1,1,1,1 -access_mqtt_session_system,mqtt.session.system,model_mqtt_session,base.group_system,1,1,1,1 +access_mqtt_device_system,ows.mqtt.device.system,model_ows_mqtt_device,base.group_system,1,1,1,1 +access_mqtt_session_system,ows.mqtt.session.system,model_ows_mqtt_session,base.group_system,1,1,1,1 access_iot_event_system,ows.iot.event.system,model_ows_iot_event,base.group_system,1,1,1,1 diff --git a/extra-addons/open_workshop/open_workshop_mqtt/static/description/api_reference.html b/extra-addons/open_workshop/open_workshop_mqtt/static/description/api_reference.html deleted file mode 100644 index 6c83e5a..0000000 --- a/extra-addons/open_workshop/open_workshop_mqtt/static/description/api_reference.html +++ /dev/null @@ -1,157 +0,0 @@ - - - - - Open Workshop MQTT - API Reference - - - -
- ← Back to Module Overview -

API Reference

-

Auto-generated from Python docstrings
Last Updated: 2026-02-15 10:53:26

- -
-

Table of Contents

- -
- -

Controllers

-
-

📄 iot_api.py (controllers/iot_api.py)

-

Class: IotApiController

-

REST API for IoT Bridge integration

-
-Method: get_iot_config() -

Get IoT device configuration for Bridge

Returns JSON with all active devices and their session configs

Example Response:
{

"devices": [
{
"device_id": "shellypmminig3-48f6eeb73a1c",
"mqtt_topic": "shaperorigin/status/pm1:0",
"parser_type": "shelly_pm_mini_g3",
"machine_name": "Shaper Origin",
"session_config": {
"strategy": "power_threshold",
"standby_threshold_w": 20,
"working_threshold_w": 100,
"start_debounce_s": 3,
"stop_debounce_s": 15,
"message_timeout_s": 20,
"heartbeat_interval_s": 300
}
}
]

}

-
-
-Method: receive_iot_event() -

Receive IoT event from Bridge and process device/session state changes.

This is the main API endpoint that receives events from the IoT Bridge microservice.
All events are idempotent via event_uid to prevent duplicate processing.

Protocol: JSON-RPC 2.0 (params must be wrapped in "params" object)

Args:

event_uid (str): Unique event identifier (UUID format, required)
Used for idempotency - duplicate submissions return 409 Conflict
event_type (str): Type of event (required), one of:
- 'device_online': Device came online → sets state='idle'
- 'device_offline': Device went offline → sets state='offline'
- 'session_started': Work session started → sets state='active', creates mqtt.session
- 'session_stopped': Session ended → sets state='idle', finalizes mqtt.session
- 'session_heartbeat': Periodic update during session (every 300s default)
- 'session_timeout': Session terminated due to message timeout
device_id (str): Device identifier, must exist in mqtt.device (required)
session_id (str): Session identifier (required for session_* events, optional otherwise)
timestamp (str): ISO 8601 timestamp in UTC (required)
payload (dict): Device-specific data (optional), may contain:
- power_w (float): Current power consumption in watts
- state (str): Device state from Bridge
- avg_power_w (float): Average power for session
- peak_power_w (float): Peak power for session
- total_energy_wh (float): Total energy consumed
- total_duration_s (int): Session duration in seconds
**kw: Additional JSON-RPC parameters (ignored)

Returns:

dict: Response dictionary with keys:
- status (str): 'success', 'duplicate', or 'error'
- event_id (int): Database ID of created/existing event
- event_uid (str): Echo of input event_uid
- timestamp (str): Processing timestamp
- message (str): Additional info (for duplicates/errors)

HTTP Status Codes:

- 201 Created: Event successfully created and processed
- 409 Conflict: Duplicate event_uid (idempotent, no changes made)
- 400 Bad Request: Missing required fields or validation error
- 404 Not Found: device_id does not exist in mqtt.device
- 500 Internal Server Error: Unexpected processing error

Side Effects:

- Creates iot.event record
- May update mqtt.device.state (offline/idle/active)
- May create/update mqtt.session record
- Updates mqtt.device.last_message_time

Example Request (JSON-RPC 2.0):

{
"jsonrpc": "2.0",
"params": {
"event_uid": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "session_started",
"device_id": "shellypmminig3-48f6eeb73a1c",
"session_id": "session-abc123",
"timestamp": "2026-02-15T10:30:45.123456Z",
"payload": {"power_w": 125.5, "state": "working"}
}
}

Example Response (Success):

{
"status": "success",
"event_id": 42,
"event_uid": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-02-15T10:30:45.234567"
}

Example Response (Duplicate):

{
"status": "duplicate",
"event_id": 42,
"event_uid": "550e8400-e29b-41d4-a716-446655440000",
"message": "Event already processed"
}

See Also:

- API.md: Complete API documentation with all event types
- models/mqtt_device.py: Device model with state field
- models/mqtt_session.py: Session model
- _process_event(): Internal event processing logic

-
-
-Method: _process_event() -

Process IoT event: create or update mqtt.session

Args:

event: ows.iot.event record

-
-
-

Models

-
-

📄 iot_event.py (models/iot_event.py)

-

Class: IotEvent

-

IoT Event Log - Records all events received from IoT Bridge.

This model stores every event submitted via POST /ows/iot/event endpoint.
Events are immutable (readonly=True) and serve as audit trail for device
activity and session detection.

Idempotency:

All events are uniquely identified by event_uid (UUID). Duplicate
submissions with same event_uid are rejected with 409 Conflict,
ensuring Bridge can safely retry failed requests.

Event Types:

- device_online: Device connected to MQTT → state='idle'
- device_offline: Device disconnected → state='offline'
- session_started: Work session detected → state='active', creates mqtt.session
- session_stopped: Session ended → state='idle', finalizes mqtt.session
- session_heartbeat: Periodic update (300s) → updates session stats
- session_timeout: Session lost due to message timeout → abnormal end

Event Flow:

1. Bridge detects MQTT message pattern
2. Bridge runs session detection state machine
3. Bridge sends event via POST /ows/iot/event (JSON-RPC 2.0)
4. Odoo creates iot.event record (this model)
5. Odoo processes event (_process_event in controllers/iot_api.py)
6. Odoo updates mqtt.device and/or mqtt.session

Payload Structure:

The payload field (JSON) contains device-specific data:
- power_w: Current power consumption (watts)
- voltage: Supply voltage
- state: Bridge state (idle/starting/working/stopping)
- avg_power_w: Average power for session
- peak_power_w: Peak power for session
- total_energy_wh: Accumulated energy (watt-hours)
- total_duration_s: Session duration (seconds)
- message_count: Number of MQTT messages

Related Models:

- mqtt.device: Device that generated this event
- mqtt.session: Session associated with this event (for session events)

Example:

# Query recent events for device
events = env['ows.iot.event'].search([
('device_id', '=', 'shellypmminig3-xxx'),
('timestamp', '>', '2026-02-15 00:00:00')
], order='timestamp desc')

# Check event payload
payload = events[0].get_payload_dict()
print(f"Power: {payload.get('power_w')}W")

SQL Constraints:

- event_uid_unique: Prevents duplicate events (enforces idempotency)

Fields Overview:

- event_uid: Unique UUID from Bridge (indexed for fast lookup)
- event_type: Type of event (session_started, device_online, etc.)
- device_id: Device identifier (string, not Many2one for flexibility)
- session_id: Session identifier (optional, for session events)
- timestamp: Event timestamp from Bridge (UTC)
- payload: JSON data with device-specific measurements
- create_date: Odoo record creation time

-
-Method: create() -

Create event and auto-link to mqtt.device and mqtt.session

-
-
-Method: get_payload_dict() -

Return payload as Python dict

-
-
-Method: mark_processed() -

Mark event as processed (or failed)

-
-
-
-

📄 mqtt_device.py (models/mqtt_device.py)

-

Class: MqttDevice

-

MQTT IoT Device Model - Represents a physical device monitored via MQTT.

This model stores device configuration and current state for IoT devices
monitored through the Bridge microservice. It supports automatic config
push to the Bridge when devices are created, updated, or deleted.

State Machine:

offline → idle (device_online event)
idle → active (session_started event)
active → idle (session_stopped event)
idle → offline (device_offline event)

Config Push Architecture:

- CREATE: Automatically pushes new device config to Bridge
- WRITE: Pushes updated config when relevant fields change
- UNLINK: Removes device from Bridge config
- Manual: User can click "Push Config to Bridge" button

Related Models:

- mqtt.session: Work sessions detected by this device
- iot.event: Event history for this device

Bridge Integration:

Configuration is pushed to Bridge via POST /config endpoint.
Bridge URL is configured in system parameter: open_workshop_mqtt.bridge_url

Example:

device = env['mqtt.device'].create({
'name': 'CNC Mill Workshop 1',
'device_id': 'shellypmminig3-48f6eeb73a1c',
'topic_pattern': 'shellypmminig3-48f6eeb73a1c/status/pm1:0',
'parser_type': 'shelly_pm',
'standby_threshold_w': 5.0,
'working_threshold_w': 50.0,
})
# Config automatically pushed to Bridge

Fields Overview:

- name: Human-readable device name
- 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 (shelly_pm, tasmota, etc.)
- Session thresholds: standby_threshold_w, working_threshold_w
- Session timing: start_debounce_s, stop_debounce_s, message_timeout_s
- Statistics: last_message_time, session_count

-
-Method: write() -

Auto-subscribe when device is added to running connection
or when topic_pattern changes

DEPRECATED: Auto-subscribe disabled - use standalone IoT Bridge container

-
-
-Method: action_view_sessions() -

Open sessions view for this device

-
-
-Method: action_start_manual_session() -

Manually start a session

-
-
-Method: action_stop_manual_session() -

Manually stop a session

-
-
-Method: get_strategy_config_dict() -

Parse strategy_config JSON and return as dict

-
-
-Method: create() -

Override create to push config to bridge after device creation

-
-
-Method: write() -

Override write to push config to bridge after device update

-
-
-Method: unlink() -

Override unlink to push config to bridge after device deletion

-
-
-Method: action_push_config_to_bridge() -

Manual action to push config to bridge.
Called from UI button.

-
-
-
-

📄 mqtt_session.py (models/mqtt_session.py)

-

Class: MqttSession

-

MQTT Device Work Session - Represents a detected work session from IoT device.

Sessions are automatically created by the IoT Bridge when power consumption
crosses the working threshold and are finalized when power drops below
standby threshold. The Bridge implements a 5-state state machine with
debounce timers to avoid false triggers.

Session Lifecycle:

1. IDLE: Device idle, power < standby_threshold_w
2. STARTING: Power rising, start_debounce_s timer active
3. WORKING: Power > working_threshold_w, session active
4. STOPPING: Power falling, stop_debounce_s timer active
5. IDLE: Session ended, record finalized

Events that create/update sessions:

- session_started: Creates new session, sets start_time
- session_heartbeat: Updates statistics during session (every 300s)
- session_stopped: Finalizes session, sets end_time and duration
- session_timeout: Abnormal termination (no MQTT messages)

Statistics Tracked:

- Duration: Total session time (end_time - start_time)
- Energy: Total energy consumed in Wh (avg_power_w * duration)
- Power: Average and peak power consumption in watts
- Messages: Number of MQTT messages received during session

Related Models:

- mqtt.device: Parent device that generated this session
- iot.event: Individual events during this session

Example:

# Created automatically by Bridge via session_started event
session = env['mqtt.session'].search([
('device_id.device_id', '=', 'shellypmminig3-xxx'),
('end_time', '=', False) # Active session
])

# Statistics available
print(f"Duration: {session.duration_hours}h")
print(f"Energy: {session.total_energy_wh}Wh")
print(f"Avg Power: {session.avg_power_w}W")

Fields Overview:

- session_id: Unique identifier from Bridge
- device_id: Link to mqtt.device
- start_time/end_time: Session timestamps
- duration: Calculated in seconds/minutes/hours
- start_power_w/end_power_w: Power at start/end
- avg_power_w/peak_power_w: Power statistics
- total_energy_wh: Accumulated energy
- status: running/completed/timeout
- message_count: Number of MQTT messages

-
-Method: get_metadata_dict() -

Parse metadata JSON and return as dict

-
-
-Method: create_or_update_session() -

Create or update session from MQTT event

Args:

session_data: Dict with session information
- session_id: str (required)
- device_id: int (required, Odoo ID)
- event_type: 'session_start' | 'session_end'
- start_time: datetime
- end_time: datetime (for session_end)
- total_duration_s: int
- standby_duration_s: int
- working_duration_s: int
- start_power_w: float
- end_power_w: float
- end_reason: str
- metadata: dict (optional)

Returns:

mqtt.session record

-
-
-Method: get_running_sessions() -

Get all running sessions, optionally filtered by device

Args:

device_id: int (optional) - Filter by device ID

Returns:

List of session dicts for state recovery

-
-
- -
-

- - Generated from source code docstrings
- Run ./build_docs.sh to regenerate after code changes -
-

-
- - diff --git a/extra-addons/open_workshop/open_workshop_mqtt/static/description/index.html b/extra-addons/open_workshop/open_workshop_mqtt/static/description/index.html index 0d4440f..e36cbc6 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/static/description/index.html +++ b/extra-addons/open_workshop/open_workshop_mqtt/static/description/index.html @@ -4,174 +4,149 @@ Open Workshop MQTT - Odoo Module -
-
-

Open Workshop MQTT

-

IoT Device Integration with Intelligent Session Detection

-
- Module Icon -
-
-
+
+

Open Workshop MQTT

-
-
-

Overview

-

- This module provides seamless integration between Odoo 18 and IoT devices via MQTT protocol. - It enables real-time monitoring of industrial equipment with intelligent work session detection - based on power consumption patterns. -

-
-
+

IoT Device Integration with MQTT

-
-
-

Key Features

-
-
-

📡 Real-time Device Monitoring

-

Track IoT devices (Shelly PM, Tasmota) via MQTT with automatic status updates.

-
-
-

🤖 Intelligent Session Detection

-

Automatic work session detection using configurable power thresholds and debounce timers.

-
-
-

📊 Device State Management

-

Track device states: offline → idle → active → idle → offline

-
-
-
-
-

🔌 REST API

-

JSON-RPC 2.0 API for event submission from IoT Bridge with idempotency support.

-
-
-

⚡ Automatic Config Push

-

Bridge receives configuration updates instantly when devices are created/modified.

-
-
-

📈 Session Analytics

-

Track average power, peak power, duration, and total energy for each work session.

-
-
-
-
+

Overview

-
-
-

Architecture

-
- Architecture Diagram -
-

- The module works in conjunction with the IoT Bridge microservice: -

-
    -
  1. IoT Device publishes MQTT messages (power, voltage, state)
  2. -
  3. IoT Bridge subscribes to topics, detects sessions, sends events to Odoo
  4. -
  5. Odoo Module receives events via REST API, tracks devices/sessions
  6. -
  7. Automatic Config Push updates Bridge when devices change
  8. -
-
-
+

This module provides seamless integration between Odoo 18 and IoT devices via MQTT protocol. It enables real-time monitoring of industrial equipment with intelligent work session detection based on power consumption patterns.

-
-
-

Installation

-

- 1. Copy the module to your Odoo addons directory
- 2. Update the apps list
- 3. Install Open Workshop MQTT module
- 4. Configure system parameter: open_workshop_mqtt.bridge_url (e.g., http://iot-bridge:8080)
- 5. Deploy IoT Bridge microservice (Docker Compose recommended) -

-
+

Key Features

+ +

Real-time Device Monitoring

+

Track IoT devices (Shelly PM, Tasmota) via MQTT with automatic status updates.

+ +

Intelligent Session Detection

+

Automatic work session detection using configurable power thresholds and debounce timers. Session Detection is implemented in the IoT Bridge microservice, which sends events to Odoo when sessions start/stop.

+ +

Device State Management

+

Track device states: offline → idle → active → idle → offline

+ +

REST API

+

JSON-RPC 2.0 API for event submission from IoT Bridge with idempotency support.

+ +

Automatic Config Push

+

Bridge receives configuration updates instantly when devices are created/modified.

+ +

Session Analytics

+

Track average power, peak power, duration, and total energy for each work session.

+ +

Architecture

+ +

The module works in conjunction with the IoT Bridge microservice:

+ +
    +
  1. IoT Device publishes MQTT messages (power, voltage, state)
  2. +
  3. IoT Bridge subscribes to topics, detects sessions, sends events to Odoo
  4. +
  5. Odoo Module receives events via REST API, tracks devices/sessions
  6. +
  7. Automatic Config Push updates Bridge when devices change
  8. +
+ +

Installation

+ +
    +
  1. Copy the module to your Odoo addons directory
  2. +
  3. Update the apps list
  4. +
  5. Install Open Workshop MQTT module
  6. +
  7. Configure system parameter: open_workshop_mqtt.bridge_url (e.g., http://iot-bridge:8080)
  8. +
  9. Deploy IoT Bridge microservice (Docker Compose recommended)
  10. +
+ +

Command Line Installation

+ +
 # Install via command line
 ./odoo-bin -d DATABASE -i open_workshop_mqtt --stop-after-init
 
 # Or via Odoo Apps UI
 Settings → Apps → Update Apps List → Search "mqtt" → Install
-            
-
-
+ -
-
-

Configuration

-
-

1. Bridge Connection

-

Set system parameter for Bridge URL:

-
Key: open_workshop_mqtt.bridge_url
-Value: http://iot-bridge:8080
-
-
-

2. Create Device

-

Add IoT device:

-
    -
  • Device ID: shellypmminig3-xxx
  • -
  • MQTT Topic: shellypmminig3-xxx/status/pm1:0
  • -
  • Parser: shelly_pm
  • -
  • Thresholds: Standby 5W, Working 50W
  • -
-
-
-
- ⚠️ Important: Config is automatically pushed to Bridge on device create/update/delete. - No manual sync required! -
-
+

Configuration

-
-
-

Usage

-

Device Management

-

Navigate to IoT Bridge → Devices

-
    -
  • View list of configured devices
  • -
  • Check current state (offline/idle/active)
  • -
  • See last message timestamp
  • -
  • Manual config push button
  • -
+

1. Bridge Connection

-

Session Monitoring

-

Navigate to IoT Bridge → Sessions

-
    -
  • View work sessions with start/end times
  • -
  • See duration and energy consumption
  • -
  • Track average and peak power
  • -
  • Filter by device or date range
  • -
+

Set system parameter for Bridge URL:

-

Event History

-

Navigate to IoT Bridge → Events

-
    -
  • View all received events (device_online, session_started, etc.)
  • -
  • Inspect event payloads (JSON)
  • -
  • Check event processing status
  • -
-
-
+
+Key: open_workshop_mqtt.bridge_url
+Value: http://iot-bridge:8080
+
-
-
-

API Reference

-

POST /ows/iot/event

-

Submit IoT event from Bridge (JSON-RPC 2.0)

-
+

2. Create Device

+ +

Add IoT device:

+
    +
  • Device ID: shellypmminig3-xxx
  • +
  • MQTT Topic: shellypmminig3-xxx/status/pm1:0
  • +
  • Parser: shelly_pm
  • +
  • Thresholds: Standby 5W, Working 50W
  • +
+ +
⚠️ Important: Config is automatically pushed to Bridge on device create/update/delete. No manual sync required!
+ +

Usage

+ +

Device Management

+

Navigate to IoT Bridge → Devices

+ +
    +
  • View list of configured devices
  • +
  • Check current state (offline/idle/active)
  • +
  • See last message timestamp
  • +
  • Manual config push button
  • +
+ +

Session Monitoring

+

Navigate to IoT Bridge → Sessions

+ +
    +
  • View work sessions with start/end times
  • +
  • See duration and energy consumption
  • +
  • Track average and peak power
  • +
  • Filter by device or date range
  • +
+ +

Event History

+

Navigate to IoT Bridge → Events

+ +
    +
  • View all received events (device_online, session_started, etc.)
  • +
  • Inspect event payloads (JSON)
  • +
  • Check event processing status
  • +
+ +

API Reference

+ +

POST /ows/iot/event

+ +

Submit IoT event from Bridge (JSON-RPC 2.0)

+ +
 {
   "jsonrpc": "2.0",
   "params": {
@@ -183,55 +158,50 @@ Value: http://iot-bridge:8080
"payload": {"power_w": 125.5} } } -
-

Event Types: device_online, device_offline, session_started, session_stopped, session_heartbeat, session_timeout

-

For full API documentation, see: API.md in module directory

-
-
+ -
-
-

Technical Details

-
-

Dependencies

-
    -
  • Odoo 18.0
  • -
  • Python 3.10+
  • -
  • IoT Bridge microservice
  • -
-
-
-

Database Models

-
    -
  • mqtt.device - Device registry
  • -
  • mqtt.session - Work sessions
  • -
  • iot.event - Event history
  • -
-
-
-
+

Event Types: device_online, device_offline, session_started, session_stopped, session_heartbeat, session_timeout

-
-
-

Support & Documentation

-
    -
  • Module Documentation: README.md
  • -
  • API Reference: 📚 View Auto-Generated API Documentation (from source code docstrings)
  • -
  • Implementation Plan: IMPLEMENTATION_PLAN.md
  • -
  • Bridge Documentation: iot_bridge/README.md
  • -
-

Note: To update API documentation after code changes, run: ./build_docs.sh

-

For issues and feature requests, please contact the development team.

-
-
+

For complete API documentation, see: API.md (auto-generated from source code)

-
-
-

License

-

- This module is licensed under MIT License. -

-
-
+

Technical Details

+ +

Dependencies

+ + +

Database Models

+ + +

Support & Documentation

+ + + +

Note: To update documentation after code changes, run: ./build_docs.sh

+ +

For issues and feature requests, please contact the development team.

+ +

License

+ +

This module is licensed under MIT License.

+ +
+ +
+

+ Generated from README.md | Run ./build_docs.sh to update after changes +

diff --git a/extra-addons/open_workshop/open_workshop_mqtt/test-direct.sh b/extra-addons/open_workshop/open_workshop_mqtt/test-direct.sh deleted file mode 100644 index 38e8a6e..0000000 --- a/extra-addons/open_workshop/open_workshop_mqtt/test-direct.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -# Direct Odoo Test - runs inside container -set -e - -DB="odoo" -MODULE="open_workshop_mqtt" - -echo "=== Running Odoo Tests for ${MODULE} in DB ${DB} ===" - -python3 /usr/bin/odoo \ - --addons-path=/mnt/extra-addons,/usr/lib/python3/dist-packages/odoo/addons \ - -d "$DB" \ - --db_host=hobbyhimmel_odoo_18-dev_db \ - --db_port=5432 \ - --db_user=odoo \ - --db_password=odoo \ - --test-enable \ - --stop-after-init \ - --load=base,web \ - -u "$MODULE" \ - --log-level=test diff --git a/extra-addons/open_workshop/open_workshop_mqtt/test-output.txt b/extra-addons/open_workshop/open_workshop_mqtt/test-output.txt deleted file mode 100644 index 0fb135d..0000000 --- a/extra-addons/open_workshop/open_workshop_mqtt/test-output.txt +++ /dev/null @@ -1,6 +0,0 @@ -=== Checking containers === -✓ Containers running: hobbyhimmel_odoo_18-dev + hobbyhimmel_odoo_18-dev_db -=== Waiting for database === -✓ PostgreSQL ready -=== Running tests on odoo (timeout: 120s) === -(Output logged to: /home/lotzm/gitea.hobbyhimmel/odoo/extra-addons/open_workshop/open_workshop_mqtt/test_20260130_174133.log) diff --git a/extra-addons/open_workshop/open_workshop_mqtt/tests/test_device_status.py b/extra-addons/open_workshop/open_workshop_mqtt/tests/test_device_status.py index e91be23..3a9c212 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/tests/test_device_status.py +++ b/extra-addons/open_workshop/open_workshop_mqtt/tests/test_device_status.py @@ -62,7 +62,7 @@ class TestDeviceStatus(MQTTTestCase): """Test device session count is computed""" # Create sessions for i in range(3): - self.env['mqtt.session'].create({ + self.env['ows.mqtt.session'].create({ 'device_id': self.device.id, 'status': 'completed', 'start_time': datetime.now(), @@ -71,7 +71,7 @@ class TestDeviceStatus(MQTTTestCase): }) # Running session - self.env['mqtt.session'].create({ + self.env['ows.mqtt.session'].create({ 'device_id': self.device.id, 'status': 'running', 'start_time': datetime.now(), diff --git a/extra-addons/open_workshop/open_workshop_mqtt/tests/test_session_detection.py b/extra-addons/open_workshop/open_workshop_mqtt/tests/test_session_detection.py index 8edb1c5..c418e70 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/tests/test_session_detection.py +++ b/extra-addons/open_workshop/open_workshop_mqtt/tests/test_session_detection.py @@ -32,7 +32,7 @@ class TestSessionDetection(MQTTTestCase): self.simulate_mqtt_message('testdevice/status/pm1:0', payload) # Verify session created - session = self.env['mqtt.session'].search([ + session = self.env['ows.mqtt.session'].search([ ('device_id', '=', self.device.id), ('status', '=', 'running'), ]) @@ -44,7 +44,7 @@ class TestSessionDetection(MQTTTestCase): def test_02_session_ends_on_power_zero(self): """Test session ends when power drops to 0""" # Setup: create running session - session = self.env['mqtt.session'].create({ + session = self.env['ows.mqtt.session'].create({ 'device_id': self.device.id, 'status': 'running', 'start_time': datetime.now(), @@ -80,7 +80,7 @@ class TestSessionDetection(MQTTTestCase): self.simulate_mqtt_message('testdevice/status/pm1:0', payload) # Should still be only 1 running session - sessions = self.env['mqtt.session'].search([ + sessions = self.env['ows.mqtt.session'].search([ ('device_id', '=', self.device.id), ('status', '=', 'running'), ]) @@ -93,7 +93,7 @@ class TestSessionDetection(MQTTTestCase): start_time = datetime.now() - timedelta(hours=2, minutes=30) - session = self.env['mqtt.session'].create({ + session = self.env['ows.mqtt.session'].create({ 'device_id': self.device.id, 'status': 'running', 'start_time': start_time, 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 0c16bfe..4429227 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 @@ -3,8 +3,8 @@ - mqtt.device.list - mqtt.device + ows.mqtt.device.list + ows.mqtt.device
@@ -29,8 +29,8 @@ - mqtt.device.form - mqtt.device + ows.mqtt.device.form + ows.mqtt.device
@@ -149,8 +149,8 @@ - mqtt.device.kanban - mqtt.device + ows.mqtt.device.kanban + ows.mqtt.device @@ -208,8 +208,8 @@ - mqtt.device.search - mqtt.device + ows.mqtt.device.search + ows.mqtt.device @@ -239,7 +239,7 @@ MQTT Devices - mqtt.device + ows.mqtt.device kanban,list,form

diff --git a/extra-addons/open_workshop/open_workshop_mqtt/views/mqtt_session_views.xml b/extra-addons/open_workshop/open_workshop_mqtt/views/mqtt_session_views.xml index 21fec9c..653a148 100644 --- a/extra-addons/open_workshop/open_workshop_mqtt/views/mqtt_session_views.xml +++ b/extra-addons/open_workshop/open_workshop_mqtt/views/mqtt_session_views.xml @@ -3,8 +3,8 @@ - mqtt.session.list - mqtt.session + ows.mqtt.session.list + ows.mqtt.session @@ -28,8 +28,8 @@ - mqtt.session.form - mqtt.session + ows.mqtt.session.form + ows.mqtt.session

@@ -101,8 +101,8 @@ - mqtt.session.pivot - mqtt.session + ows.mqtt.session.pivot + ows.mqtt.session @@ -116,8 +116,8 @@ - mqtt.session.graph - mqtt.session + ows.mqtt.session.graph + ows.mqtt.session @@ -128,8 +128,8 @@ - mqtt.session.graph.line - mqtt.session + ows.mqtt.session.graph.line + ows.mqtt.session @@ -140,8 +140,8 @@ - mqtt.session.search - mqtt.session + ows.mqtt.session.search + ows.mqtt.session @@ -177,7 +177,7 @@ MQTT Sessions - mqtt.session + ows.mqtt.session list,form,pivot,graph {'search_default_this_week': 1} diff --git a/python_proto_type/.gitignore b/python_proto_type/.gitignore deleted file mode 100644 index 6e8b16f..0000000 --- a/python_proto_type/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# Config with secrets -config.yaml - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -env/ -venv/ -ENV/ -build/ -dist/ -*.egg-info/ - -# Data & Logs -data/ -logs/ -*.log - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db diff --git a/python_proto_type/README.md b/python_proto_type/README.md deleted file mode 100644 index 9a0e82f..0000000 --- a/python_proto_type/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# Open Workshop IoT Bridge - Python Prototype - -MQTT-basierte IoT Bridge für Odoo 18 Community zur Erfassung von Maschinenlaufzeiten. - -## Projektstruktur - -``` -python_prototype/ -├── main.py # Haupteinstiegspunkt -├── config.yaml # Produktionskonfiguration (nicht in Git) -├── config.yaml.example # Beispielkonfiguration -│ -├── Core Components (Produktionscode) -├── mqtt_client.py # MQTT Client mit TLS -├── shelly_parser.py # Parser für Shelly PM Mini G3 -├── event_normalizer.py # Event Schema v1 -├── session_detector.py # Session Detection (Dual-Threshold) -├── event_storage.py # JSON Storage -│ -├── data/ # Laufzeitdaten -│ ├── events.jsonl # Event-Log -│ └── sessions.json # Session-Daten -│ -├── tests/ # Tests -│ ├── unit/ # Unit Tests (schnell, isoliert) -│ │ └── test_session_detector.py -│ ├── integration/ # Integration Tests (mit MQTT) -│ │ └── test_mqtt_integration.py -│ └── tools/ # Test-Hilfsprogramme -│ └── shelly_simulator.py -│ -└── venv/ # Virtual Environment -``` - -## Installation - -```bash -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt - -cp config.yaml.example config.yaml -# config.yaml anpassen -``` - -## Tests - -```bash -# Unit Tests (schnell, ~0.05s) -pytest tests/unit/ -v - -# Integration Tests (mit MQTT, ~30-60s) -# Benötigt MQTT Konfiguration: -# Option 1: Nutzt existierende config.yaml -pytest tests/integration/ -v -s - -# Option 2: Mit Umgebungsvariablen -export MQTT_HOST=mqtt.majufilo.eu -export MQTT_PORT=8883 -export MQTT_USERNAME=mosquitto -export MQTT_PASSWORD=dein_passwort -pytest tests/integration/ -v -s - -# Alle Tests -pytest tests/ -v -``` - -**Wichtig:** Integration Tests lesen MQTT-Zugangsdaten aus: -1. Umgebungsvariablen (`MQTT_HOST`, `MQTT_PASSWORD`, etc.) ODER -2. Existierender `config.yaml` im Projektroot - -**Niemals Passwörter im Source Code committen!** - -## Betrieb - -```bash -# Bridge starten -python main.py - -# Mit alternativer Config -python main.py --config custom_config.yaml -``` - -## Manuelle Tests - -```bash -# Shelly Simulator für Tests -python tests/tools/shelly_simulator.py --scenario session_end -python tests/tools/shelly_simulator.py --scenario full_session -python tests/tools/shelly_simulator.py --scenario timeout - -python3 tests/tools/shelly_simulator.py --broker localhost --port 1883 --no-tls --username "" --password "" --scenario full_session -``` - -## Session Detection States - -- **IDLE**: Power < 20W -- **STARTING**: Power >= 20W, 3s debounce -- **STANDBY**: 20-100W (Maschine an, Spindel aus) -- **WORKING**: >= 100W (Spindel läuft) -- **STOPPING**: < 20W, 15s debounce -- **Timeout**: 20s keine Messages → Session-Ende - -## Odoo Integration - -**Deployment-Strategie:** Direkte Integration in Odoo (kein separater Container) - -**Begründung:** -- Werkstatt-Setup: maximal 10 Maschinen -- Performance: `check_timeouts()` alle 10s = ~10ms CPU-Zeit pro Durchlauf -- CPU-Last: < 0.1% bei 10 Maschinen → vernachlässigbar -- Vorteile: Direkter DB-Zugriff, ACID-Transaktionen, keine API-Overhead -- Einfachheit: Ein Container, keine zusätzliche Infrastruktur - -**Implementation in Odoo:** -- MQTT Client als Odoo Thread oder Cron-Job -- `check_timeouts()` als scheduled action alle 10 Sekunden -- Direkte Verwendung von `workshop.machine`, `workshop.session` Models - -**Alternative (nur bei >50 Maschinen nötig):** Separater Microservice-Container - -## Roadmap - -- [x] M0-M3: MQTT + Parser + Session Detection -- [x] Unit + Integration Tests -- [x] Timeout Detection mit check_timeouts() -- [x] M4: Multi-Device Support (2+ Maschinen parallel) -- [x] M5: Reconnect + Error Handling - - MQTT Auto-Reconnect mit Exponential Backoff (1s → 60s) - - State Recovery: Laufende Sessions werden nach Neustart wiederhergestellt - - Robustes Error Handling in allen Parsern - - Alle 21 Tests bestanden ✅ -- [ ] M6-M10: Odoo Integration diff --git a/python_proto_type/config.yaml.example b/python_proto_type/config.yaml.example deleted file mode 100644 index a6cd58c..0000000 --- a/python_proto_type/config.yaml.example +++ /dev/null @@ -1,70 +0,0 @@ -# MQTT Broker Configuration -mqtt: - host: "localhost" # MQTT Broker IP/Hostname - port: 1883 # Standard MQTT Port (1883 unencrypted, 8883 encrypted) - username: "" # Optional: MQTT Username - password: "" # Optional: MQTT Password - client_id: "ows_iot_bridge_prototype" - keepalive: 60 - - # Topics to subscribe - topics: - - "shellies/+/status" # Shelly Status Updates - - "shellypmminig3/events" # Shelly Events - - "shellies/+/online" # Shelly Online Status - # Add more topics as needed - -# Device Configuration -# Multi-Device Support: Jedes Shelly PM Mini G3 Gerät = 1 Maschine -# Unterscheide Geräte durch verschiedene topic_prefix in MQTT Topics -devices: - - shelly_id: "shellypmminig3-48f6eeb73a1c" - machine_name: "Shaper Origin" - machine_id: "shaper-origin-01" - device_type: "shelly_pm_mini_g3" - - # Dual Power Thresholds (in Watts) - # IDLE: Power < standby_threshold_w - # STANDBY: standby_threshold_w <= Power < working_threshold_w (Maschine an, Spindel aus) - # WORKING: Power >= working_threshold_w (Spindel läuft) - standby_threshold_w: 20 # Start tracking session - working_threshold_w: 100 # Active work detected - - # Debounce times (in seconds) - start_debounce_s: 3 # Power >= standby_threshold for X seconds → session_start - stop_debounce_s: 15 # Power < standby_threshold for Y seconds → session_end - - # Timeout detection (in seconds) - message_timeout_s: 20 # No message for X seconds → session_end (timeout) - - enabled: true - - # Zweite Maschine - anderes Shelly-Gerät, andere Thresholds - # Wichtig: Jedes Shelly muss eigenen topic_prefix haben! - - shelly_id: "shellypmminig3-48f6eeb73a2d" - machine_name: "CNC Fräse" - machine_id: "cnc-mill-01" - device_type: "shelly_pm_mini_g3" - standby_threshold_w: 30 # Höherer Standby-Wert für CNC - working_threshold_w: 150 # Spindel braucht mehr Power - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - enabled: true - -# Logging Configuration -logging: - level: "INFO" # DEBUG, INFO, WARNING, ERROR - format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - file: "logs/ows_iot_bridge.log" # Optional: log to file - console: true - -# Output Configuration -output: - # Where to store events and sessions - events_file: "data/events.json" - sessions_file: "data/sessions.json" - - # Console output - print_events: true - print_sessions: true diff --git a/python_proto_type/event_normalizer.py b/python_proto_type/event_normalizer.py deleted file mode 100644 index a99cfbd..0000000 --- a/python_proto_type/event_normalizer.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Event Normalizer for Open Workshop IoT Bridge - -Converts device-specific data formats into a unified Event Schema v1 -for consistent processing across different IoT device types. -""" - -import logging -import uuid -from datetime import datetime, timezone -from typing import Dict, Optional - - -class EventNormalizer: - """ - Normalizes device-specific events into unified Event Schema v1 - - Event Schema v1: - { - "event_id": "uuid4", - "event_type": "power_measurement", - "timestamp": "2026-01-22T18:30:45.123456Z", - "machine": { - "machine_id": "shaper-origin-01", - "machine_name": "Shaper Origin" - }, - "device": { - "device_id": "48f6eeb73a1c", - "device_type": "shelly_pm_mini_g3", - "topic_prefix": "shaperorigin" - }, - "metrics": { - "power_w": 52.9, - "voltage_v": 234.8, - "current_a": 0.267, - "frequency_hz": 49.9 - }, - "raw_data": { ... } # Optional: Original device data - } - """ - - def __init__(self, device_config: list = None): - """ - Initialize normalizer with device configuration - - Args: - device_config: List of device configurations from config.yaml - """ - self.logger = logging.getLogger(__name__) - self.device_config = device_config or [] - - # Build topic_prefix to machine mapping - self.prefix_machine_map = {} - for device in self.device_config: - topic_prefix = device.get('topic_prefix') - if topic_prefix: - self.prefix_machine_map[topic_prefix] = { - 'machine_id': device.get('machine_id'), - 'machine_name': device.get('machine_name'), - 'device_type': device.get('device_type', 'unknown') - } - - def normalize_shelly_event(self, shelly_data: Dict) -> Optional[Dict]: - """ - Convert Shelly PM Mini G3 data to Event Schema v1 - - Args: - shelly_data: Parsed Shelly data from ShellyParser - - Returns: - Normalized event dict or None if data incomplete - """ - try: - # Extract device ID and find topic prefix - device_id = shelly_data.get('device_id', 'unknown') - topic_prefix = self._find_topic_prefix(device_id, shelly_data) - - if not topic_prefix: - self.logger.warning(f"Could not determine topic_prefix for device {device_id}") - return None - - # Get machine info from config - machine_info = self.prefix_machine_map.get(topic_prefix) - if not machine_info: - self.logger.warning(f"No machine config found for topic_prefix: {topic_prefix}") - return None - - # Build normalized event - event = { - "event_id": str(uuid.uuid4()), - "event_type": self._determine_event_type(shelly_data), - "timestamp": self._normalize_timestamp(shelly_data.get('timestamp')), - "machine": { - "machine_id": machine_info['machine_id'], - "machine_name": machine_info['machine_name'] - }, - "device": { - "device_id": device_id, - "device_type": machine_info['device_type'], - "topic_prefix": topic_prefix - }, - "metrics": self._extract_metrics(shelly_data), - "raw_data": shelly_data # Keep original for debugging - } - - return event - - except Exception as e: - self.logger.error(f"Failed to normalize Shelly event: {e}", exc_info=True) - return None - - def _find_topic_prefix(self, device_id: str, shelly_data: Dict) -> Optional[str]: - """ - Find topic_prefix for device - - Device ID can be: - - topic_prefix itself (e.g. 'shaperorigin' from status messages) - - actual device ID (e.g. '48f6eeb73a1c' from RPC events) - """ - # Check if device_id is already a known topic_prefix - if device_id in self.prefix_machine_map: - return device_id - - # Otherwise, we need to infer it (currently only one device configured) - # For multiple devices, we'd need more sophisticated matching - if len(self.prefix_machine_map) == 1: - return list(self.prefix_machine_map.keys())[0] - - # TODO: For multi-device setups, implement device_id to topic_prefix mapping - self.logger.warning(f"Cannot map device_id {device_id} to topic_prefix") - return None - - def _determine_event_type(self, shelly_data: Dict) -> str: - """Determine event type from Shelly data""" - msg_type = shelly_data.get('message_type', 'unknown') - - if msg_type in ['status', 'event']: - return 'power_measurement' - - return 'unknown' - - def _normalize_timestamp(self, timestamp_str: Optional[str]) -> str: - """ - Normalize timestamp to ISO 8601 UTC format - - Args: - timestamp_str: ISO timestamp string or None - - Returns: - ISO 8601 UTC timestamp string - """ - if timestamp_str: - try: - # Parse and ensure UTC - dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) - return dt.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z') - except Exception as e: - self.logger.warning(f"Failed to parse timestamp {timestamp_str}: {e}") - - # Fallback: current time in UTC - return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') - - def _extract_metrics(self, shelly_data: Dict) -> Dict: - """ - Extract metrics from Shelly data - - Returns dict with standardized metric names - """ - metrics = {} - - # Power (always present in our use case) - # Shelly uses 'apower' (active power) - if 'apower' in shelly_data: - metrics['power_w'] = shelly_data['apower'] - - # Voltage (only in status messages) - if 'voltage' in shelly_data and shelly_data['voltage'] is not None: - metrics['voltage_v'] = shelly_data['voltage'] - - # Current (in status and some events) - if 'current' in shelly_data and shelly_data['current'] is not None: - metrics['current_a'] = shelly_data['current'] - - # Frequency (only in status messages) - if 'frequency' in shelly_data and shelly_data['frequency'] is not None: - metrics['frequency_hz'] = shelly_data['frequency'] - - # Energy counters (if available) - if 'total_energy' in shelly_data and shelly_data['total_energy'] is not None: - metrics['energy_total_wh'] = shelly_data['total_energy'] - - return metrics diff --git a/python_proto_type/event_storage.py b/python_proto_type/event_storage.py deleted file mode 100644 index 16e21fc..0000000 --- a/python_proto_type/event_storage.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -Event Storage Module - Persists events and sessions to JSON files -For later migration to Odoo database models -""" - -import json -import logging -from pathlib import Path -from typing import Dict, Optional, List -from datetime import datetime - - -class EventStorage: - """ - Stores events and sessions to JSON files - - Format designed for easy migration to Odoo models: - - events.jsonl: JSON Lines format (one event per line) → open_workshop.power_event - - sessions.json: JSON array of sessions → open_workshop.session - """ - - def __init__(self, events_file: str = "data/events.jsonl", - sessions_file: str = "data/sessions.json"): - self.logger = logging.getLogger(__name__) - self.events_file = Path(events_file) - self.sessions_file = Path(sessions_file) - - # Ensure data directory exists - self.events_file.parent.mkdir(parents=True, exist_ok=True) - - # Initialize sessions file if it doesn't exist - if not self.sessions_file.exists(): - self._write_sessions([]) - - def store_event(self, event: Dict) -> bool: - """ - Append event to JSONL file (one JSON object per line) - - Format for Odoo migration: - { - "event_id": "uuid", - "event_type": "power_measurement", - "timestamp": "ISO 8601", - "machine": {"machine_id": "...", "machine_name": "..."}, - "device": {...}, - "metrics": {"power_w": 45.7, "voltage_v": 230.2, ...} - } - """ - try: - with open(self.events_file, 'a', encoding='utf-8') as f: - # Write one JSON object per line (JSONL format) - json.dump(event, f, ensure_ascii=False) - f.write('\n') - - self.logger.debug(f"Event {event.get('event_id', 'N/A')} stored") - return True - - except Exception as e: - self.logger.error(f"Failed to store event: {e}") - return False - - def store_session_event(self, session_event: Dict) -> bool: - """ - Store session_start or session_end event - - Updates sessions.json with structured session data - - Format for Odoo migration (open_workshop.session): - { - "session_id": "uuid", - "machine_id": "shaper-origin-01", - "machine_name": "Shaper Origin", - "start_time": "2026-01-22T18:36:20.993Z", - "end_time": "2026-01-22T18:38:01.993Z", // null if running - "total_duration_s": 101, // null if running - "standby_duration_s": 80, // null if running (machine on, not working) - "working_duration_s": 21, // null if running (active work) - "start_power_w": 45.7, - "end_power_w": 0.0, // null if running - "end_reason": "power_drop", // or "timeout", null if running - """ - try: - sessions = self._read_sessions() - - session_id = session_event.get('session_id') - event_type = session_event.get('event_type') - machine = session_event.get('machine', {}) - session_data = session_event.get('session_data', {}) - - if event_type == 'session_start': - # Add new session - sessions.append({ - "session_id": session_id, - "machine_id": machine.get('machine_id'), - "machine_name": machine.get('machine_name'), - "start_time": session_data.get('start_time'), - "end_time": None, - "total_duration_s": None, - "standby_duration_s": None, - "working_duration_s": None, - "start_power_w": session_event.get('power_w'), - "end_power_w": None, - "end_reason": None, - "status": "running" - }) - - elif event_type == 'session_end': - # Update existing session - for session in sessions: - if session['session_id'] == session_id: - session.update({ - "end_time": session_data.get('end_time'), - "total_duration_s": session_data.get('total_duration_s'), - "standby_duration_s": session_data.get('standby_duration_s'), - "working_duration_s": session_data.get('working_duration_s'), - "end_power_w": session_event.get('power_w'), - "end_reason": session_data.get('end_reason'), - "status": "completed" - }) - break - - self._write_sessions(sessions) - self.logger.debug(f"Session {event_type} {session_id} stored") - return True - - except Exception as e: - self.logger.error(f"Failed to store session event: {e}", exc_info=True) - return False - - def load_sessions(self) -> List[Dict]: - """ - Load all sessions from sessions.json - - Returns: - List of session dicts (running and completed) - """ - try: - return self._read_sessions() - except Exception as e: - self.logger.error(f"Failed to load sessions: {e}") - return [] - - def get_running_sessions(self) -> List[Dict]: - """ - Get all currently running sessions (end_time is None) - - Returns: - List of running session dicts - """ - try: - sessions = self._read_sessions() - return [s for s in sessions if s.get('end_time') is None] - except Exception as e: - self.logger.error(f"Failed to get running sessions: {e}") - return [] - - def _handle_session_end(self, event: Dict) -> bool: - """Handle session_end event""" - sessions = self._read_sessions() - session_id = event['session_id'] - - # Find and update session - for session in sessions: - if session['session_id'] == session_id: - session_data = event.get('session_data', {}) - - session['end_time'] = session_data.get('end_time') - session['total_duration_s'] = session_data.get('total_duration_s') - session['standby_duration_s'] = session_data.get('standby_duration_s') - session['working_duration_s'] = session_data.get('working_duration_s') - session['end_power_w'] = event.get('power_w') - session['end_reason'] = session_data.get('end_reason', 'normal') - session['status'] = 'completed' - - self._write_sessions(sessions) - - total_min = session['total_duration_s'] / 60 if session['total_duration_s'] else 0 - standby_min = session['standby_duration_s'] / 60 if session['standby_duration_s'] else 0 - working_min = session['working_duration_s'] / 60 if session['working_duration_s'] else 0 - - self.logger.info( - f"Session {session_id[:8]}... completed ({session['end_reason']}) - " - f"Total: {total_min:.1f}min, Standby: {standby_min:.1f}min, Working: {working_min:.1f}min" - ) - return True - - self.logger.error(f"Session {session_id} not found for update") - return False - - def _read_sessions(self) -> list: - """Read all sessions from JSON file""" - try: - if self.sessions_file.exists(): - with open(self.sessions_file, 'r', encoding='utf-8') as f: - return json.load(f) - return [] - except Exception as e: - self.logger.error(f"Failed to read sessions: {e}") - return [] - - def _write_sessions(self, sessions: list) -> bool: - """Write sessions to JSON file""" - try: - with open(self.sessions_file, 'w', encoding='utf-8') as f: - json.dump(sessions, f, ensure_ascii=False, indent=2) - return True - except Exception as e: - self.logger.error(f"Failed to write sessions: {e}") - return False - - def get_active_sessions(self) -> list: - """Get all running sessions""" - sessions = self._read_sessions() - return [s for s in sessions if s['status'] == 'running'] - - def get_session_by_id(self, session_id: str) -> Optional[Dict]: - """Get session by ID""" - sessions = self._read_sessions() - for session in sessions: - if session['session_id'] == session_id: - return session - return None - - def get_sessions_by_machine(self, machine_id: str, limit: int = 10) -> list: - """Get recent sessions for a machine""" - sessions = self._read_sessions() - machine_sessions = [s for s in sessions if s['machine_id'] == machine_id] - - # Sort by start_time descending - machine_sessions.sort(key=lambda x: x['start_time'], reverse=True) - - return machine_sessions[:limit] diff --git a/python_proto_type/main.py b/python_proto_type/main.py deleted file mode 100644 index 02a412f..0000000 --- a/python_proto_type/main.py +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env python3 -""" -Open Workshop IoT Bridge - Python Prototype -Main entry point for MQTT to Odoo bridge - -Phase 1: Standalone prototype (without Odoo) -""" - -import sys -import logging -import yaml -import time -import signal -import json -import argparse -from pathlib import Path -from typing import Dict - -from mqtt_client import MQTTClient -from shelly_parser import ShellyParser -from event_normalizer import EventNormalizer -from session_detector import SessionDetector -from event_storage import EventStorage - - -class IoTBridge: - """Main IoT Bridge Application""" - - def __init__(self, config_path: str = 'config.yaml'): - """ - Initialize IoT Bridge - - Args: - config_path: Path to config.yaml file - """ - # Load configuration - self.config = self._load_config(config_path) - - # Setup logging - self._setup_logging() - - self.logger = logging.getLogger(__name__) - self.logger.info("=== Open Workshop IoT Bridge Starting ===") - - # Initialize Shelly Parser with device config - self.shelly_parser = ShellyParser(device_config=self.config.get('devices', [])) - - # Initialize Event Normalizer - self.event_normalizer = EventNormalizer(device_config=self.config.get('devices', [])) - - # Initialize Session Detector - self.session_detector = SessionDetector(device_config=self.config.get('devices', [])) - - # Initialize Event Storage - output_config = self.config.get('output', {}) - self.event_storage = EventStorage( - events_file=output_config.get('events_file', 'data/events.jsonl'), - sessions_file=output_config.get('sessions_file', 'data/sessions.json') - ) - - # Initialize MQTT Client - self.mqtt_client = MQTTClient( - config=self.config['mqtt'], - message_callback=self.on_mqtt_message - ) - - self.running = False - - # Setup signal handlers for graceful shutdown - signal.signal(signal.SIGINT, self._signal_handler) - signal.signal(signal.SIGTERM, self._signal_handler) - - def _load_config(self, config_path: str) -> dict: - """Load configuration from YAML file""" - config_file = Path(config_path) - - if not config_file.exists(): - print(f"Error: Config file not found: {config_path}") - print(f"Please copy config.yaml.example to config.yaml and adjust settings") - sys.exit(1) - - with open(config_file, 'r') as f: - config = yaml.safe_load(f) - - return config - - def _setup_logging(self): - """Setup logging configuration""" - log_config = self.config.get('logging', {}) - - # Create logs directory if logging to file - if log_config.get('file'): - log_file = Path(log_config['file']) - log_file.parent.mkdir(parents=True, exist_ok=True) - - # Configure logging - log_level = getattr(logging, log_config.get('level', 'INFO').upper()) - log_format = log_config.get('format', '%(asctime)s - %(name)s - %(levelname)s - %(message)s') - - handlers = [] - - # Console handler - if log_config.get('console', True): - console_handler = logging.StreamHandler() - console_handler.setFormatter(logging.Formatter(log_format)) - handlers.append(console_handler) - - # File handler - if log_config.get('file'): - file_handler = logging.FileHandler(log_config['file']) - file_handler.setFormatter(logging.Formatter(log_format)) - handlers.append(file_handler) - - logging.basicConfig( - level=log_level, - format=log_format, - handlers=handlers - ) - - def _signal_handler(self, signum, frame): - """Handle shutdown signals""" - self.logger.info(f"Received signal {signum}, shutting down...") - self.stop() - - def on_mqtt_message(self, topic: str, payload): - """ - Callback for incoming MQTT messages - - Args: - topic: MQTT topic - payload: Message payload (parsed JSON or raw string) - """ - # Log RAW topic for analysis - if 'shellypmminig3' in topic and 'debug' not in topic: - self.logger.info(f"RAW Topic: {topic}") - - # Only process JSON payloads - if not isinstance(payload, dict): - return - - # Parse Shelly message - parsed_data = self.shelly_parser.parse_message(topic, payload) - - if parsed_data: - # Normalize to Event Schema v1 - normalized_event = self.event_normalizer.normalize_shelly_event(parsed_data) - - if normalized_event: - # Store event (for later Odoo migration) - self.event_storage.store_event(normalized_event) - - # Log normalized event (condensed) - metrics = normalized_event.get('metrics', {}) - power_w = metrics.get('power_w', 'N/A') - self.logger.debug( - f"Event: {normalized_event['machine']['machine_name']} - " - f"Power: {power_w}W" - ) - - # Process through Session Detector - session_event = self.session_detector.process_event(normalized_event) - - if session_event: - # Session state change occurred! - self._log_session_event(session_event) - - # Store session event (for Odoo migration) - self.event_storage.store_session_event(session_event) - - def _log_session_event(self, session_event: Dict): - """Log session start/end events""" - self.logger.info("\n" + "=" * 70) - - if session_event['event_type'] == 'session_start': - self.logger.info("🚀 SESSION START") - else: - self.logger.info("🏁 SESSION END") - - self.logger.info("=" * 70) - self.logger.info(f"Session ID: {session_event['session_id']}") - self.logger.info(f"Machine: {session_event['machine']['machine_name']} ({session_event['machine']['machine_id']})") - self.logger.info(f"Timestamp: {session_event['timestamp']}") - self.logger.info(f"Power: {session_event['power_w']:.1f}W") - - session_data = session_event.get('session_data', {}) - self.logger.info(f"Start Time: {session_data.get('start_time')}") - - if 'end_time' in session_data: - self.logger.info(f"End Time: {session_data['end_time']}") - duration_s = session_data.get('duration_s', 0) - duration_min = duration_s / 60 - self.logger.info(f"Duration: {duration_s}s ({duration_min:.2f} min)") - - self.logger.info("=" * 70 + "\n") - - def _restore_running_sessions(self): - """Restore running sessions after bridge restart""" - self.logger.info("Checking for running sessions to restore...") - - running_sessions = self.event_storage.get_running_sessions() - - if running_sessions: - self.logger.info(f"Found {len(running_sessions)} running session(s)") - self.session_detector.restore_state(running_sessions) - else: - self.logger.info("No running sessions to restore") - - def start(self): - """Start the IoT Bridge""" - self.logger.info("Starting IoT Bridge...") - - # Restore running sessions after restart - self._restore_running_sessions() - - # Create data directory - data_dir = Path('data') - data_dir.mkdir(exist_ok=True) - - # Connect to MQTT - if not self.mqtt_client.connect(): - self.logger.error("Failed to connect to MQTT Broker") - return False - - # Start MQTT loop - self.mqtt_client.start() - - # Wait for connection - if not self.mqtt_client.wait_for_connection(timeout=10): - self.logger.error("MQTT connection timeout") - return False - - self.logger.info("IoT Bridge started successfully") - self.logger.info("Listening for MQTT messages... (Press Ctrl+C to stop)") - - self.running = True - - # Main loop - try: - while self.running: - time.sleep(1) - - # Check for session timeouts periodically - timeout_events = self.session_detector.check_timeouts() - for session_event in timeout_events: - self._log_session_event(session_event) - self.event_storage.store_session_event(session_event) - - except KeyboardInterrupt: - self.logger.info("Interrupted by user") - - return True - - def stop(self): - """Stop the IoT Bridge""" - self.running = False - self.logger.info("Stopping IoT Bridge...") - self.mqtt_client.stop() - self.logger.info("IoT Bridge stopped") - - -def main(): - """Main entry point""" - parser = argparse.ArgumentParser(description='Open Workshop IoT Bridge') - parser.add_argument('--config', default='config.yaml', help='Path to config file (default: config.yaml)') - args = parser.parse_args() - - # Check if config file exists - config_file = Path(args.config) - - if not config_file.exists(): - print("\n" + "="*60) - print(f"Configuration file not found: {config_file}") - print("="*60) - print("\nPlease create config.yaml from the example:") - print(" cp config.yaml.example config.yaml") - print("\nThen edit config.yaml with your MQTT broker settings.") - print("="*60 + "\n") - sys.exit(1) - - # Start bridge - bridge = IoTBridge(str(config_file)) - bridge.start() - - -if __name__ == '__main__': - main() diff --git a/python_proto_type/mqtt_client.py b/python_proto_type/mqtt_client.py deleted file mode 100644 index 1105d3c..0000000 --- a/python_proto_type/mqtt_client.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -""" -MQTT Client für Open Workshop IoT Bridge -Verbindet sich mit MQTT Broker und empfängt Device Events -""" - -import logging -import time -import json -import ssl -from typing import Callable, Optional -import paho.mqtt.client as mqtt - - -class MQTTClient: - """MQTT Client Wrapper für Device Event Empfang""" - - def __init__(self, config: dict, message_callback: Optional[Callable] = None): - """ - Initialize MQTT Client - - Args: - config: MQTT configuration dict from config.yaml - message_callback: Callback function for incoming messages - """ - self.config = config - self.message_callback = message_callback - self.logger = logging.getLogger(__name__) - - # Create MQTT Client - self.client = mqtt.Client( - client_id=config.get('client_id', 'ows_iot_bridge'), - protocol=mqtt.MQTTv5 - ) - - # Set callbacks - self.client.on_connect = self._on_connect - self.client.on_disconnect = self._on_disconnect - self.client.on_message = self._on_message - - # Set username/password if provided - if config.get('username') and config.get('password'): - self.client.username_pw_set( - config['username'], - config['password'] - ) - - # Enable TLS/SSL if port is 8883 - if config.get('port') == 8883: - self.client.tls_set(cert_reqs=ssl.CERT_NONE) - self.client.tls_insecure_set(True) - self.logger.info("TLS/SSL enabled for port 8883") - - self.connected = False - self.topics = config.get('topics', []) - self.reconnect_delay = 1 # Start with 1 second - self.max_reconnect_delay = 60 # Max 60 seconds - self.reconnecting = False - - def _on_connect(self, client, userdata, flags, rc, properties=None): - """Callback when connected to MQTT broker""" - if rc == 0: - self.connected = True - self.reconnecting = False - self.reconnect_delay = 1 # Reset delay on successful connect - self.logger.info(f"Connected to MQTT Broker at {self.config['host']}:{self.config['port']}") - - # Subscribe to all configured topics - for topic in self.topics: - self.client.subscribe(topic) - self.logger.info(f"Subscribed to topic: {topic}") - else: - self.logger.error(f"Failed to connect to MQTT Broker, return code {rc}") - self.connected = False - self._schedule_reconnect() - - def _on_disconnect(self, client, userdata, rc, properties=None): - """Callback when disconnected from MQTT broker""" - self.connected = False - if rc != 0: - self.logger.warning(f"Unexpected disconnect from MQTT Broker (rc={rc})") - self._schedule_reconnect() - else: - self.logger.info("Disconnected from MQTT Broker") - - def _schedule_reconnect(self): - """Schedule automatic reconnection with exponential backoff""" - if self.reconnecting: - return # Already reconnecting - - self.reconnecting = True - self.logger.info(f"Attempting reconnect in {self.reconnect_delay}s...") - time.sleep(self.reconnect_delay) - - try: - self.client.reconnect() - self.logger.info("Reconnection initiated") - except Exception as e: - self.logger.error(f"Reconnection failed: {e}") - # Exponential backoff - self.reconnect_delay = min(self.reconnect_delay * 2, self.max_reconnect_delay) - self.reconnecting = False - - def _on_message(self, client, userdata, msg): - """Callback when message received""" - try: - topic = msg.topic - payload = msg.payload.decode('utf-8') - - # Log all shellypmminig3 topics (exclude debug) - if 'shellypmminig3' in topic and 'debug' not in topic: - self.logger.info(f"📡 TOPIC: {topic}") - - self.logger.debug(f"Message received on topic '{topic}'") - - # Try to parse as JSON - try: - payload_json = json.loads(payload) - except json.JSONDecodeError: - # Debug logs are expected to be non-JSON, only warn for non-debug topics - if 'debug' not in topic: - self.logger.warning(f"Message on '{topic}' is not valid JSON: {payload[:100]}") - payload_json = None - - # Call user callback if provided - if self.message_callback: - self.message_callback(topic, payload_json or payload) - else: - # Default: just log - self.logger.info(f"Topic: {topic}") - if payload_json: - self.logger.info(f"Payload: {json.dumps(payload_json, indent=2)}") - else: - self.logger.info(f"Payload: {payload}") - - except Exception as e: - self.logger.error(f"Error processing message: {e}", exc_info=True) - - def connect(self): - """Connect to MQTT broker""" - try: - self.logger.info(f"Connecting to MQTT Broker {self.config['host']}:{self.config['port']}...") - self.client.connect( - self.config['host'], - self.config['port'], - self.config.get('keepalive', 60) - ) - return True - except Exception as e: - self.logger.error(f"Failed to connect to MQTT Broker: {e}") - return False - - def start(self): - """Start the MQTT client loop (non-blocking)""" - self.client.loop_start() - - def stop(self): - """Stop the MQTT client loop""" - self.client.loop_stop() - self.client.disconnect() - self.logger.info("MQTT Client stopped") - - def publish(self, topic: str, payload: dict): - """ - Publish message to MQTT topic - - Args: - topic: MQTT topic - payload: Message payload (will be JSON encoded) - """ - try: - payload_json = json.dumps(payload) - result = self.client.publish(topic, payload_json) - if result.rc == mqtt.MQTT_ERR_SUCCESS: - self.logger.debug(f"Published to {topic}") - return True - else: - self.logger.error(f"Failed to publish to {topic}, rc={result.rc}") - return False - except Exception as e: - self.logger.error(f"Error publishing to {topic}: {e}") - return False - - def wait_for_connection(self, timeout: int = 10) -> bool: - """ - Wait for connection to be established - - Args: - timeout: Maximum seconds to wait - - Returns: - True if connected, False if timeout - """ - start_time = time.time() - while not self.connected and (time.time() - start_time) < timeout: - time.sleep(0.1) - - return self.connected diff --git a/python_proto_type/requirements.txt b/python_proto_type/requirements.txt deleted file mode 100644 index 63fd606..0000000 --- a/python_proto_type/requirements.txt +++ /dev/null @@ -1,17 +0,0 @@ -# MQTT Client -paho-mqtt>=2.0.0 - -# Configuration -pyyaml>=6.0 - -# Logging & Utilities -python-dateutil>=2.8.2 - -# For UUID generation -uuid>=1.30 - -# Optional: für spätere JSON Schema Validation -jsonschema>=4.17.0 - -# Optional: für besseres Logging -colorlog>=6.7.0 diff --git a/python_proto_type/session_detector.py b/python_proto_type/session_detector.py deleted file mode 100644 index c8bb2a2..0000000 --- a/python_proto_type/session_detector.py +++ /dev/null @@ -1,514 +0,0 @@ -""" -Session Detection Engine for Open Workshop IoT Bridge - -Detects machine run sessions with dual power thresholds: -- STANDBY: Machine on, not working (e.g. 20-100W) -- WORKING: Active work (e.g. >= 100W) - -Includes timeout detection for when machine powers off (no MQTT messages). -""" - -import logging -import uuid -from datetime import datetime, timezone, timedelta -from typing import Dict, Optional, List -from enum import Enum - - -class SessionState(Enum): - """Session detection states""" - IDLE = "idle" # Machine off (power < standby_threshold) - STARTING = "starting" # Power above standby threshold, waiting for debounce - STANDBY = "standby" # Machine on, not working (standby <= power < working) - WORKING = "working" # Active work (power >= working threshold) - STOPPING = "stopping" # Power below standby threshold, waiting for debounce - - -class SessionDetector: - """ - Detects machine run sessions with dual thresholds and timeout detection - - State Machine: - IDLE -> STARTING -> STANDBY/WORKING -> STOPPING -> IDLE - - - IDLE: Power < standby_threshold - - STARTING: Power >= standby_threshold for < start_debounce_s - - STANDBY: Power >= standby_threshold < working_threshold (confirmed) - - WORKING: Power >= working_threshold (confirmed) - - STOPPING: Power < standby_threshold for < stop_debounce_s - - Back to IDLE: Power < standby_threshold for >= stop_debounce_s OR timeout - - Timeout: No MQTT message for > message_timeout_s → session_end (machine powered off) - """ - - def __init__(self, device_config: list = None): - """ - Initialize session detector - - Args: - device_config: List of device configurations from config.yaml - """ - self.logger = logging.getLogger(__name__) - self.device_config = device_config or [] - - # Build machine_id to config mapping - self.machine_config = {} - for device in self.device_config: - machine_id = device.get('machine_id') - if machine_id: - self.machine_config[machine_id] = { - 'standby_threshold_w': device.get('standby_threshold_w', 20), - 'working_threshold_w': device.get('working_threshold_w', 100), - 'start_debounce_s': device.get('start_debounce_s', 3), - 'stop_debounce_s': device.get('stop_debounce_s', 15), - 'message_timeout_s': device.get('message_timeout_s', 60), - 'machine_name': device.get('machine_name', 'Unknown'), - } - - # State tracking per machine - self.machine_states = {} # machine_id -> state info - - def restore_state(self, running_sessions: List[Dict]) -> None: - """ - Restore state from running sessions after bridge restart - - Args: - running_sessions: List of session dicts with end_time=None from EventStorage - """ - if not running_sessions: - self.logger.info("No running sessions to restore") - return - - self.logger.info(f"Restoring state for {len(running_sessions)} running sessions") - - for session in running_sessions: - machine_id = session.get('machine_id') - session_id = session.get('session_id') - - if not machine_id or not session_id: - self.logger.warning(f"Invalid session data, skipping: {session}") - continue - - # Get machine config - config = self.machine_config.get(machine_id) - if not config: - self.logger.warning(f"No config for machine {machine_id}, cannot restore") - continue - - # Parse start time - try: - start_time = datetime.fromisoformat(session['start_time'].replace('Z', '+00:00')) - except Exception as e: - self.logger.error(f"Invalid start_time in session {session_id}: {e}") - continue - - # Restore machine state - self.machine_states[machine_id] = { - 'state': SessionState.STANDBY, # Start in STANDBY, will update on next message - 'state_since': start_time, - 'current_session_id': session_id, - 'session_start_time': start_time, - 'last_power': session.get('start_power_w'), - 'last_message_time': start_time, - 'standby_duration_s': session.get('standby_duration_s', 0), - 'working_duration_s': session.get('working_duration_s', 0), - 'last_state_change': start_time, - } - - self.logger.info( - f"✓ Restored session {session_id} for {session.get('machine_name', machine_id)}" - ) - - def check_timeouts(self) -> List[Dict]: - """ - Check all machines for message timeouts and end sessions if needed. - Should be called periodically (e.g. every second). - - Returns: - List of session_end events for timed-out sessions - """ - timeout_events = [] - current_time = datetime.now(timezone.utc) - - for machine_id, state_info in self.machine_states.items(): - # Only check machines in active session states - if state_info['state'] not in [SessionState.STANDBY, SessionState.WORKING]: - continue - - # Skip if no last message time - if not state_info['last_message_time']: - continue - - # Get machine config - config = self.machine_config.get(machine_id) - if not config: - continue - - # Check if timeout exceeded - time_since_last_message = (current_time - state_info['last_message_time']).total_seconds() - if time_since_last_message > config['message_timeout_s']: - machine_name = config.get('machine_name', 'Unknown') - self.logger.warning( - f"⏱️ {machine_name}: Message timeout " - f"({time_since_last_message:.0f}s > {config['message_timeout_s']}s) → SESSION END" - ) - - # End session with timeout - timeout_event = self._end_session_timeout(machine_id, machine_name, current_time, config, state_info) - if timeout_event: - timeout_events.append(timeout_event) - - return timeout_events - - def process_event(self, event: Dict) -> Optional[Dict]: - """ - Process a normalized event and detect session changes - - Args: - event: Normalized event from EventNormalizer - - Returns: - Session event dict if state change occurred, None otherwise - - Session Event Format: - { - "session_id": "uuid4", - "event_type": "session_start" | "session_end", - "timestamp": "ISO 8601 UTC", - "machine": { "machine_id": "...", "machine_name": "..." }, - "power_w": 123.4, - "session_data": { - "start_time": "ISO 8601 UTC", - "end_time": "ISO 8601 UTC", # only for session_end - "duration_s": 123 # only for session_end - } - } - """ - try: - # Extract machine info - machine = event.get('machine', {}) - machine_id = machine.get('machine_id') - - if not machine_id: - self.logger.warning("Event missing machine_id, skipping") - return None - - # Get machine config - config = self.machine_config.get(machine_id) - if not config: - self.logger.warning(f"No config found for machine {machine_id}") - return None - - # Extract power measurement - metrics = event.get('metrics', {}) - power_w = metrics.get('power_w') - - if power_w is None: - self.logger.debug(f"Event missing power_w, skipping") - return None - - # Initialize machine state if needed - if machine_id not in self.machine_states: - self.machine_states[machine_id] = { - 'state': SessionState.IDLE, - 'state_since': datetime.now(timezone.utc), - 'current_session_id': None, - 'session_start_time': None, - 'last_power': None, - 'last_message_time': None, - 'standby_duration_s': 0, - 'working_duration_s': 0, - 'last_state_change': datetime.now(timezone.utc), - } - - state_info = self.machine_states[machine_id] - timestamp = datetime.fromisoformat(event['timestamp'].replace('Z', '+00:00')) - machine_name = machine.get('machine_name', 'Unknown') - - # Check for timeout (no message received) - if state_info['last_message_time']: - time_since_last_message = (timestamp - state_info['last_message_time']).total_seconds() - if time_since_last_message > config['message_timeout_s']: - if state_info['state'] in [SessionState.STANDBY, SessionState.WORKING]: - self.logger.warning( - f"{machine_name}: Message timeout " - f"({time_since_last_message:.0f}s > {config['message_timeout_s']}s) → SESSION END" - ) - return self._end_session_timeout(machine_id, machine_name, timestamp, config, state_info) - - # Update last message time - state_info['last_message_time'] = timestamp - - # Update last power - state_info['last_power'] = power_w - - # Process state machine - return self._process_state_machine( - machine_id=machine_id, - machine_name=machine.get('machine_name'), - power_w=power_w, - timestamp=timestamp, - config=config, - state_info=state_info - ) - - except Exception as e: - self.logger.error(f"Error processing event: {e}", exc_info=True) - return None - - def _process_state_machine( - self, - machine_id: str, - machine_name: str, - power_w: float, - timestamp: datetime, - config: Dict, - state_info: Dict - ) -> Optional[Dict]: - """ - Process state machine logic with dual thresholds - - Returns session event if state change occurred - """ - current_state = state_info['state'] - standby_threshold = config['standby_threshold_w'] - working_threshold = config['working_threshold_w'] - start_debounce = config['start_debounce_s'] - stop_debounce = config['stop_debounce_s'] - - time_in_state = (timestamp - state_info['state_since']).total_seconds() - - # Update duration tracking for active states - if current_state == SessionState.STANDBY: - time_since_last_update = (timestamp - state_info['last_state_change']).total_seconds() - state_info['standby_duration_s'] += time_since_last_update - state_info['last_state_change'] = timestamp - elif current_state == SessionState.WORKING: - time_since_last_update = (timestamp - state_info['last_state_change']).total_seconds() - state_info['working_duration_s'] += time_since_last_update - state_info['last_state_change'] = timestamp - - # State machine transitions - if current_state == SessionState.IDLE: - if power_w >= standby_threshold: - # Transition to STARTING - self.logger.info(f"🟡 {machine_name}: Power {power_w:.1f}W >= {standby_threshold}W → STARTING") - state_info['state'] = SessionState.STARTING - state_info['state_since'] = timestamp - - elif current_state == SessionState.STARTING: - if power_w < standby_threshold: - # False start, back to IDLE - self.logger.info(f"⚪ {machine_name}: Power dropped before debounce → IDLE") - state_info['state'] = SessionState.IDLE - state_info['state_since'] = timestamp - - elif time_in_state >= start_debounce: - # Debounce passed, transition to STANDBY or WORKING - session_id = str(uuid.uuid4()) - state_info['current_session_id'] = session_id - state_info['session_start_time'] = timestamp - state_info['standby_duration_s'] = 0 - state_info['working_duration_s'] = 0 - state_info['last_state_change'] = timestamp - - if power_w >= working_threshold: - state_info['state'] = SessionState.WORKING - self.logger.info(f"🔵 {machine_name}: Session START → WORKING (debounce {time_in_state:.1f}s) - Power: {power_w:.1f}W") - else: - state_info['state'] = SessionState.STANDBY - self.logger.info(f"🟢 {machine_name}: Session START → STANDBY (debounce {time_in_state:.1f}s) - Power: {power_w:.1f}W") - - state_info['state_since'] = timestamp - - # Generate session_start event - return { - 'session_id': session_id, - 'event_type': 'session_start', - 'timestamp': timestamp.isoformat().replace('+00:00', 'Z'), - 'machine': { - 'machine_id': machine_id, - 'machine_name': machine_name - }, - 'power_w': power_w, - 'session_data': { - 'start_time': timestamp.isoformat().replace('+00:00', 'Z') - } - } - - elif current_state == SessionState.STANDBY: - if power_w < standby_threshold: - # Transition to STOPPING - self.logger.info(f"🟠 {machine_name}: Power {power_w:.1f}W < {standby_threshold}W → STOPPING") - state_info['state'] = SessionState.STOPPING - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - elif power_w >= working_threshold: - # Transition to WORKING - self.logger.info(f"🔵 {machine_name}: Power {power_w:.1f}W >= {working_threshold}W → WORKING") - state_info['state'] = SessionState.WORKING - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - elif current_state == SessionState.WORKING: - if power_w < standby_threshold: - # Transition to STOPPING - self.logger.info(f"🟠 {machine_name}: Power {power_w:.1f}W < {standby_threshold}W → STOPPING") - state_info['state'] = SessionState.STOPPING - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - elif power_w < working_threshold: - # Transition to STANDBY - self.logger.info(f"🟢 {machine_name}: Power {power_w:.1f}W < {working_threshold}W → STANDBY") - state_info['state'] = SessionState.STANDBY - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - elif current_state == SessionState.STOPPING: - if power_w >= standby_threshold: - # Power back up, cancel STOPPING - if power_w >= working_threshold: - self.logger.info(f"🔵 {machine_name}: Power back up → WORKING") - state_info['state'] = SessionState.WORKING - else: - self.logger.info(f"🟢 {machine_name}: Power back up → STANDBY") - state_info['state'] = SessionState.STANDBY - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - else: - # Power still low, check if debounce passed - if time_in_state >= stop_debounce: - # Debounce passed, session ended - return self._end_session_normal(machine_id, machine_name, power_w, timestamp, state_info) - - return None - - def _end_session_normal( - self, - machine_id: str, - machine_name: str, - power_w: float, - timestamp: datetime, - state_info: Dict - ) -> Dict: - """End session normally (power drop)""" - session_id = state_info['current_session_id'] - start_time = state_info['session_start_time'] - duration_s = (timestamp - start_time).total_seconds() - - standby_duration = state_info.get('standby_duration_s') or 0 - working_duration = state_info.get('working_duration_s') or 0 - - self.logger.info( - f"🔴 {machine_name}: Session END (power drop) - " - f"Total: {duration_s:.0f}s, Standby: {standby_duration:.0f}s, Working: {working_duration:.0f}s" - ) - - # Generate session_end event - session_event = { - 'session_id': session_id, - 'event_type': 'session_end', - 'timestamp': timestamp.isoformat().replace('+00:00', 'Z'), - 'machine': { - 'machine_id': machine_id, - 'machine_name': machine_name - }, - 'power_w': power_w, - 'session_data': { - 'start_time': start_time.isoformat().replace('+00:00', 'Z'), - 'end_time': timestamp.isoformat().replace('+00:00', 'Z'), - 'total_duration_s': int(duration_s), - 'standby_duration_s': int(standby_duration), - 'working_duration_s': int(working_duration), - 'end_reason': 'power_drop' - } - } - - # Reset to IDLE - self._reset_session_state(state_info, timestamp) - - return session_event - - def _end_session_timeout( - self, - machine_id: str, - machine_name: str, - timestamp: datetime, - config: Dict, - state_info: Dict - ) -> Dict: - """End session due to timeout (no messages)""" - session_id = state_info['current_session_id'] - start_time = state_info['session_start_time'] - duration_s = (timestamp - start_time).total_seconds() - - standby_duration = state_info.get('standby_duration_s') or 0 - working_duration = state_info.get('working_duration_s') or 0 - - self.logger.warning( - f"⏱️ {machine_name}: Session END (TIMEOUT) - " - f"Total: {duration_s:.0f}s, Standby: {standby_duration:.0f}s, Working: {working_duration:.0f}s" - ) - - # Generate session_end event - session_event = { - 'session_id': session_id, - 'event_type': 'session_end', - 'timestamp': timestamp.isoformat().replace('+00:00', 'Z'), - 'machine': { - 'machine_id': machine_id, - 'machine_name': machine_name - }, - 'power_w': 0.0, # Assume power is 0 on timeout - 'session_data': { - 'start_time': start_time.isoformat().replace('+00:00', 'Z'), - 'end_time': timestamp.isoformat().replace('+00:00', 'Z'), - 'total_duration_s': int(duration_s), - 'standby_duration_s': int(standby_duration), - 'working_duration_s': int(working_duration), - 'end_reason': 'timeout' - } - } - - # Reset to IDLE - self._reset_session_state(state_info, timestamp) - - return session_event - - def _reset_session_state(self, state_info: Dict, timestamp: datetime): - """Reset session state to IDLE""" - state_info['state'] = SessionState.IDLE - state_info['state_since'] = timestamp - state_info['current_session_id'] = None - state_info['session_start_time'] = None - state_info['standby_duration_s'] = 0 - state_info['working_duration_s'] = 0 - state_info['last_state_change'] = timestamp - - def get_machine_state(self, machine_id: str) -> Optional[str]: - """Get current state of a machine""" - state_info = self.machine_states.get(machine_id) - if state_info: - return state_info['state'].value - return None - - def get_active_sessions(self) -> Dict: - """ - Get all currently active sessions - - Returns dict: machine_id -> session info - """ - active_sessions = {} - - for machine_id, state_info in self.machine_states.items(): - if state_info['state'] == SessionState.RUNNING: - active_sessions[machine_id] = { - 'session_id': state_info['current_session_id'], - 'start_time': state_info['session_start_time'].isoformat().replace('+00:00', 'Z'), - 'current_power': state_info['last_power'] - } - - return active_sessions diff --git a/python_proto_type/setup.sh b/python_proto_type/setup.sh deleted file mode 100755 index a02a885..0000000 --- a/python_proto_type/setup.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -# Setup script for Open Workshop IoT Bridge Python Prototype - -echo "===================================" -echo "Open Workshop IoT Bridge - Setup" -echo "===================================" -echo "" - -# Check if Python 3 is installed -if ! command -v python3 &> /dev/null; then - echo "Error: Python 3 is not installed" - exit 1 -fi - -echo "Python version: $(python3 --version)" -echo "" - -# Create virtual environment -echo "Creating virtual environment..." -python3 -m venv venv - -if [ $? -ne 0 ]; then - echo "Error: Failed to create virtual environment" - exit 1 -fi - -echo "✓ Virtual environment created" -echo "" - -# Activate virtual environment -echo "Activating virtual environment..." -source venv/bin/activate - -# Upgrade pip -echo "Upgrading pip..." -pip install --upgrade pip - -# Install requirements -echo "" -echo "Installing dependencies from requirements.txt..." -pip install -r requirements.txt - -if [ $? -ne 0 ]; then - echo "Error: Failed to install requirements" - exit 1 -fi - -echo "" -echo "✓ Dependencies installed" -echo "" - -# Check if config.yaml exists -if [ ! -f "config.yaml" ]; then - echo "Creating config.yaml from template..." - cp config.yaml.example config.yaml - echo "✓ config.yaml created" - echo "" - echo "⚠️ Please edit config.yaml with your MQTT broker settings!" - echo "" -fi - -# Create data and logs directories -mkdir -p data logs - -echo "===================================" -echo "Setup complete!" -echo "===================================" -echo "" -echo "Next steps:" -echo " 1. Edit config.yaml with your MQTT broker settings" -echo " 2. Activate venv: source venv/bin/activate" -echo " 3. Run the bridge: python main.py" -echo "" diff --git a/python_proto_type/shelly_parser.py b/python_proto_type/shelly_parser.py deleted file mode 100644 index bc8b0f9..0000000 --- a/python_proto_type/shelly_parser.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -""" -Shelly PM Mini G3 Parser -Parst MQTT Messages vom Shelly PM Mini G3 -""" - -import logging -from typing import Dict, Optional -from datetime import datetime - - -class ShellyParser: - """Parser für Shelly PM Mini G3 MQTT Messages""" - - def __init__(self, device_config: list = None): - """ - Initialize parser - - Args: - device_config: List of device configurations from config.yaml - """ - self.logger = logging.getLogger(__name__) - self.device_config = device_config or [] - - # Build topic-prefix to machine mapping - self.prefix_machine_map = {} - for device in self.device_config: - topic_prefix = device.get('topic_prefix') - if topic_prefix: - self.prefix_machine_map[topic_prefix] = { - 'machine_id': device.get('machine_id'), - 'machine_name': device.get('machine_name'), - 'device_id': None # Will be extracted from messages - } - - def parse_message(self, topic: str, payload: dict) -> Optional[Dict]: - """ - Parse Shelly MQTT message - - Args: - topic: MQTT topic - payload: Message payload (already parsed as JSON) - - Returns: - Parsed data dict or None if not a relevant message - """ - try: - # Ignore debug logs - if 'debug/log' in topic: - return None - - # Parse different message types - if '/status/pm1:0' in topic: - return self._parse_status_message(topic, payload) - elif '/events/rpc' in topic: - return self._parse_rpc_event(topic, payload) - elif '/telemetry' in topic: - return self._parse_telemetry(topic, payload) - - return None - - except Exception as e: - self.logger.error(f"Error parsing message from {topic}: {e}", exc_info=True) - return None - - def _parse_status_message(self, topic: str, payload: dict) -> Dict: - """ - Parse full status message - Topic: shaperorigin/status/pm1:0 - - Payload format (Shelly PM Mini G3): - { - "id": 0, - "voltage": 230.0, - "current": 0.217, - "apower": 50.0, - "freq": 50.0, - "aenergy": {"total": 12345.6, "by_minute": [...], "minute_ts": 1234567890}, - "temperature": {"tC": 35.2, "tF": 95.4} - } - """ - try: - # Extract device ID from topic prefix - device_id = self._extract_device_id_from_topic(topic) - - data = { - 'message_type': 'status', - 'device_id': device_id, - 'timestamp': datetime.utcnow().isoformat() + 'Z', - 'voltage': payload.get('voltage'), - 'current': payload.get('current'), - 'apower': payload.get('apower'), # Active Power in Watts - 'frequency': payload.get('freq'), - 'total_energy': payload.get('aenergy', {}).get('total'), - } - - self.logger.debug(f"Parsed status message: apower={data['apower']}W") - return data - - except Exception as e: - self.logger.error(f"Error parsing status message: {e}", exc_info=True) - return None - - def _parse_rpc_event(self, topic: str, payload: dict) -> Optional[Dict]: - """ - Parse RPC NotifyStatus event - Topic: shellypmminig3/events/rpc - """ - try: - if payload.get('method') != 'NotifyStatus': - return None - - device_id = payload.get('src', '').replace('shellypmminig3-', '') - params = payload.get('params', {}) - pm_data = params.get('pm1:0', {}) - - # Get timestamp from params or use current time - ts = params.get('ts') - if ts: - timestamp = datetime.utcfromtimestamp(ts).isoformat() + 'Z' - else: - timestamp = datetime.utcnow().isoformat() + 'Z' - - data = { - 'message_type': 'event', - 'device_id': device_id, - 'timestamp': timestamp, - 'apower': pm_data.get('apower'), - 'current': pm_data.get('current'), - 'voltage': pm_data.get('voltage'), - } - - # Only return if we have actual data - if data['apower'] is not None or data['current'] is not None: - self.logger.debug(f"Parsed RPC event: {pm_data}") - return data - - return None - - except Exception as e: - self.logger.error(f"Error parsing RPC event: {e}", exc_info=True) - return None - - def _parse_telemetry(self, topic: str, payload: dict) -> Dict: - """ - Parse telemetry message - Topic: shelly/pmmini/shellypmminig3-48f6eeb73a1c/telemetry - """ - # Extract device ID from topic - parts = topic.split('/') - device_id = parts[2] if len(parts) > 2 else 'unknown' - device_id = device_id.replace('shellypmminig3-', '') - - # Get timestamp - ts = payload.get('ts') - if ts: - timestamp = datetime.utcfromtimestamp(ts).isoformat() + 'Z' - else: - timestamp = datetime.utcnow().isoformat() + 'Z' - - data = { - 'message_type': 'telemetry', - 'device_id': device_id, - 'timestamp': timestamp, - 'voltage': payload.get('voltage_v'), - 'current': payload.get('current_a'), - 'apower': payload.get('power_w'), # Active Power in Watts - 'frequency': payload.get('freq_hz'), - 'total_energy': payload.get('energy_wh'), - } - - self.logger.debug(f"Parsed telemetry: apower={data['apower']}W") - return data - - def _extract_device_id_from_topic(self, topic: str) -> str: - """Extract device ID (topic prefix) from topic string""" - # Topic format: shaperorigin/status/pm1:0 - # Extract the prefix (shaperorigin) - parts = topic.split('/') - if len(parts) > 0: - topic_prefix = parts[0] - # Check if this prefix is in our config - if topic_prefix in self.prefix_machine_map: - # Use the actual device_id if we've learned it, otherwise use prefix - device_info = self.prefix_machine_map[topic_prefix] - return device_info.get('device_id') or topic_prefix - return topic_prefix - return 'unknown' - - def get_power_value(self, parsed_data: Dict) -> Optional[float]: - """ - Extract power value from parsed data - - Returns: - Power in Watts or None - """ - return parsed_data.get('apower') - - def get_device_id(self, parsed_data: Dict) -> str: - """Get device ID from parsed data""" - return parsed_data.get('device_id', 'unknown') diff --git a/python_proto_type/tests/__init__.py b/python_proto_type/tests/__init__.py deleted file mode 100644 index 73e40f0..0000000 --- a/python_proto_type/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tests für Open Workshop IoT Bridge -""" diff --git a/python_proto_type/tests/integration/__init__.py b/python_proto_type/tests/integration/__init__.py deleted file mode 100644 index 343db61..0000000 --- a/python_proto_type/tests/integration/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Integration Tests - Testen mit echten externen Services (MQTT Broker, Storage) -""" diff --git a/python_proto_type/tests/integration/test_mqtt_integration.py b/python_proto_type/tests/integration/test_mqtt_integration.py deleted file mode 100644 index ce61021..0000000 --- a/python_proto_type/tests/integration/test_mqtt_integration.py +++ /dev/null @@ -1,550 +0,0 @@ -#!/usr/bin/env python3 -""" -Integration Tests mit echtem MQTT Broker - -Testet die komplette Pipeline: -MQTT → Parser → Normalizer → SessionDetector → Storage - -Usage: - pytest test_mqtt_integration.py -v -s -""" - -import pytest -import subprocess -import time -import json -import signal -import os -from pathlib import Path -from datetime import datetime -import paho.mqtt.client as mqtt_client -import ssl - - -@pytest.fixture(scope="session") -def mqtt_config(): - """MQTT Broker Konfiguration aus Umgebungsvariablen oder config.yaml""" - - # Option 1: Aus Umgebungsvariablen - if os.getenv('MQTT_HOST'): - return { - 'host': os.getenv('MQTT_HOST'), - 'port': int(os.getenv('MQTT_PORT', '8883')), - 'username': os.getenv('MQTT_USERNAME'), - 'password': os.getenv('MQTT_PASSWORD'), - 'topic_prefix': os.getenv('MQTT_TEST_TOPIC_PREFIX', 'pytest-test') - } - - # Option 2: Aus config.yaml lesen - config_path = Path(__file__).parent.parent.parent / 'config.yaml' - if config_path.exists(): - import yaml - with open(config_path) as f: - config = yaml.safe_load(f) - - mqtt_conf = config.get('mqtt', {}) - return { - 'host': mqtt_conf.get('host'), - 'port': mqtt_conf.get('port', 8883), - 'username': mqtt_conf.get('username'), - 'password': mqtt_conf.get('password'), - 'topic_prefix': 'pytest-test' # Immer separates Topic für Tests - } - - pytest.skip("Keine MQTT Konfiguration gefunden. Setze Umgebungsvariablen oder erstelle config.yaml") - - -@pytest.fixture(scope="session") -def workspace_dir(): - """Workspace-Verzeichnis""" - return Path(__file__).parent - - -@pytest.fixture(scope="session") -def test_config_file(workspace_dir, mqtt_config): - """Erstellt temporäre test_config.yaml""" - # Relative Pfade zu den Test Storage Files (relativ zum Projektroot) - config_content = f""" -mqtt: - host: "{mqtt_config['host']}" - port: {mqtt_config['port']} - username: "{mqtt_config['username']}" - password: "{mqtt_config['password']}" - client_id: "ows_iot_bridge_pytest" - keepalive: 60 - topics: - - "{mqtt_config['topic_prefix']}-m1/#" - - "{mqtt_config['topic_prefix']}-m2/#" -devices: - - topic_prefix: "{mqtt_config['topic_prefix']}-m1" - machine_name: "PyTest Machine" - machine_id: "pytest-machine-01" - device_type: "shelly_pm_mini_g3" - standby_threshold_w: 20 - working_threshold_w: 100 - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - enabled: true - - topic_prefix: "{mqtt_config['topic_prefix']}-m2" - machine_name: "PyTest CNC" - machine_id: "pytest-machine-02" - device_type: "shelly_pm_mini_g3" - standby_threshold_w: 30 - working_threshold_w: 150 - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - enabled: true - -logging: - level: "INFO" - console: true - -output: - events_file: "data/test_events.jsonl" - sessions_file: "data/test_sessions.json" -""" - - config_path = workspace_dir / "test_config.yaml" - config_path.write_text(config_content) - - yield config_path - - # Cleanup - if config_path.exists(): - config_path.unlink() - - -@pytest.fixture(scope="session") -def test_storage_files(workspace_dir): - """Gibt Pfade zu Test-Storage-Dateien zurück (werden von Bridge erstellt)""" - # Test-Dateien im Projektroot data/ Verzeichnis - project_root = workspace_dir.parent.parent - data_dir = project_root / "data" - - events_file = data_dir / "test_events.jsonl" - sessions_file = data_dir / "test_sessions.json" - - # NICHT löschen - zum Debugging beibehalten! - - yield { - 'events': events_file, - 'sessions': sessions_file - } - - # Cleanup nach allen Tests (optional auskommentiert) - # if events_file.exists(): - # events_file.unlink() - # if sessions_file.exists(): - # sessions_file.unlink() - - -@pytest.fixture(scope="module") -def bridge_process(workspace_dir, test_config_file, test_storage_files): - """Startet die IoT Bridge als Subprocess""" - - # Bridge starten mit test_config.yaml - # cwd muss das Projektroot sein, nicht tests/integration/ - project_root = workspace_dir.parent.parent - config_path = workspace_dir / 'test_config.yaml' - - env = os.environ.copy() - env['PYTHONUNBUFFERED'] = '1' - - # Use python from venv explicitly - python_exe = project_root / 'venv' / 'bin' / 'python' - bridge_log = workspace_dir / 'bridge_output.log' - - with open(bridge_log, 'w') as log_file: - process = subprocess.Popen( - [str(python_exe), 'main.py', '--config', str(config_path)], - cwd=project_root, - env=env, - stdout=log_file, - stderr=subprocess.STDOUT, - text=True - ) - - # Warten bis Bridge gestartet ist - time.sleep(3) - - # Prüfen ob Prozess läuft - if process.poll() is not None: - with open(bridge_log, 'r') as f: - output = f.read() - pytest.fail(f"Bridge konnte nicht gestartet werden:\n{output}") - - # Debug: Bridge Output ausgeben - with open(bridge_log, 'r') as f: - output = f.read() - if output: - print(f"\n=== Bridge Output ===\n{output}\n===\n") - - yield process - - # Bridge sauber beenden - process.send_signal(signal.SIGTERM) - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - - -@pytest.fixture -def mqtt_sender(mqtt_config): - """MQTT Client zum Senden von Test-Messages""" - - client_id = f"pytest-sender-{int(time.time())}" - client = mqtt_client.Client(client_id=client_id, protocol=mqtt_client.MQTTv5) - client.username_pw_set(mqtt_config['username'], mqtt_config['password']) - - # TLS/SSL - client.tls_set(cert_reqs=ssl.CERT_NONE) - client.tls_insecure_set(True) - - client.connect(mqtt_config['host'], mqtt_config['port'], keepalive=60) - client.loop_start() - time.sleep(1) - - yield client - - client.loop_stop() - client.disconnect() - - -def send_shelly_message(mqtt_sender, topic: str, power_w: float): - """Sendet eine Shelly PM Mini G3 Status Message""" - message = { - "id": 0, - "voltage": 230.0, - "current": round(power_w / 230.0, 3), - "apower": power_w, - "freq": 50.0, - "aenergy": { - "total": 12345.6, - "by_minute": [0.0, 0.0, 0.0], - "minute_ts": int(time.time()) - }, - "temperature": { - "tC": 35.2, - "tF": 95.4 - } - } - - mqtt_sender.publish(topic, json.dumps(message), qos=1) - time.sleep(0.1) # Kurze Pause zwischen Messages - - -def read_sessions(sessions_file: Path): - """Liest sessions.json""" - if not sessions_file.exists(): - return [] - - content = sessions_file.read_text().strip() - if not content or content == "[]": - return [] - - return json.loads(content) - - -def wait_for_session_count(sessions_file: Path, expected_count: int, timeout: int = 10): - """Wartet bis die erwartete Anzahl Sessions vorhanden ist""" - start_time = time.time() - - while time.time() - start_time < timeout: - sessions = read_sessions(sessions_file) - if len(sessions) >= expected_count: - return sessions - time.sleep(0.5) - - sessions = read_sessions(sessions_file) - pytest.fail( - f"Timeout: Erwartete {expected_count} Sessions, gefunden: {len(sessions)}\n" - f"Sessions: {json.dumps(sessions, indent=2)}" - ) - - -class TestMQTTIntegration: - """Integration Tests mit echtem MQTT""" - - def test_bridge_is_running(self, bridge_process): - """Bridge läuft erfolgreich""" - assert bridge_process.poll() is None, "Bridge Prozess ist abgestürzt" - - def test_session_start_standby(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Session Start → STANDBY""" - topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - # Anzahl Sessions vor dem Test - initial_count = len(read_sessions(sessions_file)) - - # Power ansteigen lassen - wie reale Maschine - send_shelly_message(mqtt_sender, topic, 0) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 30) # STARTING - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) # Nach 3s → STANDBY - - # Warten auf Session-Start (3s debounce + Verarbeitung) - time.sleep(3) - - # Session prüfen - sessions = wait_for_session_count(sessions_file, initial_count + 1, timeout=5) - - # Neueste Session - latest = sessions[-1] - assert latest['machine_id'] == 'pytest-machine-01' - assert latest['status'] == 'running' - assert latest['start_power_w'] >= 20 - - def test_session_end_with_stop_debounce(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Session Ende mit stop_debounce (15s unter 20W)""" - topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - initial_count = len(read_sessions(sessions_file)) - - # Session starten - send_shelly_message(mqtt_sender, topic, 0) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(2) # Session sollte jetzt laufen - - # STANDBY für 3 Sekunden - for _ in range(3): - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - - # Power runterfahren → STOPPING - send_shelly_message(mqtt_sender, topic, 15) - - # STOPPING für 16 Sekunden (> 15s stop_debounce) - for i in range(16): - send_shelly_message(mqtt_sender, topic, 10 - i * 0.5) - time.sleep(1) - - # Warten auf Session-Ende - time.sleep(2) - - # Prüfen ob Session beendet wurde - sessions = read_sessions(sessions_file) - - # Finde die Session die gerade beendet wurde - completed_sessions = [s for s in sessions if s.get('status') == 'completed' and s.get('end_reason') == 'power_drop'] - - assert len(completed_sessions) > 0, f"Keine completed Session mit power_drop gefunden. Sessions: {json.dumps(sessions[-3:], indent=2)}" - - latest = completed_sessions[-1] - assert latest['end_reason'] == 'power_drop' - assert latest['total_duration_s'] is not None - assert latest['standby_duration_s'] is not None - - def test_standby_to_working_transition(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """STANDBY → WORKING Transition""" - topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - # Session im STANDBY starten - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(2) - - # STANDBY halten - for _ in range(3): - send_shelly_message(mqtt_sender, topic, 60) - time.sleep(1) - - # → WORKING - send_shelly_message(mqtt_sender, topic, 120) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 150) - - # WORKING halten - for _ in range(5): - send_shelly_message(mqtt_sender, topic, 150) - time.sleep(1) - - # Session beenden - send_shelly_message(mqtt_sender, topic, 10) - for _ in range(16): - send_shelly_message(mqtt_sender, topic, 5) - time.sleep(1) - - time.sleep(2) - - # Prüfen - sessions = read_sessions(sessions_file) - completed = [s for s in sessions if s.get('status') == 'completed'] - - assert len(completed) > 0 - latest = completed[-1] - - # Sollte sowohl STANDBY als auch WORKING Zeit haben - assert latest.get('standby_duration_s', 0) > 0, "Keine STANDBY Zeit" - assert latest.get('working_duration_s', 0) > 0, "Keine WORKING Zeit" - - def test_timeout_detection(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Timeout Detection (20s keine Messages)""" - topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - initial_count = len(read_sessions(sessions_file)) - - # Session starten - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(2) - - # Letzte Message - send_shelly_message(mqtt_sender, topic, 50) - - # 25 Sekunden warten (> 20s timeout) - print("\n⏱️ Warte 25 Sekunden für Timeout Detection...") - time.sleep(25) - - # Prüfen ob Session mit timeout beendet wurde - sessions = read_sessions(sessions_file) - timeout_sessions = [s for s in sessions if s.get('end_reason') == 'timeout'] - - assert len(timeout_sessions) > 0, f"Keine timeout Session gefunden. Sessions: {json.dumps(sessions[-3:], indent=2)}" - - def test_multi_device_parallel_sessions(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Zwei Maschinen starten parallel Sessions""" - topic_machine1 = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - topic_machine2 = f"{mqtt_config['topic_prefix']}-m2/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - initial_count = len(read_sessions(sessions_file)) - - print("\n🔄 Starte parallele Sessions auf 2 Maschinen...") - - # Maschine 1: Session starten (20W threshold) - send_shelly_message(mqtt_sender, topic_machine1, 50) - time.sleep(0.5) - - # Maschine 2: Session starten (30W threshold) - send_shelly_message(mqtt_sender, topic_machine2, 80) - time.sleep(0.5) - - # Beide Maschinen halten - for _ in range(4): - send_shelly_message(mqtt_sender, topic_machine1, 60) - send_shelly_message(mqtt_sender, topic_machine2, 100) - time.sleep(1) - - # Warten auf Sessions - time.sleep(3) - - # Beide Maschinen sollten jetzt laufende Sessions haben - sessions = read_sessions(sessions_file) - new_sessions = [s for s in sessions if len(sessions) > initial_count][-2:] # Letzte 2 neue Sessions - running_sessions = [s for s in new_sessions if s.get('status') == 'running'] - - assert len(running_sessions) == 2, f"Erwartet exakt 2 neue laufende Sessions, gefunden: {len(running_sessions)}" - - # Prüfen dass beide machine_ids vorhanden sind - machine_ids = [s['machine_id'] for s in running_sessions] - assert 'pytest-machine-01' in machine_ids, "Maschine 1 Session fehlt" - assert 'pytest-machine-02' in machine_ids, "Maschine 2 Session fehlt" - - print(f"✅ 2 parallele Sessions erfolgreich: {machine_ids}") - - def test_multi_device_independent_timeouts(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Maschine 1 Timeout, Maschine 2 läuft weiter""" - topic_machine1 = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - topic_machine2 = f"{mqtt_config['topic_prefix']}-m2/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - print("\n⏱️ Teste unabhängige Timeouts...") - - # Beide Maschinen starten - send_shelly_message(mqtt_sender, topic_machine1, 50) - send_shelly_message(mqtt_sender, topic_machine2, 80) - time.sleep(1) - - for _ in range(3): - send_shelly_message(mqtt_sender, topic_machine1, 50) - send_shelly_message(mqtt_sender, topic_machine2, 80) - time.sleep(1) - - time.sleep(2) - - # Maschine 1: Letzte Message, dann Timeout - send_shelly_message(mqtt_sender, topic_machine1, 50) - - # Maschine 2: Läuft weiter - for _ in range(26): # 26 Sekunden - send_shelly_message(mqtt_sender, topic_machine2, 80) - time.sleep(1) - - # Prüfen - sessions = read_sessions(sessions_file) - - # Maschine 1 sollte timeout haben - m1_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-01'] - timeout_session = [s for s in m1_sessions if s.get('end_reason') == 'timeout'] - assert len(timeout_session) > 0, "Maschine 1 sollte Timeout-Session haben" - - # Maschine 2 sollte noch laufen - m2_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-02'] - running_session = [s for s in m2_sessions if s.get('status') == 'running'] - assert len(running_session) > 0, "Maschine 2 sollte noch laufen" - - print("✅ Unabhängige Timeouts funktionieren") - - def test_multi_device_different_thresholds(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Unterschiedliche Power-Thresholds pro Maschine""" - topic_machine1 = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - topic_machine2 = f"{mqtt_config['topic_prefix']}-m2/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - # Cleanup: Alte Sessions löschen für sauberen Test - if sessions_file.exists(): - sessions_file.unlink() - - print("\n🔧 Teste unterschiedliche Thresholds...") - - # Maschine 1: 25W (über 20W threshold) → sollte starten - for _ in range(4): - send_shelly_message(mqtt_sender, topic_machine1, 25) - time.sleep(1) - - time.sleep(3) - - # Maschine 2: 25W (unter 30W threshold) → sollte NICHT starten - for _ in range(4): - send_shelly_message(mqtt_sender, topic_machine2, 25) - time.sleep(1) - - time.sleep(3) - - sessions = read_sessions(sessions_file) - - # Maschine 1 sollte Session haben - m1_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-01' and s.get('status') == 'running'] - assert len(m1_sessions) > 0, "Maschine 1 sollte bei 25W Session haben (threshold 20W)" - - # Maschine 2 sollte KEINE Session haben - m2_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-02' and s.get('status') == 'running'] - assert len(m2_sessions) == 0, "Maschine 2 sollte bei 25W KEINE Session haben (threshold 30W)" - - print("✅ Unterschiedliche Thresholds funktionieren") - - -if __name__ == '__main__': - pytest.main([__file__, '-v', '-s']) diff --git a/python_proto_type/tests/tools/__init__.py b/python_proto_type/tests/tools/__init__.py deleted file mode 100644 index ccc6070..0000000 --- a/python_proto_type/tests/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Test Tools - Hilfsprogramme für manuelle Tests und Debugging -""" diff --git a/python_proto_type/tests/tools/shelly_simulator.py b/python_proto_type/tests/tools/shelly_simulator.py deleted file mode 100755 index 50fdf15..0000000 --- a/python_proto_type/tests/tools/shelly_simulator.py +++ /dev/null @@ -1,375 +0,0 @@ -#!/usr/bin/env python3 -""" -Test MQTT Client - Simuliert Shelly PM Mini G3 Verhalten - -Sendet MQTT Messages auf das Topic 'shaperorigin/status/pm1:0' -mit verschiedenen Test-Szenarien für die Session Detection. - -Usage: - python test_shelly_simulator.py --scenario standby - python test_shelly_simulator.py --scenario working - python test_shelly_simulator.py --scenario session_end - python test_shelly_simulator.py --scenario full_session -""" - -import argparse -import json -import os -import sys -import time -import ssl -from datetime import datetime -from pathlib import Path -from paho.mqtt import client as mqtt_client -import yaml - - -class ShellySimulator: - """Simuliert Shelly PM Mini G3 MQTT Messages""" - - def __init__(self, broker_host: str, broker_port: int, username: str, password: str, topic_prefix: str = "testshelly"): - self.broker_host = broker_host - self.broker_port = broker_port - self.username = username - self.password = password - self.topic_prefix = topic_prefix - self.topic = f"{topic_prefix}/status/pm1:0" - self.client = None - - def connect(self): - """Verbindung zum MQTT Broker herstellen""" - client_id = f"shelly-simulator-{int(time.time())}" - self.client = mqtt_client.Client(client_id=client_id, protocol=mqtt_client.MQTTv5) - self.client.username_pw_set(self.username, self.password) - - # TLS/SSL - self.client.tls_set(cert_reqs=ssl.CERT_NONE) - self.client.tls_insecure_set(True) - - print(f"Verbinde zu MQTT Broker {self.broker_host}:{self.broker_port}...") - self.client.connect(self.broker_host, self.broker_port, keepalive=60) - self.client.loop_start() - time.sleep(1) - print("✓ Verbunden") - - def disconnect(self): - """Verbindung trennen""" - if self.client: - self.client.loop_stop() - self.client.disconnect() - print("✓ Verbindung getrennt") - - def send_power_message(self, power_w: float, interval_s: int = 1): - """ - Sendet eine Shelly Status Message im echten Shelly PM Mini G3 Format - - Args: - power_w: Leistung in Watt - interval_s: Pause nach dem Senden in Sekunden - """ - # Echtes Shelly PM Mini G3 Format (ohne result wrapper) - message = { - "id": 0, - "voltage": 230.0, - "current": round(power_w / 230.0, 3), - "apower": power_w, - "freq": 50.0, - "aenergy": { - "total": round(12345.6 + (power_w * interval_s / 3600), 1), - "by_minute": [0.0, 0.0, 0.0], - "minute_ts": int(time.time()) - }, - "temperature": { - "tC": 35.2, - "tF": 95.4 - } - } - - payload = json.dumps(message) - self.client.publish(self.topic, payload, qos=1) - - timestamp = datetime.now().strftime("%H:%M:%S") - print(f"[{timestamp}] 📤 Gesendet: {power_w:.1f}W") - - if interval_s > 0: - time.sleep(interval_s) - - def scenario_standby(self): - """Szenario: Maschine im STANDBY (20-100W)""" - print("\n=== SZENARIO: STANDBY (20-100W) ===") - print("Simuliert: Maschine an, Spindel aus\n") - - # Anfahren - print("Phase 1: Anfahren (0 → 50W)") - self.send_power_message(0, 1) - self.send_power_message(10, 1) - self.send_power_message(30, 1) # > 20W → STARTING - self.send_power_message(45, 1) - self.send_power_message(50, 2) # Nach 3s debounce → STANDBY - - # STANDBY halten - print("\nPhase 2: STANDBY halten (50W für 20s)") - for i in range(20): - self.send_power_message(50 + (i % 5) * 2, 1) # 50-58W variieren - - print("\n✓ Szenario abgeschlossen") - - def scenario_working(self): - """Szenario: Maschine WORKING (>=100W)""" - print("\n=== SZENARIO: WORKING (>=100W) ===") - print("Simuliert: Spindel läuft\n") - - # Anfahren direkt zu WORKING - print("Phase 1: Anfahren (0 → 150W)") - self.send_power_message(0, 1) - self.send_power_message(30, 1) # > 20W → STARTING - self.send_power_message(80, 1) - self.send_power_message(120, 1) - self.send_power_message(150, 2) # Nach 3s debounce → WORKING - - # WORKING halten - print("\nPhase 2: WORKING halten (150W für 20s)") - for i in range(20): - self.send_power_message(150 + (i % 10) * 5, 1) # 150-195W variieren - - print("\n✓ Szenario abgeschlossen") - - def scenario_session_end(self): - """Szenario: Session sauber beenden mit stop_debounce""" - print("\n=== SZENARIO: SESSION END (mit stop_debounce 15s) ===") - print("Simuliert: Maschine einschalten → arbeiten → ausschalten\n") - - # Start: STANDBY - print("Phase 1: Session Start → STANDBY (50W)") - self.send_power_message(0, 1) - self.send_power_message(30, 1) # STARTING - self.send_power_message(50, 1) - self.send_power_message(50, 1) - self.send_power_message(50, 2) # Nach 3s → STANDBY - - # STANDBY 10s - print("\nPhase 2: STANDBY halten (50W für 10s)") - for _ in range(10): - self.send_power_message(50, 1) - - # Runterfahren - print("\nPhase 3: Herunterfahren (50W → 0W)") - self.send_power_message(40, 1) - self.send_power_message(25, 1) - self.send_power_message(15, 1) # < 20W → STOPPING - - # STOPPING für 15 Sekunden (stop_debounce) - print("\nPhase 4: STOPPING (< 20W für 15s = stop_debounce)") - for i in range(16): - self.send_power_message(10 - i * 0.6, 1) # 10W → ~0W über 15s - print(f" STOPPING seit {i+1}s / 15s") - - print("\n✓ Session sollte jetzt beendet sein (nach stop_debounce)") - print(" Erwartung: Session END mit end_reason='power_drop'") - - def scenario_full_session(self): - """Szenario: Komplette Session mit STANDBY→WORKING→STANDBY→END""" - print("\n=== SZENARIO: FULL SESSION ===") - print("Simuliert: Start → STANDBY → WORKING → STANDBY → END\n") - - # Start → STANDBY - print("Phase 1: Start → STANDBY (50W)") - self.send_power_message(0, 1) - self.send_power_message(30, 1) - self.send_power_message(50, 1) - self.send_power_message(50, 2) # → STANDBY - - # STANDBY 5s - print("\nPhase 2: STANDBY (50W für 5s)") - for _ in range(5): - self.send_power_message(55, 1) - - # STANDBY → WORKING - print("\nPhase 3: STANDBY → WORKING (50W → 150W)") - self.send_power_message(70, 1) - self.send_power_message(90, 1) - self.send_power_message(110, 1) # >= 100W → WORKING - self.send_power_message(130, 1) - self.send_power_message(150, 1) - - # WORKING 10s - print("\nPhase 4: WORKING (150W für 10s)") - for i in range(10): - self.send_power_message(150 + (i % 5) * 10, 1) - - # WORKING → STANDBY - print("\nPhase 5: WORKING → STANDBY (150W → 60W)") - self.send_power_message(120, 1) - self.send_power_message(90, 1) # < 100W → STANDBY - self.send_power_message(70, 1) - self.send_power_message(60, 1) - - # STANDBY 5s - print("\nPhase 6: STANDBY (60W für 5s)") - for _ in range(5): - self.send_power_message(60, 1) - - # Session END - print("\nPhase 7: Session END (60W → 0W mit stop_debounce)") - self.send_power_message(40, 1) - self.send_power_message(20, 1) - self.send_power_message(10, 1) # < 20W → STOPPING - - print("\nPhase 8: STOPPING (15s debounce)") - for i in range(16): - self.send_power_message(5 - i * 0.3, 1) - print(f" STOPPING seit {i+1}s / 15s") - - print("\n✓ Full Session abgeschlossen") - print(" Erwartung:") - print(" - Session START") - print(" - STANDBY → WORKING → STANDBY Transitionen") - print(" - Session END nach stop_debounce") - print(" - standby_duration_s und working_duration_s getrackt") - - def scenario_timeout(self): - """Szenario: Timeout Detection (keine Messages für 20s)""" - print("\n=== SZENARIO: TIMEOUT (message_timeout_s = 20s) ===") - print("Simuliert: Maschine läuft → plötzlicher Stromausfall (keine Messages mehr)\n") - - # Start → STANDBY - print("Phase 1: Session Start → STANDBY (50W)") - self.send_power_message(0, 1) - self.send_power_message(30, 1) - self.send_power_message(50, 1) - self.send_power_message(50, 2) # → STANDBY - - # STANDBY 10s - print("\nPhase 2: STANDBY (50W für 10s)") - for _ in range(10): - self.send_power_message(50, 1) - - # Keine Messages mehr (Stromausfall simulieren) - print("\n⚡ STROMAUSFALL: Keine Messages mehr für 25 Sekunden") - print(" (message_timeout_s = 20s)") - for i in range(25): - time.sleep(1) - print(f" Keine Messages seit {i+1}s / 20s", end="\r") - - print("\n\n✓ Timeout Szenario abgeschlossen") - print(" Erwartung: Session END nach 20s mit end_reason='timeout'") - - -def main(): - parser = argparse.ArgumentParser(description="Shelly PM Mini G3 MQTT Simulator") - parser.add_argument( - "--scenario", - choices=["standby", "working", "session_end", "full_session", "timeout", "loop"], - required=True, - help="Test-Szenario" - ) - parser.add_argument("--host", default=None, help="MQTT Broker Host (default: from env/config.yaml)") - parser.add_argument("--port", type=int, default=None, help="MQTT Broker Port (default: from env/config.yaml)") - parser.add_argument("--username", default=None, help="MQTT Username (default: from env/config.yaml)") - parser.add_argument("--password", default=None, help="MQTT Password (default: from env/config.yaml)") - parser.add_argument("--topic-prefix", default="testshelly", help="MQTT Topic Prefix (default: testshelly)") - - args = parser.parse_args() - - # Load credentials from environment or config.yaml if not provided via CLI - mqtt_config = {} - - if not all([args.host, args.port, args.username, args.password]): - # Try environment variables first - mqtt_config = { - 'host': os.getenv('MQTT_HOST'), - 'port': int(os.getenv('MQTT_PORT', 0)) if os.getenv('MQTT_PORT') else None, - 'username': os.getenv('MQTT_USERNAME'), - 'password': os.getenv('MQTT_PASSWORD') - } - - # If not all available, try config.yaml - if not all(mqtt_config.values()): - config_path = Path(__file__).parent.parent.parent / "config.yaml" - print(f"Lade MQTT Credentials aus {config_path}...") - if config_path.exists(): - try: - with open(config_path, 'r') as f: - config = yaml.safe_load(f) - mqtt_section = config.get('mqtt', {}) - mqtt_config = { - 'host': mqtt_section.get('host'), - 'port': mqtt_section.get('port'), - 'username': mqtt_section.get('username'), - 'password': mqtt_section.get('password') - } - except Exception as e: - print(f"⚠️ Warnung: Fehler beim Lesen von config.yaml: {e}") - - # Use CLI args or fallback to loaded config - host = args.host or mqtt_config.get('host') - port = args.port or mqtt_config.get('port') - username = args.username or mqtt_config.get('username') - password = args.password or mqtt_config.get('password') - - # Validate that we have all required credentials - if not all([host, port, username, password]): - print("❌ Fehler: MQTT Credentials fehlen!") - print("") - print("Bitte setze die Credentials über:") - print(" 1) Kommandozeilen-Parameter:") - print(" --host --port --username --password ") - print("") - print(" 2) Umgebungsvariablen:") - print(" export MQTT_HOST=mqtt.majufilo.eu") - print(" export MQTT_PORT=8883") - print(" export MQTT_USERNAME=mosquitto") - print(" export MQTT_PASSWORD=xxx") - print("") - print(" 3) config.yaml im Projektroot") - print("") - sys.exit(1) - - print("=" * 60) - print("SHELLY PM MINI G3 SIMULATOR") - print("=" * 60) - - simulator = ShellySimulator( - broker_host=host, - broker_port=port, - username=username, - password=password, - topic_prefix=args.topic_prefix - ) - - try: - simulator.connect() - - # Szenario ausführen - if args.scenario == "standby": - simulator.scenario_standby() - elif args.scenario == "working": - simulator.scenario_working() - elif args.scenario == "session_end": - simulator.scenario_session_end() - elif args.scenario == "full_session": - simulator.scenario_full_session() - elif args.scenario == "timeout": - simulator.scenario_timeout() - elif args.scenario == "loop": - print("Starte Endlosschleife (STRG+C zum Beenden)...") - while True: - simulator.scenario_full_session() - else: - print(f"❌ Unbekanntes Szenario: {args.scenario}") - exit(1) - - print("\n" + "=" * 60) - print("Test abgeschlossen. Prüfe sessions.json für Ergebnisse.") - print("=" * 60) - - except KeyboardInterrupt: - print("\n\n⚠️ Abgebrochen") - except Exception as e: - print(f"\n❌ Fehler: {e}") - finally: - simulator.disconnect() - - -if __name__ == "__main__": - main() diff --git a/python_proto_type/tests/unit/__init__.py b/python_proto_type/tests/unit/__init__.py deleted file mode 100644 index 8ff464d..0000000 --- a/python_proto_type/tests/unit/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Unit Tests - Testen einzelne Komponenten isoliert ohne externe Dependencies -""" diff --git a/python_proto_type/tests/unit/test_session_detector.py b/python_proto_type/tests/unit/test_session_detector.py deleted file mode 100644 index af7ef5b..0000000 --- a/python_proto_type/tests/unit/test_session_detector.py +++ /dev/null @@ -1,396 +0,0 @@ -#!/usr/bin/env python3 -""" -Unit Tests für SessionDetector - -Testet die Dual-Threshold Session Detection ohne MQTT Broker. -Direktes Testen der Logik mit simulierten Events. - -Usage: - pytest test_session_detector.py -v - pytest test_session_detector.py::test_standby_to_working -v -""" - -import pytest -from datetime import datetime, timezone, timedelta -from session_detector import SessionDetector, SessionState - - -@pytest.fixture -def detector(): - """SessionDetector mit Test-Konfiguration""" - config = [ - { - 'machine_id': 'test-machine-01', - 'machine_name': 'Test Machine', - 'standby_threshold_w': 20, - 'working_threshold_w': 100, - 'start_debounce_s': 3, - 'stop_debounce_s': 15, - 'message_timeout_s': 20, - } - ] - return SessionDetector(device_config=config) - - -def create_event(power_w: float, machine_id: str = 'test-machine-01', timestamp: datetime = None): - """Helper: Erstellt ein normalisiertes Event""" - if timestamp is None: - timestamp = datetime.now(timezone.utc) - - return { - 'event_id': 'test-event', - 'event_type': 'power_measurement', - 'timestamp': timestamp.isoformat().replace('+00:00', 'Z'), - 'machine': { - 'machine_id': machine_id, - 'machine_name': 'Test Machine' - }, - 'metrics': { - 'power_w': power_w - } - } - - -class TestSessionStart: - """Tests für Session-Start mit start_debounce""" - - def test_idle_to_starting(self, detector): - """Power >= 20W → STARTING""" - event = create_event(power_w=30) - result = detector.process_event(event) - - assert result is None # Noch kein Session-Start - assert detector.get_machine_state('test-machine-01') == 'starting' - - def test_starting_to_standby(self, detector): - """Nach 3s debounce mit 20-100W → STANDBY""" - start_time = datetime.now(timezone.utc) - - # T+0: Power 30W → STARTING - event1 = create_event(power_w=30, timestamp=start_time) - result1 = detector.process_event(event1) - assert result1 is None - - # T+1: Power 50W → noch STARTING - event2 = create_event(power_w=50, timestamp=start_time + timedelta(seconds=1)) - result2 = detector.process_event(event2) - assert result2 is None - - # T+3: Power 50W → STANDBY (debounce passed) - event3 = create_event(power_w=50, timestamp=start_time + timedelta(seconds=3)) - result3 = detector.process_event(event3) - - assert result3 is not None - assert result3['event_type'] == 'session_start' - assert result3['session_data']['start_time'] is not None - assert detector.get_machine_state('test-machine-01') == 'standby' - - def test_starting_to_working(self, detector): - """Nach 3s debounce mit >=100W → WORKING""" - start_time = datetime.now(timezone.utc) - - # T+0: Power 30W → STARTING - event1 = create_event(power_w=30, timestamp=start_time) - detector.process_event(event1) - - # T+1: Power 120W → noch STARTING - event2 = create_event(power_w=120, timestamp=start_time + timedelta(seconds=1)) - detector.process_event(event2) - - # T+3: Power 150W → WORKING (debounce passed) - event3 = create_event(power_w=150, timestamp=start_time + timedelta(seconds=3)) - result3 = detector.process_event(event3) - - assert result3 is not None - assert result3['event_type'] == 'session_start' - assert detector.get_machine_state('test-machine-01') == 'working' - - def test_false_start(self, detector): - """Power fällt vor debounce → zurück zu IDLE""" - start_time = datetime.now(timezone.utc) - - # T+0: Power 30W → STARTING - event1 = create_event(power_w=30, timestamp=start_time) - detector.process_event(event1) - - # T+1: Power 10W → zurück zu IDLE (false start) - event2 = create_event(power_w=10, timestamp=start_time + timedelta(seconds=1)) - result2 = detector.process_event(event2) - - assert result2 is None - assert detector.get_machine_state('test-machine-01') == 'idle' - - -class TestStateTransitions: - """Tests für Zustandsübergänge während Session""" - - def test_standby_to_working(self, detector): - """STANDBY → WORKING bei Power >= 100W""" - start_time = datetime.now(timezone.utc) - - # Session starten im STANDBY - detector.process_event(create_event(power_w=30, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - assert detector.get_machine_state('test-machine-01') == 'standby' - - # Power auf 150W → WORKING - event = create_event(power_w=150, timestamp=start_time + timedelta(seconds=10)) - result = detector.process_event(event) - - assert result is None # Keine session_end/start, nur Transition - assert detector.get_machine_state('test-machine-01') == 'working' - - def test_working_to_standby(self, detector): - """WORKING → STANDBY bei Power < 100W""" - start_time = datetime.now(timezone.utc) - - # Session starten im WORKING - detector.process_event(create_event(power_w=150, timestamp=start_time)) - detector.process_event(create_event(power_w=150, timestamp=start_time + timedelta(seconds=3))) - assert detector.get_machine_state('test-machine-01') == 'working' - - # Power auf 60W → STANDBY - event = create_event(power_w=60, timestamp=start_time + timedelta(seconds=10)) - result = detector.process_event(event) - - assert result is None - assert detector.get_machine_state('test-machine-01') == 'standby' - - -class TestSessionEnd: - """Tests für Session-Ende mit stop_debounce""" - - def test_session_end_from_standby(self, detector): - """STANDBY → STOPPING → IDLE nach 15s < 20W""" - start_time = datetime.now(timezone.utc) - - # Session starten - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # STANDBY für 10s - for i in range(10): - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3 + i))) - - # Power fällt unter 20W → STOPPING - t_stopping = start_time + timedelta(seconds=13) - event_stopping = create_event(power_w=10, timestamp=t_stopping) - result_stopping = detector.process_event(event_stopping) - - assert result_stopping is None - assert detector.get_machine_state('test-machine-01') == 'stopping' - - # STOPPING für 14s → noch keine Session-End - for i in range(14): - result = detector.process_event( - create_event(power_w=10, timestamp=t_stopping + timedelta(seconds=i + 1)) - ) - assert result is None # Noch kein Session-Ende - - # T+15: stop_debounce passed → SESSION END - event_end = create_event(power_w=5, timestamp=t_stopping + timedelta(seconds=15)) - result_end = detector.process_event(event_end) - - assert result_end is not None - assert result_end['event_type'] == 'session_end' - assert result_end['session_data']['end_reason'] == 'power_drop' - assert detector.get_machine_state('test-machine-01') == 'idle' - - def test_stopping_canceled_power_back_up(self, detector): - """STOPPING abgebrochen wenn Power wieder >= 20W""" - start_time = datetime.now(timezone.utc) - - # Session starten - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # Power fällt → STOPPING - t_stopping = start_time + timedelta(seconds=10) - detector.process_event(create_event(power_w=10, timestamp=t_stopping)) - assert detector.get_machine_state('test-machine-01') == 'stopping' - - # Nach 5s: Power wieder hoch → zurück zu STANDBY - event_back = create_event(power_w=60, timestamp=t_stopping + timedelta(seconds=5)) - result = detector.process_event(event_back) - - assert result is None # Kein Session-Ende - assert detector.get_machine_state('test-machine-01') == 'standby' - - -class TestDurationTracking: - """Tests für Zeiterfassung in STANDBY und WORKING""" - - def test_standby_duration_tracked(self, detector): - """standby_duration_s wird korrekt akkumuliert""" - start_time = datetime.now(timezone.utc) - - # Session Start → STANDBY - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # STANDBY für 10 Sekunden - for i in range(10): - detector.process_event( - create_event(power_w=50, timestamp=start_time + timedelta(seconds=3 + i)) - ) - - # Session beenden - t_end = start_time + timedelta(seconds=20) - detector.process_event(create_event(power_w=10, timestamp=t_end)) - - # STOPPING für genau 15 Sekunden (stop_debounce) - for i in range(14): - result = detector.process_event( - create_event(power_w=5, timestamp=t_end + timedelta(seconds=i + 1)) - ) - assert result is None # Noch kein Session-Ende - - # T+15: stop_debounce passed → SESSION END - result = detector.process_event( - create_event(power_w=0, timestamp=t_end + timedelta(seconds=15)) - ) - - assert result is not None - assert result['session_data']['standby_duration_s'] >= 9 # ~10s STANDBY - assert result['session_data']['working_duration_s'] == 0 # Kein WORKING - - def test_working_duration_tracked(self, detector): - """working_duration_s wird korrekt akkumuliert""" - start_time = datetime.now(timezone.utc) - - # Session Start → WORKING - detector.process_event(create_event(power_w=150, timestamp=start_time)) - detector.process_event(create_event(power_w=150, timestamp=start_time + timedelta(seconds=3))) - - # WORKING für 10 Sekunden - for i in range(10): - detector.process_event( - create_event(power_w=150, timestamp=start_time + timedelta(seconds=3 + i)) - ) - - # Session beenden - t_end = start_time + timedelta(seconds=20) - detector.process_event(create_event(power_w=10, timestamp=t_end)) - - # STOPPING für genau 15 Sekunden - for i in range(14): - result = detector.process_event( - create_event(power_w=5, timestamp=t_end + timedelta(seconds=i + 1)) - ) - assert result is None - - # T+15: stop_debounce passed - result = detector.process_event( - create_event(power_w=0, timestamp=t_end + timedelta(seconds=15)) - ) - - assert result is not None - assert result['session_data']['working_duration_s'] >= 9 # ~10s WORKING - assert result['session_data']['standby_duration_s'] == 0 # Kein STANDBY - - def test_mixed_standby_working_duration(self, detector): - """STANDBY und WORKING Zeiten separat getrackt""" - start_time = datetime.now(timezone.utc) - - # Start → STANDBY - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # STANDBY 5s - for i in range(5): - detector.process_event( - create_event(power_w=50, timestamp=start_time + timedelta(seconds=3 + i)) - ) - - # → WORKING - t_working = start_time + timedelta(seconds=8) - detector.process_event(create_event(power_w=150, timestamp=t_working)) - - # WORKING 5s - for i in range(5): - detector.process_event( - create_event(power_w=150, timestamp=t_working + timedelta(seconds=i + 1)) - ) - - # → STANDBY - t_standby2 = t_working + timedelta(seconds=6) - detector.process_event(create_event(power_w=60, timestamp=t_standby2)) - - # STANDBY 5s - for i in range(5): - detector.process_event( - create_event(power_w=60, timestamp=t_standby2 + timedelta(seconds=i + 1)) - ) - - # Session beenden - t_end = t_standby2 + timedelta(seconds=6) - detector.process_event(create_event(power_w=10, timestamp=t_end)) - - # STOPPING für genau 15 Sekunden - for i in range(14): - result = detector.process_event( - create_event(power_w=5, timestamp=t_end + timedelta(seconds=i + 1)) - ) - assert result is None - - # T+15: stop_debounce passed - result = detector.process_event( - create_event(power_w=0, timestamp=t_end + timedelta(seconds=15)) - ) - - assert result is not None - # ~10s STANDBY (5s + 5s) - assert result['session_data']['standby_duration_s'] >= 9 - assert result['session_data']['standby_duration_s'] <= 12 - # ~5s WORKING - assert result['session_data']['working_duration_s'] >= 4 - assert result['session_data']['working_duration_s'] <= 7 - - -class TestTimeoutDetection: - """Tests für Timeout-basierte Session-Beendigung""" - - def test_timeout_from_standby(self, detector): - """Timeout nach 20s ohne Messages im STANDBY""" - start_time = datetime.now(timezone.utc) - - # Session starten - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # Letzte Message - last_msg = start_time + timedelta(seconds=10) - detector.process_event(create_event(power_w=50, timestamp=last_msg)) - - # 21 Sekunden später → TIMEOUT - timeout_event = create_event(power_w=50, timestamp=last_msg + timedelta(seconds=21)) - result = detector.process_event(timeout_event) - - assert result is not None - assert result['event_type'] == 'session_end' - assert result['session_data']['end_reason'] == 'timeout' - assert result['power_w'] == 0.0 # Bei Timeout wird 0W angenommen - - def test_timeout_from_working(self, detector): - """Timeout nach 20s ohne Messages im WORKING""" - start_time = datetime.now(timezone.utc) - - # Session starten im WORKING - detector.process_event(create_event(power_w=150, timestamp=start_time)) - detector.process_event(create_event(power_w=150, timestamp=start_time + timedelta(seconds=3))) - - # Letzte Message - last_msg = start_time + timedelta(seconds=10) - detector.process_event(create_event(power_w=150, timestamp=last_msg)) - - # 21 Sekunden später → TIMEOUT - timeout_event = create_event(power_w=150, timestamp=last_msg + timedelta(seconds=21)) - result = detector.process_event(timeout_event) - - assert result is not None - assert result['event_type'] == 'session_end' - assert result['session_data']['end_reason'] == 'timeout' - - -if __name__ == '__main__': - pytest.main([__file__, '-v'])