From 8999622da387fc283e3b5322912976a18c59dfb9 Mon Sep 17 00:00:00 2001 From: Jacek Fedorynski Date: Mon, 6 Feb 2023 23:13:52 +0100 Subject: [PATCH] Add expressions Adds the possibility for a mapping source to be an expression. Expressions make the mappings more expressive (duh) as they can do arbitrary arithmetic, use more than one input to derive the output and also do time-dependent calculations. They are evaluated on a Forth-style RPN stack machine. Currently the expressions are exposed as is in the configuration interface. In the future we might want to make them more user-friendly by accepting infix syntax and human readable usages. Bumps config version to 6. --- EXPRESSIONS.md | 122 ++++++++++++++++ README.md | 2 + config-tool-web/code.js | 242 ++++++++++++++++++++++++++++++- config-tool-web/examples.js | 190 +++++++++++++++++++++++++ config-tool-web/index.html | 16 +++ config-tool-web/usages.js | 8 ++ config-tool/common.py | 54 ++++++- config-tool/get_config.py | 42 ++++++ config-tool/set_config.py | 44 ++++++ firmware/src/config.cc | 159 ++++++++++++++++++++- firmware/src/globals.cc | 2 + firmware/src/globals.h | 3 + firmware/src/remapper.cc | 277 ++++++++++++++++++++++++++++++++---- firmware/src/types.h | 59 ++++++++ 14 files changed, 1186 insertions(+), 34 deletions(-) create mode 100644 EXPRESSIONS.md diff --git a/EXPRESSIONS.md b/EXPRESSIONS.md new file mode 100644 index 0000000..12aee0f --- /dev/null +++ b/EXPRESSIONS.md @@ -0,0 +1,122 @@ +# Expressions + +_Please note: this is an experimental feature that is likely to evolve in the future._ + +Instead of setting a mapping input to a button or an axis and possibly adding a scaling factor, you can have HID Remapper evaluate an arbitrary arithmetic expression to determine the value to which the output of the mapping will be set. This allows for more flexibility, because the expression can be more complex than simply multiplying an input value by a factor. + +HID Remapper expressions are currently input using [Reverse Polish Notation](https://en.wikipedia.org/wiki/Reverse_Polish_notation). If you’ve heard of the Forth programming language or used an HP scientific calculator, this may sound familiar to you. In the future I would like to also allow more user-friendly forms of input. + +Each expression is a list of operations. The operations all work on _the stack_. The stack is simply a list of values, which the operations can add to and remove from. Last value added to the stack is said to be on top of the stack. + +Let’s consider the simplest of expressions: 2+3. In RPN syntax this would be written as: +``` +2 3 add +``` +Note how the operands come first and the operator comes last. Let’s walk through how HID Remapper evaluates such an expression. Initially, the stack is empty. Then we go through each element of the expression, process it, and the value that is on top of the stack after we’re done is said to be the result of the expression (which will be used as the output value of the mapping). +When we encounter the `2`, we simply add it to the stack. The stack is now \[2\]. When we encounter the `3`, we also add it to the stack. The stack is now \[2, 3\]. Things get interesting when we encounter the `add` operation. It removes the top two values from the stack (2 and 3), adds them, giving 5, and puts that result on the stack. The stack now contains one value (5). If this was the expression that we set as the input of a mapping and set the output to, say, "Cursor X", this would result in the cursor moving 5 units to the right on every iteration of the mapping engine. + +Of course in the real world we’ll want the result of the expression to not always be the same, but instead depend on the state of the inputs coming from the devices connected to the HID Remapper. + +Let’s consider such a real world example. Let’s say we have a gaming controller, like a PS5 DualSense, and we want to map it so that moving the left analog stick moves the mouse cursor. To make things more interesting, let’s also say that we want to add a _dead zone_, so that small noise or drift in the analog stick doesn’t move the cursor. + +To achieve this we add a mapping with "Expression 1" as the input and "Cursor X" as the output (the Y or vertical axis would also need to be configured in a similar way). Now let’s think what our expression needs to look like. First we need to fetch the current value of the input. To do that we need to know the usage code that the controller uses for the left analog stick. For now this knowledge has to be acquired through outside means. In the future perhaps HID Remapper can help with this. Game controllers usually use the code 0x00010030 for the horizontal axis of the left analog stick, which also happens to be the code that mice use for the horizontal axis. To fetch the value of this input, we start our expression like this: +``` +0x00010030 input_state +``` +The `0x00010030` part puts that value on the stack. The `input_state` operation takes one value from the stack, fetches the input state value corresponding to that usage code, and puts it on the stack. What values does a controller send for the left analog stick? Again, this is something that you would have to find out through outside means. In our case it sends values between 0 and 255, with 0 corresponding to the stick all the way to the left, 255 all the way to the right, and 128 corresponding to the stick’s neutral center position. What we would like to have is negative values for when the stick is moved to the left and positive values when it’s moved to the right, so we’ll just subtract 128 from the input value. We can do that by extending our expression like this: +``` +0x00010030 input_state -128 add +``` +Then let’s handle the dead zone. Let’s say that if the current value is between -10 and 10, we want to act as if it was zero, giving us around 8% dead zone. To do this we’ll use a certain trick involving Boolean logic. The expression evaluation process doesn’t have conditional logic, but it does have a `gt` operation that takes two values from the stack and puts either 1 or 0 back to the stack, depending on whether one of the values is greater than the other. This way we can know whether the input value was greater than some threshold or not. But how can we set the output to the original value if it’s bigger than the threshold and to zero if not? Let’s consider this expression and walk through it step by step: +``` +0x00010030 input_state -128 add dup abs 10 gt mul +``` +As we’ve discussed before, `0x00010030 input_state -128 add` fetches the input value and subtracts 128 from it. `dup` makes a copy of the top of the stack, if the stack was \[x\] before it, it is now \[x, x\]. `abs` takes a value from the stack and puts its absolute value back on the stack. So if the stack was, say \[-30, -30\], it will now be \[-30, 30\]. If it was \[5, 5\], it will still be \[5, 5\]. `10` puts the threshold value on the stack and `gt` takes two values from the stack and compares them. If the stack was \[-30, 30, 10\] before, it will now be \[-30, 1\]. If it was \[5, 5, 10\], it will now be \[5, 0\]. Now comes the trick. Even though `0` and `1` in our example corresponds to logical _false_ and _true_, there’s nothing stopping us from treating it as a number and multiplying it by the other value on the stack. That’s what the `mul` operation does. If the stack was \[-30, 1\], it will now be \[-30\]. If it was \[5, 0\], it will now be \[0\], effectively giving us the dead zone that we wanted. + +Two things remain, first we should scale the output to a reasonable range. Through experimentation we find that multiplying it by 0.025 makes the cursor move with the speed we want, so we do just that: +``` +0x00010030 input_state -128 add dup abs 10 gt mul 0.025 mul +``` +Finally, whenever we map an absolute input like a button or an analog stick to a relative output like a mouse axis, we need to make sure that the output values are produced at the correct rate. We only want this mapping to move the cursor every 1 millisecond and depending on other factors, the expression might be evaluated more often than that. You can take a look at HID Remapper’s source code if you want to know the details, but for now just add this to the end of the expression whenever you map an absolute input to a relative output: +``` +0x00010030 input_state -128 add dup abs 10 gt mul 0.025 mul auto_repeat mul +``` +Still with us? Let’s consider another example more briefly. Let’s say we want the D-pad on our game controller to also move the mouse cursor. We happen to know (again, through outside means) that the D-pad sends the usage code 0x00010039 with values between 0 and 7 when it’s pressed in some direction and a value outside this range (say, 8 or 15) when it’s in the neutral position. Here’s an expression that we could use for the "Cursor X" mapping: +``` +0x00010039 input_state 7 gt not 0x00010039 input_state 45 mul sin mul auto_repeat mul +``` +`0x00010039 input_state` fetches the input value. `7 gt` checks if it’s outside the valid range. `not` inverts that condition. If at this point we have a `1` on the stack, this means that the D-pad is being pressed in some direction. If we have a `0`, it means that it’s not. Then we fetch the input value again (we could use `dup` before the `7 gt`, but then we’d also need `swap` which we currently don’t have). The D-pad sends a 0 for North, 1 for North-East, 2 for East etc. So to get a value in degrees (0-360), we multiply the received value by 45 (`45 mul`). Trigonometry tells us that to get the horizontal axis part of the movement in a certain direction, we can use the sine function (`sin`). Now the stack is either `1` and a valid sine result or `0` and some garbage value that we’ll disregard by multiplying the two values (`mul`). Again, we add the `auto_repeat mul` because we’re mapping an absolute input to a relative output. + +For the vertical axis the expression is almost the same, we just need to use cosine instead of sine and negate its output value: +``` +0x00010039 input_state 7 gt not 0x00010039 input_state 45 mul cos -1 mul mul auto_repeat mul +``` +In this case we don’t do additional scaling because we’re happy with the way it is, but we could of course do that if we wanted to. + +The two examples above already show us how we can do things that weren’t previously possible with the existing mapping mechanism, but let’s go further. As you’ve noticed we can fetch the state of some input more than once in an expression. What we can also do is fetch the state of multiple different inputs and do some calculations on them to produce an output. When would we want to do that? We could for example check if some button is pressed and apply some part of the calculation only if it is, emulating the layer mechanism in a more flexible way. Or let’s say that we have a joystick, like the ones used for flight simulators, that has a stick and a throttle lever. Here’s an expression that makes the stick move the mouse cursor, but with the speed controlled by the throttle lever! +``` +0x00010030 input_state -128 add dup abs 10 gt mul 0.025 mul 0x00010036 input_state -1 mul 255 add 0.007 mul mul auto_repeat mul +``` +Our joystick sends the 0x00010030 usage code (range 0-255) for the X axis of the stick and the 0x00010036 usage code (also 0-255, but with 0 being the maximum speed and 255 the minimum) for the throttle lever. We fetch the state of the stick (`0x00010030 input_state`), re-center it around zero (`-128 add`), apply the dead zone (`dup abs 10 gt mul`), apply a scaling (`0.025 mul`), fetch the state of the throttle lever (`0x00010036 input_state`), invert it so that zero corresponds to the state we want to be the minimum speed (`-1 mul 255 add`), scale it (`0.007 mul`), and finally multiply the stick value by the throttle value (`mul`). + +In addition to fetching the state of inputs in expressions, you can also fetch the current time. This has some interesting applications, for example you could write an expression like this to make a button work in "turbo" mode (press and release the button quickly when you hold it): +``` +time 200 mod 100 gt 0x00090001 input_state_binary mul +``` +`time` fetches the current time in milliseconds. `200 mod` takes that value modulo 200. `100 gt` checks if the result is in the upper half of the 200 millisecond time window, `0x00090001 input_state_binary` fetches the state of the button input and `mul` multiplies the two values, effectively pressing the button 50% of the time, with a 200 ms period. + +The `time` operation could also be used to make a mouse jiggler (a function that makes the mouse move by itself). Check the "Examples" section in the web configuration tool to see what that expression might look like. + +Looking for more inspiration? Try writing a set of expressions that will press one button when an analog trigger on a game controller is half-pressed and another button when it is fully pressed, like a dual stage trigger on a flight sim joystick. + +## Known limitations + +Currently the set of operations that you can perform in expressions is fairly limited. There’s no way to store information between subsequent evaluations of the expression and no way to access the previous state of an input. Also expressions don’t really play that well with other features like macros, tap-hold and sticky mappings. Hopefully some of these issues can be addressed in the future. + +## Tips and tricks + +Use `input_state` to fetch the state of relative usages like a mouse axis and non-binary absolute usages like a D-pad or an analog stick or trigger. Use `input_state_binary` to fetch the state of binary absolute usages (buttons). + +When writing an expression that maps an absolute input like a button to a relative input like a mouse axis, put `auto_repeat mul` at the end for consistent speed of movement. + +The values stored on the stack are 32-bit integers that are multiplied by 1000 to simulate fractional values. Therefore it is impossible to put 0.0001 on the stack. If you want to multiply a value by 0.0001, first multiply it by 0.001 and then by 0.1. + +When fetching the currently active layers with `layer_state` (and, similarly, `sticky_state`), the bitmask value is put on the stack as is, without the x1000 scaling just mentioned. Therefore if you want to perform a `bitwise_and` operation on that value, let’s say to check if layer 1 is active, you need to either use 0.002 or 0x02 as the other operand (hex values are passed as is because they’re normally used for HID usage codes). For example: +``` +layer_state 0x02 bitwise_and not not 0x00090001 input_state_binary mul +``` + +Currently, the tap-hold and the sticky logic is only triggered for usages that are an input in a mapping with those flags set. Therefore if you want to make use of those states in an expression, you have to add another dummy mapping with the button in question set as input and `Nothing` set as output, with the appropriate tap/hold/sticky flag set. + +## Operation reference + +Here's a list of all operations that can be used in an expression. Each operation takes the values listed in the Input column from the stack and puts the values listed in the Output column on the stack. If multiple values are listed, the last one corresponds to the top of the stack. + +| Operation | Input | Output | Notes | +| --- | --- | --- | --- | +| `12345` | | 12345 | Puts the value on the stack. | +| `0x00120034` | | 0x00120034 | Puts the value on the stack. Use for usage codes. | +| `input_state` | _usage_ | state of _usage_ input | | +| `input_state_binary` | _usage_ | state of _usage_ input | Use for buttons. | +| `add` | _x_, _y_ | _x + y_ | | +| `mul` | _x_, _y_ | _x * y_ | | +| `eq` | _x_, _y_ | _x == y_ | 1 if equal, 0 otherwise. | +| `mod` | _x_, _y_ | _x % y_ | Modulo function. | +| `gt` | _x_, _y_ | _x > y_ | 1 if x > y, 0 otherwise. | +| `not` | _x_ | _!x_ | 1 if x == 0, 0 otherwise. | +| `abs` | _x_ | _abs(x)_ | -x if x < 0, x otherwise. | +| `sin` | _x_ | _sin(x)_ | Sine function, x in degrees. | +| `cos` | _x_ | _cos(x)_ | Cosine function, x in degrees. | +| `relu`| _x_ | _relu(x)_ | 0 if x < 0, x otherwise. | +| `clamp` | _x_, _y_, _z_ | _clamp(x, y, z)_ | y if x < y, z if x > z, x otherwise. | +| `dup` | _x_ | _x_, _x_ | Duplicates top value on the stack. | +| `bitwise_or` | _x_, _y_ | _x \| y_ | | +| `bitwise_and` | _x_, _y_ | _x & y_ | | +| `bitwise_not` | _x_ | _~x_ | | +| `time` | | current time | In milliseconds, starting at some arbitrary point. | +| `auto_repeat` | | _auto\_repeat_ | 1 if mappings from absolute to relative usages should product output. | +| `scaling` | | _scaling_ | Value as defined in the mapping. Useful for quick parameterization. | +| `layer_state` | | _layer\_state_ | Bit mask of currently active layers. | +| `sticky_state` | _usage_ | _sticky\_state(usage)_ | Bit mask of layers on which given usage is in sticky state. | +| `tap_state` | _usage_ | _tap\_state(usage)_ | 1 if input is in tap state, 0 otherwise. | +| `hold_state` | _usage_ | _hold\_state(usage)_ | 1 if input is in hold state, 0 otherwise. | diff --git a/README.md b/README.md index b6064db..71aa97b 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ Sometimes you want a certain button to send multiple outputs, but not at the sam To make a button send a certain macro, add a mapping with that button as input and with "Macro X" as output. +The _expressions_ mechanism is an advanced and experimental feature that can be used for more complex mappings. Among other things it lets you map things like analog sticks, triggers and D-pads on game controllers, applying dead zones and other non-linear transformations to them. See [here](EXPRESSIONS.md) for more info on how to use them. + The configuration tool comes with a list of standard inputs like mouse buttons and axes, keyboard keys and media keys like play/pause, mute, etc. Some devices will use inputs from outside that list. Good news is they can still be mapped. To make the device-specific inputs appear on the list, just connect your device to the remapper, and the remapper to your computer, and click the "Open device" button before you define the mappings. The configuration tool will fetch the list of inputs declared by your device and they will show up at the bottom of the input list. Unfortunately they will only appear as hex codes and will not have human friendly names. Therefore it might require some trial and error to find the input you want (and some devices will have a lot of them!). The remapper supports high-resolution mouse scrolling on the output side, which should work on Windows and modern Linux desktops. To experience it, add a mapping with "Cursor Y" as input and "V scroll" as output (perhaps on a layer). The "Partial scroll timeout" setting is related to this and you can safely ignore it if you're not mapping anything to mouse scroll. It applies when high-resolution scrolling is _not_ in use and is the time after which a "half-tick" of the scroll is forgotten. diff --git a/config-tool-web/code.js b/config-tool-web/code.js index 64d99cd..80c5c7a 100644 --- a/config-tool-web/code.js +++ b/config-tool-web/code.js @@ -7,7 +7,7 @@ const STICKY_FLAG = 1 << 0; const TAP_FLAG = 1 << 1; const HOLD_FLAG = 1 << 2; const CONFIG_SIZE = 32; -const CONFIG_VERSION = 5; +const CONFIG_VERSION = 6; const VENDOR_ID = 0xCAFE; const PRODUCT_ID = 0xBAF2; const DEFAULT_PARTIAL_SCROLL_TIMEOUT = 1000000; @@ -16,9 +16,11 @@ const DEFAULT_SCALING = 1000; const NLAYERS = 4; const NMACROS = 8; +const NEXPRESSIONS = 8; const MACRO_ITEMS_IN_PACKET = 6; const LAYERS_USAGE_PAGE = 0xFFF10000; +const EXPR_USAGE_PAGE = 0xFFF30000; const RESET_INTO_BOOTSEL = 1; const SET_CONFIG = 2; @@ -37,6 +39,42 @@ const FLASH_B_SIDE = 14; const CLEAR_MACROS = 15; const APPEND_TO_MACRO = 16; const GET_MACRO = 17; +const INVALID_COMMAND = 18; +const CLEAR_EXPRESSIONS = 19; +const APPEND_TO_EXPRESSION = 20; +const GET_EXPRESSION = 21; + +const ops = { + "PUSH": 0, + "PUSH_USAGE": 1, + "INPUT_STATE": 2, + "ADD": 3, + "MUL": 4, + "EQ": 5, + "TIME": 6, + "MOD": 7, + "GT": 8, + "NOT": 9, + "INPUT_STATE_BINARY": 10, + "ABS": 11, + "DUP": 12, + "SIN": 13, + "COS": 14, + "DEBUG": 15, + "AUTO_REPEAT": 16, + "RELU": 17, + "CLAMP": 18, + "SCALING": 19, + "LAYER_STATE": 20, + "STICKY_STATE": 21, + "TAP_STATE": 22, + "HOLD_STATE": 23, + "BITWISE_OR": 24, + "BITWISE_AND": 25, + "BITWISE_NOT": 26, +} + +const opcodes = Object.fromEntries(Object.entries(ops).map(([key, value]) => [value, key])); const UINT8 = Symbol('uint8'); const UINT32 = Symbol('uint32'); @@ -63,6 +101,9 @@ let config = { macros: [ [], [], [], [], [], [], [], [] ], + expressions: [ + '', '', '', '', '', '', '', '' + ], }; const ignored_usages = new Set([ ]); @@ -99,6 +140,7 @@ document.addEventListener("DOMContentLoaded", function () { modal = new bootstrap.Modal(document.getElementById('usage_modal'), {}); setup_usages_modal(); setup_macros(); + setup_expressions(); set_ui_state(); }); @@ -194,6 +236,40 @@ async function load_from_device() { config['macros'].push(macro); } + config['expressions'] = []; + + for (let expr_i = 0; expr_i < NEXPRESSIONS; expr_i++) { + let expression = []; + let i = 0; + while (true) { + await send_feature_command(GET_EXPRESSION, [[UINT32, expr_i], [UINT32, i]]); + const fields = await read_config_feature([UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8, UINT8]); + const nelems = fields[0]; + if (nelems == 0) { + break; + } + let elems = fields.slice(1); + for (let j = 0; j < nelems; j++) { + const elem = elems[0]; + elems = elems.slice(1); + if ([ops['PUSH'], ops['PUSH_USAGE']].includes(elem)) { + const val = elems[3] << 24 | elems[2] << 16 | elems[1] << 8 | elems[0]; + elems = elems.slice(4); + if (elem == ops['PUSH']) { + expression.push(val.toString()); + } else { + expression.push('0x' + val.toString(16).padStart(8, '0')); + } + } else { + expression.push(opcodes[elem].toLowerCase()); + } + } + i += nelems; + } + + config['expressions'].push(expression.join(' ')); + } + set_ui_state(); } catch (e) { display_error(e); @@ -251,6 +327,44 @@ async function save_to_device() { macro_i++; } + await send_feature_command(CLEAR_EXPRESSIONS); + let expr_i = 0; + for (const expr of config['expressions']) { + if (expr_i >= NEXPRESSIONS) { + break; + } + + let elems = expr_to_elems(expr); + while (elems.length > 0) { + let bytes_left = 24; + let items_to_send = []; + let nelems = 0; + while ((elems.length > 0) && (bytes_left > 0)) { + const elem = elems[0]; + if ([ops["PUSH"], ops["PUSH_USAGE"]].includes(elem[0])) { + if (bytes_left >= 5) { + items_to_send.push([UINT8, elem[0]]); + items_to_send.push([UINT32, elem[1] >>> 0]); + bytes_left -= 5; + nelems++; + elems = elems.slice(1); + } else { + break + } + } else { + items_to_send.push([UINT8, elem[0]]); + bytes_left--; + nelems++; + elems = elems.slice(1); + } + } + await send_feature_command(APPEND_TO_EXPRESSION, + [[UINT8, expr_i], [UINT8, nelems]].concat(items_to_send)); + } + + expr_i++; + } + await send_feature_command(PERSIST_CONFIG); await send_feature_command(RESUME); } catch (e) { @@ -356,6 +470,30 @@ function set_macro_previews() { } } +function set_expressions_ui_state() { + const json_to_ui = function (op) { + if (op.toLowerCase().startsWith('0x')) { + return op; + } + if (/^[0-9-]/.test(op)) { + return (parseInt(op, 10) / 1000).toString(); + } + return op; + } + + let expr_i = 0; + for (const expr of config['expressions']) { + if (expr_i >= NEXPRESSIONS) { + break; + } + + document.getElementById('expression_' + expr_i).querySelector('.expression_input').value = + expr.split(/\s+/).map(json_to_ui).join(' '); + + expr_i++; + } +} + function set_ui_state() { if (config['version'] == 3) { config['unmapped_passthrough_layers'] = config['unmapped_passthrough'] ? [0] : []; @@ -372,12 +510,16 @@ function set_ui_state() { mapping['hold'] = false; } config['tap_hold_threshold'] = DEFAULT_TAP_HOLD_THRESHOLD; + } + if (config['version'] < 6) { + config['expressions'] = ['', '', '', '', '', '', '', '']; config['version'] = CONFIG_VERSION; } set_config_ui_state(); set_mappings_ui_state(); set_macros_ui_state(); + set_expressions_ui_state(); } function add_mapping(mapping) { @@ -412,6 +554,7 @@ function add_mapping(mapping) { target_button.addEventListener("click", show_usage_modal(mapping, 'target', target_button)); container.appendChild(clone); set_forced_layers(mapping, clone); + set_forced_flags(mapping, clone); } function download_json() { @@ -459,6 +602,7 @@ function file_uploaded() { check_json_version(new_config['version']); config = new_config; set_ui_state(); + validate_ui_expressions(); switch_to_mappings_tab(); } catch (e) { display_error(e); @@ -550,7 +694,7 @@ function add_crc(data) { } function check_json_version(config_version) { - if (!([3, 4, 5].includes(config_version))) { + if (!([3, 4, 5, 6].includes(config_version))) { throw new Error("Incompatible version."); } } @@ -566,7 +710,7 @@ async function check_device_version() { // device because it could be version X, ignore our GET_CONFIG call with version Y and // just happen to have Y at the right place in the buffer from some previous call done // by some other software. - for (const version of [CONFIG_VERSION, 4, 3, 2]) { + for (const version of [CONFIG_VERSION, 5, 4, 3, 2]) { await send_feature_command(GET_CONFIG, [], version); const [received_version] = await read_config_feature([UINT8]); if (received_version == version) { @@ -635,6 +779,40 @@ function layer_checkbox_onchange(mapping, element, layer) { }; } +function expression_onchange(i) { + const ui_to_json = function (op) { + if (op.toLowerCase().startsWith('0x')) { + if (isNaN(parseInt(op, 16))) { + throw new Error('Invalid expression: "' + op + '"'); + } + return op; + } + if (/^[0-9-]/.test(op)) { + const x = parseFloat(op); + if (isNaN(x)) { + throw new Error('Invalid expression: "' + op + '"'); + } + return Math.round(x * 1000).toString(); + } + if ((op.toUpperCase() in ops) && !['PUSH', 'PUSH_USAGE'].includes(op.toUpperCase())) { + return op.toLowerCase(); + } + throw new Error('Invalid expression: "' + op + '"'); + } + + return function () { + const expr_input = document.getElementById('expression_' + i).querySelector('.expression_input'); + try { + config['expressions'][i] = + expr_input.value.split(/\s+/).filter((x) => (x.length > 0)).map(ui_to_json).join(' '); + expr_input.classList.remove('is-invalid'); + } catch (e) { + expr_input.classList.add('is-invalid'); + config['expressions'][i] = ''; + } + } +} + function show_usage_modal(mapping, source_or_target, element) { return function () { document.querySelector('.usage_modal_title').innerText = "Select " + (source_or_target == 'source' ? "input" : "output"); @@ -653,6 +831,10 @@ function show_usage_modal(mapping, source_or_target, element) { set_forced_layers(mapping, element.closest(".mapping_container")); } + if (source_or_target == "source") { + set_forced_flags(mapping, element.closest(".mapping_container")); + } + if (source_or_target == "macro_item") { element.setAttribute('data-hid-usage', usage); set_macros_config_from_ui_state(); @@ -777,6 +959,18 @@ function set_macros_config_from_ui_state() { set_macro_previews(); } +function setup_expressions() { + const expr_container = document.getElementById('expressions_container'); + let template = document.getElementById('expression_template'); + for (let i = 0; i < NEXPRESSIONS; i++) { + let clone = template.content.cloneNode(true).firstElementChild; + clone.id = 'expression_' + i; + clone.querySelector('.expression_label').innerText = 'Expression ' + (i + 1); + clone.querySelector('.expression_input').addEventListener("input", expression_onchange(i)); + expr_container.appendChild(clone); + } +} + function partial_scroll_timeout_onchange() { let value = document.getElementById('partial_scroll_timeout_input').value; if (value === '') { @@ -813,6 +1007,7 @@ function interval_override_onchange() { function load_example(n) { config = structuredClone(examples[n]['config']); set_ui_state(); + validate_ui_expressions(); switch_to_mappings_tab(); } @@ -896,6 +1091,47 @@ function set_forced_layers(mapping, mapping_container) { } } +function set_forced_flags(mapping, mapping_container) { + mapping_container.querySelector(".sticky_checkbox").disabled = false; + mapping_container.querySelector(".tap_checkbox").disabled = false; + mapping_container.querySelector(".hold_checkbox").disabled = false; + const usage_int = parseInt(mapping['source_usage'], 16); + if (((usage_int & 0xFFFF0000) >>> 0) == EXPR_USAGE_PAGE) { + mapping_container.querySelector(".sticky_checkbox").checked = false; + mapping_container.querySelector(".tap_checkbox").checked = false; + mapping_container.querySelector(".hold_checkbox").checked = false; + mapping_container.querySelector(".sticky_checkbox").disabled = true; + mapping_container.querySelector(".tap_checkbox").disabled = true; + mapping_container.querySelector(".hold_checkbox").disabled = true; + mapping['sticky'] = false; + mapping['tap'] = false; + mapping['hold'] = false; + } +} + function switch_to_mappings_tab() { bootstrap.Tab.getOrCreateInstance(document.getElementById("nav-mappings-tab")).show(); } + +function expr_to_elems(expr) { + const convert_elem = function (elem) { + if (elem.toLowerCase().startsWith('0x')) { + return [ops["PUSH_USAGE"], parseInt(elem, 16)]; + } + if (/^[0-9-]/.test(elem)) { + return [ops["PUSH"], parseInt(elem, 10)]; + } + if (elem.toUpperCase() in ops) { + return [ops[elem.toUpperCase()]]; + } + throw new Error('Invalid expression: "' + elem + '"'); + } + + return expr.split(/\s+/).filter((x) => (x.length > 0)).map(convert_elem); +} + +function validate_ui_expressions() { + for (let i = 0; i < NEXPRESSIONS; i++) { + expression_onchange(i)(); + } +} diff --git a/config-tool-web/examples.js b/config-tool-web/examples.js index 4287dbc..f0d5592 100644 --- a/config-tool-web/examples.js +++ b/config-tool-web/examples.js @@ -693,6 +693,196 @@ const examples = [ ] } }, + { + 'description': 'expressions: gamepad-to-mouse adapter', + 'config': { + "version": 6, + "unmapped_passthrough_layers": [], + "partial_scroll_timeout": 1000000, + "interval_override": 0, + "tap_hold_threshold": 200000, + "mappings": [ + { + "target_usage": "0x00090001", + "source_usage": "0x00090002", + "scaling": 1000, + "layers": [ + 0 + ], + "sticky": false, + "tap": false, + "hold": false + }, + { + "target_usage": "0x00090002", + "source_usage": "0x00090003", + "scaling": 1000, + "layers": [ + 0 + ], + "sticky": false, + "tap": false, + "hold": false + }, + { + "target_usage": "0x00010030", + "source_usage": "0xfff30001", + "scaling": 1000, + "layers": [ + 0 + ], + "sticky": false, + "tap": false, + "hold": false + }, + { + "target_usage": "0x00010031", + "source_usage": "0xfff30002", + "scaling": 1000, + "layers": [ + 0 + ], + "sticky": false, + "tap": false, + "hold": false + }, + { + "target_usage": "0x00010038", + "source_usage": "0xfff30003", + "scaling": 1000, + "layers": [ + 0 + ], + "sticky": false, + "tap": false, + "hold": false + }, + { + "target_usage": "0x00010038", + "source_usage": "0xfff30004", + "scaling": 1000, + "layers": [ + 0 + ], + "sticky": false, + "tap": false, + "hold": false + }, + { + "target_usage": "0x00010030", + "source_usage": "0xfff30005", + "scaling": 1000, + "layers": [ + 0 + ], + "sticky": false, + "tap": false, + "hold": false + }, + { + "target_usage": "0x00010031", + "source_usage": "0xfff30006", + "scaling": 1000, + "layers": [ + 0 + ], + "sticky": false, + "tap": false, + "hold": false + } + ], + "macros": [ + [], + [], + [], + [], + [], + [], + [], + [] + ], + "expressions": [ + "0x00010030 input_state -128000 add dup abs 10000 gt mul 25 mul auto_repeat mul", + "0x00010031 input_state -128000 add dup abs 10000 gt mul 25 mul auto_repeat mul", + "0x00010033 input_state 1 mul 0x00010034 input_state -1 mul add 250 mul auto_repeat mul", + "0x00010035 input_state -128000 add dup abs 10000 gt mul -1 mul 250 mul auto_repeat mul", + "0x00010039 input_state 7000 gt not 0x00010039 input_state 45000 mul sin 1000 mul mul auto_repeat mul", + "0x00010039 input_state 7000 gt not 0x00010039 input_state 45000 mul cos -1000 mul mul auto_repeat mul", + "", + "" + ] + } + }, + { + 'description': 'expressions: middle button enables mouse jiggler', + 'config': { + "version": 6, + "unmapped_passthrough_layers": [ + 0, + 1, + 2, + 3 + ], + "partial_scroll_timeout": 1000000, + "interval_override": 0, + "tap_hold_threshold": 200000, + "mappings": [ + { + "target_usage": "0xfff10001", + "source_usage": "0x00090003", + "scaling": 1000, + "layers": [ + 0 + ], + "sticky": true, + "tap": false, + "hold": false + }, + { + "target_usage": "0x00010030", + "source_usage": "0xfff30001", + "scaling": 1000, + "layers": [ + 1 + ], + "sticky": false, + "tap": false, + "hold": false + }, + { + "target_usage": "0x00010031", + "source_usage": "0xfff30002", + "scaling": 1000, + "layers": [ + 1 + ], + "sticky": false, + "tap": false, + "hold": false + } + ], + "macros": [ + [], + [], + [], + [], + [], + [], + [], + [] + ], + "expressions": [ + "time 2000000 mod 500000 gt not time 500000 mod 720 mul cos mul auto_repeat mul", + "time 2000000 mod 500000 gt not time 500000 mod 720 mul sin -1000 mul mul auto_repeat mul", + "", + "", + "", + "", + "", + "" + ] + } + }, ]; export default examples; diff --git a/config-tool-web/index.html b/config-tool-web/index.html index 6a66a60..9f8c7d1 100644 --- a/config-tool-web/index.html +++ b/config-tool-web/index.html @@ -35,6 +35,7 @@

HID Remapper Configuration

+ @@ -138,6 +139,14 @@

HID Remapper Configuration

+ +