Add whatsapp feature

This commit is contained in:
Urtzi Alfaro
2025-11-13 16:01:08 +01:00
parent d7df2b0853
commit 9bc048d360
74 changed files with 9765 additions and 533 deletions

View 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',
]

View 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())

View 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

View 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)

View 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