Saswell Integration

Hi Guys,

I’ve been trying to add my Curv thermostats (They work with scinan API) by using the integration above, but that project has been abandoned years ago and I couldn’t get any response from the author.

I can see that the code is quite out of date and many functions no longer exist in the Home Assistant or have been changed. Hence my question is:

Could some help me with upgrading the code to now-days version, please?

From my side, I did get a response from the api and multiple values can reach the HA, but cannot get around climate device creation.

See example below:

List device: devs=[{'is_on': True, 'away': False, 'temperature': 20.1, 'target_temperature': 16.0, 'online': True, 'id': '10382435CC050629'}]

Then many errors come up when trying to sort things, such as:

AttributeError: 'SaswellClimate' object has no attribute '_attr_hvac_mode'

OR

AttributeError: 'SaswellClimate' object has no attribute '_attr_preset_mode'

Any help would be highly appreciated.

Update:

Managed to get output commands working ok, but values are not refreshing. See current code:

"""
Saswell platform that offers a Saswell climate device.

For more details about this platform, please refer to the documentation
https://home-assistant.io/components/climate/saswell
"""

import asyncio
import logging

from datetime import timedelta

import time
import voluptuous as vol

from homeassistant.components.climate.const import (
    HVAC_MODE_HEAT,
    HVAC_MODE_OFF,
    PRESET_AWAY,
    PRESET_HOME,
    SUPPORT_TARGET_TEMPERATURE, 
    SUPPORT_PRESET_MODE
)

from homeassistant.components.sensor import PLATFORM_SCHEMA

from homeassistant.const import (
    CONF_NAME, 
    CONF_USERNAME, 
    CONF_PASSWORD, 
    CONF_SCAN_INTERVAL,
    TEMP_CELSIUS,
    ATTR_TEMPERATURE
)
from homeassistant.helpers.event import async_track_time_interval

import homeassistant.helpers.config_validation as cv

try:
    from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity
except ImportError:
    from homeassistant.components.climate import (
        PLATFORM_SCHEMA,
        ClimateDevice as ClimateEntity,
    )

_LOGGER = logging.getLogger(__name__)

TOKEN_FILE = ".saswell.token."
USER_AGENT = "Thermostat/3.1.0 (iPhone; iOS 11.3; Scale/3.00)"

AUTH_URL = "http://api.scinan.com/oauth2/authorize?client_id=100000" \
    "&passwd=%s&redirect_uri=http%%3A//localhost.com%%3A8080" \
    "/testCallBack.action&response_type=token&userId=%s"
LIST_URL = "http://api.scinan.com/v1.0/devices/list?format=json"
CTRL_URL = "http://api.scinan.com/v1.0/sensors/control?" \
    "control_data=%%7B%%22value%%22%%3A%%22%s%%22%%7D&device_id=%s" \
    "&format=json&sensor_id=%s&sensor_type=1"

CONF_TEMPERATURE = 'temperature'
CONF_TARGET_TEMPERATURE = 'target_temperature'
CONF_OPERATION = 'operation'
CONF_AWAY = 'away'
CONF_IS_ON = 'is_on'

CONF_OPERATION_LIST = 'hvac_modes'
CONF_FAN_LIST = 'fan_list'
CONF_SWING_LIST = 'swing_list'

DEFAULT_NAME = 'saswell'

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
    vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
    vol.Required(CONF_USERNAME): cv.string,
    vol.Required(CONF_PASSWORD): cv.string,
    vol.Optional(CONF_SCAN_INTERVAL, default=timedelta(seconds=300)): (
        vol.All(cv.time_period, cv.positive_timedelta)),
})


async def async_setup_platform(hass, config, async_add_devices,
                               discovery_info=None):
    """Set up the Saswell climate devices."""
    name = config.get(CONF_NAME)
    username = config.get(CONF_USERNAME)
    password = config.get(CONF_PASSWORD)
    scan_interval = config.get(CONF_SCAN_INTERVAL)

    saswell = SaswellData(hass, username, password)
    await saswell.update_data()
    if not saswell.devs:
        _LOGGER.error("No sensors added: %s.", name)
        return None

    devices = []
    for index in range(len(saswell.devs)):
        devices.append(SaswellClimate(saswell, name, index))
    async_add_devices(devices)

    saswell.devices = devices
    async_track_time_interval(hass, saswell.async_update, scan_interval)


class SaswellClimate(ClimateEntity):
    """Representation of a Saswell climate device."""

    def __init__(self, saswell, name, index):
        """Initialize the climate device."""
        name += "_"
        name += saswell.devs[index]['id']
        self._name = name
        self._index = index
        self._saswell = saswell

    @property
    def name(self):
        """Return the name of the climate device."""
        return self._name

    @property
    def available(self):
        """Return if the sensor data are available."""
        return self.get_value('online')

    @property
    def supported_features(self):
        """Return the list of supported features."""
        return SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE

    @property
    def temperature_unit(self):
        """Return the unit of measurement."""
        return TEMP_CELSIUS

    @property
    def target_temperature_step(self):
        """Return the supported step of target temperature."""
        return 0.5

    @property
    def current_temperature(self):
        """Return the current temperature."""
        return self.get_value(CONF_TEMPERATURE)

    @property
    def target_temperature(self):
        """Return the temperature we try to reach."""
        return self.get_value(CONF_TARGET_TEMPERATURE)

    @property
    def hvac_mode(self):
        """Return current operation ie. heat, cool, idle."""
        return HVAC_MODE_HEAT if self.is_on else HVAC_MODE_OFF

    @property
    def hvac_modes(self):
        """Return the list of available operation modes."""
        return [HVAC_MODE_HEAT, HVAC_MODE_OFF]

    @property
    def is_away_mode_on(self):
        """Return if away mode is on."""
        return self.get_value(CONF_AWAY)

    @property
    def is_on(self):
        """Return true if the device is on."""
        return self.get_value(CONF_IS_ON)

    @property
    def preset_modes(self):
        """Return the list of available preset modes."""
        return [PRESET_AWAY, PRESET_HOME]

    @property
    def preset_mode(self):
        """Return the current preset mode."""
        return PRESET_AWAY if self.is_away_mode_on else PRESET_HOME

    @property
    def should_poll(self):  # pylint: disable=no-self-use
        """No polling needed."""
        return False

    async def async_set_temperature(self, **kwargs):
        """Set new target temperatures."""
        temperature = kwargs.get(ATTR_TEMPERATURE)
        if temperature is not None:
            await self.set_value(CONF_TARGET_TEMPERATURE, temperature)

    async def async_set_hvac_mode(self, operation_mode):
        """Set new target hvac mode."""
        if operation_mode == HVAC_MODE_HEAT:
            await self.set_value(CONF_IS_ON, False)
        else:
            await self.set_value(CONF_IS_ON, True)

    async def async_set_preset_mode(self, preset_mode):
        """Set new target hvac mode."""
        if preset_mode == PRESET_AWAY:
            await self.set_value(CONF_AWAY, True)
        else:
            await self.set_value(CONF_AWAY, False)

    async def async_turn_on(self):
        """Turn on."""
        await self.set_value(CONF_IS_ON, True)

    async def async_turn_off(self):
        """Turn off."""
        await self.set_value(CONF_IS_ON, False)

    def get_value(self, prop):
        """Get property value"""
        devs = self._saswell.devs
        if devs and self._index < len(devs):
            return devs[self._index][prop]
        return None

    async def set_value(self, prop, value):
        """Set property value"""
        if await self._saswell.control(self._index, prop, value):
            self.async_schedule_update_ha_state()


class SaswellData():
    """Class for handling the data retrieval."""

    def __init__(self, hass, username, password):
        """Initialize the data object."""
        self._hass = hass
        self._username = username.replace('@', '%40')
        self._password = password
        self._token_path = hass.config.path(TOKEN_FILE + username)
        self.devs = None

        try:
            with open(self._token_path) as file:
                self._token = file.read()
                _LOGGER.debug("Load token: %s", self._token_path)
        except BaseException:
            self._token = None

    async def async_update(self, time):
        """Update online data and update ha state."""
        old_devs = self.devs
        await self.update_data()

        tasks = []
        index = 0
        for device in self.devices:
            if not old_devs or not self.devs \
                    or old_devs[index] != self.devs[index]:
                _LOGGER.info('%s: => %s', device.name, device.state)
                tasks.append(device.async_update_ha_state())

        if tasks:
            await asyncio.wait(tasks, loop=self._hass.loop)

    async def update_data(self):
        """Update online data."""
        try:
            json = await self.request(LIST_URL)
            if ('error' in json) and (json['error'] != '0'):
                _LOGGER.debug("Reset token: error=%s", json['error'])
                self._token = None
                json = await self.request(LIST_URL)
            devs = []
            for dev in json:
                status = dev['status'].split(',')
                devs.append({'is_on': status[1] == '1',
                             'away': status[5] == '1', #8?
                             'temperature': float(status[2]),
                             'target_temperature': float(status[3]),
                             'online': dev['online'] == '1',
                             'id': dev['id']})
            self.devs = devs
            _LOGGER.info("List device: devs=%s", self.devs)
        except BaseException:
            import traceback
            _LOGGER.error('Exception: %s', traceback.format_exc())

    async def control(self, index, prop, value):
        """Control device via server."""
        try:
            if prop == 'is_on':
                sensor_id = '01'
                data = '1' if value else '0'
            elif prop == 'target_temperature':
                sensor_id = '02'
                data = value
            elif prop == 'away':
                sensor_id = '03'
                data = '1' if value else '0'
            else:
                return False

            device_id = self.devs[index]['id']
            json = await self.request(CTRL_URL % (data, device_id, sensor_id))
            _LOGGER.debug("Control device: prop=%s, json=%s", prop, json)
            if json['result']:
                self.devs[index][prop] = value
                return True
            return False
        except BaseException:
            import traceback
            _LOGGER.error('Exception: %s', traceback.format_exc())
            return False

    async def request(self, url):
        """Request from server."""
        session = self._hass.helpers.aiohttp_client.async_get_clientsession()
        if self._token is None:
            headers = {'User-Agent': USER_AGENT}
            url = AUTH_URL % (self._password, self._username)
            async with session.get(url, headers=headers) as response:
                text = await response.text()
            _LOGGER.info("Get token: %s", text)
            start = text.find('token:')
            if start == -1 :
                return None

            start += 6
            end = text.find('\n', start) - 1
            self._token = text[start:end]
            with open(self._token_path, 'w') as file:
                file.write(self._token)

        headers = {'User-Agent': USER_AGENT}
        url += "&timestamp=%s&token=%s" % \
            (time.strftime('%Y-%m-%d%%20%H%%3A%M%%3A%S'), self._token)
        _LOGGER.debug("URL: %s", url)
        async with session.get(url, headers=headers) as response:
            return await response.json(content_type=None)

I’ve got a new error being thrown out:

Error doing job: Task exception was never retrieved
Traceback (most recent call last):
  File "/config/custom_components/saswell/climate.py", line 258, in async_update
    await asyncio.wait(tasks, loop=self._hass.loop)
TypeError: wait() got an unexpected keyword argument 'loop'

Any ideas, please?

@mikihacia Have you tested this one?

It uses the Scinan API aswell. I have not tested it yet, I am just researching floor heating products to find something that seem to be supported in some way by Home Assistant.

There is also a similar, now abandoned project done for Homey. Maybe some ideas to be found there?

I believe that is programmed in JS, though.

It actually seems to works pretty well. Thanks!