Events
An Event is something that happened, at a point in time. It is the third of Solarian's three data types, and the one that carries discrete occurrences rather than state or intent.
| Type | Means | Flows |
|---|---|---|
| Observation | what the world currently looks like | module → controller → skill |
| Event | something that happened | module → controller → skill |
| Action | something we want to happen | skill → controller → robot → module |
Defined in solarian/core/event.py.
Events vs. observations
The line matters, and it is easy to get wrong. Continuous signals stay observations. Joint positions, a joystick axis, the set of keys currently held — sampling those into one event per tick would flood the event path and make it useless for the discrete things it is good at.
Events are for transitions: a key went down, a button was clicked, a limit switch tripped. A skill jogging an arm wants to ask "is w down right now" — an observation. A controller starting a skill wants to know "w was just pressed" — an event.
So a KeyboardModule emits KEY_DOWN / KEY_UP events and publishes held_keys as an observation, recomputed from those same events.
Drained on the control tick
Modules only ever enqueue. The host drains the queue on its own control tick and dispatches from there:
while running:
events = drain() # dispatches to @event_handler methods
tick_local_modules() # each publishes into its own buffer
fan_out(events) # a copy into every skill's inbox
for skill in active_skills:
actions += skill.step() # drains its inbox, then control_step(dt)
dispatch(actions)
Two consequences worth relying on:
- Handlers run on the control thread, not on whatever device callback thread produced the event. A pynput listener thread can no longer mutate state the control loop is mid-read.
- Ordering is deterministic. Every handler and every running skill sees the same batch, in the same order, at the same instant.
The transport underneath depends on how the emitting module is scheduled — a deque for inline and threaded modules, a multiprocessing.Queue for process-backed ones — but the host does not care which. drain_events() reads them back the same way. See Execution Modes.
The host is whoever owns the module. Both Controller and Robot are event hosts, sharing the same EventHost machinery, so a limit switch on the robot reaches a robot-side handler without a round trip to the controller.
Every skill the host is running gets its own copy of each drained batch, posted to the skill's event_inbox and emptied into self.events at the top of its next step. That holds whether the skill runs in a process, on a thread, or inline on the host's own loop, so a skill never has to know which it is to read its events — only the kind of queue differs, and nothing above it can tell. It also means a skill the host skips on some tick keeps those events for the step that does run, instead of losing them.
Declaring what a module emits
A module lists its event types in an events class attribute and calls emit():
from solarian.core.event import EventType
from solarian.core.execution import ExecutionMode
from solarian.core.module import Module
class FootPedalModule(Module):
execution_mode = ExecutionMode.THREAD
events = (EventType.BUTTON_DOWN, EventType.BUTTON_UP)
def control_step(self):
if self._pedal_just_went_down():
self.emit(EventType.BUTTON_DOWN, button="pedal")
emit(event_type, **data) stamps the event with the module's name and the current time. Keep data small and picklable — events from a process-backed module cross a queue.
Reacting: @event_handler
@event_handler mirrors @action_handler. Decorate a method on a controller — or on a robot — with the event type it handles:
from solarian.core.event import Event, EventType, event_handler
class MyController(Controller):
@event_handler(EventType.KEY_DOWN)
def on_key_down(self, event: Event) -> None:
if event.data["key"] == "q":
self.stop_all_skills()
Handlers are collected by walking the MRO root → leaf. Several methods may handle the same event type and all of them run, so a subclass can react to a key press without displacing the base class's own reaction to it. Redeclaring a handler by the same method name does override it, which is how you replace inherited behavior deliberately.
Skills receive the same batch as their control_step's events argument, so a skill can react to a transition without the controller having to route it.
The Event record
Event is frozen — it is a record of the past, and the same instance is handed to every handler and every running skill on a tick.
- Name
type- Type
- int
- Description
An
EventTypevalue.
- Name
source- Type
- str
- Description
Name of the module that emitted it.
- Name
data- Type
- mapping
- Description
Payload, e.g.
{"key": "w"}.
- Name
timestamp- Type
- float
- Description
Wall-clock time the event occurred.
Event types
EventType is an open registry, exactly like ActionType. Core reserves values below CUSTOM_START (100):
- Name
KEY_DOWN- Type
- 0
- Description
A key was pressed.
datacarrieskey.
- Name
KEY_UP- Type
- 1
- Description
A key was released.
datacarrieskey.
- Name
BUTTON_DOWN- Type
- 2
- Description
A pointing-device or controller button went down.
- Name
BUTTON_UP- Type
- 3
- Description
A pointing-device or controller button came up.
- Name
CLICK- Type
- 4
- Description
A displayed observation image was clicked, emitted by the controller's
MouseModule.datacarriespoint(image pixels, mapped back from the display scaling),moduleandfield(which image), andbutton. See Services for using it to seed a tracker.
Projects and plugins subclass to add their own, starting at 100:
from solarian.core.event import EventType
class MyEventType(EventType):
LIMIT_SWITCH = 100
TAG_DETECTED = 101
Reusing a reserved value raises at class-definition time.
Binding keys to skills
The common case — a key starts a skill — is one handler that constructs the skill it wants:
class So106Controller(Controller):
keyboard = KeyboardModule()
@event_handler(EventType.KEY_DOWN)
def start_bound_skill(self, event: Event) -> None:
match event.data.get("key"):
case "h":
self.start_skill(MoveSkill(params={"pose": "home", "arm": "right_arm", "gripper": "right_gripper"}))
case "l":
self.start_skill(LeaderFollowSkill(params={"leader": "leader_arm", "arm": "right_arm", "gripper": "right_gripper"}))
start_skill preempts whatever holds the same modules, so a key never has to check what is running. space stops everything, and is the only key the framework itself binds.
See also
- Modules — what emits events, and how each is scheduled.
- Observations — for continuous state.
- Actions — for intent.
- Controller and Robot — the loops that drain and dispatch.
- Skills — which receive the batch in
control_step.