From 0b5b9337bdd76fdfca14c96a91e00196f5b1c69d Mon Sep 17 00:00:00 2001 From: MizterB <5458030+MizterB@users.noreply.github.com> Date: Wed, 17 Apr 2024 09:58:38 -0400 Subject: [PATCH 1/7] Clean up and add new API --- .gitignore | 2 + custom_components.json | 12 - .../proliphix_plus/manifest.json | 2 +- .../proliphix_plus/proliphix/__init__.py | 0 .../proliphix_plus/proliphix/api.py | 513 ++++++++++++++++++ .../proliphix_plus/proliphix/const.py | 409 ++++++++++++++ hacs.json | 7 + 7 files changed, 932 insertions(+), 13 deletions(-) create mode 100644 .gitignore delete mode 100644 custom_components.json create mode 100644 custom_components/proliphix_plus/proliphix/__init__.py create mode 100644 custom_components/proliphix_plus/proliphix/api.py create mode 100644 custom_components/proliphix_plus/proliphix/const.py create mode 100644 hacs.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a4dae1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +__pycache__ diff --git a/custom_components.json b/custom_components.json deleted file mode 100644 index 1d85aba..0000000 --- a/custom_components.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "proliphix_plus": { - "version": "0.2.1", - "local_location": "/custom_components/proliphix_plus/__init__.py", - "remote_location": "https://raw.githubusercontent.com/MizterB/homeassistant-proliphix-plus/master/custom_components/proliphix_plus/__init__.py", - "visit_repo": "https://github.com/MizterB/homeassistant-proliphix-plus", - "changelog": "https://github.com/MizterB/homeassistant-proliphix-plus", - "resources": [ - "https://raw.githubusercontent.com/MizterB/homeassistant-proliphix-plus/master/custom_components/proliphix_plus/climate.py" - ] - } -} diff --git a/custom_components/proliphix_plus/manifest.json b/custom_components/proliphix_plus/manifest.json index 3c89ea7..cf36bd2 100644 --- a/custom_components/proliphix_plus/manifest.json +++ b/custom_components/proliphix_plus/manifest.json @@ -5,5 +5,5 @@ "requirements": [], "dependencies": [], "codeowners": ["@MizterB"], - "version": "0.2.1" + "version": "2024.4" } diff --git a/custom_components/proliphix_plus/proliphix/__init__.py b/custom_components/proliphix_plus/proliphix/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/proliphix_plus/proliphix/api.py b/custom_components/proliphix_plus/proliphix/api.py new file mode 100644 index 0000000..e2fd27a --- /dev/null +++ b/custom_components/proliphix_plus/proliphix/api.py @@ -0,0 +1,513 @@ +"""Define a base client for interacting with a Proliphix thermostat.""" + +import asyncio +from datetime import datetime, timedelta, timezone +import logging +from typing import Optional +from urllib.parse import parse_qs, urlencode + +from aiohttp import BasicAuth, ClientSession +from aiohttp.client_exceptions import ClientError + +from .const import ( + MANUFACTURER, + OID, + CurrentPeriod, + FanMode, + FanState, + HVACMode, + HVACState, + ScheduleClass, + SetbackStatus, + TemperatureScale, +) + +_LOGGER = logging.getLogger(__name__) + +CONNECT_TIMEOUT: int = 30 +UPDATE_TIMEOUT: int = 30 + + +OIDS_CORE = [ + OID.SERIAL_NUMBER, + OID.SYSTEM_MIM_MODEL_NUMBER, + OID.FIRMWARE_VERSION, + OID.COMMON_DEV_NAME, + OID.SITE_NAME, + OID.TEMPERATURE_SCALE, +] + +OIDS_SCHEDULE = [ + OID.THERM_PERIOD_START_IN_PERIOD_1, + OID.THERM_PERIOD_START_IN_PERIOD_2, + OID.THERM_PERIOD_START_IN_PERIOD_3, + OID.THERM_PERIOD_START_IN_PERIOD_4, + OID.THERM_PERIOD_START_OUT_PERIOD_1, + OID.THERM_PERIOD_START_OUT_PERIOD_2, + OID.THERM_PERIOD_START_OUT_PERIOD_3, + OID.THERM_PERIOD_START_OUT_PERIOD_4, + OID.THERM_PERIOD_START_AWAY_PERIOD_1, + OID.THERM_PERIOD_START_AWAY_PERIOD_2, + OID.THERM_PERIOD_START_AWAY_PERIOD_3, + OID.THERM_PERIOD_START_AWAY_PERIOD_4, +] + +OIDS_STATE = [ + OID.SYSTEM_TIME_SECS, + OID.THERM_SENSOR_TEMP_LOCAL, + OID.THERM_SENSOR_TEMP_REMOTE_1, + OID.THERM_SENSOR_TEMP_REMOTE_2, # Added + # OID.THERM_SENSOR_STATE_REMOTE_1, + OID.THERM_HVAC_MODE, + OID.THERM_HVAC_STATE, + OID.THERM_FAN_MODE, + OID.THERM_FAN_STATE, + OID.THERM_SETBACK_HEAT, + OID.THERM_SETBACK_COOL, + OID.THERM_SETBACK_STATUS, + OID.THERM_CURRENT_PERIOD, + OID.THERM_CURRENT_CLASS, + OID.THERM_RELATIVE_HUMIDITY, + OID.THERM_HOLD_DURATION, + OID.THERM_HOLD_MODE, + OID.THERM_PERIOD_START_IN_PERIOD_1, + OID.THERM_PERIOD_START_IN_PERIOD_2, + OID.THERM_PERIOD_START_IN_PERIOD_3, + OID.THERM_PERIOD_START_IN_PERIOD_4, + OID.THERM_PERIOD_START_OUT_PERIOD_1, + OID.THERM_PERIOD_START_OUT_PERIOD_2, + OID.THERM_PERIOD_START_OUT_PERIOD_3, + OID.THERM_PERIOD_START_OUT_PERIOD_4, + OID.THERM_PERIOD_START_AWAY_PERIOD_1, + OID.THERM_PERIOD_START_AWAY_PERIOD_2, + OID.THERM_PERIOD_START_AWAY_PERIOD_3, + OID.THERM_PERIOD_START_AWAY_PERIOD_4, +] + + +class Proliphix: + """Object representing a Proliphix thermostat.""" + + def __init__( + self, + host: str, + port: int = 80, + username: str = "admin", + password: str = "admin", + ssl: bool = False, + *, + session: Optional[ClientSession] = None, + ) -> None: + """Initialize the Proliphix object.""" + self.host: str = host + self.port: str = port + self.username = username + self.password = password + self.ssl = ssl + + self._cache = {} + self._change_callbacks = {} + + self._auth: BasicAuth = BasicAuth(self.username, self.password) + self._session: ClientSession = session + if not self._session or self._session.closed: + self._session = ClientSession() + + self._hold_until = None + self._schedule = None + + self._register_change_callback( + [OID.THERM_SETBACK_STATUS, OID.THERM_HOLD_DURATION], self._update_hold_until + ) + self._register_change_callback( + OIDS_SCHEDULE + [OID.SYSTEM_TIME_SECS], self._update_current_schedule + ) + + @property + def url(self): + """Get the base URL to connect to the thermostat.""" + protocol = "http" + if self.ssl: + protocol = "https" + return f"{protocol}://{self.host}:{self.port}" + + async def _post(self, endpoint: str, data: dict, **kwargs) -> dict: + """Make a POST request to the thermostat.""" + url = f"{self.url}{endpoint}" + try: + _LOGGER.debug("POST %s with %s and %s", url, data, kwargs) + async with self._session.post( + url, data=data, auth=self._auth, **kwargs + ) as resp: + resp_text = await resp.text() + resp_dict = parse_qs(resp_text) + _LOGGER.debug( + "POST RESPONSE from %s with %s and %s is: %s", + url, + data, + kwargs, + resp_text, + ) + resp.raise_for_status() + return resp_dict + except ClientError as e: + _LOGGER.error(e) + + def _register_change_callback(self, oids: OID | list[OID], callback) -> None: + """Register a callback for a changed OID.""" + oids = oids if isinstance(oids, list) else [oids] + for oid in oids: + self._change_callbacks[oid] = callback + + def _process_response(self, response: dict) -> dict[OID, str]: + """Map a get/set response back to OIDs.""" + resp = {} + for oid_str, value in response.items(): + oid_obj = OID.get_by_val(oid_str) + resp[oid_obj] = value[0] if value else "" + return resp + + def _update_cache(self, oid_dict: dict) -> None: + """Update the cache with OID data.""" + changes = {} + for oid, new_value in oid_dict.items(): + if new_value != self._cache.get(oid): + old_value = self._cache.get(oid) + changes[oid] = [old_value, new_value] + _LOGGER.debug("Updating cache with these changes: %s", changes) + # First update all of the cache values + for oid, change in changes.items(): + new_value = change[1] + self._cache[oid] = new_value + # Then call any change callbacks + for oid, change in changes.items(): + if oid in self._change_callbacks: + old_value = change[0] + new_value = change[1] + callback = self._change_callbacks[oid] + callback(oid, old_value, new_value) + + async def get_oids(self, oids: OID | list[OID]) -> dict[OID, str]: + """Get the values of OIDs.""" + oids = oids if isinstance(oids, list) else [oids] + data = urlencode({k.value: None for k in oids}) + resp = await self._post("/get", data=data) + resp = self._process_response(resp) + self._update_cache(resp) + return resp + + async def set_oids(self, oid_values: dict[OID, str]) -> dict[OID, str]: + """Set the values of OIDs.""" + data = urlencode({k.value: v for k, v in oid_values.items()}) + "&submit=Submit" + resp = await self._post("/pdp", data=data) + resp = self._process_response(resp) + self._update_cache(resp) + return resp + + async def connect(self) -> None: + """Connect to the thermostat.""" + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + _LOGGER.debug("Connecting to Proliphix thermostat at %s", self.url) + await self.get_oids(OIDS_CORE) + except asyncio.TimeoutError as e: + _LOGGER.error( + "Failed to connect to Proliphix thermostat at %s after %s seconds", + self.url, + CONNECT_TIMEOUT, + ) + raise ConnectionError(e) from e + + async def refresh_state(self) -> None: + """Update the themostat state attributes.""" + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + _LOGGER.debug("Refreshing state attributes") + await self.get_oids(OIDS_STATE) + except asyncio.TimeoutError as e: + _LOGGER.error( + "Failed to refresh state attributes after %s seconds", + CONNECT_TIMEOUT, + ) + raise ConnectionError(e) from e + + async def refresh_schedule(self) -> None: + """Update the themostat schedule atributes.""" + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + _LOGGER.debug("Refreshing schedule attributes") + await self.get_oids(OIDS_SCHEDULE) + except asyncio.TimeoutError as e: + _LOGGER.error( + "Failed to refresh schedule attributes after %s seconds", + CONNECT_TIMEOUT, + ) + raise ConnectionError(e) from e + + @property + def manufacturer(self) -> str | None: + """Manufacturer name.""" + return MANUFACTURER + + @property + def model(self) -> str | None: + """Model name.""" + val = self._cache.get(OID.SYSTEM_MIM_MODEL_NUMBER) + if not val: + return None + return str(val) + + @property + def serial(self) -> str | None: + """Serial number.""" + val = self._cache.get(OID.SERIAL_NUMBER) + if not val: + return None + return str(val) + + @property + def firmware(self) -> str | None: + """Firmware version.""" + val = self._cache.get(OID.FIRMWARE_VERSION) + if not val: + return None + return str(val) + + @property + def name(self) -> str | None: + """Device name.""" + val = self._cache.get(OID.COMMON_DEV_NAME) + if not val: + return None + return str(val) + + @property + def site_name(self) -> str | None: + """Site name.""" + val = self._cache.get(OID.SITE_NAME) + if not val: + return None + return str(val) + + @property + def temperature_scale(self) -> TemperatureScale | None: + """Temperature scale (units).""" + val = self._cache.get(OID.TEMPERATURE_SCALE) + if not val: + return None + scale = next((s for s in TemperatureScale if s.value == val), None) + return scale + + @property + def system_time(self) -> datetime | None: + """Current system time of the thermostat.""" + val = self._cache.get(OID.SYSTEM_TIME_SECS) + if not val: + return None + # The system time is in local time, but without offset data + systime = datetime.fromtimestamp(int(val), timezone.utc) + # Prevent value conversions by overrding the timezone to the correct local one + local_tzinfo = datetime.now().astimezone().tzinfo + systime_local = systime.replace(tzinfo=local_tzinfo) + return systime_local + + @property + def temperature_local(self) -> float | None: + """Local temperature of the thermostat.""" + val = self._cache.get(OID.THERM_SENSOR_TEMP_LOCAL) + if not val: + return None + temp = float(val) / 10 + return temp + + @property + def temperature_remote_1(self) -> float | None: + """Temperature of remote sensor 1.""" + val = self._cache.get(OID.THERM_SENSOR_TEMP_REMOTE_1) + if not val or val == "FAILED5": + return None + temp = float(val) / 10 + return temp + + @property + def temperature_remote_2(self) -> float | None: + """Temperature of remote sensor 2.""" + val = self._cache.get(OID.THERM_SENSOR_TEMP_REMOTE_2) + if not val or val == "FAILED5": + return None + temp = float(val) / 10 + return temp + + @property + def hvac_mode(self) -> HVACMode | None: + """HVAC mode of the thermostat.""" + val = self._cache.get(OID.THERM_HVAC_MODE) + if not val: + return None + mode = next((m for m in HVACMode if m.value == val), None) + return mode + + @property + def hvac_state(self) -> HVACState | None: + """HVAC state of the thermostat.""" + val = self._cache.get(OID.THERM_HVAC_STATE) + if not val: + return None + state = next((s for s in HVACState if s.value == val), None) + return state + + @property + def fan_mode(self) -> FanMode | None: + """Fan mode of the thermostat.""" + val = self._cache.get(OID.THERM_FAN_MODE) + if not val: + return None + mode = next((m for m in FanMode if m.value == val), None) + return mode + + @property + def fan_state(self) -> FanState | None: + """Fan state of the thermostat.""" + val = self._cache.get(OID.THERM_FAN_STATE) + if not val: + return None + state = next((f for f in FanState if f.value == val), None) + return state + + @property + def setback_heat(self) -> float | None: + """Target heating temperature.""" + val = self._cache.get(OID.THERM_SETBACK_HEAT) + if not val: + return None + temp = float(val) / 10 + return temp + + @property + def setback_cool(self) -> float | None: + """Target cooling temperature.""" + val = self._cache.get(OID.THERM_SETBACK_COOL) + if not val: + return None + temp = float(val) / 10 + return temp + + @property + def setback_status(self) -> SetbackStatus | None: + """Setback status (normal, hold, override).""" + val = self._cache.get(OID.THERM_SETBACK_STATUS) + if not val: + return None + status = next((s for s in SetbackStatus if s.value == val), None) + return status + + @property + def current_period(self) -> CurrentPeriod | None: + """Current schedule period.""" + val = self._cache.get(OID.THERM_CURRENT_PERIOD) + if not val: + return None + scale = next((p for p in CurrentPeriod if p.value == val), None) + return scale + + @property + def current_class(self) -> ScheduleClass | None: + """Current schedule class (in, out, away).""" + val = self._cache.get(OID.THERM_CURRENT_CLASS) + if not val: + return None + scale = next((c for c in ScheduleClass if c.value == val), None) + return scale + + @property + def relative_humidity(self) -> float | None: + """Relative humidity at the thermostat.""" + if self.model != "NT150": + return None + val = self._cache.get(OID.THERM_RELATIVE_HUMIDITY) + if not val: + return None + humidity = float(val) / 10 + return humidity + + @property + def hold_duration(self) -> int | None: + """Hours to hold.""" + val = self._cache.get(OID.THERM_HOLD_DURATION) + if not val: + return None + return int(val) + + @property + def next_period(self) -> str | None: + """Next period.""" + return self._next_period + + @property + def next_period_start(self) -> str | None: + """Next period start.""" + return self._next_period_start + + @property + def hold_until(self) -> str | None: + """Hold until datetime (computed property).""" + return self._hold_until + + @property + def current_schedule(self) -> str | None: + """Current schedule (computed property).""" + return self._current_schedule + + def _update_hold_until(self, oid: OID, from_val: str, to_val: str) -> None: + """Update the hold until time.""" + _LOGGER.debug("Updating hold until time due to change in %s", oid) + if self.setback_status == SetbackStatus.HOLD and self.hold_duration is not None: + if self.hold_duration == 0: + self._hold_until = datetime.max + else: + self._hold_until = self.system_time + timedelta( + hours=self.hold_duration + ) + else: + self._hold_until = None + + def _update_current_schedule(self, oid: OID, from_val: str, to_val: str) -> None: + today = self.system_time.replace(hour=0, minute=0, second=0, microsecond=0) + + def get_dt(oid: OID) -> datetime: + mins_after_midnight = int(self._cache.get(oid, 0)) + result = today + timedelta(minutes=mins_after_midnight) + if result < self.system_time: + result = today + timedelta(days=1, minutes=mins_after_midnight) + return result + + current_schedule = { + ScheduleClass.IN: { + CurrentPeriod.MORNING: get_dt(OID.THERM_PERIOD_START_IN_PERIOD_1), + CurrentPeriod.DAY: get_dt(OID.THERM_PERIOD_START_IN_PERIOD_2), + CurrentPeriod.EVENING: get_dt(OID.THERM_PERIOD_START_IN_PERIOD_3), + CurrentPeriod.NIGHT: get_dt(OID.THERM_PERIOD_START_IN_PERIOD_4), + }, + ScheduleClass.OUT: { + CurrentPeriod.MORNING: get_dt(OID.THERM_PERIOD_START_OUT_PERIOD_1), + CurrentPeriod.DAY: get_dt(OID.THERM_PERIOD_START_OUT_PERIOD_2), + CurrentPeriod.EVENING: get_dt(OID.THERM_PERIOD_START_OUT_PERIOD_3), + CurrentPeriod.NIGHT: get_dt(OID.THERM_PERIOD_START_OUT_PERIOD_4), + }, + ScheduleClass.AWAY: { + CurrentPeriod.MORNING: get_dt(OID.THERM_PERIOD_START_AWAY_PERIOD_1), + CurrentPeriod.DAY: get_dt(OID.THERM_PERIOD_START_AWAY_PERIOD_2), + CurrentPeriod.EVENING: get_dt(OID.THERM_PERIOD_START_AWAY_PERIOD_3), + CurrentPeriod.NIGHT: get_dt(OID.THERM_PERIOD_START_AWAY_PERIOD_4), + }, + } + self._current_schedule = current_schedule + + next_period = None + next_period_start = datetime.max.replace(tzinfo=timezone.utc) + for period, start in current_schedule[self.current_class].items(): + if start > self.system_time and start <= next_period_start: + next_period = period + next_period_start = start + self._next_period = next_period + self._next_period_start = next_period_start diff --git a/custom_components/proliphix_plus/proliphix/const.py b/custom_components/proliphix_plus/proliphix/const.py new file mode 100644 index 0000000..f8959c1 --- /dev/null +++ b/custom_components/proliphix_plus/proliphix/const.py @@ -0,0 +1,409 @@ +"""Constants for interacting with a Proliphix thermostat.""" + +from enum import Enum + +MANUFACTURER = "Proliphix" + + +class OID(Enum): + """Object ID.""" + + # State Related Objects + THERM_HVAC_MODE = "OID4.1.1" + THERM_HVAC_STATE = "OID4.1.2" + THERM_FAN_MODE = "OID4.1.3" + THERM_FAN_STATE = "OID4.1.4" + THERM_SETBACK_HEAT = "OID4.1.5" + THERM_SETBACK_COOL = "OID4.1.6" + THERM_CONFIG_HUMIDITY_COOL = "OID4.2.22" # NT150 only + THERM_SETBACK_STATUS = "OID4.1.9" + THERM_CURRENT_PERIOD = "OID4.1.10" + THERM_ACTIVE_PERIOD = "OID4.1.2" + THERM_CURRENT_CLASS = "OID4.1.11" + + # Alarms + COMMON_ALARM_STATUS_LOW_TEMP_ALARM = "OID1.13.2.1" + COMMON_ALARM_STATUS_HIGH_TEMP_ALARM = "OID1.13.2.2" + COMMON_ALARM_STATUS_FILTER_REMINDER = "OID1.13.2.3" + COMMON_ALARM_STATUS_HIGH_HUMIDITY = "OID1.13.2.4" # NT150 only + THERM_CONFIG_LOW_TEMP_PENDING = "OID4.2.11" + THERM_CONFIG_HIGH_TEMP_PENDING = "OID4.2.13" + THERM_CONFIG_FILTER_REMINDER_PENDING = "OID4.2.9" + THERM_CONFIG_HIGH_HUMIDITY_PENDING = "OID4.2.17" + + # Sensors + THERM_SENSOR_CORRECTION_REMOTE_1 = "OID4.3.4.2" + THERM_SENSOR_CORRECTION_REMOTE_2 = "OID4.3.4.3" + THERM_SENSOR_NAME_REMOTE_1 = "OID4.3.5.2" + THERM_SENSOR_NAME_REMOTE_2 = "OID4.3.5.3" + THERM_SENSOR_STATE_LOCAL = "OID4.3.6.1" + THERM_SENSOR_STATE_REMOTE_1 = "OID4.3.6.2" + THERM_SENSOR_STATE_REMOTE_2 = "OID4.3.6.3" + THERM_SENSOR_AVERAGE_LOCAL = "OID4.3.8.1" + THERM_SENSOR_AVERAGE_REMOTE_1 = "OID4.3.8.2" + THERM_SENSOR_AVERAGE_REMOTE_2 = "OID4.3.8.3" + THERM_SENSOR_TYPE_REMOTE_1 = "OID4.3.9.2" + THERM_SENSOR_TYPE_REMOTE_2 = "OID4.3.9.3" + + # Temperature Related Objects + THERM_AVERAGE_TEMP = "OID4.1.13" + THERM_SENSOR_TEMP_LOCAL = "OID4.3.2.1" + THERM_SENSOR_TEMP_REMOTE_1 = "OID4.3.2.2" + THERM_SENSOR_TEMP_REMOTE_2 = "OID4.3.2.3" + THERM_RELATIVE_HUMIDITY = "OID4.1.14" + + # System Related Objects + SYSTEM_UPTIME = "OID2.1.1" + SYSTEM_TIME_SECS = "OID2.5.1" + COMMON_DEV_NAME = "OID1.2" + SYSTEM_MIM_MODEL_NUMBER = "OID2.7.1" + + # Schedule + THERM_PERIOD_START_IN_PERIOD_1 = "OID4.4.1.3.1.1" + THERM_PERIOD_START_IN_PERIOD_2 = "OID4.4.1.3.1.2" + THERM_PERIOD_START_IN_PERIOD_3 = "OID4.4.1.3.1.3" + THERM_PERIOD_START_IN_PERIOD_4 = "OID4.4.1.3.1.4" + THERM_PERIOD_START_OUT_PERIOD_1 = "OID4.4.1.3.2.1" + THERM_PERIOD_START_OUT_PERIOD_2 = "OID4.4.1.3.2.2" + THERM_PERIOD_START_OUT_PERIOD_3 = "OID4.4.1.3.2.3" + THERM_PERIOD_START_OUT_PERIOD_4 = "OID4.4.1.3.2.4" + THERM_PERIOD_START_AWAY_PERIOD_1 = "OID4.4.1.3.3.1" + THERM_PERIOD_START_AWAY_PERIOD_2 = "OID4.4.1.3.3.2" + THERM_PERIOD_START_AWAY_PERIOD_3 = "OID4.4.1.3.3.3" + THERM_PERIOD_START_AWAY_PERIOD_4 = "OID4.4.1.3.3.4" + THERM_PERIOD_SETBACK_COOL_IN_PERIOD_1 = "OID4.4.1.5.1.1" + THERM_PERIOD_SETBACK_COOL_IN_PERIOD_2 = "OID4.4.1.5.1.2" + THERM_PERIOD_SETBACK_COOL_IN_PERIOD_3 = "OID4.4.1.5.1.3" + THERM_PERIOD_SETBACK_COOL_IN_PERIOD_4 = "OID4.4.1.5.1.4" + THERM_PERIOD_SETBACK_COOL_OUT_PERIOD_1 = "OID4.4.1.5.2.1" + THERM_PERIOD_SETBACK_COOL_OUT_PERIOD_2 = "OID4.4.1.5.2.2" + THERM_PERIOD_SETBACK_COOL_OUT_PERIOD_3 = "OID4.4.1.5.2.3" + THERM_PERIOD_SETBACK_COOL_OUT_PERIOD_4 = "OID4.4.1.5.2.4" + THERM_PERIOD_SETBACK_COOL_AWAY_PERIOD_1 = "OID4.4.1.5.3.1" + THERM_PERIOD_SETBACK_COOL_AWAY_PERIOD_2 = "OID4.4.1.5.3.2" + THERM_PERIOD_SETBACK_COOL_AWAY_PERIOD_3 = "OID4.4.1.5.3.3" + THERM_PERIOD_SETBACK_COOL_AWAY_PERIOD_4 = "OID4.4.1.5.3.4" + THERM_PERIOD_SETBACK_HEAT_IN_PERIOD_1 = "OID4.4.1.4.1.1" + THERM_PERIOD_SETBACK_HEAT_IN_PERIOD_2 = "OID4.4.1.4.1.2" + THERM_PERIOD_SETBACK_HEAT_IN_PERIOD_3 = "OID4.4.1.4.1.3" + THERM_PERIOD_SETBACK_HEAT_IN_PERIOD_4 = "OID4.4.1.4.1.4" + THERM_PERIOD_SETBACK_HEAT_OUT_PERIOD_1 = "OID4.4.1.4.2.1" + THERM_PERIOD_SETBACK_HEAT_OUT_PERIOD_2 = "OID4.4.1.4.2.2" + THERM_PERIOD_SETBACK_HEAT_OUT_PERIOD_3 = "OID4.4.1.4.2.3" + THERM_PERIOD_SETBACK_HEAT_OUT_PERIOD_4 = "OID4.4.1.4.2.4" + THERM_PERIOD_SETBACK_HEAT_AWAY_PERIOD_1 = "OID4.4.1.4.3.1" + THERM_PERIOD_SETBACK_HEAT_AWAY_PERIOD_2 = "OID4.4.1.4.3.2" + THERM_PERIOD_SETBACK_HEAT_AWAY_PERIOD_3 = "OID4.4.1.4.3.3" + THERM_PERIOD_SETBACK_HEAT_AWAY_PERIOD_4 = "OID4.4.1.4.3.4" + THERM_PERIOD_SETBACK_FAN_IN_PERIOD_1 = "OID4.4.1.6.1.1" + THERM_PERIOD_SETBACK_FAN_IN_PERIOD_2 = "OID4.4.1.6.1.2" + THERM_PERIOD_SETBACK_FAN_IN_PERIOD_3 = "OID4.4.1.6.1.3" + THERM_PERIOD_SETBACK_FAN_IN_PERIOD_4 = "OID4.4.1.6.1.4" + THERM_PERIOD_SETBACK_FAN_OUT_PERIOD_1 = "OID4.4.1.6.2.1" + THERM_PERIOD_SETBACK_FAN_OUT_PERIOD_2 = "OID4.4.1.6.2.2" + THERM_PERIOD_SETBACK_FAN_OUT_PERIOD_3 = "OID4.4.1.6.2.3" + THERM_PERIOD_SETBACK_FAN_OUT_PERIOD_4 = "OID4.4.1.6.2.4" + THERM_PERIOD_SETBACK_FAN_AWAY_PERIOD_1 = "OID4.4.1.6.3.1" + THERM_PERIOD_SETBACK_FAN_AWAY_PERIOD_2 = "OID4.4.1.6.3.2" + THERM_PERIOD_SETBACK_FAN_AWAY_PERIOD_3 = "OID4.4.1.6.3.3" + THERM_PERIOD_SETBACK_FAN_AWAY_PERIOD_4 = "OID4.4.1.6.3.4" + THERM_DEFAULT_CLASS_ID_SUNDAY = "OID4.4.3.2.1" + THERM_DEFAULT_CLASS_ID_MONDAY = "OID4.4.3.2.2" + THERM_DEFAULT_CLASS_ID_TUESDAY = "OID4.4.3.2.3" + THERM_DEFAULT_CLASS_ID_WEDNESDAY = "OID4.4.3.2.4" + THERM_DEFAULT_CLASS_ID_THURSDAY = "OID4.4.3.2.5" + THERM_DEFAULT_CLASS_ID_FRIDAY = "OID4.4.3.2.6" + THERM_DEFAULT_CLASS_ID_SATURDAY = "OID4.4.3.2.7" + + # Special Days + THERM_SCHEDULE_SPECIAL_INDEX_1 = "OID4.4.4.1.1" + THERM_SCHEDULE_SPECIAL_START_DAY_1 = "OID4.4.4.2.1" + THERM_SCHEDULE_SPECIAL_MONTH_1 = "OID4.4.4.3.1" + THERM_SCHEDULE_SPECIAL_YEAR_1 = "OID4.4.4.4.1" + THERM_SCHEDULE_SPECIAL_DURATION_1 = "OID4.4.4.5.1" + THERM_SCHEDULE_SPECIAL_CLASS_1 = "OID4.4.4.6.1" + THERM_SCHEDULE_SPECIAL_INDEX_2 = "OID4.4.4.1.2" + THERM_SCHEDULE_SPECIAL_START_DAY_2 = "OID4.4.4.2.2" + THERM_SCHEDULE_SPECIAL_MONTH_2 = "OID4.4.4.3.2" + THERM_SCHEDULE_SPECIAL_YEAR_2 = "OID4.4.4.4.2" + THERM_SCHEDULE_SPECIAL_DURATION_2 = "OID4.4.4.5.2" + THERM_SCHEDULE_SPECIAL_CLASS_2 = "OID4.4.4.6.2" + THERM_SCHEDULE_SPECIAL_INDEX_3 = "OID4.4.4.1.3" + THERM_SCHEDULE_SPECIAL_START_DAY_3 = "OID4.4.4.2.3" + THERM_SCHEDULE_SPECIAL_MONTH_3 = "OID4.4.4.3.3" + THERM_SCHEDULE_SPECIAL_YEAR_3 = "OID4.4.4.4.3" + THERM_SCHEDULE_SPECIAL_DURATION_3 = "OID4.4.4.5.3" + THERM_SCHEDULE_SPECIAL_CLASS_3 = "OID4.4.4.6.3" + THERM_SCHEDULE_SPECIAL_INDEX_4 = "OID4.4.4.1.4" + THERM_SCHEDULE_SPECIAL_START_DAY_4 = "OID4.4.4.2.4" + THERM_SCHEDULE_SPECIAL_MONTH_4 = "OID4.4.4.3.4" + THERM_SCHEDULE_SPECIAL_YEAR_4 = "OID4.4.4.4.4" + THERM_SCHEDULE_SPECIAL_DURATION_4 = "OID4.4.4.5.4" + THERM_SCHEDULE_SPECIAL_CLASS_4 = "OID4.4.4.6.4" + THERM_SCHEDULE_SPECIAL_INDEX_5 = "OID4.4.4.1.5" + THERM_SCHEDULE_SPECIAL_START_DAY_5 = "OID4.4.4.2.5" + THERM_SCHEDULE_SPECIAL_MONTH_5 = "OID4.4.4.3.5" + THERM_SCHEDULE_SPECIAL_YEAR_5 = "OID4.4.4.4.5" + THERM_SCHEDULE_SPECIAL_DURATION_5 = "OID4.4.4.5.5" + THERM_SCHEDULE_SPECIAL_CLASS_5 = "OID4.4.4.6.5" + THERM_SCHEDULE_SPECIAL_INDEX_6 = "OID4.4.4.1.6" + THERM_SCHEDULE_SPECIAL_START_DAY_6 = "OID4.4.4.2.6" + THERM_SCHEDULE_SPECIAL_MONTH_6 = "OID4.4.4.3.6" + THERM_SCHEDULE_SPECIAL_YEAR_6 = "OID4.4.4.4.6" + THERM_SCHEDULE_SPECIAL_DURATION_6 = "OID4.4.4.5.6" + THERM_SCHEDULE_SPECIAL_CLASS_6 = "OID4.4.4.6.6" + THERM_SCHEDULE_SPECIAL_INDEX_7 = "OID4.4.4.1.7" + THERM_SCHEDULE_SPECIAL_START_DAY_7 = "OID4.4.4.2.7" + THERM_SCHEDULE_SPECIAL_MONTH_7 = "OID4.4.4.3.7" + THERM_SCHEDULE_SPECIAL_YEAR_7 = "OID4.4.4.4.7" + THERM_SCHEDULE_SPECIAL_DURATION_7 = "OID4.4.4.5.7" + THERM_SCHEDULE_SPECIAL_CLASS_7 = "OID4.4.4.6.7" + THERM_SCHEDULE_SPECIAL_INDEX_8 = "OID4.4.4.1.8" + THERM_SCHEDULE_SPECIAL_START_DAY_8 = "OID4.4.4.2.8" + THERM_SCHEDULE_SPECIAL_MONTH_8 = "OID4.4.4.3.8" + THERM_SCHEDULE_SPECIAL_YEAR_8 = "OID4.4.4.4.8" + THERM_SCHEDULE_SPECIAL_DURATION_8 = "OID4.4.4.5.8" + THERM_SCHEDULE_SPECIAL_CLASS_8 = "OID4.4.4.6.8" + THERM_SCHEDULE_SPECIAL_INDEX_9 = "OID4.4.4.1.9" + THERM_SCHEDULE_SPECIAL_START_DAY_9 = "OID4.4.4.2.9" + THERM_SCHEDULE_SPECIAL_MONTH_9 = "OID4.4.4.3.9" + THERM_SCHEDULE_SPECIAL_YEAR_9 = "OID4.4.4.4.9" + THERM_SCHEDULE_SPECIAL_DURATION_9 = "OID4.4.4.5.9" + THERM_SCHEDULE_SPECIAL_CLASS_9 = "OID4.4.4.6.9" + THERM_SCHEDULE_SPECIAL_INDEX_10 = "OID4.4.4.1.10" + THERM_SCHEDULE_SPECIAL_START_DAY_10 = "OID4.4.4.2.10" + THERM_SCHEDULE_SPECIAL_MONTH_10 = "OID4.4.4.3.10" + THERM_SCHEDULE_SPECIAL_YEAR_10 = "OID4.4.4.4.10" + THERM_SCHEDULE_SPECIAL_DURATION_10 = "OID4.4.4.5.10" + THERM_SCHEDULE_SPECIAL_CLASS_10 = "OID4.4.4.6.10" + THERM_SCHEDULE_SPECIAL_INDEX_11 = "OID4.4.4.1.11" + THERM_SCHEDULE_SPECIAL_START_DAY_11 = "OID4.4.4.2.11" + THERM_SCHEDULE_SPECIAL_MONTH_11 = "OID4.4.4.3.11" + THERM_SCHEDULE_SPECIAL_YEAR_11 = "OID4.4.4.4.11" + THERM_SCHEDULE_SPECIAL_DURATION_11 = "OID4.4.4.5.11" + THERM_SCHEDULE_SPECIAL_CLASS_11 = "OID4.4.4.6.11" + THERM_SCHEDULE_SPECIAL_INDEX_12 = "OID4.4.4.1.12" + THERM_SCHEDULE_SPECIAL_START_DAY_12 = "OID4.4.4.2.12" + THERM_SCHEDULE_SPECIAL_MONTH_12 = "OID4.4.4.3.12" + THERM_SCHEDULE_SPECIAL_YEAR_12 = "OID4.4.4.4.12" + THERM_SCHEDULE_SPECIAL_DURATION_12 = "OID4.4.4.5.12" + THERM_SCHEDULE_SPECIAL_CLASS_12 = "OID4.4.4.6.12" + THERM_SCHEDULE_SPECIAL_INDEX_13 = "OID4.4.4.1.13" + THERM_SCHEDULE_SPECIAL_START_DAY_13 = "OID4.4.4.2.13" + THERM_SCHEDULE_SPECIAL_MONTH_13 = "OID4.4.4.3.13" + THERM_SCHEDULE_SPECIAL_YEAR_13 = "OID4.4.4.4.13" + THERM_SCHEDULE_SPECIAL_DURATION_13 = "OID4.4.4.5.13" + THERM_SCHEDULE_SPECIAL_CLASS_13 = "OID4.4.4.6.13" + THERM_SCHEDULE_SPECIAL_INDEX_14 = "OID4.4.4.1.14" + THERM_SCHEDULE_SPECIAL_START_DAY_14 = "OID4.4.4.2.14" + THERM_SCHEDULE_SPECIAL_MONTH_14 = "OID4.4.4.3.14" + THERM_SCHEDULE_SPECIAL_YEAR_14 = "OID4.4.4.4.14" + THERM_SCHEDULE_SPECIAL_DURATION_14 = "OID4.4.4.5.14" + THERM_SCHEDULE_SPECIAL_CLASS_14 = "OID4.4.4.6.14" + THERM_SCHEDULE_SPECIAL_INDEX_15 = "OID4.4.4.1.15" + THERM_SCHEDULE_SPECIAL_START_DAY_15 = "OID4.4.4.2.15" + THERM_SCHEDULE_SPECIAL_MONTH_15 = "OID4.4.4.3.15" + THERM_SCHEDULE_SPECIAL_YEAR_15 = "OID4.4.4.4.15" + THERM_SCHEDULE_SPECIAL_DURATION_15 = "OID4.4.4.5.15" + THERM_SCHEDULE_SPECIAL_CLASS_15 = "OID4.4.4.6.15" + THERM_SCHEDULE_SPECIAL_INDEX_16 = "OID4.4.4.1.16" + THERM_SCHEDULE_SPECIAL_START_DAY_16 = "OID4.4.4.2.16" + THERM_SCHEDULE_SPECIAL_MONTH_16 = "OID4.4.4.3.16" + THERM_SCHEDULE_SPECIAL_YEAR_16 = "OID4.4.4.4.16" + THERM_SCHEDULE_SPECIAL_DURATION_16 = "OID4.4.4.5.16" + THERM_SCHEDULE_SPECIAL_CLASS_16 = "OID4.4.4.6.16" + THERM_SCHEDULE_SPECIAL_INDEX_17 = "OID4.4.4.1.17" + THERM_SCHEDULE_SPECIAL_START_DAY_17 = "OID4.4.4.2.17" + THERM_SCHEDULE_SPECIAL_MONTH_17 = "OID4.4.4.3.17" + THERM_SCHEDULE_SPECIAL_YEAR_17 = "OID4.4.4.4.17" + THERM_SCHEDULE_SPECIAL_DURATION_17 = "OID4.4.4.5.17" + THERM_SCHEDULE_SPECIAL_CLASS_17 = "OID4.4.4.6.17" + THERM_SCHEDULE_SPECIAL_INDEX_18 = "OID4.4.4.1.18" + THERM_SCHEDULE_SPECIAL_START_DAY_18 = "OID4.4.4.2.18" + THERM_SCHEDULE_SPECIAL_MONTH_18 = "OID4.4.4.3.18" + THERM_SCHEDULE_SPECIAL_YEAR_18 = "OID4.4.4.4.18" + THERM_SCHEDULE_SPECIAL_DURATION_18 = "OID4.4.4.5.18" + THERM_SCHEDULE_SPECIAL_CLASS_18 = "OID4.4.4.6.18" + THERM_SCHEDULE_SPECIAL_INDEX_19 = "OID4.4.4.1.19" + THERM_SCHEDULE_SPECIAL_START_DAY_19 = "OID4.4.4.2.19" + THERM_SCHEDULE_SPECIAL_MONTH_19 = "OID4.4.4.3.19" + THERM_SCHEDULE_SPECIAL_YEAR_19 = "OID4.4.4.4.19" + THERM_SCHEDULE_SPECIAL_DURATION_19 = "OID4.4.4.5.19" + THERM_SCHEDULE_SPECIAL_CLASS_19 = "OID4.4.4.6.19" + THERM_SCHEDULE_SPECIAL_INDEX_20 = "OID4.4.4.1.20" + THERM_SCHEDULE_SPECIAL_START_DAY_20 = "OID4.4.4.2.20" + THERM_SCHEDULE_SPECIAL_MONTH_20 = "OID4.4.4.3.20" + THERM_SCHEDULE_SPECIAL_YEAR_20 = "OID4.4.4.4.20" + THERM_SCHEDULE_SPECIAL_DURATION_20 = "OID4.4.4.5.20" + THERM_SCHEDULE_SPECIAL_CLASS_20 = "OID4.4.4.6.20" + + # Usage Statistics + THERM_HEAT_1_USAGE = "OID4.5.1" + THERM_HEAT_2_USAGE = "OID4.5.2" + THERM_COOL_1_USAGE = "OID4.5.3" + THERM_COOL_2_USAGE = "OID4.5.4" + THERM_FAN_USAGE = "OID4.5.5" + THERM_LAST_USAGE_RESET = "OID4.5.6" + THERM_EXTERNAL_USAGE = "OID4.5.7" + THERM_USAGE_OPTIONS = "OID4.5.8" + THERM_HEAT_3_USAGE = "OID4.5.9" + + # Reverse Engineeered Objects + FIRMWARE_VERSION = "OID1.1" + SERIAL_NUMBER = "OID1.8" + DISPLAY_CONTRAST = "OID2.2.3" # 20-40, steps of 2 + REMOTE_ACCESS_STATE = "OID1.10.1" + REMOTE_SERVER_ADDRESS = "OID1.10.3" + REMOTE_SERVER_PORT = "OID1.10.4" + REMOTE_SERVER_INTERVAL = "OID1.10.5" # 60-1440, steps of 60 + SITE_NAME = "OID1.10.9" + THERM_HOLD_MODE = "OID4.1.8" + TEMPERATURE_SCALE = "OID4.1.21" + THERM_HOLD_DURATION = "OID4.1.22" + + @classmethod + def get_by_val(cls, value): + """Look up an OID by its value.""" + return cls._value2member_map_.get(value) + + +class HVACMode(Enum): + """HVAC Mode.""" + + OFF = "1" + HEAT = "2" + COOL = "3" + AUTO = "4" + + +class HVACState(Enum): + """HVAC State.""" + + INITIALIZING = "1" + OFF = "2" + HEAT = "3" + HEAT_2 = "4" + HEAT_3 = "5" + COOL = "6" + COOL_2 = "7" + DELAY = "8" + RESET_RELAYS = "9" + + +class FanMode(Enum): + """Fan Mode.""" + + AUTO = "1" + ON = "2" + SCHEDULE = "3" + + +class FanState(Enum): + """Fan State.""" + + INIT = "0" + OFF = "1" + ON = "2" + + +class SetbackStatus(Enum): + """Setback_Status.""" + + NORMAL = "1" + HOLD = "2" + OVERRIDE = "3" + UNKNOWN_1 = "4" + UNKNOWN_2 = "5" + UNKNOWN_3 = "6" + UNKNOWN_4 = "7" + + +class CurrentPeriod(Enum): + """Current Period.""" + + MORNING = "1" + DAY = "2" + EVENING = "3" + NIGHT = "4" + + +class ScheduleClass(Enum): + """Combination of Current Class and Default Class.""" + + IN = "1" + OUT = "2" + AWAY = "3" + OTHER = "4" # Only used by CurrentClass + + +class ActivePeriod(Enum): + """Active Period.""" + + MORNING = "1" + DAY = "2" + EVENING = "3" + NIGHT = "4" + HOLD = "5" + OVERRIDE = "6" + + +class SensorState(Enum): + """Sensor State.""" + + NOT_PRESENT = "0" + DISABLED = "1" + ENABLED = "2" + + +class SensorAverage(Enum): + """Sensor Average.""" + + DISABLED = "1" + ENABLED = "2" + + +class SensorType(Enum): + """Sensor Type.""" + + ANALOG = "1" + THERMISTOR = "2" + + +class CommonAlarmStatus(Enum): + """Commmon Alarm Status.""" + + GREEN = "1" + YELLOW = "2" + RED = "3" + + +class AlarmPendingState(Enum): + """Alarm Pending State.""" + + NO = "1" + YES = "2" + CLEAR = "3" + + +class FanSetback(Enum): + """Fan Setback.""" + + DISABLE = "0" + MINUTES_15 = "15" + MINUTES_30 = "30" + MINUTES_45 = "45" + ON = "60" + + +class ThermUsageOption(Enum): + """Therm Usage Option.""" + + INCLUDE_HEAT = "1" + EXCLUDE_HEAT = "" + + +class TemperatureScale(Enum): + """Temperature Scale.""" + + FARENHEIT = "1" + CELSIUS = "2" diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..87ee43c --- /dev/null +++ b/hacs.json @@ -0,0 +1,7 @@ +{ + "name": "Proliphix Plus", + "content_in_root": false, + "render_readme": true, + "homeassistant": "2024.4.0" + } + \ No newline at end of file From 7b0a23d4662c17622eb52cedc98bba55cc296146 Mon Sep 17 00:00:00 2001 From: MizterB <5458030+MizterB@users.noreply.github.com> Date: Wed, 17 Apr 2024 16:25:27 -0400 Subject: [PATCH 2/7] Config flow and basic sensor --- custom_components/proliphix_plus/__init__.py | 124 ++++- custom_components/proliphix_plus/climate.py | 465 +++++++++++------- .../proliphix_plus/config_flow.py | 100 ++++ custom_components/proliphix_plus/const.py | 3 + .../proliphix_plus/manifest.json | 13 +- .../proliphix_plus/proliphix/api.py | 62 +-- .../proliphix_plus/proliphix/const.py | 2 +- custom_components/proliphix_plus/sensor.py | 91 ++++ 8 files changed, 639 insertions(+), 221 deletions(-) create mode 100644 custom_components/proliphix_plus/config_flow.py create mode 100644 custom_components/proliphix_plus/const.py create mode 100644 custom_components/proliphix_plus/sensor.py diff --git a/custom_components/proliphix_plus/__init__.py b/custom_components/proliphix_plus/__init__.py index 145d0f2..0c064a4 100644 --- a/custom_components/proliphix_plus/__init__.py +++ b/custom_components/proliphix_plus/__init__.py @@ -1 +1,123 @@ -VERSION = "0.2.1" \ No newline at end of file +"""The Proliphix integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN +from .proliphix.api import Proliphix + +# PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = 15 +UPDATE_TIMEOUT = 30 + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Proliphix from a config entry.""" + + coordinator = ProliphixDataUpdateCoordinator( + hass, + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_SSL], + ) + try: + await coordinator.connect() + except Exception as ex: + _LOGGER.error("Error connecting to Proliphix: %s", ex) + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +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): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class ProliphixDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for Proliphix.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int, ssl: bool) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{host}:{port}", + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + session = async_get_clientsession(hass) + self.proliphix: Proliphix = Proliphix( + host=host, port=port, ssl=ssl, session=session + ) + + async def connect(self) -> None: + """Connect to Proliphix.""" + await self.proliphix.connect() + await self.proliphix.refresh_state() + await self.proliphix.refresh_schedule() + + async def _async_update_data(self) -> None: + """Fetch data from Proliphix.""" + try: + await self.proliphix.refresh_state() + except TimeoutError as err: + raise UpdateFailed(f"Timeout while communicating with API: {err}") from err + + +class ProliphixEntity(CoordinatorEntity[ProliphixDataUpdateCoordinator]): + """Base class for Proliphix entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ProliphixDataUpdateCoordinator, + **kwargs, + ) -> None: + """Init Proliphix entity.""" + self.proliphix = coordinator.proliphix + super().__init__(coordinator) + + @property + def unique_id(self) -> str: + """Return the unique id.""" + return f"{self.proliphix.serial}_{self.name}" + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + + return DeviceInfo( + identifiers={(DOMAIN, self.proliphix.serial)}, + serial_number=self.proliphix.serial, + manufacturer=self.proliphix.manufacturer, + model=self.proliphix.model, + name=( + self.proliphix.name if self.proliphix.name else self.proliphix.serial + ), + sw_version=self.proliphix.firmware, + configuration_url=f"{self.proliphix.url}", + ) diff --git a/custom_components/proliphix_plus/climate.py b/custom_components/proliphix_plus/climate.py index f75f753..ad7d9f2 100644 --- a/custom_components/proliphix_plus/climate.py +++ b/custom_components/proliphix_plus/climate.py @@ -3,31 +3,53 @@ This is a custom platform """ -from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA -from homeassistant.components.climate.const import ( - HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, HVAC_MODE_FAN_ONLY, - FAN_AUTO, FAN_ON, - CURRENT_HVAC_OFF, CURRENT_HVAC_HEAT, CURRENT_HVAC_COOL, CURRENT_HVAC_IDLE, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE) -from homeassistant.const import ( - CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, PRECISION_TENTHS, TEMP_FAHRENHEIT, - ATTR_TEMPERATURE) +import datetime import logging +import time +from urllib.parse import urlencode + +import dateutil.parser +import requests import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + FAN_AUTO, + FAN_ON, + HVAC_MODE_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + PRECISION_TENTHS, + TEMP_FAHRENHEIT, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity -import time, datetime, dateutil.parser -import requests -from urllib.parse import urlencode - _LOGGER = logging.getLogger(__name__) -''' +""" Mapping of Proliphix OID values to friendly names -''' +""" # Data to be retrieved whenever polling the thermostat status OID_MAP = { "1.2": "commonDevName", @@ -61,99 +83,82 @@ "4.4.1.3.3.1": "thermPeriodStartAwayMorning", "4.4.1.3.3.2": "thermPeriodStartAwayDay", "4.4.1.3.3.3": "thermPeriodStartAwayEvening", - "4.4.1.3.3.4": "thermPeriodStartAwayNight" + "4.4.1.3.3.4": "thermPeriodStartAwayNight", } -THERM_HVAC_MODE_MAP = { - '1': 'Off', - '2': 'Heat', - '3': 'Cool', - '4': 'Auto' -} +THERM_HVAC_MODE_MAP = {"1": "Off", "2": "Heat", "3": "Cool", "4": "Auto"} THERM_HVAC_STATE_MAP = { - '1': 'Initializing', - '2': 'Off', - '3': 'Heat', - '4': 'Heat2', - '5': 'Heat3', - '6': 'Cool', - '7': 'Cool2', - '8': 'Delay', - '9': 'ResetRelays' + "1": "Initializing", + "2": "Off", + "3": "Heat", + "4": "Heat2", + "5": "Heat3", + "6": "Cool", + "7": "Cool2", + "8": "Delay", + "9": "ResetRelays", } -THERM_FAN_MODE_MAP = { - '1': 'Auto', - '2': 'On', - '3': 'Schedule' -} +THERM_FAN_MODE_MAP = {"1": "Auto", "2": "On", "3": "Schedule"} -THERM_FAN_STATE_MAP = { - '0': 'Init', - '1': 'Off', - '2': 'On' -} +THERM_FAN_STATE_MAP = {"0": "Init", "1": "Off", "2": "On"} THERM_SETBACK_STATUS_MAP = { - '1': 'Normal', - '2': 'Hold', - '3': 'Override', - '4': 'Unknown', - '5': 'Unknown', - '6': 'Unknown', - '7': 'Unknown' + "1": "Normal", + "2": "Hold", + "3": "Override", + "4": "Unknown", + "5": "Unknown", + "6": "Unknown", + "7": "Unknown", } -THERM_CURRENT_PERIOD_MAP = { - '1': 'Morning', - '2': 'Day', - '3': 'Evening', - '4': 'Night' -} +THERM_CURRENT_PERIOD_MAP = {"1": "Morning", "2": "Day", "3": "Evening", "4": "Night"} -THERM_CURRENT_CLASS_MAP = { - '1': 'In', - '2': 'Out', - '3': 'Away', - '4': 'Other' -} +THERM_CURRENT_CLASS_MAP = {"1": "In", "2": "Out", "3": "Away", "4": "Other"} THERM_ACTIVE_PERIOD_MAP = { - '1': 'Morning', - '2': 'Day', - '3': 'Evening', - '4': 'Night', - '5': 'Hold', - '6': 'Override' + "1": "Morning", + "2": "Day", + "3": "Evening", + "4": "Night", + "5": "Hold", + "6": "Override", } -THERM_SENSOR_STATE_MAP = { - '0': 'NotPresent', - '1': 'Disabled', - '2': 'Enabled' -} +THERM_SENSOR_STATE_MAP = {"0": "NotPresent", "1": "Disabled", "2": "Enabled"} FAN_SCHEDULE = "Schedule" # Preset modes supported by this component -PRESET_SCHEDULE = "Schedule" # Restore the normal daily schedule -PRESET_IN = "In" # Switch to 'In' schedule -PRESET_OUT = "Out" # Switch to 'Out' schedule -PRESET_AWAY = "Away" # Switch to 'Away' schedule -PRESET_ECO = "Eco" # Switch to 'Eco' mode -PRESET_MANUAL_TEMP = "Override" # Override currently scheduled activity until the next schedule change -PRESET_MANUAL_PERM = "Hold" # Override the schedule indefinitely - -PRESET_MODES = [PRESET_IN, PRESET_OUT, PRESET_AWAY, - PRESET_MANUAL_TEMP, PRESET_MANUAL_PERM] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=80): cv.port, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, -}) +PRESET_SCHEDULE = "Schedule" # Restore the normal daily schedule +PRESET_IN = "In" # Switch to 'In' schedule +PRESET_OUT = "Out" # Switch to 'Out' schedule +PRESET_AWAY = "Away" # Switch to 'Away' schedule +PRESET_ECO = "Eco" # Switch to 'Eco' mode +PRESET_MANUAL_TEMP = ( + "Override" # Override currently scheduled activity until the next schedule change +) +PRESET_MANUAL_PERM = "Hold" # Override the schedule indefinitely + +PRESET_MODES = [ + PRESET_IN, + PRESET_OUT, + PRESET_AWAY, + PRESET_MANUAL_TEMP, + PRESET_MANUAL_PERM, +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=80): cv.port, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Proliphix thermostat""" @@ -165,21 +170,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([ProliphixThermostat(hostname, port, username, password)]) -class ProliphixDevice(): - +class ProliphixDevice: def __init__(self, hostname, port, username, password): self._hostname = hostname self._port = port self._username = username self._password = password - ''' + """ Retrieve one or many values from the device. Argument 'oids' can be any of the following: - A single OID string ("4.3.2.1") - A list of OID strings (["4.3.2.1", "1.2"]) - A dict of OID strings, mapped to friendly names ({"4.3.2.1" : "thermSensorTempLocal", "1.2" : "commonDevName"}) - Return value is a dict of OID mapped to its value. The OID will be be converted to the friendly name if it was provided. - ''' + Return value is a dict of OID mapped to its value. The OID will be be converted to the friendly name if it was provided. + """ def get(self, oids): valueDict = {} @@ -197,11 +201,13 @@ def get(self, oids): url = "http://%s:%s/get" % (self._hostname, str(self._port)) data = "&".join([("OID" + oid + "=") for oid in oidList]) try: - response = requests.post(url, auth=(self._username, self._password), data=data) - for pair in response.text.split('&'): + response = requests.post( + url, auth=(self._username, self._password), data=data + ) + for pair in response.text.split("&"): if len(pair) == 0: continue - oid, value = pair.split('=') + oid, value = pair.split("=") oid = oid[3:] # Strip 'OID' from the key # Remap code to friendly name if remapOIDNames: @@ -209,15 +215,15 @@ def get(self, oids): valueDict[oid] = value except requests.exceptions.ConnectionError: _LOGGER.error("Unable to connect to {}".format(url)) - + return valueDict - ''' + """ Set values on the device. Argument 'oids' can be any of the following: - Dict of OIDs mapped to values ({"4.1.1" : "1"}) - Dict of querystring-style OIDs mapped to values ({"OID4.1.1" : "1"}) - Dict of friendly names mapped to values ({"thermHvacMode" : "1"}) - ''' + """ def set(self, oids): valueDict = {} @@ -349,29 +355,52 @@ def update(self): self._holdDuration = int(self._data.get("thermHoldDuration", 0)) self._classPeriodSchedule = { "In": { - "Morning": self._computeScheduleDateTime(self._data.get("thermPeriodStartInMorning", 0)), - "Day": self._computeScheduleDateTime(self._data.get("thermPeriodStartInDay", 0)), - "Evening": self._computeScheduleDateTime(self._data.get("thermPeriodStartInEvening", 0)), - "Night": self._computeScheduleDateTime(self._data.get("thermPeriodStartInNight", 0)) + "Morning": self._computeScheduleDateTime( + self._data.get("thermPeriodStartInMorning", 0) + ), + "Day": self._computeScheduleDateTime( + self._data.get("thermPeriodStartInDay", 0) + ), + "Evening": self._computeScheduleDateTime( + self._data.get("thermPeriodStartInEvening", 0) + ), + "Night": self._computeScheduleDateTime( + self._data.get("thermPeriodStartInNight", 0) + ), }, "Out": { - "Morning": self._computeScheduleDateTime(self._data.get("thermPeriodStartOutMorning", 0)), - "Day": self._computeScheduleDateTime(self._data.get("thermPeriodStartOutDay", 0)), - "Evening": self._computeScheduleDateTime(self._data.get("thermPeriodStartOutEvening", 0)), - "Night": self._computeScheduleDateTime(self._data.get("thermPeriodStartOutNight", 0)) + "Morning": self._computeScheduleDateTime( + self._data.get("thermPeriodStartOutMorning", 0) + ), + "Day": self._computeScheduleDateTime( + self._data.get("thermPeriodStartOutDay", 0) + ), + "Evening": self._computeScheduleDateTime( + self._data.get("thermPeriodStartOutEvening", 0) + ), + "Night": self._computeScheduleDateTime( + self._data.get("thermPeriodStartOutNight", 0) + ), }, "Away": { - "Morning": self._computeScheduleDateTime(self._data.get("thermPeriodStartAwayMorning", 0)), - "Day": self._computeScheduleDateTime(self._data.get("thermPeriodStartAwayDay", 0)), - "Evening": self._computeScheduleDateTime(self._data.get("thermPeriodStartAwayEvening", 0)), - "Night": self._computeScheduleDateTime(self._data.get("thermPeriodStartAwayNight", 0)) - } + "Morning": self._computeScheduleDateTime( + self._data.get("thermPeriodStartAwayMorning", 0) + ), + "Day": self._computeScheduleDateTime( + self._data.get("thermPeriodStartAwayDay", 0) + ), + "Evening": self._computeScheduleDateTime( + self._data.get("thermPeriodStartAwayEvening", 0) + ), + "Night": self._computeScheduleDateTime( + self._data.get("thermPeriodStartAwayNight", 0) + ), + }, } self._nextPeriod, self._nextPeriodStart = self._getNextPeriodSchedule() self._holdUntil = self._getHoldUntil() self._scheduleSummary = self._getScheduleSummary() - # Compute current preset based on setback status and current class # Normal if self._setbackStatus == "1": @@ -417,17 +446,21 @@ def state_attributes(self): "hold_hours": self._holdDuration, "fan_state": THERM_FAN_STATE_MAP.get(self._fanMode), "setback_status": THERM_SETBACK_STATUS_MAP.get(self._setbackStatus), - "next_period" : self._nextPeriod, - "next_period_start" : self._nextPeriodStart, + "next_period": self._nextPeriod, + "next_period_start": self._nextPeriodStart, "hold_until": self._holdUntil, - "schedule_summary": self._scheduleSummary + "schedule_summary": self._scheduleSummary, } if self._model in ["NT150", "NT160"]: custom_attributes["humidity"] = self._relativeHumidity if self._model not in ["NT10"]: - custom_attributes["remote_sensor_state"] = THERM_SENSOR_STATE_MAP.get(self._remoteSensorState) + custom_attributes["remote_sensor_state"] = THERM_SENSOR_STATE_MAP.get( + self._remoteSensorState + ) if THERM_SENSOR_STATE_MAP.get(self._remoteSensorState) == "Enabled": - custom_attributes["remote_sensor_temperature"] = float(self._remoteSensorTemperature) / 10 + custom_attributes["remote_sensor_temperature"] = ( + float(self._remoteSensorTemperature) / 10 + ) attributes = {} attributes.update(default_attributes) attributes.update(custom_attributes) @@ -457,13 +490,13 @@ def hvac_mode(self): Need to be one of HVAC_MODE_*. """ # TODO: Re-enable Fan mode - if self._hvacMode == '2': + if self._hvacMode == "2": return HVAC_MODE_HEAT - elif self._hvacMode == '3': + elif self._hvacMode == "3": return HVAC_MODE_COOL - elif self._hvacMode == '4': + elif self._hvacMode == "4": return HVAC_MODE_HEAT_COOL - elif self._hvacMode == '1': + elif self._hvacMode == "1": return HVAC_MODE_OFF else: return HVAC_MODE_OFF @@ -484,11 +517,11 @@ def hvac_action(self): # TODO: Add logic for fan if self.hvac_mode == HVAC_MODE_OFF: return CURRENT_HVAC_OFF - elif self._hvacState in ['1', '2', '8', '9']: + elif self._hvacState in ["1", "2", "8", "9"]: return CURRENT_HVAC_IDLE - elif self._hvacState in ['3', '4', '5']: + elif self._hvacState in ["3", "4", "5"]: return CURRENT_HVAC_HEAT - elif self._hvacState in ['6', '7']: + elif self._hvacState in ["6", "7"]: return CURRENT_HVAC_COOL else: return CURRENT_HVAC_IDLE @@ -563,7 +596,6 @@ def fan_mode(self): elif self._fanMode == "3": return FAN_SCHEDULE - @property def fan_modes(self): """Return the list of available fan modes. @@ -598,7 +630,7 @@ def set_temperature(self, **kwargs): else: return # Enable a temporary override at the selected temperature - self._device.set({targetSetback: targetTemp, "thermSetbackStatus": '2'}) + self._device.set({targetSetback: targetTemp, "thermSetbackStatus": "2"}) def set_humidity(self, humidity): """Set new target humidity.""" @@ -631,20 +663,47 @@ def set_preset_mode(self, preset_mode): # Set the normal weekly schedule to In/Out/Away based on preset if preset_mode == PRESET_IN: self._device.set( - {"4.1.9": "1", "4.4.3.2.1": "1", "4.4.3.2.2": "1", "4.4.3.2.3": "1", "4.4.3.2.4": "1", - "4.4.3.2.5": "1", "4.4.3.2.6": "1", "4.4.3.2.7": "1"}) + { + "4.1.9": "1", + "4.4.3.2.1": "1", + "4.4.3.2.2": "1", + "4.4.3.2.3": "1", + "4.4.3.2.4": "1", + "4.4.3.2.5": "1", + "4.4.3.2.6": "1", + "4.4.3.2.7": "1", + } + ) elif preset_mode == PRESET_OUT: self._device.set( - {"4.1.9": "1", "4.4.3.2.1": "2", "4.4.3.2.2": "2", "4.4.3.2.3": "2", "4.4.3.2.4": "2", - "4.4.3.2.5": "2", "4.4.3.2.6": "2", "4.4.3.2.7": "2"}) + { + "4.1.9": "1", + "4.4.3.2.1": "2", + "4.4.3.2.2": "2", + "4.4.3.2.3": "2", + "4.4.3.2.4": "2", + "4.4.3.2.5": "2", + "4.4.3.2.6": "2", + "4.4.3.2.7": "2", + } + ) elif preset_mode == PRESET_AWAY: self._device.set( - {"4.1.9": "1", "4.4.3.2.1": "3", "4.4.3.2.2": "3", "4.4.3.2.3": "3", "4.4.3.2.4": "3", - "4.4.3.2.5": "3", "4.4.3.2.6": "3", "4.4.3.2.7": "3"}) + { + "4.1.9": "1", + "4.4.3.2.1": "3", + "4.4.3.2.2": "3", + "4.4.3.2.3": "3", + "4.4.3.2.4": "3", + "4.4.3.2.5": "3", + "4.4.3.2.6": "3", + "4.4.3.2.7": "3", + } + ) elif preset_mode == PRESET_MANUAL_TEMP: - self._set_hold_mode('Override') + self._set_hold_mode("Override") elif preset_mode == PRESET_MANUAL_PERM: - self._set_hold_mode('Hold') + self._set_hold_mode("Hold") elif preset_mode == PRESET_ECO: self._turnOnEcoMode() else: @@ -662,7 +721,7 @@ def turn_aux_heat_off(self): @property def supported_features(self): """Return the list of supported features.""" - baseline_features = (SUPPORT_FAN_MODE | SUPPORT_PRESET_MODE) + baseline_features = SUPPORT_FAN_MODE | SUPPORT_PRESET_MODE if self.hvac_mode == HVAC_MODE_HEAT_COOL: return baseline_features | SUPPORT_TARGET_TEMPERATURE_RANGE elif self.hvac_mode in [HVAC_MODE_HEAT, HVAC_MODE_COOL]: @@ -693,17 +752,33 @@ def max_humidity(self): def _turn_away_mode_on(self): """Turn away mode on by setting the default weekly schedule for each day to OUT.""" self._device.set( - {"4.4.3.2.1": "2", "4.4.3.2.2": "2", "4.4.3.2.3": "2", "4.4.3.2.4": "2", "4.4.3.2.5": "2", "4.4.3.2.6": "2", - "4.4.3.2.7": "2"}) - time.sleep(1) # Allow time for change to process, then force update + { + "4.4.3.2.1": "2", + "4.4.3.2.2": "2", + "4.4.3.2.3": "2", + "4.4.3.2.4": "2", + "4.4.3.2.5": "2", + "4.4.3.2.6": "2", + "4.4.3.2.7": "2", + } + ) + time.sleep(1) # Allow time for change to process, then force update self.update() def _turn_away_mode_off(self): """Turn away mode off by setting the default weekly schedule for each day to IN.""" self._device.set( - {"4.4.3.2.1": "1", "4.4.3.2.2": "1", "4.4.3.2.3": "1", "4.4.3.2.4": "1", "4.4.3.2.5": "1", "4.4.3.2.6": "1", - "4.4.3.2.7": "1"}) - time.sleep(1) # Allow time for change to process, then force update + { + "4.4.3.2.1": "1", + "4.4.3.2.2": "1", + "4.4.3.2.3": "1", + "4.4.3.2.4": "1", + "4.4.3.2.5": "1", + "4.4.3.2.6": "1", + "4.4.3.2.7": "1", + } + ) + time.sleep(1) # Allow time for change to process, then force update self.update() def _set_hold_mode(self, hold): @@ -711,14 +786,16 @@ def _set_hold_mode(self, hold): # TODO: this can be enhanced for value, label in THERM_SETBACK_STATUS_MAP.items(): if label == hold: - self._device.set({"thermSetbackStatus": value, "thermHoldDuration": "0"}) + self._device.set( + {"thermSetbackStatus": value, "thermHoldDuration": "0"} + ) def _restore_last_state(self, restoredState): - ''' + """ Use this to restore custom computed attributes, such as holdUntil, scheduleSummary :param restoredState: :return: - ''' + """ if not restoredState: return @@ -728,20 +805,27 @@ def _computeScheduleDateTime(self, minsAfterMidnight=0): if minsAfterMidnight is None: minsAfterMidnight = 0 minsAfterMidnight = int(minsAfterMidnight) - result = datetime.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta( - minutes=minsAfterMidnight) + result = datetime.datetime.today().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + datetime.timedelta(minutes=minsAfterMidnight) # If the time is in the past, result should be same time tomorrow if result < datetime.datetime.now(): - result = datetime.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0) + datetime.timedelta( - days=1, minutes=minsAfterMidnight) + result = datetime.datetime.today().replace( + hour=0, minute=0, second=0, microsecond=0 + ) + datetime.timedelta(days=1, minutes=minsAfterMidnight) return result def _getNextPeriodSchedule(self): currentClass = THERM_CURRENT_CLASS_MAP.get(self._currentClass, 1) nextPeriod = "Unknown" nextPeriodStart = datetime.datetime.max - for period, startDateTime in self._classPeriodSchedule.get(currentClass, {}).items(): - if startDateTime >= datetime.datetime.now() and startDateTime <= nextPeriodStart: + for period, startDateTime in self._classPeriodSchedule.get( + currentClass, {} + ).items(): + if ( + startDateTime >= datetime.datetime.now() + and startDateTime <= nextPeriodStart + ): nextPeriod = period nextPeriodStart = startDateTime return (nextPeriod, nextPeriodStart) @@ -755,41 +839,59 @@ def _fixClockDrift(self): now -= time.altzone else: now -= time.timezone - drift = now - int(self._data.get('systemTimeSecs', now)) + drift = now - int(self._data.get("systemTimeSecs", now)) if drift > 60: - _LOGGER.warning("{} {} time drifted by {} seconds, resetting".format(self._siteName, self._deviceName, drift)) + _LOGGER.warning( + "{} {} time drifted by {} seconds, resetting".format( + self._siteName, self._deviceName, drift + ) + ) self._device.set({"systemTimeSecs": set_now}) def _setHoldDuration(self, hours): currentHoldDuration = int(self._holdDuration) if hours != currentHoldDuration: self._device.set({"thermHoldDuration": int(hours)}) - strCurrent = "indefinite" if currentHoldDuration == 0 else "{} hours".format(currentHoldDuration) + strCurrent = ( + "indefinite" + if currentHoldDuration == 0 + else "{} hours".format(currentHoldDuration) + ) strNew = "indefinite" if hours == 0 else "{} hours".format(hours) - _LOGGER.info("{} {} hold duration changed from {} to {}".format(self._siteName, self._deviceName, strCurrent, strNew)) + _LOGGER.info( + "{} {} hold duration changed from {} to {}".format( + self._siteName, self._deviceName, strCurrent, strNew + ) + ) def _getScheduleSummary(self): summary = "" setbackStatusLabel = THERM_SETBACK_STATUS_MAP.get(self._setbackStatus, "") - if self._hvacMode == '1': # Off + if self._hvacMode == "1": # Off return "Off" - #elif self._isEcoModeOn(): + # elif self._isEcoModeOn(): # summary = "Eco mode" elif setbackStatusLabel == "Normal": - summary = "{} - {} until {}".format(THERM_CURRENT_CLASS_MAP.get(self._currentClass), - THERM_CURRENT_PERIOD_MAP.get(self._currentPeriod), - self._relativeDateTime(self._getNextPeriodSchedule()[1])) + summary = "{} - {} until {}".format( + THERM_CURRENT_CLASS_MAP.get(self._currentClass), + THERM_CURRENT_PERIOD_MAP.get(self._currentPeriod), + self._relativeDateTime(self._getNextPeriodSchedule()[1]), + ) elif setbackStatusLabel == "Hold": if int(self._holdDuration) == 0: summary = "Hold indefinitely" else: - summary = "Hold until {}".format(self._relativeDateTime(self._holdUntil)) + summary = "Hold until {}".format( + self._relativeDateTime(self._holdUntil) + ) elif setbackStatusLabel == "Override": - summary = "Override {} - {} until {}".format(THERM_CURRENT_CLASS_MAP.get(self._currentClass), - THERM_CURRENT_PERIOD_MAP.get(self._currentPeriod), - self._relativeDateTime(self._getNextPeriodSchedule()[1])) + summary = "Override {} - {} until {}".format( + THERM_CURRENT_CLASS_MAP.get(self._currentClass), + THERM_CURRENT_PERIOD_MAP.get(self._currentPeriod), + self._relativeDateTime(self._getNextPeriodSchedule()[1]), + ) return summary def _relativeDateTime(self, dt): @@ -799,7 +901,7 @@ def _relativeDateTime(self, dt): if dt.date() == datetime.date.today(): dayString = "" elif dt.date() == datetime.date.today() + datetime.timedelta(days=1): - dayString ="tomorrow" + dayString = "tomorrow" elif dt.date() == datetime.date.today() + datetime.timedelta(days=-1): dayString = "yesterday" else: @@ -811,35 +913,46 @@ def _relativeDateTime(self, dt): else: dayString = dt.strftime("%b %d") if len(dayString) > 0: - result = "{}, {}".format(dayString, dt.strftime("%I:%M%p").lstrip('0')) + result = "{}, {}".format(dayString, dt.strftime("%I:%M%p").lstrip("0")) else: - result = "{}".format(dt.strftime("%I:%M%p").lstrip('0')) + result = "{}".format(dt.strftime("%I:%M%p").lstrip("0")) return result.capitalize() def _getHoldUntil(self): holdUntil = None if THERM_SETBACK_STATUS_MAP.get(self._data.get("thermSetbackStatus")) == "Hold": # If hold was just enabled, compute a new 'until' datetime - if self._data.get("thermSetbackStatus") != self._previousData.get("thermSetbackStatus"): + if self._data.get("thermSetbackStatus") != self._previousData.get( + "thermSetbackStatus" + ): if int(self._holdDuration) == 0: holdUntil = "indefinitely" else: - holdUntil = datetime.datetime.now() + datetime.timedelta(hours=int(self._holdDuration)) + holdUntil = datetime.datetime.now() + datetime.timedelta( + hours=int(self._holdDuration) + ) else: holdUntil = None return holdUntil def _isEcoModeOn(self): if self._configEcoModeEnabled: - if int(self._setbackHeat)/10 == self._configEcoModeTemperature and \ - THERM_SETBACK_STATUS_MAP.get(self._setbackStatus, "") == "Hold" and \ - self._holdDuration == 0: + if ( + int(self._setbackHeat) / 10 == self._configEcoModeTemperature + and THERM_SETBACK_STATUS_MAP.get(self._setbackStatus, "") == "Hold" + and self._holdDuration == 0 + ): return True else: return False def _turnOnEcoMode(self): # Heat with indefinite hold at eco temperature - self._device.set({"thermHvacMode": "2", "thermHoldDuration": "0", - "thermSetbackStatus": "2", - "thermSetbackHeat": str(self._configEcoModeTemperature * 10)}) \ No newline at end of file + self._device.set( + { + "thermHvacMode": "2", + "thermHoldDuration": "0", + "thermSetbackStatus": "2", + "thermSetbackHeat": str(self._configEcoModeTemperature * 10), + } + ) diff --git a/custom_components/proliphix_plus/config_flow.py b/custom_components/proliphix_plus/config_flow.py new file mode 100644 index 0000000..a204024 --- /dev/null +++ b/custom_components/proliphix_plus/config_flow.py @@ -0,0 +1,100 @@ +"""Config flow for Proliphix integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .proliphix.api import Proliphix + +_LOGGER = logging.getLogger(__name__) + +CONNECTION_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=80): int, + vol.Required(CONF_USERNAME, default="admin"): str, + vol.Required(CONF_PASSWORD, default="admin"): str, + vol.Required(CONF_SSL, default=False): bool, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + session = async_get_clientsession(hass) + proliphix = Proliphix( + host=data[CONF_HOST], + port=data[CONF_PORT], + username=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + ssl=data[CONF_SSL], + session=session, + ) + try: + await proliphix.connect() + except ConnectionError as connection_error: + _LOGGER.error("Error connecting to Proliphix: %s", connection_error) + raise CannotConnect from connection_error + + config_entry_name = f"{proliphix.site_name}: " if proliphix.site_name else "" + config_entry_name += proliphix.name if proliphix.name else proliphix.serial_number + return {"config_entry_name": config_entry_name} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Proliphix.""" + + VERSION = 1 + + 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: + unique_id = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=info["config_entry_name"], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=CONNECTION_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/custom_components/proliphix_plus/const.py b/custom_components/proliphix_plus/const.py new file mode 100644 index 0000000..d392db8 --- /dev/null +++ b/custom_components/proliphix_plus/const.py @@ -0,0 +1,3 @@ +"""Constants for the Proliphix integration.""" + +DOMAIN = "proliphix_plus" diff --git a/custom_components/proliphix_plus/manifest.json b/custom_components/proliphix_plus/manifest.json index cf36bd2..f822bbc 100644 --- a/custom_components/proliphix_plus/manifest.json +++ b/custom_components/proliphix_plus/manifest.json @@ -1,9 +1,14 @@ { "domain": "proliphix_plus", "name": "Proliphix Plus", + "codeowners": ["@MizterB"], + "config_flow": true, + "dependencies": [], "documentation": "https://github.com/MizterB/homeassistant-proliphix-plus", + "homekit": {}, + "iot_class": "local_polling", "requirements": [], - "dependencies": [], - "codeowners": ["@MizterB"], - "version": "2024.4" -} + "ssdp": [], + "zeroconf": [], + "version": "2024.4.0" +} \ No newline at end of file diff --git a/custom_components/proliphix_plus/proliphix/api.py b/custom_components/proliphix_plus/proliphix/api.py index e2fd27a..103c74a 100644 --- a/custom_components/proliphix_plus/proliphix/api.py +++ b/custom_components/proliphix_plus/proliphix/api.py @@ -1,9 +1,8 @@ """Define a base client for interacting with a Proliphix thermostat.""" import asyncio -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import logging -from typing import Optional from urllib.parse import parse_qs, urlencode from aiohttp import BasicAuth, ClientSession @@ -96,7 +95,7 @@ def __init__( password: str = "admin", ssl: bool = False, *, - session: Optional[ClientSession] = None, + session: ClientSession | None = None, ) -> None: """Initialize the Proliphix object.""" self.host: str = host @@ -120,7 +119,7 @@ def __init__( [OID.THERM_SETBACK_STATUS, OID.THERM_HOLD_DURATION], self._update_hold_until ) self._register_change_callback( - OIDS_SCHEDULE + [OID.SYSTEM_TIME_SECS], self._update_current_schedule + [*OIDS_SCHEDULE, OID.SYSTEM_TIME_SECS], self._update_current_schedule ) @property @@ -210,7 +209,7 @@ async def connect(self) -> None: async with asyncio.timeout(CONNECT_TIMEOUT): _LOGGER.debug("Connecting to Proliphix thermostat at %s", self.url) await self.get_oids(OIDS_CORE) - except asyncio.TimeoutError as e: + except TimeoutError as e: _LOGGER.error( "Failed to connect to Proliphix thermostat at %s after %s seconds", self.url, @@ -224,7 +223,7 @@ async def refresh_state(self) -> None: async with asyncio.timeout(CONNECT_TIMEOUT): _LOGGER.debug("Refreshing state attributes") await self.get_oids(OIDS_STATE) - except asyncio.TimeoutError as e: + except TimeoutError as e: _LOGGER.error( "Failed to refresh state attributes after %s seconds", CONNECT_TIMEOUT, @@ -237,7 +236,7 @@ async def refresh_schedule(self) -> None: async with asyncio.timeout(CONNECT_TIMEOUT): _LOGGER.debug("Refreshing schedule attributes") await self.get_oids(OIDS_SCHEDULE) - except asyncio.TimeoutError as e: + except TimeoutError as e: _LOGGER.error( "Failed to refresh schedule attributes after %s seconds", CONNECT_TIMEOUT, @@ -295,8 +294,7 @@ def temperature_scale(self) -> TemperatureScale | None: val = self._cache.get(OID.TEMPERATURE_SCALE) if not val: return None - scale = next((s for s in TemperatureScale if s.value == val), None) - return scale + return next((s for s in TemperatureScale if s.value == val), None) @property def system_time(self) -> datetime | None: @@ -305,11 +303,10 @@ def system_time(self) -> datetime | None: if not val: return None # The system time is in local time, but without offset data - systime = datetime.fromtimestamp(int(val), timezone.utc) + systime = datetime.fromtimestamp(int(val), UTC) # Prevent value conversions by overrding the timezone to the correct local one local_tzinfo = datetime.now().astimezone().tzinfo - systime_local = systime.replace(tzinfo=local_tzinfo) - return systime_local + return systime.replace(tzinfo=local_tzinfo) @property def temperature_local(self) -> float | None: @@ -317,8 +314,7 @@ def temperature_local(self) -> float | None: val = self._cache.get(OID.THERM_SENSOR_TEMP_LOCAL) if not val: return None - temp = float(val) / 10 - return temp + return float(val) / 10 @property def temperature_remote_1(self) -> float | None: @@ -326,8 +322,7 @@ def temperature_remote_1(self) -> float | None: val = self._cache.get(OID.THERM_SENSOR_TEMP_REMOTE_1) if not val or val == "FAILED5": return None - temp = float(val) / 10 - return temp + return float(val) / 10 @property def temperature_remote_2(self) -> float | None: @@ -335,8 +330,7 @@ def temperature_remote_2(self) -> float | None: val = self._cache.get(OID.THERM_SENSOR_TEMP_REMOTE_2) if not val or val == "FAILED5": return None - temp = float(val) / 10 - return temp + return float(val) / 10 @property def hvac_mode(self) -> HVACMode | None: @@ -344,8 +338,7 @@ def hvac_mode(self) -> HVACMode | None: val = self._cache.get(OID.THERM_HVAC_MODE) if not val: return None - mode = next((m for m in HVACMode if m.value == val), None) - return mode + return next((m for m in HVACMode if m.value == val), None) @property def hvac_state(self) -> HVACState | None: @@ -353,8 +346,7 @@ def hvac_state(self) -> HVACState | None: val = self._cache.get(OID.THERM_HVAC_STATE) if not val: return None - state = next((s for s in HVACState if s.value == val), None) - return state + return next((s for s in HVACState if s.value == val), None) @property def fan_mode(self) -> FanMode | None: @@ -362,8 +354,7 @@ def fan_mode(self) -> FanMode | None: val = self._cache.get(OID.THERM_FAN_MODE) if not val: return None - mode = next((m for m in FanMode if m.value == val), None) - return mode + return next((m for m in FanMode if m.value == val), None) @property def fan_state(self) -> FanState | None: @@ -371,8 +362,7 @@ def fan_state(self) -> FanState | None: val = self._cache.get(OID.THERM_FAN_STATE) if not val: return None - state = next((f for f in FanState if f.value == val), None) - return state + return next((f for f in FanState if f.value == val), None) @property def setback_heat(self) -> float | None: @@ -380,8 +370,7 @@ def setback_heat(self) -> float | None: val = self._cache.get(OID.THERM_SETBACK_HEAT) if not val: return None - temp = float(val) / 10 - return temp + return float(val) / 10 @property def setback_cool(self) -> float | None: @@ -389,8 +378,7 @@ def setback_cool(self) -> float | None: val = self._cache.get(OID.THERM_SETBACK_COOL) if not val: return None - temp = float(val) / 10 - return temp + return float(val) / 10 @property def setback_status(self) -> SetbackStatus | None: @@ -398,8 +386,7 @@ def setback_status(self) -> SetbackStatus | None: val = self._cache.get(OID.THERM_SETBACK_STATUS) if not val: return None - status = next((s for s in SetbackStatus if s.value == val), None) - return status + return next((s for s in SetbackStatus if s.value == val), None) @property def current_period(self) -> CurrentPeriod | None: @@ -407,8 +394,7 @@ def current_period(self) -> CurrentPeriod | None: val = self._cache.get(OID.THERM_CURRENT_PERIOD) if not val: return None - scale = next((p for p in CurrentPeriod if p.value == val), None) - return scale + return next((p for p in CurrentPeriod if p.value == val), None) @property def current_class(self) -> ScheduleClass | None: @@ -416,8 +402,7 @@ def current_class(self) -> ScheduleClass | None: val = self._cache.get(OID.THERM_CURRENT_CLASS) if not val: return None - scale = next((c for c in ScheduleClass if c.value == val), None) - return scale + return next((c for c in ScheduleClass if c.value == val), None) @property def relative_humidity(self) -> float | None: @@ -427,8 +412,7 @@ def relative_humidity(self) -> float | None: val = self._cache.get(OID.THERM_RELATIVE_HUMIDITY) if not val: return None - humidity = float(val) / 10 - return humidity + return float(val) / 10 @property def hold_duration(self) -> int | None: @@ -504,7 +488,7 @@ def get_dt(oid: OID) -> datetime: self._current_schedule = current_schedule next_period = None - next_period_start = datetime.max.replace(tzinfo=timezone.utc) + next_period_start = datetime.max.replace(tzinfo=UTC) for period, start in current_schedule[self.current_class].items(): if start > self.system_time and start <= next_period_start: next_period = period diff --git a/custom_components/proliphix_plus/proliphix/const.py b/custom_components/proliphix_plus/proliphix/const.py index f8959c1..c081b2f 100644 --- a/custom_components/proliphix_plus/proliphix/const.py +++ b/custom_components/proliphix_plus/proliphix/const.py @@ -18,7 +18,7 @@ class OID(Enum): THERM_CONFIG_HUMIDITY_COOL = "OID4.2.22" # NT150 only THERM_SETBACK_STATUS = "OID4.1.9" THERM_CURRENT_PERIOD = "OID4.1.10" - THERM_ACTIVE_PERIOD = "OID4.1.2" + THERM_ACTIVE_PERIOD = "OID4.1.12" THERM_CURRENT_CLASS = "OID4.1.11" # Alarms diff --git a/custom_components/proliphix_plus/sensor.py b/custom_components/proliphix_plus/sensor.py new file mode 100644 index 0000000..cc4dd90 --- /dev/null +++ b/custom_components/proliphix_plus/sensor.py @@ -0,0 +1,91 @@ +"""Sensors for Proliphix.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import ProliphixDataUpdateCoordinator, ProliphixEntity +from .const import DOMAIN +from .proliphix.const import TemperatureScale + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ProliphixSensorDescriptionMixin: + """Mixin for Proliphix sensor.""" + + value_fn: Callable[[ProliphixEntity], StateType] + + +@dataclass(frozen=True) +class ProliphixSensorDescription( + SensorEntityDescription, ProliphixSensorDescriptionMixin +): + """Class describing Proliphix sensor entities.""" + + +SENSORS: tuple[ProliphixSensorDescription, ...] = ( + ProliphixSensorDescription( + key="temperature_local", + name="Temperature", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.proliphix.temperature_local, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Proliphix sensors from config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [ + ProliphixSensorEntity(coordinator, entity_description) + for entity_description in SENSORS + ] + async_add_entities(entities) + + +class ProliphixSensorEntity(ProliphixEntity, SensorEntity): + """Representation of an Proliphix sensor.""" + + entity_description: ProliphixSensorDescription + + def __init__( + self, + coordinator: ProliphixDataUpdateCoordinator, + entity_description: ProliphixSensorDescription, + ) -> None: + """Set up the instance.""" + self.entity_description = entity_description + super().__init__(coordinator) + + @property + def native_value(self) -> StateType: + """Return the state.""" + return self.entity_description.value_fn(self) + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of measurement.""" + unit = self.entity_description.native_unit_of_measurement + if self.device_class == SensorDeviceClass.TEMPERATURE: + if self.proliphix.temperature_scale == TemperatureScale.FARENHEIT: + unit = UnitOfTemperature.FAHRENHEIT + elif self.proliphix.temperature_scale == TemperatureScale.CELSIUS: + unit = UnitOfTemperature.CELSIUS + return unit From 5dd90f5c9a50717c6872b9bf2fbe2687faaba4e8 Mon Sep 17 00:00:00 2001 From: MizterB <5458030+MizterB@users.noreply.github.com> Date: Thu, 18 Apr 2024 16:22:22 -0400 Subject: [PATCH 3/7] Read-only climate --- custom_components/proliphix_plus/__init__.py | 2 +- custom_components/proliphix_plus/climate.py | 1188 ++++------------- custom_components/proliphix_plus/const.py | 8 + .../proliphix_plus/proliphix/api.py | 9 + 4 files changed, 296 insertions(+), 911 deletions(-) diff --git a/custom_components/proliphix_plus/__init__.py b/custom_components/proliphix_plus/__init__.py index 0c064a4..8dd122c 100644 --- a/custom_components/proliphix_plus/__init__.py +++ b/custom_components/proliphix_plus/__init__.py @@ -21,7 +21,7 @@ from .proliphix.api import Proliphix # PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR, Platform.BINARY_SENSOR] -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/proliphix_plus/climate.py b/custom_components/proliphix_plus/climate.py index ad7d9f2..626fee6 100644 --- a/custom_components/proliphix_plus/climate.py +++ b/custom_components/proliphix_plus/climate.py @@ -1,958 +1,326 @@ -""" -Support for Proliphix Thermostats. +"""Climate for Proliphix.""" -This is a custom platform -""" - -import datetime import logging -import time -from urllib.parse import urlencode - -import dateutil.parser -import requests -import voluptuous as vol - -from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice -from homeassistant.components.climate.const import ( - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - CURRENT_HVAC_COOL, - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - CURRENT_HVAC_OFF, + +from homeassistant.components.climate import ( FAN_AUTO, FAN_ON, - HVAC_MODE_COOL, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, - SUPPORT_FAN_MODE, - SUPPORT_PRESET_MODE, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - PRECISION_TENTHS, - TEMP_FAHRENHEIT, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.restore_state import RestoreEntity - -_LOGGER = logging.getLogger(__name__) - -""" -Mapping of Proliphix OID values to friendly names -""" -# Data to be retrieved whenever polling the thermostat status -OID_MAP = { - "1.2": "commonDevName", - "1.8": "serialNumber", - "1.10.9": "siteName", - "2.5.1": "systemTimeSecs", - "2.7.1": "systemMimModelNumber", - "4.3.2.1": "thermSensorTempLocal", - "4.3.2.2": "thermSensorTempRemote1", - "4.3.6.2": "thermSensorStateRemote1", - "4.1.1": "thermHvacMode", - "4.1.2": "thermHvacState", - "4.1.3": "thermFanMode", - "4.1.4": "thermFanState", - "4.1.5": "thermSetbackHeat", - "4.1.6": "thermSetbackCool", - "4.1.9": "thermSetbackStatus", - "4.1.10": "thermCurrentPeriod", - "4.1.11": "thermCurrentClass", - "4.1.14": "thermRelativeHumidity", - "4.1.22": "thermHoldDuration", - "4.1.8": "thermHoldMode", - "4.4.1.3.1.1": "thermPeriodStartInMorning", - "4.4.1.3.1.2": "thermPeriodStartInDay", - "4.4.1.3.1.3": "thermPeriodStartInEvening", - "4.4.1.3.1.4": "thermPeriodStartInNight", - "4.4.1.3.2.1": "thermPeriodStartOutMorning", - "4.4.1.3.2.2": "thermPeriodStartOutDay", - "4.4.1.3.2.3": "thermPeriodStartOutEvening", - "4.4.1.3.2.4": "thermPeriodStartOutNight", - "4.4.1.3.3.1": "thermPeriodStartAwayMorning", - "4.4.1.3.3.2": "thermPeriodStartAwayDay", - "4.4.1.3.3.3": "thermPeriodStartAwayEvening", - "4.4.1.3.3.4": "thermPeriodStartAwayNight", -} - -THERM_HVAC_MODE_MAP = {"1": "Off", "2": "Heat", "3": "Cool", "4": "Auto"} - -THERM_HVAC_STATE_MAP = { - "1": "Initializing", - "2": "Off", - "3": "Heat", - "4": "Heat2", - "5": "Heat3", - "6": "Cool", - "7": "Cool2", - "8": "Delay", - "9": "ResetRelays", -} - -THERM_FAN_MODE_MAP = {"1": "Auto", "2": "On", "3": "Schedule"} - -THERM_FAN_STATE_MAP = {"0": "Init", "1": "Off", "2": "On"} - -THERM_SETBACK_STATUS_MAP = { - "1": "Normal", - "2": "Hold", - "3": "Override", - "4": "Unknown", - "5": "Unknown", - "6": "Unknown", - "7": "Unknown", -} - -THERM_CURRENT_PERIOD_MAP = {"1": "Morning", "2": "Day", "3": "Evening", "4": "Night"} - -THERM_CURRENT_CLASS_MAP = {"1": "In", "2": "Out", "3": "Away", "4": "Other"} - -THERM_ACTIVE_PERIOD_MAP = { - "1": "Morning", - "2": "Day", - "3": "Evening", - "4": "Night", - "5": "Hold", - "6": "Override", -} - -THERM_SENSOR_STATE_MAP = {"0": "NotPresent", "1": "Disabled", "2": "Enabled"} - -FAN_SCHEDULE = "Schedule" - -# Preset modes supported by this component -PRESET_SCHEDULE = "Schedule" # Restore the normal daily schedule -PRESET_IN = "In" # Switch to 'In' schedule -PRESET_OUT = "Out" # Switch to 'Out' schedule -PRESET_AWAY = "Away" # Switch to 'Away' schedule -PRESET_ECO = "Eco" # Switch to 'Eco' mode -PRESET_MANUAL_TEMP = ( - "Override" # Override currently scheduled activity until the next schedule change + PRESET_AWAY, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, ) -PRESET_MANUAL_PERM = "Hold" # Override the schedule indefinitely - -PRESET_MODES = [ +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ProliphixDataUpdateCoordinator, ProliphixEntity +from .const import ( + DOMAIN, + FAN_SCHEDULE, + PRESET_HOLD, PRESET_IN, PRESET_OUT, - PRESET_AWAY, - PRESET_MANUAL_TEMP, - PRESET_MANUAL_PERM, -] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=80): cv.port, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } + PRESET_OVERRIDE, +) +from .proliphix.const import ( + # Activity as PlxActivity, + FanMode as PlxFanMode, + HVACMode as PlxHVACMode, + HVACState as PlxHVACState, + ScheduleClass as PlxScheduleClass, + SetbackStatus as PlxSetBackStatus, + TemperatureScale as PlxTemperatureScale, ) +_LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Proliphix thermostat""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - port = config.get(CONF_PORT) - hostname = config.get(CONF_HOST) - - add_devices([ProliphixThermostat(hostname, port, username, password)]) - - -class ProliphixDevice: - def __init__(self, hostname, port, username, password): - self._hostname = hostname - self._port = port - self._username = username - self._password = password - - """ - Retrieve one or many values from the device. Argument 'oids' can be any of the following: - - A single OID string ("4.3.2.1") - - A list of OID strings (["4.3.2.1", "1.2"]) - - A dict of OID strings, mapped to friendly names ({"4.3.2.1" : "thermSensorTempLocal", "1.2" : "commonDevName"}) - Return value is a dict of OID mapped to its value. The OID will be be converted to the friendly name if it was provided. - """ - - def get(self, oids): - valueDict = {} - oidList = [] - remapOIDNames = False - - if isinstance(oids, str): - oidList = [oids] - elif isinstance(oids, list): - oidList = list(oids) - elif isinstance(oids, dict): - oidList = list(oids.keys()) - remapOIDNames = True - - url = "http://%s:%s/get" % (self._hostname, str(self._port)) - data = "&".join([("OID" + oid + "=") for oid in oidList]) - try: - response = requests.post( - url, auth=(self._username, self._password), data=data - ) - for pair in response.text.split("&"): - if len(pair) == 0: - continue - oid, value = pair.split("=") - oid = oid[3:] # Strip 'OID' from the key - # Remap code to friendly name - if remapOIDNames: - oid = oids[oid] - valueDict[oid] = value - except requests.exceptions.ConnectionError: - _LOGGER.error("Unable to connect to {}".format(url)) - - return valueDict - - """ - Set values on the device. Argument 'oids' can be any of the following: - - Dict of OIDs mapped to values ({"4.1.1" : "1"}) - - Dict of querystring-style OIDs mapped to values ({"OID4.1.1" : "1"}) - - Dict of friendly names mapped to values ({"thermHvacMode" : "1"}) - """ - - def set(self, oids): - valueDict = {} - _LOGGER.info("SET oids: %s", oids) - for key, value in oids.items(): - if key[0:3] == "OID": - valueDict[key] = value - elif key[0:1].isdigit(): - valueDict["OID%s" % key] = value - else: - oid = self._oidNameToID(key) - if oid is not None: - valueDict["OID%s" % oid] = value - url = "http://%s:%s/pdp" % (self._hostname, str(self._port)) - data = urlencode(valueDict) - data += "&submit=Submit" - _LOGGER.debug("SET Request: %s Data: %s", url, data) - requests.post(url, auth=(self._username, self._password), data=data) - - def _oidNameToID(self, oidName): - for id, name in OID_MAP.items(): - if name == oidName: - return id - return None - - def _oidIDToName(self, oidID): - oidID = oidID.replace("OID", "") # Strip out the OID prefix if included - for id, name in OID_MAP.items(): - if id == oidID: - return name - return None - - -class ProliphixThermostat(ClimateDevice, RestoreEntity): - """Representation a Proliphix thermostat.""" - - def __init__(self, hostname, port, username, password): - """Initialize the thermostat.""" - self._device = ProliphixDevice(hostname, port, username, password) - self._data = {} - - self._manufacturer = "Proliphix" - self._serial = None - self._deviceName = None - self._siteName = None - self._model = None - self._temperature = None - self._remoteSensorTemperature = None - self._remoteSensorState = None - self._hvacMode = None - self._hvacState = None - self._fanMode = None - self._fanState = None - self._setbackHeat = None - self._setbackCool = None - self._setbackStatus = None - self._currentPeriod = None - self._currentClass = None - self._relativeHumidity = None - self._holdDuration = None - self._classPeriodSchedule = None - self._nextPeriodSchedule = None - - # TODO: Make these configurable and update logic - self._configEcoModeEnabled = True - self._configEcoModeTemperature = 50 - self._configAwayModeEnabled = True - - # Custom extensions - self._holdUntil = None - self._scheduleText = None - - self._preset_mode = None - - # Initialize the object - self.update() - self._fixClockDrift() - self._setHoldDuration(0) - - async def async_added_to_hass(self): - """Handle all entity which are about to be added.""" - await super().async_added_to_hass() - # Restore a saved state and process as needed - last_state = await self.async_get_last_state() - self._restore_last_state(last_state) - - @property - def unique_id(self): - """Return unique ID for this device.""" - return self._serial - - @property - def name(self): - """Return the name of the thermostat.""" - if self._siteName == "": - return self._deviceName - return "{}: {}".format(self._siteName, self._deviceName) - - @property - def should_poll(self): - """Set up polling needed for thermostat.""" - return True - - def update(self): - """Update the data from the thermostat.""" - time.sleep(1) - - # Use this to determine if something was set directly on the thermostat - self._previousData = self._data - - self._data = self._device.get(OID_MAP) - self._serial = self._data.get("serialNumber") - self._model = self._data.get("systemMimModelNumber") - self._deviceName = self._data.get("commonDevName") - self._siteName = self._data.get("siteName") - self._temperature = float(self._data.get("thermSensorTempLocal", 0)) - self._remoteSensorTemperature = self._data.get("thermSensorTempRemote1", 0) - self._remoteSensorState = self._data.get("thermSensorStateRemote1", 0) - self._hvacMode = self._data.get("thermHvacMode", 1) - self._hvacState = self._data.get("thermHvacState", 2) - self._fanMode = self._data.get("thermFanMode", 1) - self._fanState = self._data.get("thermFanState", 1) - self._setbackHeat = self._data.get("thermSetbackHeat", 0) - self._setbackCool = self._data.get("thermSetbackCool", 0) - self._setbackStatus = self._data.get("thermSetbackStatus", 1) - self._currentPeriod = self._data.get("thermCurrentPeriod", 1) - self._currentClass = self._data.get("thermCurrentClass", 1) - self._relativeHumidity = float(self._data.get("thermRelativeHumidity", 0)) - self._holdDuration = int(self._data.get("thermHoldDuration", 0)) - self._classPeriodSchedule = { - "In": { - "Morning": self._computeScheduleDateTime( - self._data.get("thermPeriodStartInMorning", 0) - ), - "Day": self._computeScheduleDateTime( - self._data.get("thermPeriodStartInDay", 0) - ), - "Evening": self._computeScheduleDateTime( - self._data.get("thermPeriodStartInEvening", 0) - ), - "Night": self._computeScheduleDateTime( - self._data.get("thermPeriodStartInNight", 0) - ), - }, - "Out": { - "Morning": self._computeScheduleDateTime( - self._data.get("thermPeriodStartOutMorning", 0) - ), - "Day": self._computeScheduleDateTime( - self._data.get("thermPeriodStartOutDay", 0) - ), - "Evening": self._computeScheduleDateTime( - self._data.get("thermPeriodStartOutEvening", 0) - ), - "Night": self._computeScheduleDateTime( - self._data.get("thermPeriodStartOutNight", 0) - ), - }, - "Away": { - "Morning": self._computeScheduleDateTime( - self._data.get("thermPeriodStartAwayMorning", 0) - ), - "Day": self._computeScheduleDateTime( - self._data.get("thermPeriodStartAwayDay", 0) - ), - "Evening": self._computeScheduleDateTime( - self._data.get("thermPeriodStartAwayEvening", 0) - ), - "Night": self._computeScheduleDateTime( - self._data.get("thermPeriodStartAwayNight", 0) - ), - }, - } - self._nextPeriod, self._nextPeriodStart = self._getNextPeriodSchedule() - self._holdUntil = self._getHoldUntil() - self._scheduleSummary = self._getScheduleSummary() - - # Compute current preset based on setback status and current class - # Normal - if self._setbackStatus == "1": - # In - if self._currentClass == "1": - self._preset_mode = PRESET_IN - # Out - if self._currentClass == "2": - self._preset_mode = PRESET_OUT - # Away - if self._currentClass == "3": - self._preset_mode = PRESET_AWAY - # Hold - elif self._setbackStatus == "2": - self._preset_mode = PRESET_MANUAL_PERM - # Override - elif self._setbackStatus == "3": - self._preset_mode = PRESET_MANUAL_TEMP - - @property - def state(self): - """Return the current state.""" - return super().state - @property - def precision(self): - """Return the precision of the system.""" - return PRECISION_TENTHS +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Proliphix climates from config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([ProliphixClimate(coordinator)]) + + +class ProliphixClimate(ProliphixEntity, ClimateEntity): + """Representation of an Proliphix climate entity.""" + + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + ) + _attr_precision = PRECISION_TENTHS + _attr_temperature_step = PRECISION_WHOLE + _attr_name = "Thermostat" + + def __init__( + self, + coordinator: ProliphixDataUpdateCoordinator, + ) -> None: + """Set up the instance.""" + super().__init__(coordinator) @property - def state_attributes(self): - """Return the optional state attributes.""" - default_attributes = super().state_attributes - custom_attributes = { - "manufacturer": self._manufacturer, - "serial": self._serial, - "model": self._model, - "device_name": self._deviceName, - "site_name": self._siteName, - "admin_url": "http://%s:%s/" % (self._device._hostname, self._device._port), - "current_period": THERM_CURRENT_PERIOD_MAP.get(self._currentPeriod), - "current_class": THERM_CURRENT_CLASS_MAP.get(self._currentClass), - "hold_hours": self._holdDuration, - "fan_state": THERM_FAN_STATE_MAP.get(self._fanMode), - "setback_status": THERM_SETBACK_STATUS_MAP.get(self._setbackStatus), - "next_period": self._nextPeriod, - "next_period_start": self._nextPeriodStart, - "hold_until": self._holdUntil, - "schedule_summary": self._scheduleSummary, - } - if self._model in ["NT150", "NT160"]: - custom_attributes["humidity"] = self._relativeHumidity - if self._model not in ["NT10"]: - custom_attributes["remote_sensor_state"] = THERM_SENSOR_STATE_MAP.get( - self._remoteSensorState - ) - if THERM_SENSOR_STATE_MAP.get(self._remoteSensorState) == "Enabled": - custom_attributes["remote_sensor_temperature"] = ( - float(self._remoteSensorTemperature) / 10 - ) - attributes = {} - attributes.update(default_attributes) - attributes.update(custom_attributes) - return attributes + def supported_features(self): + """Return the supported features.""" + features = ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + if self.proliphix.hvac_mode == PlxHVACMode.AUTO: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + elif self.proliphix.hvac_mode in [PlxHVACMode.HEAT, PlxHVACMode.COOL]: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE + return features @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - - @property - def current_humidity(self): - """Return the current humidity.""" - if self._model in ["NT150", "NT160"]: - return self._relativeHumidity + scale = self.proliphix.temperature_scale + if scale == PlxTemperatureScale.CELSIUS: + unit = UnitOfTemperature.CELSIUS + elif scale == PlxTemperatureScale.FARENHEIT: + unit = UnitOfTemperature.FAHRENHEIT else: - return super().target_humidity + unit = None + return unit @property - def target_humidity(self): - """Return the humidity we try to reach.""" - return super().target_humidity + def current_temperature(self) -> float: + """Return the current temperature.""" + return self.proliphix.temperature_local @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode. - Need to be one of HVAC_MODE_*. - """ - # TODO: Re-enable Fan mode - if self._hvacMode == "2": - return HVAC_MODE_HEAT - elif self._hvacMode == "3": - return HVAC_MODE_COOL - elif self._hvacMode == "4": - return HVAC_MODE_HEAT_COOL - elif self._hvacMode == "1": - return HVAC_MODE_OFF - else: - return HVAC_MODE_OFF + def target_temperature(self) -> float: + """Return the target temperature.""" + target = self.proliphix.temperature_local + if self.proliphix.hvac_mode == PlxHVACMode.AUTO: + if self.proliphix.hvac_state in [ + PlxHVACState.HEAT, + PlxHVACState.HEAT_2, + PlxHVACState.HEAT_3, + ]: + target = self.proliphix.setback_heat + elif self.proliphix.hvac_state in [PlxHVACState.COOL, PlxHVACState.COOL_2]: + target = self.proliphix.setback_cool + + if self.proliphix.hvac_mode == PlxHVACMode.HEAT: + target = self.proliphix.setback_heat + + if self.proliphix.hvac_mode == PlxHVACMode.COOL: + target = self.proliphix.setback_cool + + return target @property - def hvac_modes(self): - """Return the list of available hvac operation modes. - Need to be a subset of HVAC_MODES. - """ - # TODO: Re-enable Fan mode - return [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL] + def target_temperature_high(self) -> float: + """Return the high target temperature.""" + return self.proliphix.setback_cool @property - def hvac_action(self): - """Return the current running hvac operation if supported. - Need to be one of CURRENT_HVAC_*. - """ - # TODO: Add logic for fan - if self.hvac_mode == HVAC_MODE_OFF: - return CURRENT_HVAC_OFF - elif self._hvacState in ["1", "2", "8", "9"]: - return CURRENT_HVAC_IDLE - elif self._hvacState in ["3", "4", "5"]: - return CURRENT_HVAC_HEAT - elif self._hvacState in ["6", "7"]: - return CURRENT_HVAC_COOL - else: - return CURRENT_HVAC_IDLE + def target_temperature_low(self) -> float: + """Return the low target temperature.""" + return self.proliphix.setback_heat - @property - def current_temperature(self): - """Return the current temperature.""" - return float(self._temperature) / 10 - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - if THERM_HVAC_MODE_MAP.get(self._hvacMode) == "Auto": - hvacStateName = THERM_HVAC_STATE_MAP.get(self._hvacState) - if hvacStateName.startswith("Cool"): - return float(self._setbackCool) / 10 - elif hvacStateName.startswith("Heat"): - return float(self._setbackHeat) / 10 - else: - return None - elif THERM_HVAC_MODE_MAP.get(self._hvacMode) == "Heat": - return float(self._setbackHeat) / 10 - elif THERM_HVAC_MODE_MAP.get(self._hvacMode) == "Cool": - return float(self._setbackCool) / 10 - return None + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + raise NotImplementedError + # if self.proliphix.hvac_mode == PlxHVACMode.HEAT: + # temperature = kwargs.get("temperature") + # if temperature is not None: + # await self.proliphix.set_temperature(heat=temperature) + # if "temperature" in kwargs: + # temperature = kwargs["temperature"] + # await self.proliphix.set_temperature(temperature=temperature) + # elif "target_temp_low" in kwargs and "target_temp_high" in kwargs: + # temperature_heat = kwargs["target_temp_low"] + # temperature_cool = kwargs["target_temp_high"] + # await self.proliphix.set_temperature( + # temperature_heat=temperature_heat, temperature_cool=temperature_cool + # ) @property - def target_temperature_high(self): - """Return the highbound target temperature we try to reach.""" - if THERM_HVAC_MODE_MAP.get(self._hvacMode) == "Auto": - return float(self._setbackCool) / 10 - return None + def current_humidity(self) -> float: + """Return the current humidity.""" + return self.proliphix.relative_humidity @property - def target_temperature_low(self): - """Return the lowbound target temperature we try to reach.""" - if THERM_HVAC_MODE_MAP.get(self._hvacMode) == "Auto": - return float(self._setbackHeat) / 10 - return None + def hvac_action(self): + """Return the current HVAC action.""" + action = None + if self.proliphix.hvac_mode == PlxHVACMode.OFF: + action = HVACAction.OFF + elif self.proliphix.hvac_state in [ + PlxHVACState.INITIALIZING, + PlxHVACState.OFF, + PlxHVACState.DELAY, + PlxHVACState.RESET_RELAYS, + ]: + action = HVACAction.IDLE + elif self.proliphix.hvac_state in [ + PlxHVACState.HEAT, + PlxHVACState.HEAT_2, + PlxHVACState.HEAT_3, + ]: + action = HVACAction.HEATING + elif self.proliphix.hvac_state == [PlxHVACState.COOL, PlxHVACState.COOL_2]: + action = HVACAction.COOLING + else: + action = HVACAction.IDLE + return action @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp. - Requires SUPPORT_PRESET_MODE. - """ - return self._preset_mode + def hvac_modes(self): + """Return the list of available HVAC operation modes.""" + return [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + ] @property - def preset_modes(self): - """Return a list of available preset modes. - Requires SUPPORT_PRESET_MODE. - """ - return PRESET_MODES + def hvac_mode(self): + """Return current HVAC mode.""" + mode_map = { + PlxHVACMode.OFF: HVACMode.OFF, + PlxHVACMode.HEAT: HVACMode.HEAT, + PlxHVACMode.COOL: HVACMode.COOL, + PlxHVACMode.AUTO: HVACMode.HEAT_COOL, + } + return mode_map.get(self.proliphix.hvac_mode, HVACMode.OFF) - @property - def is_aux_heat(self): - """Return true if aux heater. - Requires SUPPORT_AUX_HEAT. - """ + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" raise NotImplementedError - - @property - def fan_mode(self): - """Return the fan setting. - Requires SUPPORT_FAN_MODE. - Infinity's internal value of 'off' displays as 'auto' on the thermostat - """ - if self._fanMode == "1": - return FAN_AUTO - elif self._fanMode == "2": - return FAN_ON - elif self._fanMode == "3": - return FAN_SCHEDULE + # _LOGGER.debug("Set hvac mode: %s", hvac_mode) + # mode_map = { + # HVACMode.OFF: InfHVACMode.OFF, + # HVACMode.HEAT: InfHVACMode.HEAT, + # HVACMode.COOL: InfHVACMode.COOL, + # HVACMode.HEAT_COOL: InfHVACMode.AUTO, + # HVACMode.FAN_ONLY: InfHVACMode.FAN_ONLY, + # } + # mode = mode_map.get(hvac_mode) + # if mode is None: + # _LOGGER.error("Invalid hvac mode: %s", hvac_mode) + # else: + # await self.proliphix.system.set_hvac_mode(mode) @property def fan_modes(self): - """Return the list of available fan modes. - Requires SUPPORT_FAN_MODE. - """ + """Return the list of available HVAC operation modes.""" return [FAN_AUTO, FAN_ON, FAN_SCHEDULE] @property - def swing_mode(self): - """Return the swing setting. - Requires SUPPORT_SWING_MODE. - """ - raise NotImplementedError - - @property - def swing_modes(self): - """Return the list of available swing modes. - Requires SUPPORT_SWING_MODE. - """ - raise NotImplementedError - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - targetTemp = int(kwargs.get(ATTR_TEMPERATURE) * 10) - if self._hvacMode == "2": - targetSetback = "thermSetbackHeat" - elif self._hvacMode == "3": - targetSetback = "thermSetbackCool" - elif self._hvacMode == "4": - # TODO: Determine the right setting to change when in Auto mode - pass - else: - return - # Enable a temporary override at the selected temperature - self._device.set({targetSetback: targetTemp, "thermSetbackStatus": "2"}) - - def set_humidity(self, humidity): - """Set new target humidity.""" - raise NotImplementedError - - def set_fan_mode(self, fan_mode): - """Set new fan mode.""" - for value, label in THERM_FAN_MODE_MAP.items(): - if label == fan_mode: - self._device.set({"4.1.3": value}) - - def set_hvac_mode(self, hvac_mode): - """Set new operation mode.""" - - # Enable selected mode & return to 'normal' status (no hold or override) - for value, label in THERM_HVAC_MODE_MAP.items(): - if label.lower() == hvac_mode: - self._device.set({"thermHvacMode": value, "thermSetbackStatus": 1}) - - def set_swing_mode(self, swing_mode): - """Set new target swing operation.""" - raise NotImplementedError - - def set_preset_mode(self, preset_mode): - """Set new preset mode.""" - # Skip if no change - if preset_mode == self._preset_mode: - return - - # Set the normal weekly schedule to In/Out/Away based on preset - if preset_mode == PRESET_IN: - self._device.set( - { - "4.1.9": "1", - "4.4.3.2.1": "1", - "4.4.3.2.2": "1", - "4.4.3.2.3": "1", - "4.4.3.2.4": "1", - "4.4.3.2.5": "1", - "4.4.3.2.6": "1", - "4.4.3.2.7": "1", - } - ) - elif preset_mode == PRESET_OUT: - self._device.set( - { - "4.1.9": "1", - "4.4.3.2.1": "2", - "4.4.3.2.2": "2", - "4.4.3.2.3": "2", - "4.4.3.2.4": "2", - "4.4.3.2.5": "2", - "4.4.3.2.6": "2", - "4.4.3.2.7": "2", - } - ) - elif preset_mode == PRESET_AWAY: - self._device.set( - { - "4.1.9": "1", - "4.4.3.2.1": "3", - "4.4.3.2.2": "3", - "4.4.3.2.3": "3", - "4.4.3.2.4": "3", - "4.4.3.2.5": "3", - "4.4.3.2.6": "3", - "4.4.3.2.7": "3", - } - ) - elif preset_mode == PRESET_MANUAL_TEMP: - self._set_hold_mode("Override") - elif preset_mode == PRESET_MANUAL_PERM: - self._set_hold_mode("Hold") - elif preset_mode == PRESET_ECO: - self._turnOnEcoMode() - else: - _LOGGER.error("Invalid preset mode: {}".format(preset_mode)) - return - - def turn_aux_heat_on(self): - """Turn auxiliary heater on.""" - raise NotImplementedError + def fan_mode(self): + """Return current fan mode.""" + mode_map = { + PlxFanMode.AUTO: FAN_AUTO, + PlxFanMode.ON: FAN_ON, + PlxFanMode.SCHEDULE: FAN_SCHEDULE, + } + return mode_map.get(self.proliphix.fan_mode, PlxFanMode.AUTO) - def turn_aux_heat_off(self): - """Turn auxiliary heater off.""" + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" raise NotImplementedError + # _LOGGER.debug("Set fan mode: %s", fan_mode) + # mode_map = { + # FAN_AUTO: InfFanMode.AUTO, + # FAN_HIGH: InfFanMode.HIGH, + # FAN_MEDIUM: InfFanMode.MEDIUM, + # FAN_LOW: InfFanMode.LOW, + # } + # mode = mode_map.get(fan_mode) + # if mode is None: + # _LOGGER.error("Invalid fan mode: %s", fan_mode) + # else: + # await self.proliphix.set_fan_mode(mode) @property - def supported_features(self): - """Return the list of supported features.""" - baseline_features = SUPPORT_FAN_MODE | SUPPORT_PRESET_MODE - if self.hvac_mode == HVAC_MODE_HEAT_COOL: - return baseline_features | SUPPORT_TARGET_TEMPERATURE_RANGE - elif self.hvac_mode in [HVAC_MODE_HEAT, HVAC_MODE_COOL]: - return baseline_features | SUPPORT_TARGET_TEMPERATURE - else: - return baseline_features - - @property - def min_temp(self): - """Return the minimum temperature.""" - return super().min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - return super().max_temp + def preset_modes(self) -> list: + """Return available preset modes.""" + return [ + PRESET_IN, + PRESET_OUT, + PRESET_AWAY, + PRESET_HOLD, + PRESET_OVERRIDE, + ] @property - def min_humidity(self): - """Return the minimum humidity.""" - return super().min_humidity - - @property - def max_humidity(self): - """Return the maximum humidity.""" - return super().max_humidity - - def _turn_away_mode_on(self): - """Turn away mode on by setting the default weekly schedule for each day to OUT.""" - self._device.set( - { - "4.4.3.2.1": "2", - "4.4.3.2.2": "2", - "4.4.3.2.3": "2", - "4.4.3.2.4": "2", - "4.4.3.2.5": "2", - "4.4.3.2.6": "2", - "4.4.3.2.7": "2", - } - ) - time.sleep(1) # Allow time for change to process, then force update - self.update() - - def _turn_away_mode_off(self): - """Turn away mode off by setting the default weekly schedule for each day to IN.""" - self._device.set( - { - "4.4.3.2.1": "1", - "4.4.3.2.2": "1", - "4.4.3.2.3": "1", - "4.4.3.2.4": "1", - "4.4.3.2.5": "1", - "4.4.3.2.6": "1", - "4.4.3.2.7": "1", - } - ) - time.sleep(1) # Allow time for change to process, then force update - self.update() - - def _set_hold_mode(self, hold): - """Update hold mode.""" - # TODO: this can be enhanced - for value, label in THERM_SETBACK_STATUS_MAP.items(): - if label == hold: - self._device.set( - {"thermSetbackStatus": value, "thermHoldDuration": "0"} - ) - - def _restore_last_state(self, restoredState): - """ - Use this to restore custom computed attributes, such as holdUntil, scheduleSummary - :param restoredState: - :return: - """ - if not restoredState: - return - - pass - - def _computeScheduleDateTime(self, minsAfterMidnight=0): - if minsAfterMidnight is None: - minsAfterMidnight = 0 - minsAfterMidnight = int(minsAfterMidnight) - result = datetime.datetime.today().replace( - hour=0, minute=0, second=0, microsecond=0 - ) + datetime.timedelta(minutes=minsAfterMidnight) - # If the time is in the past, result should be same time tomorrow - if result < datetime.datetime.now(): - result = datetime.datetime.today().replace( - hour=0, minute=0, second=0, microsecond=0 - ) + datetime.timedelta(days=1, minutes=minsAfterMidnight) - return result - - def _getNextPeriodSchedule(self): - currentClass = THERM_CURRENT_CLASS_MAP.get(self._currentClass, 1) - nextPeriod = "Unknown" - nextPeriodStart = datetime.datetime.max - for period, startDateTime in self._classPeriodSchedule.get( - currentClass, {} - ).items(): - if ( - startDateTime >= datetime.datetime.now() - and startDateTime <= nextPeriodStart - ): - nextPeriod = period - nextPeriodStart = startDateTime - return (nextPeriod, nextPeriodStart) - - def _fixClockDrift(self): - """Lifted from https://github.com/sdague/proliphix/blob/master/proliphix/proliphix.py""" - now = int(time.time()) - is_dst = time.localtime().tm_isdst - set_now = now - time.timezone - if is_dst == 1: - now -= time.altzone - else: - now -= time.timezone - drift = now - int(self._data.get("systemTimeSecs", now)) - - if drift > 60: - _LOGGER.warning( - "{} {} time drifted by {} seconds, resetting".format( - self._siteName, self._deviceName, drift - ) - ) - self._device.set({"systemTimeSecs": set_now}) - - def _setHoldDuration(self, hours): - currentHoldDuration = int(self._holdDuration) - if hours != currentHoldDuration: - self._device.set({"thermHoldDuration": int(hours)}) - strCurrent = ( - "indefinite" - if currentHoldDuration == 0 - else "{} hours".format(currentHoldDuration) - ) - strNew = "indefinite" if hours == 0 else "{} hours".format(hours) - _LOGGER.info( - "{} {} hold duration changed from {} to {}".format( - self._siteName, self._deviceName, strCurrent, strNew - ) - ) - - def _getScheduleSummary(self): - summary = "" - setbackStatusLabel = THERM_SETBACK_STATUS_MAP.get(self._setbackStatus, "") - - if self._hvacMode == "1": # Off - return "Off" - # elif self._isEcoModeOn(): - # summary = "Eco mode" - elif setbackStatusLabel == "Normal": - summary = "{} - {} until {}".format( - THERM_CURRENT_CLASS_MAP.get(self._currentClass), - THERM_CURRENT_PERIOD_MAP.get(self._currentPeriod), - self._relativeDateTime(self._getNextPeriodSchedule()[1]), - ) - elif setbackStatusLabel == "Hold": - if int(self._holdDuration) == 0: - summary = "Hold indefinitely" - else: - summary = "Hold until {}".format( - self._relativeDateTime(self._holdUntil) - ) - elif setbackStatusLabel == "Override": - summary = "Override {} - {} until {}".format( - THERM_CURRENT_CLASS_MAP.get(self._currentClass), - THERM_CURRENT_PERIOD_MAP.get(self._currentPeriod), - self._relativeDateTime(self._getNextPeriodSchedule()[1]), - ) - return summary - - def _relativeDateTime(self, dt): - if isinstance(dt, str): - dt = dateutil.parser.parse(dt) - dayString = "" - if dt.date() == datetime.date.today(): - dayString = "" - elif dt.date() == datetime.date.today() + datetime.timedelta(days=1): - dayString = "tomorrow" - elif dt.date() == datetime.date.today() + datetime.timedelta(days=-1): - dayString = "yesterday" - else: - dayDiff = (dt.date() - datetime.date.today()).days - if dayDiff > 0 and dayDiff < 7: - dayString = dt.strftime("%A") - elif dayDiff > 0 and dayDiff < -7: - dayString = "Last {}".format(dt.strftime("%A")) - else: - dayString = dt.strftime("%b %d") - if len(dayString) > 0: - result = "{}, {}".format(dayString, dt.strftime("%I:%M%p").lstrip("0")) - else: - result = "{}".format(dt.strftime("%I:%M%p").lstrip("0")) - return result.capitalize() - - def _getHoldUntil(self): - holdUntil = None - if THERM_SETBACK_STATUS_MAP.get(self._data.get("thermSetbackStatus")) == "Hold": - # If hold was just enabled, compute a new 'until' datetime - if self._data.get("thermSetbackStatus") != self._previousData.get( - "thermSetbackStatus" - ): - if int(self._holdDuration) == 0: - holdUntil = "indefinitely" - else: - holdUntil = datetime.datetime.now() + datetime.timedelta( - hours=int(self._holdDuration) - ) - else: - holdUntil = None - return holdUntil - - def _isEcoModeOn(self): - if self._configEcoModeEnabled: - if ( - int(self._setbackHeat) / 10 == self._configEcoModeTemperature - and THERM_SETBACK_STATUS_MAP.get(self._setbackStatus, "") == "Hold" - and self._holdDuration == 0 - ): - return True - else: - return False - - def _turnOnEcoMode(self): - # Heat with indefinite hold at eco temperature - self._device.set( - { - "thermHvacMode": "2", - "thermHoldDuration": "0", - "thermSetbackStatus": "2", - "thermSetbackHeat": str(self._configEcoModeTemperature * 10), - } - ) + def preset_mode(self): + """Return current preset mode.""" + mode = None + if self.proliphix.setback_status == PlxSetBackStatus.NORMAL: + if self.proliphix.current_class == PlxScheduleClass.IN: + mode = PRESET_IN + if self.proliphix.current_class == PlxScheduleClass.OUT: + mode = PRESET_OUT + if self.proliphix.current_class == PlxScheduleClass.AWAY: + mode = PRESET_AWAY + elif self.proliphix.setback_status == PlxSetBackStatus.HOLD: + mode = PRESET_HOLD + elif self.proliphix.setback_status == PlxSetBackStatus.OVERRIDE: + mode = PRESET_OVERRIDE + return mode + + async def async_set_preset_mode(self, preset_mode): + """Set new target preset mode.""" + raise NotImplementedError + # _LOGGER.debug("Set preset mode: %s", preset_mode) + # if preset_mode == PRESET_SCHEDULE: + # # Remove all holds to restore the normal schedule + # await self.proliphix.set_hold_mode(mode=InfHoldMode.OFF) + # elif preset_mode == PRESET_HOME: + # # Set to home until the next scheduled activity + # await self.proliphix.set_hold_mode( + # mode=InfHoldMode.UNTIL, activity=InfActivity.HOME + # ) + # elif preset_mode == PRESET_AWAY: + # # Set to away until the next scheduled activity + # await self.proliphix.set_hold_mode( + # mode=InfHoldMode.UNTIL, activity=InfActivity.AWAY + # ) + # elif preset_mode == PRESET_SLEEP: + # # Set to sleep until the next scheduled activity + # await self.proliphix.set_hold_mode( + # mode=InfHoldMode.UNTIL, activity=InfActivity.SLEEP + # ) + # elif preset_mode == PRESET_WAKE: + # # Set to wake until the next scheduled activity + # await self.proliphix.set_hold_mode( + # mode=InfHoldMode.UNTIL, activity=InfActivity.WAKE + # ) + # elif preset_mode == PRESET_HOLD: + # # Set to manual and hold indefinitely + # await self.proliphix.set_hold_mode( + # mode=InfHoldMode.INDEFINITE, activity=InfActivity.MANUAL + # ) + # elif preset_mode == PRESET_HOLD_UNTIL: + # # Set to manual and hold indefinitely + # await self.proliphix.set_hold_mode( + # mode=InfHoldMode.UNTIL, activity=InfActivity.MANUAL + # ) + # else: + # _LOGGER.error("Invalid preset mode: %s", preset_mode) + + async def async_set_hold_mode(self, mode, activity, until): + """Set the hold mode.""" + raise NotImplementedError + # hold_mode = next((m for m in InfHoldMode if m.value == mode), None) + # hold_activity = next((a for a in InfActivity if a.value == activity), None) + # today = self.system.local_time.replace( + # hour=0, minute=0, second=0, microsecond=0 + # ) + # hold_until = today + timedelta(seconds=until) + # await self.proliphix.set_hold_mode( + # mode=hold_mode, activity=hold_activity, until=hold_until + # ) diff --git a/custom_components/proliphix_plus/const.py b/custom_components/proliphix_plus/const.py index d392db8..916b6af 100644 --- a/custom_components/proliphix_plus/const.py +++ b/custom_components/proliphix_plus/const.py @@ -1,3 +1,11 @@ """Constants for the Proliphix integration.""" DOMAIN = "proliphix_plus" + +FAN_SCHEDULE = "Schedule" + +PRESET_IN = "In" +PRESET_OUT = "Out" +PRESET_AWAY = "Away" +PRESET_HOLD = "Hold" +PRESET_OVERRIDE = "Override" diff --git a/custom_components/proliphix_plus/proliphix/api.py b/custom_components/proliphix_plus/proliphix/api.py index 103c74a..dbbc689 100644 --- a/custom_components/proliphix_plus/proliphix/api.py +++ b/custom_components/proliphix_plus/proliphix/api.py @@ -495,3 +495,12 @@ def get_dt(oid: OID) -> datetime: next_period_start = start self._next_period = next_period self._next_period_start = next_period_start + + async def set_temperature(self, heat: float, cool: float) -> None: + """Set the target heating and cooling temperatures.""" + temps = {} + if heat: + temps[OID.THERM_SETBACK_HEAT] = str(int(heat * 10)) + if cool: + temps[OID.THERM_SETBACK_COOL] = str(int(cool * 10)) + await self.set_oids(temps) From c0319c17d85f5bb186a8ea0c0430da0579cb430b Mon Sep 17 00:00:00 2001 From: MizterB <5458030+MizterB@users.noreply.github.com> Date: Fri, 19 Apr 2024 11:34:51 -0400 Subject: [PATCH 4/7] Write to thermostat --- custom_components/proliphix_plus/climate.py | 168 ++++++++---------- .../proliphix_plus/proliphix/api.py | 36 ++-- 2 files changed, 100 insertions(+), 104 deletions(-) diff --git a/custom_components/proliphix_plus/climate.py b/custom_components/proliphix_plus/climate.py index 626fee6..6b30ff2 100644 --- a/custom_components/proliphix_plus/climate.py +++ b/custom_components/proliphix_plus/climate.py @@ -1,5 +1,6 @@ """Climate for Proliphix.""" +import asyncio import logging from homeassistant.components.climate import ( @@ -26,7 +27,7 @@ PRESET_OVERRIDE, ) from .proliphix.const import ( - # Activity as PlxActivity, + OID, FanMode as PlxFanMode, HVACMode as PlxHVACMode, HVACState as PlxHVACState, @@ -129,20 +130,18 @@ def target_temperature_low(self) -> float: async def async_set_temperature(self, **kwargs): """Set new target temperature.""" - raise NotImplementedError - # if self.proliphix.hvac_mode == PlxHVACMode.HEAT: - # temperature = kwargs.get("temperature") - # if temperature is not None: - # await self.proliphix.set_temperature(heat=temperature) - # if "temperature" in kwargs: - # temperature = kwargs["temperature"] - # await self.proliphix.set_temperature(temperature=temperature) - # elif "target_temp_low" in kwargs and "target_temp_high" in kwargs: - # temperature_heat = kwargs["target_temp_low"] - # temperature_cool = kwargs["target_temp_high"] - # await self.proliphix.set_temperature( - # temperature_heat=temperature_heat, temperature_cool=temperature_cool - # ) + _LOGGER.debug("Set temperature: %s", kwargs) + if "temperature" in kwargs: + if self.proliphix.hvac_mode == PlxHVACMode.HEAT: + await self.proliphix.set_setback_heat(kwargs["temperature"]) + elif self.proliphix.hvac_mode == PlxHVACMode.COOL: + await self.proliphix.set_setback_cool(kwargs["temperature"]) + elif "target_temp_low" in kwargs: + await self.proliphix.set_setback_heat(kwargs["target_temp_low"]) + elif "target_temp_high" in kwargs: + await self.proliphix.set_setback_cool(kwargs["target_temp_high"]) + await asyncio.sleep(1) + await self.coordinator.async_refresh() @property def current_humidity(self) -> float: @@ -182,7 +181,6 @@ def hvac_modes(self): HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, ] @property @@ -197,21 +195,20 @@ def hvac_mode(self): return mode_map.get(self.proliphix.hvac_mode, HVACMode.OFF) async def async_set_hvac_mode(self, hvac_mode): - """Set new target hvac mode.""" - raise NotImplementedError - # _LOGGER.debug("Set hvac mode: %s", hvac_mode) - # mode_map = { - # HVACMode.OFF: InfHVACMode.OFF, - # HVACMode.HEAT: InfHVACMode.HEAT, - # HVACMode.COOL: InfHVACMode.COOL, - # HVACMode.HEAT_COOL: InfHVACMode.AUTO, - # HVACMode.FAN_ONLY: InfHVACMode.FAN_ONLY, - # } - # mode = mode_map.get(hvac_mode) - # if mode is None: - # _LOGGER.error("Invalid hvac mode: %s", hvac_mode) - # else: - # await self.proliphix.system.set_hvac_mode(mode) + """Set new target HVAC mode.""" + _LOGGER.debug("Set hvac mode: %s", hvac_mode) + mode_map = { + HVACMode.OFF: PlxHVACMode.OFF, + HVACMode.HEAT: PlxHVACMode.HEAT, + HVACMode.COOL: PlxHVACMode.COOL, + HVACMode.HEAT_COOL: PlxHVACMode.AUTO, + } + mode = mode_map.get(hvac_mode) + if mode is None: + _LOGGER.error("Invalid hvac mode: %s", hvac_mode) + else: + await self.proliphix.set_hvac_mode(mode) + await self.coordinator.async_refresh() @property def fan_modes(self): @@ -230,19 +227,18 @@ def fan_mode(self): async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" - raise NotImplementedError - # _LOGGER.debug("Set fan mode: %s", fan_mode) - # mode_map = { - # FAN_AUTO: InfFanMode.AUTO, - # FAN_HIGH: InfFanMode.HIGH, - # FAN_MEDIUM: InfFanMode.MEDIUM, - # FAN_LOW: InfFanMode.LOW, - # } - # mode = mode_map.get(fan_mode) - # if mode is None: - # _LOGGER.error("Invalid fan mode: %s", fan_mode) - # else: - # await self.proliphix.set_fan_mode(mode) + _LOGGER.debug("Set fan mode: %s", fan_mode) + mode_map = { + FAN_AUTO: PlxFanMode.AUTO, + FAN_ON: PlxFanMode.ON, + FAN_SCHEDULE: PlxFanMode.SCHEDULE, + } + mode = mode_map.get(fan_mode) + if mode is None: + _LOGGER.error("Invalid fan mode: %s", fan_mode) + else: + await self.proliphix.set_fan_mode(mode) + await self.coordinator.async_refresh() @property def preset_modes(self) -> list: @@ -274,53 +270,37 @@ def preset_mode(self): async def async_set_preset_mode(self, preset_mode): """Set new target preset mode.""" - raise NotImplementedError - # _LOGGER.debug("Set preset mode: %s", preset_mode) - # if preset_mode == PRESET_SCHEDULE: - # # Remove all holds to restore the normal schedule - # await self.proliphix.set_hold_mode(mode=InfHoldMode.OFF) - # elif preset_mode == PRESET_HOME: - # # Set to home until the next scheduled activity - # await self.proliphix.set_hold_mode( - # mode=InfHoldMode.UNTIL, activity=InfActivity.HOME - # ) - # elif preset_mode == PRESET_AWAY: - # # Set to away until the next scheduled activity - # await self.proliphix.set_hold_mode( - # mode=InfHoldMode.UNTIL, activity=InfActivity.AWAY - # ) - # elif preset_mode == PRESET_SLEEP: - # # Set to sleep until the next scheduled activity - # await self.proliphix.set_hold_mode( - # mode=InfHoldMode.UNTIL, activity=InfActivity.SLEEP - # ) - # elif preset_mode == PRESET_WAKE: - # # Set to wake until the next scheduled activity - # await self.proliphix.set_hold_mode( - # mode=InfHoldMode.UNTIL, activity=InfActivity.WAKE - # ) - # elif preset_mode == PRESET_HOLD: - # # Set to manual and hold indefinitely - # await self.proliphix.set_hold_mode( - # mode=InfHoldMode.INDEFINITE, activity=InfActivity.MANUAL - # ) - # elif preset_mode == PRESET_HOLD_UNTIL: - # # Set to manual and hold indefinitely - # await self.proliphix.set_hold_mode( - # mode=InfHoldMode.UNTIL, activity=InfActivity.MANUAL - # ) - # else: - # _LOGGER.error("Invalid preset mode: %s", preset_mode) - - async def async_set_hold_mode(self, mode, activity, until): - """Set the hold mode.""" - raise NotImplementedError - # hold_mode = next((m for m in InfHoldMode if m.value == mode), None) - # hold_activity = next((a for a in InfActivity if a.value == activity), None) - # today = self.system.local_time.replace( - # hour=0, minute=0, second=0, microsecond=0 - # ) - # hold_until = today + timedelta(seconds=until) - # await self.proliphix.set_hold_mode( - # mode=hold_mode, activity=hold_activity, until=hold_until - # ) + _LOGGER.debug("Set preset mode: %s", preset_mode) + if preset_mode in [PRESET_IN, PRESET_OUT, PRESET_AWAY]: + schedule_class = getattr(PlxScheduleClass, preset_mode.upper()) + settings = { + OID.THERM_SETBACK_STATUS: PlxSetBackStatus.NORMAL, + OID.THERM_DEFAULT_CLASS_ID_SUNDAY: schedule_class, + OID.THERM_DEFAULT_CLASS_ID_MONDAY: schedule_class, + OID.THERM_DEFAULT_CLASS_ID_TUESDAY: schedule_class, + OID.THERM_DEFAULT_CLASS_ID_WEDNESDAY: schedule_class, + OID.THERM_DEFAULT_CLASS_ID_THURSDAY: schedule_class, + OID.THERM_DEFAULT_CLASS_ID_FRIDAY: schedule_class, + OID.THERM_DEFAULT_CLASS_ID_SATURDAY: schedule_class, + } + elif preset_mode == PRESET_HOLD: + settings = { + OID.THERM_SETBACK_STATUS: PlxSetBackStatus.HOLD, + OID.THERM_HOLD_DURATION: 0, + } + elif preset_mode == PRESET_OVERRIDE: + settings = { + OID.THERM_SETBACK_STATUS: PlxSetBackStatus.OVERRIDE, + OID.THERM_HOLD_DURATION: 0, + } + else: + _LOGGER.error("Invalid preset mode: %s", preset_mode) + return + + await self.proliphix.set_oids(settings) + # The set operation will update more than just the settings above, + # so force a refresh of the entire thermostat status. Based on testing, + # it takes at least 6 seconds of wait time until the thermostat can + # return the updated status. + await asyncio.sleep(6) + await self.coordinator.async_refresh() diff --git a/custom_components/proliphix_plus/proliphix/api.py b/custom_components/proliphix_plus/proliphix/api.py index dbbc689..521ce4a 100644 --- a/custom_components/proliphix_plus/proliphix/api.py +++ b/custom_components/proliphix_plus/proliphix/api.py @@ -2,6 +2,7 @@ import asyncio from datetime import UTC, datetime, timedelta +from enum import Enum import logging from urllib.parse import parse_qs, urlencode @@ -197,7 +198,15 @@ async def get_oids(self, oids: OID | list[OID]) -> dict[OID, str]: async def set_oids(self, oid_values: dict[OID, str]) -> dict[OID, str]: """Set the values of OIDs.""" - data = urlencode({k.value: v for k, v in oid_values.items()}) + "&submit=Submit" + data = ( + urlencode( + { + k.value: v.value if isinstance(v, Enum) else v + for k, v in oid_values.items() + } + ) + + "&submit=Submit" + ) resp = await self._post("/pdp", data=data) resp = self._process_response(resp) self._update_cache(resp) @@ -340,6 +349,10 @@ def hvac_mode(self) -> HVACMode | None: return None return next((m for m in HVACMode if m.value == val), None) + async def set_hvac_mode(self, mode: HVACMode) -> None: + """Set the HVAC mode of the thermostat.""" + await self.set_oids({OID.THERM_HVAC_MODE: mode.value}) + @property def hvac_state(self) -> HVACState | None: """HVAC state of the thermostat.""" @@ -356,6 +369,10 @@ def fan_mode(self) -> FanMode | None: return None return next((m for m in FanMode if m.value == val), None) + async def set_fan_mode(self, mode: FanMode) -> None: + """Set the fan mode of the thermostat.""" + await self.set_oids({OID.THERM_FAN_MODE: mode.value}) + @property def fan_state(self) -> FanState | None: """Fan state of the thermostat.""" @@ -372,6 +389,10 @@ def setback_heat(self) -> float | None: return None return float(val) / 10 + async def set_setback_heat(self, temperature: float) -> None: + """Set the target heating temperature.""" + await self.set_oids({OID.THERM_SETBACK_HEAT: int(temperature * 10)}) + @property def setback_cool(self) -> float | None: """Target cooling temperature.""" @@ -380,6 +401,10 @@ def setback_cool(self) -> float | None: return None return float(val) / 10 + async def set_setback_cool(self, temperature: float) -> None: + """Set the target cooling temperature.""" + await self.set_oids({OID.THERM_SETBACK_COOL: int(temperature * 10)}) + @property def setback_status(self) -> SetbackStatus | None: """Setback status (normal, hold, override).""" @@ -495,12 +520,3 @@ def get_dt(oid: OID) -> datetime: next_period_start = start self._next_period = next_period self._next_period_start = next_period_start - - async def set_temperature(self, heat: float, cool: float) -> None: - """Set the target heating and cooling temperatures.""" - temps = {} - if heat: - temps[OID.THERM_SETBACK_HEAT] = str(int(heat * 10)) - if cool: - temps[OID.THERM_SETBACK_COOL] = str(int(cool * 10)) - await self.set_oids(temps) From 04fa3f23915a2c200e2f390b3fd7eeb1db7a90cf Mon Sep 17 00:00:00 2001 From: MizterB <5458030+MizterB@users.noreply.github.com> Date: Fri, 19 Apr 2024 11:55:33 -0400 Subject: [PATCH 5/7] Include schedule in refreshes --- custom_components/proliphix_plus/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/proliphix_plus/__init__.py b/custom_components/proliphix_plus/__init__.py index 8dd122c..cf5fbd8 100644 --- a/custom_components/proliphix_plus/__init__.py +++ b/custom_components/proliphix_plus/__init__.py @@ -83,6 +83,7 @@ async def _async_update_data(self) -> None: """Fetch data from Proliphix.""" try: await self.proliphix.refresh_state() + await self.proliphix.refresh_schedule() except TimeoutError as err: raise UpdateFailed(f"Timeout while communicating with API: {err}") from err From 82f71c392b74338133161eb53a581d6e4a9ede39 Mon Sep 17 00:00:00 2001 From: MizterB <5458030+MizterB@users.noreply.github.com> Date: Sun, 21 Apr 2024 11:26:17 -0400 Subject: [PATCH 6/7] Handle no response --- custom_components/proliphix_plus/proliphix/api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/proliphix_plus/proliphix/api.py b/custom_components/proliphix_plus/proliphix/api.py index 521ce4a..080060d 100644 --- a/custom_components/proliphix_plus/proliphix/api.py +++ b/custom_components/proliphix_plus/proliphix/api.py @@ -162,6 +162,10 @@ def _register_change_callback(self, oids: OID | list[OID], callback) -> None: def _process_response(self, response: dict) -> dict[OID, str]: """Map a get/set response back to OIDs.""" resp = {} + if response is None: + # Change this level if useful + _LOGGER.debug("No response from thermostat") + return resp for oid_str, value in response.items(): oid_obj = OID.get_by_val(oid_str) resp[oid_obj] = value[0] if value else "" From 3652dfb3c4bc90f3d8709c6e66496c566eba6b42 Mon Sep 17 00:00:00 2001 From: MizterB <5458030+MizterB@users.noreply.github.com> Date: Wed, 10 Jul 2024 15:08:36 -0400 Subject: [PATCH 7/7] Improve Target Temperature feature handling --- custom_components/proliphix_plus/climate.py | 36 ++++++++++--------- .../proliphix_plus/manifest.json | 4 +-- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/custom_components/proliphix_plus/climate.py b/custom_components/proliphix_plus/climate.py index 6b30ff2..d975501 100644 --- a/custom_components/proliphix_plus/climate.py +++ b/custom_components/proliphix_plus/climate.py @@ -73,10 +73,9 @@ def __init__( def supported_features(self): """Return the supported features.""" features = ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE - if self.proliphix.hvac_mode == PlxHVACMode.AUTO: - features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - elif self.proliphix.hvac_mode in [PlxHVACMode.HEAT, PlxHVACMode.COOL]: - features = features | ClimateEntityFeature.TARGET_TEMPERATURE + features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + features = features | ClimateEntityFeature.TARGET_TEMPERATURE + return features @property @@ -99,34 +98,37 @@ def current_temperature(self) -> float: @property def target_temperature(self) -> float: """Return the target temperature.""" - target = self.proliphix.temperature_local if self.proliphix.hvac_mode == PlxHVACMode.AUTO: if self.proliphix.hvac_state in [ PlxHVACState.HEAT, PlxHVACState.HEAT_2, PlxHVACState.HEAT_3, ]: - target = self.proliphix.setback_heat + return self.proliphix.setback_heat elif self.proliphix.hvac_state in [PlxHVACState.COOL, PlxHVACState.COOL_2]: - target = self.proliphix.setback_cool - - if self.proliphix.hvac_mode == PlxHVACMode.HEAT: - target = self.proliphix.setback_heat - - if self.proliphix.hvac_mode == PlxHVACMode.COOL: - target = self.proliphix.setback_cool - - return target + return self.proliphix.setback_cool + elif self.proliphix.hvac_mode == PlxHVACMode.HEAT: + return self.proliphix.setback_heat + elif self.proliphix.hvac_mode == PlxHVACMode.COOL: + return self.proliphix.setback_cool + else: + return None @property def target_temperature_high(self) -> float: """Return the high target temperature.""" - return self.proliphix.setback_cool + if self.proliphix.hvac_mode == PlxHVACMode.AUTO: + return self.proliphix.setback_cool + else: + return None @property def target_temperature_low(self) -> float: """Return the low target temperature.""" - return self.proliphix.setback_heat + if self.proliphix.hvac_mode == PlxHVACMode.AUTO: + return self.proliphix.setback_heat + else: + return None async def async_set_temperature(self, **kwargs): """Set new target temperature.""" diff --git a/custom_components/proliphix_plus/manifest.json b/custom_components/proliphix_plus/manifest.json index f822bbc..ff256c3 100644 --- a/custom_components/proliphix_plus/manifest.json +++ b/custom_components/proliphix_plus/manifest.json @@ -10,5 +10,5 @@ "requirements": [], "ssdp": [], "zeroconf": [], - "version": "2024.4.0" -} \ No newline at end of file + "version": "2024.7.0" +}