Creating a custom integration (Climote)

Hi there @VatzU, tested this today, works as expected per your updates. Boost from HA applies quickly on the Climote itself (pretty much same speed as their mobile app). Change of Set Temp is alos working.
I have the refresh_interval set to 2 hours.

Have you had contact with the Climote company ? They happen to be based in my home town (Dundalk, Ireland) so I could make contact with them on your behalf and make an introduction if you want ?

I see you are in Wroclaw, I visit there regularly (outside Covid) as my employer has a IT office there

Glad to hear it’s working well for you :0)
In fact i’m in Cork, somehow this forum decided to place me in Wroclaw ;0)
No, i didn’t reach to them as website responses come in JSON which is ok to process. THe main limitation is still that Climote works on mobile text. If only it could have alternative to connect through Wifi…

Even if it will connect to wifi it will still be slow as it will have to connect to the climote backend.
I have the home assistant ring doorbell integration and I have setup an automation when the motion detector on the doorbell detects movement to turn on the outside light. It all works fine however there is a lag of about 2 seconds…

Very happy to see this integration going well done @VatzU for the great work and @deccos for testing it up, planning to put it soon too.

surely their sms interface ends up on an API endpoint of some type. If you had access to that endpoint could’nt you bypass the whole SMS part

My concern is not really about the delay but the amount of queries through text. Update every 2h would mean ~4000 requests a year. I don’t believe Climote was build for such usage :0)
Even then, update every 2h is not the best for any automation. I will try to think of the button or automation process that would specifically enforce Climote update if it is to be used.
Having wifi on Climote would simply mean we could refresh as often as we like* and have more real-time data available.
Still, having this is better than nothing :0)

I see what you mean. Maybe we should only interrogate climote when there is an action or via a button in the integration to refresh the status.
I am wondering what is happening if you keep the app open or the website logged on. I am sure it will be the same kind of refresh i.e. text communication with the climote.

I have a similar integration with the alarm that also have a sim card in it… they all have it like that so they can hook you on a subscription base system :slight_smile:

So the 4k SMS per year, yeah I am paying for it with my subscription to climote :slight_smile:

@deccos aaa, then you mean ‘hacking’ into the climote device itself? Well, as far as I’m reading there is some RF interface for reading from remote thermostat. We could get details from Climote on what this RF link is and what it allows but then still you would most likely need some additional device to do the in-between translation

No, I didnt mean talking direct to the device. I’m not really sure what I meant now !

Normally, their App (or your integration) calls their API endpoint, and this in turn sends an SMS to the device to tell it what to do . Is this correct ?

Yes, app, website all communicate with an actual device through SMS. Even if climote gave us API, the SMS will still be behind it. THe only way to get around it would be to connect to device directly, but it does not support it really… I guess it all comes down to what @andy73 said - they just want to keep us on subscription model :0)

Andy’s idea of an “on demand” update rather than a scheduled one would be useful. At least then it would be down to the HA admin to determine when they want to call for an update within their automations (or whatever)

Hi @deccos can you share please the config you have… It seems to pick up the zones and the temperatures (I only the self thermostat in the climote base) but I cannot see any boost command I can see only an Off operation that fails

Thanks!

sorry my bad, did not downloaded the latest code. looking to see how I change the default to 0.5 h… Looking very good!

@andy73 to do .5h you would need to change the code. I can add it to my list to have another config option

thanks, I have figure it out the code and did it :slight_smile: even as it is now is very useful to me as boosting with 0.5h was most of the activities that I was doing…

Well done

line 194 needs to be like that (I do not have the temperature thermostats from Climote)

        return int(self._climote.data[zone]["temperature"]) \
            if self._climote.data[zone]["temperature"] != '--' else 0

(was n/a and needs to be --)

Can I contribute to this project? Never coded in python but seems easy enough… Home assistant way of coding might be harder to grasp :slight_smile:

Hi guys, we had no action on this…

I am ok with some code changes that I have added to remove the warning on HA + some rules and customs controls (defined as helpers)

I personally do not need more than that as i all need is the Boost by 0.5h and cancel boost. Everything else I can control through HA for example if the temperature drop in a certain room I can initiate a boot and I can cancel it if the temperature is at a certain stage. I do not need more than that.

Anyone any more updates?
Thanks,.
Adrian

hi @andy73 . sorry, got stuck on day-to-day routine ;0)
the list of features/updates is still valid. Feel free to contribute your changes in git (not sure how it works, never used ;0)
The only thing i was working on was the button to force update (in case you have rare intervals set and you want to refresh on demand). Will try to post it into code soon

