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:
- User interacts with an element in the portal
- Portal creates a
MessageCreateEventon theui_cmdschannel UICommandsManagerextracts the interaction name from the message- The manager looks up the registered handler (either a
@handler()decorated method or the element's default handler) - The handler is called with an
InteractionContextand the payload - If
auto_updateis 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:
- Check for a registered
@handler()method matching the interaction name (supports string matching and regex patterns) - If no
@handler()is found, fall back to the interaction element's built-inhandler()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/Method | Description |
|---|---|
ctx.element | The Interaction instance that was triggered |
ctx.interaction | Alias for ctx.element |
ctx.method | The interaction name string |
ctx.message | The 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_update | Whether 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()passesallow_invoking_channel=Trueto 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
- UI Overview -- introduction to the UI system
- Interactive Elements -- all interactive element types
- Declarative UI -- the
UIbase class and schema generation