1+ import struct
12import threading
23import time
4+
35from modules .base_module import BaseModule
4- import yaml
56
6- import struct
77
88class Controller (BaseModule ):
9+ """Handles gamepad/joystick input and maps events to pub/sub topics."""
10+
11+ # Joystick event constants
12+ JS_EVENT_BUTTON = 0x01
13+ JS_EVENT_AXIS = 0x02
14+ JS_EVENT_INIT = 0x80
15+ JS_EVENT_FORMAT = "IhBB"
16+ JS_EVENT_SIZE = struct .calcsize (JS_EVENT_FORMAT )
17+
918 def __init__ (self , ** kwargs ):
1019 self .running = False
1120 self .button_action_map = kwargs .get ('button_action_map' , {})
1221 self .thread = threading .Thread (target = self .listen , daemon = True )
1322 self .global_deadzone = kwargs .get ('deadzone' , 0.0 )
1423 self .global_debounce = kwargs .get ('debounce' , 800 ) # Minimum change threshold
15- self .modifier_buttons = set (kwargs .get ('modifier_buttons' , []))
24+ self .modifier_buttons = set (kwargs .get ('modifier_buttons' , [])) # Allow alternate mappings when these buttons are held
1625 self .pressed_buttons = set ()
1726 self .axis_last_value = {} # Track last value for each axis
1827 self .axis_last_time = {} # Track last event time (ms) for each axis
19- self .device = kwargs .get ('device' , "/dev/input/js0" )
28+ self .device = kwargs .get ('device' , "/dev/input/js0" ) # Required to specify the gamepad device
29+ if 'button_names' not in kwargs :
30+ raise ValueError ("button_names must be provided in configuration" )
31+ if 'axis_names' not in kwargs :
32+ raise ValueError ("axis_names must be provided in configuration" )
33+ self .button_names = kwargs ['button_names' ]
34+ self .axis_names = kwargs ['axis_names' ]
35+
36+ def _get_active_mapping (self ):
37+ """Get the current button map based on pressed modifier buttons."""
38+ active_mods = sorted (self .pressed_buttons & self .modifier_buttons )
39+ mapping_key = '+' .join (active_mods ) if active_mods else 'default'
40+ return self .button_action_map .get (mapping_key , self .button_action_map .get ('default' , {}))
2041
2142 def setup_messaging (self ):
2243 self .running = True
2344 self .thread .start ()
2445
2546 def listen (self ):
47+ """Poll the joystick device for input events."""
2648 self .log ("Controller listening for input..." )
27- JS_EVENT_FORMAT = "IhBB"
28- JS_EVENT_SIZE = struct .calcsize (JS_EVENT_FORMAT )
2949 while self .running :
3050 try :
3151 with open (self .device , "rb" ) as jsdev :
3252 while self .running :
33- evbuf = jsdev .read (JS_EVENT_SIZE )
53+ evbuf = jsdev .read (self . JS_EVENT_SIZE )
3454 if not evbuf :
3555 continue
36- time_ms , value , type_ , number = struct .unpack (JS_EVENT_FORMAT , evbuf )
37- # print(f"time={time} value={value} type={type_} number={number}")
38- self .handle_js_event (time_ms , value , type_ , number )
56+ time_ms , value , type_ , number = struct .unpack (self .JS_EVENT_FORMAT , evbuf )
57+ self ._handle_js_event (time_ms , value , type_ , number )
3958 except FileNotFoundError :
4059 self .log (f"No gamepad found at { self .device } . Waiting for connection..." , level = 'warning' )
4160 time .sleep (1 )
4261 except Exception as e :
43- self .log (f"Error reading { self . device } : { e } " , level = 'error' )
62+ self .log (f"Error: { e } " , level = 'error' )
4463 time .sleep (1 )
4564
46- def handle_js_event (self , time_ms , value , type_ , number ):
47- # type_: 0x01 = button, 0x02 = axis, 0x80 = init
48- JS_EVENT_BUTTON = 1
49- JS_EVENT_AXIS = 0x02
50- JS_EVENT_INIT = 0x80
51-
65+ def _handle_js_event (self , time_ms , value , type_ , number ):
66+ """Route joystick events to appropriate handlers."""
67+ is_init = (type_ & self .JS_EVENT_INIT ) != 0
68+ event_type = type_ & ~ self .JS_EVENT_INIT
69+
5270 topic = None
5371 args = {}
5472
55- is_init = (type_ & JS_EVENT_INIT ) != 0
56- event_type = type_ & ~ JS_EVENT_INIT
57-
58- # Map button/axis numbers to names as needed for your controller
59- button_names = {
60- 0 : 'BTN_A' , 1 : 'BTN_B' , 2 : 'BTN_X' , 3 : 'BTN_Y' ,
61- 4 : 'BTN_TL' , 5 : 'BTN_TR' , 6 : 'BTN_SELECT' , 7 : 'BTN_START' ,
62- 8 : 'BTN_MODE' , 9 : 'BTN_THUMBL' , 10 : 'BTN_THUMBR'
63- }
64- axis_names = {
65- 0 : 'ABS_X' , 1 : 'ABS_Y' , 2 : 'ABS_Z' , 3 : 'ABS_RZ' ,
66- 4 : 'ABS_RX' , 5 : 'ABS_RY' , 6 : 'ABS_HAT0X' , 7 : 'ABS_HAT0Y'
67- }
68-
69- # self.log(f"JS Event: time={time_ms} value={value} type={event_type} number={number} (init={is_init})")
70-
71- if event_type == JS_EVENT_BUTTON :
72- # self.log(f"JS Button Event: number={number} value={value} (init={is_init})")
73- button = button_names .get (number , f'BTN_{ number } ' )
74- # value: 1=pressed, 0=released
75- if value == 1 :
76- self .pressed_buttons .add (button )
77- elif value == 0 and button in self .pressed_buttons :
78- self .pressed_buttons .remove (button )
79-
80- active_mods = sorted ([b for b in self .pressed_buttons if b in self .modifier_buttons ])
81- mapping_key = '+' .join (active_mods ) if active_mods else 'default'
82- button_map = self .button_action_map .get (mapping_key , self .button_action_map .get ('default' , {}))
83- if button in button_map and value == 1 :
84- actions = button_map [button ]
85- for mapping in actions :
86- topic = mapping .get ('topic' )
87- if not topic :
88- self .log (f"Empty topic for button { button } mapping, skipping." , level = 'warning' )
89- continue # Skip empty topic
90- args = mapping .get ('args' , {})
91- self .publish (topic , ** args )
92- self .log (f"Published to topic { topic } with args { args } (jsdev)" )
93-
94- elif event_type == JS_EVENT_AXIS :
95- axis = axis_names .get (number , f'AXIS_{ number } ' )
96- # self.log(f"Axis event: {axis} value={value} (jsdev)")
97- # value: -32767..32767 for sticks, -1/0/1 for hats
98- active_mods = sorted ([b for b in self .pressed_buttons if b in self .modifier_buttons ])
99- mapping_key = '+' .join (active_mods ) if active_mods else 'default'
100- button_map = self .button_action_map .get (mapping_key , self .button_action_map .get ('default' , {}))
101-
102- if axis in button_map :
103- actions = button_map [axis ]
104- for mapping in actions :
105- deadzone = mapping .get ('deadzone' , self .global_deadzone )
106- if deadzone > abs (value ):
107- continue # Skip if within deadzone
108-
109- debounce = mapping .get ('debounce' , self .global_debounce ) # debounce in ms
110- now = int (time .time () * 1000 ) # current time in ms
111- last_time = self .axis_last_time .get (axis , None )
112-
113- # Only allow if enough time has passed
114- if last_time is not None and (now - last_time ) < debounce :
115- continue # Skip if within debounce interval
116- self .axis_last_time [axis ] = now
117- last_value = self .axis_last_value .get (axis , 0 )
118- delta = value - last_value
119- self .axis_last_value [axis ] = value
120-
121- topic = mapping .get ('topic' )
122- if not topic :
123- self .log (f"Empty topic for axis { axis } mapping, skipping." , level = 'warning' )
124- continue # Skip empty topic
125- args = dict (mapping .get ('args' , {})) # Copy to avoid mutation
126- modifier = mapping .get ('modifier' , None )
127- if modifier is not None :
128- scale = modifier .get ('scale' , 1.0 )
129- args ['delta' ] = delta * scale
130- # self.log(f"Axis {axis} moved to {args.get('delta', value)} (jsdev)")
131- self .publish (topic , ** args )
132- self .log (f"Published to topic { topic } with args { args } (jsdev)" )
73+ if event_type == self .JS_EVENT_BUTTON :
74+ topic , args = self ._handle_button_event (value , number )
75+ elif event_type == self .JS_EVENT_AXIS :
76+ topic , args = self ._handle_axis_event (value , number )
77+
13378 self .publish ('controller/event' , event = (time_ms , value , type_ , number , topic , args ))
13479
80+ def _handle_button_event (self , value , number ):
81+ """Handle button press/release events."""
82+ button = self .button_names .get (number , f'BTN_{ number } ' )
83+ topic = None
84+ args = {}
85+
86+ if value == 1 :
87+ self .pressed_buttons .add (button )
88+ elif value == 0 :
89+ self .pressed_buttons .discard (button )
90+
91+ button_map = self ._get_active_mapping ()
92+ if button in button_map and value == 1 :
93+ for mapping in button_map [button ]:
94+ topic = mapping .get ('topic' )
95+ if not topic :
96+ self .log (f"Empty topic for button { button } mapping, skipping." , level = 'warning' )
97+ continue
98+ args = mapping .get ('args' , {})
99+ self .publish (topic , ** args )
100+ self .log (f"Published to topic { topic } with args { args } (jsdev)" )
101+
102+ return topic , args
103+
104+ def _handle_axis_event (self , value , number ):
105+ """Handle axis movement events."""
106+ axis = self .axis_names .get (number , f'AXIS_{ number } ' )
107+ topic = None
108+ args = {}
109+
110+ button_map = self ._get_active_mapping ()
111+ if axis not in button_map :
112+ return topic , args
113+
114+ for mapping in button_map [axis ]:
115+ deadzone = mapping .get ('deadzone' , self .global_deadzone )
116+ if deadzone > abs (value ):
117+ continue
118+
119+ debounce = mapping .get ('debounce' , self .global_debounce )
120+ now = int (time .monotonic () * 1000 )
121+ last_time = self .axis_last_time .get (axis )
122+
123+ if last_time is not None and (now - last_time ) < debounce :
124+ continue
125+
126+ self .axis_last_time [axis ] = now
127+ last_value = self .axis_last_value .get (axis , 0 )
128+ delta = value - last_value
129+ self .axis_last_value [axis ] = value
130+
131+ topic = mapping .get ('topic' )
132+ if not topic :
133+ self .log (f"Empty topic for axis { axis } mapping, skipping." , level = 'warning' )
134+ continue
135+
136+ args = dict (mapping .get ('args' , {}))
137+ modifier = mapping .get ('modifier' )
138+ if modifier is not None :
139+ scale = modifier .get ('scale' , 1.0 )
140+ args ['delta' ] = delta * scale
141+
142+ self .publish (topic , ** args )
143+ self .log (f"Published to topic { topic } with args { args } (jsdev)" )
144+
145+ return topic , args
146+
147+ # Keep public method for backwards compatibility with tests
148+ def handle_js_event (self , time_ms , value , type_ , number ):
149+ """Public wrapper for _handle_js_event (for testing compatibility)."""
150+ return self ._handle_js_event (time_ms , value , type_ , number )
151+
135152 def stop (self ):
153+ """Stop the controller listener thread."""
136154 self .running = False
137155 self .thread .join ()
0 commit comments