Zum Hauptinhalt springen

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:

  1. 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.

  2. 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.

  3. 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:

  1. Device Model - Extends GravitySensorModel
  2. Status Model - Dataclass stored in Redis for current readings
  3. Calibration Model - Optional, for storing calibration points

The Base Class: GravitySensorModel

GravitySensorModel (in fermentrack/gravity_sensors/models.py) provides:

  • UUID primary key
  • name field for human-readable identification
  • brewhouse foreign key for multi-tenant support
  • installed boolean flag
  • last_checkin timestamp
  • active_log property 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

important

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 dictionary
  • from_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
]
important
  • 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:

EndpointMethodDescription
/api/yoursensor/ (Pattern 1)POSTDevice data submission (token in body)
/api/yoursensor/<key>/ (Pattern 2)POSTDevice data submission (key in URL)
/api/yoursensor/create/POSTCreate new device (auth req)
/api/yoursensor/device/GETList all user's devices
/api/yoursensor/device/<id>GETRetrieve specific device
/api/yoursensor/device/<id>PATCHUpdate device settings
/api/yoursensor/device/<id>DELETEDelete device
/api/.../device/<id>/statusGETGet current status from Redis
/api/.../device/<id>/actionPATCHPerform 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.py with AppConfig
  • Registered app in config/settings/base.py

Step 2: Models

  • Device model extends BackupMixin, GravitySensorModel
  • BACKUP_FIELDS list 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 DeviceTypeChoices in fermentrack/gravity_sensors/models.py
  • Added to ALLOWED_GRAVITY_SENSOR_MODELS in fermentrack/ferment_logs/models.py
  • Added to limit_choices_to on active_gravity_sensor_content_type
  • Added case to translate_gravity_sensor_type() method
  • Added to GravitySensorList view
  • Added to GravitySensorGet view
  • Added to GravitySensorNotLoggingList view

Step 8-9: Backup/Restore

  • All models use BackupMixin
  • All models have BACKUP_FIELDS defined
  • 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