utils.controller

Controller configuration utilities.

Shared between the host GUI tool and robot code.

 1"""Controller configuration utilities.
 2
 3Shared between the host GUI tool and robot code.
 4"""
 5
 6from .config_io import (
 7    load_actions_from_file,
 8    load_assignments_from_file,
 9    load_config,
10    save_actions_to_file,
11    save_assignments_to_file,
12    save_config,
13)
14from .model import (
15    ANALOG_EVENT_TRIGGER_MODES,
16    ActionDefinition,
17    BUTTON_EVENT_TRIGGER_MODES,
18    ControllerConfig,
19    FullConfig,
20    InputType,
21    EventTriggerMode,
22    parse_qualified_name,
23)
24
25__all__ = [
26    "ANALOG_EVENT_TRIGGER_MODES",
27    "ActionDefinition",
28    "BUTTON_EVENT_TRIGGER_MODES",
29    "ControllerConfig",
30    "FullConfig",
31    "InputType",
32    "EventTriggerMode",
33    "load_actions_from_file",
34    "load_assignments_from_file",
35    "load_config",
36    "parse_qualified_name",
37    "save_actions_to_file",
38    "save_assignments_to_file",
39    "save_config",
40]
ANALOG_EVENT_TRIGGER_MODES = [<EventTriggerMode.SCALED: 'scaled'>, <EventTriggerMode.SQUARED: 'squared'>, <EventTriggerMode.RAW: 'raw'>, <EventTriggerMode.SEGMENTED: 'segmented'>, <EventTriggerMode.SPLINE: 'spline'>]
@dataclass
class ActionDefinition:
100@dataclass
101class ActionDefinition:
102    """A named action with metadata.
103
104    Actions are the interface between controller inputs and robot behavior.
105    The `extra` dict allows forward-compatible extension without breaking
106    existing configs.
107
108    Actions belong to a group (default "general"). The fully qualified name
109    is ``group.name`` (e.g. ``intake.run``, ``shooter.fire``).
110    """
111    name: str
112    description: str = ""
113    group: str = DEFAULT_GROUP
114    input_type: InputType = InputType.BUTTON
115    trigger_mode: EventTriggerMode = EventTriggerMode.ON_TRUE
116    deadband: float = 0.0
117    threshold: float = 0.5     # For BOOLEAN_TRIGGER: axis > threshold = True
118    inversion: bool = False
119    slew_rate: float = 0.0  # Max output change rate (units/sec), 0 = disabled.
120    # Symmetric by default. For asymmetric, set
121    # extra[EXTRA_NEGATIVE_SLEW_RATE] to a negative value.
122    scale: float = 1.0
123    extra: dict = field(default_factory=dict)
124
125    @property
126    def qualified_name(self) -> str:
127        """Return the fully qualified name: group.name."""
128        return f"{self.group}.{self.name}"

A named action with metadata.

Actions are the interface between controller inputs and robot behavior. The extra dict allows forward-compatible extension without breaking existing configs.

Actions belong to a group (default "general"). The fully qualified name is group.name (e.g. intake.run, shooter.fire).

ActionDefinition( name: str, description: str = '', group: str = 'general', input_type: InputType = <InputType.BUTTON: 'button'>, trigger_mode: EventTriggerMode = <EventTriggerMode.ON_TRUE: 'on_true'>, deadband: float = 0.0, threshold: float = 0.5, inversion: bool = False, slew_rate: float = 0.0, scale: float = 1.0, extra: dict = <factory>)
name: str
description: str = ''
group: str = 'general'
input_type: InputType = <InputType.BUTTON: 'button'>
trigger_mode: EventTriggerMode = <EventTriggerMode.ON_TRUE: 'on_true'>
deadband: float = 0.0
threshold: float = 0.5
inversion: bool = False
slew_rate: float = 0.0
scale: float = 1.0
extra: dict
qualified_name: str
125    @property
126    def qualified_name(self) -> str:
127        """Return the fully qualified name: group.name."""
128        return f"{self.group}.{self.name}"

Return the fully qualified name: group.name.

BUTTON_EVENT_TRIGGER_MODES = [<EventTriggerMode.ON_TRUE: 'on_true'>, <EventTriggerMode.ON_FALSE: 'on_false'>, <EventTriggerMode.WHILE_TRUE: 'while_true'>, <EventTriggerMode.WHILE_FALSE: 'while_false'>, <EventTriggerMode.TOGGLE_ON_TRUE: 'toggle_on_true'>]
@dataclass
class ControllerConfig:
186@dataclass
187class ControllerConfig:
188    """Configuration for a single controller (port + bindings)."""
189    port: int
190    name: str = ""
191    controller_type: str = DEFAULT_CONTROLLER_TYPE
192    bindings: dict[str, list[str]] = field(default_factory=dict)

