Creating a custom integration (Climote)

222

got it working1231 ! thank you!

This stopped working for me a while back and finally figured out why - I had to add a line saying

“version”: “1.0.0”

to manifest.json

Hey - I’ve tried adding this but I’m getting the same error message after adding the yaml to the config file. Can’t for the life of me figure out what I’m doing wrong.

image

image

@al.rdb how did you get this working?

This is the code from climote.py, I’m guessing something isn’t right in here that the integration isn’t being created?

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

I would say is a problem with configuration.yml. My config looks like this:

climate:
  - platform: climote
    username: !secret climote_user
    password: !secret climote_pass
    id: !secret climote_id
    refresh_interval: 4

That’s how I have mine set up too. The error I’m receiving is pointing towards the integration not being set up by the .py file for some reason.

@andy73 how does your init.py file look? Is it the same as this or am I missing lines?

"""Setup for CLimote"""

HI @Criticalan that is the content but the file name needs to be

__init__.py

I wish I would have some time to make a proper HACS integration out of this code :smiley:

1 Like

I can see now that your file name is correct, one thing that I do not have in my climote folder is the .gitignore. the pycache is probably created by the os…

I’ll see if removing the gitignore file helps. Outside of that, I’m stumped. Might be time to learn python properly and dig into the python code. *Cries in yaml"

https://drive.google.com/file/d/1f2DjbwI82o1oIQHfO7ve1OUxyrIVdpT8/view?usp=sharing

there is what I have so you can compare with yours.

1 Like

There must have been something wrong with the code I pulled from Github. Using your files, I was finally able to add the config and restart. However, I’m not seeing any new entities or devices yet so I’ll need to look at that this evening. Thanks for your help @andy73

I do not use those entities directly - I have created my own helpers and manage all via automation to call services to start and stop specific area

alias: Boost_bedrooms_on
description: ""
trigger:
  - platform: state
    entity_id: input_boolean.boost_bedrooms
    to: "on"
condition: []
action:
  - service: timer.start
    data:
      duration: "0"
    target:
      entity_id: timer.heating_bedrooms
  - service: climate.set_hvac_mode
    data:
      hvac_mode: heat
    entity_id: climate.bedrooms
mode: single

alias: Boost_bedrooms_off
description: ""
trigger:
  - platform: state
    entity_id: input_boolean.boost_bedrooms
    to: "off"
condition: []
action:
  - service: timer.finish
    data: {}
    entity_id: timer.heating_bedrooms
  - service: climate.set_hvac_mode
    data:
      hvac_mode: "off"
    entity_id: climate.bedrooms
mode: single

the new changes in the Climote app broke also our custom integration. Trying to figure out what we can change to get it back to work. I have also contacted Climote maybe they put a new API in place…

WIll keep in touch

1 Like

very odd, it started to work, it might be the high number of 504 gateway timeout errors that I see in the browser.
Also just to mention that the new Climote app is asking you to change the password. The password on the web and app seems different, I have set them both to be the same.

1 Like

hi Andy73 and Criticalan,

I had this integration working a year ago. Since then I reinstalled HA and didn’t set Climote up again. I know it was working for me before, but by following github, I’m getting same issue as Criticalan reported above: Creating a custom integration (Climote) - #44 by Criticalan
Screenshot 2022-09-29 103930

I also compared the files provided by Andy73 with the Github ones and found some differences.

climate.py: Andy’s on the right

manifest.json: Andy’s on the right

So I replaced the above Github files with Andy’s and got the same issue
Screenshot 2022-09-29 103930

Your help would be appreciated guys.

I’ve updated the last code I have which has that version in the manifest. climote.zip - Google Drive
You need also to re-install the application and then reset the password, log in on www.climote.ie and log in there with the old password and update it to use the same password like the app (for consistency).

my yml is:

climate:
  - platform: climote
    username: !secret climote_user
    password: !secret climote_pass
    id: !secret climote_id
    refresh_interval: 4

timer:
  heating_living:
    name: Boost first floor remaining
    duration: '00:30:00'
  heating_bedrooms:
    name: Boost bedrooms remaining
    duration: '00:30:00'
  heating_water:
    name: Boost water remaining
    duration: '00:30:00'

input_boolean:
  boost_living:
    name: Boost ground floor heating
    initial: off
  boost_bedrooms:
    name: Boost bedrooms heating
    initial: off
  boost_water:
    name: Boost water heating
    initial: off

Means you need to update your Climote app on the phone that would request a new password.