vsslctrl 0.1.0.dev1__py3-none-any.whl

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.
vsslctrl/discovery.py ADDED
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """ Scan for apple devices. """
4
+
5
+ import json
6
+ import asyncio
7
+ from typing import Any, Optional, cast
8
+
9
+ from zeroconf import IPVersion, ServiceStateChange, Zeroconf
10
+ from zeroconf.asyncio import (
11
+ AsyncServiceBrowser,
12
+ AsyncServiceInfo,
13
+ AsyncZeroconf,
14
+ InterfaceChoice,
15
+ )
16
+
17
+ from .utils import group_list_by_property
18
+ from .api_alpha import APIAlpha
19
+ from .decorators import logging_helpers
20
+ from .exceptions import ZeroConfNotInstalled, ZoneConnectionError, ZoneError
21
+
22
+ from .data_structure import ZoneStatusExtKeys
23
+
24
+
25
+ #
26
+ # Check to see if Zeroconf is available on the system
27
+ #
28
+ def check_zeroconf_availability():
29
+ try:
30
+ import zeroconf
31
+ import zeroconf.asyncio
32
+
33
+ return True
34
+ except ImportError:
35
+ raise ZeroConfNotInstalled(
36
+ "Error: 'zeroconf' package is not installed. Install using 'pip install zeroconf'."
37
+ )
38
+ return False
39
+
40
+
41
+ #
42
+ # Attempt to connect to zone and return id and serial from a given host IP
43
+ #
44
+ async def fetch_zone_id_serial(host):
45
+ try:
46
+ # Open a connection to the server
47
+ reader, writer = await asyncio.wait_for(
48
+ asyncio.open_connection(host, APIAlpha.TCP_PORT), APIAlpha.TIMEOUT
49
+ )
50
+
51
+ # Request zones status
52
+ writer.write(APIAlpha.ZONE_STATUS)
53
+
54
+ # Wait until the data is flushed
55
+ await writer.drain()
56
+
57
+ # Receive response
58
+ response = await reader.read(1024)
59
+ string = response[APIAlpha.JSON_HEADER_LENGTH :].decode("ascii")
60
+ metadata = json.loads(string)
61
+
62
+ writer.close()
63
+ await writer.wait_closed()
64
+
65
+ if (
66
+ ZoneStatusExtKeys.ID not in metadata
67
+ or ZoneStatusExtKeys.SERIAL_NUMBER not in metadata
68
+ ):
69
+ raise ZoneError(
70
+ f"Host {host}:{APIAlpha.TCP_PORT} didnt return ID or serial number"
71
+ )
72
+
73
+ except (asyncio.TimeoutError, asyncio.CancelledError):
74
+ raise ZoneConnectionError(f"Connection to {host}:{APIAlpha.TCP_PORT} timed out")
75
+
76
+ # Return the ID and serial
77
+ return (
78
+ metadata[ZoneStatusExtKeys.ID],
79
+ metadata[ZoneStatusExtKeys.SERIAL_NUMBER],
80
+ )
81
+
82
+
83
+ @logging_helpers()
84
+ class VsslDiscovery:
85
+ SERVICE_STRING: str = "_airplay._tcp.local."
86
+
87
+ def __init__(self, aiozc: AsyncZeroconf = None, discovery_time: int = 5):
88
+ self.discovery_time = discovery_time
89
+ self.discovered_zones = []
90
+ self.zeroconf_available = check_zeroconf_availability()
91
+
92
+ self.aiozc = aiozc
93
+ self.aiobrowser = None
94
+
95
+ #
96
+ # Discover
97
+ #
98
+ async def discover(self):
99
+ if not self.zeroconf_available:
100
+ return
101
+
102
+ self.discovered_zones = []
103
+
104
+ if not isinstance(self.aiozc, AsyncZeroconf):
105
+ self.aiozc = AsyncZeroconf(
106
+ ip_version=IPVersion.V4Only, interfaces=InterfaceChoice.All
107
+ )
108
+
109
+ task = asyncio.create_task(self._run())
110
+
111
+ await asyncio.sleep(self.discovery_time)
112
+ await self._close()
113
+ task.cancel()
114
+
115
+ hosts = []
116
+ for zone in self.discovered_zones:
117
+ try:
118
+ zone_id, serial = await fetch_zone_id_serial(zone["host"])
119
+ if zone_id and serial:
120
+ zone["zone_id"] = zone_id
121
+ zone["serial"] = serial
122
+ hosts.append(zone)
123
+
124
+ except Exception:
125
+ self._log_error(
126
+ f'Error fetching zone info for discovered host {zone["host"]}'
127
+ )
128
+
129
+ return group_list_by_property(hosts, "serial")
130
+
131
+ #
132
+ # Run
133
+ #
134
+ async def _run(self) -> None:
135
+ await self.aiozc.zeroconf.async_wait_for_start()
136
+ self._log_debug(f"Browsing for {self.SERVICE_STRING} services")
137
+
138
+ self.aiobrowser = AsyncServiceBrowser(
139
+ self.aiozc.zeroconf,
140
+ self.SERVICE_STRING,
141
+ handlers=[self._on_service_state_change],
142
+ )
143
+
144
+ while True:
145
+ await asyncio.sleep(1)
146
+
147
+ #
148
+ # Close
149
+ #
150
+ async def _close(self) -> None:
151
+ assert self.aiozc is not None
152
+ assert self.aiobrowser is not None
153
+ await self.aiobrowser.async_cancel()
154
+ await self.aiozc.async_close()
155
+
156
+ #
157
+ # on_service_state_change
158
+ #
159
+ def _on_service_state_change(
160
+ self,
161
+ zeroconf: Zeroconf,
162
+ service_type: str,
163
+ name: str,
164
+ state_change: ServiceStateChange,
165
+ ) -> None:
166
+ asyncio.ensure_future(self._fetch_service_info(zeroconf, service_type, name))
167
+
168
+ #
169
+ # show_service_info
170
+ #
171
+ async def _fetch_service_info(
172
+ self, zeroconf: Zeroconf, service_type: str, name: str
173
+ ) -> None:
174
+ info = AsyncServiceInfo(service_type, name)
175
+ await info.async_request(zeroconf, 3000)
176
+
177
+ if info:
178
+ manufacturer = info.properties.get(b"manufacturer", None)
179
+ if manufacturer and manufacturer.startswith(b"VSSL"):
180
+ self.discovered_zones.append(
181
+ {
182
+ # Convert byte representation of IP address to string
183
+ "host": info.parsed_addresses()[0],
184
+ "name": name.rstrip(f".{self.SERVICE_STRING}"),
185
+ "model": info.properties.get(b"model", b"")
186
+ .decode("utf-8")
187
+ .lstrip(f"VSSL")
188
+ .strip(),
189
+ "mac_addr": info.properties.get(b"deviceid", b"").decode(
190
+ "utf-8"
191
+ ),
192
+ }
193
+ )
vsslctrl/event_bus.py ADDED
@@ -0,0 +1,159 @@
1
+ import asyncio
2
+ import traceback
3
+ from enum import IntEnum
4
+ from typing import Callable
5
+ from .exceptions import VsslCtrlException
6
+ from .decorators import logging_helpers
7
+
8
+ #
9
+ # Event Bus
10
+ #
11
+
12
+
13
+ @logging_helpers("EventBus:")
14
+ class EventBus:
15
+ WILDCARD = "*"
16
+ FUTURE_TIMEOUT = 5
17
+
18
+ def __init__(self):
19
+ self.subscribers = {}
20
+ self.event_queue = asyncio.Queue()
21
+
22
+ self.running = False
23
+
24
+ self.process = asyncio.create_task(self.process_events())
25
+
26
+ #
27
+ # Stop
28
+ #
29
+ def stop(self):
30
+ self.running = False
31
+ self.process.cancel()
32
+ self._log_debug(f"stopped event processing")
33
+
34
+ #
35
+ # Subscribe
36
+ #
37
+ def subscribe(self, event_type, callback: Callable, entity="*", once=False):
38
+ # Make sure we are using async callbacks
39
+ if callback is not None and asyncio.iscoroutinefunction(callback):
40
+ event_type = event_type.lower()
41
+ if event_type not in self.subscribers:
42
+ self.subscribers[event_type] = []
43
+ self._log_debug(
44
+ f"subscription for event: {event_type} | entity: {entity} | cb: {callback.__name__}"
45
+ )
46
+ self.subscribers[event_type].append((callback, entity, once))
47
+ else:
48
+ message = f"{callback.__name__} must be a coroutine. Event: {event_type} | Entity: {entity}"
49
+ self._log_error(message)
50
+ raise VsslCtrlException(message)
51
+
52
+ #
53
+ # Unsubscribe
54
+ #
55
+ def unsubscribe(self, event_type, callback):
56
+ event_type = event_type.lower()
57
+ if event_type in self.subscribers:
58
+ self.subscribers[event_type] = [
59
+ cb for cb in self.subscribers[event_type] if cb[0] != callback
60
+ ]
61
+
62
+ #
63
+ # Get a future value from the event bus
64
+ #
65
+ def future(self, event_type, entity=None) -> asyncio.Future:
66
+ future = asyncio.Future()
67
+
68
+ async def future_callback(data, *args):
69
+ nonlocal future
70
+ future.set_result(data)
71
+
72
+ self.subscribe(event_type, future_callback, entity, once=True)
73
+
74
+ return future
75
+
76
+ #
77
+ # Helper to await a future with a timeout
78
+ #
79
+ async def wait_future(self, future, timeout: int = FUTURE_TIMEOUT):
80
+ if timeout != 0:
81
+ try:
82
+ return await asyncio.wait_for(future, timeout)
83
+ except asyncio.TimeoutError as error:
84
+ self._log_error(f"timeout waiting for future")
85
+ raise error
86
+ else:
87
+ return await future
88
+
89
+ #
90
+ # wait_for, will wait for an event to be fired and return the result
91
+ #
92
+ async def wait_for(
93
+ self,
94
+ event_type,
95
+ entity=None,
96
+ timeout: int = FUTURE_TIMEOUT,
97
+ timeout_result=None,
98
+ ):
99
+ future = self.future(event_type, entity)
100
+ try:
101
+ return await self.wait_future(future, timeout=timeout)
102
+ except asyncio.TimeoutError as error:
103
+ return timeout_result
104
+
105
+ #
106
+ # Publish
107
+ #
108
+ def publish(self, event_type, entity=None, data=None):
109
+ asyncio.create_task(self.publish_async(event_type, entity, data))
110
+
111
+ #
112
+ # Publish Async (Use when inside events loop)
113
+ #
114
+ async def publish_async(self, event_type, entity=None, data=None):
115
+ event_type = event_type.lower()
116
+ await self.event_queue.put((event_type, entity, data))
117
+
118
+ #
119
+ # Process Events
120
+ #
121
+ async def process_events(self):
122
+ self._log_debug(f"starting event processing")
123
+
124
+ self.running = True
125
+ while self.running:
126
+ try:
127
+ event_type, entity, data = await self.event_queue.get()
128
+
129
+ if self._is_log_level("debug"):
130
+ message = (
131
+ f"processing event: {event_type} | entity: {entity} | data: "
132
+ )
133
+ if isinstance(data, IntEnum):
134
+ message += f"{data.name} ({data.value})"
135
+ else:
136
+ message += str(data)
137
+ self._log_debug(message)
138
+
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)
151
+
152
+ except asyncio.CancelledError:
153
+ break
154
+ except Exception as e:
155
+ # Capture the traceback as a string
156
+ traceback_str = traceback.format_exc()
157
+ self._log_error(
158
+ f"exception occurred processing event: {e}\n{traceback_str}"
159
+ )
vsslctrl/exceptions.py ADDED
@@ -0,0 +1,21 @@
1
+ import traceback
2
+
3
+
4
+ class VsslCtrlException(Exception):
5
+ """VSSL Exception"""
6
+
7
+ def __init__(self, message):
8
+ super().__init__(message)
9
+ self.traceback = traceback.format_exc()
10
+
11
+
12
+ class ZoneError(VsslCtrlException):
13
+ """Zone Exception"""
14
+
15
+
16
+ class ZoneConnectionError(ZoneError):
17
+ """Zone Connection Exception Exception"""
18
+
19
+
20
+ class ZeroConfNotInstalled(VsslCtrlException):
21
+ """Zone Exception"""
vsslctrl/group.py ADDED
@@ -0,0 +1,187 @@
1
+ import logging
2
+ from . import zone
3
+ from typing import Dict, Union
4
+ from .data_structure import ZoneDataClass
5
+
6
+
7
+ """
8
+ Note from VSSSL API, here for archive purpose
9
+
10
+ AddZoneToGroup: If z1 is currently playing then you can create a new group by adding z2 by ~SetGroup(vsslSerial, 1, 2). Z1 and z2
11
+ will now be playing the z1 stream. If the z1 stream is stopped then the group will automatically dissolve. If a stream is
12
+ started on z2 while z2 is in the group then the z2 content will not output on the z2 speaker until z2 is removed from the group.
13
+
14
+ RemoveZoneFromGroup: If a group exists with z1 as the parent and (z2, z3) as children then you can remove z2 from the group by
15
+ setting its parent to 255 ~SetGroup(vsslSerial, 255, 2).
16
+
17
+ DissolveGroup: If a group exists with z1 as the parent and (z2, z3) as children then you can remove z2 from the group by
18
+ setting the child group to 255 ~SetGroup(vsslSerial, 1, 255).
19
+
20
+ Ref: https://vssl.gitbook.io/vssl-rest-api/zone-control/set-group
21
+
22
+ """
23
+
24
+
25
+ class ZoneGroup(ZoneDataClass):
26
+ #
27
+ # Group Events
28
+ #
29
+ class Events:
30
+ PREFIX = "zone.group."
31
+ INDEX_CHANGE = PREFIX + "index_change"
32
+ SOURCE_CHANGE = PREFIX + "source_change"
33
+ IS_MASTER_CHANGE = PREFIX + "is_master_change"
34
+
35
+ DEFAULTS = {"index": 0, "source": None, "is_master": False}
36
+
37
+ def __init__(self, zone: "zone.Zone"):
38
+ self.zone = zone
39
+
40
+ """ On a A.3x the group index are, Im not sure if this is consistant with A1.x or A6.x:
41
+
42
+ Zone 1: 9
43
+ Zone 2: 10
44
+ Zone 3: 11
45
+
46
+ This can be assigned to other group members when this zone is the source.
47
+
48
+ I have not idea if this is how it was intended, but its working on the A3.x
49
+
50
+ """
51
+ self._index_id = zone.id + 8
52
+
53
+ # index is assigned when a stream is started (see action_32)
54
+ self._index = 0
55
+ self._source = None
56
+ self._is_master = False
57
+
58
+ #
59
+ # Group Add Zone
60
+ #
61
+ def add_member(self, zone_id: "zone.Zone.IDs"):
62
+ if self.zone.id == zone_id:
63
+ self.zone._log_error(f"Zone {zone_id} cant be parent and member")
64
+ return False
65
+
66
+ if zone.Zone.IDs.is_not_valid(zone_id):
67
+ self.zone._log_error(f"Zone {zone_id} doesnt exist")
68
+ return False
69
+
70
+ if self.is_member:
71
+ self.zone._log_error(
72
+ f"Zone {self.zone.id} already a member of Zone {self.source} group"
73
+ )
74
+ return False
75
+
76
+ if self.zone.transport.is_stopped:
77
+ self.zone._log_error(
78
+ f"Zone {self.zone.id} cant be a group master when not playing a source"
79
+ )
80
+ return False
81
+
82
+ self.zone.api_alpha.request_action_4B_add(zone_id)
83
+
84
+ #
85
+ # Group Remove Child
86
+ #
87
+ def remove_member(self, zone_id: "zone.Zone.IDs"):
88
+ self.zone.api_alpha.request_action_4B_remove(zone_id)
89
+
90
+ #
91
+ # Group Dissolve
92
+ #
93
+ def dissolve(self):
94
+ self.zone.api_alpha.request_action_4B_dissolve()
95
+
96
+ #
97
+ # Leave any groups if is a member
98
+ #
99
+ def leave(self) -> None:
100
+ self.zone.api_alpha.request_action_4B_remove(self.zone.id)
101
+
102
+ @property
103
+ def index_id(self):
104
+ return self._index_id
105
+
106
+ @index_id.setter
107
+ def index_id(self, index_id: int):
108
+ pass # read-only
109
+
110
+ #
111
+ # Group Index
112
+ #
113
+ # The index is assigned when the zone starts a stream.
114
+ #
115
+ @property
116
+ def index(self):
117
+ return self._index
118
+
119
+ @index.setter
120
+ def index(self, index: int):
121
+ pass # read-only
122
+
123
+ #
124
+ # Group Source
125
+ #
126
+ @property
127
+ def source(self):
128
+ return self._source
129
+
130
+ @source.setter
131
+ def source(self, grs: int):
132
+ pass # read-only
133
+
134
+ def _set_source(self, grs: int):
135
+ new_source = (
136
+ zone.Zone.IDs(grs) if grs != 255 and zone.Zone.IDs.is_valid(grs) else None
137
+ )
138
+
139
+ if self.source != new_source:
140
+ self._source = new_source
141
+ return True
142
+
143
+ @property
144
+ def is_member(self):
145
+ return self.source is not None
146
+
147
+ #
148
+ # Group: This zone is a master for a group
149
+ #
150
+ # TODO: propogate track meta to member zones.
151
+ # Only play state is propgated by the VSSL device
152
+ #
153
+ @property
154
+ def is_master(self):
155
+ return self._is_master
156
+
157
+ @is_master.setter
158
+ def is_master(self, grm: int):
159
+ pass # read-only
160
+
161
+ def _set_is_master(self, grm: int):
162
+ if self.is_master != bool(grm):
163
+ self._is_master = grm != 0
164
+ return True
165
+
166
+ #
167
+ # Get groups master zone
168
+ #
169
+ @property
170
+ def master(self):
171
+ if self.is_master:
172
+ return self
173
+ if self.source is not None:
174
+ return self.zone.vssl.get_zone(self.source)
175
+
176
+ #
177
+ # Get group member zones
178
+ #
179
+ @property
180
+ def members(self):
181
+ if not self.is_master:
182
+ return []
183
+ return [
184
+ zone
185
+ for zone in self.zone.vssl.zones.values()
186
+ if zone.group.index == self.index and zone.group.is_member
187
+ ]