diff --git a/README.md b/README.md index 163b678f8..e18f25735 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ User-mode driver, mapper, and GTK 4 based GUI for game controllers, including bu - Allows to setup, configure and use the Steam Controller (2015 & 2026) without ever launching Steam - Emulates the Xbox 360 controller, mouse, trackball and keyboard -- Connect multiple controllers at the same time +- Connect multiple controllers at the same time, each with its own remembered profile - Supports profiles, switchable in the GUI, or with a controller button - Joystick, Touchpad and Gyroscope input - Haptic Feedback and in-game Rumble support @@ -73,6 +73,75 @@ Beware that even when you do use automapper instead of manual assignment, you st *Note that there is no automatic discovery of evdev nodes for gyroscope/touchpad, so if your controller has one of those, it currently needs SCC to implement a custom driver for it.*
*That does mean that devices like Nintendo controllers do not have a working gyroscope, as I do not own any.* +## Using multiple controllers + +SC Controller can drive several controllers at once — Steam Controllers (v1 and +v2), a DualShock 4 and others can all be connected together. + +- **One window, two bars: pick the controller, then its profile.** Just connect + them: a controller-selector bar lists every connected controller (by type, + numbered when you have more than one of the same model) together with its + current profile; choosing one shows it on the big controller image, and a + second bar sets that controller's profile. There is no separate window per + device. The controller that connected *first* is the primary one — it is the + one drawn by default and the target when a command (a menu, the OSD) does not + name a specific controller. +- **Each controller keeps its own profile.** Selecting a controller and setting + a profile applies only to that controller. The choice is remembered and + restored automatically the next time that controller connects, so you do not + have to re-pick it every session. +- **Disconnecting is safe.** Turning one controller off (or letting it go idle) + leaves the window and the other controllers untouched; when it comes back it + returns to its remembered profile. + +![SC Controller with three controllers connected](docs/multiple-controllers.jpg?raw=true) + +*Two Steam Controller v1s and a Steam Controller v2 connected at once: the +selector at the top lists each controller — numbered when there are duplicates — +alongside its current profile.* + +### Telling controllers apart + +How a controller is identified — and therefore which remembered profile and +per-controller settings it gets — is governed by **Use Serial Numbers to +Identify Controllers** in *Settings*: + +- **Off (default):** controllers are identified by connection order (first + connected, second connected, …). This is simplest for a fixed setup, but if + you change which controller powers on first they will swap profiles. +- **On:** each controller is identified by its own hardware serial number, so + its profile and settings follow the physical device no matter what order + things connect in. + +Turn this **on** when you regularly use more than one controller — especially +two of the same model, such as two Steam Controllers — and want each to reliably +keep its own profile. + +### PlayStation controllers: turn off Steam's controller support + +A DualShock 4 or DualSense **cannot be claimed exclusively over Bluetooth**, and +this is worth knowing before you conclude something is broken. + +Steam Controllers are taken exclusively: SC Controller claims the USB interface, +which detaches the kernel driver, and nothing else can reach the device. Over +Bluetooth a DS4/DS5 is reached through `/dev/hidrawN` instead — and hidraw +allows **several readers at once**. If Steam is running with PlayStation +controller support enabled, Steam and SC Controller both receive every report +and both act on it. There is no way for us to lock Steam out. + +What that looks like in practice, and it does not look like a conflict: + +- the touchpad moves the pointer with an acceleration and glide you never + configured, whatever the pad is actually bound to +- clicking the touchpad produces a mouse button instead of your binding, and + rebinding it changes nothing +- your own bindings *do* work — so the controller seems half-configured rather + than contested + +Fix it in **Steam → Settings → Controller** by turning off *PlayStation +Controller Support*. Connecting the pad **by USB** also works, for a different +reason: claiming the USB interface takes the device away from Steam too. + ## Like what I'm doing? You can check out the ways to donate on [my website](https://rys.rs/donate), or just go straight to my [Ko-Fi](https://ko-fi.com/martinrys). @@ -150,6 +219,23 @@ We also have a [Discord](https://discord.gg/d3G7dDJ2G7) available. +### AppImage: install the udev rules + +The AppImage is self-contained but **cannot install the udev rules** it needs (those live in a system directory). Without them your user can't access the controller and SC Controller can't create the virtual gamepad (`/dev/uinput`), so a detected controller appears to "do nothing". Distro packages install these rules for you; **AppImage users must do it once, by hand:** + +1. Download `69-sc-controller.rules` from the [latest release](https://github.com/Patola/sc-controller-cc/releases/latest). +2. Copy it into place — this needs `sudo`: + ```sh + sudo cp 69-sc-controller.rules /etc/udev/rules.d/69-sc-controller.rules + ``` +3. Reload and re-apply the rules: + ```sh + sudo udevadm control --reload-rules && sudo udevadm trigger + ``` +4. Unplug and replug the controller (or its wireless dongle) — or reboot. + +Only the AppImage needs this; the Arch and other distro packages already ship these rules. **The Steam Deck doesn't need it either** — SteamOS already ships udev rules for Steam devices, so the AppImage works out of the box there. + ## Building the package by yourself ### Dependencies @@ -187,6 +273,35 @@ docker build -o build-output --build-arg BASE_CODENAME=noble . - Execute `./run.sh`, this automatically builds the project into a venv called `.venv`, activates it and runs sc-controller, which in turn runs scc-daemon if one does not run already - If you are debugging an issue, running `./run.sh daemon` first will launch the daemon in debug mode, allowing you to launch sc-controller in another terminal with `./run.sh` - note that sc-controller launched via `run.sh` always runs in debug mode too. +### Regenerating controller artwork (for contributors) + +Some SVG assets under `images/` are **generated** from source drawings by scripts +in `tools/`, so edit the source and rerun the script rather than hand-editing the +committed output. All scripts run from the repository root and optimise their +output with [`svgo`](https://github.com/svg/svgo) when it is on `PATH` (optional; +without it the SVGs are just left un-minified). The `svgo` config +(`tools/svgo.config.js`) deliberately preserves the element ids, `` +geometry, `viewBox` and `display:none` layers that the GUI relies on. + +- **`tools/gen_sc2_image.py`** — builds the Steam Controller v2 GUI artwork + (`images/controller-images/sc2.svg`, the face-button glyphs and side-panel + icons) from the traced sources in `tools/` (`sc2-source.svg`, `sc2-assets/`). + +- **`tools/gen_binding_display.py`** — builds the per-controller *Display Current + Bindings* templates in `images/binding-display/.svg`. Instead of + a hand-drawn asset per controller, it derives each template straight from that + controller's GUI drawing (`images/controller-images/.svg`): it scales the + drawing into the OSD canvas, recolours it into the binding-display palette + (green outlines over two greys on a dark backdrop) and drops a marker ring at + each control's `AREA_*` anchor so the binding boxes can draw connector lines to + them. The OSD then picks the file up automatically via the controller's gui + `background` name (see `scc/osd/binding_display.py`, `_resolve_image`). + + To add a controller: give it an entry in the script's `CONTROLLERS` table (its + source drawing + which `AREA_*` anchors each binding box points at) and a + matching box layout in `LAYOUTS` in `scc/osd/binding_display.py`, then rerun the + script. Controllers that share a physical control set can share a layout. + ## AI notice If you contribute AI-assisted work, please disclose it, make certain you have reviewed it, and ensure that you have tested the changes. diff --git a/TODO.md b/TODO.md index 7d5070afd..d63322d42 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,135 @@ List of (possibly) planned features in no particular order: +- Selectable output device: virtual Xbox (today's default), virtual DS4/DS5, + or NO virtual controller at all. The current "Xbox 360 pad" is just a + generic uinput device wearing an X360 identity defined entirely in + config["output"] (scc/config.py: vendor/product/name/buttons/axes), so the + architecture already treats identity as data. Three tiers of work: + - no-controller mode (keyboard+mouse only) ALREADY EXISTS as the + undocumented SCC_NOGAMEPAD env var (scc/mapper.py create_gamepad); + promote it to a config key + GUI toggle (trivial), or per-profile + (moderate: create/destroy the uinput gamepad on profile switch). + Essential for games that refuse mouse/keyboard input while any + controller is detected; + - evdev-level DS4/DS5 identity presets (Sony VID/PID + the button/axis + layout SDL's gamecontrollerdb expects on the classic path): preset + table + GUI dropdown; gives PlayStation glyphs in most games, but no + gyro/touchpad/lightbar (those ride on hidraw, which uinput cannot + fake; SDL HIDAPI just falls back to evdev); + - faithful DS4/DS5 emulation via /dev/uhid: present a real HID device so + the kernel's hid-playstation binds and exposes motion/touchpad nodes + -> native in-game gyro from ANY supported controller. Big: a uhid + backend beside uinput, authentic report descriptors/streams, a udev + rule for /dev/uhid. Prior art exists (fake-DS4-over-uhid projects). + +- DualShock 4 / DualSense (ds4/ds5) polish. The HID driver is now functional + (mapper rstick/dpad guards, touchpad coordinate scaling + click highlight), but + rough edges remain: + - the two analog sticks are asymmetric in Input Test -- the left and right + stick brighten / behave differently from each other; + - the input icons drawn around the controller are the generic ones, not the + DualShock face symbols (cross / circle / square / triangle); + - the DS4 gyro works as relative and absolute (host-side euler integration in + _step_orientation, verified on hardware) but the absolute path is gyro-only, + so it DRIFTS and gimbals on large combined rotations. The clean next step is + accelerometer drift-correction: fuse gyro + accel (complementary filter) to + pin pitch/roll to the gravity vector -- the raw accel is still in q1-q3 right + after decode, before _step_orientation overwrites them (yaw has no absolute + reference without a magnetometer, so it drifts inherently); + - gyro -> MOUSE routes to the stick instead: an Axes/Rels IntEnum value + collision (ABS_X == REL_X) makes a mouse axis serialize/round-trip as a stick + axis. The gyro editor labels + chooser display are fixed, but the save/reload + path still needs a proper Axes-vs-Rels disambiguation; use gamepad axes for + gyro meanwhile; + - no rumble: neither DS4Controller nor DS4HidRawController drives the pad (the + DS5 driver does) -- port the DS5/kernel output report; over Bluetooth the + DS4HidRawController is input-only, so rumble + lightbar there need output + reports with the BT CRC32 wrapper (mirror DS5HidRawController); + - the lightbar LED is not driven (no DS4-specific set_led); + - DS5 is UNVERIFIED (no DualSense hardware here): its HID touchpad scaling was + added by analogy to the DS4 (DualSense pad assumed 1920x1080) and not tested; + the DS5HidRawController touchpad is still unscaled and stores cpad as unsigned + c_uint16, which can't hold the signed scaled range -- it needs a field type + change as well as scaling. - Multiple on-screen menus (and possibly keyboards) when using multiple controllers - Injecting emulated xbox controller into wine +- mnuImage right-click "change background" menu has no `sc2` entry (the v2 + image is selected automatically via sc2.config.json `gui.background`, but + it can't be picked manually from that menu yet). +- Custom small (24px) controller icons per supported controller. Today only the + Steam Controller v1 (sc-*) and v2 (sc2-*) have bespoke top-down glyphs; every + other type (deck, ds4, ds5, evdev, hid, scbt, fake) reuses the same generic + silhouette, just recolored. Draw a distinct glyph per type so each controller + is recognisable at a glance. The v2 glyph could also be refined further (its + trackpads are necessarily small at 24px). +- Steam Controller v1 GET_SERIAL reliability (nicety). The flaky v1 serial read + is now handled gracefully - usb.py retries a stalled control request instead of + tearing the dongle down, and sc_dongle falls back to a generated id if it never + reads - so multiple v1s with "Use Serial Numbers" on are detected reliably. + Remaining nicety: investigate *why* GET_SERIAL stalls, so a v1 always ends up + with its real serial (today a persistent stall yields a positional id instead). +- Continuous "HD rumble" for the Steam Controller v2 (and v1/Deck). The SC pads + are LRA voice-coil actuators, not ERM spin-motors. We already drive single + pulses (v1: FEEDBACK report 0x8F; v2: interrupt-OUT report 0x82, effect 0x01 = + one click) which suit pad/scroll detents but NOT sustained, amplitude/ + frequency-modulated game rumble. Gap: the v2's continuous-rumble report is + unknown (see sc2.py feedback(): "sustained game rumble may need another report, + not yet found"); the v2 uses its own report scheme (interrupt-OUT 0x82), + distinct from the Deck's feature-report commands, so it needs confirming for + the v2 specifically. Approach (do NOT brute-force the HID space by trial and + error - a wrong report just does nothing and gives no signal): + 1. Read the canonical implementations: SDL's hidapi Steam driver + (SDL_hidapi_steam.c / SDL_hidapi_steamdeck.c - ID_TRIGGER_RUMBLE_CMD plus + the left/right gain "magic numbers") and the Linux kernel + drivers/hid/hid-steam.c (FF play_effect, derived from SDL's Deck code). + 2. Check first whether SDL3 already rumbles the v2 by its VID/PID - if so, + its source *is* the v2 report format and no capture is needed. + 3. Otherwise capture ground truth: run Steam Input on the v2, trigger rumble + (Steam's controller rumble test, or a rumbling game) and capture the USB + OUTPUT reports with usbmon + Wireshark; decode the continuous-rumble + report Steam actually sends. + 4. Replicate it in sc2.py feedback() and diff the emitted bytes against the + capture to confirm. + 5. Map the emulated gamepad's FF_RUMBLE strong/weak magnitudes to the LRA's + amplitude/frequency/gain and tune for feel (LRA != ERM, so a curve is + needed). + Plumbing already exists (emulated gamepad FF -> controller.feedback()); the + missing piece is the v2 continuous-rumble report itself. Refs: SDL hidapi steam + driver, kernel hid-steam.c, and Alice Mikhaylenko's "Steam Deck, HID, and + libmanette adventures" writeup. +- Deck OSD menu fixes. (a) "Display Current Bindings..." and "Run Program..." + ship disabled in the menu settings; once enabled they appear in the OSD, but + selecting them does nothing - their shell() actions (scc-osd-show-bindings, + scc-osd-launcher) don't actually run/work on the Deck. Make them functional. + (b) Remove "Turn Controller OFF" from the Deck's OSD menu - the Deck's + built-in controller can't be powered off (today it shows and does nothing). + Entries defined in scc/gui/global_settings.py (~L45-58, e.g. + "Turn Controller OFF" -> osd(turnoff())); menu data in + default_menus/Default.menu. +- Generalize the OSD "Turn Controller OFF" hiding. It's currently hidden only + for the Deck's built-in controls (controller type == "deck", checked in + scc/osd/menu.py against the --controller-type the daemon passes). Replace that + hardcoded type check with a per-controller capability (a ControllerFlags bit or + a controller.can_turnoff()) so any controller that can't be powered off + remotely hides the entry, not just the Deck. +- Deck tray/status icon not visible. On the Steam Deck the status (tray) icon + doesn't appear even with the option enabled - works on desktop now that + libdbusmenu is bundled, so this is a Deck/gamescope SNI-tray-host issue to + investigate. +- Rebrand the AppImage desktop app-id. app_info.id in AppImageBuilder.yml / + AppImageBuilder.debian.yml is still org.c0rn3j.sc-controller (upstream), so the + installed .desktop carries the upstream id, and the after_bundle step symlinks + it as org.c0rn3j.sc-controller.desktop. Switch both to org.patola.sc-controller-cc + once the fork is stable and we have committed/PR'd to upstream. +- Replace the last deprecated GTK stock-icon calls. macro_editor.py (the + up/down/delete buttons) and modeshift_editor.py (the clear button) still call + `Gtk.Image.new_from_stock("gtk-go-up" / "gtk-go-down" / "gtk-delete", ...)`. + They render today (GTK maps the stock id to an icon internally) but the stock + API is deprecated; move them to `Gtk.Image.new_from_icon_name` with freedesktop + names (go-up / go-down / edit-delete, or the -symbolic variants - all present in + Adwaita/Breeze). Same class as the profile_switcher.py save/edit buttons, which + were actively blank because `new_from_icon_name` was handed the stock ids + "gtk-save"/"gtk-edit" (now document-save / document-edit). Hard stuff: - Injecting emulated xbox controller into PlayOnLinux @@ -10,7 +138,22 @@ Very hard stuff: - Visual feedback in binding editor ( [what this guy says](https://www.reddit.com/r/linux_gaming/comments/5pcdmr/sc_controller_use_steam_controller_without_steam/dcqpvf4/) ) **Done** stuff: +- "Act on release" (inverted button): a general InvertedButtonModifier plus a + checkbox in the button action editor (next to Toggle/Repeat) that fires a + binding on *release* instead of press - for always-on sensors like the + capacitive grips. Round-trips with the Custom Action `inverted(...)` token. +- Dedicated v2 controller artwork: traced SVG (tools/sc2-source.svg) wired by + tools/gen_sc2_image.py into controller-images/sc2.svg + v2 face-overlay + glyphs (button-images/sc2_*.svg, lifted from the drawn symbols so the face + buttons are blank in the art -> no duplication, monochrome ABXY, round Steam, + single dots) + v2 side-panel icons (images/sc2/*.svg, per-controller override + added in app.apply_gui_config_buttons). Control-name ids on sticks/pads/dpad/ + bumpers + grip-touch shapes so everything highlights on hover; darker body + (#b8b8b8). sc2.config.json points at it all. Replaces the borrowed Deck image. - Multicontroller support +- Per-controller profile memory: each controller's profile is remembered by id + (config["controllers"][id]["profile"]) and restored on (re)connect - follows + the physical device with "Use Serial Numbers" on, per-slot otherwise. - Configurable gamepad type (e.g. 4 axes and 16 buttons) - Steam Profile import - Radial Menu for the Joystick/Trackpad @@ -32,4 +175,68 @@ Very hard stuff: - Trigger settings - DPAD that acts only when clicked - 8-way DPAD -- Selector for media keys \ No newline at end of file +- Selector for media keys + +## SC2 actuator audio streaming (haptic reports 0x86-0x89) + +The v2's haptic actuators can be driven as speakers: reports 0x86-0x89 carry +stream configuration plus PCM / u-law data (layouts from iczero's RE, collected +in CouchTurtle/sc2-research; not verified here, and not present in SDL3). + +This is the one piece of the haptic family that needs design rather than just +wiring, because nothing in sc-controller has a concept of a *stream*. Every +haptic today is a fire-and-forget event attached to an action. Streaming needs +a source (file? synthesised? routed from the system mixer?), buffering and +timing against the USB endpoint, and some answer to what a *profile* would even +store. Deferred until there is a reason to want it beyond completeness. + +Prerequisites: the simpler effects (0x83 tone / 0x84 sweep / 0x85 script) land +first, since they settle how per-effect parameters are represented in +HapticData and in the GUI. + +## Haptic feedback on button presses + +feedback() is offered by ten actions -- XY, ball, circular, circularabs, dpad, +hipfire, menu, mouse, trackpad, trigger -- but NOT by button. Haptics were +conceived upstream as continuous feedback tied to movement (pad-travel ticks, +trackball detents) rather than a per-press buzz, so ButtonAction never grew +set_haptic. + +On the v2 that reads as a gap: a button press producing a tick is exactly what +the hardware is for, and it is what makes the pads feel like buttons already. + +Deferred deliberately. Adding MOD_FEEDBACK to ButtonAction changes which +modifiers the editor offers for buttons on EVERY controller, so the GUI side +is not a small change and it needs thinking about before it is worth doing -- +what a v1 user is offered, whether the feedback panel makes sense next to a +button binding, and whether it should be per-press or press-and-release. + +## Haptic feedback on the DS4 + +The DualShock 4 has rumble motors and the daemon never uses them. No DS4 code +path implements haptics at all: `DS4Controller` and `DS4HidRawController` +inherit the no-op `Controller.feedback()`, and `DS4EvdevController` inherits +`EvdevController.feedback`, whose entire body is a TODO docstring. So a profile +with Feedback Enabled on a DS4 is silently doing nothing -- the GUI offers the +setting, the modifier is stored, and the effect is dropped at the driver. + +This is a gap rather than a regression; it has never worked. + +The DS5 already does it (`ds5drv.feedback`), and the DS4's output report is the +same shape -- a small motor pair, `motor_left` heavy / `motor_right` light, with +a scheduled clear afterwards because the motors misbehave when shut off under +50 ms. That method is close to a template. + +Two things to settle rather than copy blindly: + + - The DS5 halves the left motor's amplitude ("the left motor is heavier, so + we must give it less oomph"). Whether the DS4's motors need the same + correction is a hardware question, not something to assume. + - The DS4 has three driver classes (HID, hidraw, evdev) and which one a pad + gets depends on how it is connected, so the fix has to land somewhere all + three reach, or be written three times. + +Also worth doing at the same time: `Controller.rumble()` is still unimplemented +for the DS4 and DS5, so FF_RUMBLE's two magnitudes get averaged into one level +before they arrive. The heavy and light effects consequently feel identical on +both pads, where the hardware could tell them apart. diff --git a/docs/actions.md b/docs/actions.md index 69bc4d33f..1a46b6cd8 100755 --- a/docs/actions.md +++ b/docs/actions.md @@ -325,6 +325,14 @@ A button whenever physical button is pressed. #### released(action) Creates action that occurs for brief moment when button is released. +#### inverted(action) +Acts on release: swaps press and release, so the wrapped action is held while +the physical button is NOT pressed and released while it is. Meant for +always-on sensors such as the capacitive handle grips, which read "on" the +whole time the controller is held - inverting them fires the action when you +let go. Unlike `pressed`/`released`, which emit a momentary tap, this is a +true held inversion of the button state. + #### pressed(action) Creates action that occurs for brief moment when finger touches pad. @@ -387,6 +395,29 @@ running feedback that you may not be able to stop. should mouse travel between two feedback ticks. +#### feedbacktone(side, [amplitude=512 [, tone_frequency=160 [, duration=200 [, lfo_frequency=0 [, lfo_depth=0 ]]]]], action) +Like feedback(), but plays a sine tone instead of a click. 'tone_frequency' and +'lfo_frequency' are in Hz, 'duration' in milliseconds and 'lfo_depth' is 0 to +255; leave 'lfo_frequency' at 0 for a steady tone. + +Needs a controller that can synthesise waveforms, currently only the Steam +Controller 2. Anything else falls back to a plain click, so a profile written +for one controller still does something on another. + + +#### feedbacksweep(side, [amplitude=512 [, tone_frequency=160 [, end_frequency=40 [, duration=200 ]]]], action) +Like feedback(), but sweeps logarithmically from 'tone_frequency' to +'end_frequency' (both Hz) over 'duration' milliseconds. Same hardware +requirement, and the same fallback, as feedbacktone(). + + +#### feedbackscript(side, [amplitude=512 [, script_id=0 ]], action) +Like feedback(), but plays an effect stored in the controller's own firmware, +selected by 'script_id'. Which ids exist and what each one feels like is +hardware specific and not yet catalogued. Same hardware requirement, and the +same fallback, as feedbacktone(). + + #### ball([friction=10.0, [mass=80.0, ]] action) Enables trackball mode. Moving finger over pad will keep repeating same action with decreasing speed, based on set mass and friction, until virtual diff --git a/docs/multiple-controllers.jpg b/docs/multiple-controllers.jpg new file mode 100644 index 000000000..788ebfa1c Binary files /dev/null and b/docs/multiple-controllers.jpg differ diff --git a/docs/profile-file.md b/docs/profile-file.md index 14ccdf5a0..d4db29b15 100644 --- a/docs/profile-file.md +++ b/docs/profile-file.md @@ -192,6 +192,45 @@ specifies feedback with amplitude of 512 (default value) and frequency of 5 generated by left motor. +#### `feedbacktone` +Like `feedback`, but plays a sine tone. Value is a list of +`[side, amplitude, tone_frequency, duration, lfo_frequency, lfo_depth]`, of +which only the side is required. Frequencies are in Hz, duration in +milliseconds, `lfo_depth` 0 to 255. + +Example: + + "pad_left": { + "action": "trackball()", + "feedbacktone": ["LEFT", 512, 220, 300, 4, 128] + }, + +#### `feedbacksweep` +Like `feedback`, but sweeps logarithmically between two frequencies. Value is +`[side, amplitude, tone_frequency, end_frequency, duration]`, only the side +being required. + +Example: + + "pad_right": { + "action": "trackball()", + "feedbacksweep": ["RIGHT", 512, 400, 80, 250] + }, + +#### `feedbackscript` +Like `feedback`, but plays an effect stored in the controller's own firmware. +Value is `[side, amplitude, script_id]`, only the side being required. + +Example: + + "pad_right": { + "action": "trackball()", + "feedbackscript": ["RIGHT", 512, 3] + }, + +Only hardware that can synthesise waveforms plays these three -- currently the +Steam Controller 2 alone. Every other controller falls back to a plain click. + #### `smooth` Enables input smoothing (see [smooth modifier](actions.md#smooth)) diff --git a/docs/steam-controller-v2-protocol.md b/docs/steam-controller-v2-protocol.md index 5bb898509..f98afead5 100644 --- a/docs/steam-controller-v2-protocol.md +++ b/docs/steam-controller-v2-protocol.md @@ -170,12 +170,71 @@ same shape as the v1/Deck command packets. The `packetType` opcodes match Haptics: **Output report `0x82`** on the **interrupt-OUT** endpoint (its number equals the interface, e.g. EP `0x02` for slot 1) — the device **stalls it over SET_REPORT control**, so it must go to the interrupt endpoint. Layout -`82 `: `side` 0=left / 1=right / 2=both; `effect` -`0x01`=click (`0x02`=longer click); `amplitude` `0x00`(medium)…`0xff`(strong). -This is a single *click* per report (verified live). Continuous variable rumble, -if the controller supports it, likely uses a different report (not yet captured; -the connect-time `0x80` report is **not** felt). The `0x81` output reports seen -at connect are unrelated (not haptic). +`82 `: `side` 0=left / 1=right / 2=both; `command` +`0x01`=click, `0x02`=strong click; `gain` a **signed i8 in decibels**, clamped +by the firmware to -23…+24. Note the last byte is NOT a 0..255 amplitude -- we +sent it as one until v0.6.0.7, which made the strength control non-monotonic. + +The full haptic report family is documented by Valve themselves: they upstreamed +this controller ("Triton") to SDL3, so `src/joystick/hidapi/SDL_hidapi_steam_triton.c` +is a reference implementation. Corroborated independently by iczero's RE, collected +at (`docs/HAPTICS.md`): + +| report | name | payload | +| --- | --- | --- | +| `0x80` | `HAPTIC_RUMBLE` | `type u8, intensity u16, left{speed u16, gain i8}, right{speed u16, gain i8}` — **verified live** | +| `0x81` | `HAPTIC_PULSE` | `side u8, on_us u16, off_us u16, repeat u16` | +| `0x82` | `HAPTIC_COMMAND` | `side u8, command u8 (0=stop all, 1=click, 2=strong click), gain i8 dB` | +| `0x83` | `HAPTIC_LFO_TONE` | `side u8, gain i8, frequency u16 Hz, duration u16, lfo_freq u16 Hz, lfo_depth u8` — **verified live** | +| `0x84` | `HAPTIC_LOG_SWEEP` | `side u8, gain i8, duration u16, start_freq u16, end_freq u16` — **verified live** | +| `0x85` | `HAPTIC_SCRIPT` | `side u8, script_id u8, gain i8` — **verified live**, ids below | + +Firmware preset ids for `0x85`, names from the same RE (the list may be partial; +nothing says `0x10` is the last). Played on hardware: the ids are real and the +names broadly describe what you feel. CONTROLLER_VERY_ON, CONTROLLER_OFF and +PHONE_RINGING_1 are strong and unmistakable; several others are subtle. +WILHELM_SCREAM is barely perceptible even at maximum gain -- it is a long +sample, so this is most likely the preset itself rather than anything we send: + +| id | name | id | name | +| --- | --- | --- | --- | +| `0x01` | CONTROLLER_ON | `0x09` | DOWN_SIX | +| `0x02` | CONTROLLER_VERY_ON | `0x0a` | WHOOP_UP_3 | +| `0x03` | TRILL_UP | `0x0b` | WHOOP_DOWN | +| `0x04` | TRILL_DOWN | `0x0c` | PHONE_RINGING_1 | +| `0x05` | CONTROLLER_OFF | `0x0d` | RINGBACK_TONE | +| `0x06` | UP_FIVE | `0x0e` | PHONE_RINGING_2 | +| `0x07` | DOWN_FIVE | `0x0f` | PHONE_RINGING_3 | +| `0x08` | UP_SIX | `0x10` | WILHELM_SCREAM | +| `0x86`–`0x89` | audio stream | actuator PCM / u-law streaming | + +`0x83` and `0x84` are hardware-verified too. A sweep is clearly distinguishable +from a flat tone, so `start_freq`/`end_freq` do what they say. The LFO fields +work but are weak: at `lfo_freq` 4 / `lfo_depth` 128 the modulation is barely +there, and only at 10 / 255 is it obvious. Depth may not be a percentage. + +`0x80` is hardware-verified: `type`, `intensity` and both `gain` fields at 0, +the two `speed` fields driving their own actuator independently. Confirmed with +`fftest` on the emulated pad, whose "strong rumble (heavy motor)" and "weak +rumble (light motor)" arrive as the two Linux FF_RUMBLE magnitudes and come out +on the left and right actuators respectively: + + strong=32768 weak=0 -> 80 00 00 00 00 80 00 00 00 00 (left only) + strong=0 weak=49152 -> 80 00 00 00 00 00 00 00 c0 00 (right only) + +**Open question:** the field is named `speed`, not amplitude, and `gain` is a +separate i8 in dB. The second case above carries the LARGER number yet is +clearly weaker in the hand, so `speed` is probably not linear amplitude -- more +likely a motor speed, where higher means a lighter, higher-frequency buzz. Both +gains are 0 dB here because that is what Valve's driver sends; exposing gain +per side, and the `intensity` field, is untried and may be the better volume +control. + +So **continuous variable rumble is `0x80`**, and the earlier note here that it was +"not yet captured" was wrong -- the connect-time `0x80` traffic is that report, +sent with zero speeds, which is why nothing was felt. SDL sends the two SDL rumble +magnitudes straight through as `left.speed` / `right.speed` (u16) with `gain` 0. +Layouts for `0x83`-`0x89` are from RE and not verified here. **Implication:** the existing `sc_dongle.py` command builders port over; the differences are the **transport** (SET_REPORT/feature to a per-slot interface diff --git a/images/binding-display/deck.svg b/images/binding-display/deck.svg new file mode 100644 index 000000000..27f17985a --- /dev/null +++ b/images/binding-display/deck.svg @@ -0,0 +1 @@ +L4L5R5R4X \ No newline at end of file diff --git a/images/binding-display/ds4.svg b/images/binding-display/ds4.svg new file mode 100644 index 000000000..5184b84fa --- /dev/null +++ b/images/binding-display/ds4.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/images/binding-display/ds5.svg b/images/binding-display/ds5.svg new file mode 100644 index 000000000..2501e08e2 --- /dev/null +++ b/images/binding-display/ds5.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/images/binding-display/sc2.svg b/images/binding-display/sc2.svg new file mode 100644 index 000000000..95d5b91e8 --- /dev/null +++ b/images/binding-display/sc2.svg @@ -0,0 +1 @@ +R4R5L5L4X \ No newline at end of file diff --git a/images/binding-display/x360.svg b/images/binding-display/x360.svg new file mode 100644 index 000000000..96821e2dd --- /dev/null +++ b/images/binding-display/x360.svg @@ -0,0 +1 @@ +X \ No newline at end of file diff --git a/images/button-images/DOTS.svg b/images/button-images/DOTS.svg index 1aa42bda3..e084c592e 100644 --- a/images/button-images/DOTS.svg +++ b/images/button-images/DOTS.svg @@ -51,7 +51,7 @@ inkscape:groupmode="layer" id="layer1"> - - - - - - + + + + + - + image/svg+xml - + - - - - + + + + STEAM - + \ No newline at end of file diff --git a/images/button-images/ELIPSE.svg b/images/button-images/ELIPSE.svg index b421c8dac..7a36d7303 100644 --- a/images/button-images/ELIPSE.svg +++ b/images/button-images/ELIPSE.svg @@ -1,76 +1,18 @@ - - - - - - - + + + + + - + image/svg+xml - + - - - - + + + + STEAM - + \ No newline at end of file diff --git a/images/button-images/sc_C.svg b/images/button-images/sc_C.svg new file mode 100644 index 000000000..2253a099e --- /dev/null +++ b/images/button-images/sc_C.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/deck/BACK.svg b/images/deck/BACK.svg new file mode 100644 index 000000000..d818d4ce4 --- /dev/null +++ b/images/deck/BACK.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/deck/C.svg b/images/deck/C.svg new file mode 100644 index 000000000..0e06f2248 --- /dev/null +++ b/images/deck/C.svg @@ -0,0 +1 @@ +STEAM \ No newline at end of file diff --git a/images/deck/DOTS.svg b/images/deck/DOTS.svg new file mode 100644 index 000000000..fc0f4b4cb --- /dev/null +++ b/images/deck/DOTS.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/deck/LGRIP.svg b/images/deck/LGRIP.svg new file mode 100644 index 000000000..cff736a50 --- /dev/null +++ b/images/deck/LGRIP.svg @@ -0,0 +1 @@ +L5 \ No newline at end of file diff --git a/images/deck/LGRIP2.svg b/images/deck/LGRIP2.svg new file mode 100644 index 000000000..119da8e4c --- /dev/null +++ b/images/deck/LGRIP2.svg @@ -0,0 +1 @@ +L4 \ No newline at end of file diff --git a/images/deck/LPAD.svg b/images/deck/LPAD.svg new file mode 100644 index 000000000..5cc901816 --- /dev/null +++ b/images/deck/LPAD.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/deck/RGRIP.svg b/images/deck/RGRIP.svg new file mode 100644 index 000000000..0849c27e8 --- /dev/null +++ b/images/deck/RGRIP.svg @@ -0,0 +1 @@ +R5 \ No newline at end of file diff --git a/images/deck/RGRIP2.svg b/images/deck/RGRIP2.svg new file mode 100644 index 000000000..8c93744eb --- /dev/null +++ b/images/deck/RGRIP2.svg @@ -0,0 +1 @@ +R4 \ No newline at end of file diff --git a/images/deck/RPAD.svg b/images/deck/RPAD.svg new file mode 100644 index 000000000..5cc901816 --- /dev/null +++ b/images/deck/RPAD.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/deck/START.svg b/images/deck/START.svg new file mode 100644 index 000000000..252773da4 --- /dev/null +++ b/images/deck/START.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc-config.json b/images/sc-config.json index cb069d7c6..537f80c8b 100644 --- a/images/sc-config.json +++ b/images/sc-config.json @@ -1,12 +1,13 @@ { - "_ " : "Currently, this file is used only when changing controller image", - "__" : "manually from context menu", + "_ ": "Loaded for the Steam Controller (v1) via SCController.get_gui_config_file", + "__": "and on manual image change. GUI section only: button/axis sets stay at", + "___": "their defaults; this just points the Steam (C) button at the logo image.", "gui": { "background": "sc", "buttons": [ "A", "B", "X", "Y", "BACK", - "C", "START", "LB", "RB", "LT", "RT", + "sc_C", "START", "LB", "RB", "LT", "RT", "LSTICK", "LPAD", "RPAD", "LG", "RG" ] }, diff --git a/images/sc/C.svg b/images/sc/C.svg new file mode 100644 index 000000000..5bb1a60a9 --- /dev/null +++ b/images/sc/C.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc2/LB.svg b/images/sc2/LB.svg new file mode 100644 index 000000000..db34ce7ee --- /dev/null +++ b/images/sc2/LB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc2/LT.svg b/images/sc2/LT.svg new file mode 100644 index 000000000..0c9322547 --- /dev/null +++ b/images/sc2/LT.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc2/RB.svg b/images/sc2/RB.svg new file mode 100644 index 000000000..d9a7fa85d --- /dev/null +++ b/images/sc2/RB.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sc2/RT.svg b/images/sc2/RT.svg new file mode 100644 index 000000000..c18d7c04d --- /dev/null +++ b/images/sc2/RT.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scc/actions.py b/scc/actions.py index eb6b93f39..b6e86cc9f 100644 --- a/scc/actions.py +++ b/scc/actions.py @@ -14,6 +14,7 @@ import inspect import logging +import operator import sys from enum import IntEnum from math import atan2, copysign, cos, sin, sqrt @@ -382,11 +383,21 @@ class RangeOP: OPS = ("<", ">", "<=", ">=") + # An analog axis parked near a modeshift threshold jitters across it, and + # every crossing runs ModeModifier's switch path -- which releases held + # buttons and recenters gyro references. With a hair-trigger comparison a + # trigger held at "70%" could re-center an absolute gyro several times a + # second, leaving it permanently at its neutral output. Once the condition + # holds, the value has to travel this far (as a fraction of the axis range) + # back past the threshold before it stops holding. + HYSTERESIS = 0.05 + def __init__(self, what, op, value): """Raises ValueError if 'what' or 'op' is not supported value""" self.what = what self.op = op self.value = value + self.held = False self.min = float(TRIGGER_MIN) self.max = float(TRIGGER_MAX) @@ -417,17 +428,19 @@ def __init__(self, what, op, value): self.axis_name = "lpad_y" self.min, self.max = float(STICK_PAD_MIN), float(STICK_PAD_MAX) elif what == LSTICK: - # Most special case of all special cases self.axis_name = LSTICK op = "ABS" + op.replace("=", "") self.children = RangeOP(SCButtons.X, op, value), RangeOP(SCButtons.Y, op, value) + self.children[0].axis_name = "lstick_x" + self.children[1].axis_name = "lstick_y" self.min, self.max = float(STICK_PAD_MIN), float(STICK_PAD_MAX) self.op_method = self.cmp_or elif what == RSTICK: - # Most special case of all special cases self.axis_name = RSTICK op = "ABS" + op.replace("=", "") self.children = RangeOP(SCButtons.X, op, value), RangeOP(SCButtons.Y, op, value) + self.children[0].axis_name = "rstick_x" + self.children[1].axis_name = "rstick_y" self.min, self.max = float(STICK_PAD_MIN), float(STICK_PAD_MAX) self.op_method = self.cmp_or else: @@ -436,41 +449,46 @@ def __init__(self, what, op, value): def cmp_or(self, mapper: Mapper) -> bool: return any(x(mapper) for x in self.children) - def cmp_gt(self, mapper: Mapper) -> bool: + def _cmp(self, mapper: Mapper, op, state, rising: bool) -> bool: + """Compares 'state' against the threshold with HYSTERESIS applied, and + latches the result. 'rising' says which side of the threshold satisfies + the operator, so the band always widens in the holding direction. + """ + margin = -RangeOP.HYSTERESIS if self.held else RangeOP.HYSTERESIS + self.held = op(state, self.value + (margin if rising else -margin)) + return self.held + + def _state(self, mapper: Mapper) -> bool: if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state > self.value + return None + try: + return float(getattr(mapper.state, self.axis_name)) / self.max + except AttributeError: + return None + + def cmp_gt(self, mapper: Mapper): + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.gt, state, True) def cmp_lt(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state < self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.lt, state, False) def cmp_ge(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state >= self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.ge, state, True) def cmp_le(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return state <= self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.le, state, False) def cmp_labs(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return abs(state) < self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.lt, abs(state), False) def cmp_gabs(self, mapper: Mapper) -> bool: - if mapper.state is None: - return False - state = float(getattr(mapper.state, self.axis_name)) / self.max - return abs(state) > self.value + state = self._state(mapper) + return False if state is None else self._cmp(mapper, operator.gt, abs(state), True) def __call__(self, mapper: Mapper) -> bool: return self.op_method(mapper) @@ -789,7 +807,10 @@ class MouseAction(WholeHapticAction, Action): def __init__(self, axis=None, speed: float | None = None) -> None: Action.__init__(self, *strip_none(axis, speed)) WholeHapticAction.__init__(self) - self._mouse_axis = axis or None + # NOT `axis or None`: Rels.REL_X has integer value 0, which is falsy, so + # `or None` would collapse REL_X to None -- describing it as "Mouse" (not + # "Mouse X") and moving both axes instead of just X. + self._mouse_axis = axis if axis is not None else None self._old_pos = None if speed: self.speed: tuple[float, float] = (speed, speed) @@ -912,10 +933,16 @@ def whole(self, mapper: Mapper, x, y, what) -> None: self._old_pos = None def gyro(self, mapper: Mapper, pitch, yaw, roll, *a): + # Use the same per-gyro-axis rate signs as the Per-Axis mouse paths + # (GyroAction.MOUSE_RATE_SIGN, hw-verified on DS4 + SC2). The old + # hardcoded negation of ALL axes predates the drivers' rate-convention + # normalization; with normalized rates it inverted pitch (screen Y), + # while its yaw/roll negation happened to match the sign table. + sp, sy, sr = GyroAction.MOUSE_RATE_SIGN if self._mouse_axis == YAW: - mapper.mouse_move(yaw * -self.speed[0], pitch * -self.speed[1]) + mapper.mouse_move(yaw * sy * self.speed[0], pitch * sp * self.speed[1]) else: - mapper.mouse_move(roll * -self.speed[0], pitch * -self.speed[1]) + mapper.mouse_move(roll * sr * self.speed[0], pitch * sp * self.speed[1]) def trigger(self, mapper: Mapper, position, old_position): delta = position - old_position @@ -1161,15 +1188,49 @@ class GyroAction(Action): """Uses *relative* gyroscope position as input for emulated axes""" COMMAND = "gyro" + # Mouse-path tuning, shared with GyroAbsAction. RATE_SIGN is per gyro axis + # (pitch, yaw, roll), hw-verified on the DS4. MOUSE_FACTOR scales the + # lean-to-turn cursor velocity into a sane default range. + MOUSE_RATE_SIGN = (1.0, -1.0, -1.0) + MOUSE_FACTOR = 0.01 def __init__(self, axis1, axis2=None, axis3=None): Action.__init__(self, axis1, *strip_none(axis2, axis3)) self.axes = [axis1, axis2, axis3] self.speed: tuple[float, float, float] = (1.0, 1.0, 1.0) + # lean-to-turn neutral reference (per gyro axis, radians); captured on + # the first gyro event after (re)activation or a Recenter Gyro action. + self._lean_ref = [None, None, None] + + def reset(self) -> None: + """Re-captures the lean-to-turn neutral pose on the next gyro event. + Called by mapper.reset_gyros (the Recenter Gyro special action) and by + ModeModifier when a gated gyro deactivates.""" + self._lean_ref = [None, None, None] def get_compatible_modifiers(self): return Action.MOD_SENSITIVITY | Action.MOD_SENS_Z + @staticmethod + def emit_axis(mapper: Mapper, axis, value): + """Sends a stick-range gyro value (STICK_PAD_MIN..STICK_PAD_MAX) to a + gamepad axis, rescaled to that axis' own range. + + Triggers need the rescale: they are unipolar (TRIGGER_MIN..TRIGGER_MAX + == 0..255), so merely clamping a stick-range value into them -- what + this used to do -- discarded the whole negative half and saturated the + positive half within about 0.7 deg of rotation. A gyro-driven trigger + behaved like a button. Mapping the positive half across the full + trigger travel keeps the neutral pose at "released" (which is what a + trigger has to rest at) and full pull at the same rotation that would + fully deflect a stick; bind the opposite rotation with a negative + sensitivity or the 'inverted' modifier. + """ + if axis in AxisAction.Z: + value = value * TRIGGER_MAX / STICK_PAD_MAX + mapper.gamepad.axisEvent(axis, AxisAction.clamp_axis(axis, value)) + mapper.syn_list.add(mapper.gamepad) + def set_speed(self, x: float, y: float, z: float) -> None: self.speed = (x, y, z) @@ -1177,26 +1238,57 @@ def get_speed(self) -> tuple[float, float, float]: return self.speed def gyro(self, mapper: Mapper, *pyr): + angles = None for i in (0, 1, 2): axis = self.axes[i] - # 'gyro' cannot map to mouse, but 'mouse' does that. - if axis in Axes.__members__.values() or type(axis) is int: - mapper.gamepad.axisEvent(axis, AxisAction.clamp_axis(axis, pyr[i] * self.speed[i] * -10)) - mapper.syn_list.add(mapper.gamepad) + # isinstance, not `in Axes.__members__.values()`: Rels and Axes are + # IntEnums with overlapping values (REL_X == ABS_X == 0), so the + # membership test would misroute a mouse axis here as a gamepad axis. + if isinstance(axis, Axes) or type(axis) is int: + GyroAction.emit_axis(mapper, axis, pyr[i] * self.speed[i] * -10) + # Relative mouse = lean-to-turn: cursor VELOCITY is proportional to + # the held tilt angle -- lean and the cursor keeps moving, return to + # level and it stops. (For laser-pointer tracking, where the cursor + # follows the rotation and stops with it, check Absolute.) + elif axis in (Rels.REL_X, Rels.REL_Y) and len(pyr) >= 7: + if angles is None: + q1, q2, q3, q4 = pyr[3:7] + if mapper.get_controller().flags & ControllerFlags.EUREL_GYROS: + angles = (q1 / 10430.37, q2 / 10430.37, q3 / 10430.37) # 2**15 / PI + else: + angles = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) + # The lean is measured against the neutral reference captured on + # the first event after (re)activation or Recenter Gyro -- NOT + # against the driver's absolute zero, whose yaw is an arbitrary + # power-on orientation (and would make yaw-lean unusable). + if self._lean_ref[i] is None: + self._lean_ref[i] = angles[i] + lean = anglediff(self._lean_ref[i], angles[i]) + # saturate at +-90 deg, then scale to a sane default velocity + v = clamp(STICK_PAD_MIN, lean * (2**15) * 2 / PI, STICK_PAD_MAX) + v = v * GyroAction.MOUSE_FACTOR * self.speed[i] + if axis == Rels.REL_X: + mapper.mouse_move(v, 0) + else: + # screen Y grows downward (sign hw-verified on the DS4) + mapper.mouse_move(0, -v) def describe(self, context): if self.name: return self.name rv = [] - - if self.axes[0] in Rels.__members__.values(): - return _("Mouse") - for x in self.axes: - if x: + # `is not None`, not truthiness: Rels.REL_X / Axes.ABS_X have value 0 + # (falsy) yet are valid axes. isinstance keeps mouse (Rels) apart from + # stick (Axes) -- their integer values collide (REL_X == ABS_X == 0). + if x is None: + continue + if isinstance(x, Rels): + s = MouseAction(x).describe(context) + else: s, trash, trash = AxisAction.get_axis_description(x) - if s not in rv: - rv.append(s) + if s not in rv: + rv.append(s) return "\n".join(rv) @@ -1204,12 +1296,15 @@ class GyroAbsAction(HapticEnabledAction, GyroAction): """Uses *absolute* gyroscope position as input for emulated axes""" COMMAND = "gyroabs" - MOUSE_FACTOR = 0.01 # Just random number to put default sensitivity into sane range def __init__(self, *blah): GyroAction.__init__(self, *blah) HapticEnabledAction.__init__(self) - self.ir = [0, 0, None, 0] # Initial rotation, last has to be determined + # Orientation reference: None = capture at the next gyro event. All + # axes capture-at-first-event, so the very first activation centers at + # the current pose exactly like every later one (re-centered by + # reset() on deactivation / Recenter Gyro). + self.ir = [None, None, None, None] self._was_oor = False self._deadzone_fn = None @@ -1229,13 +1324,25 @@ def get_previewable(self) -> bool: GYROAXES = (0, 1, 2) def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): + rates = (pitch, yaw, roll) # raw angular rates, used for the mouse axes if mapper.get_controller().flags & ControllerFlags.EUREL_GYROS: pyr = [q1 / 10430.37, q2 / 10430.37, q3 / 10430.37] # 2**15 / PI else: pyr = list(quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0)) for i in self.GYROAXES: - self.ir[i] = self.ir[i] or pyr[i] - pyr[i] = anglediff(self.ir[i], pyr[i]) * (2**15) * self.speed[2] * 2 / PI + # explicit None test, not `or`: a legitimate 0.0 reference is falsy + # and would re-capture on every event + if self.ir[i] is None: + self.ir[i] = pyr[i] + # speed[i], not speed[2]: sensitivity is per gyro axis, and the Z + # one used to be applied to all three -- and then applied a SECOND + # time, per-axis, down in the output loop, so a sensitivity of 2 + # came out as 4. Scaling here rather than there also means the + # clamp and the out-of-range haptic below see the value that is + # actually emitted: sensitivity now moves the rotation needed for + # full deflection, instead of merely scaling an already saturated + # reading. + pyr[i] = anglediff(self.ir[i], pyr[i]) * (2**15) * self.speed[i] * 2 / PI if self.haptic: oor = False # oor - Out Of Range for i in self.GYROAXES: @@ -1257,17 +1364,25 @@ def gyro(self, mapper: Mapper, pitch, yaw, roll, q1, q2, q3, q4): pyr[i] = int(clamp(STICK_PAD_MIN, pyr[i], STICK_PAD_MAX)) for i in self.GYROAXES: axis = self.axes[i] - if axis in Axes.__members__.values() or type(axis) == int: - val = AxisAction.clamp_axis(axis, pyr[i] * self.speed[i]) + # isinstance, not `in Axes.__members__.values()`: REL_X == ABS_X == 0 + # and REL_Y == ABS_Y == 1 by IntEnum value, so the membership test + # swallowed the mouse axes into this gamepad branch (the elifs below + # never ran) -- gyro->mouse moved the stick instead of the cursor. + if isinstance(axis, Axes) or type(axis) == int: + val = pyr[i] if self._deadzone_fn: - val, trash = self._deadzone_fn(val, 0, STICK_PAD_MAX) - val = int(val) - mapper.gamepad.axisEvent(axis, val) - mapper.syn_list.add(mapper.gamepad) + # deadzone works in stick range, before the axis rescale + val, trash = self._deadzone_fn(clamp(STICK_PAD_MIN, val, STICK_PAD_MAX), 0, STICK_PAD_MAX) + GyroAction.emit_axis(mapper, axis, val) + # Absolute mouse = laser pointer: the angular RATE is the move delta + # (like MouseAction.gyro); the rate integrates to the rotation angle, + # so the cursor tracks the controller's absolute orientation and stops + # when the rotation stops (Steam's gyro-mouse behavior). For + # angle-proportional cursor velocity (lean-to-turn), uncheck Absolute. elif axis == Rels.REL_X: - mapper.mouse_move(AxisAction.clamp_axis(axis, pyr[i] * GyroAbsAction.MOUSE_FACTOR * self.speed[i]), 0) + mapper.mouse_move(rates[i] * GyroAction.MOUSE_RATE_SIGN[i] * self.speed[i], 0) elif axis == Rels.REL_Y: - mapper.mouse_move(0, AxisAction.clamp_axis(axis, pyr[i] * GyroAbsAction.MOUSE_FACTOR * self.speed[i])) + mapper.mouse_move(0, rates[i] * GyroAction.MOUSE_RATE_SIGN[i] * self.speed[i]) class ResetGyroAction(Action): @@ -1354,7 +1469,19 @@ def get_compatible_modifiers(self): def gyro(self, mapper: Mapper, *pyr): q1, q2, q3, q4 = pyr[-4:] - pyr = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) + if mapper.get_controller().flags & ControllerFlags.EUREL_GYROS: + # q1-q3 already hold euler angles in 2**15/PI fixed point (q4 unused). + # Feeding them into quat2euler as if they were a quaternion computes + # atan2 of near-zero noise products -> arbitrary full-range angles that + # constantly cross MIN and fire tilt actions with the pad at rest. + # + # Slot order below is (front down/up, TILTED left/right, ROTATED + # left/right) = (pitch, roll, yaw), so swap yaw/roll into that order; + # pitch and yaw are negated to match the slot firing directions against + # the DS4 integration conventions (all three axes hw-verified). + pyr = (-q1 / 10430.37, q3 / 10430.37, -q2 / 10430.37) + else: + pyr = quat2euler(q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0) for j in (0, 1, 2): i = j * 2 if self.actions[i]: diff --git a/scc/config.py b/scc/config.py index 97acf77e0..e8b0c5da3 100644 --- a/scc/config.py +++ b/scc/config.py @@ -14,6 +14,25 @@ log = logging.getLogger("Config") +def _is_steam_deck() -> bool: + """Return True when running on Steam Deck hardware (LCD reports DMI product + name 'Jupiter', OLED 'Galileo'). + + Used to flip the 'autokill_daemon' default on: the Deck has no built-in + keyboard, so a daemon left running after the GUI closes keeps emulating and + locks the user out of the device's own controls (only the touchscreen still + works). Desktop machines are unaffected. + """ + for path in ("/sys/class/dmi/id/product_name", "/sys/class/dmi/id/board_name"): + try: + with open(path) as f: + if f.read().strip() in ("Jupiter", "Galileo"): + return True + except OSError: + pass + return False + + class Config: DEFAULTS = { "autoswitch_osd": True, # True to show OSD message when profile is autoswitched @@ -47,7 +66,7 @@ class Config: "enable_status_icon": False, "minimize_to_status_icon": True, "minimize_on_start": False, - "autokill_daemon": False, + "autokill_daemon": _is_steam_deck(), # default ON on the Steam Deck (no built-in keyboard) "news": { # Controls "new in this version" message "enabled": True, # if disabled, no querying is done @@ -126,6 +145,10 @@ class Config: "menu_control": "LSTICK", "menu_confirm": "A", "menu_cancel": "B", + # Remembered profile name, restored when this controller (re)connects. + # None means "use the global default". See SCCDaemon.add_controller and + # SCCDaemon._remember_controller_profile. + "profile": None, } def __init__(self) -> None: @@ -136,13 +159,42 @@ def reload(self) -> None: """(Re)loads configuration. Works as load(), but handles exceptions""" try: self.load() + except FileNotFoundError: + # First run. Nothing to preserve, nothing to warn about. + self.create() except Exception as e: - log.warning("Failed to load configuration; Creating new one.") - log.warning("Reason: %s", (e,)) + # Keep the unreadable file. Everything the user ever configured is + # in it, and create() below overwrites it with defaults -- so + # without this, one bad parse silently discards the lot. It has + # happened: an interrupted save used to leave truncated JSON, and + # the next start "recovered" by wiping it. + backup = self._keep_broken_config() + log.error("Failed to load configuration: %s", e) + if backup: + log.error("Kept the unreadable file as %s; starting from defaults.", backup) + else: + log.error("Could not preserve the unreadable file; starting from defaults.") self.create() if self.check_values(): self.save() + def _keep_broken_config(self): + """Move an unreadable config aside and return where it went, or None. + + Numbered rather than a single .broken, so a second failure cannot + overwrite the copy taken during the first one -- which would be the + only surviving record of the user's settings. + """ + try: + for i in range(1, 100): + backup = "%s.broken.%d" % (self.filename, i) + if not os.path.exists(backup): + os.replace(self.filename, backup) + return backup + except Exception as e: + log.warning("Could not preserve %s: %s", self.filename, e) + return None + def _check_dict(self, values, defaults): """Recursively checks if 'config' contains all keys in 'defaults'. @@ -216,15 +268,37 @@ def create(self) -> None: self.save() def save(self) -> None: - """Saves configuration file""" + """Saves configuration file. + + Atomically: a truncated config.json is not merely a bad save, it is + unreadable at the next start, and recovery from that means falling + back to defaults. Writing in place left exactly that window open -- + anything killing the process mid-write (Ctrl-C on the daemon, a + reboot) cost the user every setting they had. + """ # Check & create directory if not os.path.exists(get_config_path()): os.makedirs(get_config_path()) # Save data = {k: self.values[k] for k in self.values} jstr = Encoder(sort_keys=True, indent=4).encode(data) - with open(self.filename, "w") as file: - file.write(jstr) + # Same directory, so the replace below is a rename within one + # filesystem and therefore atomic. + tmp = "%s.tmp.%d" % (self.filename, os.getpid()) + try: + with open(tmp, "w") as f: + f.write(jstr) + f.flush() + # The rename can otherwise land before the contents do, which + # on a crash leaves a config.json that is present but empty. + os.fsync(f.fileno()) + os.replace(tmp, self.filename) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise log.debug("Configuration saved") def __iter__(self): diff --git a/scc/constants.py b/scc/constants.py index d6e2c43c0..9d7bcf4df 100644 --- a/scc/constants.py +++ b/scc/constants.py @@ -115,6 +115,38 @@ class HapticPos(IntEnum): BOTH = 2 # emulated +class HapticEffect(IntEnum): + """Which kind of haptic effect a HapticData describes. + + CLICK is the only one every controller can do, and the only one a v1 + understands at all; the rest need hardware with a synthesising actuator + (currently just the Steam Controller 2, output reports 0x83-0x85). A + driver that does not know an effect should fall back to CLICK rather than + going silent. + """ + + CLICK = 0 # a single tick -- what feedback() has always meant + TONE = 1 # sine at a fixed frequency, optionally LFO-modulated + SWEEP = 2 # logarithmic frequency sweep between two frequencies + SCRIPT = 3 # a preset effect stored in the controller firmware + + +class HapticEffect(IntEnum): + """Which kind of haptic effect a HapticData describes. + + CLICK is the only one every controller can do, and the only one a v1 + understands at all; the rest need hardware with a synthesising actuator + (currently just the Steam Controller 2, output reports 0x83-0x85). A + driver that does not know an effect should fall back to CLICK rather than + going silent. + """ + + CLICK = 0 # a single tick -- what feedback() has always meant + TONE = 1 # sine at a fixed frequency, optionally LFO-modulated + SWEEP = 2 # logarithmic frequency sweep between two frequencies + SCRIPT = 3 # a preset effect stored in the controller firmware + + class ControllerFlags(IntFlag): """Used by mapper to workaround some physical differences between Steam Controller and other pads.""" diff --git a/scc/controller.py b/scc/controller.py index 8f211a8e1..5da6af5d3 100644 --- a/scc/controller.py +++ b/scc/controller.py @@ -4,7 +4,7 @@ import time from typing import TYPE_CHECKING -from scc.constants import HapticPos +from scc.constants import HapticEffect, HapticPos if TYPE_CHECKING: from scc.drivers.hiddrv import HIDDecoder @@ -133,6 +133,17 @@ def feedback(self, data) -> None: 'data' is HapticData instance. """ + def rumble(self, strong: int, weak: int, duration_ms: int) -> bool: + """Plays continuous game rumble, if the hardware has real rumble motors. + + 'strong' and 'weak' are the two FF_RUMBLE magnitudes (0..65535): the + heavy low-frequency motor and the light high-frequency one. Returns True + if handled. The default returns False, which makes the mapper fall back + to emulating rumble as a train of haptic clicks -- the only option on a + Steam Controller v1, whose "motors" are the pad actuators. + """ + return False + def turnoff(self) -> None: """Turns off controller, if supported""" @@ -143,10 +154,25 @@ def disconnected(self) -> None: class HapticData: """Simple container to hold haptic feedback settings""" - def __init__(self, position: HapticPos, amplitude: int = 512, frequency: int = 4, period: int = 1024, count: int = 1) -> None: + _EFFECT_ATTRS = ("effect", "duration", "tone_frequency", "end_frequency", + "lfo_frequency", "lfo_depth", "script_id") + + def __init__(self, position: HapticPos, amplitude: int = 512, frequency: int = 4, period: int = 1024, count: int = 1, + effect=HapticEffect.CLICK, duration=200, tone_frequency=160, + end_frequency=40, lfo_frequency=0, lfo_depth=0, script_id=0) -> None: """'frequency' is used only when emulating touchpad and describes how many pixels should mouse travel between two feedback ticks. + + Everything from 'effect' on describes richer effects that only some + hardware can synthesise, and is ignored by drivers that cannot. They are + kept out of the 'data' tuple deliberately: that tuple is unpacked + positionally by existing drivers, so growing it would break them. + + 'duration' is in milliseconds; 'tone_frequency', 'end_frequency' and + 'lfo_frequency' in Hz; 'lfo_depth' 0..255; 'script_id' selects a preset + stored in the controller's own firmware. + """ data: tuple[int, int, int, int] = (int(position), int(amplitude), int(period), int(count)) if data[0] not in (HapticPos.LEFT, HapticPos.RIGHT, HapticPos.BOTH): @@ -160,11 +186,25 @@ def __init__(self, position: HapticPos, amplitude: int = 512, frequency: int = 4 self.data = data # send to controller self.frequency = frequency # used internally + self.effect = HapticEffect(effect) + self.duration = int(duration) + self.tone_frequency = int(tone_frequency) + self.end_frequency = int(end_frequency) + self.lfo_frequency = int(lfo_frequency) + self.lfo_depth = max(0, min(255, int(lfo_depth))) + self.script_id = max(0, min(255, int(script_id))) def with_position(self, position: HapticPos) -> HapticData: """Creates copy of HapticData with position value changed""" trash, amplitude, period, count = self.data - return HapticData(position, amplitude, self.frequency, period, count) + rv = HapticData(position, amplitude, 1, period, count) + # self.frequency is already scaled by 1000, so it cannot be handed back + # to the constructor -- doing so multiplied it again on every copy, and + # send_feedback() copies for every HapticPos.BOTH effect. + rv.frequency = self.frequency + for attr in self._EFFECT_ATTRS: + setattr(rv, attr, getattr(self, attr)) + return rv def get_position(self) -> HapticPos: return HapticPos(self.data[0]) @@ -185,4 +225,8 @@ def __mul__(self, by) -> HapticData: """Allows multiplying HapticData by scalar to get same values with increased amplitude.""" position, amplitude, period, count = self.data amplitude = min(amplitude * by, 0x8000) - return HapticData(position, amplitude, self.frequency, period, count) + rv = HapticData(position, amplitude, 1, period, count) + rv.frequency = self.frequency + for attr in self._EFFECT_ATTRS: + setattr(rv, attr, getattr(self, attr)) + return rv diff --git a/scc/drivers/ds4drv.py b/scc/drivers/ds4drv.py index 3ba5e8b1f..cd76424ff 100644 --- a/scc/drivers/ds4drv.py +++ b/scc/drivers/ds4drv.py @@ -27,7 +27,7 @@ except ImportError: from scc.lib.hidraw import HIDRaw -from scc.constants import STICK_PAD_MAX, STICK_PAD_MIN, ControllerFlags, HapticPos, SCButtons +from scc.constants import STICK_PAD_MAX, STICK_PAD_MIN, TRIGGER_MAX, ControllerFlags, HapticPos, SCButtons from scc.controller import Controller from scc.drivers.evdevdrv import ( HAVE_EVDEV, @@ -136,10 +136,11 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=1.0, - offset=-127.5, - clamp_max=257, - deadzone=10, + scale=2.0 / 255, + offset=-1.0, + clamp_min=STICK_PAD_MIN, + clamp_max=STICK_PAD_MAX, + deadzone=10 * 2.0 / 255, ), ), ) @@ -149,10 +150,11 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=-1.0, - offset=127.5, - clamp_max=257, - deadzone=10, + scale=-2.0 / 255, + offset=1.0, + clamp_min=STICK_PAD_MIN, + clamp_max=STICK_PAD_MAX, + deadzone=10 * 2.0 / 255, ), ), ) @@ -162,10 +164,11 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=1.0, - offset=-127.5, - clamp_max=257, - deadzone=10, + scale=2.0 / 255, + offset=-1.0, + clamp_min=STICK_PAD_MIN, + clamp_max=STICK_PAD_MAX, + deadzone=10 * 2.0 / 255, ), ), ) @@ -175,10 +178,11 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=-1.0, - offset=127.5, - clamp_max=257, - deadzone=10, + scale=-2.0 / 255, + offset=1.0, + clamp_min=STICK_PAD_MIN, + clamp_max=STICK_PAD_MAX, + deadzone=10 * 2.0 / 255, ), ), ) @@ -188,9 +192,9 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=1.0, - clamp_max=1, - deadzone=10, + scale=1.0 / 255, + clamp_max=TRIGGER_MAX, + deadzone=10.0 / 255, ), ), ) @@ -200,9 +204,9 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: size=8, data=AxisDataUnion( axis=AxisModeData( - scale=1.0, - clamp_max=1, - deadzone=10, + scale=1.0 / 255, + clamp_max=TRIGGER_MAX, + deadzone=10.0 / 255, ), ), ) @@ -245,6 +249,9 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: for x, sc in enumerate(DS4Controller.BUTTON_MAP): self._decoder.buttons.button_map[x] = button_to_bit(sc) + self._packet_size = 64 + self._decoder.packet_size = 64 + def input(self, endpoint: int, data: bytes | bytearray) -> None: # Special override for CPAD touch button if _lib.decode(ctypes.byref(self._decoder), bytes(data)) and self.mapper: diff --git a/scc/drivers/ds5drv.py b/scc/drivers/ds5drv.py index df2ddfcbb..fc117c8f5 100644 --- a/scc/drivers/ds5drv.py +++ b/scc/drivers/ds5drv.py @@ -34,6 +34,7 @@ STICK_PAD_MAX, STICK_PAD_MIN, STICK_PAD_RES, + TRIGGER_MAX, ControllerFlags, HapticPos, SCButtons, @@ -265,20 +266,20 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: mode=AxisMode.AXIS, byte_offset=1, size=8, - data=AxisDataUnion(axis=AxisModeData(scale=1.0, offset=-127.5, clamp_max=257, deadzone=2)), + data=AxisDataUnion(axis=AxisModeData(scale=2.0/255, offset=-1.0, clamp_min=STICK_PAD_MIN, clamp_max=STICK_PAD_MAX, deadzone=2*2.0/255)), ) self._decoder.axes[AxisType.AXIS_LSTICK_Y] = AxisData( mode=AxisMode.AXIS, byte_offset=2, size=8, - data=AxisDataUnion(axis=AxisModeData(scale=-1.0, offset=127.5, clamp_max=257, deadzone=2)), + data=AxisDataUnion(axis=AxisModeData(scale=-2.0/255, offset=1.0, clamp_min=STICK_PAD_MIN, clamp_max=STICK_PAD_MAX, deadzone=2*2.0/255)), ) self._decoder.axes[AxisType.AXIS_RSTICK_X] = AxisData( mode=AxisMode.AXIS, byte_offset=3, size=8, data=AxisDataUnion( - axis=AxisModeData(scale=1.0, offset=-127.5, clamp_max=257, deadzone=2), + axis=AxisModeData(scale=2.0/255, offset=-1.0, clamp_min=STICK_PAD_MIN, clamp_max=STICK_PAD_MAX, deadzone=2*2.0/255), ), ) self._decoder.axes[AxisType.AXIS_RSTICK_Y] = AxisData( @@ -286,7 +287,7 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: byte_offset=4, size=8, data=AxisDataUnion( - axis=AxisModeData(scale=-1.0, offset=127.5, clamp_max=257, deadzone=2), + axis=AxisModeData(scale=-2.0/255, offset=1.0, clamp_min=STICK_PAD_MIN, clamp_max=STICK_PAD_MAX, deadzone=2*2.0/255), ), ) @@ -294,14 +295,14 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: self._decoder.axes[AxisType.AXIS_LTRIG] = AxisData( mode=AxisMode.AXIS, byte_offset=5, - size=8, # Not sure about the size - data=AxisDataUnion(axis=AxisModeData(scale=1.0, clamp_max=1, deadzone=10)), + size=8, + data=AxisDataUnion(axis=AxisModeData(scale=1.0/255, clamp_max=TRIGGER_MAX, deadzone=10.0/255)), ) self._decoder.axes[AxisType.AXIS_RTRIG] = AxisData( mode=AxisMode.AXIS, byte_offset=6, - size=8, # Not sure about the size - data=AxisDataUnion(axis=AxisModeData(scale=1.0, clamp_max=1, deadzone=10)), + size=8, + data=AxisDataUnion(axis=AxisModeData(scale=1.0/255, clamp_max=TRIGGER_MAX, deadzone=10.0/255)), ) # Gyro @@ -351,6 +352,7 @@ def _load_hid_descriptor(self, config, max_size, vid, pid, test_mode) -> None: self._decoder.buttons.button_map[x] = button_to_bit(sc) self._packet_size = 64 + self._decoder.packet_size = 64 def input(self, endpoint: int, data: bytearray) -> None: # Special override for CPAD touch button diff --git a/scc/drivers/hiddrv.c b/scc/drivers/hiddrv.c index 4c9b62916..f1aac93de 100644 --- a/scc/drivers/hiddrv.c +++ b/scc/drivers/hiddrv.c @@ -3,7 +3,8 @@ #include #include #include -#define CLAMP(min, x, max) x +#include +#define CLAMP(min, x, max) ((x) < (min) ? (min) : ((x) > (max) ? (max) : (x))) #define HIDDRV_MODULE_VERSION 8 PyObject* module; @@ -131,15 +132,25 @@ union Value { }; -static union Value grab_value(const char* data, const size_t byte_offset, uint8_t bit_offset) { - union Value val = *((union Value*)(data + byte_offset)); - val.u64 = val.u64 >> bit_offset; +static union Value grab_value(const char* data, size_t data_len, const size_t byte_offset, uint8_t bit_offset) { + union Value val; + val.u64 = 0; + if (byte_offset < data_len) { + size_t avail = data_len - byte_offset; + if (avail > sizeof(val)) + avail = sizeof(val); + memcpy(&val, data + byte_offset, avail); + } + if (bit_offset < 64) + val.u64 = val.u64 >> bit_offset; + else + val.u64 = 0; return val; } -static int grab_with_size(const uint8_t size, const char* data, const size_t byte_offset, uint8_t bit_offset) { - union Value val = grab_value(data, byte_offset, bit_offset); +static int grab_with_size(const uint8_t size, const char* data, size_t data_len, const size_t byte_offset, uint8_t bit_offset) { + union Value val = grab_value(data, data_len, byte_offset, bit_offset); // if (size == 16) // printf(" => %i\n", (int)val.u16); switch (size) { @@ -162,7 +173,7 @@ bool decode(struct HIDDecoder* dec, const char* data) { switch (dec->axes[i].mode) { case AXIS: fval = ((grab_with_size(dec->axes[i].size, - data, dec->axes[i].byte_offset, dec->axes[i].bit_offset) + data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset) * dec->axes[i].data.axis.scale) + dec->axes[i].data.axis.offset ); @@ -179,10 +190,10 @@ bool decode(struct HIDDecoder* dec, const char* data) { break; case AXIS_NO_SCALE: dec->state.axes[i] = grab_with_size(dec->axes[i].size, - data, dec->axes[i].byte_offset, dec->axes[i].bit_offset); + data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); break; case DPAD: - value = grab_value(data, dec->axes[i].byte_offset, + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); if ((value.u32 >> dec->axes[i].data.dpad.button1) & 1) { dec->state.buttons |= dec->axes[i].data.dpad.button; @@ -193,7 +204,9 @@ bool decode(struct HIDDecoder* dec, const char* data) { } break; case HATSWITCH: - value = grab_value(data, dec->axes[i].byte_offset, + if (i + 1 >= AXIS_COUNT) + break; + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); switch (value.u8 & 0b1111) { case 0: // up @@ -243,17 +256,17 @@ bool decode(struct HIDDecoder* dec, const char* data) { } break; case DS4ACCEL: - value = grab_value(data, dec->axes[i].byte_offset, + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); dec->state.axes[i] = value.s16; break; case DS4GYRO: - value = grab_value(data, dec->axes[i].byte_offset, + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); dec->state.axes[i] = -value.s16; break; case DS4TOUCHPAD: - value = grab_value(data, dec->axes[i].byte_offset, + value = grab_value(data, dec->packet_size, dec->axes[i].byte_offset, dec->axes[i].bit_offset); dec->state.axes[i] = ((value.u16 & 0x0FFF) * dec->axes[i].data.axis.scale) @@ -266,9 +279,9 @@ bool decode(struct HIDDecoder* dec, const char* data) { // Buttons if (dec->buttons.enabled) { - union Value value = grab_value(data, dec->buttons.byte_offset, dec->buttons.bit_offset); + union Value value = grab_value(data, dec->packet_size, dec->buttons.byte_offset, dec->buttons.bit_offset); for (i=0; ibuttons.button_map[i] < 33) { + if (dec->buttons.button_map[i] < 32) { uint32_t bit = (value.u32 >> i) & 1; dec->state.buttons |= bit << dec->buttons.button_map[i]; } diff --git a/scc/drivers/hiddrv.py b/scc/drivers/hiddrv.py index 1665c11ca..d0857c42b 100644 --- a/scc/drivers/hiddrv.py +++ b/scc/drivers/hiddrv.py @@ -48,6 +48,13 @@ LIBUSB_DT_REPORT = 0x22 AXIS_COUNT = 24 # Must match number of axis fields in HIDControllerInput and values in AxisType BUTTON_COUNT = 32 # Must match (or be less than) number of bits in HIDControllerInput.buttons +# "This input bit maps to no button". It has to be OUTSIDE 0..BUTTON_COUNT-1: +# the decoder turns a mapping into `1 << button_map[i]`, so any in-range value +# names a real button. This used to be BUTTON_COUNT - 1, which is bit 31 -- +# RSTICKPRESS -- so on a JSON-configured controller every unmapped input bit +# pressed the right stick. 32 is only safe to use now that the C guard rejects +# it (it would otherwise shift a uint32_t by 32, which is undefined). +BUTTON_UNMAPPED = BUTTON_COUNT ALLOWED_SIZES = [1, 2, 4, 8, 16, 32] SYS_DEVICES = "/sys/devices" @@ -67,7 +74,8 @@ def button_to_bit(sc) -> int: bit += 1 if sc & 1 == 1: return bit - return BUTTON_COUNT - 1 + return BUTTON_UNMAPPED + class HIDDrvError(Exception): pass @@ -323,8 +331,7 @@ def _build_button_map(self, config: dict): Generates default if config is not available. """ if config: - # Last possible value is default "maps-to-nothing" mapping - buttons = [BUTTON_COUNT - 1] * BUTTON_COUNT + buttons = [BUTTON_UNMAPPED] * BUTTON_COUNT for keycode, value in config.get("buttons", {}).items(): keycode = int(keycode) - FIRST_BUTTON # TODO(Martin): Compat crutch for saved devices - autoconvert it to current standard instead diff --git a/scc/drivers/sc2.py b/scc/drivers/sc2.py index f7f85c52e..277f00cd2 100644 --- a/scc/drivers/sc2.py +++ b/scc/drivers/sc2.py @@ -5,9 +5,10 @@ via the wireless Puck (0x1304): lizard-mode disable plus button / stick / pad / trigger / gyro input through scc-daemon to uinput, and click haptics. Wired (0x1302) is supported too (single HID interface 0), and the GUI gets -images/sc2-config.json. Still TODO: continuous variable rumble, the Bluetooth -(0x1303) transport, real serial read-back, and a dedicated v2 GUI background -image (currently reuses the Deck's). +images/sc2-config.json. Continuous rumble uses output report 0x80. Still TODO: +the Bluetooth (0x1303) transport, real serial read-back, the richer haptic +effects (tone/sweep/script, reports 0x83-0x85), actuator audio streaming +(0x86-0x89), and a dedicated v2 GUI background image (reuses the Deck's). Architecture mirrors the existing drivers: - the wireless "Controller Puck" (0x1304) is a multi-slot dongle, like @@ -31,13 +32,19 @@ import usb1 -from scc.constants import STICK_PAD_MAX, STICK_PAD_MIN, ControllerFlags, HapticPos, SCButtons +from scc.constants import ( + STICK_PAD_MAX, + STICK_PAD_MIN, + ControllerFlags, + HapticEffect, + HapticPos, + SCButtons, +) from scc.controller import HapticData from scc.drivers.sc_dongle import SCController, SCPacketType from scc.drivers.usb import SCUSBDevice, register_hotplug_device if TYPE_CHECKING: - from scc.controller import HapticData from scc.sccdaemon import SCCDaemon log = logging.getLogger("SC2") @@ -120,12 +127,49 @@ def _log_imu_calib(idata) -> None: # for one), so this cannot be expressed as a profile and has to be built in. # Emitted on press and, more softly, on release, because that is what a real # dome switch does: it clicks going down and again springing back. -PAD_CLICK_PRESS = 0x6000 -PAD_CLICK_RELEASE = 0x3000 +PAD_CLICK_PRESS = 0x1000 +PAD_CLICK_RELEASE = 0x0400 + +# Report 0x82's last byte is a SIGNED gain in decibels, clamped by the firmware +# to this range -- not a 0..255 amplitude, which is what it was being fed. See +# the haptic notes in docs/steam-controller-v2-protocol.md. +HAPTIC_GAIN_MIN_DB = -23 +HAPTIC_GAIN_MAX_DB = 24 +# The Strength slider's own bounds (glade adjFAmplitude). The gain range is +# spread across exactly this, so every part of the slider does something: +# anchoring 512 to 0 dB instead put +24 dB at amplitude 8192, leaving the top +# three quarters of the travel clamped and indistinguishable on hardware. +HAPTIC_AMPLITUDE_MIN = 16 +HAPTIC_AMPLITUDE_MAX = 32767 + + +def _gain_db(amplitude: int) -> int: + """HapticData amplitude -> report 0x82 gain in dB. + + Logarithmic, because the slider is linear in amplitude while the device + takes decibels; this keeps the perceived step per pixel roughly even. + """ + a = max(HAPTIC_AMPLITUDE_MIN, min(HAPTIC_AMPLITUDE_MAX, int(amplitude))) + t = math.log(a / HAPTIC_AMPLITUDE_MIN) / math.log(HAPTIC_AMPLITUDE_MAX / HAPTIC_AMPLITUDE_MIN) + return int(round(HAPTIC_GAIN_MIN_DB + t * (HAPTIC_GAIN_MAX_DB - HAPTIC_GAIN_MIN_DB))) TURNOFF_QUIET_PERIOD = 0.5 +# Report 0x82 offers two click presets, and we only ever used the quiet one. +# Reach for the strong one past this gain, so strength still has an audible +# range even if the firmware turns out to ignore the gain byte for a preset +# effect -- which is the open question about it. +HAPTIC_STRONG_CLICK_ABOVE_DB = 18 +HAPTIC_CLICK = 0x01 +HAPTIC_CLICK_STRONG = 0x02 + +# Set SCC_HAPTIC_DEBUG=1 to log every haptic report as it goes out. +_HAPTIC_DEBUG = bool(os.environ.get("SCC_HAPTIC_DEBUG")) +if _HAPTIC_DEBUG: + log.setLevel(logging.INFO) + + # --- report 0x42 layout (see docs/steam-controller-v2-protocol.md) ---------- # 0 report id (0x42) # 1 packet counter @@ -374,6 +418,7 @@ def __init__(self, driver: SC2Device, ccidx: int, in_endpoint: int, out_endpoint super().__init__(driver, ccidx, in_endpoint) self._out_ep = out_endpoint # interrupt-OUT endpoint for haptics self._old_state = SC2_NULL + self._rumble_stop = None # pending 'stop rumble' task, see rumble() # pad-press button -> which actuator to pulse _PAD_CLICK_SIDES = ((SCButtons.LPAD, HapticPos.LEFT), (SCButtons.RPAD, HapticPos.RIGHT)) @@ -395,6 +440,39 @@ def queue_pad_click(self, idata) -> None: elif was and not now: self.mapper.send_feedback(HapticData(pos, amplitude=PAD_CLICK_RELEASE)) + def rumble(self, strong: int, weak: int, duration_ms: int) -> bool: + """Continuous game rumble via output report 0x80 (HAPTIC_RUMBLE). + + Layout `80 + `, all little-endian, both gains 0 dB -- which + is what Valve's own SDL3 driver for this controller sends. + + SDL calls its two magnitudes low_frequency_rumble and + high_frequency_rumble and puts them on left and right respectively; + those are the same two motors Linux calls strong (heavy) and weak + (light), so they map straight across. Sending one averaged value to + both sides instead -- which is what the uinput shim used to hand us -- + made fftest's "strong rumble" and "weak rumble" feel identical. + """ + left = max(0, min(0xFFFF, int(strong))) + right = max(0, min(0xFFFF, int(weak))) + self._send_rumble(left, right) + # 0x80 carries no duration -- it runs until something changes it -- so + # the stop has to be scheduled here or a single effect rumbles forever. + if self._rumble_stop: + self.mapper.cancel_task(self._rumble_stop) + self._rumble_stop = None + if (left or right) and duration_ms > 0: + self._rumble_stop = self.mapper.schedule( + duration_ms / 1000.0, lambda *a: self._send_rumble(0, 0)) + return True + + def _send_rumble(self, left: int, right: int) -> None: + report = struct.pack(" str: return "sc2" @@ -429,11 +507,16 @@ def clear_mappings(self) -> None: def configure( self, idle_timeout: int | None = None, enable_gyros: bool | None = None, led_level: int | None = None ) -> None: - # Replay the config blocks captured from Steam. These put the controller - # into gamepad mode; the exact gyro-enable register is still TODO. + # Replay the config blocks captured from Steam, which put the controller + # into gamepad mode. The blocks are (register, value16-LE) triplets, and + # register 0x30 is the IMU-stream bitmask -- the same one the v1 driver + # writes (see sc_dongle.configure, where it sits at the identical + # position, right after the 0x30 byte). if led_level is not None: self._led_level = led_level - # main config block: 87 0f 30 18 00 07 07 00 08 07 00 31 02 00 52 03 + if enable_gyros is not None: + self._enable_gyros = enable_gyros + # main config block: 87 0f 30 00 07 07 00 08 07 00 31 02 00 52 03 self._driver.overwrite_control( self._ccidx, bytes( @@ -441,7 +524,7 @@ def configure( SCPacketType.CONFIGURE, 0x0F, 0x30, - 0x18, + self._imu_mask(), 0x00, 0x07, 0x07, @@ -462,9 +545,36 @@ def configure( self._ccidx, struct.pack(">BBBB", SCPacketType.CONFIGURE, 0x03, 0x2D, int(self._led_level)) ) + # IMU-stream bits for CONFIGURE register 0x30, from the v1 protocol + IMU_GYRO = 0x10 # raw angular rates + IMU_ACCEL = 0x08 # accelerometer + IMU_QUAT = 0x04 # fused orientation quaternion + + def _imu_mask(self) -> int: + """IMU streams to turn on. + + The block captured from Steam carried 0x18 -- rates and accel, but NOT + the fused quaternion. Since parse_input derives the euler angles this + controller reports through EUREL_GYROS from that quaternion, and an + all-zero quaternion converts to exactly zero (atan2(0,1) and asin(0)), + every orientation-based binding silently did nothing: absolute gyro, + lean-to-turn and tilt all sat at their neutral output while the + rate-based ones worked. Confirmed by a user's IMU-CALIB dump, whose + euler columns read 0.0 while the rates moved. + + It only worked at all on controllers where Steam had already switched + the quaternion on, because nothing here ever wrote this register -- + set_gyro_enabled was a no-op, so we inherited whatever state the pad + was left in. That is why the same build behaved differently on two + otherwise identical SC2s. + + Rates and accel stay on unconditionally, exactly as before, so nothing + that works today can regress; only the quaternion bit is switched. + """ + return self.IMU_GYRO | self.IMU_ACCEL | (self.IMU_QUAT if self._enable_gyros else 0) + def set_gyro_enabled(self, enabled: bool) -> None: - self._enable_gyros = enabled - # TODO: which 0x87 CONFIGURE register enables the IMU? + self.configure(enable_gyros=enabled) def get_gyro_enabled(self) -> bool: return self._enable_gyros @@ -477,11 +587,36 @@ def feedback(self, data: HapticData) -> None: # it suits pad/scroll detents; sustained game rumble may need another # report (not yet found). data.data = (position, amplitude, period, count). pos = data.data[0] - amp = data.data[1] if len(data.data) > 1 else 0x4000 + amp = data.data[1] if len(data.data) > 1 else 512 if amp <= 0: return side = {HapticPos.LEFT: 0, HapticPos.RIGHT: 1, HapticPos.BOTH: 2}.get(pos, 1) - report = bytes([0x82, side, 0x01, min(255, amp >> 7)]) + gain = _gain_db(amp) + effect = getattr(data, "effect", HapticEffect.CLICK) + if effect == HapticEffect.TONE: + # 0x83 HAPTIC_LFO_TONE: side u8, gain i8, freq u16, duration u16, + # lfo_freq u16, lfo_depth u8 + report = struct.pack(" HAPTIC_STRONG_CLICK_ABOVE_DB else HAPTIC_CLICK + report = struct.pack(" the 2**15/PI fixed point EUREL_GYROS wants + + +def _quat_to_eurel(q1: int, q2: int, q3: int, q4: int) -> tuple[int, int, int]: + """Convert the SC1 hardware quaternion (q1=w q2=x q3=y q4=z, unit * 32767) + to DS4/SC2-convention EUREL euler angles (pitch, yaw, roll) in 2**15/PI fixed + point. Measured from held poses: the SC1 and SC2 use the identical quaternion + convention -- x=pitch (nose-up +), y=roll (roll-right +), z=yaw (yaw-left +) -- + so this is byte-for-byte the sc2.parse_input mapping. Feeding these to the + mapper (with EUREL_GYROS set) puts SC1 on the same single gyro code path as + the DS4/SC2, where all the axis/sign conventions are hardware-verified.""" + w, x, y, z = q1 / 32767.0, q2 / 32767.0, q3 / 32767.0, q4 / 32767.0 + pitch = atan2(2.0 * (w * x + y * z), 1.0 - 2.0 * (x * x + y * y)) + roll = asin(max(-1.0, min(1.0, 2.0 * (w * y - z * x)))) + yaw = atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)) + return ( + int(-pitch * _EUREL_SCALE), + int(-yaw * _EUREL_SCALE), + int(roll * _EUREL_SCALE), + ) + class ControllerInput(NamedTuple): """Based on INPUT_FORMAT except anything starting with "ukn_""" @@ -92,7 +116,39 @@ class ControllerInput(NamedTuple): log = logging.getLogger("SCDongle") - +_CALIB = bool(os.environ.get("SCC_GYRO_CALIB")) +if _CALIB: + # The daemon leaves the root logger at WARNING; opt this logger into INFO so + # the IMU-calibration dump is visible. + log.setLevel(logging.INFO) +_calib_last_t = 0.0 + + +def _log_imu_calib(idata) -> None: + """Throttled IMU dump for Steam Controller v1 gyro calibration (gate: env + SCC_GYRO_CALIB=1). Logs the accel gravity vector, the raw hardware + quaternion, what quat2euler currently makes of it, and the raw rates -- so + held poses reveal the quaternion's real axis/sign convention vs the + EUREL convention the DS4/SC2 use.""" + global _calib_last_t + now = time.time() + if now - _calib_last_t < 0.25: + return + _calib_last_t = now + ax, ay, az = idata.accel_x, idata.accel_y, idata.accel_z + mag = sqrt(ax * ax + ay * ay + az * az) or 1.0 + q = (idata.q1 / 32767.0, idata.q2 / 32767.0, idata.q3 / 32767.0, idata.q4 / 32767.0) + qnorm = sqrt(sum(c * c for c in q)) * 32767.0 + e = quat2euler(*q) + deg = 180.0 / PI + log.info( + "IMU-CALIB accel unit=(% .2f % .2f % .2f) |a|=%6.0f | quat=(% 6d % 6d % 6d % 6d) |q|=%6.0f | " + "quat2euler deg=(% 6.1f % 6.1f % 6.1f) | rates(gpitch,groll,gyaw)=(% 5d % 5d % 5d)", + ax / mag, ay / mag, az / mag, mag, + idata.q1, idata.q2, idata.q3, idata.q4, qnorm, + e[0] * deg, e[1] * deg, e[2] * deg, + idata.gpitch, idata.groll, idata.gyaw, + ) class Dongle(SCUSBDevice): MAX_ENDPOINTS = 4 @@ -186,7 +242,9 @@ class SCConfigType(IntEnum): class SCController(Controller): - flags: int = ControllerFlags.LPAD_RPAD_IS_CIRCLE + # The SC1 quaternion is converted to euler host-side (input()) and handed to + # the mapper in q1-q3 as EUREL angles, so SC1 shares the DS4/SC2 gyro path. + flags: int = ControllerFlags.LPAD_RPAD_IS_CIRCLE | ControllerFlags.EUREL_GYROS def __init__(self, driver: Deck | Dongle | SCByBt | SCByCable | SC2Device, ccidx: int, endpoint: int) -> None: Controller.__init__(self) @@ -206,6 +264,11 @@ def __init__(self, driver: Deck | Dongle | SCByBt | SCByCable | SC2Device, ccidx def get_type(self) -> str: return "sc" + def get_gui_config_file(self) -> str: + # Steam Controller (v1): GUI-only config that puts the Steam logo on the + # C button (image + side icon). Inherited by SCByCable / SCByBt. + return "sc-config.json" + def __repr__(self) -> str: return f"" @@ -266,6 +329,15 @@ def input(self, idata: ControllerInput) -> None: q4=idata.q4, ) + if _CALIB: + _log_imu_calib(idata) # logs the RAW quaternion, before conversion + # Convert the hardware quaternion (q1-q4) to EUREL euler angles in + # q1-q3 (q4 unused) so the mapper's gyro paths -- absolute, tilt, + # lean-to-turn -- match the DS4/SC2 exactly. Only when the gyro is + # streaming (a nonzero quaternion); disabled, q1-q4 are 0. + if idata.q1 or idata.q2 or idata.q3 or idata.q4: + p, y, r = _quat_to_eurel(idata.q1, idata.q2, idata.q3, idata.q4) + idata = idata._replace(q1=p, q2=y, q3=r, q4=0) self.mapper.input(self, old_state, idata) def _generate_id(self) -> str: @@ -302,8 +374,17 @@ def cb(rawserial) -> None: self._driver.make_request( self._ccidx, cb, struct.pack(">BBB61x", SCPacketType.GET_SERIAL, SCPacketLength.GET_SERIAL, 0x01), + on_giveup=self._on_serial_giveup, ) + def _on_serial_giveup(self) -> None: + """Called when the GET_SERIAL request kept stalling. Add the controller + with a generated id anyway, so it still appears (it just won't have a + stable serial-based identity).""" + log.warning("GET_SERIAL kept stalling for SC on endpoint %s; using a generated id", self._endpoint) + self.generate_serial() + self.on_serial_got() + def generate_serial(self) -> None: """Called only if ignore_serials is enabled""" if len(self._driver._available_serials) > 0: @@ -318,7 +399,13 @@ def on_serial_got(self) -> None: except UnicodeDecodeError: log.debug("Failed to decode wireless SC serial") self._serial = self._driver._available_serials.pop() - self._id = str(self._serial) + serial = str(self._serial).strip() + if not serial or serial in self._driver.daemon.get_active_ids(): + # A blank or already-used id would make two controllers collapse into + # one in the GUI (it keys controllers by id). Keep them distinct by + # falling back to a generated positional id. + serial = self._generate_id() + self._id = serial self._driver.daemon.add_controller(self) def apply_config(self, config: dict) -> None: diff --git a/scc/drivers/usb.py b/scc/drivers/usb.py index 9a2728d4b..bfa7578d2 100644 --- a/scc/drivers/usb.py +++ b/scc/drivers/usb.py @@ -20,6 +20,8 @@ import usb1 if TYPE_CHECKING: + from collections.abc import Callable + from usb1 import USBContext, USBDevice, USBDeviceHandle, USBTransfer from scc.drivers.hiddrv import HIDDrvFakeDaemon @@ -27,6 +29,11 @@ log = logging.getLogger("USB") +# How many times to retry a stalling control request (notably the flaky Steam +# Controller v1 GET_SERIAL) on successive flushes before giving up, instead of +# letting the stall propagate and tear down the whole device. +REQUEST_MAX_ATTEMPTS = 20 + class SCUSBDevice: """Base class for all handled usb devices.""" @@ -98,10 +105,13 @@ def overwrite_control(self, index, data) -> None: break self.send_control(index, data) - def make_request(self, index, callback, data, size=64) -> None: + def make_request(self, index: int, callback: Callable[[bytes], None], data: bytes, + size: int = 64, on_giveup: Callable[[], None] | None = None) -> None: """Schedule a synchronous request that requires response. Request is done ASAP and provided callback is called with received data. + If the control transfer keeps stalling it is retried on later flushes, + and 'on_giveup' (if given) is called once the retries are exhausted. """ self._rmsg.append( ( @@ -115,26 +125,53 @@ def make_request(self, index, callback, data, size=64) -> None: index, size, callback, + on_giveup, + 0, # stall-retry attempts so far ), ) def flush(self) -> None: - """Flush all prepared control messages to the device.""" + """Flush all prepared control messages to the device. + + A control-endpoint stall (USBErrorPipe) is recovered from rather than + propagated: letting it reach the mainloop would tear down the whole + device and drop its controllers. Notably the Steam Controller v1 + GET_SERIAL request is flaky and can stall (more so with several dongles + connected at once); such a request is retried on a later flush. + """ while len(self._cmsg): msg = self._cmsg.pop() - self.handle.controlWrite(*msg) + try: + self.handle.controlWrite(*msg) + except usb1.USBErrorPipe: + # Config command stalled; drop it (it is re-sent on the next + # configure) instead of tearing the whole device down. + pass + requeue = [] while len(self._rmsg): - msg, index, size, callback = self._rmsg.pop() - self.handle.controlWrite(*msg) - data = self.handle.controlRead( - 0xA1, # request_type - 0x01, # request - 0x0300, # value - index, - size, - ) + msg, index, size, callback, on_giveup, attempts = self._rmsg.pop() + try: + self.handle.controlWrite(*msg) + data = self.handle.controlRead( + 0xA1, # request_type + 0x01, # request + 0x0300, # value + index, + size, + ) + except usb1.USBErrorPipe: + # Control protocol stall; it clears on the next SETUP, so retry on + # a later flush rather than letting it close the whole device. + if attempts + 1 < REQUEST_MAX_ATTEMPTS: + requeue.append((msg, index, size, callback, on_giveup, attempts + 1)) + else: + log.warning("Control request to %s kept stalling; giving up after %d tries", self, attempts + 1) + if on_giveup: + on_giveup() + continue callback(data) + self._rmsg.extend(requeue) def force_restart(self) -> None: """Restart device, close handle and try to re-grab it again. diff --git a/scc/gui/action_editor.py b/scc/gui/action_editor.py index b03913a2e..111398db8 100644 --- a/scc/gui/action_editor.py +++ b/scc/gui/action_editor.py @@ -12,7 +12,7 @@ from gi.repository import GLib, Gtk from scc.actions import Action, NoAction, RingAction, TriggerAction -from scc.constants import CUT, LINEAR, MINIMUM, ROUND, TRIGGER_CLICK, HapticPos, SCButtons +from scc.constants import CUT, LINEAR, MINIMUM, ROUND, TRIGGER_CLICK, HapticEffect, HapticPos, SCButtons from scc.gui.ae import AEComponent from scc.gui.controller_widget import GYROS, PADS, PRESSABLE, STICKS, TRIGGERS from scc.gui.dwsnc import headerbar @@ -27,6 +27,8 @@ BallModifier, ClickModifier, DeadzoneModifier, + HAPTIC_EFFECT_MODIFIERS, + HAPTIC_SCRIPTS, FeedbackModifier, ModeModifier, NameModifier, @@ -66,6 +68,29 @@ DEADZONE_MODES = [CUT, ROUND, LINEAR, MINIMUM] +# Feedback effects and the parameters each one uses, in display order. The +# widgets for these are built at runtime rather than in the .glade: six rows of +# label+scale that are mostly hidden at any moment is a lot of XML to maintain +# by hand, and building them from this table keeps the rows, the show/hide rule +# and the modifier arguments from drifting apart. +# +# (attribute, label, min, max, page step, default, choices) +# "choices" turns the row into a named dropdown instead of a slider, for +# parameters that are an enumeration rather than a magnitude. +# The step is how far Page Up/Down and a click on the trough move; the arrow +# keys and the spin button always move by one, because these are values you +# know rather than dial by feel -- 220 Hz, 300 ms. +FEEDBACK_PARAMS = { + "tone_frequency": (_("Frequency (Hz)"), 20, 1000, 10, 160, None), + "end_frequency": (_("End frequency (Hz)"), 20, 1000, 10, 40, None), + "duration": (_("Duration (ms)"), 10, 5000, 50, 200, None), + "lfo_frequency": (_("LFO rate (Hz)"), 0, 50, 5, 0, None), + "lfo_depth": (_("LFO depth"), 0, 255, 10, 0, None), + "script_id": (_("Preset"), 0, 255, 1, HAPTIC_SCRIPTS[0][0], HAPTIC_SCRIPTS), +} + + + class ActionEditor(Editor): GLADE = "action_editor.ui" ERROR_CSS = " #error {background-color:green; color:red;} " @@ -120,6 +145,8 @@ def __init__(self, app, callback) -> None: self.deadzone = [0, 0] # Deadzone slider values, set later self.deadzone_mode = None # None for 'disabled' self.feedback_position = None # None for 'disabled' + self.feedback_effect = HapticEffect.CLICK # chosen haptic effect + self.feedback_params = {} # effect parameter values by name self.smoothing = None # None for 'disabled' self.friction = -1 # -1 for 'disabled' self.click = False # Click modifier value. None for disabled @@ -163,6 +190,7 @@ def setup_widgets(self): self.feedback[i], # default value ), ) + self._build_feedback_effects() for key in SMT: i = SMT.index(key) self.smoothing_widgets.append( @@ -185,9 +213,6 @@ def setup_widgets(self): ), ) - if self.app.osd_mode: - self.builder.get_object("entName").set_sensitive(False) - def load_components(self): """Loads list of editor components""" # Import and load components @@ -219,10 +244,6 @@ def on_Dialog_destroy(self, *a): if self._selected_component is not None: self._selected_component.hidden() - def on_Dialog_key_press_event(self, controller, keyval, keycode, state): - if self.app.osd_mode and keyval == 65471: - self.on_btOK_clicked() - def set_osd_enabled(self, value): """Sets value of OSD modifier checkbox, without firing any more events.""" self._recursing = True @@ -600,6 +621,21 @@ def update_modifiers(self, *a): self.smoothing = smoothing set_action = True + # Feedback effect and its parameters. These have to be diffed here like + # every other modifier setting: update_modifiers is what decides the + # action is dirty, and anything it does not track is dropped the next + # time some other change rebuilds the editor from state. + if getattr(self, "_feedback_effects_supported", False): + effect = self.get_feedback_effect() + if self.feedback_effect != effect: + self.feedback_effect = effect + set_action = True + for key in self.feedback_effect_rows: + value = self._row_value(key) + if self.feedback_params.get(key) != value: + self.feedback_params[key] = value + set_action = True + # Rest if self.click is not None and cbRequireClick.get_active() != self.click: self.click = cbRequireClick.get_active() @@ -660,11 +696,18 @@ def generate_modifiers(self, action, from_custom: bool = False): cbFeedback = self.builder.get_object("cbFeedback") grFeedback = self.builder.get_object("grFeedback") if from_custom or (cbFeedback.get_active() and grFeedback.get_sensitive()): - # Build FeedbackModifier arguments - feedback = [FEEDBACK_SIDES[cbFeedbackSide.get_active()]] + feedback - feedback += [action] - # Create modifier - action = FeedbackModifier(*feedback) + side = FEEDBACK_SIDES[cbFeedbackSide.get_active()] + effect = self.feedback_effect + if effect == HapticEffect.CLICK: + action = FeedbackModifier(*([side] + feedback + [action])) + else: + # amplitude, then this effect's own parameters in the + # order its modifier declares them + cls = next(c for c in HAPTIC_EFFECT_MODIFIERS if c.EFFECT == effect) + args = [side, self.feedback[0]] + args += [self.feedback_params.get(k, FEEDBACK_PARAMS[k][4]) + for k in cls.PARAMS] + action = cls(*(args + [action])) if (cm & Action.MOD_SMOOTH) != 0: if self.smoothing != None: @@ -719,6 +762,116 @@ def strip_modifiers(action): return action return action + def _build_feedback_effects(self): + """Adds the effect chooser and its parameter rows to the feedback grid. + + Hidden entirely on controllers that can only do a click, so nobody is + offered an effect that would silently degrade on their hardware. + """ + from scc.gui.ae import has_haptic_effects + grid = self.builder.get_object("grFeedback") + self.feedback_effect_rows = {} + + self._cbFeedbackEffect = Gtk.ComboBoxText() + for cls in HAPTIC_EFFECT_MODIFIERS: + self._cbFeedbackEffect.append_text(cls.LABEL) + self._cbFeedbackEffect.set_active(0) + self._cbFeedbackEffect.connect("changed", self.on_feedback_effect_changed) + self._lblFeedbackEffect = Gtk.Label(label=_("Effect"), xalign=0) + # row 1 is the side chooser; the effect belongs above it, since it + # decides what the rest of the panel even means + grid.insert_row(1) + grid.attach(self._lblFeedbackEffect, 0, 1, 1, 1) + grid.attach(self._cbFeedbackEffect, 1, 1, 1, 1) + + row = 16 # well past the glade-defined rows + for key, (label, lo, hi, step, default, choices) in FEEDBACK_PARAMS.items(): + lbl = Gtk.Label(label=label, xalign=0) + if choices: + w = Gtk.ComboBoxText() + for value, name in choices: + w.append(str(value), name) + w.set_active_id(str(default)) + w.connect("changed", self.on_feedback_effect_changed) + w.set_hexpand(True) + field = w + else: + # Slider to explore the range by feel, spin button beside it to + # land on a value exactly. They share one adjustment, so neither + # can disagree with the other, and the signal is connected there + # rather than to either widget. + adj = Gtk.Adjustment(value=default, lower=lo, upper=hi, + step_increment=1, page_increment=step) + adj.connect("value-changed", self.on_feedback_effect_changed) + w = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + w.set_digits(0) + w.set_draw_value(False) # the spin button is showing the number + w.set_hexpand(True) + spin = Gtk.SpinButton(adjustment=adj, climb_rate=1, digits=0) + spin.set_numeric(True) + field = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + field.append(w) + field.append(spin) + grid.attach(lbl, 0, row, 1, 1) + grid.attach(field, 1, row, 1, 1) + self.feedback_effect_rows[key] = (lbl, w, field, default) + self.feedback_params[key] = default + row += 1 + + self._feedback_effects_supported = has_haptic_effects(self.app) + self._update_feedback_effect_rows() + + def _row_value(self, key): + """Current value of a parameter row, whichever widget kind it uses.""" + trash, w, trash2, default = self.feedback_effect_rows[key] + if isinstance(w, Gtk.ComboBoxText): + return int(w.get_active_id()) if w.get_active_id() else default + return int(w.get_value()) + + def _set_row_value(self, key, value): + trash, w, trash2, default = self.feedback_effect_rows[key] + if isinstance(w, Gtk.ComboBoxText): + # an id the firmware has but our list does not: keep it rather than + # silently rewriting the profile to something else + if w.set_active_id(str(int(value))) is False: + w.append(str(int(value)), _("Preset %s") % (int(value),)) + w.set_active_id(str(int(value))) + else: + w.set_value(value) + + def get_feedback_effect(self): + """Currently chosen HapticEffect (CLICK if the chooser is not shown).""" + if not getattr(self, "_feedback_effects_supported", False): + return HapticEffect.CLICK + return HAPTIC_EFFECT_MODIFIERS[self._cbFeedbackEffect.get_active()].EFFECT + + def _update_feedback_effect_rows(self): + """Shows only the rows the chosen effect actually uses.""" + supported = getattr(self, "_feedback_effects_supported", False) + self._lblFeedbackEffect.set_visible(supported) + self._cbFeedbackEffect.set_visible(supported) + + effect = self.get_feedback_effect() + used = next(c.PARAMS for c in HAPTIC_EFFECT_MODIFIERS if c.EFFECT == effect) + for key, (lbl, trash, field, trash2) in self.feedback_effect_rows.items(): + visible = supported and key in used + for widget in (lbl, field): + widget.set_visible(visible) + # the click-only sliders (pad-travel frequency, click period) mean + # nothing to a synthesised effect + click_only = effect == HapticEffect.CLICK + for name in ("Frequency", "Period"): + for prefix in ("lblF", "sclF", "btClearF"): + w = self.builder.get_object(prefix + name) + if w: + w.set_visible(click_only) + + def on_feedback_effect_changed(self, *a): + if self._recursing: + return + self._update_feedback_effect_rows() + self.update_modifiers() + def load_modifiers(self, action, index: int = -1): """Parse action for modifiers and update UI accordingly. @@ -739,10 +892,19 @@ def load_modifiers(self, action, index: int = -1): self.click = True action = action.action if isinstance(action, FeedbackModifier): + # the effect subclasses are FeedbackModifiers too, so this one + # branch covers all four; only CLICK uses frequency/period self.feedback_position = action.haptic.get_position() self.feedback[0] = action.haptic.get_amplitude() - self.feedback[1] = action.haptic.get_frequency() - self.feedback[2] = action.haptic.get_period() + effect = getattr(action.haptic, "effect", HapticEffect.CLICK) + self.feedback_effect = effect + if effect == HapticEffect.CLICK: + self.feedback[1] = action.haptic.get_frequency() + self.feedback[2] = action.haptic.get_period() + else: + for key in self.feedback_effect_rows: + value = getattr(action.haptic, key, FEEDBACK_PARAMS[key][4]) + self.feedback_params[key] = int(value) action = action.action if isinstance(action, SmoothModifier): self.smoothing = (action.level, action.multiplier, action.filter) @@ -779,6 +941,16 @@ def load_modifiers(self, action, index: int = -1): cbFeedback.set_active(True) for i in range(len(self.feedback)): self.feedback_widgets[i][0].set_value(self.feedback[i]) + # The effect chooser and its rows are widgets like any other here: their + # 'changed' handlers call update_modifiers, so writing them any earlier -- + # while the loop above is still parsing -- rebuilds the action from a + # half-loaded editor and discards everything not applied yet. + for i, c in enumerate(HAPTIC_EFFECT_MODIFIERS): + if c.EFFECT == self.feedback_effect: + self._cbFeedbackEffect.set_active(i) + for key in self.feedback_effect_rows: + self._set_row_value(key, self.feedback_params[key]) + self._update_feedback_effect_rows() for grp in self.feedback_widgets: for w in grp[0:-1]: w.set_sensitive(self.feedback_position is not None) diff --git a/scc/gui/ae/__init__.py b/scc/gui/ae/__init__.py index 941b2590d..08496fdcb 100644 --- a/scc/gui/ae/__init__.py +++ b/scc/gui/ae/__init__.py @@ -10,11 +10,103 @@ from gi.repository import Gdk, GLib, Gtk from scc.actions import Action, NoAction, XYAction +from scc.constants import SCButtons from scc.gui.editor import ComboSetter from scc.tools import _, ensure_size log = logging.getLogger("AE") +# Rear-paddle display names. On controllers whose four rear paddles are +# physically labeled L4/L5/R4/R5 (Steam Controller 2, Steam Deck) the generic +# "Left Grip (2)" naming is confusing -- especially on the SC2, where "grip" +# reads as the capacitive handle sensor instead. The Steam Controller v1's +# LGRIP/RGRIP are its squeeze grips and keep the "Left/Right Grip" naming. +PADDLE_TYPES = ("sc2", "deck") +PADDLE_NAMES = { + SCButtons.LGRIP: "L4", + SCButtons.RGRIP: "R4", + SCButtons.LGRIP2: "L5", + SCButtons.RGRIP2: "R5", +} + + +# Controllers whose actuators can synthesise waveforms, and so can play the +# tone / sweep / firmware-script haptic effects rather than only a click. +HAPTIC_EFFECT_TYPES = ("sc2",) + + +def controller_type(app): + """get_type() of the controller the GUI is currently editing, or None.""" + try: + c = app.profile_switchers[0].get_controller() if app else None + return c.get_type() if c else None + except Exception: + return None + + +def has_haptic_effects(app) -> bool: + """True if the current controller can play more than a plain click. + + Used to hide the effect chooser rather than offering options that would + silently degrade to a click on hardware that cannot synthesise them. + + An UNKNOWN controller counts as capable. There are three states here, not + two: a controller that supports effects, one that does not, and no + controller at all -- daemon not running, or the pad simply switched off. + Treating the third like the second hid the chooser from anyone editing a + profile with their controller off, which is an ordinary thing to do and + says nothing about the hardware the profile is for. Offering an effect + that later degrades to a click is a far smaller harm than making a + feature vanish depending on whether a controller happened to be awake. + """ + ctype = controller_type(app) + if ctype is None: + return True + return ctype in HAPTIC_EFFECT_TYPES + + +def button_label(app, button, default): + """Per-controller display name for a button: the rear paddles are + L4/L5/R4/R5 on paddle controllers (sc2/deck), the default (grip) name + elsewhere. `app` may be None (falls back to the default label).""" + ctype = None + try: + c = app.profile_switchers[0].get_controller() if app else None + ctype = c.get_type() if c else None + except Exception: + pass + if ctype in PADDLE_TYPES and button in PADDLE_NAMES: + return PADDLE_NAMES[button] + return default + + +# Buttons that only SOME controllers have and whose presence is reliably +# recorded in the controller's gui config "buttons" capability list (sc2 and +# deck ship configs; everything else falls back to DEFAULT_BUTTONS, which +# correctly lacks these). Only these are ever filtered out of button lists -- +# filtering arbitrary buttons against the capability list would wrongly hide +# valid entries on configless controllers (e.g. the DS4's stick presses). +OPTIONAL_BUTTONS = { + SCButtons.LGRIP2, SCButtons.RGRIP2, + SCButtons.LGRIPTOUCH, SCButtons.RGRIPTOUCH, + SCButtons.LSTICKTOUCH, SCButtons.RSTICKTOUCH, +} + + +def button_available(app, button) -> bool: + """True unless `button` is an optional extra (OPTIONAL_BUTTONS) that the + currently displayed controller's capability list does not include.""" + if button not in OPTIONAL_BUTTONS: + return True + try: + available = app.background.get_config()["buttons"] if app else None + except Exception: + available = None + if not available: + return True + from scc.tools import nameof + return nameof(button) in available + class AEComponent(ComboSetter): GLADE = None diff --git a/scc/gui/ae/buttons.py b/scc/gui/ae/buttons.py index 40f3db3db..b6042210a 100644 --- a/scc/gui/ae/buttons.py +++ b/scc/gui/ae/buttons.py @@ -15,6 +15,7 @@ from scc.gui.key_grabber import KeyGrabber from scc.gui.parser import InvalidAction from scc.macros import Cycle, Macro, PressAction, ReleaseAction +from scc.modifiers import InvertedButtonModifier from scc.tools import _ from scc.uinput import Keys, Rels @@ -50,18 +51,22 @@ def load(self): if not self.loaded: AEComponent.load(self) self.setup_image() - if self.app.osd_mode: - self.builder.get_object("btnGrabKey").set_sensitive(False) - self.builder.get_object("btnGrabAnother").set_sensitive(False) def area_action_selected(self, area, action): self.set_active_area(area) + if self.builder.get_object("cbActOnRelease").get_active(): + action = InvertedButtonModifier(action) self.editor.set_action(action) def set_action(self, mode, action): cbToggle = self.builder.get_object("cbToggle") cbRepeat = self.builder.get_object("cbRepeat") + cbActOnRelease = self.builder.get_object("cbActOnRelease") if self.handles(mode, action): + # "Act on release" wraps the action in an InvertedButtonModifier + is_inverted = isinstance(action, InvertedButtonModifier) + if is_inverted: + action = action.action self.keys = set() is_togle, is_repeat = False, False if isinstance(action, MultiAction): @@ -80,6 +85,7 @@ def set_action(self, mode, action): is_togle = True cbToggle.set_active(is_togle) cbRepeat.set_active(is_repeat) + cbActOnRelease.set_active(is_inverted) area = action_to_area(action) if area is not None: self.set_active_area(area) @@ -90,6 +96,10 @@ def get_button_title(self): return _("Key or Button") def handles(self, mode, action): + # "Act on release" wraps the real action in an InvertedButtonModifier; + # look through it to the wrapped action. + if isinstance(action, InvertedButtonModifier): + return self.handles(mode, action.action) # Handles ButtonAction and MultiAction if all subactions are ButtonAction if isinstance(action, (ButtonAction, NoAction, InvalidAction)): return True @@ -127,8 +137,11 @@ def modifiers_first(key): def apply_keys(self, *a): """Common part of on_*key_grabbed""" + if not self.keys: + return cbToggle = self.builder.get_object("cbToggle") cbRepeat = self.builder.get_object("cbRepeat") + cbActOnRelease = self.builder.get_object("cbActOnRelease") keys = sorted(self.keys, key=ButtonsComponent.modifiers_first) action = ButtonAction(keys[0]) if len(keys) > 1: @@ -139,6 +152,8 @@ def apply_keys(self, *a): action.repeat = True elif cbToggle.get_active(): action = Cycle(PressAction(action), ReleaseAction(action)) + if cbActOnRelease.get_active(): + action = InvertedButtonModifier(action) self.editor.set_action(action) def on_btnGrabKey_clicked(self, *a): @@ -166,6 +181,9 @@ def on_cbRepeat_toggled(self, cbRepeat): cbToggle.set_active(False) self.apply_keys() + def on_cbActOnRelease_toggled(self, cb: Gtk.CheckButton) -> None: + self.apply_keys() + def hide_toggle(self): """Hides 'set as toggle button' option""" cbToggle = self.builder.get_object("cbToggle") diff --git a/scc/gui/ae/first_page.py b/scc/gui/ae/first_page.py index c943b27a2..5b7e5b438 100644 --- a/scc/gui/ae/first_page.py +++ b/scc/gui/ae/first_page.py @@ -6,7 +6,8 @@ import logging from scc.actions import Action -from scc.gui.ae import AEComponent +from scc.constants import SCButtons +from scc.gui.ae import AEComponent, button_label from scc.tools import _, nameof log = logging.getLogger("AE.1st") @@ -96,6 +97,8 @@ def load(self): "RPAD": _("Right Pad"), "LGRIP": _("Left Grip"), "RGRIP": _("Right Grip"), + "LGRIP2": _("Left Grip 2"), + "RGRIP2": _("Right Grip 2"), "LB": _("Left Bumper"), "RB": _("Right Bumper"), "LEFT": _("Left Trigger"), @@ -104,8 +107,12 @@ def load(self): "RSTICK": _("Right Stick"), } + name = nameof(self.editor.get_id()) + what = long_names.get(name, name.title()) + # Per-controller paddle naming (L4/L5/R4/R5 on sc2/deck) + what = button_label(self.app, getattr(SCButtons, name, None), what) markup = markup % { - "what": long_names.get(nameof(self.editor.get_id()), nameof(self.editor.get_id()).title()), + "what": what, } self.builder.get_object("lblMarkup").set_markup(markup.strip(" \r\n\t")) return True diff --git a/scc/gui/ae/gyro.py b/scc/gui/ae/gyro.py index 5456a4df7..1e99afb9a 100644 --- a/scc/gui/ae/gyro.py +++ b/scc/gui/ae/gyro.py @@ -5,8 +5,9 @@ import itertools import logging -from scc.actions import Action, AxisAction, GyroAbsAction, GyroAction, MultiAction, NoAction, RangeOP +from scc.actions import Action, AxisAction, GyroAbsAction, GyroAction, MouseAction, MultiAction, NoAction, RangeOP from scc.constants import LSTICK, SCButtons +from scc.uinput import Rels from scc.gui.ae import AEComponent, describe_action from scc.gui.ae.gyro_action import TRIGGERS, fill_buttons, is_gyro_enable from scc.gui.simple_chooser import SimpleChooser @@ -37,7 +38,7 @@ def load(self): cbGyroButton = self.builder.get_object("cbGyroButton") self._recursing = True cbGyroButton = self.builder.get_object("cbGyroButton") - fill_buttons(cbGyroButton) + fill_buttons(cbGyroButton, self.app) self._recursing = False self.buttons = [self.builder.get_object(x) for x in ("btPitch", "btYaw", "btRoll")] self.cbs = [self.builder.get_object(x) for x in ("cbPitchAbs", "cbYawAbs", "cbRollAbs")] @@ -89,14 +90,25 @@ def on_select_axis(self, source, *a): i = self.buttons.index(source) def cb(action): - self.axes[i] = action.parameters[0] + self.axes[i] = None if isinstance(action, NoAction) else action.parameters[0] self.update() self.send() b = SimpleChooser(self.app, "axis", cb) b.set_title(_("Select Axis")) - b.hide_mouse() - b.display_action(Action.AC_STICK, AxisAction(self.axes[i])) + b.show_clear() + # Mouse is a valid target here in both modes: Absolute checked gives + # laser-pointer tracking (cursor follows the rotation, Steam-style, + # rate-based); unchecked gives lean-to-turn (cursor velocity follows the + # held tilt angle). hide_mouse() wrongly blocked it, greying out the + # mouse arrows. + # + # Show the current axis with the matching action class, or the chooser + # highlights the stick for a mouse axis (Axes/Rels value collision) and + # re-confirming would silently retarget the binding to the stick. + axis = self.axes[i] + current = MouseAction(axis) if isinstance(axis, Rels) else AxisAction(axis) + b.display_action(Action.AC_STICK, current) b.show(self.editor.window) def on_abs_changed(self, source, *a): @@ -144,7 +156,13 @@ def on_sclSoftLevel_format_value(self, scale, value, *a): def update(self, *a): for i in range(3): - self.labels[i].set_label(describe_action(Action.AC_STICK, AxisAction, self.axes[i])) + # A mouse axis (REL_*) must be described as a MouseAction; forcing it + # through AxisAction mislabels "Mouse X/Y" as "LStick X/Y". Use an + # isinstance check, not "in Rels.values()" -- Axes and Rels are IntEnums + # with overlapping values, so ABS_X == REL_X and membership misfires. + axis = self.axes[i] + cls = MouseAction if isinstance(axis, Rels) else AxisAction + self.labels[i].set_label(describe_action(Action.AC_STICK, cls, axis)) def send(self, *a): if self._recursing: diff --git a/scc/gui/ae/gyro_action.py b/scc/gui/ae/gyro_action.py index 8acb81a89..56df78ed3 100644 --- a/scc/gui/ae/gyro_action.py +++ b/scc/gui/ae/gyro_action.py @@ -6,7 +6,7 @@ from scc.actions import Action, GyroAbsAction, GyroAction, MouseAbsAction, MouseAction, MultiAction, NoAction, RangeOP from scc.constants import LSTICK, ROLL, YAW, SCButtons -from scc.gui.ae import AEComponent +from scc.gui.ae import AEComponent, button_available, button_label from scc.gui.parser import GuiActionParser from scc.modifiers import ModeModifier, SensitivityModifier from scc.special_actions import CemuHookAction @@ -38,8 +38,17 @@ class GyroActionComponent(AEComponent): (SCButtons.LPAD, _("Left Pad Pressed")), (SCButtons.RPAD, _("Right Pad Pressed")), (None, None), + # The rear paddles. Labels are per-controller (see button_label): + # L4/R4/L5/R5 on sc2/deck, Left/Right Grip (2) elsewhere. (SCButtons.LGRIP, _("Left Grip")), (SCButtons.RGRIP, _("Right Grip")), + (SCButtons.LGRIP2, _("Left Grip 2")), + (SCButtons.RGRIP2, _("Right Grip 2")), + # Capacitive handle sensors (Steam Controller 2). On the SC2 the + # entries above are the rear paddles; the handle TOUCH sensors are + # separate buttons -- and the natural "gyro while gripping" enabler. + (SCButtons.LGRIPTOUCH, _("Left Grip Touched")), + (SCButtons.RGRIPTOUCH, _("Right Grip Touched")), (LSTICK, _("Left Stick Tilted")), (None, None), (SCButtons.A, _("A")), @@ -67,7 +76,7 @@ def load(self) -> None: self.builder.get_object("sclSoftLevel").set_format_value_func(self.on_sclSoftLevel_format_value) self._recursing = True cbGyroButton = self.builder.get_object("cbGyroButton") - fill_buttons(cbGyroButton) + fill_buttons(cbGyroButton, self.app) self._recursing = False def set_action(self, mode: int, action) -> None: @@ -313,9 +322,13 @@ def is_gyro_enable(modemod) -> bool: return False -def fill_buttons(cb): +def fill_buttons(cb, app=None): cb.set_row_separator_func(lambda model, iter: model.get_value(iter, 1) is None) model = cb.get_model() for button, text in GyroActionComponent.BUTTONS: + if button is not None: + if not button_available(app, button): + continue # e.g. grip-touch / GRIP2 on controllers without them + text = button_label(app, button, text) model.append((None if button is None else nameof(button), text)) cb.set_active(0) diff --git a/scc/gui/app.py b/scc/gui/app.py index 639929f95..83428486b 100644 --- a/scc/gui/app.py +++ b/scc/gui/app.py @@ -16,11 +16,12 @@ from urllib.parse import unquote gi.require_version("Gdk", "4.0") +gi.require_version("GdkPixbuf", "2.0") gi.require_version("Gio", "2.0") gi.require_version("GLib", "2.0") gi.require_version("GObject", "2.0") gi.require_version("Gtk", "4.0") -from gi.repository import Gdk, Gio, GLib, GObject, Gtk +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, GObject, Gtk from scc.actions import NoAction from scc.config import Config @@ -43,6 +44,7 @@ from scc.tools import ( _, check_access, + find_controller_icon, find_gksudo, find_profile, get_profile_name, @@ -59,6 +61,45 @@ log = logging.getLogger("App") +# Human-friendly default names per controller type (controller.get_type()). +# Used when the user has not given the controller a custom name. Wrapped in +# _() at lookup time so they remain translatable. +CONTROLLER_TYPE_NAMES = { + "sc": "Steam Controller v1", + "scbt": "Steam Controller v1 (Bluetooth)", + "sc2": "Steam Controller v2", + "deck": "Steam Deck", + "ds4": "DualShock 4", + "ds4evdev": "DualShock 4", + "ds4bt_hidraw": "DualShock 4", + "ds5": "DualSense", + "ds5evdev": "DualSense", + "ds5bt_hidraw": "DualSense", + "hid": "HID Controller", + "evdev": "Controller", + "rpad": "Remote Pad", + "fake": "Fake Controller", +} + +# Human-friendly default names per controller type (controller.get_type()). +# Used when the user has not given the controller a custom name. Wrapped in +# _() at lookup time so they remain translatable. +CONTROLLER_TYPE_NAMES = { + "sc": "Steam Controller v1", + "scbt": "Steam Controller v1 (Bluetooth)", + "sc2": "Steam Controller v2", + "deck": "Steam Deck", + "ds4": "DualShock 4", + "ds4evdev": "DualShock 4", + "ds5": "DualSense", + "ds5evdev": "DualSense", + "ds5bt_hidraw": "DualSense", + "hid": "HID Controller", + "evdev": "Controller", + "rpad": "Remote Pad", + "fake": "Fake Controller", +} + def reorder_box_child(box, child, position): """Move child to a zero-based position using GTK4 sibling ordering.""" @@ -83,7 +124,6 @@ class App(Gtk.Application, UserDataManager, BindingEditor): OBSERVE_COLOR = "#FF60A0FF" # ARGB CONFIG = "scc.config.json" RELEASE_URL = "https://github.com/C0rn3j/sc-controller/releases/tag/v%s" - OSD_MODE_PROF_NAME = ".scc-osd.profile_editor" def __init__(self, gladepath: str = "/usr/share/scc", imagepath: str = "/usr/share/scc/images"): Gtk.Application.__init__( @@ -117,8 +157,7 @@ def __init__(self, gladepath: str = "/usr/share/scc", imagepath: str = "/usr/sha self.status = "unknown" self.context_menu_for = None self.daemon_changed_profile = False - self.osd_mode = False # In OSD mode, only active profile can be editted - self.osd_mode_mapper: OSDModeMapper | None = None + self.osk_edit_mode = False # --osd: open only the OSD-keyboard bindings editor self.background = None self.outdated_version = None self.profile_switchers: list[ProfileSwitcher] = [] @@ -193,6 +232,15 @@ def _setup_popover_menus(self): ps.connect("new-clicked", self.on_new_clicked) ps.connect("save-clicked", self.on_save_clicked) + # Controller selector: shown above the profile switcher only when more + # than one controller is connected. Lets the user pick which controller + # the editor shows (replacing the old stack of one profile bar per + # controller + the per-bar "switch-to" pen button). Each row is the + # controller's icon + name, with its current profile as dim secondary + # text; selecting a row makes that controller the active/edited one. + self._selector_recursing = False + self.controller_selector = self._build_controller_selector() + # Drag&drop target content = self.builder.get_object("content") for value_type in (Gdk.FileList, GObject.TYPE_STRING): @@ -242,11 +290,6 @@ def _setup_popover_menus(self): self.main_area.put(self.dpad_test, 40, 90) self.main_area.put(self.cpad_test, 150, 90) - # OSD mode (if used) - if self.osd_mode: - self.builder.get_object("btDaemon").set_sensitive(False) - self.window.set_title(_("Edit Profile")) - # Headerbar headerbar(self.builder.get_object("hbWindow")) @@ -292,6 +335,9 @@ def apply_gui_config_buttons(self, config) -> None: btDPAD = self.builder.get_object("btDPAD") btGYRO = self.builder.get_object("btGYRO") btC = self.builder.get_object("btC") + btLGRIPTOUCH = self.builder.get_object("btLGRIPTOUCH") + btRGRIPTOUCH = self.builder.get_object("btRGRIPTOUCH") + btRSTICK = self.builder.get_object("btRSTICK") buttons = ControllerImage.get_names(config.get("buttons", {})) axes = ControllerImage.get_names(config.get("axes", {})) @@ -303,10 +349,18 @@ def apply_gui_config_buttons(self, config) -> None: if w: w.set_sensitive(nameof(b) in buttons) # Buttons (as GTK Widgets) + # A controller may ship its own side-panel icons under + # images//.svg (e.g. images/sc2/); use them when + # present, else fall back to the shared default icon. + bg = config.get("gui", {}).get("background") for b in self.button_widgets: try: w = self.button_widgets[b] icon, trash = ControllerManager.get_button_icon(config, b, True) + if bg: + cand = os.path.join(self.imagepath, bg, nameof(b) + ".svg") + if os.path.exists(cand): + icon = cand w.icon.set_filename(icon) except Exception: log.exception(f"Failed to set button widget icon for {b}") @@ -329,45 +383,62 @@ def apply_gui_config_buttons(self, config) -> None: # TODO: Maybe actual detection w.set_sensitive(gyros) - for w in (btC, btCPAD, btDPAD, btGYRO): - w.set_visible(w.get_sensitive()) + for w in (btC, btCPAD, btDPAD, btGYRO, btLGRIPTOUCH, btRGRIPTOUCH, btRSTICK): + if w: + w.set_visible(w.get_sensitive()) # Re-layout if needed expected_layout = "default" - if len(axes) >= 8 and btC.get_sensitive(): + if "rstick_x" in axes and btC.get_sensitive(): expected_layout = "deck" if expected_layout != self.current_ui_layout: self.apply_ui_layout(expected_layout) + # The Steam Deck maps its lower back paddles to LGRIP/RGRIP (labelled + # L5/R5) and the upper ones to LGRIP2/RGRIP2 (L4/R4) - the reverse of the + # SC2. Left alone the side panel reads L5/R5 above L4/R4; reorder the + # paddle buttons so they match the device (L4/R4 above L5/R5). Every other + # controller (incl. the SC2, whose LGRIP=L4) keeps the .glade order. + if bg == "deck": + grip_pairs = (("btLGRIP2", "btLGRIP"), ("btRGRIP2", "btRGRIP")) + else: + grip_pairs = (("btLGRIP", "btLGRIP2"), ("btRGRIP", "btRGRIP2")) + for above, below in grip_pairs: + wa = self.builder.get_object(above) + wb = self.builder.get_object(below) + if wa and wb and wa.get_parent() is wb.get_parent(): + parent = wa.get_parent() + current = wa.get_next_sibling() + while current is not None and current is not wb: + current = current.get_next_sibling() + if current is None: + parent.reorder_child_after(wa, wb.get_prev_sibling()) + stckEditor.set_visible_child(grEditor) GLib.idle_add(self.on_c_size_allocate) def apply_ui_layout(self, layout: str) -> None: """Changes layout of ui elements to fit additional buttons needed for Deck""" if layout == "deck": - # Move 'C' button bellow LGRIP - btRGRIP: Gtk.Button | None = self.builder.get_object("btRGRIP") + btLGRIP: Gtk.Button | None = self.builder.get_object("btLGRIP") + # Put 'DPAD' at the top of the left column (above the back paddles), + # to mirror the Deck's physical layout: D-Pad, L4, L5, View, Steam. + btDPAD: Gtk.Button | None = self.builder.get_object("btDPAD") + btDPAD.get_parent().remove(btDPAD) + btLGRIP.get_parent().append(btDPAD) + reorder_box_child(btLGRIP.get_parent(), btDPAD, 2) + # Move 'C' (Steam) to the bottom of the LEFT column (was the right) btC: Gtk.Button | None = self.builder.get_object("btC") btC.get_parent().remove(btC) btC.set_margin_end(0) - btRGRIP.get_parent().append(btC) - reorder_box_child(btRGRIP.get_parent(), btC, 5) + btLGRIP.get_parent().append(btC) # Move 'GYRO' button to middle of image (where C was) btGYRO: Gtk.Button | None = self.builder.get_object("btGYRO") btGYRO.get_parent().remove(btGYRO) vbC = self.builder.get_object("vbC") vbC.append(btGYRO) btGYRO.set_margin_top(30) - # Resize buttons at bottom - # for w in ['btLSTICK', 'btRSTICK', 'btLPAD', 'btRPAD']: - # w.set_size_request(150, -1) - # Move 'DPAD' bellow 'LGRIP' - btLGRIP: Gtk.Button | None = self.builder.get_object("btLGRIP") - btDPAD: Gtk.Button | None = self.builder.get_object("btDPAD") - btDPAD.get_parent().remove(btDPAD) - btLGRIP.get_parent().append(btDPAD) - reorder_box_child(btLGRIP.get_parent(), btDPAD, 5) def setup_statusicon(self) -> None: if self.statusicon is None: @@ -831,12 +902,6 @@ def on_switch_to_clicked(self, ps, *a) -> None: def on_profile_saved(self, giofile: Gio.File, send: bool = True) -> None: """Called when selected profile is saved to disk""" - if self.osd_mode: - # Special case, profile shouldn't be changed while in osd_mode - if not giofile.get_path().endswith(".mod"): - self.profile_switchers[0].set_profile_modified(False, self.current.is_template) - return - if giofile.get_path().endswith(".mod"): # Special case, this one is saved only to be sent to daemon # and user doesn't need to know about it @@ -850,8 +915,9 @@ def on_profile_saved(self, giofile: Gio.File, send: bool = True) -> None: self.profile_switchers[0].set_profile_modified(False, self.current.is_template) if send and self.dm.is_alive() and not self.daemon_changed_profile: - for ps in self.profile_switchers: - controller = ps.get_controller() + # Re-send to every controller currently running this profile (not + # just the active one), so a saved profile reloads on all of them. + for controller in self.dm.get_controllers(): if controller: active = controller.get_profile() if active.endswith(".mod"): @@ -979,7 +1045,7 @@ def on_exiting_n_daemon_killed(self, *a): self.quit() def on_mnuExit_activate(self, *a): - if not self.osd_mode and self.app.config["gui"]["autokill_daemon"]: + if self.app.config["gui"]["autokill_daemon"]: log.debug("Terminating scc-daemon") for x in ("content", "mnuEmulationEnabled", "mnuEmulationEnabledTray"): w = self.builder.get_object(x) @@ -1007,49 +1073,42 @@ def on_daemon_alive(self, *a) -> None: if not self.release_notes_visible(): self.hide_error() self.just_started = False - if self.osd_mode: - self.enable_osd_mode() - elif self.profile_switchers[0].get_file() is not None and not self.just_started: + if self.profile_switchers[0].get_file() is not None and not self.just_started: self.dm.set_profile(self.current_file.get_path()) GLib.timeout_add_seconds(1, self.check) self.enable_test_mode() def on_daemon_ccunt_changed(self, daemon, count: int) -> None: - if self.controller_count == 0: - # First controller connected - # - # 'event' signal should be connected only on first controller, - # so this block is executed only when number of connected - # controllers changes from 0 to 1 - if len(self.dm.get_controllers()) > 0: - c = self.dm.get_controllers()[0] - self.load_gui_config_for_controller(c, first=True) - if count > self.controller_count: - # Controller added - while len(self.profile_switchers) < count: - s = self.add_switcher() - elif count < self.controller_count: - # Controller removed - while len(self.profile_switchers) > max(1, count): - s = self.profile_switchers.pop() - s.set_controller(None) - self.remove_switcher(s) - - # Assign controllers to widgets - for i in range(count): - c = self.dm.get_controllers()[i] - self.profile_switchers[i].set_controller(c) - - if count == 0: - # Special case, no controllers are connected, but one widget has to stay on screen - self.profile_switchers[0].set_controller(None) - # First load, default controller decided by _ensure_config() in controller_image.py - if not self._controller_shown: - self.load_gui_config_for_controller(None, first=True) + # A single profile switcher always shows the *active* controller; any + # others are reachable through the controller selector above it (built + # in setup_widgets). So here we only keep the active controller in the + # switcher and rebuild the selector. + controllers = list(self.dm.get_controllers()) + ps0 = self.profile_switchers[0] + first_connect = self.controller_count == 0 and count >= 1 + + if count >= 1: + active = ps0.get_controller() + if active not in controllers: + # No active controller yet (first connect) or the active one was + # disconnected: fall back to the first connected controller and + # switch the editor image to it. + active = controllers[0] + ps0.set_controller(active) + self.load_gui_config_for_controller(active, first=first_connect) else: - self.enable_test_mode(self.profile_switchers[0].get_controller()) + # No controllers connected, but one switcher has to stay on screen + ps0.set_controller(None) + self.load_gui_config_for_controller(None, first=True) + self.rebuild_controller_selector() self.controller_count = count + if count >= 1: + # Re-arm Input Test on the (now) active controller. Without this, a + # controller connected *after* startup is never observed -- the + # enable at 'alive' ran while no controller was present -- so Input + # Test stays blank until the user toggles it off and on again. + self.enable_test_mode() def new_profile(self, profile: Profile, name: str) -> None: filename = os.path.join(get_profiles_path(), name + ".sccprofile") @@ -1086,9 +1145,6 @@ def add_switcher(self, margin_start: int = 24, margin_end: int = 24) -> ProfileS sepSwitchers.set_visible(True) vbSwitchers.set_visible(True) - if self.osd_mode: - ps.set_allow_switch(False) - if len(self.profile_switchers) > 0: ps.set_profile_list(self.profile_switchers[0].get_profile_list()) ps.set_switch_to_enabled(True) @@ -1106,12 +1162,118 @@ def remove_switcher(self, s): if len(vbSwitchers.observe_children()) == 2: sepSwitchers.set_visible(False) + def _build_controller_selector(self) -> Gtk.ComboBox: + """Creates the 'which controller' combo and packs it above the profile + switcher. Hidden until 2+ controllers are connected.""" + # model columns: controller object, icon pixbuf, name, current profile + model = Gtk.ListStore(object, GdkPixbuf.Pixbuf, str, str) + combo = Gtk.ComboBox.new_with_model(model) + rPix, rName, rProf = Gtk.CellRendererPixbuf(), Gtk.CellRendererText(), Gtk.CellRendererText() + rProf.set_property("foreground", "#888888") + combo.pack_start(rPix, False) + combo.pack_start(rName, True) + combo.pack_start(rProf, False) + combo.add_attribute(rPix, "pixbuf", 1) + combo.add_attribute(rName, "text", 2) + combo.add_attribute(rProf, "text", 3) + combo.set_margin_start(12) + combo.set_margin_end(12) + combo.set_margin_top(4) + combo.connect("changed", self.on_controller_selected) + combo.connect("notify::popup-shown", self._refresh_selector_profiles) + vbSwitchers = self.builder.get_object("vbSwitchers") + vbSwitchers.append(combo) + # Layout top-to-bottom: [ selector ][ separator ][ profile switcher ] + vbSwitchers.reorder_child_after(combo, None) + vbSwitchers.reorder_child_after(self.builder.get_object("sepSwitchers"), vbSwitchers.get_first_child()) + combo.set_visible(False) + return combo + + def _load_controller_pixbuf(self, c: ControllerManager) -> GdkPixbuf.Pixbuf | None: + """Loads the 24px icon for a controller, or None if unavailable.""" + try: + iconname = self.config.get_controller_config(c.get_id()).get("icon") + if iconname: + path = find_controller_icon(iconname) + if path and os.path.exists(path): + return GdkPixbuf.Pixbuf.new_from_file_at_size(path, 24, 24) + except Exception as e: + log.debug("No selector icon for %s: %s", c.get_id(), e) + return None + + def controller_display_name(self, c: ControllerManager) -> str: + """Human-friendly controller name: the user's custom name if one was set + in controller settings, otherwise a per-type label (e.g. 'Steam + Controller v2') rather than the raw internal id (e.g. 'sc1' / '3:4').""" + name = self.config.get_controller_config(c.get_id())["name"] + if name and name != c.get_id(): + return name # user-customised + return _(CONTROLLER_TYPE_NAMES.get(c.get_type(), "Controller")) + + def rebuild_controller_selector(self) -> None: + """Refills the controller selector from the connected controllers and + shows it only when more than one is connected.""" + combo = self.controller_selector + controllers = list(self.dm.get_controllers()) + active = self.profile_switchers[0].get_controller() + # Friendly names, disambiguating duplicates of the same type with #N + # (e.g. two 'Steam Controller v1' become '... #1' and '... #2'). + names = [self.controller_display_name(c) for c in controllers] + dupes = {n for n in names if names.count(n) > 1} + seen = {} + labels = [] + for n in names: + if n in dupes: + seen[n] = seen.get(n, 0) + 1 + labels.append("%s #%d" % (n, seen[n])) + else: + labels.append(n) + self._selector_recursing = True + model = combo.get_model() + model.clear() + active_iter = None + for c, label in zip(controllers, labels): + prof = get_profile_name(c.get_profile() or "") or "" + it = model.append((c, self._load_controller_pixbuf(c), label, prof)) + if c is active: + active_iter = it + if active_iter is not None: + combo.set_active_iter(active_iter) + self._selector_recursing = False + multi = len(controllers) >= 2 + combo.set_visible(multi) + self.builder.get_object("sepSwitchers").set_visible(multi) + + def _refresh_selector_profiles(self, combo: Gtk.ComboBox, *a: object) -> None: + """Refreshes each row's profile subtext when the dropdown is opened.""" + if not combo.get_property("popup-shown"): + return + for row in combo.get_model(): + row[3] = get_profile_name(row[0].get_profile() or "") or "" + + def on_controller_selected(self, combo: Gtk.ComboBox) -> None: + """Makes the chosen controller the active (edited) one, with the same + image transition the old switch-to button used.""" + if self._selector_recursing: + return + it = combo.get_active_iter() + if it is None: + return + c = combo.get_model().get_value(it, 0) + ps0 = self.profile_switchers[0] + if c is None or c is ps0.get_controller(): + return + ps0.set_controller(c) + ps0.set_profile(c.get_profile()) + self.load_gui_config_for_controller(c, False) + self.enable_test_mode() + def enable_test_mode(self, controller: ControllerManager | None = None) -> None: """Disables and re-enables Input Test mode. If sniffing is disabled in daemon configuration, 2nd call fails and logs error. """ - if self.dm.is_alive() and not self.osd_mode: + if self.dm.is_alive(): if self.test_mode_controller: self.test_mode_controller.unlock_all() if controller is None: @@ -1160,62 +1322,6 @@ def enable_test_mode(self, controller: ControllerManager | None = None) -> None: ) self.test_mode_controller = c - def enable_osd_mode(self): - # TODO: Support for multiple controllers here - self.osd_mode_controller = 0 - osd_mode_profile = Profile(GuiActionParser()) - osd_mode_profile.load(find_profile(App.OSD_MODE_PROF_NAME)) - try: - c = self.dm.get_controllers()[self.osd_mode_controller] - except IndexError: - log.error("osd_mode: Controller not connected") - self.quit() - return - - def on_lock_failed(*a): - log.error("osd_mode: Locking failed") - self.quit() - - def on_lock_success(*a): - log.debug("osd_mode: Locked everything") - from scc.gui.osd_mode import OSDModeMapper, OSDModeMappings - - self.osd_mode_mapper = OSDModeMapper(self, osd_mode_profile) - self.osd_mode_mapper.set_target_window(self.window.get_window()) - self.builder.get_object("btUndo").set_visible(False) - self.builder.get_object("btRedo").set_visible(False) - - m = OSDModeMappings(self, self.osd_mode_mapper, self.builder.get_object("OsdmodeMappings")) - m.set_controller(self.profile_switchers[0].get_controller()) - m.show() - - # Locks everything but pads. Pads are emulating mouse and this is - # better left in daemon - involving socket in mouse controls - # adds too much lags. - c.lock( - on_lock_success, - on_lock_failed, - "A", - "B", - "X", - "Y", - "START", - "BACK", - "LB", - "RB", - "C", - "LSTICK", - "LGRIP", - "RGRIP", - "LT", - "RT", - "LSTICKPRESS", - ) - - # Ask daemon to temporaly reconfigure pads for mouse emulation - c.replace(DaemonManager.nocallback, on_lock_failed, SCPads.LPAD, osd_mode_profile.pads[SCPads.LPAD]) - c.replace(DaemonManager.nocallback, on_lock_failed, SCPads.RPAD, osd_mode_profile.pads[SCPads.RPAD]) - def on_observe_failed(self, error) -> None: log.debug("Failed to enable test mode: %s", error) @@ -1236,8 +1342,7 @@ def on_daemon_version(self, daemon, version) -> None: # and we can check if there is anything new to inform user about elif self.app.config["gui"]["news"]["last_version"] != App.get_release(): if self.app.config["gui"]["news"]["enabled"]: - if not self.osd_mode: - self.check_release_notes() + self.check_release_notes() def on_daemon_error(self, daemon, error): log.debug("Daemon reported error '%s'", error) @@ -1270,16 +1375,16 @@ def on_daemon_error(self, daemon, error): return # If check() fails to find error reason, error message is displayed as it is - if self.osd_mode: - self.quit() - self.show_error(msg) self.set_daemon_status("error", True) def on_daemon_event_observer(self, daemon, c, what: str, data: list[int]) -> None: - if self.osd_mode_mapper: - self.osd_mode_mapper.handle_event(daemon, what, data) - elif what in (*SCPads, *SCSticks): + # Only react to the controller Input Test is observing. Other connected + # controllers also emit events; without this, their input would show on + # the selected controller's image. + if c is not self.test_mode_controller: + return + if what in (*SCPads, *SCSticks): widget, area = { SCPads.CPAD: (self.cpad_test, "CPADTEST"), SCPads.DPAD: (self.dpad_test, "DPADTEST"), @@ -1418,10 +1523,11 @@ def on_btRenameProfile_clicked(self, *a) -> None: controllers = list(self.dm.get_controllers()) for c in controllers: if get_profile_name(c.get_profile()) == old_name: - ps = self.profile_switchers[controllers.index(c)] - ps.set_profile(new_name, True) c.set_profile(new_fname) + if c is self.profile_switchers[0].get_controller(): + self.profile_switchers[0].set_profile(new_name, True) self.load_profile_list() + self.rebuild_controller_selector() dlg.set_visible(False) def on_mnuProfileDelete_activate(self, *a) -> None: @@ -1469,8 +1575,6 @@ def on_window_key_press_event(self, controller, keyval, keycode, state) -> None: if (state & Gdk.ModifierType.CONTROL_MASK) != 0: if keyval == 115: self.on_save_clicked() - elif self.osd_mode and keyval == 65471: - self.on_save_clicked() def show_error(self, message, ribar=None): if self.ribar is None or self.ribar.get_label() is None: @@ -1495,8 +1599,15 @@ def hide_error(self, *a): def on_daemon_reconfigured(self, *a) -> None: log.debug("Reloading config...") self.config.reload() + # If Input Test was just turned off, drop any highlights left over from + # the last observed press (e.g. a held grip sensor): with sniffing off no + # release event arrives to clear them, so they'd stay stuck on the image. + if not self.config["enable_sniffing"] and self.hilights[App.OBSERVE_COLOR]: + self.hilights[App.OBSERVE_COLOR].clear() + self._update_background() for ps in self.profile_switchers: ps.set_controller(ps.get_controller()) + self.rebuild_controller_selector() def on_daemon_dead(self, *a): if self.just_started: @@ -1505,12 +1616,14 @@ def on_daemon_dead(self, *a): self.set_daemon_status("unknown", True) return - if self.osd_mode: - self.quit() - for ps in self.profile_switchers: ps.set_controller(None) ps.on_daemon_dead() + self._selector_recursing = True + self.controller_selector.get_model().clear() + self._selector_recursing = False + self.controller_selector.set_visible(False) + self.builder.get_object("sepSwitchers").set_visible(False) self.set_daemon_status("dead", False) def on_mnuEmulationEnabled_toggled(self, cb): @@ -1535,13 +1648,17 @@ def do_startup(self, *a) -> None: Gtk.IconTheme.get_for_display(display).add_search_path(self.imagepath) self.load_profile_list() self.setup_widgets() - if self.app.config["gui"]["enable_status_icon"]: + # No tray icon for the transient OSD-keyboard bindings editor launch. + if self.app.config["gui"]["enable_status_icon"] and not self.osk_edit_mode: self.setup_statusicon() self.set_daemon_status("unknown", True) def do_local_options(self, trash, lo): set_logging_level(lo.contains("verbose"), lo.contains("debug")) - self.osd_mode = lo.contains("osd") + # --osd opens the standalone OSD-keyboard bindings editor (do_activate) - + # the same dialog as Settings > Menus & Keyboard > Advanced. Used on both + # X11 and Wayland for a consistent, reliable single-window experience. + self.osk_edit_mode = lo.contains("osd") return -1 def do_command_line(self, cl: Gio.ApplicationCommandLine) -> int: @@ -1568,10 +1685,17 @@ def i_told_you_to_quit(*a) -> Never: return 0 def do_activate(self, *a) -> None: + if self.osk_edit_mode: + # "Edit Bindings" (OSD menu): show only the OSD-keyboard bindings + # editor, never the main window, and quit when it is closed - so it + # can't pile up duplicate main windows. + if not getattr(self, "_osk_editor", None): + self.open_osk_editor() + return first_activation = not self._activated self._activated = True self._cancel_startup_tray_wait() - if first_activation and not self.osd_mode and self.config["gui"]["minimize_on_start"] and self.statusicon: + if first_activation and self.config["gui"]["minimize_on_start"] and self.statusicon: self.window.set_visible(False) if not self.statusicon.get_property("active"): # Fall back to a window if the desktop cannot provide a tray within 3 seconds @@ -1594,6 +1718,36 @@ def _startup_tray_unavailable(self): self.window.present() return GLib.SOURCE_REMOVE + def open_osk_editor(self) -> None: + """Opens the standalone OSD-keyboard bindings editor (the same window + reachable from Settings > Menus & Keyboard > Advanced) as the only + window, quitting the app when it closes. Backs the OSD menu's + 'Edit Bindings' item.""" + import fcntl + + import scc.osd.osk_actions + from scc.actions import Action + from scc.gui.osk_binding_editor import OSKBindingEditor + # Single-instance: each "Edit Bindings" is its own process (the app is + # NON_UNIQUE), so without this a repeated launch would stack a second + # editor window. Hold an exclusive lock for our lifetime; if another + # launch already holds it, just quit instead of opening a duplicate. + try: + self._osk_lock = open(os.path.join(get_config_path(), ".osk-editor.lock"), "w") + fcntl.flock(self._osk_lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + log.info("OSD-keyboard bindings editor already open; not opening another") + self.quit() + return + except Exception: + log.exception("OSK editor single-instance lock failed; opening anyway") + # The OSD-keyboard profile uses OSK.* actions; register them so the + # editor can parse it (GlobalSettings does the same before opening it). + Action.register_all(scc.osd.osk_actions, prefix="OSK") + self._osk_editor = OSKBindingEditor(self) + self._osk_editor.window.connect("destroy", lambda *a: self.quit()) + self._osk_editor.show(None) + def remove_dot_profile(self) -> None: """Checks if first profile in list begins with dot and if yes, removes it. This is done to undo automatic addition that is done when daemon reports @@ -1676,7 +1830,7 @@ def aso(long_name, short_name, description, arg=None, flags=GLib.OptionFlags.IN_ aso("verbose", b"v", "Be verbose") aso("debug", b"d", "Be more verbose (debug mode)") - aso("osd", b"o", "OSD mode (OSD-controllable editor for current profile)") + aso("osd", b"o", "Open the OSD-keyboard bindings editor") def save_profile_selection(self, path) -> None: """Saves name of profile into config file""" diff --git a/scc/gui/area_to_action.py b/scc/gui/area_to_action.py index 73b6740cd..4b4cb74c9 100644 --- a/scc/gui/area_to_action.py +++ b/scc/gui/area_to_action.py @@ -83,10 +83,15 @@ Rels.REL_Y, 1, ), - "MOUSE_X": (MouseAction, Rels.REL_X, 1), - "MOUSE_Y": (MouseAction, Rels.REL_Y, 1), - "MOUSE_WHEEL": (MouseAction, Rels.REL_WHEEL, 1), - "MOUSE_HWHEEL": (MouseAction, Rels.REL_HWHEEL, 1), + # Full-axis (whole) mouse entries are bare, mirroring the bare stick entries + # (ABS_X etc.) -- the whole X axis is MouseAction(REL_X), no direction. The + # trailing "1" made these 2-param, so action_to_area could never match the + # 1-param MouseAction the editors store (gyro Mouse X/Y went un-highlighted), + # and it duplicated the directional MOUSE_LEFT/RIGHT/UP/DOWN entries anyway. + "MOUSE_X": (MouseAction, Rels.REL_X), + "MOUSE_Y": (MouseAction, Rels.REL_Y), + "MOUSE_WHEEL": (MouseAction, Rels.REL_WHEEL), + "MOUSE_HWHEEL": (MouseAction, Rels.REL_HWHEEL), # Mouse buttons "MOUSE1": (ButtonAction, Keys.BTN_LEFT), "MOUSE2": (ButtonAction, Keys.BTN_MIDDLE), diff --git a/scc/gui/binding_editor.py b/scc/gui/binding_editor.py index fa01d5243..98e44fa68 100644 --- a/scc/gui/binding_editor.py +++ b/scc/gui/binding_editor.py @@ -101,7 +101,10 @@ def set_action(self, profile: Profile, id: SCButtons | SCSticks | SCPads, action self.button_widgets[id.name].update() elif id in BUTTONS: before, profile.buttons[id] = profile.buttons[id], action - self.button_widgets[id].update() + # Some buttons (e.g. the stick-touch sensors, set via the Touch tab) + # have no on-screen widget; just store the action for those. + if id in self.button_widgets: + self.button_widgets[id].update() elif id in SCTriggers: side = id before, profile.triggers[side] = profile.triggers[side], action diff --git a/scc/gui/global_settings.py b/scc/gui/global_settings.py index 5adbb1e2d..15a38c026 100644 --- a/scc/gui/global_settings.py +++ b/scc/gui/global_settings.py @@ -343,9 +343,13 @@ def on_cbShowOSD_toggled(self, cb): def on_btRestartEmulation_clicked(self, *a): rvRestartWarning = self.builder.get_object("rvRestartWarning") - self.app.dm.stop() rvRestartWarning.set_reveal_child(False) - GLib.timeout_add_seconds(1, self.app.dm.start) + # Use the daemon's built-in restart (stop-and-wait, *then* start) rather + # than a manual stop + fixed 1s delay + start. The old approach raced the + # daemon's own shutdown: with several USB devices the new daemon could + # start before the old one had died and released them, so it hit a stale + # pidfile / still-claimed devices and the controllers stayed undetected. + self.app.dm.restart() def on_restarting_checkbox_toggled(self, *a): if self._recursing: diff --git a/scc/gui/modeshift_editor.py b/scc/gui/modeshift_editor.py index a3988128d..a77a57d7f 100644 --- a/scc/gui/modeshift_editor.py +++ b/scc/gui/modeshift_editor.py @@ -10,6 +10,7 @@ from scc.actions import Action, NoAction, RangeOP from scc.constants import HapticPos, SCButtons +from scc.gui.ae import button_available, button_label from scc.gui.controller_widget import PADS, STICKS from scc.gui.dwsnc import headerbar from scc.gui.editor import Editor @@ -34,6 +35,8 @@ class ModeshiftEditor(Editor): (None, None), (SCButtons.LGRIP, _("Left Grip")), (SCButtons.RGRIP, _("Right Grip")), + (SCButtons.LGRIP2, _("Left Grip 2")), + (SCButtons.RGRIP2, _("Right Grip 2")), (SCButtons.LB, _("Left Bumper")), (SCButtons.RB, _("Right Bumper")), (None, None), @@ -48,6 +51,10 @@ class ModeshiftEditor(Editor): (SCButtons.RPAD, _("Right Pad Pressed")), (SCButtons.LPADTOUCH, _("Left Pad Touched")), (SCButtons.RPADTOUCH, _("Right Pad Touched")), + (SCButtons.LSTICKTOUCH, _("Left Stick Touched")), + (SCButtons.RSTICKTOUCH, _("Right Stick Touched")), + (SCButtons.LGRIPTOUCH, _("Left Grip Touched")), + (SCButtons.RGRIPTOUCH, _("Right Grip Touched")), ) def __init__(self, app, callback): @@ -60,6 +67,10 @@ def __init__(self, app, callback): self.current_page = 0 self.actions = ([], [], []) self.nomods = [NoAction(), NoAction(), NoAction()] + # Touch tab: bound to the capacitive stick-touch sensor (a separate + # button), only shown when editing a stick-press action. + self.touch_id = None + self.touch_action = NoAction() self.setup_widgets() def setup_widgets(self): @@ -93,6 +104,11 @@ def _fill_button_chooser(self, *a): if any([x[0] == item for x in self.actions[self.current_page]]): # Skip already added buttons continue + if isinstance(item, SCButtons): + if not button_available(self.app, item): + continue # optional button this controller doesn't have + # Per-controller paddle naming (L4/L5/R4/R5 on sc2/deck) + text = button_label(self.app, item, text) if type(item) is str: # Special case for soft pull items button = getattr(SCButtons, item.split(" ")[-1]) @@ -198,6 +214,10 @@ def on_clearb_clicked(self, trash, index, button): model.clear() # Fill it again for button, text in self.BUTTONS: + if isinstance(button, SCButtons): + if not button_available(self.app, button): + continue # optional button this controller doesn't have + text = button_label(self.app, button, text) model.append((None if button is None else nameof(button), text)) if button is not None: if nameof(button) == active: @@ -241,9 +261,16 @@ def on_chosen(id, action): def on_ntbMore_switch_page(self, ntb, box, index): self.current_page = index + cb = self.builder.get_object("cbButtonChooser") + add = self.builder.get_object("btAddAction") + if index >= len(self.actions): + # Touch tab: no mode-shift grid / button chooser here + cb.set_sensitive(False) + add.set_sensitive(False) + return self._fill_button_chooser() - self.builder.get_object("cbButtonChooser").set_sensitive(box.get_sensitive()) - self.builder.get_object("btAddAction").set_sensitive(box.get_sensitive()) + cb.set_sensitive(box.get_sensitive()) + add.set_sensitive(box.get_sensitive()) def on_nomodbt_clicked(self, button, *a): actionButton = self.action_widgets[self.current_page][1] @@ -261,6 +288,20 @@ def on_nomodclear_clicked(self, button, *a): actionButton = self.action_widgets[self.current_page][1] actionButton.set_label(self.nomods[self.current_page].describe(self.mode)) + def on_btTouch_clicked(self, *a: object) -> None: + """'Touch' tab: edit the action bound to the stick-touch sensor.""" + def on_chosen(id: str, action: Action) -> None: + self.touch_action = action + self.builder.get_object("btTouch").set_label(action.describe(self.mode)) + + ae = self._choose_editor(self.touch_action, on_chosen) + ae.set_input(self.touch_id, self.touch_action, mode=Action.AC_BUTTON) + ae.show(self.window) + + def on_btClearTouch_clicked(self, *a: object) -> None: + self.touch_action = NoAction() + self.builder.get_object("btTouch").set_label(self.touch_action.describe(self.mode)) + def on_btAddAction_clicked(self, *a): cbButtonChooser = self.builder.get_object("cbButtonChooser") item = cbButtonChooser.get_model().get_value(cbButtonChooser.get_active_iter(), 0) @@ -304,6 +345,9 @@ def on_btOK_clicked(self, *a): """Handler for OK button""" if self.ac_callback is not None: self.ac_callback(self.id, self._make_action()) + if self.touch_id is not None: + # Touch tab maps to a separate input (the stick-touch sensor) + self.ac_callback(self.touch_id, self.touch_action) self.close() def _make_action(self): @@ -410,3 +454,24 @@ def set_input(self, id, action, mode=None): if mode != Action.AC_BUTTON: for w in ("vbHold", "vbDoubleClick", "lblHold", "lblDoubleClick"): self.builder.get_object(w).set_sensitive(False) + + # Touch tab: only for the stick-press inputs; binds the capacitive + # stick-touch sensor (a separate button). Hidden for everything else. + # (Editor.show() uses window.show(), not show_all, so hiding sticks.) + touch_for = {SCButtons.STICKPRESS: SCButtons.LSTICKTOUCH, + SCButtons.RSTICKPRESS: SCButtons.RSTICKTOUCH} + self.touch_id = touch_for.get(id) if id in SCButtons.__members__.values() else None + vbTouch = self.builder.get_object("vbTouch") + lblTouch = self.builder.get_object("lblTouch") + if self.touch_id is not None: + try: + self.touch_action = self.app.current.buttons[self.touch_id] or NoAction() + except (KeyError, TypeError): + self.touch_action = NoAction() + self.builder.get_object("btTouch").set_label(self.touch_action.describe(self.mode)) + vbTouch.set_visible(True) + lblTouch.set_visible(True) + else: + self.touch_action = NoAction() + vbTouch.set_visible(False) + lblTouch.set_visible(False) diff --git a/scc/gui/osd_mode.py b/scc/gui/osd_mode.py deleted file mode 100644 index 63c06d2bf..000000000 --- a/scc/gui/osd_mode.py +++ /dev/null @@ -1,193 +0,0 @@ -"""SC Controller - OSD Mode Mapper - -Very special case of mapper used when main application is launched in "odd mode". -That means it's drawn in OSD layer, cannot be clicked and cannot react to -keyboard. This mapper emulates input events on it using GTK methods. - -Mouse movement (but not buttons) are passed to uinput as usuall. -""" - -import logging - -from gi.repository import Gdk, GLib, Gtk - -from scc.constants import SCButtons -from scc.gui.gdk_to_key import KEY_TO_GDK, KEY_TO_KEYCODE -from scc.osd.slave_mapper import SlaveMapper -from scc.uinput import Keys - -log = logging.getLogger("OSDModMapper") - - -class OSDModeMapper(SlaveMapper): - def __init__(self, app, profile): - SlaveMapper.__init__(self, profile, None, keyboard="osd", mouse="osd") - self.app = app - self.set_special_actions_handler(self) - self.target_window = None - - def on_sa_restart(self, *a): - """Restart / exit handler""" - self.app.quit() - - def set_target_window(self, w): - self.target_window = w - - def create_keyboard(self, name): - return OSDModeKeyboard(self) - - def create_mouse(self, name): - return OSDModeMouse(self) - - -class OSDModeKeyboard: - """Emulates uinput keyboard emulator""" - - def __init__(self, mapper): - self.mapper = mapper - self.display = Gdk.Display.get_default() - self.manager = self.display.get_device_manager() - self.device = [ - x for x in self.manager.list_devices(Gdk.DeviceType.MASTER) if x.get_source() == Gdk.InputSource.KEYBOARD - ][0] - - def pressEvent(self, keys): - for k in keys: - event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) - event.time = Gtk.get_current_event_time() - event.hardware_keycode = KEY_TO_KEYCODE[k] - event.keyval = KEY_TO_GDK[k] - event.window = self.mapper.target_window - event.set_device(self.device) - Gtk.main_do_event(event) - - def releaseEvent(self, keys=[]): - for k in keys: - event = Gdk.Event.new(Gdk.EventType.KEY_RELEASE) - event.time = Gtk.get_current_event_time() - event.hardware_keycode = KEY_TO_KEYCODE[k] - event.keyval = KEY_TO_GDK[k] - event.window = self.mapper.target_window - event.set_device(self.device) - Gtk.main_do_event(event) - - -class OSDModeMouse: - """Emulates uinput keyboard emulator too""" - - def __init__(self, mapper): - self.mapper = mapper - self.display = Gdk.Display.get_default() - self.manager = self.display.get_device_manager() - self.device = [ - x for x in self.manager.list_devices(Gdk.DeviceType.MASTER) if x.get_source() == Gdk.InputSource.MOUSE - ][0] - - def synEvent(self, *a): - pass - - def keyEvent(self, key, val) -> None: - tp = Gdk.EventType.BUTTON_PRESS if val else Gdk.EventType.BUTTON_RELEASE - event = Gdk.Event.new(tp) - event.button = int(key) - Keys.BTN_LEFT + 1 - window, event.x, event.y = Gdk.Window.at_pointer() - screen, x, y, mask = Gdk.Display.get_default().get_pointer() - event.x_root, event.y_root = x, y - - gtk_window = None - for w in Gtk.Window.list_toplevels(): - if w.get_window(): - if window.get_toplevel().get_xid() == w.get_window().get_xid(): - gtk_window = w - break - if gtk_window: - if gtk_window.get_type_hint() == Gdk.WindowTypeHint.COMBO: - # Special case, clicking on combo does nothing, so - # pressing "space" is emulated instead. - if not val: - return - event = Gdk.Event.new(Gdk.EventType.KEY_PRESS) - event.time = Gtk.get_current_event_time() - event.hardware_keycode = 65 - event.keyval = Gdk.KEY_space - event.window = self.mapper.target_window - event.time = Gtk.get_current_event_time() - event.window = window - event.set_device(self.device) - Gtk.main_do_event(event) - - -class OSDModeMappings: - ICONS = { - "imgOsdmodeAct": SCButtons.A, - "imgOsdmodeClose": SCButtons.B, - "imgOsdmodeExit": SCButtons.C, - "imgOsdmodeSave": SCButtons.Y, - "imgOsdmodeOK": SCButtons.Y, - } - - MAIN_WINDOW_BUTTONS = {"vbOsdmodeExit", "vbOsdmodeSave"} - OTHER_WINDOW_BUTTONS = {"vbOsdmodeExit", "vbOsdmodeAct", "vbOsdmodeClose", "vbOsdmodeOK"} - - def __init__(self, app, mapper, window): - self.app = app - self.mapper = mapper - self.window = window - self.parent = app.window - self.first_window = None - GLib.timeout_add(10, self.move_around) - focus = Gtk.EventControllerFocus.new() - focus.connect("enter", self.on_main_window_focus_in_event) - focus.connect("leave", self.on_main_window_focus_out_event) - self.app.window.add_controller(focus) - self.on_main_window_focus_in_event() - - def set_controller(self, c): - config = c.load_gui_config(self.app.imagepath or {}) - for name in OSDModeMappings.ICONS: - w = self.app.builder.get_object(name) - icon, trash = c.get_button_icon(config, OSDModeMappings.ICONS[name]) - w.set_from_file(icon) - - def on_main_window_focus_in_event(self, *a): - for x in self.OTHER_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(False) - for x in self.MAIN_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(True) - - def on_main_window_focus_out_event(self, *a): - for x in self.MAIN_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(False) - for x in self.OTHER_WINDOW_BUTTONS: - self.app.builder.get_object(x).set_visible(True) - - def get_target_position(self): - pos = self.first_window.get_position() - size = self.first_window.get_geometry() - my_size = self.window.get_window().get_geometry() - tx = pos.x + 0.5 * (size.width - my_size.width) - ty = pos.y + size.height + 100 - return tx, ty - - def show(self): - self.window.set_visible(True) - self.window.get_window().set_override_redirect(True) - - def move_around(self, *a): - if self.first_window is None: - active = self.window.get_window().get_screen().get_active_window() - if active is None: - return None - self.first_window = active - - tx, ty = self.get_target_position() - self.window.get_window().move(tx, ty) - return True - - -def direction(x): - if x >= 1: - return 1 - if x <= -1: - return -1 - return 0 diff --git a/scc/gui/simple_chooser.py b/scc/gui/simple_chooser.py index 34509d210..f273b0f2c 100644 --- a/scc/gui/simple_chooser.py +++ b/scc/gui/simple_chooser.py @@ -8,6 +8,7 @@ import importlib import logging +from scc.actions import NoAction from scc.gui.ae import AEComponent from scc.gui.dwsnc import headerbar from scc.gui.editor import Editor @@ -60,3 +61,11 @@ def hide_axes(self): def hide_mouse(self): """Prevents user from selecting mouse-related stuff""" self.component.hide_mouse() + + def show_clear(self): + """Reveals a 'Clear' button. The caller's callback must accept a NoAction + (i.e. handle the unset case).""" + self.builder.get_object("btClear").set_visible(True) + + def on_btClear_clicked(self, *a): + self.set_action(NoAction()) diff --git a/scc/lib/daemon.py b/scc/lib/daemon.py index 0385a8aef..1cd22ae02 100644 --- a/scc/lib/daemon.py +++ b/scc/lib/daemon.py @@ -39,7 +39,7 @@ def daemonize(self) -> None: # decouple from parent environment os.chdir("/") os.setsid() - os.umask(0) + os.umask(0o077) # do second fork try: diff --git a/scc/lib/xwrappers.py b/scc/lib/xwrappers.py index 26107ec3d..8511c44bf 100644 --- a/scc/lib/xwrappers.py +++ b/scc/lib/xwrappers.py @@ -18,6 +18,7 @@ from ctypes import ( CDLL, + CFUNCTYPE, POINTER, Structure, byref, @@ -50,6 +51,21 @@ def _load_lib(*names): libX11 = _load_lib("libX11.so", "libX11.so.6") libXext = _load_lib("libXext.so", "libXext.so.6") +# By default Xlib aborts the whole process on an X protocol error (e.g. BadWindow +# when querying a stale/invalid window id - which happens under XWayland, where +# the focused window may not be an X window). Install a no-op error handler so the +# offending call fails quietly (callers already cope with missing data) instead of +# killing the process (this previously crashed scc-osd-daemon from Autoswitch). +_XErrorHandler = CFUNCTYPE(c_int, c_void_p, c_void_p) + + +def _ignore_x_error(display: int, error: int) -> int: + return 0 + + +_x_error_handler = _XErrorHandler(_ignore_x_error) # keep a reference so it isn't GC'd +libX11.XSetErrorHandler(_x_error_handler) + # Types XID = c_ulong diff --git a/scc/mapper.py b/scc/mapper.py index a6cb42c56..5f85d494a 100644 --- a/scc/mapper.py +++ b/scc/mapper.py @@ -6,7 +6,7 @@ import traceback from typing import TYPE_CHECKING -from scc.actions import Action, ButtonAction, GyroAbsAction +from scc.actions import Action, ButtonAction, GyroAction from scc.aliases import ALL_AXES, ALL_BUTTONS from scc.config import Config from scc.constants import ( @@ -45,6 +45,13 @@ log = logging.getLogger("Mapper") +# SCC_HAPTIC_DEBUG=1 also dumps the raw force-feedback effect as the uinput +# shim hands it over, which is the only way to tell a shim-side problem from a +# driver-side one. +_RUMBLE_DEBUG = bool(os.environ.get("SCC_HAPTIC_DEBUG")) +if _RUMBLE_DEBUG: + log.setLevel(logging.INFO) + class Mapper: DEBUG: bool = False @@ -172,6 +179,22 @@ def _rumble_ready(self, fd, event) -> None: self.generate_feedback() return + # Controllers with real rumble motors take the magnitude directly; + # only fall through to the click-train emulation below if they + # cannot, which on a v1 is always, its "motors" being pad actuators. + if _RUMBLE_DEBUG: + log.info("FF-RAW type=%d level=%d strong=%d weak=%d dur=%d reps=%d", + ef.type, ef.level, ef.strong, ef.weak, ef.duration, ef.repetitions) + strong, weak = ef.strong, ef.weak + if not strong and not weak and ef.level > 0: + # Only FF_RUMBLE carries the two magnitudes. Every other effect + # type -- and any older libuinput -- provides just the averaged + # level, so drive both motors from that instead of handing the + # controller a silent (0, 0) and returning as if it played. + strong = weak = ef.level + if self.controller and self.controller.rumble( + strong, weak, int(ef.duration * max(1, ef.repetitions))): + return period_command = 0 amplitude = 0 if ef.level != 0 and ef.repetitions > 0: @@ -465,8 +488,12 @@ def cancel_all(self) -> None: self.clear_bt_stick_mouse_velocity() def reset_gyros(self) -> None: + # GyroAction covers GyroAbsAction (subclass): absolute actions re-capture + # their orientation reference (ir), relative ones their lean-to-turn + # neutral pose. Rate-based outputs (laser-pointer mouse, relative stick) + # have no reference by nature, so recentering rightly leaves them alone. for a in self.profile.get_all_actions(): - if isinstance(a, GyroAbsAction): + if isinstance(a, GyroAction): a.reset() def input(self, controller: Controller, old_state: CInput, state: CInput) -> None: @@ -510,7 +537,14 @@ def input(self, controller: Controller, old_state: CInput, state: CInput) -> Non elif not self.buttons & SCButtons.LPADTOUCH: if FE_STICK in fe or self.old_state.lpad_x != state.lpad_x or self.old_state.lpad_y != state.lpad_y: self.profile.lstick.whole(self, state.lpad_x, state.lpad_y, LSTICK) - if controller.flags & ControllerFlags.HAS_RSTICK: + # HAS_RSTICK controllers store the right stick either as a real rstick + # (Steam Controller 2 / Deck) or, for gamepads on the generic HID decoder + # (DS4/DS5), as the right pad (pads[RIGHT]). The latter's state struct + # (HIDControllerInput) has no rstick_* fields, so guard the access: + # without it every event raised AttributeError here, which aborted the + # rest of input processing (right pad, triggers, touchpad) -- the reason + # those controls were dead on the DS4. + if self.controller.flags & ControllerFlags.HAS_RSTICK and hasattr(state, "rstick_x"): if ( FE_STICK in fe or self.old_state.rstick_x != state.rstick_x diff --git a/scc/modifiers.py b/scc/modifiers.py index 0190c78c1..f0ed6605c 100644 --- a/scc/modifiers.py +++ b/scc/modifiers.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +from __future__ import annotations + import inspect import itertools import logging @@ -41,6 +43,8 @@ STICK_PAD_MIN, LSTICKTILT, TRIGGER_MAX, + HapticEffect, + ControllerFlags, HapticPos, SCButtons, SCPads, SCSticks, SCTouchpads, ) @@ -344,6 +348,39 @@ def button_release(self, mapper) -> None: mapper.schedule(0.02, self._release) +class InvertedButtonModifier(Modifier): + """Acts on button release instead of press ("Act on release"). + + Swaps press and release, so the wrapped action is held while the physical + button is NOT pressed. Meant for always-on sensors such as the Steam + Controller's capacitive handle grips, which read "on" the whole time the + controller is held - inverting them lets the action fire when you let go. + Unlike PressedModifier/ReleasedModifier (which emit a momentary tap), this + is a true held inversion of the button state. + """ + COMMAND = "inverted" + + def describe(self, context: int) -> str: + if context in (Action.AC_STICK, Action.AC_PAD): + return _("(act on release)") + "\n" + self.action.describe(context) + return _("(act on release)") + " " + self.action.describe(context) + + def strip(self) -> Action: + return self.action.strip() + + def compress(self) -> Action: + self.action = self.action.compress() + return self + + def button_press(self, mapper: Mapper) -> None: + # Physical press -> the wrapped action is released + self.action.button_release(mapper) + + def button_release(self, mapper: Mapper) -> None: + # Physical release -> the wrapped action is pressed + self.action.button_press(mapper) + + class BallModifier(Modifier, WholeHapticAction): """Emulates ball-like movement with inertia and friction. @@ -370,6 +407,7 @@ def _mod_init( self, friction=DEFAULT_FRICTION, mass=80.0, mean_len=DEFAULT_MEAN_LEN, r=0.02, ampli=65536, degree=40.0, ): self.speed = (1.0, 1.0) + self._speed_handed_over = False self.friction = friction self._xvel = 0.0 self._yvel = 0.0 @@ -398,6 +436,33 @@ def set_speed(self, x, y, *a) -> None: def get_speed(self): return self.speed + def _step_aside(self, mapper): + """Hand this modifier's sensitivity to the child before bypassing. + + SensitivityModifier walks down to the first action with set_speed() and + stops there, so on a trackball binding the speed lands on the ball -- + which then applies it itself, in _add and _roll. Both are skipped when + the ball steps aside for a stick, so without this the sensitivity + sliders do nothing at all: the child is still at 1.0. + + Multiplied into whatever the child already has, so it is right whichever + side of the ball the sens() ended up on, and guarded so it happens once, + because set_speed is absolute rather than cumulative. + """ + if self._speed_handed_over: + return + self._speed_handed_over = True + if self.speed == (1.0, 1.0): + return + a = self.action + while a is not None: + if hasattr(a, "set_speed"): + own = a.get_speed() if hasattr(a, "get_speed") else (1.0, 1.0) + a.set_speed(own[0] * self.speed[0], own[1] * self.speed[1], + own[2] if len(own) > 2 else 1.0) + return + a = getattr(a, "action", None) + def get_compatible_modifiers(self): return ( Action.MOD_SENSITIVITY @@ -505,6 +570,7 @@ def pad(self, mapper, position, what): def change(self, mapper, dx, dy, what): if what in (None, LSTICK, RSTICK): + self._step_aside(mapper) return self.action.change(mapper, dx, dy, what) if mapper.is_touched(what): if mapper.was_touched(what): @@ -524,6 +590,7 @@ def change(self, mapper, dx, dy, what): def whole(self, mapper, x, y, what): if what == RSTICK: + self._step_aside(mapper) return self.action.whole(mapper, x, y, what) if mapper.is_touched(what): if mapper.is_touched(what) and not mapper.was_touched(what): @@ -562,6 +629,13 @@ def get_haptic(self): return WholeHapticAction.get_haptic(self) def compress(self) -> CircularModifier | Self: + # Modifier.compress does this for every other modifier; overriding it + # here dropped it, so a sens() written INSIDE a ball survived as a live + # SensitivityModifier -- which has no whole(), so the binding did + # nothing at all. The GUI always writes sens() outside, so this only + # ever bit hand-written profiles. + if self.action: + self.action = self.action.compress() # ball(circular(...) has to be turned around if isinstance(self.action, CircularModifier): cm = self.action @@ -1003,7 +1077,24 @@ def gyro(self, mapper, pitch, yaw, roll, *q): sel = self.select(mapper) if sel is not self.old_action: if self.old_action: + # Neutralize the outgoing gyro action so its output axis doesn't stay + # stuck when the enable button is released. Relative GyroAction zeroes + # on (0,0,0), but GyroAbsAction ignores pitch/yaw/roll (it tracks + # q1-q4), so it would emit its last orientation and leave the axis + # deflected. Reset the reference first so the neutralizing call below + # emits 0. Covers MultiAction (mixed relative+absolute) via .actions. + for a in getattr(self.old_action, "actions", None) or (self.old_action,): + if hasattr(a, "reset"): + a.reset() self.old_action.gyro(mapper, 0, 0, 0, *q) + # That neutralizing call just re-captured the reference at the + # RELEASE pose (reset -> first-event capture). Reset once more so + # the next activation captures ITS OWN pose: the gyro re-centers + # on every engage (Steam-like), instead of carrying a stale + # offset accumulated while deactivated. + for a in getattr(self.old_action, "actions", None) or (self.old_action,): + if hasattr(a, "reset"): + a.reset() self.old_action = sel return sel.gyro(mapper, pitch, yaw, roll, *q) @@ -1298,6 +1389,12 @@ class FeedbackModifier(Modifier): COMMAND = "feedback" PROFILE_KEY_PRIORITY = -4 + # See HAPTIC_EFFECT_MODIFIERS. Click uses none of the extra parameter rows + # -- its knobs are this modifier's own frequency and period -- but it still + # has to declare that, or anything iterating the effects trips over it. + EFFECT = HapticEffect.CLICK + PARAMS = () + LABEL = _("Click") def _mod_init(self, position, amplitude=512, frequency=4, period=1024, count=1): self.haptic = HapticData(position, amplitude, frequency, period, count) @@ -1338,6 +1435,107 @@ def compress(self): return self.action.compress() +class _EffectFeedbackModifier(FeedbackModifier): + """Shared base for the richer haptic effects. + + These behave exactly like feedback() -- wrap an action, hand it a + HapticData -- and differ only in which effect that data describes. They + are separate commands rather than extra parameters on feedback() because + feedback() appears in every existing profile and its argument list is + positional; growing it would change the meaning of files already on disk. + + Only hardware with a synthesising actuator can play them (currently the + Steam Controller 2). Drivers that cannot fall back to a plain click, so a + profile written for one controller still does something on another. + """ + + EFFECT = None + # Parameters after 'amplitude', in _mod_init order. The action editor builds + # these modifiers positionally from this, so it lives here rather than in + # the GUI -- kept in two places it drifts, and the drift is silent: values + # land in the wrong parameter. + PARAMS = () + + def _make_haptic(self, position, amplitude, **kw): + return HapticData(position, amplitude, effect=self.EFFECT, **kw) + + def _attach(self, haptic): + self.haptic = haptic + a = self.action + while a: + if hasattr(a, "set_haptic"): + a.set_haptic(self.haptic) + break + a = a.action if hasattr(a, "action") else None + + @classmethod + def decode(cls, data, a, *b): + args = list(data[cls.COMMAND]) + if hasattr(HapticPos, args[0]): + args[0] = getattr(HapticPos, args[0]) + args.append(a) + return cls(*args) + + def to_string(self, multiline=False, pad=0): + return self._mod_to_string(self.strip_defaults(), multiline, pad) + + +class FeedbackToneModifier(_EffectFeedbackModifier): + """Sine tone at a fixed frequency, optionally modulated by an LFO.""" + + COMMAND = "feedbacktone" + PROFILE_KEY_PRIORITY = -4 + EFFECT = HapticEffect.TONE + PARAMS = ("tone_frequency", "duration", "lfo_frequency", "lfo_depth") + LABEL = _("Tone") + + def _mod_init(self, position, amplitude=512, tone_frequency=160, duration=200, + lfo_frequency=0, lfo_depth=0): + self._attach(self._make_haptic(position, amplitude, + tone_frequency=tone_frequency, duration=duration, + lfo_frequency=lfo_frequency, lfo_depth=lfo_depth)) + + def __str__(self): + return "" % (self.action,) + + +class FeedbackSweepModifier(_EffectFeedbackModifier): + """Logarithmic frequency sweep between two frequencies.""" + + COMMAND = "feedbacksweep" + PROFILE_KEY_PRIORITY = -4 + EFFECT = HapticEffect.SWEEP + PARAMS = ("tone_frequency", "end_frequency", "duration") + LABEL = _("Sweep") + + def _mod_init(self, position, amplitude=512, tone_frequency=160, + end_frequency=40, duration=200): + self._attach(self._make_haptic(position, amplitude, + tone_frequency=tone_frequency, end_frequency=end_frequency, + duration=duration)) + + def __str__(self): + return "" % (self.action,) + + +class FeedbackScriptModifier(_EffectFeedbackModifier): + """Preset effect stored in the controller's own firmware.""" + + COMMAND = "feedbackscript" + PROFILE_KEY_PRIORITY = -4 + EFFECT = HapticEffect.SCRIPT + PARAMS = ("script_id",) + LABEL = _("Preset") + + def _mod_init(self, position, amplitude=512, script_id=0): + # amplitude second, matching feedback() and the other two effects, so + # every one of them serialises as (side, amplitude, ...) + self._attach(self._make_haptic(position, amplitude, script_id=script_id)) + + def __str__(self): + return "" % (self.action,) + + class RotateInputModifier(Modifier): """Rotates ball or stick input along axis""" @@ -1599,3 +1797,40 @@ def whole(self, mapper, x, y, what) -> None: # Set axis on child action self.action.axis(mapper, angle * self.speed, 0) mapper.force_event.add(FE_PAD) + + +# Every haptic effect the action editor can offer, in display order. Kept here +# rather than in the GUI so it can be tested without importing Gtk -- when it +# lived in action_editor.py a missing attribute on one entry crashed the editor +# on construction and no test could see it. +HAPTIC_EFFECT_MODIFIERS = ( + FeedbackModifier, + FeedbackToneModifier, + FeedbackSweepModifier, + FeedbackScriptModifier, +) + + +# Preset effects stored in the Steam Controller 2's firmware, played by +# feedbackscript() / output report 0x85. Names are from iczero's reverse +# engineering (collected in CouchTurtle/sc2-research) and are NOT verified +# here -- what each actually feels like is still to be checked on hardware. +# The list may be partial: nothing states that 0x10 is the last one. +HAPTIC_SCRIPTS = ( + (0x01, "Controller on"), + (0x02, "Controller very on"), + (0x03, "Trill up"), + (0x04, "Trill down"), + (0x05, "Controller off"), + (0x06, "Up five"), + (0x07, "Down five"), + (0x08, "Up six"), + (0x09, "Down six"), + (0x0A, "Whoop up"), + (0x0B, "Whoop down"), + (0x0C, "Phone ringing 1"), + (0x0D, "Ringback tone"), + (0x0E, "Phone ringing 2"), + (0x0F, "Phone ringing 3"), + (0x10, "Wilhelm scream"), +) diff --git a/scc/osd/__init__.py b/scc/osd/__init__.py index 8f14e1501..080054a35 100644 --- a/scc/osd/__init__.py +++ b/scc/osd/__init__.py @@ -157,6 +157,7 @@ def _add_arguments(self) -> None: Use negative value to specify as distance from bottom side (default: -20)""", ) self.argparser.add_argument("--controller", type=str, help="""id of controller to use""") + self.argparser.add_argument("--controller-type", type=str, help="""type of controller to use""") self.argparser.add_argument("-d", action="store_true", help="""display debug messages""") def choose_controller(self, daemonmanager: DaemonManager) -> ControllerManager: @@ -385,6 +386,7 @@ def __init__(self): GObject.GObject.__init__(self) TimerManager.__init__(self) self._direction = 0 + self._positions = {} def _move(self, *a): self.emit("direction", *self.DIRECTION_TO_XY[self._direction]) @@ -393,7 +395,24 @@ def _move(self, *a): else: self.cancel_timer("move") - def set_stick(self, *data): + def set_stick(self, *data, source=None): + """Feed a position in. 'source' names the input it came from, for + callers that accept more than one. + + Without it, every input shares one direction and a neutral reading + cancels whatever another input is driving: _move() emits on each change + and cancels the repeat timer when the direction reaches 0, so a menu fed + by both a stick and a d-pad advanced once per event -- at the controller + report rate rather than the intended interval -- and then stopped dead as + soon as the stick was held still and stopped producing events. + """ + if source is not None: + self._positions[source] = (data[0], data[1]) + # whichever input is actually deflected decides; an idle one + # reading (0, 0) must not cancel a repeat another is driving + data = max(self._positions.values(), + key=lambda p: max(abs(p[0]), abs(p[1]))) + direction = 0 # Y if data[1] < STICK_PAD_MIN / 2: diff --git a/scc/osd/binding_display.py b/scc/osd/binding_display.py index bc04c8003..57a47f85e 100644 --- a/scc/osd/binding_display.py +++ b/scc/osd/binding_display.py @@ -5,20 +5,23 @@ Reuses styles from OSD Menu and OSD Dialog """ +from __future__ import annotations import base64 import logging import os +import re import sys +from collections.abc import Callable from enum import IntEnum -from typing import Self +from typing import TYPE_CHECKING, Self from gi.repository import Gtk from scc.actions import Action, AxisAction, DPadAction, MouseAction, MultiAction, XYAction from scc.config import Config -from scc.constants import SCButtons, SCTriggers, SCPads -from scc.gui.daemon_manager import DaemonManager +from scc.constants import DPAD, LEFT, RIGHT, SCButtons, SCTriggers, SCPads +from scc.gui.daemon_manager import ControllerManager, DaemonManager from scc.gui.svg_widget import SVGEditor, SVGWidget from scc.modifiers import DoubleclickModifier, ModeModifier from scc.osd import OSDWindow @@ -29,6 +32,9 @@ from scc.tools import _, nameof from scc.uinput import Rels +if TYPE_CHECKING: + from xml.etree import ElementTree as ET + log = logging.getLogger("osd.binds") @@ -45,6 +51,7 @@ def __init__(self, config=None): self.group = None self.limits = {} self.background = None + self._layout_key = None # gui "background" name -> per-controller LAYOUTS self._eh_ids = [] self._stick = 0, 0 @@ -53,8 +60,22 @@ def __init__(self, config=None): self.c.set_name("osd-keyboard-container") def on_profile_changed(self, daemon: DaemonManager, filename: str): - profile = Profile(TalkingActionParser()).load(filename) - Generator(SVGEditor(self.background), profile) + self._draw_profile(filename) + + def _draw_profile(self, filename: str) -> None: + """(Re)draws the binding boxes for the given profile onto the current + background. No-op until the background image has been built, which + happens in on_daemon_connected() once the connected controller (and + thus which per-controller image to load) is known. + """ + if self.background is None or not filename: + return + try: + profile = Profile(TalkingActionParser()).load(filename) + except Exception: + log.exception("Failed to load profile %s", filename) + return + Generator(SVGEditor(self.background), profile, self._layout_key) def use_daemon(self, d): """Allows (re)using already existing DaemonManager instance in same process.""" @@ -70,19 +91,30 @@ def _add_arguments(self): ) def compute_position(self): - """Unlike other OSD windows, this one is scaled to 80% of screen size and centered in on active screen.""" - x, y = 10, 10 + """Fit the (per-controller) binding image to the active screen and centre + it. Unlike other OSD windows this one is nearly screen-sized, so it is + capped to 80% of the screen in BOTH dimensions and scaled down to fit.""" iw, ih = self.background.image_width, self.background.image_height geometry = self.get_active_screen_geometry() - if geometry: - width, height = iw, ih - if width > geometry.width * 0.8: - width = geometry.width * 0.8 - height = int(float(ih) / float(iw) * float(width)) - self.background.resize(width, height) - self.background.hilight({}) - x = geometry.x + ((geometry.width - width) / 2) - y = geometry.y + ((geometry.height - height) / 2) + if geometry is None: + # The Steam Deck (gamescope) reports no active window; fall back to the + # primary monitor so the image is still fitted instead of shown at full + # 1280x720, which overflows the Deck's 1280x800 screen. + screen = self.get_window().get_screen() + geometry = screen.get_monitor_geometry(screen.get_primary_monitor()) + if geometry is None: + return 10, 10 + # Cap to 80% of the screen in BOTH dimensions: width-only scaling left the + # window overflowing on screens barely larger than the image (the Deck's + # 1280x800 vs the 1280x720 image), and off-screen boxes made their + # connector lines appear to shoot outside the window. + scale = min(1.0, geometry.width * 0.8 / iw, geometry.height * 0.8 / ih) + width, height = int(iw * scale), int(ih * scale) + if scale < 1.0: + self.background.resize(width, height) + self.background.hilight({}) + x = geometry.x + (geometry.width - width) // 2 + y = geometry.y + (geometry.height - height) // 2 return x, y def parse_arguments(self, argv): @@ -114,6 +146,16 @@ def success(*a): self.on_failed_to_lock("Controller not connected") return + # The binding-display image is per-controller, so it can only be + # resolved now that we know which controller is connected. show() left + # the window unbuilt; build it here and draw the profile the controller + # already has (the daemon may have reported the profile before the + # window existed, so relying on the profile-changed signal alone would + # leave the boxes blank until the next profile change). + if self.background is None: + self._build_and_show(self._resolve_image(c)) + self._draw_profile(c.get_profile()) + self._eh_ids += [ (c, c.connect("event", self.on_event)), (c, c.connect("lost", self.on_controller_lost)), @@ -123,6 +165,55 @@ def success(*a): locks = ["RB", "LB", self.args.cancel_with] c.lock(success, self.on_failed_to_lock, *locks) + def _resolve_image(self, controller: ControllerManager) -> str: + """Picks the binding-display SVG for the connected controller. + + Order of preference: + 1. an explicit image given on the command line + 2. "binding_display" filename set in the controller's gui config + 3. convention: binding-display/.svg + (looked up in ~/.config/scc first, then the bundled images dir) + 4. the generic binding-display.svg (user override, then bundled) + The generic fallback keeps controllers without a dedicated layout + working - they just render on the old template, as before. + """ + images_path = os.path.join(get_share_path(), "images") + config_path = get_config_path() + candidates = [] + # 1. explicit command-line image (default equals self.bdisplay) + cli = getattr(self.args, "image", None) + if cli and cli != self.bdisplay: + candidates.append(cli) + # 2./3. per-controller, from the controller's gui config + try: + config = controller.load_gui_config(images_path) + except Exception: + log.exception("Failed to load controller gui config") + config = None + gui = (config or {}).get("gui") or {} + explicit = gui.get("binding_display") + if explicit: + if "/" in explicit: + candidates.append(explicit) + else: + candidates.append(os.path.join(config_path, explicit)) + candidates.append(os.path.join(images_path, explicit)) + background = gui.get("background") + self._layout_key = background # selects the per-controller box layout + if background: + # per-controller templates live in the binding-display/ subdir, with + # an optional user override under ~/.config/scc/binding-display/. + fname = os.path.join("binding-display", "%s.svg" % (background,)) + candidates.append(os.path.join(config_path, fname)) + candidates.append(os.path.join(images_path, fname)) + # 4. generic fallback (already user-override-then-bundled) + candidates.append(self.bdisplay) + for path in candidates: + if path and os.path.exists(path): + log.debug("Using binding-display image: %s", path) + return path + return self.bdisplay + def quit(self, code=-1): if self.get_controller(): self.get_controller().unlock_all() @@ -132,14 +223,22 @@ def quit(self, code=-1): OSDWindow.quit(self, code) def show(self, *a): - if self.background is None: - self.realize() - self.background = SVGWidget(self.args.image, init_hilighted=True) - self.c.append(self.background) - self.set_child(self.c) - + # The background image is per-controller and only known once the daemon + # reports the connected controller, so the real show is deferred to + # on_daemon_connected() -> _build_and_show(). Until the background + # exists this is a no-op (run() calls show() before the daemon is up). + if self.background is not None: + OSDWindow.show(self, *a) + self.move(*self.compute_position()) + + def _build_and_show(self, image: str) -> None: + """Builds the window around the given background image and shows it.""" + self.realize() + self.background = SVGWidget(image, init_hilighted=True) + self.c.append(self.background) + self.set_child(self.c) self.position = tuple(int(value) for value in self.compute_position()) - OSDWindow.show(self, *a) + OSDWindow.show(self) def on_event(self, daemon, what, data): """Called when button press, button release or stick / pad update is @@ -199,8 +298,10 @@ class Box: SPACING = 2 MIN_WIDTH = 100 MIN_HEIGHT = 50 + MIN_SCALE = 0.4 # smallest font shrink before lines may overflow anyway - def __init__(self, anchor_x, anchor_y, align, name, min_width=MIN_WIDTH, min_height=MIN_HEIGHT, max_width=999999): + def __init__(self, anchor_x: int, anchor_y: int, align: Align, name: str, min_width: int = MIN_WIDTH, + min_height: int = MIN_HEIGHT, max_width: int = 999999, max_height: int = 999999) -> None: self.name = name self.lines = [] self.anchor = anchor_x, anchor_y @@ -209,6 +310,7 @@ def __init__(self, anchor_x, anchor_y, align, name, min_width=MIN_WIDTH, min_hei self.x, self.y = 0, 0 self.min_width = min_width self.max_width = max_width + self.max_height = max_height self.min_height = min_height def to_string(self): @@ -237,7 +339,7 @@ def add(self, icon, context, action: Action): action = action.strip() if isinstance(action, MenuAction): - if self.name == "bcs" and action.menu_id == "Default.menu": + if self.name in ("bcs", "system") and action.menu_id == "Default.menu": # Special case, this action is expected in every profile, # so there is no need to draw it here return LineCollection() @@ -277,6 +379,17 @@ def calculate(self, gen): self.width += 2 * self.PADDING + self.icount * (gen.line_height + self.SPACING) self.width = min(self.width, self.max_width) self.height = max(self.height, self.min_height) + # Auto-scale the font for this box so all its lines fit within max_height. + # place() draws every line regardless of box height, so without this a + # crowded box (e.g. a stick bound to a big radial/dpad, or mode-heavy face + # buttons) overflows downward off the box and the screen. + content = self.height - 2 * self.PADDING + avail = self.max_height - 2 * self.PADDING + if content > avail and content > 0: + self.scale = max(self.MIN_SCALE, avail / content) + self.height = self.max_height + else: + self.scale = 1.0 anchor_x, anchor_y = self.anchor if (self.align & Align.TOP) != 0: @@ -307,9 +420,12 @@ def place(self, gen, root): y=self.y, ) + scale = getattr(self, "scale", 1.0) + lh = gen.line_height * scale + text_style = gen.label_style(scale) y = self.y + self.PADDING for line in self.lines: - h = gen.line_height + h = lh x = self.x + self.PADDING for icon in line.icons: image = find_image(icon) @@ -327,12 +443,12 @@ def place(self, gen, root): x += h + self.SPACING x = self.x + self.PADDING + self.icount * (h + self.SPACING) y += h - txt = SVGEditor.add_element(root, "text", x=x, y=y, style=gen.label_template.attrib["style"]) - max_line_width = self.max_width - gen.line_height - self.PADDING - while line.text and line.get_size(gen)[0] > max_line_width: + txt = SVGEditor.add_element(root, "text", x=x, y=y, style=text_style) + max_line_width = self.max_width - lh - self.PADDING + while line.text and line.get_size(gen)[0] * scale > max_line_width: line.text = line.text[:-1] SVGEditor.set_text(txt, line.text) - y += self.SPACING + y += self.SPACING * scale def place_marker(self, gen, root): x1, y1 = self.x, self.y @@ -380,19 +496,133 @@ def place_marker(self, gen, root): ) +# --- per-controller binding-display layouts -------------------------------- +# Which controls go in which box is semantic, so it lives here rather than in +# the template SVG (which only supplies marker positions + the canvas). Keyed by +# the controller's gui "background" name; controllers without an entry fall back +# to the v1 layout (Generator._build_v1). Box positions are auto-placed from the +# Align flags; size caps are fractions of the canvas. Only bound controls draw a +# line and a box with no bound controls is hidden (Generator._build_layout), so +# every variant (grip-squeeze, stick/pad touch & press, ...) can be listed and +# simply stays invisible until the user binds it. +_B, _T, _P, _S = Action.AC_BUTTON, Action.AC_TRIGGER, Action.AC_PAD, Action.AC_STICK + + +def _btn(name: str) -> Callable[[Profile], Action | None]: + return lambda p: p.buttons.get(SCButtons[name]) + + +def _pad(side: str) -> Callable[[Profile], Action | None]: + return lambda p: p.pads.get(side) + + +def _trig(side: str) -> Callable[[Profile], Action | None]: + return lambda p: p.triggers.get(side) + + +def _stick(p: Profile) -> Action: + return p.stick + + +def _rstick(p: Profile) -> Action | None: + return getattr(p, "rstick", None) + + +LAYOUTS = { + # Steam Controller v2 (2026) -- Steam Deck control set: two sticks, a D-pad, + # two trackpads, four system buttons, back paddles + grip-squeeze sensors. + # Six boxes: four corners + top/bottom centre, controller art in the middle. + "sc2": [ + dict(name="system", align=Align.TOP, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("BACK", _B, _btn("BACK")), ("C", _B, _btn("C")), + ("START", _B, _btn("START")), ("DOTS", _B, _btn("DOTS"))]), + dict(name="lshoulder", align=Align.LEFT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("LT", _T, _trig(LEFT)), ("LB", _B, _btn("LB")), + ("LGRIP", _B, _btn("LGRIP")), ("LGRIP2", _B, _btn("LGRIP2")), + ("LGRIPTOUCH", _B, _btn("LGRIPTOUCH"))]), + dict(name="rshoulder", align=Align.RIGHT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RT", _T, _trig(RIGHT)), ("RB", _B, _btn("RB")), + ("RGRIP", _B, _btn("RGRIP")), ("RGRIP2", _B, _btn("RGRIP2")), + ("RGRIPTOUCH", _B, _btn("RGRIPTOUCH"))]), + dict(name="lthumb", align=Align.LEFT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("STICK", _S, _stick), ("DPAD", _P, _pad(DPAD)), + ("LPAD", _P, _pad(LEFT))]), + dict(name="rthumb", align=Align.RIGHT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RSTICK", _S, _rstick), ("RPAD", _P, _pad(RIGHT))]), + dict(name="face", align=Align.BOTTOM, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("A", _B, _btn("A")), ("B", _B, _btn("B")), + ("X", _B, _btn("X")), ("Y", _B, _btn("Y"))]), + ], +} + +# The Steam Deck's built-in controller shares the v2's physical control set (same +# boxes, same controls), so it reuses the v2 layout. Controls the Deck lacks stay +# unbound and their boxes render nothing, so the shared list is safe. +LAYOUTS["deck"] = LAYOUTS["sc2"] + +# Standard gamepads (DualShock 4, DualSense, Xbox 360). Their control model +# differs from the Steam controllers: the right stick is the right pad +# (pads[RIGHT], not rstick) and the d-pad is the left pad (pads[LEFT]); there are +# no trackpads or grip-touch. Same six-box frame. ds4/ds5/x360 are physically +# alike, so they share one layout. +_GAMEPAD_LAYOUT = [ + dict(name="system", align=Align.TOP, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("BACK", _B, _btn("BACK")), ("C", _B, _btn("C")), + ("START", _B, _btn("START"))]), + dict(name="lshoulder", align=Align.LEFT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("LT", _T, _trig(LEFT)), ("LB", _B, _btn("LB")), + ("LGRIP", _B, _btn("LGRIP"))]), + dict(name="rshoulder", align=Align.RIGHT | Align.TOP, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RT", _T, _trig(RIGHT)), ("RB", _B, _btn("RB")), + ("RGRIP", _B, _btn("RGRIP"))]), + dict(name="lthumb", align=Align.LEFT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("STICK", _S, _stick), ("DPAD", _P, _pad(LEFT))]), + dict(name="rthumb", align=Align.RIGHT | Align.BOTTOM, + min_width_f=0.18, max_width_f=0.27, max_height_f=0.42, + controls=[("RSTICK", _P, _pad(RIGHT))]), + dict(name="face", align=Align.BOTTOM, ax=0, max_width_f=0.4, max_height_f=0.22, + controls=[("A", _B, _btn("A")), ("B", _B, _btn("B")), + ("X", _B, _btn("X")), ("Y", _B, _btn("Y"))]), +] +LAYOUTS["ds4"] = LAYOUTS["ds5"] = LAYOUTS["x360"] = _GAMEPAD_LAYOUT + + class Generator: PADDING = 10 - def __init__(self, editor, profile: Profile): + def __init__(self, editor: SVGEditor, profile: Profile, layout_key: str | None = None) -> None: background = SVGEditor.get_element(editor, "background") self.label_template = SVGEditor.get_element(editor, "label_template") self.line_height = int(float(self.label_template.attrib.get("height") or 8)) self.char_width = int(float(self.label_template.attrib.get("width") or 8)) self.full_width = int(float(background.attrib.get("width") or 800)) self.full_height = int(float(background.attrib.get("height") or 800)) + self._label_style = self.label_template.attrib.get("style", "") + m = re.search(r"font-size:\s*([\d.]+)", self._label_style) + self.font_size = float(m.group(1)) if m else self.line_height * 1.45 + root = SVGEditor.get_element(editor, "root") + + layout = LAYOUTS.get(layout_key) + if layout is None: + self._build_v1(profile, root) + else: + self._build_layout(profile, root, layout) + editor.commit() + + def _build_v1(self, profile: Profile, root: ET.Element) -> None: + """The original 5-box layout (Steam Controller v1: one stick, no D-pad, + three system buttons). Used for v1 and any controller without a dedicated + entry in LAYOUTS.""" boxes = [] - box_bcs = Box(0, self.PADDING, Align.TOP, "bcs") + box_bcs = Box(0, self.PADDING, Align.TOP, "bcs", max_height=self.full_height * 0.25) box_bcs.add("BACK", Action.AC_BUTTON, profile.buttons.get(SCButtons.BACK)) box_bcs.add("C", Action.AC_BUTTON, profile.buttons.get(SCButtons.C)) box_bcs.add("START", Action.AC_BUTTON, profile.buttons.get(SCButtons.START)) @@ -406,6 +636,7 @@ def __init__(self, editor, profile: Profile): min_height=self.full_height * 0.5, min_width=self.full_width * 0.2, max_width=self.full_width * 0.275, + max_height=self.full_height * 0.85, ) box_left.add("LT", Action.AC_TRIGGER, profile.triggers.get(SCTriggers.LT)) box_left.add("LB", Action.AC_BUTTON, profile.buttons.get(SCButtons.LB)) @@ -421,6 +652,7 @@ def __init__(self, editor, profile: Profile): min_height=self.full_height * 0.5, min_width=self.full_width * 0.2, max_width=self.full_width * 0.275, + max_height=self.full_height * 0.85, ) box_right.add("RT", Action.AC_TRIGGER, profile.triggers.get(SCTriggers.RT)) box_right.add("RB", Action.AC_BUTTON, profile.buttons.get(SCButtons.RB)) @@ -429,7 +661,8 @@ def __init__(self, editor, profile: Profile): boxes.append(box_right) box_abxy = Box( - 4 * self.PADDING, self.PADDING, Align.RIGHT | Align.BOTTOM, "abxy", max_width=self.full_width * 0.45, + 4 * self.PADDING, self.PADDING, Align.RIGHT | Align.BOTTOM, "abxy", + max_width=self.full_width * 0.45, max_height=self.full_height * 0.25, ) box_abxy.add("A", Action.AC_BUTTON, profile.buttons.get(SCButtons.A)) box_abxy.add("B", Action.AC_BUTTON, profile.buttons.get(SCButtons.B)) @@ -438,15 +671,12 @@ def __init__(self, editor, profile: Profile): boxes.append(box_abxy) box_lstick = Box( - 4 * self.PADDING, self.PADDING, Align.LEFT | Align.BOTTOM, "lstick", max_width=self.full_width * 0.45, + 4 * self.PADDING, self.PADDING, Align.LEFT | Align.BOTTOM, "lstick", + max_width=self.full_width * 0.45, max_height=self.full_height * 0.25, ) box_lstick.add("LSTICK", Action.AC_STICK, profile.lstick) boxes.append(box_lstick) - w = int(float(background.attrib.get("width") or 800)) - h = int(float(background.attrib.get("height") or 800)) - - root = SVGEditor.get_element(editor, "root") for b in boxes: b.calculate(self) @@ -465,7 +695,46 @@ def __init__(self, editor, profile: Profile): for b in boxes: b.place(self, root) - editor.commit() + def _build_layout(self, profile: Profile, root: ET.Element, layout: list[dict]) -> None: + """Builds boxes from a per-controller LAYOUTS spec. Box positions are + auto-placed from the Align flags; size caps come from canvas fractions. + Boxes whose controls are all unbound draw nothing and are dropped.""" + boxes = [] + for spec in layout: + box = Box( + spec.get("ax", self.PADDING), + spec.get("ay", self.PADDING), + spec["align"], + spec["name"], + min_width=spec.get("min_width_f", 0) * self.full_width or Box.MIN_WIDTH, + min_height=spec.get("min_height_f", 0) * self.full_height or Box.MIN_HEIGHT, + max_width=spec.get("max_width_f", 1.0) * self.full_width, + max_height=spec.get("max_height_f", 1.0) * self.full_height, + ) + for icon, context, getter in spec["controls"]: + box.add(icon, context, getter(profile)) + boxes.append(box) + + for b in boxes: + b.calculate(self) + # Hide boxes that ended up with no bound controls. + boxes = [b for b in boxes if b.lines] + + for b in boxes: + b.place_marker(self, root) + for b in boxes: + b.place(self, root) + + def label_style(self, scale: float) -> str: + """Label text style with font-size scaled by `scale` (used to shrink a + crowded box so its lines fit). Returns the template style unchanged at + scale 1.0.""" + if scale >= 0.999: + return self._label_style + fs = self.font_size * scale + if re.search(r"font-size:\s*[\d.]+px", self._label_style): + return re.sub(r"font-size:\s*[\d.]+px", "font-size:%.1fpx" % (fs,), self._label_style) + return "font-size:%.1fpx;%s" % (fs, self._label_style) def equal_width(self, *boxes) -> None: """Sets width of all passed boxes to width of widest box""" diff --git a/scc/osd/grid_menu.py b/scc/osd/grid_menu.py index 8208b6906..a6bdced3e 100644 --- a/scc/osd/grid_menu.py +++ b/scc/osd/grid_menu.py @@ -25,7 +25,7 @@ def __init__(self, cls="osd-menu"): Menu.__init__(self, cls) self.ipr = 1 # items per row - def scroll_wrap(self, parent): + def scroll_wrap(self, parent: Gtk.Widget) -> Gtk.Widget: """Grid menus manage their own fixed layout - return unchanged""" return parent diff --git a/scc/osd/keyboard.py b/scc/osd/keyboard.py index cc01aa72d..725a2fa60 100644 --- a/scc/osd/keyboard.py +++ b/scc/osd/keyboard.py @@ -405,7 +405,7 @@ def use_daemon(self, d: DaemonManager) -> None: self._cononect_handlers() self.on_daemon_connected(self.daemon) - def redraw_background(self, *a) -> None: + def redraw_background(self, *a: object) -> None: """Forces a repaint of the keyboard background image. Called by the OSD daemon after recolor()/update_labels() when the diff --git a/scc/osd/menu.py b/scc/osd/menu.py index 259a9e326..3b8de1dfc 100644 --- a/scc/osd/menu.py +++ b/scc/osd/menu.py @@ -40,6 +40,7 @@ from scc.gui.daemon_manager import DaemonManager from scc.menu_data import MenuData, Separator, Submenu from scc.osd import OSDWindow, StickController, menu_generators +from scc.parser import TalkingActionParser from scc.paths import get_share_path from scc.tools import _, circle_to_square, clamp, find_icon, find_menu from scc.x11 import autoswitcher, get_xdisplay @@ -94,6 +95,14 @@ def __init__(self, cls="osd-menu", layer=None): self._menuid = None self._use_cursor = False self._eh_ids = [] + # Reveal-after-lock state: the menu is not made visible until inputs are + # locked, so a button press can't reach the normal mapping before the + # (asynchronous) lock lands. A fallback reveals anyway if the lock is + # slow, so the menu can never get stuck invisible. + self._inputs_locked = False + self._show_pending = False + self._reveal_timer = None + self._quit_done = False self._control_with = SCSticks.LSTICK self._control_with_dpad: bool = False self._confirm_with = "A" @@ -110,7 +119,7 @@ def create_parent(self) -> Gtk.Box: v.set_name("osd-menu") return v - def scroll_wrap(self, parent) -> Gtk.ScrolledWindow: + def scroll_wrap(self, parent: Gtk.Widget) -> Gtk.ScrolledWindow: """Wrap the vertical item list in a scrolled viewport capped to the screen height So very long menus (e.g. hundreds of profiles) don't run off-screen. @@ -153,7 +162,7 @@ def _fit_scroll(self) -> None: else: sw.set_size_request(natw, nath) - def _ensure_visible(self, widget) -> None: + def _ensure_visible(self, widget: Gtk.Widget) -> None: """Scroll the viewport so the selected item and its section heading stay visible.""" sw = getattr(self, "_scrollwindow", None) if sw is None or widget is None: @@ -202,6 +211,14 @@ def use_daemon(self, d): if not self._is_submenu: self._connect_handlers() self.on_daemon_connected(self.daemon) + else: + # A submenu reuses the parent menu's input lock (the parent forwards + # events to it), so it never requests its own lock and + # _on_inputs_locked() is never called. Treat its inputs as already + # locked; otherwise the reveal-when-locked gate added for the + # dead-menu fix keeps the submenu window permanently hidden (it is + # navigable via forwarded input but never shown). + self._inputs_locked = True def use_config(self, c): """Allows reusing already existin Config instance in same process. @@ -262,10 +279,15 @@ def _add_arguments(self) -> None: self.argparser.add_argument("items", type=str, nargs="*", metavar="id title", help="Menu items") def parse_menu(self): + # Parse actions only when we may need to filter items by what they do + # (dropping "Turn Controller OFF" on the Steam Deck). Other controllers + # keep the lighter no-parse load: the daemon runs a menu action by id, so + # the OSD menu itself never needs the parsed action. + parser = TalkingActionParser() if getattr(self.args, "controller_type", None) == "deck" else None if self.args.from_profile: try: self._menuid = self.args.items[0] - self.items = MenuData.from_profile(self.args.from_profile, self._menuid) + self.items = MenuData.from_profile(self.args.from_profile, self._menuid, parser) except OSError: log.exception("%s: Profile file not found" % (sys.argv[0])) return False @@ -275,7 +297,7 @@ def parse_menu(self): elif self.args.from_file: try: self._menuid = self.args.from_file - self.items = MenuData.from_file(self.args.from_file) + self.items = MenuData.from_file(self.args.from_file, parser) except Exception: log.exception("%s: Failed to load menu file" % (sys.argv[0])) return False @@ -288,6 +310,24 @@ def parse_menu(self): return False return True + def _drop_inapplicable_items(self, items): + """Removes generated menu items that don't apply to the connected + controller - currently "Turn Controller OFF" for the Steam Deck's + built-in controls, which can't be powered off. The live controller + isn't known when items are built (that happens before we connect to + the daemon), so the daemon passes its type via --controller-type. + """ + if getattr(self.args, "controller_type", None) != "deck": + return items + def is_turnoff(action): + # turnoff() directly, or wrapped e.g. as osd(turnoff()) - unwrap .action + while action is not None: + if getattr(action, "SA", "") == "turnoff": + return True + action = getattr(action, "action", None) + return False + return [i for i in items if not is_turnoff(getattr(i, "action", None))] + def parse_arguments(self, argv): if not OSDWindow.parse_arguments(self, argv): return False @@ -300,7 +340,7 @@ def parse_arguments(self, argv): self._size = self.args.size # Create buttons that are displayed on screen - items = self.items.generate(self) + items = self._drop_inapplicable_items(self.items.generate(self)) self.items = [] for item in items: item.widget = self.generate_widget(item) @@ -415,7 +455,42 @@ def show(self, *a): if not self.select(0): self.next_item(1) self._fit_scroll() - OSDWindow.show(self, *a) + # Reveal only once inputs are locked, so a button press can never reach + # the normal mapping before the lock lands. That matters for more than a + # stray keystroke: if the press is handled by the old action (e.g. a key + # goes DOWN) and the release is then captured by the menu, the key never + # goes UP and gets stuck. So we must never show the menu while unlocked: + # if the lock does not land within the timeout, give up and CLOSE the + # menu rather than revealing it. + self._show_pending = True + if self._reveal_timer is None: + self._reveal_timer = GLib.timeout_add(2000, self._lock_timed_out) + self._reveal_if_locked() + + def _on_inputs_locked(self, *a: object) -> None: + """Called from the lock-success callback, once inputs are diverted to + this menu and it is safe to reveal it.""" + self._inputs_locked = True + self._reveal_if_locked() + + def _reveal_if_locked(self) -> None: + """Reveal the menu, but only once it is both requested and locked.""" + if not (self._show_pending and self._inputs_locked): + return + self._show_pending = False + if self._reveal_timer is not None: + GLib.source_remove(self._reveal_timer) + self._reveal_timer = None + OSDWindow.show(self) + + def _lock_timed_out(self, *a: object) -> None: + """The lock never landed; close the menu instead of revealing it while + unlocked (which could leak input or strand a key).""" + self._reveal_timer = None + if self._show_pending: + self._show_pending = False + self.quit(3) + return False def on_daemon_connected(self, *a) -> None: if not self.config: @@ -473,6 +548,7 @@ def use_controller(self, controller: ControllerManager) -> None: def lock_inputs(self) -> None: def success(*a) -> None: + self._on_inputs_locked() log.info("Successfully locked input") locks = [self._control_with, self._confirm_with, self._cancel_with] @@ -481,6 +557,19 @@ def success(*a) -> None: self.controller.lock(success, self.on_failed_to_lock, *locks) def quit(self, code=-2) -> None: + # A menu must unlock exactly once. quit() can be re-entered — most + # importantly by a *previous* menu's leftover timeout firing after this + # object should be gone — and unlock_all() is per-CLIENT: it clears every + # lock the OSD daemon holds, not just this menu's. Re-running it would + # strip the locks of whatever menu is open *now*, leaving it visible but + # dead (it stops responding and leaks input to the normal mapping). So + # make quit idempotent: a menu that already quit never unlocks again. + if self._quit_done: + return + self._quit_done = True + if self._reveal_timer is not None: + GLib.source_remove(self._reveal_timer) + self._reveal_timer = None if not self._is_submenu: if self.get_controller(): self.get_controller().unlock_all() @@ -613,7 +702,8 @@ def on_event(self, daemon, what, data): # if point_in_gtkrect(i.widget.get_allocation(), x, y): # self.select(self.items.index(i)) else: - self._scon.set_stick(x, y) + # Keep the selected input and DPAD separate in the pacer. + self._scon.set_stick(x, y, source=what) elif what == self._confirm_with: if data[0] == 0: # Button released if self._selected and self._selected.callback: diff --git a/scc/osd/quick_menu.py b/scc/osd/quick_menu.py index c604f8825..4f6c07762 100644 --- a/scc/osd/quick_menu.py +++ b/scc/osd/quick_menu.py @@ -106,7 +106,6 @@ def _check_on_screen_position(self, quick=False): def lock_inputs(self): def success(*a): - log.error("Sucessfully locked input") config = self.controller.load_gui_config(os.path.join(get_share_path(), "images")) if config and config["gui"] and config["gui"]["buttons"]: buttons = config["gui"]["buttons"] @@ -115,10 +114,16 @@ def success(*a): icon = self._icons[i] name = buttons[self.BUTTON_INDEXES[i]] filename, trash = find_icon("buttons/%s" % name) - icon.set_filename(filename) - icon.queue_draw() + # Some controllers (e.g. the v2) name their face buttons + # sc2_A/B/X/Y, which have no icon under buttons/. Only + # override when a real icon is found, otherwise keep the + # generic A/B/X/Y/LB/RB icon already set in generate_widget. + if filename: + icon.set_filename(filename) + icon.queue_draw() except IndexError: pass + self._on_inputs_locked() locks = [x for x in self.BUTTONS] + [self._cancel_with] self.controller.lock(success, self.on_failed_to_lock, *locks) @@ -137,7 +142,7 @@ def parse_arguments(self, argv): self._timeout = self.args.timeout # Create buttons that are displayed on screen - items = self.items.generate(self) + items = self._drop_inapplicable_items(self.items.generate(self)) self.items = [] self._button_index = 0 for item in items: @@ -240,6 +245,13 @@ def cancel_timer(self): GLib.source_remove(self._timer) self._timer = None + def quit(self, code: int = -2) -> None: + # Cancel the auto-timeout before quitting; otherwise it lingers and fires + # after this menu is gone, calling quit() again -> unlock_all(), which is + # per-client and would clear the *next* menu's locks. See Menu.quit. + self.cancel_timer() + Menu.quit(self, code) + def on_event(self, daemon, what, data): if self._submenu: return self._submenu.on_event(daemon, what, data) diff --git a/scc/osd/radial_menu.py b/scc/osd/radial_menu.py index e3c69b3b6..de7e79546 100644 --- a/scc/osd/radial_menu.py +++ b/scc/osd/radial_menu.py @@ -51,7 +51,7 @@ def __init__(self) -> None: self.set_app_paintable(True) self.connect("draw", self._on_draw_clip_circle) - def scroll_wrap(self, parent): + def scroll_wrap(self, parent: Gtk.Widget) -> Gtk.Widget: """Radial menus draw items on an SVG - no scroll viewport - return unchanged""" return parent diff --git a/scc/sccdaemon.py b/scc/sccdaemon.py index 039c56ef5..db558e708 100644 --- a/scc/sccdaemon.py +++ b/scc/sccdaemon.py @@ -41,10 +41,11 @@ from scc.mapper import Mapper from scc.menu_data import MenuData from scc.parser import TalkingActionParser +from scc.paths import get_default_menus_path, get_default_profiles_path, get_menus_path, get_profiles_path from scc.poller import Poller from scc.profile import Profile from scc.scheduler import Scheduler -from scc.tools import clamp, find_binary, find_menu, find_profile, nameof, set_logging_level, shjoin, shsplit +from scc.tools import clamp, find_binary, find_menu, find_profile, get_profile_name, nameof, set_logging_level, shjoin, shsplit from scc.uinput import CannotCreateUInputException if TYPE_CHECKING: @@ -74,6 +75,20 @@ ) log = logging.getLogger("SCCDaemon") + + +class SafeTalkingActionParser(TalkingActionParser): + """Parser that blocks dangerous actions (shell, profile, restart, etc.) + for use in IPC Replace: commands where the caller is not fully trusted.""" + BLOCKED = frozenset(('shell', 'profile', 'restart', 'exit', 'turnoff')) + + def _parse_action(self, frm=None): + if frm is None: + frm = Action.ALL + frm = {k: v for k, v in frm.items() if k not in self.BLOCKED} + return super()._parse_action(frm) + + tlog = logging.getLogger("Socket Thread") @@ -294,6 +309,31 @@ def _set_profile(self, mapper: Mapper, filename: str) -> None: else: self.send_profile_info(None, self._send_to_all, mapper=mapper) + def _remember_controller_profile(self, client: "Client", filename: str) -> None: + """Persists a controller's profile so it is restored on (re)connect. + + Only explicit, user-initiated selections are remembered: the autoswitch + daemon's contextual per-window switches and transient live-edit (.mod) + profiles are skipped. Stored by name under + config["controllers"][]["profile"]; with "Use Serial Numbers" on + that id is the physical device, otherwise it is the connection slot. + """ + if client is self.autoswitch_daemon: + # Autoswitcher changes are contextual, not the controller's choice + return + if filename.endswith(".mod"): + # Transient profile produced while live-editing in the GUI + return + c = client.mapper.get_controller() if client.mapper else None + if c is None: + return + name = get_profile_name(filename) + config = Config() + cc = config.get_controller_config(c.get_id()) + if cc.get("profile") != name: + cc["profile"] = name + config.save() + def _send_to_all(self, message_str: bytes) -> None: """Sends message to all connect clients. @@ -316,7 +356,8 @@ def on_sa_restart(self, *a) -> None: with self.lock: for c in self.clients: c.close() - os.system(f"{sys.executable} {sys.argv[0]} None restart &") + subprocess.Popen([sys.executable, sys.argv[0], "None", "restart"], + start_new_session=True) def on_sa_led(self, mapper: Mapper, action: LedAction) -> None: """Called when 'led' action is used""" @@ -325,6 +366,25 @@ def on_sa_led(self, mapper: Mapper, action: LedAction) -> None: def on_sa_shell(self, mapper: Mapper, action: ShellCommandAction) -> Popen[bytes]: """Called when 'shell' action is used""" + cmd = shsplit(action.command) + # scc's own helpers (scc-osd-launcher, scc-osd-show-bindings, sc-controller + # ...) are Python entry points whose shebang and PATH can't be relied on in + # every environment - notably inside the AppImage, where a bare shell spawn + # silently fails (the same reason the daemon launches its other helpers via + # find_python() + find_binary()). Launch these the same way, bypassing the + # shebang; arbitrary user commands still go through the shell unchanged. + if cmd and (cmd[0].startswith("scc-") or cmd[0] == "sc-controller"): + args = cmd[1:] + # scc-osd-show-bindings renders, and locks input on, one specific + # controller. Without --controller it falls back to the first + # connected controller (OSDWindow.choose_controller), so with several + # controllers it shows the wrong one's bindings AND its cancel button + # is locked on that other controller, so the window can't be + # dismissed. Target the controller that actually invoked the action. + c = mapper.get_controller() + if c and cmd[0] == "scc-osd-show-bindings" and "--controller" not in args: + args = ["--controller", c.get_id(), *args] + return subprocess.Popen([sys.executable, find_binary(cmd[0]), *args]) return subprocess.Popen(action.command, shell=True) def on_sa_gestures(self, mapper: Mapper, action: GesturesAction, x, y, what: str) -> None: @@ -411,8 +471,9 @@ def on_sa_keyboard(self, mapper: Mapper, action: KeyboardAction) -> None: def on_sa_menu(self, mapper: Mapper, action: MenuAction, *pars) -> None: """Called when 'menu' action is used""" p = [action.MENU_TYPE] - if mapper.get_controller(): - p += ["--controller", mapper.get_controller().get_id()] + c = mapper.get_controller() + if c: + p += ["--controller", c.get_id(), "--controller-type", c.get_type()] if "." in action.menu_id: path = find_menu(action.menu_id) if not path: @@ -591,9 +652,27 @@ def add_controller(self, c: Controller) -> None: else: # New controller, but no mapper created mapper = self.init_mapper() - self.load_default_profile(mapper) mapper.set_controller(c) c.set_mapper(mapper) + + # Load this controller's remembered profile if it has a valid one, + # otherwise fall back to the global default. Done explicitly (rather + # than relying on the mapper's existing profile) so a reused/pooled + # mapper never carries over the profile of a previously-bound + # controller. With a single controller and no remembered profile this + # is exactly the old behavior (the global default is loaded). + remembered = Config().get_controller_config(c.get_id()).get("profile") + path = find_profile(remembered) if remembered else None + if path: + try: + mapper.profile.load(path).compress() + log.debug("Loaded remembered profile '%s' for %s", remembered, c.get_id()) + except Exception as e: + log.warning("Failed to load remembered profile '%s' for %s: %s", remembered, c.get_id(), e) + self.load_default_profile(mapper) + else: + self.load_default_profile(mapper) + if mapper == self.default_mapper: log.debug("Assigned default_mapper to %s", c) if mapper.profile.gyro: @@ -724,11 +803,15 @@ class SSHandler(StreamRequestHandler): def handle(self): instance._sshandler(self.connection, self.rfile, self.wfile) - self.sserver = ThreadingUnixStreamServer(self.socket_file, SSHandler) + old_umask = os.umask(0o077) + try: + self.sserver = ThreadingUnixStreamServer(self.socket_file, SSHandler) + finally: + os.umask(old_umask) + os.chmod(self.socket_file, stat.S_IRUSR | stat.S_IWUSR) t = threading.Thread(target=self.sserver.serve_forever) t.daemon = True t.start() - os.chmod(self.socket_file, stat.S_IRUSR | stat.S_IWUSR) log.debug("Created control socket %s", self.socket_file) def _start_gesture( @@ -804,7 +887,23 @@ def _handle_message(self, client: Client, message: bytes) -> None: with self.lock: try: filename = message[8:].decode("utf-8").strip("\t ") - self._set_profile(client.mapper, filename) + if "/" in filename: + # abspath, not realpath: this has to reject '..' while + # still accepting a profile that is a symlink to + # somewhere else, which is how people keep them in a + # dotfiles repo. abspath collapses '..' lexically, so + # traversal cannot escape; symlinks are left alone. + path = os.path.abspath(filename) + allowed = (os.path.abspath(get_profiles_path()), + os.path.abspath(get_default_profiles_path())) + if not any(path.startswith(d + os.sep) for d in allowed): + raise ValueError("Profile path outside allowed directories") + else: + path = find_profile(filename) + if not path: + raise ValueError("Profile '%s' not found" % filename) + self._set_profile(client.mapper, path) + self._remember_controller_profile(client, filename) log.info("Loaded profile '%s'", filename) client.wfile.write(b"OK.\n") except Exception as e: @@ -879,7 +978,11 @@ def _handle_message(self, client: Client, message: bytes) -> None: elif message.startswith(b"Replace:"): try: source, actionstr = message.split(b":", 1)[1].strip(b" \t\r").split(b" ", 1) - action = TalkingActionParser().restart(actionstr.decode()).parse().compress() + parsed = SafeTalkingActionParser().restart(actionstr.decode()).parse() + if parsed is None: + client.wfile.write(b"Fail: action rejected (blocked or parse error)\n") + return + action = parsed.compress() except Exception as e: e = str(e).encode("utf-8").decode("unicode_escape").encode("latin1") client.wfile.write(b"Fail: failed to parse: " + e + b"\n") @@ -1013,16 +1116,25 @@ def release(mapper): if menu_id in (None, "None"): menuaction = self.osd_ids[item_id] elif "." in menu_id: - # TODO: Move this common place - with open(menu_id) as file: - data = json.loads(file.read()) + if "/" in menu_id: + path = os.path.abspath(menu_id) # see the Profile: handler + allowed = (os.path.abspath(get_menus_path()), + os.path.abspath(get_default_menus_path())) + if not any(path.startswith(d + os.sep) for d in allowed): + raise KeyError("Menu path outside allowed directories") + else: + path = find_menu(menu_id) + if not path: + raise KeyError("Menu '%s' not found" % menu_id) + with open(path) as f: + data = json.loads(f.read()) menudata = MenuData.from_json_data(data, TalkingActionParser()) menuaction = menudata.get_by_id(item_id).action else: menuaction = client.mapper.profile.menus[menu_id].get_by_id(item_id).action client.wfile.write(b"OK.\n") - except Exception: - log.warning("Selected menu item is no longer valid.") + except Exception as e: + log.warning("Selected menu item is no longer valid: %s", e) client.wfile.write(b"Fail: Selected menu item is no longer valid\n") if menuaction: client.mapper.schedule(0, press) @@ -1324,6 +1436,16 @@ class LockedAction(ReportingAction): def __init__(self, what, client: Client, original_action) -> None: ReportingAction.__init__(self, what, client) self.original_action = original_action + # If the button being locked is currently held, the original action's + # press already ran (a key it bound may be DOWN). Its release will now be + # captured by this lock instead of the original action, leaving the key + # stuck down (it has locked people's keyboards). Release the original + # action here so the matching key-UP is sent. + if what in SCButtons.__members__.values() and self.mapper and (self.mapper.buttons & what): + try: + original_action.button_release(self.mapper) + except Exception as e: + log.warning("Failed to release held action while locking %s: %s", what, e) original_action.cancel(self.mapper) self._store_lock() log.debug("%s locked by %s", nameof(self.what), self.client) @@ -1350,6 +1472,11 @@ def __init__(self, what, client: Client, new_action, original_action) -> None: ReportingAction.__init__(self, what, client) self.original_action = original_action self.new_action = new_action.compress() + if what in SCButtons.__members__.values() and self.mapper and (self.mapper.buttons & what): + try: + original_action.button_release(self.mapper) + except Exception as e: + log.warning("Failed to release held action while replacing %s: %s", what, e) original_action.cancel(self.mapper) self._store_lock() log.debug("%s replaced by %s", nameof(self.what), self.client) @@ -1361,10 +1488,10 @@ def trigger(self, mapper: Mapper, position, old_position) -> None: self.new_action.trigger(mapper, position, old_position) def button_press(self, mapper: Mapper, number=1) -> None: - self.new_action.button_press(mapper, mapper) + self.new_action.button_press(mapper) def button_release(self, mapper: Mapper) -> None: - self.new_action.button_release(mapper, mapper) + self.new_action.button_release(mapper) def whole(self, mapper: Mapper, x, y, what) -> None: self.new_action.whole(mapper, x, y, what) @@ -1432,6 +1559,7 @@ def __init__(self, binary_name: str, debug: bool, restart_after: int = 5) -> Non if debug: self.args.append("debug") self._killed: bool = False + self._lock = threading.Lock() self.p = None self.t: Thread = threading.Thread(target=self._threaded) self.t.daemon = True @@ -1439,13 +1567,19 @@ def __init__(self, binary_name: str, debug: bool, restart_after: int = 5) -> Non def _threaded(self, *a) -> None: while not self._killed: - self.p = subprocess.Popen(self.args, stdin=None) - self.p.communicate() - if self.p and self.p.returncode == 8: - log.warning("%s exited with code 8; not restarting", self.binary_name) + with self._lock: + if self._killed: + return + self.p = subprocess.Popen(self.args, stdin=None) + p = self.p + p.communicate() + with self._lock: + p = self.p + if p and p.returncode == 8: + log.warning("%s exited with code 8; not restarting", self.binary_name) + self.p = None + return self.p = None - return - self.p = None if not self._killed: log.warning("%s died; restarting after %ss", self.binary_name, self.restart_after) time.sleep(self.restart_after) @@ -1456,6 +1590,7 @@ def mark_killed(self) -> None: def kill(self) -> None: self.mark_killed() - if self.p: - self.p.kill() - self.p = None + with self._lock: + if self.p: + self.p.kill() + self.p = None diff --git a/scc/scheduler.py b/scc/scheduler.py index 69c8ec506..99b1442bc 100644 --- a/scc/scheduler.py +++ b/scc/scheduler.py @@ -1,8 +1,8 @@ """SC Controller - Scheduler Centralized scheduler that should be used everywhere. -Runs in SCCDaemon's (single-threaded) mainloop. That means all callbacks are -also called on main thread. +Callbacks are executed on the main thread via run(). The schedule() +method may be called from any thread (e.g. socket handler threads). Use schedule(delay, callback, *data) to register one-time task. """ @@ -10,6 +10,7 @@ import logging import queue +import threading import time log = logging.getLogger("Scheduler") @@ -19,9 +20,11 @@ class Scheduler: def __init__(self) -> None: + self._lock = threading.Lock() self._scheduled = queue.PriorityQueue() self._next = None self._now: float = time.time() + self._seq = 0 def schedule(self, delay, callback, *data) -> Task: """Schedules one-time task to be executed no sooner than after 'delay' of seconds. @@ -30,42 +33,47 @@ def schedule(self, delay, callback, *data) -> Task: Returned Task instance can be used to cancel task once scheduled. """ - task = Task(self._now + delay, callback, data) - if self._next is None or task.time < self._next.time: - if self._next: - self._scheduled.put(self._next) - self._next = task - else: - self._scheduled.put(task) - return task + with self._lock: + task = Task(self._now + delay, self._seq, callback, data) + self._seq += 1 + if self._next is None or task.time < self._next.time: + if self._next: + self._scheduled.put(self._next) + self._next = task + else: + self._scheduled.put(task) + return task def cancel_task(self, task: Task) -> bool: """Returns True if task was sucessfully removed or False if task was already executed or not known at all. - Note that this is slow as hell and completly thread-unsafe, - so it _has_ to be called on main thread. + Note that this is slow as hell, so it _has_ to be called on + main thread. """ - if task == self._next: - self._next = None if self._scheduled.empty() else self._scheduled.get() - return True - # Fun part: All tasks are removed from PriorityQueue - # until correct is found. Then everything is put back - tasks, found = [], False - while not self._scheduled.empty(): - t = self._scheduled.get() - if t == task: - found = True - break - tasks.append(t) - for t in tasks: - self._scheduled.put(t) - return found + with self._lock: + if task == self._next: + self._next = None if self._scheduled.empty() else self._scheduled.get() + return True + tasks, found = [], False + while not self._scheduled.empty(): + t = self._scheduled.get() + if t == task: + found = True + break + tasks.append(t) + for t in tasks: + self._scheduled.put(t) + return found def run(self) -> None: self._now = time.time() - while self._next and self._now >= self._next.time: - callback, data = self._next.callback, self._next.data - self._next = None if self._scheduled.empty() else self._scheduled.get() + while True: + with self._lock: + if not (self._next and self._now >= self._next.time): + break + callback, data = self._next.callback, self._next.data + self._next = (None if self._scheduled.empty() + else self._scheduled.get()) callback(*data) def get_poll_timeout(self, maximum: float = 0.01) -> float: @@ -76,8 +84,9 @@ def get_poll_timeout(self, maximum: float = 0.01) -> float: class Task: - def __init__(self, time, callback, data) -> None: + def __init__(self, time, seq, callback, data) -> None: self.time = time + self.seq = seq self.callback = callback self.data = data @@ -87,4 +96,6 @@ def cancel(self) -> None: self.data = () def __lt__(self, other) -> None: - self.time < other.time + if self.time != other.time: + return self.time < other.time + return self.seq < other.seq diff --git a/scc/tools.py b/scc/tools.py index 394112f32..124ceb66f 100644 --- a/scc/tools.py +++ b/scc/tools.py @@ -259,6 +259,11 @@ def find_profile(name: str) -> str | None: Returns None if profile cannot be found. """ + # A plain filename and nothing else. basename() alone lets '..' through, + # since basename('..') == '..', and for menus that resolves to the + # directory itself. + if name in (os.curdir, os.pardir) or os.path.basename(name) != name: + return None filename = f"{name}.sccprofile" for p in (get_profiles_path(), get_default_profiles_path()): path = os.path.join(p, filename) @@ -341,6 +346,11 @@ def find_menu(name: str) -> str | None: Returns None if menu cannot be found. """ + # A plain filename and nothing else. basename() alone lets '..' through, + # since basename('..') == '..', and for menus that resolves to the + # directory itself. + if name in (os.curdir, os.pardir) or os.path.basename(name) != name: + return None for p in (get_menus_path(), get_default_menus_path()): path = os.path.join(p, name) if os.path.exists(path): diff --git a/scc/uinput.c b/scc/uinput.c index 51f35b031..ccfb79b8f 100644 --- a/scc/uinput.c +++ b/scc/uinput.c @@ -47,6 +47,12 @@ struct feedback_effect { int32_t repetitions; uint16_t type; int16_t level; + // FF_RUMBLE's two magnitudes, kept unmerged. 'level' above averages them + // for drivers that can only do one intensity (the v1 emulates rumble as a + // click train); hardware with real motors wants them apart, or the heavy + // and light effects feel identical. + uint16_t strong; + uint16_t weak; }; int uinput_init( @@ -292,6 +298,11 @@ int uinput_ff_read(int fd, int ff_effects_max, struct feedback_effect** ff_effec ff_effects[eid]->repetitions = 0; ff_effects[eid]->type = upload.effect.type; ff_effects[eid]->level = 0x4FFF; + // Only FF_RUMBLE fills these; default them so a slot + // reused from a previous rumble cannot leak its + // magnitudes into an effect of another type. + ff_effects[eid]->strong = 0; + ff_effects[eid]->weak = 0; // This part converts all possible event types to one that // SCC and controller really supports. Only output level is used. switch (upload.effect.type) { @@ -324,6 +335,8 @@ int uinput_ff_read(int fd, int ff_effects_max, struct feedback_effect** ff_effec avg = upload.effect.u.rumble.strong_magnitude / 3 + upload.effect.u.rumble.weak_magnitude / 6; ff_effects[eid]->level = (int32_t)MIN(avg, 0x7FFF); + ff_effects[eid]->strong = upload.effect.u.rumble.strong_magnitude; + ff_effects[eid]->weak = upload.effect.u.rumble.weak_magnitude; if (ff_effects[eid]->continuous_rumble) { // See comment in case EV_FF: block ff_effects[eid]->duration = INFINITE_RUMBLE; diff --git a/scc/uinput.py b/scc/uinput.py index a33882615..76fcf6dd2 100644 --- a/scc/uinput.py +++ b/scc/uinput.py @@ -218,6 +218,8 @@ class FeedbackEvent(ctypes.Structure): ("repetitions", c_int32), ("type", c_uint16), ("level", c_int16), + ("strong", c_uint16), # FF_RUMBLE magnitudes, unmerged; see uinput.c + ("weak", c_uint16), ] def __init__(self): diff --git a/setup.py b/setup.py index ca0c2380d..1dd318642 100755 --- a/setup.py +++ b/setup.py @@ -33,6 +33,13 @@ ] + [ # menu icons subfolders ("share/scc/images/menu-icons/" + x.split("/")[-1], [x + "/LICENSES"] + glob.glob(x + "/*.png")) for x in glob.glob("images/menu-icons/*") +] + [ # per-controller icon override dirs: images//*.svg (e.g. sc2, deck) + ("share/scc/images/" + d.split("/")[-1], glob.glob(d + "/*.svg")) + for d in (p.rstrip("/") for p in glob.glob("images/*/")) + if d.split("/")[-1] not in ( + "button-images", "controller-icons", "controller-images", + "24x24", "256x256", "menu-icons") + and glob.glob(d + "/*.svg") ] + [ (f"share/locale/{path.parent.parent.name}/LC_MESSAGES", [str(path)]) for path in Path("scc/locale").glob("*/LC_MESSAGES/*.mo") diff --git a/tests/test_action_editor.py b/tests/test_action_editor.py new file mode 100644 index 000000000..561d2597d --- /dev/null +++ b/tests/test_action_editor.py @@ -0,0 +1,194 @@ +"""Action editor smoke tests. + +These construct the real ActionEditor against a stub app. They exist because +the GUI is the one part of this codebase the rest of the suite cannot reach -- +importing it loads Gtk, so it was left untested, and two bugs shipped that +opening the editor would have caught immediately: a missing class attribute +that made it fail to construct at all, and effect settings being dropped +whenever anything else rebuilt the editor from its state. + +Skipped without a display. Run them under `xvfb-run -a python -m pytest` to +get the coverage on a headless machine. +""" +import os + +import pytest + +pytestmark = pytest.mark.skipif( + not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"), + reason="needs a display; try xvfb-run", +) + + +@pytest.fixture +def editor(): + import gi + + gi.require_version("Gtk", "4.0") + gi.require_version("Gdk", "4.0") + import scc.actions # noqa: F401 (import order: the GUI needs it first) + from scc.gui.action_editor import ActionEditor + from scc.paths import get_share_path + from scc.profile import Profile + + class FakeController: + def get_type(self): + return "sc2" + + class FakeSwitcher: + def get_controller(self): + return FakeController() + + class FakeApp: + gladepath = os.path.join(os.getcwd(), "ui") + imagepath = os.path.join(get_share_path(), "images") + profile_switchers = (FakeSwitcher(),) + + def __getattr__(self, name): + return lambda *a, **kw: None + + ae = ActionEditor(FakeApp(), lambda *a: None) + ae.set_input(Profile.RPAD, scc.actions.MouseAction()) + yield ae + ae.window.destroy() + + +def test_constructs(editor): + """It used to raise AttributeError during setup_widgets.""" + assert editor.window is not None + + +def test_effect_rows_follow_the_chosen_effect(editor): + from scc.modifiers import HAPTIC_EFFECT_MODIFIERS + + for i, cls in enumerate(HAPTIC_EFFECT_MODIFIERS): + editor._cbFeedbackEffect.set_active(i) + shown = [k for k, (trash, trash2, field, trash3) + in editor.feedback_effect_rows.items() if field.get_visible()] + assert shown == list(cls.PARAMS), ( + "%s shows rows %s but declares %s" % (cls.LABEL, shown, cls.PARAMS)) + assert editor.get_feedback_effect() == cls.EFFECT + + # Presenting the GTK4 window must preserve each row's visibility. + editor.window.present() + still_shown = [k for k, (trash, trash2, field, trash3) + in editor.feedback_effect_rows.items() if field.get_visible()] + assert still_shown == list(cls.PARAMS), ( + "%s revived rows %s after present" % (cls.LABEL, still_shown)) + + +def test_effect_survives_a_rebuild(editor): + """update_modifiers() rebuilds the action from tracked state. Anything it + does not track is silently lost the next time some other change fires it. + """ + from scc.actions import MouseAction + from scc.constants import HapticEffect + from scc.modifiers import HAPTIC_EFFECT_MODIFIERS + + editor.builder.get_object("cbFeedback").set_active(True) + for i, cls in enumerate(HAPTIC_EFFECT_MODIFIERS): + if cls.EFFECT == HapticEffect.CLICK: + continue + editor._cbFeedbackEffect.set_active(i) + for n, key in enumerate(cls.PARAMS): + # via the accessor: some rows are dropdowns, not scales + editor._set_row_value(key, 100 + n * 37) + editor.update_modifiers() + before = editor.generate_modifiers(MouseAction()).to_string() + editor.update_modifiers() # a rebuild triggered by anything else + after = editor.generate_modifiers(MouseAction()).to_string() + assert before == after, "%s lost its settings on rebuild" % (cls.LABEL,) + assert cls.COMMAND in after, "%s did not survive as %s" % (cls.LABEL, cls.COMMAND) + + +def test_opening_the_editor_keeps_the_feedback(editor): + """Open a saved binding the way the GUI does, and get back what was saved. + + set_input() is the real entry point, and it is the one that broke: writing + the effect widgets while load_modifiers was still parsing fired their + 'changed' handlers, and update_modifiers rebuilt the action from an editor + that had not applied the feedback yet -- so the dialog came up with no + action at all and feedback switched off. + """ + from scc.actions import MouseAction + from scc.constants import HapticPos + from scc.modifiers import FeedbackToneModifier + from scc.profile import Profile + + action = FeedbackToneModifier(HapticPos.RIGHT, 512, 220, 300, 4, 128, MouseAction()) + editor.set_input(Profile.RPAD, action) + + assert editor.builder.get_object("entAction").get_text() == action.to_string() + assert editor.builder.get_object("cbFeedback").get_active() + assert editor.feedback_position == HapticPos.RIGHT + assert editor.generate_modifiers(editor._action).to_string() == action.to_string() + + +def test_loading_an_effect_restores_the_widgets(editor): + from scc.actions import MouseAction + from scc.constants import HapticEffect, HapticPos + from scc.modifiers import FeedbackToneModifier + from scc.profile import Profile + + editor.set_input(Profile.RPAD, + FeedbackToneModifier(HapticPos.RIGHT, 512, 220, 300, 4, 128, MouseAction())) + + assert editor.get_feedback_effect() == HapticEffect.TONE + assert editor.feedback_effect == HapticEffect.TONE + assert editor._row_value("tone_frequency") == 220 + assert editor._row_value("duration") == 300 + assert editor.feedback_params["lfo_depth"] == 128 + + +def test_preset_row_is_a_named_dropdown(editor): + """The firmware presets are an enumeration, not a magnitude, so they get + names rather than a 0-255 slider. + """ + import gi + + gi.require_version("Gtk", "4.0") + from gi.repository import Gtk + + from scc.modifiers import HAPTIC_SCRIPTS + + trash, widget, trash2, trash3 = editor.feedback_effect_rows["script_id"] + assert isinstance(widget, Gtk.ComboBoxText) + for value, name in HAPTIC_SCRIPTS: + editor._set_row_value("script_id", value) + assert editor._row_value("script_id") == value + + # an id the firmware may have that our list does not must survive, not be + # silently rewritten to something else + editor._set_row_value("script_id", 0x42) + assert editor._row_value("script_id") == 0x42 + + +def test_numeric_rows_pair_a_slider_with_a_spin_button(editor): + """A slider alone cannot hit 220 Hz on a 20-1000 range: that is ~5 Hz per + pixel. The spin button is how you land on a value exactly, and it shares the + slider's adjustment so the two can never disagree. + """ + import gi + + gi.require_version("Gtk", "4.0") + from gi.repository import Gtk + + trash, scale, field, trash2 = editor.feedback_effect_rows["tone_frequency"] + spins = [] + child = field.get_first_child() + while child is not None: + if isinstance(child, Gtk.SpinButton): + spins.append(child) + child = child.get_next_sibling() + assert len(spins) == 1 + assert spins[0].get_adjustment() is scale.get_adjustment() + + spins[0].set_value(223) + assert editor._row_value("tone_frequency") == 223 + editor._set_row_value("tone_frequency", 447) + assert spins[0].get_value_as_int() == 447 + + # arrow keys and the spin button step by one; only Page Up/Down jumps + adj = scale.get_adjustment() + assert adj.get_step_increment() == 1 + assert adj.get_page_increment() > 1 diff --git a/tests/test_app_startup.py b/tests/test_app_startup.py index 3ff790c32..3958caa47 100644 --- a/tests/test_app_startup.py +++ b/tests/test_app_startup.py @@ -14,9 +14,9 @@ @pytest.fixture def app(monkeypatch): app = SimpleNamespace( - _activated=False, _startup_tray_timeout=None, osd_mode=False, + _activated=False, _startup_tray_timeout=None, osk_edit_mode=False, config={"gui": {"minimize_on_start": True}}, - window=Mock(), statusicon=Mock(), + window=Mock(), statusicon=Mock(), open_osk_editor=Mock(), ) app.statusicon.get_property.return_value = False for name in ("do_activate", "_cancel_startup_tray_wait", "on_startup_tray_active", @@ -52,19 +52,29 @@ def test_missing_tray_shows_fallback_window(app): assert app._startup_tray_timeout is None -@pytest.mark.parametrize("mode", ["disabled", "no_icon", "osd"]) +@pytest.mark.parametrize("mode", ["disabled", "no_icon"]) def test_startup_without_minimizing(app, mode): if mode == "disabled": app.config["gui"]["minimize_on_start"] = False elif mode == "no_icon": app.statusicon = None - else: - app.osd_mode = True app.do_activate() app.window.present.assert_called_once() assert app._startup_tray_timeout is None +def test_osd_mode_opens_only_keyboard_editor(app): + app.osk_edit_mode = True + app.do_activate() + app.open_osk_editor.assert_called_once() + app.window.present.assert_not_called() + app.window.set_visible.assert_not_called() + assert app._startup_tray_timeout is None + app._osk_editor = object() + app.do_activate() + app.open_osk_editor.assert_called_once() + + def test_reactivation_cancels_pending_startup_minimize(app): app.do_activate() app.do_activate() diff --git a/tests/test_button_map.py b/tests/test_button_map.py new file mode 100644 index 000000000..633d6df9f --- /dev/null +++ b/tests/test_button_map.py @@ -0,0 +1,72 @@ +"""What an unmapped HID input bit maps to. + +The decoder turns each entry into `1 << button_map[i]`, so every value in +0..BUTTON_COUNT-1 names a real button. The "maps to nothing" sentinel was +BUTTON_COUNT - 1 -- bit 31, which is RSTICKPRESS -- so on a JSON-configured +controller every input bit the config did not name pressed the right stick. + +It only became fixable once the C guard rejected 32 rather than 33: before +that, an out-of-range sentinel would have been shifted into a uint32_t by 32, +which is undefined behaviour rather than a no-op. +""" +import pytest + +try: + # OSError, not ImportError, when libhiddrv is unbuilt. + from scc.drivers import hiddrv +except (ImportError, OSError) as e: + pytest.skip("hiddrv unavailable: %s" % (e,), allow_module_level=True) + +from scc.constants import SCButtons + + +def test_the_sentinel_is_not_a_real_button(): + """The whole bug in one line: it has to be outside the valid range.""" + assert hiddrv.BUTTON_UNMAPPED >= hiddrv.BUTTON_COUNT, ( + "%r names bit %d, which is a button" + % (hiddrv.BUTTON_UNMAPPED, hiddrv.BUTTON_UNMAPPED)) + + +def test_bit_31_is_rstickpress_and_so_cannot_be_the_sentinel(): + """Pins why BUTTON_COUNT - 1 was the wrong choice, so that reverting to it + fails here rather than on someone's controller. + """ + assert int(SCButtons.RSTICKPRESS).bit_length() - 1 == hiddrv.BUTTON_COUNT - 1 + assert hiddrv.BUTTON_UNMAPPED != hiddrv.BUTTON_COUNT - 1 + + +def test_an_empty_config_maps_nothing_to_a_button(): + config = {"buttons": {}} + m = hiddrv.USBHIDController._build_button_map(hiddrv.USBHIDController, config) + assert set(m) == {hiddrv.BUTTON_UNMAPPED} + + +def test_named_buttons_survive_and_the_rest_stay_unmapped(): + first = hiddrv.FIRST_BUTTON + config = {"buttons": {str(first): "A", str(first + 1): "RSTICKPRESS"}} + m = hiddrv.USBHIDController._build_button_map(hiddrv.USBHIDController, config) + + assert m[0] == int(SCButtons.A).bit_length() - 1 + assert m[1] == int(SCButtons.RSTICKPRESS).bit_length() - 1 + assert set(m[2:]) == {hiddrv.BUTTON_UNMAPPED}, ( + "unnamed bits map to a real button again") + + +def test_rstickpress_is_reachable_only_when_asked_for(): + """The regression this guards: with the old sentinel, a config naming no + buttons at all still produced a map full of RSTICKPRESS's bit. + """ + rstick_bit = int(SCButtons.RSTICKPRESS).bit_length() - 1 + m = hiddrv.USBHIDController._build_button_map(hiddrv.USBHIDController, {"buttons": {}}) + assert rstick_bit not in set(m) + + +def test_the_sentinel_fits_the_c_field(): + """button_map is a uint8 array; anything wider is silently truncated into + a valid bit position. + """ + assert 0 <= hiddrv.BUTTON_UNMAPPED <= 255 + + +def test_an_unknown_mask_is_reported_as_unmapped(): + assert hiddrv.button_to_bit(0) == hiddrv.BUTTON_UNMAPPED diff --git a/tests/test_config_durability.py b/tests/test_config_durability.py new file mode 100644 index 000000000..1ed16f7e9 --- /dev/null +++ b/tests/test_config_durability.py @@ -0,0 +1,105 @@ +"""Configuration must survive a bad save and a bad parse. + +Two faults compounded here. save() wrote in place, so anything that killed the +process mid-write left truncated JSON; and reload() answered any parse failure +by calling create(), which overwrites the file with defaults. Together they +turned one interrupted write into "every setting the user ever chose is gone", +with no copy kept. It happened twice on the maintainer's machine before anyone +worked out why. +""" +import json +import os + +import pytest + +from scc.config import Config + + +@pytest.fixture +def cfg(tmp_path, monkeypatch): + """A Config rooted in a temp dir, so nothing here touches ~/.config/scc.""" + monkeypatch.setattr("scc.config.get_config_path", lambda: str(tmp_path)) + c = Config() + c.filename = os.path.join(str(tmp_path), "config.json") + return c + + +def test_a_saved_config_reads_back(cfg): + cfg.set("recent_max", 7) + cfg.save() + fresh = Config() + fresh.filename = cfg.filename + fresh.load() + assert fresh["recent_max"] == 7 + + +def test_save_leaves_no_temp_files_behind(cfg): + cfg.save() + leftovers = [f for f in os.listdir(os.path.dirname(cfg.filename)) if ".tmp." in f] + assert leftovers == [], "temp files accumulate in the config directory: %r" % leftovers + + +def test_the_file_is_never_seen_half_written(cfg, monkeypatch): + """The window that caused this: a reader arriving mid-save must see either + the old contents or the new ones, never a truncated file. + """ + cfg.set("recent_max", 1) + cfg.save() + seen = [] + + real_replace = os.replace + + def watched_replace(src, dst): + # whatever a reader would find at the moment before the swap + with open(dst) as f: + seen.append(f.read()) + return real_replace(src, dst) + + monkeypatch.setattr(os, "replace", watched_replace) + cfg.set("recent_max", 2) + cfg.save() + + assert seen, "save() did not go through os.replace; it is not atomic" + assert json.loads(seen[0])["recent_max"] == 1, "the old file was modified in place" + + +def test_an_unreadable_config_is_kept_not_deleted(cfg): + """The part that actually lost data.""" + with open(cfg.filename, "w") as f: + f.write('{"recent_max": 7, "gui": {') # truncated, as an interrupted write leaves it + cfg.reload() + + backup = cfg.filename + ".broken.1" + assert os.path.exists(backup), "the unreadable config was discarded" + with open(backup) as f: + assert f.read() == '{"recent_max": 7, "gui": {' + assert os.path.exists(cfg.filename), "no usable config was created" + json.loads(open(cfg.filename).read()) # defaults, and valid + + +def test_a_second_failure_does_not_overwrite_the_first_backup(cfg): + """The first backup holds the real settings; a later, emptier failure must + not clobber it. + """ + with open(cfg.filename, "w") as f: + f.write("the original settings") + cfg.reload() + with open(cfg.filename, "w") as f: + f.write("garbage from a later run") + cfg.reload() + + with open(cfg.filename + ".broken.1") as f: + assert f.read() == "the original settings" + assert os.path.exists(cfg.filename + ".broken.2") + + +def test_a_missing_config_is_just_a_first_run(cfg): + """No file is not an error and must not leave a .broken behind. + + (Config.__init__ already wrote one, so remove it to get back to the + genuine first-run state.) + """ + os.unlink(cfg.filename) + cfg.reload() + assert os.path.exists(cfg.filename) + assert not os.path.exists(cfg.filename + ".broken.1") diff --git a/tests/test_gyro.py b/tests/test_gyro.py new file mode 100644 index 000000000..8f3a66d6c --- /dev/null +++ b/tests/test_gyro.py @@ -0,0 +1,249 @@ +"""Runtime behaviour of the gyro actions and of the analog modeshift gate. + +Both are driven through a fake mapper: the real one needs a daemon, a +controller and uinput devices. +""" +import math + +from scc.actions import GyroAbsAction, GyroAction, RangeOP +from scc.constants import STICK_PAD_MAX, TRIGGER_MAX, ControllerFlags, SCButtons +from scc.parser import ActionParser +from scc.uinput import Axes + +# driver-side euler encoding: 2**15 / PI fixed point, see ControllerFlags.EUREL_GYROS +EUREL = 32768.0 / math.pi + + +class FakeGamepad: + def __init__(self): + self.events = {} + + def axisEvent(self, axis, value): + self.events.setdefault(axis, []).append(value) + + +class FakeController: + flags = ControllerFlags.EUREL_GYROS + + +class FakeState: + rtrig = 0 + + +class FakeMapper: + def __init__(self): + self.gamepad = FakeGamepad() + self.syn_list = set() + self.mouse_moves = [] + self.state = FakeState() + self._controller = FakeController() + + def get_controller(self): + return self._controller + + def mouse_move(self, dx, dy): + self.mouse_moves.append((dx, dy)) + + def send_feedback(self, *a): + pass + + +def sweep(action, mapper, to_degrees, steps=20): + """Rotates all three gyro axes from neutral to 'to_degrees'.""" + prev = None + for n in range(steps + 1): + a = math.radians(to_degrees * n / steps) + rates = (0, 0, 0) if prev is None else tuple([(a - prev) * 3000.0] * 3) + prev = a + q = int(a * EUREL) + action.gyro(mapper, rates[0], rates[1], rates[2], q, q, q, 0) + + +def peak(mapper, axis): + vals = mapper.gamepad.events[axis] + return max(vals, key=abs) + + +class TestGyroAxisRange: + """A gyro bound to a trigger must use the trigger's own 0..255 range. + + Feeding it a stick-range value and clamping (what it used to do) threw + away the negative half and saturated within a fraction of a degree, so + the trigger behaved like a button. + """ + + def test_absolute_trigger_is_proportional(self): + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_Z), m, 20) + # 20 of the 90 deg that deflect a stick fully -> ~22% of trigger travel + assert 0.15 * TRIGGER_MAX < peak(m, Axes.ABS_Z) < 0.30 * TRIGGER_MAX + + def test_absolute_trigger_reaches_full_pull(self): + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_Z), m, 90) + assert peak(m, Axes.ABS_Z) == TRIGGER_MAX + + def test_absolute_trigger_rests_released(self): + """A trigger has to sit at 0 when the controller is not rotated.""" + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_Z), m, 0) + assert set(m.gamepad.events[Axes.ABS_Z]) == {0} + + def test_relative_trigger_is_not_digital(self): + m = FakeMapper() + sweep(GyroAction(Axes.ABS_Z), m, -20) + assert 0 < peak(m, Axes.ABS_Z) < TRIGGER_MAX + + def test_stick_range_is_unchanged(self): + """The rescale must apply to triggers only.""" + m = FakeMapper() + sweep(GyroAbsAction(Axes.ABS_X), m, 90) + assert peak(m, Axes.ABS_X) == STICK_PAD_MAX + + +class TestGyroSensitivity: + """Per-axis sensitivity used to be read from index 2 for every axis and + then applied a second time per-axis, squaring it. + """ + + def test_applied_once(self): + plain, scaled = FakeMapper(), FakeMapper() + a = GyroAbsAction(Axes.ABS_X) + sweep(a, plain, 10) + b = GyroAbsAction(Axes.ABS_X) + b.set_speed(2.0, 2.0, 2.0) + sweep(b, scaled, 10) + assert peak(scaled, Axes.ABS_X) == 2 * peak(plain, Axes.ABS_X) + + def test_is_per_axis(self): + """Sensitivity of gyro axis 0 must not be taken from axis 2.""" + m = FakeMapper() + a = GyroAbsAction(Axes.ABS_X, Axes.ABS_Y, Axes.ABS_RX) + a.set_speed(1.0, 1.0, 3.0) + sweep(a, m, 10) + assert peak(m, Axes.ABS_RX) == 3 * peak(m, Axes.ABS_X) + + def test_moves_the_full_deflection_point(self): + """Sensitivity has to change the rotation needed to peg the axis, not + just scale a value that already saturated at 90 deg. + """ + m = FakeMapper() + a = GyroAbsAction(Axes.ABS_X) + a.set_speed(3.0, 3.0, 3.0) + sweep(a, m, 35) # 3x35 deg is past the 90 deg full-deflection point + assert peak(m, Axes.ABS_X) == STICK_PAD_MAX + + +class TestRangeOPHysteresis: + """`mode(RT >= 0.7, ...)` must not flip while the trigger is parked near + the threshold: every flip runs ModeModifier's switch path, which recenters + gyro references and releases held buttons. + """ + + def test_holds_through_jitter(self): + m = FakeMapper() + op = RangeOP(SCButtons.RT, ">=", 0.7) + m.state.rtrig = int(0.9 * TRIGGER_MAX) + assert op(m) + # dip just under the raw threshold, still inside the hysteresis band + m.state.rtrig = int((0.7 - RangeOP.HYSTERESIS / 2) * TRIGGER_MAX) + assert op(m) + + def test_still_releases(self): + m = FakeMapper() + op = RangeOP(SCButtons.RT, ">=", 0.7) + m.state.rtrig = int(0.9 * TRIGGER_MAX) + assert op(m) + m.state.rtrig = int((0.7 - 2 * RangeOP.HYSTERESIS) * TRIGGER_MAX) + assert not op(m) + + def test_needs_the_full_threshold_to_engage(self): + """Approaching from below, the band tightens rather than loosens.""" + m = FakeMapper() + op = RangeOP(SCButtons.RT, ">=", 0.7) + m.state.rtrig = int((0.7 + RangeOP.HYSTERESIS / 2) * TRIGGER_MAX) + assert not op(m) + m.state.rtrig = int((0.7 + 2 * RangeOP.HYSTERESIS) * TRIGGER_MAX) + assert op(m) + + def test_less_than_direction(self): + m = FakeMapper() + op = RangeOP(SCButtons.RT, "<", 0.3) + m.state.rtrig = 0 + assert op(m) + m.state.rtrig = int((0.3 + RangeOP.HYSTERESIS / 2) * TRIGGER_MAX) + assert op(m) + m.state.rtrig = int((0.3 + 2 * RangeOP.HYSTERESIS) * TRIGGER_MAX) + assert not op(m) + + def test_gated_absolute_gyro_survives_jitter(self): + """End to end: the reported symptom was an absolute gyro producing + nothing at all when gated behind a trigger held at 70%. + """ + action = ActionParser().restart("mode(RT >= 0.7, gyroabs(Axes.ABS_X), None)").parse().compress() + m = FakeMapper() + prev = None + for n in range(21): + m.state.rtrig = TRIGGER_MAX if n % 3 else int(0.69 * TRIGGER_MAX) + a = math.radians(20.0 * n / 20) + rate = 0 if prev is None else (a - prev) * 3000.0 + prev = a + q = int(a * EUREL) + action.gyro(m, rate, rate, rate, q, q, q, 0) + assert peak(m, Axes.ABS_X) > 0.15 * STICK_PAD_MAX + + +class TestFeedbackEffects: + """The action editor drives its effect chooser entirely off these classes. + A missing attribute on any one of them crashes the editor on construction, + as FeedbackModifier lacking PARAMS did -- so check every entry, not just + the ones that were interesting to write. + """ + + def test_every_effect_is_complete(self): + import scc.actions # noqa: F401 (import order: modifiers needs it first) + from scc.modifiers import HAPTIC_EFFECT_MODIFIERS + + assert HAPTIC_EFFECT_MODIFIERS + seen = set() + for cls in HAPTIC_EFFECT_MODIFIERS: + for attr in ("EFFECT", "PARAMS", "LABEL", "COMMAND"): + assert hasattr(cls, attr), "%s has no %s" % (cls.__name__, attr) + assert cls.EFFECT not in seen, "duplicate effect on %s" % (cls.__name__,) + seen.add(cls.EFFECT) + + def test_params_are_real_haptic_fields(self): + import scc.actions # noqa: F401 + from scc.constants import HapticPos + from scc.controller import HapticData + from scc.modifiers import HAPTIC_EFFECT_MODIFIERS + + h = HapticData(HapticPos.LEFT) + for cls in HAPTIC_EFFECT_MODIFIERS: + for name in cls.PARAMS: + assert hasattr(h, name), ( + "%s.PARAMS names %r, which HapticData does not carry" + % (cls.__name__, name)) + + +class TestFeedbackEffectSignatures: + """The action editor builds each effect modifier positionally from its + PARAMS. If PARAMS and the signature drift apart the GUI silently writes + values into the wrong parameter -- which it did once, feeding amplitude + into script_id. + """ + + def test_params_match_signatures(self): + import inspect + + from scc.modifiers import ( + FeedbackScriptModifier, + FeedbackSweepModifier, + FeedbackToneModifier, + ) + + for cls in (FeedbackToneModifier, FeedbackSweepModifier, FeedbackScriptModifier): + args = inspect.getfullargspec(cls._mod_init).args + assert args == ["self", "position", "amplitude", *cls.PARAMS], ( + "%s signature %s does not match PARAMS %s" + % (cls.__name__, args, cls.PARAMS)) diff --git a/tests/test_osd_navigation.py b/tests/test_osd_navigation.py new file mode 100644 index 000000000..26e27d62c --- /dev/null +++ b/tests/test_osd_navigation.py @@ -0,0 +1,137 @@ +"""Menu navigation pacing. + +StickController turns positions into repeated 'direction' signals. Menus accept +more than one input at once -- the selected input plus DPAD -- and they all +feed one controller. + +Sharing it naively breaks in a way that looks like two unrelated bugs: nudging +the stick scrolls far too fast, and holding it fully deflected stops the list +dead. Both come from the same place. _move() emits on every direction change and +cancels the repeat timer whenever the direction reaches zero, so an idle input +reporting (0, 0) cancels the repeat the stick is driving. Movement then happens +only on changes -- once per event, at the controller's report rate -- and stops +entirely as soon as the stick is held still and stops producing events. + +Needs Gtk for the GObject signal; run under xvfb-run on a headless machine. +""" +import os + +import pytest + +pytestmark = pytest.mark.skipif( + not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"), + reason="needs a display; try xvfb-run", +) + + +@pytest.fixture +def pacer(): + """A StickController with its timer calls recorded rather than run.""" + import gi + + gi.require_version("Gtk", "4.0") + from scc.osd import StickController + + sc = StickController() + sc.emits, sc.timers = [], [] + sc.connect("direction", lambda o, x, y: sc.emits.append((x, y))) + sc.timer = lambda name, delay, cb, *a: sc.timers.append("schedule") + sc.cancel_timer = lambda name: sc.timers.append("cancel") + return sc + + +def test_an_idle_input_does_not_cancel_another_that_is_held(pacer): + """The bug, stated directly: a d-pad sitting at rest must not stop the + repeat that a held stick is driving. + """ + from scc.constants import LEFT, LSTICK, STICK_PAD_MAX + + for trash in range(3): + pacer.set_stick(0, STICK_PAD_MAX, source=LSTICK) + pacer.set_stick(0, 0, source=LEFT) # d-pad, untouched + + assert pacer.emits == [(0, -1)], "moved once per event instead of once per interval" + assert pacer.timers == ["schedule"], "the repeat timer was cancelled by the idle input" + + +def test_releasing_everything_still_stops(pacer): + """The cancel has to survive: once nothing is deflected, the repeat ends.""" + from scc.constants import LEFT, LSTICK, STICK_PAD_MAX + + pacer.set_stick(0, STICK_PAD_MAX, source=LSTICK) + pacer.set_stick(0, 0, source=LEFT) + pacer.set_stick(0, 0, source=LSTICK) + + assert pacer.emits[-1] == (0, 0) + assert pacer.timers[-1] == "cancel" + + +@pytest.mark.parametrize("source_name", ["LSTICK", "LEFT", "DPAD"]) +def test_any_single_input_navigates(pacer, source_name): + """Whichever input a given controller delivers its d-pad on, it works: the + DS4 sends it as LEFT, the SC2 as DPAD. + """ + import scc.constants as c + + source = getattr(c, source_name) + pacer.set_stick(0, c.STICK_PAD_MAX, source=source) + assert pacer.emits == [(0, -1)] + assert pacer.timers == ["schedule"] + + +def test_the_deflected_input_wins_over_an_idle_one(pacer): + """Order must not matter -- the idle input may be reported after the held + one, which is precisely when it used to clobber it. + """ + from scc.constants import DPAD, LSTICK, STICK_PAD_MIN + + pacer.set_stick(0, 0, source=DPAD) + pacer.set_stick(0, STICK_PAD_MIN, source=LSTICK) + pacer.set_stick(0, 0, source=DPAD) + + assert pacer.emits == [(0, 1)], "a later idle report overrode the held input" + + +def test_callers_with_a_single_input_are_unaffected(pacer): + """launcher and dialog pass no source; they must behave exactly as before.""" + from scc.constants import STICK_PAD_MAX + + pacer.set_stick(0, STICK_PAD_MAX) + pacer.set_stick(0, STICK_PAD_MAX) + assert pacer.emits == [(0, -1)], "a repeated identical position re-fired" + pacer.set_stick(0, 0) + assert pacer.emits[-1] == (0, 0) + assert pacer.timers[-1] == "cancel" + + +def test_the_menu_tells_the_pacer_which_input_an_event_came_from(): + """The wiring, not just the pacer. + + StickController can only separate the inputs if the menu says which one an + event arrived on. Without this the fix above is inert -- every input shares + one entry again -- and nothing else in the suite would notice. + """ + from scc.constants import SCPads, SCSticks, STICK_PAD_MAX + from scc.osd.menu import Menu + + seen = [] + + class FakeScon: + def set_stick(self, x, y, source=None): + seen.append((x, y, source)) + + class FakeMenu: + _submenu = None + _use_cursor = False + _control_with = SCSticks.LSTICK + _control_with_dpad = True + _confirm_with = "A" + _cancel_with = "B" + _scon = FakeScon() + + m = FakeMenu() + Menu.on_event(m, None, SCSticks.LSTICK, (0, STICK_PAD_MAX)) + Menu.on_event(m, None, SCPads.DPAD, (0, 0)) + + assert seen == [(0, STICK_PAD_MAX, SCSticks.LSTICK), (0, 0, SCPads.DPAD)], ( + "the menu did not pass the source through: %r" % (seen,)) diff --git a/tests/test_parser/test_modifiers.py b/tests/test_parser/test_modifiers.py index 303ba8029..9664494e6 100644 --- a/tests/test_parser/test_modifiers.py +++ b/tests/test_parser/test_modifiers.py @@ -37,6 +37,13 @@ def test_released(self): a = _parse_compressed("pressed(axis(KEY_A))") assert isinstance(a, PressedModifier) + def test_inverted(self): + """Tests if InvertedButtonModifier is parsed + """ + a = _parse_compressed("inverted(button(KEY_A))") + assert isinstance(a, InvertedButtonModifier) + assert isinstance(a.action, ButtonAction) + def test_touched(self): """Tests if TouchedModifier is parsed""" a = _parse_compressed("touched(button(KEY_A))") @@ -203,6 +210,24 @@ def test_feedback(self): assert _parses_as_itself(FeedbackModifier(HapticPos.LEFT, MouseAction())) assert _parses_as_itself(FeedbackModifier(HapticPos.RIGHT, MouseAction())) + def test_feedbacktone(self): + """Tests if FeedbackToneModifier survives a to_string/parse round trip.""" + assert _parses_as_itself(FeedbackToneModifier(HapticPos.BOTH, MouseAction())) + assert _parses_as_itself(FeedbackToneModifier(HapticPos.LEFT, 512, 220, MouseAction())) + assert _parses_as_itself( + FeedbackToneModifier(HapticPos.RIGHT, 512, 220, 300, 4, 128, MouseAction())) + + def test_feedbacksweep(self): + """Tests if FeedbackSweepModifier survives a to_string/parse round trip.""" + assert _parses_as_itself(FeedbackSweepModifier(HapticPos.BOTH, MouseAction())) + assert _parses_as_itself( + FeedbackSweepModifier(HapticPos.LEFT, 512, 400, 80, 250, MouseAction())) + + def test_feedbackscript(self): + """Tests if FeedbackScriptModifier survives a to_string/parse round trip.""" + assert _parses_as_itself(FeedbackScriptModifier(HapticPos.BOTH, MouseAction())) + assert _parses_as_itself(FeedbackScriptModifier(HapticPos.RIGHT, 512, 3, MouseAction())) + def test_rotate(self): """Tests if RotateInputModifier can be converted to string and parsed back to same. diff --git a/tests/test_profile/test_modifiers.py b/tests/test_profile/test_modifiers.py index 81fa99383..0b34d958b 100644 --- a/tests/test_profile/test_modifiers.py +++ b/tests/test_profile/test_modifiers.py @@ -1,7 +1,7 @@ import inspect from scc.actions import AxisAction, GyroAction -from scc.constants import HapticPos, SCButtons +from scc.constants import HapticEffect, HapticPos, SCButtons from scc.modifiers import * from scc.uinput import Axes @@ -53,6 +53,13 @@ def test_released(self): assert isinstance(a, ReleasedModifier) assert _is_axis_with_value(a.action) + def test_inverted(self): + """Tests if InvertedButtonModifier is parsed correctly from json. + """ + a = parser.from_json_data({"action": "inverted(axis(ABS_X))"}) + assert isinstance(a, InvertedButtonModifier) + assert _is_axis_with_value(a.action) + def test_touched(self): """Tests if TouchedModifier is parsed correctly from json.""" a = parser.from_json_data({"action": "touched(button(KEY_A))"}) @@ -171,6 +178,33 @@ def test_feedback(self): assert a.haptic.get_period() == 2048 assert _is_axis_with_value(a.action) + def test_feedbacktone(self): + """Tests if FeedbackToneModifier is parsed correctly from json.""" + a = parser.from_json_data({"action": "axis(ABS_X)", "feedbacktone": ["RIGHT", 512, 220, 300, 4, 128]}) + assert isinstance(a, FeedbackToneModifier) + assert a.haptic.effect == HapticEffect.TONE + assert a.haptic.tone_frequency == 220 + assert a.haptic.duration == 300 + assert a.haptic.lfo_depth == 128 + assert _is_axis_with_value(a.action) + + def test_feedbacksweep(self): + """Tests if FeedbackSweepModifier is parsed correctly from json.""" + a = parser.from_json_data({"action": "axis(ABS_X)", "feedbacksweep": ["LEFT", 512, 400, 80, 250]}) + assert isinstance(a, FeedbackSweepModifier) + assert a.haptic.effect == HapticEffect.SWEEP + assert a.haptic.tone_frequency == 400 + assert a.haptic.end_frequency == 80 + assert _is_axis_with_value(a.action) + + def test_feedbackscript(self): + """Tests if FeedbackScriptModifier is parsed correctly from json.""" + a = parser.from_json_data({"action": "axis(ABS_X)", "feedbackscript": ["RIGHT", 512, 3]}) + assert isinstance(a, FeedbackScriptModifier) + assert a.haptic.effect == HapticEffect.SCRIPT + assert a.haptic.script_id == 3 + assert _is_axis_with_value(a.action) + def test_rotate(self): """Tests if RotateInputModifier is parsed correctly from json.""" a = parser.from_json_data({"action": "axis(ABS_X)", "rotate": 33.14}) diff --git a/tests/test_ui.py b/tests/test_ui.py index e9ec2a6c8..c13872dab 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -66,3 +66,27 @@ def test_every_toplevel_object_has_id(self) -> None: # assert result.returncode == 0 and not diagnostics, ( # f"GTK diagnostics failed for {filename}\n{diagnostics}" # ) + +def _adjustment(filename, id_): + """Returns a GtkAdjustment's properties as floats.""" + root = ET.parse(filename).getroot() + for obj in root.iter("object"): + if obj.get("class") == "GtkAdjustment" and obj.get("id") == id_: + return {p.get("name"): float(p.text) for p in obj.iter("property")} + raise AssertionError("no adjustment %s in %s" % (id_, filename)) + + +def test_haptic_strength_slider_reaches_the_amplitude_the_driver_accepts(): + """A GtkAdjustment's usable maximum is upper - page_size, and page_size means + nothing for a slider. Left at 128 it stopped the haptic Strength slider at + 32639, which reads to a user as an off-by-something rather than as a limit. + + Some adjustments do use page-size deliberately (global_settings sets + upper 10.01 / page-size 0.01 to land on a round 10.00), so this pins the one + value that has to agree with the driver rather than banning the property. + """ + from scc.drivers.sc2 import HAPTIC_AMPLITUDE_MAX, HAPTIC_AMPLITUDE_MIN + + adj = _adjustment("ui/action_editor.ui", "adjFAmplitude") + assert adj["lower"] == HAPTIC_AMPLITUDE_MIN + assert adj["upper"] - adj.get("page-size", 0) == HAPTIC_AMPLITUDE_MAX diff --git a/tools/gen_binding_display.py b/tools/gen_binding_display.py new file mode 100644 index 000000000..3ebffe9d5 --- /dev/null +++ b/tools/gen_binding_display.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Generate the per-controller binding-display templates from controller art. + +The OSD "Display Current Bindings" window (scc/osd/binding_display.py) draws each +control's binding into boxes laid out around a controller picture, using a +per-controller template SVG. This tool builds those templates -- one per entry +in CONTROLLERS -- straight from the existing controller drawings +(images/controller-images/.svg), so there is no hand-drawn per-controller +art to maintain: change a controller image and rerun this. + +For each controller it emits images/binding-display/.svg, containing all the +elements Generator needs: + - a 1280x720 canvas with `background` (sizes the layout + dark backdrop), + `label_template` (label font/metrics) and `root` (boxes are drawn into it); + - the controller drawing, scaled + centred into the canvas and recoloured into + the "Matrix" binding-display palette (green outlines + two greys) so it reads + as a subdued filled silhouette behind the bright binding boxes/labels; + - the six `markers_` groups (system/lshoulder/rshoulder/lthumb/rthumb/ + face -- the box set in binding_display.py LAYOUTS[]), each a ring at the + matching AREA_* anchor centre of the source drawing, mapped into canvas + coords. Each box draws a connector line to up to two of them. + +The AREA_* anchors live in an untransformed layer of the source drawing in its +own coords; the same drawing->canvas transform places both the drawing and the +markers, so they always register regardless of the source viewBox (sc2.svg has a +non-zero origin). If you change ART_MAX_*_FRAC, both move together. + +Run from repo root: python3 tools/gen_binding_display.py +""" +import copy +import math +import os +import re +import sys +import xml.etree.ElementTree as ET + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _svgo # noqa: E402 + +SVG = "http://www.w3.org/2000/svg" +ET.register_namespace("", SVG) + +CANVAS_W, CANVAS_H = 1280, 720 +OUT_DIR = "images/binding-display" + +# The controller drawing's footprint in the canvas (leaves the margins free for +# the binding boxes). Both the drawing and its AREA-anchor markers are placed +# through this, so they stay registered. +ART_MAX_W_FRAC = 0.38 +ART_MAX_H_FRAC = 0.70 + +# "Matrix" binding-display palette: the GUI controller art is full-colour, but +# here it is flattened to green outlines over two greys on a dark backdrop, so it +# recedes behind the bright binding boxes and labels while staying readable. +GREEN = "#047100" # every outline/stroke -> this green +GRAY_LIGHT = "#3d3d3d" # lighter fills (luminance >= GRAY_SPLIT) +GRAY_DARK = "#262626" # darker fills, and the default for un-filled shapes +GRAY_SPLIT = 80 # fill luminance split between GRAY_LIGHT and GRAY_DARK +MARKER_GREEN = "#06a400" # marker rings + connector lines (matches Generator) + +# Standard gamepads (ds4, ds5, x360) share one anchor set: left stick + d-pad +# (LPAD) on the left, right stick (RPAD) on the right, the ABXY face cluster, and +# the top bumpers/triggers. Their drawings use the same AREA_* names. +_GAMEPAD_MARKERS = { + "system": ["BACK", "START"], "lshoulder": ["LB_1", "LT_1"], + "rshoulder": ["RB_4", "RT_1"], "lthumb": ["STICK_1", "LPAD_1"], + "rthumb": ["RPAD_1"], "face": ["Y", "A"], +} + +# Per-controller source drawing + the AREA_* anchors each binding box points at. +# The box names match scc/osd/binding_display.py LAYOUTS[]. A box draws at +# most two connector lines; anchor names differ per controller image. +CONTROLLERS = { + "sc2": { + "src": "images/controller-images/sc2.svg", + "markers": { + "system": ["BACK", "START"], "lshoulder": ["LB", "LGRIPTOUCH"], + "rshoulder": ["RB", "RGRIPTOUCH"], "lthumb": ["STICK", "LPAD"], + "rthumb": ["RSTICK", "RPAD"], "face": ["Y", "A"], + }, + }, + "deck": { + "src": "images/controller-images/deck.svg", + # Deck AREA naming differs (segmented pads/bumpers, no grip-touch). + "markers": { + "system": ["BACK", "START"], "lshoulder": ["LB_2", "LT_2"], + "rshoulder": ["RB_2", "RT_2"], "lthumb": ["STICK", "LPAD_1"], + "rthumb": ["RSTICK", "RPAD_1"], "face": ["Y", "A"], + }, + }, + "ds4": {"src": "images/controller-images/ds4.svg", "markers": _GAMEPAD_MARKERS}, + "ds5": {"src": "images/controller-images/ds5.svg", "markers": _GAMEPAD_MARKERS}, + "x360": {"src": "images/controller-images/x360.svg", "markers": _GAMEPAD_MARKERS}, +} + +_FILL = re.compile(r"fill:\s*#([0-9a-fA-F]{3,6})") +_STROKE = re.compile(r"stroke:\s*#([0-9a-fA-F]{3,6})") + + +def q(tag: str) -> str: + return "{%s}%s" % (SVG, tag) + + +def parse_viewbox(svg: ET.Element) -> tuple[float, float, float, float]: + """(x, y, w, h) of the source viewBox -- x/y may be non-zero (e.g. sc2.svg).""" + vb = svg.get("viewBox") + if vb: + p = [float(x) for x in vb.replace(",", " ").split()] + return p[0], p[1], p[2], p[3] + return 0.0, 0.0, float(svg.get("width")), float(svg.get("height")) + + +# --- affine transforms (a, b, c, d, e, f) mapping (x,y) -> (ax+cy+e, bx+dy+f) --- +IDENTITY = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) +_TRANSFORM = re.compile(r"(matrix|translate|scale|rotate)\s*\(([^)]*)\)") + + +def _compose(a: tuple, b: tuple) -> tuple: + """Return a after b (apply b first, then a) -- SVG's nested-transform order.""" + a1, b1, c1, d1, e1, f1 = a + a2, b2, c2, d2, e2, f2 = b + return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, + a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, + a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1) + + +def _parse_transform(s: str | None) -> tuple: + """Parse an SVG transform attribute into one composed affine matrix. Handles + space- OR comma-separated args and a list of transforms (leftmost outermost).""" + m = IDENTITY + if not s: + return m + for name, argstr in _TRANSFORM.findall(s): + v = [float(x) for x in re.split(r"[\s,]+", argstr.strip()) if x] + if name == "translate": + t = (1.0, 0.0, 0.0, 1.0, v[0], v[1] if len(v) > 1 else 0.0) + elif name == "scale": + t = (v[0], 0.0, 0.0, v[1] if len(v) > 1 else v[0], 0.0, 0.0) + elif name == "matrix": + t = tuple(v[:6]) + elif name == "rotate": + rad = math.radians(v[0]) + cos, sin = math.cos(rad), math.sin(rad) + t = (cos, sin, -sin, cos, 0.0, 0.0) + if len(v) >= 3: # rotate about (cx, cy) + t = _compose(_compose((1.0, 0.0, 0.0, 1.0, v[1], v[2]), t), + (1.0, 0.0, 0.0, 1.0, -v[1], -v[2])) + else: + t = IDENTITY + m = _compose(m, t) + return m + + +def read_area_centers(root: ET.Element) -> dict[str, tuple[float, float]]: + """Centre of each AREA_ rect in the drawing's user (viewBox) space. + + The rects may sit inside groups with their own transforms (the Deck nests + them under translated groups in a separate layer), so the full ancestor + transform chain is accumulated -- reading raw x/y put the Deck's shoulder + anchors hundreds of units outside the viewBox. sc2's anchors are in an + identity layer, so its result is unchanged.""" + centers = {} + + def walk(el: ET.Element, acc: tuple) -> None: + acc = _compose(acc, _parse_transform(el.get("transform"))) + for child in el: + rid = child.get("id") or "" + if child.tag == q("rect") and rid.startswith("AREA_"): + try: + x, y, w, h = (float(child.get(k)) for k in ("x", "y", "width", "height")) + except (TypeError, ValueError): + continue + a, b, c, d, e, f = acc + cx, cy = x + w / 2.0, y + h / 2.0 + centers[rid[5:]] = (a * cx + c * cy + e, b * cx + d * cy + f) + else: + walk(child, acc) + + walk(root, IDENTITY) + return centers + + +def _luminance(hexcolor: str) -> float: + h = hexcolor.lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return 0.299 * r + 0.587 * g + 0.114 * b + + +def _gray(hexcolor: str) -> str: + """Map a source fill to one of the two binding-display greys by luminance, so + light body panels stay lighter than dark detailing.""" + return GRAY_LIGHT if _luminance(hexcolor) >= GRAY_SPLIT else GRAY_DARK + + +def recolor(el: ET.Element) -> None: + """Recolour a drawing subtree into the Matrix palette in place: every stroke + becomes GREEN, every explicit fill becomes one of the two greys. Shapes with + no fill inherit the drawing group's GRAY_DARK default (so they read instead + of falling back to SVG black); stroke-only outlines keep their fill:none.""" + style = el.get("style") + if style: + style = _FILL.sub(lambda m: "fill:" + _gray(m.group(1)), style) + style = _STROKE.sub(lambda _m: "stroke:" + GREEN, style) + el.set("style", style) + if (el.get("fill") or "").startswith("#"): + el.set("fill", _gray(el.get("fill"))) + if (el.get("stroke") or "").startswith("#"): + el.set("stroke", GREEN) + for child in el: + recolor(child) + + +def strip_areas(el: ET.Element) -> None: + """Remove AREA_* hotspot rects recursively -- they are invisible in the GUI + but recolour() would give them a grey fill and cover the drawing.""" + for child in list(el): + if (child.get("id") or "").startswith("AREA_"): + el.remove(child) + else: + strip_areas(child) + + +def build(key: str, spec: dict) -> None: + src_path = spec["src"] + if not os.path.exists(src_path): + raise SystemExit("run from repo root: %s not found" % src_path) + src = ET.parse(src_path).getroot() + vx, vy, cw, ch = parse_viewbox(src) + centers = read_area_centers(src) + + # Scale + centre the drawing in the canvas. + s = min(CANVAS_W * ART_MAX_W_FRAC / cw, CANVAS_H * ART_MAX_H_FRAC / ch) + ox = (CANVAS_W - cw * s) / 2.0 + oy = (CANVAS_H - ch * s) / 2.0 + + def to_canvas(pt: tuple[float, float]) -> tuple[float, float]: + return ox + s * (pt[0] - vx), oy + s * (pt[1] - vy) + + out = ET.Element(q("svg"), { + "width": str(CANVAS_W), "height": str(CANVAS_H), + "viewBox": "0 0 %d %d" % (CANVAS_W, CANVAS_H), "version": "1.1"}) + ET.SubElement(out, q("defs"), {"id": "defs1"}) + + # background: drives the Generator's layout (it reads width/height) and gives + # the OSD a dark backdrop so the labels read. + ET.SubElement(out, q("rect"), { + "id": "background", "x": "0", "y": "0", + "width": str(CANVAS_W), "height": str(CANVAS_H), + "style": "fill:#000000;fill-opacity:0.85"}) + + # The controller drawing, scaled into the canvas so it registers with the + # markers, then recoloured. The group's GRAY_DARK default catches shapes with + # no explicit fill (which would otherwise render SVG-black on the dark bg). + g = ET.SubElement(out, q("g"), { + "fill": GRAY_DARK, + "transform": "translate(%g,%g) scale(%g) translate(%g,%g)" % (ox, oy, s, -vx, -vy)}) + for child in list(src): + if child.get("id") == "layerAreas": + continue + g.append(copy.deepcopy(child)) + strip_areas(g) + recolor(g) + + # foreground: label_template + root (boxes drawn here) + the marker groups. + root = ET.SubElement(out, q("g"), {"id": "root", "style": "display:inline"}) + lt = ET.SubElement(root, q("text"), { + "id": "label_template", + "style": "font-size:22px;font-family:'Ubuntu Mono';fill:#ffffff", + "width": "11", "height": "15", "x": "-100", "y": "-100"}) + lt.text = "X" + + missing = [] + for name, anchors in spec["markers"].items(): + mg = ET.SubElement(root, q("g"), {"id": "markers_%s" % name}) + for a in anchors: + if a not in centers: + missing.append(a) + continue + cx, cy = to_canvas(centers[a]) + ET.SubElement(mg, q("circle"), { + "cx": "%g" % cx, "cy": "%g" % cy, "r": "5", + "style": "fill:none;stroke:%s;stroke-width:1" % MARKER_GREEN}) + + out_path = os.path.join(OUT_DIR, "%s.svg" % key) + ET.ElementTree(out).write(out_path, encoding="unicode", xml_declaration=True) + _svgo.optimize(out_path) + print("wrote %s (from %s, scale %.3f at %.1f,%.1f)" % (out_path, src_path, s, ox, oy)) + if missing: + print(" WARNING: AREA anchors not found in %s: %s" % (src_path, ", ".join(missing))) + + +def main() -> None: + """Build every controller's binding-display template into OUT_DIR.""" + os.makedirs(OUT_DIR, exist_ok=True) + for key, spec in CONTROLLERS.items(): + build(key, spec) + + +if __name__ == "__main__": + main() diff --git a/ui/action_editor.ui b/ui/action_editor.ui index 3a2198715..63d2f61ac 100644 --- a/ui/action_editor.ui +++ b/ui/action_editor.ui @@ -18,7 +18,6 @@ 16 128 - 128 32 32767 512 @@ -1382,7 +1381,7 @@ - + diff --git a/ui/ae/buttons.ui b/ui/ae/buttons.ui index f62f72720..bbe63a7e4 100644 --- a/ui/ae/buttons.ui +++ b/ui/ae/buttons.ui @@ -40,6 +40,27 @@ + + + Act on release + 150 + True + True + False + start + end + 0 + True + Fire when the button or sensor is released instead of pressed. Useful for always-on sensors like the capacitive handle grips, which read "on" while the controller is held. + + + + False + True + end + 2 + + diff --git a/ui/ae/dpad.ui b/ui/ae/dpad.ui index ae1d01502..e2b35c619 100644 --- a/ui/ae/dpad.ui +++ b/ui/ae/dpad.ui @@ -85,6 +85,14 @@ Right Grip RGRIP + + Left Grip 2 + LGRIP2 + + + Right Grip 2 + RGRIP2 + - x @@ -117,6 +125,22 @@ Right Stick Press RSTICKPRESS + + Left Stick Touched + LSTICKTOUCH + + + Right Stick Touched + RSTICKTOUCH + + + Left Grip Touched + LGRIPTOUCH + + + Right Grip Touched + RGRIPTOUCH + Left Pad Press LPADPRESS diff --git a/ui/ae/gyro_action.ui b/ui/ae/gyro_action.ui index 0c4281d59..1b060440e 100644 --- a/ui/ae/gyro_action.ui +++ b/ui/ae/gyro_action.ui @@ -39,7 +39,7 @@ mouse_cam - gyroabs(Rels.REL_Y, [Rels.REL_X|None, Rels.REL_X]) + gyro(Rels.REL_Y, [Rels.REL_X|None, Rels.REL_X]) Mouse (Emulate Stick) mouse_stick diff --git a/ui/ae/special_action.ui b/ui/ae/special_action.ui index 36f5d7607..5955fc5bb 100644 --- a/ui/ae/special_action.ui +++ b/ui/ae/special_action.ui @@ -124,6 +124,14 @@ Right Grip RGRIP + + Left Grip 2 + LGRIP2 + + + Right Grip 2 + RGRIP2 + - x @@ -156,6 +164,22 @@ Right Stick Press RSTICKPRESS + + Left Stick Touched + LSTICKTOUCH + + + Right Stick Touched + RSTICKTOUCH + + + Left Grip Touched + LGRIPTOUCH + + + Right Grip Touched + RGRIPTOUCH + Left Pad Press LPADPRESS diff --git a/ui/app.ui b/ui/app.ui index 055d20bcc..c2ff6792f 100644 --- a/ui/app.ui +++ b/ui/app.ui @@ -400,15 +400,15 @@ - + 1 1 - 0 + 1 170 - + 1 1 170 @@ -446,15 +446,15 @@ - + 1 1 - 0 + 1 170 - + 1 1 170 @@ -486,6 +486,16 @@ bottom 12 + + + 220 + 1 + 1 + + + + + 1 @@ -535,6 +545,16 @@ 3 2 + + + 220 + 1 + 1 + + + + + diff --git a/ui/controller_settings.ui b/ui/controller_settings.ui index 139966d32..bbf361eb8 100644 --- a/ui/controller_settings.ui +++ b/ui/controller_settings.ui @@ -76,6 +76,14 @@ Right Grip RGRIP + + Left Grip 2 + LGRIP2 + + + Right Grip 2 + RGRIP2 + - x @@ -108,6 +116,22 @@ Right Stick Press RSTICKPRESS + + Left Stick Touched + LSTICKTOUCH + + + Right Stick Touched + RSTICKTOUCH + + + Left Grip Touched + LGRIPTOUCH + + + Right Grip Touched + RGRIPTOUCH + Left Pad Press LPADPRESS diff --git a/ui/global_settings.ui b/ui/global_settings.ui index 100fafc65..97fcb71c4 100644 --- a/ui/global_settings.ui +++ b/ui/global_settings.ui @@ -1799,7 +1799,7 @@ grips, triggers, finger positions on both pads and stick angle. - Minimize to status icon instead closing + Minimize to status icon instead of closing cbShowOSD 1 diff --git a/ui/modeshift_editor.ui b/ui/modeshift_editor.ui index 51f7b2284..9bc6bdd21 100644 --- a/ui/modeshift_editor.ui +++ b/ui/modeshift_editor.ui @@ -427,6 +427,101 @@ + + + True + False + vertical + + + True + False + 10 + 10 + 10 + 5 + 15 + + + 150 + True + False + When touched + 0 + + + 0 + 0 + + + + + True + True + True + True + + + + 1 + 0 + + + + + True + True + True + + + + True + False + gtk-clear + + + + + 2 + 0 + + + + + False + True + 0 + + + + + True + False + 10 + True + Action triggered when the capacitive sensor under the stick is touched (no press needed). + True + + + False + True + 1 + + + + + + + True + False + True + Touch + + + 3 + False + + 0 2 diff --git a/ui/simple_chooser.ui b/ui/simple_chooser.ui index 86641c9d9..f744a16da 100644 --- a/ui/simple_chooser.ui +++ b/ui/simple_chooser.ui @@ -6,7 +6,18 @@ 0 - + + + + Clear + True + True + True + Unset this axis + + + +