Skip to content

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} dict
  • cls._commands -- {name: Command} dict
  • cls._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

  1. DeviceType class creation (DeviceTypeMeta.__new__): registers the DeviceType class in _types
  2. 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 BaseDevice fields: 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 StateField declared on the DeviceType
  • A _device_type_cls class attribute pointing back to the DeviceType

WeatherStationReading (extends BaseReading):

  • device -- ForeignKey to WeatherStationDevice (related_name "readings")
  • timestamp -- DateTimeField (indexed)
  • received_at -- DateTimeField (auto_now_add)
  • Plus one nullable column per Reading declared on the DeviceType
  • Composite index on (device, timestamp)

WeatherStationMessage (extends BaseMessage):

  • device -- ForeignKey to WeatherStationDevice (related_name "messages")
  • topic, payload_json, payload_raw, content_type, correlation_id, user_properties
  • processing_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:

  1. Django loads INSTALLED_APPS, which includes "django_devicehub"
  2. DjangoDevicehubConfig.ready() is called:
    • autodiscover_modules("devices") imports devices.py from every installed app
    • Each DeviceType subclass triggers DeviceTypeMeta.__new__(), which registers it in the registry
  3. Your app's models.py is imported (by Django's model loading):
    • create_models() calls generate the concrete Django models
    • Models are registered in the registry
  4. auto_register_admin() (if AUTO_ADMIN is True):
    • 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

  1. Parse topic: Match against {prefix}/(\w+)/([^/]+)/(data|status|cmd) to extract type_name, device_id, and message_type
  2. Resolve DeviceType: Look up the type name in the registry
  3. Resolve Device: Query the database for an active device with the given ID
  4. Store raw message: Create a Message record (audit trail)
  5. Route by message type:
    • data: parse reading values, create a Reading record, update device heartbeat, fire device_data_received signal, call on_data() hook
    • status: update device status/battery/firmware, fire device_status_changed signal if status changed, call on_status_change() hook
  6. Mark message: mark_completed() or mark_failed(error) on the Message record

Data payload formats

The router supports two payload formats:

Flat format (recommended):

{
    "temperature": 25.3,
    "humidity": 80.1,
    "timestamp": "2025-01-15T12:00:00+00:00"
}

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:

DjangoDevicehubError
├── DeviceTypeError       # Error in DeviceType definition
├── RegistryError         # Name conflicts, registration issues
├── BrokerError           # MQTT connection/publish failures
├── AuthenticationError   # Device auth failures
└── RoutingError          # Message routing errors