Skip to content

Advanced Configuration

This page covers advanced features of the pydoover configuration system: nested Object elements with class-level inheritance, Django-style child overrides, deep nesting paths, variable references for device-set values, and element visibility and ordering controls.

Object with Nested Elements

Object elements can contain other elements, creating nested configuration structures. Declare child elements as class attributes on an Object subclass.

from pydoover.config import Object, Number, Boolean, String, Schema


class AlertConfig(Object):
    threshold = Number("Threshold", default=40.0)
    cooldown_minutes = Number("Cooldown Minutes", default=15.0)
    enabled = Boolean("Enabled", default=True)
    recipient = String("Recipient Email", default="")


class AppConfig(Schema):
    site_name = String("Site Name", default="Unnamed")
    alerts = AlertConfig("Alert Settings")

Access nested values by chaining attributes.

threshold = self.config.alerts.threshold.value       # 40.0
cooldown = self.config.alerts.cooldown_minutes.value  # 15.0
enabled = self.config.alerts.enabled.value            # True

Object Subclass Inheritance

Object subclasses inherit elements from their parent Object classes, just like Schema subclasses inherit from parent Schemas.

class BaseAlertConfig(Object):
    threshold = Number("Threshold", default=40.0)
    enabled = Boolean("Enabled", default=True)


class ExtendedAlertConfig(BaseAlertConfig):
    # Inherits threshold and enabled
    cooldown_minutes = Number("Cooldown Minutes", default=15.0)
    severity = String("Severity", default="warning")

ExtendedAlertConfig has four elements: threshold and enabled (inherited) plus cooldown_minutes and severity (its own).

Controlling Additional Elements

By default, Object accepts keys in the deployment config that are not declared in the schema (additional_elements=True). Set this to False to reject unknown keys.

class StrictConfig(Object):
    name = String("Name", default="")
    value = Number("Value", default=0.0)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, additional_elements=False, **kwargs)

With additional_elements=False, loading a config dictionary with keys not declared as elements raises a ValueError.

Collapsible UI Sections

Object elements render as collapsible sections in the Doover UI. Control the collapse behaviour with collapsible and default_collapsed.

advanced_settings = AdvancedConfig(
    "Advanced Settings",
    collapsible=True,
    default_collapsed=True,
)

This creates a section that starts collapsed in the form, keeping the UI clean for common settings while making advanced options accessible.

Django-Style Child Overrides

When creating an instance of an Object, you can override attributes of child elements using double-underscore (__) syntax. This avoids having to create a new subclass just to change one attribute.

class AlertConfig(Object):
    threshold = Number("Threshold", default=40.0)
    enabled = Boolean("Enabled", default=True)


class AppConfig(Schema):
    # Override the default threshold to 50.0 and mark enabled as advanced
    alerts = AlertConfig(
        "Alert Settings",
        threshold__default=50.0,
        enabled__advanced=True,
    )

The syntax is child_name__attribute=value. This sets threshold.default to 50.0 and enabled.advanced to True on the instance without modifying the AlertConfig class.

Supported Override Attributes

You can override any attribute that exists on the child element. Common overrides include:

OverrideExampleEffect
child__defaultthreshold__default=50.0Change the default value
child__advancedenabled__advanced=TrueMove to the advanced section
child__hiddendebug__hidden=TrueHide from the UI
child__descriptionname__description="..."Change the help text
child__requiredemail__required=FalseMake optional

Deep Nesting

The double-underscore syntax supports multiple levels of nesting. For an Object within an Object, chain the element names.

class InnerConfig(Object):
    count = Number("Count", default=10.0)
    label = String("Label", default="default")


class OuterConfig(Object):
    inner = InnerConfig("Inner Settings")
    name = String("Name", default="outer")


class AppConfig(Schema):
    outer = OuterConfig(
        "Outer Settings",
        # Override a deeply nested element
        inner__count__default=99.0,
        inner__label__hidden=True,
    )

