From cf0b6b26c9ac57e31b52ad242f49c0f6cea3362c Mon Sep 17 00:00:00 2001 From: TheTrain <32771064+TheTrainGoes@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:37:35 -0400 Subject: [PATCH] Add Absolute Analog Absolute Analog allows for 9 seperate absolute analog values to be configured and then later picked from either the GPIO mapping page or as hotkeys. The use case for this is mainly shifters in sim racing, which, in some cases, look for analog values to go through gear shifts. It may have other uses beyond that. --- CMakeLists.txt | 1 + headers/addons/absolute_analog.h | 34 ++ proto/config.proto | 16 + proto/enums.proto | 27 + src/addons/absolute_analog.cpp | 76 +++ src/config_utils.cpp | 12 + src/gamepad.cpp | 31 +- src/gp2040.cpp | 2 + src/webconfig.cpp | 38 ++ www/server/app.js | 37 ++ www/src/Addons/AbsoluteAnalog.scss | 41 ++ www/src/Addons/AbsoluteAnalog.tsx | 608 +++++++++++++++++++ www/src/Locales/en/Addons/AbsoluteAnalog.jsx | 15 + www/src/Locales/en/Index.jsx | 2 + www/src/Locales/en/PinMapping.jsx | 9 + www/src/Locales/en/Proto/GpioAction.jsx | 9 + www/src/Locales/en/SettingsPage.jsx | 9 + www/src/Pages/AddonsConfigPage.tsx | 7 + www/src/Pages/SettingsPage.jsx | 9 + 19 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 headers/addons/absolute_analog.h create mode 100644 src/addons/absolute_analog.cpp create mode 100644 www/src/Addons/AbsoluteAnalog.scss create mode 100644 www/src/Addons/AbsoluteAnalog.tsx create mode 100644 www/src/Locales/en/Addons/AbsoluteAnalog.jsx diff --git a/CMakeLists.txt b/CMakeLists.txt index a8f3e87a03..949cfc9af9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -268,6 +268,7 @@ src/usbhostmanager.cpp src/config_legacy.cpp src/config_utils.cpp src/webconfig.cpp +src/addons/absolute_analog.cpp src/addons/analog.cpp src/addons/board_led.cpp src/addons/bootsel_button.cpp diff --git a/headers/addons/absolute_analog.h b/headers/addons/absolute_analog.h new file mode 100644 index 0000000000..f4d1040b83 --- /dev/null +++ b/headers/addons/absolute_analog.h @@ -0,0 +1,34 @@ +#ifndef _AbsoluteAnalog_H +#define _AbsoluteAnalog_H + +#include "gpaddon.h" +#include "gamepad.h" + +#ifndef ABSOLUTE_ANALOG_ENABLED +#define ABSOLUTE_ANALOG_ENABLED 0 +#endif + +#define ABSOLUTE_ANALOG_COUNT 9 + +// Axis values are stored as -255 to 255 +#define ABSOLUTE_ANALOG_AXIS_MAX 255 + +#define AbsoluteAnalogName "AbsoluteAnalog" + +// Also used by the hotkey handler in gamepad.cpp +void applyAbsoluteAnalogValue(GamepadState& state, uint8_t index); + +class AbsoluteAnalogAddon : public GPAddon { +public: + virtual bool available(); + virtual void setup(); + virtual void process(); + virtual void preprocess() {} + virtual void postprocess(bool sent) {} + virtual void reinit(); + virtual std::string name() { return AbsoluteAnalogName; } +private: + Mask_t pinMasks[ABSOLUTE_ANALOG_COUNT]; +}; + +#endif // _AbsoluteAnalog_H_ diff --git a/proto/config.proto b/proto/config.proto index 860aaaf3e0..0fe009bf8f 100644 --- a/proto/config.proto +++ b/proto/config.proto @@ -908,6 +908,21 @@ message HETriggerOptions optional int32 smoothingFactor = 13; } +message AbsoluteAnalogEntry +{ + optional bool enabled = 1; + optional AbsoluteAnalogStick stick = 2; + // -255 to 255, scaled to the gamepad axis range at runtime + optional int32 x = 3; + optional int32 y = 4; +} + +message AbsoluteAnalogOptions +{ + optional bool enabled = 1; + repeated AbsoluteAnalogEntry entries = 2 [(nanopb).max_count = 9]; +} + message AddonOptions { optional BootselButtonOptions bootselButtonOptions = 1; @@ -940,6 +955,7 @@ message AddonOptions optional GamepadUSBHostOptions gamepadUSBHostOptions = 28; optional TG16Options tg16Options = 29; optional HETriggerOptions heTriggerOptions = 30; + optional AbsoluteAnalogOptions absoluteAnalogOptions = 31; } message MigrationHistory diff --git a/proto/enums.proto b/proto/enums.proto index db377d366b..db60024d05 100644 --- a/proto/enums.proto +++ b/proto/enums.proto @@ -361,6 +361,24 @@ enum GpioAction MODE_WHEEL_PEDAL_GAS = 128; MODE_WHEEL_PEDAL_BRAKE = 129; MODE_WHEEL_PEDAL_CLUTCH = 130; + + ABSOLUTE_ANALOG_1 = 131; + ABSOLUTE_ANALOG_2 = 132; + ABSOLUTE_ANALOG_3 = 133; + ABSOLUTE_ANALOG_4 = 134; + ABSOLUTE_ANALOG_5 = 135; + ABSOLUTE_ANALOG_6 = 136; + ABSOLUTE_ANALOG_7 = 137; + ABSOLUTE_ANALOG_8 = 138; + ABSOLUTE_ANALOG_9 = 139; +} + +enum AbsoluteAnalogStick +{ + option (nanopb_enumopt).long_names = false; + + ABSOLUTE_ANALOG_STICK_LEFT = 0; + ABSOLUTE_ANALOG_STICK_RIGHT = 1; } enum GpioDirection @@ -463,6 +481,15 @@ enum GamepadHotkey HOTKEY_RS_RIGHT = 85; HOTKEY_REBOOT_WEBCONFIG = 86; HOTKEY_REBOOT_USB = 87; + HOTKEY_ABSOLUTE_ANALOG_1 = 88; + HOTKEY_ABSOLUTE_ANALOG_2 = 89; + HOTKEY_ABSOLUTE_ANALOG_3 = 90; + HOTKEY_ABSOLUTE_ANALOG_4 = 91; + HOTKEY_ABSOLUTE_ANALOG_5 = 92; + HOTKEY_ABSOLUTE_ANALOG_6 = 93; + HOTKEY_ABSOLUTE_ANALOG_7 = 94; + HOTKEY_ABSOLUTE_ANALOG_8 = 95; + HOTKEY_ABSOLUTE_ANALOG_9 = 96; } enum AnimationEffects diff --git a/src/addons/absolute_analog.cpp b/src/addons/absolute_analog.cpp new file mode 100644 index 0000000000..c66f728c03 --- /dev/null +++ b/src/addons/absolute_analog.cpp @@ -0,0 +1,76 @@ +#include "addons/absolute_analog.h" +#include "storagemanager.h" +#include "helper.h" +#include "config.pb.h" + +// Scale -255..255 into the gamepad's 16-bit axis range +static uint16_t scaleAxis(int32_t value) { + if (value > ABSOLUTE_ANALOG_AXIS_MAX) value = ABSOLUTE_ANALOG_AXIS_MAX; + if (value < -ABSOLUTE_ANALOG_AXIS_MAX) value = -ABSOLUTE_ANALOG_AXIS_MAX; + + int32_t scaled = (int32_t)GAMEPAD_JOYSTICK_MID + + ((value * ((int32_t)GAMEPAD_JOYSTICK_MID + 1)) / ABSOLUTE_ANALOG_AXIS_MAX); + + if (scaled < (int32_t)GAMEPAD_JOYSTICK_MIN) scaled = (int32_t)GAMEPAD_JOYSTICK_MIN; + if (scaled > (int32_t)GAMEPAD_JOYSTICK_MAX) scaled = (int32_t)GAMEPAD_JOYSTICK_MAX; + + return (uint16_t)scaled; +} + +void applyAbsoluteAnalogValue(GamepadState& state, uint8_t index) { + const AbsoluteAnalogOptions& options = Storage::getInstance().getAddonOptions().absoluteAnalogOptions; + if (!options.enabled || index >= options.entries_count) return; + + const AbsoluteAnalogEntry& entry = options.entries[index]; + if (!entry.enabled) return; + + if (entry.stick == ABSOLUTE_ANALOG_STICK_RIGHT) { + state.rx = scaleAxis(entry.x); + state.ry = scaleAxis(entry.y); + } else { + state.lx = scaleAxis(entry.x); + state.ly = scaleAxis(entry.y); + } +} + +bool AbsoluteAnalogAddon::available() { + const AbsoluteAnalogOptions& options = Storage::getInstance().getAddonOptions().absoluteAnalogOptions; + return options.enabled; +} + +void AbsoluteAnalogAddon::setup() { + for (uint8_t index = 0; index < ABSOLUTE_ANALOG_COUNT; index++) { + pinMasks[index] = 0; + } + + GpioMappingInfo* pinMappings = Storage::getInstance().getProfilePinMappings(); + for (Pin_t pin = 0; pin < (Pin_t)NUM_BANK0_GPIOS; pin++) + { + switch (pinMappings[pin].action) { + case GpioAction::ABSOLUTE_ANALOG_1: pinMasks[0] |= 1 << pin; break; + case GpioAction::ABSOLUTE_ANALOG_2: pinMasks[1] |= 1 << pin; break; + case GpioAction::ABSOLUTE_ANALOG_3: pinMasks[2] |= 1 << pin; break; + case GpioAction::ABSOLUTE_ANALOG_4: pinMasks[3] |= 1 << pin; break; + case GpioAction::ABSOLUTE_ANALOG_5: pinMasks[4] |= 1 << pin; break; + case GpioAction::ABSOLUTE_ANALOG_6: pinMasks[5] |= 1 << pin; break; + case GpioAction::ABSOLUTE_ANALOG_7: pinMasks[6] |= 1 << pin; break; + case GpioAction::ABSOLUTE_ANALOG_8: pinMasks[7] |= 1 << pin; break; + case GpioAction::ABSOLUTE_ANALOG_9: pinMasks[8] |= 1 << pin; break; + default: break; + } + } +} + +void AbsoluteAnalogAddon::reinit() { + setup(); +} + +void AbsoluteAnalogAddon::process() { + Gamepad * gamepad = Storage::getInstance().GetGamepad(); + + for (uint8_t index = 0; index < ABSOLUTE_ANALOG_COUNT; index++) { + if (pinMasks[index] && (gamepad->debouncedGpio & pinMasks[index])) { + applyAbsoluteAnalogValue(gamepad->state, index); + } + } +} diff --git a/src/config_utils.cpp b/src/config_utils.cpp index df4c67bd27..961a511281 100644 --- a/src/config_utils.cpp +++ b/src/config_utils.cpp @@ -11,6 +11,7 @@ #include "BoardConfig.h" #include "GamepadConfig.h" #include "version.h" +#include "addons/absolute_analog.h" #include "addons/analog.h" #include "addons/board_led.h" #include "addons/bootsel_button.h" @@ -1094,6 +1095,17 @@ void ConfigUtils::initUnsetPropertiesWithDefaults(Config& config) // reminder that this must be set or else nanopb won't retain anything config.addonOptions.heTriggerOptions.triggers_count = HETRIGGER_COUNT; + // addonOptions.absoluteAnalogOptions + INIT_UNSET_PROPERTY(config.addonOptions.absoluteAnalogOptions, enabled, !!ABSOLUTE_ANALOG_ENABLED); + for (uint8_t absoluteAnalogIndex = 0; absoluteAnalogIndex < ABSOLUTE_ANALOG_COUNT; absoluteAnalogIndex++) { + AbsoluteAnalogEntry& absoluteAnalogEntry = config.addonOptions.absoluteAnalogOptions.entries[absoluteAnalogIndex]; + INIT_UNSET_PROPERTY(absoluteAnalogEntry, enabled, false); + INIT_UNSET_PROPERTY(absoluteAnalogEntry, stick, ABSOLUTE_ANALOG_STICK_LEFT); + INIT_UNSET_PROPERTY(absoluteAnalogEntry, x, 0); + INIT_UNSET_PROPERTY(absoluteAnalogEntry, y, 0); + } + config.addonOptions.absoluteAnalogOptions.entries_count = ABSOLUTE_ANALOG_COUNT; + // keyboardMapping INIT_UNSET_PROPERTY(config.addonOptions.keyboardHostOptions, enabled, KEYBOARD_HOST_ENABLED); INIT_UNSET_PROPERTY(config.addonOptions.keyboardHostOptions, deprecatedPinDplus, KEYBOARD_HOST_PIN_DPLUS); diff --git a/src/gamepad.cpp b/src/gamepad.cpp index 3ba87281c1..21d16ad5bc 100644 --- a/src/gamepad.cpp +++ b/src/gamepad.cpp @@ -16,6 +16,8 @@ #include "storagemanager.h" #include "system.h" +#include "addons/absolute_analog.h" + // MUST BE DEFINED for mpgs uint32_t getMillis() { return to_ms_since_boot(get_absolute_time()); @@ -793,7 +795,34 @@ void Gamepad::processHotkeyAction(GamepadHotkey action) { case HOTKEY_RS_RIGHT: state.rx = GAMEPAD_JOYSTICK_MAX; break; - + case HOTKEY_ABSOLUTE_ANALOG_1: + applyAbsoluteAnalogValue(state, 0); + break; + case HOTKEY_ABSOLUTE_ANALOG_2: + applyAbsoluteAnalogValue(state, 1); + break; + case HOTKEY_ABSOLUTE_ANALOG_3: + applyAbsoluteAnalogValue(state, 2); + break; + case HOTKEY_ABSOLUTE_ANALOG_4: + applyAbsoluteAnalogValue(state, 3); + break; + case HOTKEY_ABSOLUTE_ANALOG_5: + applyAbsoluteAnalogValue(state, 4); + break; + case HOTKEY_ABSOLUTE_ANALOG_6: + applyAbsoluteAnalogValue(state, 5); + break; + case HOTKEY_ABSOLUTE_ANALOG_7: + applyAbsoluteAnalogValue(state, 6); + break; + case HOTKEY_ABSOLUTE_ANALOG_8: + applyAbsoluteAnalogValue(state, 7); + break; + case HOTKEY_ABSOLUTE_ANALOG_9: + applyAbsoluteAnalogValue(state, 8); + break; + default: // Unknown action break; } diff --git a/src/gp2040.cpp b/src/gp2040.cpp index b87fe05df7..56912e56a8 100644 --- a/src/gp2040.cpp +++ b/src/gp2040.cpp @@ -14,6 +14,7 @@ #include "usbhostmanager.h" // Inputs for Core0 +#include "addons/absolute_analog.h" #include "addons/analog.h" #include "addons/bootsel_button.h" #include "addons/focus_mode.h" @@ -118,6 +119,7 @@ void GP2040::setup() { addons.LoadAddon(new TG16padInput()); // Input override addons + addons.LoadAddon(new AbsoluteAnalogAddon()); // Overrides analog stick input addons.LoadAddon(new ReverseInput()); addons.LoadAddon(new TurboInput()); // Turbo overrides button states and should be close to the end addons.LoadAddon(new InputMacro()); diff --git a/src/webconfig.cpp b/src/webconfig.cpp index 83a6ef5413..8e220117d2 100644 --- a/src/webconfig.cpp +++ b/src/webconfig.cpp @@ -34,6 +34,7 @@ #include "lwip/apps/httpd.h" #include "lwip/def.h" #include "lwip/mem.h" +#include "addons/absolute_analog.h" #include "addons/input_macro.h" #define PATH_CGI_ACTION "/cgi/action" @@ -108,6 +109,24 @@ static void __attribute__((noinline)) docToValue(T& value, const DynamicJsonDocu } } +// ArduinoJson stores const char* keys by pointer, so these must be literals, not a stack buffer. +static const struct { + const char* enabled; + const char* stick; + const char* x; + const char* y; +} ABSOLUTE_ANALOG_KEYS[ABSOLUTE_ANALOG_COUNT] = { + { "absoluteAnalog1Enabled", "absoluteAnalog1Stick", "absoluteAnalog1X", "absoluteAnalog1Y" }, + { "absoluteAnalog2Enabled", "absoluteAnalog2Stick", "absoluteAnalog2X", "absoluteAnalog2Y" }, + { "absoluteAnalog3Enabled", "absoluteAnalog3Stick", "absoluteAnalog3X", "absoluteAnalog3Y" }, + { "absoluteAnalog4Enabled", "absoluteAnalog4Stick", "absoluteAnalog4X", "absoluteAnalog4Y" }, + { "absoluteAnalog5Enabled", "absoluteAnalog5Stick", "absoluteAnalog5X", "absoluteAnalog5Y" }, + { "absoluteAnalog6Enabled", "absoluteAnalog6Stick", "absoluteAnalog6X", "absoluteAnalog6Y" }, + { "absoluteAnalog7Enabled", "absoluteAnalog7Stick", "absoluteAnalog7X", "absoluteAnalog7Y" }, + { "absoluteAnalog8Enabled", "absoluteAnalog8Stick", "absoluteAnalog8X", "absoluteAnalog8Y" }, + { "absoluteAnalog9Enabled", "absoluteAnalog9Stick", "absoluteAnalog9X", "absoluteAnalog9Y" }, +}; + // Don't inline this function, we do not want to consume stack space in the calling function static void __attribute__((noinline)) cleanAddonGpioMappings(Pin_t& addonPin, Pin_t oldAddonPin) { @@ -1939,6 +1958,16 @@ std::string setAddonOptions() docToValue(heTriggerOptions.emaSmoothing, doc, "heTriggerSmoothing"); docToValue(heTriggerOptions.smoothingFactor, doc, "heTriggerSmoothingFactor"); + AbsoluteAnalogOptions& absoluteAnalogOptions = Storage::getInstance().getAddonOptions().absoluteAnalogOptions; + docToValue(absoluteAnalogOptions.enabled, doc, "AbsoluteAnalogAddonEnabled"); + for (int index = 0; index < ABSOLUTE_ANALOG_COUNT; index++) { + docToValue(absoluteAnalogOptions.entries[index].enabled, doc, ABSOLUTE_ANALOG_KEYS[index].enabled); + docToValue(absoluteAnalogOptions.entries[index].stick, doc, ABSOLUTE_ANALOG_KEYS[index].stick); + docToValue(absoluteAnalogOptions.entries[index].x, doc, ABSOLUTE_ANALOG_KEYS[index].x); + docToValue(absoluteAnalogOptions.entries[index].y, doc, ABSOLUTE_ANALOG_KEYS[index].y); + } + absoluteAnalogOptions.entries_count = ABSOLUTE_ANALOG_COUNT; + EventManager::getInstance().triggerEvent(new GPStorageSaveEvent(true)); return serialize_json(doc); @@ -2397,6 +2426,15 @@ std::string getAddonOptions() writeDoc(doc, "heTriggerSmoothing", heTriggerOptions.emaSmoothing); writeDoc(doc, "heTriggerSmoothingFactor", heTriggerOptions.smoothingFactor); + const AbsoluteAnalogOptions& absoluteAnalogOptions = Storage::getInstance().getAddonOptions().absoluteAnalogOptions; + writeDoc(doc, "AbsoluteAnalogAddonEnabled", absoluteAnalogOptions.enabled); + for (int index = 0; index < ABSOLUTE_ANALOG_COUNT; index++) { + writeDoc(doc, ABSOLUTE_ANALOG_KEYS[index].enabled, absoluteAnalogOptions.entries[index].enabled); + writeDoc(doc, ABSOLUTE_ANALOG_KEYS[index].stick, absoluteAnalogOptions.entries[index].stick); + writeDoc(doc, ABSOLUTE_ANALOG_KEYS[index].x, absoluteAnalogOptions.entries[index].x); + writeDoc(doc, ABSOLUTE_ANALOG_KEYS[index].y, absoluteAnalogOptions.entries[index].y); + } + return serialize_json(doc); } diff --git a/www/server/app.js b/www/server/app.js index 6ef46fe31a..7a3c7a21b5 100644 --- a/www/server/app.js +++ b/www/server/app.js @@ -518,6 +518,43 @@ app.get('/api/getAddonsOptions', (req, res) => { keyboardHostMouseMovement: 0, AnalogInputEnabled: 1, BoardLedAddonEnabled: 1, + AbsoluteAnalogAddonEnabled: 1, + absoluteAnalog1Enabled: 0, + absoluteAnalog1Stick: 0, + absoluteAnalog1X: 0, + absoluteAnalog1Y: 0, + absoluteAnalog2Enabled: 0, + absoluteAnalog2Stick: 0, + absoluteAnalog2X: 0, + absoluteAnalog2Y: 0, + absoluteAnalog3Enabled: 0, + absoluteAnalog3Stick: 0, + absoluteAnalog3X: 0, + absoluteAnalog3Y: 0, + absoluteAnalog4Enabled: 0, + absoluteAnalog4Stick: 0, + absoluteAnalog4X: 0, + absoluteAnalog4Y: 0, + absoluteAnalog5Enabled: 0, + absoluteAnalog5Stick: 0, + absoluteAnalog5X: 0, + absoluteAnalog5Y: 0, + absoluteAnalog6Enabled: 0, + absoluteAnalog6Stick: 0, + absoluteAnalog6X: 0, + absoluteAnalog6Y: 0, + absoluteAnalog7Enabled: 0, + absoluteAnalog7Stick: 0, + absoluteAnalog7X: 0, + absoluteAnalog7Y: 0, + absoluteAnalog8Enabled: 0, + absoluteAnalog8Stick: 0, + absoluteAnalog8X: 0, + absoluteAnalog8Y: 0, + absoluteAnalog9Enabled: 0, + absoluteAnalog9Stick: 0, + absoluteAnalog9X: 0, + absoluteAnalog9Y: 0, FocusModeAddonEnabled: 1, focusModeMacroLockEnabled: 0, BuzzerSpeakerAddonEnabled: 1, diff --git a/www/src/Addons/AbsoluteAnalog.scss b/www/src/Addons/AbsoluteAnalog.scss new file mode 100644 index 0000000000..c099c07e36 --- /dev/null +++ b/www/src/Addons/AbsoluteAnalog.scss @@ -0,0 +1,41 @@ +.absolute-analog-entry { + border: 1px solid rgba(128, 128, 128, 0.35); + border-radius: 0.375rem; + padding: 0.75rem; +} + +.absolute-analog-pad { + touch-action: none; + cursor: pointer; + user-select: none; + color: inherit; + + &.disabled { + cursor: not-allowed; + opacity: 0.45; + } + + .absolute-analog-pad-label { + font-size: 0.6rem; + fill: currentColor; + opacity: 0.7; + text-transform: uppercase; + } + + .absolute-analog-pad-guide { + fill: none; + stroke: currentColor; + opacity: 0.35; + } + + .absolute-analog-pad-line { + stroke: currentColor; + opacity: 0.6; + } + + .absolute-analog-pad-dot { + fill: #28a745; + stroke: currentColor; + stroke-width: 1; + } +} diff --git a/www/src/Addons/AbsoluteAnalog.tsx b/www/src/Addons/AbsoluteAnalog.tsx new file mode 100644 index 0000000000..45b59a26ee --- /dev/null +++ b/www/src/Addons/AbsoluteAnalog.tsx @@ -0,0 +1,608 @@ +import { useCallback, useRef } from 'react'; +import { Col, FormCheck, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import * as yup from 'yup'; + +import Section from '../Components/Section'; +import FormControl from '../Components/FormControl'; +import FormSelect from '../Components/FormSelect'; +import { AddonPropTypes } from '../Pages/AddonsConfigPage'; + +import './AbsoluteAnalog.scss'; + +// Stored as -255 to 255; the firmware scales these to the gamepad axis range +export const ABSOLUTE_ANALOG_AXIS_MAX = 255; + +const PAD_SIZE = 180; +const PAD_CENTER = PAD_SIZE / 2; +const PAD_RADIUS = 70; + +const ENTRIES = [ + { + number: 1, + enabledKey: 'absoluteAnalog1Enabled', + stickKey: 'absoluteAnalog1Stick', + xKey: 'absoluteAnalog1X', + yKey: 'absoluteAnalog1Y', + }, + { + number: 2, + enabledKey: 'absoluteAnalog2Enabled', + stickKey: 'absoluteAnalog2Stick', + xKey: 'absoluteAnalog2X', + yKey: 'absoluteAnalog2Y', + }, + { + number: 3, + enabledKey: 'absoluteAnalog3Enabled', + stickKey: 'absoluteAnalog3Stick', + xKey: 'absoluteAnalog3X', + yKey: 'absoluteAnalog3Y', + }, + { + number: 4, + enabledKey: 'absoluteAnalog4Enabled', + stickKey: 'absoluteAnalog4Stick', + xKey: 'absoluteAnalog4X', + yKey: 'absoluteAnalog4Y', + }, + { + number: 5, + enabledKey: 'absoluteAnalog5Enabled', + stickKey: 'absoluteAnalog5Stick', + xKey: 'absoluteAnalog5X', + yKey: 'absoluteAnalog5Y', + }, + { + number: 6, + enabledKey: 'absoluteAnalog6Enabled', + stickKey: 'absoluteAnalog6Stick', + xKey: 'absoluteAnalog6X', + yKey: 'absoluteAnalog6Y', + }, + { + number: 7, + enabledKey: 'absoluteAnalog7Enabled', + stickKey: 'absoluteAnalog7Stick', + xKey: 'absoluteAnalog7X', + yKey: 'absoluteAnalog7Y', + }, + { + number: 8, + enabledKey: 'absoluteAnalog8Enabled', + stickKey: 'absoluteAnalog8Stick', + xKey: 'absoluteAnalog8X', + yKey: 'absoluteAnalog8Y', + }, + { + number: 9, + enabledKey: 'absoluteAnalog9Enabled', + stickKey: 'absoluteAnalog9Stick', + xKey: 'absoluteAnalog9X', + yKey: 'absoluteAnalog9Y', + }, +] as const; + +const clampAxis = (value: number) => { + const rounded = Math.round(Number(value)); + if (Number.isNaN(rounded)) return 0; + return Math.min( + Math.max(rounded, -ABSOLUTE_ANALOG_AXIS_MAX), + ABSOLUTE_ANALOG_AXIS_MAX, + ); +}; + +const axisToPad = (value: number) => + PAD_CENTER + (clampAxis(value) / ABSOLUTE_ANALOG_AXIS_MAX) * PAD_RADIUS; + +type AnalogPadProps = { + x: number; + y: number; + disabled: boolean; + labels: { up: string; down: string; left: string; right: string }; + onChange: (x: number, y: number) => void; +}; + +const AnalogPad = ({ x, y, disabled, labels, onChange }: AnalogPadProps) => { + const svgRef = useRef(null); + const dragging = useRef(false); + + const updateFromEvent = useCallback( + (event: React.PointerEvent) => { + const svg = svgRef.current; + if (!svg) return; + + const rect = svg.getBoundingClientRect(); + const normalizedX = + (((event.clientX - rect.left) / rect.width) * PAD_SIZE - PAD_CENTER) / + PAD_RADIUS; + const normalizedY = + (((event.clientY - rect.top) / rect.height) * PAD_SIZE - PAD_CENTER) / + PAD_RADIUS; + + // Dragging is clamped to the circle; typed values can still reach the corners + const magnitude = Math.hypot(normalizedX, normalizedY); + const scale = magnitude > 1 ? 1 / magnitude : 1; + + onChange( + clampAxis(normalizedX * scale * ABSOLUTE_ANALOG_AXIS_MAX), + clampAxis(normalizedY * scale * ABSOLUTE_ANALOG_AXIS_MAX), + ); + }, + [onChange], + ); + + return ( + { + if (disabled) return; + dragging.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + updateFromEvent(event); + }} + onPointerMove={(event) => { + if (disabled || !dragging.current) return; + updateFromEvent(event); + }} + onPointerUp={(event) => { + dragging.current = false; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }} + onPointerCancel={() => { + dragging.current = false; + }} + > + + + + + {labels.up} + + + {labels.down} + + + {labels.left} + + + {labels.right} + + + + ); +}; + +export const absoluteAnalogScheme = { + AbsoluteAnalogAddonEnabled: yup + .number() + .required() + .label('Absolute Analog Add-On Enabled'), + absoluteAnalog1Enabled: yup + .number() + .label('Absolute Analog 1 Enabled') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog1Stick: yup + .number() + .label('Absolute Analog 1 Stick') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog1X: yup + .number() + .label('Absolute Analog 1 X') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog1Y: yup + .number() + .label('Absolute Analog 1 Y') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog2Enabled: yup + .number() + .label('Absolute Analog 2 Enabled') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog2Stick: yup + .number() + .label('Absolute Analog 2 Stick') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog2X: yup + .number() + .label('Absolute Analog 2 X') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog2Y: yup + .number() + .label('Absolute Analog 2 Y') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog3Enabled: yup + .number() + .label('Absolute Analog 3 Enabled') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog3Stick: yup + .number() + .label('Absolute Analog 3 Stick') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog3X: yup + .number() + .label('Absolute Analog 3 X') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog3Y: yup + .number() + .label('Absolute Analog 3 Y') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog4Enabled: yup + .number() + .label('Absolute Analog 4 Enabled') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog4Stick: yup + .number() + .label('Absolute Analog 4 Stick') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog4X: yup + .number() + .label('Absolute Analog 4 X') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog4Y: yup + .number() + .label('Absolute Analog 4 Y') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog5Enabled: yup + .number() + .label('Absolute Analog 5 Enabled') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog5Stick: yup + .number() + .label('Absolute Analog 5 Stick') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog5X: yup + .number() + .label('Absolute Analog 5 X') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog5Y: yup + .number() + .label('Absolute Analog 5 Y') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog6Enabled: yup + .number() + .label('Absolute Analog 6 Enabled') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog6Stick: yup + .number() + .label('Absolute Analog 6 Stick') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog6X: yup + .number() + .label('Absolute Analog 6 X') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog6Y: yup + .number() + .label('Absolute Analog 6 Y') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog7Enabled: yup + .number() + .label('Absolute Analog 7 Enabled') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog7Stick: yup + .number() + .label('Absolute Analog 7 Stick') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog7X: yup + .number() + .label('Absolute Analog 7 X') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog7Y: yup + .number() + .label('Absolute Analog 7 Y') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog8Enabled: yup + .number() + .label('Absolute Analog 8 Enabled') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog8Stick: yup + .number() + .label('Absolute Analog 8 Stick') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog8X: yup + .number() + .label('Absolute Analog 8 X') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog8Y: yup + .number() + .label('Absolute Analog 8 Y') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog9Enabled: yup + .number() + .label('Absolute Analog 9 Enabled') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog9Stick: yup + .number() + .label('Absolute Analog 9 Stick') + .validateRangeWhenValue('AbsoluteAnalogAddonEnabled', 0, 1), + absoluteAnalog9X: yup + .number() + .label('Absolute Analog 9 X') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), + absoluteAnalog9Y: yup + .number() + .label('Absolute Analog 9 Y') + .validateRangeWhenValue( + 'AbsoluteAnalogAddonEnabled', + -ABSOLUTE_ANALOG_AXIS_MAX, + ABSOLUTE_ANALOG_AXIS_MAX, + ), +}; + +export const absoluteAnalogState = { + AbsoluteAnalogAddonEnabled: 0, + absoluteAnalog1Enabled: 0, + absoluteAnalog1Stick: 0, + absoluteAnalog1X: 0, + absoluteAnalog1Y: 0, + absoluteAnalog2Enabled: 0, + absoluteAnalog2Stick: 0, + absoluteAnalog2X: 0, + absoluteAnalog2Y: 0, + absoluteAnalog3Enabled: 0, + absoluteAnalog3Stick: 0, + absoluteAnalog3X: 0, + absoluteAnalog3Y: 0, + absoluteAnalog4Enabled: 0, + absoluteAnalog4Stick: 0, + absoluteAnalog4X: 0, + absoluteAnalog4Y: 0, + absoluteAnalog5Enabled: 0, + absoluteAnalog5Stick: 0, + absoluteAnalog5X: 0, + absoluteAnalog5Y: 0, + absoluteAnalog6Enabled: 0, + absoluteAnalog6Stick: 0, + absoluteAnalog6X: 0, + absoluteAnalog6Y: 0, + absoluteAnalog7Enabled: 0, + absoluteAnalog7Stick: 0, + absoluteAnalog7X: 0, + absoluteAnalog7Y: 0, + absoluteAnalog8Enabled: 0, + absoluteAnalog8Stick: 0, + absoluteAnalog8X: 0, + absoluteAnalog8Y: 0, + absoluteAnalog9Enabled: 0, + absoluteAnalog9Stick: 0, + absoluteAnalog9X: 0, + absoluteAnalog9Y: 0, +}; + +const AbsoluteAnalog = ({ + values, + errors, + handleChange, + handleCheckbox, + setFieldValue, +}: AddonPropTypes) => { + const { t } = useTranslation(); + + const padLabels = { + up: t('AbsoluteAnalog:pad-up-label'), + down: t('AbsoluteAnalog:pad-down-label'), + left: t('AbsoluteAnalog:pad-left-label'), + right: t('AbsoluteAnalog:pad-right-label'), + }; + + return ( +
+ + handleCheckbox('AbsoluteAnalogAddonEnabled')} + /> +
+ ); +}; + +export default AbsoluteAnalog; diff --git a/www/src/Locales/en/Addons/AbsoluteAnalog.jsx b/www/src/Locales/en/Addons/AbsoluteAnalog.jsx new file mode 100644 index 0000000000..715a33e35f --- /dev/null +++ b/www/src/Locales/en/Addons/AbsoluteAnalog.jsx @@ -0,0 +1,15 @@ +export default { + 'header-text': 'Absolute Analog', + 'desc-text': + 'Each absolute analog value can be assigned to a GPIO pin on the Pin Mapping page, or to a hotkey on the Settings page. While the assigned pin or hotkey is held, the selected stick is forced to the configured position. Drag the dot or enter values between -255 and 255.', + 'entry-label': 'Absolute Analog {{index}}', + 'stick-label': 'Stick', + 'stick-left-label': 'Left Stick', + 'stick-right-label': 'Right Stick', + 'x-label': 'X (-255 left / +255 right)', + 'y-label': 'Y (-255 up / +255 down)', + 'pad-up-label': 'Up', + 'pad-down-label': 'Down', + 'pad-left-label': 'Left', + 'pad-right-label': 'Right', +}; diff --git a/www/src/Locales/en/Index.jsx b/www/src/Locales/en/Index.jsx index 5b6469c6c8..62ce618c9b 100644 --- a/www/src/Locales/en/Index.jsx +++ b/www/src/Locales/en/Index.jsx @@ -17,6 +17,7 @@ import Rotary from './Addons/Rotary'; import PCF8575 from './Addons/PCF8575'; import ReactiveLED from './Addons/ReactiveLED'; import HETrigger from './Addons/HETrigger'; +import AbsoluteAnalog from './Addons/AbsoluteAnalog'; import InputMacroAddon from './InputMacroAddon'; import LayoutConfig from './LayoutConfig'; import Proto from './Proto/Index'; @@ -45,5 +46,6 @@ export default { ReactiveLED, Proto, HETrigger, + AbsoluteAnalog, BootModeMapping }; diff --git a/www/src/Locales/en/PinMapping.jsx b/www/src/Locales/en/PinMapping.jsx index 818a8f73cd..ec4c22517a 100644 --- a/www/src/Locales/en/PinMapping.jsx +++ b/www/src/Locales/en/PinMapping.jsx @@ -100,5 +100,14 @@ export default { MENU_NAVIGATION_SELECT: 'Menu Select', MENU_NAVIGATION_BACK: 'Menu Back', MENU_NAVIGATION_TOGGLE: 'Menu Toggle', + ABSOLUTE_ANALOG_1: 'Absolute Analog 1', + ABSOLUTE_ANALOG_2: 'Absolute Analog 2', + ABSOLUTE_ANALOG_3: 'Absolute Analog 3', + ABSOLUTE_ANALOG_4: 'Absolute Analog 4', + ABSOLUTE_ANALOG_5: 'Absolute Analog 5', + ABSOLUTE_ANALOG_6: 'Absolute Analog 6', + ABSOLUTE_ANALOG_7: 'Absolute Analog 7', + ABSOLUTE_ANALOG_8: 'Absolute Analog 8', + ABSOLUTE_ANALOG_9: 'Absolute Analog 9', }, }; diff --git a/www/src/Locales/en/Proto/GpioAction.jsx b/www/src/Locales/en/Proto/GpioAction.jsx index 83a24b72ce..e3f0ecf61d 100644 --- a/www/src/Locales/en/Proto/GpioAction.jsx +++ b/www/src/Locales/en/Proto/GpioAction.jsx @@ -132,4 +132,13 @@ export default { 'MODE_WHEEL_PEDAL_GAS': 'Gas Pedal', 'MODE_WHEEL_PEDAL_BRAKE': 'Brake Pedal', 'MODE_WHEEL_PEDAL_CLUTCH': 'Clutch Pedal', + 'ABSOLUTE_ANALOG_1': 'Absolute Analog 1', + 'ABSOLUTE_ANALOG_2': 'Absolute Analog 2', + 'ABSOLUTE_ANALOG_3': 'Absolute Analog 3', + 'ABSOLUTE_ANALOG_4': 'Absolute Analog 4', + 'ABSOLUTE_ANALOG_5': 'Absolute Analog 5', + 'ABSOLUTE_ANALOG_6': 'Absolute Analog 6', + 'ABSOLUTE_ANALOG_7': 'Absolute Analog 7', + 'ABSOLUTE_ANALOG_8': 'Absolute Analog 8', + 'ABSOLUTE_ANALOG_9': 'Absolute Analog 9', }; \ No newline at end of file diff --git a/www/src/Locales/en/SettingsPage.jsx b/www/src/Locales/en/SettingsPage.jsx index 7716f93ae7..59213f1d06 100644 --- a/www/src/Locales/en/SettingsPage.jsx +++ b/www/src/Locales/en/SettingsPage.jsx @@ -159,6 +159,15 @@ export default { 'rs-down': 'Right Stick Down', 'rs-left': 'Right Stick Left', 'rs-right': 'Right Stick Right', + 'absolute-analog-1': 'Absolute Analog 1', + 'absolute-analog-2': 'Absolute Analog 2', + 'absolute-analog-3': 'Absolute Analog 3', + 'absolute-analog-4': 'Absolute Analog 4', + 'absolute-analog-5': 'Absolute Analog 5', + 'absolute-analog-6': 'Absolute Analog 6', + 'absolute-analog-7': 'Absolute Analog 7', + 'absolute-analog-8': 'Absolute Analog 8', + 'absolute-analog-9': 'Absolute Analog 9', }, 'forced-setup-mode-label': 'Forced Setup Mode', 'forced-setup-mode-options': { diff --git a/www/src/Pages/AddonsConfigPage.tsx b/www/src/Pages/AddonsConfigPage.tsx index 0c147e6892..c4d63fb692 100644 --- a/www/src/Pages/AddonsConfigPage.tsx +++ b/www/src/Pages/AddonsConfigPage.tsx @@ -61,6 +61,10 @@ import HETrigger, { HETriggerScheme, HETriggerState, } from '../Addons/HETrigger'; +import AbsoluteAnalog, { + absoluteAnalogScheme, + absoluteAnalogState, +} from '../Addons/AbsoluteAnalog'; export type AddonPropTypes = { values: typeof DEFAULT_VALUES; @@ -91,6 +95,7 @@ const schema = yup.object().shape({ ...reactiveLEDScheme, ...gamepadUSBHostScheme, ...HETriggerScheme, + ...absoluteAnalogScheme, }); export const DEFAULT_VALUES = { @@ -116,6 +121,7 @@ export const DEFAULT_VALUES = { ...reactiveLEDState, ...gamepadUSBHostState, ...HETriggerState, + ...absoluteAnalogState, } as const; const ADDONS = [ @@ -141,6 +147,7 @@ const ADDONS = [ DRV8833Rumble, ReactiveLED, HETrigger, + AbsoluteAnalog, ]; const FormContext = ({ setStoredData }) => { diff --git a/www/src/Pages/SettingsPage.jsx b/www/src/Pages/SettingsPage.jsx index db6cc2b9ca..a427551754 100644 --- a/www/src/Pages/SettingsPage.jsx +++ b/www/src/Pages/SettingsPage.jsx @@ -278,6 +278,15 @@ const HOTKEY_ACTIONS = [ { labelKey: 'hotkey-actions.rs-down', value: 83 }, { labelKey: 'hotkey-actions.rs-left', value: 84 }, { labelKey: 'hotkey-actions.rs-right', value: 85 }, + { labelKey: 'hotkey-actions.absolute-analog-1', value: 88 }, + { labelKey: 'hotkey-actions.absolute-analog-2', value: 89 }, + { labelKey: 'hotkey-actions.absolute-analog-3', value: 90 }, + { labelKey: 'hotkey-actions.absolute-analog-4', value: 91 }, + { labelKey: 'hotkey-actions.absolute-analog-5', value: 92 }, + { labelKey: 'hotkey-actions.absolute-analog-6', value: 93 }, + { labelKey: 'hotkey-actions.absolute-analog-7', value: 94 }, + { labelKey: 'hotkey-actions.absolute-analog-8', value: 95 }, + { labelKey: 'hotkey-actions.absolute-analog-9', value: 96 }, ]; const FORCED_SETUP_MODES = [