vsslctrl 0.1.12.dev1__tar.gz → 0.1.14.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/PKG-INFO +86 -30
  2. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/README.md +84 -28
  3. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/pyproject.toml +1 -1
  4. vsslctrl-0.1.14.dev1/tests/test_event_bus.py +206 -0
  5. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/__init__.py +1 -1
  6. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/api_alpha.py +6 -0
  7. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/api_base.py +36 -2
  8. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/api_bravo.py +6 -0
  9. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/core.py +20 -16
  10. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/device.py +4 -0
  11. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/event_bus.py +19 -22
  12. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/zone.py +8 -7
  13. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl.egg-info/PKG-INFO +86 -30
  14. vsslctrl-0.1.12.dev1/tests/test_event_bus.py +0 -70
  15. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/LICENSE +0 -0
  16. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/setup.cfg +0 -0
  17. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/tests/test_integration.py +0 -0
  18. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/tests/test_settings.py +0 -0
  19. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/tests/test_zone.py +0 -0
  20. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/data_structure.py +0 -0
  21. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/decorators.py +0 -0
  22. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/discovery.py +0 -0
  23. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/exceptions.py +0 -0
  24. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/group.py +0 -0
  25. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/io.py +0 -0
  26. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/settings.py +0 -0
  27. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/track.py +0 -0
  28. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/transport.py +0 -0
  29. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl/utils.py +0 -0
  30. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl.egg-info/SOURCES.txt +0 -0
  31. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl.egg-info/dependency_links.txt +0 -0
  32. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl.egg-info/requires.txt +0 -0
  33. {vsslctrl-0.1.12.dev1 → vsslctrl-0.1.14.dev1}/vsslctrl.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: vsslctrl
3
- Version: 0.1.12.dev1
3
+ Version: 0.1.14.dev1
4
4
  Summary: Package for controlling VSSL's range of streaming amplifiers
5
5
  Author-email: vsslctrl <vsslcontrolled@proton.me>
6
6
  Classifier: Development Status :: 4 - Beta
@@ -76,7 +76,7 @@ async def main():
76
76
  vssl = Vssl(DeviceModels.A1)
77
77
  a1 = vssl.add_zone('192.168.1.10')
78
78
 
79
- # Connect and initiate zones.
79
+ # Connect and initialise zone.
80
80
  await vssl.initialise()
81
81
 
82
82
  """Control Examples"""
@@ -112,7 +112,7 @@ async def main():
112
112
  zone3 = vssl.add_zone('192.168.1.12', ZoneIDs.ZONE_3)
113
113
  #... up to 6 zones for A.6(x)
114
114
 
115
- # Connect and initiate zones.
115
+ # Connect and initialise zones.
116
116
  await vssl.initialise()
117
117
 
118
118
  """Control Examples"""
@@ -134,6 +134,77 @@ async def main():
134
134
  asyncio.run(main())