Hi, I can’t seem to get this working - most likely I’m doing something wrong:

  1. Upload home-assistant-climote-master folder and contents to config/custom_components
  2. Restart HA
  3. Add the text to the configuration.yaml: climate: - platform: climote …
  4. Checking validity of congfiguration and seeing error: Platform error climate.command_line - No module named ‘homeassistant.components.command_line.climate’
    3

Also, can’t find any Climote cards in Lovelace.

Could you please help me?

Thanks, Alex

This is how I got it working. I might tidy up code further but do not have the time…

  1. unzip into custom_components it should be a climote folder with all the files in it - maybe here you got the structure wrong
  2. restart HA
  3. add in configuration.yaml (of course update your secrets.yaml file with your credentials
climate:
  - platform: climote
    username: !secret climote_user
    password: !secret climote_pass
    id: !secret climote_id
    refresh_interval: 4
  1. restart HA
    I have used a series of buttons and timers helpers and rules to turn the heating on and off for 30 min

dashboard code

cards:
  - type: entities
    entities:
      - entity: input_boolean.boost_living
      - entity: timer.heating_living
      - entity: input_boolean.boost_bedrooms
      - entity: timer.heating_bedrooms
      - entity: input_boolean.boost_water
      - entity: timer.heating_water
    title: Climote controls
    state_color: true
    show_header_toggle: false

rules - sample for water, duplicate and change for bedrooms and living

alias: Boost_water_on
description: ''
trigger:
  - platform: state
    entity_id: input_boolean.boost_water
    to: 'on'
condition: []
action:
  - service: climate.set_hvac_mode
    data:
      hvac_mode: heat
    entity_id: climate.water
  - delay:
      hours: 0
      minutes: 0
      seconds: 4
      milliseconds: 0
  - service: timer.start
    data:
      duration: '0'
    target:
      entity_id: timer.heating_water
mode: single

alias: Boost_water_off
description: ''
trigger:
  - platform: state
    entity_id: input_boolean.boost_water
    to: 'off'
condition: []
action:
  - service: climate.set_hvac_mode
    data:
      hvac_mode: 'off'
    entity_id: climate.water
  - delay:
      hours: 0
      minutes: 0
      seconds: 4
      milliseconds: 0
  - service: timer.finish
    data: {}
    entity_id: timer.heating_water
mode: single

alias: Boost_water_done
description: ''
trigger:
  - platform: state
    entity_id: timer.heating_water
    to: idle
    for: '00:00:02'
condition: []
action:
  - service: automation.turn_off
    data: {}
    entity_id: automation.boost_water_on
  - service: automation.turn_off
    data: {}
    entity_id: automation.boost_water_off
  - service: input_boolean.turn_off
    data: {}
    entity_id: input_boolean.boost_water
  - service: automation.turn_on
    data: {}
    entity_id: automation.boost_water_on
  - service: automation.turn_on
    data: {}
    entity_id: automation.boost_water_off
mode: single

here is my version of climate.py

import logging
import polling
import json
import xmljson
import lxml
from lxml import etree as ET

import voluptuous as vol

from datetime import timedelta
from bs4 import BeautifulSoup
import requests

from homeassistant.util import Throttle
import homeassistant.helpers.config_validation as cv
from homeassistant.components.climate import (
    ClimateEntity, PLATFORM_SCHEMA)  
from homeassistant.components.climate.const import (SUPPORT_TARGET_TEMPERATURE, HVAC_MODE_OFF, HVAC_MODE_HEAT,CURRENT_HVAC_HEAT,CURRENT_HVAC_IDLE)
from homeassistant.const import (
    CONF_ID, CONF_NAME, ATTR_TEMPERATURE, CONF_PASSWORD,
    CONF_USERNAME, TEMP_CELSIUS, CONF_DEVICES)

_LOGGER = logging.getLogger(__name__)

MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=60)

#: Interval in hours that module will try to refresh data from the climote.
CONF_REFRESH_INTERVAL = 'refresh_interval'

NOCHANGE = 'nochange'
DOMAIN = 'climote'
ICON = "mdi:thermometer"

MAX_TEMP = 35
MIN_TEMP = 5

#SUPPORT_FLAGS = (SUPPORT_ON_OFF | SUPPORT_TARGET_TEMPERATURE)
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
SUPPORT_MODES = [HVAC_MODE_HEAT, HVAC_MODE_OFF]

