diff --git a/jc/lib.py b/jc/lib.py index a195855d..ff0b8807 100644 --- a/jc/lib.py +++ b/jc/lib.py @@ -16,6 +16,7 @@ 'acpi', 'airport', 'airport-s', + 'amixer', 'apt-cache-show', 'apt-get-sqq', 'arp', diff --git a/jc/parsers/amixer.py b/jc/parsers/amixer.py new file mode 100644 index 00000000..e65811bb --- /dev/null +++ b/jc/parsers/amixer.py @@ -0,0 +1,277 @@ +r"""jc - JSON Convert `amixer sget` command output parser + +Usage (cli): + + $ amixer sget | jc --amixer + $ amixer sget Master | jc --amixer + $ amixer sget Capture | jc --amixer + $ amixer sget Speakers | jc --amixer + +Usage (module): + + import jc + result = jc.parse('amixer', ) + +Schema: + + { + "control_name": string, + "capabilities": [ + string + ], + "playback_channels": [ + string + ], + "limits": { + "playback_min": integer, + "playback_max": integer + }, + "mono": { + "playback_value": integer, + "percentage": integer, + "db": float, + "status": boolean + } + } + +Examples: + + $ amixer sget Master | jc --amixer -p + { + "control_name": "Capture", + "capabilities": [ + "cvolume", + "cswitch" + ], + "playback_channels": [], + "limits": { + "playback_min": 0, + "playback_max": 63 + }, + "front_left": { + "playback_value": 63, + "percentage": 100, + "db": 30.0, + "status": true + }, + "front_right": { + "playback_value": 63, + "percentage": 100, + "db": 30.0, + "status": true + } + } + + $ amixer sget Master | jc --amixer -p -r + { + "control_name": "Master", + "capabilities": [ + "pvolume", + "pvolume-joined", + "pswitch", + "pswitch-joined" + ], + "playback_channels": [ + "Mono" + ], + "limits": { + "playback_min": "0", + "playback_max": "87" + }, + "mono": { + "playback_value": "87", + "percentage": "100%", + "db": "0.00db", + "status": "on" + } + } + + +""" +from typing import List, Dict + +import jc.utils +from jc.utils import convert_to_int + +class info(): + """Provides parser metadata (version, author, etc.)""" + version = '1.0' + description = '`amixer` command parser' + author = 'Eden Refael' + author_email = 'edenraf@hotmail.com' + compatible = ['linux'] + magic_commands = ['amixer'] + tags = ['command'] + + +__version__ = info.version + + +def _process(proc_data: dict) -> dict: + """ + Processes raw structured data to match the schema requirements. + + Parameters: + proc_data: (dict) raw structured data from the parser + + Returns: + (dict) processed structured data adhering to the schema + """ + # Initialize the processed dictionary + processed = { + "control_name": proc_data.get("control_name", ""), + "capabilities": proc_data.get("capabilities", []), + "playback_channels": proc_data.get("playback_channels", []), + "limits": { + "playback_min": convert_to_int(proc_data.get("limits", {}).get("playback_min", 0)), + "playback_max": convert_to_int(proc_data.get("limits", {}).get("playback_max", 0)), + }, + } + + # Process Mono or channel-specific data + channels = ["mono", "front_left", "front_right"] + for channel in channels: + if channel in proc_data: + channel_data = proc_data[channel] + processed[channel] = { + "playback_value": convert_to_int(channel_data.get("playback_value", 0)), + "percentage": convert_to_int(channel_data.get("percentage", "0%").strip("%")), + "db": float(channel_data.get("db", "0.0db").strip("db")), + "status": channel_data.get("status", "off") == "on", + } + + return processed + + +def parse( + data: str, + raw: bool = False, + quiet: bool = False +) -> List[Dict]: + """ + Main text parsing function, The amixer is alsa mixer tool and output, Will work with Linux OS only. + + + Parameters: + data: (string) text data to parse + raw: (boolean) unprocessed output if True + quiet: (boolean) suppress warning messages if True + + + Returns: + List of Dictionaries. Raw or processed structured data. + push test + """ + """ + The Algorithm for parsing the `amixer sget` command, Input Explained/Rules/Pseudo Algorithm: + 1. There will always be the first line which tells the user about the control name. + 2. There will always be the Capabilities which include many of capabilities - It will be listed and separated by `" "`. + 3. After that we'll need to distinct between the Channel - Could be many of channels - It will be listed and separated + by `" "`. + 3a. Capture channels - List of channels + 3b. Playback channels - List of channels + 4. Limits - We'll always have the minimum limit and the maximum limit. + + + Input Example: + 1."":~$ amixer sget Capture + Simple mixer control 'Capture',0 + Capabilities: cvolume cswitch + Capture channels: Front Left - Front Right + Limits: Capture 0 - 63 + Front Left: Capture 63 [100%] [30.00db] [on] + Front Right: Capture 63 [100%] [30.00db] [on] + + + + + 2."":~$ amixer sget Master + Simple mixer control 'Master',0 + Capabilities: pvolume pvolume-joined pswitch pswitch-joined + Playback channels: Mono + Limits: Playback 0 - 87 + Mono: Playback 87 [100%] [0.00db] [on] + + + + + + 3."":~$ amixer sget Speaker + Simple mixer control 'Speaker',0 + Capabilities: pvolume pswitch + Playback channels: Front Left - Front Right + Limits: Playback 0 - 87 + Mono: + Front Left: Playback 87 [100%] [0.00db] [on] + Front Right: Playback 87 [100%] [0.00db] [on] + + + + + 4."":~$ amixer sget Headphone + Simple mixer control 'Headphone',0 + Capabilities: pvolume pswitch + Playback channels: Front Left - Front Right + Limits: Playback 0 - 87 + Mono: + Front Left: Playback 0 [0%] [-65.25db] [off] + Front Right: Playback 0 [0%] [-65.25db] [off] + """ + # checks os compatibility and print a stderr massage if not compatible. quiet True could remove this check. + jc.utils.compatibility(__name__, info.compatible, quiet) + + # check if string + jc.utils.input_type_check(data) + + # starts the parsing from here + mapping = {} + # split lines and than work on each line + lines = data.splitlines() + first_line = lines[0].strip() + + # Extract the control name from the first line + if first_line.startswith("Simple mixer control"): + control_name = first_line.split("'")[1] + else: + raise ValueError("Invalid amixer output format: missing control name.") + # map the control name + mapping["control_name"] = control_name + + # Process subsequent lines for capabilities, channels, limits, and channel-specific mapping. + # gets the lines from the next line - because we already took care the first line. + for line in lines[1:]: + # strip the line (maybe there are white spaces in the begin&end) + line = line.strip() + + if line.startswith("Capabilities:"): + mapping["capabilities"] = line.split(":")[1].strip().split() + elif line.startswith("Playback channels:"): + mapping["playback_channels"] = line.split(":")[1].strip().split(" - ") + elif line.startswith("Limits:"): + limits = line.split(":")[1].strip().split(" - ") + mapping["limits"] = { + "playback_min": limits[0].split()[1], + "playback_max": limits[1] + } + elif line.startswith("Mono:") or line.startswith("Front Left:") or line.startswith("Front Right:"): + # Identify the channel name and parse its information + channel_name = line.split(":")[0].strip().lower().replace(" ", "_") + channel_info = line.split(":")[1].strip() + # Example: "Playback 255 [100%] [0.00db] [on]" + channel_data = channel_info.split(" ") + if channel_data[0] == "": + continue + playback_value = channel_data[1] + percentage = channel_data[2].strip("[]") # Extract percentage e.g., "100%" + db_value = channel_data[3].strip("[]") # Extract db value e.g., "0.00db" + status = channel_data[4].strip("[]") # Extract status e.g., "on" or "off" + + # Store channel mapping in the dictionary + mapping[channel_name] = { + "playback_value": playback_value, + "percentage": percentage, + "db": db_value.lower(), + "status": status + } + + return mapping if raw else _process(mapping) diff --git a/runtests.sh b/runtests.sh index b6f62a72..05723cc4 100755 --- a/runtests.sh +++ b/runtests.sh @@ -2,4 +2,4 @@ # system should be in "America/Los_Angeles" timezone for all tests to pass # ensure no local plugin parsers are installed for all tests to pass -python3 -m unittest -v +TZ=America/Los_Angeles python3 -m unittest -v diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-capture-processed.json b/tests/fixtures/ubuntu-22.04/amixer-control-capture-processed.json new file mode 100644 index 00000000..a920155e --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-capture-processed.json @@ -0,0 +1 @@ +{"control_name":"Capture","capabilities":["cvolume","cswitch"],"playback_channels":[],"limits":{"playback_min":0,"playback_max":63},"front_left":{"playback_value":63,"percentage":100,"db":30.0,"status":true},"front_right":{"playback_value":63,"percentage":100,"db":30.0,"status":true}} \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-capture.json b/tests/fixtures/ubuntu-22.04/amixer-control-capture.json new file mode 100644 index 00000000..db020ce4 --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-capture.json @@ -0,0 +1 @@ +{"control_name": "Capture", "capabilities": ["cvolume", "cswitch"], "limits": {"playback_min": "0", "playback_max": "63"}, "front_left": {"playback_value": "63", "percentage": "100%", "db": "30.00db", "status": "on"}, "front_right": {"playback_value": "63", "percentage": "100%", "db": "30.00db", "status": "on"}} \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-capture.out b/tests/fixtures/ubuntu-22.04/amixer-control-capture.out new file mode 100644 index 00000000..906458e5 --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-capture.out @@ -0,0 +1,6 @@ +Simple mixer control 'Capture',0 + Capabilities: cvolume cswitch + Capture channels: Front Left - Front Right + Limits: Capture 0 - 63 + Front Left: Capture 63 [100%] [30.00dB] [on] + Front Right: Capture 63 [100%] [30.00dB] [on] diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-headphone-processed.json b/tests/fixtures/ubuntu-22.04/amixer-control-headphone-processed.json new file mode 100644 index 00000000..9156c7de --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-headphone-processed.json @@ -0,0 +1 @@ +{"control_name":"Headphone","capabilities":["pvolume","pswitch"],"playback_channels":["Front Left","Front Right"],"limits":{"playback_min":0,"playback_max":87},"front_left":{"playback_value":0,"percentage":0,"db":-65.25,"status":false},"front_right":{"playback_value":0,"percentage":0,"db":-65.25,"status":false}} \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-headphone.json b/tests/fixtures/ubuntu-22.04/amixer-control-headphone.json new file mode 100644 index 00000000..ad0bbb7e --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-headphone.json @@ -0,0 +1 @@ +{"control_name": "Headphone", "capabilities": ["pvolume", "pswitch"], "playback_channels": ["Front Left", "Front Right"], "limits": {"playback_min": "0", "playback_max": "87"}, "front_left": {"playback_value": "0", "percentage": "0%", "db": "-65.25db", "status": "off"}, "front_right": {"playback_value": "0", "percentage": "0%", "db": "-65.25db", "status": "off"}} \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-headphone.out b/tests/fixtures/ubuntu-22.04/amixer-control-headphone.out new file mode 100644 index 00000000..b83219e0 --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-headphone.out @@ -0,0 +1,7 @@ +Simple mixer control 'Headphone',0 + Capabilities: pvolume pswitch + Playback channels: Front Left - Front Right + Limits: Playback 0 - 87 + Mono: + Front Left: Playback 0 [0%] [-65.25dB] [off] + Front Right: Playback 0 [0%] [-65.25dB] [off] \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-master-processed.json b/tests/fixtures/ubuntu-22.04/amixer-control-master-processed.json new file mode 100644 index 00000000..687f96c8 --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-master-processed.json @@ -0,0 +1 @@ +{"control_name":"Master","capabilities":["pvolume","pvolume-joined","pswitch","pswitch-joined"],"playback_channels":["Mono"],"limits":{"playback_min":0,"playback_max":87},"mono":{"playback_value":87,"percentage":100,"db":0.0,"status":true}} \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-master.json b/tests/fixtures/ubuntu-22.04/amixer-control-master.json new file mode 100644 index 00000000..4485b058 --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-master.json @@ -0,0 +1 @@ +{"control_name": "Master", "capabilities": ["pvolume", "pvolume-joined", "pswitch", "pswitch-joined"], "playback_channels": ["Mono"], "limits": {"playback_min": "0", "playback_max": "87"}, "mono": {"playback_value": "87", "percentage": "100%", "db": "0.00db", "status": "on"}} \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-master.out b/tests/fixtures/ubuntu-22.04/amixer-control-master.out new file mode 100644 index 00000000..e3ff2463 --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-master.out @@ -0,0 +1,5 @@ +Simple mixer control 'Master',0 + Capabilities: pvolume pvolume-joined pswitch pswitch-joined + Playback channels: Mono + Limits: Playback 0 - 87 + Mono: Playback 87 [100%] [0.00dB] [on] \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-speakers-processed.json b/tests/fixtures/ubuntu-22.04/amixer-control-speakers-processed.json new file mode 100644 index 00000000..651b1aa9 --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-speakers-processed.json @@ -0,0 +1 @@ +{"control_name":"Speaker","capabilities":["pvolume","pswitch"],"playback_channels":["Front Left","Front Right"],"limits":{"playback_min":0,"playback_max":87},"front_left":{"playback_value":87,"percentage":100,"db":0.0,"status":true},"front_right":{"playback_value":87,"percentage":100,"db":0.0,"status":true}} \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-speakers.json b/tests/fixtures/ubuntu-22.04/amixer-control-speakers.json new file mode 100644 index 00000000..35b9b59d --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-speakers.json @@ -0,0 +1 @@ +{"control_name": "Speaker", "capabilities": ["pvolume", "pswitch"], "playback_channels": ["Front Left", "Front Right"], "limits": {"playback_min": "0", "playback_max": "87"}, "front_left": {"playback_value": "87", "percentage": "100%", "db": "0.00db", "status": "on"}, "front_right": {"playback_value": "87", "percentage": "100%", "db": "0.00db", "status": "on"}} \ No newline at end of file diff --git a/tests/fixtures/ubuntu-22.04/amixer-control-speakers.out b/tests/fixtures/ubuntu-22.04/amixer-control-speakers.out new file mode 100644 index 00000000..29a2bd50 --- /dev/null +++ b/tests/fixtures/ubuntu-22.04/amixer-control-speakers.out @@ -0,0 +1,7 @@ +Simple mixer control 'Speaker',0 + Capabilities: pvolume pswitch + Playback channels: Front Left - Front Right + Limits: Playback 0 - 87 + Mono: + Front Left: Playback 87 [100%] [0.00dB] [on] + Front Right: Playback 87 [100%] [0.00dB] [on] \ No newline at end of file diff --git a/tests/test_amixer.py b/tests/test_amixer.py new file mode 100644 index 00000000..bfa21616 --- /dev/null +++ b/tests/test_amixer.py @@ -0,0 +1,48 @@ +import unittest +import jc.parsers.amixer +import os +import json + +THIS_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class AmixerTests(unittest.TestCase): + AMIXER_CMD = 'amixer' + UBUNTU_22_04_TEST_FIXTURES_PATH = f'{THIS_DIR}/fixtures/ubuntu-22.04/' + AMIXER_CONTROL_PATH = f'{UBUNTU_22_04_TEST_FIXTURES_PATH}amixer-control-' + TEST_FILES_NAME = [ + f"{AMIXER_CONTROL_PATH}capture", + f'{AMIXER_CONTROL_PATH}headphone', + f'{AMIXER_CONTROL_PATH}master', + f'{AMIXER_CONTROL_PATH}speakers', + ] + + def setUp(self): + self.test_files_out = [f'{file}.out' for file in self.TEST_FILES_NAME] + self.test_files_json = [f'{file}.json' for file in self.TEST_FILES_NAME] + self.test_files_processed_json = [f'{file}-processed.json' for file in self.TEST_FILES_NAME] + + def test_amixer_sget(self): + for file_out, file_json, file_processed_json in zip(self.test_files_out, self.test_files_json, + self.test_files_processed_json): + with open(file_out, 'r') as f: + amixer_sget_raw_output: str = f.read() + with open(file_json, 'r') as f: + expected_amixer_sget_json_output: str = f.read() + expected_amixer_sget_json_map: dict = json.loads(expected_amixer_sget_json_output) + with open(file_processed_json, 'r') as f: + expected_amixer_sget_processed_json_output: str = f.read() + expected_amixer_sget_processed_json_map: dict = json.loads(expected_amixer_sget_processed_json_output) + + # Tests for raw=True + amixer_sget_json_map: dict = jc.parse(self.AMIXER_CMD, amixer_sget_raw_output, raw=True, + quiet=True) + self.assertEqual(amixer_sget_json_map, expected_amixer_sget_json_map) + # Tests for raw=False process + amixer_sget_json_processed_map: dict = jc.parse(self.AMIXER_CMD, amixer_sget_raw_output, raw=False, + quiet=True) + self.assertEqual(amixer_sget_json_processed_map, expected_amixer_sget_processed_json_map) + + +if __name__ == '__main__': + unittest.main()