Skip to content

Instantly share code, notes, and snippets.

@t3hmrman
Last active Jul 6, 2021
Embed
What would you like to do?
Small script to bind to VolumeDown wheel on Logitech G930 (or other) Headsets with alsa
#!/bin/env python
# This script assumes a ton of facts about your system:
# 1) You're using a Logitech G930 (you can change this below)
# 2) aplay is installed and available on the PATH
# 3) amixer is installed and available on the path
import subprocess as sp
import re
import sys
import pprint
import logging
from math import ceil,floor
logging.basicConfig(level=logging.DEBUG)
pp = pprint.PrettyPrinter(indent=4)
DEFAULT_STEP = 0.01
HEADSET_DEVICE_NAME_FRAGMENT = r"Logitech G930 Headset"
CMD_CARD_LISTING = ["aplay", "-l"]
RE_HEADSET_PATTERN = re.compile(HEADSET_DEVICE_NAME_FRAGMENT)
RE_CARD_NUM_PATTERN = re.compile(r"card\s+(\d)\s*:")
RE_DEVICE_NUM_PATTERN = re.compile(r"device\s+(\d)\s*:")
RE_PCM = re.compile(r"PCM")
RE_VOLUME = re.compile(r"Volume")
RE_CONTENT_SECTION_NAME = re.compile(r"name=")
RE_CONTENT_SECTION_IFACE = re.compile(r"iface=")
RE_CONTENT_SECTION_NUMID = re.compile(r"numid=")
RE_KEY = re.compile(r"([^=]+)=")
RE_VALUE = re.compile(r"=([^=]+)")
RE_CONTENT_SECTION_SEPERATORS = re.compile(r",|\||:")
RE_DB = re.compile(r"dB")
RE_MAX = re.compile(r"max")
def is_valid_direction(s):
"""
Determine if direction is valid
"""
return s == "up" or s == "down"
def is_likely_volume_name(s):
"""
Detect if a amixer content section line is likely to be the volume we care about
"""
return RE_PCM.search(s) is not None and RE_VOLUME.search(s) is not None
def is_content_section_header(s):
"""
Detect if an amixer content output line is the beginning of a section
"""
return RE_CONTENT_SECTION_NAME.search(s) is not None \
and RE_CONTENT_SECTION_IFACE.search(s) is not None \
and RE_CONTENT_SECTION_NUMID.search(s) is not None
def get_card_and_headset_numbers():
"""
Determine the card number for the headset
"""
lines = sp.check_output(CMD_CARD_LISTING) \
.decode() \
.splitlines()
# Get the line with the headset on it
headset_lines = [l for l in lines if RE_HEADSET_PATTERN.search(l) is not None]
if len(headset_lines) != 1:
raise Exception("Expected only one line to have a headset!")
# Parse for the card number and device number
headset_line = headset_lines[0]
try:
card_num = int(RE_CARD_NUM_PATTERN.search(headset_line).group(1))
device_num = int(RE_DEVICE_NUM_PATTERN.search(headset_line).group(1))
except IndexError as ie:
raise Exception("Expected to find at least one group!")
return card_num, device_num
def construct_amixer_cmd(card_num=2, num_id=4, direction="up", current=-1, max_volume=-1, step=DEFAULT_STEP):
"""
Generate the amixer command that will increase/decrease the sound
Aiming to generate a command like amixer -c 2 cset numid=4 50%
Defaults are specific to the computer this was written on, they may have to be changed.
"""
# Ensure current and max are valid values
if current < 0 or max_volume < 0:
raise Exception("current and/or max volume values are invalid, please ensure parsing did not fail")
cmd = ["amixer"]
cmd.append("-c")
cmd.append(str(card_num))
cmd.append("cset")
cmd.append("numid={}".format(num_id))
# Calculate the increment based on step and max
next_increment = current
rounding_fn = ceil
# Change increment depending on direction
if direction == "up":
next_increment += max_volume * step
rounding_fn = ceil
elif direction == "down":
next_increment -= max_volume * step
rounding_fn = floor
else:
raise Exception("Invalid direction {}".format(direction))
logging.info("Calculated step, {} -> {} (max is {})".format(current, next_increment, max_volume))
next_increment = max(0, next_increment)
next_increment = min(max_volume, next_increment)
next_increment = rounding_fn(next_increment)
cmd.append(str(next_increment))
return cmd
def get_volume_info_for_card(card_num):
"""
Retrieve and lightly parse volume info from amixer
"""
lines = sp.check_output(["amixer", "-c", str(card_num), "contents"]) \
.decode() \
.splitlines()
# Find line with first section of thing that seems like volume, and find lines till next section
try:
volume_info_start_indexes = [i for i, item in enumerate(lines) if is_content_section_header and is_likely_volume_name(item)]
volume_info_start_index = volume_info_start_indexes[0]
except IndexError as ie:
raise Exception("Failed to find expected volume content line beginning")
try:
volume_info_end_indexes = [i for i in range(volume_info_start_index + 1, len(lines)) if is_content_section_header(lines[i])]
volume_info_end_index = volume_info_end_indexes[0]
except IndexError as ie:
raise Exception("Failed to find expected volume content line end")
# Gather and re-distribute lines, should roughly become "key=value" lines
volume_info_lines = re.split(RE_CONTENT_SECTION_SEPERATORS, "".join(lines[volume_info_start_index:volume_info_end_index]))
volume_info_lines = [l.strip() for l in volume_info_lines if RE_KEY.search(l) is not None or RE_VALUE.search(l) is not None]
# Replace misleading lines, ex. prepend "db_" to lines that have max=, but contain dB (non numeric)
volume_info_lines = ["db_" + l if RE_DB.search(l) is not None and RE_MAX.match(l) is not None else l for l in volume_info_lines]
logging.debug("Volume info lines:")
logging.debug(pp.pformat(volume_info_lines))
# Attempt to parse out volume info from the lines
volume_info = {RE_KEY.search(l).group(1):RE_VALUE.search(l).group(1) for l in volume_info_lines}
# Ensure specific values have been found
if volume_info["values"] is None or volume_info["max"] is None:
raise Exception("Most important volume info values and max are missing :(")
volume_info["values"] = int(volume_info["values"])
volume_info["max"] = int(volume_info["max"])
volume_info["numid"] = int(volume_info["numid"])
logging.debug("Parsed volume info:")
logging.debug(pp.pformat(volume_info))
return volume_info
def print_usage():
print("usage: headset-ctl <up|down>")
if __name__ == "__main__":
if len(sys.argv) != 2:
print_usage()
sys.exit(0)
# Get direction from args
direction = sys.argv[1]
if not is_valid_direction(direction):
raise Exception("Invalid direction {}, please use 'up' or 'down'".format(direction))
card_num, device_num = get_card_and_headset_numbers()
logging.info("Detected card number [{}], device number [{}]".format(card_num, device_num))
volume_info = get_volume_info_for_card(card_num)
logging.info("Retrieved and parsed Volume info:\n{},".format(pp.pformat(volume_info)))
cmd = construct_amixer_cmd(card_num=card_num,
num_id=volume_info["numid"],
direction=direction,
current=volume_info["values"],
max_volume=volume_info["max"])
logging.info("Generated command:\n{}".format(pp.pformat(cmd)))
sp.check_output(cmd)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment