Runtime composition と factory 設計

controller backend の選択は、runtime builder を作る構成起点で完了させる。MacroRuntimeBuilderPortFactory[ControllerOutputPort] を受け取り、実行時には controller backend を判定しない。

SwbtControllerOutputPortFactory の実装 module は nyxpy.framework.core.hardware.swbt.factory である。

構成の流れ

settings / CLI / GUI
  ↓
controller_config_from_settings(...)
  ↓
make_controller_port_factory(...)
  ├─ serial: existing serial ControllerOutputPort factory
  └─ swbt: SwbtControllerOutputPortFactory
  ↓
MacroRuntimeBuilder(controller_factory=..., manual_controller_factory=...)
  ↓
ExecutionContext(controller=ControllerOutputPort)

GUI manual input は runtime port を迂回しない。現行と同じく MacroRuntimeBuilder.controller_output_for_manual_input() から GUI lifetime の ControllerOutputPort を受け取り、VirtualControllerModel.set_controller(...) へ渡す。

Controller config

from dataclasses import dataclass
from enum import Enum
from pathlib import Path


class ControllerBackend(str, Enum):
    SERIAL = "serial"
    SWBT = "swbt"


@dataclass(frozen=True)
class SerialControllerConfig:
    device: str | None = None
    protocol: str = "CH552"
    baudrate: int = 9600


@dataclass(frozen=True)
class SwbtControllerConfig:
    model: SwbtControllerModel
    adapter: str | None = None
    profile_path: Path | None = None
    connect_timeout_sec: float = 30.0
    report_period_us: int | None = 8000


ControllerConfig = SerialControllerConfig | SwbtControllerConfig

SwbtControllerConfigcontroller_type 文字列を持たない。設定正規化の時点で SwbtControllerModel へ解決する。

profile_pathNone の場合は、session 作成前に .nyxpy/swbt/<controller>-profile.json へ補完する。相対 path は現在の shell directory ではなく workspace root を基準に解決する。adapterNone または空文字の場合、接続操作は NYX_SWBT_ADAPTER_NOT_SELECTED で失敗させる。

operation_timeout_sec は settings / config に出さず、session / factory の内部既定値として扱う。diagnostics は config path ではなく writer を session へ渡す。

make_controller_port_factory

from collections.abc import Callable

from nyxpy.framework.core.io.ports import ControllerOutputPort
from nyxpy.framework.core.runtime.builder import PortFactory
from nyxpy.framework.core.runtime.context import RuntimeBuildRequest


def make_controller_port_factory(
    *,
    config: ControllerConfig,
    serial_factory,
    swbt_factory: SwbtControllerOutputPortFactory,
    allow_dummy: Callable[[RuntimeBuildRequest], bool],
    detection_timeout_sec: float,
) -> PortFactory[ControllerOutputPort]:
    match config:
        case SerialControllerConfig():

            def create_serial(request: RuntimeBuildRequest, _definition) -> ControllerOutputPort:
                return serial_factory.create(
                    name=config.device,
                    baudrate=config.baudrate,
                    allow_dummy=allow_dummy(request),
                    timeout_sec=detection_timeout_sec,
                )

            return create_serial

        case SwbtControllerConfig():

            def create_swbt(request: RuntimeBuildRequest, _definition) -> ControllerOutputPort:
                return swbt_factory.create(
                    config=config,
                    allow_dummy=allow_dummy(request),
                    timeout_sec=config.connect_timeout_sec,
                )

            return create_swbt

ここでの match は構成処理である。ControllerOutputPort 実装内で backend dispatch を行うものではない。

GUI lifetime controller

既存の runtime builder は GUI lifetime 用 controller を別 factory で受け取れる。swbt backend でもこの仕組みを使う。

GuiAppServices.apply_settings(...)
  -> builder.controller_output_for_manual_input()
  -> SettingsApplyOutcome.manual_controller
  -> MainWindow._apply_runtime_ports(...)
  -> VirtualControllerModel.set_controller(port)

swbt の manual input 用に別 session を作らない。

VirtualControllerModel
  -> ControllerOutputPort
  -> SwbtControllerOutputPort

SwbtControllerOutputPortFactory

swbt factory は session と active port を cache する。同じ adapter / controller model / pairing profile / report period の transport resource は SwbtControllerSession に集約する。同一物理 adapter は controller model や pairing profile が違っても同時に開かず、有効な SwbtControllerOutputPort を 1 つだけにする。

SwbtControllerOutputPortFactory
  ├─ config から session key を作る
  ├─ session key ごとに SwbtControllerSession を cache する
  ├─ session key ごとに active SwbtControllerOutputPort を 1 つだけ管理する
  ├─ 同じ物理 adapter の別 session key を作る前に既存 port / session を閉じる
  ├─ create() で open + reconnect 済み session を得る
  ├─ create() で旧 active port の close を試し、失敗時は session の終端 close で回復してから新しい SwbtControllerOutputPort を返す
  ├─ pair(config) で明示 pairing を行う
  ├─ reconnect(config) で明示 reconnect を行う
  ├─ pair/reconnect/disconnect 前に active port を close する
  ├─ disconnect(config) で factory-managed cached session と active port を閉じる
  ├─ status(config) で factory-managed cached session の状態を返す
  └─ close() で active port と cached session をすべて close する

session key に含める値:

model.controller_type
adapter
profile_path
report_period_us

session key に含めない値:

connect_timeout_sec
operation_timeout_sec
allow_dummy
diagnostics writer
reset_on_port_create

接続試行ごとの値:

connect_timeout_sec
allow_dummy

macro 実行時の接続は reconnect のみである。pairing は runtime の副作用として行わない。

allow_dummy=True による dummy fallback は create() だけで有効にする。pair()reconnect() は実接続操作なので dummy fallback しない。

