From ef9491be74c076766349dce2c09a432da5e6608f Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 7 Sep 2024 04:18:52 +0200 Subject: [PATCH 1/6] Support multiple connected sensors --- IoTuring/Entity/Entity.py | 14 ++- IoTuring/Entity/EntityData.py | 39 +++++--- .../HomeAssistantWarehouse.py | 94 ++++++++++--------- pyproject.toml | 4 +- 4 files changed, 85 insertions(+), 66 deletions(-) diff --git a/IoTuring/Entity/Entity.py b/IoTuring/Entity/Entity.py index 06369058f..743764846 100644 --- a/IoTuring/Entity/Entity.py +++ b/IoTuring/Entity/Entity.py @@ -19,6 +19,9 @@ class Entity(ConfiguratorObject, LogObject): + entitySensors: list[EntitySensor] + entityCommands: list[EntityCommand] + def __init__(self, single_configuration: SingleConfiguration) -> None: super().__init__(single_configuration) @@ -128,11 +131,12 @@ def GetAllEntityData(self) -> list: """ safe - Return list of entity sensors and commands """ return self.entityCommands.copy() + self.entitySensors.copy() # Safe return: nobody outside can change the callback ! - def GetAllUnconnectedEntityData(self) -> list[EntityData]: - """ safe - Return All EntityCommands and EntitySensors without connected command """ - connected_sensors = [command.GetConnectedEntitySensor() - for command in self.entityCommands - if command.SupportsState()] + def GetAllUnconnectedEntityData(self) -> list[EntityCommand|EntitySensor]: + """ safe - Return All EntityCommands and EntitySensors without connected sensors """ + connected_sensors = [] + for command in self.entityCommands: + connected_sensors.extend(command.GetConnectedEntitySensors()) + unconnected_sensors = [sensor for sensor in self.entitySensors if sensor not in connected_sensors] return self.entityCommands.copy() + unconnected_sensors.copy() diff --git a/IoTuring/Entity/EntityData.py b/IoTuring/Entity/EntityData.py index 701ebae59..f43cb3b50 100644 --- a/IoTuring/Entity/EntityData.py +++ b/IoTuring/Entity/EntityData.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: from IoTuring.Entity.Entity import Entity @@ -117,24 +117,35 @@ def SetExtraAttribute(self, attribute_name, attribute_value, valueFormatterOptio class EntityCommand(EntityData): - def __init__(self, entity, key, callbackFunction, - connectedEntitySensorKey=None, customPayload={}): - """ - If a key for the entity sensor is passed, warehouses that support it use this command as a switch with state. - Better to register the sensor before this command to avoud unexpected behaviours. - CustomPayload overrides HomeAssistant discovery configuration + def __init__(self, entity: Entity, key: str, callbackFunction: Callable, + connectedEntitySensorKeys: str | list = [], + customPayload={}): + """Create a new EntityCommand. + + If key or keys for the entity sensor is passed, warehouses that support it can use this command as a switch with state. + Order of sensors matter, first sensors state topic will be used. + Better to register the sensors before this command to avoid unexpected behaviours. + + Args: + entity (Entity): The entity this command belongs to. + key (str): The KEY of this command + callbackFunction (Callable): Function to be called + connectedEntitySensorKeys (str | list, optional): A key to a sensor or a list of keys. Defaults to []. + customPayload (dict, optional): Overrides HomeAssistant discovery configuration. Defaults to {}. """ + EntityData.__init__(self, entity, key, customPayload) self.callbackFunction = callbackFunction - self.connectedEntitySensorKey = connectedEntitySensorKey + self.connectedEntitySensorKeys = connectedEntitySensorKeys if isinstance( + connectedEntitySensorKeys, list) else [connectedEntitySensorKeys] - def SupportsState(self): - return self.connectedEntitySensorKey is not None + def SupportsState(self) -> bool: + """ True if this command supports state (has a connected sensors) """ + return bool(self.connectedEntitySensorKeys) - def GetConnectedEntitySensor(self) -> EntitySensor: - """ Returns the entity sensor connected to this command, if this command supports state. - Otherwise returns None. """ - return self.GetEntity().GetEntitySensorByKey(self.connectedEntitySensorKey) + def GetConnectedEntitySensors(self) -> list[EntitySensor]: + """ Returns the entity sensors connected to this command. Returns empty list if none found. """ + return [self.GetEntity().GetEntitySensorByKey(key) for key in self.connectedEntitySensorKeys] def CallCallback(self, message): """ Safely run callback for this command, passing the message (a paho.mqtt.client.MQTTMessage). diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py index c3182bbcd..353d78f12 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/HomeAssistantWarehouse.py @@ -39,7 +39,6 @@ CONFIGURATION_SEND_LOOP_SKIP_NUMBER = 10 -EXTERNAL_ENTITY_DATA_CONFIGURATION_FILE_FILENAME = "entities.yaml" LWT_TOPIC_SUFFIX = "LWT" LWT_PAYLOAD_ONLINE = "ONLINE" @@ -47,6 +46,12 @@ PAYLOAD_ON = consts.STATE_ON PAYLOAD_OFF = consts.STATE_OFF +# Entity configuration file for HAWH: +EXTERNAL_ENTITY_DATA_CONFIGURATION_FILE_FILENAME = "entities.yaml" +# Set HA entity type, e.g. number, light, switch: +ENTITY_CONFIG_CUSTOM_TYPE_KEY = "custom_type" +# Custom topic keys for discovery. Use list for multiple topics: +ENTITY_CONFIG_CUSTOM_TOPIC_SUFFIX = "_key" class HomeAssistantEntityBase(LogObject): """ Base class for all entities in HomeAssistantWarehouse """ @@ -76,10 +81,7 @@ def __init__(self, self.GetEntityDataCustomConfigurations(self.name) # Get data type: - if "custom_type" in self.discovery_payload: - self.data_type = self.discovery_payload.pop("custom_type") - else: - self.data_type = "" + self.data_type = self.discovery_payload.pop(ENTITY_CONFIG_CUSTOM_TYPE_KEY, "") # Set name: self.SetDiscoveryPayloadName() @@ -125,8 +127,16 @@ def AddTopic(self, topic_name: str, topic_path: str = ""): # Add as an attribute: setattr(self, topic_name, topic_path) - # Add to the discovery payload: - self.discovery_payload[topic_name] = topic_path + + # Check for custom topic: + discovery_keys = self.discovery_payload.pop(topic_name + ENTITY_CONFIG_CUSTOM_TOPIC_SUFFIX, topic_name) + if not isinstance(discovery_keys, list): + discovery_keys = [discovery_keys] + + # Add to discovery payload: + for discovery_key in discovery_keys: + self.discovery_payload[discovery_key] = topic_path + def SendTopicData(self, topic, data) -> None: self.wh.client.SendTopicData(topic, data) @@ -181,7 +191,7 @@ def __init__(self, entityData: EntityData, wh: "HomeAssistantWarehouse") -> None self.default_topics = { "availability_topic": self.wh.MakeValuesTopic(LWT_TOPIC_SUFFIX), "state_topic": self.MakeEntityDataTopic(self.entityData), - "json_attributes_topic": self.MakeEntityDataExtraAttributesTopic(self.entityData), + "json_attributes_topic": self.MakeEntityDataTopic(self.entityData, TOPIC_DATA_EXTRA_ATTRIBUTES_SUFFIX), "command_topic": self.MakeEntityDataTopic(self.entityData) } @@ -214,14 +224,10 @@ def SetDiscoveryPayloadName(self) -> None: return super().SetDiscoveryPayloadName() - def MakeEntityDataTopic(self, entityData: EntityData) -> str: + def MakeEntityDataTopic(self, entityData: EntityData, suffix:str = "") -> str: """ Uses MakeValuesTopic but receives an EntityData to manage itself its id""" - return self.wh.MakeValuesTopic(entityData.GetId()) + return self.wh.MakeValuesTopic(entityData.GetId() + suffix) - def MakeEntityDataExtraAttributesTopic(self, entityData: EntityData) -> str: - """ Uses MakeValuesTopic but receives an EntityData to manage itself its id, appending a suffix to distinguish - the extra attrbiutes from the original value """ - return self.wh.MakeValuesTopic(entityData.GetId() + TOPIC_DATA_EXTRA_ATTRIBUTES_SUFFIX) class HomeAssistantSensor(HomeAssistantEntity): @@ -254,11 +260,13 @@ def SendValues(self, callback_value:str|None= None): """ if self.entitySensor.HasValue(): - if callback_value is not None: - sensor_value = callback_value + if callback_value is None: + value = self.entitySensor.GetValue() else: - sensor_value = ValueFormatter.FormatValue( - self.entitySensor.GetValue(), + value = callback_value + + sensor_value = ValueFormatter.FormatValue( + value, self.entitySensor.GetValueFormatterOptions(), INCLUDE_UNITS_IN_SENSORS) @@ -283,47 +291,44 @@ def __init__(self, entityData: EntityCommand, wh: "HomeAssistantWarehouse") -> N self.entityCommand = entityData - self.AddTopic("availability_topic") self.AddTopic("command_topic") - self.connected_sensor = self.GetConnectedSensor() + self.connected_sensors = self.GetConnectedSensors() - if self.connected_sensor: + if self.connected_sensors: self.SetDefaultDataType("switch") - # Get discovery payload from connected sensor? - for payload_key in self.connected_sensor.discovery_payload: - if payload_key not in self.discovery_payload: - self.discovery_payload[payload_key] = self.connected_sensor.discovery_payload[payload_key] + # Get discovery payload from connected sensors + for sensor in self.connected_sensors: + for payload_key in sensor.discovery_payload: + if payload_key not in self.discovery_payload: + self.discovery_payload[payload_key] = sensor.discovery_payload[payload_key] else: # Button as default data type: self.SetDefaultDataType("button") self.command_callback = self.GenerateCommandCallback() - def GetConnectedSensor(self) -> HomeAssistantSensor | None: - """ Get the connected sensor of this command """ - if self.entityCommand.SupportsState(): - return HomeAssistantSensor( - entityData=self.entityCommand.GetConnectedEntitySensor(), - wh=self.wh) - else: - return None + + def GetConnectedSensors(self) -> list[HomeAssistantSensor]: + """ Get the connected sensors of this command """ + return [HomeAssistantSensor(entityData=sensor, wh=self.wh) + for sensor in self.entityCommand.GetConnectedEntitySensors()] + def GenerateCommandCallback(self) -> Callable: """ Generate the callback function """ def CommandCallback(message): status = self.entityCommand.CallCallback(message) if status and self.wh.client.IsConnected(): - if self.connected_sensor: + if self.connected_sensors: # Only set value if it was already set, to exclude optimistic switches - if self.connected_sensor.entitySensor.HasValue(): - self.Log(self.LOG_DEBUG, "Switch callback: sending state to " + - self.connected_sensor.state_topic) - self.connected_sensor.SendValues(callback_value = message.payload.decode('utf-8')) + for sensor in self.connected_sensors: + if sensor.entitySensor.HasValue(): + sensor.SendValues(callback_value = message.payload.decode('utf-8')) - # Optimistic switches with extra attributes: - elif self.connected_sensor.supports_extra_attributes: - self.connected_sensor.SendExtraAttributes() + # Optimistic switches with extra attributes: + elif sensor.supports_extra_attributes: + sensor.SendExtraAttributes() return CommandCallback @@ -387,7 +392,7 @@ def Start(self): super().Start() # Then run other inits (start the Loop method for example) def CollectEntityData(self) -> None: - """ Collect entities and save them ass hass entities """ + """ Collect entities and save them as hass entities """ # Add the Lwt sensor: self.homeAssistantEntities["sensors"].append(LwtSensor(self)) @@ -399,9 +404,8 @@ def CollectEntityData(self) -> None: # It's a command: if isinstance(entityData, EntityCommand): hasscommand = HomeAssistantCommand(entityData, self) - if hasscommand.connected_sensor: - self.homeAssistantEntities["connected_sensors"].append( - hasscommand.connected_sensor) + if hasscommand.connected_sensors: + self.homeAssistantEntities["connected_sensors"].extend(hasscommand.connected_sensors) self.homeAssistantEntities["commands"].append(hasscommand) # It's a sensor: diff --git a/pyproject.toml b/pyproject.toml index cec873bb7..cf8540444 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "IoTuring" version = "2024.6.1" -description = "Simple and powerful cross-platform script to control your pc and share statistics using communication protocols like MQTT and home control hubs like HomeAssistant." +description = "Your Windows, Linux, macOS computer as MQTT and HomeAssistant integration." readme = "README.md" requires-python = ">=3.8" license = {file = "COPYING"} -keywords = ["iot","mqtt","monitor"] +keywords = ["iot","mqtt","monitor","homeassistant"] authors = [ {name = "richibrics", email = "riccardo.briccola.dev@gmail.com"}, {name = "infeeeee", email = "gyetpet@mailbox.org"} From ac7f447b5b086abf629ded4c75a8ea298467303e Mon Sep 17 00:00:00 2001 From: Jonas Fenchel Date: Sat, 21 Sep 2024 20:30:06 +0200 Subject: [PATCH 2/6] implement brightness for linux with acpi --- .../Deployments/Brightness/Brightness.py | 149 +++++++++++++++ .../ToImplement/Brightness/Brightness.py | 170 ------------------ .../ToImplement/Brightness/settings.yaml | 16 -- .../HomeAssistantWarehouse/entities.yaml | 5 + 4 files changed, 154 insertions(+), 186 deletions(-) create mode 100644 IoTuring/Entity/Deployments/Brightness/Brightness.py delete mode 100644 IoTuring/Entity/ToImplement/Brightness/Brightness.py delete mode 100644 IoTuring/Entity/ToImplement/Brightness/settings.yaml diff --git a/IoTuring/Entity/Deployments/Brightness/Brightness.py b/IoTuring/Entity/Deployments/Brightness/Brightness.py new file mode 100644 index 000000000..87fe2df40 --- /dev/null +++ b/IoTuring/Entity/Deployments/Brightness/Brightness.py @@ -0,0 +1,149 @@ +from IoTuring.Entity.Entity import Entity +from IoTuring.Configurator.MenuPreset import MenuPreset +from IoTuring.Entity.EntityData import EntitySensor, EntityCommand +from IoTuring.MyApp.SystemConsts.OperatingSystemDetection import OperatingSystemDetection as OsD +from IoTuring.Entity.ValueFormat import ValueFormatterOptions +import subprocess +import re +import os +import sys + + +KEY = 'brightness' +KEY_STATE = 'brightness_state' + +CONFIG_KEY_GPU = 'gpu' + +VALUEFORMATTER_OPTIONS_PERCENT = ValueFormatterOptions( + ValueFormatterOptions.TYPE_PERCENTAGE) + +class Brightness(Entity): + NAME = "Brightness" + ALLOW_MULTI_INSTANCE = True + + + def Initialize(self): + + self.RegisterEntitySensor( + EntitySensor(self, KEY_STATE, + supportsExtraAttributes=False, + valueFormatterOptions=VALUEFORMATTER_OPTIONS_PERCENT)) + + + + if OsD.IsWindows(): + self.specificGetBrightness = self.GetBrightness_Win + self.specificSetBrightness = self.SetBrightness_Win + import wmi + import pythoncom + if OsD.IsMacos(): + self.specificGetBrightness = self.GetBrightness_macOS + self.specificSetBrightness = self.SetBrightness_macOS + if OsD.IsLinux(): + self.configuredGPU: str = self.GetFromConfigurations(CONFIG_KEY_GPU) + self.specificGetBrightness = self.GetBrightness_Linux + self.specificSetBrightness = self.SetBrightness_Linux + else: + self.Log(self.Logger.LOG_WARNING, + 'No brightness sensor available for this operating system') + + self.RegisterEntityCommand(EntityCommand(self, KEY, self.Callback, KEY_STATE)) + + def Callback(self, message): + state = message.payload.decode("utf-8") + try: + # Value from 0 and 100 + self.specificSetBrightness(int(state)) + except ValueError: # Not int -> not a message for that function + return + except Exception as e: + raise Exception("Error during brightness set: " + str(e)) + + + def Update(self): + brightness = self.specificGetBrightness() + self.SetEntitySensorValue(KEY_STATE, brightness) + + def SetBrightness_macOS(self, value: str|int): + value = value/100 # cause I need it from 0 to 1 + command = 'brightness ' + str(value) + subprocess.Popen(command.split(), stdout=subprocess.PIPE) + + def SetBrightness_Linux(self, value): + # use acpi to controll backlight + with open(f'/sys/class/backlight/{self.configuredGPU}/brightness', 'w') as file: + file.write(f'{str(value)}\n') + + def SetBrightness_Win(self, value): + pythoncom.CoInitialize() + return wmi.WMI(namespace='wmi').WmiMonitorBrightnessMethods()[0].WmiSetBrightness(value, 0) + + def GetBrightness_macOS(self) -> float: + try: + command = 'brightness -l' + process = subprocess.Popen(command.split(), stdout=subprocess.PIPE) + stdout = process.communicate()[0] + brightness = re.findall( + 'display 0: brightness.*$', str(stdout))[0][22:30] + brightness = float(brightness)*100 # is between 0 and 1 + return brightness + except: + raise Exception( + 'You sure you installed Brightness from Homebrew ? (else try checking you PATH)') + + def GetBrightness_Linux(self) -> int: + # get the content of the file /sys/class/backlight/intel_backlight/brightness + with open(f'/sys/class/backlight/{self.configuredGPU}/brightness', 'r') as file: + content = file.read() + brightness = int(content.strip('\n')) + return self.ConvertBrightness(brightness, from_scale=255, to_scale=100) + + def GetBrightness_Win(self) -> int: + return int(wmi.WMI(namespace='wmi').WmiMonitorBrightness()[0].CurrentBrightness) + + def ConvertBrightness(self, value, from_scale=255, to_scale=100) -> int: + """Function to convert brightness values from one scale to another. + + Args: + value (int): The brightness value to convert. + from_scale (int): The original scale of the brightness value. Default is 255. + to_scale (int): The target scale of the brightness value. Default is 100. + + Returns: + float: The converted brightness value. + """ + return int((value / from_scale) * to_scale) + + + @classmethod + def ConfigurationPreset(cls): + preset = MenuPreset() + if OsD.IsLinux(): + # find all GPUs in /sys/class/backlight by listing all directories + gpus = [gpu for gpu in os.listdir('/sys/class/backlight') if os.path.isdir(f'/sys/class/backlight/{gpu}')] + + preset.AddEntry( + name="which GPUs backlight you want to control?", + key=CONFIG_KEY_GPU, + question_type='select', + choices=gpus + ) + return preset + + @classmethod + def CheckSystemSupport(cls): + if OsD.IsWindows(): #TODO needs to be tested + # if wmi and pythoncom are not available, raise an exception + if ['wmi', 'pythoncom'] not in sys.modules: + raise Exception( + 'Brightness not available, have you installed \'wmi\' on pip ?') + elif OsD.IsMacos(): #TODO needs to be tested + if not OsD.CommandExists('brightness'): + raise Exception( + 'Brightness not avaidlable, have you installed \'brightness\' on Homebrew ?') + elif OsD.IsLinux(): + if not os.path.exists('/sys/class/backlight'): #TODO check if this dir always exists + raise Exception( + 'Brightness not available, no backlight found in /sys/class/backlight') + else: + raise NotImplementedError('Brightness not available for this OS') \ No newline at end of file diff --git a/IoTuring/Entity/ToImplement/Brightness/Brightness.py b/IoTuring/Entity/ToImplement/Brightness/Brightness.py deleted file mode 100644 index d7c5d6c47..000000000 --- a/IoTuring/Entity/ToImplement/Brightness/Brightness.py +++ /dev/null @@ -1,170 +0,0 @@ -from IoTuring.Entity.Entity import Entity -import subprocess - -supports_win_brightness = True -try: - import wmi # Only to get windows brightness - import pythoncom -except: - supports_win_brightness = False - - -IN_TOPIC = 'brightness/set' # Receive a set message -OUT_TOPIC = 'brightness/get' # Send a message with the value - - -class Brightness(Entity): - def Initialize(self): - self.SubscribeToTopic(IN_TOPIC) - self.AddTopic(OUT_TOPIC) - self.stopCommand = False - self.stopSensor = False - self.stateOff = False - - def PostInitialize(self): - os = self.GetOS() - - # Sensor function settings - if(os == self.consts.FIXED_VALUE_OS_WINDOWS): - self.GetBrightness_OS = self.GetBrightness_Win - elif(os == self.consts.FIXED_VALUE_OS_MACOS): - self.GetBrightness_OS = self.GetBrightness_macOS - elif(os == self.consts.FIXED_VALUE_OS_LINUX): - self.GetBrightness_OS = self.GetBrightness_Linux - else: - self.Log(self.Logger.LOG_WARNING, - 'No brightness sensor available for this operating system') - self.stopSensor = True - - # Command function settings - if(os == self.consts.FIXED_VALUE_OS_WINDOWS): - self.SetBrightness_OS = self.SetBrightness_Win - elif(os == self.consts.FIXED_VALUE_OS_MACOS): - self.SetBrightness_OS = self.SetBrightness_macOS - elif(os == self.consts.FIXED_VALUE_OS_LINUX): - self.SetBrightness_OS = self.SetBrightness_Linux - else: - self.Log(self.Logger.LOG_WARNING, - 'No brightness command available for this operating system') - self.stopCommand = True - - - def Callback(self, message): - state = message.payload.decode("utf-8") - if not self.stopCommand: - - if state == self.consts.ON_STATE and self.stateOff is not False: - state = self.stateOff if self.stateOff is not None else 100 - - if state == self.consts.OFF_STATE: - self.stateOff = self.GetTopicValue(OUT_TOPIC) - state = 1 - elif self.stateOff is not False: - self.stateOff = False - - try: - # Value from 0 and 100 - self.SetBrightness_OS(int(state)) - except ValueError: # Not int -> not a message for that function - return - except Exception as e: - raise Exception("Error during brightness set: " + str(e)) - - # Finally, tell the sensor to update and to send - self.CallUpdate() - self.SendOnlineState() - self.lastSendingTime = None # Force sensor to send immediately - - def Update(self): - if not self.stopSensor: - self.SetTopicValue(OUT_TOPIC, self.GetBrightness_OS(), - self.ValueFormatter.TYPE_PERCENTAGE) - self.SendOnlineState() - - def SetBrightness_macOS(self, value): - value = value/100 # cause I need it from 0 to 1 - command = 'brightness ' + str(value) - subprocess.Popen(command.split(), stdout=subprocess.PIPE) - - def SetBrightness_Linux(self, value): - command = 'xbacklight -set ' + str(value) - subprocess.Popen(command.split(), stdout=subprocess.PIPE) - - def SetBrightness_Win(self, value): - if supports_win_brightness: - pythoncom.CoInitialize() - return wmi.WMI(namespace='wmi').WmiMonitorBrightnessMethods()[0].WmiSetBrightness(value, 0) - else: - raise Exception( - 'No WMI module installed') - - def GetBrightness_macOS(self): - try: - command = 'brightness -l' - process = subprocess.Popen(command.split(), stdout=subprocess.PIPE) - stdout = process.communicate()[0] - brightness = re.findall( - 'display 0: brightness.*$', str(stdout))[0][22:30] - brightness = float(brightness)*100 # is between 0 and 1 - return brightness - except: - raise Exception( - 'You sure you installed Brightness from Homebrew ? (else try checking you PATH)') - - def GetBrightness_Linux(self): - try: - command = 'xbacklight' - process = subprocess.Popen(command.split(), stdout=subprocess.PIPE) - stdout = process.communicate()[0] - brightness = float(stdout) - return brightness - except: - raise Exception( - 'You sure you installed Brightness from Homebrew ? (else try checking you PATH)') - - def GetBrightness_Win(self): - if supports_win_brightness: - return int(wmi.WMI(namespace='wmi').WmiMonitorBrightness() - [0].CurrentBrightness) - else: - raise Exception( - 'No WMI module installed') - - def GetOS(self): - # Get OS from OsSensor and get temperature based on the os - os = self.FindEntity('Os') - if os: - if not os.postinitializeState: # I run this function in post initialize so the os sensor might not be ready - os.CallPostInitialize() - os.CallUpdate() - return os.GetTopicValue() - - def ManageDiscoveryData(self, discovery_data): - for data in discovery_data: - data['expire_after']="" - - self.SendOnlineState() - - discovery_data[0]['payload']['brightness_state_topic'] = self.SelectTopic( - OUT_TOPIC) - discovery_data[0]['payload']['state_topic'] = self.SelectTopic( - self.STATE_TOPIC) - discovery_data[0]['payload']['brightness_command_topic'] = self.SelectTopic( - IN_TOPIC) - discovery_data[0]['payload']['command_topic'] = self.SelectTopic( - IN_TOPIC) - discovery_data[0]['payload']['payload_on'] = self.consts.ON_STATE - discovery_data[0]['payload']['payload_off'] = self.consts.OFF_STATE - discovery_data[0]['payload']['brightness_scale'] = 100 - - return discovery_data - - STATE_TOPIC = 'brightness/state' - - def SendOnlineState(self): - if self.GetTopicValue(OUT_TOPIC) and int(self.GetTopicValue(OUT_TOPIC)) > 1: - self.mqtt_client.SendTopicData( - self.SelectTopic(self.STATE_TOPIC), self.consts.ON_STATE) - else: - self.mqtt_client.SendTopicData( - self.SelectTopic(self.STATE_TOPIC), self.consts.OFF_STATE) diff --git a/IoTuring/Entity/ToImplement/Brightness/settings.yaml b/IoTuring/Entity/ToImplement/Brightness/settings.yaml deleted file mode 100644 index df09aa287..000000000 --- a/IoTuring/Entity/ToImplement/Brightness/settings.yaml +++ /dev/null @@ -1,16 +0,0 @@ -requirements: - sensors: - - Os: - dont_send: True - -discovery: - homeassistant: # Must match discovery preset name - - topic: "brightness/get" - disable: True - payload: - name: "Brightness level" - unit_of_measurement: "%" - - topic: "brightness/set" - type: "light" - payload: - name: "Brightness" \ No newline at end of file diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml index f00f615f0..ccce50da5 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml @@ -126,3 +126,8 @@ TerminalButton: name: Terminal Button icon: mdi:console custom_type: button +Brightness: + name: Display Brightness + unit_of_measurement: "%" + icon: mdi:brightness-6 + custom_type: number From 024b4f32f42aed4810819248aa11ea3ee40edbf6 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sat, 21 Sep 2024 20:30:06 +0200 Subject: [PATCH 3/6] Brightness as light --- .../Deployments/Brightness/Brightness.py | 288 +++++++++++------- IoTuring/Entity/ValueFormat/ValueFormatter.py | 9 + .../ValueFormat/ValueFormatterOptions.py | 3 +- .../HomeAssistantWarehouse/entities.yaml | 13 +- 4 files changed, 201 insertions(+), 112 deletions(-) diff --git a/IoTuring/Entity/Deployments/Brightness/Brightness.py b/IoTuring/Entity/Deployments/Brightness/Brightness.py index 87fe2df40..9e93de8f0 100644 --- a/IoTuring/Entity/Deployments/Brightness/Brightness.py +++ b/IoTuring/Entity/Deployments/Brightness/Brightness.py @@ -1,149 +1,221 @@ -from IoTuring.Entity.Entity import Entity +from pathlib import Path +from IoTuring.Entity.Entity import Entity from IoTuring.Configurator.MenuPreset import MenuPreset from IoTuring.Entity.EntityData import EntitySensor, EntityCommand -from IoTuring.MyApp.SystemConsts.OperatingSystemDetection import OperatingSystemDetection as OsD -from IoTuring.Entity.ValueFormat import ValueFormatterOptions -import subprocess +from IoTuring.MyApp.SystemConsts import OperatingSystemDetection as OsD +from IoTuring.MyApp.SystemConsts import DesktopEnvironmentDetection as De +from IoTuring.Entity.ValueFormat import ValueFormatterOptions + + import re import os import sys -KEY = 'brightness' -KEY_STATE = 'brightness_state' +KEY_CMD = "command" +KEY_STATE = "state" +KEY_BRIGHTNESS = "value" + +CONFIG_KEY_GPU = "gpu" + + +class BrightnessCmds: + def __init__( + self, + scale: float = 100, + decimals: int = 0, + set_command: str = "{}", + get_command: str = "", + ) -> None: + self.scale = scale + self.decimals = decimals + self.set_command = set_command + self.get_command = get_command + + def Set(self, value: int) -> None: + if not 0 <= value <= 255: + raise Exception("Invalid value") + scaled_value = (value / 255) * self.scale + scaled_value = round(scaled_value, self.decimals) + scaled_value = int(scaled_value) if self.decimals == 0 else scaled_value + self._SetValue(value_str=str(scaled_value)) + + def _SetValue(self, value_str: str) -> None: + command = self.set_command.format(value_str) + OsD.RunCommand(command, shell=True) + + def Get(self) -> int: + scaled_value = float(self._GetValue()) + value = (scaled_value / self.scale) * 255 + return int(value) + + def _GetValue(self) -> str: + value_str = OsD.RunCommand(self.get_command).stdout + return value_str + + +class Brightness_Macos(BrightnessCmds): + def __init__(self) -> None: + super().__init__( + scale=1, + decimals=2, + set_command="brightness {}", + get_command="brightness -l", + ) + + def _GetValue(self) -> str: + stdout = super()._GetValue() + brightness = re.findall("display 0: brightness.*$", stdout)[0][22:30] + return brightness + + +class Brightness_Win(BrightnessCmds): + def __init__(self, monitor_id: int = 0) -> None: + super().__init__() + + import pythoncom + import wmi + + pythoncom.CoInitialize() + self.monitor_id = monitor_id + self.wmi = wmi.WMI(namespace="wmi") + + def _SetValue(self, value_str: str) -> None: + self.wmi.WmiMonitorBrightnessMethods()[self.monitor_id].WmiSetBrightness( + int(value_str), 0 + ) + + def _GetValue(self) -> str: + return self.wmi.WmiMonitorBrightness()[self.monitor_id].CurrentBrightness -CONFIG_KEY_GPU = 'gpu' -VALUEFORMATTER_OPTIONS_PERCENT = ValueFormatterOptions( - ValueFormatterOptions.TYPE_PERCENTAGE) +class Brightness_Linux_ACPI(BrightnessCmds): + def __init__(self, configuredGPU: str) -> None: + self.gpu_path = Path(f"/sys/class/backlight/{configuredGPU}") + scale = int(self.get_from_file("max_brightness")) + super().__init__(scale=scale) + + def _SetValue(self, value_str: str) -> None: + with open(self.gpu_path.joinpath("brightness"), "w") as file: + file.write(f"{value_str}\n") + + def _GetValue(self) -> str: + return self.get_from_file("brightness") + + def get_from_file(self, file_name: str) -> str: + with open(self.gpu_path.joinpath(file_name), "r") as file: + content = file.read() + return content.strip("\n") + + +class Brightness_Linux_Gnome(BrightnessCmds): + def __init__(self) -> None: + dbus_command = " ".join( + [ + "gdbus call --session", + "--dest org.gnome.SettingsDaemon.Power", + "--object-path /org/gnome/SettingsDaemon/Power", + "--method org.freedesktop.DBus.Properties.{}", + "org.gnome.SettingsDaemon.Power.Screen Brightness", + ] + ) + + super().__init__( + set_command=dbus_command.format("Set") + ' ""', + get_command=dbus_command.format("Get"), + ) + + def _GetValue(self) -> str: + stdout = super()._GetValue() + brightness = re.findall(r"<(\d*)", stdout)[0] + return brightness + class Brightness(Entity): NAME = "Brightness" ALLOW_MULTI_INSTANCE = True + brightness_cmds: BrightnessCmds - def Initialize(self): - + def Initialize(self): self.RegisterEntitySensor( - EntitySensor(self, KEY_STATE, - supportsExtraAttributes=False, - valueFormatterOptions=VALUEFORMATTER_OPTIONS_PERCENT)) - - - + EntitySensor( + self, + KEY_STATE, + valueFormatterOptions=ValueFormatterOptions( + value_type=ValueFormatterOptions.TYPE_BINARY + ), + ) + ) + self.RegisterEntitySensor(EntitySensor(self, KEY_BRIGHTNESS)) + self.RegisterEntityCommand( + EntityCommand( + self, + KEY_CMD, + self.Callback, + connectedEntitySensorKeys=[KEY_STATE, KEY_BRIGHTNESS], + ) + ) + if OsD.IsWindows(): - self.specificGetBrightness = self.GetBrightness_Win - self.specificSetBrightness = self.SetBrightness_Win - import wmi - import pythoncom - if OsD.IsMacos(): - self.specificGetBrightness = self.GetBrightness_macOS - self.specificSetBrightness = self.SetBrightness_macOS - if OsD.IsLinux(): - self.configuredGPU: str = self.GetFromConfigurations(CONFIG_KEY_GPU) - self.specificGetBrightness = self.GetBrightness_Linux - self.specificSetBrightness = self.SetBrightness_Linux + self.brightness_cmds = Brightness_Win() + elif OsD.IsMacos(): + self.brightness_cmds = Brightness_Macos() + elif OsD.IsLinux(): + if De.GetDesktopEnvironment() == "gnome": + self.brightness_cmds = Brightness_Linux_Gnome() + else: + configuredGPU: str = self.GetFromConfigurations(CONFIG_KEY_GPU) + self.brightness_cmds = Brightness_Linux_ACPI(configuredGPU) else: - self.Log(self.Logger.LOG_WARNING, - 'No brightness sensor available for this operating system') - - self.RegisterEntityCommand(EntityCommand(self, KEY, self.Callback, KEY_STATE)) + raise Exception("Unsupported OS!") def Callback(self, message): state = message.payload.decode("utf-8") - try: - # Value from 0 and 100 - self.specificSetBrightness(int(state)) - except ValueError: # Not int -> not a message for that function - return - except Exception as e: - raise Exception("Error during brightness set: " + str(e)) - + self.brightness_cmds.Set(int(state)) def Update(self): - brightness = self.specificGetBrightness() - self.SetEntitySensorValue(KEY_STATE, brightness) - - def SetBrightness_macOS(self, value: str|int): - value = value/100 # cause I need it from 0 to 1 - command = 'brightness ' + str(value) - subprocess.Popen(command.split(), stdout=subprocess.PIPE) - - def SetBrightness_Linux(self, value): - # use acpi to controll backlight - with open(f'/sys/class/backlight/{self.configuredGPU}/brightness', 'w') as file: - file.write(f'{str(value)}\n') + value = self.brightness_cmds.Get() + self.SetEntitySensorValue(KEY_BRIGHTNESS, value) + self.SetEntitySensorValue(KEY_STATE, 1 if value > 0 else 0) - def SetBrightness_Win(self, value): - pythoncom.CoInitialize() - return wmi.WMI(namespace='wmi').WmiMonitorBrightnessMethods()[0].WmiSetBrightness(value, 0) - - def GetBrightness_macOS(self) -> float: - try: - command = 'brightness -l' - process = subprocess.Popen(command.split(), stdout=subprocess.PIPE) - stdout = process.communicate()[0] - brightness = re.findall( - 'display 0: brightness.*$', str(stdout))[0][22:30] - brightness = float(brightness)*100 # is between 0 and 1 - return brightness - except: - raise Exception( - 'You sure you installed Brightness from Homebrew ? (else try checking you PATH)') - - def GetBrightness_Linux(self) -> int: - # get the content of the file /sys/class/backlight/intel_backlight/brightness - with open(f'/sys/class/backlight/{self.configuredGPU}/brightness', 'r') as file: - content = file.read() - brightness = int(content.strip('\n')) - return self.ConvertBrightness(brightness, from_scale=255, to_scale=100) - - def GetBrightness_Win(self) -> int: - return int(wmi.WMI(namespace='wmi').WmiMonitorBrightness()[0].CurrentBrightness) - - def ConvertBrightness(self, value, from_scale=255, to_scale=100) -> int: - """Function to convert brightness values from one scale to another. - - Args: - value (int): The brightness value to convert. - from_scale (int): The original scale of the brightness value. Default is 255. - to_scale (int): The target scale of the brightness value. Default is 100. - - Returns: - float: The converted brightness value. - """ - return int((value / from_scale) * to_scale) - - - @classmethod + @classmethod def ConfigurationPreset(cls): preset = MenuPreset() if OsD.IsLinux(): # find all GPUs in /sys/class/backlight by listing all directories - gpus = [gpu for gpu in os.listdir('/sys/class/backlight') if os.path.isdir(f'/sys/class/backlight/{gpu}')] + gpus = [ + gpu + for gpu in os.listdir("/sys/class/backlight") + if os.path.isdir(f"/sys/class/backlight/{gpu}") + ] preset.AddEntry( name="which GPUs backlight you want to control?", key=CONFIG_KEY_GPU, - question_type='select', - choices=gpus - ) + question_type="select", + choices=gpus, + ) return preset - + @classmethod def CheckSystemSupport(cls): - if OsD.IsWindows(): #TODO needs to be tested + if OsD.IsWindows(): # TODO needs to be tested # if wmi and pythoncom are not available, raise an exception - if ['wmi', 'pythoncom'] not in sys.modules: + if ["wmi", "pythoncom"] not in sys.modules: raise Exception( - 'Brightness not available, have you installed \'wmi\' on pip ?') - elif OsD.IsMacos(): #TODO needs to be tested - if not OsD.CommandExists('brightness'): + "Brightness not available, have you installed 'wmi' on pip ?" + ) + elif OsD.IsMacos(): # TODO needs to be tested + if not OsD.CommandExists("brightness"): raise Exception( - 'Brightness not avaidlable, have you installed \'brightness\' on Homebrew ?') + "Brightness not avaidlable, have you installed 'brightness' on Homebrew ?" + ) elif OsD.IsLinux(): - if not os.path.exists('/sys/class/backlight'): #TODO check if this dir always exists + if not Path("/sys/class/backlight").exists(): + # TODO check if this dir always exists raise Exception( - 'Brightness not available, no backlight found in /sys/class/backlight') + "Brightness not available, no backlight found in /sys/class/backlight" + ) else: - raise NotImplementedError('Brightness not available for this OS') \ No newline at end of file + raise cls.UnsupportedOsException() diff --git a/IoTuring/Entity/ValueFormat/ValueFormatter.py b/IoTuring/Entity/ValueFormat/ValueFormatter.py index 01011d1e5..81105f482 100644 --- a/IoTuring/Entity/ValueFormat/ValueFormatter.py +++ b/IoTuring/Entity/ValueFormat/ValueFormatter.py @@ -67,9 +67,18 @@ def _ParseValue(value, options: ValueFormatterOptions | None, includeUnit: bool) return ValueFormatter.BitPerSecondFormatter(value, options, includeUnit) elif valueType == ValueFormatterOptions.TYPE_BYTE_PER_SECOND: return ValueFormatter.BytePerSecondFormatter(value, options, includeUnit) + elif valueType == ValueFormatterOptions.TYPE_BINARY: + return ValueFormatter.BinaryFormatter(value) else: return str(value) + @staticmethod + def BinaryFormatter(value): + if int(value) > 0: + return 1 + else: + return 0 + @staticmethod def TimeFormatter(value, options: ValueFormatterOptions, includeUnit: bool): # Get value in seconds, and adjustable diff --git a/IoTuring/Entity/ValueFormat/ValueFormatterOptions.py b/IoTuring/Entity/ValueFormat/ValueFormatterOptions.py index 272f46c3b..0d28330de 100644 --- a/IoTuring/Entity/ValueFormat/ValueFormatterOptions.py +++ b/IoTuring/Entity/ValueFormat/ValueFormatterOptions.py @@ -1,4 +1,4 @@ -class ValueFormatterOptions(): +class ValueFormatterOptions(): TYPE_NONE = 0 TYPE_BYTE = 1 TYPE_TIME = 2 @@ -10,6 +10,7 @@ class ValueFormatterOptions(): TYPE_RADIOPOWER = 8 TYPE_BYTE_PER_SECOND = 9 TYPE_BIT_PER_SECOND = 10 + TYPE_BINARY = 11 DO_NOT_TOUCH_DECIMALS = -1 diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml index ccce50da5..dc81286bc 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml @@ -126,8 +126,15 @@ TerminalButton: name: Terminal Button icon: mdi:console custom_type: button -Brightness: +Brightness.*command: + command_topic_key: + - brightness_command_topic + - command_topic name: Display Brightness - unit_of_measurement: "%" icon: mdi:brightness-6 - custom_type: number + custom_type: light + on_command_type: brightness + payload_on: "1" + payload_off: "0" +Brightness.*value: + state_topic_key: brightness_state_topic From 4dc7f185073f232da5eaeef650c8d52737f9383a Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 22 Sep 2024 01:15:00 +0200 Subject: [PATCH 4/6] Brightness documentation, check support, configuration --- IoTuring/Configurator/ConfiguratorObject.py | 2 +- .../Deployments/Brightness/Brightness.py | 208 ++++++++++++------ 2 files changed, 147 insertions(+), 63 deletions(-) diff --git a/IoTuring/Configurator/ConfiguratorObject.py b/IoTuring/Configurator/ConfiguratorObject.py index 31b00e2db..7a0c866a9 100644 --- a/IoTuring/Configurator/ConfiguratorObject.py +++ b/IoTuring/Configurator/ConfiguratorObject.py @@ -48,7 +48,7 @@ def ConfigurationPreset(cls) -> MenuPreset: return MenuPreset() @classmethod - def AllowMultiInstance(cls): + def AllowMultiInstance(cls) -> bool: """ Return True if this Entity can have multiple instances, useful for customizable entities These entities are the ones that must have a tag to be recognized """ return cls.ALLOW_MULTI_INSTANCE diff --git a/IoTuring/Entity/Deployments/Brightness/Brightness.py b/IoTuring/Entity/Deployments/Brightness/Brightness.py index 9e93de8f0..735cc5612 100644 --- a/IoTuring/Entity/Deployments/Brightness/Brightness.py +++ b/IoTuring/Entity/Deployments/Brightness/Brightness.py @@ -1,4 +1,7 @@ from pathlib import Path +import re +import sys + from IoTuring.Entity.Entity import Entity from IoTuring.Configurator.MenuPreset import MenuPreset from IoTuring.Entity.EntityData import EntitySensor, EntityCommand @@ -7,11 +10,6 @@ from IoTuring.Entity.ValueFormat import ValueFormatterOptions -import re -import os -import sys - - KEY_CMD = "command" KEY_STATE = "state" KEY_BRIGHTNESS = "value" @@ -20,6 +18,13 @@ class BrightnessCmds: + """Base class storing commands for setting and getting brightness. + + In child classes override _GetValue and _SetValue methods. + Value should be a string from/to this methods, + conversion to int and scaling happens in Get and Set methods. + """ + def __init__( self, scale: float = 100, @@ -27,6 +32,17 @@ def __init__( set_command: str = "{}", get_command: str = "", ) -> None: + """Set parameters for this platform + + Args: + scale (float, optional): The maximum value of brightness. Defaults to 100. + decimals (int, optional): Required decimals. Defaults to 0. + set_command (str, optional): Terminal command to set brightness. + "{}" will be replaced with brightness value. Defaults to "{}". + Ignore with custom _SetValue method in subclasses. + get_command (str, optional): Terminal command to get brightness. Defaults to "". + Ignore with custom _GetValue method in subclasses. + """ self.scale = scale self.decimals = decimals self.set_command = set_command @@ -37,7 +53,8 @@ def Set(self, value: int) -> None: raise Exception("Invalid value") scaled_value = (value / 255) * self.scale scaled_value = round(scaled_value, self.decimals) - scaled_value = int(scaled_value) if self.decimals == 0 else scaled_value + scaled_value = int( + scaled_value) if self.decimals == 0 else scaled_value self._SetValue(value_str=str(scaled_value)) def _SetValue(self, value_str: str) -> None: @@ -53,6 +70,14 @@ def _GetValue(self) -> str: value_str = OsD.RunCommand(self.get_command).stdout return value_str + @classmethod + def CheckPlatformSupported(cls) -> None: + raise NotImplementedError("Should be implemented in subclasses") + + @classmethod + def AllowMultiInstance(cls) -> bool: + return False + class Brightness_Macos(BrightnessCmds): def __init__(self) -> None: @@ -68,13 +93,20 @@ def _GetValue(self) -> str: brightness = re.findall("display 0: brightness.*$", stdout)[0][22:30] return brightness + @classmethod + def CheckPlatformSupported(cls) -> None: + if not OsD.CommandExists("brightness"): + raise Exception( + "Brightness not available, have you installed 'brightness' on Homebrew ?" + ) + class Brightness_Win(BrightnessCmds): def __init__(self, monitor_id: int = 0) -> None: super().__init__() - import pythoncom - import wmi + import pythoncom # type: ignore + import wmi # type: ignore pythoncom.CoInitialize() self.monitor_id = monitor_id @@ -88,10 +120,19 @@ def _SetValue(self, value_str: str) -> None: def _GetValue(self) -> str: return self.wmi.WmiMonitorBrightness()[self.monitor_id].CurrentBrightness + @classmethod + def CheckPlatformSupported(cls) -> None: + if ["wmi", "pythoncom"] not in sys.modules: + raise Exception( + "Brightness not available, have you installed 'wmi' on pip ?" + ) + class Brightness_Linux_ACPI(BrightnessCmds): + ROOT_PATH = Path("/sys/class/backlight") + def __init__(self, configuredGPU: str) -> None: - self.gpu_path = Path(f"/sys/class/backlight/{configuredGPU}") + self.gpu_path = self.ROOT_PATH.joinpath(configuredGPU) scale = int(self.get_from_file("max_brightness")) super().__init__(scale=scale) @@ -107,22 +148,39 @@ def get_from_file(self, file_name: str) -> str: content = file.read() return content.strip("\n") + @classmethod + def get_gpus(cls) -> list[str]: + return [str(d.name) for d in cls.ROOT_PATH.iterdir() if d.is_dir()] + + @classmethod + def CheckPlatformSupported(cls) -> None: + if not cls.ROOT_PATH.exists(): + raise Exception("ACPI: dir not found!") + if not [f for f in cls.ROOT_PATH.iterdir()]: + raise Exception("ACPI: No Gpu found!") + + @classmethod + def AllowMultiInstance(cls) -> bool: + return bool(len(cls.get_gpus()) > 1) + class Brightness_Linux_Gnome(BrightnessCmds): + DBUS_COMMAND_TEMPLATE = " ".join( + [ + "gdbus call --session", + "--dest org.gnome.SettingsDaemon.Power", + "--object-path /org/gnome/SettingsDaemon/Power", + "--method org.freedesktop.DBus.Properties.{}", + "org.gnome.SettingsDaemon.Power.Screen Brightness", + ] + ) + def __init__(self) -> None: - dbus_command = " ".join( - [ - "gdbus call --session", - "--dest org.gnome.SettingsDaemon.Power", - "--object-path /org/gnome/SettingsDaemon/Power", - "--method org.freedesktop.DBus.Properties.{}", - "org.gnome.SettingsDaemon.Power.Screen Brightness", - ] - ) super().__init__( - set_command=dbus_command.format("Set") + ' ""', - get_command=dbus_command.format("Get"), + set_command=self.DBUS_COMMAND_TEMPLATE.format( + "Set") + ' ""', + get_command=self.DBUS_COMMAND_TEMPLATE.format("Get"), ) def _GetValue(self) -> str: @@ -130,10 +188,16 @@ def _GetValue(self) -> str: brightness = re.findall(r"<(\d*)", stdout)[0] return brightness + @classmethod + def CheckPlatformSupported(cls) -> None: + stdout = OsD.RunCommand(cls.DBUS_COMMAND_TEMPLATE.format("Get")).stdout + if not re.findall(r"<\d", stdout): + raise Exception("Gnome: Dbus Brightness not supported!") + class Brightness(Entity): NAME = "Brightness" - ALLOW_MULTI_INSTANCE = True + # ALLOW_MULTI_INSTANCE depends on platform. See AllowMultiInstance() brightness_cmds: BrightnessCmds @@ -157,18 +221,12 @@ def Initialize(self): ) ) - if OsD.IsWindows(): - self.brightness_cmds = Brightness_Win() - elif OsD.IsMacos(): - self.brightness_cmds = Brightness_Macos() - elif OsD.IsLinux(): - if De.GetDesktopEnvironment() == "gnome": - self.brightness_cmds = Brightness_Linux_Gnome() - else: - configuredGPU: str = self.GetFromConfigurations(CONFIG_KEY_GPU) - self.brightness_cmds = Brightness_Linux_ACPI(configuredGPU) + command_class = self.GetCommandClass() + if command_class == Brightness_Linux_ACPI: + configuredGPU: str = self.GetFromConfigurations(CONFIG_KEY_GPU) + self.brightness_cmds = Brightness_Linux_ACPI(configuredGPU) else: - raise Exception("Unsupported OS!") + self.brightness_cmds = command_class() def Callback(self, message): state = message.payload.decode("utf-8") @@ -182,40 +240,66 @@ def Update(self): @classmethod def ConfigurationPreset(cls): preset = MenuPreset() - if OsD.IsLinux(): - # find all GPUs in /sys/class/backlight by listing all directories - gpus = [ - gpu - for gpu in os.listdir("/sys/class/backlight") - if os.path.isdir(f"/sys/class/backlight/{gpu}") - ] - - preset.AddEntry( - name="which GPUs backlight you want to control?", - key=CONFIG_KEY_GPU, - question_type="select", - choices=gpus, - ) + + if cls.GetCommandClass() == Brightness_Linux_ACPI: + gpus = Brightness_Linux_ACPI.get_gpus() + + if len(gpus) > 1: + preset.AddEntry( + name="which GPUs backlight you want to control?", + key=CONFIG_KEY_GPU, + question_type="select", + choices=gpus, + default=gpus[0] + ) + else: + # Set hidden default value, if only one gpu: + preset.AddEntry( + name="which GPUs backlight you want to control?", + key=CONFIG_KEY_GPU, + question_type="select", + choices=gpus, + default=gpus[0], + display_if_key_value={CONFIG_KEY_GPU: False} + ) + return preset @classmethod def CheckSystemSupport(cls): - if OsD.IsWindows(): # TODO needs to be tested - # if wmi and pythoncom are not available, raise an exception - if ["wmi", "pythoncom"] not in sys.modules: - raise Exception( - "Brightness not available, have you installed 'wmi' on pip ?" - ) - elif OsD.IsMacos(): # TODO needs to be tested - if not OsD.CommandExists("brightness"): - raise Exception( - "Brightness not avaidlable, have you installed 'brightness' on Homebrew ?" - ) + cls.GetCommandClass() + + @classmethod + def AllowMultiInstance(cls): + return cls.GetCommandClass().AllowMultiInstance() + + @classmethod + def GetCommandClass(cls) -> type: + """Get Brightness Command class. Raises exception if not supported""" + + classes: list[type] = [] + exceptions: list[str] = [] + + if OsD.IsWindows(): + classes.append(Brightness_Win) + elif OsD.IsMacos(): + classes.append(Brightness_Macos) + elif OsD.IsLinux(): - if not Path("/sys/class/backlight").exists(): - # TODO check if this dir always exists - raise Exception( - "Brightness not available, no backlight found in /sys/class/backlight" - ) + + if De.GetDesktopEnvironment() == "gnome": + classes.append(Brightness_Linux_Gnome) + + classes.append(Brightness_Linux_ACPI) + else: raise cls.UnsupportedOsException() + + for cls in classes: + try: + cls.CheckPlatformSupported() + return cls + except Exception as e: + exceptions.append(str(e)) + + raise Exception(" ".join(exceptions)) From 9b835dae9fde21308ece9a64b0e334ce40a2301b Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 22 Sep 2024 01:32:40 +0200 Subject: [PATCH 5/6] Win dependencies, comments --- IoTuring/Entity/Deployments/Brightness/Brightness.py | 7 +++++-- .../Deployments/HomeAssistantWarehouse/entities.yaml | 2 ++ pyproject.toml | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/IoTuring/Entity/Deployments/Brightness/Brightness.py b/IoTuring/Entity/Deployments/Brightness/Brightness.py index 735cc5612..3e6a386fa 100644 --- a/IoTuring/Entity/Deployments/Brightness/Brightness.py +++ b/IoTuring/Entity/Deployments/Brightness/Brightness.py @@ -80,6 +80,7 @@ def AllowMultiInstance(cls) -> bool: class Brightness_Macos(BrightnessCmds): + # TODO needs to be tested def __init__(self) -> None: super().__init__( scale=1, @@ -102,6 +103,8 @@ def CheckPlatformSupported(cls) -> None: class Brightness_Win(BrightnessCmds): + # TODO needs to be tested + # TODO support multiple monitors def __init__(self, monitor_id: int = 0) -> None: super().__init__() @@ -124,7 +127,7 @@ def _GetValue(self) -> str: def CheckPlatformSupported(cls) -> None: if ["wmi", "pythoncom"] not in sys.modules: raise Exception( - "Brightness not available, have you installed 'wmi' on pip ?" + "Wmi or Pythoncom package missing" ) @@ -253,7 +256,7 @@ def ConfigurationPreset(cls): default=gpus[0] ) else: - # Set hidden default value, if only one gpu: + # Set default value, if only one gpu, hidden question: preset.AddEntry( name="which GPUs backlight you want to control?", key=CONFIG_KEY_GPU, diff --git a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml index dc81286bc..44df3b3a4 100644 --- a/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml +++ b/IoTuring/Warehouse/Deployments/HomeAssistantWarehouse/entities.yaml @@ -127,6 +127,8 @@ TerminalButton: icon: mdi:console custom_type: button Brightness.*command: +# command_topic for OFF command only: +# https://www.home-assistant.io/integrations/light.mqtt/#brightness-without-on-commands command_topic_key: - brightness_command_topic - command_topic diff --git a/pyproject.toml b/pyproject.toml index cf8540444..aeb03a733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,9 @@ dependencies = [ "InquirerPy", "PyObjC; sys_platform == 'darwin'", "IoTuring-applesmc; sys_platform == 'darwin'", - "tinyWinToast; sys_platform == 'win32'" + "tinyWinToast; sys_platform == 'win32'", + "pywin32; sys_platform == 'win32'", + "wmi; sys_platform == 'win32'" ] [project.optional-dependencies] From 3821161211f03192dba0a084727e3399f1586fd0 Mon Sep 17 00:00:00 2001 From: infeeeee Date: Sun, 22 Sep 2024 01:50:30 +0200 Subject: [PATCH 6/6] Fix test --- IoTuring/Entity/Deployments/Brightness/Brightness.py | 1 + 1 file changed, 1 insertion(+) diff --git a/IoTuring/Entity/Deployments/Brightness/Brightness.py b/IoTuring/Entity/Deployments/Brightness/Brightness.py index 3e6a386fa..b4d647b5d 100644 --- a/IoTuring/Entity/Deployments/Brightness/Brightness.py +++ b/IoTuring/Entity/Deployments/Brightness/Brightness.py @@ -1,3 +1,4 @@ +from __future__ import annotations from pathlib import Path import re import sys