Repository: ikalchev/HAP-python

Issue:
Events are sent for characteristics that have not changed 
When `client_update_value` or `set_value` is called an event is generated even when the internal state hasn't changed. For cameras motion sensors this can result in 100x of events per hour per camera

Comments:

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 9478080..579fa74 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8, 3.9, "3.10.0-alpha.5"]
+        python-version: [3.6, 3.7, 3.8, 3.9]
 
     steps:
     - uses: actions/checkout@v1
diff --git a/pyhap/accessory.py b/pyhap/accessory.py
index b59eb73..3acc3ce 100644
--- a/pyhap/accessory.py
+++ b/pyhap/accessory.py
@@ -283,7 +283,7 @@ class Accessory:
 
     # Driver
 
-    def publish(self, value, sender, sender_client_addr=None):
+    def publish(self, value, sender, sender_client_addr=None, immediate=False):
         """Append AID and IID of the sender and forward it to the driver.
 
         Characteristics call this method to send updates.
@@ -299,7 +299,7 @@ class Accessory:
             HAP_REPR_IID: self.iid_manager.get_iid(sender),
             HAP_REPR_VALUE: value,
         }
-        self.driver.publish(acc_data, sender_client_addr)
+        self.driver.publish(acc_data, sender_client_addr, immediate)
 
 
 class Bridge(Accessory):
diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py
index 8ca9540..fa67658 100644
--- a/pyhap/accessory_driver.py
+++ b/pyhap/accessory_driver.py
@@ -467,7 +467,7 @@ class AccessoryDriver:
         for topic in client_topics:
             self.async_subscribe_client_topic(client, topic, subscribe=False)
 
-    def publish(self, data, sender_client_addr=None):
+    def publish(self, data, sender_client_addr=None, immediate=False):
         """Publishes an event to the client.
 
         The publishing occurs only if the current client is subscribed to the topic for
@@ -482,14 +482,14 @@ class AccessoryDriver:
             return
 
         if threading.current_thread() == self.tid:
-            self.async_send_event(topic, data, sender_client_addr)
+            self.async_send_event(topic, data, sender_client_addr, immediate)
             return
 
         self.loop.call_soon_threadsafe(
-            self.async_send_event, topic, data, sender_client_addr
+            self.async_send_event, topic, data, sender_client_addr, immediate
         )
 
-    def async_send_event(self, topic, data, sender_client_addr):
+    def async_send_event(self, topic, data, sender_client_addr, immediate):
         """Send an event to a client.
 
         Must be called in the event loop
@@ -513,8 +513,8 @@ class AccessoryDriver:
                     client_addr,
                 )
                 continue
-            logger.debug("Sending event to client: %s", client_addr)
-            pushed = self.http_server.push_event(data, client_addr)
+            logger.debug("Sending event to client: %s, immediate: %s", client_addr, immediate)
+            pushed = self.http_server.push_event(data, client_addr, immediate)
             if not pushed:
                 logger.debug(
                     "Could not send event to %s, probably stale socket.", client_addr
diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py
index 8693923..3d7a020 100644
--- a/pyhap/characteristic.py
+++ b/pyhap/characteristic.py
@@ -6,6 +6,9 @@ a temperature measuring or a device status.
 """
 import logging
 
