padsquire 0.1.0__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.
Files changed (46) hide show
  1. padsquire/__init__.py +4 -0
  2. padsquire/__main__.py +7 -0
  3. padsquire/ble.py +125 -0
  4. padsquire/cli.py +233 -0
  5. padsquire/cli_ble.py +218 -0
  6. padsquire/cli_probe.py +88 -0
  7. padsquire/cli_uboot.py +303 -0
  8. padsquire/client.py +191 -0
  9. padsquire/data/70-gamesir.rules +18 -0
  10. padsquire/data/padsquire.svg +11 -0
  11. padsquire/desktop.py +110 -0
  12. padsquire/gcm.py +524 -0
  13. padsquire/hid.py +203 -0
  14. padsquire/installer.py +187 -0
  15. padsquire/jlboot.py +353 -0
  16. padsquire/jlcrypt.py +74 -0
  17. padsquire/jlflash.py +199 -0
  18. padsquire/jlwrite.py +152 -0
  19. padsquire/liveinput.py +148 -0
  20. padsquire/paths.py +81 -0
  21. padsquire/probe.py +372 -0
  22. padsquire/safety.py +182 -0
  23. padsquire/simulator.py +123 -0
  24. padsquire/ufw.py +228 -0
  25. padsquire/updatemode.py +328 -0
  26. padsquire/updater.py +206 -0
  27. padsquire/web/__init__.py +2 -0
  28. padsquire/web/server.py +695 -0
  29. padsquire/web/setup.py +196 -0
  30. padsquire/web/static/app.js +1360 -0
  31. padsquire/web/static/fonts/OFL.txt +93 -0
  32. padsquire/web/static/fonts/barlow-condensed-latin-600-normal.woff2 +0 -0
  33. padsquire/web/static/fonts/barlow-condensed-latin-700-normal.woff2 +0 -0
  34. padsquire/web/static/fonts/barlow-latin-400-normal.woff2 +0 -0
  35. padsquire/web/static/fonts/barlow-latin-500-normal.woff2 +0 -0
  36. padsquire/web/static/fonts/barlow-latin-600-normal.woff2 +0 -0
  37. padsquire/web/static/index.html +119 -0
  38. padsquire/web/static/style.css +612 -0
  39. padsquire/web/update.py +326 -0
  40. padsquire-0.1.0.dist-info/METADATA +206 -0
  41. padsquire-0.1.0.dist-info/RECORD +46 -0
  42. padsquire-0.1.0.dist-info/WHEEL +5 -0
  43. padsquire-0.1.0.dist-info/entry_points.txt +2 -0
  44. padsquire-0.1.0.dist-info/licenses/LICENSE +661 -0
  45. padsquire-0.1.0.dist-info/licenses/LICENSES/CC-BY-SA-4.0.txt +428 -0
  46. padsquire-0.1.0.dist-info/top_level.txt +1 -0
