Add whatsapp feature
This commit is contained in:
19
services/production/app/services/iot/__init__.py
Normal file
19
services/production/app/services/iot/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
IoT integration services for equipment connectivity
|
||||
"""
|
||||
|
||||
from .base_connector import (
|
||||
BaseIoTConnector,
|
||||
SensorReading,
|
||||
ConnectionStatus,
|
||||
EquipmentCapabilities,
|
||||
ConnectorFactory
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'BaseIoTConnector',
|
||||
'SensorReading',
|
||||
'ConnectionStatus',
|
||||
'EquipmentCapabilities',
|
||||
'ConnectorFactory',
|
||||
]
|
||||
242
services/production/app/services/iot/base_connector.py
Normal file
242
services/production/app/services/iot/base_connector.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
Base IoT connector interface for equipment integration
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SensorReading:
|
||||
"""Standardized sensor reading data structure"""
|
||||
timestamp: datetime
|
||||
temperature: Optional[float] = None
|
||||
temperature_zones: Optional[Dict[str, float]] = None
|
||||
target_temperature: Optional[float] = None
|
||||
humidity: Optional[float] = None
|
||||
target_humidity: Optional[float] = None
|
||||
energy_consumption_kwh: Optional[float] = None
|
||||
power_current_kw: Optional[float] = None
|
||||
operational_status: Optional[str] = None
|
||||
cycle_stage: Optional[str] = None
|
||||
cycle_progress_percentage: Optional[float] = None
|
||||
time_remaining_minutes: Optional[int] = None
|
||||
motor_speed_rpm: Optional[float] = None
|
||||
door_status: Optional[str] = None
|
||||
steam_level: Optional[float] = None
|
||||
product_weight_kg: Optional[float] = None
|
||||
moisture_content: Optional[float] = None
|
||||
additional_sensors: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionStatus:
|
||||
"""Connection status information"""
|
||||
is_connected: bool
|
||||
status: str # connected, disconnected, error, unknown
|
||||
message: str
|
||||
response_time_ms: Optional[int] = None
|
||||
error_details: Optional[str] = None
|
||||
last_successful_connection: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EquipmentCapabilities:
|
||||
"""Equipment IoT capabilities"""
|
||||
supports_temperature: bool = False
|
||||
supports_humidity: bool = False
|
||||
supports_energy_monitoring: bool = False
|
||||
supports_remote_control: bool = False
|
||||
supports_realtime: bool = False
|
||||
temperature_zones: int = 1
|
||||
supported_protocols: List[str] = None
|
||||
manufacturer_specific_features: Optional[Dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.supported_protocols is None:
|
||||
self.supported_protocols = []
|
||||
|
||||
|
||||
class BaseIoTConnector(ABC):
|
||||
"""
|
||||
Base abstract class for IoT equipment connectors
|
||||
|
||||
All manufacturer-specific connectors must implement this interface
|
||||
"""
|
||||
|
||||
def __init__(self, equipment_id: str, config: Dict[str, Any]):
|
||||
"""
|
||||
Initialize the IoT connector
|
||||
|
||||
Args:
|
||||
equipment_id: Unique equipment identifier
|
||||
config: Connection configuration including endpoint, credentials, etc.
|
||||
"""
|
||||
self.equipment_id = equipment_id
|
||||
self.config = config
|
||||
self.endpoint = config.get('endpoint')
|
||||
self.port = config.get('port')
|
||||
self.credentials = config.get('credentials', {})
|
||||
self._is_connected = False
|
||||
self._last_error: Optional[str] = None
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> ConnectionStatus:
|
||||
"""
|
||||
Establish connection to the equipment
|
||||
|
||||
Returns:
|
||||
ConnectionStatus with connection details
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> bool:
|
||||
"""
|
||||
Close connection to the equipment
|
||||
|
||||
Returns:
|
||||
True if disconnected successfully
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def test_connection(self) -> ConnectionStatus:
|
||||
"""
|
||||
Test connection without establishing persistent connection
|
||||
|
||||
Returns:
|
||||
ConnectionStatus with test results
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_current_reading(self) -> Optional[SensorReading]:
|
||||
"""
|
||||
Get current sensor readings from the equipment
|
||||
|
||||
Returns:
|
||||
SensorReading with current data or None if unavailable
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_capabilities(self) -> EquipmentCapabilities:
|
||||
"""
|
||||
Discover equipment capabilities
|
||||
|
||||
Returns:
|
||||
EquipmentCapabilities describing what the equipment supports
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get equipment status information
|
||||
|
||||
Returns:
|
||||
Dictionary with status details
|
||||
"""
|
||||
pass
|
||||
|
||||
async def set_target_temperature(self, temperature: float) -> bool:
|
||||
"""
|
||||
Set target temperature (if supported)
|
||||
|
||||
Args:
|
||||
temperature: Target temperature in Celsius
|
||||
|
||||
Returns:
|
||||
True if command sent successfully
|
||||
"""
|
||||
raise NotImplementedError("Remote control not supported by this equipment")
|
||||
|
||||
async def start_cycle(self, params: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Start production cycle (if supported)
|
||||
|
||||
Args:
|
||||
params: Cycle parameters
|
||||
|
||||
Returns:
|
||||
True if cycle started successfully
|
||||
"""
|
||||
raise NotImplementedError("Remote control not supported by this equipment")
|
||||
|
||||
async def stop_cycle(self) -> bool:
|
||||
"""
|
||||
Stop current production cycle (if supported)
|
||||
|
||||
Returns:
|
||||
True if cycle stopped successfully
|
||||
"""
|
||||
raise NotImplementedError("Remote control not supported by this equipment")
|
||||
|
||||
def get_protocol_name(self) -> str:
|
||||
"""Get the protocol name used by this connector"""
|
||||
return self.__class__.__name__.replace('Connector', '').lower()
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if currently connected"""
|
||||
return self._is_connected
|
||||
|
||||
def get_last_error(self) -> Optional[str]:
|
||||
"""Get last error message"""
|
||||
return self._last_error
|
||||
|
||||
def _set_error(self, error: str):
|
||||
"""Set error message"""
|
||||
self._last_error = error
|
||||
|
||||
def _clear_error(self):
|
||||
"""Clear error message"""
|
||||
self._last_error = None
|
||||
|
||||
|
||||
class ConnectorFactory:
|
||||
"""
|
||||
Factory for creating appropriate IoT connectors based on protocol
|
||||
"""
|
||||
|
||||
_connectors: Dict[str, type] = {}
|
||||
|
||||
@classmethod
|
||||
def register_connector(cls, protocol: str, connector_class: type):
|
||||
"""
|
||||
Register a connector implementation
|
||||
|
||||
Args:
|
||||
protocol: Protocol name (e.g., 'rest_api', 'opc_ua')
|
||||
connector_class: Connector class implementing BaseIoTConnector
|
||||
"""
|
||||
cls._connectors[protocol.lower()] = connector_class
|
||||
|
||||
@classmethod
|
||||
def create_connector(cls, protocol: str, equipment_id: str, config: Dict[str, Any]) -> BaseIoTConnector:
|
||||
"""
|
||||
Create connector instance for specified protocol
|
||||
|
||||
Args:
|
||||
protocol: Protocol name
|
||||
equipment_id: Equipment identifier
|
||||
config: Connection configuration
|
||||
|
||||
Returns:
|
||||
Connector instance
|
||||
|
||||
Raises:
|
||||
ValueError: If protocol not supported
|
||||
"""
|
||||
connector_class = cls._connectors.get(protocol.lower())
|
||||
if not connector_class:
|
||||
raise ValueError(f"Unsupported IoT protocol: {protocol}")
|
||||
|
||||
return connector_class(equipment_id, config)
|
||||
|
||||
@classmethod
|
||||
def get_supported_protocols(cls) -> List[str]:
|
||||
"""Get list of supported protocols"""
|
||||
return list(cls._connectors.keys())
|
||||
156
services/production/app/services/iot/rational_connector.py
Normal file
156
services/production/app/services/iot/rational_connector.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Rational ConnectedCooking API connector
|
||||
For Rational iCombi ovens with ConnectedCooking cloud platform
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from .rest_api_connector import GenericRESTAPIConnector
|
||||
from .base_connector import SensorReading, EquipmentCapabilities
|
||||
|
||||
|
||||
class RationalConnectedCookingConnector(GenericRESTAPIConnector):
|
||||
"""
|
||||
Connector for Rational iCombi ovens via ConnectedCooking platform
|
||||
|
||||
Expected configuration:
|
||||
{
|
||||
"endpoint": "https://www.connectedcooking.com/api/v1",
|
||||
"port": 443,
|
||||
"credentials": {
|
||||
"username": "your-email@example.com",
|
||||
"password": "your-password",
|
||||
# Or use API token if available
|
||||
"token": "your-bearer-token"
|
||||
},
|
||||
"additional_config": {
|
||||
"unit_id": "12345", # Rational unit ID from ConnectedCooking
|
||||
"data_endpoint": "/units/{unit_id}/status",
|
||||
"status_endpoint": "/units/{unit_id}",
|
||||
"timeout": 15
|
||||
}
|
||||
}
|
||||
|
||||
API Documentation: Contact Rational at cc-support@rational-online.com
|
||||
"""
|
||||
|
||||
def __init__(self, equipment_id: str, config: Dict[str, Any]):
|
||||
# Replace equipment_id with unit_id for Rational API
|
||||
self.unit_id = config.get('additional_config', {}).get('unit_id', equipment_id)
|
||||
|
||||
# Update endpoints to use unit_id
|
||||
if 'additional_config' not in config:
|
||||
config['additional_config'] = {}
|
||||
|
||||
config['additional_config'].setdefault(
|
||||
'data_endpoint', f'/units/{self.unit_id}/status'
|
||||
)
|
||||
config['additional_config'].setdefault(
|
||||
'status_endpoint', f'/units/{self.unit_id}'
|
||||
)
|
||||
|
||||
super().__init__(equipment_id, config)
|
||||
|
||||
def _parse_sensor_data(self, data: Dict[str, Any]) -> SensorReading:
|
||||
"""
|
||||
Parse Rational-specific API response
|
||||
|
||||
Expected Rational ConnectedCooking response format (example):
|
||||
{
|
||||
"timestamp": "2025-01-12T10:30:00Z",
|
||||
"unit_status": "cooking",
|
||||
"cooking_mode": "combi_steam",
|
||||
"cabinet_temperature": 185.0,
|
||||
"core_temperature": 72.0,
|
||||
"humidity": 65,
|
||||
"door_open": false,
|
||||
"time_remaining_seconds": 720,
|
||||
"energy_consumption": 12.5,
|
||||
...
|
||||
}
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Map Rational fields to standard SensorReading
|
||||
cabinet_temp = data.get('cabinet_temperature')
|
||||
core_temp = data.get('core_temperature')
|
||||
|
||||
# Multi-zone temperature support
|
||||
temperature_zones = {}
|
||||
if cabinet_temp is not None:
|
||||
temperature_zones['cabinet'] = cabinet_temp
|
||||
if core_temp is not None:
|
||||
temperature_zones['core'] = core_temp
|
||||
|
||||
# Map Rational-specific statuses
|
||||
unit_status = data.get('unit_status', '').lower()
|
||||
operational_status = self._map_rational_status(unit_status)
|
||||
|
||||
# Convert time remaining from seconds to minutes
|
||||
time_remaining_seconds = data.get('time_remaining_seconds')
|
||||
time_remaining_minutes = int(time_remaining_seconds / 60) if time_remaining_seconds else None
|
||||
|
||||
return SensorReading(
|
||||
timestamp=self._parse_timestamp(data.get('timestamp')),
|
||||
temperature=cabinet_temp, # Primary temperature is cabinet
|
||||
temperature_zones=temperature_zones if temperature_zones else None,
|
||||
target_temperature=data.get('target_temperature') or data.get('cabinet_target_temperature'),
|
||||
humidity=data.get('humidity'),
|
||||
target_humidity=data.get('target_humidity'),
|
||||
energy_consumption_kwh=data.get('energy_consumption'),
|
||||
power_current_kw=data.get('current_power_kw'),
|
||||
operational_status=operational_status,
|
||||
cycle_stage=data.get('cooking_mode') or data.get('program_name'),
|
||||
cycle_progress_percentage=data.get('progress_percentage'),
|
||||
time_remaining_minutes=time_remaining_minutes,
|
||||
door_status='open' if data.get('door_open') else 'closed',
|
||||
steam_level=data.get('steam_level'),
|
||||
additional_sensors={
|
||||
'cooking_mode': data.get('cooking_mode'),
|
||||
'program_name': data.get('program_name'),
|
||||
'fan_speed': data.get('fan_speed'),
|
||||
'core_temperature': core_temp,
|
||||
}
|
||||
)
|
||||
|
||||
def _map_rational_status(self, rational_status: str) -> str:
|
||||
"""Map Rational-specific status to standard operational status"""
|
||||
status_map = {
|
||||
'idle': 'idle',
|
||||
'preheating': 'warming_up',
|
||||
'cooking': 'running',
|
||||
'cooling': 'cooling_down',
|
||||
'cleaning': 'maintenance',
|
||||
'error': 'error',
|
||||
'off': 'idle'
|
||||
}
|
||||
return status_map.get(rational_status, 'unknown')
|
||||
|
||||
async def get_capabilities(self) -> EquipmentCapabilities:
|
||||
"""Get Rational iCombi capabilities"""
|
||||
return EquipmentCapabilities(
|
||||
supports_temperature=True,
|
||||
supports_humidity=True,
|
||||
supports_energy_monitoring=True,
|
||||
supports_remote_control=True, # ConnectedCooking supports remote operation
|
||||
supports_realtime=True,
|
||||
temperature_zones=2, # Cabinet + Core
|
||||
supported_protocols=['rest_api'],
|
||||
manufacturer_specific_features={
|
||||
'manufacturer': 'Rational',
|
||||
'product_line': 'iCombi',
|
||||
'platform': 'ConnectedCooking',
|
||||
'features': [
|
||||
'HACCP_documentation',
|
||||
'recipe_management',
|
||||
'remote_start',
|
||||
'cooking_programs',
|
||||
'automatic_cleaning'
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Register connector
|
||||
from .base_connector import ConnectorFactory
|
||||
ConnectorFactory.register_connector('rational_connected_cooking', RationalConnectedCookingConnector)
|
||||
ConnectorFactory.register_connector('rational', RationalConnectedCookingConnector) # Alias
|
||||
328
services/production/app/services/iot/rest_api_connector.py
Normal file
328
services/production/app/services/iot/rest_api_connector.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Generic REST API connector for IoT equipment
|
||||
Supports standard REST endpoints with JSON responses
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .base_connector import (
|
||||
BaseIoTConnector,
|
||||
SensorReading,
|
||||
ConnectionStatus,
|
||||
EquipmentCapabilities
|
||||
)
|
||||
|
||||
|
||||
class GenericRESTAPIConnector(BaseIoTConnector):
|
||||
"""
|
||||
Generic REST API connector for equipment with standard REST interfaces
|
||||
|
||||
Expected configuration:
|
||||
{
|
||||
"endpoint": "https://api.example.com",
|
||||
"port": 443,
|
||||
"credentials": {
|
||||
"api_key": "your-api-key",
|
||||
"token": "bearer-token", # Optional
|
||||
"username": "user", # Optional
|
||||
"password": "pass" # Optional
|
||||
},
|
||||
"additional_config": {
|
||||
"data_endpoint": "/api/v1/equipment/{equipment_id}/data",
|
||||
"status_endpoint": "/api/v1/equipment/{equipment_id}/status",
|
||||
"capabilities_endpoint": "/api/v1/equipment/{equipment_id}/capabilities",
|
||||
"timeout": 10,
|
||||
"verify_ssl": true
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, equipment_id: str, config: Dict[str, Any]):
|
||||
super().__init__(equipment_id, config)
|
||||
|
||||
self.timeout = config.get('additional_config', {}).get('timeout', 10)
|
||||
self.verify_ssl = config.get('additional_config', {}).get('verify_ssl', True)
|
||||
|
||||
# API endpoints (support templating with {equipment_id})
|
||||
self.data_endpoint = config.get('additional_config', {}).get(
|
||||
'data_endpoint', '/data'
|
||||
).replace('{equipment_id}', equipment_id)
|
||||
|
||||
self.status_endpoint = config.get('additional_config', {}).get(
|
||||
'status_endpoint', '/status'
|
||||
).replace('{equipment_id}', equipment_id)
|
||||
|
||||
self.capabilities_endpoint = config.get('additional_config', {}).get(
|
||||
'capabilities_endpoint', '/capabilities'
|
||||
).replace('{equipment_id}', equipment_id)
|
||||
|
||||
# Build full base URL
|
||||
port_str = f":{self.port}" if self.port and self.port not in [80, 443] else ""
|
||||
self.base_url = f"{self.endpoint}{port_str}"
|
||||
|
||||
# Authentication headers
|
||||
self._headers = self._build_auth_headers()
|
||||
|
||||
# HTTP client (will be created on demand)
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
def _build_auth_headers(self) -> Dict[str, str]:
|
||||
"""Build authentication headers from credentials"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
# API Key authentication
|
||||
if 'api_key' in self.credentials:
|
||||
headers['X-API-Key'] = self.credentials['api_key']
|
||||
|
||||
# Bearer token authentication
|
||||
if 'token' in self.credentials:
|
||||
headers['Authorization'] = f"Bearer {self.credentials['token']}"
|
||||
|
||||
# Basic auth (will be handled by httpx.BasicAuth if needed)
|
||||
|
||||
return headers
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client"""
|
||||
if self._client is None:
|
||||
auth = None
|
||||
if 'username' in self.credentials and 'password' in self.credentials:
|
||||
auth = httpx.BasicAuth(
|
||||
username=self.credentials['username'],
|
||||
password=self.credentials['password']
|
||||
)
|
||||
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=self._headers,
|
||||
auth=auth,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify_ssl
|
||||
)
|
||||
|
||||
return self._client
|
||||
|
||||
async def connect(self) -> ConnectionStatus:
|
||||
"""Establish connection (test connectivity)"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
start_time = time.time()
|
||||
|
||||
# Try to fetch status to verify connection
|
||||
response = await client.get(self.status_endpoint)
|
||||
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
if response.status_code == 200:
|
||||
self._is_connected = True
|
||||
self._clear_error()
|
||||
return ConnectionStatus(
|
||||
is_connected=True,
|
||||
status="connected",
|
||||
message="Successfully connected to equipment API",
|
||||
response_time_ms=response_time,
|
||||
last_successful_connection=datetime.now(timezone.utc)
|
||||
)
|
||||
else:
|
||||
self._is_connected = False
|
||||
error_msg = f"HTTP {response.status_code}: {response.text}"
|
||||
self._set_error(error_msg)
|
||||
return ConnectionStatus(
|
||||
is_connected=False,
|
||||
status="error",
|
||||
message="Failed to connect to equipment API",
|
||||
response_time_ms=response_time,
|
||||
error_details=error_msg
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
self._is_connected = False
|
||||
error_msg = f"Connection timeout: {str(e)}"
|
||||
self._set_error(error_msg)
|
||||
return ConnectionStatus(
|
||||
is_connected=False,
|
||||
status="error",
|
||||
message="Connection timeout",
|
||||
error_details=error_msg
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._is_connected = False
|
||||
error_msg = f"Connection error: {str(e)}"
|
||||
self._set_error(error_msg)
|
||||
return ConnectionStatus(
|
||||
is_connected=False,
|
||||
status="error",
|
||||
message="Failed to connect",
|
||||
error_details=error_msg
|
||||
)
|
||||
|
||||
async def disconnect(self) -> bool:
|
||||
"""Close connection"""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
self._is_connected = False
|
||||
return True
|
||||
|
||||
async def test_connection(self) -> ConnectionStatus:
|
||||
"""Test connection without persisting client"""
|
||||
result = await self.connect()
|
||||
await self.disconnect()
|
||||
return result
|
||||
|
||||
async def get_current_reading(self) -> Optional[SensorReading]:
|
||||
"""Get current sensor readings from equipment"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(self.data_endpoint)
|
||||
|
||||
if response.status_code != 200:
|
||||
self._set_error(f"Failed to fetch data: HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Parse response into SensorReading
|
||||
# This mapping can be customized per manufacturer
|
||||
return self._parse_sensor_data(data)
|
||||
|
||||
except Exception as e:
|
||||
self._set_error(f"Error fetching sensor data: {str(e)}")
|
||||
return None
|
||||
|
||||
def _parse_sensor_data(self, data: Dict[str, Any]) -> SensorReading:
|
||||
"""
|
||||
Parse API response into standardized SensorReading
|
||||
Override this method for manufacturer-specific parsing
|
||||
"""
|
||||
# Default parsing - assumes standard field names
|
||||
return SensorReading(
|
||||
timestamp=self._parse_timestamp(data.get('timestamp')),
|
||||
temperature=data.get('temperature'),
|
||||
temperature_zones=data.get('temperature_zones'),
|
||||
target_temperature=data.get('target_temperature'),
|
||||
humidity=data.get('humidity'),
|
||||
target_humidity=data.get('target_humidity'),
|
||||
energy_consumption_kwh=data.get('energy_consumption_kwh'),
|
||||
power_current_kw=data.get('power_current_kw') or data.get('power_kw'),
|
||||
operational_status=data.get('operational_status') or data.get('status'),
|
||||
cycle_stage=data.get('cycle_stage') or data.get('stage'),
|
||||
cycle_progress_percentage=data.get('cycle_progress_percentage') or data.get('progress'),
|
||||
time_remaining_minutes=data.get('time_remaining_minutes') or data.get('time_remaining'),
|
||||
motor_speed_rpm=data.get('motor_speed_rpm'),
|
||||
door_status=data.get('door_status'),
|
||||
steam_level=data.get('steam_level'),
|
||||
product_weight_kg=data.get('product_weight_kg'),
|
||||
moisture_content=data.get('moisture_content'),
|
||||
additional_sensors=data.get('additional_sensors') or {}
|
||||
)
|
||||
|
||||
def _parse_timestamp(self, timestamp_value: Any) -> datetime:
|
||||
"""Parse timestamp from various formats"""
|
||||
if timestamp_value is None:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
if isinstance(timestamp_value, datetime):
|
||||
return timestamp_value
|
||||
|
||||
if isinstance(timestamp_value, str):
|
||||
# Try ISO format
|
||||
try:
|
||||
return datetime.fromisoformat(timestamp_value.replace('Z', '+00:00'))
|
||||
except:
|
||||
pass
|
||||
|
||||
if isinstance(timestamp_value, (int, float)):
|
||||
# Unix timestamp
|
||||
return datetime.fromtimestamp(timestamp_value, tz=timezone.utc)
|
||||
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
async def get_capabilities(self) -> EquipmentCapabilities:
|
||||
"""Discover equipment capabilities"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(self.capabilities_endpoint)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return EquipmentCapabilities(
|
||||
supports_temperature=data.get('supports_temperature', True),
|
||||
supports_humidity=data.get('supports_humidity', False),
|
||||
supports_energy_monitoring=data.get('supports_energy_monitoring', False),
|
||||
supports_remote_control=data.get('supports_remote_control', False),
|
||||
supports_realtime=data.get('supports_realtime', True),
|
||||
temperature_zones=data.get('temperature_zones', 1),
|
||||
supported_protocols=['rest_api'],
|
||||
manufacturer_specific_features=data.get('additional_features')
|
||||
)
|
||||
else:
|
||||
# Return default capabilities if endpoint not available
|
||||
return EquipmentCapabilities(
|
||||
supports_temperature=True,
|
||||
supports_realtime=True,
|
||||
supported_protocols=['rest_api']
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Return minimal capabilities on error
|
||||
self._set_error(f"Error fetching capabilities: {str(e)}")
|
||||
return EquipmentCapabilities(
|
||||
supports_temperature=True,
|
||||
supports_realtime=True,
|
||||
supported_protocols=['rest_api']
|
||||
)
|
||||
|
||||
async def get_status(self) -> Dict[str, Any]:
|
||||
"""Get equipment status"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(self.status_endpoint)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
return {
|
||||
"error": f"HTTP {response.status_code}",
|
||||
"connected": False
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
"connected": False
|
||||
}
|
||||
|
||||
async def set_target_temperature(self, temperature: float) -> bool:
|
||||
"""Set target temperature (if supported)"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
|
||||
# POST to control endpoint
|
||||
control_endpoint = self.config.get('additional_config', {}).get(
|
||||
'control_endpoint', '/control'
|
||||
).replace('{equipment_id}', self.equipment_id)
|
||||
|
||||
response = await client.post(
|
||||
control_endpoint,
|
||||
json={"target_temperature": temperature}
|
||||
)
|
||||
|
||||
return response.status_code in [200, 201, 202]
|
||||
|
||||
except Exception as e:
|
||||
self._set_error(f"Error setting temperature: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
# Register this connector with the factory
|
||||
from .base_connector import ConnectorFactory
|
||||
ConnectorFactory.register_connector('rest_api', GenericRESTAPIConnector)
|
||||
149
services/production/app/services/iot/wachtel_connector.py
Normal file
149
services/production/app/services/iot/wachtel_connector.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Wachtel REMOTE connector
|
||||
For Wachtel bakery ovens with REMOTE monitoring system
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from .rest_api_connector import GenericRESTAPIConnector
|
||||
from .base_connector import SensorReading, EquipmentCapabilities
|
||||
|
||||
|
||||
class WachtelREMOTEConnector(GenericRESTAPIConnector):
|
||||
"""
|
||||
Connector for Wachtel ovens via REMOTE monitoring system
|
||||
|
||||
Expected configuration:
|
||||
{
|
||||
"endpoint": "https://remote.wachtel.de/api", # Example endpoint
|
||||
"port": 443,
|
||||
"credentials": {
|
||||
"username": "bakery-username",
|
||||
"password": "bakery-password"
|
||||
},
|
||||
"additional_config": {
|
||||
"oven_id": "oven-serial-number",
|
||||
"data_endpoint": "/ovens/{oven_id}/readings",
|
||||
"status_endpoint": "/ovens/{oven_id}/status",
|
||||
"timeout": 10
|
||||
}
|
||||
}
|
||||
|
||||
Note: Actual API endpoints need to be obtained from Wachtel
|
||||
Contact: support@wachtel.de or visit https://www.wachtel.de
|
||||
"""
|
||||
|
||||
def __init__(self, equipment_id: str, config: Dict[str, Any]):
|
||||
self.oven_id = config.get('additional_config', {}).get('oven_id', equipment_id)
|
||||
|
||||
if 'additional_config' not in config:
|
||||
config['additional_config'] = {}
|
||||
|
||||
config['additional_config'].setdefault(
|
||||
'data_endpoint', f'/ovens/{self.oven_id}/readings'
|
||||
)
|
||||
config['additional_config'].setdefault(
|
||||
'status_endpoint', f'/ovens/{self.oven_id}/status'
|
||||
)
|
||||
|
||||
super().__init__(equipment_id, config)
|
||||
|
||||
def _parse_sensor_data(self, data: Dict[str, Any]) -> SensorReading:
|
||||
"""
|
||||
Parse Wachtel REMOTE API response
|
||||
|
||||
Expected format (to be confirmed with actual API):
|
||||
{
|
||||
"timestamp": "2025-01-12T10:30:00Z",
|
||||
"oven_status": "baking",
|
||||
"deck_temperatures": [180, 185, 190], # Multiple deck support
|
||||
"target_temperatures": [180, 185, 190],
|
||||
"energy_consumption_kwh": 15.2,
|
||||
"current_power_kw": 18.5,
|
||||
"operation_hours": 1245,
|
||||
...
|
||||
}
|
||||
"""
|
||||
# Parse deck temperatures (Wachtel ovens typically have multiple decks)
|
||||
deck_temps = data.get('deck_temperatures', [])
|
||||
temperature_zones = {}
|
||||
|
||||
if deck_temps:
|
||||
for i, temp in enumerate(deck_temps, 1):
|
||||
temperature_zones[f'deck_{i}'] = temp
|
||||
|
||||
# Primary temperature is average or first deck
|
||||
primary_temp = deck_temps[0] if deck_temps else data.get('temperature')
|
||||
|
||||
# Map Wachtel status to standard status
|
||||
oven_status = data.get('oven_status', '').lower()
|
||||
operational_status = self._map_wachtel_status(oven_status)
|
||||
|
||||
return SensorReading(
|
||||
timestamp=self._parse_timestamp(data.get('timestamp')),
|
||||
temperature=primary_temp,
|
||||
temperature_zones=temperature_zones if temperature_zones else None,
|
||||
target_temperature=data.get('target_temperature'),
|
||||
humidity=None, # Wachtel deck ovens typically don't have humidity sensors
|
||||
target_humidity=None,
|
||||
energy_consumption_kwh=data.get('energy_consumption_kwh'),
|
||||
power_current_kw=data.get('current_power_kw'),
|
||||
operational_status=operational_status,
|
||||
cycle_stage=data.get('baking_program'),
|
||||
cycle_progress_percentage=data.get('cycle_progress'),
|
||||
time_remaining_minutes=data.get('time_remaining_minutes'),
|
||||
door_status=None, # Deck ovens don't typically report door status
|
||||
steam_level=data.get('steam_injection_active'),
|
||||
additional_sensors={
|
||||
'deck_count': len(deck_temps),
|
||||
'operation_hours': data.get('operation_hours'),
|
||||
'maintenance_due': data.get('maintenance_due'),
|
||||
'deck_temperatures': deck_temps,
|
||||
'target_temperatures': data.get('target_temperatures'),
|
||||
}
|
||||
)
|
||||
|
||||
def _map_wachtel_status(self, wachtel_status: str) -> str:
|
||||
"""Map Wachtel-specific status to standard operational status"""
|
||||
status_map = {
|
||||
'off': 'idle',
|
||||
'standby': 'idle',
|
||||
'preheating': 'warming_up',
|
||||
'baking': 'running',
|
||||
'ready': 'idle',
|
||||
'error': 'error',
|
||||
'maintenance': 'maintenance'
|
||||
}
|
||||
return status_map.get(wachtel_status, 'unknown')
|
||||
|
||||
async def get_capabilities(self) -> EquipmentCapabilities:
|
||||
"""Get Wachtel oven capabilities"""
|
||||
# Try to determine number of decks from config or API
|
||||
deck_count = self.config.get('additional_config', {}).get('deck_count', 3)
|
||||
|
||||
return EquipmentCapabilities(
|
||||
supports_temperature=True,
|
||||
supports_humidity=False, # Typically not available on deck ovens
|
||||
supports_energy_monitoring=True,
|
||||
supports_remote_control=False, # REMOTE is monitoring only
|
||||
supports_realtime=True,
|
||||
temperature_zones=deck_count,
|
||||
supported_protocols=['rest_api'],
|
||||
manufacturer_specific_features={
|
||||
'manufacturer': 'Wachtel',
|
||||
'product_line': 'Deck Ovens',
|
||||
'platform': 'REMOTE',
|
||||
'features': [
|
||||
'multi_deck_monitoring',
|
||||
'energy_consumption_tracking',
|
||||
'maintenance_alerts',
|
||||
'operation_hours_tracking',
|
||||
'deck_specific_temperature_control'
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Register connector
|
||||
from .base_connector import ConnectorFactory
|
||||
ConnectorFactory.register_connector('wachtel_remote', WachtelREMOTEConnector)
|
||||
ConnectorFactory.register_connector('wachtel', WachtelREMOTEConnector) # Alias
|
||||
Reference in New Issue
Block a user