+from uuid import UUID
+
+
 from pyhap.const import (
     HAP_PERMISSION_READ,
     HAP_REPR_DESC,
@@ -78,6 +81,20 @@ PROP_VALID_VALUES = "ValidValues"
 
 PROP_NUMERIC = (PROP_MAX_VALUE, PROP_MIN_VALUE, PROP_MIN_STEP, PROP_UNIT)
 
+CHAR_BUTTON_EVENT = UUID("00000126-0000-1000-8000-0026BB765291")
+CHAR_PROGRAMMABLE_SWITCH_EVENT = UUID("00000073-0000-1000-8000-0026BB765291")
+
+
+IMMEDIATE_NOTIFY = {
+    CHAR_BUTTON_EVENT,  # Button Event
+    CHAR_PROGRAMMABLE_SWITCH_EVENT,  # Programmable Switch Event
+}
+
+# Special case, Programmable Switch Event always have a null value
+ALWAYS_NULL = {
+    CHAR_PROGRAMMABLE_SWITCH_EVENT,  # Programmable Switch Event
+}
+
 
 class CharacteristicError(Exception):
     """Generic exception class for characteristic errors."""
@@ -138,6 +155,9 @@ class Characteristic:
 
     def _get_default_value(self):
         """Return default value for format."""
+        if self.type_id in ALWAYS_NULL:
+            return None
+
         if self.properties.get(PROP_VALID_VALUES):
             return min(self.properties[PROP_VALID_VALUES].values())
 
@@ -198,6 +218,10 @@ class Characteristic:
         if valid_values:
             self.properties[PROP_VALID_VALUES] = valid_values
 
+        if self.type_id in ALWAYS_NULL:
+            self.value = None
+            return
+
         try:
             self.value = self.to_valid_value(self.value)
         except ValueError:
@@ -224,9 +248,12 @@ class Characteristic:
         """
         logger.debug("set_value: %s to %s", self.display_name, value)
         value = self.to_valid_value(value)
+        changed = self.value != value
         self.value = value
-        if should_notify and self.broker:
+        if changed and should_notify and self.broker:
             self.notify()
+        if self.type_id in ALWAYS_NULL:
+            self.value = None
 
     def client_update_value(self, value, sender_client_addr=None):
         """Called from broker for value change in Home app.
@@ -239,11 +266,15 @@ class Characteristic:
             value,
             sender_client_addr,
         )
+        changed = self.value != value
         self.value = value
-        self.notify(sender_client_addr)
+        if changed:
+            self.notify(sender_client_addr)
         if self.setter_callback:
             # pylint: disable=not-callable
             self.setter_callback(value)
+        if self.type_id in ALWAYS_NULL:
+            self.value = None
 
     def notify(self, sender_client_addr=None):
         """Notify clients about a value change. Sends the value.
@@ -251,7 +282,8 @@ class Characteristic:
         .. seealso:: accessory.publish
         .. seealso:: accessory_driver.publish
         """
-        self.broker.publish(self.value, self, sender_client_addr)
+        immediate = self.type_id in IMMEDIATE_NOTIFY
+        self.broker.publish(self.value, self, sender_client_addr, immediate)
 
     # pylint: disable=invalid-name
     def to_HAP(self):
diff --git a/pyhap/hap_protocol.py b/pyhap/hap_protocol.py
index 623c5d9..a84b2ac 100644
--- a/pyhap/hap_protocol.py
+++ b/pyhap/hap_protocol.py
@@ -9,9 +9,12 @@ import time
 from cryptography.exceptions import InvalidTag
 import h11
 
+from pyhap.accessory import get_topic
+from pyhap.const import HAP_REPR_AID, HAP_REPR_IID
+
 from .hap_crypto import HAPCrypto
-from .hap_handler import HAPResponse, HAPServerHandler
 from .hap_event import create_hap_event
+from .hap_handler import HAPResponse, HAPServerHandler
 
 logger = logging.getLogger(__name__)
 
@@ -23,6 +26,8 @@ HIGH_WRITE_BUFFER_SIZE = 2 ** 19
 # reopen homekit.
 IDLE_CONNECTION_TIMEOUT_SECONDS = 90 * 60 * 60
 
+EVENT_COALESCE_TIME_WINDOW = 0.5
+
 
 class HAPServerProtocol(asyncio.Protocol):
     """A asyncio.Protocol implementing the HAP protocol."""
@@ -42,6 +47,7 @@ class HAPServerProtocol(asyncio.Protocol):
 
         self.last_activity = None
         self.hap_crypto = None
+        self._event_timer = None
         self._event_queue = []
 
     def connection_lost(self, exc: Exception) -> None:
@@ -90,10 +96,15 @@ class HAPServerProtocol(asyncio.Protocol):
             del self.connections[self.peername]
         self.transport.close()
 
-    def queue_event(self, data: dict) -> None:
+    def queue_event(self, data: dict, immediate: bool) -> None:
         """Queue an event for sending."""
         self._event_queue.append(data)
-        self.loop.call_soon(self._process_events)
+        if immediate:
+            self.loop.call_soon(self._send_events)
+        elif not self._event_timer:
+            self._event_timer = self.loop.call_later(
+                EVENT_COALESCE_TIME_WINDOW, self._send_events
+            )
 
     def send_response(self, response: HAPResponse) -> None:
         """Send a HAPResponse object."""
