diff --git a/monitorcontrol/__init__.py b/monitorcontrol/__init__.py index 729e7f9..6378ef6 100644 --- a/monitorcontrol/__init__.py +++ b/monitorcontrol/__init__.py @@ -5,6 +5,7 @@ get_input_name, Monitor, PowerMode, + AudioMuteMode, InputSource, ColorPreset, ) diff --git a/monitorcontrol/__main__.py b/monitorcontrol/__main__.py index 8040995..431e2e6 100644 --- a/monitorcontrol/__main__.py +++ b/monitorcontrol/__main__.py @@ -1,4 +1,4 @@ -from . import get_monitors, get_input_name, PowerMode +from . import get_monitors, get_input_name, PowerMode, AudioMuteMode from typing import List, Optional import argparse import importlib.metadata @@ -30,6 +30,12 @@ def get_parser() -> argparse.ArgumentParser: action="store_true", help="Get the luminance of the first monitor.", ) + group.add_argument("--set-volume", type=int, help="Set the volume of all monitors.") + group.add_argument( + "--get-volume", + action="store_true", + help="Get the volume of the first monitor.", + ) group.add_argument( "--get-power-mode", action="store_true", @@ -40,6 +46,16 @@ def get_parser() -> argparse.ArgumentParser: choices=[mode.name for mode in PowerMode], help="Set the power mode of all monitors.", ) + group.add_argument( + "--get-audio-mute-mode", + action="store_true", + help="Get the audio mute mode of the first monitor.", + ) + group.add_argument( + "--set-audio-mute-mode", + choices=[mode.name for mode in AudioMuteMode], + help="Set the audio mute mode of all monitors.", + ) group.add_argument( "--version", action="store_true", help="Show the version and exit." ) @@ -109,12 +125,24 @@ def main(argv: Optional[List[str]] = None): luminance = monitor_obj.get_luminance() print(str(luminance)) return + elif args.get_volume: + monitor_obj = get_monitors()[monitor_index] + with monitor_obj: + volume = monitor_obj.get_volume() + sys.stdout.write(str(volume) + "\n") + return elif args.get_power_mode: monitor_obj = get_monitors()[monitor_index] with monitor_obj: power = monitor_obj.get_power_mode() print(str(power.name)) return + elif args.get_audio_mute_mode: + monitor_obj = get_monitors()[monitor_index] + with monitor_obj: + audio_mute = monitor_obj.get_audio_mute_mode() + sys.stdout.write(str(audio_mute.name) + "\n") + return elif args.set_luminance is not None: if args.monitor is None: for monitor_obj in get_monitors(): @@ -125,6 +153,16 @@ def main(argv: Optional[List[str]] = None): with monitor_obj: monitor_obj.set_luminance(args.set_luminance) return + elif args.set_volume is not None: + if args.monitor is None: + for monitor_obj in get_monitors(): + with monitor_obj: + monitor_obj.set_volume(args.set_volume) + else: + monitor_obj = get_monitors()[monitor_index] + with monitor_obj: + monitor_obj.set_volume(args.set_volume) + return elif args.set_power_mode is not None: if args.monitor is None: for monitor_obj in get_monitors(): @@ -135,6 +173,16 @@ def main(argv: Optional[List[str]] = None): with monitor_obj: monitor_obj.set_power_mode(args.set_power_mode) return + elif args.set_audio_mute_mode is not None: + if args.monitor is None: + for monitor_obj in get_monitors(): + with monitor_obj: + monitor_obj.set_audio_mute_mode(args.set_audio_mute_mode) + else: + monitor_obj = get_monitors()[monitor_index] + with monitor_obj: + monitor_obj.set_audio_mute_mode(args.set_audio_mute_mode) + return elif args.get_input_source: monitor_obj = get_monitors()[monitor_index] with monitor_obj: diff --git a/monitorcontrol/monitorcontrol.py b/monitorcontrol/monitorcontrol.py index d2259db..8166a76 100644 --- a/monitorcontrol/monitorcontrol.py +++ b/monitorcontrol/monitorcontrol.py @@ -38,6 +38,16 @@ class PowerMode(enum.IntEnum): off_hard = 0x05 +@enum.unique +class AudioMuteMode(enum.Enum): + """Monitor audio mute modes.""" + + #: On. + on = 0x01 + #: Off. + off = 0x02 + + @enum.unique class InputSource(enum.IntEnum): """Monitor input sources.""" @@ -243,6 +253,49 @@ def set_luminance(self, value: int): """ self._set_vcp_feature(vcp_codes.image_luminance, value) + def get_volume(self) -> int: + """ + Gets the monitors sound volume level. + + Returns: + Current sound volume value. + + Example: + Basic Usage:: + + from monitorcontrol import get_monitors + + for monitor in get_monitors(): + with monitor: + print(monitor.get_volume()) + + Raises: + VCPError: Failed to get sound volume from the VCP. + """ + return self._get_vcp_feature(vcp_codes.sound_volume) + + def set_volume(self, value: int): + """ + Sets the monitors set_volume level. + + Args: + value: New set_volume value (typically 0-100). + + Example: + Basic Usage:: + + from monitorcontrol import get_monitors + + for monitor in get_monitors(): + with monitor: + monitor.set_volume(50) + + Raises: + ValueError: Luminance outside of valid range. + VCPError: Failed to set sound volume in the VCP. + """ + self._set_vcp_feature(vcp_codes.sound_volume, value) + def get_color_preset(self) -> int: """ Gets the monitors color preset. @@ -401,6 +454,65 @@ def set_power_mode(self, value: Union[int, str, PowerMode]): self._set_vcp_feature(vcp_codes.display_power_mode, mode_value) + def get_audio_mute_mode(self) -> AudioMuteMode: + """ + Get the monitor audio mute mode. + + Returns: + Value from the :py:class:`AudioMuteMode` enumeration. + + Example: + Basic Usage:: + + from monitorcontrol import get_monitors + + for monitor in get_monitors(): + with monitor: + print(monitor.get_audio_mute_mode()) + + Raises: + VCPError: Failed to get the audio mute mode. + ValueError: Set audio mute state outside of valid range. + KeyError: Set audio mute mode string is invalid. + """ + value = self._get_vcp_feature(vcp_codes.display_audio_mute_mode) + return AudioMuteMode(value) + + def set_audio_mute_mode(self, value: Union[int, str, AudioMuteMode]): + """ + Set the monitor audio mute mode. + + Args: + value: + An integer audio mute mode, + or a string representing the audio mute mode, + or a value from :py:class:`AudioMuteMode`. + + Example: + Basic Usage:: + + from monitorcontrol import get_monitors + + for monitor in get_monitors(): + with monitor: + monitor.set_audio_mute_mode("standby") + + Raises: + VCPError: Failed to get or set the audio mute mode + ValueError: audio mute state outside of valid range. + AttributeError: audio mute mode string is invalid. + """ + if isinstance(value, str): + mode_value = getattr(AudioMuteMode, value).value + elif isinstance(value, int): + mode_value = AudioMuteMode(value).value + elif isinstance(value, AudioMuteMode): + mode_value = value.value + else: + raise TypeError("unsupported mode type: " + repr(type(value))) + + self._set_vcp_feature(vcp_codes.display_audio_mute_mode, mode_value) + def get_input_source(self) -> int: """ Gets the monitors input source diff --git a/monitorcontrol/vcp/vcp_codes.py b/monitorcontrol/vcp/vcp_codes.py index 55c2629..d1444ab 100644 --- a/monitorcontrol/vcp/vcp_codes.py +++ b/monitorcontrol/vcp/vcp_codes.py @@ -56,6 +56,12 @@ def writeable(self) -> bool: code_type="rw", function="c", ) +sound_volume = VCPCode( + name="sound volume", + value=0x62, + code_type="rw", + function="c", +) image_contrast = VCPCode( name="image contrast", value=0x12, @@ -98,3 +104,9 @@ def writeable(self) -> bool: code_type="rw", function="nc", ) +display_audio_mute_mode = VCPCode( + name="display audio mute mode", + value=0x8D, + code_type="rw", + function="nc", +) diff --git a/tests/test_cli.py b/tests/test_cli.py index d0dfd22..327a100 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -33,6 +33,19 @@ def test_set_luminance(value: int): api_mock.assert_called_once_with(value) +def test_get_volume(): + with get_monitors_mock, mock.patch.object(Monitor, "get_volume") as api_mock: + main(["--get-volume"]) + api_mock.assert_called_once() + + +@pytest.mark.parametrize("value", [0, 100]) +def test_set_volume(value: int): + with get_monitors_mock, mock.patch.object(Monitor, "set_volume") as api_mock: + main(["--set-volume", str(value)]) + api_mock.assert_called_once_with(value) + + def test_get_power(): with get_monitors_mock, mock.patch.object(Monitor, "get_power_mode") as api_mock: main(["--get-power"]) @@ -46,6 +59,25 @@ def test_set_power(value: int): api_mock.assert_called_once_with(value) +def test_get_audio_mute(): + with ( + get_monitors_mock, + mock.patch.object(Monitor, "get_audio_mute_mode") as api_mock, + ): + main(["--get-audio-mute"]) + api_mock.assert_called_once() + + +@pytest.mark.parametrize("value", ["on", "off"]) +def test_set_audio_mute(value: int): + with ( + get_monitors_mock, + mock.patch.object(Monitor, "set_audio_mute_mode") as api_mock, + ): + main(["--set-audio-mute", str(value)]) + api_mock.assert_called_once_with(value) + + def test_get_input_source(): with get_monitors_mock, mock.patch.object(Monitor, "get_input_source") as api_mock: main(["--get-input-source"]) diff --git a/tests/test_monitorcontrol.py b/tests/test_monitorcontrol.py index 764d7fa..32fb698 100644 --- a/tests/test_monitorcontrol.py +++ b/tests/test_monitorcontrol.py @@ -81,7 +81,9 @@ def get_test_vcps() -> List[Type[vcp.VCP]]: else: unit_test_vcp_dict = { 0x10: {"current": 50, "maximum": 100}, + 0x62: {"current": 50, "maximum": 100}, 0xD6: {"current": 1, "maximum": 5}, + 0x8D: {"current": 1, "maximum": 2}, 0x12: {"current": 50, "maximum": 100}, 0x60: {"current": "HDMI1", "maximum": 3}, } @@ -131,6 +133,22 @@ def test_luminance( monitor.set_luminance(original) +@pytest.mark.parametrize( + "volume, expected", [(100, 100), (0, 0), (50, 50), (101, ValueError)] +) +def test_volume(monitor: Monitor, volume: int, expected: Union[int, Type[Exception]]): + original = monitor.get_volume() + try: + if isinstance(expected, int): + monitor.set_volume(volume) + assert monitor.get_volume() == expected + elif isinstance(expected, type(Exception)): + with pytest.raises(expected): + monitor.set_volume(volume) + finally: + monitor.set_volume(original) + + @pytest.mark.skipif(USE_ATTACHED_MONITORS, reason="not going to change your contrast") def test_contrast(monitor: Monitor): contrast = monitor.get_contrast() @@ -177,6 +195,32 @@ def test_power_mode( monitor.set_power_mode(mode) +@pytest.mark.skipif(USE_ATTACHED_MONITORS, reason="not going to mute your monitors") +@pytest.mark.parametrize( + "mode, expected", + [ + ("on", 0x01), + (0x01, 0x01), + ("INVALID", AttributeError), + (["on"], TypeError), + (0x00, ValueError), + (0x03, ValueError), + ("off", 0x02), + ], +) +def test_audio_mute_mode( + monitor: Monitor, + mode: Union[str, int], + expected: Union[int, Type[Exception]], +): + if isinstance(expected, (int, str)): + monitor.set_audio_mute_mode(mode) + assert monitor.get_audio_mute_mode().value == expected + elif isinstance(expected, type(Exception)): + with pytest.raises(expected): + monitor.set_audio_mute_mode(mode) + + # ASUS VG27A when set to a mode that doesnt exist returned analog1 (0x1) @pytest.mark.skipif( USE_ATTACHED_MONITORS, reason="Real monitors dont support all input types"