Skip to content

UI Commands Manager

The UICommandsManager is the runtime engine that connects interactive UI elements to your application code. It extends RPCManager to route user interactions from the portal to handler methods on your application, and manages the current values of all interactive elements.

How It Works

When a user interacts with a UI element (clicks a button, moves a slider, selects an option), the portal sends a message to the ui_cmds channel on the device. The UICommandsManager receives this message, identifies which interaction was triggered, and dispatches to the appropriate handler.

The event flow:

  1. User interacts with an element in the portal
  2. Portal creates a MessageCreateEvent on the ui_cmds channel
  3. UICommandsManager extracts the interaction name from the message
  4. The manager looks up the registered handler (either a @handler() decorated method or the element's default handler)
  5. The handler is called with an InteractionContext and the payload
  6. If auto_update is enabled, the element's value is updated with the payload after successful handling

Setting Up the Manager

The framework creates and configures the UICommandsManager automatically. It subscribes to the ui_cmds channel and registers all interactions found in the UI tree.

# This happens internally during app startup:
manager = UICommandsManager(app)
manager.subscribe("ui_cmds")
manager._set_interactions(ui.get_interactions())

The _set_interactions() method links each Interaction element to the manager by setting the element's _manager reference. This is what makes the value property and set() method work on interactive elements at runtime.

Reading and Writing Values

The manager maintains a local cache of interaction values, synchronised with the ui_cmds channel aggregate.

get_value(key)

Returns the current value for an interaction. If no value exists in the aggregate, falls back to the element's default value. Raises KeyError if neither exists.

# Typically accessed through the interaction element
current_mode = self.ui.mode_select.value  # calls manager.get_value("mode_select")

set_value(key, value, log_update)

Updates an interaction's value in the channel aggregate and optionally creates a log message.

# Typically accessed through the interaction element
await self.ui.mode_select.set("manual")

When log_update is True (the default), a message is created on the ui_cmds channel recording the value change. This creates a historical record of user interactions. The log message includes the app key, interaction key, and new value.

The @handler() Decorator

The @handler() decorator marks an async method on your application as the handler for a specific UI interaction. See Interactive Elements for the full decorator API.

from pydoover.docker import Application
from pydoover.ui import UI, Button, FloatInput, handler, InteractionContext

class MyUI(UI):
    restart = Button("Restart")
    threshold = FloatInput("Alert Threshold", min_val=0, max_val=100)

class MyApp(Application):
    ui_cls = MyUI

    @handler("restart")
    async def on_restart(self, ctx: InteractionContext, payload):
        await self.tags.status.set("restarting")
        self.request_shutdown()

    @handler("threshold", auto_update=True)
    async def on_threshold(self, ctx: InteractionContext[FloatInput], payload):
        await self.tags.alert_threshold.set(float(payload))

Handler Resolution

When a message arrives on the ui_cmds channel, the manager resolves the handler in this order:

  1. Check for a registered @handler() method matching the interaction name (supports string matching and regex patterns)
  2. If no @handler() is found, fall back to the interaction element's built-in handler() method

The default handler() method on Interaction simply calls self.set(payload), which updates the element's value. This means simple interactions that just store a value work without any custom handler code.

auto_update Behaviour

When auto_update=True (the default), the on_success hook automatically updates the interaction's value with the payload after the handler returns without raising an exception.

# With auto_update=True (default):
# After on_mode_change returns, mode_select.value is set to payload
@handler("mode_select")
async def on_mode_change(self, ctx, payload):
    await self.tags.mode.set(payload)
    # No need to call ctx.set_value(payload) -- auto_update handles it

Set auto_update=False when you need to control the value update yourself, such as when you want to validate or transform the payload before storing it.

@handler("threshold", auto_update=False)
async def on_threshold(self, ctx: InteractionContext, payload):
    value = float(payload)
    if value < 10:
        # Reject -- don't update the UI element
        return
    # Manually update with the validated value
    await ctx.set_value(value)
    await self.tags.alert_threshold.set(value)

InteractionContext

The context object passed to every handler provides access to the interaction element and convenience methods.

Property/MethodDescription
ctx.elementThe Interaction instance that was triggered
ctx.interactionAlias for ctx.element
ctx.methodThe interaction name string
ctx.messageThe raw channel message that triggered the handler
ctx.set_value(value, max_age, log_update)Update the interaction's value in the portal
ctx.auto_updateWhether auto_update is enabled for this handler

The ctx.set_value() method delegates to the interaction element's set() method, which updates both the aggregate and creates a log message (if log_update=True).

InteractionContext is generic over the interaction type. You can annotate it to get type hints for the specific element:

from pydoover.ui import InteractionContext, Select

@handler("mode_select")
async def on_mode(self, ctx: InteractionContext[Select], payload):
    # ctx.element is typed as Select
    options = ctx.element.options

Processor Context

When running in a processor (not a Docker app), the UICommandsManager works slightly differently:

  • The manager does not subscribe to aggregate update events (there is no persistent connection)
  • set_value() passes allow_invoking_channel=True to avoid recursion issues when the processor was invoked by the same channel

The API surface remains the same; only the internal transport differs.

Complete Example

from pydoover.docker import Application
from pydoover.ui import (
    UI, NumericVariable, BooleanVariable, Button, Select,
    FloatInput, Slider, Submodule, Option, Colour,
    handler, InteractionContext, ConfirmDialog, tag_ref,
)
from pydoover.tags import Tags, Number, Boolean, String

class AppTags(Tags):
    temperature = Number(default=0.0)
    setpoint = Number(default=25.0)
    mode = String(default="auto")
    pump_running = Boolean(default=False)

class AppUI(UI, display_name="Process Control", icon="settings"):
    temp = NumericVariable(
        "Temperature", value=AppTags.temperature, units="C"
    )
    setpoint_input = FloatInput("Setpoint", min_val=0, max_val=100, default=25)
    mode_select = Select(
        "Mode", options=[Option("Auto"), Option("Manual"), Option("Off")]
    )
    pump_toggle = Button(
        "Emergency Stop",
        requires_confirm=ConfirmDialog(
            title="Confirm Emergency Stop",
            colour=Colour.red,
        ),
    )

class ProcessApp(Application):
    tags_cls = AppTags
    ui_cls = AppUI

    @handler("setpoint_input")
    async def on_setpoint(self, ctx: InteractionContext[FloatInput], payload):
        await self.tags.setpoint.set(float(payload))

    @handler("mode_select")
    async def on_mode(self, ctx: InteractionContext[Select], payload):
        await self.tags.mode.set(payload)

    @handler("pump_toggle", auto_update=False)
    async def on_emergency_stop(self, ctx: InteractionContext, payload):
        await self.tags.pump_running.set(False)
        await self.tags.mode.set("off")
        await ctx.set_value("stopped")

Next Steps