#DEVICE_SCHEMA = vol.Schema({
#    vol.Required(CONF_ID): cv.positive_int,
#    vol.Optional(CONF_NAME): cv.string,
#}, extra=vol.ALLOW_EXTRA)


def validate_name(config):
    """Validate device name."""
    if CONF_NAME in config:
        return config
    climoteid = config[CONF_ID]
    name = 'climote_{}'.format(climoteid)
    config[CONF_NAME] = name
    return config

# CONFIG_SCHEMA = vol.Schema(
    # {
        # DOMAIN: vol.Schema(
            # {
                # vol.Required(CONF_USERNAME): cv.string,
                # vol.Required(CONF_PASSWORD): cv.string,
                # vol.Required(CONF_ID): cv.string,
                # vol.Optional(CONF_REFRESH_INTERVAL, default=24): cv.string,
            # }
        # )
    # },
    # extra=vol.ALLOW_EXTRA,
# )

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
   vol.Required(CONF_USERNAME): cv.string,
   vol.Required(CONF_PASSWORD): cv.string,
   vol.Required(CONF_ID): cv.string,
   vol.Optional(CONF_REFRESH_INTERVAL, default=24): cv.string,
#   vol.Required(CONF_DEVICES):
#       vol.Schema({cv.string: DEVICE_SCHEMA})
})

def setup_platform(hass, config, add_entities, discovery_info=None):
    """Set up the ephember thermostat."""
    _LOGGER.info('Setting up climote platform')
    username = config.get(CONF_USERNAME)
    password = config.get(CONF_PASSWORD)
    climoteid = config.get(CONF_ID)


    interval = int(config.get(CONF_REFRESH_INTERVAL))

    # Add devices
    climote = ClimoteService(username, password, climoteid)
    if not (climote.initialize()):
        return False

    entities = []
    for id, name in climote.zones.items():
        entities.append(Climote(climote, id, name, interval))
    add_entities(entities)

    return


#new function added
# async def async_setup(hass, config):
    # """Set up Climote components."""
   # if DOMAIN not in config:
       # return True

   # conf = config[DOMAIN]

   # config_flow.register_flow_implementation(
       # hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET]
   # )

    # return True

# async def async_setup_entry(hass, config, async_add_entities,
                               # discovery_info=None):
    # """Set up the climote platform."""
    # _LOGGER.info('Setting up climote platform')
    # _LOGGER.info('usernamekey:%s', CONF_USERNAME)
    # username = config.get(CONF_USERNAME)
    # password = config.get(CONF_PASSWORD)
    
    # interval = int(config.get(CONF_REFRESH_INTERVAL))

    # # Add devices
    # climote = ClimoteService(username, password, climoteid)
    # if not (climote.initialize()):
        # return False

    # entities = []
    # for id, name in climote.zones.items():
        # entities.append(Climote(climote, id, name, interval))
    # add_entities(entities)


class Climote(ClimateEntity):
    """Representation of a Climote device."""

    def __init__(self, climoteService, zoneid, name, interval):
        """Initialize the thermostat."""
        _LOGGER.info('Initialize Climote Entity')
        self._climote = climoteService
        self._zoneid = zoneid
        self._name = name
        self._force_update = False
        self.throttled_update = Throttle(timedelta(hours=interval))(self._throttled_update)

    @property
    def supported_features(self):
        """Return the list of supported features."""
        return SUPPORT_FLAGS 
    
    @property
    def hvac_mode(self):
#        """Return current operation ie. heat, cool, idle."""
#        return 'idle'
        """Return current operation. ie. heat, idle."""
        zone = "zone" + str(self._zoneid)
        return 'heat' if self._climote.data[zone]["status"] == '5' else 'idle'

    @property
    def hvac_modes(self):
        """Return the list of available hvac operation modes.
        Need to be a subset of HVAC_MODES.
        """
        return SUPPORT_MODES
        
    @property
    def name(self):
        """Return the name of the thermostat."""
        return self._name

    @property
    def icon(self):
        """Return the icon to use in the frontend."""
        return ICON

    @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 1

    @property
    def current_temperature(self):
        zone = "zone" + str(self._zoneid)
        _LOGGER.info("current_temperature: Zone: %s, Temp %s C",
                     zone, self._climote.data[zone]["temperature"])
        return int(self._climote.data[zone]["temperature"]) \
            if self._climote.data[zone]["temperature"] != '--' else 0