135
135
  ```
136
136
 
137
+ ### Device Discovery Helper
138
+
139
+ You can discover VSSL devices on the network using [mDNS](https://wikipedia.org/wiki/Multicast_DNS) / Bonjour if you have the [`zeroconf`](https://pypi.org/project/zeroconf/) package installed.
140
+
141
+ This uses airplay service string `_airplay._tcp.local.`, therefore airplay needs to available and will not work across VLANs without other provisions.
142
+
143
+ **Note:** This is designed to be a helper and its not recommended to be used for the initialization of the VSSL class.
144
+
145
+ ```python
146
+ import asyncio
147
+ from vsslctrl import Vssl
148
+
149
+ async def main():
150
+
151
+ print(await Vssl.discover())
152
+
153
+ """
154
+ {
155
+ 'XXXXXXXXXXXX': [
156
+ {
157
+ 'host': '192.168.168.25',
158
+ 'name': 'Living Room',
159
+ 'model': 'A1x',
160
+ 'mac_addr': 'AA:BB:CC:DD:EE:FF',
161
+ 'zone_id': '7',
162
+ 'serial': 'XXXXXXXXXXXX'
163
+ }
164
+ ]
165
+ }
166
+
167
+ """
168
+
169
+ asyncio.run(main())
170
+ ```
171
+
172
+
173
+ # API Functionality
174
+
175
+ Most functionality is achieved via `getters` and `setters` of the two main classes `Vssl`, `Zone`.
176
+
177
+ The classes will update the physical VSSL device when setting a property and once feedback has been received, the classes internal state will be updated. For example:
178
+
179
+ ```python
180
+ # Setting the zone name
181
+ zone1.settings.name = 'Living Room'
182
+ >>> 'Old Zone Name'
183
+
184
+ # Printing zone name
185
+ print(zone1.settings.name)
186
+ >>> 'Living Room'
187
+ ```
188
+
189
+ **Important** in the above example, `zone1.settings.name` won't be set to its new value until after the VSSL device has changed the name and the `Zone` class has received confirmation feedback. If you need to wait for the value change, you can await a `[property_name]_CHANGE` events as below:
190
+
191
+ ```python
192
+ from vsslctrl.settings import ZoneSettings
193
+ # Setting the zone name and wait for feedback
194
+ future_name = vssl.event_bus.future(ZoneSettings.Events.NAME_CHANGE, zone1.id)
195
+ zone1.settings.name = 'Bathroom'
196
+ # Helper to await a future with timeout
197
+ new_name = await vssl.event_bus.wait_future(future_name)
198
+ # Printing zone name
199
+ print(new_name)
200
+ >>> 'Bathroom'
201
+ # or
202
+ print(zone1.settings.name)
203
+ >>> 'Bathroom'
204
+ ```
205
+
206
+ # API Reference
207
+
137
208
  # `DeviceModels`
138
209
 
139
210
  A device model has to be passed to VSSL so it knows internally what features are supported by the device.
@@ -150,35 +221,20 @@ This might be removed in the future if we can differentiate different models fro
150
221
 
151
222
  # `ZoneIDs`
152
223
 
153
- | Property | Description |
154
- | ------------|--------- |
155
- | `A1` | A.1(x) Only |
156
- | `ZONE_1` | Zone 1 of A.3(x) and A.6(x) |
157
- | `ZONE_2` | Zone 2 of A.3(x) and A.6(x) |
158
- | `ZONE_3` | Zone 3 of A.3(x) and A.6(x) |
159
- | `ZONE_4` | Zone 4 of A.6(x) |
160
- | `ZONE_5` | Zone 5 of A.6(x) |
161
- | `ZONE_6` | Zone 6 of A.6(x) |
224
+ A `ZoneIDs` must be passed to each `zone` you which to control and it must match the zone on the VSSL device.
162
225
 
226
+ If you are unsure of your `ZoneIDs` you could use the discovery helper to find out the correct mapping.
163
227
 
164
- # API
165
-
166
- Most functionality is achieved via `getters` and `setters` of the two main classes `Vssl`, `Zone`.
167
-
168
- The classes will update the physical VSSL device when setting a property and once feedback has been received, the classes internal state will be updated. For example:
169
-
170
- ```python
171
- # Setting the zones name
172
- zone1.settings.name = 'Living Room'
173
- >>> None
174
-
175
- # Printing zone name
176
- zone_name = zone1.settings.name
177
- print(zone_name)
178
- >>> 'Living Room'
179
- ```
228
+ | Property | Description | A.1(x) | A.3(x) | A.6(x)
229
+ | ------------|--------- |-------- | -------- | -------- |
230
+ | `A1` | |✔️ | | |
231
+ | `ZONE_1` | Zone 1 | | ✔️| ✔️|
232
+ | `ZONE_2` | Zone 2 | | ✔️| ✔️|
233
+ | `ZONE_3` | Zone 3 | | ✔️| ✔️|
234
+ | `ZONE_4` | Zone 4 | | | ✔️|
235
+ | `ZONE_5` | Zone 5 | | | ✔️|
236
+ | `ZONE_6` | Zone 6 | | | ✔️|
180
237
 
181
- **Important** in the above example, `zone1.settings.name` wont be set to its new value until after the VSSL device has changed the name and the `Zone` class has received confirmation feedback. If you need to wait for the value change, you can await a `[property_name]_CHANGE` events.
182
238
 
183
239
  # `Vssl`
184
240
 
@@ -55,7 +55,7 @@ async def main():
55
55
  vssl = Vssl(DeviceModels.A1)
56
56
  a1 = vssl.add_zone('192.168.1.10')
57
57
 
58
- # Connect and initiate zones.
58
+ # Connect and initialise zone.
59
59
  await vssl.initialise()
60
60
 
61
61
  """Control Examples"""
@@ -91,7 +91,7 @@ async def main():
91
91
  zone3 = vssl.add_zone('192.168.1.12', ZoneIDs.ZONE_3)
92
92
  #... up to 6 zones for A.6(x)
93
93
 
94
- # Connect and initiate zones.
94
+ # Connect and initialise zones.
95
95
  await vssl.initialise()
96
96
 
97
97
  """Control Examples"""
@@ -113,6 +113,77 @@ async def main():
113
113
  asyncio.run(main())
114
114
  ```
115
115
 
