Skip to content

Commit

Permalink
Merge pull request #12 from PeteRager/2021.06.11
Browse files Browse the repository at this point in the history
Moving Test Branch to Main Branch for Release 0.0.2
  • Loading branch information
PeteRager committed Jun 8, 2021
2 parents 7751128 + 679df1d commit 344fe3b
Show file tree
Hide file tree
Showing 6 changed files with 417 additions and 105 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
__pycache__/
*.py[cod]
test_cases/~$Test Cases.xlsx
32 changes: 28 additions & 4 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,21 @@ A custom component for Home Assistant to integrate with Lennox iComfort S30 or L
- Home Assistant >= 2021.04.06 (others may work)
- Thermostat linked to a lennoxicomfort.com account

# Current State and Suggested Usage

The 0.0.2 has been tested in a Single Home with 2 S30 Controllers (5 Zones Total) running a Heat / Cool system. Standard operations seem to work.

The 0.0.2 release is not hardened for failure. Internet failures may cause the system to disconnect and not recover. The login session may expire and this will cause the system to disconnect. If these occur, Home Assistant will need to be restarted. Clearly this is not ideal. Hardening is the main focus of release 0.0.3. If any of these happen please report and capture the log files.

Recommended Usage: Evaluation and Testing. See if this works with your system, report the issues so we can get it solid.

# Installation
Copy the 'lennoxs30' folder and contents to <HA config directory>/custom_components/

Add the configuration to configuration.yaml. If HA is running and you press the "Validate Configuration" button you may get an error. HA must be restarted before it will see the new custom component.

Restart HA

# Configuration
### Example configuation

Expand All @@ -30,21 +42,33 @@ logger:
```
# Behavior

This integration will automatically detect all the homes, systems and zones in your account and add a Climate Entity for each discovered zone.
This integration will automatically detect all the homes, systems and zones in your account and add a Climate Entity for each discovered zone. The names of the climate entities will be climate.<system_name>_<zone_name>.

System name is the name you gave to your S30. By default Lennox names the Zones - "Zone 1", "Zone 2", "Zone 3", "Zone 4"

The integration creates internal Entity_Ids using the GUID of your S30 plus the zone index (0,1,2,3). Hence renaming your zone or system in the S30 should not cause duplicates.

# Functions

The HVAC_MODE of each zone may be set to off, cool, or heat.
The HVAC_MODE of each zone may be set to off, cool, heat or heat_cool. The speficic modes enabled will be specific to your HVAC equipment.

The FAN_MODE may be set to auto, on, circulate

The heating and cooling setpoints can be set

Zone Temperature and Humidity are reported

# Unsupported
Presets are supported. The Preset List is the list of schedules that you have configured in the S30. When you are running a schedule; changes to the temperature or fan create a temporary schedule override (the Mobile APP does the same thing). The override will automatically end at the end of the Next Period (e.g. at the time of your next schedule period.) To cancel the override, there is a preset called "Cancel Hold". Invoking this preset will remove the hold and re-enable the underlying schedule.

# Reporting Bugs

Please create issues to track bugs. Please capture the logs with debug turned on.

## A note on Debug Log Files

The Lennox configuration that comes back from the API contains every configuration parameter of your system - including Personally Identifiable Information. It is highly recommend to not publicly post these log files. Information includes - the address of your residence and email address. Issues #14 tracks this and we will work to scrub this information from the log file.


My system is a single zone air conditioner. Hence, many capabilites are unsupported due to ability to test and ability to intercept the API calls for functions outside of my system. Your help is needed.

### Platform Parameters
| Name | Type | Requirement | Default | Description |
Expand Down
25 changes: 15 additions & 10 deletions custom_components/lennoxs30/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,35 @@

async def async_setup(hass, config):
hass.states.async_set("lennox30.state", "Running")
print("async_setup config [" + str(config) + "]")
_LOGGER.info("__init__:async_setup config")

email = config.get(DOMAIN).get(CONF_EMAIL)
password = config.get(DOMAIN).get(CONF_PASSWORD)

print("email [" +str(email) + "] password [" + str(password) + "]")

s30api = s30api_async.s30api_async(email, password)

if await s30api.serverConnect() == False:
print("Connection Failed")
_LOGGER.error("__init__:async_setup connection failed")
return False

for lsystem in s30api.getSystems():
if await s30api.subscribe(lsystem) == False:
print("Data Subscription Failed lsystem [" + str(lsystem) + "]")
_LOGGER.error("__init__:async_setup config Data Subscription Failed lsystem [" + lsystem.sysId + "]")
return False
count = 0
while (count == 0):

# Wait for zones to appear on each system
sytemsWithZones = 0
numOfSystems = len(s30api.getSystems())
while (sytemsWithZones < numOfSystems):
_LOGGER.debug("__init__:async_setup waiting for zone config to arrive numSystems [" + str(numOfSystems) + "] sytemsWithZonze [" + str(sytemsWithZones) + "]")
sytemsWithZones = 0
await asyncio.sleep(1)
await s30api.retrieve()
for lsystem in s30api.getSystems():
for zone in lsystem.getZoneList():
count += 1
await asyncio.sleep(1)
numZones = len(lsystem.getZoneList())
_LOGGER.debug("__init__:async_setup wait for zones system [" + lsystem.sysId + "] numZone [" + str(numZones) + "]")
if numZones > 0:
sytemsWithZones += 1

# Launch the retrieve loop
asyncio.create_task(retrieve_task(s30api))
Expand Down
185 changes: 125 additions & 60 deletions custom_components/lennoxs30/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from homeassistant.components.climate import ClimateEntity, PLATFORM_SCHEMA
from homeassistant.components.climate.const import (
CURRENT_HVAC_COOL, CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, HVAC_MODE_DRY,
HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_COOL,
HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_COOL, ATTR_HVAC_MODE,
HVAC_MODE_HEAT_COOL, PRESET_AWAY, SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE, SUPPORT_PRESET_MODE, SUPPORT_FAN_MODE,
FAN_ON, FAN_AUTO, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH,
Expand All @@ -20,8 +20,7 @@
FAN_ON, FAN_AUTO, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH,
PRESET_NONE,
)
from homeassistant.const import (
CONF_USERNAME, CONF_PASSWORD, TEMP_CELSIUS, TEMP_FAHRENHEIT,
from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT,
ATTR_TEMPERATURE)

_LOGGER = logging.getLogger(__name__)
Expand All @@ -30,6 +29,9 @@
# HA doesn't have a 'circulate' state defined for fan.
FAN_CIRCULATE = 'circulate'

PRESET_CANCEL_HOLD = 'Cancel Hold'
PRESET_SCHEDULE_OVERRIDE = 'Schedule Hold'

SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE |
SUPPORT_TARGET_TEMPERATURE_RANGE |
SUPPORT_PRESET_MODE |
Expand All @@ -53,11 +55,6 @@

DOMAIN = "lennoxs30"

_LOGGER = logging.getLogger(__name__)

#def setup(hass, config):
# print("setup")
# return True

from homeassistant.const import (CONF_EMAIL, CONF_PASSWORD)

Expand Down Expand Up @@ -102,17 +99,15 @@ def __init__(self, hass, s30api: s30api_async, system: s30api_async.lennox_syste
self._min_temp = 60
self._max_temp = 80
self._myname = self._system.name + '_' + self._zone.name
#self.unique_id = self._system.sysId + '_' + str(self._zone.id)
s = 'climate' + "." + self._system.sysId + '-' + str(self._zone.id)
s = s.replace("-","")
#self.entity_id = s

@property
def unique_id(self) -> str:
return (self._system.sysId + '_' + str(self._zone.id)).replace("-","")

def update_callback(self):
_LOGGER.warning("update_callback myname [" + self._myname + "]")
_LOGGER.info("update_callback myname [" + self._myname + "]")
self.async_schedule_update_ha_state()

@property
Expand Down Expand Up @@ -166,7 +161,7 @@ def max_temp(self):

maxTemp = None
if self._zone.heatingOption == True:
minTemp = self._zone.maxHsp
maxTemp = self._zone.maxHsp
if self._zone.coolingOption == True:
if maxTemp == None:
maxTemp = self._zone.maxCsp
Expand Down Expand Up @@ -213,6 +208,8 @@ def current_humidity(self):
def hvac_mode(self):
"""Return the current hvac operation mode."""
r = self._zone.getSystemMode()
if r == s30api_async.LENNOX_HVAC_HEAT_COOL:
r = HVAC_MODE_HEAT_COOL
_LOGGER.debug("climate:hvac_mode name[" + self._myname + "] mode [" + r + "]")
return r

Expand All @@ -227,8 +224,29 @@ def hvac_modes(self):
modes.append(HVAC_MODE_HEAT)
if self._zone.dehumidificationOption == True:
modes.append(HVAC_MODE_DRY)
if self._zone.coolingOption == True and self._zone.heatingOption == True:
modes.append(HVAC_MODE_HEAT_COOL)
return modes

async def async_set_hvac_mode(self, hvac_mode):
"""Set new hvac operation mode."""
t_hvac_mode = hvac_mode
# Only this mode needs to be mapped
if t_hvac_mode == HVAC_MODE_HEAT_COOL:
t_hvac_mode = s30api_async.LENNOX_HVAC_HEAT_COOL
_LOGGER.info("climate:async_set_hvac_mode zone [" + self._myname + "] ha_mode [" + str(hvac_mode) + "] lennox_mode [" + t_hvac_mode + "]")
await self._zone.setHVACMode(t_hvac_mode)
# We'll do a couple polls until we get the state
for x in range(1, 10):
await asyncio.sleep(0.5)
await self._s30api.retrieve()
if self._zone.getSystemMode() == hvac_mode:
_LOGGER.info("async_set_hvac_mode - got change with fast poll iteration [" + str(x) + "]")
return
_LOGGER.info("async_set_hvac_mode - unabled to retrieve change with fast poll")



@property
def hvac_action(self):
"""Return the current hvac state/action."""
Expand All @@ -237,15 +255,40 @@ def hvac_action(self):

@property
def preset_mode(self):
"""Return the current preset mode."""
# if self._api.away_mode == 1:
# return PRESET_AWAY
return None
if self._zone.overrideActive == True:
return PRESET_SCHEDULE_OVERRIDE
scheduleId = self._zone.scheduleId
if scheduleId is None:
return None
schedule = self._system.getSchedule(scheduleId)
if schedule is None:
return None
return schedule.name

@property
def preset_modes(self):
"""Return a list of available preset modes."""
return [PRESET_NONE, PRESET_AWAY]
presets = []
for schedule in self._system.getSchedules():
# Everything above 16 seems to be internal schedules
if schedule.id >= 16:
continue
presets.append(schedule.name)
presets.append(PRESET_CANCEL_HOLD)
_LOGGER.debug("climate:preset_modes name[" + self._myname + "] presets[" + str(presets) + "]")
return presets

async def async_set_preset_mode(self, preset_mode):
_LOGGER.info("climate:async_set_preset_mode name[" + self._myname + "] preset_mode [" + preset_mode + "]")

if preset_mode == PRESET_CANCEL_HOLD:
return await self._zone.setScheduleHold(False)
await self._zone.setSchedule(preset_mode)

# if preset_mode == PRESET_AWAY:
# self._turn_away_mode_on()
# else:
# self._turn_away_mode_off()


@property
def is_away_mode_on(self):
Expand All @@ -265,60 +308,82 @@ def fan_modes(self):

async def async_set_temperature(self, **kwargs):
"""Set new target temperature"""

r_hvacMode = None
if kwargs.get(ATTR_HVAC_MODE) is not None:
r_hvacMode = kwargs.get(ATTR_HVAC_MODE)
r_temperature = None
if kwargs.get(ATTR_TEMPERATURE) is not None:
sp = kwargs.get(ATTR_TEMPERATURE)
r_temperature = kwargs.get(ATTR_TEMPERATURE)
r_csp = None
if kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None:
r_csp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
r_hsp = None
if kwargs.get(ATTR_TARGET_TEMP_LOW) is not None:
r_hsp = kwargs.get(ATTR_TARGET_TEMP_LOW)

_LOGGER.info("climate:async_set_temperature zone [" + self._myname + "] hvacMode [" + str(r_hvacMode) + "] temperature [" + str(r_temperature) + "] temp_high [" + str(r_csp) + "] temp_low [" + str(r_hsp) + "]")

# A temperature must be specified
if r_temperature is None and r_csp is None and r_hsp is None:
_LOGGER.error("climate:async_set_temperature - no temperature given zone [" + self._myname + "] hvacMode [" + str(r_hvacMode) + "] temperature [" + str(r_temperature) + "] temp_high [" + str(r_csp) + "] temp_low [" + str(r_hsp) + "]")
return

# Either provide temperature or high/low but not both
if r_temperature != None and (r_csp != None or r_hsp != None):
_LOGGER.error("climate:async_set_temperature - pass either temperature or temp_high / low - zone [" + self._myname + "] hvacMode [" + str(r_hvacMode) + "] temperature [" + str(r_temperature) + "] temp_high [" + str(r_csp) + "] temp_low [" + str(r_hsp) + "]")
return

# If no temperature, must specify both high and low
if r_temperature == None and (r_csp == None or r_hsp == None):
_LOGGER.error("climate:async_set_temperature - must provide both temp_high / low - zone [" + self._myname + "] hvacMode [" + str(r_hvacMode) + "] temperature [" + str(r_temperature) + "] temp_high [" + str(r_csp) + "] temp_low [" + str(r_hsp) + "]")
return

# If an HVAC mode is requested; and we are not in that mode, then the first step
# is to switch the zone into that mode before setting the temperature
if (r_hvacMode != None and r_hvacMode != self.hvac_mode):
_LOGGER.info("climate:async_set_temperature zone [" + self._myname + "] setting hvacMode [" + str(r_hvacMode) + "]")
result = await self.async_set_hvac_mode(r_hvacMode)
if result == False:
_LOGGER.error("climate:async_set_temperature zone [" + self._myname + "] failed setting hvacMode [" + str(r_hvacMode) + "]")
return

if (r_hvacMode == None):
r_hvacMode = self.hvac_mode

if r_temperature is not None:
if self.hvac_mode == HVAC_MODE_COOL:
_LOGGER.info("set_temperature system in cool mode, setting csp [" + str(sp) + "]")
await self._system.setCoolSPF(sp)
_LOGGER.info("climate:async_set_temperature set_temperature system in cool mode - zone [" + self._myname + "] temperature [" + str(r_temperature) + "]")
result = await self._zone.setCoolSPF(r_temperature)
if result == False:
_LOGGER.error("climate:async_set_temperature - failed - zone [" + self._myname + "] temperature [" + str(r_temperature) + "]")
return False
return True
elif self.hvac_mode == HVAC_MODE_HEAT:
_LOGGER.info("set_temperature system in heat mode, setting hsp [" + str(sp) + "]")
await self._system.setCoolSPF(sp)
_LOGGER.info("climate:async_set_temperature set_temperature system in heat mode - zone [" + self._myname + "] sp [" + str(r_temperature) + "]")
result = await self._zone.setCoolSPF(r_temperature)
if result == False:
_LOGGER.error("climate:async_set_temperature - failed - zone [" + self._myname + "] temperature [" + str(r_temperature) + "]")
return False
return True
else:
_LOGGER.error("set_temperature System Mode is [" + self.hvac_mode + "] unable to set temperature" )
_LOGGER.error("set_temperature System Mode is [" + r_hvacMode + "] unable to set temperature")
return False
else:
csp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
hsp = kwargs.get(ATTR_TARGET_TEMP_LOW)
await self._system.setHeatCoolSPF(hsp, csp)
# if self.hvac_mode == HVAC_MODE_COOL:
# _LOGGER.info("set_temperature system in cool mode, setting csp [" + str(csp) + "]")
# await self._system.setCoolSPF(csp)
# elif self.hvac_mode == HVAC_MODE_HEAT:
# _LOGGER.info("set_temperature system in heat mode, setting hsp [" + str(hsp) + "]")
# await self._system.setHeatSPF(hsp)
# else:
# _LOGGER.error("set_temperature System Mode is [" + self.hvac_mode + "] unable to set temperature" )
_LOGGER.info("climate:async_set_temperature zone [" + self._myname + "] csp [" + str(r_csp) + "] hsp [" + str(r_hsp) + "]")
result = await self._zone.setHeatCoolSPF(r_hsp, r_csp)

async def async_set_fan_mode(self, fan_mode):
"""Set new fan mode."""
await self._system.setFanMode(fan_mode)

async def async_set_hvac_mode(self, hvac_mode):
"""Set new hvac operation mode."""
await self._system.setHVACMode(hvac_mode)
# We'll do a couple polls until we get the state
for x in range(1, 10):
await asyncio.sleep(0.5)
await self._s30api.retrieve()
if self._zone.getSystemMode() == hvac_mode:
_LOGGER.info("async_set_hvac_mode - got change with fast poll iteration [" + str(x) + "]")
return
_LOGGER.info("async_set_hvac_mode - unabled to retrieve change with fast poll")

def set_preset_mode(self, preset_mode):
"""Set new preset mode."""
print("TODO")

# if preset_mode == PRESET_AWAY:
# self._turn_away_mode_on()
# else:
# self._turn_away_mode_off()
_LOGGER.info("climate:async_set_temperature name[" + self._myname + "] fanMode [ " + str(fan_mode) + "]")
await self._zone.setFanMode(fan_mode)

def _turn_away_mode_on(self):
print("TODO")
raise NotImplementedError
# """Turn away mode on."""
# self._api.away_mode = 1

def _turn_away_mode_off(self):
print("TODO")
raise NotImplementedError
# """Turn away mode off."""
# self._api.away_mode = 0
# self._api.away_mode = 0
Loading

0 comments on commit 344fe3b

Please sign in to comment.