swbt-python 0.2.0__py3-none-any.whl → 0.3.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/gamepad/output.py +13 -4
- swbt/gamepad/runtime.py +23 -4
- swbt/imu.py +116 -0
- swbt/input.py +122 -0
- swbt/protocol/imu_report.py +186 -0
- swbt/protocol/input_report.py +21 -18
- swbt/protocol/profiles/base.py +8 -0
- swbt/protocol/profiles/joycon.py +3 -3
- swbt/protocol/profiles/pro_controller.py +1 -1
- swbt/protocol/session.py +112 -0
- swbt/protocol/spi.py +12 -0
- swbt/protocol/subcommand.py +45 -40
- swbt/report_loop.py +17 -3
- {swbt_python-0.2.0.dist-info → swbt_python-0.3.0.dist-info}/METADATA +2 -3
- {swbt_python-0.2.0.dist-info → swbt_python-0.3.0.dist-info}/RECORD +18 -15
- {swbt_python-0.2.0.dist-info → swbt_python-0.3.0.dist-info}/WHEEL +0 -0
- {swbt_python-0.2.0.dist-info → swbt_python-0.3.0.dist-info}/entry_points.txt +0 -0
- {swbt_python-0.2.0.dist-info → swbt_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
swbt/gamepad/output.py
CHANGED
|
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
|
|
|
5
5
|
|
|
6
6
|
from swbt.diagnostics import DiagnosticsRecorder
|
|
7
7
|
from swbt.protocol.output_report import OutputReportParser
|
|
8
|
+
from swbt.protocol.session import SwitchHidSession
|
|
8
9
|
from swbt.protocol.subcommand import (
|
|
9
10
|
SESSION_STATE_SUBCOMMANDS,
|
|
10
11
|
SubcommandResponder,
|
|
@@ -12,7 +13,8 @@ from swbt.protocol.subcommand import (
|
|
|
12
13
|
)
|
|
13
14
|
from swbt.state_store import InputStateStore
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
ReplyBuilder = Callable[[], bytes]
|
|
17
|
+
ReplySender = Callable[[ReplyBuilder], Awaitable[bytes]]
|
|
16
18
|
ReplySenderRequirement = Callable[[], None]
|
|
17
19
|
|
|
18
20
|
|
|
@@ -23,6 +25,7 @@ class OutputReportDispatcher:
|
|
|
23
25
|
diagnostics: DiagnosticsRecorder
|
|
24
26
|
require_reply_sender: ReplySenderRequirement
|
|
25
27
|
send_subcommand_reply: ReplySender
|
|
28
|
+
session: SwitchHidSession
|
|
26
29
|
state_store: InputStateStore
|
|
27
30
|
output_report_parser: OutputReportParser = field(default_factory=OutputReportParser)
|
|
28
31
|
subcommand_responder: SubcommandResponder = field(default_factory=SubcommandResponder)
|
|
@@ -49,7 +52,13 @@ class OutputReportDispatcher:
|
|
|
49
52
|
self.require_reply_sender()
|
|
50
53
|
state = await self.state_store.snapshot()
|
|
51
54
|
try:
|
|
52
|
-
reply = self.
|
|
55
|
+
reply = await self.send_subcommand_reply(
|
|
56
|
+
lambda: self.subcommand_responder.respond(
|
|
57
|
+
output_report,
|
|
58
|
+
state=state,
|
|
59
|
+
session=self.session,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
53
62
|
except UnsupportedSubcommandError:
|
|
54
63
|
self.diagnostics.record_event(
|
|
55
64
|
"unsupported_subcommand",
|
|
@@ -59,10 +68,11 @@ class OutputReportDispatcher:
|
|
|
59
68
|
)
|
|
60
69
|
raise
|
|
61
70
|
if output_report.subcommand_id in SESSION_STATE_SUBCOMMANDS:
|
|
62
|
-
session_state = self.
|
|
71
|
+
session_state = self.session.state
|
|
63
72
|
self.diagnostics.record_event(
|
|
64
73
|
"subcommand_session_state",
|
|
65
74
|
imu_enabled=session_state.imu_enabled,
|
|
75
|
+
imu_encoding_format=session_state.imu_encoding_format,
|
|
66
76
|
imu_mode=_format_optional_byte(session_state.imu_mode),
|
|
67
77
|
packet_id=output_report.packet_id,
|
|
68
78
|
report_mode=_format_optional_byte(session_state.report_mode),
|
|
@@ -79,7 +89,6 @@ class OutputReportDispatcher:
|
|
|
79
89
|
report_id=_format_report_id(reply[0]),
|
|
80
90
|
subcommand_id=subcommand_id,
|
|
81
91
|
)
|
|
82
|
-
await self.send_subcommand_reply(reply)
|
|
83
92
|
|
|
84
93
|
|
|
85
94
|
def _format_report_id(report_id: int) -> str:
|
swbt/gamepad/runtime.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Stateful runtime for gamepad lifecycle and input behavior."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
from collections.abc import Callable
|
|
4
5
|
from dataclasses import replace
|
|
5
6
|
from types import TracebackType
|
|
6
7
|
|
|
@@ -26,6 +27,7 @@ from swbt.gamepad.transport_factory import (
|
|
|
26
27
|
from swbt.input import Button, IMUFrame, InputState, Stick
|
|
27
28
|
from swbt.protocol.input_report import InputReportBuilder
|
|
28
29
|
from swbt.protocol.profiles.base import ControllerColors
|
|
30
|
+
from swbt.protocol.session import SwitchHidSession
|
|
29
31
|
from swbt.protocol.subcommand import SubcommandResponder
|
|
30
32
|
from swbt.report_loop import ReportLoop
|
|
31
33
|
from swbt.state_store import InputStateStore
|
|
@@ -103,12 +105,16 @@ class ControllerRuntime:
|
|
|
103
105
|
controller_colors=self._config.controller_colors
|
|
104
106
|
or self._config.profile.controller_colors,
|
|
105
107
|
)
|
|
108
|
+
self._protocol_session = SwitchHidSession(self._controller_profile)
|
|
106
109
|
self._output_report_dispatcher = OutputReportDispatcher(
|
|
107
110
|
diagnostics=self._diagnostics,
|
|
108
111
|
require_reply_sender=self._require_subcommand_reply_sender,
|
|
109
112
|
send_subcommand_reply=self._send_subcommand_reply,
|
|
113
|
+
session=self._protocol_session,
|
|
110
114
|
state_store=self._state_store,
|
|
111
|
-
subcommand_responder=SubcommandResponder(
|
|
115
|
+
subcommand_responder=SubcommandResponder(
|
|
116
|
+
profile=self._controller_profile,
|
|
117
|
+
),
|
|
112
118
|
)
|
|
113
119
|
self._report_loop: ReportLoop | None = None
|
|
114
120
|
self._lifecycle_lock = asyncio.Lock()
|
|
@@ -198,6 +204,7 @@ class ControllerRuntime:
|
|
|
198
204
|
transport = self._ensure_transport()
|
|
199
205
|
self._record_run_metadata()
|
|
200
206
|
self._connection_state = "opening"
|
|
207
|
+
self._reset_protocol_session()
|
|
201
208
|
self._register_transport_callbacks()
|
|
202
209
|
self._connected_event.clear()
|
|
203
210
|
try:
|
|
@@ -207,7 +214,10 @@ class ControllerRuntime:
|
|
|
207
214
|
transport=transport,
|
|
208
215
|
state_store=self._state_store,
|
|
209
216
|
report_period_us=self._config.report_period_us,
|
|
210
|
-
input_report_builder=InputReportBuilder(
|
|
217
|
+
input_report_builder=InputReportBuilder(
|
|
218
|
+
self._controller_profile,
|
|
219
|
+
),
|
|
220
|
+
session=self._protocol_session,
|
|
211
221
|
diagnostics=self._diagnostics,
|
|
212
222
|
)
|
|
213
223
|
self._connection_state = "opened"
|
|
@@ -219,6 +229,15 @@ class ControllerRuntime:
|
|
|
219
229
|
self._is_open = False
|
|
220
230
|
raise
|
|
221
231
|
|
|
232
|
+
def _reset_protocol_session(self) -> None:
|
|
233
|
+
"""Create fresh host-requested state for the next HID connection."""
|
|
234
|
+
self._protocol_session = SwitchHidSession(self._controller_profile)
|
|
235
|
+
self._output_report_dispatcher.session = self._protocol_session
|
|
236
|
+
self._output_report_dispatcher.subcommand_responder = SubcommandResponder(
|
|
237
|
+
profile=self._controller_profile,
|
|
238
|
+
)
|
|
239
|
+
self._configured_device_info_bluetooth_address = None
|
|
240
|
+
|
|
222
241
|
async def pair(self, timeout: float | None = None) -> None: # noqa: ASYNC109
|
|
223
242
|
"""Start pairing advertising and wait for a host connection.
|
|
224
243
|
|
|
@@ -583,8 +602,8 @@ class ControllerRuntime:
|
|
|
583
602
|
def _require_subcommand_reply_sender(self) -> None:
|
|
584
603
|
_ = self._subcommand_reply_sender()
|
|
585
604
|
|
|
586
|
-
async def _send_subcommand_reply(self,
|
|
587
|
-
await self._subcommand_reply_sender().send_subcommand_reply(
|
|
605
|
+
async def _send_subcommand_reply(self, build_reply: Callable[[], bytes]) -> bytes:
|
|
606
|
+
return await self._subcommand_reply_sender().send_subcommand_reply(build_reply)
|
|
588
607
|
|
|
589
608
|
def _subcommand_reply_sender(self) -> ReportLoop:
|
|
590
609
|
if self._report_loop is None:
|
swbt/imu.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Shared virtual IMU calibration values."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from math import degrees, isfinite, radians
|
|
5
|
+
from struct import pack
|
|
6
|
+
|
|
7
|
+
from swbt.errors import InvalidInputError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class _ThreeAxisCalibration:
|
|
12
|
+
"""Shared XYZ zero/reference model for factory sensor calibration."""
|
|
13
|
+
|
|
14
|
+
zero_raw: tuple[int, int, int] = (0, 0, 0)
|
|
15
|
+
reference_raw: tuple[int, int, int] = (0, 0, 0)
|
|
16
|
+
|
|
17
|
+
def to_spi_bytes(self) -> bytes:
|
|
18
|
+
"""Return zero XYZ followed by reference XYZ as signed Int16LE."""
|
|
19
|
+
return pack("<6h", *self.zero_raw, *self.reference_raw)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AccelerometerCalibration(_ThreeAxisCalibration):
|
|
24
|
+
"""Virtual accelerometer calibration and fixed conversion scale."""
|
|
25
|
+
|
|
26
|
+
reference_raw: tuple[int, int, int] = (0x4000, 0x4000, 0x4000)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def reference_acceleration_g(self) -> float:
|
|
30
|
+
"""Return the fixed reference acceleration in G."""
|
|
31
|
+
return 4.0
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def g_per_raw(self) -> float:
|
|
35
|
+
"""Return the fixed accelerometer sensitivity in G per raw unit."""
|
|
36
|
+
return 1 / 4096
|
|
37
|
+
|
|
38
|
+
def accelerations_to_raw(
|
|
39
|
+
self,
|
|
40
|
+
accelerations_g: tuple[float, float, float],
|
|
41
|
+
) -> tuple[int, int, int]:
|
|
42
|
+
"""Convert XYZ accelerations in G to raw values."""
|
|
43
|
+
values = tuple(
|
|
44
|
+
self._validate_acceleration(f"{axis}_g", value)
|
|
45
|
+
for axis, value in zip("xyz", accelerations_g, strict=True)
|
|
46
|
+
)
|
|
47
|
+
converted = tuple(
|
|
48
|
+
zero + round(value / self.g_per_raw)
|
|
49
|
+
for zero, value in zip(self.zero_raw, values, strict=True)
|
|
50
|
+
)
|
|
51
|
+
return (converted[0], converted[1], converted[2])
|
|
52
|
+
|
|
53
|
+
def raw_to_accelerations(
|
|
54
|
+
self,
|
|
55
|
+
raw: tuple[int, int, int],
|
|
56
|
+
) -> tuple[float, float, float]:
|
|
57
|
+
"""Convert XYZ raw values to accelerations in G."""
|
|
58
|
+
converted = tuple(
|
|
59
|
+
(value - zero) * self.g_per_raw for zero, value in zip(self.zero_raw, raw, strict=True)
|
|
60
|
+
)
|
|
61
|
+
return (converted[0], converted[1], converted[2])
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _validate_acceleration(name: str, value: object) -> float:
|
|
65
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or not isfinite(value):
|
|
66
|
+
msg = f"{name} must be a finite number: {value}"
|
|
67
|
+
raise InvalidInputError(msg)
|
|
68
|
+
return float(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class GyroCalibration(_ThreeAxisCalibration):
|
|
73
|
+
"""Virtual gyroscope zero, reference, and fixed conversion scale."""
|
|
74
|
+
|
|
75
|
+
reference_raw: tuple[int, int, int] = (0x343B, 0x343B, 0x343B)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def dps_per_raw(self) -> float:
|
|
79
|
+
"""Return the fixed gyroscope sensitivity in degrees per second per raw unit."""
|
|
80
|
+
return 0.070
|
|
81
|
+
|
|
82
|
+
def gyro_rates_to_raw(
|
|
83
|
+
self,
|
|
84
|
+
rates_rad_s: tuple[float, float, float],
|
|
85
|
+
) -> tuple[int, int, int]:
|
|
86
|
+
"""Convert XYZ angular velocities in radians per second to raw values."""
|
|
87
|
+
x_rad_s = self._validate_rate("x_rad_s", rates_rad_s[0])
|
|
88
|
+
y_rad_s = self._validate_rate("y_rad_s", rates_rad_s[1])
|
|
89
|
+
z_rad_s = self._validate_rate("z_rad_s", rates_rad_s[2])
|
|
90
|
+
return (
|
|
91
|
+
self.zero_raw[0] + round(degrees(x_rad_s) / self.dps_per_raw),
|
|
92
|
+
self.zero_raw[1] + round(degrees(y_rad_s) / self.dps_per_raw),
|
|
93
|
+
self.zero_raw[2] + round(degrees(z_rad_s) / self.dps_per_raw),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def raw_to_gyro_rates(
|
|
97
|
+
self,
|
|
98
|
+
raw: tuple[int, int, int],
|
|
99
|
+
) -> tuple[float, float, float]:
|
|
100
|
+
"""Convert XYZ raw values to angular velocities in radians per second."""
|
|
101
|
+
return (
|
|
102
|
+
radians((raw[0] - self.zero_raw[0]) * self.dps_per_raw),
|
|
103
|
+
radians((raw[1] - self.zero_raw[1]) * self.dps_per_raw),
|
|
104
|
+
radians((raw[2] - self.zero_raw[2]) * self.dps_per_raw),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _validate_rate(name: str, value: object) -> float:
|
|
109
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or not isfinite(value):
|
|
110
|
+
msg = f"{name} must be a finite number: {value}"
|
|
111
|
+
raise InvalidInputError(msg)
|
|
112
|
+
return float(value)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
DEFAULT_ACCELEROMETER_CALIBRATION = AccelerometerCalibration()
|
|
116
|
+
DEFAULT_GYRO_CALIBRATION = GyroCalibration()
|
swbt/input.py
CHANGED
|
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
|
|
5
5
|
from enum import Enum, auto
|
|
6
6
|
|
|
7
7
|
from swbt.errors import InvalidInputError
|
|
8
|
+
from swbt.imu import DEFAULT_ACCELEROMETER_CALIBRATION, DEFAULT_GYRO_CALIBRATION
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class Button(Enum):
|
|
@@ -309,6 +310,42 @@ class IMUFrame:
|
|
|
309
310
|
"""
|
|
310
311
|
return cls.raw(gyro=(x, y, z))
|
|
311
312
|
|
|
313
|
+
@classmethod
|
|
314
|
+
def gyro_rate(
|
|
315
|
+
cls,
|
|
316
|
+
*,
|
|
317
|
+
x_rad_s: float = 0.0,
|
|
318
|
+
y_rad_s: float = 0.0,
|
|
319
|
+
z_rad_s: float = 0.0,
|
|
320
|
+
) -> "IMUFrame":
|
|
321
|
+
"""Return a frame from XYZ gyroscope rates in radians per second.
|
|
322
|
+
|
|
323
|
+
Conversion uses the fixed virtual sensitivity of ``0.070 dps/raw``.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
x_rad_s: X-axis angular velocity in radians per second.
|
|
327
|
+
y_rad_s: Y-axis angular velocity in radians per second.
|
|
328
|
+
z_rad_s: Z-axis angular velocity in radians per second.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
IMUFrame: Frame with converted gyroscope raw values and zero accelerometer.
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
InvalidInputError: A converted raw value is outside the signed 16-bit range.
|
|
335
|
+
"""
|
|
336
|
+
return cls.raw(gyro=DEFAULT_GYRO_CALIBRATION.gyro_rates_to_raw((x_rad_s, y_rad_s, z_rad_s)))
|
|
337
|
+
|
|
338
|
+
def to_gyro_rate(self) -> tuple[float, float, float]:
|
|
339
|
+
"""Return XYZ gyroscope rates in radians per second.
|
|
340
|
+
|
|
341
|
+
Conversion uses the fixed virtual sensitivity of ``0.070 dps/raw``.
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
tuple[float, float, float]: X, Y, and Z angular velocities in radians per
|
|
345
|
+
second.
|
|
346
|
+
"""
|
|
347
|
+
return DEFAULT_GYRO_CALIBRATION.raw_to_gyro_rates((self.gyro_x, self.gyro_y, self.gyro_z))
|
|
348
|
+
|
|
312
349
|
@classmethod
|
|
313
350
|
def accel(cls, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
|
|
314
351
|
"""Return an IMU frame with only accelerometer axes set.
|
|
@@ -326,6 +363,41 @@ class IMUFrame:
|
|
|
326
363
|
"""
|
|
327
364
|
return cls.raw(accel=(x, y, z))
|
|
328
365
|
|
|
366
|
+
@classmethod
|
|
367
|
+
def accel_g(
|
|
368
|
+
cls,
|
|
369
|
+
*,
|
|
370
|
+
x_g: float = 0.0,
|
|
371
|
+
y_g: float = 0.0,
|
|
372
|
+
z_g: float = 0.0,
|
|
373
|
+
) -> "IMUFrame":
|
|
374
|
+
"""Return a frame from XYZ accelerations in G using ``1/4096 G/raw``.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
x_g: X-axis acceleration in G.
|
|
378
|
+
y_g: Y-axis acceleration in G.
|
|
379
|
+
z_g: Z-axis acceleration in G.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
IMUFrame: Frame with converted accelerometer raw values and zero gyroscope.
|
|
383
|
+
|
|
384
|
+
Raises:
|
|
385
|
+
InvalidInputError: A value is non-finite or converts outside signed 16-bit.
|
|
386
|
+
"""
|
|
387
|
+
return cls.raw(
|
|
388
|
+
accel=DEFAULT_ACCELEROMETER_CALIBRATION.accelerations_to_raw((x_g, y_g, z_g))
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def to_accel_g(self) -> tuple[float, float, float]:
|
|
392
|
+
"""Return XYZ accelerations in G using ``1/4096 G/raw``.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
tuple[float, float, float]: X, Y, and Z accelerations in G.
|
|
396
|
+
"""
|
|
397
|
+
return DEFAULT_ACCELEROMETER_CALIBRATION.raw_to_accelerations(
|
|
398
|
+
(self.accel_x, self.accel_y, self.accel_z)
|
|
399
|
+
)
|
|
400
|
+
|
|
329
401
|
def with_gyro(self, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
|
|
330
402
|
"""Return a frame with replaced gyroscope axes.
|
|
331
403
|
|
|
@@ -345,6 +417,32 @@ class IMUFrame:
|
|
|
345
417
|
gyro=(x, y, z),
|
|
346
418
|
)
|
|
347
419
|
|
|
420
|
+
def with_gyro_rate(
|
|
421
|
+
self,
|
|
422
|
+
*,
|
|
423
|
+
x_rad_s: float = 0.0,
|
|
424
|
+
y_rad_s: float = 0.0,
|
|
425
|
+
z_rad_s: float = 0.0,
|
|
426
|
+
) -> "IMUFrame":
|
|
427
|
+
"""Return a frame with gyroscope rates replaced from radians per second.
|
|
428
|
+
|
|
429
|
+
Conversion uses the fixed virtual sensitivity of ``0.070 dps/raw``.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
x_rad_s: Replacement X-axis angular velocity in radians per second.
|
|
433
|
+
y_rad_s: Replacement Y-axis angular velocity in radians per second.
|
|
434
|
+
z_rad_s: Replacement Z-axis angular velocity in radians per second.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
IMUFrame: Copy of this frame with accelerometer axes preserved.
|
|
438
|
+
|
|
439
|
+
Raises:
|
|
440
|
+
InvalidInputError: A converted raw value is outside the signed 16-bit range.
|
|
441
|
+
"""
|
|
442
|
+
return self.with_gyro(
|
|
443
|
+
*DEFAULT_GYRO_CALIBRATION.gyro_rates_to_raw((x_rad_s, y_rad_s, z_rad_s))
|
|
444
|
+
)
|
|
445
|
+
|
|
348
446
|
def with_accel(self, x: int = 0, y: int = 0, z: int = 0) -> "IMUFrame":
|
|
349
447
|
"""Return a frame with replaced accelerometer axes.
|
|
350
448
|
|
|
@@ -364,6 +462,30 @@ class IMUFrame:
|
|
|
364
462
|
gyro=(self.gyro_x, self.gyro_y, self.gyro_z),
|
|
365
463
|
)
|
|
366
464
|
|
|
465
|
+
def with_accel_g(
|
|
466
|
+
self,
|
|
467
|
+
*,
|
|
468
|
+
x_g: float = 0.0,
|
|
469
|
+
y_g: float = 0.0,
|
|
470
|
+
z_g: float = 0.0,
|
|
471
|
+
) -> "IMUFrame":
|
|
472
|
+
"""Return a frame with accelerations replaced using ``1/4096 G/raw``.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
x_g: Replacement X-axis acceleration in G.
|
|
476
|
+
y_g: Replacement Y-axis acceleration in G.
|
|
477
|
+
z_g: Replacement Z-axis acceleration in G.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
IMUFrame: Copy of this frame with gyroscope axes preserved.
|
|
481
|
+
|
|
482
|
+
Raises:
|
|
483
|
+
InvalidInputError: A value is non-finite or converts outside signed 16-bit.
|
|
484
|
+
"""
|
|
485
|
+
return self.with_accel(
|
|
486
|
+
*DEFAULT_ACCELEROMETER_CALIBRATION.accelerations_to_raw((x_g, y_g, z_g))
|
|
487
|
+
)
|
|
488
|
+
|
|
367
489
|
@classmethod
|
|
368
490
|
def _validate_i16(cls, field_name: str, value: object) -> int:
|
|
369
491
|
if not isinstance(value, int) or not cls.MIN <= value <= cls.MAX:
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Pure IMU wire encoding from explicit state and report time."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from math import cos, sin, sqrt
|
|
6
|
+
from struct import pack_into
|
|
7
|
+
|
|
8
|
+
from swbt.imu import GyroCalibration
|
|
9
|
+
from swbt.input import IMUFrame
|
|
10
|
+
|
|
11
|
+
_Quaternion = tuple[float, float, float, float]
|
|
12
|
+
_IDENTITY_QUATERNION: _Quaternion = (0.0, 0.0, 0.0, 1.0)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ImuMode(IntEnum):
|
|
16
|
+
"""Host-selected IMU wire mode."""
|
|
17
|
+
|
|
18
|
+
DISABLED = 0x00
|
|
19
|
+
STANDARD = 0x01
|
|
20
|
+
QUATERNION_1 = 0x02
|
|
21
|
+
QUATERNION_2 = 0x03
|
|
22
|
+
QUATERNION_3 = 0x04
|
|
23
|
+
QUATERNION_4 = 0x05
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ImuEncodingState:
|
|
28
|
+
"""State required to encode the next quaternion IMU block."""
|
|
29
|
+
|
|
30
|
+
orientation: _Quaternion = _IDENTITY_QUATERNION
|
|
31
|
+
previous_report_ns: int | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ImuEncodingResult:
|
|
36
|
+
"""One encoded IMU block and the state for the next report."""
|
|
37
|
+
|
|
38
|
+
block: bytes
|
|
39
|
+
state: ImuEncodingState
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def encode_disabled_imu() -> ImuEncodingResult:
|
|
43
|
+
"""Encode the disabled IMU block and reset its encoding state."""
|
|
44
|
+
return ImuEncodingResult(block=bytes(36), state=ImuEncodingState())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def encode_imu_block(
|
|
48
|
+
*,
|
|
49
|
+
state: ImuEncodingState,
|
|
50
|
+
mode: ImuMode,
|
|
51
|
+
frames: tuple[IMUFrame, IMUFrame, IMUFrame],
|
|
52
|
+
gyro_calibration: GyroCalibration,
|
|
53
|
+
now_ns: int,
|
|
54
|
+
) -> ImuEncodingResult:
|
|
55
|
+
"""Encode one IMU block for the explicit host-selected mode."""
|
|
56
|
+
if mode is ImuMode.DISABLED:
|
|
57
|
+
return encode_disabled_imu()
|
|
58
|
+
if mode is ImuMode.STANDARD:
|
|
59
|
+
return ImuEncodingResult(
|
|
60
|
+
block=encode_standard_imu(frames),
|
|
61
|
+
state=ImuEncodingState(),
|
|
62
|
+
)
|
|
63
|
+
return encode_quaternion_imu(
|
|
64
|
+
state=state,
|
|
65
|
+
frames=frames,
|
|
66
|
+
gyro_calibration=gyro_calibration,
|
|
67
|
+
now_ns=now_ns,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def encode_standard_imu(frames: tuple[IMUFrame, IMUFrame, IMUFrame]) -> bytes:
|
|
72
|
+
"""Encode three raw six-axis frames as signed Int16LE values."""
|
|
73
|
+
result = bytearray(36)
|
|
74
|
+
for offset, frame in zip((0, 12, 24), frames, strict=True):
|
|
75
|
+
pack_into(
|
|
76
|
+
"<6h",
|
|
77
|
+
result,
|
|
78
|
+
offset,
|
|
79
|
+
frame.accel_x,
|
|
80
|
+
frame.accel_y,
|
|
81
|
+
frame.accel_z,
|
|
82
|
+
frame.gyro_x,
|
|
83
|
+
frame.gyro_y,
|
|
84
|
+
frame.gyro_z,
|
|
85
|
+
)
|
|
86
|
+
return bytes(result)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def encode_quaternion_imu(
|
|
90
|
+
*,
|
|
91
|
+
state: ImuEncodingState,
|
|
92
|
+
frames: tuple[IMUFrame, IMUFrame, IMUFrame],
|
|
93
|
+
gyro_calibration: GyroCalibration,
|
|
94
|
+
now_ns: int,
|
|
95
|
+
) -> ImuEncodingResult:
|
|
96
|
+
"""Encode one 36-byte packing-mode-2 block without mutating inputs."""
|
|
97
|
+
elapsed_seconds = 0.0
|
|
98
|
+
if state.previous_report_ns is not None:
|
|
99
|
+
elapsed_seconds = max(0, now_ns - state.previous_report_ns) / 1_000_000_000
|
|
100
|
+
|
|
101
|
+
orientation = state.orientation
|
|
102
|
+
sample_seconds = elapsed_seconds / len(frames)
|
|
103
|
+
for frame in frames:
|
|
104
|
+
rates = gyro_calibration.raw_to_gyro_rates((frame.gyro_x, frame.gyro_y, frame.gyro_z))
|
|
105
|
+
orientation = _next_orientation(orientation, rates, sample_seconds)
|
|
106
|
+
|
|
107
|
+
next_state = ImuEncodingState(
|
|
108
|
+
orientation=orientation,
|
|
109
|
+
previous_report_ns=now_ns,
|
|
110
|
+
)
|
|
111
|
+
return ImuEncodingResult(
|
|
112
|
+
block=_pack_mode_2(frames, orientation, timestamp_ms=now_ns // 1_000_000),
|
|
113
|
+
state=next_state,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _next_orientation(
|
|
118
|
+
orientation: _Quaternion,
|
|
119
|
+
rates_rad_s: tuple[float, float, float],
|
|
120
|
+
elapsed_seconds: float,
|
|
121
|
+
) -> _Quaternion:
|
|
122
|
+
rotation = tuple(rate * elapsed_seconds for rate in rates_rad_s)
|
|
123
|
+
magnitude = sqrt(sum(value * value for value in rotation))
|
|
124
|
+
if magnitude == 0:
|
|
125
|
+
return orientation
|
|
126
|
+
half_angle = magnitude / 2
|
|
127
|
+
vector_scale = sin(half_angle) / magnitude
|
|
128
|
+
delta = (
|
|
129
|
+
rotation[0] * vector_scale,
|
|
130
|
+
rotation[1] * vector_scale,
|
|
131
|
+
rotation[2] * vector_scale,
|
|
132
|
+
cos(half_angle),
|
|
133
|
+
)
|
|
134
|
+
return _normalize(_hamilton_product(orientation, delta))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _hamilton_product(left: _Quaternion, right: _Quaternion) -> _Quaternion:
|
|
138
|
+
lx, ly, lz, lw = left
|
|
139
|
+
rx, ry, rz, rw = right
|
|
140
|
+
return (
|
|
141
|
+
lw * rx + lx * rw + ly * rz - lz * ry,
|
|
142
|
+
lw * ry + ly * rw + lz * rx - lx * rz,
|
|
143
|
+
lw * rz + lz * rw + lx * ry - ly * rx,
|
|
144
|
+
lw * rw - lx * rx - ly * ry - lz * rz,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _normalize(value: _Quaternion) -> _Quaternion:
|
|
149
|
+
inverse = 1 / sqrt(sum(component * component for component in value))
|
|
150
|
+
return (value[0] * inverse, value[1] * inverse, value[2] * inverse, value[3] * inverse)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _pack_mode_2(
|
|
154
|
+
frames: tuple[IMUFrame, IMUFrame, IMUFrame],
|
|
155
|
+
orientation: _Quaternion,
|
|
156
|
+
*,
|
|
157
|
+
timestamp_ms: int,
|
|
158
|
+
) -> bytes:
|
|
159
|
+
result = bytearray(36)
|
|
160
|
+
for offset, frame in zip((0, 12, 24), frames, strict=True):
|
|
161
|
+
pack_into("<3h", result, offset, frame.accel_x, frame.accel_y, frame.accel_z)
|
|
162
|
+
|
|
163
|
+
max_index = max(range(4), key=lambda index: abs(orientation[index]))
|
|
164
|
+
sign = -1 if orientation[max_index] < 0 else 1
|
|
165
|
+
components = [
|
|
166
|
+
int(orientation[(max_index + index + 1) & 3] * sign * 0x40000000) >> 10
|
|
167
|
+
for index in range(3)
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
_put_bits(result, 48, 2, 2)
|
|
171
|
+
_put_bits(result, 50, 2, max_index)
|
|
172
|
+
_put_bits(result, 52, 21, components[0])
|
|
173
|
+
_put_bits(result, 73, 21, components[1])
|
|
174
|
+
_put_bits(result, 94, 2, components[2])
|
|
175
|
+
_put_bits(result, 144, 19, components[2] >> 2)
|
|
176
|
+
_put_bits(result, 271, 11, timestamp_ms)
|
|
177
|
+
_put_bits(result, 282, 6, 3)
|
|
178
|
+
return bytes(result)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _put_bits(target: bytearray, start: int, width: int, value: int) -> None:
|
|
182
|
+
masked = value & ((1 << width) - 1)
|
|
183
|
+
for bit in range(width):
|
|
184
|
+
if masked & (1 << bit):
|
|
185
|
+
absolute = start + bit
|
|
186
|
+
target[absolute // 8] |= 1 << (absolute % 8)
|
swbt/protocol/input_report.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Input report builders."""
|
|
2
2
|
|
|
3
3
|
from swbt.input import InputState, Stick
|
|
4
|
+
from swbt.protocol.imu_report import encode_standard_imu
|
|
4
5
|
from swbt.protocol.profiles.base import ControllerProfile
|
|
5
6
|
from swbt.protocol.profiles.pro_controller import default_controller_profile
|
|
6
7
|
|
|
@@ -8,11 +9,20 @@ from swbt.protocol.profiles.pro_controller import default_controller_profile
|
|
|
8
9
|
class InputReportBuilder:
|
|
9
10
|
"""Build Switch HID input reports from immutable input state."""
|
|
10
11
|
|
|
11
|
-
def __init__(
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
profile: ControllerProfile | None = None,
|
|
15
|
+
) -> None:
|
|
12
16
|
"""Create a report builder."""
|
|
13
17
|
self._profile = profile or default_controller_profile()
|
|
14
18
|
|
|
15
|
-
def build_0x30(
|
|
19
|
+
def build_0x30(
|
|
20
|
+
self,
|
|
21
|
+
state: InputState,
|
|
22
|
+
*,
|
|
23
|
+
timer: int = 0,
|
|
24
|
+
imu_block: bytes | None = None,
|
|
25
|
+
) -> bytes:
|
|
16
26
|
"""Build a 0x30 standard full input report."""
|
|
17
27
|
self._profile.validate_input_state(state)
|
|
18
28
|
report = bytearray(49)
|
|
@@ -23,9 +33,17 @@ class InputReportBuilder:
|
|
|
23
33
|
report[6:9] = self._pack_stick(state.left_stick)
|
|
24
34
|
report[9:12] = self._pack_stick(state.right_stick)
|
|
25
35
|
report[12] = self._profile.vibrator_input
|
|
26
|
-
|
|
36
|
+
block = encode_standard_imu(state.imu_frames) if imu_block is None else imu_block
|
|
37
|
+
self._place_imu_block(report, block)
|
|
27
38
|
return bytes(report)
|
|
28
39
|
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _place_imu_block(report: bytearray, imu_block: bytes) -> None:
|
|
42
|
+
if len(imu_block) != 36:
|
|
43
|
+
msg = f"IMU block must be 36 bytes, got {len(imu_block)}"
|
|
44
|
+
raise ValueError(msg)
|
|
45
|
+
report[13:49] = imu_block
|
|
46
|
+
|
|
29
47
|
def _pack_buttons(self, report: bytearray, state: InputState) -> None:
|
|
30
48
|
for button in state.buttons:
|
|
31
49
|
offset, mask = self._profile.button_bit(button)
|
|
@@ -40,18 +58,3 @@ class InputReportBuilder:
|
|
|
40
58
|
(stick.y >> 4) & 0xFF,
|
|
41
59
|
)
|
|
42
60
|
)
|
|
43
|
-
|
|
44
|
-
@staticmethod
|
|
45
|
-
def _pack_imu_frames(report: bytearray, state: InputState) -> None:
|
|
46
|
-
cursor = 13
|
|
47
|
-
for frame in state.imu_frames:
|
|
48
|
-
for value in (
|
|
49
|
-
frame.accel_x,
|
|
50
|
-
frame.accel_y,
|
|
51
|
-
frame.accel_z,
|
|
52
|
-
frame.gyro_x,
|
|
53
|
-
frame.gyro_y,
|
|
54
|
-
frame.gyro_z,
|
|
55
|
-
):
|
|
56
|
-
report[cursor : cursor + 2] = int(value).to_bytes(2, "little", signed=True)
|
|
57
|
-
cursor += 2
|
swbt/protocol/profiles/base.py
CHANGED
|
@@ -4,6 +4,12 @@ from dataclasses import dataclass, field
|
|
|
4
4
|
from enum import Enum
|
|
5
5
|
|
|
6
6
|
from swbt.errors import InvalidInputError, UnsupportedInputError
|
|
7
|
+
from swbt.imu import (
|
|
8
|
+
DEFAULT_ACCELEROMETER_CALIBRATION,
|
|
9
|
+
DEFAULT_GYRO_CALIBRATION,
|
|
10
|
+
AccelerometerCalibration,
|
|
11
|
+
GyroCalibration,
|
|
12
|
+
)
|
|
7
13
|
from swbt.input import Button, InputState, Stick
|
|
8
14
|
from swbt.protocol.buttons import PRO_CONTROLLER_BUTTON_BITS, ButtonBitMap
|
|
9
15
|
from swbt.protocol.descriptors import SWITCH_PRO_CONTROLLER_HID_REPORT_DESCRIPTOR
|
|
@@ -126,6 +132,8 @@ class ControllerProfile:
|
|
|
126
132
|
hid_report_descriptor: bytes = SWITCH_PRO_CONTROLLER_HID_REPORT_DESCRIPTOR
|
|
127
133
|
hid_sdp_policy: HidSdpPolicy = field(default_factory=HidSdpPolicy)
|
|
128
134
|
controller_colors: ControllerColors = field(default_factory=ControllerColors)
|
|
135
|
+
accelerometer_calibration: AccelerometerCalibration = DEFAULT_ACCELEROMETER_CALIBRATION
|
|
136
|
+
gyro_calibration: GyroCalibration = DEFAULT_GYRO_CALIBRATION
|
|
129
137
|
button_bits: ButtonBitMap = field(default_factory=lambda: PRO_CONTROLLER_BUTTON_BITS)
|
|
130
138
|
imu_enable_modes: tuple[int, ...] = (0x00, 0x01)
|
|
131
139
|
supports_left_stick: bool = True
|
swbt/protocol/profiles/joycon.py
CHANGED
|
@@ -14,7 +14,7 @@ from swbt.protocol.profiles.base import (
|
|
|
14
14
|
HidSdpPolicy,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
_JOYCON_IMU_ENABLE_MODES = (0x00, 0x01, 0x02, 0x03, 0x04, 0x05)
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _joycontrol_hid_sdp_policy() -> HidSdpPolicy:
|
|
@@ -63,7 +63,7 @@ class JoyConLeftProfile(ControllerProfile):
|
|
|
63
63
|
hid_sdp_policy: HidSdpPolicy = field(default_factory=_joycontrol_hid_sdp_policy)
|
|
64
64
|
controller_colors: ControllerColors = field(default_factory=_joycon_left_controller_colors)
|
|
65
65
|
button_bits: ButtonBitMap = field(default_factory=lambda: JOYCON_LEFT_BUTTON_BITS)
|
|
66
|
-
imu_enable_modes: tuple[int, ...] =
|
|
66
|
+
imu_enable_modes: tuple[int, ...] = _JOYCON_IMU_ENABLE_MODES
|
|
67
67
|
supports_left_stick: bool = True
|
|
68
68
|
supports_right_stick: bool = False
|
|
69
69
|
|
|
@@ -79,6 +79,6 @@ class JoyConRightProfile(ControllerProfile):
|
|
|
79
79
|
hid_sdp_policy: HidSdpPolicy = field(default_factory=_joycontrol_hid_sdp_policy)
|
|
80
80
|
controller_colors: ControllerColors = field(default_factory=_joycon_right_controller_colors)
|
|
81
81
|
button_bits: ButtonBitMap = field(default_factory=lambda: JOYCON_RIGHT_BUTTON_BITS)
|
|
82
|
-
imu_enable_modes: tuple[int, ...] =
|
|
82
|
+
imu_enable_modes: tuple[int, ...] = _JOYCON_IMU_ENABLE_MODES
|
|
83
83
|
supports_left_stick: bool = False
|
|
84
84
|
supports_right_stick: bool = True
|
|
@@ -15,7 +15,7 @@ class ProControllerProfile(ControllerProfile):
|
|
|
15
15
|
device_type: int = 0x03
|
|
16
16
|
device_info_tail: bytes = b"\x03\x02"
|
|
17
17
|
button_bits: ButtonBitMap = field(default_factory=lambda: PRO_CONTROLLER_BUTTON_BITS)
|
|
18
|
-
imu_enable_modes: tuple[int, ...] = (0x00, 0x01, 0x02)
|
|
18
|
+
imu_enable_modes: tuple[int, ...] = (0x00, 0x01, 0x02, 0x03, 0x04, 0x05)
|
|
19
19
|
supports_left_stick: bool = True
|
|
20
20
|
supports_right_stick: bool = True
|
|
21
21
|
|
swbt/protocol/session.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Connection-scoped Switch HID protocol state."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, replace
|
|
4
|
+
|
|
5
|
+
from swbt.errors import ProtocolError
|
|
6
|
+
from swbt.input import IMUFrame
|
|
7
|
+
from swbt.protocol.imu_report import (
|
|
8
|
+
ImuEncodingState,
|
|
9
|
+
ImuMode,
|
|
10
|
+
encode_imu_block,
|
|
11
|
+
)
|
|
12
|
+
from swbt.protocol.profiles.base import ControllerProfile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SwitchHidSessionState:
|
|
17
|
+
"""Immutable host-requested state for one HID connection."""
|
|
18
|
+
|
|
19
|
+
report_mode: int | None = None
|
|
20
|
+
report_mode_supported: bool = False
|
|
21
|
+
unsupported_report_mode: int | None = None
|
|
22
|
+
imu_mode: ImuMode = ImuMode.DISABLED
|
|
23
|
+
imu_encoding_state: ImuEncodingState = field(default_factory=ImuEncodingState)
|
|
24
|
+
vibration_enabled: bool = False
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def imu_enabled(self) -> bool:
|
|
28
|
+
"""Return whether the host selected an active IMU mode."""
|
|
29
|
+
return self.imu_mode is not ImuMode.DISABLED
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def imu_encoding_format(self) -> str:
|
|
33
|
+
"""Return the diagnostic wire-format name derived from the IMU mode."""
|
|
34
|
+
if self.imu_mode is ImuMode.DISABLED:
|
|
35
|
+
return "disabled"
|
|
36
|
+
if self.imu_mode is ImuMode.STANDARD:
|
|
37
|
+
return "standard"
|
|
38
|
+
return "quaternion"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def apply_imu_mode_request(
|
|
42
|
+
state: SwitchHidSessionState,
|
|
43
|
+
*,
|
|
44
|
+
requested_mode: int,
|
|
45
|
+
accepted_modes: tuple[int, ...],
|
|
46
|
+
) -> SwitchHidSessionState:
|
|
47
|
+
"""Return the session state for a newly accepted IMU encoding epoch."""
|
|
48
|
+
if requested_mode not in accepted_modes:
|
|
49
|
+
msg = f"unsupported enable IMU value: 0x{requested_mode:02x}"
|
|
50
|
+
raise ProtocolError(msg)
|
|
51
|
+
try:
|
|
52
|
+
mode = ImuMode(requested_mode)
|
|
53
|
+
except ValueError as error:
|
|
54
|
+
msg = f"unknown enable IMU value: 0x{requested_mode:02x}"
|
|
55
|
+
raise ProtocolError(msg) from error
|
|
56
|
+
return replace(
|
|
57
|
+
state,
|
|
58
|
+
imu_mode=mode,
|
|
59
|
+
imu_encoding_state=ImuEncodingState(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SwitchHidSession:
|
|
64
|
+
"""Own host-requested and IMU encoding state for one HID connection."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, profile: ControllerProfile) -> None:
|
|
67
|
+
"""Create a disabled session for the configured controller profile."""
|
|
68
|
+
self._profile = profile
|
|
69
|
+
self._state = SwitchHidSessionState()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def state(self) -> SwitchHidSessionState:
|
|
73
|
+
"""Return the current immutable session state."""
|
|
74
|
+
return self._state
|
|
75
|
+
|
|
76
|
+
def set_report_mode(self, mode: int, *, supported: bool) -> None:
|
|
77
|
+
"""Record one host-selected input report mode."""
|
|
78
|
+
self._state = replace(
|
|
79
|
+
self._state,
|
|
80
|
+
report_mode=mode,
|
|
81
|
+
report_mode_supported=supported,
|
|
82
|
+
unsupported_report_mode=None if supported else mode,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def set_imu_mode(self, mode: int) -> None:
|
|
86
|
+
"""Start a new IMU encoding epoch for an accepted host mode."""
|
|
87
|
+
self._state = apply_imu_mode_request(
|
|
88
|
+
self._state,
|
|
89
|
+
requested_mode=mode,
|
|
90
|
+
accepted_modes=self._profile.imu_enable_modes,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def set_vibration_enabled(self, enabled: bool) -> None:
|
|
94
|
+
"""Record the host-selected vibration enable state."""
|
|
95
|
+
self._state = replace(self._state, vibration_enabled=enabled)
|
|
96
|
+
|
|
97
|
+
def encode_imu(
|
|
98
|
+
self,
|
|
99
|
+
frames: tuple[IMUFrame, IMUFrame, IMUFrame],
|
|
100
|
+
*,
|
|
101
|
+
now_ns: int,
|
|
102
|
+
) -> bytes:
|
|
103
|
+
"""Encode one IMU block and retain only its explicit next state."""
|
|
104
|
+
result = encode_imu_block(
|
|
105
|
+
state=self._state.imu_encoding_state,
|
|
106
|
+
mode=self._state.imu_mode,
|
|
107
|
+
frames=frames,
|
|
108
|
+
gyro_calibration=self._profile.gyro_calibration,
|
|
109
|
+
now_ns=now_ns,
|
|
110
|
+
)
|
|
111
|
+
self._state = replace(self._state, imu_encoding_state=result.state)
|
|
112
|
+
return result.block
|
swbt/protocol/spi.py
CHANGED
|
@@ -16,6 +16,8 @@ class VirtualSpiFlash:
|
|
|
16
16
|
COLOR_INFO_EXISTS_ADDRESS = 0x601B
|
|
17
17
|
COLOR_INFO_EXISTS = 0x01
|
|
18
18
|
CONTROLLER_COLORS_ADDRESS = 0x6050
|
|
19
|
+
FACTORY_ACCELEROMETER_CALIBRATION_ADDRESS = 0x6020
|
|
20
|
+
FACTORY_GYRO_CALIBRATION_ADDRESS = 0x602C
|
|
19
21
|
|
|
20
22
|
def __init__(self, *, profile: ControllerProfile | None = None) -> None:
|
|
21
23
|
"""Create a virtual SPI flash image."""
|
|
@@ -27,6 +29,16 @@ class VirtualSpiFlash:
|
|
|
27
29
|
self._data[
|
|
28
30
|
self.CONTROLLER_COLORS_ADDRESS : self.CONTROLLER_COLORS_ADDRESS + len(controller_colors)
|
|
29
31
|
] = controller_colors
|
|
32
|
+
accelerometer_calibration = profile.accelerometer_calibration.to_spi_bytes()
|
|
33
|
+
accelerometer_address = self.FACTORY_ACCELEROMETER_CALIBRATION_ADDRESS
|
|
34
|
+
self._data[
|
|
35
|
+
accelerometer_address : accelerometer_address + len(accelerometer_calibration)
|
|
36
|
+
] = accelerometer_calibration
|
|
37
|
+
gyro_calibration = profile.gyro_calibration.to_spi_bytes()
|
|
38
|
+
self._data[
|
|
39
|
+
self.FACTORY_GYRO_CALIBRATION_ADDRESS : self.FACTORY_GYRO_CALIBRATION_ADDRESS
|
|
40
|
+
+ len(gyro_calibration)
|
|
41
|
+
] = gyro_calibration
|
|
30
42
|
|
|
31
43
|
def read(self, address: int, size: int) -> bytes:
|
|
32
44
|
"""Read bytes from the virtual SPI address space."""
|
swbt/protocol/subcommand.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
"""Subcommand reply generation."""
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
|
|
5
3
|
from swbt.errors import ProtocolError
|
|
6
4
|
from swbt.input import InputState
|
|
7
5
|
from swbt.protocol.input_report import InputReportBuilder
|
|
8
6
|
from swbt.protocol.output_report import OutputReport
|
|
9
7
|
from swbt.protocol.profiles.base import ControllerProfile
|
|
10
8
|
from swbt.protocol.profiles.pro_controller import default_controller_profile
|
|
9
|
+
from swbt.protocol.session import SwitchHidSession
|
|
11
10
|
from swbt.protocol.spi import VirtualSpiFlash
|
|
12
11
|
|
|
13
12
|
SIMPLE_ACK_SUBCOMMANDS = {0x08, 0x30}
|
|
@@ -21,18 +20,6 @@ MCU_CONFIG_DATA = bytes.fromhex(
|
|
|
21
20
|
)
|
|
22
21
|
|
|
23
22
|
|
|
24
|
-
@dataclass
|
|
25
|
-
class SubcommandSessionState:
|
|
26
|
-
"""Mutable host-requested subcommand state for one responder lifetime."""
|
|
27
|
-
|
|
28
|
-
report_mode: int | None = None
|
|
29
|
-
report_mode_supported: bool = False
|
|
30
|
-
unsupported_report_mode: int | None = None
|
|
31
|
-
imu_mode: int | None = None
|
|
32
|
-
imu_enabled: bool = False
|
|
33
|
-
vibration_enabled: bool = False
|
|
34
|
-
|
|
35
|
-
|
|
36
23
|
class UnsupportedSubcommandError(ProtocolError):
|
|
37
24
|
"""Raised when a subcommand is not supported by the responder."""
|
|
38
25
|
|
|
@@ -51,22 +38,13 @@ class SubcommandResponder:
|
|
|
51
38
|
*,
|
|
52
39
|
spi_flash: VirtualSpiFlash | None = None,
|
|
53
40
|
profile: ControllerProfile | None = None,
|
|
54
|
-
session_state: SubcommandSessionState | None = None,
|
|
55
41
|
device_info_bluetooth_address: bytes = DEFAULT_DEVICE_INFO_BLUETOOTH_ADDRESS,
|
|
56
42
|
) -> None:
|
|
57
43
|
"""Create a responder."""
|
|
58
44
|
self._profile = profile or default_controller_profile()
|
|
59
|
-
self._session_state = (
|
|
60
|
-
session_state if session_state is not None else SubcommandSessionState()
|
|
61
|
-
)
|
|
62
45
|
self._device_info_bluetooth_address = bytes(device_info_bluetooth_address)
|
|
63
46
|
self._spi_flash = spi_flash or VirtualSpiFlash(profile=self._profile)
|
|
64
47
|
|
|
65
|
-
@property
|
|
66
|
-
def session_state(self) -> SubcommandSessionState:
|
|
67
|
-
"""Return the mutable subcommand state owned by this responder."""
|
|
68
|
-
return self._session_state
|
|
69
|
-
|
|
70
48
|
def set_device_info_bluetooth_address(self, bluetooth_address: bytes) -> None:
|
|
71
49
|
"""Update the Bluetooth address returned by subcommand 0x02."""
|
|
72
50
|
if len(bluetooth_address) != 6:
|
|
@@ -74,13 +52,20 @@ class SubcommandResponder:
|
|
|
74
52
|
raise ProtocolError(msg)
|
|
75
53
|
self._device_info_bluetooth_address = bytes(bluetooth_address)
|
|
76
54
|
|
|
77
|
-
def respond(
|
|
55
|
+
def respond(
|
|
56
|
+
self,
|
|
57
|
+
output_report: OutputReport,
|
|
58
|
+
*,
|
|
59
|
+
state: InputState,
|
|
60
|
+
session: SwitchHidSession | None = None,
|
|
61
|
+
timer: int = 0,
|
|
62
|
+
) -> bytes:
|
|
78
63
|
"""Return a 0x21 reply for an output report with a subcommand."""
|
|
79
64
|
if output_report.subcommand_id is None:
|
|
80
65
|
msg = "output report does not include a subcommand"
|
|
81
66
|
raise ProtocolError(msg)
|
|
82
67
|
|
|
83
|
-
ack, data = self._reply_data(output_report)
|
|
68
|
+
ack, data = self._reply_data(output_report, session=session)
|
|
84
69
|
return self._build_0x21_reply(
|
|
85
70
|
subcommand_id=output_report.subcommand_id,
|
|
86
71
|
ack=ack,
|
|
@@ -89,17 +74,31 @@ class SubcommandResponder:
|
|
|
89
74
|
timer=timer,
|
|
90
75
|
)
|
|
91
76
|
|
|
92
|
-
def _reply_data(
|
|
77
|
+
def _reply_data(
|
|
78
|
+
self,
|
|
79
|
+
output_report: OutputReport,
|
|
80
|
+
*,
|
|
81
|
+
session: SwitchHidSession | None,
|
|
82
|
+
) -> tuple[int, bytes]:
|
|
93
83
|
subcommand_id = output_report.subcommand_id
|
|
94
84
|
if subcommand_id is None:
|
|
95
85
|
msg = "output report does not include a subcommand"
|
|
96
86
|
raise ProtocolError(msg)
|
|
97
87
|
if subcommand_id == 0x03:
|
|
98
|
-
return 0x80, self._set_input_report_mode(
|
|
88
|
+
return 0x80, self._set_input_report_mode(
|
|
89
|
+
output_report.subcommand_payload,
|
|
90
|
+
_require_session(session),
|
|
91
|
+
)
|
|
99
92
|
if subcommand_id == 0x40:
|
|
100
|
-
return 0x80, self._set_imu_enabled(
|
|
93
|
+
return 0x80, self._set_imu_enabled(
|
|
94
|
+
output_report.subcommand_payload,
|
|
95
|
+
_require_session(session),
|
|
96
|
+
)
|
|
101
97
|
if subcommand_id == 0x48:
|
|
102
|
-
return 0x80, self._set_vibration_enabled(
|
|
98
|
+
return 0x80, self._set_vibration_enabled(
|
|
99
|
+
output_report.subcommand_payload,
|
|
100
|
+
_require_session(session),
|
|
101
|
+
)
|
|
103
102
|
if subcommand_id in SIMPLE_ACK_SUBCOMMANDS:
|
|
104
103
|
return 0x80, b""
|
|
105
104
|
if subcommand_id == 0x02:
|
|
@@ -114,23 +113,21 @@ class SubcommandResponder:
|
|
|
114
113
|
return 0x80, _nfc_ir_mcu_state_payload(output_report.subcommand_payload)
|
|
115
114
|
raise UnsupportedSubcommandError(subcommand_id, output_report.subcommand_payload)
|
|
116
115
|
|
|
117
|
-
def _set_input_report_mode(self, payload: bytes) -> bytes:
|
|
116
|
+
def _set_input_report_mode(self, payload: bytes, session: SwitchHidSession) -> bytes:
|
|
118
117
|
mode = _first_payload_byte(payload, "set input report mode")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
None if self._session_state.report_mode_supported else mode
|
|
118
|
+
session.set_report_mode(
|
|
119
|
+
mode,
|
|
120
|
+
supported=mode == SUPPORTED_INPUT_REPORT_MODE,
|
|
123
121
|
)
|
|
124
122
|
return b""
|
|
125
123
|
|
|
126
|
-
def _set_imu_enabled(self, payload: bytes) -> bytes:
|
|
124
|
+
def _set_imu_enabled(self, payload: bytes, session: SwitchHidSession) -> bytes:
|
|
127
125
|
imu_mode = _imu_enable_payload(payload, self._profile.imu_enable_modes)
|
|
128
|
-
|
|
129
|
-
self._session_state.imu_enabled = imu_mode != 0x00
|
|
126
|
+
session.set_imu_mode(imu_mode)
|
|
130
127
|
return b""
|
|
131
128
|
|
|
132
|
-
def _set_vibration_enabled(self, payload: bytes) -> bytes:
|
|
133
|
-
|
|
129
|
+
def _set_vibration_enabled(self, payload: bytes, session: SwitchHidSession) -> bytes:
|
|
130
|
+
session.set_vibration_enabled(_enable_payload(payload, "enable vibration"))
|
|
134
131
|
return b""
|
|
135
132
|
|
|
136
133
|
def _spi_read_reply_data(self, payload: bytes) -> bytes:
|
|
@@ -171,6 +168,13 @@ def _first_payload_byte(payload: bytes, subcommand_name: str) -> int:
|
|
|
171
168
|
return payload[0]
|
|
172
169
|
|
|
173
170
|
|
|
171
|
+
def _require_session(session: SwitchHidSession | None) -> SwitchHidSession:
|
|
172
|
+
if session is None:
|
|
173
|
+
msg = "session-state subcommand requires an explicit SwitchHidSession"
|
|
174
|
+
raise ProtocolError(msg)
|
|
175
|
+
return session
|
|
176
|
+
|
|
177
|
+
|
|
174
178
|
def _enable_payload(payload: bytes, subcommand_name: str) -> bool:
|
|
175
179
|
value = _first_payload_byte(payload, subcommand_name)
|
|
176
180
|
if value not in (0x00, 0x01):
|
|
@@ -183,7 +187,8 @@ def _imu_enable_payload(payload: bytes, accepted_modes: tuple[int, ...]) -> int:
|
|
|
183
187
|
value = _first_payload_byte(payload, "enable IMU")
|
|
184
188
|
if value in accepted_modes:
|
|
185
189
|
return value
|
|
186
|
-
|
|
190
|
+
accepted = ", ".join(f"0x{mode:02X}" for mode in accepted_modes)
|
|
191
|
+
msg = f"enable IMU subcommand argument must be one of: {accepted}"
|
|
187
192
|
raise ProtocolError(msg)
|
|
188
193
|
|
|
189
194
|
|
swbt/report_loop.py
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from collections import deque
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from contextlib import suppress
|
|
7
|
+
from time import monotonic_ns
|
|
6
8
|
|
|
7
9
|
from swbt.diagnostics import DiagnosticsRecorder
|
|
8
10
|
from swbt.protocol.input_report import InputReportBuilder
|
|
11
|
+
from swbt.protocol.session import SwitchHidSession
|
|
9
12
|
from swbt.state_store import InputStateStore
|
|
10
13
|
from swbt.transport.base import HidDeviceTransport
|
|
11
14
|
|
|
@@ -21,14 +24,18 @@ class ReportLoop:
|
|
|
21
24
|
transport: HidDeviceTransport,
|
|
22
25
|
state_store: InputStateStore,
|
|
23
26
|
input_report_builder: InputReportBuilder,
|
|
27
|
+
session: SwitchHidSession,
|
|
24
28
|
report_period_us: int = 8000,
|
|
25
29
|
diagnostics: DiagnosticsRecorder | None = None,
|
|
30
|
+
clock_ns: Callable[[], int] = monotonic_ns,
|
|
26
31
|
) -> None:
|
|
27
32
|
"""Create a report loop helper."""
|
|
28
33
|
self._transport = transport
|
|
29
34
|
self._state_store = state_store
|
|
30
35
|
self._report_period_seconds = report_period_us / 1_000_000
|
|
31
36
|
self._input_report_builder = input_report_builder
|
|
37
|
+
self._session = session
|
|
38
|
+
self._clock_ns = clock_ns
|
|
32
39
|
self._diagnostics = diagnostics
|
|
33
40
|
self._reply_queue: deque[bytes] = deque()
|
|
34
41
|
self._timer = 0
|
|
@@ -57,11 +64,13 @@ class ReportLoop:
|
|
|
57
64
|
async with self._send_lock:
|
|
58
65
|
await self._send_current_input_locked(reason=reason)
|
|
59
66
|
|
|
60
|
-
async def send_subcommand_reply(self,
|
|
61
|
-
"""
|
|
67
|
+
async def send_subcommand_reply(self, build_report: Callable[[], bytes]) -> bytes:
|
|
68
|
+
"""Apply a subcommand transition and send its reply under the send lock."""
|
|
62
69
|
async with self._send_lock:
|
|
70
|
+
report = build_report()
|
|
63
71
|
await self._send_subcommand_reply_locked(report)
|
|
64
72
|
self._holdoff_periodic_after_reply()
|
|
73
|
+
return report
|
|
65
74
|
|
|
66
75
|
def queue_reply(self, report: bytes) -> None:
|
|
67
76
|
"""Queue one subcommand reply for priority transmission."""
|
|
@@ -80,7 +89,12 @@ class ReportLoop:
|
|
|
80
89
|
|
|
81
90
|
async def _send_current_input_locked(self, *, reason: str) -> None:
|
|
82
91
|
state = await self._state_store.snapshot()
|
|
83
|
-
|
|
92
|
+
imu_block = self._session.encode_imu(state.imu_frames, now_ns=self._clock_ns())
|
|
93
|
+
report = self._input_report_builder.build_0x30(
|
|
94
|
+
state,
|
|
95
|
+
timer=self._timer,
|
|
96
|
+
imu_block=imu_block,
|
|
97
|
+
)
|
|
84
98
|
await self._send_report(report, reason=reason)
|
|
85
99
|
self._advance_timer()
|
|
86
100
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swbt-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python library for presenting an NX-compatible virtual Bluetooth HID input device.
|
|
5
5
|
Keywords: bluetooth,bluetooth-hid,controller,gamepad,hid,nx
|
|
6
6
|
Author: niart120
|
|
@@ -83,7 +83,6 @@ asyncio.run(main())
|
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
Pro Controller 相当の仮想デバイスを作成し、ペアリング後に A ボタン入力を送信するコードの例です。
|
|
86
|
-
接続情報ファイルの形式、入力 API の使い分けなどに関する詳しい説明は [Usage Guide](https://niart120.github.io/swbt-python/usage/) にあります。
|
|
87
86
|
|
|
88
87
|
### Joy-Con L/R
|
|
89
88
|
|
|
@@ -129,7 +128,7 @@ macOS 15.7.7 / CSR8510 A10 では、Pro Controller のペアリング、保存
|
|
|
129
128
|
|
|
130
129
|
### 実験的構成
|
|
131
130
|
|
|
132
|
-
Linux は experimental です。手順は Hardware Guide に整備されていますが、専用 USB Bluetooth ドングルにアクセスできるか、ペアリングできるか、入力が反映されるかは未確認です。macOS は Pro Controller
|
|
131
|
+
Linux は experimental です。手順は Hardware Guide に整備されていますが、専用 USB Bluetooth ドングルにアクセスできるか、ペアリングできるか、入力が反映されるかは未確認です。macOS は Pro Controller の一部挙動のみ検証済みです。Joy-Con、別ドングル、別ファームウェアでの互換性は未確認です。
|
|
133
132
|
|
|
134
133
|
## 開発
|
|
135
134
|
|
|
@@ -10,25 +10,28 @@ swbt/gamepad/connection.py,sha256=My3deMw4L64UoEXjeyRNBs1D6UvrvkeC2P7yf_PLPYQ,77
|
|
|
10
10
|
swbt/gamepad/constants.py,sha256=gDesRFvVxZ3SMSiP4qzd42em_Dt9M5hIu3-2exgKEus,84
|
|
11
11
|
swbt/gamepad/core.py,sha256=vGY24NsCxJ37XY3jagk6P1DZzDM1JJwXdggZM7fk1Ug,15042
|
|
12
12
|
swbt/gamepad/interface.py,sha256=GgPo5VwFhvcPIDy265R_dOQSYrBN1MYekqyPvIuXWQY,9431
|
|
13
|
-
swbt/gamepad/output.py,sha256=
|
|
14
|
-
swbt/gamepad/runtime.py,sha256=
|
|
13
|
+
swbt/gamepad/output.py,sha256=Et2efB09tkNZF50_0ANk-JjHTJsJBpbzSdsJeWMDtfA,4101
|
|
14
|
+
swbt/gamepad/runtime.py,sha256=28wDzmCWWEihSLnzg2E0MYP73z3fIGoc2Vu98XZuH88,30508
|
|
15
15
|
swbt/gamepad/transport_factory.py,sha256=2LLGIF3Bc4ufBNjp1AcdqNkHNP8ZBDFU2PNt898vo8o,2059
|
|
16
|
-
swbt/
|
|
16
|
+
swbt/imu.py,sha256=sT1_MsYECQlQ-pSfayD8gojD9l3w9zEtOI3xP1IovQM,4237
|
|
17
|
+
swbt/input.py,sha256=7gv75lzrcmoV8Sijz7f1pvsheRrmUi_TCIMW3DjUVjw,22275
|
|
17
18
|
swbt/probe.py,sha256=DnHMGhfDU2ooyC01JID7JQRly6BAyjFJVTEi-Mco0uY,7283
|
|
18
19
|
swbt/protocol/__init__.py,sha256=Uvdlp06CBsKDivW1juPY-T5LaqJVCF00MbQaHl13XPo,35
|
|
19
20
|
swbt/protocol/buttons.py,sha256=P0ojZ8tlpwwR007N_QL158a1rWOXYnYhZ3n8eCtZodc,1740
|
|
20
21
|
swbt/protocol/descriptors.py,sha256=8J-l9xYUoCs5PODO6nkuEo4YCzhnTUzA_EAzKz-UC7g,2982
|
|
21
|
-
swbt/protocol/
|
|
22
|
+
swbt/protocol/imu_report.py,sha256=xpo21hiRD5Bnq8-z3PBLXoDxSZNAWf-hMOltguosEeg,5656
|
|
23
|
+
swbt/protocol/input_report.py,sha256=jy8AuIwUFPbsyp2XVqBnpU_AD9MeFuMO8FWdrW4Bgig,2063
|
|
22
24
|
swbt/protocol/output_report.py,sha256=00d-QZRBWFJ9k5ZLl5I0nSAe2pZY0oR50Z0YvZ2_YWs,1751
|
|
23
25
|
swbt/protocol/profiles/__init__.py,sha256=LZH7FTwdMTyWlT-tJ2y558mdsPOHq9gOK5jYdgNkPUc,82
|
|
24
|
-
swbt/protocol/profiles/base.py,sha256=
|
|
25
|
-
swbt/protocol/profiles/joycon.py,sha256=
|
|
26
|
-
swbt/protocol/profiles/pro_controller.py,sha256=
|
|
26
|
+
swbt/protocol/profiles/base.py,sha256=hCYEZQ37TlJdwBzjVr-VBhG7kdyJF2V2VrLaBYEEbZM,9885
|
|
27
|
+
swbt/protocol/profiles/joycon.py,sha256=vLuvdq217jkQZEkqs7yPuJ-8yUwK6x_6W_1f_tuvF9I,2715
|
|
28
|
+
swbt/protocol/profiles/pro_controller.py,sha256=zR4rjaf1AfU1VFi2xkgGM4dqiJWAuX_jQ_1G9WrDfz8,957
|
|
27
29
|
swbt/protocol/rumble.py,sha256=94Qq4gstmU5KlER-i0eW3mSr1HKY3CgArdz9D5gpqso,584
|
|
28
|
-
swbt/protocol/
|
|
29
|
-
swbt/protocol/
|
|
30
|
+
swbt/protocol/session.py,sha256=TtnnFySalCXsEUwVv0DX9w1E2xq1yFIfmgCrA6wGHzs,3758
|
|
31
|
+
swbt/protocol/spi.py,sha256=buKyNYYOHOSYbpVh3Wa2968FL4VIO_43QcSXNowNfJs,2594
|
|
32
|
+
swbt/protocol/subcommand.py,sha256=1Z3Xf8xo7AydhyTa3bHgHM24hN2YS37yp7eioOHTu0A,7633
|
|
30
33
|
swbt/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
31
|
-
swbt/report_loop.py,sha256=
|
|
34
|
+
swbt/report_loop.py,sha256=3qGLLWpJo_lWMdYXkkLqajZrQJtTLF2w7Z1z7iruB8c,4903
|
|
32
35
|
swbt/state_store.py,sha256=Mf8uKR5CoQdzyMmqflNE_2KUWE_ZgmLaHcGXUcVThW4,2693
|
|
33
36
|
swbt/transport/__init__.py,sha256=b_Vqsg0k_g7XclN9XHQrcJxqCwGIklxG5NqzISal4_I,68
|
|
34
37
|
swbt/transport/_bumble_acl.py,sha256=RnueTwEgAd0w3YgxnXkw0AKQXMeECIRope96W-xQ1K8,1425
|
|
@@ -40,8 +43,8 @@ swbt/transport/_bumble_usb_devices.py,sha256=soAKiHTzeeTYdb7M357yaac-RxG2c_BiKWH
|
|
|
40
43
|
swbt/transport/base.py,sha256=lAGAJjGMg5CwtdAKmefCZglWqT3mWzm7CFOOCGGKul4,5619
|
|
41
44
|
swbt/transport/bumble.py,sha256=gB0vy1GW-1Mbg_EZuYSrdOKVClKyNEKXMy1jju41Bts,30147
|
|
42
45
|
swbt/transport/fake.py,sha256=Pmr-9a3qg2v9Rs86wBjINdWmWi5KOZlcFMH3AOmWF58,12903
|
|
43
|
-
swbt_python-0.
|
|
44
|
-
swbt_python-0.
|
|
45
|
-
swbt_python-0.
|
|
46
|
-
swbt_python-0.
|
|
47
|
-
swbt_python-0.
|
|
46
|
+
swbt_python-0.3.0.dist-info/licenses/LICENSE,sha256=FzzUoYjuIh5DzpeaujIbDUTylYnyzcHRVU-_ZMyowyk,1065
|
|
47
|
+
swbt_python-0.3.0.dist-info/WHEEL,sha256=uOqnPWqgFlbov4NeTCercq7cBQ2UN7xh5fiW55lOnAg,81
|
|
48
|
+
swbt_python-0.3.0.dist-info/entry_points.txt,sha256=YYQc7VgoSy74iZ6s8Qr6iJpnvFoDpp8G4-v0KJH5TGo,48
|
|
49
|
+
swbt_python-0.3.0.dist-info/METADATA,sha256=Dta7JIdGnIRn3tZ8xqguwesI8Vf94-4CIL5b-UMbHWA,5994
|
|
50
|
+
swbt_python-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|