Configuration for a single controller (port + bindings).

ControllerConfig( port: int, name: str = '', controller_type: str = 'xbox', bindings: dict[str, list[str]] = <factory>)
port: int
name: str = ''
controller_type: str = 'xbox'
bindings: dict[str, list[str]]
@dataclass
class FullConfig:
195@dataclass
196class FullConfig:
197    """Top-level configuration: action definitions + controller bindings."""
198    actions: dict[str, ActionDefinition] = field(default_factory=dict)
199    controllers: dict[int, ControllerConfig] = field(default_factory=dict)
200    empty_groups: set[str] = field(default_factory=set)
201    version: str = ""

Top-level configuration: action definitions + controller bindings.

FullConfig( actions: dict[str, ActionDefinition] = <factory>, controllers: dict[int, ControllerConfig] = <factory>, empty_groups: set[str] = <factory>, version: str = '')
actions: dict[str, ActionDefinition]
controllers: dict[int, ControllerConfig]
empty_groups: set[str]
version: str = ''
class InputType(enum.Enum):
86class InputType(Enum):
87    """Type of controller input an action is designed for.
88
89    BOOLEAN_TRIGGER converts an analog axis (e.g. left_trigger) into a
90    boolean using a threshold comparison — not related to
91    ``commands2.button.Trigger``.
92    """
93    BUTTON = "button"
94    ANALOG = "analog"
95    OUTPUT = "output"
96    BOOLEAN_TRIGGER = "boolean_trigger"
97    VIRTUAL_ANALOG = "virtual_analog"

Type of controller input an action is designed for.

BOOLEAN_TRIGGER converts an analog axis (e.g. left_trigger) into a boolean using a threshold comparison — not related to commands2.button.Trigger.

BUTTON = <InputType.BUTTON: 'button'>
ANALOG = <InputType.ANALOG: 'analog'>
OUTPUT = <InputType.OUTPUT: 'output'>
BOOLEAN_TRIGGER = <InputType.BOOLEAN_TRIGGER: 'boolean_trigger'>
VIRTUAL_ANALOG = <InputType.VIRTUAL_ANALOG: 'virtual_analog'>
class EventTriggerMode(enum.Enum):
43class EventTriggerMode(Enum):
44    """How a button/analog action is triggered or shaped.
45
46    Button modes control when commands fire via ``commands2.button.Trigger``
47    bindings (not to be confused with the Xbox "trigger" — the analog
48    left_trigger / right_trigger inputs).  Each mode maps to a Trigger
49    binding method: ON_TRUE -> ``.onTrue()``, WHILE_TRUE -> ``.whileTrue()``,
50    etc.
51
52    Analog modes control how the analog value is shaped/curved before
53    it reaches the subsystem.
54    """
55    # Button modes
56    ON_TRUE = "on_true"
57    ON_FALSE = "on_false"
58    WHILE_TRUE = "while_true"
59    WHILE_FALSE = "while_false"
60    TOGGLE_ON_TRUE = "toggle_on_true"
61    # Analog response curves
62    RAW = "raw"
63    SCALED = "scaled"
64    SQUARED = "squared"
65    SEGMENTED = "segmented"
66    SPLINE = "spline"

How a button/analog action is triggered or shaped.

Button modes control when commands fire via commands2.button.Trigger bindings (not to be confused with the Xbox "trigger" — the analog left_trigger / right_trigger inputs). Each mode maps to a Trigger binding method: ON_TRUE -> .onTrue(), WHILE_TRUE -> .whileTrue(), etc.

Analog modes control how the analog value is shaped/curved before it reaches the subsystem.

ON_TRUE = <EventTriggerMode.ON_TRUE: 'on_true'>
ON_FALSE = <EventTriggerMode.ON_FALSE: 'on_false'>
WHILE_TRUE = <EventTriggerMode.WHILE_TRUE: 'while_true'>
WHILE_FALSE = <EventTriggerMode.WHILE_FALSE: 'while_false'>
TOGGLE_ON_TRUE = <EventTriggerMode.TOGGLE_ON_TRUE: 'toggle_on_true'>
RAW = <EventTriggerMode.RAW: 'raw'>
SCALED = <EventTriggerMode.SCALED: 'scaled'>
SQUARED = <EventTriggerMode.SQUARED: 'squared'>
SEGMENTED = <EventTriggerMode.SEGMENTED: 'segmented'>
SPLINE = <EventTriggerMode.SPLINE: 'spline'>
def load_actions_from_file( path: str | pathlib.Path) -> dict[str, ActionDefinition]:
265def load_actions_from_file(path: str | Path) -> dict[str, ActionDefinition]:
266    """Load only the actions from a YAML config file.
267
268    Returns dict keyed by qualified name.  Used for import/merge.
269    """
270    path = Path(path)
271    with open(path) as f:
272        data = yaml.safe_load(f)
273    if not data:
274        return {}
275
276    version = data.get("version", "")
277    if not version:
278        log.warning(
279            "Config file '%s' has no version field — assuming %s",
280            path, CONFIG_VERSION)
281    elif version != CONFIG_VERSION:
282        log.warning(
283            "Config file '%s' version '%s' does not match "
284            "expected '%s'", path, version, CONFIG_VERSION)
285
286    actions, _empty = _load_actions_dict(data.get("actions") or {})
287    return actions