@@ -162,17 +173,31 @@ class HAPServerProtocol(asyncio.Protocol):
                 if self.conn.our_state is h11.MUST_CLOSE:
                     self.finish_and_close()
                     return
-            self._send_events()
         except h11.ProtocolError as protocol_ex:
             self._handle_invalid_conn_state(protocol_ex)
 
     def _send_events(self):
         """Send any pending events."""
+        if self._event_timer:
+            self._event_timer.cancel()
+            self._event_timer = None
         if not self._event_queue:
             return
-        self.write(create_hap_event(self._event_queue))
+        subscribed_events = self._event_queue_with_active_subscriptions()
+        if subscribed_events:
+            self.write(create_hap_event(subscribed_events))
         self._event_queue = []
 
+    def _event_queue_with_active_subscriptions(self):
+        """Remove any topics that have been unsubscribed after the event was generated."""
+        topics = self.accessory_driver.topics
+        return [
+            event
+            for event in self._event_queue
+            if self.peername
+            in topics.get(get_topic(event[HAP_REPR_AID], event[HAP_REPR_IID]), [])
+        ]
+
     def _process_one_event(self) -> bool:
         """Process one http event."""
         event = self.conn.next_event()
diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py
index 28d2587..5016355 100644
--- a/pyhap/hap_server.py
+++ b/pyhap/hap_server.py
@@ -70,7 +70,7 @@ class HAPServer:
         self.server.close()
         self.connections.clear()
 