padsquire/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Linux tools for GameSir controllers (Nova 2 Lite first)."""
3
+
4
+ __version__ = "0.1.0"
padsquire/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """`python -m padsquire`: the same as the padsquire command."""
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
padsquire/ble.py ADDED
@@ -0,0 +1,125 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Bluetooth LE transport for the GCM protocol, via bleak (BlueZ on Linux).
3
+
4
+ What the Android app does for a Nova 2 Lite (BluetoothLeService):
5
+ - finds the BLE config identity "GameSir-Nova 2 Lite_xxxx" (not "_DFU")
6
+ - service 0x8650; enables notifications on 0x865F and 0x8655
7
+ - writes each command to 0x865F (write with response)
8
+ - waits ~60 ms (bleWriteAfterReadIntervalTime) and reads the reply from 0x865F
9
+
10
+ ASSUMPTION: replies are read from 0x865F rather than delivered by notification
11
+ (the Nova 2 Lite config sets bleIsNotifyCallback = false). If hardware proves
12
+ otherwise, NOTIFY_REPLIES switches to notification mode.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from typing import Callable
19
+
20
+ SERVICE_UUID = "00008650-0000-1000-8000-00805f9b34fb"
21
+ COMMAND_UUID = "0000865f-0000-1000-8000-00805f9b34fb"
22
+ DATA_UUID = "00008655-0000-1000-8000-00805f9b34fb"
23
+ NAME_PREFIX = "GameSir-Nova 2 Lite_"
24
+ DFU_NAME = "GameSir-Nova 2 Lite_DFU"
25
+
26
+ READ_DELAY = 0.06
27
+ READ_ATTEMPTS = 4
28
+ NOTIFY_REPLIES = False
29
+
30
+
31
+ class BleError(RuntimeError):
32
+ pass
33
+
34
+
35
+ def _bleak():
36
+ try:
37
+ import bleak
38
+ except ImportError:
39
+ raise BleError("Bluetooth support needs bleak: pip install 'padsquire[ble]'") from None
40
+ return bleak
41
+
42
+
43
+ def is_config_name(name: str | None) -> bool:
44
+ return bool(name) and name.startswith(NAME_PREFIX) and name != DFU_NAME
45
+
46
+
47
+ async def scan(timeout: float = 6.0) -> list[tuple[str, str]]:
48
+ """(address, name) of every Nova 2 Lite config channel in range."""
49
+ bleak = _bleak()
50
+ try:
51
+ found = await bleak.BleakScanner.discover(timeout=timeout)
52
+ except FileNotFoundError:
53
+ raise BleError("Bluetooth is unavailable: is bluetoothd running? "
54
+ "(systemctl status bluetooth)") from None
55
+ except Exception as e:
56
+ raise BleError(f"Bluetooth scan failed: {e or type(e).__name__} "
57
+ "(is the adapter powered on? bluetoothctl power on)") from e
58
+ return sorted((d.address, d.name) for d in found if is_config_name(d.name))
59
+
60
+
61
+ class BleTransport:
62
+ """Wraps a connected bleak client (or anything with the same methods)."""
63
+
64
+ def __init__(self, client, read_delay: float = READ_DELAY, attempts: int = READ_ATTEMPTS):
65
+ self.client = client
66
+ self.read_delay = read_delay
67
+ self.attempts = attempts
68
+ self._notified: asyncio.Queue[bytes] = asyncio.Queue()
69
+ self._lock = asyncio.Lock()
70
+
71
+ async def start(self) -> None:
72
+ # The app subscribes to both characteristics; some firmware only answers
73
+ # once that has happened. Failures here are not fatal.
74
+ for uuid in (COMMAND_UUID, DATA_UUID):
75
+ try:
76
+ await self.client.start_notify(uuid, self._on_notify if uuid == COMMAND_UUID else _ignore)
77
+ except Exception:
78
+ pass
79
+
80
+ def _on_notify(self, _sender, data: bytearray) -> None:
81
+ if NOTIFY_REPLIES:
82
+ self._notified.put_nowait(bytes(data))
83
+
84
+ async def request(self, frame: bytes, accept: Callable[[bytes], bool] | None = None) -> bytes:
85
+ accept = accept or (lambda _r: True)
86
+ async with self._lock:
87
+ await self.client.write_gatt_char(COMMAND_UUID, frame, response=True)
88
+ last = b""
89
+ for _ in range(self.attempts):
90
+ await asyncio.sleep(self.read_delay)
91
+ if NOTIFY_REPLIES:
92
+ last = await asyncio.wait_for(self._notified.get(), timeout=1.0)
93
+ else:
94
+ last = bytes(await self.client.read_gatt_char(COMMAND_UUID))
95
+ if accept(last):
96
+ return last
97
+ raise BleError(f"no valid reply to {frame.hex(' ')} (last read: {last.hex(' ') or 'nothing'})")
98
+
99
+
100
+ def _ignore(_sender, _data) -> None:
101
+ pass
102
+
103
+
104
+ class connect:
105
+ """async with connect(address) as transport: ..."""
106
+
107
+ def __init__(self, address: str, timeout: float = 15.0):
108
+ self.address, self.timeout = address, timeout
109
+
110
+ async def __aenter__(self) -> BleTransport:
111
+ bleak = _bleak()
112
+ self._client = bleak.BleakClient(self.address, timeout=self.timeout)
113
+ try:
114
+ await self._client.connect()
115
+ except FileNotFoundError:
116
+ raise BleError("Bluetooth is unavailable: is bluetoothd running? "
117
+ "(systemctl status bluetooth)") from None
118
+ except Exception as e:
119
+ raise BleError(f"could not connect to {self.address}: {e or type(e).__name__}") from e
120
+ transport = BleTransport(self._client)
121
+ await transport.start()
122
+ return transport
123
+
124
+ async def __aexit__(self, *exc) -> None:
125
+ await self._client.disconnect()
padsquire/cli.py ADDED
@@ -0,0 +1,233 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """PadSquire command line."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from . import __version__, installer, jlflash, paths, safety, ufw
13
+
14
+ DEFAULT_FW_DIR = paths.FIRMWARE_DIR
15
+
16
+
17
+ def cmd_extract(args) -> int:
18
+ try:
19
+ manifest = installer.extract(args.installer, args.output, model=args.model)
20
+ except (installer.ExtractError, OSError) as e:
21
+ print(f"error: {e}", file=sys.stderr)
22
+ return 1
23
+ print(f"GameSir Connect {manifest['gamesir_connect_version']}: "
24
+ f"extracted {len(manifest['firmware'])} firmware files to {args.output}")
25
+ for fw in manifest["firmware"]:
26
+ region = f", {fw['region']}" if fw["region"] else ""
27
+ print(f" {fw['target']:10} v{fw['version']}{region:15} {fw['file']}")
28
+ return 0
29
+
30
+
31
+ def cmd_update(args) -> int:
32
+ from . import updater
33
+
34
+ shown = []
35
+
36
+ def progress(done, total):
37
+ if sys.stderr.isatty():
38
+ pct = f"{done * 100 // total}%" if total else f"{done >> 20} MB"
39
+ print(f"\rdownloading GameSir Connect... {pct}", end="", file=sys.stderr, flush=True)
40
+ shown.append(True)
41
+
42
+ try:
43
+ r = updater.update(args.firmware_dir, feed_url=args.feed, check_only=args.check,
44
+ force=args.force, keep_installer=args.keep_installer, progress=progress)
45
+ except (updater.UpdateError, installer.ExtractError, OSError) as e:
46
+ print(("\n" if shown else "") + f"error: {e}", file=sys.stderr)
47
+ return 1
48
+ if shown:
49
+ print(file=sys.stderr)
50
+ have = r.current or "none"
51
+ if r.updated:
52
+ print(f"updated firmware from GameSir Connect {have} to {r.latest}")
53
+ for fw in r.manifest["firmware"]:
54
+ region = f", {fw['region']}" if fw["region"] else ""
55
+ print(f" {fw['target']:10} v{fw['version']}{region:15} {fw['file']}")
56
+ elif updater.version_key(r.latest) > updater.version_key(r.current):
57
+ print(f"GameSir Connect {r.latest} is available (you have {have}); "
58
+ "run `padsquire update` to fetch it")
59
+ else:
60
+ print(f"up to date (GameSir Connect {have})")
61
+ return 0
62
+
63
+
64
+ def cmd_gui(args) -> int:
65
+ try:
66
+ from .web.server import serve
67
+ except ImportError:
68
+ print("the GUI needs aiohttp: pip install --user 'padsquire[gui]'", file=sys.stderr)
69
+ return 1
70
+ serve(args.firmware_dir, port=args.port, simulate=args.simulate, open_browser=not args.no_browser,
71
+ tab=args.tab, keep_running=args.keep_running or args.no_browser)
72
+ return 0
73
+
74
+
75
+ def cmd_menu(args) -> int:
76
+ from . import desktop
77
+ if args.remove:
78
+ gone = desktop.remove_entry()
79
+ print("Removed " + ", ".join(map(str, gone)) if gone else "PadSquire wasn't in the app menu.")
80
+ return 0
81
+ for f in desktop.install_entry():
82
+ print(f"Wrote {f}")
83
+ print("PadSquire is in your app menu. To take it out again: padsquire menu --remove")
84
+ return 0
85
+
86
+
87
+ def cmd_udev_rules(args) -> int:
88
+ from . import desktop
89
+ if args.path:
90
+ print(desktop.rules_path())
91
+ else:
92
+ sys.stdout.write(desktop.rules_text())
93
+ return 0
94
+
95
+
96
+ def cmd_ufw(args) -> int:
97
+ try:
98
+ f = ufw.load(args.file)
99
+ except (ufw.UfwError, OSError) as e:
100
+ print(f"error: {e}", file=sys.stderr)
101
+ return 1
102
+ problems = f.problems()
103
+ if args.json:
104
+ print(json.dumps({
105
+ "chip": f.chip, "jl_pid": f.jl_pid, "jl_vid": f.jl_vid,
106
+ "tool_version": f.tool_version, "size": len(f.data), "ok": not problems,
107
+ "problems": problems,
108
+ "entries": [{"name": e.name, "type": e.type, "offset": e.offset,
109
+ "length": e.length, "crc_ok": e.crc_ok} for e in f.entries],
110
+ }, indent=2))
111
+ else:
112
+ print(f"chip {f.chip}")
113
+ print(f"identity PID={f.jl_pid} VID={f.jl_vid}")
114
+ print(f"built by {f.tool_version}")
115
+ print(f"size {len(f.data)} bytes, {len(f.entries)} partitions")
116
+ for e in f.entries:
117
+ mark = "ok " if e.crc_ok else "BAD"
118
+ print(f" [{mark}] {e.name:16} offset {e.offset:#09x} {e.length:>8} bytes")
119
+ print("verdict " + ("intact" if not problems else "DAMAGED: " + "; ".join(problems)))
120
+ return 0 if not problems else 1
121
+
122
+
123
+ def cmd_plan(args) -> int:
124
+ try:
125
+ bundled = installer.load_manifest(args.firmware_dir)
126
+ except (OSError, KeyError, ValueError) as e:
127
+ print(f"error: cannot read manifest in {args.firmware_dir}: {e}", file=sys.stderr)
128
+ return 1
129
+ device = safety.DeviceState(args.target, args.pid, args.vid, args.version)
130
+ if not (args.pid and args.vid):
131
+ print("BLOCKED device identity unknown (--pid/--vid); refusing to guess")
132
+ return 2
133
+ fw = safety.choose_firmware(device, bundled)
134
+ if fw is None:
135
+ print("no matching firmware in this release for that device")
136
+ return 1
137
+ plan = safety.plan_update(
138
+ device, fw, Path(args.firmware_dir) / fw.file,
139
+ dongle_version=args.dongle_version, bundled=bundled, reinstall=args.reinstall,
140
+ )
141
+ print(f"candidate {fw.file} (v{fw.version})")
142
+ errors = list(plan.errors)
143
+ if args.backup is None:
144
+ errors.append("no flash backup of this controller (--backup, from `padsquire uboot backup`)")
145
+ else:
146
+ image = safety.choose_flash_image(Path(args.firmware_dir) / fw.file, args.backup)
147
+ errors += image.errors
148
+ if image.partition:
149
+ print(f"image {image.partition} -> {image.start:#x}-{image.end - 1:#x}; keeps "
150
+ + ", ".join(jlflash.RESERVED))
151
+ for w in plan.warnings:
152
+ print(f"warning {w}")
153
+ for e in errors:
154
+ print(f"BLOCKED {e}")
155
+ print("verdict " + ("would flash" if not errors else "will not flash"))
156
+ return 0 if not errors else 2
157
+
158
+
159
+ def main(argv=None) -> int:
160
+ if os.environ.get("PADSQUIRE_DEBUG"):
161
+ import logging
162
+ logging.basicConfig(level=logging.DEBUG, format="%(asctime)s.%(msecs)03d %(name)s %(message)s",
163
+ datefmt="%H:%M:%S")
164
+ for line in paths.migrate(): # the folders of the old name, gamesir-linux, once
165
+ print(f"PadSquire: {line}", file=sys.stderr)
166
+ p = argparse.ArgumentParser(prog="padsquire", description="PadSquire: Linux tools for GameSir controllers")
167
+ p.add_argument("--version", action="version", version=__version__)
168
+ sub = p.add_subparsers(dest="cmd", required=True)
169
+
170
+ e = sub.add_parser("extract", help="pull firmware out of a GameSir Connect installer")
171
+ e.add_argument("installer", help="GameSir Connect Setup .exe")
172
+ e.add_argument("-o", "--output", type=Path, default=DEFAULT_FW_DIR)
173
+ e.add_argument("--model", default="T4N2", help="GameSir model code (T4N2 = Nova 2 Lite)")
174
+ e.set_defaults(func=cmd_extract)
175
+
176
+ from .updater import FEED_URL
177
+ up = sub.add_parser("update", help="check for new official firmware and download it")
178
+ up.add_argument("--check", action="store_true", help="only report whether a newer release exists")
179
+ up.add_argument("--force", action="store_true", help="re-download even if already current")
180
+ up.add_argument("--firmware-dir", type=Path, default=DEFAULT_FW_DIR)
181
+ up.add_argument("--keep-installer", type=Path, metavar="PATH", help="also save the installer here")
182
+ up.add_argument("--feed", default=FEED_URL, help=argparse.SUPPRESS)
183
+ up.set_defaults(func=cmd_update)
184
+
185
+ u = sub.add_parser("ufw", help="inspect and verify a .ufw firmware file")
186
+ u.add_argument("file")
187
+ u.add_argument("--json", action="store_true")
188
+ u.set_defaults(func=cmd_ufw)
189
+
190
+ pl = sub.add_parser(
191
+ "plan", help="dry run: would this device be updated, and with what?",
192
+ description="Until device detection exists, describe the device by hand.",
193
+ )
194
+ pl.add_argument("--firmware-dir", type=Path, default=DEFAULT_FW_DIR)
195
+ pl.add_argument("--target", choices=["controller", "dongle"], required=True)
196
+ pl.add_argument("--pid", help="JieLi PID string, e.g. AC695X_TRANS")
197
+ pl.add_argument("--vid", help="JieLi VID string, e.g. N2LT")
198
+ pl.add_argument("--version", type=int, help="installed firmware version, e.g. 8101")
199
+ pl.add_argument("--dongle-version", type=int)
200
+ pl.add_argument("--reinstall", action="store_true")
201
+ pl.add_argument("--backup", type=Path, help="this controller's flash backup (.bin with its .json)")
202
+ pl.set_defaults(func=cmd_plan)
203
+
204
+ from . import cli_ble, cli_probe, cli_uboot
205
+ cli_ble.register(sub)
206
+ cli_probe.register(sub)
207
+ cli_uboot.register(sub)
208
+
209
+ g = sub.add_parser("gui", help="open the graphical settings app in your browser")
210
+ g.add_argument("--simulate", action="store_true", help="start connected to a simulated controller")
211
+ g.add_argument("--port", type=int, default=0, help="port on 127.0.0.1 (default: any free port)")
212
+ g.add_argument("--no-browser", action="store_true",
213
+ help="print the address instead of opening it (keeps running until Ctrl+C)")
214
+ g.add_argument("--tab", action="store_true", help="open in a browser tab instead of its own window")
215
+ g.add_argument("--keep-running", action="store_true",
216
+ help="don't stop when the app's window is closed")
217
+ g.add_argument("--firmware-dir", type=Path, default=DEFAULT_FW_DIR)
218
+ g.set_defaults(func=cmd_gui)
219
+
220
+ m = sub.add_parser("menu", help="add PadSquire to your app menu (no root needed)")
221
+ m.add_argument("--remove", action="store_true", help="take it out of the app menu again")
222
+ m.set_defaults(func=cmd_menu)
223
+
224
+ r = sub.add_parser("udev-rules", help="print the udev rules that let you use the controller without sudo")
225
+ r.add_argument("--path", action="store_true", help="print where the rules file is instead")
226
+ r.set_defaults(func=cmd_udev_rules)
227
+
228
+ args = p.parse_args(argv)
229
+ return args.func(args)
230
+
231
+
232
+ if __name__ == "__main__":
233
+ sys.exit(main())
padsquire/cli_ble.py ADDED
@@ -0,0 +1,218 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """`padsquire ble ...` and `padsquire usb ...`: configure a Nova 2 Lite over
3
+ Bluetooth LE or the USB cable. Both speak GCM, so they share every action."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import asyncio
9
+ import contextlib
10
+ import sys
11
+
12
+ from . import ble, hid
13
+ from .client import NotApplied, NovaClient
14
+ from .gcm import DeadZone, Feature, ProtocolError, Vibration
15
+ from .simulator import SimulatedNova
16
+
17
+
18
+ def _names(enum) -> dict[str, object]:
19
+ return {m.name.lower().replace("_", "-"): m for m in enum}
20
+
21
+
22
+ ZONES = _names(DeadZone)
23
+ FEATURES = _names(Feature)
24
+ ON_OFF = {"on": True, "off": False, "true": True, "false": False, "1": True, "0": False}
25
+
26
+
27
+ def _byte(text: str) -> int:
28
+ v = int(text)
29
+ if not 0 <= v <= 255:
30
+ raise argparse.ArgumentTypeError("must be 0-255")
31
+ return v
32
+
33
+
34
+ @contextlib.asynccontextmanager
35
+ async def _open(args):
36
+ if args.simulate:
37
+ yield NovaClient(SimulatedNova())
38
+ return
39
+ if args.link == "usb":
40
+ async with hid.connect(_usb_path(args)) as transport:
41
+ yield NovaClient(transport)
42
+ return
43
+ address = args.address
44
+ if not address:
45
+ found = await ble.scan()
46
+ if not found:
47
+ raise ble.BleError(
48
+ "no Nova 2 Lite found. Put it in Bluetooth mode and pair it, or pass --address")
49
+ if len(found) > 1:
50
+ raise ble.BleError("several found, pick one with --address: "
51
+ + ", ".join(f"{a} ({n})" for a, n in found))
52
+ address = found[0][0]
53
+ async with ble.connect(address) as transport:
54
+ yield NovaClient(transport)
55
+
56
+
57
+ def _usb_path(args) -> str:
58
+ if args.device:
59
+ return args.device
60
+ found = hid.find()
61
+ if not found:
62
+ raise hid.HidError("no Nova 2 Lite found on USB. Connect the cable and switch to PC/Xbox "
63
+ "mode (hold Menu + View until the LED is green), or pass --device")
64
+ if len(found) > 1:
65
+ raise hid.HidError("several found, pick one with --device: "
66
+ + ", ".join(f"{d.path} ({d.link})" for d in found))
67
+ return found[0].path
68
+
69
+
70
+ # -- actions -------------------------------------------------------------------
71
+
72
+ async def a_scan(args) -> int:
73
+ if args.link == "usb" and not args.simulate:
74
+ found = hid.find()
75
+ for d in found:
76
+ print(f"{d.path} GameSir Nova 2 Lite ({d.link})")
77
+ if not found:
78
+ print("nothing found (is the cable in, and the LED green?)", file=sys.stderr)
79
+ return 0 if found else 1
80
+ if args.simulate:
81
+ print("00:00:00:00:00:00 GameSir-Nova 2 Lite_SIM (simulated)")
82
+ return 0
83
+ found = await ble.scan(args.timeout)
84
+ for address, name in found:
85
+ print(f"{address} {name}")
86
+ if not found:
87
+ print("nothing found (is the controller in Bluetooth mode?)", file=sys.stderr)
88
+ return 0 if found else 1
89
+
90
+
91
+ async def a_info(args) -> int:
92
+ async with _open(args) as nova:
93
+ info = await nova.info()
94
+ bat = await nova.battery()
95
+ print(f"firmware {info.firmware_version}")
96
+ print(f"hardware {info.hardware_version}")
97
+ print(f"MAC {info.mac}")
98
+ print(f"made {info.production_date}")
99
+ print(f"protocol GCM {info.gcm_version}")
100
+ print(f"battery {bat.level}%{' (charging)' if bat.charging else ''} [raw {bat.raw:#04x}]")
101
+ return 0
102
+
103
+
104
+ async def a_deadzone(args) -> int:
105
+ async with _open(args) as nova:
106
+ if args.zone is None:
107
+ for name, zone in ZONES.items():
108
+ print(f"{name:22} {await nova.dead_zone(zone)}")
109
+ elif args.value is None:
110
+ print(await nova.dead_zone(ZONES[args.zone]))
111
+ else:
112
+ await nova.set_dead_zone(ZONES[args.zone], args.value)
113
+ print(f"{args.zone} = {args.value}")
114
+ return 0
115
+
116
+
117
+ async def a_feature(args) -> int:
118
+ async with _open(args) as nova:
119
+ if args.name is None:
120
+ for name, feat in FEATURES.items():
121
+ print(f"{name:22} {'on' if await nova.feature(feat) else 'off'}")
122
+ elif args.state is None:
123
+ print("on" if await nova.feature(FEATURES[args.name]) else "off")
124
+ else:
125
+ await nova.set_feature(FEATURES[args.name], ON_OFF[args.state])
126
+ print(f"{args.name} = {args.state}")
127
+ return 0
128
+
129
+
130
+ VIB_FIELDS = ["left_grip", "left_trigger", "right_grip", "right_trigger"]
131
+
132
+
133
+ async def a_vibration(args) -> int:
134
+ async with _open(args) as nova:
135
+ v = await nova.vibration()
136
+ changes = {f: getattr(args, f) for f in VIB_FIELDS if getattr(args, f) is not None}
137
+ if changes:
138
+ v = Vibration(**{**v.__dict__, **changes})
139
+ await nova.set_vibration(v)
140
+ for f in VIB_FIELDS:
141
+ print(f"{f.replace('_', '-'):15} {getattr(v, f)}")
142
+ return 0
143
+
144
+
145
+ async def a_sleep(args) -> int:
146
+ async with _open(args) as nova:
147
+ if args.minutes is not None:
148
+ await nova.set_auto_sleep(args.minutes)
149
+ print(f"auto sleep: {await nova.auto_sleep()}")
150
+ return 0
151
+
152
+
153
+ async def a_restore(args) -> int:
154
+ if not args.yes:
155
+ print("this resets every setting on the controller; add --yes to confirm", file=sys.stderr)
156
+ return 2
157
+ async with _open(args) as nova:
158
+ await nova.restore_defaults()
159
+ print("restored factory defaults")
160
+ return 0
161
+
162
+
163
+ # -- wiring --------------------------------------------------------------------
164
+
165
+ def register(sub) -> None:
166
+ p = sub.add_parser("ble", help="configure the controller over Bluetooth LE")
167
+ p.add_argument("--address", help="Bluetooth address (default: scan for one)")
168
+ _add_actions(p, "ble")
169
+
170
+ p = sub.add_parser("usb", help="configure the controller over the USB cable (green LED mode)")
171
+ p.add_argument("--device", help="hidraw node, e.g. /dev/hidraw11 (default: find it)")
172
+ _add_actions(p, "usb")
173
+
174
+
175
+ def _add_actions(p, link: str) -> None:
176
+ p.add_argument("--simulate", action="store_true", help="talk to a simulated controller")
177
+ p.set_defaults(link=link, address=None, device=None)
178
+ acts = p.add_subparsers(dest="action", required=True)
179
+
180
+ s = acts.add_parser("scan", help="list connected Nova 2 Lite controllers"
181
+ if link == "usb" else "list Nova 2 Lite controllers in range")
182
+ s.add_argument("--timeout", type=float, default=6.0)
183
+ s.set_defaults(run=a_scan)
184
+
185
+ acts.add_parser("info", help="firmware version, battery, MAC").set_defaults(run=a_info)
186
+
187
+ d = acts.add_parser("deadzone", help="show or set dead zones")
188
+ d.add_argument("zone", nargs="?", choices=list(ZONES))
189
+ d.add_argument("value", nargs="?", type=_byte, help="raw value (units unconfirmed)")
190
+ d.set_defaults(run=a_deadzone)
191
+
192
+ f = acts.add_parser("feature", help="show or toggle features (stick swap, D-pad lock, ...)")
193
+ f.add_argument("name", nargs="?", choices=list(FEATURES))
194
+ f.add_argument("state", nargs="?", choices=list(ON_OFF))
195
+ f.set_defaults(run=a_feature)
196
+
197
+ v = acts.add_parser("vibration", help="show or set rumble strength")
198
+ for field in VIB_FIELDS:
199
+ v.add_argument("--" + field.replace("_", "-"), dest=field, type=_byte)
200
+ v.set_defaults(run=a_vibration)
201
+
202
+ z = acts.add_parser("sleep", help="show or set the auto-sleep time")
203
+ z.add_argument("minutes", nargs="?", type=_byte)
204
+ z.set_defaults(run=a_sleep)
205
+
206
+ r = acts.add_parser("restore", help="reset all settings to factory defaults")
207
+ r.add_argument("--yes", action="store_true")
208
+ r.set_defaults(run=a_restore)
209
+
210
+ p.set_defaults(func=run)
211
+
212
+
213
+ def run(args) -> int:
214
+ try:
215
+ return asyncio.run(args.run(args))
216
+ except (ble.BleError, hid.HidError, ProtocolError, NotApplied, ValueError) as e:
217
+ print(f"error: {e}", file=sys.stderr)
218
+ return 1
padsquire/cli_probe.py ADDED
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """`padsquire probe`: read-only first contact with a controller."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from . import probe
12
+
13
+ GUIDE = [
14
+ ("wired-xinput",
15
+ "Connect the controller with the USB cable. Hold Home + X for 3 s (LED blue: XInput)."),
16
+ ("wired-dinput", "Still on the cable: hold Home + Y for 3 s (LED green: DInput)."),
17
+ ("wired-switch", "Still on the cable: hold Home + B for 3 s (LED red: Switch mode)."),
18
+ ("dongle", "Unplug the cable. Plug in the 2.4 GHz dongle and turn the controller on "
19
+ "so it connects to the dongle."),
20
+ ("controller-update-mode", "Unplug everything. Hold M + X + B while plugging in the USB cable "
21
+ "(update mode). Nothing will be flashed."),
22
+ ("dongle-update-mode", "Unplug the controller. Hold the dongle's pairing button while plugging "
23
+ "the dongle in (update mode). Nothing will be flashed."),
24
+ ("bluetooth", "Unplug everything. Turn the controller on in Bluetooth mode; the phone app uses "
25
+ "Android mode, so try that first (Home + A for 3 s, LED yellow)."),
26
+ ]
27
+
28
+
29
+ def _ask(prompt: str) -> str:
30
+ try:
31
+ return input(prompt).strip().lower()
32
+ except EOFError:
33
+ return "q"
34
+
35
+
36
+ def _usb_id(text: str) -> tuple[str, str]:
37
+ vid, sep, pid = text.lower().partition(":")
38
+ if not sep or not all(len(x) == 4 and all(c in "0123456789abcdef" for c in x) for x in (vid, pid)):
39
+ raise argparse.ArgumentTypeError(f"expected VID:PID in hex, like 054c:09cc, not {text!r}")
40
+ return vid, pid
41
+
42
+
43
+ def cmd_probe(args) -> int:
44
+ out = args.output or Path(f"gamesir-probe-{datetime.now():%Y%m%d-%H%M%S}.json")
45
+ report = probe.new_report()
46
+
47
+ if args.guided:
48
+ print("Read-only probe: nothing on the controller is changed.\n"
49
+ "For each step: set the controller up as described, then press Enter.\n"
50
+ "Type s to skip a step, q to stop and save.\n")
51
+ for label, text in GUIDE:
52
+ print(f"--- {label}\n{text}")
53
+ answer = _ask("[Enter] probe / [s]kip / [q]uit: ")
54
+ if answer == "q":
55
+ break
56
+ if answer == "s":
57
+ continue
58
+ snap = probe.snapshot(label, usb=label != "bluetooth", bluetooth=label == "bluetooth",
59
+ address=args.address, extra_ids=args.device)
60
+ report["snapshots"].append(snap)
61
+ probe.save(report, out)
62
+ print("\n".join(probe.summarize(snap)) + "\n")
63
+ else:
64
+ bluetooth = args.bluetooth or args.bluetooth_only or args.address is not None
65
+ snap = probe.snapshot(args.label, usb=not args.bluetooth_only, bluetooth=bluetooth,
66
+ address=args.address, extra_ids=args.device)
67
+ report["snapshots"].append(snap)
68
+ print("\n".join(probe.summarize(snap)))
69
+
70
+ probe.save(report, out)
71
+ print(f"\nreport saved to {out}; please share this file")
72
+ return 0
73
+
74
+
75
+ def register(sub) -> None:
76
+ p = sub.add_parser("probe", help="read-only: collect details about a connected controller",
77
+ description="Collects USB, input and Bluetooth details into a JSON report. "
78
+ "Never changes controller settings or flashes anything.")
79
+ p.add_argument("--guided", action="store_true", help="walk through every connection mode")
80
+ p.add_argument("--bluetooth", action="store_true", help="also probe Bluetooth")
81
+ p.add_argument("--bluetooth-only", action="store_true", help="skip USB, probe only Bluetooth")
82
+ p.add_argument("--address", help="Bluetooth address to connect to (default: auto)")
83
+ p.add_argument("--label", default="manual", help="name for this snapshot")
84
+ p.add_argument("-o", "--output", type=Path)
85
+ p.add_argument("--device", type=_usb_id, action="append", default=[], metavar="VID:PID",
86
+ help="also include this USB device, e.g. 054c:09cc when the dongle "
87
+ "presents as a DualShock 4 (repeatable)")
88
+ p.set_defaults(func=cmd_probe)