The path inner__count__default resolves to: outer -> inner (child element) -> count (child element) -> default (attribute). The final segment is always the attribute name to set.

Information Circle

Element _name values must not contain double underscores (__). This is the same constraint as Django's field name rule, and it ensures the path resolution is unambiguous.

If any segment of the path does not resolve to an existing child element, a TypeError is raised with a descriptive message indicating where the path broke.

Variable References

Configuration elements can have their default values set from device-level variables using the Variable class. This allows a single schema to adapt its defaults based on the device it is deployed to.

from pydoover.config import Schema, Number, Variable

class Config(Schema):
    num_inputs = Number(
        "Number of Inputs",
        default=Variable("device", "digitalInputCount"),
        description="Number of digital inputs on this device",
    )

The Variable("device", "digitalInputCount") serialises to "$device.digitalInputCount" in the JSON Schema output. At deployment time, the platform resolves this reference to the actual value from the device's variable store.

Variable Constructor

Variable(scope: str, name: str)
  • scope -- the variable scope, typically "device" for device-level variables
  • name -- the variable name within that scope

The string representation is $scope.name, which the platform parses and resolves during deployment configuration injection.

from pydoover.config import Variable

# Resolves to "$device.analogInputCount"
analog_count = Variable("device", "analogInputCount")

# Resolves to "$device.modbusSupported"
modbus = Variable("device", "modbusSupported")

Optional Elements

Elements with default=None and required=False are treated as fully optional. If the operator does not provide a value, .value returns None.

from pydoover.config import String, Number

# Optional string -- .value returns None if not set
notes = String(
    "Notes",
    default=None,
    required=False,
    description="Optional notes about this installation",
)

# Optional number
override_threshold = Number(
    "Override Threshold",
    default=None,
    required=False,
    description="Leave empty to use the default threshold",
)

In the generated JSON Schema, optional elements have their type set to [type, "null"] (e.g., ["string", "null"]) to indicate that null is a valid value.

Hidden Elements

Hidden elements are not displayed in the configuration form but are still part of the schema. They are useful for internal settings that should not be changed by the operator.

from pydoover.config import String, Integer

# Hidden element for internal use
internal_version = Integer(
    "Schema Version",
    default=2,
    hidden=True,
)

# Hidden API key
api_key = String(
    "API Key",
    default="",
    hidden=True,
    description="Internal API key for the external service",
)

Hidden elements still appear in the JSON Schema output (with "x-hidden": true) and can be set through the API, but the form UI does not render them.

Position Control

By default, elements appear in the UI in the order they are declared. Override this with the position parameter to control ordering explicitly.

from pydoover.config import Schema, String, Number

class Config(Schema):
    # Will appear second in the UI
    advanced_setting = Number(
        "Advanced Setting",
        default=0.0,
        position=10,
    )
    # Will appear first in the UI (lower position number)
    site_name = String(
        "Site Name",
        default="Unnamed",
        position=1,
    )

Position values are integers. Lower numbers appear earlier. When no position is set, elements are assigned sequential positions based on their declaration order.

Advanced Section

Elements marked with advanced=True are grouped into an "advanced" section in the configuration form, visually separated from the main settings.

from pydoover.config import Schema, Number, Integer

class Config(Schema):
    poll_interval = Number("Poll Interval", default=30.0)
    retry_count = Integer(
        "Retry Count",
        default=3,
        advanced=True,
        description="Number of retries for failed API calls",
    )
    timeout = Number(
        "Timeout",
        default=60.0,
        advanced=True,
        description="Request timeout in seconds",
    )

Schema Export and doover_config.json

The doover_config.json file is the deployment artifact that carries the application's configuration schema. It maps application names to their schema definitions.

from pathlib import Path
from pydoover.config import Schema, Number, String

class Config(Schema, name="Temperature Monitor"):
    threshold = Number("Threshold", default=40.0)
    site = String("Site Name", default="Unnamed")