116
+ ### Device Discovery Helper
117
+
118
+ You can discover VSSL devices on the network using [mDNS](https://wikipedia.org/wiki/Multicast_DNS) / Bonjour if you have the [`zeroconf`](https://pypi.org/project/zeroconf/) package installed.
119
+
120
+ This uses airplay service string `_airplay._tcp.local.`, therefore airplay needs to available and will not work across VLANs without other provisions.
121
+
122
+ **Note:** This is designed to be a helper and its not recommended to be used for the initialization of the VSSL class.
123
+
124
+ ```python
125
+ import asyncio
126
+ from vsslctrl import Vssl
127
+
128
+ async def main():
129
+
130
+ print(await Vssl.discover())
131
+
132
+ """
133
+ {
134
+ 'XXXXXXXXXXXX': [
135
+ {
136
+ 'host': '192.168.168.25',
137
+ 'name': 'Living Room',
138
+ 'model': 'A1x',
139
+ 'mac_addr': 'AA:BB:CC:DD:EE:FF',
140
+ 'zone_id': '7',
141
+ 'serial': 'XXXXXXXXXXXX'
142
+ }
143
+ ]
144
+ }
145
+
146
+ """
147
+
148
+ asyncio.run(main())
149
+ ```
150
+
151
+
152
+ # API Functionality
153
+
154
+ Most functionality is achieved via `getters` and `setters` of the two main classes `Vssl`, `Zone`.
155
+
156
+ The classes will update the physical VSSL device when setting a property and once feedback has been received, the classes internal state will be updated. For example:
157
+
158
+ ```python
159
+ # Setting the zone name
160
+ zone1.settings.name = 'Living Room'
161
+ >>> 'Old Zone Name'
162
+
163
+ # Printing zone name
164
+ print(zone1.settings.name)
165
+ >>> 'Living Room'
166
+ ```
167
+
168
+ **Important** in the above example, `zone1.settings.name` won't be set to its new value until after the VSSL device has changed the name and the `Zone` class has received confirmation feedback. If you need to wait for the value change, you can await a `[property_name]_CHANGE` events as below:
169
+
170
+ ```python
171
+ from vsslctrl.settings import ZoneSettings
172
+ # Setting the zone name and wait for feedback
173
+ future_name = vssl.event_bus.future(ZoneSettings.Events.NAME_CHANGE, zone1.id)
174
+ zone1.settings.name = 'Bathroom'
175
+ # Helper to await a future with timeout
176
+ new_name = await vssl.event_bus.wait_future(future_name)
177
+ # Printing zone name
178
+ print(new_name)
179
+ >>> 'Bathroom'
180
+ # or
181
+ print(zone1.settings.name)
182
+ >>> 'Bathroom'
183
+ ```
184
+
185
+ # API Reference
186
+
116
187
  # `DeviceModels`
117
188
 
118
189
  A device model has to be passed to VSSL so it knows internally what features are supported by the device.
@@ -129,35 +200,20 @@ This might be removed in the future if we can differentiate different models fro
129
200
 
130
201
  # `ZoneIDs`
131
202
 
132
- | Property | Description |
133
- | ------------|--------- |
134
- | `A1` | A.1(x) Only |
135
- | `ZONE_1` | Zone 1 of A.3(x) and A.6(x) |
136
- | `ZONE_2` | Zone 2 of A.3(x) and A.6(x) |
137
- | `ZONE_3` | Zone 3 of A.3(x) and A.6(x) |
138
- | `ZONE_4` | Zone 4 of A.6(x) |
139
- | `ZONE_5` | Zone 5 of A.6(x) |
140
- | `ZONE_6` | Zone 6 of A.6(x) |
203
+ A `ZoneIDs` must be passed to each `zone` you which to control and it must match the zone on the VSSL device.
141
204
 
205
+ If you are unsure of your `ZoneIDs` you could use the discovery helper to find out the correct mapping.
142
206
 
143
- # API
144
-
145
- Most functionality is achieved via `getters` and `setters` of the two main classes `Vssl`, `Zone`.
146
-
147
- The classes will update the physical VSSL device when setting a property and once feedback has been received, the classes internal state will be updated. For example:
148
-
149
- ```python
150
- # Setting the zones name
151
- zone1.settings.name = 'Living Room'
152
- >>> None
153
-
154
- # Printing zone name
155
- zone_name = zone1.settings.name
156
- print(zone_name)
157
- >>> 'Living Room'
158
- ```
207
+ | Property | Description | A.1(x) | A.3(x) | A.6(x)
208
+ | ------------|--------- |-------- | -------- | -------- |
209
+ | `A1` | |✔️ | | |
210
+ | `ZONE_1` | Zone 1 | | ✔️| ✔️|
211
+ | `ZONE_2` | Zone 2 | | ✔️| ✔️|
212
+ | `ZONE_3` | Zone 3 | | ✔️| ✔️|
213
+ | `ZONE_4` | Zone 4 | | | ✔️|
214
+ | `ZONE_5` | Zone 5 | | | ✔️|
215
+ | `ZONE_6` | Zone 6 | | | ✔️|
159
216
 
160
- **Important** in the above example, `zone1.settings.name` wont be set to its new value until after the VSSL device has changed the name and the `Zone` class has received confirmation feedback. If you need to wait for the value change, you can await a `[property_name]_CHANGE` events.
161
217
 
162
218
  # `Vssl`
163
219
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vsslctrl"
7
- version = "0.1.12.dev1"
7
+ version = "0.1.14.dev1"
8
8
  description = "Package for controlling VSSL's range of streaming amplifiers"
9
9
  authors = [
10
10
  { name="vsslctrl", email="vsslcontrolled@proton.me" },
@@ -0,0 +1,206 @@
1
+ import asyncio
2
+ import pytest
3
+ import pytest_asyncio
4
+ from vsslctrl.event_bus import EventBus
5
+
6
+
7
+ class TestEventBus:
8
+ @pytest_asyncio.fixture(autouse=True)
9
+ async def event_bus(self):
10
+ bus = EventBus()
11
+ yield bus
12
+ bus.stop() # Ensure the event bus is stopped after each test
13
+
14
+ @pytest.mark.asyncio
15
+ async def test_event_bus(self, event_bus):
16
+ # Instantiate the EventBus
17
+ # event_bus = EventBus()
18
+
19
+ async def publish_with_entity(self, entity=None):
20
+ # Define a callback function to be subscribed to the event
21
+ async def callback(data, *args, **kwargs):
22
+ callback.called = True
23
+ callback.data = data
24
+
25
+ # Subscribe the callback function to the event
26
+ event_type = "test_event"
27
+ event_bus.subscribe(event_type, callback, entity)
28
+ assert event_bus.subscribers.get(event_type) is not None
29
+ assert len(event_bus.subscribers.get(event_type)) == 1
30
+ assert event_bus.subscribers.get(event_type)[0][0] == callback
31
+
32
+ try:
33
+ # Publish the event
34
+ event_data = "test_data"
35
+ event_bus.publish(event_type, data=event_data, entity=entity)
36
+
37
+ # Wait for a short time to allow the event to be processed
38
+ await asyncio.sleep(0.1)
39
+
40
+ # Assert that the callback function has been called
41
+ assert hasattr(callback, "called") and callback.called is True
42
+ assert getattr(callback, "data") == event_data
43
+ finally:
44
+ # Unsubscribe the callback function
45
+ event_bus.unsubscribe(event_type, callback)
46
+ # Ensure the callback function was not called after unsubscribing
47
+ assert len(event_bus.subscribers.get(event_type)) == 0
48
+
49
+ await publish_with_entity(None)
50
+ await publish_with_entity(1)
51
+ await publish_with_entity("test")
52
+
53
+ @pytest.mark.asyncio
54
+ async def test_event_bus_wildcard_event_type(self, event_bus):
55
+ # Define a callback function to be subscribed to the event
56
+ async def callback(data: int = 0, *args, **kwargs):
57
+ callback.data = data + 1
58
+
59
+ event_bus.subscribe(event_bus.WILDCARD, callback, 1)
60
+ # Ensure the callback function is subscribed
61
+ assert len(event_bus.subscribers.get(event_bus.WILDCARD)) == 1
62
+
63
+ try:
64
+ # Publish an event
65
+ event_bus.publish("random_event_1", 1, 0)
66
+ # Wait for a short time to allow the event to be processed
67
+ await asyncio.sleep(0.1)
68
+ assert getattr(callback, "data") == 1
69
+
70
+ # Publish an event
71
+ event_bus.publish("random_event_2.other", 1, 20)
72
+ # Wait for a short time to allow the event to be processed
73
+ await asyncio.sleep(0.1)
74
+ assert getattr(callback, "data") == 21
75
+
76
+ # Publish an event with different ID
77
+ event_bus.publish("random_event_2", 2, 30)
78
+ # Wait for a short time to allow the event to be processed
79
+ await asyncio.sleep(0.1)
80
+ assert getattr(callback, "data") == 21
81
+
82
+ finally:
83
+ # Unsubscribe the callback function
84
+ event_bus.unsubscribe(event_bus.WILDCARD, callback)
85
+ # Ensure the callback function was not called after unsubscribing
86
+ assert len(event_bus.subscribers.get(event_bus.WILDCARD)) == 0
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_event_bus_scoped_wildcard_event_type(self, event_bus):
90
+ # Define a callback function to be subscribed to the event
91
+ async def callback(data: int = 0, *args, **kwargs):
92
+ callback.data = data + 1
93
+
94
+ event_type = "zone.api." + event_bus.WILDCARD
95
+
96
+ event_bus.subscribe(event_type, callback, 1)
97
+ # Ensure the callback function is subscribed
98
+ assert len(event_bus.subscribers.get(event_type)) == 1
99
+
100
+ try:
101
+ # Publish an event
102
+ event_bus.publish("zone.api.connected", 1, 0)
103
+ # Wait for a short time to allow the event to be processed
104
+ await asyncio.sleep(0.1)
105
+ assert getattr(callback, "data") == 1
106
+
107
+ # Publish an event
108
+ event_bus.publish("zone.api.disconnected", 1, 20)
109
+ # Wait for a short time to allow the event to be processed
110
+ await asyncio.sleep(0.1)
111
+ assert getattr(callback, "data") == 21
112
+
113
+ # Publish an different event
114
+ event_bus.publish("zone.api", 1, 30)
115
+ # Wait for a short time to allow the event to be processed
116
+ await asyncio.sleep(0.1)
117
+ assert getattr(callback, "data") == 21 # should be unchanged
118
+
119
+ # Publish an different event
120
+ event_bus.publish("zone", 1, 40)
121
+ # Wait for a short time to allow the event to be processed
122
+ await asyncio.sleep(0.1)
123
+ assert getattr(callback, "data") == 21 # should be unchanged
124
+
125
+ finally:
126
+ # Unsubscribe the callback function
127
+ event_bus.unsubscribe(event_type, callback)
128
+ # Ensure the callback function was not called after unsubscribing
129
+ assert len(event_bus.subscribers.get(event_type)) == 0
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_event_bus_wildcard_entity(self, event_bus):
133
+ # Define a callback function to be subscribed to the event
134
+ async def callback(data: int = 0, *args, **kwargs):
135
+ callback.data = data + 1
136
+
137
+ test_event = "test_event"
138
+
139
+ event_bus.subscribe(test_event, callback, event_bus.WILDCARD)
140
+ # Ensure the callback function is subscribed
141
+ assert len(event_bus.subscribers.get(test_event)) == 1
142
+
143
+ try:
144
+ # Publish an event
145
+ event_bus.publish(test_event, 1, 0)
146
+ await asyncio.sleep(0.1)
147
+ assert getattr(callback, "data") == 1
148
+
149
+ # Publish an event with different ID
150
+ event_bus.publish(test_event, 2, 20)
151
+ await asyncio.sleep(0.1)
152
+ assert getattr(callback, "data") == 21
153
+
154
+ # Publish an event with different ID
155
+ event_bus.publish("different_event", 2, 20)
156
+ await asyncio.sleep(0.1)
157
+ assert getattr(callback, "data") == 21
158
+
159
+ finally:
160
+ # Unsubscribe the callback function
161
+ event_bus.unsubscribe(test_event, callback)
162
+ # Ensure the callback function was not called after unsubscribing
163
+ assert len(event_bus.subscribers.get(test_event)) == 0
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_event_bus_wildcard(self, event_bus):
167
+ # Define a callback function to be subscribed to the event
168
+ async def callback(data: int = 0, *args, **kwargs):
169
+ callback.data = data + 1
170
+
171
+ event_bus.subscribe(event_bus.WILDCARD, callback, event_bus.WILDCARD)
172
+ # Ensure the callback function is subscribed
173
+ assert len(event_bus.subscribers.get(event_bus.WILDCARD)) == 1
174
+
175
+ try:
176
+ # Publish an event
177
+ event_bus.publish("random_event_1", 1, 0)
178
+ await asyncio.sleep(0.1)
179
+ assert getattr(callback, "data") == 1
180
+
181
+ # Publish an event with different ID
182
+ event_bus.publish("random_event_2", 2, 20)
183
+ await asyncio.sleep(0.1)
184
+ assert getattr(callback, "data") == 21
185
+
186
+ # Publish an event with different ID
187
+ event_bus.publish("random_event_3", 3, 30)
188
+ await asyncio.sleep(0.1)
189
+ assert getattr(callback, "data") == 31
190
+ finally:
191
+ # Unsubscribe the callback function
192
+ event_bus.unsubscribe(event_bus.WILDCARD, callback)
193
+ # Ensure the callback function was not called after unsubscribing
194
+ assert len(event_bus.subscribers.get(event_bus.WILDCARD)) == 0
195
+
196
+ @pytest.mark.asyncio
197
+ async def test_event_bus_future(self, event_bus):
198
+ # Subscribe the callback function to the event
199
+ event_type = "test_future_event"
200
+ future = event_bus.future(event_type, 1)
201
+ # Publish event
202
+ event_data = "test_data"
203
+ event_bus.publish(event_type, 1, event_data)
204
+ assert await future == event_data
205
+ # Make sure future is unsubscribed
206
+ assert len(event_bus.subscribers.get(event_type)) == 0
@@ -1,4 +1,4 @@
1
- VSSL_VERSION = "0.1.12.dev1"
1
+ VSSL_VERSION = "0.1.14.dev1"
2
2
  VSSL_NAME = "VSSL"
3
3
 
4
4
  from .core import Vssl
@@ -39,6 +39,12 @@ class APIAlpha(APIBase):
39
39
  self.vssl = vssl_host
40
40
  self.zone = zone
41
41
 
42
+ #
43
+ # Send event on event bus
44
+ #
45
+ def _event_publish(self, event_type, data=None):
46
+ self.zone._event_publish(event_type, (self.zone.host, self.TCP_PORT))
47
+
42
48
  #
43
49
  # Send keep alive
44
50
  #
@@ -45,6 +45,17 @@ class APIBase(ABC):
45
45
 
46
46
  FRIST_BYTE = 1
47
47
 
48
+ #
49
+ # API Events
50
+ #
51
+ class Events:
52
+ PREFIX = "zone.api."
53
+ CONNECTING = PREFIX + "connecting"
54
+ CONNECTED = PREFIX + "connected"
55
+ DISCONNECTING = PREFIX + "disconnecting"
56
+ DISCONNECTED = PREFIX + "disconnected"
57
+ RECONNECTING = PREFIX + "reconnecting"
58
+
48
59
  def __init__(self, host, port):
49
60
  self.host = host
50
61
  self.port = port
@@ -89,6 +100,7 @@ class APIBase(ABC):
89
100
  return self.connected
90
101
 
91
102
  self._connecting = True
103
+ self._event_publish(self.Events.CONNECTING)
92
104
 
93
105
  try:
94
106
  self._log_debug(f"Attemping connection to {self.host}:{self.port}")
@@ -98,6 +110,7 @@ class APIBase(ABC):
98
110
 
99
111
  # Connected
100
112
  self.connection_event.set()
113
+ self._event_publish(self.Events.CONNECTED)
101
114
 
102
115
  # cancel any reconnecting loops
103
116
  self._cancel_keep_connected()
@@ -140,6 +153,7 @@ class APIBase(ABC):
140
153
  @final
141
154
  async def disconnect(self):
142
155
  self._disconnecting = True
156
+ self._event_publish(self.Events.DISCONNECTING)
143
157
 
144
158
  # cancel any reconnecting loops
145
159
  self._cancel_keep_connected()
@@ -152,9 +166,14 @@ class APIBase(ABC):
152
166
 
153
167
  if self._writer:
154
168
  try:
169
+ # If there's unsent data in the buffer, wait_closed() might hang.
170
+ # Fix: Call await self._writer.drain() before closing to ensure all data is sent
171
+ await self._writer.drain()
172
+
155
173
  # Writer hangs on disconnect sometimes
156
- self._writer.close()
157
- await asyncio.wait_for(self._writer.wait_closed(), self.TIMEOUT)
174
+ if not self._writer.is_closing():
175
+ self._writer.close()
176
+ await asyncio.wait_for(self._writer.wait_closed(), self.TIMEOUT)
158
177
 
159
178
  except asyncio.CancelledError:
160
179
  self._log_debug(f"writer close timeout")
@@ -168,6 +187,10 @@ class APIBase(ABC):
168
187
  self._log_error(f"Unexpected error occurred while disconnecting: {e}")
169
188
  # Handle unexpected errors
170
189
  finally:
190
+ # If nothing works, you may need to forcefully close the socket:
191
+ # if hasattr(self._writer, 'transport'):
192
+ # self._writer.transport.close()
193
+
171
194
  self._writer = None
172
195
 
173
196
  self._reader = None
@@ -176,6 +199,8 @@ class APIBase(ABC):
176
199
 
177
200
  self._disconnecting = False
178
201
 
202
+ self._event_publish(self.Events.DISCONNECTED)
203
+
179
204
  return not self.connected
180
205
 
181
206
  #
@@ -184,7 +209,9 @@ class APIBase(ABC):
184
209
  @final
185
210
  async def reconnect(self):
186
211
  if not self._reconnecting and not self._is_keep_connected_running():
212
+ self._event_publish(self.Events.RECONNECTING)
187
213
  await self.disconnect()
214
+ await asyncio.sleep(1)
188
215
  self._keep_connected()
189
216
 
190
217
  #
@@ -306,6 +333,13 @@ class APIBase(ABC):
306
333
  async def _read_byte_stream(self):
307
334
  pass
308
335
 
336
+ #
337
+ # Send event on event bus
338
+ #
339
+ @abstractmethod
340
+ def _event_publish(self, event_type, data=None):
341
+ pass
342
+
309
343
  #
310
344
  # Send a keep alive
311
345
  #
@@ -23,6 +23,12 @@ class APIBravo(APIBase):
23
23
  self.vssl = vssl_host
24
24
  self.zone = zone
25
25
 
26
+ #
27
+ # Send event on event bus
28
+ #
29
+ def _event_publish(self, event_type, data=None):
30
+ self.zone._event_publish(event_type, (self.zone.host, self.TCP_PORT))
31
+
26
32
  #
27
33
  # Send keep alive
28
34
  #
@@ -28,7 +28,6 @@ class Vssl:
28
28
  MODEL_CHANGE = PREFIX + "model_changed"
29
29
  SW_VERSION_CHANGE = PREFIX + "sw_version_changed"
30
30
  SERIAL_CHANGE = PREFIX + "serial_changed"
31
- ALL = EventBus.WILDCARD
32
31
 
33
32
  def __init__(self, model: Models):
34
33
  self.event_bus = EventBus()
@@ -50,7 +49,7 @@ class Vssl:
50
49
  #
51
50
  async def initialise(self, init_timeout: int = 10):
52
51
  if len(self.zones) < 1:
53
- raise VsslCtrlException("Add atleast one zone before initializing")
52
+ raise VsslCtrlException("Add minimum one zone before initializing")
54
53
 
55
54
  zones_to_init = self.zones.copy()
56
55
 
@@ -64,9 +63,9 @@ class Vssl:
64
63
  await first_zone.initialise()
65
64
 
66
65
  # Wait until we have some basic infomation
67
- await self.event_bus.wait_future(future_serial)
68
- await self.event_bus.wait_future(future_sw_version)
69
- await self.event_bus.wait_future(future_name)
66
+ await self.event_bus.wait_future(future_serial, init_timeout)
67
+ await self.event_bus.wait_future(future_sw_version, init_timeout)
68
+ await self.event_bus.wait_future(future_name, init_timeout)
70
69
 
71
70
  # Check we haven't added too many zones
72
71
  if len(self.zones) > self.model.zone_count:
@@ -92,7 +91,7 @@ class Vssl:
92
91
  raise
93
92
 
94
93
  except asyncio.TimeoutError:
95
- message = f"Timeout during VSSL initialization. Are any zones avaiable?"
94
+ message = f"Timeout during VSSL initialization. Are any zones available?"
96
95
  self._log_critical(message)
97
96
  await first_zone.disconnect()
98
97
  raise VsslCtrlException(message)
@@ -114,17 +113,14 @@ class Vssl:
114
113
  #
115
114
  # Discover host on the network using zero_conf package
116
115
  #
117
- async def discover(self, *args):
118
- try:
119
- check_zeroconf_availability()
116
+ @staticmethod
117
+ async def discover(*args):
118
+ check_zeroconf_availability()
120
119
 
121
- from .discovery import VsslDiscovery
120
+ from .discovery import VsslDiscovery
122
121
 
123
- service = VsslDiscovery(*args)
124
- return await service.discover()
125
- except ZeroConfNotInstalled as e:
126
- self._log_error(e)
127
- raise
122
+ service = VsslDiscovery(*args)
123
+ return await service.discover()
128
124
 
129
125
  #
130
126
  # Update a property and fire an event
@@ -257,7 +253,7 @@ class Vssl:
257
253
  return None
258
254
 
259
255
  #
260
- # Get a Zone that is connected
256
+ # Get a zone that is connected
261
257
  #
262
258
  def get_connected_zone(self):
263
259
  if self.zones:
@@ -265,6 +261,14 @@ class Vssl:
265
261
  zone = self.zones[zone_id]
266
262
  if zone.connected:
267
263
  return zone
264
+ self._log_error("There are no connected zones.")
265
+
266
+ #
267
+ # Has a connected zone
268
+ #
269
+ @property
270
+ def connected(self):
271
+ return True if self.get_connected_zone() else False
268
272
 
269
273
  #
270
274
  # Get the device name
@@ -322,6 +322,10 @@ class Models(VsslEnum):
322
322
 
323
323
  @classmethod
324
324
  def get_model_by_name(cls, name):
325
+ # Return None immediately if input is None
326
+ if name is None:
327
+ return None
328
+
325
329
  # Preprocess input name: convert to lowercase and remove dots
326
330
  name_cleaned = name.lower().replace(".", "")
327
331
 
@@ -1,14 +1,11 @@
1
1
  import asyncio
2
2
  import traceback
3
+ import fnmatch
3
4
  from enum import IntEnum
4
5
  from typing import Callable
5
6
  from .exceptions import VsslCtrlException
6
7
  from .decorators import logging_helpers
7
8
 
8
- #
9
- # Event Bus
10
- #
11
-
12
9
 
13
10
  @logging_helpers("EventBus:")
14
11
  class EventBus:
@@ -23,6 +20,10 @@ class EventBus:
23
20
 
24
21
  self.process = asyncio.create_task(self.process_events())
25
22
 
23
+ # Helper for wildcard matching, so we can use partial wildcards. e.g zone.api.connected
24
+ def _matches_pattern(self, event_type, pattern):
25
+ return fnmatch.fnmatch(event_type, pattern)
26
+
26
27
  #
27
28
  # Stop
28
29
  #
@@ -67,7 +68,8 @@ class EventBus:
67
68
 
68
69
  async def future_callback(data, *args):
69
70
  nonlocal future
70
- future.set_result(data)
71
+ if not future.done(): # Ensure the future is not already resolved
72
+ future.set_result(data)
71
73
 
72
74
  self.subscribe(event_type, future_callback, entity, once=True)
73
75
 
@@ -115,12 +117,8 @@ class EventBus:
115
117
  event_type = event_type.lower()
116
118
  await self.event_queue.put((event_type, entity, data))
117
119
 
118
- #
119
- # Process Events
120
- #
121
120
  async def process_events(self):
122
121
  self._log_debug(f"starting event processing")
123
-
124
122
  self.running = True
125
123
  while self.running:
126
124
  try:
@@ -136,23 +134,22 @@ class EventBus:
136
134
  message += str(data)
137
135
  self._log_debug(message)
138
136
 
139
- for event in [event_type, self.WILDCARD]:
140
- if event in self.subscribers:
141
- for callback, subscribed_entity, once in self.subscribers[
142
- event
143
- ]:
144
- if entity is None or subscribed_entity in {
145
- entity,
146
- self.WILDCARD,
147
- }:
148
- await callback(data, entity, event_type)
149
- if once:
150
- self.unsubscribe(event, callback)
137
+ # Check for matching subscribers, including wildcards.
138
+ # e.g "zone.*", "zone.api.*"
139
+ matched_subscribers = []
140
+ for pattern in self.subscribers:
141
+ if self._matches_pattern(event_type, pattern):
142
+ matched_subscribers.extend(self.subscribers[pattern])
143
+
144
+ for callback, subscribed_entity, once in matched_subscribers:
145
+ if entity is None or subscribed_entity in {entity, self.WILDCARD}:
146
+ await callback(data, entity, event_type)
147
+ if once:
148
+ self.unsubscribe(pattern, callback)
151
149
 
152
150
  except asyncio.CancelledError:
153
151
  break
154
152
  except Exception as e:
155
- # Capture the traceback as a string
156
153
  traceback_str = traceback.format_exc()
157
154
  self._log_error(
158
155
  f"exception occurred processing event: {e}\n{traceback_str}"
@@ -29,7 +29,6 @@ class Zone:
29
29
  # Zone Events
30
30
  #
31
31
  class Events:
32
- ALL = "*"
33
32
  PREFIX = "zone."
34
33
  INITIALISED = PREFIX + "initialised"
35
34
  ID_RECEIVED = PREFIX + "id_received"
@@ -79,7 +78,7 @@ class Zone:
79
78
  )
80
79
 
81
80
  # Initialise
82
- async def initialise(self):
81
+ async def initialise(self, init_timeout: int = 10):
83
82
  # Data we require from the device
84
83
  future_id = self.vssl.event_bus.future(self.Events.ID_RECEIVED, self.id)
85
84
  future_serial = self.vssl.event_bus.future(self.Events.SERIAL_RECEIVED, self.id)
@@ -106,13 +105,15 @@ class Zone:
106
105
 
107
106
  try:
108
107
  # Wait for the ID, serial and name to be returned from the device
109
- received_id = await self.vssl.event_bus.wait_future(future_id)
110
- received_serial = await self.vssl.event_bus.wait_future(future_serial)
111
- await self.vssl.event_bus.wait_future(future_name)
108
+ received_id = await self.vssl.event_bus.wait_future(future_id, init_timeout)
109
+ received_serial = await self.vssl.event_bus.wait_future(
110
+ future_serial, init_timeout
111
+ )
112
+ await self.vssl.event_bus.wait_future(future_name, init_timeout)
112
113
  except asyncio.TimeoutError:
113
- message = f"Zone {self.id}: initialization timeout. Is the zone avaiable?"
114
+ message = f"Zone {self.id}: initialization timeout. Is the zone available?"
114
115
  self._log_critical(message)
115
- await first_zone.disconnect()
116
+ await self.disconnect()
116
117
  raise ZoneError(message)
117
118
 
118
119
  # Confirm the zone id is matches the returned zone ID
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: vsslctrl
3
- Version: 0.1.12.dev1
3
+ Version: 0.1.14.dev1
4
4
  Summary: Package for controlling VSSL's range of streaming amplifiers
5
5
  Author-email: vsslctrl <vsslcontrolled@proton.me>
6
6
  Classifier: Development Status :: 4 - Beta
@@ -76,7 +76,7 @@ async def main():
76
76
  vssl = Vssl(DeviceModels.A1)
77
77
  a1 = vssl.add_zone('192.168.1.10')
78
78
 
79
- # Connect and initiate zones.
79
+ # Connect and initialise zone.
80
80
  await vssl.initialise()
81
81
 
82
82
  """Control Examples"""
@@ -112,7 +112,7 @@ async def main():
112
112
  zone3 = vssl.add_zone('192.168.1.12', ZoneIDs.ZONE_3)
113
113
  #... up to 6 zones for A.6(x)
114
114
 
115
- # Connect and initiate zones.
115
+ # Connect and initialise zones.
116
116
  await vssl.initialise()
117
117
 
118
118
  """Control Examples"""
@@ -134,6 +134,77 @@ async def main():
134
134
  asyncio.run(main())
135
135
  ```
136
136
 
137
+ ### Device Discovery Helper
138
+
139
+ You can discover VSSL devices on the network using [mDNS](https://wikipedia.org/wiki/Multicast_DNS) / Bonjour if you have the [`zeroconf`](https://pypi.org/project/zeroconf/) package installed.
140
+
141
+ This uses airplay service string `_airplay._tcp.local.`, therefore airplay needs to available and will not work across VLANs without other provisions.
142
+
143
+ **Note:** This is designed to be a helper and its not recommended to be used for the initialization of the VSSL class.
144
+
145
+ ```python
146
+ import asyncio
147
+ from vsslctrl import Vssl
148
+
149
+ async def main():
150
+
151
+ print(await Vssl.discover())
152
+
153
+ """
154
+ {
155
+ 'XXXXXXXXXXXX': [
156
+ {
157
+ 'host': '192.168.168.25',
158
+ 'name': 'Living Room',
159
+ 'model': 'A1x',
160
+ 'mac_addr': 'AA:BB:CC:DD:EE:FF',
161
+ 'zone_id': '7',
162
+ 'serial': 'XXXXXXXXXXXX'
163
+ }
164
+ ]
165
+ }
166
+
167
+ """
168
+
169
+ asyncio.run(main())
170
+ ```
171
+
172
+
173
+ # API Functionality
174
+
175
+ Most functionality is achieved via `getters` and `setters` of the two main classes `Vssl`, `Zone`.
176
+
177
+ The classes will update the physical VSSL device when setting a property and once feedback has been received, the classes internal state will be updated. For example:
178
+
179
+ ```python
180
+ # Setting the zone name
181
+ zone1.settings.name = 'Living Room'
182
+ >>> 'Old Zone Name'
183
+
184
+ # Printing zone name
185
+ print(zone1.settings.name)
186
+ >>> 'Living Room'
187
+ ```
188
+
189
+ **Important** in the above example, `zone1.settings.name` won't be set to its new value until after the VSSL device has changed the name and the `Zone` class has received confirmation feedback. If you need to wait for the value change, you can await a `[property_name]_CHANGE` events as below:
190
+
191
+ ```python
192
+ from vsslctrl.settings import ZoneSettings
193
+ # Setting the zone name and wait for feedback
194
+ future_name = vssl.event_bus.future(ZoneSettings.Events.NAME_CHANGE, zone1.id)
195
+ zone1.settings.name = 'Bathroom'
196
+ # Helper to await a future with timeout
197
+ new_name = await vssl.event_bus.wait_future(future_name)
198
+ # Printing zone name
199
+ print(new_name)
200
+ >>> 'Bathroom'
201
+ # or
202
+ print(zone1.settings.name)
203
+ >>> 'Bathroom'
204
+ ```
205
+
206
+ # API Reference
207
+
137
208
  # `DeviceModels`
138
209
 
139
210
  A device model has to be passed to VSSL so it knows internally what features are supported by the device.
@@ -150,35 +221,20 @@ This might be removed in the future if we can differentiate different models fro
150
221
 
151
222
  # `ZoneIDs`
152
223
 
153
- | Property | Description |
154
- | ------------|--------- |
155
- | `A1` | A.1(x) Only |
156
- | `ZONE_1` | Zone 1 of A.3(x) and A.6(x) |
157
- | `ZONE_2` | Zone 2 of A.3(x) and A.6(x) |
158
- | `ZONE_3` | Zone 3 of A.3(x) and A.6(x) |
159
- | `ZONE_4` | Zone 4 of A.6(x) |
160
- | `ZONE_5` | Zone 5 of A.6(x) |
161
- | `ZONE_6` | Zone 6 of A.6(x) |
224
+ A `ZoneIDs` must be passed to each `zone` you which to control and it must match the zone on the VSSL device.
162
225
 
226
+ If you are unsure of your `ZoneIDs` you could use the discovery helper to find out the correct mapping.
163
227
 
164
- # API
165
-
166
- Most functionality is achieved via `getters` and `setters` of the two main classes `Vssl`, `Zone`.
167
-
168
- The classes will update the physical VSSL device when setting a property and once feedback has been received, the classes internal state will be updated. For example:
169
-
170
- ```python
171
- # Setting the zones name
172
- zone1.settings.name = 'Living Room'
173
- >>> None
174
-
175
- # Printing zone name
176
- zone_name = zone1.settings.name
177
- print(zone_name)
178
- >>> 'Living Room'
179
- ```
228
+ | Property | Description | A.1(x) | A.3(x) | A.6(x)
229
+ | ------------|--------- |-------- | -------- | -------- |
230
+ | `A1` | |✔️ | | |
231
+ | `ZONE_1` | Zone 1 | | ✔️| ✔️|
232
+ | `ZONE_2` | Zone 2 | | ✔️| ✔️|
233
+ | `ZONE_3` | Zone 3 | | ✔️| ✔️|
234
+ | `ZONE_4` | Zone 4 | | | ✔️|
235
+ | `ZONE_5` | Zone 5 | | | ✔️|
236
+ | `ZONE_6` | Zone 6 | | | ✔️|
180
237
 
181
- **Important** in the above example, `zone1.settings.name` wont be set to its new value until after the VSSL device has changed the name and the `Zone` class has received confirmation feedback. If you need to wait for the value change, you can await a `[property_name]_CHANGE` events.
182
238
 
183
239
  # `Vssl`
184
240
 
@@ -1,70 +0,0 @@
1
- import asyncio
2
- import pytest
3
- import pytest_asyncio
4
- from vsslctrl.event_bus import EventBus
5
-
6
-
7
- class TestEventBus:
8
-
9
- @pytest_asyncio.fixture(autouse=True)
10
- async def event_bus(self):
11
- bus = EventBus()
12
- yield bus
13
- bus.stop() # Ensure the event bus is stopped after each test
14
-
15
-
16
- @pytest.mark.asyncio
17
- async def test_event_bus(self, event_bus):
18
- # Instantiate the EventBus
19
- #event_bus = EventBus()
20
-
21
- async def publish_with_entity(self, entity = None):
22
-
23
- # Define a callback function to be subscribed to the event
24
- async def callback(data, *args, **kwargs):
25
- callback.called = True
26
- callback.data = data
27
-
28
- # Subscribe the callback function to the event
29
- event_type = "test_event"
30
- event_bus.subscribe(event_type, callback, entity)
31
- assert event_bus.subscribers.get(event_type) is not None
32
- assert len(event_bus.subscribers.get(event_type)) == 1
33
- assert event_bus.subscribers.get(event_type)[0][0] == callback
34
-
35
-
36
- try:
37
- # Publish the event
38
- event_data = "test_data"
39
- event_bus.publish(event_type, data=event_data, entity=entity)
40
-
41
- # Wait for a short time to allow the event to be processed
42
- await asyncio.sleep(0.1)
43
-
44
- # Assert that the callback function has been called
45
- assert hasattr(callback, 'called') and callback.called is True
46
- assert getattr(callback, 'data') == event_data
47
- finally:
48
- # Unsubscribe the callback function
49
- event_bus.unsubscribe(event_type, callback)
50
- # Ensure the callback function was not called after unsubscribing
51
- assert len(event_bus.subscribers.get(event_type)) == 0
52
-
53
-
54
- await publish_with_entity(None)
55
- await publish_with_entity(1)
56
- await publish_with_entity('tet')
57
-
58
- @pytest.mark.asyncio
59
- async def test_event_bus_future(self, event_bus):
60
-
61
- # Subscribe the callback function to the event
62
- event_type = "test_event"
63
- future = event_bus.future(event_type, 1)
64
- event_data = "test_data"
65
- event_bus.publish(event_type, data=event_data, entity=1)
66
- data = await future
67
- assert data == event_data
68
- assert len(event_bus.subscribers.get(event_type)) == 0
69
-
70
-
File without changes
File without changes