first commit
This commit is contained in:
328
layers/business/sensor_service.py
Normal file
328
layers/business/sensor_service.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user