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 +3 -0
- riffpi/cli.py +33 -0
- riffpi/daemon.py +246 -0
- riffpi/expression_pedal.py +88 -0
- riffpi/joystick.py +198 -0
- riffpi/keypad.py +169 -0
- riffpi/mcp_button.py +27 -0
- riffpi/mcp_led.py +22 -0
- riffpi/rotary_encoder.py +168 -0
- riffpi/service.py +92 -0
- riffpi-0.2.4.dist-info/METADATA +96 -0
- riffpi-0.2.4.dist-info/RECORD +15 -0
- riffpi-0.2.4.dist-info/WHEEL +4 -0
- riffpi-0.2.4.dist-info/entry_points.txt +2 -0
- riffpi-0.2.4.dist-info/licenses/LICENSE.md +313 -0
riffpi/__init__.py
ADDED
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()
|