diff --git a/custom_components/smartir/__init__.py b/custom_components/smartir/__init__.py index dbd16558f..6aa49a2fe 100644 --- a/custom_components/smartir/__init__.py +++ b/custom_components/smartir/__init__.py @@ -5,42 +5,49 @@ from distutils.version import StrictVersion import json import logging -import os.path import requests import struct import voluptuous as vol from aiohttp import ClientSession -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, __version__ as current_ha_version) +from homeassistant.const import ATTR_FRIENDLY_NAME, __version__ as current_ha_version import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType +from pathlib import Path _LOGGER = logging.getLogger(__name__) -DOMAIN = 'smartir' -VERSION = '1.18.1' +DOMAIN = "smartir" +VERSION = "1.18.2" MANIFEST_URL = ( "https://raw.githubusercontent.com/" "smartHomeHub/SmartIR/{}/" - "custom_components/smartir/manifest.json") + "custom_components/smartir/manifest.json" +) REMOTE_BASE_URL = ( "https://raw.githubusercontent.com/" "smartHomeHub/SmartIR/{}/" - "custom_components/smartir/") -COMPONENT_ABS_DIR = os.path.dirname( - os.path.abspath(__file__)) + "custom_components/smartir/" +) +COMPONENT_ABS_DIR = Path(__file__).parent.resolve() + +CONF_CHECK_UPDATES = "check_updates" +CONF_UPDATE_BRANCH = "update_branch" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_CHECK_UPDATES, default=True): cv.boolean, + vol.Optional(CONF_UPDATE_BRANCH, default="master"): vol.In( + ["master", "rc"] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) -CONF_CHECK_UPDATES = 'check_updates' -CONF_UPDATE_BRANCH = 'update_branch' - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_CHECK_UPDATES, default=True): cv.boolean, - vol.Optional(CONF_UPDATE_BRANCH, default='master'): vol.In( - ['master', 'rc']) - }) -}, extra=vol.ALLOW_EXTRA) async def async_setup(hass, config): """Set up the SmartIR component.""" @@ -58,85 +65,106 @@ async def _check_updates(service): async def _update_component(service): await _update(hass, update_branch, True) - hass.services.async_register(DOMAIN, 'check_updates', _check_updates) - hass.services.async_register(DOMAIN, 'update_component', _update_component) + hass.services.async_register(DOMAIN, "check_updates", _check_updates) + hass.services.async_register(DOMAIN, "update_component", _update_component) if check_updates: await _update(hass, update_branch, False, False) return True + async def _update(hass, branch, do_update=False, notify_if_latest=True): try: async with aiohttp.ClientSession() as session: async with session.get(MANIFEST_URL.format(branch)) as response: if response.status == 200: - - data = await response.json(content_type='text/plain') - min_ha_version = data['homeassistant'] - last_version = data['updater']['version'] - release_notes = data['updater']['releaseNotes'] + + data = await response.json(content_type="text/plain") + min_ha_version = data["homeassistant"] + last_version = data["updater"]["version"] + release_notes = data["updater"]["releaseNotes"] if StrictVersion(last_version) <= StrictVersion(VERSION): if notify_if_latest: hass.components.persistent_notification.async_create( - "You're already using the latest version!", - title='SmartIR') + "You're already using the latest version!", + title="SmartIR", + ) return - if StrictVersion(current_ha_version) < StrictVersion(min_ha_version): + if StrictVersion(current_ha_version) < StrictVersion( + min_ha_version + ): hass.components.persistent_notification.async_create( "There is a new version of SmartIR integration, but it is **incompatible** " - "with your system. Please first update Home Assistant.", title='SmartIR') + "with your system. Please first update Home Assistant.", + title="SmartIR", + ) return if do_update is False: hass.components.persistent_notification.async_create( "A new version of SmartIR integration is available ({}). " "Call the ``smartir.update_component`` service to update " - "the integration. \n\n **Release notes:** \n{}" - .format(last_version, release_notes), title='SmartIR') + "the integration. \n\n **Release notes:** \n{}".format( + last_version, release_notes + ), + title="SmartIR", + ) return # Begin update - files = data['updater']['files'] + files = data["updater"]["files"] has_errors = False for file in files: try: source = REMOTE_BASE_URL.format(branch) + file - dest = os.path.join(COMPONENT_ABS_DIR, file) - os.makedirs(os.path.dirname(dest), exist_ok=True) + dest = COMPONENT_ABS_DIR / file + dest.parent.mkdir(parents=True, exist_ok=True) await Helper.downloader(source, dest) except Exception: has_errors = True - _LOGGER.error("Error updating %s. Please update the file manually.", file) + _LOGGER.error( + "Error updating %s. Please update the file manually.", + file, + ) if has_errors: hass.components.persistent_notification.async_create( "There was an error updating one or more files of SmartIR. " - "Please check the logs for more information.", title='SmartIR') + "Please check the logs for more information.", + title="SmartIR", + ) else: hass.components.persistent_notification.async_create( - "Successfully updated to {}. Please restart Home Assistant." - .format(last_version), title='SmartIR') + "Successfully updated to {}. Please restart Home Assistant.".format( + last_version + ), + title="SmartIR", + ) except Exception: - _LOGGER.error("An error occurred while checking for updates.") + _LOGGER.error("An error occurred while checking for updates.") + -class Helper(): +class Helper: @staticmethod async def downloader(source, dest): async with aiohttp.ClientSession() as session: async with session.get(source) as response: if response.status == 200: - async with aiofiles.open(dest, mode='wb') as f: + async with aiofiles.open(str(dest), mode="wb") as f: await f.write(await response.read()) else: raise Exception("File not found") @staticmethod def pronto2lirc(pronto): - codes = [int(binascii.hexlify(pronto[i:i+2]), 16) for i in range(0, len(pronto), 2)] + codes = [ + int(binascii.hexlify(pronto[i : i + 2]), 16) + for i in range(0, len(pronto), 2) + ] if codes[0]: raise ValueError("Pronto code should start with 0000") @@ -154,18 +182,18 @@ def lirc2broadlink(pulses): pulse = int(pulse * 269 / 8192) if pulse < 256: - array += bytearray(struct.pack('>B', pulse)) + array += bytearray(struct.pack(">B", pulse)) else: array += bytearray([0x00]) - array += bytearray(struct.pack('>H', pulse)) + array += bytearray(struct.pack(">H", pulse)) packet = bytearray([0x26, 0x00]) - packet += bytearray(struct.pack(' self._max_temperature: - _LOGGER.warning('The temperature value is out of min/max range') + _LOGGER.warning("The temperature value is out of min/max range") return if self._precision == PRECISION_WHOLE: @@ -316,7 +364,7 @@ async def async_set_temperature(self, **kwargs): if hvac_mode: await self.async_set_hvac_mode(hvac_mode) return - + if not self._hvac_mode.lower() == HVACMode.OFF: await self.send_command() @@ -325,7 +373,7 @@ async def async_set_temperature(self, **kwargs): async def async_set_hvac_mode(self, hvac_mode): """Set operation mode.""" self._hvac_mode = hvac_mode - + if not hvac_mode == HVACMode.OFF: self._last_on_operation = hvac_mode @@ -335,9 +383,9 @@ async def async_set_hvac_mode(self, hvac_mode): async def async_set_fan_mode(self, fan_mode): """Set fan mode.""" self._current_fan_mode = fan_mode - + if not self._hvac_mode.lower() == HVACMode.OFF: - await self.send_command() + await self.send_command() self.async_write_ha_state() async def async_set_swing_mode(self, swing_mode): @@ -351,7 +399,7 @@ async def async_set_swing_mode(self, swing_mode): async def async_turn_off(self): """Turn off.""" await self.async_set_hvac_mode(HVACMode.OFF) - + async def async_turn_on(self): """Turn on.""" if self._last_on_operation is not None: @@ -366,30 +414,36 @@ async def send_command(self): operation_mode = self._hvac_mode fan_mode = self._current_fan_mode swing_mode = self._current_swing_mode - target_temperature = '{0:g}'.format(self._target_temperature) + target_temperature = "{0:g}".format(self._target_temperature) if operation_mode.lower() == HVACMode.OFF: - await self._controller.send(self._commands['off']) + await self._controller.send(self._commands["off"]) return - if 'on' in self._commands: - await self._controller.send(self._commands['on']) + if "on" in self._commands: + await self._controller.send(self._commands["on"]) await asyncio.sleep(self._delay) if self._support_swing == True: await self._controller.send( - self._commands[operation_mode][fan_mode][swing_mode][target_temperature]) + self._commands[operation_mode][fan_mode][swing_mode][ + target_temperature + ] + ) else: await self._controller.send( - self._commands[operation_mode][fan_mode][target_temperature]) + self._commands[operation_mode][fan_mode][target_temperature] + ) except Exception as e: _LOGGER.exception(e) - + @callback - async def _async_temp_sensor_changed(self, event: Event[EventStateChangedData]) -> None: + async def _async_temp_sensor_changed( + self, event: Event[EventStateChangedData] + ) -> None: """Handle temperature sensor changes.""" - new_state = event.data["new_state"] + new_state = event.data["new_state"] if new_state is None: return @@ -398,7 +452,9 @@ async def _async_temp_sensor_changed(self, event: Event[EventStateChangedData]) self.async_write_ha_state() @callback - async def _async_humidity_sensor_changed(self, event: Event[EventStateChangedData]) -> None: + async def _async_humidity_sensor_changed( + self, event: Event[EventStateChangedData] + ) -> None: """Handle humidity sensor changes.""" new_state = event.data["new_state"] @@ -407,13 +463,15 @@ async def _async_humidity_sensor_changed(self, event: Event[EventStateChangedDat self._async_update_humidity(new_state) self.async_write_ha_state() - + @callback - async def _async_power_sensor_changed(self, event: Event[EventStateChangedData]) -> None: + async def _async_power_sensor_changed( + self, event: Event[EventStateChangedData] + ) -> None: entity_id = event.data["entity_id"] old_state = event.data["old_state"] new_state = event.data["new_state"] - + if new_state is None: return @@ -422,7 +480,10 @@ async def _async_power_sensor_changed(self, event: Event[EventStateChangedData]) if new_state.state == STATE_ON and self._hvac_mode == HVACMode.OFF: self._on_by_remote = True - if self._power_sensor_restore_state == True and self._last_on_operation is not None: + if ( + self._power_sensor_restore_state == True + and self._last_on_operation is not None + ): self._hvac_mode = self._last_on_operation else: self._hvac_mode = STATE_ON @@ -451,4 +512,100 @@ def _async_update_humidity(self, state): if state.state != STATE_UNKNOWN and state.state != STATE_UNAVAILABLE: self._current_humidity = float(state.state) except ValueError as ex: - _LOGGER.error("Unable to update from humidity sensor: %s", ex) \ No newline at end of file + _LOGGER.error("Unable to update from humidity sensor: %s", ex) + + +class VerySmartIRClimate(SmartIRClimate): + def __init__(self, hass, config, hvac, lom=[]): + _LOGGER.debug( + f"VerySmartIRClimate init started for device {config.get(CONF_NAME)}" + ) + self.hass = hass + self._unique_id = config.get(CONF_UNIQUE_ID) + self._name = config.get(CONF_NAME) + self._device_code = config.get(CONF_DEVICE_CODE) + self._controller_data = config.get(CONF_CONTROLLER_DATA) + self._delay = config.get(CONF_DELAY) + self._temperature_sensor = config.get(CONF_TEMPERATURE_SENSOR) + self._humidity_sensor = config.get(CONF_HUMIDITY_SENSOR) + self._power_sensor = config.get(CONF_POWER_SENSOR) + self._power_sensor_restore_state = config.get(CONF_POWER_SENSOR_RESTORE_STATE) + + self._hvac_dev = hvac + self._manufacturer = hvac.brand + self._supported_models = lom + self._supported_controller = "Broadlink" + self._commands_encoding = "Hex" + self._min_temperature = hvac.capabilities["temperature"][0] + self._max_temperature = hvac.capabilities["temperature"][-1] + self._precision = hvac.temperature_step + + # Need translating + valid_hvac_modes = [ + x if x != "fan" else "fan_only" for x in hvac.capabilities["mode"] + ] + + self._operation_modes = [x for x in valid_hvac_modes if x in HVAC_MODES] + if "fan" in hvac.all_capabilities: + self._fan_modes = hvac.all_capabilities["fan"] + else: + self._fan_modes = [] + if "swing" in hvac.all_capabilities: + self._swing_modes = hvac.all_capabilities["swing"] + else: + self._swing_modes = [] + + # Supported features + self._support_flags = SUPPORT_FLAGS + + self._target_temperature = self._hvac_dev.status["temperature"] + self._hvac_mode = self._hvac_dev.status["mode"] + if "fan" in self._hvac_dev.status: + self._current_fan_mode = self._hvac_dev.status["fan"] + else: + self._current_fan_mode = None + if "swing" in self._hvac_dev.status: + self._current_swing_mode = self._hvac_dev.status["swing"] + self._support_flags = self._support_flags | ClimateEntityFeature.SWING_MODE + self._support_swing = True + else: + self._current_swing_mode = None + self._support_swing = False + self._last_on_operation = None + + self._current_temperature = None + self._current_humidity = None + + self._unit = hass.config.units.temperature_unit + + self._temp_lock = asyncio.Lock() + self._on_by_remote = False + + # Init the IR/RF controller + self._controller = get_controller( + self.hass, + self._supported_controller, + self._commands_encoding, + self._controller_data, + self._delay, + ) + + async def send_command(self): + async with self._temp_lock: + try: + self._on_by_remote = False + self._hvac_dev.set_value("mode", self._hvac_mode) + self._hvac_dev.set_value("fan", self._current_fan_mode) + self._hvac_dev.set_value("swing", self._current_swing_mode) + self._hvac_dev.set_value("temperature", self._target_temperature) + + # Build it + frames = self._hvac_dev.build_ircode() + bframe = self._hvac_dev.to_broadlink(frames) + await self._controller.send(bframe.hex()) + self._hvac_dev.update_status() + await asyncio.sleep(self._delay) + return + + except Exception as e: + _LOGGER.exception(e) diff --git a/custom_components/smartir/controller.py b/custom_components/smartir/controller.py index 30758f62b..984d9e56d 100644 --- a/custom_components/smartir/controller.py +++ b/custom_components/smartir/controller.py @@ -10,16 +10,16 @@ _LOGGER = logging.getLogger(__name__) -BROADLINK_CONTROLLER = 'Broadlink' -XIAOMI_CONTROLLER = 'Xiaomi' -MQTT_CONTROLLER = 'MQTT' -LOOKIN_CONTROLLER = 'LOOKin' -ESPHOME_CONTROLLER = 'ESPHome' +BROADLINK_CONTROLLER = "Broadlink" +XIAOMI_CONTROLLER = "Xiaomi" +MQTT_CONTROLLER = "MQTT" +LOOKIN_CONTROLLER = "LOOKin" +ESPHOME_CONTROLLER = "ESPHome" -ENC_BASE64 = 'Base64' -ENC_HEX = 'Hex' -ENC_PRONTO = 'Pronto' -ENC_RAW = 'Raw' +ENC_BASE64 = "Base64" +ENC_HEX = "Hex" +ENC_PRONTO = "Pronto" +ENC_RAW = "Raw" BROADLINK_COMMANDS_ENCODING = [ENC_BASE64, ENC_HEX, ENC_PRONTO] XIAOMI_COMMANDS_ENCODING = [ENC_PRONTO, ENC_RAW] @@ -35,16 +35,19 @@ def get_controller(hass, controller, encoding, controller_data, delay): XIAOMI_CONTROLLER: XiaomiController, MQTT_CONTROLLER: MQTTController, LOOKIN_CONTROLLER: LookinController, - ESPHOME_CONTROLLER: ESPHomeController + ESPHOME_CONTROLLER: ESPHomeController, } try: - return controllers[controller](hass, controller, encoding, controller_data, delay) + return controllers[controller]( + hass, controller, encoding, controller_data, delay + ) except KeyError: raise Exception("The controller is not supported.") class AbstractController(ABC): """Representation of a controller.""" + def __init__(self, hass, controller, encoding, controller_data, delay): self.check_encoding(encoding) self.hass = hass @@ -70,46 +73,46 @@ class BroadlinkController(AbstractController): def check_encoding(self, encoding): """Check if the encoding is supported by the controller.""" if encoding not in BROADLINK_COMMANDS_ENCODING: - raise Exception("The encoding is not supported " - "by the Broadlink controller.") + raise Exception( + "The encoding is not supported " "by the Broadlink controller." + ) async def send(self, command): """Send a command.""" commands = [] - if not isinstance(command, list): + if not isinstance(command, list): command = [command] for _command in command: if self._encoding == ENC_HEX: try: _command = binascii.unhexlify(_command) - _command = b64encode(_command).decode('utf-8') + _command = b64encode(_command).decode("utf-8") except: - raise Exception("Error while converting " - "Hex to Base64 encoding") + raise Exception("Error while converting " "Hex to Base64 encoding") if self._encoding == ENC_PRONTO: try: - _command = _command.replace(' ', '') + _command = _command.replace(" ", "") _command = bytearray.fromhex(_command) _command = Helper.pronto2lirc(_command) _command = Helper.lirc2broadlink(_command) - _command = b64encode(_command).decode('utf-8') + _command = b64encode(_command).decode("utf-8") except: - raise Exception("Error while converting " - "Pronto to Base64 encoding") + raise Exception( + "Error while converting " "Pronto to Base64 encoding" + ) - commands.append('b64:' + _command) + commands.append("b64:" + _command) service_data = { ATTR_ENTITY_ID: self._controller_data, - 'command': commands, - 'delay_secs': self._delay + "command": commands, + "delay_secs": self._delay, } - await self.hass.services.async_call( - 'remote', 'send_command', service_data) + await self.hass.services.async_call("remote", "send_command", service_data) class XiaomiController(AbstractController): @@ -118,18 +121,18 @@ class XiaomiController(AbstractController): def check_encoding(self, encoding): """Check if the encoding is supported by the controller.""" if encoding not in XIAOMI_COMMANDS_ENCODING: - raise Exception("The encoding is not supported " - "by the Xiaomi controller.") + raise Exception( + "The encoding is not supported " "by the Xiaomi controller." + ) async def send(self, command): """Send a command.""" service_data = { ATTR_ENTITY_ID: self._controller_data, - 'command': self._encoding.lower() + ':' + command + "command": self._encoding.lower() + ":" + command, } - await self.hass.services.async_call( - 'remote', 'send_command', service_data) + await self.hass.services.async_call("remote", "send_command", service_data) class MQTTController(AbstractController): @@ -138,18 +141,13 @@ class MQTTController(AbstractController): def check_encoding(self, encoding): """Check if the encoding is supported by the controller.""" if encoding not in MQTT_COMMANDS_ENCODING: - raise Exception("The encoding is not supported " - "by the mqtt controller.") + raise Exception("The encoding is not supported " "by the mqtt controller.") async def send(self, command): """Send a command.""" - service_data = { - 'topic': self._controller_data, - 'payload': command - } + service_data = {"topic": self._controller_data, "payload": command} - await self.hass.services.async_call( - 'mqtt', 'publish', service_data) + await self.hass.services.async_call("mqtt", "publish", service_data) class LookinController(AbstractController): @@ -158,14 +156,14 @@ class LookinController(AbstractController): def check_encoding(self, encoding): """Check if the encoding is supported by the controller.""" if encoding not in LOOKIN_COMMANDS_ENCODING: - raise Exception("The encoding is not supported " - "by the LOOKin controller.") + raise Exception( + "The encoding is not supported " "by the LOOKin controller." + ) async def send(self, command): """Send a command.""" - encoding = self._encoding.lower().replace('pronto', 'prontohex') - url = f"http://{self._controller_data}/commands/ir/" \ - f"{encoding}/{command}" + encoding = self._encoding.lower().replace("pronto", "prontohex") + url = f"http://{self._controller_data}/commands/ir/" f"{encoding}/{command}" await self.hass.async_add_executor_job(requests.get, url) @@ -175,12 +173,14 @@ class ESPHomeController(AbstractController): def check_encoding(self, encoding): """Check if the encoding is supported by the controller.""" if encoding not in ESPHOME_COMMANDS_ENCODING: - raise Exception("The encoding is not supported " - "by the ESPHome controller.") - + raise Exception( + "The encoding is not supported " "by the ESPHome controller." + ) + async def send(self, command): """Send a command.""" - service_data = {'command': json.loads(command)} + service_data = {"command": json.loads(command)} await self.hass.services.async_call( - 'esphome', self._controller_data, service_data) \ No newline at end of file + "esphome", self._controller_data, service_data + ) diff --git a/custom_components/smartir/fan.py b/custom_components/smartir/fan.py index 4c9bb9352..095983695 100644 --- a/custom_components/smartir/fan.py +++ b/custom_components/smartir/fan.py @@ -2,22 +2,24 @@ import aiofiles import json import logging -import os.path import voluptuous as vol from homeassistant.components.fan import ( - FanEntity, FanEntityFeature, - PLATFORM_SCHEMA, DIRECTION_REVERSE, DIRECTION_FORWARD) -from homeassistant.const import ( - CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) + FanEntity, + FanEntityFeature, + PLATFORM_SCHEMA, + DIRECTION_REVERSE, + DIRECTION_FORWARD, +) +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import Event, EventStateChangedData, callback -from homeassistant.helpers.event import async_track_state_change, async_track_state_change_event +from homeassistant.helpers.event import async_track_state_change_event import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.percentage import ( ordered_list_item_to_percentage, - percentage_to_ordered_list_item + percentage_to_ordered_list_item, ) from . import COMPONENT_ABS_DIR, Helper from .controller import get_controller @@ -27,54 +29,60 @@ DEFAULT_NAME = "SmartIR Fan" DEFAULT_DELAY = 0.5 -CONF_UNIQUE_ID = 'unique_id' -CONF_DEVICE_CODE = 'device_code' +CONF_UNIQUE_ID = "unique_id" +CONF_DEVICE_CODE = "device_code" CONF_CONTROLLER_DATA = "controller_data" CONF_DELAY = "delay" -CONF_POWER_SENSOR = 'power_sensor' +CONF_POWER_SENSOR = "power_sensor" SPEED_OFF = "off" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_DEVICE_CODE): cv.positive_int, - vol.Required(CONF_CONTROLLER_DATA): cv.string, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.string, - vol.Optional(CONF_POWER_SENSOR): cv.entity_id -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_DEVICE_CODE): cv.positive_int, + vol.Required(CONF_CONTROLLER_DATA): cv.string, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.string, + vol.Optional(CONF_POWER_SENSOR): cv.entity_id, + } +) + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the IR Fan platform.""" device_code = config.get(CONF_DEVICE_CODE) - device_files_subdir = os.path.join('codes', 'fan') - device_files_absdir = os.path.join(COMPONENT_ABS_DIR, device_files_subdir) + device_files_absdir = COMPONENT_ABS_DIR / "codes" / "fan" - if not os.path.isdir(device_files_absdir): - os.makedirs(device_files_absdir) + device_files_absdir.mkdir(parents=True, exist_ok=True) - device_json_filename = str(device_code) + '.json' - device_json_path = os.path.join(device_files_absdir, device_json_filename) + device_json_path = device_files_absdir / (str(device_code) + ".json") - if not os.path.exists(device_json_path): - _LOGGER.warning("Couldn't find the device Json file. The component will " \ - "try to download it from the GitHub repo.") + if not device_json_path.exists(): + _LOGGER.warning( + "Couldn't find the device Json file. The component will " + "try to download it from the GitHub repo." + ) try: - codes_source = ("https://raw.githubusercontent.com/" - "smartHomeHub/SmartIR/master/" - "codes/fan/{}.json") + codes_source = ( + "https://raw.githubusercontent.com/" + "smartHomeHub/SmartIR/master/" + "codes/fan/{}.json" + ) await Helper.downloader(codes_source.format(device_code), device_json_path) except Exception: - _LOGGER.error("There was an error while downloading the device Json file. " \ - "Please check your internet connection or if the device code " \ - "exists on GitHub. If the problem still exists please " \ - "place the file manually in the proper directory.") + _LOGGER.error( + "There was an error while downloading the device Json file. " + "Please check your internet connection or if the device code " + "exists on GitHub. If the problem still exists please " + "place the file manually in the proper directory." + ) return try: - async with aiofiles.open(device_json_path, mode='r') as j: + async with aiofiles.open(device_json_path, mode="r") as j: _LOGGER.debug(f"loading json file {device_json_path}") content = await j.read() device_data = json.loads(content) @@ -83,9 +91,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.error("The device JSON file is invalid") return - async_add_entities([SmartIRFan( - hass, config, device_data - )]) + async_add_entities([SmartIRFan(hass, config, device_data)]) + class SmartIRFan(FanEntity, RestoreEntity): def __init__(self, hass, config, device_data): @@ -97,13 +104,13 @@ def __init__(self, hass, config, device_data): self._delay = config.get(CONF_DELAY) self._power_sensor = config.get(CONF_POWER_SENSOR) - self._manufacturer = device_data['manufacturer'] - self._supported_models = device_data['supportedModels'] - self._supported_controller = device_data['supportedController'] - self._commands_encoding = device_data['commandsEncoding'] - self._speed_list = device_data['speed'] - self._commands = device_data['commands'] - + self._manufacturer = device_data["manufacturer"] + self._supported_models = device_data["supportedModels"] + self._supported_controller = device_data["supportedController"] + self._commands_encoding = device_data["commandsEncoding"] + self._speed_list = device_data["speed"] + self._commands = device_data["commands"] + self._speed = SPEED_OFF self._direction = None self._last_on_speed = None @@ -111,52 +118,53 @@ def __init__(self, hass, config, device_data): self._support_flags = ( FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_OFF - | FanEntityFeature.TURN_ON) + | FanEntityFeature.TURN_ON + ) - if (DIRECTION_REVERSE in self._commands and \ - DIRECTION_FORWARD in self._commands): + if DIRECTION_REVERSE in self._commands and DIRECTION_FORWARD in self._commands: self._direction = DIRECTION_REVERSE - self._support_flags = ( - self._support_flags | FanEntityFeature.DIRECTION) - if ('oscillate' in self._commands): + self._support_flags = self._support_flags | FanEntityFeature.DIRECTION + if "oscillate" in self._commands: self._oscillating = False - self._support_flags = ( - self._support_flags | FanEntityFeature.OSCILLATE) - + self._support_flags = self._support_flags | FanEntityFeature.OSCILLATE self._temp_lock = asyncio.Lock() self._on_by_remote = False - #Init the IR/RF controller + # Init the IR/RF controller self._controller = get_controller( self.hass, - self._supported_controller, + self._supported_controller, self._commands_encoding, self._controller_data, - self._delay) + self._delay, + ) async def async_added_to_hass(self): """Run when entity about to be added.""" await super().async_added_to_hass() - + last_state = await self.async_get_last_state() if last_state is not None: - if 'speed' in last_state.attributes: - self._speed = last_state.attributes['speed'] + if "speed" in last_state.attributes: + self._speed = last_state.attributes["speed"] - #If _direction has a value the direction controls appears - #in UI even if SUPPORT_DIRECTION is not provided in the flags - if ('direction' in last_state.attributes and \ - self._support_flags & FanEntityFeature.DIRECTION): - self._direction = last_state.attributes['direction'] + # If _direction has a value the direction controls appears + # in UI even if SUPPORT_DIRECTION is not provided in the flags + if ( + "direction" in last_state.attributes + and self._support_flags & FanEntityFeature.DIRECTION + ): + self._direction = last_state.attributes["direction"] - if 'last_on_speed' in last_state.attributes: - self._last_on_speed = last_state.attributes['last_on_speed'] + if "last_on_speed" in last_state.attributes: + self._last_on_speed = last_state.attributes["last_on_speed"] if self._power_sensor: - async_track_state_change_event(self.hass, self._power_sensor, - self._async_power_sensor_changed) + async_track_state_change_event( + self.hass, self._power_sensor, self._async_power_sensor_changed + ) @property def unique_id(self): @@ -171,15 +179,14 @@ def name(self): @property def state(self): """Return the current state.""" - if (self._on_by_remote or \ - self._speed != SPEED_OFF): + if self._on_by_remote or self._speed != SPEED_OFF: return STATE_ON return SPEED_OFF @property def percentage(self): """Return speed percentage of the fan.""" - if (self._speed == SPEED_OFF): + if self._speed == SPEED_OFF: return 0 return ordered_list_item_to_percentage(self._speed_list, self._speed) @@ -213,21 +220,20 @@ def supported_features(self): def extra_state_attributes(self): """Platform specific attributes.""" return { - 'last_on_speed': self._last_on_speed, - 'device_code': self._device_code, - 'manufacturer': self._manufacturer, - 'supported_models': self._supported_models, - 'supported_controller': self._supported_controller, - 'commands_encoding': self._commands_encoding, + "last_on_speed": self._last_on_speed, + "device_code": self._device_code, + "manufacturer": self._manufacturer, + "supported_models": self._supported_models, + "supported_controller": self._supported_controller, + "commands_encoding": self._commands_encoding, } async def async_set_percentage(self, percentage: int): """Set the desired speed for the fan.""" - if (percentage == 0): - self._speed = SPEED_OFF + if percentage == 0: + self._speed = SPEED_OFF else: - self._speed = percentage_to_ordered_list_item( - self._speed_list, percentage) + self._speed = percentage_to_ordered_list_item(self._speed_list, percentage) if not self._speed == SPEED_OFF: self._last_on_speed = self._speed @@ -251,11 +257,14 @@ async def async_set_direction(self, direction: str): self.async_write_ha_state() - async def async_turn_on(self, percentage: int = None, preset_mode: str = None, **kwargs): + async def async_turn_on( + self, percentage: int = None, preset_mode: str = None, **kwargs + ): """Turn on the fan.""" if percentage is None: percentage = ordered_list_item_to_percentage( - self._speed_list, self._last_on_speed or self._speed_list[0]) + self._speed_list, self._last_on_speed or self._speed_list[0] + ) await self.async_set_percentage(percentage) @@ -267,15 +276,15 @@ async def send_command(self): async with self._temp_lock: self._on_by_remote = False speed = self._speed - direction = self._direction or 'default' + direction = self._direction or "default" oscillating = self._oscillating if speed.lower() == SPEED_OFF: - command = self._commands['off'] + command = self._commands["off"] elif oscillating: - command = self._commands['oscillate'] + command = self._commands["oscillate"] else: - command = self._commands[direction][speed] + command = self._commands[direction][speed] try: await self._controller.send(command) @@ -283,7 +292,9 @@ async def send_command(self): _LOGGER.exception(e) @callback - async def _async_power_sensor_changed(self, event: Event[EventStateChangedData]) -> None: + async def _async_power_sensor_changed( + self, event: Event[EventStateChangedData] + ) -> None: """Handle power sensor changes.""" entity_id = event.data["entity_id"] old_state = event.data["old_state"] @@ -304,4 +315,4 @@ async def _async_power_sensor_changed(self, event: Event[EventStateChangedData]) self._on_by_remote = False if self._speed != SPEED_OFF: self._speed = SPEED_OFF - self.async_write_ha_state() \ No newline at end of file + self.async_write_ha_state() diff --git a/custom_components/smartir/manifest.json b/custom_components/smartir/manifest.json index f8a1a8f26..928078cac 100644 --- a/custom_components/smartir/manifest.json +++ b/custom_components/smartir/manifest.json @@ -4,18 +4,18 @@ "documentation": "https://github.com/smartHomeHub/SmartIR", "dependencies": [], "codeowners": ["@smartHomeHub"], - "requirements": ["aiofiles>=0.6.0"], + "requirements": ["aiofiles>=0.6.0", "pyhvac>=0.1.5"], "homeassistant": "2025.5.0", - "version": "1.18.1", + "version": "1.18.2", "updater": { - "version": "1.18.1", + "version": "1.18.2", "releaseNotes": "-- Implements new async_track_state_change_event", "files": [ "__init__.py", "climate.py", "media_player.py", "fan.py", - "light.py", + "light.py", "controller.py", "manifest.json", "services.yaml" diff --git a/custom_components/smartir/media_player.py b/custom_components/smartir/media_player.py index bb3cb3060..5648ab8ca 100644 --- a/custom_components/smartir/media_player.py +++ b/custom_components/smartir/media_player.py @@ -2,16 +2,15 @@ import aiofiles import json import logging -import os.path import voluptuous as vol -from homeassistant.components.media_player import ( - MediaPlayerEntity, PLATFORM_SCHEMA) +from homeassistant.components.media_player import MediaPlayerEntity, PLATFORM_SCHEMA from homeassistant.components.media_player.const import ( - MediaPlayerEntityFeature, MediaType) -from homeassistant.const import ( - CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) + MediaPlayerEntityFeature, + MediaType, +) +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from . import COMPONENT_ABS_DIR, Helper @@ -23,56 +22,62 @@ DEFAULT_DEVICE_CLASS = "tv" DEFAULT_DELAY = 0.5 -CONF_UNIQUE_ID = 'unique_id' -CONF_DEVICE_CODE = 'device_code' +CONF_UNIQUE_ID = "unique_id" +CONF_DEVICE_CODE = "device_code" CONF_CONTROLLER_DATA = "controller_data" CONF_DELAY = "delay" -CONF_POWER_SENSOR = 'power_sensor' -CONF_SOURCE_NAMES = 'source_names' -CONF_DEVICE_CLASS = 'device_class' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_DEVICE_CODE): cv.positive_int, - vol.Required(CONF_CONTROLLER_DATA): cv.string, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.string, - vol.Optional(CONF_POWER_SENSOR): cv.entity_id, - vol.Optional(CONF_SOURCE_NAMES): dict, - vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): cv.string -}) +CONF_POWER_SENSOR = "power_sensor" +CONF_SOURCE_NAMES = "source_names" +CONF_DEVICE_CLASS = "device_class" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_DEVICE_CODE): cv.positive_int, + vol.Required(CONF_CONTROLLER_DATA): cv.string, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.string, + vol.Optional(CONF_POWER_SENSOR): cv.entity_id, + vol.Optional(CONF_SOURCE_NAMES): dict, + vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): cv.string, + } +) + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the IR Media Player platform.""" device_code = config.get(CONF_DEVICE_CODE) - device_files_subdir = os.path.join('codes', 'media_player') - device_files_absdir = os.path.join(COMPONENT_ABS_DIR, device_files_subdir) + device_files_absdir = COMPONENT_ABS_DIR / "codes" / "media_player" - if not os.path.isdir(device_files_absdir): - os.makedirs(device_files_absdir) + device_files_absdir.mkdir(parents=True, exist_ok=True) - device_json_filename = str(device_code) + '.json' - device_json_path = os.path.join(device_files_absdir, device_json_filename) + device_json_path = device_files_absdir / (str(device_code) + ".json") - if not os.path.exists(device_json_path): - _LOGGER.warning("Couldn't find the device Json file. The component will " \ - "try to download it from the GitHub repo.") + if not device_json_path.exists(): + _LOGGER.warning( + "Couldn't find the device Json file. The component will " + "try to download it from the GitHub repo." + ) try: - codes_source = ("https://raw.githubusercontent.com/" - "smartHomeHub/SmartIR/master/" - "codes/media_player/{}.json") + codes_source = ( + "https://raw.githubusercontent.com/" + "smartHomeHub/SmartIR/master/" + "codes/media_player/{}.json" + ) await Helper.downloader(codes_source.format(device_code), device_json_path) except Exception: - _LOGGER.error("There was an error while downloading the device Json file. " \ - "Please check your internet connection or if the device code " \ - "exists on GitHub. If the problem still exists please " \ - "place the file manually in the proper directory.") + _LOGGER.error( + "There was an error while downloading the device Json file. " + "Please check your internet connection or if the device code " + "exists on GitHub. If the problem still exists please " + "place the file manually in the proper directory." + ) return try: - async with aiofiles.open(device_json_path, mode='r') as j: + async with aiofiles.open(device_json_path, mode="r") as j: _LOGGER.debug(f"loading json file {device_json_path}") content = await j.read() device_data = json.loads(content) @@ -81,9 +86,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.error("The device JSON file is invalid") return - async_add_entities([SmartIRMediaPlayer( - hass, config, device_data - )]) + async_add_entities([SmartIRMediaPlayer(hass, config, device_data)]) + class SmartIRMediaPlayer(MediaPlayerEntity, RestoreEntity): def __init__(self, hass, config, device_data): @@ -95,11 +99,11 @@ def __init__(self, hass, config, device_data): self._delay = config.get(CONF_DELAY) self._power_sensor = config.get(CONF_POWER_SENSOR) - self._manufacturer = device_data['manufacturer'] - self._supported_models = device_data['supportedModels'] - self._supported_controller = device_data['supportedController'] - self._commands_encoding = device_data['commandsEncoding'] - self._commands = device_data['commands'] + self._manufacturer = device_data["manufacturer"] + self._supported_models = device_data["supportedModels"] + self._supported_controller = device_data["supportedController"] + self._commands_encoding = device_data["commandsEncoding"] + self._commands = device_data["commands"] self._state = STATE_OFF self._sources_list = [] @@ -108,49 +112,73 @@ def __init__(self, hass, config, device_data): self._device_class = config.get(CONF_DEVICE_CLASS) - #Supported features - if 'off' in self._commands and self._commands['off'] is not None: - self._support_flags = self._support_flags | MediaPlayerEntityFeature.TURN_OFF + # Supported features + if "off" in self._commands and self._commands["off"] is not None: + self._support_flags = ( + self._support_flags | MediaPlayerEntityFeature.TURN_OFF + ) - if 'on' in self._commands and self._commands['on'] is not None: + if "on" in self._commands and self._commands["on"] is not None: self._support_flags = self._support_flags | MediaPlayerEntityFeature.TURN_ON - if 'previousChannel' in self._commands and self._commands['previousChannel'] is not None: - self._support_flags = self._support_flags | MediaPlayerEntityFeature.PREVIOUS_TRACK - - if 'nextChannel' in self._commands and self._commands['nextChannel'] is not None: - self._support_flags = self._support_flags | MediaPlayerEntityFeature.NEXT_TRACK - - if ('volumeDown' in self._commands and self._commands['volumeDown'] is not None) \ - or ('volumeUp' in self._commands and self._commands['volumeUp'] is not None): - self._support_flags = self._support_flags | MediaPlayerEntityFeature.VOLUME_STEP - - if 'mute' in self._commands and self._commands['mute'] is not None: - self._support_flags = self._support_flags | MediaPlayerEntityFeature.VOLUME_MUTE - - if 'sources' in self._commands and self._commands['sources'] is not None: - self._support_flags = self._support_flags | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY_MEDIA + if ( + "previousChannel" in self._commands + and self._commands["previousChannel"] is not None + ): + self._support_flags = ( + self._support_flags | MediaPlayerEntityFeature.PREVIOUS_TRACK + ) + + if ( + "nextChannel" in self._commands + and self._commands["nextChannel"] is not None + ): + self._support_flags = ( + self._support_flags | MediaPlayerEntityFeature.NEXT_TRACK + ) + + if ( + "volumeDown" in self._commands and self._commands["volumeDown"] is not None + ) or ("volumeUp" in self._commands and self._commands["volumeUp"] is not None): + self._support_flags = ( + self._support_flags | MediaPlayerEntityFeature.VOLUME_STEP + ) + + if "mute" in self._commands and self._commands["mute"] is not None: + self._support_flags = ( + self._support_flags | MediaPlayerEntityFeature.VOLUME_MUTE + ) + + if "sources" in self._commands and self._commands["sources"] is not None: + self._support_flags = ( + self._support_flags + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.PLAY_MEDIA + ) for source, new_name in config.get(CONF_SOURCE_NAMES, {}).items(): - if source in self._commands['sources']: + if source in self._commands["sources"]: if new_name is not None: - self._commands['sources'][new_name] = self._commands['sources'][source] + self._commands["sources"][new_name] = self._commands["sources"][ + source + ] - del self._commands['sources'][source] + del self._commands["sources"][source] - #Sources list - for key in self._commands['sources']: + # Sources list + for key in self._commands["sources"]: self._sources_list.append(key) self._temp_lock = asyncio.Lock() - #Init the IR/RF controller + # Init the IR/RF controller self._controller = get_controller( self.hass, - self._supported_controller, + self._supported_controller, self._commands_encoding, self._controller_data, - self._delay) + self._delay, + ) async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -199,7 +227,7 @@ def media_content_type(self): @property def source_list(self): return self._sources_list - + @property def source(self): return self._source @@ -213,17 +241,17 @@ def supported_features(self): def extra_state_attributes(self): """Platform specific attributes.""" return { - 'device_code': self._device_code, - 'manufacturer': self._manufacturer, - 'supported_models': self._supported_models, - 'supported_controller': self._supported_controller, - 'commands_encoding': self._commands_encoding, + "device_code": self._device_code, + "manufacturer": self._manufacturer, + "supported_models": self._supported_models, + "supported_controller": self._supported_controller, + "commands_encoding": self._commands_encoding, } async def async_turn_off(self): """Turn the media player off.""" - await self.send_command(self._commands['off']) - + await self.send_command(self._commands["off"]) + if self._power_sensor is None: self._state = STATE_OFF self._source = None @@ -231,7 +259,7 @@ async def async_turn_off(self): async def async_turn_on(self): """Turn the media player off.""" - await self.send_command(self._commands['on']) + await self.send_command(self._commands["on"]) if self._power_sensor is None: self._state = STATE_ON @@ -239,33 +267,33 @@ async def async_turn_on(self): async def async_media_previous_track(self): """Send previous track command.""" - await self.send_command(self._commands['previousChannel']) + await self.send_command(self._commands["previousChannel"]) self.async_write_ha_state() async def async_media_next_track(self): """Send next track command.""" - await self.send_command(self._commands['nextChannel']) + await self.send_command(self._commands["nextChannel"]) self.async_write_ha_state() async def async_volume_down(self): """Turn volume down for media player.""" - await self.send_command(self._commands['volumeDown']) + await self.send_command(self._commands["volumeDown"]) self.async_write_ha_state() async def async_volume_up(self): """Turn volume up for media player.""" - await self.send_command(self._commands['volumeUp']) + await self.send_command(self._commands["volumeUp"]) self.async_write_ha_state() - + async def async_mute_volume(self, mute): """Mute the volume.""" - await self.send_command(self._commands['mute']) + await self.send_command(self._commands["mute"]) self.async_write_ha_state() async def async_select_source(self, source): """Select channel from source.""" self._source = source - await self.send_command(self._commands['sources'][source]) + await self.send_command(self._commands["sources"][source]) self.async_write_ha_state() async def async_play_media(self, media_type, media_id, **kwargs): @@ -282,7 +310,9 @@ async def async_play_media(self, media_type, media_id, **kwargs): self._source = "Channel {}".format(media_id) for digit in media_id: - await self.send_command(self._commands['sources']["Channel {}".format(digit)]) + await self.send_command( + self._commands["sources"]["Channel {}".format(digit)] + ) self.async_write_ha_state() async def send_command(self, command): @@ -291,7 +321,7 @@ async def send_command(self, command): await self._controller.send(command) except Exception as e: _LOGGER.exception(e) - + async def async_update(self): if self._power_sensor is None: return