-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
1519 lines (1333 loc) · 57.6 KB
/
Copy pathapp.py
File metadata and controls
1519 lines (1333 loc) · 57.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KeyAxis - turn a Hall-effect keyboard into analog pedals + a live travel monitor.
Backend for a pywebview desktop app. This module owns ALL hardware:
* Raw HID access to the keyboard's vendor interface (via the `hid` / hidapi package).
* A virtual Xbox 360 gamepad (via the optional `vgamepad` / ViGEmBus package).
The UI is a single embedded HTML page (ui.html, a sibling file) rendered in a native
WebView2 window. The JS side talks to this file exclusively through
`window.pywebview.api.METHOD(...)`, which resolves to the JSON returned by the matching
method on the `Api` class below.
Design notes
------------
* A single background daemon thread ("the reader") owns every byte of HID I/O once the
stream is armed. It reads the ~1000 Hz event-driven travel stream, keeps a
lock-protected snapshot of per-key depth for the UI to poll, and - when pedals are
active - maps configured keys onto virtual-gamepad axes and pushes them immediately.
The UI never sits in the input path; it only polls light-weight state every ~50 ms.
* All HID reads/writes happen on that one thread to avoid cross-thread use of a single
hidapi handle. The main (API) thread only opens/closes the handle while the reader is
stopped, and flips flags the reader observes.
* vgamepad is optional. If it (or ViGEmBus) is missing, the monitor still works fully;
only the pedal-output feature is disabled, with a clear message.
Verified M68 protocol (Redragon M68 / E-YOOSO HZ-68, VID 0x0416 PID 0x7372):
* Use the vendor interface: the hid.enumerate() entry with usage_page == 0xff1b.
* Every write is: h.write(bytes([0x01]) + payload_padded_to_63) -> 64 bytes total.
* ARM (start stream) / DISARM (stop stream) payloads are defined per-profile below.
* A travel frame from h.read(64) (hidapi prepends the report id) looks like:
d[0]=0x01 report id, d[1]=0x21, d[5]=0x03 (travel frame; 0x01 = idle/ack),
d[7]=row, d[8]=col, d[9]=depth in 0..40 (0.1 mm/unit -> 0.0..4.0 mm)
* Always DISARM on stop / disconnect / app close.
"""
import os
import sys
import json
import math
import threading
import time
import traceback
# --- Make stdout UTF-8 safe on Windows even when launched without a real console. ---
try:
sys.stdout.reconfigure(encoding="utf-8") # Python 3.7+
except Exception:
pass
try:
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
# =============================================================================
# Optional / required third-party imports (fail soft where the contract allows).
# =============================================================================
# pywebview is required to actually run the app, but importing app.py for tests
# or tooling should not hard-crash if it is absent.
try:
import webview # pywebview
WEBVIEW_OK = True
except Exception:
webview = None
WEBVIEW_OK = False
# hidapi is required for any real hardware work.
try:
import hid
HID_OK = True
except Exception:
hid = None
HID_OK = False
# vgamepad is OPTIONAL. Importing it does NOT prove ViGEmBus is installed - that is only
# discovered when we actually instantiate a virtual pad, so we probe lazily.
try:
import vgamepad as vg
VGAMEPAD_IMPORT_OK = True
except Exception:
vg = None
VGAMEPAD_IMPORT_OK = False
# keysuppress is our own optional module (Windows low-level key blocking, so a
# pedal key doesn't also type its letter). Guard it: the app runs fine without it.
try:
import keysuppress
except Exception:
keysuppress = None
# =============================================================================
# Device protocol layer - structured so more boards can be added later.
# Only the M68 / HZ-68 is implemented for now.
# =============================================================================
class DeviceProfile:
"""Static description of a supported Hall-effect board and its wire protocol."""
def __init__(self, key, name, vid, pid, usage_page,
arm_payload, disarm_payload, max_depth=40):
self.key = key # short internal id, e.g. "m68"
self.name = name # display name
self.vid = vid
self.pid = pid
self.usage_page = usage_page # vendor interface selector
self.arm_payload = bytes(arm_payload)
self.disarm_payload = bytes(disarm_payload)
self.max_depth = max_depth # depth units at full travel (40 -> 4.0 mm)
# Redragon M68 / E-YOOSO HZ-68. All bytes below are the tested-working sequences.
M68_ARM = [
0x21, 0x00, 0x00, 0x00, 0x18, 0x02,
0x3e, 0x26, 0x3e, 0x1e, 0x1e, 0x1e, 0x3e, 0x1e, 0x1e,
0x3e, 0x1e, 0x3e, 0x2e, 0x10, 0x2e, 0x30, 0x3e,
]
M68_DISARM = [0x21, 0x00, 0x00, 0x00, 0x18, 0x03]
M68_PROFILE = DeviceProfile(
key="m68",
name="Redragon M68 / E-YOOSO HZ-68",
vid=0x0416,
pid=0x7372,
usage_page=0xff1b,
arm_payload=M68_ARM,
disarm_payload=M68_DISARM,
max_depth=40,
)
# Registry of supported boards. Add new DeviceProfile entries here later.
SUPPORTED_PROFILES = [M68_PROFILE]
# =============================================================================
# Paths.
#
# Two different roots, and mixing them up breaks the installed build:
#
# resource_path() - READ-ONLY files shipped with the app (ui.html). When
# frozen by PyInstaller these live in the unpacked bundle
# dir (sys._MEIPASS), which is DELETED when the app exits.
# user_data_path() - files we WRITE (calibration, settings, crash log). These
# must survive restarts and must not need admin rights, so
# once installed they go to %LOCALAPPDATA%\KeyAxis.
#
# Running from source keeps writing next to app.py (handy during development);
# the moment we're frozen, or the app directory isn't writable (Program Files),
# we switch to LOCALAPPDATA and migrate any existing files across once.
# =============================================================================
APP_NAME = "KeyAxis"
def _frozen():
return getattr(sys, "frozen", False)
def resource_path(rel):
"""Absolute path to a read-only file shipped with the app."""
base = getattr(sys, "_MEIPASS", None) \
or os.path.dirname(os.path.abspath(__file__))
return os.path.join(base, rel)
def _app_dir():
"""Directory the app itself lives in (the .exe's folder when frozen)."""
if _frozen():
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.abspath(__file__))
def _dir_writable(path):
try:
probe = os.path.join(path, ".keyaxis_write_test")
with open(probe, "w") as f:
f.write("")
os.remove(probe)
return True
except Exception:
return False
_DATA_DIR = None
def user_data_dir():
"""
Where we store files we write. Cached after the first call.
Frozen (or unwritable app dir) -> %LOCALAPPDATA%\\KeyAxis, else next to app.py.
"""
global _DATA_DIR
if _DATA_DIR is not None:
return _DATA_DIR
here = _app_dir()
if not _frozen() and _dir_writable(here):
_DATA_DIR = here
return _DATA_DIR
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
d = os.path.join(base, APP_NAME)
try:
os.makedirs(d, exist_ok=True)
except Exception:
d = here # last resort; better than crashing
_DATA_DIR = d
_migrate_legacy_data(here, d)
return _DATA_DIR
def _migrate_legacy_data(old_dir, new_dir):
"""
First run of an installed build: copy any calibration/settings that were
written by a from-source run so the user doesn't have to calibrate again.
Never overwrites files already in the new location.
"""
if os.path.abspath(old_dir) == os.path.abspath(new_dir):
return
for fn in ("calibration.json", "settings.json"):
src, dst = os.path.join(old_dir, fn), os.path.join(new_dir, fn)
try:
if os.path.exists(src) and not os.path.exists(dst):
with open(src, "rb") as a, open(dst, "wb") as b:
b.write(a.read())
except Exception:
pass
def user_data_path(filename):
"""Absolute path to one of our writable files."""
return os.path.join(user_data_dir(), filename)
# =============================================================================
# Calibration - learns the true (row,col) + peak depth per physical key NAME.
# =============================================================================
CALIBRATION_FILENAME = "calibration.json"
def _calibration_path():
return user_data_path(CALIBRATION_FILENAME)
def _sanitize_calibration(data, default_max=40):
"""
Coerce an arbitrary calibration payload into a clean
{ name: {"row": int, "col": int, "max": int} } dict.
Accepts either the full { "keys": {...} } envelope or a bare keys dict.
Tolerates junk: skips any entry missing a usable row/col. Returns {} on
anything unusable so the app simply reads as "uncalibrated".
"""
if isinstance(data, dict) and "keys" in data:
raw = data.get("keys")
else:
raw = data
out = {}
if not isinstance(raw, dict):
return out
for name, v in raw.items():
if not isinstance(v, dict):
continue
try:
row = int(v.get("row"))
col = int(v.get("col"))
except (TypeError, ValueError):
continue
try:
mx = int(round(float(v.get("max", default_max))))
except (TypeError, ValueError):
mx = default_max
# Keep max within a sane device range; a 0 or negative max would make a
# pedal divide-to-zero, so floor it at 1 and cap at the device ceiling.
if mx < 1:
mx = default_max
if mx > default_max:
mx = default_max
key = str(name)
if not key:
continue
out[key] = {"row": row, "col": col, "max": mx}
return out
# =============================================================================
# Persistent app settings (window size, last profile, key-suppress toggle) and
# the key-name -> Windows virtual-key map used for key-suppression.
# =============================================================================
SETTINGS_FILENAME = "settings.json"
# suppress defaults OFF: keeping a pedal key multi-function (e.g. W = throttle AND
# a game action) is the better default; suppression is an opt-in toggle.
DEFAULT_SETTINGS = {"window": {"w": 1100, "h": 760},
"suppress": False, "last_profile": None}
def _settings_path():
return user_data_path(SETTINGS_FILENAME)
# Windows virtual-key codes for the UI template key names, so a bound pedal key's
# keystroke can be swallowed. Fn is a hardware key with no VK (never types anyway).
NAME_TO_VK = {}
for _ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789":
NAME_TO_VK[_ch] = ord(_ch)
NAME_TO_VK.update({
"Esc": 0x1B, "Tab": 0x09, "Caps": 0x14, "Enter": 0x0D, "Back": 0x08,
"Space": 0x20, "Del": 0x2E, "PgUp": 0x21, "PgDn": 0x22,
"LShift": 0xA0, "RShift": 0xA1, "LCtrl": 0xA2, "RCtrl": 0xA3,
"LAlt": 0xA4, "RAlt": 0xA5, "LWin": 0x5B,
"Left": 0x25, "Up": 0x26, "Right": 0x27, "Down": 0x28,
"-": 0xBD, "=": 0xBB, "[": 0xDB, "]": 0xDD, "\\": 0xDC,
";": 0xBA, "'": 0xDE, ",": 0xBC, ".": 0xBE, "/": 0xBF, "`": 0xC0,
})
class HidBoard:
"""
Thin wrapper around one open hidapi handle to a board's vendor interface.
All I/O methods are intended to be called from a single thread at a time
(the reader thread once streaming; the API thread only while stopped).
"""
def __init__(self, profile):
self.profile = profile
self._h = None
self._path = None
# -- discovery / lifecycle ------------------------------------------------
@staticmethod
def find_path(profile):
"""Return the bytes path of the vendor interface, or None if not present."""
if not HID_OK:
return None
try:
for d in hid.enumerate(profile.vid, profile.pid):
if d.get("usage_page", 0) == profile.usage_page:
return d.get("path")
except Exception:
return None
return None
def open(self):
"""Open the vendor interface in non-blocking mode. Raises on failure."""
if not HID_OK:
raise RuntimeError("hidapi (the 'hid' package) is not installed.")
path = self.find_path(self.profile)
if not path:
raise RuntimeError("Device not found (vendor interface missing).")
h = hid.device()
h.open_path(path)
h.set_nonblocking(1)
self._h = h
self._path = path
def is_open(self):
return self._h is not None
def reopen(self):
"""
Close and re-open the SAME interface in place (recover from a transient
USB drop). Keeps this HidBoard object valid so the reader's reference and
self._board stay pointed at it. Returns True on success.
"""
try:
if self._h is not None:
try:
self._h.close()
except Exception:
pass
self._h = None
# Prefer the path we already had; if that no longer resolves, re-scan.
path = self._path or self.find_path(self.profile)
h = hid.device()
try:
h.open_path(path)
except Exception:
path = self.find_path(self.profile)
if not path:
return False
h = hid.device()
h.open_path(path)
h.set_nonblocking(1)
self._h = h
self._path = path
return True
except Exception:
self._h = None
return False
def close(self):
h, self._h = self._h, None
self._path = None
if h is not None:
try:
h.close()
except Exception:
pass
# -- framing helpers ------------------------------------------------------
def _send(self, payload):
"""
Prepend report id 0x01, pad payload to 63 bytes -> 64 total, and write.
Returns the number of bytes written (64 on success).
"""
if self._h is None:
raise RuntimeError("Board is not open.")
body = bytes(payload[:63]).ljust(63, b"\x00")
return self._h.write(bytes([0x01]) + body)
def arm(self):
"""Start the travel stream."""
return self._send(self.profile.arm_payload)
def disarm(self):
"""Stop the travel stream."""
return self._send(self.profile.disarm_payload)
def read(self):
"""
Non-blocking read of one 64-byte report. Returns a list of ints
(possibly empty when nothing is pending). Raises on HID error
(e.g. device unplugged).
"""
if self._h is None:
raise RuntimeError("Board is not open.")
return self._h.read(64)
def parse_frame(self, d):
"""
Parse a raw report into (row, col, depth) for a travel frame, else None.
depth is clamped to 0..max_depth.
"""
if not d or len(d) < 10:
return None
if d[0] != 0x01 or d[1] != 0x21:
return None
if d[5] != 0x03: # 0x03 = travel-data frame; 0x01 = idle/ack
return None
row, col, depth = d[7], d[8], d[9]
if depth < 0:
depth = 0
if depth > self.profile.max_depth:
depth = self.profile.max_depth
return row, col, depth
# =============================================================================
# Pedal math (pure function - trivially testable).
# =============================================================================
def pedal_value(depth, max_depth, deadzone, curve, invert):
"""
Map a raw travel depth to a 0..1 axis value.
norm = clamp(depth / max_depth, 0, 1)
if norm < deadzone: 0
else rescale (norm - deadzone) / (1 - deadzone)
then norm ** curve (1.0 = linear, >1 = softer initial bite)
then optional invert (1 - norm)
"""
try:
norm = float(depth) / float(max_depth) if max_depth else 0.0
except Exception:
norm = 0.0
if norm < 0.0:
norm = 0.0
elif norm > 1.0:
norm = 1.0
dz = 0.0
try:
dz = float(deadzone)
except Exception:
dz = 0.0
if dz < 0.0:
dz = 0.0
elif dz > 0.999:
dz = 0.999
if norm < dz:
norm = 0.0
else:
norm = (norm - dz) / (1.0 - dz)
try:
c = float(curve)
except Exception:
c = 1.0
if c > 0 and c != 1.0:
norm = norm ** c
if invert:
norm = 1.0 - norm
# Defense in depth: NaN survives every < / > comparison above (all False),
# so an explicit finiteness check is the only thing that stops a NaN from
# ever reaching vgamepad.
if not math.isfinite(norm):
return 0.0
if norm < 0.0:
norm = 0.0
elif norm > 1.0:
norm = 1.0
return norm
# =============================================================================
# The Api class - the JS<->Python bridge. Every method returns a JSON-able dict.
# =============================================================================
class Api:
"""
All hardware state and the reader thread live here. Every public method is
callable from JS as window.pywebview.api.<method>(...). Methods are written to
be robust and thread-safe: shared state is guarded by locks and no method blocks
the caller for longer than a short thread join.
"""
#: axes we can drive on the virtual pad
VALID_AXES = ("RT", "LT", "LSY", "LSX", "RSY", "RSX")
def __init__(self):
# The active device profile (only M68 for now).
self._profile = M68_PROFILE
self._board = None # HidBoard or None
# Calibration map: physical key NAME -> {"row","col","max"}. Loaded from
# calibration.json next to app.py; empty dict means "uncalibrated".
self._calibration = self._load_calibration()
# Persistent settings (window size, last-used profile, key-suppress toggle).
self._settings = self._load_settings()
# Reader-thread control.
self._thread = None
self._running = False # reader loop should keep going
self._recovering = False # mid self-heal of a transient USB drop
# Feature flags.
self._monitoring = False # travel stream wanted for display
self._pedals_active = False # gamepad output wanted
# Live snapshot for the MONITOR tab. Guarded by _lock.
self._live = {} # "row,col" -> depth 0..40
self._last = {"row": None, "col": None, "depth": 0}
# Pedal configuration + computed values. Guarded by _lock.
self._pedals = [] # list of normalized pedal dicts
self._pedal_values = {} # name -> 0..1
self._axis_values = {a: 0.0 for a in self.VALID_AXES}
# Active key-suppression reasons (see _push_suppression).
self._suppress_pedals = set()
self._suppress_guard = set()
# Virtual gamepad (created lazily when pedals start).
self._gamepad = None
# ViGEmBus probe result, cached after the first real probe so we never
# spawn a throwaway pad twice (each one flickers a connect/disconnect).
# None = not probed yet; otherwise (ok: bool, msg: str).
self._vigem_probe = None
# Locks: _lock guards shared state dicts; _gp_lock serialises gamepad calls;
# _op_lock serialises start/stop/connect operations so flags stay coherent.
self._lock = threading.Lock()
self._gp_lock = threading.Lock()
self._op_lock = threading.Lock()
# pywebview window (set by main() once created) for native file dialogs.
# NOTE: this MUST stay underscore-prefixed. pywebview builds the JS bridge
# by walking dir(api) and recursing into any non-callable attribute; the
# Window object contains .NET/WinForms members, and comparing one of those
# (System.Drawing.Rectangle) against this Api instance raises, which kills
# the whole bridge ("Backend bridge unavailable"). Names starting with "_"
# are skipped by that walk, so keep the underscore.
self._window = None
# ------------------------------------------------------------------ status
def status(self):
"""Overall snapshot for the header/status strip."""
connected = (self._board is not None and self._board.is_open()) \
or self._recovering
# ViGEm usability must match vigem_available(): a live pad proves it; a
# recorded probe failure denies it; otherwise it's usable if vgamepad is
# importable (the definitive test is deferred to start_pedals). Do NOT
# gate on a pad being live here or the header falsely reads "missing".
if self._gamepad is not None:
vigem = True
elif not VGAMEPAD_IMPORT_OK:
vigem = False
elif self._vigem_probe is not None:
vigem = bool(self._vigem_probe[0])
else:
vigem = True
return {
"connected": connected,
"device": self._profile.name if connected else None,
"vigem": vigem,
"monitoring": self._monitoring,
"pedals_active": self._pedals_active,
}
def vigem_available(self):
"""
Report whether virtual-gamepad output is usable.
We deliberately do NOT instantiate a throwaway pad to probe. Creating a
pad spawns a virtual controller (Windows Game Bar popup), and destroying
it would double-free the ViGEm target and crash the process. So we report
based on the library being importable, plus any pad that is already live.
The definitive test happens exactly ONCE in start_pedals(), which creates
the single persistent pad and reports any failure cleanly. This means a
virtual controller only ever appears when the user actually starts pedals.
"""
if not VGAMEPAD_IMPORT_OK:
return {
"ok": False,
"msg": "vgamepad is not installed. Pedal output is disabled. "
"Install with: pip install vgamepad",
}
if self._gamepad is not None:
return {"ok": True, "msg": "ViGEmBus ready (virtual pad active)."}
if self._vigem_probe is not None:
ok, msg = self._vigem_probe
return {"ok": ok, "msg": msg}
return {
"ok": True,
"msg": "vgamepad ready. ViGEmBus is verified when you start pedals.",
}
# -------------------------------------------------------------- connection
def connect(self):
"""Find and open the vendor interface. Idempotent-ish."""
with self._op_lock:
if not HID_OK:
return {"ok": False, "msg": "hidapi is not installed (pip install hid).",
"device": None}
if self._board is not None and self._board.is_open():
return {"ok": True, "msg": "Already connected.",
"device": self._profile.name}
board = HidBoard(self._profile)
try:
board.open()
except Exception as e:
return {"ok": False, "msg": str(e), "device": None}
self._board = board
return {"ok": True, "msg": "Connected.", "device": self._profile.name}
def disconnect(self):
"""Stop the stream and close the handle. Always DISARMs on the way out."""
with self._op_lock:
self._monitoring = False
# Flip under _gp_lock so an in-flight reader push (during the join
# window below) sees the cleared flag and won't leave an axis set.
with self._gp_lock:
self._pedals_active = False
self._stop_stream_locked() # joins reader -> reader DISARMs
if self._board is not None:
self._board.close()
self._board = None
self._release_gamepad() # resets to neutral as last write
with self._lock:
self._live.clear()
self._last = {"row": None, "col": None, "depth": 0}
self._pedal_values.clear()
self._axis_values = {a: 0.0 for a in self.VALID_AXES}
return {"ok": True}
# ------------------------------------------------------------- monitor tab
def start_monitor(self):
"""ARM the stream so the reader fills the live depth map for display."""
with self._op_lock:
if self._board is None or not self._board.is_open():
r = self._connect_locked()
if not r["ok"]:
return {"ok": False, "msg": r["msg"]}
self._monitoring = True
ok, msg = self._ensure_stream_locked()
if not ok:
self._monitoring = False
return {"ok": False, "msg": msg}
return {"ok": True, "msg": "Monitoring travel stream."}
def stop_monitor(self):
"""
Stop display monitoring. The travel stream is LEFT RUNNING while the board
stays connected - tearing it down and re-arming on every tab switch is what
upset this flaky board and caused the random disconnects. The stream is only
stopped on disconnect()/shutdown().
"""
with self._op_lock:
self._monitoring = False
return {"ok": True}
def live(self):
"""Light-weight poll target for the MONITOR tab (~50 ms)."""
with self._lock:
return {
"active": self._running and self._monitoring,
"keys": dict(self._live),
"last": dict(self._last),
}
# -------------------------------------------------------------- pedals tab
# -------------------------------------------------------------- settings
def _load_settings(self):
try:
with open(_settings_path(), "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
out = dict(DEFAULT_SETTINGS)
out.update({k: data[k] for k in data if k in DEFAULT_SETTINGS})
if not isinstance(out.get("window"), dict):
out["window"] = dict(DEFAULT_SETTINGS["window"])
return out
except Exception:
pass
return dict(DEFAULT_SETTINGS)
def _save_settings(self):
try:
with open(_settings_path(), "w", encoding="utf-8") as f:
json.dump(self._settings, f, indent=2)
return True
except Exception:
return False
def get_settings(self):
"""Return the persisted settings (window size, suppress flag, last profile)."""
with self._lock:
return json.loads(json.dumps(self._settings)) # deep copy
def save_settings(self, data):
"""Merge & persist a partial settings update from the UI."""
if not isinstance(data, dict):
return {"ok": False}
with self._lock:
for k in ("suppress", "last_profile", "window"):
if k in data:
self._settings[k] = data[k]
ok = self._save_settings()
return {"ok": bool(ok)}
# ----------------------------------------------------- key-suppression
# There are TWO independent reasons we might swallow a key, and the hook
# takes one flat set - so track them separately and always push the union.
# Otherwise whichever one updates last wipes the other's keys.
# _suppress_pedals - opt-in, the bound pedal keys while pedals are driving
# _suppress_guard - the Windows keys while the calibration wizard is open
VK_LWIN, VK_RWIN = 0x5B, 0x5C
def _push_suppression(self):
"""Apply the union of every active suppression reason."""
if keysuppress is None or not keysuppress.available():
return
vks = set(self._suppress_pedals) | set(self._suppress_guard)
if vks:
keysuppress.ensure_started()
keysuppress.set_suppressed(vks)
def _apply_suppression(self, pedals):
"""If enabled, swallow the bound pedal keys' OS keystrokes while driving."""
vks = set()
if self._settings.get("suppress", False):
for p in pedals:
name = p.get("key")
vk = NAME_TO_VK.get(name) if name else None
if vk:
vks.add(vk)
self._suppress_pedals = vks
self._push_suppression()
def _clear_suppression(self):
self._suppress_pedals = set()
self._push_suppression()
def calibration_guard(self, on=True):
"""
Swallow the Windows keys while the calibration wizard is open.
Pressing Win during calibration pops the Start menu over the app and
steals focus mid-wizard. The wizard has to see the key press to learn
it, and it still does - KeyAxis reads travel over HID, which the hook
never touches. We only stop Windows acting on the keystroke.
Always released when the wizard closes, and again on shutdown().
"""
self._suppress_guard = {self.VK_LWIN, self.VK_RWIN} if on else set()
self._push_suppression()
return {"ok": True, "guarded": bool(self._suppress_guard)}
def start_pedals(self, cfg):
"""
Begin driving the virtual gamepad from the configured pedals.
cfg = { "pedals": [ {name, axis, row, col, deadzone, curve, invert}, ... ] }
"""
with self._op_lock:
pedals, err = self._normalize_pedals(cfg)
if err:
return {"ok": False, "msg": err}
if not pedals:
return {"ok": False, "msg": "No pedals configured."}
# Ensure device.
if self._board is None or not self._board.is_open():
r = self._connect_locked()
if not r["ok"]:
return {"ok": False, "msg": r["msg"]}
# Ensure a virtual gamepad exists. Do NOT probe with
# vigem_available() first - that would spawn an extra throwaway pad
# (and its flicker). Just try to create the ONE real pad we keep;
# a creation failure here IS the "ViGEmBus missing" signal.
if self._gamepad is None:
if not VGAMEPAD_IMPORT_OK:
return {
"ok": False,
"msg": "vgamepad is not installed. Pedal output is "
"disabled. Install with: pip install vgamepad",
}
try:
self._gamepad = vg.VX360Gamepad()
except Exception as e:
self._gamepad = None
# Cache the negative result so later vigem_available()
# calls don't re-probe and flicker again.
self._vigem_probe = (
False,
"vgamepad is installed but ViGEmBus was not found. "
"Install the ViGEmBus driver, then reconnect. (%s)" % e,
)
return {"ok": False,
"msg": "Could not create virtual gamepad - ViGEmBus "
"missing or not running. (%s)" % e}
# Install config + reset axis state to neutral.
with self._lock:
self._pedals = pedals
self._pedal_values = {p["name"]: 0.0 for p in pedals}
self._axis_values = {a: 0.0 for a in self.VALID_AXES}
self._reset_gamepad()
self._pedals_active = True
ok, msg = self._ensure_stream_locked()
if not ok:
self._pedals_active = False
return {"ok": False, "msg": msg}
# Optionally swallow the bound keys' typing while driving.
self._apply_suppression(pedals)
return {"ok": True,
"msg": "Pedals active: %s" % ", ".join(p["name"] for p in pedals)}
def stop_pedals(self):
"""Stop gamepad output and centre all axes. Keeps monitoring if requested."""
with self._op_lock:
# Flip the flag, zero the cached values, and push the neutral reset
# all under _gp_lock. Because the reader also pushes under _gp_lock
# and re-checks _pedals_active there, it cannot interleave a non-zero
# axis after this reset - the reset is guaranteed to be the LAST
# gamepad write, so no axis can be left stuck.
with self._gp_lock:
self._pedals_active = False
with self._lock:
self._pedal_values = {p["name"]: 0.0 for p in self._pedals}
self._axis_values = {a: 0.0 for a in self.VALID_AXES}
self._reset_gamepad_locked()
self._clear_suppression()
# Leave the stream running while connected (see stop_monitor). It stops
# only on disconnect()/shutdown(), so tab/mode switches never re-arm.
return {"ok": True}
def pedal_state(self):
"""Poll target for the live pedal gauges (~50 ms)."""
with self._lock:
return {
"active": self._pedals_active and self._running,
"values": dict(self._pedal_values),
}
# ---------------------------------------------------------- profile I/O
def save_profile(self, cfg):
"""Write the pedal config to a user-chosen file via the native save dialog."""
try:
pedals, err = self._normalize_pedals(cfg)
if err:
return {"ok": False, "msg": err}
payload = {
"app": "KeyAxis",
"version": 1,
"device": self._profile.key,
"pedals": pedals,
}
path = self._save_dialog("keyaxis-profile.json")
if not path:
return {"ok": False, "msg": "Save cancelled."}
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
return {"ok": True, "path": path}
except Exception as e:
return {"ok": False, "msg": "Save failed: %s" % e}
def load_profile(self):
"""Read a pedal config from a user-chosen file via the native open dialog."""
try:
path = self._open_dialog()
if not path:
return {"ok": False, "msg": "Load cancelled."}
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
pedals, err = self._normalize_pedals(data)
if err:
return {"ok": False, "msg": err}
return {"ok": True, "cfg": {"pedals": pedals}}
except Exception as e:
return {"ok": False, "msg": "Load failed: %s" % e}
# ---------------------------------------------------------- calibration
def get_calibration(self):
"""
Return the learned key map for the UI.
{ "done": bool, "keys": { name: {"row","col","max"} } }
'done' is True once at least one key has been calibrated. The UI uses the
map to label the board, light the correct cell, drive binding, and scale
pedals; when 'done' is False it should guide the user to calibrate.
"""
with self._lock:
keys = {k: dict(v) for k, v in self._calibration.items()}
return {"done": bool(keys), "keys": keys}
def save_calibration(self, data):
"""
Persist a calibration map to calibration.json and update it in memory.
'data' may be the full { "keys": {...} } envelope or a bare keys dict;
each entry needs a usable row/col and (optionally) a peak 'max'. Junk
entries are skipped rather than rejecting the whole save.
"""
try:
clean = _sanitize_calibration(data, self._profile.max_depth)
payload = {
"app": "KeyAxis",
"version": 1,
"device": self._profile.key,
"keys": clean,
}
with open(_calibration_path(), "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
with self._lock:
self._calibration = clean
return {"ok": True, "keys": clean}
except Exception as e:
return {"ok": False, "msg": "Save failed: %s" % e}
def last_key(self):
"""
Convenience accessor for the most recent travel frame, for the
calibration wizard's capture loop: { "row","col","depth" }.
"""
with self._lock:
return dict(self._last)
# =========================================================================
# Internal helpers (not part of the JS contract).
# =========================================================================
def _load_calibration(self):
"""Read calibration.json next to app.py. Missing/corrupt -> {} (uncalibrated)."""
try:
with open(_calibration_path(), "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
return {}
try:
return _sanitize_calibration(data, self._profile.max_depth)
except Exception:
return {}
def _connect_locked(self):
"""connect() body assuming _op_lock is already held."""
if not HID_OK: