Repository: XKNX/xknx (python)

Issue:
Feature request: add the "ignore_internal_state" property to "Sensors" the same way it works already for "Binary Sensors"
Hi,

In the last release the property "ignore_internal_state" was added to the knx "binary sensor", and it would be great to have this property for knx "sensors" as well.

For example I am using a sensor with type "scene_number" together with an automation in order to execute some actions within HA when a knx scene number is called, but if the same scene is called two times without any other scene number in between, the value of the HA sensor state is not changed and the automation is not called again.

Note : the KNX scene entity is no help in this case as its state in HA does not change when triggered (it stays on "scening") and is only used the other way around (make HA to trigger a KNX scene).

Thank you very much in advance !

Pull request description:
Add always_callback option to Sensor

<!--
  You are awesome! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template!.
-->
## Description
<!--
Please include a summary of the change and which issue is fixed.
Please also include relevant motivation and context.
List any dependencies that are required for this change.
-->
Allow sensors to trigger the update callbacks even if the internal state did not change.
This feature is opt in and disabled per default.

Fixes #465

## Type of change
<!--
Please tick the applicable options.
NOTE: Ticking multiple options most likely indicates
that your change is to big and it is suggested to split it into several smaller PRs.
-->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

## Checklist:

- [x] I have performed a self-review of my own code
- [x] The documentation has been adjusted accordingly
- [x] The changes generate no new warnings
- [x] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog
- [x] The Homeassistant plugin has been adjusted in case of new config options


Patch:
diff --git a/changelog.md b/changelog.md
index 97c850a5..7fdd1b95 100644
--- a/changelog.md
+++ b/changelog.md
@@ -4,6 +4,7 @@
 
 ### Devices
 
+- Sensor: add `always_callback` option
 - ClimateMode: Refactor climate modes in operation_mode and controller_mode, also fixes a bug for binary operation modes where the mode would be set to AWAY no matter what value was sent to the bus.
 
 ### Internals
diff --git a/docs/sensor.md b/docs/sensor.md
index c741e111..22ae2581 100644
--- a/docs/sensor.md
+++ b/docs/sensor.md
@@ -15,6 +15,7 @@ Sensors are monitoring temperature, air humidity, pressure etc. from KNX bus.
     sensor = Sensor(
         xknx=xknx,
         name='DiningRoom.Temperature.Sensor',
+        always_callback=False,
         group_address_state='6/2/1',
         sync_state=True,
         value_type='temperature'
@@ -25,6 +26,7 @@ Sensors are monitoring temperature, air humidity, pressure etc. from KNX bus.
 
 * `xknx` is the XKNX object.
 * `name` is the name of the object.
+* `always_callback` defines if a callback/update should always be triggered no matter if the previous and the new state are identical.
 * `group_address_state` is the KNX group address of the sensor device.
 * `sync_state` defines if the value should be actively read from the bus. If `False` no GroupValueRead telegrams will be sent to its group address. Defaults to `True`
 * `value_type` controls how the value should be rendered in a human readable representation. The attribut may have may have the values `percent`, `temperature`, `illuminance`, `speed_ms` or `current`.
diff --git a/home-assistant-plugin/custom_components/xknx/factory.py b/home-assistant-plugin/custom_components/xknx/factory.py
index de552ff2..005d24d5 100644
--- a/home-assistant-plugin/custom_components/xknx/factory.py
+++ b/home-assistant-plugin/custom_components/xknx/factory.py
@@ -214,6 +214,7 @@ def _create_sensor(knx_module: XKNX, config: ConfigType) -> XknxSensor:
         name=config[CONF_NAME],
         group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
         sync_state=config[SensorSchema.CONF_SYNC_STATE],
+        always_callback=config[SensorSchema.CONF_ALWAYS_CALLBACK],
         value_type=config[CONF_TYPE],
     )
 
diff --git a/home-assistant-plugin/custom_components/xknx/schema.py b/home-assistant-plugin/custom_components/xknx/schema.py
index 9c628bed..d97a532a 100644
--- a/home-assistant-plugin/custom_components/xknx/schema.py
+++ b/home-assistant-plugin/custom_components/xknx/schema.py
@@ -306,6 +306,7 @@ class NotifySchema:
 class SensorSchema:
     """Voluptuous schema for KNX sensors."""
 
+    CONF_ALWAYS_CALLBACK = "always_callback"
     CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
     CONF_SYNC_STATE = CONF_SYNC_STATE
     DEFAULT_NAME = "KNX Sensor"
@@ -318,6 +319,7 @@ class SensorSchema:
                 cv.boolean,
                 cv.string,
             ),
+            vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean,
             vol.Required(CONF_STATE_ADDRESS): cv.string,
             vol.Required(CONF_TYPE): vol.Any(int, float, str),
         }
diff --git a/xknx/devices/sensor.py b/xknx/devices/sensor.py
index 1ac1471e..de62ec95 100644
--- a/xknx/devices/sensor.py
+++ b/xknx/devices/sensor.py
@@ -19,14 +19,15 @@ class Sensor(Device):
         xknx,
         name,
         group_address_state=None,
-        sync_state=True,
-        value_type=None,
+        sync_state: bool = True,
+        always_callback: bool = False,
+        value_type: str = None,
         device_updated_cb=None,
     ):
         """Initialize Sensor class."""
         # pylint: disable=too-many-arguments
         super().__init__(xknx, name, device_updated_cb)
-
+        self.always_callback = always_callback
         self.sensor_value = RemoteValueSensor(
             xknx,
             group_address_state=group_address_state,
@@ -45,6 +46,7 @@ class Sensor(Device):
         """Initialize object from configuration structure."""
         group_address_state = config.get("group_address_state")
         sync_state = config.get("sync_state", True)
+        always_callback = config.get("always_callback", False)
         value_type = config.get("value_type")
 
         return cls(
@@ -52,11 +54,16 @@ class Sensor(Device):
             name,
             group_address_state=group_address_state,
             sync_state=sync_state,
+            always_callback=always_callback,
             value_type=value_type,
         )
 
     async def process_group_write(self, telegram):
         """Process incoming and outgoing GROUP WRITE telegram."""
+        await self.sensor_value.process(telegram, always_callback=self.always_callback)
+
+    async def process_group_response(self, telegram):
+        """Process incoming GroupValueResponse telegrams."""
         await self.sensor_value.process(telegram)
 
     def unit_of_measurement(self):


Test patch:
diff --git a/test/devices_tests/sensor_test.py b/test/devices_tests/sensor_test.py
index 2043eb3c..5875ea4e 100644
--- a/test/devices_tests/sensor_test.py
+++ b/test/devices_tests/sensor_test.py
@@ -47,6 +47,55 @@ class TestSensor(unittest.TestCase):
         self.assertEqual(sensor.unit_of_measurement(), "K")
         self.assertEqual(sensor.ha_device_class(), None)
 
+    def test_always_callback_sensor(self):
+        """Test always callback sensor."""
+        xknx = XKNX()
+        sensor = Sensor(
+            xknx,
+            "TestSensor",
+            group_address_state="1/2/3",
+            always_callback=False,
+            value_type="volume_liquid_litre",
+        )
+
+        after_update_callback = Mock()
+
+        async def async_after_update_callback(device):
+            """Async callback."""
+            after_update_callback(device)
+
+        sensor.register_device_updated_cb(async_after_update_callback)
+
+        payload = DPTArray(
+            (
+                0x00,
+                0x00,
+                0x01,
+                0x00,
+            )
+        )
+
+        #  set initial payload of sensor
+        sensor.sensor_value.payload = payload
+
+        telegram = Telegram(group_address=GroupAddress("1/2/3"), payload=payload)
+
+        # verify not called when always_callback is False
+        self.loop.run_until_complete(sensor.process(telegram))
+        after_update_callback.assert_not_called()
+        after_update_callback.reset_mock()
+
+        sensor.always_callback = True
+
+        # verify called when always_callback is True
+        self.loop.run_until_complete(sensor.process(telegram))
+        after_update_callback.assert_called_once()
+        after_update_callback.reset_mock()
+
+        # verify not called when processing read responses
+        self.loop.run_until_complete(sensor.process_group_response(telegram))
+        after_update_callback.assert_not_called()
+
     def test_str_acceleration(self):
         """Test resolve state with acceleration sensor."""
         xknx = XKNX()