Repository: XKNX/xknx

Issue:
Climate devices with setpoint_shift
While testing #175 @Julius2342 encountered below error which needs to be analysed and properly fixed.

```
2019-02-19 09:51:47 ERROR (MainThread) [homeassistant.core] Error doing job: Task exception was never retrieved
Traceback (most recent call last):
  File "/home/julius/home-assistant/homeassistant/helpers/service.py", line 291, in _handle_service_platform_call
    await func(entity, data)
  File "/home/julius/home-assistant/homeassistant/components/climate/__init__.py", line 564, in async_service_temperature_set
    await entity.async_set_temperature(**kwargs)
  File "/home/julius/home-assistant/homeassistant/components/knx/climate.py", line 238, in async_set_temperature
    await self.device.set_target_temperature(temperature)
  File "/home/julius/xknx/xknx/devices/climate.py", line 180, in set_target_temperature
    await self.set_target_temperature_setpoint_shift(target_temperature)
  File "/home/julius/xknx/xknx/devices/climate.py", line 191, in set_target_temperature_setpoint_shift
    raise DeviceIllegalValue("setpoint_shift_max exceeded", setpoint_shift)
xknx.exceptions.exception.DeviceIllegalValue: <DeviceIllegalValue description="setpoint_shift_max exceeded" value="68" />

```

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/22086036/badge)](https://coveralls.io/builds/22086036)

Coverage decreased (-0.2%) to 89.01% when pulling **4489376f1cfc3e75e34f6d2f01e96943f22d4b18 on farmio:climate-setpoint_shift** into **843be9da597d0aa8094a7d82e2dea94480b2c3af on XKNX:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/22086036/badge)](https://coveralls.io/builds/22086036)

Coverage decreased (-0.2%) to 89.01% when pulling **4489376f1cfc3e75e34f6d2f01e96943f22d4b18 on farmio:climate-setpoint_shift** into **843be9da597d0aa8094a7d82e2dea94480b2c3af on XKNX:master**.


Patch:
diff --git a/home-assistant-plugin/custom_components/xknx/climate.py b/home-assistant-plugin/custom_components/xknx/climate.py
index 71b9356f..938fab5b 100644
--- a/home-assistant-plugin/custom_components/xknx/climate.py
+++ b/home-assistant-plugin/custom_components/xknx/climate.py
@@ -56,11 +56,6 @@ OPERATION_MODES_INV = dict((
 
 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
     vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-    vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string,
-    vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): cv.string,
-    vol.Optional(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string,
-    vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string,
-    vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string,
     vol.Optional(CONF_SETPOINT_SHIFT_STEP,
                  default=DEFAULT_SETPOINT_SHIFT_STEP): vol.All(
                      float, vol.Range(min=0, max=2)),
@@ -68,6 +63,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
         vol.All(int, vol.Range(min=0, max=32)),
     vol.Optional(CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN):
         vol.All(int, vol.Range(min=-32, max=0)),
+    vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string,
+    vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): cv.string,
+    vol.Optional(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string,
+    vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string,
+    vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string,
     vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string,
     vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string,
     vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string,
@@ -211,7 +211,7 @@ class KNXClimate(ClimateDevice):
     @property
     def target_temperature_step(self):
         """Return the supported step of target temperature."""
-        return self.device.setpoint_shift_step
+        return self.device.temperature_step
 
     @property
     def target_temperature(self):
@@ -246,9 +246,10 @@ class KNXClimate(ClimateDevice):
     @property
     def operation_list(self):
         """Return the list of available operation modes."""
-        return [OPERATION_MODES.get(operation_mode.value) for
-                operation_mode in
-                self.device.mode.operation_modes]
+        _operations = [OPERATION_MODES.get(operation_mode.value) for
+                       operation_mode in
+                       self.device.mode.operation_modes]
+        return list(filter(None, _operations))
 
     async def async_set_operation_mode(self, operation_mode):
         """Set operation mode."""
diff --git a/xknx/devices/climate.py b/xknx/devices/climate.py
index ec81987a..12d450f6 100644
--- a/xknx/devices/climate.py
+++ b/xknx/devices/climate.py
@@ -4,7 +4,6 @@ Module for managing the climate within a room.
 * It reads/listens to a temperature address from KNX bus.
 * Manages and sends the desired setpoint to KNX bus.
 """
-from xknx.exceptions import DeviceIllegalValue
 from xknx.knx import GroupAddress
 
 from .climate_mode import ClimateMode
@@ -13,16 +12,61 @@ from .remote_value_1count import RemoteValue1Count
 from .remote_value_switch import RemoteValueSwitch
 from .remote_value_temp import RemoteValueTemp
 
+DEFAULT_SETPOINT_SHIFT_MAX = 6
+DEFAULT_SETPOINT_SHIFT_MIN = -6
+DEFAULT_SETPOINT_SHIFT_STEP = 0.5
+DEFAULT_TEMPERATUE_STEP = 0.1
+
+
+class SetpointShiftValue(RemoteValue1Count):
+    """Class for managing setpoint_shift values."""
+
+    def __init__(self,
+                 xknx,
+                 group_address=None,
+                 group_address_state=None,
+                 device_name=None,
+                 after_update_cb=None,
+                 setpoint_shift_step=DEFAULT_SETPOINT_SHIFT_STEP,
+                 min_temp_delta=DEFAULT_SETPOINT_SHIFT_MIN,
+                 max_temp_delta=DEFAULT_SETPOINT_SHIFT_MAX):
+        """Initialize SetpointShift class."""
+        # pylint: disable=too-many-arguments
+        super().__init__(xknx,
+                         group_address,
+                         group_address_state,
+                         device_name,
+                         after_update_cb)
+
+        self.setpoint_shift_step = setpoint_shift_step
+        self.min_temp_delta = min_temp_delta
+        self.max_temp_delta = max_temp_delta
+
+    @property
+    def value(self):
+        """Return current value in Kelvin."""
+        if super().value is None:
+            return None
+        return super().value * self.setpoint_shift_step
+
+    async def set(self, value):
+        """Set new value from Kelvin."""
+        if value > self.max_temp_delta:
+            self.xknx.logger.warning("setpoint_shift_max exceeded at %s: %s",
+                                     self.device_name, value)
+            value = self.max_temp_delta
+        if value < self.min_temp_delta:
+            self.xknx.logger.warning("setpoint_shift_min exceeded at %s: %s",
+                                     self.device_name, value)
+            value = self.min_temp_delta
+        steps = int(value / self.setpoint_shift_step)
+        await super().set(steps)
+
 
 class Climate(Device):
     """Class for managing the climate."""
 
     # pylint: disable=too-many-instance-attributes,invalid-name
-
-    DEFAULT_SETPOINT_SHIFT_STEP = 0.5
-    DEFAULT_SETPOINT_SHIFT_MAX = 6
-    DEFAULT_SETPOINT_SHIFT_MIN = -6
-
     def __init__(self,
                  xknx,
                  name,
@@ -59,22 +103,23 @@ class Climate(Device):
             group_address_state=group_address_temperature,
             device_name=self.name,
             after_update_cb=self.after_update)
+
         self.target_temperature = RemoteValueTemp(
             xknx,
             group_address_target_temperature,
             group_address_target_temperature_state,
             device_name=self.name,
             after_update_cb=self.after_update)
-        self.setpoint_shift = RemoteValue1Count(
+
+        self._setpoint_shift = SetpointShiftValue(
             xknx,
             group_address_setpoint_shift,
             group_address_setpoint_shift_state,
             device_name=self.name,
-            after_update_cb=self.after_update)
-
-        self.setpoint_shift_step = setpoint_shift_step
-        self.setpoint_shift_max = setpoint_shift_max
-        self.setpoint_shift_min = setpoint_shift_min
+            after_update_cb=self.after_update,
+            setpoint_shift_step=setpoint_shift_step,
+            min_temp_delta=setpoint_shift_min,
+            max_temp_delta=setpoint_shift_max)
 
         self.supports_on_off = \
             group_address_on_off is not None or \
@@ -104,11 +149,11 @@ class Climate(Device):
         group_address_setpoint_shift_state = \
             config.get('group_address_setpoint_shift_state')
         setpoint_shift_step = \
-            config.get('setpoint_shift_step', cls.DEFAULT_SETPOINT_SHIFT_STEP)
+            config.get('setpoint_shift_step', DEFAULT_SETPOINT_SHIFT_STEP)
         setpoint_shift_max = \
-            config.get('setpoint_shift_max', cls.DEFAULT_SETPOINT_SHIFT_MAX)
+            config.get('setpoint_shift_max', DEFAULT_SETPOINT_SHIFT_MAX)
         setpoint_shift_min = \
-            config.get('setpoint_shift_min', cls.DEFAULT_SETPOINT_SHIFT_MIN)
+            config.get('setpoint_shift_min', DEFAULT_SETPOINT_SHIFT_MIN)
         group_address_on_off = \
             config.get('group_address_on_off')
         group_address_on_off_state = \
@@ -145,7 +190,7 @@ class Climate(Device):
             return True
         return self.temperature.has_group_address(group_address) or \
             self.target_temperature.has_group_address(group_address) or \
-            self.setpoint_shift.has_group_address(group_address) or \
+            self._setpoint_shift.has_group_address(group_address) or \
             self.on.has_group_address(group_address)
 
     @property
@@ -165,9 +210,9 @@ class Climate(Device):
     @property
     def initialized_for_setpoint_shift_calculations(self):
         """Test if object is initialized for setpoint shift calculations."""
-        if not self.setpoint_shift.initialized:
+        if not self._setpoint_shift.initialized:
             return False
-        if self.setpoint_shift.value is None:
+        if self._setpoint_shift.value is None:
             return False
         if not self.target_temperature.initialized:
             return False
@@ -175,53 +220,71 @@ class Climate(Device):
             return False
         return True
 
+    @property
+    def temperature_step(self):
+        """Return smallest possible temperature step."""
+        if self._setpoint_shift.initialized:
+            return self._setpoint_shift.setpoint_shift_step
+        return DEFAULT_TEMPERATUE_STEP
+
     async def set_target_temperature(self, target_temperature):
-        """Calculate setpoint shift shift and send it to  KNX bus."""
+        """Send new target temperature or setpoint_shift to KNX bus."""
         if self.initialized_for_setpoint_shift_calculations:
-            await self.set_target_temperature_setpoint_shift(target_temperature)
-        # broadcast new target temperature and set internally
-        await self.target_temperature.set(target_temperature)
+            temperature_delta = target_temperature-self.base_temperature
+            await self.set_setpoint_shift(temperature_delta)
+        else:
+            await self.target_temperature.set(target_temperature)
 
-    async def set_target_temperature_setpoint_shift(self, target_temperature):
-        """Set target temperature via setpoint_shift group address."""
-        temperature_delta = target_temperature-self.target_temperature.value
-        setpoint_shift_delta = int(temperature_delta/self.setpoint_shift_step)
-        setpoint_shift = self.setpoint_shift.value + setpoint_shift_delta
-
-        if setpoint_shift > self.setpoint_shift_max:
-            raise DeviceIllegalValue("setpoint_shift_max exceeded", setpoint_shift)
-        if setpoint_shift < self.setpoint_shift_min:
-            raise DeviceIllegalValue("setpoint_shift_min exceeded", setpoint_shift)
+    @property
+    def base_temperature(self):
+        """
+        Return the base temperature.
+
+        Base temperature is the default temperature (setpoint-shift=0) for the active climate mode.
+        As this value is usually not available via KNX, we have to derive this from the current
+        target temperature and the current set point shift.
+        """
+        if self.initialized_for_setpoint_shift_calculations:
+            return self.target_temperature.value - self.setpoint_shift
+        return None
 
-        await self.setpoint_shift.set(setpoint_shift)
+    @property
+    def setpoint_shift(self):
+        """Return current offset from base temperature in Kelvin."""
+        return self._setpoint_shift.value
+
+    async def set_setpoint_shift(self, offset):
+        """Send new temperature offset to KNX bus."""
+        base_temperature = self.base_temperature
+        await self._setpoint_shift.set(offset)
+        # broadcast new target temperature and set internally
+        if self.target_temperature.writable and \
+                base_temperature is not None:
+            await self.target_temperature.set(base_temperature + self.setpoint_shift)
 
     @property
     def target_temperature_max(self):
-        """Return the maxium possible target temperature."""
+        """Return the highest possible target temperature."""
         if self.max_temp is not None:
             return self.max_temp
-        if not self.initialized_for_setpoint_shift_calculations:
-            return None
-        return (self.target_temperature.value -
-                self.setpoint_shift.value * self.setpoint_shift_step +
-                self.setpoint_shift_max * self.setpoint_shift_step)
+        if self.initialized_for_setpoint_shift_calculations:
+            return self.base_temperature + self._setpoint_shift.max_temp_delta
+        return None
 
     @property
     def target_temperature_min(self):
-        """Return the minimum possible target temperature."""
+        """Return the lowest possible target temperature."""
         if self.min_temp is not None:
             return self.min_temp
-        if not self.initialized_for_setpoint_shift_calculations:
-            return None
-        return (self.target_temperature.value -
-                self.setpoint_shift.value * self.setpoint_shift_step +
-                self.setpoint_shift_min * self.setpoint_shift_step)
+        if self.initialized_for_setpoint_shift_calculations:
+            return self.base_temperature + self._setpoint_shift.min_temp_delta
+        return None
 
     async def process_group_write(self, telegram):
         """Process incoming GROUP WRITE telegram."""
         await self.temperature.process(telegram)
         await self.target_temperature.process(telegram)
-        await self.setpoint_shift.process(telegram)
+        await self._setpoint_shift.process(telegram)
         await self.on.process(telegram)
         if self.mode is not None:
             await self.mode.process_group_write(telegram)
@@ -231,7 +294,7 @@ class Climate(Device):
         state_addresses = []
         state_addresses.extend(self.temperature.state_addresses())
         state_addresses.extend(self.target_temperature.state_addresses())
-        state_addresses.extend(self.setpoint_shift.state_addresses())
+        state_addresses.extend(self._setpoint_shift.state_addresses())
         if self.supports_on_off:
             state_addresses.extend(self.on.state_addresses())
         if self.mode is not None:
@@ -241,8 +304,8 @@ class Climate(Device):
     def __str__(self):
         """Return object as readable string."""
         return '<Climate name="{0}" ' \
-            'temperature="{1}"  ' \
-            'target_temperature="{2}"  ' \
+            'temperature="{1}" ' \
+            'target_temperature="{2}" ' \
             'setpoint_shift="{3}" ' \
             'setpoint_shift_step="{4}" ' \
             'setpoint_shift_max="{5}" ' \
@@ -253,10 +316,10 @@ class Climate(Device):
                 self.name,
                 self.temperature.group_addr_str(),
                 self.target_temperature.group_addr_str(),
-                self.setpoint_shift.group_addr_str(),
-                self.setpoint_shift_step,
-                self.setpoint_shift_max,
-                self.setpoint_shift_min,
+                self._setpoint_shift.group_addr_str(),
+                self._setpoint_shift.setpoint_shift_step,
+                self._setpoint_shift.max_temp_delta,
+                self._setpoint_shift.min_temp_delta,
                 self.on.group_addr_str())
 
     def __eq__(self, other):
diff --git a/xknx/devices/climate_mode.py b/xknx/devices/climate_mode.py
index f964f4d4..8e3d3fe0 100644
--- a/xknx/devices/climate_mode.py
+++ b/xknx/devices/climate_mode.py
@@ -251,16 +251,10 @@ class ClimateMode(Device):
         if self.supports_operation_mode:
             if self.group_address_operation_mode_state:
                 state_addresses.append(self.group_address_operation_mode_state)
-            elif self.group_address_operation_mode:
-                state_addresses.append(self.group_address_operation_mode)
             if self.group_address_controller_status_state:
                 state_addresses.append(self.group_address_controller_status_state)
-            elif self.group_address_controller_status:
-                state_addresses.append(self.group_address_controller_status)
             if self.group_address_controller_mode_state:
                 state_addresses.append(self.group_address_controller_mode_state)
-            elif self.group_address_controller_mode:
-                state_addresses.append(self.group_address_controller_mode)
             # Note: telegrams setting splitted up operation modes are not yet implemented
         return state_addresses
 
diff --git a/xknx/devices/remote_value.py b/xknx/devices/remote_value.py
index 262de78d..9e56c475 100644
--- a/xknx/devices/remote_value.py
+++ b/xknx/devices/remote_value.py
@@ -38,6 +38,11 @@ class RemoteValue():
         """Evaluate if remote value is initialized with group address."""
         return bool(self.group_address_state or self.group_address)
 
+    @property
+    def writable(self):
+        """Evaluate if remote value has a group_address set."""
+        return isinstance(self.group_address, GroupAddress)
+
     def has_group_address(self, group_address):
         """Test if device has given group address."""
         return group_address in [self.group_address, self.group_address_state]
@@ -98,8 +103,12 @@ class RemoteValue():
     async def set(self, value):
         """Set new value."""
         if not self.initialized:
-            self.xknx.logger.info("Setting value of uninitialized device %s (value %s)", self.device_name, value)
+            self.xknx.logger.info("Setting value of uninitialized device: %s (value: %s)", self.device_name, value)
             return
+        if not self.writable:
+            self.xknx.logger.warning("Attempted to set value for non-writable device: %s (value: %s)", self.device_name, value)
+            return
+
         payload = self.to_knx(value)  # pylint: disable=assignment-from-no-return
         updated = False
         if self.payload is None or payload != self.payload:
diff --git a/xknx/knx/dpt_hvac_contr_mode.py b/xknx/knx/dpt_hvac_contr_mode.py
index 0a553224..0f34788f 100644
--- a/xknx/knx/dpt_hvac_contr_mode.py
+++ b/xknx/knx/dpt_hvac_contr_mode.py
@@ -8,7 +8,7 @@ from .dpt_hvac_mode import HVACOperationMode
 
 class DPTHVACContrMode(DPTBase):
     """
-    Abstraction for KNX KNX HVAC mod.
+    Abstraction for KNX HVAC controller mode.
 
     DPT 20.105
     """
@@ -37,11 +37,11 @@ class DPTHVACContrMode(DPTBase):
         cls.test_bytesarray(raw, 1)
         if raw[0] in DPTHVACContrMode.SUPPORTED_MODES:
             return DPTHVACContrMode.SUPPORTED_MODES[raw[0]]
-        raise CouldNotParseKNXIP("Could not parse HVACOperationMode")
+        raise CouldNotParseKNXIP("Could not parse DPTHVACContrMode")
 
     @classmethod
     def to_knx(cls, value):
         """Serialize to KNX/IP raw data."""
         if value in DPTHVACContrMode.SUPPORTED_MODES_INV:
             return (DPTHVACContrMode.SUPPORTED_MODES_INV[value],)
-        raise ConversionError("Could not parse HVACOperationMode", value=value)
+        raise ConversionError("Could not parse DPTHVACContrMode", value=value)
diff --git a/xknx/knx/dpt_hvac_mode.py b/xknx/knx/dpt_hvac_mode.py
index 0f0954c5..b1b9db94 100644
--- a/xknx/knx/dpt_hvac_mode.py
+++ b/xknx/knx/dpt_hvac_mode.py
@@ -32,7 +32,7 @@ class HVACOperationMode(Enum):
 
 class DPTHVACMode(DPTBase):
     """
-    Abstraction for KNX KNX HVAC mod.
+    Abstraction for KNX HVAC mode.
 
     DPT 20.102
     """
@@ -107,4 +107,4 @@ class DPTControllerStatus(DPTBase):
             return (0x24,)
         if value == HVACOperationMode.FROST_PROTECTION:
             return (0x28,)
-        raise ConversionError("Could not parse HVACOperationMode", value=value)
+        raise ConversionError("Could not parse DPTControllerStatus", value=value)


Test patch:
diff --git a/test/devices_tests/climate_test.py b/test/devices_tests/climate_test.py
index 91118f1b..bd31820b 100644
--- a/test/devices_tests/climate_test.py
+++ b/test/devices_tests/climate_test.py
@@ -20,7 +20,6 @@ class TestClimate(unittest.TestCase):
     """Test class for Climate objects."""
 
     # pylint: disable=invalid-name,too-many-public-methods
-
     def setUp(self):
         """Set up test class."""
         self.loop = asyncio.new_event_loop()
@@ -132,6 +131,7 @@ class TestClimate(unittest.TestCase):
             xknx,
             name=None,
             group_address_operation_mode='1/2/5',
+            group_address_operation_mode_state='1/2/13',
             group_address_operation_mode_protection='1/2/6',
             group_address_operation_mode_night='1/2/7',
             group_address_operation_mode_comfort='1/2/8',
@@ -152,7 +152,7 @@ class TestClimate(unittest.TestCase):
             [GroupAddress("1/2/1"),
              GroupAddress("1/2/4"),
              GroupAddress("1/2/12"),
-             GroupAddress("1/2/5"),
+             GroupAddress("1/2/13"),
              GroupAddress("1/2/10")])
 
     #
@@ -182,7 +182,7 @@ class TestClimate(unittest.TestCase):
         after_update_callback.reset_mock()
 
         self.loop.run_until_complete(asyncio.Task(
-            climate.setpoint_shift.set(-2)))
+            climate.set_setpoint_shift(-2)))
         after_update_callback.assert_called_with(climate)
         after_update_callback.reset_mock()
 
@@ -397,7 +397,7 @@ class TestClimate(unittest.TestCase):
             'TestClimate',
             group_address_setpoint_shift='1/2/3')
         self.assertFalse(climate2.initialized_for_setpoint_shift_calculations)
-        self.loop.run_until_complete(asyncio.Task(climate2.setpoint_shift.set(4)))
+        self.loop.run_until_complete(asyncio.Task(climate2.set_setpoint_shift(4)))
         self.assertFalse(climate2.initialized_for_setpoint_shift_calculations)
 
         climate3 = Climate(
@@ -405,7 +405,7 @@ class TestClimate(unittest.TestCase):
             'TestClimate',
             group_address_target_temperature='1/2/2',
             group_address_setpoint_shift='1/2/3')
-        self.loop.run_until_complete(asyncio.Task(climate3.setpoint_shift.set(4)))
+        self.loop.run_until_complete(asyncio.Task(climate3.set_setpoint_shift(4)))
         self.assertFalse(climate3.initialized_for_setpoint_shift_calculations)
         self.loop.run_until_complete(asyncio.Task(climate3.target_temperature.set(23.00)))
         self.assertTrue(climate3.initialized_for_setpoint_shift_calculations)
@@ -449,7 +449,7 @@ class TestClimate(unittest.TestCase):
             group_address_setpoint_shift='1/2/3',
             max_temp='42',
             min_temp='3')
-        self.loop.run_until_complete(asyncio.Task(climate.setpoint_shift.set(4)))
+        self.loop.run_until_complete(asyncio.Task(climate.set_setpoint_shift(4)))
         self.assertFalse(climate.initialized_for_setpoint_shift_calculations)
         self.loop.run_until_complete(asyncio.Task(climate.target_temperature.set(23.00)))
         self.assertTrue(climate.initialized_for_setpoint_shift_calculations)
@@ -470,24 +470,26 @@ class TestClimate(unittest.TestCase):
             group_address_target_temperature='1/2/2',
             group_address_setpoint_shift='1/2/3')
 
-        self.loop.run_until_complete(asyncio.Task(climate.setpoint_shift.set(4)))
+        self.loop.run_until_complete(asyncio.Task(climate.set_setpoint_shift(3)))
         self.assertEqual(xknx.telegrams.qsize(), 1)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
-            Telegram(GroupAddress('1/2/3'), payload=DPTArray(4)))
+            # DEFAULT_SETPOINT_SHIFT_STEP is 0.5 -> payload = setpoint_shift * 2
+            Telegram(GroupAddress('1/2/3'), payload=DPTArray(6)))
 
         self.loop.run_until_complete(asyncio.Task(climate.target_temperature.set(23.00)))
         self.assertEqual(xknx.telegrams.qsize(), 1)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
             Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(23.00))))
+        self.assertEqual(climate.base_temperature, 20)
 
         # First change
         self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(24.00)))
         self.assertEqual(xknx.telegrams.qsize(), 2)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
-            Telegram(GroupAddress('1/2/3'), payload=DPTArray(6)))
+            Telegram(GroupAddress('1/2/3'), payload=DPTArray(8)))
         self.assertEqual(
             xknx.telegrams.get_nowait(),
             Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(24.00))))
@@ -498,18 +500,20 @@ class TestClimate(unittest.TestCase):
         self.assertEqual(xknx.telegrams.qsize(), 2)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
-            Telegram(GroupAddress('1/2/3'), payload=DPTArray(5)))
+            Telegram(GroupAddress('1/2/3'), payload=DPTArray(7)))
         self.assertEqual(
             xknx.telegrams.get_nowait(),
             Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(23.50))))
         self.assertEqual(climate.target_temperature.value, 23.50)
 
         # Test max target temperature
-        self.assertEqual(climate.target_temperature_max, 24.00)
+        # Base (20) - setpoint_shift_max (6)
+        self.assertEqual(climate.target_temperature_max, 26.00)
 
         # third change - limit exceeded, setting to max
-        with self.assertRaises(DeviceIllegalValue):
-            self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(24.50)))
+        self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(26.50)))
+        self.assertEqual(climate.target_temperature_max, 26.00)
+        self.assertEqual(climate.setpoint_shift, 6)
 
     def test_target_temperature_down(self):
         """Test decrease target temperature."""
@@ -521,46 +525,50 @@ class TestClimate(unittest.TestCase):
             group_address_target_temperature='1/2/2',
             group_address_setpoint_shift='1/2/3')
 
-        self.loop.run_until_complete(asyncio.Task(climate.setpoint_shift.set(1)))
+        self.loop.run_until_complete(asyncio.Task(climate.set_setpoint_shift(1)))
         self.assertEqual(xknx.telegrams.qsize(), 1)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
-            Telegram(GroupAddress('1/2/3'), payload=DPTArray(1)))
+            # DEFAULT_SETPOINT_SHIFT_STEP is 0.5 -> payload = setpoint_shift * 2
+            Telegram(GroupAddress('1/2/3'), payload=DPTArray(2)))
 
         self.loop.run_until_complete(asyncio.Task(climate.target_temperature.set(23.00)))
         self.assertEqual(xknx.telegrams.qsize(), 1)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
             Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(23.00))))
+        self.assertEqual(climate.base_temperature, 22.0)
 
         # First change
-        self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(21.00)))
+        self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(20.50)))
         self.assertEqual(xknx.telegrams.qsize(), 2)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
             Telegram(GroupAddress('1/2/3'), payload=DPTArray(0xFD)))  # -3
         self.assertEqual(
             xknx.telegrams.get_nowait(),
-            Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(21.00))))
-        self.assertEqual(climate.target_temperature.value, 21.00)
+            Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(20.50))))
+        self.assertEqual(climate.target_temperature.value, 20.50)
 
         # Second change
-        self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(19.50)))
+        self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(19.00)))
         self.assertEqual(xknx.telegrams.qsize(), 2)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
-            Telegram(GroupAddress('1/2/3'), payload=DPTArray(0xFA)))  # -3
+            Telegram(GroupAddress('1/2/3'), payload=DPTArray(0xFA)))  # -6
         self.assertEqual(
             xknx.telegrams.get_nowait(),
-            Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(19.50))))
-        self.assertEqual(climate.target_temperature.value, 19.50)
+            Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(19.00))))
+        self.assertEqual(climate.target_temperature.value, 19.00)
 
         # Test min target temperature
-        self.assertEqual(climate.target_temperature_min, 19.50)
+        # Base (22) - setpoint_shift_min (6)
+        self.assertEqual(climate.target_temperature_min, 16.00)
 
-        # third change - limit exceeded, setting to max
-        with self.assertRaises(DeviceIllegalValue):
-            self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(19.00)))
+        # third change - limit exceeded, setting to min
+        self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(15.50)))
+        self.assertEqual(climate.target_temperature_min, 16.00)
+        self.assertEqual(climate.setpoint_shift, -6)
 
     def test_target_temperature_modified_step(self):
         """Test increase target temperature with modified step size."""
@@ -572,34 +580,114 @@ class TestClimate(unittest.TestCase):
             group_address_target_temperature='1/2/2',
             group_address_setpoint_shift='1/2/3',
             setpoint_shift_step=0.1,
-            setpoint_shift_max=20,
-            setpoint_shift_min=-20)
+            setpoint_shift_max=10,
+            setpoint_shift_min=-10)
 
-        self.loop.run_until_complete(asyncio.Task(climate.setpoint_shift.set(10)))
+        self.loop.run_until_complete(asyncio.Task(climate.set_setpoint_shift(3)))
         self.assertEqual(xknx.telegrams.qsize(), 1)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
-            Telegram(GroupAddress('1/2/3'), payload=DPTArray(10)))
+            # setpoint_shift_step is 0.1 -> payload = setpoint_shift * 10
+            Telegram(GroupAddress('1/2/3'), payload=DPTArray(30)))
 
         self.loop.run_until_complete(asyncio.Task(climate.target_temperature.set(23.00)))
         self.assertEqual(xknx.telegrams.qsize(), 1)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
             Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(23.00))))
-
+        self.assertEqual(climate.base_temperature, 20.00)
         self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(24.00)))
         self.assertEqual(xknx.telegrams.qsize(), 2)
         self.assertEqual(
             xknx.telegrams.get_nowait(),
-            Telegram(GroupAddress('1/2/3'), payload=DPTArray(20)))
+            Telegram(GroupAddress('1/2/3'), payload=DPTArray(40)))
         self.assertEqual(
             xknx.telegrams.get_nowait(),
             Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(24.00))))
         self.assertEqual(climate.target_temperature.value, 24.00)
 
         # Test max/min target temperature
-        self.assertEqual(climate.target_temperature_max, 24.00)
-        self.assertEqual(climate.target_temperature_min, 20.00)
+        self.assertEqual(climate.target_temperature_max, 30.00)
+        self.assertEqual(climate.target_temperature_min, 10.00)
+
+    #
+    # TEST BASE TEMPERATURE
+    #
+    def test_base_temperature(self):
+        """Test base temperature."""
+        # pylint: disable=no-self-use
+        xknx = XKNX(loop=self.loop)
+        climate = Climate(
+            xknx,
+            'TestClimate',
+            group_address_target_temperature_state='1/2/1',
+            group_address_target_temperature='1/2/2',
+            group_address_setpoint_shift='1/2/3')
+
+        self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(21.00)))
+        self.assertEqual(xknx.telegrams.qsize(), 1)
+        self.assertEqual(
+            xknx.telegrams.get_nowait(),
+            Telegram(GroupAddress('1/2/2'), payload=DPTArray(DPT2ByteFloat().to_knx(21.00))))
+        self.assertFalse(climate.initialized_for_setpoint_shift_calculations)
+        self.assertEqual(climate.base_temperature, None)
+
+        # setpoint_shift initialized after target_temperature (no temperature change)
+        self.loop.run_until_complete(asyncio.Task(climate.set_setpoint_shift(1)))
+        self.assertEqual(xknx.telegrams.qsize(), 1)
+        self.assertEqual(
+            xknx.telegrams.get_nowait(),
+            # DEFAULT_SETPOINT_SHIFT_STEP is 0.5 -> payload = setpoint_shift * 2
+            Telegram(GroupAddress('1/2/3'), payload=DPTArray(2)))
+        self.assertTrue(climate.initialized_for_setpoint_shift_calculations)
+        self.assertEqual(climate.base_temperature, 20.00)
+
+        # setpoint_shift changed after initialisation
+        self.loop.run_until_complete(asyncio.Task(climate.set_setpoint_shift(2)))
+        # setpoint_shift and target_temperature are sent to the bus
+        self.assertEqual(xknx.telegrams.qsize(), 2)
+        self.assertEqual(
+            xknx.telegrams.get_nowait(),
+            # DEFAULT_SETPOINT_SHIFT_STEP is 0.5 -> payload = setpoint_shift * 2
+            Telegram(GroupAddress('1/2/3'), payload=DPTArray(4)))
+        self.assertTrue(climate.initialized_for_setpoint_shift_calculations)
+        self.assertEqual(climate.base_temperature, 20.00)
+        self.assertEqual(climate.target_temperature.value, 22)
+
+    #
+    # TEST TEMPERATURE STEP
+    #
+    def test_temperature_step(self):
+        """Test base temperature step."""
+        # pylint: disable=no-self-use
+        xknx = XKNX(loop=self.loop)
+        climate = Climate(
+            xknx,
+            'TestClimate',
+            group_address_target_temperature_state='1/2/1',
+            group_address_target_temperature='1/2/2')
+
+        self.loop.run_until_complete(asyncio.Task(climate.set_target_temperature(21.00)))
+        # default temperature_step for non setpoint_shift
+        self.assertEqual(climate.temperature_step, 0.1)
+
+        climate = Climate(
+            xknx,
+            'TestClimate',
+            group_address_target_temperature_state='1/2/1',
+            group_address_target_temperature='1/2/2',
+            group_address_setpoint_shift='1/2/3')
+        # default temperature_step for setpoint_shift
+        self.assertEqual(climate.temperature_step, 0.5)
+
+        climate = Climate(
+            xknx,
+            'TestClimate',
+            group_address_target_temperature_state='1/2/1',
+            group_address_target_temperature='1/2/2',
+            group_address_setpoint_shift='1/2/3',
+            setpoint_shift_step=0.3)
+        self.assertEqual(climate.temperature_step, 0.3)
 
     #
     # TEST SYNC
@@ -625,20 +713,46 @@ class TestClimate(unittest.TestCase):
             xknx,
             'TestClimate',
             group_address_operation_mode='1/2/3',
-            group_address_controller_status='1/2/4')
+            group_address_operation_mode_state='1/2/4')
         self.loop.run_until_complete(asyncio.Task(climate_mode.sync(False)))
-        self.assertEqual(xknx.telegrams.qsize(), 2)
+        self.assertEqual(xknx.telegrams.qsize(), 1)
         telegram1 = xknx.telegrams.get_nowait()
         self.assertEqual(
             telegram1,
-            Telegram(GroupAddress('1/2/3'), TelegramType.GROUP_READ))
-        telegram2 = xknx.telegrams.get_nowait()
-        self.assertEqual(
-            telegram2,
             Telegram(GroupAddress('1/2/4'), TelegramType.GROUP_READ))
 
+    def test_sync_controller_status(self):
+        """Test sync function / sending group reads to KNX bus for controller status."""
+        xknx = XKNX(loop=self.loop)
+        climate_mode = ClimateMode(
+            xknx,
+            'TestClimate',
+            group_address_operation_mode='1/2/23',
+            group_address_controller_status_state='1/2/24')
+        self.loop.run_until_complete(asyncio.Task(climate_mode.sync(False)))
+        self.assertEqual(xknx.telegrams.qsize(), 1)
+        telegram1 = xknx.telegrams.get_nowait()
+        self.assertEqual(
+            telegram1,
+            Telegram(GroupAddress('1/2/24'), TelegramType.GROUP_READ))
+
+    def test_sync_controller_mode(self):
+        """Test sync function / sending group reads to KNX bus for controller mode."""
+        xknx = XKNX(loop=self.loop)
+        climate_mode = ClimateMode(
+            xknx,
+            'TestClimate',
+            group_address_controller_mode='1/2/13',
+            group_address_controller_mode_state='1/2/14')
+        self.loop.run_until_complete(asyncio.Task(climate_mode.sync(False)))
+        self.assertEqual(xknx.telegrams.qsize(), 1)
+        telegram1 = xknx.telegrams.get_nowait()
+        self.assertEqual(
+            telegram1,
+            Telegram(GroupAddress('1/2/14'), TelegramType.GROUP_READ))
+
     def test_sync_operation_mode_state(self):
-        """Test sync function / sending group reads to KNX bus for operation mode with explicit state addresses."""
+        """Test sync function / sending group reads to KNX bus for multiple mode addresses."""
         xknx = XKNX(loop=self.loop)
         climate_mode = ClimateMode(
             xknx,
@@ -646,9 +760,11 @@ class TestClimate(unittest.TestCase):
             group_address_operation_mode='1/2/3',
             group_address_operation_mode_state='1/2/5',
             group_address_controller_status='1/2/4',
-            group_address_controller_status_state='1/2/6')
+            group_address_controller_status_state='1/2/6',
+            group_address_controller_mode='1/2/13',
+            group_address_controller_mode_state='1/2/14')
         self.loop.run_until_complete(asyncio.Task(climate_mode.sync(False)))
-        self.assertEqual(xknx.telegrams.qsize(), 2)
+        self.assertEqual(xknx.telegrams.qsize(), 3)
         telegram1 = xknx.telegrams.get_nowait()
         self.assertEqual(
             telegram1,
@@ -657,6 +773,10 @@ class TestClimate(unittest.TestCase):
         self.assertEqual(
             telegram2,
             Telegram(GroupAddress('1/2/6'), TelegramType.GROUP_READ))
+        telegram3 = xknx.telegrams.get_nowait()
+        self.assertEqual(
+            telegram3,
+            Telegram(GroupAddress('1/2/14'), TelegramType.GROUP_READ))
 
     #
     # TEST PROCESS
diff --git a/test/devices_tests/remote_value_test.py b/test/devices_tests/remote_value_test.py
index aadbb2c2..68752080 100644
--- a/test/devices_tests/remote_value_test.py
+++ b/test/devices_tests/remote_value_test.py
@@ -46,12 +46,20 @@ class TestRemoteValue(unittest.TestCase):
             mock_warn.assert_called_with('from_knx not implemented for %s', 'RemoteValue')
 
     def test_info_set_uninitialized(self):
-        """Test for warning if from_knx is not implemented."""
+        """Test for info if RemoteValue is not initialized."""
         xknx = XKNX(loop=self.loop)
         remote_value = RemoteValue(xknx)
         with patch('logging.Logger.info') as mock_info:
             self.loop.run_until_complete(asyncio.Task(remote_value.set(23)))
-            mock_info.assert_called_with('Setting value of uninitialized device %s (value %s)', 'Unknown', 23)
+            mock_info.assert_called_with('Setting value of uninitialized device: %s (value: %s)', 'Unknown', 23)
+
+    def test_info_set_unwritable(self):
+        """Test for warning if RemoteValue is read only."""
+        xknx = XKNX(loop=self.loop)
+        remote_value = RemoteValue(xknx, group_address_state=GroupAddress('1/2/3'))
+        with patch('logging.Logger.warning') as mock_info:
+            self.loop.run_until_complete(asyncio.Task(remote_value.set(23)))
+            mock_info.assert_called_with('Attempted to set value for non-writable device: %s (value: %s)', 'Unknown', 23)
 
     def test_default_value_unit(self):
         """Test for the default value of unit_of_measurement."""
diff --git a/test/str_test.py b/test/str_test.py
index 724672b1..a4680eb7 100644
--- a/test/str_test.py
+++ b/test/str_test.py
@@ -12,7 +12,8 @@ from xknx.exceptions import (
     CouldNotParseTelegram, DeviceIllegalValue)
 from xknx.io.gateway_scanner import GatewayDescriptor
 from xknx.knx import (
-    DPTArray, DPTBinary, GroupAddress, PhysicalAddress, Telegram)
+    DPTArray, DPTBinary, GroupAddress, PhysicalAddress, Telegram,
+    TelegramDirection)
 from xknx.knxip import (
     HPAI, CEMIFrame, ConnectionStateRequest, ConnectionStateResponse,
     ConnectRequest, ConnectRequestType, ConnectResponse, DIBDeviceInformation,
@@ -79,9 +80,11 @@ class TestStringRepresentations(unittest.TestCase):
             group_address_on_off_state='1/2/15')
         self.assertEqual(
             str(climate),
-            '<Climate name="Wohnzimmer" temperature="None/GroupAddress("1/2/1")/None/None"  target_temperature="GroupAddress("1/2/2")/None/None/'
-            'None"  setpoint_shift="GroupAddress("1/2/3")/GroupAddress("1/2/4")/None/None" setpoint_shift_step="0.1" setpoint_shift_max="20" set'
-            'point_shift_min="-20" group_address_on_off="GroupAddress("1/2/14")/GroupAddress("1/2/15")/None/None" />')
+            '<Climate name="Wohnzimmer" temperature="None/GroupAddress("1/2/1")/None/None" '
+            'target_temperature="GroupAddress("1/2/2")/None/None/None" '
+            'setpoint_shift="GroupAddress("1/2/3")/GroupAddress("1/2/4")/None/None" '
+            'setpoint_shift_step="0.1" setpoint_shift_max="20" setpoint_shift_min="-20" '
+            'group_address_on_off="GroupAddress("1/2/14")/GroupAddress("1/2/15")/None/None" />')
 
     def test_climate_mode(self):
         """Test string representation of climate mode object."""
@@ -220,7 +223,11 @@ class TestStringRepresentations(unittest.TestCase):
         self.assertEqual(
             str(sensor),
             '<Sensor name="MeinSensor" sensor="None/GroupAddress("1/2/3")/None/None" value="None" unit="%"/>')
-        self.loop.run_until_complete(asyncio.Task(sensor.sensor_value.set(25)))
+        # self.loop.run_until_complete(asyncio.Task(sensor.sensor_value.set(25)))
+        telegram = Telegram(group_address=GroupAddress('1/2/3'),
+                            direction=TelegramDirection.INCOMING,
+                            payload=DPTArray((0x40)))
+        self.loop.run_until_complete(asyncio.Task(sensor.process_group_write(telegram)))
         self.assertEqual(
             str(sensor),
             '<Sensor name="MeinSensor" sensor="None/GroupAddress("1/2/3")/<DPTArray value="[0x40]" />/25" value="25" unit="%"/>')