#!/usr/bin/env python3 # # Copyright 2016 Red Hat, Inc. # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), # to deal in the Software without restriction, including without limitation # the rights to use, copy, modify, merge, publish, distribute, sublicense, # and/or sell copies of the Software, and to permit persons to whom the # Software is furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice (including the next # paragraph) shall be included in all copies or substantial portions of the # Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. import evdev import subprocess import sys import argparse import textwrap from typing import Any, Callable, Dict, List, Optional, Tuple, Union # This must be on a single line, as we replace it using merge_ratbagd.py while building. # fmt: off from ratbagd import Ratbagd, RatbagdDevice, RatbagdProfile, RatbagdMacro, RatbagdResolution, RatbagdButton, RatbagdLed, RatbagdUnavailableError, RatbagError, RatbagErrorCode, RatbagCapabilityError, RatbagDeviceType # NOQA # fmt: on RATBAGD_API_VERSION = int("@RATBAGD_API_VERSION@") def humanize(string: str) -> str: return string.lower().replace("_", "-") button_special_names = [ humanize(e.name) for e in RatbagdButton.ActionSpecial if e.name != "INVALID" ] led_mode_names = [humanize(e.name) for e in RatbagdLed.Mode] button_specials_strmap = { **{e: e.name.lower().replace("_", "-") for e in RatbagdButton.ActionSpecial}, **{e.name.lower().replace("_", "-"): e for e in RatbagdButton.ActionSpecial}, } def convert_str_to_evcode(s: str) -> int: if not s.startswith("KEY_") and not s.startswith("BTN_"): msg = f"Don't know how to convert {s}" raise ValueError(msg) try: return evdev.ecodes.ecodes[s] except KeyError as e: msg = f"No such button or key `{s}`" raise ValueError(msg) from e def list_devices(ratbagd: Ratbagd, args: argparse.Namespace) -> None: if not ratbagd.devices: print("No devices available.") for device in ratbagd.devices: print("{:20s} {:32s}".format(device.id + ":", device.name)) def find_device(ratbagd: Ratbagd, args: argparse.Namespace) -> RatbagdDevice: device = ratbagd[args.device] if device is None: for device in ratbagd.devices: if args.device in device.name: return device print(f"Unable to find device {args.device}") sys.exit(1) return device def find_profile( ratbagd: Ratbagd, args: argparse.Namespace ) -> Tuple[RatbagdProfile, RatbagdDevice]: device = find_device(ratbagd, args) try: profile = device.profiles[args.profile_n] except IndexError: print(f"Invalid profile index {args.profile_n}") sys.exit(1) except AttributeError: profile = device.active_profile if ratbagd is None: print("The device has no active profile") sys.exit(1) return profile, device def find_resolution( ratbagd: Ratbagd, args: argparse.Namespace ) -> Tuple[RatbagdResolution, RatbagdProfile, RatbagdDevice]: profile, device = find_profile(ratbagd, args) try: resolution = profile.resolutions[args.resolution_n] except IndexError: print(f"Invalid resolution index {args.resolution_n}") sys.exit(1) except AttributeError: resolution = profile.active_resolution if resolution is None: print("The device has no active resolution") sys.exit(1) return resolution, profile, device def find_button( ratbagd: Ratbagd, args: argparse.Namespace ) -> Tuple[RatbagdButton, RatbagdProfile, RatbagdDevice]: profile, device = find_profile(ratbagd, args) try: button = profile.buttons[args.button_n] except IndexError: print(f"Invalid button index {args.button_n}") sys.exit(1) return button, profile, device def find_led( ratbagd: Ratbagd, args: argparse.Namespace ) -> Tuple[RatbagdLed, RatbagdProfile, RatbagdDevice]: profile, device = find_profile(ratbagd, args) try: led = profile.leds[args.led_n] except IndexError: print(f"Invalid LED index {args.led_n}") sys.exit(1) return led, profile, device def print_led( _device: RatbagdDevice, _profile: RatbagdProfile, led: RatbagdLed, level: int ) -> None: leds = { RatbagdLed.Mode.BREATHING: "breathing", RatbagdLed.Mode.CYCLE: "cycle", RatbagdLed.Mode.OFF: "off", RatbagdLed.Mode.ON: "on", } depths = { RatbagdLed.ColorDepth.MONOCHROME: "monochrome", RatbagdLed.ColorDepth.RGB_888: "rgb", RatbagdLed.ColorDepth.RGB_111: "rgb111", } if led.mode == RatbagdLed.Mode.OFF: print( " " * level + "LED: {}, depth: {}, mode: {}".format( led.index, depths[led.colordepth], leds[led.mode] ) ) elif led.mode == RatbagdLed.Mode.ON: print( " " * level + "LED: {}, depth: {}, mode: {}, color: {:02x}{:02x}{:02x}".format( led.index, depths[led.colordepth], leds[led.mode], led.color[0], led.color[1], led.color[2], ) ) elif led.mode == RatbagdLed.Mode.CYCLE: print( " " * level + "LED: {}, depth: {}, mode: {}, duration: {}, brightness: {}".format( led.index, depths[led.colordepth], leds[led.mode], led.effect_duration, led.brightness, ) ) elif led.mode == RatbagdLed.Mode.BREATHING: print( " " * level + "LED: {}, depth: {}, mode: {}, color: {:02x}{:02x}{:02x}, duration: {}," " brightness: {}".format( led.index, depths[led.colordepth], leds[led.mode], led.color[0], led.color[1], led.color[2], led.effect_duration, led.brightness, ) ) def print_led_caps( _device: RatbagdDevice, _profile: RatbagdProfile, led: RatbagdLed, level: int ) -> None: leds = { RatbagdLed.Mode.BREATHING: "breathing", RatbagdLed.Mode.CYCLE: "cycle", RatbagdLed.Mode.OFF: "off", RatbagdLed.Mode.ON: "on", } supported = sorted([v for k, v in leds.items() if k in led.modes]) print(" " * level + "Modes: {}".format(", ".join(supported))) def print_button( _device: RatbagdDevice, _profile: RatbagdProfile, button: RatbagdButton, level: int ) -> None: header = " " * level + f"Button: {button.index} is mapped to " if button.action_type == RatbagdButton.ActionType.BUTTON: print(f"{header}'button {button.mapping}'") elif button.action_type == RatbagdButton.ActionType.SPECIAL: print(f"{header}'{button_specials_strmap[button.special]}'") elif button.action_type == RatbagdButton.ActionType.KEY: key_name = evdev.ecodes.KEY[button.key][RatbagdMacro._PREFIX_LEN :] print(f"{header}key '{key_name}'") elif button.action_type == RatbagdButton.ActionType.MACRO: print(f"{header}macro '{str(button.macro)}'") elif button.action_type == RatbagdButton.ActionType.NONE: print(f"{header}none") else: print(f"{header}UNKNOWN") def print_resolution( _device: RatbagdDevice, _profile: RatbagdProfile, resolution: RatbagdResolution, level: int, ) -> None: if resolution.resolution == (0, 0): print(" " * level + f"{resolution.index}: ") return if len(resolution.resolution) == 2: dpi = f"{resolution.resolution[0]}x{resolution.resolution[1]}" else: dpi = f"{resolution.resolution[0]}" print( " " * level + "{}: {}dpi{}{}{}".format( resolution.index, dpi, " (active)" if resolution.is_active else "", " (default)" if resolution.is_default else "", " (disabled)" if resolution.is_disabled else "", ) ) def print_profile(device: RatbagdDevice, profile: RatbagdProfile, level: int) -> None: print( " " * (level - 2) + "Profile {}:{}{}{}".format( profile.index, " (disabled)" if profile.disabled else "", " (active)" if profile.is_active else "", " (dirty)" if profile.dirty else "", ) ) if not profile.disabled: print(" " * level + "Name: {}".format(profile.name or "n/a")) if profile.report_rate: print(" " * level + f"Report Rate: {profile.report_rate}Hz") if profile.resolutions: print(" " * level + "Resolutions:") for resolution in profile.resolutions: print_resolution(device, profile, resolution, level + 2) if profile.angle_snapping != -1: print(" " * level + f"Angle Snapping: {bool(profile.angle_snapping)}") if profile.debounce != -1: print(" " * level + f"Debounce time: {profile.debounce}ms") if profile.buttons: for button in profile.buttons: print_button(device, profile, button, level) if profile.leds: for led in profile.leds: print_led(device, profile, led, level) def print_device(device: RatbagdDevice, level: int) -> None: profile = device.profiles[0] # there should be always one print(" " * level + f"{device.id} - {device.name}") print(" " * level + f" Model: {device.model}") print(" " * level + f" Device Type: {device.device_type.name.capitalize()}") if device.firmware_version: print(" " * level + f" Firmware version: {device.firmware_version}") print(" " * level + f" Number of Buttons: {len(profile.buttons)}") print(" " * level + f" Number of Leds: {len(profile.leds)}") print(" " * level + f"Number of Profiles: {len(device.profiles)}") for profile in device.profiles: print_profile(device, profile, level + 2) def show_device(ratbagd: Ratbagd, args: argparse.Namespace) -> None: device = find_device(ratbagd, args) print_device(device, 0) def show_profile(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) print(f"Profile {args.profile} on {device.id} ({device.name})") print_profile(device, profile, 0) def show_resolution(ratbagd: Ratbagd, args: argparse.Namespace) -> None: resolution, p, d = find_resolution(ratbagd, args) print( "Resolution {} on Profile {} on {} ({})".format( args.resolution, args.profile, d.id, d.name ) ) print_resolution(d, p, resolution, 0) caps = { RatbagdResolution.CAP_INDIVIDUAL_REPORT_RATE: "individual-report-rate", RatbagdResolution.CAP_SEPARATE_XY_RESOLUTION: "separate-xy-resolution", } capabilities = [caps[c] for c in resolution.capabilities] print(" Capabilities: {}".format(", ".join(capabilities))) def show_button(ratbagd: Ratbagd, args: argparse.Namespace) -> None: button, profile, device = find_button(ratbagd, args) print( "Button {} on Profile {} on {} ({})".format( args.button, args.profile, device.id, device.name ) ) print_button(device, profile, button, 0) def func_led_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: led, profile, device = find_led(ratbagd, args) print_led(device, profile, led, 0) def func_led_caps(ratbagd: Ratbagd, args: argparse.Namespace) -> None: led, profile, device = find_led(ratbagd, args) print_led_caps(device, profile, led, 0) def func_led_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: led, _profile, device = find_led(ratbagd, args) try: mode = args.mode except AttributeError: pass else: leds = { "breathing": RatbagdLed.Mode.BREATHING, "cycle": RatbagdLed.Mode.CYCLE, "off": RatbagdLed.Mode.OFF, "on": RatbagdLed.Mode.ON, } led.mode = leds[mode] try: color = args.color except AttributeError: pass else: led.color = color try: duration = args.duration except AttributeError: pass else: led.effect_duration = duration try: brightness = args.brightness except AttributeError: pass else: led.brightness = brightness commit(device, args) def func_led_get_all(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) for led in profile.leds: print_led(device, profile, led, 0) def func_button_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: button, profile, _device = find_button(ratbagd, args) print_button(button, profile, button, 0) def func_button_action_set_none(ratbagd: Ratbagd, args: argparse.Namespace) -> None: button, _profile, device = find_button(ratbagd, args) if button.ActionType.NONE not in button.action_types: raise RatbagCapabilityError("disabling buttons is not supported on this device") button.disable() commit(device, args) def func_button_action_set_button(ratbagd: Ratbagd, args: argparse.Namespace) -> None: button, _profile, device = find_button(ratbagd, args) button.mapping = args.target_button commit(device, args) def func_button_action_set_special(ratbagd: Ratbagd, args: argparse.Namespace) -> None: button, _profile, device = find_button(ratbagd, args) try: special = args.target_special except AttributeError: pass else: button.special = button_specials_strmap[special] commit(device, args) def func_button_action_set_key(ratbagd: Ratbagd, args: argparse.Namespace) -> None: button, _profile, device = find_button(ratbagd, args) if button.ActionType.KEY not in button.action_types: raise RatbagCapabilityError("assigning a key is not supported on this device") try: key = args.target_key except AttributeError: pass else: button.key = convert_str_to_evcode(key) commit(device, args) def func_button_action_set_macro(ratbagd: Ratbagd, args: argparse.Namespace) -> None: button, _profile, device = find_button(ratbagd, args) if button.ActionType.MACRO not in button.action_types: raise RatbagCapabilityError("assigning a macro is not supported on this device") macro_keys = args.target_macro macro = RatbagdMacro() for s in macro_keys: is_press = True is_release = True is_timeout = False s = s.upper() if s[0] == "T": is_timeout = True is_press = False is_release = False elif s[0] == "+": is_release = False s = s[1:] elif s[0] == "-": is_press = False s = s[1:] if is_timeout: t = int(s[1:]) macro.append(RatbagdButton.Macro.WAIT, t) else: code = convert_str_to_evcode(s) if is_press: macro.append(RatbagdButton.Macro.KEY_PRESS, code) if is_release: macro.append(RatbagdButton.Macro.KEY_RELEASE, code) button.macro = macro commit(device, args) def func_button_count(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, _device = find_profile(ratbagd, args) print(len(profile.buttons)) def func_dpi_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: ratbagd, _profile, _device = find_resolution(ratbagd, args) if len(ratbagd.resolution) == 2: print(f"{ratbagd.resolution[0]}x{ratbagd.resolution[1]}dpi") else: print(f"{ratbagd.resolution[0]}dpi") def func_dpi_get_all(ratbagd: Ratbagd, args: argparse.Namespace) -> None: resolution, _profile, _device = find_resolution(ratbagd, args) dpis = resolution.resolutions print(" ".join([str(x) for x in dpis])) def func_dpi_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: resolution, _profile, device = find_resolution(ratbagd, args) dpi = args.dpi_n if len(resolution.resolution) > len(dpi): dpi = (dpi[0], dpi[0]) resolution.resolution = dpi commit(device, args) def func_report_rate_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, _device = find_profile(ratbagd, args) print(profile.report_rate) def func_report_rate_get_all(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, _device = find_profile(ratbagd, args) rates = profile.report_rates print(" ".join([str(x) for x in rates])) def func_report_rate_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) profile.report_rate = args.rate_n commit(device, args) def func_angle_snapping_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, _device = find_profile(ratbagd, args) if profile.angle_snapping == -1: return print(bool(profile.angle_snapping)) def func_angle_snapping_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) profile.angle_snapping = 1 if args.angle_snapping_n else 0 commit(device, args) def func_debounce_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, _device = find_profile(ratbagd, args) if profile.debounce == -1: return print(profile.debounce) def func_debounce_get_all(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, _device = find_profile(ratbagd, args) values = profile.debounces print(" ".join([str(x) for x in values])) def func_debounce_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) profile.debounce = args.debounce_n commit(device, args) def func_resolution_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: resolution, profile, device = find_resolution(ratbagd, args) print_resolution(device, profile, resolution, 0) def func_resolution_active_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, _device = find_profile(ratbagd, args) print(profile.active_resolution.index) def func_resolution_active_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: resolution, _profile, device = find_resolution(ratbagd, args) resolution.set_active() commit(device, args) def func_resolution_default_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, _device = find_profile(ratbagd, args) resolution = None for resolution in profile.resolutions: if resolution.is_default: break if resolution is None: print("The device has no default resolution") sys.exit(1) print(resolution.index) def func_default_resolution_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: # FIXME: capabilities check? resolution, _profile, device = find_resolution(ratbagd, args) resolution.set_default() commit(device, args) def func_resolution_disabled_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: resolution, _profile, _device = find_resolution(ratbagd, args) print(resolution.is_disabled) def func_resolution_enabled_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: resolution, _profile, _device = find_resolution(ratbagd, args) print(not resolution.is_disabled) def func_resolution_disabled_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: resolution, _profile, device = find_resolution(ratbagd, args) resolution.set_disabled(True) commit(device, args) def func_resolution_enabled_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: resolution, _profile, device = find_resolution(ratbagd, args) resolution.set_disabled(False) commit(device, args) def func_profile_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) print_profile(device, profile, 0) def func_profile_name_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, _device = find_profile(ratbagd, args) # See https://github.com/libratbag/libratbag/issues/617 # ratbag converts to ascii, so this has no real effect there, but # ratbag-command may still have a non-ascii string. string = bytes(profile.name, "utf-8", "ignore") print(string.decode("utf-8")) def func_profile_name_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) if not profile.name: raise RatbagCapabilityError( "assigning a profile name is not supported on this profile" ) profile.name = args.name commit(device, args) def func_profile_active_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: device = find_device(ratbagd, args) profile = device.active_profile if profile is None: print("The device has no active profile") sys.exit(1) print(profile.index) def func_profile_active_set(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) profile.set_active() commit(device, args) def func_profile_enable(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) profile.disabled = False commit(device, args) def func_profile_disable(ratbagd: Ratbagd, args: argparse.Namespace) -> None: profile, device = find_profile(ratbagd, args) profile.disabled = True commit(device, args) def func_device_name_get(ratbagd: Ratbagd, args: argparse.Namespace) -> None: device = find_device(ratbagd, args) print(device.name) ################################################################################ # these are definitions to be reused in the dict that defines our language # key elements """the type of the element (see 'types' below)""" of_type = "type" """the name of the element, it'll be the one matching the args on the CLI""" name = "name" """the group to logically associate commands while printing the help""" group = "group" """list of positional arguments for the given command""" pos_args = "pos_args" """a tag that we can refer latrer in an element of type 'link'""" tag = "tag" """the element pointed to in an element of type 'link'""" dest = "dest" """the function to associate to the switch or command""" func = "func" """this is a particular command that is an integer, but not an terminating argument. example: profile active get profile **2** button 3 get - "profile" needs to be a switch - "2" needs to be translated as a N_access, given it is a requirement to be able to call 'button' """ N_access = "N_access" # argparse.add_argument parameters (forwarded as such) """'type' of the argument""" arg_type = "arg_type" """'metavar' of the argument""" metavar = "metavar" """'help' of the argument""" help_str = "help" """'nargs' of the argument""" nargs = "nargs" """'choices' of the argument""" choices = "choices" # types """an option to interpret as a command (example 'list', 'info')""" command = "command" """an argument that is required for the given command arguments are leaf nodes and can not have children """ argument = "argument" """provides a list of choice of commands for instance, a switch of [A, B] means we can have A or B only when parsing the command line """ switch = "switch" """same as list, except we can loop inside the list for instance, a set of [A, B] means we can have A and B (and A, ...) one after the other, no matter the order """ set = "set" """a reference to any other element in the tree marked with a tag""" link = "link" ################################################################################ def commit(device: RatbagdDevice, args: argparse.Namespace) -> None: if args.nocommit: return device.commit() def color(string: str) -> Tuple[int, int, int]: try: int_value = int(string, 16) except ValueError as e: msg = "%r is not a color in hex format" % string raise ValueError(msg) from e r = (int_value >> 16) & 0xFF g = (int_value >> 8) & 0xFF b = (int_value >> 0) & 0xFF return (r, g, b) def u8(string: str) -> int: int_value = int(string) msg = "%r is not a single byte" % string if int_value < 0 or int_value > 255: raise ValueError(msg) return int_value def dpi(string: str) -> Union[Tuple[int, int], Tuple[int,]]: try: int_value = int(string) except ValueError: pass else: return (int_value,) if string.endswith("dpi"): string = string[:-3] x, y = string.split("x") try: int_x = int(x) int_y = int(y) except ValueError as e: raise ValueError("%r is not a valid dpi" % string) from e return (int_x, int_y) RatbagParserSchemaElement = Dict[str, Any] # TODO: make it easier to introspect this. # note: 'hidrawX' is assumed before each command parser_def: List[RatbagParserSchemaElement] = [ { of_type: command, name: "info", help_str: "Show device information", func: show_device, group: "Device", }, { of_type: command, name: "name", help_str: "Returns the device name", func: func_device_name_get, }, { of_type: switch, name: "profile", help_str: "Access profile information", tag: "profile", group: "Profile", switch: [ { of_type: switch, name: "active", help_str: "access active profile information", switch: [ { of_type: command, name: "get", help_str: "Show current active profile", func: func_profile_active_get, }, { of_type: command, name: "set", help_str: "Set current active profile", pos_args: [ { of_type: argument, name: "profile_n", metavar: "N", help_str: "The profile to set as current", arg_type: int, }, ], func: func_profile_active_set, }, ], }, ], N_access: { of_type: N_access, name: "profile_n", metavar: "N", help_str: "The profile to act on", switch: [ { of_type: command, name: "get", help_str: "Show selected profile information", func: func_profile_get, }, { of_type: switch, name: "name", help_str: "access profile name information", switch: [ { of_type: command, name: "get", help_str: "Show the name of the profile", func: func_profile_name_get, }, { of_type: command, name: "set", help_str: "Set the name of the profile", pos_args: [ { of_type: argument, name: "name", metavar: "blah", help_str: "The name to set", }, ], func: func_profile_name_set, }, ], }, { of_type: command, name: "enable", help_str: "Enable a profile", func: func_profile_enable, }, { of_type: command, name: "disable", help_str: "Disable a profile", func: func_profile_disable, }, { of_type: link, dest: "resolution", }, { of_type: link, dest: "dpi", }, { of_type: link, dest: "rate", }, { of_type: link, dest: "angle_snapping", }, { of_type: link, dest: "debounce", }, { of_type: link, dest: "button", }, { of_type: link, dest: "led", }, ], }, }, { of_type: switch, name: "resolution", help_str: """Access resolution information Resolution commands work on the given profile, or on the active profile if none is given.""", tag: "resolution", group: "Resolution", switch: [ { of_type: switch, name: "active", help_str: "access active resolution information", switch: [ { of_type: command, name: "get", help_str: "Show current active resolution", func: func_resolution_active_get, }, { of_type: command, name: "set", help_str: "Set current active resolution", pos_args: [ { of_type: argument, name: "resolution_n", metavar: "N", help_str: "The resolution to set as current", arg_type: int, }, ], func: func_resolution_active_set, }, ], }, { of_type: switch, name: "default", help_str: "access default resolution information", switch: [ { of_type: command, name: "get", help_str: "Show current default resolution", func: func_resolution_default_get, }, { of_type: command, name: "set", help_str: "Set current default resolution", pos_args: [ { of_type: argument, name: "resolution_n", metavar: "N", help_str: "The resolution to set as default", arg_type: int, }, ], func: func_default_resolution_set, }, ], }, ], N_access: { of_type: N_access, name: "resolution_n", metavar: "N", help_str: "The resolution to act on", switch: [ { of_type: command, name: "get", help_str: "Show selected resolution", func: func_resolution_get, }, { of_type: switch, name: "disabled", help_str: "access disabled resolution information", switch: [ { of_type: command, name: "get", help_str: "Show if resolution is disabled", func: func_resolution_disabled_get, }, { of_type: command, name: "set", help_str: "Set resolution as disabled", func: func_resolution_disabled_set, }, ], }, { of_type: switch, name: "enabled", help_str: "access enabled resolution information", switch: [ { of_type: command, name: "get", help_str: "Show if resolution is enabled", func: func_resolution_enabled_get, }, { of_type: command, name: "set", help_str: "Set resolution as enabled", func: func_resolution_enabled_set, }, ], }, { of_type: link, dest: "dpi", }, ], }, }, { of_type: switch, name: "dpi", help_str: """Access DPI information DPI commands work on the given profile and resolution, or on the active resolution of the active profile if none are given.""", tag: "dpi", group: "DPI", switch: [ { of_type: command, name: "get", help_str: "Show current DPI value", func: func_dpi_get, }, { of_type: command, name: "get-all", help_str: "Show all available DPIs", func: func_dpi_get_all, }, { of_type: command, name: "set", help_str: "Set the DPI value to N", pos_args: [ { of_type: argument, name: "dpi_n", metavar: "N", help_str: "The resolution to set as current", arg_type: dpi, }, ], func: func_dpi_set, }, ], }, { of_type: switch, name: "rate", help_str: """Access report rate information Rate commands work on the given profile, or on the active profile if none is given.""", tag: "rate", group: "Rate", switch: [ { of_type: command, name: "get", help_str: "Show current report rate", func: func_report_rate_get, }, { of_type: command, name: "get-all", help_str: "Show all available report rates", func: func_report_rate_get_all, }, { of_type: command, name: "set", help_str: "Set the report rate to N", pos_args: [ { of_type: argument, name: "rate_n", metavar: "N", help_str: "The report rate to set as current", arg_type: int, }, ], func: func_report_rate_set, }, ], }, { of_type: switch, name: "angle_snapping", help_str: """Angle snapping information Angle snapping commands work on the given profile, or on the active profile if none is given.""", tag: "angle_snapping", group: "AngleSnapping", switch: [ { of_type: command, name: "get", help_str: "Show current angle snapping", func: func_angle_snapping_get, }, { of_type: command, name: "set", help_str: "Set the current angle snapping to N", pos_args: [ { of_type: argument, name: "angle_snapping_n", metavar: "N", help_str: "The angle snapping to set as current", arg_type: int, }, ], func: func_angle_snapping_set, }, ], }, { of_type: switch, name: "debounce", help_str: """Access debounce time information Debounce commands work on the given profile, or on the active profile if none is given.""", tag: "debounce", group: "Debounce", switch: [ { of_type: command, name: "get", help_str: "Show current debounce time", func: func_debounce_get, }, { of_type: command, name: "get-all", help_str: "Show all available debounce times", func: func_debounce_get_all, }, { of_type: command, name: "set", help_str: "Set the current debounce time to N", pos_args: [ { of_type: argument, name: "debounce_n", metavar: "N", help_str: "The debounce time to set as current", arg_type: int, }, ], func: func_debounce_set, }, ], }, { of_type: switch, name: "button", help_str: """Access Button information Button commands work on the given profile, or on the active profile if none is given.""", tag: "button", group: "Button", switch: [ { of_type: command, name: "count", help_str: "Print the number of buttons", func: func_button_count, }, ], N_access: { of_type: N_access, name: "button_n", metavar: "N", help_str: "The button to act on", switch: [ { of_type: command, name: "get", help_str: "Show selected button", func: func_button_get, }, { of_type: switch, name: "action", help_str: "Act on the selected button", switch: [ { of_type: command, name: "get", help_str: "Print the button action", func: func_button_get, }, { of_type: switch, name: "set", help_str: "Set an action on the selected button", switch: [ { of_type: command, name: "button", help_str: "Set the button action to button B", pos_args: [ { of_type: argument, name: "target_button", metavar: "B", help_str: "The new button value to assign", arg_type: int, }, ], func: func_button_action_set_button, }, { of_type: command, name: "special", help_str: ( "Set the button action to special action S" ), pos_args: [ { of_type: argument, name: "target_special", metavar: "S", help_str: "The new special value to assign", choices: button_special_names, }, ], func: func_button_action_set_special, }, { of_type: command, name: "key", help_str: "Set the button action to key", pos_args: [ { of_type: argument, name: "target_key", metavar: "S", help_str: "The new key value to assign", }, ], func: func_button_action_set_key, }, { of_type: command, name: "macro", help_str: """Set the button action to the given macro Macro syntax: A macro is a series of key events or waiting periods. Keys must be specified in linux/input.h key names. KEY_A Press and release 'a' +KEY_A Press 'a' -KEY_A Release 'a' t300 Wait 300ms""", pos_args: [ { of_type: argument, name: "target_macro", metavar: "...", help_str: "The new macro to assign", nargs: argparse.REMAINDER, }, ], func: func_button_action_set_macro, }, { of_type: command, name: "disabled", help_str: "Disable this button", pos_args: [], func: func_button_action_set_none, }, ], }, ], }, ], }, }, { of_type: switch, name: "led", help_str: """Access LED information LED commands work on the given profile, or on the active profile if none is given.""", tag: "led", group: "LED", switch: [ { of_type: command, name: "get", help_str: "Show current LED value", func: func_led_get_all, }, ], N_access: { of_type: N_access, name: "led_n", metavar: "N", help_str: "The LED to act on", switch: [ { of_type: command, name: "get", help_str: "Show current LED value", func: func_led_get, }, { of_type: command, name: "capabilities", help_str: "Show LED capabilities", func: func_led_caps, }, { of_type: set, name: "set", help_str: "Act on the selected LED", switch: [ { of_type: command, name: "mode", help_str: "The mode to set as current", pos_args: [ { of_type: argument, name: "mode", metavar: "mode", help_str: "The mode to set as current", choices: led_mode_names, }, ], }, { of_type: command, name: "color", help_str: "The color to set as current", pos_args: [ { of_type: argument, name: "color", metavar: "RRGGBB", help_str: ( "The color in hex format to set as current" ), arg_type: color, }, ], }, { of_type: command, name: "duration", help_str: "The duration to set as current", pos_args: [ { of_type: argument, name: "duration", metavar: "R", help_str: "The duration in ms to set as current", arg_type: int, }, ], }, { of_type: command, name: "brightness", help_str: "The brightness to set as current", pos_args: [ { of_type: argument, name: "brightness", metavar: "B", help_str: "The brightness to set as current", arg_type: u8, }, ], }, ], func: func_led_set, }, ], }, }, ] class ParseError(Exception): pass RatbagParserFunction = Callable[[Ratbagd, argparse.Namespace], None] class RatbagParser: tagged: Dict[str, "RatbagParser"] = {} def __init__( self, type: str, name: str, group: Optional[str] = None, tag: Optional[str] = None, func: Optional[RatbagParserFunction] = None, help: Optional[str] = None, ) -> None: self.type = type self.name = name self.tag = tag self.group = group if tag is not None: RatbagParser.tagged[tag] = self self.func = func self.help = help def repr_args(self) -> str: return "name='{}', tag='{}', func='{}', help='{}'".format( self.name, self.tag, self.func, self.help ) def __repr__(self) -> str: return f"{type(self)}({self.repr_args()})" def store_function(self, parser: argparse.ArgumentParser) -> None: if self.func is not None: parser.set_defaults(func=self.func) def _add_to_subparsers(self, parent: Any) -> None: raise ParseError(f"please implement _add_to_subparsers on {type(self)}") def add_to_subparsers(self, parent: Any) -> None: self._add_to_subparsers(parent) def _sub_parse( self, input_string: List[str], ns: argparse.Namespace ) -> argparse.ArgumentParser: raise ParseError(f"please implement _sub_parse on {type(self)}") def sub_parse( self, input_string: List[str], ns: argparse.Namespace ) -> argparse.ArgumentParser: r = self._sub_parse(input_string, ns) return r # noqa: RET504 def print_help(self, group: Optional[str], prefix: str = "") -> None: if self.group is not None: print(f"\n{self.group} Commands:") self._print_help(prefix) def _print_help(self, prefix: str) -> None: raise ParseError(f"please implement _print_help on {type(self)}") class RatbagParserSwitch(RatbagParser): def __init__( self, type: str, name: str, group: Optional[str] = None, switch: Optional[List[RatbagParserSchemaElement]] = None, N_access: Optional[RatbagParserSchemaElement] = None, tag: Optional[str] = None, func: Optional[RatbagParserFunction] = None, help: Optional[str] = None, ) -> None: super().__init__(type, name, group, tag, func, help) self.switch: List[RatbagParser] = ( [classes[obj[of_type]](**obj) for obj in switch] if switch is not None else [] ) self.N_access = ( RatbagParserNAccess(**N_access) if N_access is not None else None ) def repr_args(self) -> str: return """switch='{}', N_access='{}', {}""".format( [repr(o) for o in self.switch], self.N_access, RatbagParser.repr_args(self) ) def _add_to_subparsers(self, parent: Any) -> None: parser = parent.add_parser(self.name, help=self.help) parser.set_defaults(subparse=self.sub_parse) def _sub_parse( self, input_string: List[str], ns: argparse.Namespace ) -> argparse.ArgumentParser: if input_string and self.N_access is not None: # retrieve first numbered element if any try: int(input_string[0]) except ValueError: # there are arguments, but they look like commands pass else: # we have a single int as first argument, switch to the # N_access subtree of the command return self.N_access.parse_parent(self, input_string, ns) parser = argparse.ArgumentParser( prog=f"{sys.argv[0]} {self.name}", description=self.help, add_help=False, ) # create a new subparser to handle all commands subs = parser.add_subparsers(title="COMMANDS", help=None) for e in self.switch: e.add_to_subparsers(subs) return parser def _print_help(self, prefix: str) -> None: if self.help and self.group is not None: print(textwrap.indent(self.help, " "), "\n") for e in self.switch: e.print_help(None, f"{prefix}{self.name} ") if self.N_access is not None: self.N_access.print_help(None, self.name + " ") def __repr__(self) -> str: return f"switch({self.repr_args()})" class RatbagParserNAccess(RatbagParserSwitch): def __init__( self, type: str, name: str, group: Optional[str] = None, switch: Optional[List[RatbagParserSchemaElement]] = None, metavar: Optional[str] = None, tag: Optional[str] = None, func: Optional[RatbagParserFunction] = None, help: Optional[str] = None, ) -> None: super().__init__(type, name, group, switch, None, tag, func, help) self.metavar = metavar def parse_parent( self, parent: Any, input_string: List[str], ns: argparse.Namespace ) -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog=f"{sys.argv[0]} {parent.name}", add_help=False ) parser.add_argument(self.name, help=self.help, type=int) # create a new subparser to handle all commands subs = parser.add_subparsers(title="COMMANDS", help=None) for e in self.switch: e.add_to_subparsers(subs) return parser def repr_args(self) -> str: return """switch='{}', metavar = '{}', {}""".format( [repr(o) for o in self.switch], self.metavar, RatbagParser.repr_args(self) ) def __repr__(self) -> str: return f"N_Access({self.repr_args()})" def _print_help(self, prefix: str) -> None: for e in self.switch: e.print_help(None, f"{prefix}N ") class RatbagParserSet(RatbagParserSwitch): def __init__( self, type: str, name: str, group: Optional[str] = None, switch: Optional[List[RatbagParserSchemaElement]] = None, N_access: Optional[RatbagParserSchemaElement] = None, tag: Optional[str] = None, func: Optional[RatbagParserFunction] = None, help: Optional[str] = None, ) -> None: super().__init__(type, name, group, switch, N_access, tag, func, help) def _add_to_subparsers(self, parent: Any) -> None: parser = parent.add_parser(self.name, help=self.help) parser.set_defaults(subparse=self.sub_parse) def _sub_parse( self, input_string: List[str], ns: argparse.Namespace ) -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog=f"{sys.argv[0]} {self.name}", add_help=False ) # create a new subparser to handle all commands subs = parser.add_subparsers(title="COMMANDS", help=None) for e in self.switch: e.add_to_subparsers(subs) if len(input_string) == 2: self.store_function(parser) else: parser.set_defaults(subparse=self.sub_parse) return parser def __repr__(self) -> str: return f"set({self.repr_args()})" def _print_help(self, prefix: str) -> None: command = prefix + "{COMMAND} ..." print(" {:<36}{}".format(command, self.help if self.help else "")) for e in self.switch: e.print_help(None, " " * len(prefix)) class RatbagParserCommand(RatbagParser): def __init__( self, type: str, name: str, group: Optional[str] = None, pos_args: Optional[List[RatbagParserSchemaElement]] = None, tag: Optional[str] = None, func: Optional[RatbagParserFunction] = None, help: Optional[str] = None, ) -> None: super().__init__(type, name, group, tag, func, help) self.pos_args: List[RatbagParserArgument] = ( [classes[obj[of_type]](**obj) for obj in pos_args] if pos_args is not None else [] ) def _add_to_subparsers(self, parent: Any) -> None: parser = parent.add_parser(self.name, help=self.help) for a in self.pos_args: a.add_to_subparsers(parser) self.store_function(parser) def __repr__(self) -> str: return f"command({self.repr_args()})" def _print_help(self, prefix: str) -> None: command = prefix + self.name for a in self.pos_args: if a.choices is None or len(a.choices) > 5: command += f" {a.metavar}" else: command += " [{}]".format("|".join(a.choices)) print(" {:<36}{}".format(command, self.help if self.help else "")) class RatbagParserArgument(RatbagParser): def __init__( self, type: str, name: str, group: Optional[str] = None, arg_type: Optional[object] = None, metavar: Optional[str] = None, nargs: Optional[int] = None, choices: Optional[List] = None, tag: Optional[str] = None, func: Optional[RatbagParserFunction] = None, help: Optional[str] = None, ) -> None: super().__init__(type, name, group, tag, func, help) self.arg_type = arg_type self.metavar = metavar self.nargs = nargs self.choices = choices def _add_to_subparsers(self, parent: Any) -> None: parent.add_argument( self.name, metavar=self.metavar, help=self.help, type=self.arg_type, nargs=self.nargs, choices=self.choices, ) class RatbagParserLink(RatbagParser): def __init__( self, type: str, dest: str, group: Optional[str] = None, tag: Optional[str] = None, func: Optional[RatbagParserFunction] = None, help: Optional[str] = None, ) -> None: super().__init__(type, dest, group, tag, func, help) self.dest = dest def get_dest(self) -> RatbagParser: try: dest = RatbagParser.tagged[self.dest] except KeyError as e: raise ParseError(f"link '{self.dest}' points to nothing") from e return dest def _add_to_subparsers(self, parent: Any) -> None: dest = self.get_dest() dest.add_to_subparsers(parent) def _print_help(self, prefix: str) -> None: dest = self.get_dest() print( " {:<36}Use {}for '{} Commands'".format( prefix + self.dest + " ...", prefix, dest.group ) ) classes = { switch: RatbagParserSwitch, set: RatbagParserSet, command: RatbagParserCommand, argument: RatbagParserArgument, link: RatbagParserLink, N_access: RatbagParserNAccess, } class RatbagParserRoot: def __init__(self, commands: List[RatbagParserSchemaElement]) -> None: self.children: List[RatbagParser] = [ classes[def_parser[of_type]](**def_parser) for def_parser in commands ] self.want_keepalive = False def parse(self, input_string: List[str]) -> argparse.Namespace: self.parser = argparse.ArgumentParser( description="Inspect and modify a configurable device", add_help=False ) self.parser.add_argument( "-V", "--version", action="version", version="@version@" ) self.parser.add_argument("--verbose", "-v", action="count", default=0) self.parser.add_argument("--help", "-h", action="store_true", default=False) self.parser.add_argument("--nocommit", action="store_true", default=False) if self.want_keepalive: self.parser.add_argument("--keepalive", action="store_true", default=False) # retrieve the global options now and remove them from the processing ns, rest = self.parser.parse_known_args(input_string) if ns.help: return ns # retrieve the device and remove it from the command processing self.parser.add_argument("device_or_list", action="store") ns, rest = self.parser.parse_known_args(rest, namespace=ns) if ns.device_or_list == "list": if rest: self.parser.error("extra arguments: '{}'".format(" ".join(rest))) ns.func = list_devices return ns ns.device = ns.device_or_list # we need a new parser or 'device_or_list' will eat all of our commands command_parser = argparse.ArgumentParser( description="command parser", prog=f"{sys.argv[0]} ", add_help=False, ) subs = command_parser.add_subparsers(title="COMMANDS") subparser = command_parser for child in self.children: child.add_to_subparsers(subs) ns.subparse = None while rest and subparser: old_rest = rest ns, rest = subparser.parse_known_args(rest, namespace=ns) if hasattr(ns, func): break if old_rest == rest: break if ns.subparse: subparser = ns.subparse(rest, ns) if rest: self.parser.error("extra arguments: '{}'".format(" ".join(rest))) return ns def print_help(self) -> None: print(f"usage: {self.parser.prog} [OPTIONS] list") print(f" {self.parser.prog} [OPTIONS] {{COMMAND}} ...\n") print(self.parser.description) print( """ Common options: --version -V show program's version number and exit --verbose, -v increase verbosity level --nocommit Do not immediately write the settings to the mouse --help, -h show this help and exit""" ) if self.want_keepalive: print( " --keepalive do not terminate ratbagd after the" " processing" ) print( """ General Commands: list List supported devices (does not take a device argument)""" ) for c in self.children: c.print_help(None) print( """ Examples: {0} profile active get {0} profile 0 resolution active set 4 {0} profile 0 resolution 1 dpi get {0} resolution 4 rate get {0} dpi set 800 {0} profile 0 led 0 set mode on {0} profile 0 led 0 set color ff00ff {0} profile 0 led 0 set duration 50 Exit codes: 0 Success 1 Unsupported feature, index out of available range or invalid device 2 Commandline arguments are invalid 3 A command failed on the device """.format( self.parser.prog ) ) def get_parser() -> RatbagParserRoot: return RatbagParserRoot(parser_def) def on_device_added(_ratbagd: Ratbagd, device: RatbagdDevice) -> None: device_names = [ "mara", "capybara", "porcupine", "paca", "vole", "woodrat", "gerbil", "shrew", "hutia", "beaver", "squirrel", "chinchilla", "rabbit", "viscacha", "hare", "degu", "gundi", "acouchy", "nutria", "paca", "hamster", "zokor", "chipmunk", "gopher", "marmot", "groundhog", "suslik", "agouti", "blesmol", ] device_attr = [ "sobbing", "whooping", "barking", "yapping", "howling", "squawking", "cheering", "warbling", "thundering", "booming", "blustering", "humming", "crying", "bawling", "roaring", "raging", "chanting", "crooning", "murmuring", "bellowing", "wailing", "weeping", "screaming", "yelling", "yodeling", "singing", "honking", "hooting", "whispering", "hollering", ] # Let's convert the sha into something not boring. This takes the first # 4 characters, creates two different indices from it to generate a # name. The rest is hope hope that never get a collision here but it's # unlikely enough. name = device_names[int(device.id[0:2], 16) % len(device_names)] attr = device_attr[int(device.id[2:4], 16) % len(device_attr)] device.id = "-".join([attr, name]) def open_ratbagd( ratbagd_process: Optional[subprocess.Popen] = None, verbose: int = 0 ) -> Optional[Ratbagd]: try: ratbagd = Ratbagd(RATBAGD_API_VERSION) ratbagd.verbose = verbose try: ratbagd.connect("device-added", on_device_added) for device in ratbagd.devices: on_device_added(ratbagd, device) except AttributeError: pass # the ratbag-command case except RatbagdUnavailableError as e: print(f"Unable to connect to ratbagd: {e}") return None if ratbagd_process is not None: # if some old version of ratbagd is still running, ratbagd_process may # have never started but our DBus bindings may succeed. Check for the # return code here, this also gives ratbagd enough time to start and # die. If we check immediately we may not have terminated yet. ratbagd_process.poll() assert ratbagd_process.returncode is None return ratbagd def main(argv: List[str]) -> None: if not argv: argv = ["list"] parser = get_parser() cmd = parser.parse(argv) if cmd.help: parser.print_help() return _r = open_ratbagd(verbose=cmd.verbose) if _r is not None: with _r as r: try: f = cmd.func except AttributeError: parser.print_help() return else: try: f(r, cmd) except RatbagCapabilityError as e: print(f"Error: {e}", file=sys.stderr) if __name__ == "__main__": main(sys.argv[1:])