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/transport/fake.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""In-memory HID transport for integration tests."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from swbt.errors import ClosedError, InvalidKeyStoreError
|
|
7
|
+
from swbt.transport.base import (
|
|
8
|
+
BondedPeer,
|
|
9
|
+
ConnectedCallback,
|
|
10
|
+
ControlDataCallback,
|
|
11
|
+
DisconnectedCallback,
|
|
12
|
+
DisconnectRequestResult,
|
|
13
|
+
InterruptDataCallback,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeHidTransport:
|
|
18
|
+
"""Record transport operations without opening Bluetooth resources."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
disconnect_request_auto_complete: bool = True,
|
|
24
|
+
disconnect_request_error: Exception | None = None,
|
|
25
|
+
bonded_peer_addresses: tuple[str, ...] = (),
|
|
26
|
+
active_reconnect_auto_connect: bool = True,
|
|
27
|
+
active_reconnect_error: BaseException | None = None,
|
|
28
|
+
send_interrupt_error: Exception | None = None,
|
|
29
|
+
close_wait: asyncio.Event | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Create a closed fake transport."""
|
|
32
|
+
self._is_open = False
|
|
33
|
+
self._open_count = 0
|
|
34
|
+
self._close_count = 0
|
|
35
|
+
self._disconnect_request_auto_complete = disconnect_request_auto_complete
|
|
36
|
+
self._disconnect_request_error = disconnect_request_error
|
|
37
|
+
self._bonded_peer_addresses = tuple(bonded_peer_addresses)
|
|
38
|
+
self._active_reconnect_auto_connect = active_reconnect_auto_connect
|
|
39
|
+
self._active_reconnect_error = active_reconnect_error
|
|
40
|
+
self._send_interrupt_error = send_interrupt_error
|
|
41
|
+
self._close_wait = close_wait
|
|
42
|
+
self._events: list[str] = []
|
|
43
|
+
self._control_channel_open = False
|
|
44
|
+
self._interrupt_channel_open = False
|
|
45
|
+
self._connected_emitted = False
|
|
46
|
+
self._disconnect_request_sent_interrupt_count: int | None = None
|
|
47
|
+
self._sent_interrupt_reports: list[bytes] = []
|
|
48
|
+
self._sent_control_reports: list[bytes] = []
|
|
49
|
+
self._interrupt_report_event = asyncio.Event()
|
|
50
|
+
self._disconnect_request_event = asyncio.Event()
|
|
51
|
+
self._close_start_event = asyncio.Event()
|
|
52
|
+
self._interrupt_callback: InterruptDataCallback | None = None
|
|
53
|
+
self._control_callback: ControlDataCallback | None = None
|
|
54
|
+
self._connected_callback: ConnectedCallback | None = None
|
|
55
|
+
self._disconnected_callback: DisconnectedCallback | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_open(self) -> bool:
|
|
59
|
+
"""Return whether the fake transport is open."""
|
|
60
|
+
return self._is_open
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def open_count(self) -> int:
|
|
64
|
+
"""Return the number of state-changing open calls."""
|
|
65
|
+
return self._open_count
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def close_count(self) -> int:
|
|
69
|
+
"""Return the number of state-changing close calls."""
|
|
70
|
+
return self._close_count
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def events(self) -> tuple[str, ...]:
|
|
74
|
+
"""Return transport lifecycle events in order."""
|
|
75
|
+
return tuple(self._events)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def sent_interrupt_reports(self) -> tuple[bytes, ...]:
|
|
79
|
+
"""Return interrupt reports sent by the gamepad."""
|
|
80
|
+
return tuple(self._sent_interrupt_reports)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def sent_control_reports(self) -> tuple[bytes, ...]:
|
|
84
|
+
"""Return control reports sent by the gamepad."""
|
|
85
|
+
return tuple(self._sent_control_reports)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def disconnect_request_sent_interrupt_count(self) -> int | None:
|
|
89
|
+
"""Return how many interrupt reports existed when disconnect was requested."""
|
|
90
|
+
return self._disconnect_request_sent_interrupt_count
|
|
91
|
+
|
|
92
|
+
async def open(self) -> None:
|
|
93
|
+
"""Open the fake transport."""
|
|
94
|
+
if self._is_open:
|
|
95
|
+
return
|
|
96
|
+
self._is_open = True
|
|
97
|
+
self._open_count += 1
|
|
98
|
+
self._disconnect_request_sent_interrupt_count = None
|
|
99
|
+
self._disconnect_request_event.clear()
|
|
100
|
+
self._close_start_event.clear()
|
|
101
|
+
self._events.append("open")
|
|
102
|
+
|
|
103
|
+
async def start_advertising(self) -> None:
|
|
104
|
+
"""Record an advertising transition."""
|
|
105
|
+
self._require_open()
|
|
106
|
+
self._events.append("start_advertising")
|
|
107
|
+
|
|
108
|
+
async def close(self) -> None:
|
|
109
|
+
"""Close the fake transport."""
|
|
110
|
+
if not self._is_open:
|
|
111
|
+
return
|
|
112
|
+
self._close_start_event.set()
|
|
113
|
+
if self._close_wait is not None:
|
|
114
|
+
await self._close_wait.wait()
|
|
115
|
+
self._is_open = False
|
|
116
|
+
self._control_channel_open = False
|
|
117
|
+
self._interrupt_channel_open = False
|
|
118
|
+
self._connected_emitted = False
|
|
119
|
+
self._close_count += 1
|
|
120
|
+
self._events.append("close")
|
|
121
|
+
|
|
122
|
+
async def request_disconnect(self) -> DisconnectRequestResult:
|
|
123
|
+
"""Record a best-effort remote disconnect request."""
|
|
124
|
+
self._require_open()
|
|
125
|
+
if self._disconnect_request_error is not None:
|
|
126
|
+
error = self._disconnect_request_error
|
|
127
|
+
self._events.append("request_disconnect_failed")
|
|
128
|
+
self._disconnect_request_event.set()
|
|
129
|
+
return DisconnectRequestResult(
|
|
130
|
+
status="failed",
|
|
131
|
+
error_type=type(error).__name__,
|
|
132
|
+
message=str(error),
|
|
133
|
+
)
|
|
134
|
+
if not self._control_channel_open and not self._interrupt_channel_open:
|
|
135
|
+
self._events.append("request_disconnect_unavailable")
|
|
136
|
+
self._disconnect_request_event.set()
|
|
137
|
+
return DisconnectRequestResult(
|
|
138
|
+
status="unavailable",
|
|
139
|
+
reason="channels_not_connected",
|
|
140
|
+
)
|
|
141
|
+
self._disconnect_request_sent_interrupt_count = len(self._sent_interrupt_reports)
|
|
142
|
+
self._events.append("request_disconnect")
|
|
143
|
+
self._disconnect_request_event.set()
|
|
144
|
+
if self._disconnect_request_auto_complete:
|
|
145
|
+
await self.complete_disconnect_request()
|
|
146
|
+
return DisconnectRequestResult(
|
|
147
|
+
status="requested",
|
|
148
|
+
channels=("control", "interrupt"),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def list_bonded_peers(self) -> tuple[BondedPeer, ...]:
|
|
152
|
+
"""Return the fake current reconnect candidate configured by a test."""
|
|
153
|
+
self._require_open()
|
|
154
|
+
if len(self._bonded_peer_addresses) > 1:
|
|
155
|
+
msg = "fake transport contains multiple current reconnect candidates"
|
|
156
|
+
raise InvalidKeyStoreError(msg)
|
|
157
|
+
return tuple(BondedPeer(address=address) for address in self._bonded_peer_addresses)
|
|
158
|
+
|
|
159
|
+
async def connect_bonded_peer(
|
|
160
|
+
self,
|
|
161
|
+
peer_address: str,
|
|
162
|
+
*,
|
|
163
|
+
connect_timeout: float | None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Record an active reconnect attempt to a fake bonded peer."""
|
|
166
|
+
_ = connect_timeout
|
|
167
|
+
self._require_open()
|
|
168
|
+
if peer_address not in self._bonded_peer_addresses:
|
|
169
|
+
msg = f"unknown bonded peer: {peer_address}"
|
|
170
|
+
raise ValueError(msg)
|
|
171
|
+
self._events.append("active_reconnect")
|
|
172
|
+
if self._active_reconnect_error is not None:
|
|
173
|
+
raise self._active_reconnect_error
|
|
174
|
+
if self._active_reconnect_auto_connect:
|
|
175
|
+
await self.connect()
|
|
176
|
+
|
|
177
|
+
async def wait_for_disconnect_request(self, *, max_wait: float = 0.5) -> None:
|
|
178
|
+
"""Wait until a fake remote disconnect request has been recorded."""
|
|
179
|
+
async with asyncio.timeout(max_wait):
|
|
180
|
+
await self._disconnect_request_event.wait()
|
|
181
|
+
|
|
182
|
+
async def wait_for_close_start(self, *, max_wait: float = 0.5) -> None:
|
|
183
|
+
"""Wait until fake transport close has started."""
|
|
184
|
+
async with asyncio.timeout(max_wait):
|
|
185
|
+
await self._close_start_event.wait()
|
|
186
|
+
|
|
187
|
+
async def complete_disconnect_request(self, reason: int | None = None) -> None:
|
|
188
|
+
"""Emit the host-side close completion for a pending fake request."""
|
|
189
|
+
if not self._control_channel_open and not self._interrupt_channel_open:
|
|
190
|
+
return
|
|
191
|
+
self._require_open()
|
|
192
|
+
self._control_channel_open = False
|
|
193
|
+
self._interrupt_channel_open = False
|
|
194
|
+
self._connected_emitted = False
|
|
195
|
+
self._events.append("disconnect_request_closed")
|
|
196
|
+
if self._disconnected_callback is not None:
|
|
197
|
+
await self._disconnected_callback(reason)
|
|
198
|
+
|
|
199
|
+
async def connect(self) -> None:
|
|
200
|
+
"""Emit a fake host connection event."""
|
|
201
|
+
self._require_open()
|
|
202
|
+
self._control_channel_open = True
|
|
203
|
+
self._interrupt_channel_open = True
|
|
204
|
+
await self._emit_connected_once()
|
|
205
|
+
|
|
206
|
+
async def open_l2cap_channel(self, channel: Literal["control", "interrupt"]) -> None:
|
|
207
|
+
"""Emit one fake L2CAP channel-open event."""
|
|
208
|
+
self._require_open()
|
|
209
|
+
if channel == "control":
|
|
210
|
+
self._control_channel_open = True
|
|
211
|
+
self._events.append("l2cap_control_open")
|
|
212
|
+
else:
|
|
213
|
+
self._interrupt_channel_open = True
|
|
214
|
+
self._events.append("l2cap_interrupt_open")
|
|
215
|
+
await self._emit_connected_once()
|
|
216
|
+
|
|
217
|
+
async def disconnect(self, reason: int | None = None) -> None:
|
|
218
|
+
"""Emit a fake host disconnection event."""
|
|
219
|
+
self._require_open()
|
|
220
|
+
self._control_channel_open = False
|
|
221
|
+
self._interrupt_channel_open = False
|
|
222
|
+
self._connected_emitted = False
|
|
223
|
+
self._events.append("disconnected")
|
|
224
|
+
if self._disconnected_callback is not None:
|
|
225
|
+
await self._disconnected_callback(reason)
|
|
226
|
+
|
|
227
|
+
async def inject_interrupt_data(self, payload: bytes) -> None:
|
|
228
|
+
"""Inject host-to-device data into the registered interrupt callback."""
|
|
229
|
+
self._require_open()
|
|
230
|
+
self._events.append("interrupt_rx")
|
|
231
|
+
if self._interrupt_callback is not None:
|
|
232
|
+
await self._interrupt_callback(bytes(payload))
|
|
233
|
+
|
|
234
|
+
async def inject_control_data(self, payload: bytes) -> None:
|
|
235
|
+
"""Inject host-to-device data into the registered control callback."""
|
|
236
|
+
self._require_open()
|
|
237
|
+
self._events.append("control_rx")
|
|
238
|
+
if self._control_callback is not None:
|
|
239
|
+
await self._control_callback(bytes(payload))
|
|
240
|
+
|
|
241
|
+
async def send_interrupt(self, payload: bytes) -> None:
|
|
242
|
+
"""Record an interrupt report."""
|
|
243
|
+
self._require_open()
|
|
244
|
+
if self._send_interrupt_error is not None:
|
|
245
|
+
raise self._send_interrupt_error
|
|
246
|
+
self._sent_interrupt_reports.append(bytes(payload))
|
|
247
|
+
self._interrupt_report_event.set()
|
|
248
|
+
|
|
249
|
+
async def wait_for_interrupt_report_count(
|
|
250
|
+
self,
|
|
251
|
+
count: int,
|
|
252
|
+
*,
|
|
253
|
+
max_wait: float = 0.5,
|
|
254
|
+
) -> tuple[bytes, ...]:
|
|
255
|
+
"""Wait until at least count interrupt reports have been recorded."""
|
|
256
|
+
async with asyncio.timeout(max_wait):
|
|
257
|
+
while len(self._sent_interrupt_reports) < count:
|
|
258
|
+
self._interrupt_report_event.clear()
|
|
259
|
+
if len(self._sent_interrupt_reports) >= count:
|
|
260
|
+
break
|
|
261
|
+
await self._interrupt_report_event.wait()
|
|
262
|
+
return self.sent_interrupt_reports
|
|
263
|
+
|
|
264
|
+
async def wait_for_interrupt_report_id(
|
|
265
|
+
self,
|
|
266
|
+
report_id: int,
|
|
267
|
+
*,
|
|
268
|
+
max_wait: float = 0.5,
|
|
269
|
+
) -> bytes:
|
|
270
|
+
"""Wait until an interrupt report with report_id has been recorded."""
|
|
271
|
+
async with asyncio.timeout(max_wait):
|
|
272
|
+
while True:
|
|
273
|
+
for report in self._sent_interrupt_reports:
|
|
274
|
+
if report and report[0] == report_id:
|
|
275
|
+
return report
|
|
276
|
+
self._interrupt_report_event.clear()
|
|
277
|
+
await self._interrupt_report_event.wait()
|
|
278
|
+
|
|
279
|
+
async def send_control(self, payload: bytes) -> None:
|
|
280
|
+
"""Record a control report."""
|
|
281
|
+
self._require_open()
|
|
282
|
+
self._sent_control_reports.append(bytes(payload))
|
|
283
|
+
|
|
284
|
+
def on_interrupt_data(self, callback: InterruptDataCallback) -> None:
|
|
285
|
+
"""Register an interrupt data callback."""
|
|
286
|
+
self._interrupt_callback = callback
|
|
287
|
+
|
|
288
|
+
def on_control_data(self, callback: ControlDataCallback) -> None:
|
|
289
|
+
"""Register a control data callback."""
|
|
290
|
+
self._control_callback = callback
|
|
291
|
+
|
|
292
|
+
def on_connected(self, callback: ConnectedCallback) -> None:
|
|
293
|
+
"""Register a connection callback."""
|
|
294
|
+
self._connected_callback = callback
|
|
295
|
+
|
|
296
|
+
def on_disconnected(self, callback: DisconnectedCallback) -> None:
|
|
297
|
+
"""Register a disconnection callback."""
|
|
298
|
+
self._disconnected_callback = callback
|
|
299
|
+
|
|
300
|
+
def _require_open(self) -> None:
|
|
301
|
+
if not self._is_open:
|
|
302
|
+
msg = "fake transport is not open"
|
|
303
|
+
raise ClosedError(msg)
|
|
304
|
+
|
|
305
|
+
async def _emit_connected_once(self) -> None:
|
|
306
|
+
if (
|
|
307
|
+
self._connected_emitted
|
|
308
|
+
or not self._control_channel_open
|
|
309
|
+
or not self._interrupt_channel_open
|
|
310
|
+
):
|
|
311
|
+
return
|
|
312
|
+
self._connected_emitted = True
|
|
313
|
+
self._events.append("connected")
|
|
314
|
+
if self._connected_callback is not None:
|
|
315
|
+
await self._connected_callback()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swbt-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library for presenting an NX-compatible virtual Bluetooth HID input device.
|
|
5
|
+
Keywords: bluetooth,bluetooth-hid,controller,gamepad,hid,nx
|
|
6
|
+
Author: niart120
|
|
7
|
+
Author-email: niart120 <38847256+niart120@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: bumble>=0.0.230,<0.0.231
|
|
22
|
+
Maintainer: niart120
|
|
23
|
+
Maintainer-email: niart120 <38847256+niart120@users.noreply.github.com>
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# swbt-python
|
|
28
|
+
|
|
29
|
+
NX 向けの仮想 Bluetooth HID 入力デバイスを Python から扱うためのライブラリです。
|
|
30
|
+
|
|
31
|
+
本ライブラリは pre-alpha 版です。実機での動作は Bluetooth adapter、driver、対象機器の firmware に依存します。
|
|
32
|
+
|
|
33
|
+
## 必要なもの
|
|
34
|
+
|
|
35
|
+
- Python 3.12 以降
|
|
36
|
+
- uv
|
|
37
|
+
- Bumble が利用可能な専用 USB Bluetooth dongle
|
|
38
|
+
|
|
39
|
+
## インストール
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
pip install swbt-python
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
ソースから動かす場合は次を使います。
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
uv sync --dev
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## ドキュメント
|
|
52
|
+
|
|
53
|
+
[公開ドキュメント](https://niart120.github.io/swbt-python/) には API、利用例、実機準備、AI エージェント向け要約があります。
|
|
54
|
+
|
|
55
|
+
- API 仕様: [API Reference](https://niart120.github.io/swbt-python/api/)
|
|
56
|
+
- 利用例: [Usage Guide](https://niart120.github.io/swbt-python/usage/)
|
|
57
|
+
- 実機構成と troubleshooting: [Hardware Guide](https://niart120.github.io/swbt-python/hardware/)
|
|
58
|
+
- AI エージェント向け要約: [Agent Brief](https://niart120.github.io/swbt-python/agent-brief/)
|
|
59
|
+
|
|
60
|
+
リポジトリを checkout している場合、同じ内容は `docs/` 配下でも確認できます。
|
|
61
|
+
|
|
62
|
+
## 利用例
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import asyncio
|
|
66
|
+
from swbt import Button, SwitchGamepad
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def main() -> None:
|
|
70
|
+
async with SwitchGamepad(
|
|
71
|
+
adapter="usb:0",
|
|
72
|
+
key_store_path="switch-bond.json",
|
|
73
|
+
) as pad:
|
|
74
|
+
await pad.connect(
|
|
75
|
+
timeout=30.0,
|
|
76
|
+
allow_pairing=True,
|
|
77
|
+
)
|
|
78
|
+
await pad.tap(Button.A)
|
|
79
|
+
await pad.neutral()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
この例は専用 Bluetooth adapter を使い、HID advertising、pairing または reconnect、periodic report loop、入力送信を行います。専用 USB Bluetooth dongle と接続情報のファイルパスを指定し、終了時は neutral を送ってから接続を閉じます。
|
|
86
|
+
|
|
87
|
+
接続方法、`key_store_path`、入力 API の使い分けは [Usage Guide](https://niart120.github.io/swbt-python/usage/) にあります。
|
|
88
|
+
|
|
89
|
+
## 実機検証
|
|
90
|
+
|
|
91
|
+
実機接続には、PC の通常 Bluetooth 機能と共有しない専用 USB Bluetooth dongle と、OS ごとの driver 準備が必要です。Windows では、[Zadig](https://zadig.akeo.ie/) などで専用 dongle に WinUSB / libwdi driver を入れてから adapter 名を確認します。
|
|
92
|
+
|
|
93
|
+
driver 準備、adapter 名の確認、troubleshooting は [Hardware Guide](https://niart120.github.io/swbt-python/hardware/) にあります。実機ログの正本は [hardware-test-log](https://niart120.github.io/swbt-python/hardware-test-log/) です。
|
|
94
|
+
|
|
95
|
+
### 確認済み構成
|
|
96
|
+
|
|
97
|
+
2026-07-04 時点では、Windows 11 / CSR8510 A10 / WinUSB / Switch 2 firmware 22.1.0 で、pairing、reconnect、Button A、D-pad、left / right stick、neutral 後の入力残りなしを確認済みです。adapter 名の例は `usb:0` です。
|
|
98
|
+
|
|
99
|
+
### 試験的構成
|
|
100
|
+
|
|
101
|
+
Linux / macOS は experimental です。手順は Hardware Guide に整備されていますが、動作検証されていないことに留意してください。adapter が開けるか、pairing できるか、入力が反映されるかは未確認です。
|
|
102
|
+
|
|
103
|
+
CSR8510 A10 以外の Bluetooth dongle、Switch 2 firmware 22.1.0 以外の対象機器は確認済み構成に含めていません。
|
|
104
|
+
|
|
105
|
+
## 開発
|
|
106
|
+
|
|
107
|
+
```powershell
|
|
108
|
+
uv sync --dev
|
|
109
|
+
uv run ruff format --check .
|
|
110
|
+
uv run ruff check .
|
|
111
|
+
uv run ty check --no-progress
|
|
112
|
+
uv run pytest tests/unit
|
|
113
|
+
uv run pytest tests/integration
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## ライセンス
|
|
117
|
+
|
|
118
|
+
MIT ライセンスです。全文は [LICENSE](https://github.com/niart120/swbt-python/blob/main/LICENSE) にあります。
|
|
119
|
+
|
|
120
|
+
## 注記
|
|
121
|
+
|
|
122
|
+
このプロジェクトは、対象機器や関連商標の権利者から承認、後援、提携を受けたものではありません。
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
swbt/__init__.py,sha256=PuZgNf9fGz3pj6M4n6UJk3FmJBCZ_7ScVS2yH7cGvR4,977
|
|
2
|
+
swbt/diagnostics.py,sha256=vEdPBA82D-de7KpJZ3uc6nGQJHTxCx2LJrWKYfaYF-M,6839
|
|
3
|
+
swbt/errors.py,sha256=4GESTg-mBGYMHPpyiZ9FtcZGDOXH5nkFV8q0bT-MylY,848
|
|
4
|
+
swbt/gamepad/__init__.py,sha256=2lbuvAZd7L5nErkQyaoDIF5TaSDgX_HdAxMX2j7nzmY,362
|
|
5
|
+
swbt/gamepad/connection.py,sha256=eqQ2CIcbhJaT9UpAtqOKsdwObQoPlRrfBwvBJcSboSI,7477
|
|
6
|
+
swbt/gamepad/core.py,sha256=BeAGQWkoE9fMnLoulnrENyYB3iYF5v81kHm6Lw6kFAU,26994
|
|
7
|
+
swbt/gamepad/output.py,sha256=2L1yWjGGgEfG4rhc2q2tlKVW9Q6r5K_tg6NmjbMkgy4,2805
|
|
8
|
+
swbt/gamepad/transport_factory.py,sha256=ezw0Xa-zTcFZneFOF6uFlZJG8JSmyzQWbnJA0dFnLnY,666
|
|
9
|
+
swbt/input.py,sha256=wQdyNqlzv8yi95damb9eGE-sR0VHFMqRqVdUdG_Cwoo,17427
|
|
10
|
+
swbt/probe.py,sha256=E-N84PKrENvT7pm8ZxGe_Er5kHOievDimqC4O0GqXXY,4479
|
|
11
|
+
swbt/protocol/__init__.py,sha256=Uvdlp06CBsKDivW1juPY-T5LaqJVCF00MbQaHl13XPo,35
|
|
12
|
+
swbt/protocol/input_report.py,sha256=bXUFr7B0RWyFAxZXOs4D5ekOdDSm8q_H2F_V86KHJDY,2459
|
|
13
|
+
swbt/protocol/output_report.py,sha256=00d-QZRBWFJ9k5ZLl5I0nSAe2pZY0oR50Z0YvZ2_YWs,1751
|
|
14
|
+
swbt/protocol/profile.py,sha256=sIjRDrD3kvIiCNfbrc_5cuzTSiffrXg9cEp_l_qkYKQ,3340
|
|
15
|
+
swbt/protocol/rumble.py,sha256=94Qq4gstmU5KlER-i0eW3mSr1HKY3CgArdz9D5gpqso,584
|
|
16
|
+
swbt/protocol/spi.py,sha256=zBXti5vyZ0b5_cw_m_xGEWuxt-iTYejfX0D-PV9nR_E,1347
|
|
17
|
+
swbt/protocol/subcommand.py,sha256=STXPKN1sCYSxvVtykuvO_PMBjX6OdMrPcBol07N3v1M,3825
|
|
18
|
+
swbt/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
19
|
+
swbt/report_loop.py,sha256=W9cEZjs7VHpIyJ1WZwwmDeGWGiiJN4IE4ksEIhNgjps,4422
|
|
20
|
+
swbt/state_store.py,sha256=06niyjEJ9Z9ht3aJCSCvhe5R2dCmKCKuoKFafBp-JyE,2172
|
|
21
|
+
swbt/transport/__init__.py,sha256=b_Vqsg0k_g7XclN9XHQrcJxqCwGIklxG5NqzISal4_I,68
|
|
22
|
+
swbt/transport/_bumble_acl.py,sha256=RnueTwEgAd0w3YgxnXkw0AKQXMeECIRope96W-xQ1K8,1425
|
|
23
|
+
swbt/transport/_bumble_hidp.py,sha256=mL_eT-otopHzg5G0p7uhqTYsqSuDwdXAiAwakQiHbCY,835
|
|
24
|
+
swbt/transport/_bumble_key_store.py,sha256=r8rFJ1QvhnBwczEnFABE2z66YFkXU_aEu64ihHec96s,7605
|
|
25
|
+
swbt/transport/_bumble_lifecycle.py,sha256=DHEMbZOfRLHD9jF62c2zLAn-TuFLk-8WzE1OdTSxoKI,11187
|
|
26
|
+
swbt/transport/_bumble_sdp.py,sha256=swD9yhqrgjqOOpT0-rgBdtiFX1VmKjS3A6TWPu6mV0k,8132
|
|
27
|
+
swbt/transport/base.py,sha256=wv3HOhv8CDWbX0T8f687DtH01gh8TW4gsCWwIi5tKH4,2885
|
|
28
|
+
swbt/transport/bumble.py,sha256=6tzI4Pn3W1ndsbAo5LO7-7v7MfU8N2pRHeOLIvkoo4Q,27524
|
|
29
|
+
swbt/transport/fake.py,sha256=Y5gTiOrmldkxp0xdG5SxYgTXpeLW8pRRRvb3F7HL9qE,12542
|
|
30
|
+
swbt_python-0.1.0.dist-info/licenses/LICENSE,sha256=FzzUoYjuIh5DzpeaujIbDUTylYnyzcHRVU-_ZMyowyk,1065
|
|
31
|
+
swbt_python-0.1.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
|
|
32
|
+
swbt_python-0.1.0.dist-info/entry_points.txt,sha256=YYQc7VgoSy74iZ6s8Qr6iJpnvFoDpp8G4-v0KJH5TGo,48
|
|
33
|
+
swbt_python-0.1.0.dist-info/METADATA,sha256=P4iKDIXfy6Xi6Ol_HRHF_w5AHpbyKzfmzR9-bXaUbTw,4811
|
|
34
|
+
swbt_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 niart120
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|