328 lines
13 KiB
Python
328 lines
13 KiB
Python
"""
|
|
Sensor business logic service
|
|
Business Layer - handles sensor-related business operations and rules
|
|
"""
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, Any, List, Optional
|
|
import logging
|
|
import uuid
|
|
|
|
from models import (
|
|
SensorReading, LegacySensorReading, SensorMetadata,
|
|
SensorType, SensorStatus, CO2Status, OccupancyLevel
|
|
)
|
|
from ..infrastructure.repositories import (
|
|
SensorReadingRepository, SensorMetadataRepository,
|
|
SystemEventRepository, RedisRepository
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class SensorService:
|
|
"""Service for sensor-related business operations"""
|
|
|
|
def __init__(self):
|
|
self.sensor_reading_repo = SensorReadingRepository()
|
|
self.sensor_metadata_repo = SensorMetadataRepository()
|
|
self.system_event_repo = SystemEventRepository()
|
|
self.redis_repo = RedisRepository()
|
|
|
|
async def process_sensor_message(self, message_data: str) -> bool:
|
|
"""Process incoming sensor message and handle business logic"""
|
|
try:
|
|
# Parse the message
|
|
data = json.loads(message_data)
|
|
logger.debug(f"Processing sensor message: {data}")
|
|
|
|
# Convert to standard format
|
|
sensor_reading = await self._parse_sensor_data(data)
|
|
|
|
# Validate business rules
|
|
validation_result = await self._validate_sensor_reading(sensor_reading)
|
|
if not validation_result["valid"]:
|
|
logger.warning(f"Sensor reading validation failed: {validation_result['errors']}")
|
|
return False
|
|
|
|
# Store the reading
|
|
stored = await self.sensor_reading_repo.create(sensor_reading)
|
|
if not stored:
|
|
return False
|
|
|
|
# Update caches and metadata
|
|
await self._update_caches(sensor_reading)
|
|
await self._update_sensor_metadata(sensor_reading)
|
|
|
|
# Check for alerts
|
|
await self._check_sensor_alerts(sensor_reading)
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing sensor message: {e}")
|
|
await self._log_processing_error(str(e), message_data)
|
|
return False
|
|
|
|
async def _parse_sensor_data(self, data: dict) -> SensorReading:
|
|
"""Parse and convert sensor data to standard format"""
|
|
# Check if legacy format
|
|
if self._is_legacy_format(data):
|
|
return await self._convert_legacy_data(data)
|
|
else:
|
|
return SensorReading(**data)
|
|
|
|
def _is_legacy_format(self, data: dict) -> bool:
|
|
"""Check if data is in legacy format"""
|
|
legacy_keys = {"sensorId", "timestamp", "value", "unit"}
|
|
return legacy_keys.issubset(data.keys()) and "energy" not in data
|
|
|
|
async def _convert_legacy_data(self, data: dict) -> SensorReading:
|
|
"""Convert legacy format to new sensor reading format"""
|
|
legacy_reading = LegacySensorReading(**data)
|
|
|
|
return SensorReading(
|
|
sensor_id=legacy_reading.sensor_id,
|
|
sensor_type=SensorType.ENERGY,
|
|
timestamp=legacy_reading.timestamp,
|
|
created_at=legacy_reading.created_at,
|
|
energy={
|
|
"value": legacy_reading.value,
|
|
"unit": legacy_reading.unit
|
|
}
|
|
)
|
|
|
|
async def _validate_sensor_reading(self, reading: SensorReading) -> Dict[str, Any]:
|
|
"""Validate sensor reading against business rules"""
|
|
errors = []
|
|
|
|
# Check timestamp is not too far in the future
|
|
future_threshold = datetime.utcnow().timestamp() + 3600 # 1 hour
|
|
if reading.timestamp > future_threshold:
|
|
errors.append("Timestamp is too far in the future")
|
|
|
|
# Check timestamp is not too old
|
|
past_threshold = datetime.utcnow().timestamp() - 86400 # 24 hours
|
|
if reading.timestamp < past_threshold:
|
|
errors.append("Timestamp is too old")
|
|
|
|
# Validate sensor values
|
|
if reading.energy:
|
|
energy_value = reading.energy.get("value", 0)
|
|
if energy_value < 0 or energy_value > 1000: # Reasonable energy range
|
|
errors.append("Energy value is out of acceptable range")
|
|
|
|
if reading.co2:
|
|
co2_value = reading.co2.get("value", 0)
|
|
if co2_value < 0 or co2_value > 50000: # Reasonable CO2 range
|
|
errors.append("CO2 value is out of acceptable range")
|
|
|
|
if reading.temperature:
|
|
temp_value = reading.temperature.get("value", 0)
|
|
if temp_value < -50 or temp_value > 100: # Reasonable temperature range
|
|
errors.append("Temperature value is out of acceptable range")
|
|
|
|
return {
|
|
"valid": len(errors) == 0,
|
|
"errors": errors
|
|
}
|
|
|
|
async def _update_caches(self, reading: SensorReading) -> None:
|
|
"""Update Redis caches with latest sensor data"""
|
|
# Cache latest sensor reading
|
|
await self.redis_repo.set_sensor_data(
|
|
reading.sensor_id,
|
|
reading.dict(),
|
|
expire_seconds=3600
|
|
)
|
|
|
|
# Update sensor status
|
|
status_data = {
|
|
"status": "online",
|
|
"last_seen": reading.timestamp,
|
|
"room": reading.room
|
|
}
|
|
await self.redis_repo.set_sensor_status(
|
|
reading.sensor_id,
|
|
status_data,
|
|
expire_seconds=1800
|
|
)
|
|
|
|
async def _update_sensor_metadata(self, reading: SensorReading) -> None:
|
|
"""Update or create sensor metadata"""
|
|
existing = await self.sensor_metadata_repo.get_by_sensor_id(reading.sensor_id)
|
|
|
|
if existing:
|
|
# Update existing metadata
|
|
updates = {
|
|
"last_seen": datetime.utcnow(),
|
|
"status": SensorStatus.ONLINE.value
|
|
}
|
|
|
|
# Add sensor type to monitoring capabilities if not present
|
|
capabilities = existing.get("monitoring_capabilities", [])
|
|
if reading.sensor_type.value not in capabilities:
|
|
capabilities.append(reading.sensor_type.value)
|
|
updates["monitoring_capabilities"] = capabilities
|
|
|
|
await self.sensor_metadata_repo.update(reading.sensor_id, updates)
|
|
else:
|
|
# Create new sensor metadata
|
|
metadata = SensorMetadata(
|
|
sensor_id=reading.sensor_id,
|
|
name=f"Sensor {reading.sensor_id}",
|
|
sensor_type=reading.sensor_type,
|
|
room=reading.room,
|
|
status=SensorStatus.ONLINE,
|
|
last_seen=datetime.utcnow(),
|
|
monitoring_capabilities=[reading.sensor_type.value]
|
|
)
|
|
|
|
await self.sensor_metadata_repo.create(metadata)
|
|
logger.info(f"Created metadata for new sensor: {reading.sensor_id}")
|
|
|
|
async def _check_sensor_alerts(self, reading: SensorReading) -> None:
|
|
"""Check for alert conditions in sensor data"""
|
|
alerts = []
|
|
|
|
# CO2 level alerts
|
|
if reading.co2:
|
|
co2_level = reading.co2.get("value", 0)
|
|
if co2_level > 5000:
|
|
alerts.append({
|
|
"event_type": "co2_critical",
|
|
"severity": "critical",
|
|
"title": "Critical CO2 Level",
|
|
"description": f"CO2 level ({co2_level} ppm) exceeds critical threshold in {reading.room or 'unknown room'}"
|
|
})
|
|
elif co2_level > 1000:
|
|
alerts.append({
|
|
"event_type": "co2_high",
|
|
"severity": "warning",
|
|
"title": "High CO2 Level",
|
|
"description": f"CO2 level ({co2_level} ppm) is above recommended levels in {reading.room or 'unknown room'}"
|
|
})
|
|
|
|
# Energy consumption alerts
|
|
if reading.energy:
|
|
energy_value = reading.energy.get("value", 0)
|
|
if energy_value > 10:
|
|
alerts.append({
|
|
"event_type": "energy_high",
|
|
"severity": "warning",
|
|
"title": "High Energy Consumption",
|
|
"description": f"Energy consumption ({energy_value} kWh) is unusually high for sensor {reading.sensor_id}"
|
|
})
|
|
|
|
# Temperature alerts
|
|
if reading.temperature:
|
|
temp_value = reading.temperature.get("value", 0)
|
|
if temp_value > 30 or temp_value < 15:
|
|
alerts.append({
|
|
"event_type": "temperature_extreme",
|
|
"severity": "warning",
|
|
"title": "Extreme Temperature",
|
|
"description": f"Temperature ({temp_value}°C) is outside normal range in {reading.room or 'unknown room'}"
|
|
})
|
|
|
|
# Log alerts as system events
|
|
for alert in alerts:
|
|
await self._log_alert_event(reading, **alert)
|
|
|
|
async def _log_alert_event(self, reading: SensorReading, event_type: str, severity: str,
|
|
title: str, description: str) -> None:
|
|
"""Log an alert as a system event"""
|
|
from models import SystemEvent
|
|
|
|
event = SystemEvent(
|
|
event_id=str(uuid.uuid4()),
|
|
event_type=event_type,
|
|
severity=severity,
|
|
timestamp=int(datetime.utcnow().timestamp()),
|
|
title=title,
|
|
description=description,
|
|
sensor_id=reading.sensor_id,
|
|
room=reading.room,
|
|
source="sensor_service",
|
|
data=reading.dict()
|
|
)
|
|
|
|
await self.system_event_repo.create(event)
|
|
|
|
async def _log_processing_error(self, error_message: str, raw_data: str) -> None:
|
|
"""Log data processing error"""
|
|
from models import SystemEvent
|
|
|
|
event = SystemEvent(
|
|
event_id=str(uuid.uuid4()),
|
|
event_type="data_processing_error",
|
|
severity="error",
|
|
timestamp=int(datetime.utcnow().timestamp()),
|
|
title="Sensor Data Processing Failed",
|
|
description=f"Failed to process sensor message: {error_message}",
|
|
source="sensor_service",
|
|
data={"raw_message": raw_data}
|
|
)
|
|
|
|
await self.system_event_repo.create(event)
|
|
|
|
async def get_sensor_details(self, sensor_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get complete sensor details including metadata and recent readings"""
|
|
# Get metadata
|
|
metadata = await self.sensor_metadata_repo.get_by_sensor_id(sensor_id)
|
|
if not metadata:
|
|
return None
|
|
|
|
# Get recent readings
|
|
recent_readings = await self.sensor_reading_repo.get_recent_by_sensor(
|
|
sensor_id=sensor_id,
|
|
limit=100,
|
|
minutes=1440 # 24 hours
|
|
)
|
|
|
|
# Get latest reading from cache
|
|
latest_reading = await self.redis_repo.get_sensor_data(sensor_id)
|
|
|
|
return {
|
|
"sensor": metadata,
|
|
"latest_reading": latest_reading,
|
|
"recent_readings_count": len(recent_readings),
|
|
"recent_readings": recent_readings[:10] # Return only 10 most recent
|
|
}
|
|
|
|
async def update_sensor_metadata(self, sensor_id: str, metadata_updates: Dict[str, Any]) -> bool:
|
|
"""Update sensor metadata with business validation"""
|
|
# Validate updates
|
|
if "sensor_id" in metadata_updates:
|
|
del metadata_updates["sensor_id"] # Cannot change sensor ID
|
|
|
|
# Update timestamp
|
|
metadata_updates["updated_at"] = datetime.utcnow()
|
|
|
|
return await self.sensor_metadata_repo.update(sensor_id, metadata_updates)
|
|
|
|
async def delete_sensor(self, sensor_id: str) -> Dict[str, Any]:
|
|
"""Delete a sensor and all its associated data"""
|
|
# Delete readings
|
|
readings_deleted = await self.sensor_reading_repo.delete_by_sensor_id(sensor_id)
|
|
|
|
# Delete metadata
|
|
metadata_deleted = await self.sensor_metadata_repo.delete(sensor_id)
|
|
|
|
# Clear cache
|
|
await self.redis_repo.delete_sensor_cache(sensor_id)
|
|
|
|
return {
|
|
"sensor_id": sensor_id,
|
|
"readings_deleted": readings_deleted,
|
|
"metadata_deleted": metadata_deleted
|
|
}
|
|
|
|
async def get_all_sensors(self, filters: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
"""Get all sensors with optional filtering"""
|
|
sensors = await self.sensor_metadata_repo.get_all(filters)
|
|
|
|
return {
|
|
"sensors": sensors,
|
|
"count": len(sensors),
|
|
"filters": filters or {}
|
|
} |