From a7dca66d26e77414e511ed2dee82de371b7a9da3 Mon Sep 17 00:00:00 2001 From: Jeff Hoskinson Date: Sat, 8 Mar 2025 23:02:00 -0500 Subject: [PATCH 1/2] Implement volume control for Windows using pycaw --- IoTuring/Entity/Deployments/Volume/Volume.py | 53 +++++++++++++++++--- pyproject.toml | 3 +- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/IoTuring/Entity/Deployments/Volume/Volume.py b/IoTuring/Entity/Deployments/Volume/Volume.py index c10f12562..4d0f56d3d 100644 --- a/IoTuring/Entity/Deployments/Volume/Volume.py +++ b/IoTuring/Entity/Deployments/Volume/Volume.py @@ -1,9 +1,13 @@ import re +from contextlib import contextmanager from IoTuring.Entity.Entity import Entity from IoTuring.Entity.EntityData import EntityCommand, EntitySensor from IoTuring.Entity.ValueFormat import ValueFormatterOptions from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD +if OsD.IsWindows(): + import comtypes + from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume KEY_STATE = 'volume_state' KEY_CMD = 'volume' @@ -63,6 +67,8 @@ def Update(self): self.SetEntitySensorValue(KEY_STATE, output_volume) self.SetEntitySensorExtraAttribute( KEY_STATE, EXTRA_KEY_MUTED_OUTPUT, output_muted) + elif OsD.IsWindows(): + self.UpdateWindows() def Callback(self, message): payloadString = message.payload.decode('utf-8') @@ -72,14 +78,18 @@ def Callback(self, message): if not 0 <= volume <= 100: raise Exception('Incorrect payload!') - cmd = commands[OsD.GetOs()] + if OsD.IsLinux() or OsD.IsMacos(): + cmd = commands[OsD.GetOs()] - # Unmute on Linux: - if 0 < volume and OsD.IsLinux(): - cmd = UNMUTE_PREFIX_LINUX + " && " + cmd + # Unmute on Linux: + if 0 < volume and OsD.IsLinux(): + cmd = UNMUTE_PREFIX_LINUX + " && " + cmd - self.RunCommand(command=cmd.format(volume), - shell=True) + self.RunCommand(command=cmd.format(volume), + shell=True) + elif OsD.IsWindows(): + with Volume.WindowsVolumeControl() as volume_control: + volume_control.SetMasterVolumeLevelScalar(volume / 100.0, None) def UpdateMac(self): # result like: output volume:44, input volume:89, alert volume:100, output muted:false @@ -101,6 +111,35 @@ def UpdateMac(self): KEY_STATE, EXTRA_KEY_ALERT_VOLUME, alert_volume, valueFormatterOptions=VALUEFORMATTEROPTIONS_PERCENTAGE_ROUND0) self.SetEntitySensorExtraAttribute( KEY_STATE, EXTRA_KEY_MUTED_OUTPUT, output_muted) + + def UpdateWindows(self): + with Volume.WindowsVolumeControl() as volume_control: + output_volume = int(volume_control.GetMasterVolumeLevelScalar() * 100) + output_muted = volume_control.GetMute() + + self.SetEntitySensorValue(KEY_STATE, output_volume) + self.SetEntitySensorExtraAttribute( + KEY_STATE, EXTRA_KEY_OUTPUT_VOLUME, output_volume, valueFormatterOptions=VALUEFORMATTEROPTIONS_PERCENTAGE_ROUND0) + self.SetEntitySensorExtraAttribute( + KEY_STATE, EXTRA_KEY_MUTED_OUTPUT, output_muted) + + @classmethod + @contextmanager + def WindowsVolumeControl(cls): + """ + Context manager to retrieve the main Windows volume control. + + Since these methods can be called in a subprocess, we need to wrap + the COM calls in CoInitialize and CoUninitialize calls. + + See https://github.com/AndreMiras/pycaw/issues/34#issuecomment-826107126 + """ + comtypes.CoInitialize() + devices = AudioUtilities.GetSpeakers() + interface = devices.Activate( + IAudioEndpointVolume._iid_, comtypes.CLSCTX_ALL, None) + yield interface.QueryInterface(IAudioEndpointVolume) + comtypes.CoUninitialize() @classmethod def CheckSystemSupport(cls): @@ -108,5 +147,3 @@ def CheckSystemSupport(cls): if not OsD.CommandExists("pactl"): raise Exception( "Only PulseAudio is supported on Linux! Please open an issue on Github!") - elif not OsD.IsMacos(): - raise cls.UnsupportedOsException() diff --git a/pyproject.toml b/pyproject.toml index cf8540444..7a28304ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ dependencies = [ "InquirerPy", "PyObjC; sys_platform == 'darwin'", "IoTuring-applesmc; sys_platform == 'darwin'", - "tinyWinToast; sys_platform == 'win32'" + "tinyWinToast; sys_platform == 'win32'", + "pycaw; sys_platform == 'win32'" ] [project.optional-dependencies] From e51b2c651d5afa5101053ff4fce6a398e03c685d Mon Sep 17 00:00:00 2001 From: Jeff Hoskinson Date: Sun, 9 Mar 2025 22:13:38 -0400 Subject: [PATCH 2/2] Wrap Windows imports in try/except for cleaner error handling --- IoTuring/Entity/Deployments/Volume/Volume.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/IoTuring/Entity/Deployments/Volume/Volume.py b/IoTuring/Entity/Deployments/Volume/Volume.py index 4d0f56d3d..dc5a6d814 100644 --- a/IoTuring/Entity/Deployments/Volume/Volume.py +++ b/IoTuring/Entity/Deployments/Volume/Volume.py @@ -5,9 +5,14 @@ from IoTuring.Entity.EntityData import EntityCommand, EntitySensor from IoTuring.Entity.ValueFormat import ValueFormatterOptions from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD -if OsD.IsWindows(): + +# Windows dependencies +try: import comtypes from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume + windows_support = True +except BaseException: + windows_support = False KEY_STATE = 'volume_state' KEY_CMD = 'volume' @@ -147,3 +152,6 @@ def CheckSystemSupport(cls): if not OsD.CommandExists("pactl"): raise Exception( "Only PulseAudio is supported on Linux! Please open an issue on Github!") + elif OsD.IsWindows(): + if not windows_support: + raise Exception("Unable to load Windows dependencies for this entity")