swbt-python 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.
- swbt/__init__.py +37 -0
- swbt/diagnostics.py +202 -0
- swbt/errors.py +33 -0
- swbt/gamepad/__init__.py +14 -0
- swbt/gamepad/connection.py +222 -0
- swbt/gamepad/core.py +697 -0
- swbt/gamepad/output.py +73 -0
- swbt/gamepad/transport_factory.py +22 -0
- swbt/input.py +550 -0
- swbt/probe.py +150 -0
- swbt/protocol/__init__.py +1 -0
- swbt/protocol/input_report.py +77 -0
- swbt/protocol/output_report.py +58 -0
- swbt/protocol/profile.py +221 -0
- swbt/protocol/rumble.py +21 -0
- swbt/protocol/spi.py +37 -0
- swbt/protocol/subcommand.py +102 -0
- swbt/py.typed +1 -0
- swbt/report_loop.py +115 -0
- swbt/state_store.py +60 -0
- swbt/transport/__init__.py +3 -0
- swbt/transport/_bumble_acl.py +33 -0
- swbt/transport/_bumble_hidp.py +34 -0
- swbt/transport/_bumble_key_store.py +220 -0
- swbt/transport/_bumble_lifecycle.py +311 -0
- swbt/transport/_bumble_sdp.py +203 -0
- swbt/transport/base.py +81 -0
- swbt/transport/bumble.py +709 -0
- swbt/transport/fake.py +315 -0
- swbt_python-0.1.0.dist-info/METADATA +122 -0
- swbt_python-0.1.0.dist-info/RECORD +34 -0
- swbt_python-0.1.0.dist-info/WHEEL +4 -0
- swbt_python-0.1.0.dist-info/entry_points.txt +3 -0
- swbt_python-0.1.0.dist-info/licenses/LICENSE +21 -0
swbt/probe.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Command line probes for swbt hardware setup."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from swbt import DiagnosticsConfig, SwitchGamepad
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
"""Build the swbt-probe argument parser.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
argparse.ArgumentParser: Parser for the ``swbt-probe`` command.
|
|
20
|
+
"""
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="swbt-probe",
|
|
23
|
+
description="Probe helper for swbt-python development and hardware setup.",
|
|
24
|
+
)
|
|
25
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
26
|
+
|
|
27
|
+
adapters_parser = subparsers.add_parser(
|
|
28
|
+
"adapters",
|
|
29
|
+
description=(
|
|
30
|
+
"Show adapter discovery guidance. This command does not open a Bluetooth adapter."
|
|
31
|
+
),
|
|
32
|
+
help="show adapter discovery guidance without opening hardware",
|
|
33
|
+
)
|
|
34
|
+
adapters_parser.add_argument(
|
|
35
|
+
"--json",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="emit machine-readable adapter probe guidance",
|
|
38
|
+
)
|
|
39
|
+
adapters_parser.set_defaults(handler=_run_adapters)
|
|
40
|
+
|
|
41
|
+
pair_parser = subparsers.add_parser(
|
|
42
|
+
"pair",
|
|
43
|
+
description=(
|
|
44
|
+
"Pairing probe requires explicit approval before any Switch-facing Bluetooth action."
|
|
45
|
+
),
|
|
46
|
+
help="show pairing probe options and approval requirements",
|
|
47
|
+
)
|
|
48
|
+
pair_parser.add_argument(
|
|
49
|
+
"--adapter",
|
|
50
|
+
default="usb:0",
|
|
51
|
+
help="adapter moniker to use after approval, for example usb:0",
|
|
52
|
+
)
|
|
53
|
+
pair_parser.add_argument(
|
|
54
|
+
"--key-store",
|
|
55
|
+
dest="key_store_path",
|
|
56
|
+
help="pairing key store path for the approved pairing run",
|
|
57
|
+
)
|
|
58
|
+
pair_parser.add_argument(
|
|
59
|
+
"--trace",
|
|
60
|
+
required=True,
|
|
61
|
+
type=Path,
|
|
62
|
+
metavar="PATH",
|
|
63
|
+
help="JSON Lines trace output path for the approved pairing run",
|
|
64
|
+
)
|
|
65
|
+
pair_parser.add_argument(
|
|
66
|
+
"--timeout",
|
|
67
|
+
default=30.0,
|
|
68
|
+
type=float,
|
|
69
|
+
help="seconds to wait for pairing during an approved run",
|
|
70
|
+
)
|
|
71
|
+
pair_parser.set_defaults(handler=_run_pair)
|
|
72
|
+
|
|
73
|
+
return parser
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
77
|
+
"""Run the swbt-probe command.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
argv: Optional argument vector. ``None`` reads arguments from ``sys.argv``.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
int: Process exit code.
|
|
84
|
+
"""
|
|
85
|
+
parser = build_parser()
|
|
86
|
+
args = parser.parse_args(argv)
|
|
87
|
+
handler = args.handler
|
|
88
|
+
return int(handler(args))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _run_adapters(args: argparse.Namespace) -> int:
|
|
92
|
+
payload = {
|
|
93
|
+
"bumble_version": _package_version("bumble"),
|
|
94
|
+
"candidate_adapters": ["usb:0"],
|
|
95
|
+
"opens_adapter": False,
|
|
96
|
+
"platform": platform.platform(),
|
|
97
|
+
"python_version": platform.python_version(),
|
|
98
|
+
"status": "adapter listing does not open hardware",
|
|
99
|
+
}
|
|
100
|
+
if args.json:
|
|
101
|
+
sys.stdout.write(f"{json.dumps(payload, sort_keys=True)}\n")
|
|
102
|
+
else:
|
|
103
|
+
sys.stdout.write("This command does not open a Bluetooth adapter.\n")
|
|
104
|
+
sys.stdout.write(f"Platform: {payload['platform']}\n")
|
|
105
|
+
sys.stdout.write(f"Python: {payload['python_version']}\n")
|
|
106
|
+
sys.stdout.write(f"Bumble: {payload['bumble_version']}\n")
|
|
107
|
+
sys.stdout.write("Candidate adapters: usb:0\n")
|
|
108
|
+
sys.stdout.write("Opening an adapter requires an explicit hardware approval scope.\n")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _run_pair(_args: argparse.Namespace) -> int:
|
|
113
|
+
asyncio.run(
|
|
114
|
+
_run_pair_probe(
|
|
115
|
+
adapter=_args.adapter,
|
|
116
|
+
key_store_path=_args.key_store_path,
|
|
117
|
+
pair_timeout=_args.timeout,
|
|
118
|
+
trace_path=_args.trace,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _run_pair_probe(
|
|
125
|
+
*,
|
|
126
|
+
adapter: str,
|
|
127
|
+
key_store_path: str | None,
|
|
128
|
+
pair_timeout: float,
|
|
129
|
+
trace_path: Path,
|
|
130
|
+
) -> None:
|
|
131
|
+
trace_path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
with trace_path.open("w", encoding="utf-8") as trace_writer:
|
|
133
|
+
diagnostics = DiagnosticsConfig(trace_writer=trace_writer)
|
|
134
|
+
async with SwitchGamepad(
|
|
135
|
+
adapter=adapter,
|
|
136
|
+
key_store_path=key_store_path,
|
|
137
|
+
diagnostics=diagnostics,
|
|
138
|
+
) as pad:
|
|
139
|
+
await pad.pair(timeout=pair_timeout)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _package_version(package_name: str) -> str:
|
|
143
|
+
try:
|
|
144
|
+
return version(package_name)
|
|
145
|
+
except PackageNotFoundError:
|
|
146
|
+
return "unknown"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Switch HID protocol helpers."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Input report builders."""
|
|
2
|
+
|
|
3
|
+
from swbt.input import Button, InputState, Stick
|
|
4
|
+
from swbt.protocol.profile import ProControllerProfile
|
|
5
|
+
|
|
6
|
+
BUTTON_BITS = {
|
|
7
|
+
Button.Y: (3, 0x01),
|
|
8
|
+
Button.X: (3, 0x02),
|
|
9
|
+
Button.B: (3, 0x04),
|
|
10
|
+
Button.A: (3, 0x08),
|
|
11
|
+
Button.R: (3, 0x40),
|
|
12
|
+
Button.ZR: (3, 0x80),
|
|
13
|
+
Button.MINUS: (4, 0x01),
|
|
14
|
+
Button.PLUS: (4, 0x02),
|
|
15
|
+
Button.RIGHT_STICK: (4, 0x04),
|
|
16
|
+
Button.LEFT_STICK: (4, 0x08),
|
|
17
|
+
Button.HOME: (4, 0x10),
|
|
18
|
+
Button.CAPTURE: (4, 0x20),
|
|
19
|
+
Button.DPAD_DOWN: (5, 0x01),
|
|
20
|
+
Button.DPAD_UP: (5, 0x02),
|
|
21
|
+
Button.DPAD_RIGHT: (5, 0x04),
|
|
22
|
+
Button.DPAD_LEFT: (5, 0x08),
|
|
23
|
+
Button.L: (5, 0x40),
|
|
24
|
+
Button.ZL: (5, 0x80),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InputReportBuilder:
|
|
29
|
+
"""Build Switch HID input reports from immutable input state."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, profile: ProControllerProfile | None = None) -> None:
|
|
32
|
+
"""Create a report builder."""
|
|
33
|
+
self._profile = profile or ProControllerProfile()
|
|
34
|
+
|
|
35
|
+
def build_0x30(self, state: InputState, *, timer: int = 0) -> bytes:
|
|
36
|
+
"""Build a 0x30 standard full input report."""
|
|
37
|
+
report = bytearray(49)
|
|
38
|
+
report[0] = 0x30
|
|
39
|
+
report[1] = timer & 0xFF
|
|
40
|
+
report[2] = self._profile.battery_connection
|
|
41
|
+
self._pack_buttons(report, state)
|
|
42
|
+
report[6:9] = self._pack_stick(state.left_stick)
|
|
43
|
+
report[9:12] = self._pack_stick(state.right_stick)
|
|
44
|
+
report[12] = self._profile.vibrator_input
|
|
45
|
+
self._pack_imu_frames(report, state)
|
|
46
|
+
return bytes(report)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _pack_buttons(report: bytearray, state: InputState) -> None:
|
|
50
|
+
for button in state.buttons:
|
|
51
|
+
offset, mask = BUTTON_BITS[button]
|
|
52
|
+
report[offset] |= mask
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _pack_stick(stick: Stick) -> bytes:
|
|
56
|
+
return bytes(
|
|
57
|
+
(
|
|
58
|
+
stick.x & 0xFF,
|
|
59
|
+
((stick.x >> 8) & 0x0F) | ((stick.y & 0x0F) << 4),
|
|
60
|
+
(stick.y >> 4) & 0xFF,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _pack_imu_frames(report: bytearray, state: InputState) -> None:
|
|
66
|
+
cursor = 13
|
|
67
|
+
for frame in state.imu_frames:
|
|
68
|
+
for value in (
|
|
69
|
+
frame.accel_x,
|
|
70
|
+
frame.accel_y,
|
|
71
|
+
frame.accel_z,
|
|
72
|
+
frame.gyro_x,
|
|
73
|
+
frame.gyro_y,
|
|
74
|
+
frame.gyro_z,
|
|
75
|
+
):
|
|
76
|
+
report[cursor : cursor + 2] = int(value).to_bytes(2, "little", signed=True)
|
|
77
|
+
cursor += 2
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Output report parser."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from swbt.errors import ProtocolError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class OutputReport:
|
|
10
|
+
"""Parsed host-to-device output report."""
|
|
11
|
+
|
|
12
|
+
report_id: int
|
|
13
|
+
packet_id: int | None
|
|
14
|
+
rumble: bytes | None
|
|
15
|
+
subcommand_id: int | None
|
|
16
|
+
subcommand_payload: bytes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OutputReportParser:
|
|
20
|
+
"""Parse Switch HID output reports."""
|
|
21
|
+
|
|
22
|
+
def parse(self, raw_report: bytes) -> OutputReport:
|
|
23
|
+
"""Parse a raw output report."""
|
|
24
|
+
if not raw_report:
|
|
25
|
+
msg = "output report is empty"
|
|
26
|
+
raise ProtocolError(msg)
|
|
27
|
+
if raw_report[0] == 0x01:
|
|
28
|
+
return self._parse_0x01(raw_report)
|
|
29
|
+
if raw_report[0] == 0x10:
|
|
30
|
+
return self._parse_0x10(raw_report)
|
|
31
|
+
msg = f"unsupported output report id: 0x{raw_report[0]:02x}"
|
|
32
|
+
raise ProtocolError(msg)
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _parse_0x01(raw_report: bytes) -> OutputReport:
|
|
36
|
+
if len(raw_report) < 11:
|
|
37
|
+
msg = "0x01 output report must include packet, rumble, and subcommand"
|
|
38
|
+
raise ProtocolError(msg)
|
|
39
|
+
return OutputReport(
|
|
40
|
+
report_id=0x01,
|
|
41
|
+
packet_id=raw_report[1],
|
|
42
|
+
rumble=raw_report[2:10],
|
|
43
|
+
subcommand_id=raw_report[10],
|
|
44
|
+
subcommand_payload=raw_report[11:],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _parse_0x10(raw_report: bytes) -> OutputReport:
|
|
49
|
+
if len(raw_report) < 10:
|
|
50
|
+
msg = "0x10 output report must include packet and rumble"
|
|
51
|
+
raise ProtocolError(msg)
|
|
52
|
+
return OutputReport(
|
|
53
|
+
report_id=0x10,
|
|
54
|
+
packet_id=raw_report[1],
|
|
55
|
+
rumble=raw_report[2:10],
|
|
56
|
+
subcommand_id=None,
|
|
57
|
+
subcommand_payload=b"",
|
|
58
|
+
)
|
swbt/protocol/profile.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Fixed protocol profile values for the controller shape."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
SWITCH_PRO_CONTROLLER_HID_REPORT_DESCRIPTOR = bytes(
|
|
6
|
+
(
|
|
7
|
+
0x05,
|
|
8
|
+
0x01,
|
|
9
|
+
0x15,
|
|
10
|
+
0x00,
|
|
11
|
+
0x09,
|
|
12
|
+
0x04,
|
|
13
|
+
0xA1,
|
|
14
|
+
0x01,
|
|
15
|
+
0x85,
|
|
16
|
+
0x30,
|
|
17
|
+
0x05,
|
|
18
|
+
0x01,
|
|
19
|
+
0x05,
|
|
20
|
+
0x09,
|
|
21
|
+
0x19,
|
|
22
|
+
0x01,
|
|
23
|
+
0x29,
|
|
24
|
+
0x0A,
|
|
25
|
+
0x15,
|
|
26
|
+
0x00,
|
|
27
|
+
0x25,
|
|
28
|
+
0x01,
|
|
29
|
+
0x75,
|
|
30
|
+
0x01,
|
|
31
|
+
0x95,
|
|
32
|
+
0x0A,
|
|
33
|
+
0x55,
|
|
34
|
+
0x00,
|
|
35
|
+
0x65,
|
|
36
|
+
0x00,
|
|
37
|
+
0x81,
|
|
38
|
+
0x02,
|
|
39
|
+
0x05,
|
|
40
|
+
0x09,
|
|
41
|
+
0x19,
|
|
42
|
+
0x0B,
|
|
43
|
+
0x29,
|
|
44
|
+
0x0E,
|
|
45
|
+
0x15,
|
|
46
|
+
0x00,
|
|
47
|
+
0x25,
|
|
48
|
+
0x01,
|
|
49
|
+
0x75,
|
|
50
|
+
0x01,
|
|
51
|
+
0x95,
|
|
52
|
+
0x04,
|
|
53
|
+
0x81,
|
|
54
|
+
0x02,
|
|
55
|
+
0x75,
|
|
56
|
+
0x01,
|
|
57
|
+
0x95,
|
|
58
|
+
0x02,
|
|
59
|
+
0x81,
|
|
60
|
+
0x03,
|
|
61
|
+
0x0B,
|
|
62
|
+
0x01,
|
|
63
|
+
0x00,
|
|
64
|
+
0x01,
|
|
65
|
+
0x00,
|
|
66
|
+
0xA1,
|
|
67
|
+
0x00,
|
|
68
|
+
0x0B,
|
|
69
|
+
0x30,
|
|
70
|
+
0x00,
|
|
71
|
+
0x01,
|
|
72
|
+
0x00,
|
|
73
|
+
0x0B,
|
|
74
|
+
0x31,
|
|
75
|
+
0x00,
|
|
76
|
+
0x01,
|
|
77
|
+
0x00,
|
|
78
|
+
0x0B,
|
|
79
|
+
0x32,
|
|
80
|
+
0x00,
|
|
81
|
+
0x01,
|
|
82
|
+
0x00,
|
|
83
|
+
0x0B,
|
|
84
|
+
0x35,
|
|
85
|
+
0x00,
|
|
86
|
+
0x01,
|
|
87
|
+
0x00,
|
|
88
|
+
0x15,
|
|
89
|
+
0x00,
|
|
90
|
+
0x27,
|
|
91
|
+
0xFF,
|
|
92
|
+
0xFF,
|
|
93
|
+
0x00,
|
|
94
|
+
0x00,
|
|
95
|
+
0x75,
|
|
96
|
+
0x10,
|
|
97
|
+
0x95,
|
|
98
|
+
0x04,
|
|
99
|
+
0x81,
|
|
100
|
+
0x02,
|
|
101
|
+
0xC0,
|
|
102
|
+
0x0B,
|
|
103
|
+
0x39,
|
|
104
|
+
0x00,
|
|
105
|
+
0x01,
|
|
106
|
+
0x00,
|
|
107
|
+
0x15,
|
|
108
|
+
0x00,
|
|
109
|
+
0x25,
|
|
110
|
+
0x07,
|
|
111
|
+
0x35,
|
|
112
|
+
0x00,
|
|
113
|
+
0x46,
|
|
114
|
+
0x3B,
|
|
115
|
+
0x01,
|
|
116
|
+
0x65,
|
|
117
|
+
0x14,
|
|
118
|
+
0x75,
|
|
119
|
+
0x04,
|
|
120
|
+
0x95,
|
|
121
|
+
0x01,
|
|
122
|
+
0x81,
|
|
123
|
+
0x02,
|
|
124
|
+
0x05,
|
|
125
|
+
0x09,
|
|
126
|
+
0x19,
|
|
127
|
+
0x0F,
|
|
128
|
+
0x29,
|
|
129
|
+
0x12,
|
|
130
|
+
0x15,
|
|
131
|
+
0x00,
|
|
132
|
+
0x25,
|
|
133
|
+
0x01,
|
|
134
|
+
0x75,
|
|
135
|
+
0x01,
|
|
136
|
+
0x95,
|
|
137
|
+
0x04,
|
|
138
|
+
0x81,
|
|
139
|
+
0x02,
|
|
140
|
+
0x75,
|
|
141
|
+
0x08,
|
|
142
|
+
0x95,
|
|
143
|
+
0x34,
|
|
144
|
+
0x81,
|
|
145
|
+
0x03,
|
|
146
|
+
0x06,
|
|
147
|
+
0x00,
|
|
148
|
+
0xFF,
|
|
149
|
+
0x85,
|
|
150
|
+
0x21,
|
|
151
|
+
0x09,
|
|
152
|
+
0x01,
|
|
153
|
+
0x75,
|
|
154
|
+
0x08,
|
|
155
|
+
0x95,
|
|
156
|
+
0x3F,
|
|
157
|
+
0x81,
|
|
158
|
+
0x03,
|
|
159
|
+
0x85,
|
|
160
|
+
0x81,
|
|
161
|
+
0x09,
|
|
162
|
+
0x02,
|
|
163
|
+
0x75,
|
|
164
|
+
0x08,
|
|
165
|
+
0x95,
|
|
166
|
+
0x3F,
|
|
167
|
+
0x81,
|
|
168
|
+
0x03,
|
|
169
|
+
0x85,
|
|
170
|
+
0x01,
|
|
171
|
+
0x09,
|
|
172
|
+
0x03,
|
|
173
|
+
0x75,
|
|
174
|
+
0x08,
|
|
175
|
+
0x95,
|
|
176
|
+
0x3F,
|
|
177
|
+
0x91,
|
|
178
|
+
0x83,
|
|
179
|
+
0x85,
|
|
180
|
+
0x10,
|
|
181
|
+
0x09,
|
|
182
|
+
0x04,
|
|
183
|
+
0x75,
|
|
184
|
+
0x08,
|
|
185
|
+
0x95,
|
|
186
|
+
0x3F,
|
|
187
|
+
0x91,
|
|
188
|
+
0x83,
|
|
189
|
+
0x85,
|
|
190
|
+
0x80,
|
|
191
|
+
0x09,
|
|
192
|
+
0x05,
|
|
193
|
+
0x75,
|
|
194
|
+
0x08,
|
|
195
|
+
0x95,
|
|
196
|
+
0x3F,
|
|
197
|
+
0x91,
|
|
198
|
+
0x83,
|
|
199
|
+
0x85,
|
|
200
|
+
0x82,
|
|
201
|
+
0x09,
|
|
202
|
+
0x06,
|
|
203
|
+
0x75,
|
|
204
|
+
0x08,
|
|
205
|
+
0x95,
|
|
206
|
+
0x3F,
|
|
207
|
+
0x91,
|
|
208
|
+
0x83,
|
|
209
|
+
0xC0,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class ProControllerProfile:
|
|
216
|
+
"""Protocol defaults for a Pro Controller compatible report shape."""
|
|
217
|
+
|
|
218
|
+
battery_connection: int = 0x91
|
|
219
|
+
vibrator_input: int = 0x00
|
|
220
|
+
bluetooth_address: bytes = b"\x00\x00\x00\x00\x00\x00"
|
|
221
|
+
hid_report_descriptor: bytes = SWITCH_PRO_CONTROLLER_HID_REPORT_DESCRIPTOR
|
swbt/protocol/rumble.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Raw rumble state."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from swbt.errors import ProtocolError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class RumbleState:
|
|
10
|
+
"""Raw 8-byte rumble payload with receive timestamp."""
|
|
11
|
+
|
|
12
|
+
raw: bytes
|
|
13
|
+
updated_at_ns: int
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_raw(cls, raw: bytes, *, updated_at_ns: int) -> "RumbleState":
|
|
17
|
+
"""Create rumble state from an 8-byte payload."""
|
|
18
|
+
if len(raw) != 8:
|
|
19
|
+
msg = f"rumble payload must be 8 bytes: {len(raw)}"
|
|
20
|
+
raise ProtocolError(msg)
|
|
21
|
+
return cls(raw=bytes(raw), updated_at_ns=updated_at_ns)
|
swbt/protocol/spi.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Virtual SPI flash."""
|
|
2
|
+
|
|
3
|
+
from swbt.errors import ProtocolError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VirtualSpiFlash:
|
|
7
|
+
"""Read-only SPI flash data used by subcommand replies."""
|
|
8
|
+
|
|
9
|
+
ADDRESS_LIMIT = 0x80000
|
|
10
|
+
STORAGE_SIZE = 0x10000
|
|
11
|
+
MAX_READ_SIZE = 0x1D
|
|
12
|
+
ERASED_BYTE = 0xFF
|
|
13
|
+
DEVICE_TYPE_ADDRESS = 0x6012
|
|
14
|
+
PRO_CONTROLLER_DEVICE_TYPE = 0x03
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
"""Create a virtual SPI flash image."""
|
|
18
|
+
self._data = bytearray([self.ERASED_BYTE] * self.STORAGE_SIZE)
|
|
19
|
+
self._data[self.DEVICE_TYPE_ADDRESS] = self.PRO_CONTROLLER_DEVICE_TYPE
|
|
20
|
+
|
|
21
|
+
def read(self, address: int, size: int) -> bytes:
|
|
22
|
+
"""Read bytes from the virtual SPI address space."""
|
|
23
|
+
if size > self.MAX_READ_SIZE:
|
|
24
|
+
msg = f"SPI read size must be {self.MAX_READ_SIZE} bytes or less: {size}"
|
|
25
|
+
raise ProtocolError(msg)
|
|
26
|
+
if address < 0 or size < 0 or address + size > self.ADDRESS_LIMIT:
|
|
27
|
+
msg = f"SPI read is outside address space: address=0x{address:x}, size={size}"
|
|
28
|
+
raise ProtocolError(msg)
|
|
29
|
+
|
|
30
|
+
out = bytearray()
|
|
31
|
+
for offset in range(size):
|
|
32
|
+
absolute_address = address + offset
|
|
33
|
+
if absolute_address < self.STORAGE_SIZE:
|
|
34
|
+
out.append(self._data[absolute_address])
|
|
35
|
+
else:
|
|
36
|
+
out.append(self.ERASED_BYTE)
|
|
37
|
+
return bytes(out)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Subcommand reply generation."""
|
|
2
|
+
|
|
3
|
+
from swbt.errors import ProtocolError
|
|
4
|
+
from swbt.input import InputState
|
|
5
|
+
from swbt.protocol.input_report import InputReportBuilder
|
|
6
|
+
from swbt.protocol.output_report import OutputReport
|
|
7
|
+
from swbt.protocol.profile import ProControllerProfile
|
|
8
|
+
from swbt.protocol.spi import VirtualSpiFlash
|
|
9
|
+
|
|
10
|
+
SIMPLE_ACK_SUBCOMMANDS = {0x03, 0x08, 0x30, 0x40, 0x48}
|
|
11
|
+
DEVICE_INFO_DATA = bytes.fromhex("04 00 03 02 00 00 00 00 00 00 01 01")
|
|
12
|
+
TRIGGER_BUTTONS_ELAPSED_DATA = bytes.fromhex("2c 01 2c 01 00 00 00 00 00 00 00 00 00 00")
|
|
13
|
+
MCU_CONFIG_DATA = bytes.fromhex(
|
|
14
|
+
"01 00 ff 00 08 00 1b 01 00 00 00 00 00 00 00 00 00 00 00 00 "
|
|
15
|
+
"00 00 00 00 00 00 00 00 00 00 00 00 00 c8"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnsupportedSubcommandError(ProtocolError):
|
|
20
|
+
"""Raised when a subcommand is not supported by the responder."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, subcommand_id: int, payload: bytes) -> None:
|
|
23
|
+
"""Create an error with fields diagnostics can record."""
|
|
24
|
+
self.subcommand_id = subcommand_id
|
|
25
|
+
self.payload = bytes(payload)
|
|
26
|
+
super().__init__(f"unsupported subcommand: 0x{subcommand_id:02x}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SubcommandResponder:
|
|
30
|
+
"""Build 0x21 replies for supported subcommands."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
spi_flash: VirtualSpiFlash | None = None,
|
|
36
|
+
profile: ProControllerProfile | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Create a responder."""
|
|
39
|
+
self._spi_flash = spi_flash or VirtualSpiFlash()
|
|
40
|
+
self._profile = profile or ProControllerProfile()
|
|
41
|
+
|
|
42
|
+
def respond(self, output_report: OutputReport, *, state: InputState, timer: int = 0) -> bytes:
|
|
43
|
+
"""Return a 0x21 reply for an output report with a subcommand."""
|
|
44
|
+
if output_report.subcommand_id is None:
|
|
45
|
+
msg = "output report does not include a subcommand"
|
|
46
|
+
raise ProtocolError(msg)
|
|
47
|
+
|
|
48
|
+
ack, data = self._reply_data(output_report)
|
|
49
|
+
return self._build_0x21_reply(
|
|
50
|
+
subcommand_id=output_report.subcommand_id,
|
|
51
|
+
ack=ack,
|
|
52
|
+
data=data,
|
|
53
|
+
state=state,
|
|
54
|
+
timer=timer,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _reply_data(self, output_report: OutputReport) -> tuple[int, bytes]:
|
|
58
|
+
subcommand_id = output_report.subcommand_id
|
|
59
|
+
if subcommand_id is None:
|
|
60
|
+
msg = "output report does not include a subcommand"
|
|
61
|
+
raise ProtocolError(msg)
|
|
62
|
+
if subcommand_id in SIMPLE_ACK_SUBCOMMANDS:
|
|
63
|
+
return 0x80, b""
|
|
64
|
+
if subcommand_id == 0x02:
|
|
65
|
+
return 0x82, DEVICE_INFO_DATA
|
|
66
|
+
if subcommand_id == 0x04:
|
|
67
|
+
return 0x83, TRIGGER_BUTTONS_ELAPSED_DATA
|
|
68
|
+
if subcommand_id == 0x10:
|
|
69
|
+
return 0x90, self._spi_read_reply_data(output_report.subcommand_payload)
|
|
70
|
+
if subcommand_id == 0x21:
|
|
71
|
+
return 0xA0, MCU_CONFIG_DATA
|
|
72
|
+
raise UnsupportedSubcommandError(subcommand_id, output_report.subcommand_payload)
|
|
73
|
+
|
|
74
|
+
def _spi_read_reply_data(self, payload: bytes) -> bytes:
|
|
75
|
+
if len(payload) < 5:
|
|
76
|
+
msg = "SPI read subcommand must include address and size"
|
|
77
|
+
raise ProtocolError(msg)
|
|
78
|
+
address = int.from_bytes(payload[0:4], "little")
|
|
79
|
+
size = payload[4]
|
|
80
|
+
return payload[:5] + self._spi_flash.read(address, size)
|
|
81
|
+
|
|
82
|
+
def _build_0x21_reply(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
subcommand_id: int,
|
|
86
|
+
ack: int,
|
|
87
|
+
data: bytes,
|
|
88
|
+
state: InputState,
|
|
89
|
+
timer: int,
|
|
90
|
+
) -> bytes:
|
|
91
|
+
if len(data) > 35:
|
|
92
|
+
msg = f"subcommand reply data is too large: {len(data)}"
|
|
93
|
+
raise ProtocolError(msg)
|
|
94
|
+
|
|
95
|
+
prefix = InputReportBuilder(self._profile).build_0x30(state, timer=timer)[:13]
|
|
96
|
+
reply = bytearray(50)
|
|
97
|
+
reply[:13] = prefix
|
|
98
|
+
reply[0] = 0x21
|
|
99
|
+
reply[13] = ack
|
|
100
|
+
reply[14] = subcommand_id
|
|
101
|
+
reply[15 : 15 + len(data)] = data
|
|
102
|
+
return bytes(reply)
|
swbt/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|