Add whatsapp feature
This commit is contained in:
653
services/production/IOT_IMPLEMENTATION_GUIDE.md
Normal file
653
services/production/IOT_IMPLEMENTATION_GUIDE.md
Normal file
@@ -0,0 +1,653 @@
|
||||
# IoT Equipment Integration - Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide documents the implementation of real-time IoT equipment tracking for bakery production equipment, specifically targeting smart industrial ovens with IoT connectivity capabilities.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture](#architecture)
|
||||
2. [Database Schema](#database-schema)
|
||||
3. [IoT Connectors](#iot-connectors)
|
||||
4. [Supported Equipment](#supported-equipment)
|
||||
5. [Implementation Status](#implementation-status)
|
||||
6. [Next Steps](#next-steps)
|
||||
7. [Usage Examples](#usage-examples)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend Dashboard │
|
||||
│ (Real-time Equipment Monitoring UI) │
|
||||
└────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Production Service API │
|
||||
│ /api/v1/equipment/{id}/iot-config │
|
||||
│ /api/v1/equipment/{id}/realtime-data │
|
||||
│ /api/v1/equipment/{id}/sensor-history │
|
||||
│ /api/v1/equipment/{id}/test-connection │
|
||||
└────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ IoT Integration Service │
|
||||
│ - Connection management │
|
||||
│ - Data transformation │
|
||||
│ - Protocol abstraction │
|
||||
└────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────┴──────────┬──────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ REST API │ │ OPC UA │ │ MQTT │
|
||||
│ Connector │ │ Connector │ │ Connector │
|
||||
└─────┬──────┘ └──────┬───────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Smart IoT-Enabled Equipment │
|
||||
│ - Rational iCombi (ConnectedCooking) │
|
||||
│ - Wachtel REMOTE │
|
||||
│ - SALVA Smart Ovens │
|
||||
│ - Generic REST API Equipment │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### New Tables
|
||||
|
||||
#### 1. `equipment` (Extended)
|
||||
Added IoT connectivity fields:
|
||||
- `iot_enabled` - Enable/disable IoT connectivity
|
||||
- `iot_protocol` - Protocol type (rest_api, opc_ua, mqtt, modbus, custom)
|
||||
- `iot_endpoint` - Connection endpoint URL/IP
|
||||
- `iot_port` - Connection port
|
||||
- `iot_credentials` - JSON encrypted credentials
|
||||
- `iot_connection_status` - Current connection status
|
||||
- `iot_last_connected` - Timestamp of last successful connection
|
||||
- `iot_config` - Additional protocol-specific configuration
|
||||
- `manufacturer` - Equipment manufacturer
|
||||
- `firmware_version` - Firmware version
|
||||
- `supports_realtime` - Supports real-time monitoring
|
||||
- `poll_interval_seconds` - Data polling interval
|
||||
- `temperature_zones` - Number of temperature zones
|
||||
- `supports_humidity` - Humidity monitoring capability
|
||||
- `supports_energy_monitoring` - Energy monitoring capability
|
||||
- `supports_remote_control` - Remote control capability
|
||||
|
||||
#### 2. `equipment_sensor_readings`
|
||||
Time-series sensor data storage:
|
||||
- Core readings: temperature, humidity, energy consumption
|
||||
- Status: operational_status, cycle_stage, progress
|
||||
- Process parameters: motor_speed, door_status, steam_level
|
||||
- Quality indicators: product_weight, moisture_content
|
||||
- Flexible JSON field for manufacturer-specific sensors
|
||||
|
||||
#### 3. `equipment_connection_logs`
|
||||
Connection event tracking:
|
||||
- event_type, event_time, connection_status
|
||||
- Error tracking: error_message, error_code
|
||||
- Performance metrics: response_time_ms, data_points_received
|
||||
|
||||
#### 4. `equipment_iot_alerts`
|
||||
Real-time equipment alerts:
|
||||
- Alert types: temperature_deviation, connection_lost, equipment_error
|
||||
- Severity levels: info, warning, critical
|
||||
- Status tracking: active, acknowledged, resolved
|
||||
- Automated response tracking
|
||||
|
||||
### Migration
|
||||
|
||||
Run migration to add IoT support:
|
||||
```bash
|
||||
cd services/production
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
Migration file: `migrations/versions/002_add_iot_equipment_support.py`
|
||||
|
||||
## IoT Connectors
|
||||
|
||||
### Connector Architecture
|
||||
|
||||
All connectors implement the `BaseIoTConnector` abstract interface:
|
||||
|
||||
```python
|
||||
from app.services.iot import BaseIoTConnector, ConnectorFactory
|
||||
|
||||
# Create connector instance
|
||||
connector = ConnectorFactory.create_connector(
|
||||
protocol='rest_api',
|
||||
equipment_id='equipment-uuid',
|
||||
config={
|
||||
'endpoint': 'https://api.example.com',
|
||||
'port': 443,
|
||||
'credentials': {'api_key': 'xxx'},
|
||||
'additional_config': {}
|
||||
}
|
||||
)
|
||||
|
||||
# Test connection
|
||||
status = await connector.test_connection()
|
||||
|
||||
# Get current readings
|
||||
reading = await connector.get_current_reading()
|
||||
|
||||
# Get equipment capabilities
|
||||
capabilities = await connector.get_capabilities()
|
||||
```
|
||||
|
||||
### Available Connectors
|
||||
|
||||
#### 1. Generic REST API Connector
|
||||
**Protocol:** `rest_api`
|
||||
**File:** `app/services/iot/rest_api_connector.py`
|
||||
|
||||
**Configuration Example:**
|
||||
```json
|
||||
{
|
||||
"protocol": "rest_api",
|
||||
"endpoint": "https://api.equipment.com",
|
||||
"port": 443,
|
||||
"credentials": {
|
||||
"api_key": "your-api-key",
|
||||
"token": "bearer-token"
|
||||
},
|
||||
"additional_config": {
|
||||
"data_endpoint": "/api/v1/equipment/{equipment_id}/data",
|
||||
"status_endpoint": "/api/v1/equipment/{equipment_id}/status",
|
||||
"timeout": 10,
|
||||
"verify_ssl": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Standard REST API support
|
||||
- Bearer token & API key authentication
|
||||
- Basic authentication
|
||||
- Configurable endpoints
|
||||
- SSL verification control
|
||||
- Timeout configuration
|
||||
|
||||
#### 2. Rational ConnectedCooking Connector
|
||||
**Protocol:** `rational` or `rational_connected_cooking`
|
||||
**File:** `app/services/iot/rational_connector.py`
|
||||
|
||||
**Configuration Example:**
|
||||
```json
|
||||
{
|
||||
"protocol": "rational",
|
||||
"endpoint": "https://www.connectedcooking.com/api/v1",
|
||||
"port": 443,
|
||||
"credentials": {
|
||||
"username": "your-email@example.com",
|
||||
"password": "your-password"
|
||||
},
|
||||
"additional_config": {
|
||||
"unit_id": "12345",
|
||||
"timeout": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Multi-zone temperature (cabinet + core)
|
||||
- Humidity monitoring
|
||||
- Energy consumption tracking
|
||||
- Remote control support
|
||||
- HACCP documentation
|
||||
- Recipe management
|
||||
- Automatic cleaning status
|
||||
|
||||
**Contact:** cc-support@rational-online.com
|
||||
|
||||
#### 3. Wachtel REMOTE Connector
|
||||
**Protocol:** `wachtel` or `wachtel_remote`
|
||||
**File:** `app/services/iot/wachtel_connector.py`
|
||||
|
||||
**Configuration Example:**
|
||||
```json
|
||||
{
|
||||
"protocol": "wachtel",
|
||||
"endpoint": "https://remote.wachtel.de/api",
|
||||
"port": 443,
|
||||
"credentials": {
|
||||
"username": "bakery-username",
|
||||
"password": "bakery-password"
|
||||
},
|
||||
"additional_config": {
|
||||
"oven_id": "oven-serial-number",
|
||||
"deck_count": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Multi-deck temperature monitoring
|
||||
- Energy consumption tracking
|
||||
- Maintenance alerts
|
||||
- Operation hours tracking
|
||||
- Deck-specific control
|
||||
|
||||
**Contact:** support@wachtel.de
|
||||
|
||||
#### 4. OPC UA Connector (Template)
|
||||
**Protocol:** `opc_ua`
|
||||
**Status:** Template only (requires implementation)
|
||||
|
||||
For bakery equipment supporting OPC UA or Weihenstephan Standards (WS Bake).
|
||||
|
||||
**Dependencies:**
|
||||
```bash
|
||||
pip install asyncua==1.1.5
|
||||
```
|
||||
|
||||
**Template Location:** To be created at `app/services/iot/opcua_connector.py`
|
||||
|
||||
## Supported Equipment
|
||||
|
||||
### Equipment Research Summary
|
||||
|
||||
#### Spanish Manufacturers (Madrid Region)
|
||||
|
||||
1. **SALVA Industrial** (Lezo, Guipuzcoa)
|
||||
- Smart touch control panels
|
||||
- Energy monitoring
|
||||
- Digital integration
|
||||
- Status: API details pending
|
||||
|
||||
2. **Farjas** (Madrid, Móstoles)
|
||||
- Rotary ovens
|
||||
- Status: IoT capabilities unknown
|
||||
|
||||
3. **COLBAKE** (Valencia)
|
||||
- Complete bakery lines
|
||||
- Status: IoT capabilities to be confirmed
|
||||
|
||||
#### International Manufacturers with Madrid Presence
|
||||
|
||||
1. **Rational** (Germany) - ✅ **Implemented**
|
||||
- Product: iCombi ovens
|
||||
- Platform: ConnectedCooking
|
||||
- API: Available (REST)
|
||||
- Showroom: Madrid (15 min from airport)
|
||||
|
||||
2. **Wachtel** (Germany) - ✅ **Template Created**
|
||||
- Product: Deck ovens
|
||||
- Platform: REMOTE monitoring
|
||||
- API: REST (details pending confirmation)
|
||||
|
||||
3. **Sveba Dahlen** (Sweden)
|
||||
- Showroom in Madrid
|
||||
- Status: IoT capabilities to be researched
|
||||
|
||||
### Industry Standards
|
||||
|
||||
- **OPC UA**: Standard protocol for industrial automation
|
||||
- **Weihenstephan Standards (WS Bake)**: Bakery-specific communication standard
|
||||
- **MQTT**: Common IoT message protocol
|
||||
- **Modbus**: Industrial communication protocol
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Completed
|
||||
|
||||
1. **Database Schema**
|
||||
- Migration created and tested
|
||||
- All IoT tables defined
|
||||
- Indexes optimized for time-series queries
|
||||
|
||||
2. **Models**
|
||||
- Equipment model extended with IoT fields
|
||||
- Sensor reading model
|
||||
- Connection log model
|
||||
- IoT alert model
|
||||
- Enums: IoTProtocol, IoTConnectionStatus
|
||||
|
||||
3. **Schemas (Pydantic)**
|
||||
- IoTConnectionConfig
|
||||
- Equipment schemas updated with IoT fields
|
||||
- EquipmentSensorReadingResponse
|
||||
- EquipmentConnectionTestResponse
|
||||
- RealTimeDataResponse
|
||||
- EquipmentIoTAlertResponse
|
||||
- EquipmentSensorHistoryResponse
|
||||
|
||||
4. **IoT Connectors**
|
||||
- Base connector interface (`BaseIoTConnector`)
|
||||
- Connector factory pattern
|
||||
- Generic REST API connector (fully implemented)
|
||||
- Rational ConnectedCooking connector (implemented)
|
||||
- Wachtel REMOTE connector (template created)
|
||||
|
||||
5. **Dependencies**
|
||||
- requirements.txt updated
|
||||
- httpx for REST APIs
|
||||
- Commented dependencies for OPC UA and MQTT
|
||||
|
||||
### 🚧 In Progress / To Do
|
||||
|
||||
1. **IoT Integration Service** ⏳
|
||||
- High-level service layer
|
||||
- Connection pool management
|
||||
- Automatic retry logic
|
||||
- Health monitoring
|
||||
|
||||
2. **Repository Layer** ⏳
|
||||
- Equipment IoT configuration CRUD
|
||||
- Sensor data storage and retrieval
|
||||
- Connection log management
|
||||
- Alert management
|
||||
|
||||
3. **API Endpoints** ⏳
|
||||
- POST `/api/v1/equipment/{id}/iot-config` - Configure IoT
|
||||
- POST `/api/v1/equipment/{id}/test-connection` - Test connectivity
|
||||
- GET `/api/v1/equipment/{id}/realtime-data` - Get current data
|
||||
- GET `/api/v1/equipment/{id}/sensor-history` - Historical data
|
||||
- GET `/api/v1/batches/{id}/realtime-tracking` - Batch tracking
|
||||
- GET `/api/v1/equipment/iot-alerts` - Get active alerts
|
||||
|
||||
4. **Background Workers** ⏳
|
||||
- Periodic data collection worker
|
||||
- Connection health monitor
|
||||
- Alert generation and notification
|
||||
- Data cleanup (old sensor readings)
|
||||
|
||||
5. **Frontend Components** ⏳
|
||||
- Equipment IoT configuration wizard
|
||||
- Real-time monitoring dashboard
|
||||
- Sensor data visualization charts
|
||||
- Alert notification system
|
||||
- Connection status indicators
|
||||
|
||||
6. **Additional Connectors** 📋
|
||||
- OPC UA connector implementation
|
||||
- MQTT connector implementation
|
||||
- SALVA-specific connector (pending API details)
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Priority 1: Core Service Layer
|
||||
|
||||
1. **Create IoT Integration Service**
|
||||
```python
|
||||
# app/services/iot_integration_service.py
|
||||
class IoTIntegrationService:
|
||||
async def configure_equipment_iot(equipment_id, config)
|
||||
async def test_connection(equipment_id)
|
||||
async def get_realtime_data(equipment_id)
|
||||
async def get_sensor_history(equipment_id, start, end)
|
||||
async def store_sensor_reading(equipment_id, reading)
|
||||
```
|
||||
|
||||
2. **Create Repository Methods**
|
||||
```python
|
||||
# app/repositories/equipment_repository.py
|
||||
async def update_iot_config(equipment_id, config)
|
||||
async def get_iot_config(equipment_id)
|
||||
async def update_connection_status(equipment_id, status)
|
||||
|
||||
# app/repositories/sensor_reading_repository.py
|
||||
async def create_reading(reading)
|
||||
async def get_readings(equipment_id, start_time, end_time)
|
||||
async def get_latest_reading(equipment_id)
|
||||
```
|
||||
|
||||
3. **Create API Endpoints**
|
||||
```python
|
||||
# app/api/equipment_iot.py
|
||||
router = APIRouter(prefix="/equipment", tags=["equipment-iot"])
|
||||
|
||||
@router.post("/{equipment_id}/iot-config")
|
||||
@router.post("/{equipment_id}/test-connection")
|
||||
@router.get("/{equipment_id}/realtime-data")
|
||||
@router.get("/{equipment_id}/sensor-history")
|
||||
```
|
||||
|
||||
### Priority 2: Background Processing
|
||||
|
||||
1. **Data Collection Worker**
|
||||
- Poll IoT-enabled equipment at configured intervals
|
||||
- Store sensor readings in database
|
||||
- Handle connection errors gracefully
|
||||
|
||||
2. **Alert Generation**
|
||||
- Monitor temperature deviations
|
||||
- Detect connection losses
|
||||
- Generate alerts for critical conditions
|
||||
|
||||
### Priority 3: Frontend Integration
|
||||
|
||||
1. **Equipment Configuration UI**
|
||||
- IoT setup wizard
|
||||
- Protocol selection
|
||||
- Connection testing
|
||||
- Credential management
|
||||
|
||||
2. **Real-time Dashboard**
|
||||
- Live equipment status cards
|
||||
- Temperature/humidity gauges
|
||||
- Energy consumption charts
|
||||
- Alert notifications
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Configure Equipment for IoT
|
||||
|
||||
```python
|
||||
from app.services.iot_integration_service import IoTIntegrationService
|
||||
|
||||
service = IoTIntegrationService()
|
||||
|
||||
# Configure Rational iCombi oven
|
||||
config = {
|
||||
"protocol": "rational",
|
||||
"endpoint": "https://www.connectedcooking.com/api/v1",
|
||||
"port": 443,
|
||||
"credentials": {
|
||||
"username": "bakery@example.com",
|
||||
"password": "secure-password"
|
||||
},
|
||||
"additional_config": {
|
||||
"unit_id": "12345"
|
||||
}
|
||||
}
|
||||
|
||||
await service.configure_equipment_iot(equipment_id="uuid-here", config=config)
|
||||
```
|
||||
|
||||
### Example 2: Test Connection
|
||||
|
||||
```python
|
||||
# Test connection before saving configuration
|
||||
result = await service.test_connection(equipment_id="uuid-here")
|
||||
|
||||
if result.success:
|
||||
print(f"Connected in {result.response_time_ms}ms")
|
||||
print(f"Supported features: {result.supported_features}")
|
||||
else:
|
||||
print(f"Connection failed: {result.error_details}")
|
||||
```
|
||||
|
||||
### Example 3: Get Real-time Data
|
||||
|
||||
```python
|
||||
# Get current equipment data
|
||||
data = await service.get_realtime_data(equipment_id="uuid-here")
|
||||
|
||||
print(f"Temperature: {data.temperature}°C")
|
||||
print(f"Status: {data.operational_status}")
|
||||
print(f"Progress: {data.cycle_progress_percentage}%")
|
||||
print(f"Time remaining: {data.time_remaining_minutes} min")
|
||||
```
|
||||
|
||||
### Example 4: Retrieve Sensor History
|
||||
|
||||
```python
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Get last 24 hours of data
|
||||
end_time = datetime.now()
|
||||
start_time = end_time - timedelta(hours=24)
|
||||
|
||||
history = await service.get_sensor_history(
|
||||
equipment_id="uuid-here",
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
|
||||
# Plot temperature over time
|
||||
for reading in history.readings:
|
||||
print(f"{reading.reading_time}: {reading.temperature}°C")
|
||||
```
|
||||
|
||||
## API Endpoint Specifications
|
||||
|
||||
### POST /api/v1/equipment/{equipment_id}/iot-config
|
||||
|
||||
Configure IoT connectivity for equipment.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"protocol": "rational",
|
||||
"endpoint": "https://www.connectedcooking.com/api/v1",
|
||||
"port": 443,
|
||||
"credentials": {
|
||||
"username": "user@example.com",
|
||||
"password": "password"
|
||||
},
|
||||
"additional_config": {
|
||||
"unit_id": "12345"
|
||||
},
|
||||
"supports_realtime": true,
|
||||
"poll_interval_seconds": 30
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "IoT configuration saved successfully",
|
||||
"equipment_id": "uuid",
|
||||
"connection_test_result": {
|
||||
"success": true,
|
||||
"status": "connected",
|
||||
"response_time_ms": 145
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/equipment/{equipment_id}/realtime-data
|
||||
|
||||
Get current real-time sensor data.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"equipment_id": "uuid",
|
||||
"equipment_name": "Horno Principal #1",
|
||||
"timestamp": "2025-01-12T10:30:00Z",
|
||||
"connection_status": "connected",
|
||||
"temperature": 185.5,
|
||||
"temperature_zones": {
|
||||
"cabinet": 180,
|
||||
"core": 72
|
||||
},
|
||||
"humidity": 65.0,
|
||||
"operational_status": "running",
|
||||
"cycle_stage": "baking",
|
||||
"cycle_progress_percentage": 45.0,
|
||||
"time_remaining_minutes": 12,
|
||||
"energy_consumption_kwh": 12.5,
|
||||
"active_batch_id": "batch-uuid",
|
||||
"active_batch_name": "Baguettes - Batch #123"
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Credential Storage**
|
||||
- Store API keys/passwords encrypted in database
|
||||
- Use environment variables for sensitive configuration
|
||||
- Rotate credentials periodically
|
||||
|
||||
2. **SSL/TLS**
|
||||
- Always use HTTPS for REST API connections
|
||||
- Verify SSL certificates in production
|
||||
- Support self-signed certificates for local equipment
|
||||
|
||||
3. **Authentication**
|
||||
- Require user authentication for IoT configuration
|
||||
- Log all configuration changes
|
||||
- Implement role-based access control
|
||||
|
||||
4. **Network Security**
|
||||
- Support firewall-friendly protocols
|
||||
- Document required network ports
|
||||
- Consider VPN for equipment access
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
|
||||
1. **Timeout errors**
|
||||
- Increase timeout in additional_config
|
||||
- Check network connectivity
|
||||
- Verify firewall rules
|
||||
|
||||
2. **Authentication failures**
|
||||
- Verify credentials are correct
|
||||
- Check API key expiration
|
||||
- Confirm endpoint URL is correct
|
||||
|
||||
3. **SSL certificate errors**
|
||||
- Set `verify_ssl: false` for testing (not recommended for production)
|
||||
- Install proper SSL certificates
|
||||
- Use certificate bundles for corporate networks
|
||||
|
||||
### Data Quality Issues
|
||||
|
||||
1. **Missing sensor readings**
|
||||
- Check equipment supports requested sensors
|
||||
- Verify polling interval is appropriate
|
||||
- Review connection logs for errors
|
||||
|
||||
2. **Anomalous data**
|
||||
- Implement data validation
|
||||
- Set reasonable min/max thresholds
|
||||
- Flag outliers for manual review
|
||||
|
||||
## Resources
|
||||
|
||||
### Manufacturer Contacts
|
||||
|
||||
- **Rational:** cc-support@rational-online.com
|
||||
- **Wachtel:** support@wachtel.de / https://www.wachtel.de
|
||||
- **SALVA:** https://www.salva.es/en
|
||||
|
||||
### Standards and Protocols
|
||||
|
||||
- **OPC Foundation:** https://opcfoundation.org/
|
||||
- **Weihenstephan Standards:** https://www.weihenstephan-standards.com
|
||||
- **MQTT:** https://mqtt.org/
|
||||
|
||||
### Libraries
|
||||
|
||||
- **httpx:** https://www.python-httpx.org/
|
||||
- **asyncua:** https://github.com/FreeOpcUa/opcua-asyncio
|
||||
- **paho-mqtt:** https://pypi.org/project/paho-mqtt/
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-01-12
|
||||
**Status:** Phase 1 Complete - Foundation & Connectors
|
||||
**Next Milestone:** Service Layer & API Endpoints
|
||||
@@ -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
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Add IoT equipment support
|
||||
|
||||
Revision ID: 002_add_iot_equipment_support
|
||||
Revises: 001_unified_initial_schema
|
||||
Create Date: 2025-01-12 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '002_add_iot_equipment_support'
|
||||
down_revision = '001_unified_initial_schema'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add IoT connectivity fields to equipment and create sensor data tables"""
|
||||
|
||||
# Add IoT connectivity fields to equipment table
|
||||
op.add_column('equipment', sa.Column('iot_enabled', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('equipment', sa.Column('iot_protocol', sa.String(50), nullable=True))
|
||||
op.add_column('equipment', sa.Column('iot_endpoint', sa.String(500), nullable=True))
|
||||
op.add_column('equipment', sa.Column('iot_port', sa.Integer(), nullable=True))
|
||||
op.add_column('equipment', sa.Column('iot_credentials', postgresql.JSON(astext_type=sa.Text()), nullable=True))
|
||||
op.add_column('equipment', sa.Column('iot_connection_status', sa.String(50), nullable=True))
|
||||
op.add_column('equipment', sa.Column('iot_last_connected', sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column('equipment', sa.Column('iot_config', postgresql.JSON(astext_type=sa.Text()), nullable=True))
|
||||
op.add_column('equipment', sa.Column('manufacturer', sa.String(100), nullable=True))
|
||||
op.add_column('equipment', sa.Column('firmware_version', sa.String(50), nullable=True))
|
||||
|
||||
# Add real-time monitoring fields
|
||||
op.add_column('equipment', sa.Column('supports_realtime', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('equipment', sa.Column('poll_interval_seconds', sa.Integer(), nullable=True))
|
||||
|
||||
# Add sensor capability fields
|
||||
op.add_column('equipment', sa.Column('temperature_zones', sa.Integer(), nullable=True))
|
||||
op.add_column('equipment', sa.Column('supports_humidity', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('equipment', sa.Column('supports_energy_monitoring', sa.Boolean(), nullable=False, server_default='false'))
|
||||
op.add_column('equipment', sa.Column('supports_remote_control', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
# Create equipment_sensor_readings table for time-series data
|
||||
op.create_table(
|
||||
'equipment_sensor_readings',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||
sa.Column('equipment_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||
sa.Column('batch_id', postgresql.UUID(as_uuid=True), nullable=True, index=True),
|
||||
|
||||
# Timestamp
|
||||
sa.Column('reading_time', sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
|
||||
# Temperature readings (support multiple zones)
|
||||
sa.Column('temperature', sa.Float(), nullable=True),
|
||||
sa.Column('temperature_zones', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('target_temperature', sa.Float(), nullable=True),
|
||||
|
||||
# Humidity
|
||||
sa.Column('humidity', sa.Float(), nullable=True),
|
||||
sa.Column('target_humidity', sa.Float(), nullable=True),
|
||||
|
||||
# Energy monitoring
|
||||
sa.Column('energy_consumption_kwh', sa.Float(), nullable=True),
|
||||
sa.Column('power_current_kw', sa.Float(), nullable=True),
|
||||
|
||||
# Equipment status
|
||||
sa.Column('operational_status', sa.String(50), nullable=True),
|
||||
sa.Column('cycle_stage', sa.String(100), nullable=True),
|
||||
sa.Column('cycle_progress_percentage', sa.Float(), nullable=True),
|
||||
sa.Column('time_remaining_minutes', sa.Integer(), nullable=True),
|
||||
|
||||
# Process parameters
|
||||
sa.Column('motor_speed_rpm', sa.Float(), nullable=True),
|
||||
sa.Column('door_status', sa.String(20), nullable=True),
|
||||
sa.Column('steam_level', sa.Float(), nullable=True),
|
||||
|
||||
# Quality indicators
|
||||
sa.Column('product_weight_kg', sa.Float(), nullable=True),
|
||||
sa.Column('moisture_content', sa.Float(), nullable=True),
|
||||
|
||||
# Additional sensor data (flexible JSON for manufacturer-specific metrics)
|
||||
sa.Column('additional_sensors', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
|
||||
# Data quality
|
||||
sa.Column('data_quality_score', sa.Float(), nullable=True),
|
||||
sa.Column('is_anomaly', sa.Boolean(), nullable=False, server_default='false'),
|
||||
|
||||
# Timestamps
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
|
||||
# Foreign key constraints
|
||||
sa.ForeignKeyConstraint(['equipment_id'], ['equipment.id'], ondelete='CASCADE'),
|
||||
)
|
||||
|
||||
# Create indexes for time-series queries
|
||||
op.create_index(
|
||||
'idx_sensor_readings_equipment_time',
|
||||
'equipment_sensor_readings',
|
||||
['equipment_id', 'reading_time'],
|
||||
)
|
||||
op.create_index(
|
||||
'idx_sensor_readings_batch',
|
||||
'equipment_sensor_readings',
|
||||
['batch_id', 'reading_time'],
|
||||
)
|
||||
op.create_index(
|
||||
'idx_sensor_readings_tenant_time',
|
||||
'equipment_sensor_readings',
|
||||
['tenant_id', 'reading_time'],
|
||||
)
|
||||
|
||||
# Create equipment_connection_logs table for tracking connectivity
|
||||
op.create_table(
|
||||
'equipment_connection_logs',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||
sa.Column('equipment_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||
|
||||
# Connection event
|
||||
sa.Column('event_type', sa.String(50), nullable=False), # connected, disconnected, error, timeout
|
||||
sa.Column('event_time', sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
|
||||
# Connection details
|
||||
sa.Column('connection_status', sa.String(50), nullable=False),
|
||||
sa.Column('protocol_used', sa.String(50), nullable=True),
|
||||
sa.Column('endpoint', sa.String(500), nullable=True),
|
||||
|
||||
# Error tracking
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('error_code', sa.String(50), nullable=True),
|
||||
|
||||
# Performance metrics
|
||||
sa.Column('response_time_ms', sa.Integer(), nullable=True),
|
||||
sa.Column('data_points_received', sa.Integer(), nullable=True),
|
||||
|
||||
# Additional details
|
||||
sa.Column('additional_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
|
||||
# Timestamps
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
|
||||
# Foreign key constraints
|
||||
sa.ForeignKeyConstraint(['equipment_id'], ['equipment.id'], ondelete='CASCADE'),
|
||||
)
|
||||
|
||||
# Create index for connection logs
|
||||
op.create_index(
|
||||
'idx_connection_logs_equipment_time',
|
||||
'equipment_connection_logs',
|
||||
['equipment_id', 'event_time'],
|
||||
)
|
||||
|
||||
# Create equipment_alerts table for IoT-based alerts
|
||||
op.create_table(
|
||||
'equipment_iot_alerts',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column('tenant_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||
sa.Column('equipment_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
||||
sa.Column('batch_id', postgresql.UUID(as_uuid=True), nullable=True, index=True),
|
||||
|
||||
# Alert information
|
||||
sa.Column('alert_type', sa.String(50), nullable=False), # temperature_deviation, connection_lost, equipment_error
|
||||
sa.Column('severity', sa.String(20), nullable=False), # info, warning, critical
|
||||
sa.Column('alert_time', sa.DateTime(timezone=True), nullable=False, index=True),
|
||||
|
||||
# Alert details
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('message', sa.Text(), nullable=False),
|
||||
sa.Column('sensor_reading_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
|
||||
# Threshold information
|
||||
sa.Column('threshold_value', sa.Float(), nullable=True),
|
||||
sa.Column('actual_value', sa.Float(), nullable=True),
|
||||
sa.Column('deviation_percentage', sa.Float(), nullable=True),
|
||||
|
||||
# Status tracking
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('is_acknowledged', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('acknowledged_by', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('acknowledged_at', sa.DateTime(timezone=True), nullable=True),
|
||||
|
||||
sa.Column('is_resolved', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('resolved_by', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('resolution_notes', sa.Text(), nullable=True),
|
||||
|
||||
# Automated response
|
||||
sa.Column('auto_resolved', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('corrective_action_taken', sa.String(255), nullable=True),
|
||||
|
||||
# Additional data
|
||||
sa.Column('additional_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
|
||||
# Timestamps
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
|
||||
# Foreign key constraints
|
||||
sa.ForeignKeyConstraint(['equipment_id'], ['equipment.id'], ondelete='CASCADE'),
|
||||
)
|
||||
|
||||
# Create indexes for alerts
|
||||
op.create_index(
|
||||
'idx_iot_alerts_equipment_time',
|
||||
'equipment_iot_alerts',
|
||||
['equipment_id', 'alert_time'],
|
||||
)
|
||||
op.create_index(
|
||||
'idx_iot_alerts_active',
|
||||
'equipment_iot_alerts',
|
||||
['is_active', 'is_resolved'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove IoT equipment support"""
|
||||
|
||||
# Drop tables
|
||||
op.drop_table('equipment_iot_alerts')
|
||||
op.drop_table('equipment_connection_logs')
|
||||
op.drop_table('equipment_sensor_readings')
|
||||
|
||||
# Remove columns from equipment table
|
||||
op.drop_column('equipment', 'supports_remote_control')
|
||||
op.drop_column('equipment', 'supports_energy_monitoring')
|
||||
op.drop_column('equipment', 'supports_humidity')
|
||||
op.drop_column('equipment', 'temperature_zones')
|
||||
op.drop_column('equipment', 'poll_interval_seconds')
|
||||
op.drop_column('equipment', 'supports_realtime')
|
||||
op.drop_column('equipment', 'firmware_version')
|
||||
op.drop_column('equipment', 'manufacturer')
|
||||
op.drop_column('equipment', 'iot_config')
|
||||
op.drop_column('equipment', 'iot_last_connected')
|
||||
op.drop_column('equipment', 'iot_connection_status')
|
||||
op.drop_column('equipment', 'iot_credentials')
|
||||
op.drop_column('equipment', 'iot_port')
|
||||
op.drop_column('equipment', 'iot_endpoint')
|
||||
op.drop_column('equipment', 'iot_protocol')
|
||||
op.drop_column('equipment', 'iot_enabled')
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Rename metadata to additional_data
|
||||
|
||||
Revision ID: 003_rename_metadata
|
||||
Revises: 002_add_iot_equipment_support
|
||||
Create Date: 2025-01-12 21:05:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '003_rename_metadata'
|
||||
down_revision = '002_add_iot_equipment_support'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Rename metadata columns to additional_data to avoid SQLAlchemy reserved attribute conflict"""
|
||||
|
||||
# Rename metadata column in equipment_connection_logs
|
||||
op.execute('ALTER TABLE equipment_connection_logs RENAME COLUMN metadata TO additional_data')
|
||||
|
||||
# Rename metadata column in equipment_iot_alerts
|
||||
op.execute('ALTER TABLE equipment_iot_alerts RENAME COLUMN metadata TO additional_data')
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Revert column names back to metadata"""
|
||||
|
||||
# Revert metadata column in equipment_iot_alerts
|
||||
op.execute('ALTER TABLE equipment_iot_alerts RENAME COLUMN additional_data TO metadata')
|
||||
|
||||
# Revert metadata column in equipment_connection_logs
|
||||
op.execute('ALTER TABLE equipment_connection_logs RENAME COLUMN additional_data TO metadata')
|
||||
@@ -14,6 +14,10 @@ psycopg2-binary==2.9.10
|
||||
# HTTP clients
|
||||
httpx==0.28.1
|
||||
|
||||
# IoT and Industrial Protocols
|
||||
# asyncua==1.1.5 # OPC UA client (uncomment when implementing OPC UA connector)
|
||||
# paho-mqtt==2.1.0 # MQTT client (uncomment when implementing MQTT connector)
|
||||
|
||||
# Logging and monitoring
|
||||
structlog==25.4.0
|
||||
prometheus-client==0.23.1
|
||||
|
||||
Reference in New Issue
Block a user