""" 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 {} }