#    @property
#    def is_on(self):
#        """Return current operation. ie. heat, idle."""
#        zone = "zone" + str(self._zoneid)
#        return True if self._climote.data[zone]["status"] == '5' else False

    @property
    def min_temp(self):
        """Return the minimum temperature."""
        return MIN_TEMP

    @property
    def max_temp(self):
        """Return the maximum temperature."""
        return MAX_TEMP

    @property
    def target_temperature(self):
        """Return the temperature we try to reach."""
        zone = "zone" + str(self._zoneid)
        _LOGGER.info("target_temperature: %s",
                     self._climote.data[zone]["thermostat"])
        return int(self._climote.data[zone]["thermostat"])

    @property
    def hvac_action(self):
        """Return current operation."""
        zone = "zone" + str(self._zoneid)
        return CURRENT_HVAC_HEAT if self._climote.data[zone]["status"] == '5' \
                           else CURRENT_HVAC_IDLE

    def set_hvac_mode(self,hvac_mode):
        if(hvac_mode==HVAC_MODE_HEAT):
            """Turn Heating Boost On."""
            res = self._climote.boost(self._zoneid, 0.5)
            self._force_update = True
            return res
        if(hvac_mode==HVAC_MODE_OFF):
#    def turn_off(self):
            """Turn Heating Boost Off."""
            res = self._climote.boost(self._zoneid, 0)
            if(res):
                self._force_update = True
            return res

    def set_temperature(self, **kwargs):
        """Set new target temperature."""
        temperature = kwargs.get(ATTR_TEMPERATURE)
        if temperature is None:
            return
        res = self._climote.set_target_temperature(1, temperature)
        if(res):
            self._force_update = True
        return res

    async def async_update(self):
        """Get the latest state from the thermostat."""
        #if self._force_update:
        #    asyncio.run_coroutine_threadsafe(throttled_update(hass,self,no_throttle=True) , hass.loop)
        #    self._force_update = False
        #else:
         #   asyncio.run_coroutine_threadsafe(throttled_update(hass,target,no_throttle=False) , hass.loop)

    async def _throttled_update(self, **kwargs):
        """Get the latest state from the thermostat with a throttle."""
        _LOGGER.info("_throttled_update Force: %s", self._force_update)
        self._climote.updateStatus(self._force_update)



class IllegalStateException(RuntimeError):
    def __init__(self, arg):
        self.args = arg


_DEFAULT_JSON = (
    '{ "holiday": "00", "hold": null, "updated_at": "00:00", '
    '"unit_time": "00:00", "zone1": { "burner": 0, "status": null, '
    '"temperature": "0", "thermostat": 0 }, "zone2": { "burner": 0, '
    '"status": "0", "temperature": "0", "thermostat": 0 }, '
    '"zone3": { "burner": 0, "status": null, "temperature": "0", '
    '"thermostat": 0 } }')
_LOGIN_URL = 'https://climote.climote.ie/manager/login'
_LOGOUT_URL = 'https://climote.climote.ie/manager/logout'
_SCHEDULE_ELEMENT = '/manager/edit-heating-schedule?heatingScheduleId'

_STATUS_URL = 'https://climote.climote.ie/manager/get-status'
_STATUS_FORCE_URL = _STATUS_URL + '?force=1'
_STATUS_RESPONSE_URL = ('https://climote.climote.ie/manager/'
                        'waiting-get-status-response')
_BOOST_URL = 'https://climote.climote.ie/manager/boost'
_SET_TEMP_URL = 'https://climote.climote.ie/manager/temperature'
_GET_SCHEDULE_URL = ('https://climote.climote.ie/manager/'
                     'get-heating-schedule?heatingScheduleId=')


