Repository: pywemo/pywemo (language: python)

Issue:
Subscriptions support multiple devices per IP address
## Description:

The pywemo subscription logic assumes there is only one device per IP address. This breaks WeMo emulators that have more than one switch per IP address. Use unique subscription URLs to differentiate between devices.

**Related issue (if applicable):** fixes #557

## Checklist:
  - [x] The code change is tested and works locally.
  - [x] Tests (pytest/vcr) are added for new devices or major features.
  - [x] There is no commented out code in this PR.
  - [x] I have read and agree to the [CONTRIBUTING document](
        https://github.com/pywemo/pywemo/blob/master/CONTRIBUTING.md).
Unsubscribe from device when unregistering
Calling [SubscriptionRegistry.unregister](https://github.com/pywemo/pywemo/blob/743197633082b71092eea6cb242685e18f30a7a8/pywemo/subscribe.py#L573) should unsubscribe from the wemo device too. But only if the existing subscription hasn't expired.


Discussion / hints:

Fix patch:
diff --git a/pywemo/subscribe.py b/pywemo/subscribe.py
index 33a3b1fa..c63226f1 100644
--- a/pywemo/subscribe.py
+++ b/pywemo/subscribe.py
@@ -140,7 +140,7 @@ class Subscription:
     scheduler_active: bool = True
     """
     Controls whether or not the subscription will continue to be periodically
-    scheduled by the Scheduler. Set to False when the device us unregistered.
+    scheduled by the Scheduler. Set to False when the device is unregistered.
     """
 
     expiration_time: float = 0.0
@@ -221,6 +221,16 @@ def maintain(self) -> int:
 
         return self._update_subscription(response.headers)
 
+    def cancel(self) -> None:
+        """Cancel a subscription."""
+        if self.expiration_time > time.time():
+            try:
+                self._unsubscribe()
+            except requests.RequestException:
+                pass
+
+        self._reset_subscription()
+
     def _subscribe(self) -> requests.Response:
         """Start/renew a subscription with a UPnP SUBSCRIBE request.
 
@@ -346,6 +356,8 @@ def _cancel_events(
             # event might execute and be removed from queue
             # concurrently.  Safe to ignore
             pass
+        if subscription.scheduler_active:
+            scheduler.enter(0, 0, subscription.cancel)
         # Prevent the subscription from being scheduled again.
         subscription.scheduler_active = False
         subscription.scheduler_event = None


Test patch:
diff --git a/tests/test_subscribe.py b/tests/test_subscribe.py
index e2b90c33..a958a1b3 100644
--- a/tests/test_subscribe.py
+++ b/tests/test_subscribe.py
@@ -420,6 +420,7 @@ def test_register_unregister(self, device, subscription_registry):
         assert subscription_registry.devices["192.168.1.100"] == device
 
         subscription_registry.unregister(device)
+        self._wait_for_registry(subscription_registry)
 
         assert len(subscription_registry._sched.queue) == 0
 
diff --git a/tests/vcr/tests.ouimeaux_device.test_dimmer/Test_PVT_RTOS_Dimmer_v2.test_is_subscribed.yaml b/tests/vcr/tests.ouimeaux_device.test_dimmer/Test_PVT_RTOS_Dimmer_v2.test_is_subscribed.yaml
index f7268a14..ec8b5cb4 100644
--- a/tests/vcr/tests.ouimeaux_device.test_dimmer/Test_PVT_RTOS_Dimmer_v2.test_is_subscribed.yaml
+++ b/tests/vcr/tests.ouimeaux_device.test_dimmer/Test_PVT_RTOS_Dimmer_v2.test_is_subscribed.yaml
@@ -17,7 +17,7 @@ interactions:
       TIMEOUT:
       - Second-300
       User-Agent:
-      - python-requests/2.26.0
+      - python-requests/2.31.0
     method: SUBSCRIBE
     uri: http://192.168.1.100:49153/upnp/event/basicevent1
   response:
@@ -27,11 +27,11 @@ interactions:
       CONTENT-LENGTH:
       - '0'
       DATE:
-      - Mon, 04 Apr 2022 03:32:29
+      - Mon, 28 Aug 2023 02:41:12
       SERVER:
       - Unspecified, UPnP/1.0, Unspecified
       SID:
-      - uuid:22950699-df04-49a3-a15d-b2517fc84183
+      - uuid:8e002dff-79cf-479a-a337-28e03fbdc4a2
       TIMEOUT:
       - Second-1801
       X-User-Agent:
@@ -39,4 +39,28 @@ interactions:
     status:
       code: 200
       message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '0'
+      SID:
+      - uuid:8e002dff-79cf-479a-a337-28e03fbdc4a2
+      User-Agent:
+      - python-requests/2.31.0
+    method: UNSUBSCRIBE
+    uri: http://192.168.1.100:49153/upnp/event/basicevent1
+  response:
+    body:
+      string: ''
+    headers: {}
+    status:
+      code: 200
+      message: OK
 version: 1
diff --git a/tests/vcr/tests.ouimeaux_device.test_insight/Test_Insight.test_subscribe.yaml b/tests/vcr/tests.ouimeaux_device.test_insight/Test_Insight.test_subscribe.yaml
index 299d78df..0aadf6ac 100644
--- a/tests/vcr/tests.ouimeaux_device.test_insight/Test_Insight.test_subscribe.yaml
+++ b/tests/vcr/tests.ouimeaux_device.test_insight/Test_Insight.test_subscribe.yaml
@@ -79,4 +79,72 @@ interactions:
     status:
       code: 200
       message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '0'
+      SID:
+      - uuid:7a5218fe-1dd2-11b2-849c-b527ba18a01e
+      User-Agent:
+      - python-requests/2.31.0
+    method: UNSUBSCRIBE
+    uri: http://192.168.1.100:49153/upnp/event/basicevent1
+  response:
+    body:
+      string: <html><body><h1>200 OK</h1></body></html>
+    headers:
+      Accept-Ranges:
+      - bytes
+      CONNECTION:
+      - close
+      CONTENT-LENGTH:
+      - '41'
+      CONTENT-TYPE:
+      - text/html
+      SERVER:
+      - Unspecified, UPnP/1.0, Unspecified
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '0'
+      SID:
+      - uuid:7a59e192-1dd2-11b2-849c-b527ba18a01e
+      User-Agent:
+      - python-requests/2.31.0
+    method: UNSUBSCRIBE
+    uri: http://192.168.1.100:49153/upnp/event/insight1
+  response:
+    body:
+      string: <html><body><h1>200 OK</h1></body></html>
+    headers:
+      Accept-Ranges:
+      - bytes
+      CONNECTION:
+      - close
+      CONTENT-LENGTH:
+      - '41'
+      CONTENT-TYPE:
+      - text/html
+      SERVER:
+      - Unspecified, UPnP/1.0, Unspecified
+    status:
+      code: 200
+      message: OK
 version: 1
diff --git a/tests/vcr/tests.test_subscribe/Test_SubscriptionRegistry.test_register_unregister.yaml b/tests/vcr/tests.test_subscribe/Test_SubscriptionRegistry.test_register_unregister.yaml
index 8471336e..639f94c4 100644
--- a/tests/vcr/tests.test_subscribe/Test_SubscriptionRegistry.test_register_unregister.yaml
+++ b/tests/vcr/tests.test_subscribe/Test_SubscriptionRegistry.test_register_unregister.yaml
@@ -17,21 +17,23 @@ interactions:
       TIMEOUT:
       - Second-300
       User-Agent:
-      - python-requests/2.25.1
+      - python-requests/2.31.0
     method: SUBSCRIBE
     uri: http://192.168.1.100:49153/upnp/event/basicevent1
   response:
     body:
       string: ''
     headers:
+      Accept-Ranges:
+      - bytes
       CONTENT-LENGTH:
       - '0'
       DATE:
-      - Tue, 26 Jan 2021 02:16:36 GMT
+      - Sun, 27 Aug 2023 16:59:22 GMT
       SERVER:
       - Unspecified, UPnP/1.0, Unspecified
       SID:
-      - uuid:84915076-1dd2-11b2-b5fd-dcf7b6ec9aaa
+      - uuid:11fdf778-44fb-11ee-b6e9-d3aaa8ff147f
       TIMEOUT:
       - Second-300
       X-User-Agent:
@@ -47,7 +49,7 @@ interactions:
       Accept-Encoding:
       - gzip, deflate
       CALLBACK:
-      - <http://192.168.1.1:8989/insight1>
+      - <http://192.168.1.1:8989/sub/insight>
       Connection:
       - keep-alive
       Content-Length:
@@ -57,21 +59,23 @@ interactions:
       TIMEOUT:
       - Second-300
       User-Agent:
-      - python-requests/2.25.1
+      - python-requests/2.31.0
     method: SUBSCRIBE
     uri: http://192.168.1.100:49153/upnp/event/insight1
   response:
     body:
       string: ''
     headers:
+      Accept-Ranges:
+      - bytes
       CONTENT-LENGTH:
       - '0'
       DATE:
-      - Tue, 26 Jan 2021 02:16:36 GMT
+      - Sun, 27 Aug 2023 16:59:22 GMT
       SERVER:
       - Unspecified, UPnP/1.0, Unspecified
       SID:
-      - uuid:849c1a56-1dd2-11b2-b5fd-dcf7b6ec9aaa
+      - uuid:12046e46-44fb-11ee-b6e9-d3aaa8ff147f
       TIMEOUT:
       - Second-300
       X-User-Agent:
@@ -79,4 +83,72 @@ interactions:
     status:
       code: 200
       message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '0'
+      SID:
+      - uuid:11fdf778-44fb-11ee-b6e9-d3aaa8ff147f
+      User-Agent:
+      - python-requests/2.31.0
+    method: UNSUBSCRIBE
+    uri: http://192.168.1.100:49153/upnp/event/basicevent1
+  response:
+    body:
+      string: <html><body><h1>200 OK</h1></body></html>
+    headers:
+      Accept-Ranges:
+      - bytes
+      CONNECTION:
+      - close
+      CONTENT-LENGTH:
+      - '41'
+      CONTENT-TYPE:
+      - text/html
+      SERVER:
+      - Unspecified, UPnP/1.0, Unspecified
+    status:
+      code: 200
+      message: OK
+- request:
+    body: null
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '0'
+      SID:
+      - uuid:12046e46-44fb-11ee-b6e9-d3aaa8ff147f
+      User-Agent:
+      - python-requests/2.31.0
+    method: UNSUBSCRIBE
+    uri: http://192.168.1.100:49153/upnp/event/insight1
+  response:
+    body:
+      string: <html><body><h1>200 OK</h1></body></html>
+    headers:
+      Accept-Ranges:
+      - bytes
+      CONNECTION:
+      - close
+      CONTENT-LENGTH:
+      - '41'
+      CONTENT-TYPE:
+      - text/html
+      SERVER:
+      - Unspecified, UPnP/1.0, Unspecified
+    status:
+      code: 200
+      message: OK
 version: 1