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/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
+ )
@@ -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
@@ -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
+