riffpi 0.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
riffpi/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RiffPi: control daemon for Kleag's MFX guitar multi-effect pedal."""
2
+
3
+ __version__ = "0.2.4"
riffpi/cli.py ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env python3
2
+ """Command-line entry point for the `riffpi` console script."""
3
+ import argparse
4
+
5
+ from .daemon import run as run_daemon
6
+ from .service import install_service, uninstall_service
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(
11
+ prog="riffpi", description="RiffPi guitar multi-effect pedal daemon."
12
+ )
13
+ subparsers = parser.add_subparsers(dest="command")
14
+ subparsers.add_parser("run", help="Run the RiffPi daemon (default if no command is given)")
15
+ subparsers.add_parser(
16
+ "install-service", help="Install, enable, and start the riffpi systemd --user service"
17
+ )
18
+ subparsers.add_parser(
19
+ "uninstall-service", help="Stop, disable, and remove the riffpi systemd --user service"
20
+ )
21
+
22
+ args = parser.parse_args()
23
+
24
+ if args.command in (None, "run"):
25
+ run_daemon()
26
+ elif args.command == "install-service":
27
+ install_service()
28
+ elif args.command == "uninstall-service":
29
+ uninstall_service()
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
riffpi/daemon.py ADDED
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env python3
2
+ import logging
3
+ import queue
4
+ import subprocess
5
+ import threading
6
+ import time
7
+ from signal import pause
8
+
9
+ import adafruit_ads1x15.ads1115 as ADS
10
+ import board
11
+ import busio
12
+ import mido
13
+ from adafruit_mcp230xx.mcp23017 import MCP23017
14
+ from digitalio import Direction
15
+
16
+ from .expression_pedal import ExpressionPedal
17
+ from .joystick import Joystick
18
+ from .keypad import KeyPad
19
+ from .mcp_button import MCPButton
20
+ from .mcp_led import MCPLed
21
+ from .rotary_encoder import RotaryEncoder
22
+
23
+ logger = logging.getLogger(__name__)
24
+ logging.basicConfig(level=logging.INFO, force=True)
25
+
26
+ # --- CONFIGURATION ---
27
+ SWITCH_CC = 64 # MIDI CC number for effect toggles
28
+ ENCODER_CC_NUMBERS = [20, 21, 22, 23] # MIDI CC for encoders
29
+
30
+ # Guitarix preset navigation settings
31
+ PRESET_BANK_CC = 32 # MIDI CC for preset bank selection
32
+ PRESET_CHANGE_CC = 0 # MIDI CC for preset change
33
+
34
+ # Keypad/Power LED (Keep original GPIO)
35
+ POWER_LED_PIN = 11
36
+
37
+ # MCP23017 1 Button/LED Map
38
+ BUTTON_PINS_MAP = [(1, 14), (1, 15), (1, 6), (1, 7)] # B6, B7, A6, A7
39
+ LED_PINS_MAP = [(1, 13), (1, 12), (1, 4), (1, 5)] # B5, B4, A4, A5
40
+
41
+ # --- MIDI/ENCODER LOGIC ---
42
+ effect_states = [False] * 4 # Global state for MIDI toggles
43
+
44
+ # Guitarix preset navigation
45
+ current_preset_bank = 0 # Current preset bank (0-3 for A-D)
46
+
47
+ # Index into `buttons` of the preset encoder's push button (the last encoder). `buttons`
48
+ # is [foot switches...] + [encoder buttons...], so this is only known once both are built;
49
+ # it's set in main() before any button-polling thread starts.
50
+ PRESET_ENCODER_INDEX = None
51
+
52
+ def send_cc(cc, value):
53
+ msg = mido.Message('control_change', control=cc, value=value)
54
+ midi_out.send(msg)
55
+
56
+ def set_preset_bank(bank_index):
57
+ """Set the current preset bank (0-3 corresponding to A-D)"""
58
+ global current_preset_bank
59
+ if 0 <= bank_index <= 3:
60
+ current_preset_bank = bank_index
61
+ # Send MIDI messages to change bank
62
+ reset() # Reset all effects
63
+ send_cc(PRESET_BANK_CC, bank_index)
64
+ send_cc(PRESET_CHANGE_CC, 0) # Select first preset in bank
65
+ logger.info(f"Switched to preset bank {chr(ord('A') + bank_index)}")
66
+
67
+ def change_preset(delta):
68
+ """Change preset by delta amount (positive for next, negative for previous)"""
69
+ # In a real implementation, we would need to know the current preset
70
+ # For now, we'll simulate changing presets
71
+ # This would require querying guitarix for actual preset info
72
+ logger.info(f"Changing preset by {delta}")
73
+
74
+ # --- BUTTON HANDLERS ---
75
+ def handle_effect_toggle(idx):
76
+ # Special handling for the preset encoder (last one)
77
+ if idx == PRESET_ENCODER_INDEX: # Last encoder is our special preset encoder
78
+ # Cycle through banks A-D (0-3) on click
79
+ new_bank = (current_preset_bank + 1) % 4
80
+ set_preset_bank(new_bank)
81
+ else:
82
+ # Standard effect toggle behavior
83
+ # logger.info(f"handle_effect_toggle {idx}")
84
+ effect_states[idx] = not effect_states[idx]
85
+ if leds[idx] is not None:
86
+ leds[idx].value = effect_states[idx]
87
+ send_cc(SWITCH_CC + idx, 127 if effect_states[idx] else 0)
88
+ # logger.info(f"Button {idx} pressed. State: {effect_states[idx]}")
89
+
90
+
91
+ def reset():
92
+ logger.info("reset")
93
+ for i in range(len(effect_states)):
94
+ effect_states[i] = False
95
+ if leds[i] is not None:
96
+ leds[i].value = False
97
+
98
+ # --- THREADS ---
99
+
100
+ def midi_input_thread():
101
+ # logger.info("Listening for incoming MIDI messages...")
102
+ for msg in midi_in:
103
+ if msg.type == 'control_change':
104
+ # logger.info(f"midi_input_thread received: {msg}")
105
+ # Update Effect States (SWITCH_CC)
106
+ if SWITCH_CC <= msg.control <= SWITCH_CC + 3:
107
+ idx = msg.control - SWITCH_CC
108
+ new_state = msg.value > 0
109
+
110
+ # Only update if the state actually changed to avoid flickering
111
+ if effect_states[idx] != new_state:
112
+ effect_states[idx] = new_state
113
+ if leds[idx] is not None:
114
+ leds[idx].value = new_state
115
+ # logger.debug(f"Sync: LED {idx} set to {new_state} via MIDI")
116
+ # Update Encoder CC Value if received externally
117
+ if msg.control in ENCODER_CC_NUMBERS:
118
+ for enc in encoders:
119
+ if enc.cc == msg.control:
120
+ enc.update_from_midi(msg.value)
121
+ break
122
+
123
+ def buttons_thread():
124
+ while True:
125
+ # MCP Button Scan
126
+ for i, btn in enumerate(buttons):
127
+ btn.check(i)
128
+ time.sleep(0.01)
129
+
130
+ # --- Main Thread Logic ---
131
+ def main_thread_loop():
132
+ # logger.info("\n[Main Thread] Starting queue processor...")
133
+
134
+ while True:
135
+ while not task_queue.empty():
136
+ try:
137
+ func, args = task_queue.get(timeout=0.1)
138
+ if func == "reset":
139
+ reset(*args)
140
+
141
+ task_queue.task_done()
142
+ except queue.Empty:
143
+ # This happens if the queue is temporarily empty
144
+ pass
145
+ time.sleep(0.001)
146
+
147
+ def link_pipewire_ports():
148
+ try:
149
+ # Link Script -> Guitarix
150
+ subprocess.run(
151
+ ['pw-link', 'Midi-Bridge:RtMidiOut Client:(capture_0) KleagMFX', 'gx_head_amp:midi_in_1'],
152
+ check=False,
153
+ )
154
+ # Link Guitarix -> Script
155
+ subprocess.run(
156
+ ['pw-link', 'gx_head_amp:midi_out_1', 'Midi-Bridge:RtMidiIn Client:(playback_0) KleagMFX'],
157
+ check=False,
158
+ )
159
+ logger.info("PipeWire MIDI ports linked successfully.")
160
+ except Exception as e:
161
+ logger.error(f"Failed to link PipeWire ports: {e}")
162
+
163
+
164
+ def run():
165
+ """Initialize hardware/MIDI and run the RiffPi daemon until interrupted."""
166
+ global midi_out, midi_in, buttons, leds, encoders, task_queue, PRESET_ENCODER_INDEX
167
+
168
+ # --- MIDI SETUP ---
169
+ midi_out = mido.open_output('KleagMFX', virtual=True)
170
+ midi_in = mido.open_input('KleagMFX', virtual=True)
171
+
172
+ # --- HARDWARE INITIALIZATION ---
173
+ i2c = busio.I2C(board.SCL, board.SDA)
174
+ i2c_lock = threading.Lock()
175
+
176
+ # ADS1115 for Joystick (kept as requested)
177
+ ads = ADS.ADS1115(i2c)
178
+
179
+ # MCP23017
180
+ mcp1 = MCP23017(i2c, address=0x20)
181
+ mcp2 = MCP23017(i2c, address=0x21)
182
+ MCP_MAP = {1: mcp1, 2: mcp2}
183
+
184
+ # Power LED
185
+ power_led = mcp1.get_pin(POWER_LED_PIN)
186
+ power_led.direction = Direction.OUTPUT
187
+ power_led.value = True
188
+
189
+ # Foot switches and their associated LED
190
+ buttons = [MCPButton(MCP_MAP[mcp], pin) for mcp, pin in BUTTON_PINS_MAP]
191
+ leds = [MCPLed(MCP_MAP[mcp], pin) for mcp, pin in LED_PINS_MAP]
192
+
193
+ # Board label: as visible on physical pedalboard: mcp number and mcp pins map
194
+ # RotaryEncoder4: 1st from left to right above : mcp n°2 clk B4=12 dt B3=11 sw B2=10
195
+ # RotaryEncoder3: 2nd from left to right above : mcp n°2 clk B7=15 dt B6=14 sw B5=13
196
+ # RotaryEncoder2: 3rd from left to right above : mcp n°1 clk A3=3 dt A2=2 sw A1=1
197
+ # RotaryEncoder1: 4th from left to right above : mcp n°1 clk A0=0, dt B0=8, sw B1=9
198
+ # --- ROTARY ENCODERS ---
199
+ encoder_configs = [
200
+ (mcp1, 0, 8, 9, "Encoder 0", ENCODER_CC_NUMBERS[0]), # CC 20
201
+ (mcp1, 3, 2, 1, "Encoder 1", ENCODER_CC_NUMBERS[1]), # CC 21
202
+ (mcp2, 15, 14, 13, "Encoder 2", ENCODER_CC_NUMBERS[2]), # CC 22
203
+ (mcp2, 12, 11, 10, "Encoder 3", ENCODER_CC_NUMBERS[3]), # CC 23
204
+ ]
205
+ encoders = []
206
+ for i, (mcp, clk_pin, dt_pin, sw_pin, name, cc) in enumerate(encoder_configs):
207
+ # Make the last encoder (index 3) a preset encoder
208
+ is_preset = (i == len(encoder_configs) - 1)
209
+ encoder = RotaryEncoder(midi_out, mcp, name, clk_pin, dt_pin, sw_pin, cc, is_preset)
210
+ encoders.append(encoder)
211
+ buttons.append(encoder.button)
212
+ effect_states.append(False)
213
+ leds.append(None)
214
+ if is_preset:
215
+ PRESET_ENCODER_INDEX = len(buttons) - 1
216
+
217
+ for i, btn in enumerate(buttons):
218
+ btn.when_pressed = handle_effect_toggle
219
+
220
+
221
+ link_pipewire_ports()
222
+ task_queue = queue.Queue()
223
+ joystick = Joystick(ads, mcp1, lock=i2c_lock)
224
+ keypad = KeyPad(task_queue, midi_out, mcp2)
225
+ pedal = ExpressionPedal(midi_out, ads, lock=i2c_lock, channel=ADS.P2)
226
+
227
+ threading.Thread(target=midi_input_thread, daemon=True).start()
228
+ threading.Thread(target=buttons_thread, daemon=True).start()
229
+ threading.Thread(target=joystick.poll_joystick, daemon=True).start()
230
+ threading.Thread(target=keypad.keypad_thread, daemon=True).start()
231
+ threading.Thread(target=pedal.poll, daemon=True).start()
232
+
233
+ for encoder in encoders:
234
+ threading.Thread(target=encoder.poll_thread, daemon=True).start()
235
+ threading.Thread(target=main_thread_loop, daemon=True).start()
236
+
237
+ logger.info("Kleag's Multi-effect daemon running.")
238
+ try:
239
+ pause()
240
+ except KeyboardInterrupt:
241
+ logger.info("Kleag's Multi-effect daemon terminating through keyboard interrupt.")
242
+
243
+
244
+ # === Main ===
245
+ if __name__ == "__main__":
246
+ run()
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ import logging
3
+ import statistics
4
+ import threading
5
+ import time
6
+ from signal import pause
7
+
8
+ import adafruit_ads1x15.ads1115 as ADS
9
+ import board
10
+ import busio
11
+ import mido
12
+ from adafruit_ads1x15.analog_in import AnalogIn
13
+
14
+ # The actual voltages measured at the physical limits of the pedal
15
+ V_MIN = 0.006
16
+ V_MAX = 2.768
17
+ MIDI_CC_NUMBER = 24
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ class ExpressionPedal:
22
+ def __init__(self, midi_out, ads: ADS.ADS1115, lock: threading.Lock, channel=ADS.P2, gain=1, v_ref=3.3):
23
+ self.midi_out = midi_out
24
+ # --- HARDWARE INITIALIZATION ---
25
+ self.ads = ads
26
+ self.lock = lock
27
+ self.chan = AnalogIn(self.ads, channel)
28
+ self.ads.data_rate = 860
29
+
30
+ # 2. Configuration
31
+ self.v_ref = v_ref
32
+ self.ads.gain = gain # Gain 1 = +/- 4.096V
33
+
34
+ self._current_midi_val = -1
35
+ self._running = False
36
+ self.window_size = 5
37
+ self.readings = [0] * self.window_size
38
+
39
+
40
+ def poll(self):
41
+ self._running = True
42
+ while self._running:
43
+ with self.lock:
44
+ voltage = self.chan.voltage
45
+
46
+ # Map voltage to 0-127 (MIDI Range)
47
+ raw_percent = ((voltage - V_MIN) * 127) / (V_MAX - V_MIN)
48
+ clamped = max(0, min(127, int(raw_percent)))
49
+
50
+ # Smoothing
51
+ self.readings.pop(0)
52
+ self.readings.append(clamped)
53
+ smoothed_val = int(statistics.median(self.readings))
54
+ logger.debug(f"Pedal: {voltage};\t{clamped};\t{smoothed_val}")
55
+
56
+ # Only send MIDI message if the value has actually changed
57
+ if abs(smoothed_val - self._current_midi_val) >= 4:
58
+ self._current_midi_val = smoothed_val
59
+ self.send_midi(smoothed_val)
60
+
61
+ time.sleep(0.01)
62
+
63
+ def send_midi(self, value):
64
+ msg = mido.Message('control_change', control=MIDI_CC_NUMBER, value=value)
65
+ self.midi_out.send(msg)
66
+ logger.debug(f"Pedal: Sent MIDI CC {MIDI_CC_NUMBER}: {value}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ logging.basicConfig(level=logging.DEBUG)
71
+ i2c = busio.I2C(board.SCL, board.SDA)
72
+ i2c_lock = threading.Lock()
73
+ ads = ADS.ADS1115(i2c)
74
+ # --- MIDI SETUP ---
75
+ # This creates a virtual MIDI port that shows up in patchage/qjackctl
76
+ midi_out = mido.open_output('ExpressionPedalPort', virtual=True)
77
+ logger.info("Virtual MIDI port 'ExpressionPedalPort' created.")
78
+
79
+ pedal = ExpressionPedal(midi_out, ads, i2c_lock)
80
+
81
+ t = threading.Thread(target=pedal.poll, daemon=True)
82
+ t.start()
83
+
84
+ logger.info("ExpressionPedal to MIDI running. Press Ctrl+C to stop.")
85
+ try:
86
+ pause()
87
+ except KeyboardInterrupt:
88
+ logger.info("Shutting down...")
riffpi/joystick.py ADDED
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ import logging
3
+ import math
4
+ import statistics
5
+ import threading
6
+ import time
7
+ from signal import pause
8
+
9
+ import adafruit_ads1x15.ads1115 as ADS
10
+ import board
11
+ import busio
12
+ import uinput
13
+ from adafruit_ads1x15.analog_in import AnalogIn
14
+ from adafruit_mcp230xx.mcp23017 import MCP23017
15
+ from digitalio import Direction, Pull
16
+ from rich.console import Console
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class Joystick:
22
+ # --- CONFIGURATION ---
23
+ # Maximum mouse speed (pixels/s) reached only at full deflection (|x|/|y| == 1.0).
24
+ SENSITIVITY = 30.0
25
+ # Fraction of travel (0.0-1.0) around center treated as "not moved". Noise is filtered
26
+ # separately (SMOOTHING_WINDOW), so this only needs to absorb residual jitter.
27
+ DEAD_ZONE = 0.02
28
+ # Exponent applied to the (post-dead-zone) normalized deflection before scaling by
29
+ # SENSITIVITY. Must be > 1: with an exponent < 1 the curve has infinite slope at 0, so
30
+ # speed jumps immediately once past the dead zone (the "dead then jumpy" feel this
31
+ # replaces). >1 makes the slope start at 0 and ramp up smoothly, so small deflections
32
+ # give fine/slow motion (useful for nudging a Guitarix knob) and only large deflections
33
+ # approach SENSITIVITY. Raise it further (e.g. 3.0-4.0) for even finer low-end control.
34
+ POWER_CURVE = 2.5
35
+ # Number of raw voltage samples averaged (median) per axis to smooth out ADC/electrical
36
+ # noise from the joystick's low-cost potentiometers before it reaches the dead zone/curve.
37
+ SMOOTHING_WINDOW = 5
38
+ LOOP_DELAY = 0.01 # General loop delay (seconds)
39
+
40
+ def __init__(self, ads: ADS.ADS1115, mcp: MCP23017, lock: threading.Lock, debug: bool = False):
41
+ self.debug = debug
42
+ # --- HARDWARE INITIALIZATION ---
43
+ self.ads = ads
44
+ self.lock = lock
45
+ # Use P0 and P1 for Joystick X and Y
46
+ self.joystick_x_axis = AnalogIn(ads, ADS.P0)
47
+ self.joystick_y_axis = AnalogIn(ads, ADS.P1)
48
+
49
+
50
+ # --- JOYSTICK/MOUSE SETUP (retained ADS1115 usage) ---
51
+ events = (uinput.REL_X, uinput.REL_Y, uinput.BTN_LEFT, uinput.BTN_RIGHT, uinput.BTN_MIDDLE)
52
+ try:
53
+ self.device = uinput.Device(events)
54
+ except Exception as e:
55
+ logger.error(
56
+ "UInput device creation failed. Check permissions (sudo or your user "
57
+ f"in the input group setup with udev). Error: {e}"
58
+ )
59
+ exit(1)
60
+
61
+ self.joystick_sw = mcp.get_pin(10) # B2
62
+ self.joystick_sw.direction = Direction.INPUT
63
+ self.joystick_sw.pull = Pull.UP
64
+ self.last_switch_state = True # True = not pressed
65
+ self.last_x = 0
66
+ self.last_y = 0
67
+ self.spike_count_x = 0
68
+ self.spike_count_y = 0
69
+ self.console = Console()
70
+
71
+ self.x_readings = [0.0] * Joystick.SMOOTHING_WINDOW
72
+ self.y_readings = [0.0] * Joystick.SMOOTHING_WINDOW
73
+ self.x_center, self.y_center = self.calibrate_center()
74
+
75
+ def calibrate_center(self, samples: int = 50, delay: float = 0.01):
76
+ """Measure the at-rest center voltage per axis instead of assuming a fixed value.
77
+
78
+ Assumes the joystick is untouched (spring-centered) when this runs, i.e. at
79
+ startup before the poll loop begins. Uses the median to stay robust if it's
80
+ bumped once during the sampling window.
81
+ """
82
+ x_samples = []
83
+ y_samples = []
84
+ for _ in range(samples):
85
+ with self.lock:
86
+ x_samples.append(self.joystick_x_axis.voltage)
87
+ y_samples.append(self.joystick_y_axis.voltage)
88
+ time.sleep(delay)
89
+ x_center = statistics.median(x_samples)
90
+ y_center = statistics.median(y_samples)
91
+ logger.info(f"Joystick calibrated center: x={x_center:.3f}V, y={y_center:.3f}V")
92
+ return x_center, y_center
93
+
94
+ def read_joystick(self):
95
+ """Return normalized X, Y values in range -1.0 .. +1.0 using ADS1115."""
96
+ with self.lock:
97
+ raw_x = self.joystick_x_axis.voltage
98
+ raw_y = self.joystick_y_axis.voltage
99
+
100
+ self.x_readings.pop(0)
101
+ self.x_readings.append(raw_x)
102
+ self.y_readings.pop(0)
103
+ self.y_readings.append(raw_y)
104
+ smoothed_x = statistics.median(self.x_readings)
105
+ smoothed_y = statistics.median(self.y_readings)
106
+
107
+ x = (smoothed_x - self.x_center) / self.x_center
108
+ y = (smoothed_y - self.y_center) / self.y_center
109
+ return max(-1, min(1, x)), max(-1, min(1, y))
110
+
111
+ def calculate_speed(self, x, y):
112
+ # --- 1. Glitch Guard Logic ---
113
+ # If the reading is exactly 1.0 (or -1.0) and we were just at 0
114
+ if abs(x) > 0.99 and abs(self.last_x) < 0.1:
115
+ self.spike_count_x += 1
116
+ if self.spike_count_x < 2: # Ignore the first frame of a max-value spike
117
+ x = 0
118
+ else:
119
+ self.spike_count_x = 0
120
+
121
+ if abs(y) > 0.99 and abs(self.last_y) < 0.1:
122
+ self.spike_count_y += 1
123
+ if self.spike_count_y < 2:
124
+ y = 0
125
+ else:
126
+ self.spike_count_y = 0
127
+
128
+ self.last_x, self.last_y = x, y
129
+
130
+ # --- 2. Axial Dead Zone Logic ---
131
+ # X Axis
132
+ if abs(x) < Joystick.DEAD_ZONE:
133
+ dx = 0
134
+ else:
135
+ norm_x = (abs(x) - Joystick.DEAD_ZONE) / (1.0 - Joystick.DEAD_ZONE)
136
+ dx = math.pow(norm_x, Joystick.POWER_CURVE) * math.copysign(Joystick.SENSITIVITY, x)
137
+
138
+ # Y Axis
139
+ if abs(y) < Joystick.DEAD_ZONE:
140
+ dy = 0
141
+ else:
142
+ norm_y = (abs(y) - Joystick.DEAD_ZONE) / (1.0 - Joystick.DEAD_ZONE)
143
+ dy = math.pow(norm_y, Joystick.POWER_CURVE) * math.copysign(Joystick.SENSITIVITY, y)
144
+
145
+ return dx, dy
146
+
147
+ def poll_joystick(self):
148
+ while True:
149
+ # 1. Joystick Analog Control (Reads from ADS1115)
150
+ x, y = self.read_joystick()
151
+ # logger.debug(f"joystick {x},{y}")
152
+ dx, dy = self.calculate_speed(x, y)
153
+ if dx != 0 or dy != 0:
154
+ # logger.debug(f"joystick move: {dx},{dy}")
155
+ try:
156
+ self.device.emit(uinput.REL_X, int(-dx))
157
+ self.device.emit(uinput.REL_Y, int(-dy))
158
+ except NameError as e: # Handle case where uinput device failed to initialize
159
+ logger.warn(f"Joystick.poll_joystick uinput failure: {e}")
160
+
161
+ # 2. Joystick Button (Reads from MCP23017)
162
+ switch_state = self.joystick_sw.value # True = not pressed
163
+ if switch_state != self.last_switch_state:
164
+ uinput_state = 1 if not switch_state else 0
165
+ # logger.debug(f"joystick button new state: {uinput_state}")
166
+ try:
167
+ self.device.emit(uinput.BTN_MIDDLE, uinput_state)
168
+ except NameError as e:
169
+ logger.error(f"Name error in joystick button: {e}")
170
+
171
+ self.last_switch_state = switch_state
172
+ if self.debug:
173
+ status_text = (
174
+ f"[bold blue]X:[/bold blue] {self.joystick_x_axis.voltage:+5.2f}V ({x:+5.2f}) [dim]dx={dx:+6.2f}[/dim] | "
175
+ f"[bold magenta]Y:[/bold magenta] {self.joystick_y_axis.voltage:+5.2f}V ({y:+5.2f}) [dim]dy={dy:+6.2f}[/dim] | "
176
+ f"[bold yellow]Switch:[/bold yellow] {'Released' if switch_state else 'Pressed'}"
177
+ )
178
+ self.console.print(status_text, end="\r")
179
+ time.sleep(Joystick.LOOP_DELAY)
180
+
181
+
182
+ # === Main ===
183
+ if __name__ == "__main__":
184
+ logging.basicConfig(level=logging.DEBUG)
185
+ # --- HARDWARE INITIALIZATION ---
186
+ i2c = busio.I2C(board.SCL, board.SDA)
187
+ i2c_lock = threading.Lock()
188
+
189
+ # ADS1115 for Joystick (kept as requested)
190
+ ads = ADS.ADS1115(i2c)
191
+
192
+ mcp = MCP23017(i2c, address=0x20)
193
+
194
+ joystick = Joystick(ads, mcp, lock=i2c_lock, debug=True)
195
+ threading.Thread(target=joystick.poll_joystick, daemon=True).start()
196
+
197
+ logger.info("Joystick daemon running.")
198
+ pause()