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]
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).
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).
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.
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.
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.
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.
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.
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.
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).
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).
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).
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).