# Export to the project root
Config.export(Path("doover_config.json"), "temp_monitor")

The resulting doover_config.json looks like:

{
    "temp_monitor": {
        "config_schema": {
            "$schema": "https://json-schema.org/draft/2020-12/schema",
            "$id": "",
            "title": "Temperature Monitor",
            "type": "object",
            "properties": {
                "threshold": {
                    "title": "Threshold",
                    "type": "number",
                    "x-name": "threshold",
                    "x-hidden": false,
                    "x-required": false,
                    "default": 40.0,
                    "x-position": 0
                },
                "site": {
                    "title": "Site Name",
                    "type": "string",
                    "x-name": "site",
                    "x-hidden": false,
                    "x-required": false,
                    "default": "Unnamed",
                    "x-position": 1
                }
            },
            "additionalElements": true,
            "required": []
        }
    }
}

If the file already exists, export() merges the new schema into the existing content. This allows multiple applications to share a single doover_config.json file.

NotSet Sentinel

NotSet is a sentinel class used internally to distinguish "no default provided" from None. When an element's default is NotSet, the element is required.

from pydoover.config import NotSet, String

# This element is required (default is NotSet)
name = String("Name")
assert name.default is NotSet
assert name.required is True

You generally do not interact with NotSet directly. It is the implicit default when you do not pass a default parameter.

Processor Config Elements

The pydoover.processor module provides specialised configuration elements that handle platform-level wiring automatically. These elements use fixed internal names (e.g., dv_proc_subscriptions) so the platform can discover them during deployment.

ElementBase TypePurpose
SubscriptionConfigStringSubscribes to a single channel by name
ManySubscriptionConfigArraySubscribes to multiple channels
ScheduleConfigStringConfigures a cron/rate schedule
IngestionEndpointConfigObjectConfigures an HTTP ingestion endpoint (CIDR, signing, throttle)
ExtendedPermissionsConfigObjectGrants cross-agent read/write permissions
SerialNumberConfigStringStores a device serial number (uses dv_serial_number)
TimezoneConfigEnumTimezone selector populated from zoneinfo
EgressChannelConfigStringEgress channel for integration subscriptions

All of these accept an optional display_name first argument (each has a sensible default) and pass through standard element kwargs like description, hidden, and default.

ManySubscriptionConfig

ManySubscriptionConfig subscribes a processor to multiple channels at once. The platform automatically creates SNS subscriptions for each channel in the list.

from pydoover.processor import ManySubscriptionConfig, SerialNumberConfig
from pydoover.config import Schema

class Config(Schema):
    subscriptions = ManySubscriptionConfig(
        default=["deployment_config", "command_send", "uplink_recv"],
        hidden=True,
    )
    serial_number = SerialNumberConfig()
Information Circle
Example

A device processor that subscribes to its deployment config, a command channel, and an uplink data channel:

from pydoover.processor import ManySubscriptionConfig, SerialNumberConfig
from pydoover.config import Schema

class DeviceProcessorConfig(Schema):
    subscriptions = ManySubscriptionConfig(
        default=["deployment_config", "command_send", "uplink_recv"],
        hidden=True,
    )
    serial_number = SerialNumberConfig()

IngestionEndpointConfig

IngestionEndpointConfig creates an HTTP endpoint that external systems can POST data to. It includes configurable CIDR ranges, signing key, and throttle settings.

from pydoover.processor import IngestionEndpointConfig, ExtendedPermissionsConfig
from pydoover.config import Schema, String

class IntegrationConfig(Schema):
    integration = IngestionEndpointConfig()
    permissions = ExtendedPermissionsConfig()
    cloud_app_key = String(
        "Cloud App Key",
        description="The app_key of the cloud app for serial number lookups.",
    )
Information Circle
Example

An integration processor that receives data from external hardware monitors via HTTP and routes it to device agents. It needs IngestionEndpointConfig for the HTTP endpoint and ExtendedPermissionsConfig to write to other agents' channels:

