Add whatsapp feature
This commit is contained in:
@@ -528,50 +528,89 @@ class QualityCheck(Base):
|
||||
}
|
||||
|
||||
|
||||
class IoTProtocol(str, enum.Enum):
|
||||
"""IoT protocol enumeration"""
|
||||
REST_API = "rest_api"
|
||||
OPC_UA = "opc_ua"
|
||||
MQTT = "mqtt"
|
||||
MODBUS = "modbus"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class IoTConnectionStatus(str, enum.Enum):
|
||||
"""IoT connection status enumeration"""
|
||||
CONNECTED = "connected"
|
||||
DISCONNECTED = "disconnected"
|
||||
ERROR = "error"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class Equipment(Base):
|
||||
"""Equipment model for tracking production equipment"""
|
||||
__tablename__ = "equipment"
|
||||
|
||||
|
||||
# Primary identification
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
|
||||
# Equipment identification
|
||||
name = Column(String(255), nullable=False)
|
||||
type = Column(SQLEnum(EquipmentType), nullable=False)
|
||||
model = Column(String(100), nullable=True)
|
||||
serial_number = Column(String(100), nullable=True)
|
||||
location = Column(String(255), nullable=True)
|
||||
|
||||
manufacturer = Column(String(100), nullable=True)
|
||||
firmware_version = Column(String(50), nullable=True)
|
||||
|
||||
# Status tracking
|
||||
status = Column(SQLEnum(EquipmentStatus), nullable=False, default=EquipmentStatus.OPERATIONAL)
|
||||
|
||||
|
||||
# Dates
|
||||
install_date = Column(DateTime(timezone=True), nullable=True)
|
||||
last_maintenance_date = Column(DateTime(timezone=True), nullable=True)
|
||||
next_maintenance_date = Column(DateTime(timezone=True), nullable=True)
|
||||
maintenance_interval_days = Column(Integer, nullable=True) # Maintenance interval in days
|
||||
|
||||
|
||||
# Performance metrics
|
||||
efficiency_percentage = Column(Float, nullable=True) # Current efficiency
|
||||
uptime_percentage = Column(Float, nullable=True) # Overall equipment effectiveness
|
||||
energy_usage_kwh = Column(Float, nullable=True) # Current energy usage
|
||||
|
||||
|
||||
# Specifications
|
||||
power_kw = Column(Float, nullable=True) # Power in kilowatts
|
||||
capacity = Column(Float, nullable=True) # Capacity (units depend on equipment type)
|
||||
weight_kg = Column(Float, nullable=True) # Weight in kilograms
|
||||
|
||||
|
||||
# Temperature monitoring
|
||||
current_temperature = Column(Float, nullable=True) # Current temperature reading
|
||||
target_temperature = Column(Float, nullable=True) # Target temperature
|
||||
|
||||
|
||||
# IoT Connectivity
|
||||
iot_enabled = Column(Boolean, default=False, nullable=False)
|
||||
iot_protocol = Column(String(50), nullable=True) # rest_api, opc_ua, mqtt, modbus, custom
|
||||
iot_endpoint = Column(String(500), nullable=True) # URL or IP address
|
||||
iot_port = Column(Integer, nullable=True) # Connection port
|
||||
iot_credentials = Column(JSON, nullable=True) # Encrypted credentials (API keys, tokens, username/password)
|
||||
iot_connection_status = Column(String(50), nullable=True) # connected, disconnected, error, unknown
|
||||
iot_last_connected = Column(DateTime(timezone=True), nullable=True)
|
||||
iot_config = Column(JSON, nullable=True) # Additional configuration (polling interval, specific endpoints, etc.)
|
||||
|
||||
# Real-time monitoring
|
||||
supports_realtime = Column(Boolean, default=False, nullable=False)
|
||||
poll_interval_seconds = Column(Integer, nullable=True) # How often to poll for data
|
||||
|
||||
# Sensor capabilities
|
||||
temperature_zones = Column(Integer, nullable=True) # Number of temperature zones
|
||||
supports_humidity = Column(Boolean, default=False, nullable=False)
|
||||
supports_energy_monitoring = Column(Boolean, default=False, nullable=False)
|
||||
supports_remote_control = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
|
||||
# Notes
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -586,6 +625,8 @@ class Equipment(Base):
|
||||
"model": self.model,
|
||||
"serial_number": self.serial_number,
|
||||
"location": self.location,
|
||||
"manufacturer": self.manufacturer,
|
||||
"firmware_version": self.firmware_version,
|
||||
"status": self.status.value if self.status else None,
|
||||
"install_date": self.install_date.isoformat() if self.install_date else None,
|
||||
"last_maintenance_date": self.last_maintenance_date.isoformat() if self.last_maintenance_date else None,
|
||||
@@ -599,6 +640,19 @@ class Equipment(Base):
|
||||
"weight_kg": self.weight_kg,
|
||||
"current_temperature": self.current_temperature,
|
||||
"target_temperature": self.target_temperature,
|
||||
"iot_enabled": self.iot_enabled,
|
||||
"iot_protocol": self.iot_protocol,
|
||||
"iot_endpoint": self.iot_endpoint,
|
||||
"iot_port": self.iot_port,
|
||||
"iot_connection_status": self.iot_connection_status,
|
||||
"iot_last_connected": self.iot_last_connected.isoformat() if self.iot_last_connected else None,
|
||||
"iot_config": self.iot_config,
|
||||
"supports_realtime": self.supports_realtime,
|
||||
"poll_interval_seconds": self.poll_interval_seconds,
|
||||
"temperature_zones": self.temperature_zones,
|
||||
"supports_humidity": self.supports_humidity,
|
||||
"supports_energy_monitoring": self.supports_energy_monitoring,
|
||||
"supports_remote_control": self.supports_remote_control,
|
||||
"is_active": self.is_active,
|
||||
"notes": self.notes,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
@@ -606,3 +660,216 @@ class Equipment(Base):
|
||||
}
|
||||
|
||||
|
||||
class EquipmentSensorReading(Base):
|
||||
"""Equipment sensor reading model for time-series IoT data"""
|
||||
__tablename__ = "equipment_sensor_readings"
|
||||
|
||||
# Primary identification
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
equipment_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
batch_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
|
||||
# Timestamp
|
||||
reading_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
# Temperature readings (support multiple zones)
|
||||
temperature = Column(Float, nullable=True)
|
||||
temperature_zones = Column(JSON, nullable=True) # {"zone1": 180, "zone2": 200, "zone3": 185}
|
||||
target_temperature = Column(Float, nullable=True)
|
||||
|
||||
# Humidity
|
||||
humidity = Column(Float, nullable=True)
|
||||
target_humidity = Column(Float, nullable=True)
|
||||
|
||||
# Energy monitoring
|
||||
energy_consumption_kwh = Column(Float, nullable=True)
|
||||
power_current_kw = Column(Float, nullable=True)
|
||||
|
||||
# Equipment status
|
||||
operational_status = Column(String(50), nullable=True) # running, idle, warming_up, cooling_down
|
||||
cycle_stage = Column(String(100), nullable=True) # preheating, baking, cooling
|
||||
cycle_progress_percentage = Column(Float, nullable=True)
|
||||
time_remaining_minutes = Column(Integer, nullable=True)
|
||||
|
||||
# Process parameters
|
||||
motor_speed_rpm = Column(Float, nullable=True)
|
||||
door_status = Column(String(20), nullable=True) # open, closed
|
||||
steam_level = Column(Float, nullable=True)
|
||||
|
||||
# Quality indicators
|
||||
product_weight_kg = Column(Float, nullable=True)
|
||||
moisture_content = Column(Float, nullable=True)
|
||||
|
||||
# Additional sensor data (flexible JSON for manufacturer-specific metrics)
|
||||
additional_sensors = Column(JSON, nullable=True)
|
||||
|
||||
# Data quality
|
||||
data_quality_score = Column(Float, nullable=True)
|
||||
is_anomaly = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary following shared pattern"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"tenant_id": str(self.tenant_id),
|
||||
"equipment_id": str(self.equipment_id),
|
||||
"batch_id": str(self.batch_id) if self.batch_id else None,
|
||||
"reading_time": self.reading_time.isoformat() if self.reading_time else None,
|
||||
"temperature": self.temperature,
|
||||
"temperature_zones": self.temperature_zones,
|
||||
"target_temperature": self.target_temperature,
|
||||
"humidity": self.humidity,
|
||||
"target_humidity": self.target_humidity,
|
||||
"energy_consumption_kwh": self.energy_consumption_kwh,
|
||||
"power_current_kw": self.power_current_kw,
|
||||
"operational_status": self.operational_status,
|
||||
"cycle_stage": self.cycle_stage,
|
||||
"cycle_progress_percentage": self.cycle_progress_percentage,
|
||||
"time_remaining_minutes": self.time_remaining_minutes,
|
||||
"motor_speed_rpm": self.motor_speed_rpm,
|
||||
"door_status": self.door_status,
|
||||
"steam_level": self.steam_level,
|
||||
"product_weight_kg": self.product_weight_kg,
|
||||
"moisture_content": self.moisture_content,
|
||||
"additional_sensors": self.additional_sensors,
|
||||
"data_quality_score": self.data_quality_score,
|
||||
"is_anomaly": self.is_anomaly,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class EquipmentConnectionLog(Base):
|
||||
"""Equipment connection log for tracking IoT connectivity"""
|
||||
__tablename__ = "equipment_connection_logs"
|
||||
|
||||
# Primary identification
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
equipment_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Connection event
|
||||
event_type = Column(String(50), nullable=False) # connected, disconnected, error, timeout
|
||||
event_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
# Connection details
|
||||
connection_status = Column(String(50), nullable=False)
|
||||
protocol_used = Column(String(50), nullable=True)
|
||||
endpoint = Column(String(500), nullable=True)
|
||||
|
||||
# Error tracking
|
||||
error_message = Column(Text, nullable=True)
|
||||
error_code = Column(String(50), nullable=True)
|
||||
|
||||
# Performance metrics
|
||||
response_time_ms = Column(Integer, nullable=True)
|
||||
data_points_received = Column(Integer, nullable=True)
|
||||
|
||||
# Additional details
|
||||
additional_data = Column(JSON, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary following shared pattern"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"tenant_id": str(self.tenant_id),
|
||||
"equipment_id": str(self.equipment_id),
|
||||
"event_type": self.event_type,
|
||||
"event_time": self.event_time.isoformat() if self.event_time else None,
|
||||
"connection_status": self.connection_status,
|
||||
"protocol_used": self.protocol_used,
|
||||
"endpoint": self.endpoint,
|
||||
"error_message": self.error_message,
|
||||
"error_code": self.error_code,
|
||||
"response_time_ms": self.response_time_ms,
|
||||
"data_points_received": self.data_points_received,
|
||||
"additional_data": self.additional_data,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class EquipmentIoTAlert(Base):
|
||||
"""Equipment IoT alert model for real-time equipment alerts"""
|
||||
__tablename__ = "equipment_iot_alerts"
|
||||
|
||||
# Primary identification
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
equipment_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
batch_id = Column(UUID(as_uuid=True), nullable=True, index=True)
|
||||
|
||||
# Alert information
|
||||
alert_type = Column(String(50), nullable=False) # temperature_deviation, connection_lost, equipment_error
|
||||
severity = Column(String(20), nullable=False) # info, warning, critical
|
||||
alert_time = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
# Alert details
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
sensor_reading_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
# Threshold information
|
||||
threshold_value = Column(Float, nullable=True)
|
||||
actual_value = Column(Float, nullable=True)
|
||||
deviation_percentage = Column(Float, nullable=True)
|
||||
|
||||
# Status tracking
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_acknowledged = Column(Boolean, default=False, nullable=False)
|
||||
acknowledged_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
acknowledged_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
is_resolved = Column(Boolean, default=False, nullable=False)
|
||||
resolved_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
resolution_notes = Column(Text, nullable=True)
|
||||
|
||||
# Automated response
|
||||
auto_resolved = Column(Boolean, default=False, nullable=False)
|
||||
corrective_action_taken = Column(String(255), nullable=True)
|
||||
|
||||
# Additional data
|
||||
additional_data = Column(JSON, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary following shared pattern"""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"tenant_id": str(self.tenant_id),
|
||||
"equipment_id": str(self.equipment_id),
|
||||
"batch_id": str(self.batch_id) if self.batch_id else None,
|
||||
"alert_type": self.alert_type,
|
||||
"severity": self.severity,
|
||||
"alert_time": self.alert_time.isoformat() if self.alert_time else None,
|
||||
"title": self.title,
|
||||
"message": self.message,
|
||||
"sensor_reading_id": str(self.sensor_reading_id) if self.sensor_reading_id else None,
|
||||
"threshold_value": self.threshold_value,
|
||||
"actual_value": self.actual_value,
|
||||
"deviation_percentage": self.deviation_percentage,
|
||||
"is_active": self.is_active,
|
||||
"is_acknowledged": self.is_acknowledged,
|
||||
"acknowledged_by": str(self.acknowledged_by) if self.acknowledged_by else None,
|
||||
"acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None,
|
||||
"is_resolved": self.is_resolved,
|
||||
"resolved_by": str(self.resolved_by) if self.resolved_by else None,
|
||||
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
|
||||
"resolution_notes": self.resolution_notes,
|
||||
"auto_resolved": self.auto_resolved,
|
||||
"corrective_action_taken": self.corrective_action_taken,
|
||||
"additional_data": self.additional_data,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,31 @@ from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from app.models.production import EquipmentType, EquipmentStatus
|
||||
from app.models.production import EquipmentType, EquipmentStatus, IoTProtocol, IoTConnectionStatus
|
||||
|
||||
|
||||
class IoTConnectionConfig(BaseModel):
|
||||
"""Schema for IoT connection configuration"""
|
||||
protocol: str = Field(..., description="IoT protocol (rest_api, opc_ua, mqtt, modbus, custom)")
|
||||
endpoint: str = Field(..., description="Connection endpoint (URL or IP address)")
|
||||
port: Optional[int] = Field(None, description="Connection port")
|
||||
username: Optional[str] = Field(None, description="Username for authentication")
|
||||
password: Optional[str] = Field(None, description="Password for authentication")
|
||||
api_key: Optional[str] = Field(None, description="API key for authentication")
|
||||
token: Optional[str] = Field(None, description="Authentication token")
|
||||
additional_config: Optional[dict] = Field(None, description="Additional protocol-specific configuration")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"protocol": "rest_api",
|
||||
"endpoint": "https://connectedcooking.com/api/v1",
|
||||
"port": 443,
|
||||
"api_key": "your-api-key-here",
|
||||
"additional_config": {"poll_interval": 30}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EquipmentCreate(BaseModel):
|
||||
@@ -18,6 +42,8 @@ class EquipmentCreate(BaseModel):
|
||||
model: Optional[str] = Field(None, max_length=100, description="Equipment model")
|
||||
serial_number: Optional[str] = Field(None, max_length=100, description="Serial number")
|
||||
location: Optional[str] = Field(None, max_length=255, description="Physical location")
|
||||
manufacturer: Optional[str] = Field(None, max_length=100, description="Manufacturer")
|
||||
firmware_version: Optional[str] = Field(None, max_length=50, description="Firmware version")
|
||||
status: EquipmentStatus = Field(default=EquipmentStatus.OPERATIONAL, description="Equipment status")
|
||||
|
||||
# Installation and maintenance
|
||||
@@ -40,6 +66,23 @@ class EquipmentCreate(BaseModel):
|
||||
current_temperature: Optional[float] = Field(None, description="Current temperature")
|
||||
target_temperature: Optional[float] = Field(None, description="Target temperature")
|
||||
|
||||
# IoT Connectivity
|
||||
iot_enabled: bool = Field(default=False, description="Enable IoT connectivity")
|
||||
iot_protocol: Optional[str] = Field(None, description="IoT protocol")
|
||||
iot_endpoint: Optional[str] = Field(None, description="IoT endpoint URL or IP")
|
||||
iot_port: Optional[int] = Field(None, description="IoT connection port")
|
||||
iot_config: Optional[dict] = Field(None, description="IoT configuration")
|
||||
|
||||
# Real-time monitoring
|
||||
supports_realtime: bool = Field(default=False, description="Supports real-time monitoring")
|
||||
poll_interval_seconds: Optional[int] = Field(None, ge=1, description="Polling interval in seconds")
|
||||
|
||||
# Sensor capabilities
|
||||
temperature_zones: Optional[int] = Field(None, ge=1, description="Number of temperature zones")
|
||||
supports_humidity: bool = Field(default=False, description="Supports humidity monitoring")
|
||||
supports_energy_monitoring: bool = Field(default=False, description="Supports energy monitoring")
|
||||
supports_remote_control: bool = Field(default=False, description="Supports remote control")
|
||||
|
||||
# Notes
|
||||
notes: Optional[str] = Field(None, description="Additional notes")
|
||||
|
||||
@@ -70,6 +113,8 @@ class EquipmentUpdate(BaseModel):
|
||||
model: Optional[str] = Field(None, max_length=100)
|
||||
serial_number: Optional[str] = Field(None, max_length=100)
|
||||
location: Optional[str] = Field(None, max_length=255)
|
||||
manufacturer: Optional[str] = Field(None, max_length=100)
|
||||
firmware_version: Optional[str] = Field(None, max_length=50)
|
||||
status: Optional[EquipmentStatus] = None
|
||||
|
||||
# Installation and maintenance
|
||||
@@ -92,6 +137,23 @@ class EquipmentUpdate(BaseModel):
|
||||
current_temperature: Optional[float] = None
|
||||
target_temperature: Optional[float] = None
|
||||
|
||||
# IoT Connectivity
|
||||
iot_enabled: Optional[bool] = None
|
||||
iot_protocol: Optional[str] = None
|
||||
iot_endpoint: Optional[str] = None
|
||||
iot_port: Optional[int] = None
|
||||
iot_config: Optional[dict] = None
|
||||
|
||||
# Real-time monitoring
|
||||
supports_realtime: Optional[bool] = None
|
||||
poll_interval_seconds: Optional[int] = Field(None, ge=1)
|
||||
|
||||
# Sensor capabilities
|
||||
temperature_zones: Optional[int] = Field(None, ge=1)
|
||||
supports_humidity: Optional[bool] = None
|
||||
supports_energy_monitoring: Optional[bool] = None
|
||||
supports_remote_control: Optional[bool] = None
|
||||
|
||||
# Notes
|
||||
notes: Optional[str] = None
|
||||
|
||||
@@ -119,6 +181,8 @@ class EquipmentResponse(BaseModel):
|
||||
model: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
firmware_version: Optional[str] = None
|
||||
status: EquipmentStatus
|
||||
|
||||
# Installation and maintenance
|
||||
@@ -141,6 +205,25 @@ class EquipmentResponse(BaseModel):
|
||||
current_temperature: Optional[float] = None
|
||||
target_temperature: Optional[float] = None
|
||||
|
||||
# IoT Connectivity
|
||||
iot_enabled: bool = False
|
||||
iot_protocol: Optional[str] = None
|
||||
iot_endpoint: Optional[str] = None
|
||||
iot_port: Optional[int] = None
|
||||
iot_connection_status: Optional[str] = None
|
||||
iot_last_connected: Optional[datetime] = None
|
||||
iot_config: Optional[dict] = None
|
||||
|
||||
# Real-time monitoring
|
||||
supports_realtime: bool = False
|
||||
poll_interval_seconds: Optional[int] = None
|
||||
|
||||
# Sensor capabilities
|
||||
temperature_zones: Optional[int] = None
|
||||
supports_humidity: bool = False
|
||||
supports_energy_monitoring: bool = False
|
||||
supports_remote_control: bool = False
|
||||
|
||||
# Status
|
||||
is_active: bool
|
||||
notes: Optional[str] = None
|
||||
@@ -196,3 +279,189 @@ class EquipmentDeletionSummary(BaseModel):
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# IoT-SPECIFIC SCHEMAS
|
||||
# ================================================================
|
||||
|
||||
class EquipmentSensorReadingResponse(BaseModel):
|
||||
"""Schema for equipment sensor reading response"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
equipment_id: UUID
|
||||
batch_id: Optional[UUID] = None
|
||||
reading_time: datetime
|
||||
|
||||
# Temperature readings
|
||||
temperature: Optional[float] = None
|
||||
temperature_zones: Optional[dict] = None
|
||||
target_temperature: Optional[float] = None
|
||||
|
||||
# Humidity
|
||||
humidity: Optional[float] = None
|
||||
target_humidity: Optional[float] = None
|
||||
|
||||
# Energy monitoring
|
||||
energy_consumption_kwh: Optional[float] = None
|
||||
power_current_kw: Optional[float] = None
|
||||
|
||||
# Equipment status
|
||||
operational_status: Optional[str] = None
|
||||
cycle_stage: Optional[str] = None
|
||||
cycle_progress_percentage: Optional[float] = None
|
||||
time_remaining_minutes: Optional[int] = None
|
||||
|
||||
# Process parameters
|
||||
motor_speed_rpm: Optional[float] = None
|
||||
door_status: Optional[str] = None
|
||||
steam_level: Optional[float] = None
|
||||
|
||||
# Quality indicators
|
||||
product_weight_kg: Optional[float] = None
|
||||
moisture_content: Optional[float] = None
|
||||
|
||||
# Additional sensor data
|
||||
additional_sensors: Optional[dict] = None
|
||||
|
||||
# Data quality
|
||||
data_quality_score: Optional[float] = None
|
||||
is_anomaly: bool = False
|
||||
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class EquipmentConnectionTestResponse(BaseModel):
|
||||
"""Schema for IoT connection test response"""
|
||||
success: bool = Field(..., description="Whether connection test succeeded")
|
||||
status: str = Field(..., description="Connection status")
|
||||
message: str = Field(..., description="Detailed message")
|
||||
response_time_ms: Optional[int] = Field(None, description="Response time in milliseconds")
|
||||
protocol_tested: str = Field(..., description="Protocol that was tested")
|
||||
endpoint_tested: str = Field(..., description="Endpoint that was tested")
|
||||
error_details: Optional[str] = Field(None, description="Error details if connection failed")
|
||||
supported_features: Optional[List[str]] = Field(None, description="List of supported IoT features")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"status": "connected",
|
||||
"message": "Successfully connected to equipment",
|
||||
"response_time_ms": 145,
|
||||
"protocol_tested": "rest_api",
|
||||
"endpoint_tested": "https://connectedcooking.com/api/v1",
|
||||
"supported_features": ["temperature", "humidity", "energy_monitoring"]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RealTimeDataResponse(BaseModel):
|
||||
"""Schema for real-time equipment data response"""
|
||||
equipment_id: UUID
|
||||
equipment_name: str
|
||||
timestamp: datetime
|
||||
connection_status: str
|
||||
|
||||
# Current readings
|
||||
temperature: Optional[float] = None
|
||||
temperature_zones: Optional[dict] = None
|
||||
humidity: Optional[float] = None
|
||||
energy_consumption_kwh: Optional[float] = None
|
||||
power_current_kw: Optional[float] = None
|
||||
|
||||
# Status
|
||||
operational_status: Optional[str] = None
|
||||
cycle_stage: Optional[str] = None
|
||||
cycle_progress_percentage: Optional[float] = None
|
||||
time_remaining_minutes: Optional[int] = None
|
||||
|
||||
# Active batch
|
||||
active_batch_id: Optional[UUID] = None
|
||||
active_batch_name: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"equipment_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"equipment_name": "Horno Principal #1",
|
||||
"timestamp": "2025-01-12T10:30:00Z",
|
||||
"connection_status": "connected",
|
||||
"temperature": 185.5,
|
||||
"temperature_zones": {"zone1": 180, "zone2": 190, "zone3": 185},
|
||||
"humidity": 65.0,
|
||||
"operational_status": "running",
|
||||
"cycle_stage": "baking",
|
||||
"cycle_progress_percentage": 45.0,
|
||||
"time_remaining_minutes": 12
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EquipmentIoTAlertResponse(BaseModel):
|
||||
"""Schema for IoT alert response"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
equipment_id: UUID
|
||||
batch_id: Optional[UUID] = None
|
||||
|
||||
# Alert information
|
||||
alert_type: str
|
||||
severity: str
|
||||
alert_time: datetime
|
||||
|
||||
# Alert details
|
||||
title: str
|
||||
message: str
|
||||
|
||||
# Threshold information
|
||||
threshold_value: Optional[float] = None
|
||||
actual_value: Optional[float] = None
|
||||
deviation_percentage: Optional[float] = None
|
||||
|
||||
# Status
|
||||
is_active: bool
|
||||
is_acknowledged: bool
|
||||
acknowledged_by: Optional[UUID] = None
|
||||
acknowledged_at: Optional[datetime] = None
|
||||
|
||||
is_resolved: bool
|
||||
resolved_by: Optional[UUID] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
resolution_notes: Optional[str] = None
|
||||
|
||||
# Automated response
|
||||
auto_resolved: bool
|
||||
corrective_action_taken: Optional[str] = None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class EquipmentSensorHistoryResponse(BaseModel):
|
||||
"""Schema for sensor reading history response"""
|
||||
equipment_id: UUID
|
||||
equipment_name: str
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
total_readings: int
|
||||
readings: List[EquipmentSensorReadingResponse]
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"equipment_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"equipment_name": "Horno Principal #1",
|
||||
"start_time": "2025-01-12T08:00:00Z",
|
||||
"end_time": "2025-01-12T12:00:00Z",
|
||||
"total_readings": 48,
|
||||
"readings": []
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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