From a39cb05228549e01f3403ff90139d220ff5fc1eb Mon Sep 17 00:00:00 2001
From: Eden Refael <42403145+AvocadoStyle@users.noreply.github.com>
Date: Sat, 21 Dec 2024 01:06:38 +0200
Subject: [PATCH] created the amixer sget command parser - READY FOR REVIEW
 (#616)

* created the amixer first skeleton

* push testing and integrate this commit and branch with issue: #591

* #591 checks the input data with jc utils

* created the data parser of the sget control of the amixer sget <controller> command.

* test commit - just for tests

* another test commit

* another test commit

* created a dedicated pseudo algorithm for the amixer sget and tried various of strings.

* orginized the docstring with general explanation about the tool and the amixer tool output and algorithm of the input parsing and input examples.

* created raw implementation, but it's raw either or either.

* orginized the content inside the amixer parser

* removed endpoint name

* added amixer to the jc parser in lib

* more explanations

* added tests for the amixer sget

* added tests for the amixer sget

* fine versioning fix

* created docstring+another explanations seperated.

* created the amixer parser docu

* added the amixer in alphabet order to the json convert lib

* Fix PEP 8: E302 violation as part of boy scout principle

* deleted not necessary file

* fixed the spaces between sections in the amixer description

* resolved commits such as amixer module docstring and preperations for  parser for raw=False.

* Revert "Fix PEP 8: E302 violation as part of boy scout principle"

This reverts commit 241d1a1c630bd21ef7bd443d55a3eb149d77c943.

* created the dedicated _process for raw=False

* created the dedicated _process for raw=False

* added tests for the _process raw=False.

* changed keys to be lowercase snake-case - Change 'dB' to 'db'

* added more dB -> db changes and used int convertor of the jc utils

---------

Co-authored-by: EdenRafael <eden.refael@kazuar.com>
Co-authored-by: Eden Refael <edeenraf@hotmail.com>
Co-authored-by: Kelly Brazil <kellyjonbrazil@gmail.com>
---
 jc/lib.py                                     |   1 +
 jc/parsers/amixer.py                          | 277 ++++++++++++++++++
 .../amixer-control-capture-processed.json     |   1 +
 .../ubuntu-22.04/amixer-control-capture.json  |   1 +
 .../ubuntu-22.04/amixer-control-capture.out   |   6 +
 .../amixer-control-headphone-processed.json   |   1 +
 .../amixer-control-headphone.json             |   1 +
 .../ubuntu-22.04/amixer-control-headphone.out |   7 +
 .../amixer-control-master-processed.json      |   1 +
 .../ubuntu-22.04/amixer-control-master.json   |   1 +
 .../ubuntu-22.04/amixer-control-master.out    |   5 +
 .../amixer-control-speakers-processed.json    |   1 +
 .../ubuntu-22.04/amixer-control-speakers.json |   1 +
 .../ubuntu-22.04/amixer-control-speakers.out  |   7 +
 tests/test_amixer.py                          |  48 +++
 15 files changed, 359 insertions(+)
 create mode 100644 jc/parsers/amixer.py
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-capture-processed.json
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-capture.json
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-capture.out
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-headphone-processed.json
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-headphone.json
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-headphone.out
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-master-processed.json
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-master.json
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-master.out
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-speakers-processed.json
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-speakers.json
 create mode 100644 tests/fixtures/ubuntu-22.04/amixer-control-speakers.out
 create mode 100644 tests/test_amixer.py

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 @@ parsers: List[str] = [
     '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 <control_name> | 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', <amixer sget command output>)
+
+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/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()