from pydoover.processor import IngestionEndpointConfig, ExtendedPermissionsConfig
from pydoover.config import Schema, String

class IntegrationConfig(Schema):
    integration = IngestionEndpointConfig()
    permissions = ExtendedPermissionsConfig()
    cloud_app_key = String(
        "Cloud App Key",
        description="The app_key of the device processor, used to look up serial numbers in tag_values.",
    )

EgressChannelConfig

EgressChannelConfig designates a channel that a processor subscribes to on every agent where a paired application is installed. This is the counterpart to IngestionEndpointConfig for the outbound direction — where ingestion handles data coming in from external systems, egress handles requests going out to them.

The typical use case is an integration processor that bridges Doover with an external API (e.g., a LoRaWAN network server). Device-side processors publish downlink requests to a channel; the integration processor subscribes to that channel across all mapped agents via egress config and forwards the requests to the external API.

from pydoover.processor import (
    IngestionEndpointConfig, ExtendedPermissionsConfig, EgressChannelConfig,
)
from pydoover.config import Schema, String

class IntegrationConfig(Schema):
    permissions = ExtendedPermissionsConfig()
    integration = IngestionEndpointConfig()
    egress_channel = EgressChannelConfig(default="tts_downlink_request")

    api_key = String("API Key", description="Bearer token for the external service")
    server_host = String("Server Host", default="api.example.com")

The default value is the channel name the integration subscribes to. Device-side processors write to this channel on their own agent, and the integration processor receives the messages via its egress subscription.

Information Circle
Real-World Example

A LoRaWAN integration processor uses EgressChannelConfig alongside IngestionEndpointConfig to build a bidirectional bridge with The Things Stack. Uplinks arrive via the ingestion endpoint and are routed to device agents; downlinks are published to the egress channel by device processors and forwarded to the TTS API:

from pydoover.processor import (
    IngestionEndpointConfig, ExtendedPermissionsConfig, EgressChannelConfig,
)
from pydoover import config

class TtsIntegrationConfig(config.Schema):
    permissions = ExtendedPermissionsConfig()
    tts_api_key = config.String(
        "TTS API Key",
        description="Bearer token for The Things Stack API",
    )
    tts_app_name = config.String(
        "TTS Application Name",
        description="Application ID in The Things Stack console",
    )
    integration = IngestionEndpointConfig()
    egress_channel = EgressChannelConfig(default="tts_downlink_request")

Source: tts

Practical Example: Complex Configuration

This example brings together several advanced features in a realistic processor configuration.

from pydoover.config import (
    Schema, Number, String, Boolean, Integer,
    Enum, Array, Object, Variable, DevicesConfig,
)
from pydoover.processor import (
    ScheduleConfig, SubscriptionConfig,
    ExtendedPermissionsConfig, TimezoneConfig,
    IngestionEndpointConfig,
)


class ThresholdConfig(Object):
    high = Number("High Threshold", default=40.0)
    low = Number("Low Threshold", default=5.0)
    hysteresis = Number("Hysteresis", default=2.0, advanced=True)


class Config(Schema, name="Environmental Monitor"):
    # Processor-specific config
    subscription = SubscriptionConfig()
    schedule = ScheduleConfig()
    timezone = TimezoneConfig()
    permissions = ExtendedPermissionsConfig()

    # Application config
    site_name = String("Site Name", default="Unnamed Site")
    sensor_count = Integer(
        "Sensor Count",
        default=Variable("device", "analogInputCount"),
    )
    temperature_unit = Enum(
        "Temperature Unit",
        choices=["Celsius", "Fahrenheit"],
        default="Celsius",
    )
    thresholds = ThresholdConfig(
        "Temperature Thresholds",
        hysteresis__default=1.0,  # Override the nested default
    )
    alerts_enabled = Boolean("Enable Alerts", default=True)
    alert_recipients = Array(
        "Alert Recipients",
        element=String("Email"),
        default=[],
    )

Related Pages