SwbtControllerSession 設計

SwbtControllerSession は、swbt-python controller の lifecycle を NyXPy の同期 ControllerOutputPort から使うための backend 内部部品である。

実装 module は nyxpy.framework.core.hardware.swbt.session とする。

SwbtControllerOutputPortapply()neutral() を session に依頼する。Bluetooth HID の open、pairing、reconnect、diagnostics writer、close は session が所有する。

なぜ session が必要か

serial backend は SerialControllerOutputPort から SerialComm.send(...) を呼ぶ。swbt backend は controller class の選択、adapter 未指定の拒否、pairing profile、diagnostics writer、swbt 例外の変換を同じ入力 port から扱う必要がある。

SwbtControllerSession はこの lifecycle 差分を吸収する。swbt-python 0.5.3 では create_profile()open()pair()reconnect()apply()neutral()close() が async API、status() が同期 API である。session は専用 event loop thread で async API の完了を待ち、上位には同期 method として見せる。

SwbtControllerOutputPort  # sync ControllerOutputPort implementation
  -> SwbtControllerSession  # swbt controller lifecycle adapter
  -> swbt-python controller

これは GUI manual input の上位 layer ではない。

責務

SwbtControllerSession が担当するもの:

  • SwbtControllerModel.controller_type から swbt controller class を解決し、controller を生成する
  • open()pair()reconnect() の実行
  • macro 実行前の reconnect
  • async controller method の完了待ち
  • InputStateapply()
  • neutral()
  • status() の取得
  • diagnostics writer の lifetime
  • swbt 例外の NyXPy 例外への変換

SwbtControllerOutputPort が担当するもの:

  • NyXPy の KeyTypeIMUFrame の状態管理
  • mapper による InputState 生成
  • session の同期 method 呼び出し
  • port close 時の neutral

public interface

class SwbtControllerSession:
    def open(self) -> None:
        """controller resource を開く。接続は開始しない。"""

    def pair(self, *, timeout_sec: float) -> None:
        """profile を作成または再利用してpairingし、connectedを確認する。"""

    def reconnect(self, *, timeout_sec: float) -> None:
        """保存済み pairing profile で reconnect し、connectedを確認する。"""

    def apply(self, state: InputState) -> None:
        """現在入力全体を置き換える。"""

    def neutral(self) -> None:
        """全入力を neutral へ戻す。"""

    def status(self) -> GamepadStatus:
        """接続状態と診断 snapshot を返す。"""

    def close(self) -> None:
        """neutral=True で controller を閉じる。"""

SwbtControllerSession.start() は作らない。CLI / GUI の pairreconnect は明示操作である。macro 実行時は factory が open() 後に reconnect() を呼ぶ。

Controller 作成

SwbtControllerModel は swbt runtime class を保持しない。session は controller_type から swbt controller class を解決する。

from swbt import DiagnosticsConfig


def create_swbt_controller(
    config: SwbtControllerConfig,
    diagnostics_writer=None,
) -> object:
    controller_cls = resolve_swbt_controller_class(config.model.controller_type)
    diagnostics = None
    if diagnostics_writer is not None:
        diagnostics = DiagnosticsConfig(trace_writer=diagnostics_writer)
    return controller_cls(
        adapter=config.adapter,
        profile_path=str(config.profile_path),
        report_period_us=config.report_period_us,
        diagnostics=diagnostics,
    )

diagnostics writer は NyX 内部 adapter で LoggerPort.technical(...) に流す。settings、GUI、CLI に diagnostics path は出さない。

初回 Pair で profile が存在しない場合は controller_cls.create_profile(adapter=..., profile_path=..., local_address=None, pair_timeout=...) を呼び、返却された接続済み controller の lifetime を session が所有する。profile が存在する場合は通常 constructor と pair() を使う。これにより、キャンセルや接続失敗後に残った profile から Pair を再試行できる。factory は Pair 前に session.open() を呼ばず、分岐判断を session に集約する。

