Architecture Overview¶
This page explains how Django DeviceHub works under the hood: the metaclass system, the registry, model generation, auto-discovery, and message routing.
System Overview¶
Django Admin
REST API
Realtime (WS/SSE)
^
|
IoT Device --MQTT--> Broker --msg--> iot_listen --> MessageRouter
|
+------------------------+------------------------+
| | |
Store Message Store Reading Fire Signals
(audit trail) (time-series) + DeviceType hooks
The Metaclass System¶
The core of Django DeviceHub is a Python metaclass called DeviceTypeMeta. When you define a class that inherits from DeviceType, the metaclass intercepts class creation and performs three tasks:
1. Collect descriptors¶
The metaclass scans the class namespace for Reading, Command, and StateField instances. It removes them from the namespace and stores them in class-level dicts:
cls._readings--{name: Reading}dictcls._commands--{name: Command}dictcls._state_fields--{name: StateField}dict
Each descriptor's .name attribute is set to its attribute name on the class.
2. Inherit from bases¶
Before collecting from the current class, the metaclass copies descriptors from all base classes. This enables inheritance -- a subclass inherits all readings, commands, and state fields from its parent and can override them.
3. Auto-register¶
If the class is not DeviceType itself and does not have _abstract = True, it is registered in the global registry via registry.register(cls).
class DeviceTypeMeta(type):
def __new__(mcs, name, bases, namespace):
readings = {}
commands = {}
state_fields = {}
# Inherit from bases
for base in bases:
if hasattr(base, "_readings"):
readings.update(base._readings)
...
# Collect from current class
for key, value in list(namespace.items()):
if isinstance(value, Reading):
value.name = key
readings[key] = value
del namespace[key]
...
namespace["_readings"] = readings
...
cls = super().__new__(mcs, name, bases, namespace)
# Register non-base, non-abstract classes
if name != "DeviceType" and not getattr(cls, "_abstract", False):
registry.register(cls)
return cls
The Registry¶
The DeviceTypeRegistry is a global singleton (registry) that tracks all device types and their generated models.
Storage¶
The registry maintains two dictionaries:
_types-- maps normalized names to DeviceType classes_models-- maps normalized names to(DeviceModel, ReadingModel, MessageModel)tuples
Names are normalized by lowercasing and stripping underscores (e.g., "WeatherStation" becomes "weatherstation").
Registration flow¶
- DeviceType class creation (
DeviceTypeMeta.__new__): registers the DeviceType class in_types create_models()call (generators/models.py): registers the three generated models in_models
Name collision detection¶
If two DeviceType classes with the same normalized name are defined in different modules, the registry raises a RegistryError:
RegistryError: DeviceType name conflict: 'WeatherStation' --
already registered by sensors.devices.WeatherStation
Lookup methods¶
from django_devicehub import registry
registry.get("weatherstation") # DeviceType class or None
registry.get_models("weatherstation") # (DeviceModel, ReadingModel, MessageModel) or None
registry.get_by_device_model(SomeModel) # DeviceType class or None
registry.all() # list of all DeviceType classes
registry.all_with_models() # list of (DeviceType, DeviceModel, ReadingModel, MessageModel)
len(registry) # number of registered types
"weatherstation" in registry # membership test
Model Generation¶
DeviceType.create_models() is the bridge between the declarative DeviceType and concrete Django models. It calls generators.models.create_models() internally.
What gets generated¶
For a DeviceType named WeatherStation:
WeatherStationDevice (extends BaseDevice):
- All
BaseDevicefields:device_id,name,status,battery_level,last_seen,firmware_version,is_active,mqtt_username,mqtt_password_hash,api_key,metadata,created_at,updated_at - Plus one column per
StateFielddeclared on the DeviceType - A
_device_type_clsclass attribute pointing back to the DeviceType
WeatherStationReading (extends BaseReading):
device-- ForeignKey toWeatherStationDevice(related_name"readings")timestamp-- DateTimeField (indexed)received_at-- DateTimeField (auto_now_add)- Plus one nullable column per
Readingdeclared on the DeviceType - Composite index on
(device, timestamp)
WeatherStationMessage (extends BaseMessage):
device-- ForeignKey toWeatherStationDevice(related_name"messages")topic,payload_json,payload_raw,content_type,correlation_id,user_propertiesprocessing_status,processing_error,received_at,processed_at
How it works internally¶
Model generation uses Python's type() built-in to dynamically create classes that inherit from the abstract base models:
model_name = "WeatherStationDevice"
attrs = {
"__module__": calling_module, # so Django finds it in the right app
"__qualname__": model_name,
"Meta": type("Meta", (), {
"verbose_name": "Weather Station",
}),
"_device_type_cls": device_type_cls,
}
# Add StateField columns
for name, field in device_type_cls._state_fields.items():
attrs[name] = field.to_django_field()
# type() triggers Django's ModelBase metaclass
DeviceModel = type(model_name, (BaseDevice,), attrs)
The __module__ is set to the calling module's __name__ (auto-detected via inspect.currentframe().f_back). This is critical -- Django's migration system uses __module__ to determine which app a model belongs to.
After generation¶
- Model references are stored on the DeviceType class:
cls._device_model,cls._reading_model,cls._message_model - The three models are registered in the global registry via
registry.register_models()
Auto-Discovery Flow¶
When Django starts up, this sequence occurs:
- Django loads
INSTALLED_APPS, which includes"django_devicehub" DjangoDevicehubConfig.ready()is called:autodiscover_modules("devices")importsdevices.pyfrom every installed app- Each
DeviceTypesubclass triggersDeviceTypeMeta.__new__(), which registers it in the registry
- Your app's
models.pyis imported (by Django's model loading):create_models()calls generate the concrete Django models- Models are registered in the registry
auto_register_admin()(ifAUTO_ADMINisTrue):- Iterates over all registered device types with models
- Generates and registers Django admin classes
The order matters: devices.py must be importable before models.py calls create_models(). The auto-discovery in ready() ensures this.
Message Router¶
The MessageRouter is the core message processing engine. It runs inside the iot_listen management command and handles incoming MQTT messages.
Routing flow¶
- Parse topic: Match against
{prefix}/(\w+)/([^/]+)/(data|status|cmd)to extracttype_name,device_id, andmessage_type - Resolve DeviceType: Look up the type name in the registry
- Resolve Device: Query the database for an active device with the given ID
- Store raw message: Create a
Messagerecord (audit trail) - Route by message type:
data: parse reading values, create aReadingrecord, update device heartbeat, firedevice_data_receivedsignal, callon_data()hookstatus: update device status/battery/firmware, firedevice_status_changedsignal if status changed, callon_status_change()hook
- Mark message:
mark_completed()ormark_failed(error)on the Message record
Data payload formats¶
The router supports two payload formats:
Flat format (recommended):
Readings list format:
{
"readings": [
{"type": "temperature", "value": 25.3},
{"type": "humidity", "value": 80.1}
],
"timestamp": "2025-01-15T12:00:00+00:00"
}
In both cases, only fields declared as Reading on the DeviceType are accepted. Unknown fields are silently ignored.
The timestamp field is optional. If not provided or unparseable, the server's current time is used.
Status payload format¶
{
"status": "online",
"battery_level": 85.5,
"firmware_version": "2.1.0",
"timestamp": "2025-01-15T12:00:00+00:00"
}
All fields are optional. Only valid status values are accepted: online, offline, error, maintenance, low_battery, provisioning. The battery field is also accepted as an alias for battery_level.
Exception Hierarchy¶
All package exceptions inherit from DjangoDevicehubError: