Repository: theolind/pymysensors

Issue:
Add support for I_PRE_SLEEP_NOTIFICATION in 2.2.0
Version 2.2.0 changed the behavior of smartsleep. Instead of sending a hearbeat before going back to sleep, the node now sends a `I_PRE_SLEEP_NOTIFICATION` internal message. The node also sends a `I_POST_SLEEP_NOTIFICATION` after waking up from sleep. See:
https://github.com/mysensors/MySensors/pull/722

**Breaking change**
The change outlined above broke the smartsleep feature in pymysensors for users using version 2.2.0 of mysensors.

**Suggested fix**
Use different internal message types for smartsleep method for different mysensors versions. Make a new const module and update these lines:
https://github.com/theolind/pymysensors/blob/dev/mysensors/const_20.py#L372-L373

Comments:

Patch:
diff --git a/mysensors/__init__.py b/mysensors/__init__.py
index 520165a..ef9dec3 100644
--- a/mysensors/__init__.py
+++ b/mysensors/__init__.py
@@ -31,6 +31,10 @@ def get_const(protocol_version):
     version = protocol_version
     if parse_ver('1.5') <= parse_ver(version) < parse_ver('2.0'):
         path = 'mysensors.const_15'
+    elif parse_ver(version) >= parse_ver('2.2'):
+        path = 'mysensors.const_22'
+    elif parse_ver(version) >= parse_ver('2.1'):
+        path = 'mysensors.const_21'
     elif parse_ver(version) >= parse_ver('2.0'):
         path = 'mysensors.const_20'
     else:
@@ -127,8 +131,8 @@ class Gateway(object):
                 type=self.const.MessageType.set, payload=value)
         return None
 
-    def _handle_heartbeat(self, msg):
-        """Process a heartbeat message."""
+    def _handle_smartsleep(self, msg):
+        """Process a message before going back to smartsleep."""
         if not self.is_sensor(msg.node_id):
             return
         while self.sensors[msg.node_id].queue:
@@ -411,8 +415,8 @@ class Gateway(object):
         thread has sent all previously queued commands to the FIFO queue.
         If the sensor attribute new_state returns True, the command will not be
         put on the queue, but the internal sensor state will be updated. When a
-        heartbeat response is received, the internal state will be pushed to
-        the sensor, via _handle_heartbeat method.
+        smartsleep message is received, the internal state will be pushed to
+        the sensor, via _handle_smartsleep method.
         """
         if not self.is_sensor(sensor_id, child_id):
             return
diff --git a/mysensors/const_20.py b/mysensors/const_20.py
index 98ff4fd..a27b6cd 100644
--- a/mysensors/const_20.py
+++ b/mysensors/const_20.py
@@ -1,4 +1,4 @@
-"""MySensors constants for version 1.5 of MySensors."""
+"""MySensors constants for version 2.0 of MySensors."""
 from enum import IntEnum
 
 import voluptuous as vol
@@ -246,11 +246,6 @@ class Internal(IntEnum):
     I_REGISTRATION_REQUEST = 26  # Register request to GW
     I_REGISTRATION_RESPONSE = 27  # Register response from GW
     I_DEBUG = 28  # Debug message
-    I_SIGNAL_REPORT_REQUEST = 29  # Device signal strength request
-    I_SIGNAL_REPORT_REVERSE = 30  # Internal
-    I_SIGNAL_REPORT_RESPONSE = 31  # Device signal strength response (RSSI)
-    I_PRE_SLEEP_NOTIFICATION = 32  # Message sent before node is going to sleep
-    I_POST_SLEEP_NOTIFICATION = 33  # Message sent after node woke up
 
 
 class Stream(IntEnum):
@@ -344,15 +339,6 @@ VALID_INTERNAL.update({
     Internal.I_REGISTRATION_REQUEST: str,
     Internal.I_REGISTRATION_RESPONSE: str,
     Internal.I_DEBUG: str,
-    Internal.I_SIGNAL_REPORT_REQUEST: str,
-    Internal.I_SIGNAL_REPORT_REVERSE: vol.All(
-        vol.Coerce(int), vol.Coerce(str)),
-    Internal.I_SIGNAL_REPORT_RESPONSE: vol.All(
-        vol.Coerce(int), vol.Coerce(str)),
-    Internal.I_PRE_SLEEP_NOTIFICATION: vol.All(
-        vol.Coerce(int), vol.Coerce(str)),
-    Internal.I_POST_SLEEP_NOTIFICATION: vol.All(
-        vol.Coerce(int), vol.Coerce(str)),
 })
 
 VALID_PAYLOADS = {
@@ -370,7 +356,7 @@ HANDLE_INTERNAL.update({
             'node_id': 255, 'ack': 0, 'sub_type': Internal.I_DISCOVER,
             'payload': ''}},
     Internal.I_HEARTBEAT_RESPONSE: {
-        'fun': '_handle_heartbeat'},
+        'fun': '_handle_smartsleep'},
     Internal.I_DISCOVER_RESPONSE: {
         'is_sensor': True},
 })
diff --git a/mysensors/const_21.py b/mysensors/const_21.py
new file mode 100644
index 0000000..7e08924
--- /dev/null
+++ b/mysensors/const_21.py
@@ -0,0 +1,107 @@
+"""MySensors constants for version 2.1 of MySensors."""
+from enum import IntEnum
+
+# pylint: disable=unused-import
+from mysensors.const_20 import (HANDLE_INTERNAL, MAX_NODE_ID,  # noqa: F401
+                                VALID_INTERNAL, VALID_PRESENTATION,
+                                VALID_SETREQ, VALID_STREAM, VALID_TYPES,
+                                MessageType, Presentation, SetReq, Stream)
+
+
+class Internal(IntEnum):
+    """MySensors internal sub-types."""
+
+    # pylint: disable=too-few-public-methods
+    # Use this to report the battery level (in percent 0-100).
+    I_BATTERY_LEVEL = 0
+    # Sensors can request the current time from the Controller using this
+    # message. The time will be reported as the seconds since 1970
+    I_TIME = 1
+    # Sensors report their library version at startup using this message type
+    I_VERSION = 2
+    # Use this to request a unique node id from the controller.
+    I_ID_REQUEST = 3
+    # Id response back to sensor. Payload contains sensor id.
+    I_ID_RESPONSE = 4
+    # Start/stop inclusion mode of the Controller (1=start, 0=stop).
+    I_INCLUSION_MODE = 5
+    # Config request from node. Reply with (M)etric or (I)mperal back to sensor
+    I_CONFIG = 6
+    # When a sensor starts up, it broadcast a search request to all neighbor
+    # nodes. They reply with a I_FIND_PARENT_RESPONSE.
+    I_FIND_PARENT_REQUEST = 7
+    I_FIND_PARENT = 7  # alias from version 2.0
+    # Reply message type to I_FIND_PARENT request.
+    I_FIND_PARENT_RESPONSE = 8
+    # Sent by the gateway to the Controller to trace-log a message
+    I_LOG_MESSAGE = 9
+    # A message that can be used to transfer child sensors
+    # (from EEPROM routing table) of a repeating node.
+    I_CHILDREN = 10
+    # Optional sketch name that can be used to identify sensor in the
+    # Controller GUI
+    I_SKETCH_NAME = 11
+    # Optional sketch version that can be reported to keep track of the version
+    # of sensor in the Controller GUI.
+    I_SKETCH_VERSION = 12
+    # Used by OTA firmware updates. Request for node to reboot.
+    I_REBOOT = 13
+    # Send by gateway to controller when startup is complete
+    I_GATEWAY_READY = 14
+    # Provides signing related preferences (first byte is preference version).
+    I_SIGNING_PRESENTATION = 15
+    I_REQUEST_SIGNING = 15  # alias from version 1.5
+    # Request for a nonce.
+    I_NONCE_REQUEST = 16
+    I_GET_NONCE = 16  # alias from version 1.5
+    # Payload is nonce data.
+    I_NONCE_RESPONSE = 17
+    I_GET_NONCE_RESPONSE = 17  # alias from version 1.5
+    I_HEARTBEAT_REQUEST = 18
+    I_HEARTBEAT = 18  # alias from version 2.0
+    I_PRESENTATION = 19
+    I_DISCOVER_REQUEST = 20
+    I_DISCOVER = 20  # alias from version 2.0
+    I_DISCOVER_RESPONSE = 21
+    I_HEARTBEAT_RESPONSE = 22
+    # Node is locked (reason in string-payload).
+    I_LOCKED = 23
+    I_PING = 24  # Ping sent to node, payload incremental hop counter
+    # In return to ping, sent back to sender, payload incremental hop counter
+    I_PONG = 25
+    I_REGISTRATION_REQUEST = 26  # Register request to GW
+    I_REGISTRATION_RESPONSE = 27  # Register response from GW
+    I_DEBUG = 28  # Debug message
+
+
+VALID_MESSAGE_TYPES = {
+    MessageType.presentation: list(Presentation),
+    MessageType.set: list(SetReq),
+    MessageType.req: list(SetReq),
+    MessageType.internal: list(Internal),
+    MessageType.stream: list(Stream),
+}
+
+
+VALID_INTERNAL = dict(VALID_INTERNAL)
+VALID_INTERNAL.update({
+    Internal.I_FIND_PARENT_REQUEST: '',
+    Internal.I_HEARTBEAT_REQUEST: '',
+    Internal.I_DISCOVER_REQUEST: '',
+})
+
+VALID_PAYLOADS = {
+    MessageType.presentation: VALID_PRESENTATION,
+    MessageType.set: VALID_SETREQ,
+    MessageType.req: {member: '' for member in list(SetReq)},
+    MessageType.internal: VALID_INTERNAL,
+    MessageType.stream: VALID_STREAM,
+}
+
+HANDLE_INTERNAL = dict(HANDLE_INTERNAL)
+HANDLE_INTERNAL.update({
+    Internal.I_GATEWAY_READY: {
+        'log': 'info', 'msg': {
+            'node_id': 255, 'ack': 0, 'sub_type': Internal.I_DISCOVER_REQUEST,
+            'payload': ''}},
+})
diff --git a/mysensors/const_22.py b/mysensors/const_22.py
new file mode 100644
index 0000000..6289960
--- /dev/null
+++ b/mysensors/const_22.py
@@ -0,0 +1,119 @@
+"""MySensors constants for version 2.2 of MySensors."""
+from enum import IntEnum
+
+import voluptuous as vol
+
+# pylint: disable=unused-import
+from mysensors.const_21 import (HANDLE_INTERNAL, MAX_NODE_ID,  # noqa: F401
+                                VALID_INTERNAL, VALID_PRESENTATION,
+                                VALID_SETREQ, VALID_STREAM, VALID_TYPES,
+                                MessageType, Presentation, SetReq, Stream)
+
+
+class Internal(IntEnum):
+    """MySensors internal sub-types."""
+
+    # pylint: disable=too-few-public-methods
+    # Use this to report the battery level (in percent 0-100).
+    I_BATTERY_LEVEL = 0
+    # Sensors can request the current time from the Controller using this
+    # message. The time will be reported as the seconds since 1970
+    I_TIME = 1
+    # Sensors report their library version at startup using this message type
+    I_VERSION = 2
+    # Use this to request a unique node id from the controller.
+    I_ID_REQUEST = 3
+    # Id response back to sensor. Payload contains sensor id.
+    I_ID_RESPONSE = 4
+    # Start/stop inclusion mode of the Controller (1=start, 0=stop).
+    I_INCLUSION_MODE = 5
+    # Config request from node. Reply with (M)etric or (I)mperal back to sensor
+    I_CONFIG = 6
+    # When a sensor starts up, it broadcast a search request to all neighbor
+    # nodes. They reply with a I_FIND_PARENT_RESPONSE.
+    I_FIND_PARENT_REQUEST = 7
+    I_FIND_PARENT = 7  # alias from version 2.0
+    # Reply message type to I_FIND_PARENT request.
+    I_FIND_PARENT_RESPONSE = 8
+    # Sent by the gateway to the Controller to trace-log a message
+    I_LOG_MESSAGE = 9
+    # A message that can be used to transfer child sensors
+    # (from EEPROM routing table) of a repeating node.
+    I_CHILDREN = 10
+    # Optional sketch name that can be used to identify sensor in the
+    # Controller GUI
+    I_SKETCH_NAME = 11
+    # Optional sketch version that can be reported to keep track of the version
+    # of sensor in the Controller GUI.
+    I_SKETCH_VERSION = 12
+    # Used by OTA firmware updates. Request for node to reboot.
+    I_REBOOT = 13
+    # Send by gateway to controller when startup is complete
+    I_GATEWAY_READY = 14
+    # Provides signing related preferences (first byte is preference version).
+    I_SIGNING_PRESENTATION = 15
+    I_REQUEST_SIGNING = 15  # alias from version 1.5
+    # Request for a nonce.
+    I_NONCE_REQUEST = 16
+    I_GET_NONCE = 16  # alias from version 1.5
+    # Payload is nonce data.
+    I_NONCE_RESPONSE = 17
+    I_GET_NONCE_RESPONSE = 17  # alias from version 1.5
+    I_HEARTBEAT_REQUEST = 18
+    I_HEARTBEAT = 18  # alias from version 2.0
+    I_PRESENTATION = 19
+    I_DISCOVER_REQUEST = 20
+    I_DISCOVER = 20  # alias from version 2.0
+    I_DISCOVER_RESPONSE = 21
+    I_HEARTBEAT_RESPONSE = 22
+    # Node is locked (reason in string-payload).
+    I_LOCKED = 23
+    I_PING = 24  # Ping sent to node, payload incremental hop counter
+    # In return to ping, sent back to sender, payload incremental hop counter
+    I_PONG = 25
+    I_REGISTRATION_REQUEST = 26  # Register request to GW
+    I_REGISTRATION_RESPONSE = 27  # Register response from GW
+    I_DEBUG = 28  # Debug message
+    I_SIGNAL_REPORT_REQUEST = 29  # Device signal strength request
+    I_SIGNAL_REPORT_REVERSE = 30  # Internal
+    I_SIGNAL_REPORT_RESPONSE = 31  # Device signal strength response (RSSI)
+    I_PRE_SLEEP_NOTIFICATION = 32  # Message sent before node is going to sleep
+    I_POST_SLEEP_NOTIFICATION = 33  # Message sent after node woke up
+
+
+VALID_MESSAGE_TYPES = {
+    MessageType.presentation: list(Presentation),
+    MessageType.set: list(SetReq),
+    MessageType.req: list(SetReq),
+    MessageType.internal: list(Internal),
+    MessageType.stream: list(Stream),
+}
+
+
+VALID_INTERNAL = dict(VALID_INTERNAL)
+VALID_INTERNAL.update({
+    Internal.I_SIGNAL_REPORT_REQUEST: str,
+    Internal.I_SIGNAL_REPORT_REVERSE: vol.All(
+        vol.Coerce(int), vol.Coerce(str)),
+    Internal.I_SIGNAL_REPORT_RESPONSE: vol.All(
+        vol.Coerce(int), vol.Coerce(str)),
+    Internal.I_PRE_SLEEP_NOTIFICATION: vol.All(
+        vol.Coerce(int), vol.Coerce(str)),
+    Internal.I_POST_SLEEP_NOTIFICATION: vol.All(
+        vol.Coerce(int), vol.Coerce(str)),
+})
+
+VALID_PAYLOADS = {
+    MessageType.presentation: VALID_PRESENTATION,
+    MessageType.set: VALID_SETREQ,
+    MessageType.req: {member: '' for member in list(SetReq)},
+    MessageType.internal: VALID_INTERNAL,
+    MessageType.stream: VALID_STREAM,
+}
+
+HANDLE_INTERNAL = dict(HANDLE_INTERNAL)
+HANDLE_INTERNAL.pop(Internal.I_HEARTBEAT_RESPONSE, None)
+HANDLE_INTERNAL.update({
+    Internal.I_PRE_SLEEP_NOTIFICATION: {
+        'fun': '_handle_smartsleep'},
+})


Test patch:
diff --git a/tests/test_message.py b/tests/test_message.py
index bdd7dda..aac8a1b 100644
--- a/tests/test_message.py
+++ b/tests/test_message.py
@@ -162,6 +162,19 @@ INTERNAL_FIXTURES_20.update({
     'I_REGISTRATION_REQUEST': '2.0.0',
     'I_REGISTRATION_RESPONSE': '1',
     'I_DEBUG': 'test debug',
+})
+
+
+INTERNAL_FIXTURES_21 = dict(INTERNAL_FIXTURES_20)
+INTERNAL_FIXTURES_21.update({
+    'I_FIND_PARENT_REQUEST': '',
+    'I_HEARTBEAT_REQUEST': '',
+    'I_DISCOVER_REQUEST': '',
+})
+
+
+INTERNAL_FIXTURES_22 = dict(INTERNAL_FIXTURES_21)
+INTERNAL_FIXTURES_22.update({
     'I_SIGNAL_REPORT_REQUEST': 'test',
     'I_SIGNAL_REPORT_REVERSE': '123',
     'I_SIGNAL_REPORT_RESPONSE': '123',
@@ -274,7 +287,8 @@ def test_validate_internal():
     """Test Internal messages."""
     versions = [
         ('1.4', INTERNAL_FIXTURES_14), ('1.5', INTERNAL_FIXTURES_15),
-        ('2.0', INTERNAL_FIXTURES_20)]
+        ('2.0', INTERNAL_FIXTURES_20), ('2.1', INTERNAL_FIXTURES_21),
+        ('2.2', INTERNAL_FIXTURES_22)]
     for protocol_version, fixture in versions:
         gateway = get_gateway(protocol_version)
         const = get_const(protocol_version)
@@ -287,7 +301,15 @@ def test_validate_internal():
                 return_value = None
             sub_type = const.Internal[name]
             msg = Message('1;255;3;0;{};{}\n'.format(sub_type, _payload))
-            valid = msg.validate(protocol_version)
+            try:
+                valid = msg.validate(protocol_version)
+            except vol.MultipleInvalid:
+                print('fixture version: ', protocol_version)
+                print('gateway version: ', gateway.protocol_version)
+                print('name: ', name)
+                print('subtype: ', sub_type)
+                print('payload: ', _payload)
+                raise
             assert valid == {
                 'node_id': 1, 'child_id': 255, 'type': 3, 'ack': 0,
                 'sub_type': sub_type, 'payload': _payload}
diff --git a/tests/test_mysensors.py b/tests/test_mysensors.py
index 56879bd..e9eb14b 100644
--- a/tests/test_mysensors.py
+++ b/tests/test_mysensors.py
@@ -668,8 +668,8 @@ class TestGateway20(TestGateway):
         ret = self.gateway.handle_queue()
         self.assertEqual(ret, '1;255;3;0;19;\n')
 
-    def test_heartbeat(self):
-        """Test heartbeat message."""
+    def test_smartsleep(self):
+        """Test smartsleep feature."""
         sensor = self._add_sensor(1)
         sensor.children[0] = ChildSensor(
             0, self.gateway.const.Presentation.S_LIGHT_LEVEL)
@@ -708,8 +708,8 @@ class TestGateway20(TestGateway):
         # nothing has changed
         self.assertEqual(ret, None)
 
-    def test_heartbeat_from_unknown(self):
-        """Test heartbeat message from unknown node."""
+    def test_smartsleep_from_unknown(self):
+        """Test smartsleep message from unknown node."""
         self.gateway.logic('1;255;3;0;22;\n')
         ret = self.gateway.handle_queue()
         self.assertEqual(ret, '1;255;3;0;19;\n')
@@ -774,6 +774,81 @@ class TestGateway20(TestGateway):
             '10.0,10.0,10.0')
 
 
+class TestGateway21(TestGateway20):
+    """Use protocol_version 2.1."""
+
+    def setUp(self):
+        """Set up gateway."""
+        self.gateway = Gateway(protocol_version='2.1')
+
+
+class TestGateway22(TestGateway21):
+    """Use protocol_version 2.2."""
+
+    def setUp(self):
+        """Set up gateway."""
+        self.gateway = Gateway(protocol_version='2.2')
+
+    def test_smartsleep(self):
+        """Test smartsleep feature."""
+        sensor = self._add_sensor(1)
+        sensor.children[0] = ChildSensor(
+            0, self.gateway.const.Presentation.S_LIGHT_LEVEL)
+        self.gateway.logic('1;0;1;0;23;43\n')
+        ret = self.gateway.handle_queue()
+        self.assertEqual(ret, None)
+        # pre sleep message
+        self.gateway.logic('1;255;3;0;32;500\n')
+        ret = self.gateway.handle_queue()
+        # nothing has changed
+        self.assertEqual(ret, None)
+        # change from controller side
+        self.gateway.set_child_value(
+            1, 0, self.gateway.const.SetReq.V_LIGHT_LEVEL, '57')
+        ret = self.gateway.handle_queue()
+        # no pre sleep message
+        self.assertEqual(ret, None)
+        # pre sleep message comes in
+        self.gateway.logic('1;255;3;0;32;500\n')
+        ret = self.gateway.handle_queue()
+        # instance responds with new values
+        self.assertEqual(ret, '1;0;1;0;23;57\n')
+        # request from node
+        self.gateway.logic('1;0;2;0;23;\n')
+        ret = self.gateway.handle_queue()
+        # no pre sleep message
+        self.assertEqual(ret, None)
+        # pre sleep message
+        self.gateway.logic('1;255;3;0;32;500\n')
+        ret = self.gateway.handle_queue()
+        # instance responds to request with current value
+        self.assertEqual(ret, '1;0;1;0;23;57\n')
+        # pre sleep message
+        self.gateway.logic('1;255;3;0;32;500\n')
+        ret = self.gateway.handle_queue()
+        # nothing has changed
+        self.assertEqual(ret, None)
+
+    def test_smartsleep_from_unknown(self):
+        """Test smartsleep message from unknown node."""
+        self.gateway.logic('1;255;3;0;32;500\n')
+        ret = self.gateway.handle_queue()
+        self.assertEqual(ret, '1;255;3;0;19;\n')
+
+    def test_set_with_new_state(self):
+        """Test set message with populated new_state."""
+        sensor = self._add_sensor(1)
+        sensor.children[0] = ChildSensor(
+            0, self.gateway.const.Presentation.S_LIGHT_LEVEL)
+        self.gateway.logic('1;0;1;0;23;43\n')
+        self.gateway.logic('1;255;3;0;32;500\n')
+        self.gateway.logic('1;0;1;0;23;57\n')
+        self.assertEqual(
+            sensor.children[0].values[self.gateway.const.SetReq.V_LIGHT_LEVEL],
+            sensor.new_state[0].values[
+                self.gateway.const.SetReq.V_LIGHT_LEVEL])
+
+
 def test_gateway_bad_protocol():
     """Test initializing gateway with a bad protocol_version."""
     gateway = Gateway(protocol_version=None)