swbt の pair() / reconnect() 自体の戻り値は None である。session は操作完了後に同期 status() を取得し、status.connection_state == "connected" を確認する。その後、公開 status の report_counters[0x30] が接続直後の値から増えるまで待つ。0x30 は周期 input report であり、増加は Switch へ入力を送れる状態になった根拠である。タイムアウト時は NYX_SWBT_INPUT_REPORT_NOT_READY とする。上位へ返す値も None であり、戻り値の truthiness や存在しない status.connected / status.message は使わない。

async bridge

ControllerOutputPort は同期 interface である。session は controller の async lifecycle / input method を内部 event loop thread へ渡して完了を待つ。status() だけは同期呼び出しする。

def _run_awaitable(self, awaitable):
    future = asyncio.run_coroutine_threadsafe(awaitable, self._loop)
    return future.result(timeout=self._operation_timeout_sec)

GUI thread で event loop を回さない。GUI の adapter refresh、pair / reconnect / disconnect、macro start、port cleanup は worker thread から session の同期 facade を呼ぶ。仮想コントローラーの manual press / release は現時点では Qt main thread から同期呼び出しする。_operation_timeout_sec は session 内部の既定値であり、通常 settings には出さない。

lifecycle state

new
  -> open
  -> pair / reconnect
  -> connected
  -> apply / neutral / status
  -> close

open() は複数回呼んでも安全にする。close() も idempotent にする。controller close が失敗した場合は closed 扱いにしない。controller close が成功して event loop stop だけが失敗した場合は controller を終端済みとして扱い、次回 close() では loop 所有権の回収だけを再試行する。

close semantics

close() は接続中なら controller.close(neutral=True) を呼ぶ。port close で neutral 済みでも、session close 時の trailing neutral は残す。

factory は active port の neutral が失敗しても処理を止めず、session.close() による controller.close(neutral=True) を必ず試す。session close が成功した場合は終端 neutral と transport close が完了したものとし、先行する port neutral error は回復済みとして扱う。factory は cache を削除し、外部が保持する旧 port を追加送信なしで無効化する。

session close または event loop stop が失敗した場合だけ、factory は session / active port の参照を保持して再試行可能にする。controller close と loop stop が両方失敗した場合は、この順序の leaf error を ExceptionGroup に格納する。create / pair / reconnect の primary 接続例外後に cleanup も失敗した場合は、primary error を先頭、その後に port neutral、controller close、loop stop の実行順で cleanup error を格納する。各 leaf は元の framework error object を維持するため、code を失わない。

排他

session 内部には RLock を置き、connection operation と input apply を直列化する。

GUI lifetime port と macro runtime port が同一 session を同時に使わないよう、GUI 側は macro start 前に VirtualControllerModel.set_controller(None) を呼び、旧 manual port を release/close する。

SwbtControllerOutputPortFactory は session key ごとに active port を 1 つだけ持つ。同一物理 adapter を別の controller model / pairing profile で指定した場合も既存 port / session を先に閉じる。create() / pair() / reconnect() で旧 active port の neutral が失敗した場合は session close へ進み、終端 close に成功した場合だけ新しい session / port へ置き換える。disconnect() / close() / 接続失敗時の session 破棄も同じ cleanup 規則を使う。

session 自体は connection lifecycle と transport lock を持つ。manual / runtime の所有権は factory の active port 管理で扱い、NYX_SWBT_ADAPTER_BUSY のような busy error は追加しない。

DummySwbtControllerSession

class DummySwbtControllerSession:
    states: list[InputState]

    def open(self) -> None: ...
    def pair(self, *, timeout_sec: float) -> None: ...
    def reconnect(self, *, timeout_sec: float) -> None: ...
    def apply(self, state: InputState) -> None: ...
    def neutral(self) -> None: ...
    def status(self) -> GamepadStatus: ...
    def close(self) -> None: ...

実機なし test では dummy session を使う。dummy status も connection_state を持つ GamepadStatus 相当の形にそろえ、production と別の connected field を作らない。これは GUI manual input 専用ではなく、SwbtControllerOutputPort の test double である。