class ClimoteService:

    def __init__(self, username, password, passcode):
        self.s = requests.Session()
        self.s.headers.update({'User-Agent':
                               'Mozilla/5.0 Home Assistant Climote Service'})
        self.config_id = None
        self.config = None
        self.logged_in = False
        self.creds = {'password': username, 'username': passcode, 'passcode':password}
        self.data = json.loads(_DEFAULT_JSON)
        self.zones = None

    def initialize(self):
        try:
            self.__login()
            self.__setConfig()
            self.__setZones()
            # if not self.__updateStatus(False):
            #    self.__updateStatus(True)
            return True if(self.config is not None) else False
        finally:
            self.__logout()

    def __login(self):
        r = self.s.post(_LOGIN_URL, data=self.creds)
        if(r.status_code == requests.codes.ok):
            # Parse the content
            soup = BeautifulSoup(r.content, "lxml")
            input = soup.find("input")  # First input has token "cs_token_rf"
            if (len(input['value']) < 2):
                return False
            self.logged_in = True
            self.token = input['value']
            _LOGGER.info("Token: %s", self.token)
            #anchors = soup.findAll("a", href=True)
            #for a in anchors:
            #    href = a['href']
            #    str = href
            #    if (str.startswith(_SCHEDULE_ELEMENT)):
            #        cut = str.find('&startday')
            #        str2 = str[:-(len(str)-cut)]
            #        self.config_id = str2[49:]
            #        _LOGGER.debug('heatingScheduleId:%s', self.config_id)
            
            #the link was commented in latest Climote page, doing simple read from page content
            str = r.text
            sched = str.find(_SCHEDULE_ELEMENT)
            if (sched):
                cut = str.find('&startday',sched)
                str2 = str[sched:-(len(str)-cut)]
                self.config_id = str2[49:]
                _LOGGER.debug('heatingScheduleId:%s', self.config_id)
            return self.logged_in

    def __logout(self):
        _LOGGER.info('Logging Out')
        r = self.s.get(_LOGOUT_URL)
        _LOGGER.debug('Logging Out Result: %s', r.status_code)
        return r.status_code == requests.codes.ok

    def boost(self, zoneid, time):
        _LOGGER.info('Boosting Zone %s', zoneid)
        return self.__boost(zoneid, time)

    def updateStatus(self, force):
        try:
            self.__login()
            self.__updateStatus(force)
        finally:
            self.__logout()

    def __updateStatus(self, force):
        def is_done(r):
            return r.text != '0'
        res = None
        tmp = self.s.headers
        try:
            # Make the initial request (force the update)
            if(force):
                r = self.s.post(_STATUS_FORCE_URL, data=self.creds)
            else:
                r = self.s.post(_STATUS_URL, data=self.creds)

            # Poll for the actual result. It happens over SMS so takes a while
            self.s.headers.update({'X-Requested-With': 'XMLHttpRequest'})
            r = polling.poll(
                lambda: self.s.post(_STATUS_RESPONSE_URL,
                                    data=self.creds),
                step=10,
                check_success=is_done,
                poll_forever=False,
                timeout=120
            )
            if(r.text == '0'):
                res = False
            else:
                self.data = json.loads(r.text)
                _LOGGER.info('Data Response %s', self.data)
                res = True
        except polling.TimeoutException:
            res = False
        finally:
            self.s.headers = tmp
        return res

    def __setConfig(self):
        if(self.logged_in is False):
            raise IllegalStateException("Not logged in")

        r = self.s.get(_GET_SCHEDULE_URL
                       + self.config_id)
        data = r.content
        xml = ET.fromstring(data)
        self.config = xmljson.parker.data(xml)
        _LOGGER.debug('Config:%s', self.config)

    def __setZones(self):
        if(self.config is None):
            return

        zones = {}
        i = 0
        _LOGGER.debug('zoneInfo: %s', self.config["zoneInfo"]["zone"])
        for zone in self.config["zoneInfo"]["zone"]:
            i += 1
            if(zone["active"] == 1):
                zones[i] = zone["label"]
        self.zones = zones

    def set_target_temperature(self, zone, temp):
        _LOGGER.debug('set_temperature zome:%s, temp:%s', zone, temp)
        res = False
        try:
            self.__login()
            data = {
                'temp-set-input[' + str(zone) + ']': temp,
                'do': 'Set',
                'cs_token_rf': self.token
            }
            r = self.s.post(_SET_TEMP_URL, data=data)
            _LOGGER.info('set_temperature: %d', r.status_code)
            res = r.status_code == requests.codes.ok
        finally:
            self.__logout()
        return res

    def __boost(self, zoneid, time):
        """Turn on the heat for a given zone, for a given number of hours"""
        res = False
        try:
            self.__login()
            data = {
                'zoneIds[' + str(zoneid) + ']': time,
                'cs_token_rf': self.token
            }
            r = self.s.post(_BOOST_URL, data=data)
            _LOGGER.info('Boosting Result: %d', r.status_code)
            res = r.status_code == requests.codes.ok
        finally:
            self.__logout()
        return res

1 Like

Thanks Andy, but it didn’t work.