Load only the actions from a YAML config file.

Returns dict keyed by qualified name. Used for import/merge.

def load_assignments_from_file( path: str | pathlib.Path) -> dict[int, ControllerConfig]:
313def load_assignments_from_file(path: str | Path) -> dict[int, ControllerConfig]:
314    """Load only the controllers section from a YAML file.
315
316    Returns dict keyed by port number.
317    """
318    path = Path(path)
319    with open(path) as f:
320        data = yaml.safe_load(f)
321    if not data:
322        return {}
323
324    controllers = {}
325    for port, ctrl_dict in (data.get("controllers") or {}).items():
326        port = int(port)
327        controllers[port] = _dict_to_controller(port, ctrl_dict)
328    return controllers

Load only the controllers section from a YAML file.

Returns dict keyed by port number.

def load_config(path: str | pathlib.Path) -> FullConfig:
234def load_config(path: str | Path) -> FullConfig:
235    """Load a FullConfig from a YAML file."""
236    path = Path(path)
237    with open(path) as f:
238        data = yaml.safe_load(f)
239
240    if not data:
241        return FullConfig()
242
243    version = data.get("version", "")
244    if not version:
245        log.warning(
246            "Config file '%s' has no version field — assuming %s",
247            path, CONFIG_VERSION)
248        version = CONFIG_VERSION
249    elif version != CONFIG_VERSION:
250        log.warning(
251            "Config file '%s' version '%s' does not match "
252            "expected '%s'", path, version, CONFIG_VERSION)
253
254    actions, empty_groups = _load_actions_dict(data.get("actions") or {})
255
256    controllers = {}
257    for port, ctrl_dict in (data.get("controllers") or {}).items():
258        port = int(port)
259        controllers[port] = _dict_to_controller(port, ctrl_dict)
260
261    return FullConfig(actions=actions, controllers=controllers,
262                      empty_groups=empty_groups, version=version)

Load a FullConfig from a YAML file.

def parse_qualified_name(qualified: str) -> tuple[str, str]:
131def parse_qualified_name(qualified: str) -> tuple[str, str]:
132    """Split a qualified name into (group, short_name).
133
134    If there is no dot, returns ('general', qualified).
135    """
136    if '.' in qualified:
137        group, _, name = qualified.partition('.')
138        return group, name
139    return DEFAULT_GROUP, qualified

Split a qualified name into (group, short_name).

If there is no dot, returns ('general', qualified).

def save_actions_to_file( actions: dict[str, ActionDefinition], path: str | pathlib.Path) -> None:
290def save_actions_to_file(actions: dict[str, ActionDefinition],
291                         path: str | Path) -> None:
292    """Save actions to a YAML file (actions section only, no controllers)."""
293    data = {}
294    if actions:
295        data["actions"] = _actions_to_nested_dict(actions)
296
297    _dump_yaml(path, data)

Save actions to a YAML file (actions section only, no controllers).

def save_assignments_to_file( controllers: dict[int, ControllerConfig], path: str | pathlib.Path) -> None:
300def save_assignments_to_file(controllers: dict[int, ControllerConfig],
301                             path: str | Path) -> None:
302    """Save controller assignments to a YAML file (controllers only, no actions)."""
303    data = {}
304    if controllers:
305        data["controllers"] = {
306            port: _controller_to_dict(ctrl)
307            for port, ctrl in controllers.items()
308        }
309
310    _dump_yaml(path, data)

Save controller assignments to a YAML file (controllers only, no actions).

def save_config( config: FullConfig, path: str | pathlib.Path) -> None:
215def save_config(config: FullConfig, path: str | Path) -> None:
216    """Save a FullConfig to a YAML file (nested action format)."""
217    data = {}
218
219    data["version"] = config.version or CONFIG_VERSION
220
221    if config.actions or config.empty_groups:
222        data["actions"] = _actions_to_nested_dict(
223            config.actions, config.empty_groups)
224
225    if config.controllers:
226        data["controllers"] = {
227            port: _controller_to_dict(ctrl)
228            for port, ctrl in config.controllers.items()
229        }
230
231    _dump_yaml(path, data)

Save a FullConfig to a YAML file (nested action format).