feat: rename the project to Infrarize
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import binascii
|
||||
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)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = 'infrarize'
|
||||
VERSION = '1.18.1'
|
||||
MANIFEST_URL = (
|
||||
"https://raw.githubusercontent.com/"
|
||||
"kobimx/Infrarize/{}/"
|
||||
"custom_components/infrarize/manifest.json")
|
||||
REMOTE_BASE_URL = (
|
||||
"https://raw.githubusercontent.com/"
|
||||
"kobimx/Infrarize/{}/"
|
||||
"custom_components/infrarize/")
|
||||
COMPONENT_ABS_DIR = os.path.dirname(
|
||||
os.path.abspath(__file__))
|
||||
|
||||
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 Infrarize component."""
|
||||
conf = config.get(DOMAIN)
|
||||
|
||||
if conf is None:
|
||||
return True
|
||||
|
||||
check_updates = conf[CONF_CHECK_UPDATES]
|
||||
update_branch = conf[CONF_UPDATE_BRANCH]
|
||||
|
||||
async def _check_updates(service):
|
||||
await _update(hass, update_branch)
|
||||
|
||||
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)
|
||||
|
||||
if check_updates:
|
||||
await _update(hass, update_branch, False, False)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Set up Infrarize from a config entry (UI-configured device)."""
|
||||
platform = entry.data.get('platform')
|
||||
if not platform:
|
||||
return False
|
||||
await hass.config_entries.async_forward_entry_setups(entry, [platform])
|
||||
# Reload the entry whenever options are changed in the UI so the new
|
||||
# controller_data / delay / sensor settings take effect immediately.
|
||||
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
|
||||
return True
|
||||
|
||||
|
||||
async def _async_options_updated(hass, entry):
|
||||
"""Triggered when the options flow saves; reloads the config entry."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass, entry):
|
||||
"""Unload an Infrarize config entry."""
|
||||
platform = entry.data.get('platform')
|
||||
if not platform:
|
||||
return True
|
||||
return await hass.config_entries.async_unload_platforms(entry, [platform])
|
||||
|
||||
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']
|
||||
|
||||
if StrictVersion(last_version) <= StrictVersion(VERSION):
|
||||
if notify_if_latest:
|
||||
hass.components.persistent_notification.async_create(
|
||||
"You're already using the latest version!",
|
||||
title='Infrarize')
|
||||
return
|
||||
|
||||
if StrictVersion(current_ha_version) < StrictVersion(min_ha_version):
|
||||
hass.components.persistent_notification.async_create(
|
||||
"There is a new version of Infrarize integration, but it is **incompatible** "
|
||||
"with your system. Please first update Home Assistant.", title='Infrarize')
|
||||
return
|
||||
|
||||
if do_update is False:
|
||||
hass.components.persistent_notification.async_create(
|
||||
"A new version of Infrarize integration is available ({}). "
|
||||
"Call the ``infrarize.update_component`` service to update "
|
||||
"the integration. \n\n **Release notes:** \n{}"
|
||||
.format(last_version, release_notes), title='Infrarize')
|
||||
return
|
||||
|
||||
# Begin update
|
||||
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)
|
||||
await Helper.downloader(source, dest)
|
||||
except Exception:
|
||||
has_errors = True
|
||||
_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 Infrarize. "
|
||||
"Please check the logs for more information.", title='Infrarize')
|
||||
else:
|
||||
hass.components.persistent_notification.async_create(
|
||||
"Successfully updated to {}. Please restart Home Assistant."
|
||||
.format(last_version), title='Infrarize')
|
||||
except Exception:
|
||||
_LOGGER.error("An error occurred while checking for updates.")
|
||||
|
||||
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:
|
||||
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)]
|
||||
|
||||
if codes[0]:
|
||||
raise ValueError("Pronto code should start with 0000")
|
||||
if len(codes) != 4 + 2 * (codes[2] + codes[3]):
|
||||
raise ValueError("Number of pulse widths does not match the preamble")
|
||||
|
||||
frequency = 1 / (codes[1] * 0.241246)
|
||||
return [int(round(code / frequency)) for code in codes[4:]]
|
||||
|
||||
@staticmethod
|
||||
def lirc2broadlink(pulses):
|
||||
array = bytearray()
|
||||
|
||||
for pulse in pulses:
|
||||
pulse = int(pulse * 269 / 8192)
|
||||
|
||||
if pulse < 256:
|
||||
array += bytearray(struct.pack('>B', pulse))
|
||||
else:
|
||||
array += bytearray([0x00])
|
||||
array += bytearray(struct.pack('>H', pulse))
|
||||
|
||||
packet = bytearray([0x26, 0x00])
|
||||
packet += bytearray(struct.pack('<H', len(array)))
|
||||
packet += array
|
||||
packet += bytearray([0x0d, 0x05])
|
||||
|
||||
# Add 0s to make ultimate packet size a multiple of 16 for 128-bit AES encryption.
|
||||
remainder = (len(packet) + 4) % 16
|
||||
if remainder:
|
||||
packet += bytearray(16 - remainder)
|
||||
return packet
|
||||
@@ -0,0 +1,463 @@
|
||||
import asyncio
|
||||
import aiofiles
|
||||
import json
|
||||
import logging
|
||||
import os.path
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import ClimateEntity, PLATFORM_SCHEMA
|
||||
from homeassistant.components.climate.const import (
|
||||
ClimateEntityFeature, HVACMode, HVAC_MODES, ATTR_HVAC_MODE)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, STATE_ON, STATE_OFF, STATE_UNKNOWN, STATE_UNAVAILABLE, ATTR_TEMPERATURE,
|
||||
PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE)
|
||||
from homeassistant.core import Event, EventStateChangedData, callback
|
||||
from homeassistant.helpers.event import async_track_state_change, async_track_state_change_event
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from . import COMPONENT_ABS_DIR, Helper
|
||||
from .controller import get_controller
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "Infrarize Climate"
|
||||
DEFAULT_DELAY = 0.5
|
||||
|
||||
CONF_UNIQUE_ID = 'unique_id'
|
||||
CONF_DEVICE_CODE = 'device_code'
|
||||
CONF_CONTROLLER_DATA = "controller_data"
|
||||
CONF_DELAY = "delay"
|
||||
CONF_TEMPERATURE_SENSOR = 'temperature_sensor'
|
||||
CONF_HUMIDITY_SENSOR = 'humidity_sensor'
|
||||
CONF_POWER_SENSOR = 'power_sensor'
|
||||
CONF_POWER_SENSOR_RESTORE_STATE = 'power_sensor_restore_state'
|
||||
|
||||
SUPPORT_FLAGS = (
|
||||
ClimateEntityFeature.TURN_OFF |
|
||||
ClimateEntityFeature.TURN_ON |
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE |
|
||||
ClimateEntityFeature.FAN_MODE
|
||||
)
|
||||
|
||||
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.positive_float,
|
||||
vol.Optional(CONF_TEMPERATURE_SENSOR): cv.entity_id,
|
||||
vol.Optional(CONF_HUMIDITY_SENSOR): cv.entity_id,
|
||||
vol.Optional(CONF_POWER_SENSOR): cv.entity_id,
|
||||
vol.Optional(CONF_POWER_SENSOR_RESTORE_STATE, default=False): cv.boolean
|
||||
})
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Set up Infrarize Climate from a config entry."""
|
||||
config = {**entry.data, **entry.options}
|
||||
config.pop('platform', None)
|
||||
config.setdefault('unique_id', entry.unique_id)
|
||||
await async_setup_platform(hass, config, async_add_entities)
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
||||
"""Set up the IR Climate platform."""
|
||||
_LOGGER.debug("Setting up the infrarize platform")
|
||||
device_code = config.get(CONF_DEVICE_CODE)
|
||||
device_files_subdir = os.path.join('codes', 'climate')
|
||||
device_files_absdir = os.path.join(COMPONENT_ABS_DIR, device_files_subdir)
|
||||
|
||||
if not os.path.isdir(device_files_absdir):
|
||||
os.makedirs(device_files_absdir)
|
||||
|
||||
device_json_filename = str(device_code) + '.json'
|
||||
device_json_path = os.path.join(device_files_absdir, device_json_filename)
|
||||
|
||||
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.")
|
||||
|
||||
try:
|
||||
codes_source = ("https://raw.githubusercontent.com/"
|
||||
"kobimx/Infrarize/master/"
|
||||
"codes/climate/{}.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.")
|
||||
return
|
||||
|
||||
try:
|
||||
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)
|
||||
_LOGGER.debug(f"{device_json_path} file loaded")
|
||||
except Exception:
|
||||
_LOGGER.error("The device JSON file is invalid")
|
||||
return
|
||||
|
||||
async_add_entities([InfrarizeClimate(
|
||||
hass, config, device_data
|
||||
)])
|
||||
|
||||
class InfrarizeClimate(ClimateEntity, RestoreEntity):
|
||||
def __init__(self, hass, config, device_data):
|
||||
_LOGGER.debug(f"InfrarizeClimate init started for device {config.get(CONF_NAME)} supported models {device_data['supportedModels']}")
|
||||
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._manufacturer = device_data['manufacturer']
|
||||
self._supported_models = device_data['supportedModels']
|
||||
self._supported_controller = device_data['supportedController']
|
||||
self._commands_encoding = device_data['commandsEncoding']
|
||||
self._min_temperature = device_data['minTemperature']
|
||||
self._max_temperature = device_data['maxTemperature']
|
||||
self._precision = device_data['precision']
|
||||
|
||||
valid_hvac_modes = [x for x in device_data['operationModes'] if x in HVAC_MODES]
|
||||
|
||||
self._operation_modes = [HVACMode.OFF] + valid_hvac_modes
|
||||
self._fan_modes = device_data['fanModes']
|
||||
self._swing_modes = device_data.get('swingModes')
|
||||
self._commands = device_data['commands']
|
||||
|
||||
self._target_temperature = self._min_temperature
|
||||
self._hvac_mode = HVACMode.OFF
|
||||
self._current_fan_mode = self._fan_modes[0]
|
||||
self._current_swing_mode = None
|
||||
self._last_on_operation = None
|
||||
|
||||
self._current_temperature = None
|
||||
self._current_humidity = None
|
||||
|
||||
self._unit = hass.config.units.temperature_unit
|
||||
|
||||
#Supported features
|
||||
self._support_flags = SUPPORT_FLAGS
|
||||
self._support_swing = False
|
||||
|
||||
if self._swing_modes:
|
||||
self._support_flags = self._support_flags | ClimateEntityFeature.SWING_MODE
|
||||
self._current_swing_mode = self._swing_modes[0]
|
||||
self._support_swing = True
|
||||
|
||||
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 async_added_to_hass(self):
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
_LOGGER.debug(f"async_added_to_hass {self} {self.name} {self.supported_features}")
|
||||
|
||||
last_state = await self.async_get_last_state()
|
||||
|
||||
if last_state is not None:
|
||||
self._hvac_mode = last_state.state
|
||||
self._current_fan_mode = last_state.attributes['fan_mode']
|
||||
self._current_swing_mode = last_state.attributes.get('swing_mode')
|
||||
self._target_temperature = last_state.attributes['temperature']
|
||||
|
||||
if 'last_on_operation' in last_state.attributes:
|
||||
self._last_on_operation = last_state.attributes['last_on_operation']
|
||||
|
||||
if self._temperature_sensor:
|
||||
async_track_state_change_event(self.hass, self._temperature_sensor,
|
||||
self._async_temp_sensor_changed)
|
||||
|
||||
temp_sensor_state = self.hass.states.get(self._temperature_sensor)
|
||||
if temp_sensor_state and temp_sensor_state.state != STATE_UNKNOWN:
|
||||
self._async_update_temp(temp_sensor_state)
|
||||
|
||||
if self._humidity_sensor:
|
||||
async_track_state_change_event(self.hass, self._humidity_sensor,
|
||||
self._async_humidity_sensor_changed)
|
||||
|
||||
humidity_sensor_state = self.hass.states.get(self._humidity_sensor)
|
||||
if humidity_sensor_state and humidity_sensor_state.state != STATE_UNKNOWN:
|
||||
self._async_update_humidity(humidity_sensor_state)
|
||||
|
||||
if self._power_sensor:
|
||||
async_track_state_change_event(self.hass, self._power_sensor,
|
||||
self._async_power_sensor_changed)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the climate device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the current state."""
|
||||
if self.hvac_mode != HVACMode.OFF:
|
||||
return self.hvac_mode
|
||||
return HVACMode.OFF
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
return self._unit
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the polling state."""
|
||||
return self._min_temperature
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the polling state."""
|
||||
return self._max_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._target_temperature
|
||||
|
||||
@property
|
||||
def target_temperature_step(self):
|
||||
"""Return the supported step of target temperature."""
|
||||
return self._precision
|
||||
|
||||
@property
|
||||
def hvac_modes(self):
|
||||
"""Return the list of available operation modes."""
|
||||
return self._operation_modes
|
||||
|
||||
@property
|
||||
def hvac_mode(self):
|
||||
"""Return hvac mode ie. heat, cool."""
|
||||
return self._hvac_mode
|
||||
|
||||
@property
|
||||
def last_on_operation(self):
|
||||
"""Return the last non-idle operation ie. heat, cool."""
|
||||
return self._last_on_operation
|
||||
|
||||
@property
|
||||
def fan_modes(self):
|
||||
"""Return the list of available fan modes."""
|
||||
return self._fan_modes
|
||||
|
||||
@property
|
||||
def fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
return self._current_fan_mode
|
||||
|
||||
@property
|
||||
def swing_modes(self):
|
||||
"""Return the swing modes currently supported for this device."""
|
||||
return self._swing_modes
|
||||
|
||||
@property
|
||||
def swing_mode(self):
|
||||
"""Return the current swing mode."""
|
||||
return self._current_swing_mode
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._current_temperature
|
||||
|
||||
@property
|
||||
def current_humidity(self):
|
||||
"""Return the current humidity."""
|
||||
return self._current_humidity
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
return self._support_flags
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Platform specific attributes."""
|
||||
return {
|
||||
'last_on_operation': self._last_on_operation,
|
||||
'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_temperature(self, **kwargs):
|
||||
"""Set new target temperatures."""
|
||||
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
|
||||
if temperature is None:
|
||||
return
|
||||
|
||||
if temperature < self._min_temperature or temperature > self._max_temperature:
|
||||
_LOGGER.warning('The temperature value is out of min/max range')
|
||||
return
|
||||
|
||||
if self._precision == PRECISION_WHOLE:
|
||||
self._target_temperature = round(temperature)
|
||||
else:
|
||||
self._target_temperature = round(temperature, 1)
|
||||
|
||||
if hvac_mode:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
return
|
||||
|
||||
if not self._hvac_mode.lower() == HVACMode.OFF:
|
||||
await self.send_command()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
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
|
||||
|
||||
await self.send_command()
|
||||
self.async_write_ha_state()
|
||||
|
||||
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()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode):
|
||||
"""Set swing mode."""
|
||||
self._current_swing_mode = swing_mode
|
||||
|
||||
if not self._hvac_mode.lower() == HVACMode.OFF:
|
||||
await self.send_command()
|
||||
self.async_write_ha_state()
|
||||
|
||||
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:
|
||||
await self.async_set_hvac_mode(self._last_on_operation)
|
||||
else:
|
||||
await self.async_set_hvac_mode(self._operation_modes[1])
|
||||
|
||||
async def send_command(self):
|
||||
async with self._temp_lock:
|
||||
try:
|
||||
self._on_by_remote = False
|
||||
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)
|
||||
|
||||
if operation_mode.lower() == HVACMode.OFF:
|
||||
await self._controller.send(self._commands['off'])
|
||||
return
|
||||
|
||||
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])
|
||||
else:
|
||||
await self._controller.send(
|
||||
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:
|
||||
"""Handle temperature sensor changes."""
|
||||
new_state = event.data["new_state"]
|
||||
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
self._async_update_temp(new_state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
async def _async_humidity_sensor_changed(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle humidity sensor changes."""
|
||||
new_state = event.data["new_state"]
|
||||
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
self._async_update_humidity(new_state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
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
|
||||
|
||||
if old_state is not None and new_state.state == old_state.state:
|
||||
return
|
||||
|
||||
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:
|
||||
self._hvac_mode = self._last_on_operation
|
||||
else:
|
||||
self._hvac_mode = STATE_ON
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
if new_state.state == STATE_OFF:
|
||||
self._on_by_remote = False
|
||||
if self._hvac_mode != HVACMode.OFF:
|
||||
self._hvac_mode = HVACMode.OFF
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_update_temp(self, state):
|
||||
"""Update thermostat with latest state from temperature sensor."""
|
||||
try:
|
||||
if state.state != STATE_UNKNOWN and state.state != STATE_UNAVAILABLE:
|
||||
self._current_temperature = float(state.state)
|
||||
except ValueError as ex:
|
||||
_LOGGER.error("Unable to update from temperature sensor: %s", ex)
|
||||
|
||||
@callback
|
||||
def _async_update_humidity(self, state):
|
||||
"""Update thermostat with latest state from humidity sensor."""
|
||||
try:
|
||||
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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,668 @@
|
||||
"""Config flow for Infrarize — 6-step UI wizard with device list selection."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import aiofiles
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
EntitySelector,
|
||||
EntitySelectorConfig,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from . import COMPONENT_ABS_DIR, Helper
|
||||
from .const import (
|
||||
ALL_CONTROLLERS_SENTINEL,
|
||||
CONF_CONTROLLER_DATA,
|
||||
CONF_DELAY,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_DEVICE_CODE,
|
||||
CONF_DEVICE_NAME,
|
||||
CONF_HUMIDITY_SENSOR,
|
||||
CONF_PLATFORM,
|
||||
CONF_POWER_SENSOR,
|
||||
CONF_POWER_SENSOR_RESTORE_STATE,
|
||||
CONF_SOURCE_NAMES,
|
||||
CONF_TEMPERATURE_SENSOR,
|
||||
CONTROLLER_HINTS,
|
||||
DEFAULT_DELAY,
|
||||
DEFAULT_DEVICE_NAMES,
|
||||
ENTITY_BASED_CONTROLLERS,
|
||||
INDEX_FILENAME,
|
||||
MANUAL_ENTRY_SENTINEL,
|
||||
PLATFORM_SUBDIR,
|
||||
PLATFORMS,
|
||||
)
|
||||
|
||||
DOMAIN = "infrarize"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Module-level helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async def _load_device_json(platform: str, device_code: int) -> dict | None:
|
||||
"""Return device JSON dict, downloading from GitHub if not cached locally."""
|
||||
subdir = PLATFORM_SUBDIR[platform]
|
||||
codes_dir = os.path.join(COMPONENT_ABS_DIR, "codes", subdir)
|
||||
os.makedirs(codes_dir, exist_ok=True)
|
||||
path = os.path.join(codes_dir, f"{device_code}.json")
|
||||
|
||||
if not os.path.exists(path):
|
||||
source = (
|
||||
"https://raw.githubusercontent.com/"
|
||||
f"kobimx/Infrarize/master/codes/{subdir}/{device_code}.json"
|
||||
)
|
||||
try:
|
||||
await Helper.downloader(source, path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with aiofiles.open(path, mode="r") as f:
|
||||
return json.loads(await f.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _controller_schema(
|
||||
controller_type: str,
|
||||
current_data: str = "",
|
||||
current_delay: str = DEFAULT_DELAY,
|
||||
) -> vol.Schema:
|
||||
"""Build controller step schema; entity picker for Broadlink/Xiaomi, text for rest."""
|
||||
if controller_type in ENTITY_BASED_CONTROLLERS:
|
||||
data_field: object = EntitySelector(EntitySelectorConfig(domain="remote"))
|
||||
else:
|
||||
data_field = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
|
||||
fields: dict = {}
|
||||
if current_data:
|
||||
fields[
|
||||
vol.Required(
|
||||
CONF_CONTROLLER_DATA,
|
||||
description={"suggested_value": current_data},
|
||||
)
|
||||
] = data_field
|
||||
else:
|
||||
fields[vol.Required(CONF_CONTROLLER_DATA)] = data_field
|
||||
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_DELAY,
|
||||
description={"suggested_value": current_delay},
|
||||
)
|
||||
] = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
|
||||
return vol.Schema(fields)
|
||||
|
||||
|
||||
def _options_schema(platform: str, defaults: dict | None = None) -> vol.Schema:
|
||||
"""Build optional-sensors schema for the given platform."""
|
||||
d = defaults or {}
|
||||
|
||||
def _opt(key: str) -> vol.Optional:
|
||||
val = d.get(key)
|
||||
if val is not None and val != "":
|
||||
return vol.Optional(key, description={"suggested_value": val})
|
||||
return vol.Optional(key)
|
||||
|
||||
# Power sensors are only ever checked for on/off state (see
|
||||
# InfrarizeClimate._async_power_sensor_changed and the equivalent methods in
|
||||
# fan.py, light.py, media_player.py, which compare .state to STATE_ON/STATE_OFF)
|
||||
# so restrict the picker to domains that expose a binary on/off state.
|
||||
power_sensor_selector = EntitySelector(
|
||||
EntitySelectorConfig(domain=["binary_sensor", "switch", "input_boolean"])
|
||||
)
|
||||
|
||||
if platform == "climate":
|
||||
return vol.Schema(
|
||||
{
|
||||
_opt(CONF_TEMPERATURE_SENSOR): EntitySelector(
|
||||
EntitySelectorConfig(domain="sensor", device_class="temperature")
|
||||
),
|
||||
_opt(CONF_HUMIDITY_SENSOR): EntitySelector(
|
||||
EntitySelectorConfig(domain="sensor", device_class="humidity")
|
||||
),
|
||||
_opt(CONF_POWER_SENSOR): power_sensor_selector,
|
||||
vol.Optional(
|
||||
CONF_POWER_SENSOR_RESTORE_STATE,
|
||||
default=bool(d.get(CONF_POWER_SENSOR_RESTORE_STATE, False)),
|
||||
): BooleanSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
if platform == "media_player":
|
||||
src_existing = d.get(CONF_SOURCE_NAMES, {})
|
||||
src_str = (
|
||||
json.dumps(src_existing)
|
||||
if isinstance(src_existing, dict) and src_existing
|
||||
else ""
|
||||
)
|
||||
fields: dict = {
|
||||
_opt(CONF_POWER_SENSOR): power_sensor_selector,
|
||||
vol.Optional(
|
||||
CONF_DEVICE_CLASS,
|
||||
description={"suggested_value": d.get(CONF_DEVICE_CLASS, "tv")},
|
||||
): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)),
|
||||
}
|
||||
src_key = (
|
||||
vol.Optional(
|
||||
CONF_SOURCE_NAMES,
|
||||
description={"suggested_value": src_str},
|
||||
)
|
||||
if src_str
|
||||
else vol.Optional(CONF_SOURCE_NAMES)
|
||||
)
|
||||
fields[src_key] = TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True)
|
||||
)
|
||||
return vol.Schema(fields)
|
||||
|
||||
# fan and light
|
||||
return vol.Schema({_opt(CONF_POWER_SENSOR): power_sensor_selector})
|
||||
|
||||
|
||||
def _parse_options(platform: str, user_input: dict) -> dict:
|
||||
"""Clean and coerce options form input before storing."""
|
||||
out: dict = {}
|
||||
|
||||
def _maybe(key: str) -> None:
|
||||
val = user_input.get(key, "")
|
||||
if isinstance(val, str):
|
||||
val = val.strip()
|
||||
if val:
|
||||
out[key] = val
|
||||
|
||||
if platform == "climate":
|
||||
_maybe(CONF_TEMPERATURE_SENSOR)
|
||||
_maybe(CONF_HUMIDITY_SENSOR)
|
||||
_maybe(CONF_POWER_SENSOR)
|
||||
out[CONF_POWER_SENSOR_RESTORE_STATE] = bool(
|
||||
user_input.get(CONF_POWER_SENSOR_RESTORE_STATE, False)
|
||||
)
|
||||
elif platform == "media_player":
|
||||
_maybe(CONF_POWER_SENSOR)
|
||||
out[CONF_DEVICE_CLASS] = (
|
||||
(user_input.get(CONF_DEVICE_CLASS) or "tv").strip() or "tv"
|
||||
)
|
||||
src_raw = user_input.get(CONF_SOURCE_NAMES, "")
|
||||
if isinstance(src_raw, str) and src_raw.strip():
|
||||
try:
|
||||
parsed = json.loads(src_raw.strip())
|
||||
if isinstance(parsed, dict):
|
||||
out[CONF_SOURCE_NAMES] = parsed
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
elif isinstance(src_raw, dict) and src_raw:
|
||||
out[CONF_SOURCE_NAMES] = src_raw
|
||||
else: # fan, light
|
||||
_maybe(CONF_POWER_SENSOR)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _device_placeholders(device_data: dict, platform: str) -> dict:
|
||||
"""Build description_placeholders dict for the options step."""
|
||||
p: dict = {
|
||||
"manufacturer": device_data.get("manufacturer", "—"),
|
||||
"models": ", ".join(device_data.get("supportedModels", [])) or "—",
|
||||
"controller_type": device_data.get("supportedController", "—"),
|
||||
"device_code": str(device_data.get("device_code", "—")),
|
||||
}
|
||||
if platform == "climate":
|
||||
p["temp_range"] = (
|
||||
f"{device_data.get('minTemperature', '—')} – "
|
||||
f"{device_data.get('maxTemperature', '—')} °C"
|
||||
)
|
||||
p["operation_modes"] = ", ".join(device_data.get("operationModes", []))
|
||||
p["fan_modes"] = ", ".join(device_data.get("fanModes", []))
|
||||
swing = device_data.get("swingModes", [])
|
||||
p["swing_modes"] = ", ".join(swing) if swing else "none"
|
||||
return p
|
||||
|
||||
|
||||
# ── Config Flow ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class InfrarizeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle multi-step UI config flow for Infrarize."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._platform: str = ""
|
||||
self._controller_filter: str = ALL_CONTROLLERS_SENTINEL
|
||||
self._manufacturer: str = ""
|
||||
self._device_code: int = 0
|
||||
self._device_data: dict = {}
|
||||
self._device_name: str = ""
|
||||
self._controller_data: str = ""
|
||||
self._delay: str = DEFAULT_DELAY
|
||||
self._index_cache: list[dict] | None = None
|
||||
|
||||
# ── Index helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_index(self) -> list[dict]:
|
||||
"""Load and cache index entries for the current platform."""
|
||||
if self._index_cache is not None:
|
||||
return self._index_cache
|
||||
path = os.path.join(COMPONENT_ABS_DIR, INDEX_FILENAME)
|
||||
try:
|
||||
async with aiofiles.open(path, mode="r") as f:
|
||||
data = json.loads(await f.read())
|
||||
self._index_cache = data.get(self._platform, [])
|
||||
except Exception:
|
||||
_LOGGER.warning("Could not load %s", INDEX_FILENAME)
|
||||
self._index_cache = []
|
||||
return self._index_cache
|
||||
|
||||
def _filtered(self, entries: list[dict]) -> list[dict]:
|
||||
if self._controller_filter == ALL_CONTROLLERS_SENTINEL:
|
||||
return entries
|
||||
return [e for e in entries if e.get("controller") == self._controller_filter]
|
||||
|
||||
# ── Step 1: platform ──────────────────────────────────────────────────────
|
||||
|
||||
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
|
||||
if user_input is not None:
|
||||
self._platform = user_input["platform"]
|
||||
return await self.async_step_controller_filter()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("platform"): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
{"label": v, "value": k}
|
||||
for k, v in PLATFORMS.items()
|
||||
],
|
||||
mode=SelectSelectorMode.LIST,
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# ── Step 2: controller filter ─────────────────────────────────────────────
|
||||
|
||||
async def async_step_controller_filter(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
entries = await self._get_index()
|
||||
|
||||
if user_input is not None:
|
||||
self._controller_filter = user_input.get(
|
||||
"controller_type", ALL_CONTROLLERS_SENTINEL
|
||||
)
|
||||
return await self.async_step_manufacturer()
|
||||
|
||||
available = sorted({e["controller"] for e in entries})
|
||||
options = [{"label": "Any (show all)", "value": ALL_CONTROLLERS_SENTINEL}]
|
||||
options += [{"label": c, "value": c} for c in available]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="controller_filter",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"controller_type", default=ALL_CONTROLLERS_SENTINEL
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options, mode=SelectSelectorMode.LIST
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# ── Step 3: manufacturer ──────────────────────────────────────────────────
|
||||
|
||||
async def async_step_manufacturer(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
entries = self._filtered(await self._get_index())
|
||||
manufacturers = sorted({e["manufacturer"] for e in entries})
|
||||
|
||||
if user_input is not None:
|
||||
sel = user_input.get("manufacturer", "")
|
||||
if sel == MANUAL_ENTRY_SENTINEL:
|
||||
return await self.async_step_device_manual()
|
||||
self._manufacturer = sel
|
||||
return await self.async_step_device_select()
|
||||
|
||||
options = [
|
||||
{"label": "— Enter code manually —", "value": MANUAL_ENTRY_SENTINEL}
|
||||
]
|
||||
options += [{"label": m, "value": m} for m in manufacturers]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="manufacturer",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("manufacturer"): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options, mode=SelectSelectorMode.DROPDOWN
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# ── Step 4a: device from index list ───────────────────────────────────────
|
||||
|
||||
async def async_step_device_select(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
entries = [
|
||||
e
|
||||
for e in self._filtered(await self._get_index())
|
||||
if e.get("manufacturer") == self._manufacturer
|
||||
]
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
sel = user_input.get("device_code", "")
|
||||
if sel == MANUAL_ENTRY_SENTINEL:
|
||||
return await self.async_step_device_manual()
|
||||
try:
|
||||
device_code = int(sel)
|
||||
except (ValueError, TypeError):
|
||||
errors["device_code"] = "invalid_device_code"
|
||||
else:
|
||||
device_data = await _load_device_json(self._platform, device_code)
|
||||
if device_data is None:
|
||||
errors["device_code"] = "invalid_device_code"
|
||||
else:
|
||||
self._device_code = device_code
|
||||
self._device_data = device_data
|
||||
models = device_data.get("supportedModels", [])
|
||||
self._device_name = (
|
||||
f"{self._manufacturer} {models[0]}"
|
||||
if models
|
||||
else DEFAULT_DEVICE_NAMES[self._platform]
|
||||
)
|
||||
return await self.async_step_device_name()
|
||||
|
||||
options = [
|
||||
{"label": "— Enter code manually —", "value": MANUAL_ENTRY_SENTINEL}
|
||||
]
|
||||
for e in sorted(entries, key=lambda x: x["code"]):
|
||||
models_str = ", ".join(e.get("models", []))
|
||||
options.append(
|
||||
{"label": f"{e['code']} — {models_str}", "value": str(e["code"])}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="device_select",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("device_code"): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options, mode=SelectSelectorMode.DROPDOWN
|
||||
)
|
||||
)
|
||||
}
|
||||
),
|
||||
description_placeholders={"manufacturer": self._manufacturer},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# ── Step 4b: manual code entry ────────────────────────────────────────────
|
||||
|
||||
async def async_step_device_manual(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
device_code = int(user_input["device_code"])
|
||||
if device_code <= 0:
|
||||
raise ValueError("non-positive")
|
||||
except (ValueError, KeyError, TypeError):
|
||||
errors["device_code"] = "invalid_device_code"
|
||||
else:
|
||||
device_data = await _load_device_json(self._platform, device_code)
|
||||
if device_data is None:
|
||||
errors["device_code"] = "invalid_device_code"
|
||||
else:
|
||||
self._device_code = device_code
|
||||
self._device_data = device_data
|
||||
manufacturer = device_data.get("manufacturer", "")
|
||||
models = device_data.get("supportedModels", [])
|
||||
self._manufacturer = manufacturer
|
||||
self._device_name = (
|
||||
f"{manufacturer} {models[0]}"
|
||||
if models
|
||||
else DEFAULT_DEVICE_NAMES[self._platform]
|
||||
)
|
||||
return await self.async_step_device_name()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="device_manual",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("device_code"): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.NUMBER)
|
||||
)
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# ── Step 4c: confirm / edit name ──────────────────────────────────────────
|
||||
|
||||
async def async_step_device_name(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
if user_input is not None:
|
||||
name = (user_input.get("name") or "").strip() or self._device_name
|
||||
self._device_name = name
|
||||
return await self.async_step_controller()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="device_name",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
"name",
|
||||
description={"suggested_value": self._device_name},
|
||||
): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"manufacturer": self._device_data.get("manufacturer", ""),
|
||||
"models": ", ".join(self._device_data.get("supportedModels", [])),
|
||||
"controller_type": self._device_data.get("supportedController", ""),
|
||||
"device_code": str(self._device_code),
|
||||
},
|
||||
)
|
||||
|
||||
# ── Step 5: controller data ───────────────────────────────────────────────
|
||||
|
||||
async def async_step_controller(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
controller_type = self._device_data.get("supportedController", "")
|
||||
hint = CONTROLLER_HINTS.get(controller_type, "Enter the controller connection string.")
|
||||
|
||||
if user_input is not None:
|
||||
controller_data = user_input.get(CONF_CONTROLLER_DATA, "")
|
||||
if isinstance(controller_data, str):
|
||||
controller_data = controller_data.strip()
|
||||
if not controller_data:
|
||||
errors[CONF_CONTROLLER_DATA] = "controller_data_required"
|
||||
else:
|
||||
self._controller_data = controller_data
|
||||
delay_raw = user_input.get(CONF_DELAY, DEFAULT_DELAY)
|
||||
try:
|
||||
float(delay_raw)
|
||||
self._delay = str(delay_raw)
|
||||
except (ValueError, TypeError):
|
||||
self._delay = DEFAULT_DELAY
|
||||
|
||||
uid = (
|
||||
f"infrarize_{self._platform}_{self._device_code}"
|
||||
f"_{self._controller_data}"
|
||||
)
|
||||
await self.async_set_unique_id(uid)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return await self.async_step_options_platform()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="controller",
|
||||
data_schema=_controller_schema(
|
||||
controller_type, self._controller_data, self._delay
|
||||
),
|
||||
description_placeholders={
|
||||
"controller_type": controller_type,
|
||||
"controller_hint": hint,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# ── Step 6: optional sensors / platform settings ──────────────────────────
|
||||
|
||||
async def async_step_options_platform(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
if user_input is not None:
|
||||
return self._create_entry(user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="options_platform",
|
||||
data_schema=_options_schema(self._platform),
|
||||
description_placeholders=_device_placeholders(
|
||||
self._device_data, self._platform
|
||||
),
|
||||
)
|
||||
|
||||
# ── Entry creation ────────────────────────────────────────────────────────
|
||||
|
||||
def _create_entry(self, options_input: dict) -> FlowResult:
|
||||
try:
|
||||
delay_float = float(self._delay)
|
||||
except (ValueError, TypeError):
|
||||
delay_float = 0.5
|
||||
|
||||
# Immutable identification stored in data
|
||||
data: dict = {
|
||||
CONF_PLATFORM: self._platform,
|
||||
CONF_DEVICE_CODE: self._device_code,
|
||||
CONF_DEVICE_NAME: self._device_name,
|
||||
}
|
||||
|
||||
# Mutable settings stored in options (editable via options flow)
|
||||
options: dict = {
|
||||
CONF_CONTROLLER_DATA: self._controller_data,
|
||||
CONF_DELAY: delay_float,
|
||||
}
|
||||
options.update(_parse_options(self._platform, options_input))
|
||||
|
||||
mfr = self._device_data.get("manufacturer", "Infrarize")
|
||||
title = f"{mfr} — {self._device_name}"
|
||||
return self.async_create_entry(title=title, data=data, options=options)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> InfrarizeOptionsFlow:
|
||||
return InfrarizeOptionsFlow(config_entry)
|
||||
|
||||
|
||||
# ── Options Flow ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class InfrarizeOptionsFlow(OptionsFlow):
|
||||
"""Edit controller and sensor settings for an existing Infrarize entry."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
self._entry = config_entry
|
||||
self._platform: str = config_entry.data.get(CONF_PLATFORM, "")
|
||||
self._device_code: int = config_entry.data.get(CONF_DEVICE_CODE, 0)
|
||||
self._device_data: dict = {}
|
||||
# Pre-fill from current options
|
||||
self._controller_data: str = config_entry.options.get(CONF_CONTROLLER_DATA, "")
|
||||
self._delay: str = str(config_entry.options.get(CONF_DELAY, DEFAULT_DELAY))
|
||||
|
||||
async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
|
||||
if not self._device_data:
|
||||
self._device_data = (
|
||||
await _load_device_json(self._platform, self._device_code) or {}
|
||||
)
|
||||
return await self.async_step_controller()
|
||||
|
||||
async def async_step_controller(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
errors: dict[str, str] = {}
|
||||
controller_type = self._device_data.get("supportedController", "")
|
||||
hint = CONTROLLER_HINTS.get(controller_type, "Enter the controller connection string.")
|
||||
|
||||
if user_input is not None:
|
||||
controller_data = user_input.get(CONF_CONTROLLER_DATA, "")
|
||||
if isinstance(controller_data, str):
|
||||
controller_data = controller_data.strip()
|
||||
if not controller_data:
|
||||
errors[CONF_CONTROLLER_DATA] = "controller_data_required"
|
||||
else:
|
||||
self._controller_data = controller_data
|
||||
delay_raw = user_input.get(CONF_DELAY, DEFAULT_DELAY)
|
||||
try:
|
||||
float(delay_raw)
|
||||
self._delay = str(delay_raw)
|
||||
except (ValueError, TypeError):
|
||||
self._delay = DEFAULT_DELAY
|
||||
return await self.async_step_options_platform()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="controller",
|
||||
data_schema=_controller_schema(
|
||||
controller_type, self._controller_data, self._delay
|
||||
),
|
||||
description_placeholders={
|
||||
"controller_type": controller_type,
|
||||
"controller_hint": hint,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_options_platform(
|
||||
self, user_input: dict | None = None
|
||||
) -> FlowResult:
|
||||
if user_input is not None:
|
||||
try:
|
||||
delay_float = float(self._delay)
|
||||
except (ValueError, TypeError):
|
||||
delay_float = 0.5
|
||||
|
||||
updated: dict = {
|
||||
CONF_CONTROLLER_DATA: self._controller_data,
|
||||
CONF_DELAY: delay_float,
|
||||
}
|
||||
updated.update(_parse_options(self._platform, user_input))
|
||||
return self.async_create_entry(title="", data=updated)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="options_platform",
|
||||
data_schema=_options_schema(self._platform, defaults=self._entry.options),
|
||||
description_placeholders=_device_placeholders(
|
||||
self._device_data, self._platform
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Constants for the Infrarize config flow and platforms."""
|
||||
from __future__ import annotations
|
||||
|
||||
# ── Sentinels ──────────────────────────────────────────────────────────────────
|
||||
MANUAL_ENTRY_SENTINEL = "__manual__"
|
||||
ALL_CONTROLLERS_SENTINEL = "Any"
|
||||
INDEX_FILENAME = "codes_index.json"
|
||||
|
||||
# ── Config-entry data keys ─────────────────────────────────────────────────────
|
||||
CONF_PLATFORM = "platform"
|
||||
CONF_DEVICE_CODE = "device_code"
|
||||
CONF_CONTROLLER_DATA = "controller_data"
|
||||
CONF_DELAY = "delay"
|
||||
CONF_DEVICE_NAME = "name"
|
||||
CONF_TEMPERATURE_SENSOR = "temperature_sensor"
|
||||
CONF_HUMIDITY_SENSOR = "humidity_sensor"
|
||||
CONF_POWER_SENSOR = "power_sensor"
|
||||
CONF_POWER_SENSOR_RESTORE_STATE = "power_sensor_restore_state"
|
||||
CONF_DEVICE_CLASS = "device_class"
|
||||
CONF_SOURCE_NAMES = "source_names"
|
||||
|
||||
# ── Defaults ───────────────────────────────────────────────────────────────────
|
||||
DEFAULT_DELAY = "0.5"
|
||||
|
||||
# ── Platform choices ───────────────────────────────────────────────────────────
|
||||
PLATFORMS: dict[str, str] = {
|
||||
"climate": "Climate (Air Conditioner / Heat Pump)",
|
||||
"fan": "Fan",
|
||||
"media_player": "Media Player (TV / Receiver)",
|
||||
"light": "Light",
|
||||
}
|
||||
|
||||
PLATFORM_SUBDIR: dict[str, str] = {
|
||||
"climate": "climate",
|
||||
"fan": "fan",
|
||||
"media_player": "media_player",
|
||||
"light": "light",
|
||||
}
|
||||
|
||||
DEFAULT_DEVICE_NAMES: dict[str, str] = {
|
||||
"climate": "Infrarize Climate",
|
||||
"fan": "Infrarize Fan",
|
||||
"media_player": "Infrarize Media Player",
|
||||
"light": "Infrarize Light",
|
||||
}
|
||||
|
||||
# ── Controller metadata ────────────────────────────────────────────────────────
|
||||
# Controllers whose device identifier is a Home Assistant remote entity ID.
|
||||
# These get an EntitySelector in the UI instead of a plain text field.
|
||||
ENTITY_BASED_CONTROLLERS: frozenset[str] = frozenset({"Broadlink", "Xiaomi"})
|
||||
|
||||
CONTROLLER_HINTS: dict[str, str] = {
|
||||
"Broadlink": "Select the Broadlink remote entity that will send IR commands.",
|
||||
"Xiaomi": "Select the Xiaomi remote entity that will send IR commands.",
|
||||
"MQTT": "Enter the MQTT topic to publish IR commands to (e.g. zigbee2mqtt/ir_blaster/set).",
|
||||
"LOOKin": "Enter the LOOKin device IP address (e.g. 192.168.1.42).",
|
||||
"ESPHome": "Enter the ESPHome remote service name (e.g. esphome_ir_blaster).",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from base64 import b64encode
|
||||
import binascii
|
||||
import requests
|
||||
import logging
|
||||
import json
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from . import Helper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
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'
|
||||
|
||||
BROADLINK_COMMANDS_ENCODING = [ENC_BASE64, ENC_HEX, ENC_PRONTO]
|
||||
XIAOMI_COMMANDS_ENCODING = [ENC_PRONTO, ENC_RAW]
|
||||
MQTT_COMMANDS_ENCODING = [ENC_RAW]
|
||||
LOOKIN_COMMANDS_ENCODING = [ENC_PRONTO, ENC_RAW]
|
||||
ESPHOME_COMMANDS_ENCODING = [ENC_RAW]
|
||||
|
||||
|
||||
def get_controller(hass, controller, encoding, controller_data, delay):
|
||||
"""Return a controller compatible with the specification provided."""
|
||||
controllers = {
|
||||
BROADLINK_CONTROLLER: BroadlinkController,
|
||||
XIAOMI_CONTROLLER: XiaomiController,
|
||||
MQTT_CONTROLLER: MQTTController,
|
||||
LOOKIN_CONTROLLER: LookinController,
|
||||
ESPHOME_CONTROLLER: ESPHomeController
|
||||
}
|
||||
try:
|
||||
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
|
||||
self._controller = controller
|
||||
self._encoding = encoding
|
||||
self._controller_data = controller_data
|
||||
self._delay = delay
|
||||
|
||||
@abstractmethod
|
||||
def check_encoding(self, encoding):
|
||||
"""Check if the encoding is supported by the controller."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, command):
|
||||
"""Send a command."""
|
||||
pass
|
||||
|
||||
|
||||
class BroadlinkController(AbstractController):
|
||||
"""Controls a Broadlink device."""
|
||||
|
||||
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.")
|
||||
|
||||
async def send(self, command):
|
||||
"""Send a command."""
|
||||
commands = []
|
||||
|
||||
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')
|
||||
except:
|
||||
raise Exception("Error while converting "
|
||||
"Hex to Base64 encoding")
|
||||
|
||||
if self._encoding == ENC_PRONTO:
|
||||
try:
|
||||
_command = _command.replace(' ', '')
|
||||
_command = bytearray.fromhex(_command)
|
||||
_command = Helper.pronto2lirc(_command)
|
||||
_command = Helper.lirc2broadlink(_command)
|
||||
_command = b64encode(_command).decode('utf-8')
|
||||
except:
|
||||
raise Exception("Error while converting "
|
||||
"Pronto to Base64 encoding")
|
||||
|
||||
commands.append('b64:' + _command)
|
||||
|
||||
service_data = {
|
||||
ATTR_ENTITY_ID: self._controller_data,
|
||||
'command': commands,
|
||||
'delay_secs': self._delay
|
||||
}
|
||||
|
||||
await self.hass.services.async_call(
|
||||
'remote', 'send_command', service_data)
|
||||
|
||||
|
||||
class XiaomiController(AbstractController):
|
||||
"""Controls a Xiaomi device."""
|
||||
|
||||
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.")
|
||||
|
||||
async def send(self, command):
|
||||
"""Send a command."""
|
||||
service_data = {
|
||||
ATTR_ENTITY_ID: self._controller_data,
|
||||
'command': self._encoding.lower() + ':' + command
|
||||
}
|
||||
|
||||
await self.hass.services.async_call(
|
||||
'remote', 'send_command', service_data)
|
||||
|
||||
|
||||
class MQTTController(AbstractController):
|
||||
"""Controls a MQTT device."""
|
||||
|
||||
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.")
|
||||
|
||||
async def send(self, command):
|
||||
"""Send a command."""
|
||||
service_data = {
|
||||
'topic': self._controller_data,
|
||||
'payload': command
|
||||
}
|
||||
|
||||
await self.hass.services.async_call(
|
||||
'mqtt', 'publish', service_data)
|
||||
|
||||
|
||||
class LookinController(AbstractController):
|
||||
"""Controls a Lookin device."""
|
||||
|
||||
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.")
|
||||
|
||||
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}"
|
||||
await self.hass.async_add_executor_job(requests.get, url)
|
||||
|
||||
|
||||
class ESPHomeController(AbstractController):
|
||||
"""Controls a ESPHome device."""
|
||||
|
||||
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.")
|
||||
|
||||
async def send(self, command):
|
||||
"""Send a command."""
|
||||
service_data = {'command': json.loads(command)}
|
||||
|
||||
await self.hass.services.async_call(
|
||||
'esphome', self._controller_data, service_data)
|
||||
@@ -0,0 +1,316 @@
|
||||
import asyncio
|
||||
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)
|
||||
from homeassistant.core import Event, EventStateChangedData, callback
|
||||
from homeassistant.helpers.event import async_track_state_change, 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
|
||||
)
|
||||
from . import COMPONENT_ABS_DIR, Helper
|
||||
from .controller import get_controller
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "Infrarize Fan"
|
||||
DEFAULT_DELAY = 0.5
|
||||
|
||||
CONF_UNIQUE_ID = 'unique_id'
|
||||
CONF_DEVICE_CODE = 'device_code'
|
||||
CONF_CONTROLLER_DATA = "controller_data"
|
||||
CONF_DELAY = "delay"
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Set up Infrarize Fan from a config entry."""
|
||||
config = {**entry.data, **entry.options}
|
||||
config.pop('platform', None)
|
||||
config.setdefault('unique_id', entry.unique_id)
|
||||
await async_setup_platform(hass, config, async_add_entities)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if not os.path.isdir(device_files_absdir):
|
||||
os.makedirs(device_files_absdir)
|
||||
|
||||
device_json_filename = str(device_code) + '.json'
|
||||
device_json_path = os.path.join(device_files_absdir, device_json_filename)
|
||||
|
||||
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.")
|
||||
|
||||
try:
|
||||
codes_source = ("https://raw.githubusercontent.com/"
|
||||
"kobimx/Infrarize/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.")
|
||||
return
|
||||
|
||||
try:
|
||||
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)
|
||||
_LOGGER.debug(f"{device_json_path} file loaded")
|
||||
except Exception:
|
||||
_LOGGER.error("The device JSON file is invalid")
|
||||
return
|
||||
|
||||
async_add_entities([InfrarizeFan(
|
||||
hass, config, device_data
|
||||
)])
|
||||
|
||||
class InfrarizeFan(FanEntity, RestoreEntity):
|
||||
def __init__(self, hass, config, device_data):
|
||||
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._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._speed = SPEED_OFF
|
||||
self._direction = None
|
||||
self._last_on_speed = None
|
||||
self._oscillating = None
|
||||
self._support_flags = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON)
|
||||
|
||||
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._oscillating = False
|
||||
self._support_flags = (
|
||||
self._support_flags | FanEntityFeature.OSCILLATE)
|
||||
|
||||
|
||||
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 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 _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 self._power_sensor:
|
||||
async_track_state_change_event(self.hass, self._power_sensor,
|
||||
self._async_power_sensor_changed)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the display name of the fan."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the current state."""
|
||||
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):
|
||||
return 0
|
||||
|
||||
return ordered_list_item_to_percentage(self._speed_list, self._speed)
|
||||
|
||||
@property
|
||||
def speed_count(self):
|
||||
"""Return the number of speeds the fan supports."""
|
||||
return len(self._speed_list)
|
||||
|
||||
@property
|
||||
def oscillating(self):
|
||||
"""Return the oscillation state."""
|
||||
return self._oscillating
|
||||
|
||||
@property
|
||||
def current_direction(self):
|
||||
"""Return the direction state."""
|
||||
return self._direction
|
||||
|
||||
@property
|
||||
def last_on_speed(self):
|
||||
"""Return the last non-idle speed."""
|
||||
return self._last_on_speed
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
return self._support_flags
|
||||
|
||||
@property
|
||||
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,
|
||||
}
|
||||
|
||||
async def async_set_percentage(self, percentage: int):
|
||||
"""Set the desired speed for the fan."""
|
||||
if (percentage == 0):
|
||||
self._speed = SPEED_OFF
|
||||
else:
|
||||
self._speed = percentage_to_ordered_list_item(
|
||||
self._speed_list, percentage)
|
||||
|
||||
if not self._speed == SPEED_OFF:
|
||||
self._last_on_speed = self._speed
|
||||
|
||||
await self.send_command()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Set oscillation of the fan."""
|
||||
self._oscillating = oscillating
|
||||
|
||||
await self.send_command()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_direction(self, direction: str):
|
||||
"""Set the direction of the fan"""
|
||||
self._direction = direction
|
||||
|
||||
if not self._speed.lower() == SPEED_OFF:
|
||||
await self.send_command()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
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])
|
||||
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self):
|
||||
"""Turn off the fan."""
|
||||
await self.async_set_percentage(0)
|
||||
|
||||
async def send_command(self):
|
||||
async with self._temp_lock:
|
||||
self._on_by_remote = False
|
||||
speed = self._speed
|
||||
direction = self._direction or 'default'
|
||||
oscillating = self._oscillating
|
||||
|
||||
if speed.lower() == SPEED_OFF:
|
||||
command = self._commands['off']
|
||||
elif oscillating:
|
||||
command = self._commands['oscillate']
|
||||
else:
|
||||
command = self._commands[direction][speed]
|
||||
|
||||
try:
|
||||
await self._controller.send(command)
|
||||
except Exception as e:
|
||||
_LOGGER.exception(e)
|
||||
|
||||
@callback
|
||||
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"]
|
||||
new_state = event.data["new_state"]
|
||||
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
if new_state.state == old_state.state:
|
||||
return
|
||||
|
||||
if new_state.state == STATE_ON and self._speed == SPEED_OFF:
|
||||
self._on_by_remote = True
|
||||
self._speed = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
if new_state.state == STATE_OFF:
|
||||
self._on_by_remote = False
|
||||
if self._speed != SPEED_OFF:
|
||||
self._speed = SPEED_OFF
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,394 @@
|
||||
import asyncio
|
||||
import aiofiles
|
||||
import json
|
||||
import logging
|
||||
import os.path
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
PLATFORM_SCHEMA,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
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 . import COMPONENT_ABS_DIR, Helper
|
||||
from .controller import get_controller
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "Infrarize Light"
|
||||
DEFAULT_DELAY = 0.5
|
||||
|
||||
CONF_UNIQUE_ID = "unique_id"
|
||||
CONF_DEVICE_CODE = "device_code"
|
||||
CONF_CONTROLLER_DATA = "controller_data"
|
||||
CONF_DELAY = "delay"
|
||||
CONF_POWER_SENSOR = "power_sensor"
|
||||
|
||||
CMD_BRIGHTNESS_INCREASE = "brighten"
|
||||
CMD_BRIGHTNESS_DECREASE = "dim"
|
||||
CMD_COLORMODE_COLDER = "colder"
|
||||
CMD_COLORMODE_WARMER = "warmer"
|
||||
CMD_POWER_ON = "on"
|
||||
CMD_POWER_OFF = "off"
|
||||
CMD_NIGHTLIGHT = "night"
|
||||
|
||||
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_entry(hass, entry, async_add_entities):
|
||||
"""Set up Infrarize Light from a config entry."""
|
||||
config = {**entry.data, **entry.options}
|
||||
config.pop('platform', None)
|
||||
config.setdefault('unique_id', entry.unique_id)
|
||||
await async_setup_platform(hass, config, async_add_entities)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass,
|
||||
config,
|
||||
async_add_entities,
|
||||
discovery_info=None,
|
||||
):
|
||||
"""Set up the IR Light platform."""
|
||||
device_code = config.get(CONF_DEVICE_CODE)
|
||||
device_files_subdir = os.path.join("codes", "light")
|
||||
device_files_absdir = os.path.join(COMPONENT_ABS_DIR, device_files_subdir)
|
||||
|
||||
if not os.path.isdir(device_files_absdir):
|
||||
os.makedirs(device_files_absdir)
|
||||
|
||||
device_json_filename = str(device_code) + ".json"
|
||||
device_json_path = os.path.join(device_files_absdir, device_json_filename)
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
try:
|
||||
codes_source = (
|
||||
"https://raw.githubusercontent.com/"
|
||||
"kobimx/Infrarize/master/"
|
||||
"codes/light/{}.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."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
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)
|
||||
_LOGGER.debug(f"{device_json_path} file loaded")
|
||||
except Exception:
|
||||
_LOGGER.error("The device JSON file is invalid")
|
||||
return
|
||||
|
||||
async_add_entities([InfrarizeLight(hass, config, device_data)])
|
||||
|
||||
|
||||
# find the closest match in a sorted list
|
||||
def closest_match(value, list):
|
||||
prev_val = None
|
||||
for index, entry in enumerate(list):
|
||||
if entry > (value or 0):
|
||||
if prev_val is None:
|
||||
return index
|
||||
diff_lo = value - prev_val
|
||||
diff_hi = entry - value
|
||||
if diff_lo < diff_hi:
|
||||
return index - 1
|
||||
return index
|
||||
prev_val = entry
|
||||
|
||||
return len(list) - 1
|
||||
|
||||
|
||||
class InfrarizeLight(LightEntity, RestoreEntity):
|
||||
def __init__(self, hass, config, device_data):
|
||||
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._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._brightnesses = device_data["brightness"]
|
||||
self._colortemps = device_data["colorTemperature"]
|
||||
self._commands = device_data["commands"]
|
||||
|
||||
self._power = STATE_ON
|
||||
self._brightness = None
|
||||
self._colortemp = None
|
||||
|
||||
self._temp_lock = asyncio.Lock()
|
||||
self._on_by_remote = False
|
||||
self._support_color_mode = ColorMode.UNKNOWN
|
||||
|
||||
if (
|
||||
CMD_COLORMODE_COLDER in self._commands
|
||||
and CMD_COLORMODE_WARMER in self._commands
|
||||
):
|
||||
self._colortemp = self.max_color_temp_kelvin
|
||||
self._support_color_mode = ColorMode.COLOR_TEMP
|
||||
|
||||
if CMD_NIGHTLIGHT in self._commands or (
|
||||
CMD_BRIGHTNESS_INCREASE in self._commands
|
||||
and CMD_BRIGHTNESS_DECREASE in self._commands
|
||||
):
|
||||
self._brightness = 100
|
||||
self._support_brightness = True
|
||||
if self._support_color_mode == ColorMode.UNKNOWN:
|
||||
self._support_color_mode = ColorMode.BRIGHTNESS
|
||||
else:
|
||||
self._support_brightness = False
|
||||
|
||||
if (
|
||||
CMD_POWER_OFF in self._commands
|
||||
and CMD_POWER_ON in self._commands
|
||||
and self._support_color_mode == ColorMode.UNKNOWN
|
||||
):
|
||||
self._support_color_mode = ColorMode.ONOFF
|
||||
|
||||
# Init the IR/RF controller
|
||||
self._controller = get_controller(
|
||||
self.hass,
|
||||
self._supported_controller,
|
||||
self._commands_encoding,
|
||||
self._controller_data,
|
||||
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:
|
||||
self._power = last_state.state
|
||||
if ATTR_BRIGHTNESS in last_state.attributes:
|
||||
self._brightness = last_state.attributes[ATTR_BRIGHTNESS]
|
||||
if ATTR_COLOR_TEMP_KELVIN in last_state.attributes:
|
||||
self._colortemp = last_state.attributes[ATTR_COLOR_TEMP_KELVIN]
|
||||
|
||||
if self._power_sensor:
|
||||
async_track_state_change_event(
|
||||
self.hass, self._power_sensor, self._async_power_sensor_changed
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the display name of the light."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def supported_color_modes(self):
|
||||
"""Return the list of supported color modes."""
|
||||
return [self._support_color_mode]
|
||||
|
||||
@property
|
||||
def color_mode(self):
|
||||
return self._support_color_mode
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self):
|
||||
return self._colortemp
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self):
|
||||
if self._colortemps:
|
||||
return self._colortemps[0]
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self):
|
||||
if self._colortemps:
|
||||
return self._colortemps[-1]
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
return self._power == STATE_ON or self._on_by_remote
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
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,
|
||||
"on_by_remote": self._on_by_remote,
|
||||
}
|
||||
|
||||
async def async_turn_on(self, **params):
|
||||
did_something = False
|
||||
# Turn the light on if off
|
||||
if self._power != STATE_ON and not self._on_by_remote:
|
||||
self._power = STATE_ON
|
||||
did_something = True
|
||||
await self.send_command(CMD_POWER_ON)
|
||||
|
||||
if (
|
||||
ATTR_COLOR_TEMP_KELVIN in params
|
||||
and ColorMode.COLOR_TEMP == self._support_color_mode
|
||||
):
|
||||
target = params.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
old_color_temp = closest_match(self._colortemp, self._colortemps)
|
||||
new_color_temp = closest_match(target, self._colortemps)
|
||||
_LOGGER.debug(
|
||||
f"Changing color temp from {self._colortemp}K step {old_color_temp} to {target}K step {new_color_temp}"
|
||||
)
|
||||
|
||||
steps = new_color_temp - old_color_temp
|
||||
did_something = True
|
||||
if steps < 0:
|
||||
cmd = CMD_COLORMODE_WARMER
|
||||
steps = abs(steps)
|
||||
else:
|
||||
cmd = CMD_COLORMODE_COLDER
|
||||
|
||||
if steps > 0 and cmd:
|
||||
# If we are heading for the highest or lowest value,
|
||||
# take the opportunity to resync by issuing enough
|
||||
# commands to go the full range.
|
||||
if new_color_temp == len(self._colortemps) - 1 or new_color_temp == 0:
|
||||
steps = len(self._colortemps)
|
||||
self._colortemp = self._colortemps[new_color_temp]
|
||||
await self.send_command(cmd, steps)
|
||||
|
||||
if ATTR_BRIGHTNESS in params and self._support_brightness:
|
||||
# before checking the supported brightnesses, make a special case
|
||||
# when a nightlight is fitted for brightness of 1
|
||||
if params.get(ATTR_BRIGHTNESS) == 1 and CMD_NIGHTLIGHT in self._commands:
|
||||
self._brightness = 1
|
||||
self._power = STATE_ON
|
||||
did_something = True
|
||||
await self.send_command(CMD_NIGHTLIGHT)
|
||||
|
||||
elif self._brightnesses:
|
||||
target = params.get(ATTR_BRIGHTNESS)
|
||||
old_brightness = closest_match(self._brightness, self._brightnesses)
|
||||
new_brightness = closest_match(target, self._brightnesses)
|
||||
did_something = True
|
||||
_LOGGER.debug(
|
||||
f"Changing brightness from {self._brightness} step {old_brightness} to {target} step {new_brightness}"
|
||||
)
|
||||
steps = new_brightness - old_brightness
|
||||
if steps < 0:
|
||||
cmd = CMD_BRIGHTNESS_DECREASE
|
||||
steps = abs(steps)
|
||||
else:
|
||||
cmd = CMD_BRIGHTNESS_INCREASE
|
||||
|
||||
if steps > 0 and cmd:
|
||||
# If we are heading for the highest or lowest value,
|
||||
# take the opportunity to resync by issuing enough
|
||||
# commands to go the full range.
|
||||
if (
|
||||
new_brightness == len(self._brightnesses) - 1
|
||||
or new_brightness == 0
|
||||
):
|
||||
steps = len(self._colortemps)
|
||||
did_something = True
|
||||
self._brightness = self._brightnesses[new_brightness]
|
||||
await self.send_command(cmd, steps)
|
||||
|
||||
# If we did nothing above, and the light is not detected as on
|
||||
# already issue the on command, even though we think the light
|
||||
# is on. This is because we may be out of sync due to use of the
|
||||
# remote when we don't have anything to detect it.
|
||||
# If we do have such monitoring, avoid issuing the command in case
|
||||
# on and off are the same remote code.
|
||||
if not did_something and not self._on_by_remote:
|
||||
self._power = STATE_ON
|
||||
await self.send_command(CMD_POWER_ON)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self):
|
||||
self._power = STATE_OFF
|
||||
await self.send_command(CMD_POWER_OFF)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_toggle(self):
|
||||
await (self.async_turn_on() if not self.is_on else self.async_turn_off())
|
||||
|
||||
async def send_command(self, cmd, count=1):
|
||||
if cmd not in self._commands:
|
||||
_LOGGER.error(f"Unknown command '{cmd}'")
|
||||
return
|
||||
_LOGGER.debug(f"Sending {cmd} remote command {count} times.")
|
||||
remote_cmd = self._commands.get(cmd)
|
||||
async with self._temp_lock:
|
||||
self._on_by_remote = False
|
||||
try:
|
||||
for _ in range(count):
|
||||
await self._controller.send(remote_cmd)
|
||||
except Exception as e:
|
||||
_LOGGER.exception(e)
|
||||
|
||||
@callback
|
||||
async def _async_power_sensor_changed(self, event):
|
||||
"""Handle power sensor changes."""
|
||||
new_state = event.data["new_state"]
|
||||
if new_state is None:
|
||||
return
|
||||
old_state = event.data["old_state"]
|
||||
if new_state.state == old_state.state:
|
||||
return
|
||||
|
||||
if new_state.state == STATE_ON:
|
||||
self._on_by_remote = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
if new_state.state == STATE_OFF:
|
||||
self._on_by_remote = False
|
||||
self._power = STATE_OFF
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"domain": "infrarize",
|
||||
"name": "Infrarize",
|
||||
"documentation": "https://github.com/kobimx/Infrarize",
|
||||
"dependencies": [],
|
||||
"codeowners": ["@kobimx"],
|
||||
"requirements": ["aiofiles>=0.6.0"],
|
||||
"homeassistant": "2025.5.0",
|
||||
"version": "1.18.1",
|
||||
"config_flow": true,
|
||||
"iot_class": "assumed_state",
|
||||
"updater": {
|
||||
"version": "1.18.1",
|
||||
"releaseNotes": "-- Implements new async_track_state_change_event",
|
||||
"files": [
|
||||
"__init__.py",
|
||||
"climate.py",
|
||||
"media_player.py",
|
||||
"fan.py",
|
||||
"light.py",
|
||||
"controller.py",
|
||||
"manifest.json",
|
||||
"services.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import asyncio
|
||||
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.const import (
|
||||
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
|
||||
from .controller import get_controller
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "Infrarize Media Player"
|
||||
DEFAULT_DEVICE_CLASS = "tv"
|
||||
DEFAULT_DELAY = 0.5
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry, async_add_entities):
|
||||
"""Set up Infrarize Media Player from a config entry."""
|
||||
config = {**entry.data, **entry.options}
|
||||
config.pop('platform', None)
|
||||
config.setdefault('unique_id', entry.unique_id)
|
||||
await async_setup_platform(hass, config, async_add_entities)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
if not os.path.isdir(device_files_absdir):
|
||||
os.makedirs(device_files_absdir)
|
||||
|
||||
device_json_filename = str(device_code) + '.json'
|
||||
device_json_path = os.path.join(device_files_absdir, device_json_filename)
|
||||
|
||||
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.")
|
||||
|
||||
try:
|
||||
codes_source = ("https://raw.githubusercontent.com/"
|
||||
"kobimx/Infrarize/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.")
|
||||
return
|
||||
|
||||
try:
|
||||
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)
|
||||
_LOGGER.debug(f"{device_json_path} file loaded")
|
||||
except Exception:
|
||||
_LOGGER.error("The device JSON file is invalid")
|
||||
return
|
||||
|
||||
async_add_entities([InfrarizeMediaPlayer(
|
||||
hass, config, device_data
|
||||
)])
|
||||
|
||||
class InfrarizeMediaPlayer(MediaPlayerEntity, RestoreEntity):
|
||||
def __init__(self, hass, config, device_data):
|
||||
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._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._state = STATE_OFF
|
||||
self._sources_list = []
|
||||
self._source = None
|
||||
self._support_flags = 0
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
for source, new_name in config.get(CONF_SOURCE_NAMES, {}).items():
|
||||
if source in self._commands['sources']:
|
||||
if new_name is not None:
|
||||
self._commands['sources'][new_name] = self._commands['sources'][source]
|
||||
|
||||
del self._commands['sources'][source]
|
||||
|
||||
#Sources list
|
||||
for key in self._commands['sources']:
|
||||
self._sources_list.append(key)
|
||||
|
||||
self._temp_lock = asyncio.Lock()
|
||||
|
||||
#Init the IR/RF controller
|
||||
self._controller = get_controller(
|
||||
self.hass,
|
||||
self._supported_controller,
|
||||
self._commands_encoding,
|
||||
self._controller_data,
|
||||
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:
|
||||
self._state = last_state.state
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Push an update after each command."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique ID."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the media player."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the device_class of the media player."""
|
||||
return self._device_class
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the player."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Return the title of current playing media."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Content type of current playing media."""
|
||||
return MediaType.CHANNEL
|
||||
|
||||
@property
|
||||
def source_list(self):
|
||||
return self._sources_list
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
return self._source
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag media player features that are supported."""
|
||||
return self._support_flags
|
||||
|
||||
@property
|
||||
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,
|
||||
}
|
||||
|
||||
async def async_turn_off(self):
|
||||
"""Turn the media player off."""
|
||||
await self.send_command(self._commands['off'])
|
||||
|
||||
if self._power_sensor is None:
|
||||
self._state = STATE_OFF
|
||||
self._source = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(self):
|
||||
"""Turn the media player off."""
|
||||
await self.send_command(self._commands['on'])
|
||||
|
||||
if self._power_sensor is None:
|
||||
self._state = STATE_ON
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_media_previous_track(self):
|
||||
"""Send previous track command."""
|
||||
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'])
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_volume_down(self):
|
||||
"""Turn volume down for media player."""
|
||||
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'])
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_mute_volume(self, mute):
|
||||
"""Mute the volume."""
|
||||
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])
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_play_media(self, media_type, media_id, **kwargs):
|
||||
"""Support channel change through play_media service."""
|
||||
if self._state == STATE_OFF:
|
||||
await self.async_turn_on()
|
||||
|
||||
if media_type != MediaType.CHANNEL:
|
||||
_LOGGER.error("invalid media type")
|
||||
return
|
||||
if not media_id.isdigit():
|
||||
_LOGGER.error("media_id must be a channel number")
|
||||
return
|
||||
|
||||
self._source = "Channel {}".format(media_id)
|
||||
for digit in media_id:
|
||||
await self.send_command(self._commands['sources']["Channel {}".format(digit)])
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def send_command(self, command):
|
||||
async with self._temp_lock:
|
||||
try:
|
||||
await self._controller.send(command)
|
||||
except Exception as e:
|
||||
_LOGGER.exception(e)
|
||||
|
||||
async def async_update(self):
|
||||
if self._power_sensor is None:
|
||||
return
|
||||
|
||||
power_state = self.hass.states.get(self._power_sensor)
|
||||
|
||||
if power_state:
|
||||
if power_state.state == STATE_OFF:
|
||||
self._state = STATE_OFF
|
||||
self._source = None
|
||||
elif power_state.state == STATE_ON:
|
||||
self._state = STATE_ON
|
||||
@@ -0,0 +1,4 @@
|
||||
check_updates:
|
||||
description: Check for Infrarize updates.
|
||||
update_component:
|
||||
description: Update Infrarize component.
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Infrarize — Choose Platform",
|
||||
"description": "Select the type of device you want to control via IR.",
|
||||
"data": {
|
||||
"platform": "Platform"
|
||||
}
|
||||
},
|
||||
"controller_filter": {
|
||||
"title": "Infrarize — Filter by Controller",
|
||||
"description": "Narrow the device list to a specific controller type, or show all.",
|
||||
"data": {
|
||||
"controller_type": "Controller Type"
|
||||
}
|
||||
},
|
||||
"manufacturer": {
|
||||
"title": "Infrarize — Select Manufacturer",
|
||||
"description": "Choose your device manufacturer. Select **'Enter code manually'** if your device is not listed.",
|
||||
"data": {
|
||||
"manufacturer": "Manufacturer"
|
||||
}
|
||||
},
|
||||
"device_select": {
|
||||
"title": "Infrarize — Select Device",
|
||||
"description": "Choose the device model for **{manufacturer}**. Each entry shows the numeric code and supported model names.",
|
||||
"data": {
|
||||
"device_code": "Device"
|
||||
}
|
||||
},
|
||||
"device_manual": {
|
||||
"title": "Infrarize — Enter Device Code",
|
||||
"description": "Enter the numeric device code. The file will be loaded from local storage or downloaded from GitHub.",
|
||||
"data": {
|
||||
"device_code": "Device Code"
|
||||
}
|
||||
},
|
||||
"device_name": {
|
||||
"title": "Infrarize — Confirm Device Name",
|
||||
"description": "**{manufacturer}** — {models}\nController: {controller_type} | Code: {device_code}\n\nEdit the name shown in Home Assistant.",
|
||||
"data": {
|
||||
"name": "Device Name"
|
||||
}
|
||||
},
|
||||
"controller": {
|
||||
"title": "Infrarize — Controller Connection",
|
||||
"description": "Controller type: **{controller_type}**\n\n{controller_hint}",
|
||||
"data": {
|
||||
"controller_data": "Controller Data",
|
||||
"delay": "Command Delay (seconds)"
|
||||
}
|
||||
},
|
||||
"options_platform": {
|
||||
"title": "Infrarize — Optional Settings",
|
||||
"description": "**{manufacturer}** — {models}\nController: {controller_type}\n\nAll fields below are optional.",
|
||||
"data": {
|
||||
"temperature_sensor": "Temperature Sensor",
|
||||
"humidity_sensor": "Humidity Sensor",
|
||||
"power_sensor": "Power Sensor",
|
||||
"power_sensor_restore_state": "Restore state from power sensor on startup",
|
||||
"device_class": "Device Class (e.g. tv, receiver)",
|
||||
"source_names": "Source Name Overrides (JSON, e.g. {\"HDMI1\": \"Apple TV\"})"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_device_code": "Device code not found locally and could not be downloaded from GitHub. Check the code number and your internet connection.",
|
||||
"controller_data_required": "Controller data is required.",
|
||||
"unknown": "Unexpected error — check the Home Assistant logs."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "A Infrarize device with this code and controller is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"controller": {
|
||||
"title": "Infrarize — Edit Controller",
|
||||
"description": "Controller type: **{controller_type}**\n\n{controller_hint}",
|
||||
"data": {
|
||||
"controller_data": "Controller Data",
|
||||
"delay": "Command Delay (seconds)"
|
||||
}
|
||||
},
|
||||
"options_platform": {
|
||||
"title": "Infrarize — Edit Optional Settings",
|
||||
"description": "**{manufacturer}** — {models}\nController: {controller_type}",
|
||||
"data": {
|
||||
"temperature_sensor": "Temperature Sensor",
|
||||
"humidity_sensor": "Humidity Sensor",
|
||||
"power_sensor": "Power Sensor",
|
||||
"power_sensor_restore_state": "Restore state from power sensor on startup",
|
||||
"device_class": "Device Class (e.g. tv, receiver)",
|
||||
"source_names": "Source Name Overrides (JSON)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"controller_data_required": "Controller data is required."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Infrarize — Choose Platform",
|
||||
"description": "Select the type of device you want to control via IR.",
|
||||
"data": {
|
||||
"platform": "Platform"
|
||||
}
|
||||
},
|
||||
"controller_filter": {
|
||||
"title": "Infrarize — Filter by Controller",
|
||||
"description": "Narrow the device list to a specific controller type, or show all.",
|
||||
"data": {
|
||||
"controller_type": "Controller Type"
|
||||
}
|
||||
},
|
||||
"manufacturer": {
|
||||
"title": "Infrarize — Select Manufacturer",
|
||||
"description": "Choose your device manufacturer. Select **'Enter code manually'** if your device is not listed.",
|
||||
"data": {
|
||||
"manufacturer": "Manufacturer"
|
||||
}
|
||||
},
|
||||
"device_select": {
|
||||
"title": "Infrarize — Select Device",
|
||||
"description": "Choose the device model for **{manufacturer}**. Each entry shows the numeric code and supported model names.",
|
||||
"data": {
|
||||
"device_code": "Device"
|
||||
}
|
||||
},
|
||||
"device_manual": {
|
||||
"title": "Infrarize — Enter Device Code",
|
||||
"description": "Enter the numeric device code. The file will be loaded from local storage or downloaded from GitHub.",
|
||||
"data": {
|
||||
"device_code": "Device Code"
|
||||
}
|
||||
},
|
||||
"device_name": {
|
||||
"title": "Infrarize — Confirm Device Name",
|
||||
"description": "**{manufacturer}** — {models}\nController: {controller_type} | Code: {device_code}\n\nEdit the name shown in Home Assistant.",
|
||||
"data": {
|
||||
"name": "Device Name"
|
||||
}
|
||||
},
|
||||
"controller": {
|
||||
"title": "Infrarize — Controller Connection",
|
||||
"description": "Controller type: **{controller_type}**\n\n{controller_hint}",
|
||||
"data": {
|
||||
"controller_data": "Controller Data",
|
||||
"delay": "Command Delay (seconds)"
|
||||
}
|
||||
},
|
||||
"options_platform": {
|
||||
"title": "Infrarize — Optional Settings",
|
||||
"description": "**{manufacturer}** — {models}\nController: {controller_type}\n\nAll fields below are optional.",
|
||||
"data": {
|
||||
"temperature_sensor": "Temperature Sensor",
|
||||
"humidity_sensor": "Humidity Sensor",
|
||||
"power_sensor": "Power Sensor",
|
||||
"power_sensor_restore_state": "Restore state from power sensor on startup",
|
||||
"device_class": "Device Class (e.g. tv, receiver)",
|
||||
"source_names": "Source Name Overrides (JSON, e.g. {\"HDMI1\": \"Apple TV\"})"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_device_code": "Device code not found locally and could not be downloaded from GitHub. Check the code number and your internet connection.",
|
||||
"controller_data_required": "Controller data is required.",
|
||||
"unknown": "Unexpected error — check the Home Assistant logs."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "A Infrarize device with this code and controller is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"controller": {
|
||||
"title": "Infrarize — Edit Controller",
|
||||
"description": "Controller type: **{controller_type}**\n\n{controller_hint}",
|
||||
"data": {
|
||||
"controller_data": "Controller Data",
|
||||
"delay": "Command Delay (seconds)"
|
||||
}
|
||||
},
|
||||
"options_platform": {
|
||||
"title": "Infrarize — Edit Optional Settings",
|
||||
"description": "**{manufacturer}** — {models}\nController: {controller_type}",
|
||||
"data": {
|
||||
"temperature_sensor": "Temperature Sensor",
|
||||
"humidity_sensor": "Humidity Sensor",
|
||||
"power_sensor": "Power Sensor",
|
||||
"power_sensor_restore_state": "Restore state from power sensor on startup",
|
||||
"device_class": "Device Class (e.g. tv, receiver)",
|
||||
"source_names": "Source Name Overrides (JSON)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"controller_data_required": "Controller data is required."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user