port 作成

class SwbtControllerOutputPortFactory:
    def create(
        self,
        *,
        config: SwbtControllerConfig,
        allow_dummy: bool,
        timeout_sec: float,
    ) -> ControllerOutputPort:
        session = self._session_for_config(config, allow_dummy=allow_dummy)
        session.open()
        session.reconnect(timeout_sec=timeout_sec)
        self._prepare_active_port_replacement(session_key(config))
        return SwbtControllerOutputPort(
            session=session,
            mapper=NyxSwbtInputMapper(model=config.model),
            on_close=lambda port: self._discard_active_port(session_key(config), port),
        )

SwbtControllerSession.start() は作らない。factory.create()open()reconnect() を順に呼ぶ。

runtime と Reconnect は pairing profile がない場合に pairing へ fallback しない。明示 Pair だけが create_profile() を呼ぶ。pairing profile がない場合や不正な場合は個別の ConfigurationError に変換する。

port 作成時は SwbtControllerOutputPort が neutral を常に試みる。reset_on_port_create という設定や引数は持たない。

create() は既存 active port の neutral が失敗しても session.close(neutral=True) を試す。session close が成功した場合は旧 port を無効化して cache を削除し、新しい session / port の作成へ進む。session close または loop stop も失敗した場合は参照を保持し、新しい port を返さない。接続本体と cleanup が両方失敗した場合は、primary 接続例外を先頭にした ordered ExceptionGroup を返す。

diagnostics writer

swbt diagnostics は path 設定ではなく writer interface として扱う。runtime / GUI / CLI には diagnostics_path--diagnostics、diagnostics UI を出さない。

NyX の production composition root は LoggerDiagnosticsWriter を生成して factory へ注入し、swbt diagnostics を LoggerPort.technical(...) へ流す。実機 test では同じ writer を tee し、tmp/hardware/swbt/<timestamp>/swbt-trace.jsonl に証跡を残す。

GUI pair / reconnect / disconnect

GUI の pair / reconnect / disconnect は入力反映経路ではない。app service から SwbtControllerOutputPortFactory の lifecycle method を呼ぶ。

Pair button
  -> swbt_factory.pair(config)
  -> session.open()
  -> session.pair(timeout_sec=...)
  -> swbt_factory.status(config).connection_state を確認
  -> success: active config / status update
  -> builder.controller_output_for_manual_input()
  -> VirtualControllerModel.set_controller(port)
Reconnect button
  -> swbt_factory.reconnect(config)
  -> session.open()
  -> session.reconnect(timeout_sec=...)
  -> swbt_factory.status(config).connection_state を確認
  -> success: active config / status update
  -> builder.controller_output_for_manual_input()
  -> VirtualControllerModel.set_controller(port)
Disconnect button
  -> VirtualControllerModel.set_controller(None)
  -> VirtualControllerModel.reset_state()
  -> builder.discard_manual_controller(previous port)
  -> previous manual port.release()
  -> previous manual port.close()
  -> swbt_factory.disconnect(config)
  -> status update

swbt の pair() / reconnect()None を返すため、その戻り値を接続成否に使わない。操作後の GamepadStatus.connection_state == "connected" を確認する。Pair / Reconnect が成功し、manual port を取得したあとだけ GUI manual input を有効化する。失敗した場合は入力 port を model から外し、利用者に error を表示する。

adapter refresh、pair、reconnect、disconnect、manual port 作成、macro start は Qt worker で実行する。widget と model の更新は worker の signal を受けた main thread で行う。

macro runtime との排他

GUI lifetime controller と macro runtime controller が同じ adapter を同時に使うと、USB transport の所有権と入力状態が競合する。GUI は次のルールを守る。

状態 許可する操作
disconnected adapter refresh、pair、reconnect
connected for manual input 仮想コントローラー操作、release all、disconnect
macro running manual input、pair、reconnect、disconnect を無効化
macro start requested while manual connected VirtualControllerModel.set_controller(None)reset_state() 後に GUI lifetime port を release() / close() し、builder cache からも外してから runtime を開始

runtime 終了後、GUI は自動で reconnect しない。利用者が Reconnect from pairing key を押した時だけ GUI lifetime port を再作成する。

排他は GUI の macro start sequence と、factory の active port 管理で担保する。manual input と runtime input を混ぜる mixer は作らない。新しい port を払い出す操作は同じ session key の旧 port を閉じる。

shutdown

MacroRuntimeBuilder.shutdown()
  ├─ manual ControllerOutputPort.close()
  ├─ preview FrameSourcePort.close()
  └─ factory close callbacks
       └─ SwbtControllerOutputPortFactory.close()
            └─ SwbtControllerSession.close()

SwbtControllerOutputPort.close() は neutral を試みる。transport の完全 close は factory / session close で行う。

build 失敗時の cleanup

MacroRuntimeBuilder.build()controllerframe_sourceresourcesartifacts の順に runtime port を生成する。後続 factory が失敗した場合は、生成済み port を取得と逆の artifacts -> resources -> frame_source -> controller の順で close する。notificationslogger は runtime close contract の対象外である。

build の元例外を失ってはならない。cleanup も失敗した場合は元例外と cleanup 例外を ExceptionGroup に保持し、どの生成処理と解放処理が失敗したかを呼び出し側が確認できるようにする。

dummy

allow_dummy=True の swbt backend では、Bluetooth 接続を開始しない DummySwbtControllerSession を test から明示利用できる。production GUI は swbt 選択時の dummy fallback を許可しない。serial manual controller と preview frame source の既存 dummy fallback は維持する。

用途:

  • mapper test
  • port contract test
  • GUI model test
  • runtime builder test

DummySwbtControllerSession は受け取った InputState を記録する。GUI manual input 専用 session ではない。