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/__init__.py +4 -0
- vsslctrl/api_alpha.py +1040 -0
- vsslctrl/api_base.py +321 -0
- vsslctrl/api_bravo.py +419 -0
- vsslctrl/core.py +322 -0
- vsslctrl/data_structure.py +242 -0
- vsslctrl/decorators.py +61 -0
- vsslctrl/discovery.py +193 -0
- vsslctrl/event_bus.py +159 -0
- vsslctrl/exceptions.py +21 -0
- vsslctrl/group.py +187 -0
- vsslctrl/io.py +229 -0
- vsslctrl/settings.py +655 -0
- vsslctrl/track.py +337 -0
- vsslctrl/transport.py +242 -0
- vsslctrl/utils.py +75 -0
- vsslctrl/zone.py +452 -0
- vsslctrl-0.1.0.dev1.dist-info/LICENSE +21 -0
- vsslctrl-0.1.0.dev1.dist-info/METADATA +385 -0
- vsslctrl-0.1.0.dev1.dist-info/RECORD +22 -0
- vsslctrl-0.1.0.dev1.dist-info/WHEEL +5 -0
- vsslctrl-0.1.0.dev1.dist-info/top_level.txt +1 -0
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
|
+
]
|