gukebox 1.0.0.dev9__tar.gz → 1.0.0.dev10__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.
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/PKG-INFO +1 -1
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api_controller.py +1 -1
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/inbound/config.py +25 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +9 -3
- gukebox-1.0.0.dev10/jukebox/adapters/outbound/sonos_discovery_adapter.py +391 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/app.py +45 -2
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/cli_presentation.py +32 -17
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/command_handlers.py +60 -4
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/commands.py +1 -0
- gukebox-1.0.0.dev10/jukebox/admin/sonos_households.py +31 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/app.py +15 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/di_container.py +9 -1
- gukebox-1.0.0.dev10/jukebox/pn532/__init__.py +8 -0
- gukebox-1.0.0.dev10/jukebox/pn532/profiles.py +32 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/definitions.py +46 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/entities.py +25 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/resolve.py +19 -1
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/runtime_resolver.py +15 -2
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/sonos/discovery.py +2 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/sonos/selection.py +20 -2
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/sonos/service.py +34 -4
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/pyproject.toml +1 -1
- gukebox-1.0.0.dev9/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -196
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/LICENSE +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/README.md +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/current_tag_router.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/discs_router.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/models.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/settings_router.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/cli_display.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_controller.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_pages/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_pages/library.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_pages/settings.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_pages/sonos.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/outbound/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/outbound/json_library_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/command_handlers.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/commands.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/di_container.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/entities/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/entities/current_tag_status.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/repositories/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/add_disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/edit_disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/get_disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/list_discs.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/remove_disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/search_discs.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/inbound/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/players/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/readers/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/di_container.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/services.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/current_tag_action.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/library.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/playback_action.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/playback_session.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/tag_event.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/ports/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/ports/player_port.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/ports/reader_port.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/repositories/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/repositories/current_tag_repository.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/repositories/library_repository.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/use_cases/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/use_cases/determine_action.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/dict_utils.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/errors.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/file_settings_repository.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/migration.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/repositories.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/runtime_validation.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/selected_sonos_group_repository.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/service_protocols.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/timing_validation.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/types.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/validation_rules.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/view_utils.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/config_utils.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/dependency_messages.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/logger.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/timing.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/sonos/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/pn532/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/pn532/pn532.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/pn532/spi.py +0 -0
|
@@ -139,7 +139,7 @@ class APIController:
|
|
|
139
139
|
@self.app.get("/api/v1/sonos/speakers", response_model=list[SonosSpeakerOutput])
|
|
140
140
|
def get_sonos_speakers():
|
|
141
141
|
try:
|
|
142
|
-
return self.sonos_service.
|
|
142
|
+
return self.sonos_service.list_network_speakers()
|
|
143
143
|
except SonosDiscoveryError as err:
|
|
144
144
|
raise HTTPException(status_code=502, detail=str(err))
|
|
145
145
|
except Exception as err:
|
|
@@ -15,6 +15,9 @@ class JukeboxCliConfig(BaseModel):
|
|
|
15
15
|
sonos_name: Optional[str] = None
|
|
16
16
|
pause_duration_seconds: Optional[int] = None
|
|
17
17
|
pause_delay_seconds: Optional[float] = None
|
|
18
|
+
pn532_spi_reset: Optional[int] = None
|
|
19
|
+
pn532_spi_cs: Optional[int] = None
|
|
20
|
+
pn532_spi_irq: Optional[int] = None
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
def parse_config() -> JukeboxCliConfig:
|
|
@@ -71,6 +74,25 @@ def parse_config() -> JukeboxCliConfig:
|
|
|
71
74
|
help="override the grace period in seconds before pausing when a tag is removed",
|
|
72
75
|
)
|
|
73
76
|
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--pn532-spi-reset",
|
|
79
|
+
default=None,
|
|
80
|
+
type=int,
|
|
81
|
+
help="override the PN532 SPI reset GPIO pin for this process",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--pn532-spi-cs",
|
|
85
|
+
default=None,
|
|
86
|
+
type=int,
|
|
87
|
+
help="override the PN532 SPI chip select GPIO pin for this process",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--pn532-spi-irq",
|
|
91
|
+
default=None,
|
|
92
|
+
type=int,
|
|
93
|
+
help="override the PN532 SPI IRQ GPIO pin for this process",
|
|
94
|
+
)
|
|
95
|
+
|
|
74
96
|
args = parser.parse_args()
|
|
75
97
|
|
|
76
98
|
return JukeboxCliConfig(
|
|
@@ -82,4 +104,7 @@ def parse_config() -> JukeboxCliConfig:
|
|
|
82
104
|
sonos_name=args.sonos_name,
|
|
83
105
|
pause_duration_seconds=args.pause_duration,
|
|
84
106
|
pause_delay_seconds=args.pause_delay,
|
|
107
|
+
pn532_spi_reset=args.pn532_spi_reset,
|
|
108
|
+
pn532_spi_cs=args.pn532_spi_cs,
|
|
109
|
+
pn532_spi_irq=args.pn532_spi_irq,
|
|
85
110
|
)
|
{gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py
RENAMED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
-
from typing import Union
|
|
5
|
+
from typing import Optional, Union
|
|
6
6
|
|
|
7
7
|
from jukebox.shared.dependency_messages import optional_extra_dependency_message
|
|
8
8
|
|
|
@@ -28,7 +28,13 @@ def spi_active():
|
|
|
28
28
|
class Pn532ReaderAdapter(ReaderPort):
|
|
29
29
|
"""Adapter for Pn532 NFC reader implementing ReaderPort."""
|
|
30
30
|
|
|
31
|
-
def __init__(
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
read_timeout_seconds: float = DEFAULT_NFC_READ_TIMEOUT_SECONDS,
|
|
34
|
+
spi_reset: Optional[int] = None,
|
|
35
|
+
spi_cs: Optional[int] = None,
|
|
36
|
+
spi_irq: Optional[int] = None,
|
|
37
|
+
):
|
|
32
38
|
if not spi_active():
|
|
33
39
|
error_message = (
|
|
34
40
|
"The SPI interface is not enabled. Please enable it to use the PN532 NFC reader."
|
|
@@ -37,7 +43,7 @@ class Pn532ReaderAdapter(ReaderPort):
|
|
|
37
43
|
LOGGER.error(error_message)
|
|
38
44
|
raise RuntimeError("SPI interface not enabled. Use raspi-config to enable it.")
|
|
39
45
|
|
|
40
|
-
self.pn532 = PN532_SPI(debug=False, reset=
|
|
46
|
+
self.pn532 = PN532_SPI(debug=False, reset=spi_reset, cs=spi_cs, irq=spi_irq)
|
|
41
47
|
self.read_timeout_seconds = read_timeout_seconds
|
|
42
48
|
ic, ver, rev, support = self.pn532.get_firmware_version()
|
|
43
49
|
LOGGER.info(f"Found PN532 with firmware version: {ver}.{rev}")
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import re
|
|
3
|
+
import select
|
|
4
|
+
import socket
|
|
5
|
+
import struct
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Optional, Protocol
|
|
9
|
+
|
|
10
|
+
from jukebox.sonos.discovery import (
|
|
11
|
+
DiscoveredSonosSpeaker,
|
|
12
|
+
SonosDiscoveryError,
|
|
13
|
+
SonosDiscoveryPort,
|
|
14
|
+
sort_sonos_speakers,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class _SonosDiscoverySnapshot:
|
|
20
|
+
speakers: list[DiscoveredSonosSpeaker]
|
|
21
|
+
retry_hosts_by_uid: dict[str, list[str]]
|
|
22
|
+
normalization_errors: list[str]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_SSDP_RESPONSE_TIMEOUT_SECONDS = 1.0
|
|
26
|
+
_SSDP_RESPONSE_POLL_INTERVAL_SECONDS = 0.1
|
|
27
|
+
_SSDP_MULTICAST_GROUP = "239.255.255.250"
|
|
28
|
+
_SSDP_MULTICAST_PORT = 1900
|
|
29
|
+
_MAX_SCAN_NETWORK_PREFIX = 22
|
|
30
|
+
_HOUSEHOLD_HEADER_RE = re.compile(rb"(?im)^x-rincon-household:\s*([^\r\n]+)")
|
|
31
|
+
_PLAYER_SEARCH = (
|
|
32
|
+
"M-SEARCH * HTTP/1.1\r\n"
|
|
33
|
+
f"HOST: {_SSDP_MULTICAST_GROUP}:{_SSDP_MULTICAST_PORT}\r\n"
|
|
34
|
+
'MAN: "ssdp:discover"\r\n'
|
|
35
|
+
"MX: 1\r\n"
|
|
36
|
+
"ST: urn:schemas-upnp-org:device:ZonePlayer:1\r\n"
|
|
37
|
+
"\r\n"
|
|
38
|
+
).encode("utf-8")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SoCoSonosDiscoveryAdapter(SonosDiscoveryPort):
|
|
42
|
+
def discover_speakers(self) -> list[DiscoveredSonosSpeaker]:
|
|
43
|
+
snapshot = self._discover_network_snapshot()
|
|
44
|
+
return self._recover_snapshot_speakers(snapshot)
|
|
45
|
+
|
|
46
|
+
def discover_household_speakers(self, household_id: str) -> list[DiscoveredSonosSpeaker]:
|
|
47
|
+
snapshot = self._discover_household_snapshot(household_id)
|
|
48
|
+
return sort_sonos_speakers(
|
|
49
|
+
[speaker for speaker in self._recover_snapshot_speakers(snapshot) if speaker.household_id == household_id]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _recover_snapshot_speakers(self, snapshot: _SonosDiscoverySnapshot) -> list[DiscoveredSonosSpeaker]:
|
|
53
|
+
speakers_by_uid = {speaker.uid: speaker for speaker in snapshot.speakers}
|
|
54
|
+
for expected_uid, hosts in snapshot.retry_hosts_by_uid.items():
|
|
55
|
+
for host in hosts:
|
|
56
|
+
try:
|
|
57
|
+
recovered = self._resolve_speaker_by_host(expected_uid, host)
|
|
58
|
+
except ValueError:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
existing = speakers_by_uid.get(recovered.uid)
|
|
62
|
+
speakers_by_uid[recovered.uid] = self._choose_preferred(existing, recovered)
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
recovered_speakers = sort_sonos_speakers(list(speakers_by_uid.values()))
|
|
66
|
+
if not recovered_speakers and snapshot.normalization_errors:
|
|
67
|
+
raise SonosDiscoveryError(
|
|
68
|
+
"Discovered Sonos speakers but failed to inspect any reachable speakers: "
|
|
69
|
+
f"{snapshot.normalization_errors[0]}"
|
|
70
|
+
)
|
|
71
|
+
return recovered_speakers
|
|
72
|
+
|
|
73
|
+
def _discover_network_snapshot(self) -> _SonosDiscoverySnapshot:
|
|
74
|
+
import soco
|
|
75
|
+
import soco.discovery
|
|
76
|
+
from requests.exceptions import RequestException
|
|
77
|
+
from soco.exceptions import SoCoException
|
|
78
|
+
from urllib3.exceptions import HTTPError
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
discovered = self._discover_multicast_network_speakers()
|
|
82
|
+
except (HTTPError, OSError, RequestException, SoCoException) as err:
|
|
83
|
+
raise SonosDiscoveryError(f"Failed to discover Sonos speakers: {err}") from err
|
|
84
|
+
|
|
85
|
+
if not discovered:
|
|
86
|
+
try:
|
|
87
|
+
discovered = soco.discovery.scan_network(
|
|
88
|
+
include_invisible=True,
|
|
89
|
+
multi_household=True,
|
|
90
|
+
networks_to_scan=_build_private_ipv4_networks_to_scan(),
|
|
91
|
+
)
|
|
92
|
+
except (HTTPError, OSError, RequestException, SoCoException) as err:
|
|
93
|
+
raise SonosDiscoveryError(f"Failed to discover Sonos speakers: {err}") from err
|
|
94
|
+
return self._normalize_snapshot(set(discovered or set()))
|
|
95
|
+
|
|
96
|
+
def _discover_household_snapshot(self, household_id: str) -> _SonosDiscoverySnapshot:
|
|
97
|
+
import soco
|
|
98
|
+
from requests.exceptions import RequestException
|
|
99
|
+
from soco.exceptions import SoCoException
|
|
100
|
+
from urllib3.exceptions import HTTPError
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
discovered = soco.discover(
|
|
104
|
+
include_invisible=True,
|
|
105
|
+
household_id=household_id,
|
|
106
|
+
allow_network_scan=True,
|
|
107
|
+
networks_to_scan=_build_private_ipv4_networks_to_scan(),
|
|
108
|
+
)
|
|
109
|
+
except (HTTPError, OSError, RequestException, SoCoException) as err:
|
|
110
|
+
raise SonosDiscoveryError(f"Failed to discover Sonos household `{household_id}`: {err}") from err
|
|
111
|
+
|
|
112
|
+
snapshot = self._normalize_snapshot(set(discovered or set()))
|
|
113
|
+
return _SonosDiscoverySnapshot(
|
|
114
|
+
speakers=[speaker for speaker in snapshot.speakers if speaker.household_id == household_id],
|
|
115
|
+
retry_hosts_by_uid=snapshot.retry_hosts_by_uid,
|
|
116
|
+
normalization_errors=snapshot.normalization_errors,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# soco.discover() only surfaces the first household that responds, so we
|
|
120
|
+
# try manual SSDP multicast discovery first before falling back to a broader
|
|
121
|
+
# subnet scan. If SoCo adds native multi-household discovery, this can go away.
|
|
122
|
+
# ref: https://soco.readthedocs.io/en/latest/api/soco.discovery.html#soco.discovery.discover
|
|
123
|
+
def _discover_multicast_network_speakers(self) -> set[Any]:
|
|
124
|
+
import soco
|
|
125
|
+
import soco.discovery
|
|
126
|
+
from requests.exceptions import RequestException
|
|
127
|
+
from soco.exceptions import SoCoException, SoCoUPnPException
|
|
128
|
+
from urllib3.exceptions import HTTPError
|
|
129
|
+
|
|
130
|
+
# To make that multicast probe reliable, we send it from each local IPv4
|
|
131
|
+
# interface via IP_MULTICAST_IF; otherwise we may only probe one network path
|
|
132
|
+
# and miss reachable households.
|
|
133
|
+
#
|
|
134
|
+
# This uses a private SoCo helper because there is no public equivalent for
|
|
135
|
+
# enumerating the interface IPv4 addresses to bind the multicast sockets to.
|
|
136
|
+
interface_addresses = soco.discovery._find_ipv4_addresses()
|
|
137
|
+
if not interface_addresses:
|
|
138
|
+
return set()
|
|
139
|
+
|
|
140
|
+
sockets = []
|
|
141
|
+
for interface_address in interface_addresses:
|
|
142
|
+
try:
|
|
143
|
+
multicast_socket = self._create_multicast_socket(interface_address)
|
|
144
|
+
except OSError:
|
|
145
|
+
continue
|
|
146
|
+
sockets.append(multicast_socket)
|
|
147
|
+
|
|
148
|
+
if not sockets:
|
|
149
|
+
return set()
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
for _ in range(3):
|
|
153
|
+
for multicast_socket in list(sockets):
|
|
154
|
+
try:
|
|
155
|
+
multicast_socket.sendto(_PLAYER_SEARCH, (_SSDP_MULTICAST_GROUP, _SSDP_MULTICAST_PORT))
|
|
156
|
+
except OSError:
|
|
157
|
+
sockets.remove(multicast_socket)
|
|
158
|
+
multicast_socket.close()
|
|
159
|
+
|
|
160
|
+
if not sockets:
|
|
161
|
+
return set()
|
|
162
|
+
|
|
163
|
+
household_hosts = self._collect_multicast_household_hosts(sockets)
|
|
164
|
+
finally:
|
|
165
|
+
for multicast_socket in sockets:
|
|
166
|
+
multicast_socket.close()
|
|
167
|
+
|
|
168
|
+
speakers = set()
|
|
169
|
+
for hosts in household_hosts.values():
|
|
170
|
+
for host in hosts:
|
|
171
|
+
try:
|
|
172
|
+
speakers.update(soco.SoCo(host).all_zones)
|
|
173
|
+
break
|
|
174
|
+
except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException):
|
|
175
|
+
continue
|
|
176
|
+
return speakers
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _create_multicast_socket(interface_address: str) -> socket.socket:
|
|
180
|
+
multicast_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
181
|
+
multicast_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, struct.pack("B", 4))
|
|
182
|
+
multicast_socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(interface_address))
|
|
183
|
+
return multicast_socket
|
|
184
|
+
|
|
185
|
+
def _collect_multicast_household_hosts(self, sockets: list[socket.socket]) -> dict[str, list[str]]:
|
|
186
|
+
deadline = time.monotonic() + _SSDP_RESPONSE_TIMEOUT_SECONDS
|
|
187
|
+
household_hosts = {}
|
|
188
|
+
|
|
189
|
+
while sockets:
|
|
190
|
+
remaining = deadline - time.monotonic()
|
|
191
|
+
if remaining <= 0:
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
ready_sockets, _, _ = select.select(
|
|
195
|
+
sockets,
|
|
196
|
+
[],
|
|
197
|
+
[],
|
|
198
|
+
min(remaining, _SSDP_RESPONSE_POLL_INTERVAL_SECONDS),
|
|
199
|
+
)
|
|
200
|
+
if not ready_sockets:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
for ready_socket in ready_sockets:
|
|
204
|
+
response, address = ready_socket.recvfrom(1024)
|
|
205
|
+
household_id = _extract_sonos_household_id(response)
|
|
206
|
+
if household_id is None:
|
|
207
|
+
continue
|
|
208
|
+
hosts = household_hosts.setdefault(household_id, [])
|
|
209
|
+
if address[0] not in hosts:
|
|
210
|
+
hosts.append(address[0])
|
|
211
|
+
|
|
212
|
+
return household_hosts
|
|
213
|
+
|
|
214
|
+
def _normalize_snapshot(self, discovered: set[Any]) -> _SonosDiscoverySnapshot:
|
|
215
|
+
normalization_errors = []
|
|
216
|
+
available_speakers = set(discovered)
|
|
217
|
+
for speaker in list(discovered):
|
|
218
|
+
try:
|
|
219
|
+
available_speakers.update(speaker.all_zones)
|
|
220
|
+
except Exception:
|
|
221
|
+
available_speakers.add(speaker)
|
|
222
|
+
|
|
223
|
+
if not available_speakers:
|
|
224
|
+
return _SonosDiscoverySnapshot(
|
|
225
|
+
speakers=[],
|
|
226
|
+
retry_hosts_by_uid={},
|
|
227
|
+
normalization_errors=normalization_errors,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
speakers_by_uid = {}
|
|
231
|
+
retry_hosts_by_uid = {}
|
|
232
|
+
for speaker in available_speakers:
|
|
233
|
+
expected_uid = _safe_speaker_uid(speaker)
|
|
234
|
+
normalized, error = self._normalize_speaker(speaker)
|
|
235
|
+
if normalized is None:
|
|
236
|
+
if error is not None:
|
|
237
|
+
normalization_errors.append(error)
|
|
238
|
+
if expected_uid is not None:
|
|
239
|
+
host = _safe_speaker_host(speaker)
|
|
240
|
+
if host is not None:
|
|
241
|
+
retry_hosts_by_uid.setdefault(expected_uid, set()).add(host)
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
existing = speakers_by_uid.get(normalized.uid)
|
|
245
|
+
speakers_by_uid[normalized.uid] = self._choose_preferred(existing, normalized)
|
|
246
|
+
|
|
247
|
+
return _SonosDiscoverySnapshot(
|
|
248
|
+
speakers=sort_sonos_speakers(list(speakers_by_uid.values())),
|
|
249
|
+
retry_hosts_by_uid={uid: sorted(hosts) for uid, hosts in sorted(retry_hosts_by_uid.items())},
|
|
250
|
+
normalization_errors=normalization_errors,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _resolve_speaker_by_host(self, expected_uid: str, host: str) -> DiscoveredSonosSpeaker:
|
|
254
|
+
from requests.exceptions import RequestException
|
|
255
|
+
from soco import SoCo
|
|
256
|
+
from soco.exceptions import SoCoException, SoCoUPnPException
|
|
257
|
+
from urllib3.exceptions import HTTPError
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
speaker = SoCo(host)
|
|
261
|
+
resolved_uid = speaker.uid
|
|
262
|
+
except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException) as err:
|
|
263
|
+
raise ValueError(f"Failed to contact saved Sonos speaker at {host}: {err}") from err
|
|
264
|
+
|
|
265
|
+
if resolved_uid != expected_uid:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"Saved Sonos speaker UID mismatch for host {host}: expected {expected_uid}, resolved {resolved_uid}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
return DiscoveredSonosSpeaker(
|
|
272
|
+
uid=speaker.uid,
|
|
273
|
+
name=speaker.player_name,
|
|
274
|
+
host=speaker.ip_address,
|
|
275
|
+
household_id=speaker.household_id,
|
|
276
|
+
is_visible=getattr(speaker, "is_visible", True) is not False,
|
|
277
|
+
)
|
|
278
|
+
except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException) as err:
|
|
279
|
+
raise ValueError(f"Failed to inspect discovered Sonos speaker at {host}: {err}") from err
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def _choose_preferred(
|
|
283
|
+
existing: Optional[DiscoveredSonosSpeaker],
|
|
284
|
+
candidate: DiscoveredSonosSpeaker,
|
|
285
|
+
) -> DiscoveredSonosSpeaker:
|
|
286
|
+
if existing is None:
|
|
287
|
+
return candidate
|
|
288
|
+
if candidate.is_visible and not existing.is_visible:
|
|
289
|
+
return candidate
|
|
290
|
+
if existing.is_visible and not candidate.is_visible:
|
|
291
|
+
return existing
|
|
292
|
+
if (candidate.name, candidate.host, candidate.uid) < (existing.name, existing.host, existing.uid):
|
|
293
|
+
return candidate
|
|
294
|
+
return existing
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def _normalize_speaker(
|
|
298
|
+
speaker: "_SonosSpeakerLike",
|
|
299
|
+
) -> tuple[Optional[DiscoveredSonosSpeaker], Optional[str]]:
|
|
300
|
+
from requests.exceptions import RequestException
|
|
301
|
+
from soco.exceptions import SoCoException, SoCoUPnPException
|
|
302
|
+
from urllib3.exceptions import HTTPError
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
return (
|
|
306
|
+
DiscoveredSonosSpeaker(
|
|
307
|
+
uid=speaker.uid,
|
|
308
|
+
name=speaker.player_name,
|
|
309
|
+
host=speaker.ip_address,
|
|
310
|
+
household_id=speaker.household_id,
|
|
311
|
+
is_visible=getattr(speaker, "is_visible", True) is not False,
|
|
312
|
+
),
|
|
313
|
+
None,
|
|
314
|
+
)
|
|
315
|
+
except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException) as err:
|
|
316
|
+
return (
|
|
317
|
+
None,
|
|
318
|
+
f"{_safe_speaker_identifier(speaker)}: {err}",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class _SonosSpeakerLike(Protocol):
|
|
323
|
+
uid: str
|
|
324
|
+
player_name: str
|
|
325
|
+
ip_address: str
|
|
326
|
+
household_id: str
|
|
327
|
+
all_zones: set[Any]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _safe_speaker_identifier(speaker: "_SonosSpeakerLike") -> str:
|
|
331
|
+
ip_address = _safe_speaker_host(speaker)
|
|
332
|
+
if ip_address:
|
|
333
|
+
return ip_address
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
uid = getattr(speaker, "uid")
|
|
337
|
+
except Exception:
|
|
338
|
+
return "unknown speaker"
|
|
339
|
+
|
|
340
|
+
return str(uid)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _safe_speaker_host(speaker: "_SonosSpeakerLike") -> Optional[str]:
|
|
344
|
+
try:
|
|
345
|
+
ip_address = getattr(speaker, "ip_address", None)
|
|
346
|
+
except Exception:
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
if ip_address:
|
|
350
|
+
return str(ip_address)
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _safe_speaker_uid(speaker: "_SonosSpeakerLike") -> Optional[str]:
|
|
355
|
+
try:
|
|
356
|
+
return str(getattr(speaker, "uid"))
|
|
357
|
+
except Exception:
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _extract_sonos_household_id(response: bytes) -> Optional[str]:
|
|
362
|
+
match = _HOUSEHOLD_HEADER_RE.search(response)
|
|
363
|
+
if match is None:
|
|
364
|
+
return None
|
|
365
|
+
return match.group(1).decode("utf-8", "ignore").strip() or None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _build_private_ipv4_networks_to_scan() -> list[str]:
|
|
369
|
+
import ifaddr
|
|
370
|
+
|
|
371
|
+
networks = set()
|
|
372
|
+
for adapter in ifaddr.get_adapters():
|
|
373
|
+
for adapter_ip in adapter.ips:
|
|
374
|
+
try:
|
|
375
|
+
ipv4_address = ipaddress.IPv4Address(adapter_ip.ip)
|
|
376
|
+
except Exception:
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
if adapter_ip.network_prefix >= 32:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
network_prefix = adapter_ip.network_prefix
|
|
383
|
+
if network_prefix < _MAX_SCAN_NETWORK_PREFIX:
|
|
384
|
+
network_prefix = _MAX_SCAN_NETWORK_PREFIX
|
|
385
|
+
|
|
386
|
+
ipv4_network = ipaddress.ip_network(f"{ipv4_address}/{network_prefix}", strict=False)
|
|
387
|
+
if not ipv4_network.is_private or ipv4_network.is_loopback or ipv4_network.is_link_local:
|
|
388
|
+
continue
|
|
389
|
+
networks.add(str(ipv4_network))
|
|
390
|
+
|
|
391
|
+
return sorted(networks)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sys
|
|
1
2
|
import traceback
|
|
2
3
|
from typing import Annotated, Optional
|
|
3
4
|
|
|
@@ -21,7 +22,12 @@ from jukebox.shared.config_utils import get_package_version
|
|
|
21
22
|
from jukebox.shared.logger import set_logger
|
|
22
23
|
from jukebox.sonos.discovery import DiscoveredSonosSpeaker
|
|
23
24
|
|
|
24
|
-
from .cli_presentation import
|
|
25
|
+
from .cli_presentation import (
|
|
26
|
+
build_sonos_household_choice_label,
|
|
27
|
+
build_sonos_speaker_choice_label,
|
|
28
|
+
render_cli_error,
|
|
29
|
+
render_sonos_speakers_output,
|
|
30
|
+
)
|
|
25
31
|
from .command_handlers import execute_server_command, execute_settings_command, execute_sonos_command
|
|
26
32
|
from .commands import (
|
|
27
33
|
ApiCommand,
|
|
@@ -36,6 +42,7 @@ from .commands import (
|
|
|
36
42
|
is_sonos_command,
|
|
37
43
|
)
|
|
38
44
|
from .di_container import build_admin_api_app, build_admin_services, build_admin_ui_app, build_settings_service
|
|
45
|
+
from .sonos_households import GroupedSonosHousehold
|
|
39
46
|
|
|
40
47
|
|
|
41
48
|
class AdminCliState:
|
|
@@ -76,8 +83,10 @@ def _run_command(ctx: typer.Context, command: object) -> None:
|
|
|
76
83
|
command=command,
|
|
77
84
|
sonos_service=services.sonos,
|
|
78
85
|
settings_service=services.settings,
|
|
86
|
+
household_prompt_fn=_prompt_for_sonos_household_selection,
|
|
79
87
|
speaker_prompt_fn=_prompt_for_sonos_speaker_selection,
|
|
80
88
|
coordinator_prompt_fn=_prompt_for_sonos_group_coordinator,
|
|
89
|
+
status_fn=_emit_cli_status,
|
|
81
90
|
)
|
|
82
91
|
else:
|
|
83
92
|
execute_server_command(
|
|
@@ -167,6 +176,25 @@ def _prompt_for_sonos_speaker_selection(speakers: list[DiscoveredSonosSpeaker])
|
|
|
167
176
|
return None
|
|
168
177
|
|
|
169
178
|
|
|
179
|
+
def _prompt_for_sonos_household_selection(households: list[GroupedSonosHousehold]) -> Optional[str]:
|
|
180
|
+
import questionary
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
typer.echo(render_sonos_speakers_output(households))
|
|
184
|
+
return questionary.select(
|
|
185
|
+
"Select the Sonos household",
|
|
186
|
+
choices=[
|
|
187
|
+
questionary.Choice(
|
|
188
|
+
title=build_sonos_household_choice_label(household),
|
|
189
|
+
value=household.household_id,
|
|
190
|
+
)
|
|
191
|
+
for household in households
|
|
192
|
+
],
|
|
193
|
+
).ask()
|
|
194
|
+
except KeyboardInterrupt:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
170
198
|
def _prompt_for_sonos_group_coordinator(speakers: list[DiscoveredSonosSpeaker]) -> Optional[str]:
|
|
171
199
|
import questionary
|
|
172
200
|
|
|
@@ -185,6 +213,12 @@ def _prompt_for_sonos_group_coordinator(speakers: list[DiscoveredSonosSpeaker])
|
|
|
185
213
|
return None
|
|
186
214
|
|
|
187
215
|
|
|
216
|
+
def _emit_cli_status(message: str) -> None:
|
|
217
|
+
if not sys.stderr.isatty():
|
|
218
|
+
return
|
|
219
|
+
typer.echo(message, err=True)
|
|
220
|
+
|
|
221
|
+
|
|
188
222
|
app = typer.Typer(help="Admin CLI for jukebox")
|
|
189
223
|
settings_app = typer.Typer(help="Inspect and manage application settings")
|
|
190
224
|
library_app = typer.Typer(help="Manage the library")
|
|
@@ -319,12 +353,21 @@ def sonos_select(
|
|
|
319
353
|
Optional[str],
|
|
320
354
|
typer.Option("--coordinator", help="coordinator UID for the selected Sonos group"),
|
|
321
355
|
] = None,
|
|
356
|
+
household: Annotated[
|
|
357
|
+
Optional[str],
|
|
358
|
+
typer.Option("--household", help="household ID to use for interactive Sonos speaker selection"),
|
|
359
|
+
] = None,
|
|
322
360
|
) -> None:
|
|
323
361
|
parsed_uids = (
|
|
324
362
|
None if uids is None else [uid.strip() for raw_uids in uids for uid in raw_uids.split(",") if uid.strip()]
|
|
325
363
|
)
|
|
326
364
|
try:
|
|
327
|
-
command = SonosSelectCommand(
|
|
365
|
+
command = SonosSelectCommand(
|
|
366
|
+
type="sonos_select",
|
|
367
|
+
uids=parsed_uids,
|
|
368
|
+
coordinator=coordinator,
|
|
369
|
+
household=household,
|
|
370
|
+
)
|
|
328
371
|
except ValidationError as err:
|
|
329
372
|
_exit_on_command_validation_error(err)
|
|
330
373
|
|
|
@@ -15,6 +15,7 @@ from jukebox.sonos.discovery import DiscoveredSonosSpeaker
|
|
|
15
15
|
from jukebox.sonos.selection import SonosSelectionResult, SonosSelectionStatus
|
|
16
16
|
|
|
17
17
|
from .commands import SettingsResetCommand, SettingsSetCommand, SettingsShowCommand
|
|
18
|
+
from .sonos_households import GroupedSonosHousehold
|
|
18
19
|
|
|
19
20
|
_SECTION_ORDER = ("paths", "admin", "playback", "reader", "player", "other")
|
|
20
21
|
_VALIDATION_SUFFIX_RE = re.compile(r"\s+\[type=.*$")
|
|
@@ -47,23 +48,35 @@ def render_cli_error(err: BaseException, verbose: bool = False) -> str:
|
|
|
47
48
|
return message
|
|
48
49
|
|
|
49
50
|
|
|
50
|
-
def render_sonos_speakers_output(
|
|
51
|
-
if not
|
|
51
|
+
def render_sonos_speakers_output(households: list[GroupedSonosHousehold]) -> str:
|
|
52
|
+
if not households:
|
|
52
53
|
return "No visible Sonos speakers found."
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
55
|
+
all_speakers = [speaker for household in households for speaker in household.speakers]
|
|
56
|
+
name_width = max(len(speaker.name) for speaker in all_speakers)
|
|
57
|
+
host_width = max(len(speaker.host) for speaker in all_speakers)
|
|
58
|
+
lines = []
|
|
59
|
+
for household in households:
|
|
60
|
+
lines.append(f"Household: {household.household_id}")
|
|
61
|
+
for index, speaker in enumerate(household.speakers, start=1):
|
|
62
|
+
lines.append(
|
|
63
|
+
" {index}. {name:<{name_width}} {host:<{host_width}} {uid}".format(
|
|
64
|
+
index=index,
|
|
65
|
+
name=speaker.name,
|
|
66
|
+
name_width=name_width,
|
|
67
|
+
host=speaker.host,
|
|
68
|
+
host_width=host_width,
|
|
69
|
+
uid=speaker.uid,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
lines.append("")
|
|
73
|
+
return "\n".join(lines[:-1])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_sonos_household_choice_label(household: GroupedSonosHousehold) -> str:
|
|
77
|
+
speaker_count = len(household.speakers)
|
|
78
|
+
suffix = "speaker" if speaker_count == 1 else "speakers"
|
|
79
|
+
return f"{household.household_id} ({speaker_count} {suffix})"
|
|
67
80
|
|
|
68
81
|
|
|
69
82
|
def build_sonos_speaker_choice_label(speaker: DiscoveredSonosSpeaker) -> str:
|
|
@@ -102,6 +115,7 @@ def render_sonos_selection_status_output(status: SonosSelectionStatus) -> str:
|
|
|
102
115
|
lines.append(f"- Coordinator UID: {status.selected_group.coordinator_uid}")
|
|
103
116
|
else:
|
|
104
117
|
lines.append(f"- Coordinator: {coordinator_speaker.name} [{coordinator_speaker.uid}]")
|
|
118
|
+
lines.append(f"- Household: {coordinator_speaker.household_id}")
|
|
105
119
|
lines.append(f"- Status: {status_label}")
|
|
106
120
|
lines.append("- Members:")
|
|
107
121
|
|
|
@@ -341,6 +355,7 @@ def _format_selected_group(value: object) -> str:
|
|
|
341
355
|
return str(value)
|
|
342
356
|
|
|
343
357
|
selected_group = cast(Dict[str, object], value)
|
|
358
|
+
household_id = selected_group.get("household_id")
|
|
344
359
|
members = selected_group.get("members")
|
|
345
360
|
coordinator_uid = selected_group.get("coordinator_uid")
|
|
346
361
|
if not isinstance(members, list) or not isinstance(coordinator_uid, str):
|
|
@@ -358,8 +373,8 @@ def _format_selected_group(value: object) -> str:
|
|
|
358
373
|
|
|
359
374
|
if not member_uids:
|
|
360
375
|
return json.dumps(value, sort_keys=True, separators=(", ", ": "))
|
|
361
|
-
|
|
362
|
-
return f"{coordinator_uid} (coordinator); members: {', '.join(member_uids)}"
|
|
376
|
+
household_label = f"; household: {household_id}" if isinstance(household_id, str) else ""
|
|
377
|
+
return f"{coordinator_uid} (coordinator){household_label}; members: {', '.join(member_uids)}"
|
|
363
378
|
|
|
364
379
|
|
|
365
380
|
def _render_cli_error_message(err: BaseException) -> str:
|