diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e6ea0..17d254c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,17 @@ on: workflow_dispatch: jobs: + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install "ruff>=0.15,<1" + - run: ruff check . + test: name: Headless tests (Python ${{ matrix.python-version }}) runs-on: macos-14 diff --git a/.gitignore b/.gitignore index a02fb39..21d1b08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,22 @@ -# Build & distribution -dist/ +# Build output (py2app / build_dmg.sh) build/ +dist/ *.spec build_py2app.log -# On ignore tous les fichiers de cache Python -**/__pycache__/ -*.pyc - -# Fichiers système macOS -.DS_Store -.AppleDouble -.LSOverride - -# Fichiers Python (si tu l'utilises) +# Python __pycache__/ *.py[cod] -*$py.class venv/ .venv/ env/ +.ruff_cache/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride -# IDE (Visual Studio Code, etc.) +# Editors .vscode/ .idea/ diff --git a/README.md b/README.md index d6bc466..931e948 100644 --- a/README.md +++ b/README.md @@ -192,14 +192,32 @@ switch2bridge-macos/ ├── build_dmg.sh # Automated build script (.app + DMG) ├── requirements.txt # Runtime dependencies ├── requirements-build.txt # + py2app, for the .app / DMG +├── ruff.toml # Lint config ├── tests/ -│ ├── test_bridge.py # Headless tests (mappings, key dispatch, BLE lifecycle, calibration) +│ ├── test_bridge.py # Headless tests (mappings, key dispatch, BLE lifecycle, calibration, CLI) │ └── test_dsu.py # DSU server over real UDP +├── .github/ +│ ├── workflows/ci.yml # Lint + tests on every PR, DMG build on main +│ ├── workflows/release.yml # Manual release: build, tag, publish +│ └── release-notes/ # One vX.Y.Z.md per release ├── AppIcon.icns # Application icon (used by py2app) ├── LICENSE └── README.md ``` +## 🛠️ Development + +```bash +pip install -r requirements.txt +python tests/test_bridge.py # headless: BLE and keyboard are mocked +python tests/test_dsu.py # real UDP round-trips on localhost +pip install ruff && ruff check . +``` + +CI runs the same on every pull request (macOS, Python 3.10 / 3.12 / 3.13). + +**Releasing** — bump `APP_VERSION` in `Switch2Bridge.py` (the single source of truth), add `.github/release-notes/vX.Y.Z.md` (`{{SHA256}}` is replaced by the DMG checksum), merge, then run **Actions → Release → Run workflow** with the tag. It refuses to publish if the tag doesn't match `APP_VERSION` or the release already exists. + ## 🔬 Technical Details ### How It Works @@ -271,7 +289,7 @@ Every connection also logs the full GATT table (`GATT: N characteristic(s): …` | LED Control | 🧪 Player 1 only | Set once on connect through the command channel | | Rumble | ❌ Not implemented | Goes through the command/vibration channels, not `…c0f8` | | Motion/Gyro | ⚠️ Plumbing ready | DSU motion fields are sent (as zeros) — the gyro bytes in the BLE report are not decoded yet | -| Native HID | ❌ Not possible | Would require DriverKit (kernel-level) | +| Native HID | ❌ Not implemented | Would need a DriverKit system extension (virtual gamepad), which requires a paid Apple Developer account to sign | ## 🤝 Contributing @@ -279,7 +297,7 @@ Contributions welcome! Areas that need work: 1. **Rumble** — the command channel is now used for calibration and LEDs; rumble is the next step (see #14) 2. **Motion controls** — decode gyro/accelerometer data -3. **True analog** — virtual HID device via DriverKit +3. **Native gamepad** — virtual HID device via DriverKit, so games see a real controller (analog already works through DSU) 4. **Cross-platform** — Linux/Windows ports ## 📜 Credits diff --git a/Switch2Bridge.py b/Switch2Bridge.py index 2bdcfbf..b6c2ca6 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -431,7 +431,7 @@ def _apply(self, cfg): try: threshold = float(sticks.get("threshold", 0.5)) except (TypeError, ValueError): - raise ValueError('"sticks.threshold" must be a number') + raise ValueError('"sticks.threshold" must be a number') from None clamped = min(max(threshold, self.THRESHOLD_MIN), self.THRESHOLD_MAX) if clamped != threshold: warnings.append(f"Stick threshold {threshold} out of range, using {clamped}") @@ -474,7 +474,7 @@ def _apply(self, cfg): try: port = int(dsu.get("port", 26760)) except (TypeError, ValueError): - raise ValueError('"dsu.port" must be an integer') + raise ValueError('"dsu.port" must be an integer') from None if not (1024 <= port <= 65535): warnings.append(f"DSU port {port} out of range, using 26760") port = 26760 diff --git a/dsu_server.py b/dsu_server.py index c2f0ea7..9f0fd38 100644 --- a/dsu_server.py +++ b/dsu_server.py @@ -184,7 +184,7 @@ def _serve(self): while not self._stop.is_set(): try: data, addr = self._sock.recvfrom(1024) - except socket.timeout: + except TimeoutError: # the 1 s recv timeout continue except OSError: break # socket closed diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..61ab791 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,5 @@ +target-version = "py310" + +[lint] +# pyflakes, syntax errors, bugbear, pyupgrade +select = ["F", "E9", "B", "UP"] diff --git a/tests/test_dsu.py b/tests/test_dsu.py index 4b8cc50..bc2c17e 100644 --- a/tests/test_dsu.py +++ b/tests/test_dsu.py @@ -137,7 +137,7 @@ def crc_ok(pkt): cli.settimeout(0.5) cli.recvfrom(1024) check("stale client pruned", False, "still receiving") -except socket.timeout: +except TimeoutError: check("stale client pruned", True) check("client_count zero", srv.client_count() == 0)