Plugin Development Guide¶
This guide explains how to create custom sensor handlers, protocol adapters, and device integrations for Estate Sentry.
Overview¶
Estate Sentry uses a plugin-based architecture that allows you to:
- Add support for new sensor types
- Integrate with new communication protocols
- Implement custom threat detection logic
- Extend the system without modifying core code
Creating a Sensor Handler¶
Handler Structure¶
Every sensor handler inherits from BaseSensorHandler:
# sensors/handlers/my_sensor.py
from typing import List, Tuple, Optional
from .base import BaseSensorHandler
class MySensorHandler(BaseSensorHandler):
"""Handler for my custom sensor type."""
# Required: Sensor types this handler supports
SENSOR_TYPES = ['MY_SENSOR_TYPE']
# Optional: Supported communication protocols
PROTOCOLS = ['mqtt', 'rest']
# Optional: Handler version
VERSION = '1.0.0'
def validate_reading(self, data: dict) -> Tuple[bool, Optional[str]]:
"""
Validate incoming sensor data.
Args:
data: Raw sensor reading data
Returns:
Tuple of (is_valid, error_message)
- If valid: (True, None)
- If invalid: (False, "Error description")
"""
# Implement your validation logic
if 'required_field' not in data:
return False, "Missing required_field"
return True, None
def process_reading(self, data: dict) -> dict:
"""
Process and normalize sensor data.
Args:
data: Validated sensor reading data
Returns:
Normalized reading data for storage
"""
return {
'value': data['required_field'],
'timestamp': data.get('timestamp'),
'metadata': data.get('metadata', {})
}
def detect_threats(self, reading: dict) -> List[dict]:
"""
Analyze reading for security threats.
Args:
reading: Processed sensor reading
Returns:
List of alert dictionaries
"""
alerts = []
# Implement threat detection logic
if self._is_suspicious(reading):
alerts.append({
'alert_type': 'CUSTOM_ALERT',
'severity': 'MEDIUM',
'title': f"Alert from {self.sensor.name}",
'description': "Suspicious activity detected",
'metadata': {'reading': reading}
})
return alerts
def _is_suspicious(self, reading: dict) -> bool:
"""Custom threat detection logic."""
# Your logic here
return False
Handler Registration¶
Handlers are automatically discovered when placed in sensors/handlers/. The registry scans for classes that:
- Inherit from
BaseSensorHandler - Have a non-empty
SENSOR_TYPESlist - Are not the base class itself
To manually register (for external packages):
# In your package's __init__.py
from sensors.registry import HandlerRegistry
from .my_handler import MySensorHandler
HandlerRegistry.register_handler(MySensorHandler)
Or use entry points in your pyproject.toml:
[project.entry-points."estate_sentry.handlers"]
my_sensor = "my_package.handlers:MySensorHandler"
Adding Sensor Types¶
Add your sensor type to the model choices:
# sensors/models.py
class Sensor(models.Model):
class SensorType(models.TextChoices):
# Existing types...
MY_SENSOR_TYPE = 'MY_SENSOR_TYPE', 'My Custom Sensor'
JSON Schema Validation¶
Define schemas for automatic validation:
class MySensorHandler(BaseSensorHandler):
# ...
@classmethod
def get_reading_schema(cls) -> dict:
"""JSON Schema for reading validation."""
return {
"type": "object",
"properties": {
"required_field": {
"type": "string",
"description": "Main sensor value"
},
"temperature": {
"type": "number",
"minimum": -50,
"maximum": 150
},
"battery_level": {
"type": "integer",
"minimum": 0,
"maximum": 100
}
},
"required": ["required_field"]
}
@classmethod
def get_connection_schema(cls) -> dict:
"""JSON Schema for connection configuration."""
return {
"type": "object",
"properties": {
"host": {"type": "string"},
"port": {"type": "integer"},
"api_key": {"type": "string"}
},
"required": ["host"]
}
Creating a Protocol Adapter¶
Protocol adapters handle communication with devices using different protocols.
Adapter Structure¶
# devices/protocols/my_protocol.py
from typing import Callable, Any
from .base import BaseProtocolAdapter
class MyProtocolAdapter(BaseProtocolAdapter):
"""Adapter for my custom protocol."""
PROTOCOL_NAME = "my_protocol"
def __init__(self, config: dict):
super().__init__(config)
self.client = None
async def connect(self) -> bool:
"""Establish connection to device."""
try:
self.client = MyProtocolClient(
host=self.config['host'],
port=self.config.get('port', 1234)
)
await self.client.connect()
self._connected = True
return True
except Exception as e:
self._connected = False
raise ConnectionError(f"Failed to connect: {e}")
async def disconnect(self) -> None:
"""Close connection."""
if self.client:
await self.client.disconnect()
self._connected = False
async def read(self) -> dict:
"""Read current state from device."""
if not self._connected:
raise RuntimeError("Not connected")
response = await self.client.query()
return {
'value': response.value,
'timestamp': response.timestamp
}
async def write(self, command: dict) -> bool:
"""Send command to device."""
if not self._connected:
return False
try:
await self.client.send(command)
return True
except Exception:
return False
async def subscribe(self, callback: Callable[[dict], Any]) -> None:
"""Subscribe to device updates."""
async for message in self.client.stream():
await callback({
'value': message.value,
'timestamp': message.timestamp
})
Protocol Registration¶
from sensors.registry import HandlerRegistry
HandlerRegistry.register_protocol(MyProtocolAdapter)
Complete Example: Glass Break Sensor¶
Here's a complete example of a glass break sensor handler:
# sensors/handlers/glass_break.py
from typing import List, Tuple, Optional
from datetime import datetime
from .base import BaseSensorHandler
class GlassBreakHandler(BaseSensorHandler):
"""Handler for glass break detection sensors."""
SENSOR_TYPES = ['GLASS_BREAK']
PROTOCOLS = ['zigbee', 'zwave', 'rest']
VERSION = '1.0.0'
# Severity thresholds
HIGH_INTENSITY_THRESHOLD = 80
MEDIUM_INTENSITY_THRESHOLD = 50
def validate_reading(self, data: dict) -> Tuple[bool, Optional[str]]:
"""Validate glass break sensor data."""
# Must have either triggered flag or intensity
if 'triggered' not in data and 'intensity' not in data:
return False, "Reading must include 'triggered' or 'intensity'"
# Validate intensity range
if 'intensity' in data:
intensity = data['intensity']
if not isinstance(intensity, (int, float)):
return False, "Intensity must be a number"
if not 0 <= intensity <= 100:
return False, "Intensity must be between 0 and 100"
return True, None
def process_reading(self, data: dict) -> dict:
"""Normalize glass break reading."""
processed = {
'timestamp': data.get('timestamp', datetime.utcnow().isoformat()),
'triggered': data.get('triggered', False),
'intensity': data.get('intensity', 0),
'frequency': data.get('frequency'), # Hz of detected sound
'duration_ms': data.get('duration_ms'), # Duration of event
}
# Infer triggered from intensity if not provided
if 'triggered' not in data and 'intensity' in data:
processed['triggered'] = data['intensity'] > self.MEDIUM_INTENSITY_THRESHOLD
return processed
def detect_threats(self, reading: dict) -> List[dict]:
"""Detect glass break threats."""
alerts = []
if not reading.get('triggered'):
return alerts
# Determine severity based on intensity
intensity = reading.get('intensity', 50)
if intensity >= self.HIGH_INTENSITY_THRESHOLD:
severity = 'HIGH'
description = "High-intensity glass break detected - possible forced entry"
elif intensity >= self.MEDIUM_INTENSITY_THRESHOLD:
severity = 'MEDIUM'
description = "Glass break detected"
else:
severity = 'LOW'
description = "Possible glass break detected (low intensity)"
alerts.append({
'alert_type': 'GLASS_BREAK',
'severity': severity,
'title': f"Glass break detected at {self.sensor.name}",
'description': description,
'metadata': {
'sensor_id': str(self.sensor.id),
'location': self.sensor.location,
'intensity': intensity,
'frequency': reading.get('frequency'),
'duration_ms': reading.get('duration_ms'),
}
})
return alerts
@classmethod
def get_reading_schema(cls) -> dict:
return {
"type": "object",
"properties": {
"triggered": {
"type": "boolean",
"description": "Whether glass break was detected"
},
"intensity": {
"type": "number",
"minimum": 0,
"maximum": 100,
"description": "Intensity of detected break (0-100)"
},
"frequency": {
"type": "number",
"description": "Frequency of detected sound in Hz"
},
"duration_ms": {
"type": "integer",
"description": "Duration of detected event in milliseconds"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
}
@classmethod
def get_connection_schema(cls) -> dict:
return {
"type": "object",
"properties": {
"protocol": {
"type": "string",
"enum": ["zigbee", "zwave", "rest"]
},
"device_id": {
"type": "string",
"description": "Device identifier on the network"
},
"sensitivity": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "Detection sensitivity (1-10)"
}
},
"required": ["device_id"]
}
Testing Handlers¶
Unit Tests¶
# sensors/tests/test_glass_break_handler.py
import pytest
from sensors.handlers.glass_break import GlassBreakHandler
from sensors.models import Sensor
@pytest.fixture
def sensor():
return Sensor(
name="Test Glass Break",
sensor_type="GLASS_BREAK",
location="Living Room"
)
@pytest.fixture
def handler(sensor):
return GlassBreakHandler(sensor)
class TestGlassBreakHandler:
def test_validate_valid_reading(self, handler):
data = {"triggered": True, "intensity": 75}
is_valid, error = handler.validate_reading(data)
assert is_valid is True
assert error is None
def test_validate_missing_required(self, handler):
data = {"extra_field": "value"}
is_valid, error = handler.validate_reading(data)
assert is_valid is False
assert "triggered" in error or "intensity" in error
def test_validate_invalid_intensity(self, handler):
data = {"intensity": 150}
is_valid, error = handler.validate_reading(data)
assert is_valid is False
assert "intensity" in error.lower()
def test_process_reading(self, handler):
data = {"triggered": True, "intensity": 80}
processed = handler.process_reading(data)
assert processed['triggered'] is True
assert processed['intensity'] == 80
assert 'timestamp' in processed
def test_detect_threats_high_intensity(self, handler):
reading = {"triggered": True, "intensity": 90}
alerts = handler.detect_threats(reading)
assert len(alerts) == 1
assert alerts[0]['severity'] == 'HIGH'
assert alerts[0]['alert_type'] == 'GLASS_BREAK'
def test_detect_threats_no_trigger(self, handler):
reading = {"triggered": False, "intensity": 0}
alerts = handler.detect_threats(reading)
assert len(alerts) == 0
Integration Tests¶
# sensors/tests/test_handler_integration.py
from rest_framework.test import APITestCase
from authentication.models import User
from sensors.models import Sensor
class GlassBreakIntegrationTest(APITestCase):
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
password='testpass'
)
self.client.force_authenticate(user=self.user)
self.sensor = Sensor.objects.create(
owner=self.user,
name="Living Room Glass Break",
sensor_type="GLASS_BREAK",
location="Living Room",
status="ACTIVE"
)
def test_submit_reading_creates_alert(self):
response = self.client.post(
f'/api/sensors/{self.sensor.id}/readings/',
{'value': {'triggered': True, 'intensity': 85}},
format='json'
)
self.assertEqual(response.status_code, 201)
# Verify alert was created
from alerts.models import Alert
alerts = Alert.objects.filter(sensor=self.sensor)
self.assertEqual(alerts.count(), 1)
self.assertEqual(alerts[0].alert_type, 'GLASS_BREAK')
self.assertEqual(alerts[0].severity, 'HIGH')
Best Practices¶
1. Validate Thoroughly¶
Always validate all expected fields:
def validate_reading(self, data: dict) -> Tuple[bool, Optional[str]]:
errors = []
if 'state' not in data:
errors.append("Missing 'state' field")
if 'state' in data and data['state'] not in ['on', 'off']:
errors.append("Invalid state value")
if errors:
return False, "; ".join(errors)
return True, None
2. Handle Missing Data Gracefully¶
def process_reading(self, data: dict) -> dict:
return {
'value': data['value'],
'timestamp': data.get('timestamp', datetime.utcnow().isoformat()),
'battery': data.get('battery_level'), # May be None
'signal': data.get('signal_strength'), # May be None
}
3. Use Appropriate Severity Levels¶
| Severity | Use Case |
|---|---|
| CRITICAL | Immediate danger, requires instant response |
| HIGH | Serious threat, requires quick response |
| MEDIUM | Potential threat, requires attention |
| LOW | Minor concern, informational |
| INFO | Status update, no action required |
4. Include Context in Alerts¶
def detect_threats(self, reading: dict) -> List[dict]:
return [{
'alert_type': 'MY_ALERT',
'severity': 'MEDIUM',
'title': f"Alert from {self.sensor.name}",
'description': f"Detected at {self.sensor.location}",
'metadata': {
'sensor_id': str(self.sensor.id),
'sensor_type': self.sensor.sensor_type,
'location': self.sensor.location,
'reading': reading,
'timestamp': datetime.utcnow().isoformat()
}
}]
5. Document Your Handler¶
class MySensorHandler(BaseSensorHandler):
"""
Handler for XYZ brand motion sensors.
Supported models:
- XYZ-100: Basic motion detection
- XYZ-200: Motion + temperature
Reading format:
{
"motion": true,
"temperature": 72.5,
"battery": 85
}
Connection config:
{
"device_id": "XYZ-100-001",
"protocol": "zigbee"
}
"""