Skip to content

Commit 6841f63

Browse files
committed
Cleanup
1 parent b052ed0 commit 6841f63

File tree

3 files changed

+172
-95
lines changed

3 files changed

+172
-95
lines changed

config/controller.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,30 @@ controller:
22
enabled: true
33
path: 'modules.controller.Controller'
44
config:
5+
device: '/dev/input/js0' # Add this
56
deadzone: 500
7+
button_names: # Add this
8+
0: 'BTN_A'
9+
1: 'BTN_B'
10+
2: 'BTN_X'
11+
3: 'BTN_Y'
12+
4: 'BTN_TL'
13+
5: 'BTN_TR'
14+
6: 'BTN_SELECT'
15+
7: 'BTN_START'
16+
8: 'BTN_MODE'
17+
9: 'BTN_THUMBL'
18+
10: 'BTN_THUMBR'
19+
11: 'KEY_RECORD' # Xbox Series controller
20+
axis_names: # Add this
21+
0: 'ABS_X'
22+
1: 'ABS_Y'
23+
2: 'ABS_Z'
24+
3: 'ABS_RZ'
25+
4: 'ABS_RX'
26+
5: 'ABS_RY'
27+
6: 'ABS_HAT0X'
28+
7: 'ABS_HAT0Y'
629
modifier_buttons:
730
- BTN_TL
831
- BTN_TR

modules/controller.py

Lines changed: 113 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,137 +1,155 @@
1+
import struct
12
import threading
23
import time
4+
35
from modules.base_module import BaseModule
4-
import yaml
56

6-
import struct
77

88
class 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()

tests/test_controller.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ def setUp(self):
2222

2323
def test_dynamic_remapping(self):
2424
config = {
25+
'button_names': {
26+
0: 'BTN_A', 1: 'BTN_B', 2: 'BTN_X', 3: 'BTN_Y',
27+
4: 'BTN_TL', 5: 'BTN_TR', 6: 'BTN_SELECT', 7: 'BTN_START',
28+
8: 'BTN_MODE', 9: 'BTN_THUMBL', 10: 'BTN_THUMBR'
29+
},
30+
'axis_names': {
31+
0: 'ABS_X', 1: 'ABS_Y', 2: 'ABS_Z', 3: 'ABS_RZ',
32+
4: 'ABS_RX', 5: 'ABS_RY', 6: 'ABS_HAT0X', 7: 'ABS_HAT0Y'
33+
},
2534
'button_action_map': {
2635
'default': {
2736
'BTN_A': [ {'topic': 'tts', 'args': {'msg': 'Default'}} ]
@@ -42,6 +51,15 @@ def test_dynamic_remapping(self):
4251

4352
def test_modifier_mapping(self):
4453
config = {
54+
'button_names': {
55+
0: 'BTN_A', 1: 'BTN_B', 2: 'BTN_X', 3: 'BTN_Y',
56+
4: 'BTN_TL', 5: 'BTN_TR', 6: 'BTN_SELECT', 7: 'BTN_START',
57+
8: 'BTN_MODE', 9: 'BTN_THUMBL', 10: 'BTN_THUMBR'
58+
},
59+
'axis_names': {
60+
0: 'ABS_X', 1: 'ABS_Y', 2: 'ABS_Z', 3: 'ABS_RZ',
61+
4: 'ABS_RX', 5: 'ABS_RY', 6: 'ABS_HAT0X', 7: 'ABS_HAT0Y'
62+
},
4563
'button_action_map': {
4664
'default': {
4765
'BTN_A': [ {'topic': 'tts', 'args': {'msg': 'Default'}} ]
@@ -70,6 +88,15 @@ def test_controller_event_published(self):
7088

7189
def test_delta_handled_correctly(self):
7290
config = {
91+
'button_names': {
92+
0: 'BTN_A', 1: 'BTN_B', 2: 'BTN_X', 3: 'BTN_Y',
93+
4: 'BTN_TL', 5: 'BTN_TR', 6: 'BTN_SELECT', 7: 'BTN_START',
94+
8: 'BTN_MODE', 9: 'BTN_THUMBL', 10: 'BTN_THUMBR'
95+
},
96+
'axis_names': {
97+
0: 'ABS_X', 1: 'ABS_Y', 2: 'ABS_Z', 3: 'ABS_RZ',
98+
4: 'ABS_RX', 5: 'ABS_RY', 6: 'ABS_HAT0X', 7: 'ABS_HAT0Y'
99+
},
73100
'button_action_map': {
74101
'default': {
75102
'ABS_X': [ {'topic': 'eye/move', 'args': {'axis': 'x'}, 'deadzone': 10, 'modifier': {'scale': 1.0}} ],
@@ -87,6 +114,15 @@ def test_delta_handled_correctly(self):
87114

88115
def test_deadzone_handled(self):
89116
config = {
117+
'button_names': {
118+
0: 'BTN_A', 1: 'BTN_B', 2: 'BTN_X', 3: 'BTN_Y',
119+
4: 'BTN_TL', 5: 'BTN_TR', 6: 'BTN_SELECT', 7: 'BTN_START',
120+
8: 'BTN_MODE', 9: 'BTN_THUMBL', 10: 'BTN_THUMBR'
121+
},
122+
'axis_names': {
123+
0: 'ABS_X', 1: 'ABS_Y', 2: 'ABS_Z', 3: 'ABS_RZ',
124+
4: 'ABS_RX', 5: 'ABS_RY', 6: 'ABS_HAT0X', 7: 'ABS_HAT0Y'
125+
},
90126
'button_action_map': {
91127
'default': {
92128
'ABS_X': [ {'topic': 'eye/move', 'args': {'axis': 'x'}, 'deadzone': 10, 'modifier': {'scale': 1.0}} ],

0 commit comments

Comments
 (0)