Implementing New Gravity Sensors
This guide documents how to add support for a new gravity sensor type in Fermentrack. It covers the backend implementation including models, API endpoints, serializers, and integration points.
Overview
Fermentrack supports multiple gravity sensor types, each implemented as a separate Django app. All gravity sensor implementations share a common base class and integrate with the same logging infrastructure.
The architecture follows three patterns based on device identification:
-
On-device identifier (e.g., iSpindel): The device has a unique ID stored on the device itself. Fermentrack generates a token, and the device sends that token with each POST request to identify itself.
-
Server-generated identifier (e.g., TiltPico): The device has no unique identifier. Fermentrack generates a unique key that becomes part of the device's POST URL.
-
Native Fermentrack Integration (e.g., TiltBridge): The device has its own management workflow built into the device to handle registration, which includes generating/capturing an identifier that can then be stored on the device. Implementation of this architecture is bespoke per device, and is not covered in this document.
Both of the first two patterns share the same core structure but differ in how devices are identified during data submission.
Project Structure
A gravity sensor implementation consists of these components:
fermentrack/<sensor_name>/
├── __init__.py
├── apps.py
├── models.py
├── migrations/
│ ├── __init__.py
│ └── ... (migrations automatically generated by Django)
├── api/
│ ├── __init__.py
│ ├── serializers.py # Django Rest Framework-compatible serializers
│ ├── device_views.py # API endpoints targeted by the device itself (typically for data capture/logging)
│ ├── views.py # API endpoints targeted by the SPA (Vue 3) User Interface (including CRUD operations)
│ └── urls.py
└── tests/
├── __init__.py
├── factories.py
└── test_*.py
Step 1: Create the Django App
Create the app directory structure and register it in settings.
apps.py
from django.apps import AppConfig
class YourSensorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'fermentrack.yoursensor'
Register in config/settings/base.py:
LOCAL_APPS = [
# ... existing apps
"fermentrack.yoursensor",
]
Step 2: Implement Models
All gravity sensors require three model components:
- Device Model - Extends
GravitySensorModel - Status Model - Dataclass stored in Redis for current readings
- Calibration Model - Optional, for storing calibration points
The Base Class: GravitySensorModel
GravitySensorModel (in fermentrack/gravity_sensors/models.py) provides:
- UUID primary key
namefield for human-readable identificationbrewhouseforeign key for multi-tenant supportinstalledboolean flaglast_checkintimestampactive_logproperty for FermentLog integration- Redis caching methods for gravity readings
- Stability detection and smoothing methods
All gravity sensors must implement two abstract methods:
def get_panel_info(self) -> GravityMainPanelInfo:
"""Return current temp/gravity for dashboard display."""
raise NotImplementedError
def get_data_for_log(self) -> dict or None:
"""Return data dict for logging to FermentLog."""
raise NotImplementedError
Device Model Implementation
All device models must inherit from BackupMixin in addition to GravitySensorModel to support backup/restore functionality. See the BackupMixin Integration section for details.
Pattern 1: On-device identifier (iSpindel)
When the device has its own identifier, use a token for authentication:
# fermentrack/ispindel/models.py
from ..backup_restore.mixins import BackupMixin
def generate_token() -> str:
"""Generate a random 5-character token."""
import random
import string
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=5))
class Ispindel(BackupMixin, GravitySensorModel):
# Required: List all fields to include in backup/restore
BACKUP_FIELDS = [
# From GravitySensorModel
'id', 'name', 'brewhouse', 'installed', 'last_checkin',
# Ispindel-specific
'name_on_device', 'id_on_device', 'token', 'interval',
'x0', 'x1', 'x2', 'x3', # Calibration coefficients
]
class Meta:
verbose_name = "iSpindel"
verbose_name_plural = "iSpindels"
# Device sends its own name/ID - we store them for reference
name_on_device = models.CharField(max_length=255, blank=True, default="")
id_on_device = models.CharField(max_length=36, blank=True, default="")
# Token for device authentication - included in POST body
token = models.CharField(
max_length=64,
default=generate_token,
unique=True,
help_text=_("The token for device authentication")
)
# Calibration coefficients for angle-to-gravity conversion
x0 = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
x1 = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
x2 = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
x3 = models.DecimalField(max_digits=10, decimal_places=4, null=True, blank=True)
def calc_gravity_from_angle(self, angle: Decimal) -> Gravity:
"""Calculate gravity using polynomial: x0 + x1*angle + x2*angle^2 + x3*angle^3"""
if any(x is None for x in [self.x0, self.x1, self.x2, self.x3]):
raise ValueError("Missing calibration coefficients")
gravity_value = self.x0 + self.x1*angle + self.x2*angle**2 + self.x3*angle**3
return Gravity(value=gravity_value, units=Gravity.UNITS_SG)
Pattern 2: Server-generated identifier (TiltPico)
When the device has no unique identifier, generate a key that becomes part of the URL:
# fermentrack/tiltpico/models.py
from ..backup_restore.mixins import BackupMixin
def generate_tilt_key() -> str:
"""Generate 6-char key: 5 random lowercase + 1 checksum char."""
random_chars = ''.join(random.choices(string.ascii_lowercase, k=5))
checksum_value = sum(ord(c) for c in random_chars) % 26
checksum_char = chr(ord('a') + checksum_value)
return random_chars + checksum_char
def validate_tilt_key_checksum(tilt_key: str) -> bool:
"""Validate the checksum character of a tilt key."""
if len(tilt_key) != 6 or not tilt_key.isalpha() or not tilt_key.islower():
return False
random_chars = tilt_key[:5]
expected = sum(ord(c) for c in random_chars) % 26
expected_char = chr(ord('a') + expected)
return tilt_key[5] == expected_char
class TiltPicoTiltHydrometer(BackupMixin, GravitySensorModel):
# Required: List all fields to include in backup/restore
BACKUP_FIELDS = [
# From GravitySensorModel
'id', 'name', 'brewhouse', 'installed', 'last_checkin',
# TiltPicoTiltHydrometer-specific
'tilt_key', 'color',
'grav_x0', 'grav_x1', 'grav_x2', # Calibration coefficients
]
class Meta:
verbose_name = _("TiltPico Tilt")
verbose_name_plural = _("TiltPico Tilts")
# Server-generated key - becomes part of POST URL
tilt_key = models.CharField(
max_length=6,
unique=True,
default=generate_tilt_key,
help_text=_("Unique key for this device (5 random chars + checksum)")
)
# Informational field updated from device data
color = models.CharField(max_length=10, blank=True, default='')
# Calibration: calibrated = x0 + x1*raw + x2*raw^2
grav_x0 = models.DecimalField(max_digits=5, decimal_places=3, default=Decimal('0.000'))
grav_x1 = models.DecimalField(max_digits=10, decimal_places=7, default=Decimal('1.0'))
grav_x2 = models.DecimalField(max_digits=10, decimal_places=7, default=Decimal('0.0'))
def calc_calibrated_gravity(self, raw_gravity: Gravity) -> Gravity:
"""Apply polynomial calibration to raw gravity reading."""
raw_sg = raw_gravity.as_sg()
calibrated = self.grav_x0 + self.grav_x1*raw_sg + self.grav_x2*(raw_sg**2)
return Gravity(value=calibrated, units=Gravity.UNITS_SG)
Status Model (Redis-Cached)
Status data changes frequently, so it's stored in Redis rather than the database. Use a dataclass with JSON serialization:
from dataclasses import dataclass
from ..custom_types.drf_serializers import CustomUnitSerializerMixin
@dataclass
class YourSensorStatus(CustomUnitSerializerMixin):
"""Current device status, stored in Redis."""
raw_temp: Optional[Temperature] = None
raw_gravity: Optional[Gravity] = None
calibrated_gravity: Optional[Gravity] = None
device: Optional["YourSensor"] = None
updated_at: Optional[datetime.datetime] = None
# How long before considering device disconnected
CONNECTION_THRESHOLD = datetime.timedelta(minutes=125)
# How long to retain status in Redis
MAX_RETENTION = datetime.timedelta(days=30)
def is_connected(self) -> bool:
"""Check if last update is within threshold."""
if self.updated_at is None:
return False
time_since_update = timezone.now() - self.updated_at
return time_since_update <= self.CONNECTION_THRESHOLD
def to_dict(self) -> dict:
"""Serialize to dictionary for JSON storage."""
return {
"raw_temp": self._serialize_temp_with_none(self.raw_temp),
"raw_gravity": self._serialize_gravity_with_none(self.raw_gravity),
"calibrated_gravity": self._serialize_gravity_with_none(self.calibrated_gravity),
"device": str(self.device.id),
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
def to_json(self) -> str:
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> "YourSensorStatus":
"""Deserialize from JSON string."""
d = json.loads(json_str)
return cls(
raw_temp=cls._unserialize_temp_with_none(d["raw_temp"]),
raw_gravity=cls._unserialize_gravity_with_none(d["raw_gravity"]),
calibrated_gravity=cls._unserialize_gravity_with_none(d["calibrated_gravity"]),
device=YourSensor.objects.get(id=d["device"]),
updated_at=datetime.datetime.fromisoformat(d["updated_at"]) if d["updated_at"] else None,
)
@classmethod
def redis_key(cls, device_id: str) -> str:
return f"yoursensor_status_{device_id}"
def save_to_redis(self):
"""Save current status to Redis."""
redis_client = local_redis.get_redis_client()
self.updated_at = timezone.now()
redis_client.set(name=self.redis_key(str(self.device.id)), value=self.to_json())
# Add to cached readings for trend analysis
if self.calibrated_gravity is not None and self.device is not None:
self.device.add_cached_reading(self.calibrated_gravity, self.updated_at)
@classmethod
def get_from_redis(cls, device_id: str) -> Optional["YourSensorStatus"]:
"""Retrieve status from Redis, with automatic expiry."""
redis_client = local_redis.get_redis_client()
status_json = redis_client.get(name=cls.redis_key(device_id))
if status_json is None:
return None
status = cls.from_json(status_json)
if status.updated_at and (timezone.now() - status.updated_at) > cls.MAX_RETENTION:
redis_client.delete(cls.redis_key(device_id))
return None
return status
Implementing get_panel_info and get_data_for_log
These methods bridge the device model with the rest of Fermentrack:
class YourSensor(GravitySensorModel):
# ... fields ...
def get_panel_info(self) -> GravityMainPanelInfo:
"""Return current readings for dashboard display."""
device_status = YourSensorStatus.get_from_redis(str(self.id))
if device_status is None:
return GravityMainPanelInfo(
current_temp=None,
current_gravity=None,
updated_at=None
)
return GravityMainPanelInfo(
current_temp=device_status.raw_temp,
current_gravity=device_status.calibrated_gravity,
updated_at=device_status.updated_at
)
def get_data_for_log(self) -> dict or None:
"""Return data for FermentLog logging."""
device_status = YourSensorStatus.get_from_redis(str(self.id))
if device_status is None:
return None
return {
'gravity': device_status.calibrated_gravity,
'gravity_temp': device_status.raw_temp,
'raw_reading': device_status.raw_gravity.as_sg() if device_status.raw_gravity else None,
'connected': device_status.is_connected()
}
Step 3: Implement Serializers
Create serializers in api/serializers.py:
from rest_framework import serializers
from ..models import YourSensor, YourSensorCalibrationPoint
from ...custom_types.drf_serializers import TemperatureField, GravityField
from ...ferment_logs.api.serializers import FermentLogSerializer
class YourSensorSerializer(serializers.ModelSerializer):
"""Serializer for CRUD operations."""
active_log = FermentLogSerializer(read_only=True)
class Meta:
model = YourSensor
fields = [
"name", "id", "brewhouse", "installed", "last_checkin",
# ... your custom fields ...
"active_log"
]
read_only_fields = ['id', 'brewhouse', 'last_checkin', 'active_log', 'installed']
class YourSensorStatusSerializer(serializers.Serializer):
"""Serializer for current device status."""
device_connected = serializers.SerializerMethodField()
raw_temp = TemperatureField()
raw_gravity = GravityField()
calibrated_gravity = GravityField()
updated_at = serializers.DateTimeField()
def get_device_connected(self, obj):
return obj.is_connected()
class YourSensorDataSerializer(serializers.Serializer):
"""
Serializer for device POST data.
Define fields based on what your device sends.
"""
temperature = serializers.DecimalField(max_digits=5, decimal_places=2)
gravity = serializers.DecimalField(max_digits=5, decimal_places=4)
# Add any device-specific fields
class YourSensorResponseSerializer(serializers.Serializer):
"""Standard response format."""
success = serializers.BooleanField()
message = serializers.CharField()
msg_code = serializers.CharField(required=False)
Step 4: Implement Device POST Endpoint
The device POST endpoint receives data from the physical device. This endpoint generally will be unauthenticated since IoT devices typically cannot perform OAuth/JWT authentication.
Pattern 1: Token in POST body (iSpindel)
# fermentrack/ispindel/api/device_views.py
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
@method_decorator(csrf_exempt, name='dispatch')
class IspindelPostView(APIView):
"""Handle POST requests from iSpindel devices."""
permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, *args, **kwargs):
# Validate incoming data
serializer = IspindelDataSerializer(data=request.data)
if not serializer.is_valid():
return JsonResponse({
'success': False,
'message': 'Invalid data format',
'msg_code': 'invalid_data'
}, status=status.HTTP_400_BAD_REQUEST)
data = serializer.validated_data
# Look up device by token (sent in POST body)
try:
device = Ispindel.objects.get(token=data['token'])
except Ispindel.DoesNotExist:
return JsonResponse({
'success': False,
'message': 'Unknown device token',
'msg_code': 'unknown_token'
}, status=status.HTTP_404_NOT_FOUND)
# Process data, update device, save status to Redis
# ... (see full implementation in fermentrack/ispindel/api/device_views.py)
# Trigger log update if device has an active log
if device.active_log is not None:
device.active_log.new_data_available()
return JsonResponse({
'success': True,
'message': 'Data received successfully',
'msg_code': 'ok'
}, status=status.HTTP_200_OK)
Pattern 2: Key in URL path (TiltPico)
# fermentrack/tiltpico/api/device_views.py
@method_decorator(csrf_exempt, name='dispatch')
class TiltPicoPostView(APIView):
"""Handle POST requests from TiltPico devices."""
permission_classes = [AllowAny]
authentication_classes = []
def post(self, request, tilt_key, *args, **kwargs):
# Normalize and validate key from URL
tilt_key = tilt_key.lower()
if not validate_tilt_key_checksum(tilt_key):
return JsonResponse({
'success': False,
'message': 'Invalid tilt key checksum',
'msg_code': 'invalid_checksum'
}, status=status.HTTP_400_BAD_REQUEST)
# Validate incoming data
serializer = TiltPicoDataSerializer(data=request.data)
if not serializer.is_valid():
return JsonResponse({
'success': False,
'message': 'Invalid data format',
'msg_code': 'invalid_data'
}, status=status.HTTP_400_BAD_REQUEST)
# Look up device by key (from URL)
try:
device = TiltPicoTiltHydrometer.objects.get(tilt_key=tilt_key)
except TiltPicoTiltHydrometer.DoesNotExist:
return JsonResponse({
'success': False,
'message': 'Unknown tilt key',
'msg_code': 'unknown_tilt_key'
}, status=status.HTTP_404_NOT_FOUND)
# Process data, save to Redis, trigger log...
# ... (see full implementation in fermentrack/tiltpico/api/device_views.py)
Step 5: Implement CRUD Views
Create views for authenticated API access by the user interface in api/views.py:
from rest_framework.mixins import (
ListModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin
)
from rest_framework.viewsets import GenericViewSet
from rest_framework.permissions import IsAuthenticated
class YourSensorViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin,
DestroyModelMixin, GenericViewSet):
"""ViewSet for CRUD operations."""
queryset = YourSensor.objects.all()
serializer_class = YourSensorSerializer
def get_queryset(self):
"""Filter to user's brewhouse only."""
return YourSensor.objects.filter(brewhouse=self.request.user.brewhouse)
def update(self, request, *args, **kwargs):
"""Clear cached readings when calibration changes."""
instance = self.get_object()
original_coeffs = {
'x0': instance.x0,
'x1': instance.x1,
# ... other calibration fields
}
response = super().update(request, *args, **kwargs)
if response.status_code in [200, 204]:
instance.refresh_from_db()
if any(original_coeffs[k] != getattr(instance, k) for k in original_coeffs):
instance.clear_cached_readings()
return response
class YourSensorStatusView(RetrieveAPIView):
"""Get current device status from Redis."""
serializer_class = YourSensorStatusSerializer
def get_queryset(self):
return YourSensor.objects.filter(brewhouse=self.request.user.brewhouse)
def get(self, request, *args, **kwargs):
instance = self.get_object()
device_status = YourSensorStatus.get_from_redis(str(instance.id))
if device_status is None:
return Response({'device_connected': False})
serializer = self.get_serializer(device_status)
return Response(serializer.data)
class YourSensorCreateView(APIView):
"""Create new device."""
permission_classes = [IsAuthenticated]
def post(self, request, *args, **kwargs):
device_name = request.data.get('name')
if not device_name:
return JsonResponse({
'success': False,
'message': 'Device name is required'
}, status=status.HTTP_400_BAD_REQUEST)
device = YourSensor.objects.create(
name=device_name,
brewhouse=request.user.brewhouse
)
return JsonResponse({
'success': True,
'message': 'Device created successfully',
'device': {
'id': str(device.id),
'name': device.name,
# For Pattern 1 (token): 'token': device.token,
# For Pattern 2 (URL key): 'tilt_key': device.tilt_key,
# ... other fields
}
}, status=status.HTTP_201_CREATED)
class YourSensorActionView(GenericAPIView):
"""Handle device actions like uninstall."""
permission_classes = [IsAuthenticated]
queryset = YourSensor.objects.all()
def get_queryset(self):
return YourSensor.objects.filter(brewhouse=self.request.user.brewhouse)
def patch(self, request, *args, **kwargs):
device = self.get_object()
data = JSONParser().parse(request)
if 'action' not in data:
return JsonResponse({
'success': False,
'message': 'No action provided',
'msg_code': 1
})
match data['action']:
case "uninstall_device":
device.delete()
return JsonResponse({
'success': True,
'message': 'Device uninstalled',
'msg_code': 0
})
case _:
return JsonResponse({
'success': False,
'message': 'Invalid action',
'msg_code': 2
})
Step 6: Configure URL Routing
Create api/urls.py:
from django.urls import path, include
from fermentrack.utils.get_router import get_router
from . import views, device_views
app_name = "yoursensor"
router = get_router()
router.register("device", views.YourSensorViewSet)
urlpatterns = [
# Device POST endpoint - MUST come before router.urls
# Pattern 1 (token in body): POST to base URL
# path("", device_views.YourSensorPostView.as_view(), name="yoursensor_post"),
# Pattern 2 (key in URL): POST to /<key>/
path("<str:device_key>/", device_views.YourSensorPostView.as_view(), name="yoursensor_post"),
# Include router URLs for ViewSet
path("", include(router.urls)),
# Additional endpoints
path("create/", views.YourSensorCreateView.as_view(), name="yoursensor_create"),
path("device/<str:pk>/status/", views.YourSensorStatusView.as_view(), name="yoursensor_status"),
path("device/<str:pk>/action/", views.YourSensorActionView.as_view(), name="yoursensor_action"),
]
Register in config/api_router.py:
urlpatterns += [
# ... existing routes
path("yoursensor/", include("fermentrack.yoursensor.api.urls", namespace="yoursensor")),
]
Step 7: Update Integration Points
Several parts of Fermentrack need to be updated to integrate your new gravity sensor type into the system.
Update FermentLog Model
The FermentLog model uses GenericForeignKey to link to gravity sensors. You must update several places to enable your sensor to be linked to fermentation logs.
In fermentrack/ferment_logs/models.py:
1. Add to ALLOWED_GRAVITY_SENSOR_MODELS constant:
class FermentLog(BackupMixin, TimeStampedModel):
# Add your model name (lowercase) to the allowed list
ALLOWED_GRAVITY_SENSOR_MODELS = [
'tiltbridgetilthydrometer', 'ispindel', 'tiltpicotilthydrometer',
'yoursensor' # Add your model name here (lowercase, no underscores)
]
2. Update limit_choices_to constraint on the ContentType FK field:
active_gravity_sensor_content_type = models.ForeignKey(
ContentType, on_delete=models.SET_NULL, null=True, blank=True,
limit_choices_to={
'model__in': ['tiltbridgetilthydrometer', 'ispindel', 'tiltpicotilthydrometer', 'yoursensor']
},
related_name='active_gravity_logs',
help_text=_("Content type of the active gravity sensor"),
default=None
)
3. Update translate_gravity_sensor_type() method:
def translate_gravity_sensor_type(self) -> str or None:
"""Translates the active gravity sensor type to a human-readable string"""
if self.active_gravity_sensor_content_type is None:
return None
if self.active_gravity_sensor_content_type.model == 'tiltbridgetilthydrometer':
return 'tiltbridge_tilt'
elif self.active_gravity_sensor_content_type.model == 'ispindel':
return 'ispindel'
elif self.active_gravity_sensor_content_type.model == 'tiltpicotilthydrometer':
return 'tiltpico_tilt'
elif self.active_gravity_sensor_content_type.model == 'yoursensor':
return 'yoursensor' # Match DeviceTypeChoices value
else:
raise NotImplementedError
Update DeviceTypeChoices
In fermentrack/gravity_sensors/models.py:
class DeviceTypeChoices(models.TextChoices):
TILTBRIDGE_TILT_HYDROMETER = 'tiltbridge_tilt', _("TiltBridge Tilt Hydrometer")
ISPINDEL = 'ispindel', _("iSpindel/GravityMon")
TILTPICO_TILT_HYDROMETER = 'tiltpico_tilt', _("TiltPico Tilt Hydrometer")
YOUR_SENSOR = 'yoursensor', _("Your Sensor Name")
Update Gravity Sensor API Views
In fermentrack/gravity_sensors/api/views.py, add your sensor to all three view classes that aggregate sensor types. Add import at the top:
from ...yoursensor.models import YourSensor
1. GravitySensorList - Returns all gravity sensors:
class GravitySensorList(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, rs_format=None):
all_devices = []
# ... existing sensor types ...
# Pull a list of YourSensor devices from the database
yoursensors = YourSensor.objects.filter(
brewhouse=request.user.brewhouse
).order_by('name')
for device in yoursensors:
device.device_type = GravitySensorModel.DeviceTypeChoices.YOUR_SENSOR
device.panel_info = device.get_panel_info().to_user_units(request.user)
all_devices.append(device)
serializer = GravitySensorSerializerWithPanelInfo(all_devices, many=True)
return JsonResponse(serializer.data, safe=False)
2. GravitySensorGet - Retrieves a specific sensor by ID:
class GravitySensorGet(RetrieveAPIView):
def get_object(self):
# ... existing sensor lookups ...
# Add lookup for YourSensor devices
try:
obj = YourSensor.objects.get(
brewhouse=self.request.user.brewhouse, **filter_kwargs
)
obj.device_type = GravitySensorModel.DeviceTypeChoices.YOUR_SENSOR
except YourSensor.DoesNotExist:
pass
if obj is None:
raise Http404
# ...
3. GravitySensorNotLoggingList - Returns sensors not currently logging:
class GravitySensorNotLoggingList(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, rs_format=None):
all_devices = []
# ... existing sensor types ...
# Append inactive YourSensor devices
yoursensor_content_type = ContentType.objects.get_for_model(YourSensor)
active_yoursensor_ids = FermentLog.objects.filter(
active_gravity_sensor_content_type=yoursensor_content_type
).values_list('active_gravity_sensor_object_id', flat=True)
yoursensor_devices = YourSensor.objects.filter(
brewhouse=request.user.brewhouse
).exclude(
id__in=active_yoursensor_ids
)
for device in yoursensor_devices:
device.device_type = GravitySensorModel.DeviceTypeChoices.YOUR_SENSOR
all_devices.append(device)
serializer = GravitySensorSerializerWithDeviceType(all_devices, many=True)
return JsonResponse(serializer.data, safe=False)
Step 8: Implement BackupMixin
All gravity sensor models must implement BackupMixin to support Fermentrack's backup and restore functionality. This enables users to export their sensor configurations and import them on a new installation.
Understanding BackupMixin
BackupMixin (in fermentrack/backup_restore/mixins.py) provides:
to_backup()- Serializes the model instance to a JSON-compatible dictionaryfrom_backup()- Restores a model instance from backup data with ownership validation- Automatic FK ownership validation via path discovery to Brewhouse
The mixin handles serialization of:
- UUID fields → strings
- Temperature/Gravity custom types → formatted strings (e.g., "65.00 F")
- datetime → ISO format strings
- timedelta → float (total_seconds)
- Decimal → strings
- ForeignKey → string of the related object's ID
Defining BACKUP_FIELDS
Every model using BackupMixin must define a BACKUP_FIELDS class attribute listing all fields to include in the backup:
class YourSensor(BackupMixin, GravitySensorModel):
BACKUP_FIELDS = [
# From GravitySensorModel (always include these)
'id', 'name', 'brewhouse', 'installed', 'last_checkin',
# YourSensor-specific fields
'token', 'color',
'x0', 'x1', 'x2', # Calibration coefficients
]
- Always include
'id'as the first field (required for restore) - Always include
'brewhouse'for ownership validation - Include all ForeignKey fields that need to be preserved
- Include all data fields the user would want to restore
Calibration Point Models
If your sensor has calibration points stored as a separate model, that model must also use BackupMixin:
from model_utils.models import TimeStampedModel, UUIDModel
from ..backup_restore.mixins import BackupMixin
class YourSensorCalibrationPoint(BackupMixin, TimeStampedModel, UUIDModel):
BACKUP_FIELDS = ['id', 'sensor_gravity', 'measured_gravity', 'yoursensor']
sensor_gravity = models.DecimalField(
max_digits=6, decimal_places=4,
help_text=_("Detected gravity from the sensor")
)
measured_gravity = models.DecimalField(
max_digits=6, decimal_places=4,
help_text=_("Reference gravity from a hydrometer")
)
yoursensor = models.ForeignKey(
YourSensor,
related_name='calibration_points',
on_delete=models.CASCADE,
)
The FK to the parent sensor (yoursensor in this example) is validated automatically during restore - it must belong to the same brewhouse.
Custom Serialization
For most fields, the default serialization works correctly. If you need custom handling (e.g., for GenericForeignKey fields), override the serialization methods:
class YourModelWithGenericFK(BackupMixin, TimeStampedModel):
BACKUP_FIELDS = ['id', 'name', 'brewhouse', 'related_content_type', 'related_object_id']
def _serialize_backup_field(self, field_name: str, value):
"""Override to serialize ContentType as model name."""
if field_name == 'related_content_type' and value is not None:
return value.model # Return model name string
return super()._serialize_backup_field(field_name, value)
@classmethod
def _deserialize_backup_field(cls, field_name: str, value, field):
"""Override to deserialize model name to ContentType."""
if field_name == 'related_content_type' and value is not None:
from django.contrib.contenttypes.models import ContentType
return ContentType.objects.get(model=value)
return super()._deserialize_backup_field(field_name, value, field)
See fermentrack/ferment_logs/models.py for a complete example of this pattern.
Step 9: Update backup_restore App
The backup_restore app coordinates backup generation and restore processing. You must update it to include your new sensor models.
Update Imports
In fermentrack/backup_restore/models.py, add imports for your models:
from fermentrack.yoursensor.models import YourSensor, YourSensorCalibrationPoint
Update RestoreRequest.process_fermentrack2_backup()
Add restore phases for your models in the correct dependency order. Models with no FK dependencies (except brewhouse) can be restored early; models that depend on other objects must come later.
In fermentrack/backup_restore/models.py, add to process_fermentrack2_backup():
def process_fermentrack2_backup(self, backup_data: dict):
# ... existing phases ...
# Phase N: YourSensor devices (no dependencies except brewhouse)
self._restore_model_batch(
backup_data, 'yoursensors', YourSensor,
'YourSensor', MAX_OBJECTS_PER_TYPE_PER_RESTORE
)
# Phase N+1: YourSensor Calibration Points (depend on YourSensor)
self._restore_model_batch(
backup_data, 'yoursensor_calibration_points', YourSensorCalibrationPoint,
'YourSensor Calibration Point', MAX_OBJECTS_PER_TYPE_PER_RESTORE * 5
)
# ... remaining phases (FermentLogs should come last) ...
Phase ordering considerations:
- Parent objects (TiltBridge, FermentationProfile) must come before children
- Gravity sensor devices must come before FermentLogs
- Calibration points must come after their parent sensors
Update BackupRequest._collect_backup_data()
Add backup collection for your models in _collect_backup_data():
def _collect_backup_data(self) -> dict:
# ... existing collections ...
# YourSensor devices
yoursensors = collect_objects(
YourSensor.objects.filter(brewhouse=self.brewhouse),
'yoursensors', 'YourSensor(s)'
)
# YourSensor Calibration Points (children of YourSensor)
yoursensor_ids = [s.id for s in yoursensors]
collect_objects(
YourSensorCalibrationPoint.objects.filter(yoursensor_id__in=yoursensor_ids),
'yoursensor_calibration_points', 'YourSensor Calibration Point(s)'
)
# ... remaining collections (Ferment Logs should come last) ...
Update GenericFK Validation (if needed)
If your sensor can be linked to FermentLogs via the GenericForeignKey, update _validate_gravity_sensor_ownership() in RestoreRequest:
def _validate_gravity_sensor_ownership(self, content_type_model: str, object_id: str) -> bool:
# Add your model to the allowed list
allowed_models = [
'tiltbridgetilthydrometer', 'ispindel', 'tiltpicotilthydrometer',
'yoursensor' # Add your model name here
]
return validate_generic_fk_ownership(
allowed_models=allowed_models,
content_type_model=content_type_model,
object_id=object_id,
brewhouse=self.brewhouse
)
Step 10: Create Migrations
Generate the initial migration:
uv run python manage.py makemigrations yoursensor
If you encounter migration history issues, you may need to create the migration file manually. See fermentrack/tiltpico/migrations/0001_initial.py for an example.
Step 11: Write Tests
Create comprehensive tests using pytest and factory_boy. Test coverage is a crucial metric that is tracked, and implementations without 100% test coverage will not be merged.
factories.py
import factory
from ..models import YourSensor
from ...brewhouses.tests.factories import BrewhouseFactory
class YourSensorFactory(factory.django.DjangoModelFactory):
class Meta:
model = YourSensor
name = factory.Sequence(lambda n: f"Test Sensor {n}")
brewhouse = factory.SubFactory(BrewhouseFactory)
test_models.py
import pytest
from .factories import YourSensorFactory
pytestmark = pytest.mark.django_db
class TestYourSensor:
def test_str_representation(self):
sensor = YourSensorFactory(name="My Sensor")
assert str(sensor) == "My Sensor"
def test_calibration_calculation(self):
sensor = YourSensorFactory(x0=Decimal('0.005'), x1=Decimal('1.0'))
result = sensor.calc_calibrated_gravity(Gravity(value=Decimal('1.050'), units=Gravity.UNITS_SG))
assert result.value == Decimal('1.055')
test_api_device_views.py
Test the device POST endpoint with various scenarios:
- Valid data submission
- Invalid/unknown device identifier
- Missing required fields
- Invalid data formats
- Calibration application
- Log triggering
See fermentrack/tiltpico/tests/test_api_device_views.py for a complete example.
test_backup.py (BackupMixin tests)
Test the backup/restore functionality:
import pytest
from .factories import YourSensorFactory, YourSensorCalibrationPointFactory
from ...brewhouses.tests.factories import BrewhouseFactory
pytestmark = pytest.mark.django_db
class TestYourSensorBackup:
def test_to_backup_returns_dict(self):
sensor = YourSensorFactory()
backup = sensor.to_backup()
assert isinstance(backup, dict)
assert 'id' in backup
assert 'name' in backup
assert 'brewhouse' in backup
def test_to_backup_includes_calibration(self):
sensor = YourSensorFactory(x0=Decimal('0.1'), x1=Decimal('1.0'))
backup = sensor.to_backup()
assert backup['x0'] == '0.1000'
assert backup['x1'] == '1.0000'
def test_from_backup_creates_object(self):
brewhouse = BrewhouseFactory()
backup_data = {
'id': str(uuid.uuid4()),
'name': 'Restored Sensor',
'brewhouse': str(brewhouse.id),
'installed': False,
'last_checkin': None,
'token': 'ABCDE',
'x0': '0.0000', 'x1': '1.0000', 'x2': '0.0000', 'x3': '0.0000',
}
restored = YourSensor.from_backup(backup_data, brewhouse)
assert restored.name == 'Restored Sensor'
assert restored.brewhouse == brewhouse
def test_from_backup_validates_fk_ownership(self):
other_brewhouse = BrewhouseFactory()
sensor = YourSensorFactory() # Created in a different brewhouse
cal_point_backup = {
'id': str(uuid.uuid4()),
'sensor_gravity': '1.0500',
'measured_gravity': '1.0480',
'yoursensor': str(sensor.id), # FK to sensor in different brewhouse
}
with pytest.raises(ValueError):
YourSensorCalibrationPoint.from_backup(cal_point_backup, other_brewhouse)
class TestYourSensorCalibrationPointBackup:
def test_to_backup_includes_fk(self):
sensor = YourSensorFactory()
cal_point = YourSensorCalibrationPointFactory(yoursensor=sensor)
backup = cal_point.to_backup()
assert backup['yoursensor'] == str(sensor.id)
See fermentrack/backup_restore/tests/test_mixins.py for additional patterns and fermentrack/backup_restore/tests/test_ft2_restore.py for integration tests.
API Endpoint Summary
A complete gravity sensor implementation generally provides these endpoints:
| Endpoint | Method | Description |
|---|---|---|
/api/yoursensor/ (Pattern 1) | POST | Device data submission (token in body) |
/api/yoursensor/<key>/ (Pattern 2) | POST | Device data submission (key in URL) |
/api/yoursensor/create/ | POST | Create new device (auth req) |
/api/yoursensor/device/ | GET | List all user's devices |
/api/yoursensor/device/<id> | GET | Retrieve specific device |
/api/yoursensor/device/<id> | PATCH | Update device settings |
/api/yoursensor/device/<id> | DELETE | Delete device |
/api/.../device/<id>/status | GET | Get current status from Redis |
/api/.../device/<id>/action | PATCH | Perform device actions |
The device API endpoints can be adjusted as necessary for your specific device/requirements. The UI API endpoints can be adjusted as well, but should generally try to match the format above where possible.
Reference Implementations
- iSpindel (Pattern 1: on-device identifier):
fermentrack/ispindel/ - TiltPico (Pattern 2: server-generated key):
fermentrack/tiltpico/
Both implementations follow the patterns described in this guide and can serve as complete working examples.
Implementation Checklist
Use this checklist to ensure your gravity sensor implementation is complete:
Step 1: Django App Setup
- Created app directory structure (
fermentrack/yoursensor/) - Created
apps.pywithAppConfig - Registered app in
config/settings/base.py
Step 2: Models
- Device model extends
BackupMixin, GravitySensorModel -
BACKUP_FIELDSlist defined with all backup fields -
get_panel_info()implemented -
get_data_for_log()implemented - Status dataclass created with Redis serialization
- Calibration model (if applicable) extends
BackupMixin, TimeStampedModel, UUIDModel
Step 3-6: API Implementation
- Serializers created in
api/serializers.py - Device POST view in
api/device_views.py - CRUD views in
api/views.py - URL routing in
api/urls.py - Routes registered in
config/api_router.py
Step 7: Integration Points
- Added to
DeviceTypeChoicesinfermentrack/gravity_sensors/models.py - Added to
ALLOWED_GRAVITY_SENSOR_MODELSinfermentrack/ferment_logs/models.py - Added to
limit_choices_toonactive_gravity_sensor_content_type - Added case to
translate_gravity_sensor_type()method - Added to
GravitySensorListview - Added to
GravitySensorGetview - Added to
GravitySensorNotLoggingListview
Step 8-9: Backup/Restore
- All models use
BackupMixin - All models have
BACKUP_FIELDSdefined - Imports added to
fermentrack/backup_restore/models.py - Restore phase added to
process_fermentrack2_backup() - Backup collection added to
_collect_backup_data() - Model name added to
_validate_gravity_sensor_ownership()
Step 10-11: Migrations & Tests
- Migrations generated and working
- Factory classes created
- Model tests written
- API view tests written
- Device POST endpoint tests written
- BackupMixin tests written
- 100% test coverage achieved