diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..6632c1d --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,18 @@ +name: Validate + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + validate-hacs: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" \ No newline at end of file diff --git a/README.md b/README.md index f5cd95e..23a90c7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,61 @@ An Home Assistant Custom Integration that interact via SwitchBot API to your Swi Supported device: * Air Conditionair -* TV [on/off] + - Custom sensor for power status tracking + - Custom sensor for temperature status tracking + - Custom sensor for humidity status tracking + - On/Off control + - Min, Max, and Steps settings + - Temperature control + - Fan control + - A/C Modes +* TV | IPTV | Streamer | Set Top Box + - Custom sensor for power status tracking + - On/Off control + - Volume +/- and mute + - Channel up/down (prev/fowd) + - Channel number to source via service +* DVD | Speaker + - Custom sensor for power status tracking + - On/Off control + - Volume +/- and mute + - Play, Pause, Stop, Prev and Next controls +* Fan + - Custom sensor for power status tracking + - On/Off control + - Buttons ION and TIMER in device settings + - Fan Speeds (1,2,3) +* Light + - Custom sensor for power status tracking + - On/Off control + - Brightness control (+/-) in device settings + - Temperature light control (+/-) in device settings +* Air Purifier + - Custom sensor for power status tracking + - On/Off control +* Water Heater + - Custom sensor for power status tracking + - Custom sensor for temperature status tracking + - On/Off control +* Vacuum Cleaner + - Cleaning / Stop command + - Go to Dock/Charge command +* Camera Shutter + - Shutter buton + - Timer buton + - Menu buton +* Others + - Custom sensor for power status tracking + - On/Off control + - Custom On and Off separated command in device settings + +_All above devices support DIY types and add custom buttons/commands in device settings_ + +To configure custom/learned buttonsmake sure the name of the button must be exactly as they appear in the app (case sensitive and characters like spaces). + +![SwitchBot app device](./docs/app.png "SwitchBot app device") + +![Home Assistant integration settings](./docs/settings.png "Home Assistant integration settings") ## Installation diff --git a/custom_components/switchbotremote/__init__.py b/custom_components/switchbotremote/__init__.py index 5226932..822e870 100644 --- a/custom_components/switchbotremote/__init__.py +++ b/custom_components/switchbotremote/__init__.py @@ -8,7 +8,16 @@ from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.REMOTE] +PLATFORMS: list[Platform] = [ + Platform.CLIMATE, + Platform.MEDIA_PLAYER, + Platform.LIGHT, + Platform.FAN, + Platform.BUTTON, + Platform.VACUUM, + Platform.REMOTE, + Platform.WATER_HEATER, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -25,10 +34,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True + async def update_listener(hass: HomeAssistant, entry: ConfigEntry): """Update listener.""" await hass.config_entries.async_reload(entry.entry_id) + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/custom_components/switchbotremote/button.py b/custom_components/switchbotremote/button.py new file mode 100644 index 0000000..1d6f7b7 --- /dev/null +++ b/custom_components/switchbotremote/button.py @@ -0,0 +1,111 @@ +import humps +from typing import List +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo +from .client.remote import SupportedRemote + +from .const import ( + DOMAIN, + IR_CAMERA_TYPES, + IR_FAN_TYPES, + IR_LIGHT_TYPES, + CLASS_BY_TYPE, + CONF_CUSTOMIZE_COMMANDS, + CONF_WITH_ION, + CONF_WITH_TIMER, + CONF_WITH_BRIGHTNESS, + CONF_WITH_TEMPERATURE, +) + + +class SwitchBotRemoteButton(ButtonEntity): + _attr_has_entity_name = False + + def __init__(self, hass: HomeAssistant, sb: SupportedRemote, command_name: str, command_icon: str) -> None: + super().__init__() + self.sb = sb + self._hass = hass + self._unique_id = sb.id + self._device_name = sb.name + self._command_name = command_name + self._command_icon = command_icon + + async def send_command(self, *args): + await self._hass.async_add_executor_job(self.sb.command, *args) + + @property + def device_info(self): + return DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="SwitchBot", + name=self._device_name, + model=CLASS_BY_TYPE[self.sb.type] + " Remote", + ) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + "_" + humps.decamelize(self._command_name) + + @property + def name(self) -> str: + """Return the display name of this button.""" + return self._device_name + " " + self._command_name.capitalize() + + @property + def icon(self) -> str: + """Return the icon of this button.""" + return self._command_icon + + async def async_press(self) -> None: + """Handle the button press.""" + await self.send_command(self._command_name, None, True) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> bool: + remotes: List[SupportedRemote] = hass.data[DOMAIN][entry.entry_id] + entities = [] + + for remote in remotes: + options = entry.data.get(remote.id, {}) + customize_commands = options.get(CONF_CUSTOMIZE_COMMANDS, []) + + if (remote.type in IR_CAMERA_TYPES): + entities.append(SwitchBotRemoteButton( + hass, remote, "SHUTTER", "mdi:camera-iris")) + entities.append(SwitchBotRemoteButton( + hass, remote, "MENU", "mdi:menu")) + entities.append(SwitchBotRemoteButton( + hass, remote, "TIMER", "mdi:timer")) + + if (remote.type in IR_FAN_TYPES): + if (options.get(CONF_WITH_ION, False)): + entities.append(SwitchBotRemoteButton( + hass, remote, "ION", "mdi:air-filter")) + if (options.get(CONF_WITH_TIMER, False)): + entities.append(SwitchBotRemoteButton( + hass, remote, "TIMER", "mdi:timer")) + + if (remote.type in IR_LIGHT_TYPES): + if (options.get(CONF_WITH_BRIGHTNESS, False)): + entities.append(SwitchBotRemoteButton( + hass, remote, "DARKER", "mdi:brightness-4")) + entities.append(SwitchBotRemoteButton( + hass, remote, "BRIGHTER", "mdi:brightness-6")) + + if (options.get(CONF_WITH_TEMPERATURE, False)): + entities.append(SwitchBotRemoteButton( + hass, remote, "WARM", "mdi:octagram-minus")) + entities.append(SwitchBotRemoteButton( + hass, remote, "WHITE", "mdi:octagram-plus")) + + for command in customize_commands: + if (command and command.strip()): + entities.append(SwitchBotRemoteButton( + hass, remote, command, "mdi:remote")) + + async_add_entities(entities) + + return True diff --git a/custom_components/switchbotremote/client/client.py b/custom_components/switchbotremote/client/client.py index a15399e..e60a256 100644 --- a/custom_components/switchbotremote/client/client.py +++ b/custom_components/switchbotremote/client/client.py @@ -64,4 +64,4 @@ def put(self, path: str, **kwargs) -> Any: return self.request("PUT", path, **kwargs) def delete(self, path: str, **kwargs) -> Any: - return self.request("DELETE", path, **kwargs) \ No newline at end of file + return self.request("DELETE", path, **kwargs) diff --git a/custom_components/switchbotremote/client/remote.py b/custom_components/switchbotremote/client/remote.py index 25b266c..cd7a993 100644 --- a/custom_components/switchbotremote/client/remote.py +++ b/custom_components/switchbotremote/client/remote.py @@ -43,7 +43,7 @@ def command( payload = humps.camelize( { "command_type": command_type, - "command": action if customize else humps.camelize(action), + "command": action, "parameter": parameter, } ) @@ -60,11 +60,11 @@ class SupportedRemote(Remote): def turn(self, state: str): state = state.lower() assert state in ("on", "off") - self.command(f"turn_{state}") + self.command(humps.camelize(f"turn_{state}")) class OtherRemote(Remote): remote_type_for = "Others" def command(self, action: str, parameter: Optional[str] = None): - super().command(action, parameter, True) \ No newline at end of file + super().command(action, parameter, True) diff --git a/custom_components/switchbotremote/climate.py b/custom_components/switchbotremote/climate.py index 8a2de83..574d633 100644 --- a/custom_components/switchbotremote/climate.py +++ b/custom_components/switchbotremote/climate.py @@ -1,8 +1,8 @@ import logging from homeassistant.components.climate import ClimateEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.components.climate.const import ( HVACMode, ClimateEntityFeature, @@ -11,12 +11,24 @@ FAN_MEDIUM, FAN_HIGH, ) -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OFF, STATE_ON, TEMP_CELSIUS from homeassistant.helpers.entity import DeviceInfo from homeassistant.config_entries import ConfigEntry from .client.remote import SupportedRemote -from .const import DOMAIN +from .const import ( + DOMAIN, + IR_CLIMATE_TYPES, + AIR_CONDITIONER_CLASS, + CONF_POWER_SENSOR, + CONF_TEMPERATURE_SENSOR, + CONF_HUMIDITY_SENSOR, + CONF_TEMP_MIN, + CONF_TEMP_MAX, + CONF_TEMP_STEP, + CONF_HVAC_MODES, +) +from .config_flow import DEFAULT_HVAC_MODES _LOGGER = logging.getLogger(__name__) @@ -36,32 +48,37 @@ FAN_HIGH: 4, } +DEFAULT_MIN_TEMP = 16 +DEFAULT_MAX_TEMP = 30 + class SwitchBotRemoteClimate(ClimateEntity, RestoreEntity): _attr_has_entity_name = False + _attr_force_update = True - def __init__(self, sb: SupportedRemote, _id: str, name: str, options: dict = {}) -> None: + def __init__(self, sb: SupportedRemote, options: dict = {}) -> None: super().__init__() self.sb = sb - self._unique_id = _id + self._unique_id = sb.id + self._device_name = sb.name self._is_on = False - self._name = name self.options = options self._last_on_operation = None - self._operation_modes = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.DRY, - HVACMode.FAN_ONLY, - HVACMode.HEAT, - ] + self._operation_modes = options.get( + CONF_HVAC_MODES, DEFAULT_HVAC_MODES) + + if HVACMode.OFF not in self._operation_modes: + self._operation_modes.append(HVACMode.OFF) self._hvac_mode = HVACMode.OFF self._temperature_unit = TEMP_CELSIUS self._target_temperature = 28 - self._target_temperature_step = 1 + self._target_temperature_step = options.get(CONF_TEMP_STEP, 1) + self._max_temp = options.get(CONF_TEMP_MAX, DEFAULT_MAX_TEMP) + self._min_temp = options.get(CONF_TEMP_MIN, DEFAULT_MIN_TEMP) + self._power_sensor = options.get(CONF_POWER_SENSOR, None) self._fan_mode = FAN_AUTO self._fan_modes = [ @@ -71,28 +88,31 @@ def __init__(self, sb: SupportedRemote, _id: str, name: str, options: dict = {}) FAN_HIGH, ] - self._supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) + self._supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - self._temperature_sensor = options.get("temperature_sensor", None) - self._humidity_sensor = options.get("umidity_sensor", None) + self._temperature_sensor = options.get(CONF_TEMPERATURE_SENSOR, None) + self._humidity_sensor = options.get(CONF_HUMIDITY_SENSOR, None) self._current_temperature = None self._current_humidity = None + @property + def device_info(self): + return DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="SwitchBot", + name=self._device_name, + model=AIR_CONDITIONER_CLASS + " Remote", + ) + @property def unique_id(self): """Return a unique ID.""" return self._unique_id @property - def device_info(self): - return DeviceInfo( - identifiers={(DOMAIN, self._unique_id)}, - manufacturer="switchbot", - name=self._name, - model="Air Conditioner", - ) + def name(self) -> str: + """Return the display name of this A/C.""" + return self._device_name @property def state(self): @@ -119,7 +139,7 @@ def fan_mode(self): @property def hvac_mode(self) -> HVACMode: """Return hvac mode ie. heat, cool.""" - return self._hvac_mode # type: ignore + return self._hvac_mode # type: ignore @property def power_state(self): @@ -144,11 +164,21 @@ def target_temperature_step(self): """Return the supported step of target temperature.""" return self._target_temperature_step + @property + def max_temp(self): + """Return the max temperature.""" + return self._max_temp + + @property + def min_temp(self): + """Return the min temperature.""" + return self._min_temp + @property def supported_features(self): """Return the list of supported features.""" return self._supported_features - + @property def current_temperature(self): """Return the current temperature.""" @@ -177,7 +207,7 @@ def set_temperature(self, **kwargs): def set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" - if hvac_mode == "off": + if hvac_mode == HVACMode.OFF: self.sb.turn("off") self._is_on = False else: @@ -192,10 +222,11 @@ def set_fan_mode(self, fan_mode): self._update_remote() def _update_remote(self): - self.sb.command( - "setAll", - f"{self.target_temperature},{HVAC_REMOTE_MODES[self.hvac_mode]},{FAN_REMOTE_MODES[self.fan_mode]},{self.power_state}", - ) + if (self._hvac_mode != HVACMode.OFF): + self.sb.command( + "setAll", + f"{self.target_temperature},{HVAC_REMOTE_MODES[self.hvac_mode]},{FAN_REMOTE_MODES[self.fan_mode]},{self.power_state}", + ) @callback def _async_update_temp(self, state): @@ -231,6 +262,28 @@ async def _async_humidity_sensor_changed(self, entity_id, old_state, new_state): self._async_update_humidity(new_state) await self.async_update_ha_state() + @callback + def _async_update_power(self, state): + """Update thermostat with latest state from temperature sensor.""" + try: + if state.state != STATE_UNKNOWN and state.state != STATE_UNAVAILABLE: + if state.state == STATE_OFF: + self._is_on = False + self._hvac_mode = HVACMode.OFF + elif state.state == STATE_ON: + self._is_on = True + self._hvac_mode = self._last_on_operation + except ValueError as ex: + _LOGGER.error("Unable to update from power sensor: %s", ex) + + async def _async_power_sensor_changed(self, entity_id, old_state, new_state): + """Handle power sensor changes.""" + if new_state is None: + return + + self._async_update_power(new_state) + await self.async_update_ha_state() + async def async_added_to_hass(self): """Run when entity about to be added.""" await super().async_added_to_hass() @@ -240,34 +293,44 @@ async def async_added_to_hass(self): if last_state is not None: self._hvac_mode = last_state.state self._fan_mode = last_state.attributes.get('fan_mode') or FAN_AUTO - self._target_temperature = last_state.attributes.get('temperature') or 28 - self._last_on_operation = last_state.attributes.get('last_on_operation') + self._target_temperature = last_state.attributes.get( + 'temperature') or 28 + self._last_on_operation = last_state.attributes.get( + 'last_on_operation') if self._temperature_sensor: - async_track_state_change(self.hass, self._temperature_sensor, - self._async_temp_sensor_changed) + async_track_state_change( + 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(self.hass, self._humidity_sensor, - self._async_humidity_sensor_changed) + async_track_state_change( + 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( + self.hass, self._power_sensor, self._async_power_sensor_changed) + + power_sensor_state = self.hass.states.get(self._power_sensor) + if power_sensor_state and power_sensor_state.state != STATE_UNKNOWN: + self._async_update_power(power_sensor_state) -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> bool: remotes = hass.data[DOMAIN][entry.entry_id] - climates = [ - SwitchBotRemoteClimate(remote, remote.id, remote.name, entry.data.get(remote.id, {})) - for remote in filter(lambda r: r.type == "Air Conditioner", remotes) + entities = [ + SwitchBotRemoteClimate(remote, entry.data.get(remote.id, {})) + for remote in filter(lambda r: r.type in IR_CLIMATE_TYPES, remotes) ] - async_add_entities(climates) + async_add_entities(entities) + + return True diff --git a/custom_components/switchbotremote/config_flow.py b/custom_components/switchbotremote/config_flow.py index 8839569..ba00cc4 100644 --- a/custom_components/switchbotremote/config_flow.py +++ b/custom_components/switchbotremote/config_flow.py @@ -10,9 +10,57 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import selector +from homeassistant.components.climate.const import HVACMode from .client import SwitchBot -from .const import DOMAIN +from .const import ( + DOMAIN, + CLASS_BY_TYPE, + + AIR_CONDITIONER_CLASS, + FAN_CLASS, + LIGHT_CLASS, + MEDIA_CLASS, + CAMERA_CLASS, + VACUUM_CLASS, + WATER_HEATER_CLASS, + OTHERS_CLASS, + + CONF_POWER_SENSOR, + CONF_TEMPERATURE_SENSOR, + CONF_HUMIDITY_SENSOR, + CONF_TEMP_MIN, + CONF_TEMP_MAX, + CONF_TEMP_STEP, + CONF_HVAC_MODES, + CONF_CUSTOMIZE_COMMANDS, + CONF_WITH_SPEED, + CONF_WITH_ION, + CONF_WITH_TIMER, + CONF_WITH_BRIGHTNESS, + CONF_WITH_TEMPERATURE, + CONF_ON_COMMAND, + CONF_OFF_COMMAND, +) + +DEFAULT_HVAC_MODES = [ + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + HVACMode.OFF, +] + +HVAC_MODES = [ + {"label": "Auto", "value": str(HVACMode.AUTO)}, + {"label": "Cool", "value": str(HVACMode.COOL)}, + {"label": "Dry", "value": str(HVACMode.DRY)}, + {"label": "Fan Only", "value": str(HVACMode.FAN_ONLY)}, + {"label": "Heat", "value": str(HVACMode.HEAT)}, + {"label": "Off", "value": str(HVACMode.OFF)}, +] _LOGGER = logging.getLogger(__name__) @@ -25,10 +73,52 @@ ) STEP_CONFIGURE_DEVICE = { - "Air Conditioner": lambda x: vol.Schema({ - vol.Optional("temperature_sensor", default=x.get("temperature_sensor")): str, - vol.Optional("umidity_sensor", default=x.get("umidity_sensor")): str, - }) + AIR_CONDITIONER_CLASS: lambda x: vol.Schema({ + vol.Optional(CONF_POWER_SENSOR, description={"suggested_value": x.get(CONF_POWER_SENSOR)}): selector({"entity": {"filter": {"domain": ["binary_sensor", "input_boolean", "light", "sensor", "switch"]}}}), + vol.Optional(CONF_TEMPERATURE_SENSOR, description={"suggested_value": x.get(CONF_TEMPERATURE_SENSOR)}): selector({"entity": {"filter": {"domain": "sensor"}}}), + vol.Optional(CONF_HUMIDITY_SENSOR, description={"suggested_value": x.get(CONF_HUMIDITY_SENSOR)}): selector({"entity": {"filter": {"domain": "sensor"}}}), + vol.Optional(CONF_TEMP_MIN, default=x.get(CONF_TEMP_MIN, 16)): int, + vol.Optional(CONF_TEMP_MAX, default=x.get(CONF_TEMP_MAX, 30)): int, + vol.Optional(CONF_TEMP_STEP, default=x.get(CONF_TEMP_STEP, 1.0)): selector({"number": {"min": 0.1, "max": 2.0, "step": 0.1, "mode": "slider"}}), + vol.Optional(CONF_HVAC_MODES, description={"suggested_value": x.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES)}): vol.All(selector({"select": {"multiple": True, "options": HVAC_MODES}})), + vol.Optional(CONF_CUSTOMIZE_COMMANDS, default=x.get(CONF_CUSTOMIZE_COMMANDS, [])): selector({"select": {"multiple": True, "custom_value": True, "options": []}}), + }), + MEDIA_CLASS: lambda x: vol.Schema({ + vol.Optional(CONF_POWER_SENSOR, description={"suggested_value": x.get(CONF_POWER_SENSOR)}): selector({"entity": {"filter": {"domain": ["binary_sensor", "input_boolean", "light", "sensor", "switch"]}}}), + vol.Optional(CONF_CUSTOMIZE_COMMANDS, default=x.get(CONF_CUSTOMIZE_COMMANDS, [])): selector({"select": {"multiple": True, "custom_value": True, "options": []}}), + }), + FAN_CLASS: lambda x: vol.Schema({ + vol.Optional(CONF_POWER_SENSOR, description={"suggested_value": x.get(CONF_POWER_SENSOR)}): selector({"entity": {"filter": {"domain": ["binary_sensor", "input_boolean", "light", "sensor", "switch"]}}}), + vol.Optional(CONF_WITH_SPEED, default=x.get(CONF_WITH_SPEED, False)): bool, + vol.Optional(CONF_WITH_ION, default=x.get(CONF_WITH_ION, False)): bool, + vol.Optional(CONF_WITH_TIMER, default=x.get(CONF_WITH_TIMER, False)): bool, + vol.Optional(CONF_CUSTOMIZE_COMMANDS, default=x.get(CONF_CUSTOMIZE_COMMANDS, [])): selector({"select": {"multiple": True, "custom_value": True, "options": []}}), + }), + LIGHT_CLASS: lambda x: vol.Schema({ + vol.Optional(CONF_POWER_SENSOR, description={"suggested_value": x.get(CONF_POWER_SENSOR)}): selector({"entity": {"filter": {"domain": ["binary_sensor", "input_boolean", "light", "sensor", "switch"]}}}), + vol.Optional(CONF_WITH_BRIGHTNESS, default=x.get(CONF_WITH_BRIGHTNESS, False)): bool, + vol.Optional(CONF_WITH_TEMPERATURE, default=x.get(CONF_WITH_TEMPERATURE, False)): bool, + vol.Optional(CONF_CUSTOMIZE_COMMANDS, default=x.get(CONF_CUSTOMIZE_COMMANDS, [])): selector({"select": {"multiple": True, "custom_value": True, "options": []}}), + }), + CAMERA_CLASS: lambda x: vol.Schema({ + vol.Optional(CONF_CUSTOMIZE_COMMANDS, default=x.get(CONF_CUSTOMIZE_COMMANDS, [])): selector({"select": {"multiple": True, "custom_value": True, "options": []}}), + }), + VACUUM_CLASS: lambda x: vol.Schema({ + vol.Optional(CONF_CUSTOMIZE_COMMANDS, default=x.get(CONF_CUSTOMIZE_COMMANDS, [])): selector({"select": {"multiple": True, "custom_value": True, "options": []}}), + }), + WATER_HEATER_CLASS: lambda x: vol.Schema({ + vol.Optional(CONF_POWER_SENSOR, description={"suggested_value": x.get(CONF_POWER_SENSOR)}): selector({"entity": {"filter": {"domain": ["binary_sensor", "input_boolean", "light", "sensor", "switch"]}}}), + vol.Optional(CONF_TEMPERATURE_SENSOR, description={"suggested_value": x.get(CONF_TEMPERATURE_SENSOR)}): selector({"entity": {"filter": {"domain": "sensor"}}}), + vol.Optional(CONF_TEMP_MIN, default=x.get(CONF_TEMP_MIN, 40)): int, + vol.Optional(CONF_TEMP_MAX, default=x.get(CONF_TEMP_MAX, 65)): int, + vol.Optional(CONF_CUSTOMIZE_COMMANDS, default=x.get(CONF_CUSTOMIZE_COMMANDS, [])): selector({"select": {"multiple": True, "custom_value": True, "options": []}}), + }), + OTHERS_CLASS: lambda x: vol.Schema({ + vol.Optional(CONF_POWER_SENSOR, description={"suggested_value": x.get(CONF_POWER_SENSOR)}): selector({"entity": {"filter": {"domain": ["binary_sensor", "input_boolean", "light", "sensor", "switch"]}}}), + vol.Optional(CONF_ON_COMMAND, default=x.get(CONF_ON_COMMAND, "")): str, + vol.Optional(CONF_OFF_COMMAND, default=x.get(CONF_OFF_COMMAND, "")): str, + vol.Optional(CONF_CUSTOMIZE_COMMANDS, default=x.get(CONF_CUSTOMIZE_COMMANDS, [])): selector({"select": {"multiple": True, "custom_value": True, "options": []}}), + }), } @@ -53,9 +143,7 @@ def async_get_options_flow(config_entry): """Get options flow for this handler.""" return OptionsFlowHandler(config_entry) - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: @@ -78,23 +166,21 @@ async def async_step_user( class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle options flow for LocalTuya integration.""" + """Handle options flow for SwitchBot integration.""" def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize localtuya options flow.""" + """Initialize SwitchBot options flow.""" self.config_entry = config_entry - # self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings()) - # self.entities = config_entry.data[CONF_ENTITIES] + self.data = config_entry.data - self.sb = SwitchBot(token=self.data["token"], secret=self.data["secret"]) + self.sb = SwitchBot( + token=self.data["token"], secret=self.data["secret"]) self.discovered_devices = [] self.selected_device = None self.entities = [] - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult: """Manage the options.""" if user_input is not None: self.selected_device = user_input["selected_device"] @@ -109,10 +195,7 @@ async def async_step_init( for remote in self.discovered_devices: devices[remote.id] = remote.name - return self.async_show_form( - step_id="init", - data_schema=vol.Schema({vol.Required("selected_device"): vol.In(devices)}) - ) + return self.async_show_form(step_id="init", data_schema=vol.Schema({vol.Required("selected_device"): vol.In(devices)})) async def async_step_edit_device(self, user_input=None): """Handle editing a device.""" @@ -127,16 +210,17 @@ async def async_step_edit_device(self, user_input=None): schema = vol.Schema({}) for remote in self.discovered_devices: - if remote.id == self.selected_device and remote.type in STEP_CONFIGURE_DEVICE: - schema = STEP_CONFIGURE_DEVICE[remote.type]( - self.config_entry.data.get(remote.id, {}) - ) + if remote.id == self.selected_device and remote.type in CLASS_BY_TYPE: + config = self.config_entry.data.get(remote.id, {}) + schema = STEP_CONFIGURE_DEVICE[CLASS_BY_TYPE[remote.type]]( + config) return self.async_show_form( step_id="edit_device", data_schema=schema ) + class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" diff --git a/custom_components/switchbotremote/const.py b/custom_components/switchbotremote/const.py index f6fd979..12dc041 100644 --- a/custom_components/switchbotremote/const.py +++ b/custom_components/switchbotremote/const.py @@ -1,3 +1,158 @@ """Constants for the SwitchBot Remote IR integration.""" +from enum import IntFlag, StrEnum DOMAIN = "switchbotremote" + +CONF_POWER_SENSOR = "power_sensor" +CONF_TEMPERATURE_SENSOR = "temperature_sensor" +CONF_HUMIDITY_SENSOR = "humidity_sensor" +CONF_TEMP_MIN = "temp_min" +CONF_TEMP_MAX = "temp_max" +CONF_TEMP_STEP = "temp_step" +CONF_HVAC_MODES = "hvac_modes" +CONF_CUSTOMIZE_COMMANDS = "customize_commands" +CONF_WITH_SPEED = "with_speed" +CONF_WITH_ION = "with_ion" +CONF_WITH_TIMER = "with_timer" +CONF_WITH_BRIGHTNESS = "with_brightness" +CONF_WITH_TEMPERATURE = "with_temperature" +CONF_ON_COMMAND = "on_command" +CONF_OFF_COMMAND = "off_command" + +"""Supported Devices""" +DIY_AIR_CONDITIONER_TYPE = "DIY Air Conditioner" +AIR_CONDITIONER_TYPE = "Air Conditioner" + +DIY_FAN_TYPE = "DIY Fan" +FAN_TYPE = "Fan" +DIY_AIR_PURIFIER_TYPE = "DIY Air Purifier" +AIR_PURIFIER_TYPE = "Air Purifier" + +DIY_LIGHT_TYPE = "DIY Light" +LIGHT_TYPE = "Light" + +DIY_TV_TYPE = "DIY TV" +TV_TYPE = "TV" +DIY_IPTV_TYPE = "DIY IPTV" +IPTV_TYPE = "IPTV" +DIY_DVD_TYPE = "DIY DVD" +DVD_TYPE = "DVD" +DIY_SPEAKER_TYPE = "DIY Speaker" +SPEAKER_TYPE = "Speaker" +DIY_SET_TOP_BOX_TYPE = "DIY Set Top Box" +SET_TOP_BOX_TYPE = "Set Top Box" +DIY_PROJECTOR_TYPE = "DIY Projector" +PROJECTOR_TYPE = "Projector" + +DIY_CAMERA_TYPE = "DIY Camera" +CAMERA_TYPE = "Camera" + +DIY_VACUUM_CLEANER_TYPE = "DIY Vacuum Cleaner" +VACUUM_CLEANER_TYPE = "Vacuum Cleaner" + +DIY_WATER_HEATER_TYPE = "DIY Water Heater" +WATER_HEATER_TYPE = "Water Heater" + +OTHERS_TYPE = "Others" + +"""IR Classes""" +AIR_CONDITIONER_CLASS = "Air Conditioner" +FAN_CLASS = "Fan" +LIGHT_CLASS = "Light" +MEDIA_CLASS = "Media" +CAMERA_CLASS = "Camera" +VACUUM_CLASS = "Vacuum" +WATER_HEATER_CLASS = "Water Heater" +OTHERS_CLASS = "Others" + +"""Class by device type""" +CLASS_BY_TYPE = { + DIY_AIR_CONDITIONER_TYPE: AIR_CONDITIONER_CLASS, + AIR_CONDITIONER_TYPE: AIR_CONDITIONER_CLASS, + + DIY_FAN_TYPE: FAN_CLASS, + FAN_TYPE: FAN_CLASS, + DIY_AIR_PURIFIER_TYPE: FAN_CLASS, + AIR_PURIFIER_TYPE: FAN_CLASS, + + DIY_LIGHT_TYPE: LIGHT_CLASS, + LIGHT_TYPE: LIGHT_CLASS, + + DIY_TV_TYPE: MEDIA_CLASS, + TV_TYPE: MEDIA_CLASS, + DIY_IPTV_TYPE: MEDIA_CLASS, + IPTV_TYPE: MEDIA_CLASS, + DIY_DVD_TYPE: MEDIA_CLASS, + DVD_TYPE: MEDIA_CLASS, + DIY_SPEAKER_TYPE: MEDIA_CLASS, + SPEAKER_TYPE: MEDIA_CLASS, + DIY_SET_TOP_BOX_TYPE: MEDIA_CLASS, + SET_TOP_BOX_TYPE: MEDIA_CLASS, + DIY_PROJECTOR_TYPE: MEDIA_CLASS, + PROJECTOR_TYPE: MEDIA_CLASS, + + DIY_CAMERA_TYPE: CAMERA_CLASS, + CAMERA_TYPE: CAMERA_CLASS, + + DIY_VACUUM_CLEANER_TYPE: VACUUM_CLASS, + VACUUM_CLEANER_TYPE: VACUUM_CLASS, + + DIY_WATER_HEATER_TYPE: WATER_HEATER_CLASS, + WATER_HEATER_TYPE: WATER_HEATER_CLASS, + + OTHERS_TYPE: OTHERS_CLASS, +} + +"""Climate Types""" +IR_CLIMATE_TYPES = [ + DIY_AIR_CONDITIONER_TYPE, + AIR_CONDITIONER_TYPE, +] + +"""Fan Types""" +IR_FAN_TYPES = [ + DIY_FAN_TYPE, + FAN_TYPE, + DIY_AIR_PURIFIER_TYPE, + AIR_PURIFIER_TYPE, +] + +"""Light Types""" +IR_LIGHT_TYPES = [ + DIY_LIGHT_TYPE, + LIGHT_TYPE, +] + +"""Media Types""" +IR_MEDIA_TYPES = [ + DIY_TV_TYPE, + TV_TYPE, + DIY_IPTV_TYPE, + IPTV_TYPE, + DIY_DVD_TYPE, + DVD_TYPE, + DIY_SPEAKER_TYPE, + SPEAKER_TYPE, + DIY_SET_TOP_BOX_TYPE, + SET_TOP_BOX_TYPE, + DIY_PROJECTOR_TYPE, + PROJECTOR_TYPE, +] + +"""Camera Types""" +IR_CAMERA_TYPES = [ + DIY_CAMERA_TYPE, + CAMERA_TYPE, +] + +"""Vacuum Types""" +IR_VACUUM_TYPES = [ + DIY_VACUUM_CLEANER_TYPE, + VACUUM_CLEANER_TYPE, +] + +"""Water Heater Types""" +IR_WATER_HEATER_TYPES = [ + DIY_WATER_HEATER_TYPE, + WATER_HEATER_TYPE, +] diff --git a/custom_components/switchbotremote/fan.py b/custom_components/switchbotremote/fan.py new file mode 100644 index 0000000..fadad90 --- /dev/null +++ b/custom_components/switchbotremote/fan.py @@ -0,0 +1,179 @@ +import logging +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item +) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OFF, STATE_ON +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change +from .client.remote import SupportedRemote + +from .const import DOMAIN, IR_FAN_TYPES, FAN_CLASS, AIR_PURIFIER_TYPE, DIY_AIR_PURIFIER_TYPE, CONF_WITH_SPEED, CONF_POWER_SENSOR + +_LOGGER = logging.getLogger(__name__) + +SPEED_COMMANDS = [ + 'lowSpeed', + 'middleSpeed', + 'highSpeed', +] + +AIR_PURIFIER_SPEED_COMMANDS = [ + 'FAN SPEED 1', + 'FAN SPEED 2', + 'FAN SPEED 3', +] + +IR_AIR_PURIFIER_TYPES = [ + DIY_AIR_PURIFIER_TYPE, + AIR_PURIFIER_TYPE, +] + + +class SwitchBotRemoteFan(FanEntity, RestoreEntity): + _attr_has_entity_name = False + _attr_speed_count = len(SPEED_COMMANDS) + + def __init__(self, hass: HomeAssistant, sb: SupportedRemote, options: dict = {}) -> None: + super().__init__() + self.sb = sb + self._hass = hass + self._unique_id = sb.id + self._device_name = sb.name + self._is_on = False + self._is_oscillating = False + self._state = STATE_OFF + self._speed = AIR_PURIFIER_SPEED_COMMANDS[0] if sb.type in IR_AIR_PURIFIER_TYPES else SPEED_COMMANDS[0] + self._supported_features = 0 + + self._power_sensor = options.get(CONF_POWER_SENSOR, None) + + if options.get(CONF_WITH_SPEED, None): + self._supported_features = FanEntityFeature.SET_SPEED + + if sb.type not in IR_AIR_PURIFIER_TYPES: + self._supported_features |= FanEntityFeature.OSCILLATE + + async def send_command(self, *args): + await self._hass.async_add_executor_job(self.sb.command, *args) + + @property + def device_info(self): + return DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="SwitchBot", + name=self._device_name, + model=FAN_CLASS+" Remote", + ) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the display name of this fan.""" + return self._device_name + + @property + def state(self) -> str | None: + return self._state + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._supported_features + + @property + def is_on(self): + """Check if fan is on.""" + return self._state + + @property + def percentage(self): + """Return the current speed percentage.""" + return ordered_list_item_to_percentage(AIR_PURIFIER_SPEED_COMMANDS if self.sb.type in IR_AIR_PURIFIER_TYPES else SPEED_COMMANDS, self._speed) + + 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 + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + speed = percentage_to_ordered_list_item( + AIR_PURIFIER_SPEED_COMMANDS if self.sb.type in IR_AIR_PURIFIER_TYPES else SPEED_COMMANDS, percentage) + await self.send_command(speed) + self._speed = speed + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + await self.send_command("swing") + self._is_oscillating = oscillating + + async def async_turn_on(self, **kwargs): + """Send the power on command.""" + await self.send_command("turnOn") + self._state = STATE_ON + self._is_on = True + + async def async_turn_off(self, **kwargs): + """Send the power on command.""" + await self.send_command("turnOff") + self._state = STATE_OFF + self._is_on = False + + @callback + def _async_update_power(self, state): + """Update thermostat with latest state from temperature sensor.""" + try: + if state.state != STATE_UNKNOWN and state.state != STATE_UNAVAILABLE and state.state != self._state: + if state.state == STATE_ON: + self._state = STATE_ON + self._is_on = True + else: + self._state = STATE_OFF + self._is_on = False + except ValueError as ex: + _LOGGER.error("Unable to update from power sensor: %s", ex) + + async def _async_power_sensor_changed(self, entity_id, old_state, new_state): + """Handle power sensor changes.""" + if new_state is None: + return + + self._async_update_power(new_state) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + if self._power_sensor: + async_track_state_change( + self.hass, self._power_sensor, self._async_power_sensor_changed) + + power_sensor_state = self.hass.states.get(self._power_sensor) + if power_sensor_state and power_sensor_state.state != STATE_UNKNOWN: + self._async_update_power(power_sensor_state) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> bool: + remotes = hass.data[DOMAIN][entry.entry_id] + + entities = [ + SwitchBotRemoteFan(hass, remote, entry.data.get(remote.id, {})) + for remote in filter(lambda r: r.type in IR_FAN_TYPES, remotes) + ] + + async_add_entities(entities) + + return True diff --git a/custom_components/switchbotremote/light.py b/custom_components/switchbotremote/light.py new file mode 100644 index 0000000..af21752 --- /dev/null +++ b/custom_components/switchbotremote/light.py @@ -0,0 +1,133 @@ +import logging +from typing import List +from homeassistant.components.light import LightEntity +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OFF, STATE_ON +from .client.remote import SupportedRemote + +from .const import DOMAIN, IR_LIGHT_TYPES, LIGHT_CLASS, CONF_POWER_SENSOR + +_LOGGER = logging.getLogger(__name__) + + +class SwitchBotRemoteLight(LightEntity, RestoreEntity): + _attr_has_entity_name = False + + def __init__(self, hass: HomeAssistant, sb: SupportedRemote, options: dict = {}) -> None: + super().__init__() + self.sb = sb + self._hass = hass + self._unique_id = sb.id + self._device_name = sb.name + self._state = STATE_OFF + self._brightness = None + + self._power_sensor = options.get(CONF_POWER_SENSOR, None) + + async def send_command(self, *args): + await self._hass.async_add_executor_job(self.sb.command, *args) + + @property + def device_info(self): + return DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="SwitchBot", + name=self._device_name, + model=LIGHT_CLASS + " Remote", + ) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the display name of this light.""" + return self._device_name + + @property + def brightness(self): + """Return the brightness of the light. + This method is optional. Removing it indicates to Home Assistant + that brightness is not supported for this light. + """ + return self._brightness + + @property + def state(self) -> str | None: + return self._state + + @property + def is_on(self): + """Check if light is on.""" + return self._state + + 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 + + async def async_turn_on(self, **kwargs): + """Send the power on command.""" + await self.send_command("turnOn") + self._state = STATE_ON + + async def async_turn_off(self, **kwargs): + """Send the power off command.""" + await self.send_command("turnOff") + self._state = STATE_OFF + + @callback + def _async_update_power(self, state): + """Update thermostat with latest state from temperature sensor.""" + try: + if state.state != STATE_UNKNOWN and state.state != STATE_UNAVAILABLE and state.state != self._state: + if state.state == STATE_ON: + self._state = STATE_ON + self._is_on = True + else: + self._state = STATE_OFF + self._is_on = False + except ValueError as ex: + _LOGGER.error("Unable to update from power sensor: %s", ex) + + async def _async_power_sensor_changed(self, entity_id, old_state, new_state): + """Handle power sensor changes.""" + if new_state is None: + return + + self._async_update_power(new_state) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + if self._power_sensor: + async_track_state_change( + self.hass, self._power_sensor, self._async_power_sensor_changed) + + power_sensor_state = self.hass.states.get(self._power_sensor) + if power_sensor_state and power_sensor_state.state != STATE_UNKNOWN: + self._async_update_power(power_sensor_state) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> bool: + remotes: List[SupportedRemote] = hass.data[DOMAIN][entry.entry_id] + + entities = [ + SwitchBotRemoteLight(hass, remote, entry.data.get(remote.id, {})) + for remote in filter(lambda r: r.type in IR_LIGHT_TYPES, remotes) + ] + + async_add_entities(entities) + + return True diff --git a/custom_components/switchbotremote/manifest.json b/custom_components/switchbotremote/manifest.json index 32674f4..6582151 100644 --- a/custom_components/switchbotremote/manifest.json +++ b/custom_components/switchbotremote/manifest.json @@ -1,13 +1,18 @@ { - "domain": "switchbotremote", - "name": "SwitchBot Remote IR", - "codeowners": [ - "@KiraPC" - ], - "config_flow": true, - "dependencies": [], - "documentation": "https://www.home-assistant.io/integrations/switchbotremote", - "iot_class": "cloud_push", - "requirements": ["pyhumps"], - "version": "0.0.1-alfa.2" -} + "domain": "switchbotremote", + "name": "SwitchBot Remote IR", + "codeowners": [ + "@KiraPC", + "@joshepw" + ], + "dependencies": [], + "integration_type": "hub", + "config_flow": true, + "documentation": "https://github.com/KiraPC/ha-switchbot-remote#readme", + "issue_tracker": "https://github.com/KiraPC/ha-switchbot-remote/issues", + "iot_class": "cloud_push", + "requirements": [ + "pyhumps" + ], + "version": "1.0.0" +} \ No newline at end of file diff --git a/custom_components/switchbotremote/media_player.py b/custom_components/switchbotremote/media_player.py new file mode 100644 index 0000000..7dfdd39 --- /dev/null +++ b/custom_components/switchbotremote/media_player.py @@ -0,0 +1,269 @@ +import logging +from homeassistant.components.media_player import MediaPlayerEntity, MediaPlayerEntityFeature +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_IDLE, + STATE_PAUSED, + STATE_PLAYING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change +from .client.remote import SupportedRemote + +from .const import DOMAIN, MEDIA_CLASS, IR_MEDIA_TYPES, DIY_PROJECTOR_TYPE, PROJECTOR_TYPE, CONF_POWER_SENSOR + +_LOGGER = logging.getLogger(__name__) + +IR_TRACK_TYPES = [ + 'DIY DVD', + 'DVD', + 'DIY Speaker', + 'Speaker', +] + +IR_PROJECTOR_TYPES = [ + DIY_PROJECTOR_TYPE, + PROJECTOR_TYPE, +] + + +class SwitchbotRemoteMediaPlayer(MediaPlayerEntity, RestoreEntity): + _attr_has_entity_name = False + + def __init__(self, hass: HomeAssistant, sb: SupportedRemote, options: dict = {}) -> None: + super().__init__() + self.sb = sb + self._hass = hass + self._unique_id = sb.id + self._device_name = sb.name + self._is_on = False + self._state = STATE_OFF + self._source = None + + self._power_sensor = options.get(CONF_POWER_SENSOR, None) + + self._supported_features = MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF + self._supported_features |= MediaPlayerEntityFeature.VOLUME_STEP + self._supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + self._supported_features |= MediaPlayerEntityFeature.PLAY_MEDIA + + if (sb.type in IR_TRACK_TYPES): + self._supported_features |= MediaPlayerEntityFeature.PLAY + self._supported_features |= MediaPlayerEntityFeature.PAUSE + self._supported_features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + self._supported_features |= MediaPlayerEntityFeature.NEXT_TRACK + self._supported_features |= MediaPlayerEntityFeature.STOP + elif (sb.type in IR_PROJECTOR_TYPES): + self._supported_features |= MediaPlayerEntityFeature.PLAY + self._supported_features |= MediaPlayerEntityFeature.PAUSE + else: + self._supported_features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + self._supported_features |= MediaPlayerEntityFeature.NEXT_TRACK + self._supported_features |= MediaPlayerEntityFeature.SELECT_SOURCE + + async def send_command(self, *args): + await self._hass.async_add_executor_job(self.sb.command, *args) + + 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 device_info(self): + return DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="SwitchBot", + name=self._device_name, + model=MEDIA_CLASS + " Remote", + ) + + @property + def should_poll(self): + """Push an update after each command.""" + return True + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return self._supported_features + + @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._device_name + + @property + def state(self): + """Return the state of the player.""" + return self._state + + async def async_turn_off(self): + """Turn the media player off.""" + await self.send_command("turnOff") + + self._state = STATE_OFF + self._source = None + + await self.async_update_ha_state() + + async def async_turn_on(self): + """Turn the media player off.""" + await self.send_command("turnOn") + + self._state = STATE_IDLE if self.sb.type in IR_TRACK_TYPES else STATE_ON + await self.async_update_ha_state() + + async def async_media_previous_track(self): + """Send previous track command.""" + if self.sb.type in IR_TRACK_TYPES: + await self.send_command("Previous") + else: + await self.send_command("channelSub") + await self.async_update_ha_state() + + async def async_media_next_track(self): + """Send next track command.""" + await self.send_command("Next") + if self.sb.type in IR_TRACK_TYPES: + await self.send_command("Next") + else: + await self.send_command("channelAdd") + await self.async_update_ha_state() + + async def async_volume_down(self): + """Turn volume down for media player.""" + if self.sb.type in IR_PROJECTOR_TYPES: + await self.send_command("VOL-", None, True) + else: + await self.send_command("volumeSub") + + await self.async_update_ha_state() + + async def async_volume_up(self): + """Turn volume up for media player.""" + if self.sb.type in IR_PROJECTOR_TYPES: + await self.send_command("VOL+", None, True) + else: + await self.send_command("volumeAdd") + + await self.async_update_ha_state() + + async def async_mute_volume(self, mute): + """Mute the volume.""" + if self.sb.type in IR_PROJECTOR_TYPES: + await self.send_command("MUTE", None, True) + else: + await self.send_command("setMute") + + await self.async_update_ha_state() + + async def async_media_play(self): + """Play/Resume media""" + self._state = STATE_PLAYING + + if self.sb.type in IR_PROJECTOR_TYPES: + await self.send_command("PLAY", None, True) + else: + await self.send_command("Play") + + await self.async_update_ha_state() + + async def async_media_pause(self): + """Pause media""" + self._state = STATE_PAUSED + + if self.sb.type in IR_PROJECTOR_TYPES: + await self.send_command("Paused", None, True) + else: + await self.send_command("Pause") + + await self.async_update_ha_state() + + async def async_media_play_pause(self): + """Play/Pause media""" + self._state = STATE_PLAYING + await self.send_command("Play") + await self.async_update_ha_state() + + async def async_media_stop(self): + """Stop media""" + self._state = STATE_IDLE + await self.send_command("Stop") + await self.async_update_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 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("SetChannel", digit, True) + await self.async_update_ha_state() + + @callback + def _async_update_power(self, state): + """Update thermostat with latest state from temperature sensor.""" + try: + if state.state != STATE_UNKNOWN and state.state != STATE_UNAVAILABLE: + if state.state == STATE_OFF: + self._state = STATE_OFF + self._source = None + elif state.state == STATE_ON: + self._state = STATE_IDLE if self.sb.type in IR_TRACK_TYPES else STATE_ON + + except ValueError as ex: + _LOGGER.error("Unable to update from power sensor: %s", ex) + + async def _async_power_sensor_changed(self, entity_id, old_state, new_state): + """Handle power sensor changes.""" + if new_state is None: + return + + self._async_update_power(new_state) + await self.async_update_ha_state() + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + if self._power_sensor: + async_track_state_change( + self.hass, self._power_sensor, self._async_power_sensor_changed) + + power_sensor_state = self.hass.states.get(self._power_sensor) + if power_sensor_state and power_sensor_state.state != STATE_UNKNOWN: + self._async_update_power(power_sensor_state) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> bool: + remotes = hass.data[DOMAIN][entry.entry_id] + + entities = [ + SwitchbotRemoteMediaPlayer(hass, remote, entry.data.get(remote.id, {})) + for remote in filter(lambda r: r.type in IR_MEDIA_TYPES, remotes) + ] + + async_add_entities(entities) + + return True diff --git a/custom_components/switchbotremote/remote.py b/custom_components/switchbotremote/remote.py index e624a8f..5363d8c 100644 --- a/custom_components/switchbotremote/remote.py +++ b/custom_components/switchbotremote/remote.py @@ -1,46 +1,103 @@ +import logging +from typing import List from homeassistant.components.remote import RemoteEntity -from homeassistant.core import HomeAssistant +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.entity import DeviceInfo from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OFF, STATE_ON from .client.remote import SupportedRemote -from .const import DOMAIN +from .const import DOMAIN, OTHERS_TYPE, CLASS_BY_TYPE, CONF_POWER_SENSOR, CONF_ON_COMMAND, CONF_OFF_COMMAND +_LOGGER = logging.getLogger(__name__) -class SwitchBotRemoteTV(RemoteEntity): + +class SwitchBotRemoteOther(RemoteEntity, RestoreEntity): _attr_has_entity_name = False - def __init__(self, sb: SupportedRemote, _id: str, name: str) -> None: + def __init__(self, sb: SupportedRemote, options: dict = {}) -> None: super().__init__() self.sb = sb - self._attr_unique_id = _id + self._device_name = sb.name + self._attr_unique_id = sb.id self._is_on = False + self._power_sensor = options.get(CONF_POWER_SENSOR, None) + self._on_command = options.get(CONF_ON_COMMAND, None) + self._off_command = options.get(CONF_OFF_COMMAND, None) + @property def device_info(self): return DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer="switchbot", - model="TV Remote", + manufacturer="SwitchBot", + name=self._device_name, + model=CLASS_BY_TYPE[self.sb.type] + " Remote", ) + @property + def name(self) -> str: + """Return the display name of this remote.""" + return self._device_name + + @property + def is_on(self): + """If the switch is currently on or off.""" + return self._is_on + def turn_on(self, activity: str = None, **kwargs): """Send the power on command.""" - self.sb.turn("on") + if self._on_command: + self.sb.command(self._on_command) def turn_off(self, activity: str = None, **kwargs): """Send the power off command.""" - self.sb.turn("off") + if self._off_command: + self.sb.command(self._off_command) + elif self._on_command: + self.sb.command(self._on_command) + + @callback + def _async_update_power(self, state): + """Update thermostat with latest state from temperature sensor.""" + try: + if state.state != STATE_UNKNOWN and state.state != STATE_UNAVAILABLE and state.state != self._is_on: + self._is_on = state.state == STATE_ON + except ValueError as ex: + _LOGGER.error("Unable to update from power sensor: %s", ex) + + async def _async_power_sensor_changed(self, entity_id, old_state, new_state): + """Handle power sensor changes.""" + if new_state is None: + return + + self._async_update_power(new_state) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + if self._power_sensor: + async_track_state_change( + self.hass, self._power_sensor, self._async_power_sensor_changed) + + power_sensor_state = self.hass.states.get(self._power_sensor) + if power_sensor_state and power_sensor_state.state != STATE_UNKNOWN: + self._async_update_power(power_sensor_state) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> bool: + remotes: List[SupportedRemote] = hass.data[DOMAIN][entry.entry_id] + entities = [] + for remote in remotes: + options = entry.data.get(remote.id, {}) -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -) -> bool: - remotes = hass.data[DOMAIN][entry.entry_id] + if (remote.type == OTHERS_TYPE and options.get("on_command", None)): + entities.append(SwitchBotRemoteOther(remote, options)) - climates = [ - SwitchBotRemoteTV(remote, remote.id, remote.name) - for remote in filter(lambda r: r.type == "TV", remotes) - ] + async_add_entities(entities) - async_add_entities(climates) + return True diff --git a/custom_components/switchbotremote/strings.json b/custom_components/switchbotremote/strings.json index d6e3212..943c5bd 100644 --- a/custom_components/switchbotremote/strings.json +++ b/custom_components/switchbotremote/strings.json @@ -1,21 +1,21 @@ { - "config": { - "step": { - "user": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - } - } -} + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/custom_components/switchbotremote/translations/en.json b/custom_components/switchbotremote/translations/en.json index aceaf10..cbbbb6e 100644 --- a/custom_components/switchbotremote/translations/en.json +++ b/custom_components/switchbotremote/translations/en.json @@ -1,44 +1,57 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "name": "Name of the switchbot Hub - This has to be unique per homeassistant app", - "token": "Insert your switchbot developer token", - "secret": "Insert your switchbot developer seret" - } - } - } - }, - "options": { - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "init": { - "title": "Customize device", - "description": "Pick the configured device you wish to edit.", - "data": { - "selected_device": "Discovered Devices" - } - }, - "edit_device": { - "title": "Configure device", - "data": { - "temperature_sensor": "temperature sensor ID to be used as air conditioner actual sensor", - "umidity_sensor": "himidity sensor ID to be used as air conditioner actual humidity" - } - } - } - } + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "name": "Name of the switchbot Hub - This has to be unique per homeassistant app", + "token": "Insert your switchbot developer token", + "secret": "Insert your switchbot developer seret" + } + } + } + }, + "options": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "init": { + "title": "Customize device", + "description": "Pick the configured device you wish to edit.", + "data": { + "selected_device": "Discovered Devices" + } + }, + "edit_device": { + "title": "Configure device", + "data": { + "temperature_sensor": "Temperature sensor ID to be used as air conditioner actual sensor", + "humidity_sensor": "Humidity sensor ID to be used as air conditioner actual humidity", + "power_sensor": "Power sensor ID to get device status", + "customize_commands": "Button names (case sensitive)", + "hvac_modes": "Supported modes", + "temp_min": "Minimum temperature", + "temp_max": "Maximum temperature", + "temp_step": "Temperature step factor", + "with_speed": "Enable fan speeds", + "with_ion": "Enable 'ION' button", + "with_timer": "Enable 'Timer' button", + "with_brightness": "Enable brightness control buttons", + "with_temperature": "Enable temperature color buttons", + "on_command": "On/Off button name", + "off_command": "Name of the Off button in case of independent operation" + } + } + } + } } \ No newline at end of file diff --git a/custom_components/switchbotremote/translations/es.json b/custom_components/switchbotremote/translations/es.json new file mode 100644 index 0000000..f9eecbb --- /dev/null +++ b/custom_components/switchbotremote/translations/es.json @@ -0,0 +1,57 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya está configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticación no válida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "name": "Nombre del Switchbot Hub: debe ser único para cada aplicación de Homeassistant", + "token": "Inserta tu token de desarrollador de SwitchBot", + "secret": "Inserta tu código de desarrollador de SwitchBot" + } + } + } + }, + "options": { + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticación no válida", + "unknown": "Error inesperado" + }, + "step": { + "init": { + "title": "Personalizar dispositivo", + "description": "Elija el dispositivo configurado que desea editar.", + "data": { + "selected_device": "Dispositivos descubiertos" + } + }, + "edit_device": { + "title": "Configurar dispositivo", + "data": { + "temperature_sensor": "ID del sensor de temperatura que se utilizará como sensor real del aire acondicionado", + "humidity_sensor": "ID del sensor de humedad que se utilizará como humedad real del aire acondicionado", + "power_sensor": "ID del sensor de encendido para obtener el estado del dispositivo", + "customize_commands": "Nombre de botones (distingue mayúsculas y minúsculas)", + "hvac_modes": "Modos soportados", + "temp_min": "Temperatura mínima", + "temp_max": "Temperatura máxima", + "temp_step": "Factor de pasos en temperatura", + "with_speed": "Habilitar velocidades", + "with_ion": "Habilitar boton de 'ION'", + "with_timer": "Habilitar boton de 'Timer'", + "with_brightness": "Habilitar botones de control brillo", + "with_temperature": "Habilitar botones de color de temperatura", + "on_command": "Nombre del botón On/Off", + "off_command": "Nombre del botón Off en caso de accionar independiente" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/switchbotremote/translations/it.json b/custom_components/switchbotremote/translations/it.json index 533544a..b939696 100644 --- a/custom_components/switchbotremote/translations/it.json +++ b/custom_components/switchbotremote/translations/it.json @@ -1,44 +1,57 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "name": "Nome dello switchbot hub - deve essere unico per installazione di homeassistant", - "token": "Inserire il token da sviluppatore di switchbot", - "secret": "Inserire il secret da sviluppatore di switchbot" - } - } - } - }, - "options": { - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "init": { - "title": "Personalizza un dispositivo", - "description": "Scegli il dispositivo configurato che si desidera personalizzare.", - "data": { - "selected_device": "Dispositivi configurati" - } - }, - "edit_device": { - "title": "Configura dispositivo", - "data": { - "temperature_sensor": "ID sensore temperatura da utilizzare", - "umidity_sensor": "ID sensore umidità da utilizzare" - } - } - } - } + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "name": "Nome dello switchbot hub - deve essere unico per installazione di homeassistant", + "token": "Inserire il token da sviluppatore di switchbot", + "secret": "Inserire il secret da sviluppatore di switchbot" + } + } + } + }, + "options": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "init": { + "title": "Personalizza un dispositivo", + "description": "Scegli il dispositivo configurato che si desidera personalizzare.", + "data": { + "selected_device": "Dispositivi configurati" + } + }, + "edit_device": { + "title": "Configura dispositivo", + "data": { + "temperature_sensor": "ID sensore temperatura da utilizzare", + "humidity_sensor": "ID sensore umidità da utilizzare", + "power_sensor": "ID sensore per indicare se dispositivo è accesa o spenta", + "customize_commands": "Nomi dei pulsanti (maiuscole e minuscole)", + "hvac_modes": "Modalità supportate", + "temp_min": "Temperatura minima", + "temp_max": "Temperatura massima", + "temp_step": "Fattore di incremento della temperatura", + "with_speed": "Abilita velocità", + "with_ion": "Abilita il pulsante 'ION'", + "with_timer": "Abilita il pulsante 'Timer'", + "with_brightness": "Abilita i pulsanti di controllo della luminosità", + "with_temperature": "Abilita i pulsanti colorati della temperatura", + "on_command": "Nome del pulsante di accensione/spegnimento", + "off_command": "Nome del pulsante Off in caso di funzionamento indipendente" + } + } + } + } } \ No newline at end of file diff --git a/custom_components/switchbotremote/vacuum.py b/custom_components/switchbotremote/vacuum.py new file mode 100644 index 0000000..2173f63 --- /dev/null +++ b/custom_components/switchbotremote/vacuum.py @@ -0,0 +1,99 @@ +from typing import List +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumEntityFeature, # v2022.5 + STATE_DOCKED, + STATE_CLEANING, + STATE_IDLE, + STATE_IDLE, + STATE_RETURNING +) +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.config_entries import ConfigEntry +from .client.remote import SupportedRemote + +from .const import DOMAIN, IR_VACUUM_TYPES, VACUUM_CLASS + + +class SwitchBotRemoteVacuum(StateVacuumEntity, RestoreEntity): + _attr_has_entity_name = False + + def __init__(self, hass: HomeAssistant, sb: SupportedRemote, options: dict = {}): + super().__init__() + self.sb = sb + self._hass = hass + self._unique_id = sb.id + self._device_name = sb.name + self._state = STATE_IDLE + + self._supported_features = VacuumEntityFeature.STATE | VacuumEntityFeature.START | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME + + async def send_command(self, *args): + await self._hass.async_add_executor_job(self.sb.command, *args) + + @property + def device_info(self): + return DeviceInfo( + identifiers={(DOMAIN, self._unique_id)}, + manufacturer="SwitchBot", + name=self._device_name, + model=VACUUM_CLASS + " Remote", + ) + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the display name of this vacuum.""" + return self._device_name + + @property + def state(self) -> str | None: + return self._state + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._supported_features + + 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 + + async def async_start(self): + """Send the power on command.""" + await self.send_command("turnOn") + self._state = STATE_CLEANING + + async def async_stop(self): + """Send the power off command.""" + await self.send_command("turnOff") + self._state = STATE_IDLE + + async def async_return_to_base(self): + """Send the power off command.""" + await self.send_command("CHARGE", None, True) + self._state = STATE_IDLE + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> bool: + remotes: List[SupportedRemote] = hass.data[DOMAIN][entry.entry_id] + + entities = [ + SwitchBotRemoteVacuum(hass, remote, entry.data.get(remote.id, {})) + for remote in filter(lambda r: r.type in IR_VACUUM_TYPES, remotes) + ] + + async_add_entities(entities) + + return True diff --git a/custom_components/switchbotremote/water_heater.py b/custom_components/switchbotremote/water_heater.py new file mode 100644 index 0000000..67e482e --- /dev/null +++ b/custom_components/switchbotremote/water_heater.py @@ -0,0 +1,178 @@ +from .const import DOMAIN, WATER_HEATER_CLASS, IR_WATER_HEATER_TYPES, CONF_POWER_SENSOR, CONF_TEMPERATURE_SENSOR, CONF_TEMP_MAX, CONF_TEMP_MIN +import logging +from typing import List +from homeassistant.components.water_heater import WaterHeaterEntity, WaterHeaterEntityFeature, STATE_HEAT_PUMP +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OFF, STATE_ON, TEMP_CELSIUS +from .client.remote import SupportedRemote + +_LOGGER = logging.getLogger(__name__) + + +DEFAULT_MIN_TEMP = 40 +DEFAULT_MAX_TEMP = 65 + + +class SwitchBotRemoteWaterHeater(WaterHeaterEntity, RestoreEntity): + _attr_has_entity_name = False + _attr_operation_list = [STATE_OFF, STATE_HEAT_PUMP] + + def __init__(self, sb: SupportedRemote, options: dict = {}) -> None: + super().__init__() + self.sb = sb + self._device_name = sb.name + self._attr_unique_id = sb.id + self._is_on = False + self._state = STATE_OFF + self._temperature_unit = TEMP_CELSIUS + self._supported_features = WaterHeaterEntityFeature.OPERATION_MODE + + self._current_temperature = None + self._power_sensor = options.get(CONF_POWER_SENSOR, None) + self._temperature_sensor = options.get(CONF_TEMPERATURE_SENSOR, None) + self._max_temp = options.get(CONF_TEMP_MAX, DEFAULT_MAX_TEMP) + self._min_temp = options.get(CONF_TEMP_MIN, DEFAULT_MIN_TEMP) + + @property + def device_info(self): + return DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer="SwitchBot", + name=self._device_name, + model=WATER_HEATER_CLASS + " Remote", + ) + + @property + def name(self) -> str: + """Return the display name of this water heater.""" + return self._device_name + + @property + def is_on(self): + """If the switch is currently on or off.""" + return self._is_on + + @property + def supported_features(self) -> WaterHeaterEntityFeature: + """Return the list of supported features.""" + return self._supported_features + + @property + def current_operation(self) -> str | None: + """Return current operation.""" + return STATE_HEAT_PUMP if self._is_on else STATE_OFF + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._temperature_unit + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def max_temp(self): + """Return the max temperature.""" + return self._max_temp + + @property + def min_temp(self): + """Return the min temperature.""" + return self._min_temp + + def turn_on(self, activity: str = None, **kwargs): + """Send the power on command.""" + self.sb.command("turnOn") + self._state = STATE_HEAT_PUMP + self._is_on = True + + def turn_off(self, activity: str = None, **kwargs): + """Send the power off command.""" + self.sb.command("turnOff") + self._state = STATE_OFF + self._is_on = False + + def set_operation_mode(self, operation_mode: str) -> None: + """Set operation mode.""" + if operation_mode == STATE_HEAT_PUMP: + self.turn_on() + + if operation_mode == STATE_OFF: + self.turn_off() + + @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) + + async def _async_temp_sensor_changed(self, entity_id, old_state, new_state): + """Handle temperature sensor changes.""" + if new_state is None: + return + + self._async_update_temp(new_state) + await self.async_update_ha_state() + + @callback + def _async_update_power(self, state): + """Update thermostat with latest state from temperature sensor.""" + try: + if state.state != STATE_UNKNOWN and state.state != STATE_UNAVAILABLE and state.state != self._state: + if state.state == STATE_ON: + self._state = STATE_HEAT_PUMP + self._is_on = True + else: + self._state = STATE_OFF + self._is_on = False + except ValueError as ex: + _LOGGER.error("Unable to update from power sensor: %s", ex) + + async def _async_power_sensor_changed(self, entity_id, old_state, new_state): + """Handle power sensor changes.""" + if new_state is None: + return + + self._async_update_power(new_state) + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + if self._temperature_sensor: + async_track_state_change( + 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._power_sensor: + async_track_state_change( + self.hass, self._power_sensor, self._async_power_sensor_changed) + + power_sensor_state = self.hass.states.get(self._power_sensor) + if power_sensor_state and power_sensor_state.state != STATE_UNKNOWN: + self._async_update_power(power_sensor_state) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities) -> bool: + remotes: List[SupportedRemote] = hass.data[DOMAIN][entry.entry_id] + + entities = [ + SwitchBotRemoteWaterHeater(remote, entry.data.get(remote.id, {})) + for remote in filter(lambda r: r.type in IR_WATER_HEATER_TYPES, remotes) + ] + + async_add_entities(entities) + + return True diff --git a/docs/app.png b/docs/app.png new file mode 100644 index 0000000..f029f71 Binary files /dev/null and b/docs/app.png differ diff --git a/docs/icon.png b/docs/icon.png new file mode 100644 index 0000000..5b5a55e Binary files /dev/null and b/docs/icon.png differ diff --git a/docs/icon@2x.png b/docs/icon@2x.png new file mode 100644 index 0000000..bcfad0b Binary files /dev/null and b/docs/icon@2x.png differ diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 0000000..844b9b3 Binary files /dev/null and b/docs/logo.png differ diff --git a/docs/logo@2x.png b/docs/logo@2x.png new file mode 100644 index 0000000..30d39e8 Binary files /dev/null and b/docs/logo@2x.png differ diff --git a/docs/settings.png b/docs/settings.png new file mode 100644 index 0000000..55797a9 Binary files /dev/null and b/docs/settings.png differ diff --git a/hacs.json b/hacs.json index 4b2c716..50e40e0 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,4 @@ { "name": "SwitchBot Remote IR", - "render_readme": true, - "iot_class": "cloud_push" + "render_readme": true }