-    def push_event(self, data, client_addr):
+    def push_event(self, data, client_addr, immediate=False):
         """Queue an event to the current connection with the provided data.
 
         :param data: The charateristic changes
@@ -86,5 +86,5 @@ class HAPServer:
         if hap_server_protocol is None:
             logger.debug("No socket for %s", client_addr)
             return False
-        hap_server_protocol.queue_event(data)
+        hap_server_protocol.queue_event(data, immediate)
         return True


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 957924d..20476b5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -49,7 +49,7 @@ class MockDriver:
     def __init__(self):
         self.loader = Loader()
 
-    def publish(self, data, client_addr=None):
+    def publish(self, data, client_addr=None, immediate=False):
         pass
 
     def add_job(self, target, *args):  # pylint: disable=no-self-use
diff --git a/tests/test_accessory.py b/tests/test_accessory.py
index 23a032d..2c20d06 100644
--- a/tests/test_accessory.py
+++ b/tests/test_accessory.py
@@ -12,6 +12,7 @@ from pyhap.const import (
     CATEGORY_CAMERA,
     CATEGORY_TARGET_CONTROLLER,
     CATEGORY_TELEVISION,
+    HAP_REPR_VALUE,
     STANDALONE_AID,
 )
 from pyhap.service import Service
@@ -460,3 +461,18 @@ async def test_bridge_run_stop():
         await bridge.stop()
     assert acc.stopped is True
     assert acc2.stopped is True
+
+
+def test_acc_with_(mock_driver):
+    """Test ProgrammableSwitchEvent is always None."""
+    acc = Accessory(mock_driver, "Test Accessory")
+    serv_stateless_switch = acc.add_preload_service("StatelessProgrammableSwitch")
+    char_doorbell_detected_switch = serv_stateless_switch.configure_char(
+        "ProgrammableSwitchEvent",
+        value=0,
+        valid_values={"SinglePress": 0},
+    )
+    char_doorbell_detected_switch.client_update_value(0)
+    assert char_doorbell_detected_switch.to_HAP()[HAP_REPR_VALUE] is None
+    char_doorbell_detected_switch.client_update_value(None)
+    assert char_doorbell_detected_switch.to_HAP()[HAP_REPR_VALUE] is None
diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py
index dc1db1d..a07281f 100644
--- a/tests/test_accessory_driver.py
+++ b/tests/test_accessory_driver.py
@@ -555,7 +555,7 @@ def test_send_events(driver):
     class HapServerMock:
         pushed_events = set()
 
-        def push_event(self, bytedata, client_addr):
+        def push_event(self, bytedata, client_addr, immediate):
             self.pushed_events.add((bytedata, client_addr))
             if client_addr == "client2":
                 return False
@@ -567,7 +567,7 @@ def test_send_events(driver):
     driver.http_server = HapServerMock()
     driver.loop = LoopMock()
     driver.topics = {"mocktopic": {"client1", "client2", "client3"}}
-    driver.async_send_event("mocktopic", "bytedata", "client1")
+    driver.async_send_event("mocktopic", "bytedata", "client1", True)
 
     # Only client2 and client3 get the event when client1 sent it
     assert driver.http_server.get_pushed_events() == {
diff --git a/tests/test_characteristic.py b/tests/test_characteristic.py
index 2d6e871..4882de8 100644
--- a/tests/test_characteristic.py
+++ b/tests/test_characteristic.py
@@ -1,5 +1,5 @@
 """Tests for pyhap.characteristic."""
-from unittest.mock import ANY, Mock, patch
+from unittest.mock import ANY, MagicMock, Mock, patch
 from uuid import uuid1
 
 import pytest
@@ -8,6 +8,7 @@ from pyhap.characteristic import (
     HAP_FORMAT_DEFAULTS,
     HAP_FORMAT_INT,
     HAP_PERMISSION_READ,
+    CHAR_PROGRAMMABLE_SWITCH_EVENT,
     Characteristic,
 )
 
@@ -123,6 +124,62 @@ def test_set_value():
         assert char.value == 3
         assert mock_notify.call_count == 1
 
+        # No change should not generate another notify
+        char.set_value(3)
+        assert char.value == 3
+        assert mock_notify.call_count == 1
+
+
+def test_set_value_immediate():
+    """Test setting the value of a characteristic generates immediate notify."""
+    char = Characteristic(
+        display_name="Switch Event",
+        type_id=CHAR_PROGRAMMABLE_SWITCH_EVENT,
+        properties=PROPERTIES.copy(),
+    )
+    assert char.value is None
+
+    publish_mock = Mock()
+    char.broker = Mock(publish=publish_mock)
+
+    char.set_value(0)
+    assert char.value is None
+    publish_mock.assert_called_with(0, char, None, True)
+
+    char.set_value(1)
+    assert char.value is None
+    publish_mock.assert_called_with(1, char, None, True)
+
+
+def test_switch_event_always_serializes_to_null_via_set_value():
+    """Test that the switch event char is always null."""
+    char = Characteristic(
+        display_name="Switch Event",
+        type_id=CHAR_PROGRAMMABLE_SWITCH_EVENT,
+        properties=PROPERTIES.copy(),
+    )
+    assert char.value is None
+    char.broker = MagicMock()
+
+    assert char.to_HAP()["value"] is None
+    char.set_value(1)
+    assert char.to_HAP()["value"] is None
+
+
+def test_switch_event_always_serializes_to_null_via_client_update_value():
+    """Test that the switch event char is always null."""
+    char = Characteristic(
+        display_name="Switch Event",
+        type_id=CHAR_PROGRAMMABLE_SWITCH_EVENT,
+        properties=PROPERTIES.copy(),
+    )
+    assert char.value is None
+    char.broker = MagicMock()
+
+    assert char.to_HAP()["value"] is None
+    char.client_update_value(1)
+    assert char.to_HAP()["value"] is None
+
 
 def test_client_update_value():
     """Test updating the characteristic value with call from the driver."""
@@ -143,6 +200,27 @@ def test_client_update_value():
         char.client_update_value(9, "mock_client_addr")
         assert char.value == 9
         mock_notify.assert_called_once_with("mock_client_addr")
+        assert len(mock_notify.mock_calls) == 1
+
+        # Same value, do not call again
+        char.client_update_value(9, "mock_client_addr")
+        assert char.value == 9
+        assert len(mock_notify.mock_calls) == 1
+
+        # New value, should notify
+        char.client_update_value(12, "mock_client_addr")
+        assert char.value == 12
+        assert len(mock_notify.mock_calls) == 2
+
+        # Same value, do not call again
+        char.client_update_value(12, "mock_client_addr")
+        assert char.value == 12
+        assert len(mock_notify.mock_calls) == 2
+
+        # New value, should notify
+        char.client_update_value(9, "mock_client_addr")
+        assert char.value == 9
+        assert len(mock_notify.mock_calls) == 3
 
 
 def test_notify():
@@ -155,11 +233,11 @@ def test_notify():
 
     with patch.object(char, "broker") as mock_broker:
         char.notify()
-    mock_broker.publish.assert_called_with(2, char, None)
+    mock_broker.publish.assert_called_with(2, char, None, False)
 
     with patch.object(char, "broker") as mock_broker:
         char.notify("mock_client_addr")
-    mock_broker.publish.assert_called_with(2, char, "mock_client_addr")
+    mock_broker.publish.assert_called_with(2, char, "mock_client_addr", False)
 
 
 def test_to_HAP_numberic():
diff --git a/tests/test_hap_server.py b/tests/test_hap_server.py
index 58b3457..e13a836 100644
--- a/tests/test_hap_server.py
+++ b/tests/test_hap_server.py
@@ -83,7 +83,7 @@ async def test_idle_connection_cleanup():
 async def test_push_event(driver):
     """Test we can create and send an event."""
     addr_info = ("1.2.3.4", 1234)
-    server = hap_server.HAPServer(addr_info, driver)
+    server = hap_server.HAPServer(("127.0.01", 5555), driver)
     server.loop = asyncio.get_event_loop()
     hap_events = []
 
@@ -94,17 +94,58 @@ async def test_push_event(driver):
         server.loop, server.connections, server.accessory_handler
     )
     hap_server_protocol.write = _save_event
+    hap_server_protocol.peername = addr_info
+    server.accessory_handler.topics["1.33"] = {addr_info}
+    server.accessory_handler.topics["2.33"] = {addr_info}
+    server.accessory_handler.topics["3.33"] = {addr_info}
 
-    assert server.push_event({"aid": 1}, addr_info) is False
+    assert server.push_event({"aid": 1, "iid": 33, "value": False}, addr_info) is False
     await asyncio.sleep(0)
     server.connections[addr_info] = hap_server_protocol
 
-    assert server.push_event({"aid": 1}, addr_info) is True
-    assert server.push_event({"aid": 2}, addr_info) is True
-    assert server.push_event({"aid": 3}, addr_info) is True
+    assert (
+        server.push_event({"aid": 1, "iid": 33, "value": False}, addr_info, True)
+        is True
+    )
+    assert (
+        server.push_event({"aid": 2, "iid": 33, "value": False}, addr_info, True)
+        is True
+    )
+    assert (
+        server.push_event({"aid": 3, "iid": 33, "value": False}, addr_info, True)
+        is True
+    )
 
     await asyncio.sleep(0)
     assert hap_events == [
-        b"EVENT/1.0 200 OK\r\nContent-Type: application/hap+json\r\nContent-Length: 51\r\n\r\n"
-        b'{"characteristics":[{"aid":1},{"aid":2},{"aid":3}]}'
+        b"EVENT/1.0 200 OK\r\nContent-Type: application/hap+json\r\nContent-Length: 120\r\n\r\n"
+        b'{"characteristics":[{"aid":1,"iid":33,"value":false},'
+        b'{"aid":2,"iid":33,"value":false},{"aid":3,"iid":33,"value":false}]}'
+    ]
+
+    hap_events = []
+    assert (
+        server.push_event({"aid": 1, "iid": 33, "value": False}, addr_info, False)
+        is True
+    )
+    assert (
+        server.push_event({"aid": 2, "iid": 33, "value": False}, addr_info, False)
+        is True
+    )
+    assert (
+        server.push_event({"aid": 3, "iid": 33, "value": False}, addr_info, False)
+        is True
+    )
+
+    await asyncio.sleep(0)
+    assert hap_events == []
+
+    # Ensure that a the event is not sent if its unsubscribed during
+    # the coalesce delay
+    server.accessory_handler.topics["1.33"].remove(addr_info)
+
+    await asyncio.sleep(0.55)
+    assert hap_events == [
+        b"EVENT/1.0 200 OK\r\nContent-Type: application/hap+json\r\nContent-Length: 87\r\n\r\n"
+        b'{"characteristics":[{"aid":2,"iid":33,"value":false},{"aid":3,"iid":33,"value":false}]}'
     ]