gukebox 1.0.0.dev9__tar.gz → 1.0.0.dev11__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.dev11}/PKG-INFO +1 -1
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api_controller.py +1 -1
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/inbound/config.py +25 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +14 -3
- gukebox-1.0.0.dev11/jukebox/adapters/outbound/sonos_discovery_adapter.py +391 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/app.py +122 -2
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/cli_presentation.py +19 -17
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/command_handlers.py +60 -4
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/commands.py +1 -0
- gukebox-1.0.0.dev11/jukebox/admin/pn532_command_handlers.py +222 -0
- gukebox-1.0.0.dev11/jukebox/admin/pn532_commands.py +20 -0
- gukebox-1.0.0.dev11/jukebox/admin/sonos_households.py +31 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/app.py +15 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/di_container.py +14 -2
- gukebox-1.0.0.dev11/jukebox/pn532/__init__.py +19 -0
- gukebox-1.0.0.dev11/jukebox/pn532/profiles.py +70 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/definitions.py +46 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/entities.py +24 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/resolve.py +20 -1
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/runtime_resolver.py +10 -1
- gukebox-1.0.0.dev11/jukebox/shared/terminal_ui.py +12 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/sonos/discovery.py +2 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/sonos/selection.py +20 -2
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/sonos/service.py +34 -4
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/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.dev11}/LICENSE +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/README.md +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/current_tag_router.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/discs_router.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/models.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/settings_router.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/cli_display.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_controller.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_pages/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_pages/library.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_pages/settings.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_pages/sonos.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/outbound/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/outbound/json_library_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/command_handlers.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/commands.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/di_container.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/entities/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/entities/current_tag_status.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/repositories/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/add_disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/edit_disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/get_disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/list_discs.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/remove_disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/search_discs.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/inbound/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/players/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/readers/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/di_container.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/services.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/current_tag_action.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/disc.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/library.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/playback_action.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/playback_session.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/tag_event.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/ports/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/ports/player_port.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/ports/reader_port.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/repositories/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/repositories/current_tag_repository.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/repositories/library_repository.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/use_cases/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/use_cases/determine_action.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/dict_utils.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/errors.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/file_settings_repository.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/migration.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/repositories.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/runtime_validation.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/selected_sonos_group_repository.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/service_protocols.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/timing_validation.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/types.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/validation_rules.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/view_utils.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/config_utils.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/dependency_messages.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/logger.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/timing.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/sonos/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/pn532/__init__.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/pn532/pn532.py +0 -0
- {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/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.dev11}/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,12 +43,17 @@ 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}")
|
|
50
|
+
self._firmware_version: tuple[int, int] = (ver, rev)
|
|
44
51
|
self.pn532.SAM_configuration()
|
|
45
52
|
|
|
53
|
+
@property
|
|
54
|
+
def firmware_version(self) -> tuple[int, int]:
|
|
55
|
+
return self._firmware_version
|
|
56
|
+
|
|
46
57
|
def read(self) -> Union[str, None]:
|
|
47
58
|
rawuid = self.pn532.read_passive_target(timeout=self.read_timeout_seconds)
|
|
48
59
|
if rawuid is None:
|
|
@@ -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,9 @@ 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 .pn532_command_handlers import execute_pn532_command
|
|
46
|
+
from .pn532_commands import Pn532ProbeCommand, Pn532ProfilesCommand, Pn532SelectCommand, is_pn532_command
|
|
47
|
+
from .sonos_households import GroupedSonosHousehold
|
|
39
48
|
|
|
40
49
|
|
|
41
50
|
class AdminCliState:
|
|
@@ -76,8 +85,19 @@ def _run_command(ctx: typer.Context, command: object) -> None:
|
|
|
76
85
|
command=command,
|
|
77
86
|
sonos_service=services.sonos,
|
|
78
87
|
settings_service=services.settings,
|
|
88
|
+
household_prompt_fn=_prompt_for_sonos_household_selection,
|
|
79
89
|
speaker_prompt_fn=_prompt_for_sonos_speaker_selection,
|
|
80
90
|
coordinator_prompt_fn=_prompt_for_sonos_group_coordinator,
|
|
91
|
+
status_fn=_emit_cli_status,
|
|
92
|
+
)
|
|
93
|
+
elif is_pn532_command(command):
|
|
94
|
+
execute_pn532_command(
|
|
95
|
+
command=command,
|
|
96
|
+
settings_service=services.settings,
|
|
97
|
+
profile_prompt_fn=_prompt_for_pn532_profile,
|
|
98
|
+
protocol_prompt_fn=_prompt_for_pn532_protocol,
|
|
99
|
+
pin_prompt_fn=_prompt_for_pn532_pin,
|
|
100
|
+
stdout_fn=typer.echo,
|
|
81
101
|
)
|
|
82
102
|
else:
|
|
83
103
|
execute_server_command(
|
|
@@ -89,6 +109,8 @@ def _run_command(ctx: typer.Context, command: object) -> None:
|
|
|
89
109
|
source_command="jukebox-admin",
|
|
90
110
|
)
|
|
91
111
|
except RuntimeError as err:
|
|
112
|
+
if state.verbose:
|
|
113
|
+
raise
|
|
92
114
|
typer.echo(str(err), err=True)
|
|
93
115
|
raise typer.Exit(code=1)
|
|
94
116
|
except SystemExit as err:
|
|
@@ -101,6 +123,9 @@ def _run_command(ctx: typer.Context, command: object) -> None:
|
|
|
101
123
|
except SettingsError as err:
|
|
102
124
|
typer.echo(render_cli_error(err, verbose=state.verbose), err=True)
|
|
103
125
|
raise typer.Exit(code=1)
|
|
126
|
+
except ModuleNotFoundError as err:
|
|
127
|
+
typer.echo(str(err), err=True)
|
|
128
|
+
raise typer.Exit(code=1)
|
|
104
129
|
except OSError as err:
|
|
105
130
|
typer.echo(str(err), err=True)
|
|
106
131
|
raise typer.Exit(code=1)
|
|
@@ -167,6 +192,57 @@ def _prompt_for_sonos_speaker_selection(speakers: list[DiscoveredSonosSpeaker])
|
|
|
167
192
|
return None
|
|
168
193
|
|
|
169
194
|
|
|
195
|
+
def _prompt_for_sonos_household_selection(households: list[GroupedSonosHousehold]) -> Optional[str]:
|
|
196
|
+
import questionary
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
typer.echo(render_sonos_speakers_output(households))
|
|
200
|
+
return questionary.select(
|
|
201
|
+
"Select the Sonos household",
|
|
202
|
+
choices=[
|
|
203
|
+
questionary.Choice(
|
|
204
|
+
title=build_sonos_household_choice_label(household),
|
|
205
|
+
value=household.household_id,
|
|
206
|
+
)
|
|
207
|
+
for household in households
|
|
208
|
+
],
|
|
209
|
+
).ask()
|
|
210
|
+
except KeyboardInterrupt:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _prompt_for_pn532_profile(profiles: list[str]) -> Optional[str]:
|
|
215
|
+
import questionary
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
return questionary.select("Select a PN532 board profile", choices=profiles).ask()
|
|
219
|
+
except KeyboardInterrupt:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _prompt_for_pn532_protocol(protocols: list[str], default: str) -> Optional[str]:
|
|
224
|
+
import questionary
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
return questionary.select("Select a PN532 protocol", choices=protocols, default=default).ask()
|
|
228
|
+
|
|
229
|
+
except KeyboardInterrupt:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _prompt_for_pn532_pin(pin_name: str, default: Optional[int]) -> Optional[str]:
|
|
234
|
+
import questionary
|
|
235
|
+
|
|
236
|
+
default_str = str(default) if default is not None else ""
|
|
237
|
+
try:
|
|
238
|
+
return questionary.text(
|
|
239
|
+
f"SPI {pin_name} pin (GPIO number, leave blank to clear):",
|
|
240
|
+
default=default_str,
|
|
241
|
+
).ask()
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
170
246
|
def _prompt_for_sonos_group_coordinator(speakers: list[DiscoveredSonosSpeaker]) -> Optional[str]:
|
|
171
247
|
import questionary
|
|
172
248
|
|
|
@@ -185,13 +261,21 @@ def _prompt_for_sonos_group_coordinator(speakers: list[DiscoveredSonosSpeaker])
|
|
|
185
261
|
return None
|
|
186
262
|
|
|
187
263
|
|
|
264
|
+
def _emit_cli_status(message: str) -> None:
|
|
265
|
+
if not sys.stderr.isatty():
|
|
266
|
+
return
|
|
267
|
+
typer.echo(message, err=True)
|
|
268
|
+
|
|
269
|
+
|
|
188
270
|
app = typer.Typer(help="Admin CLI for jukebox")
|
|
189
271
|
settings_app = typer.Typer(help="Inspect and manage application settings")
|
|
190
272
|
library_app = typer.Typer(help="Manage the library")
|
|
191
273
|
sonos_app = typer.Typer(help="Inspect Sonos speakers and manage the saved Sonos selection")
|
|
274
|
+
pn532_app = typer.Typer(help="Inspect and debug the PN532 NFC reader")
|
|
192
275
|
app.add_typer(settings_app, name="settings")
|
|
193
276
|
app.add_typer(library_app, name="library")
|
|
194
277
|
app.add_typer(sonos_app, name="sonos")
|
|
278
|
+
app.add_typer(pn532_app, name="pn532")
|
|
195
279
|
|
|
196
280
|
|
|
197
281
|
@app.callback()
|
|
@@ -319,12 +403,21 @@ def sonos_select(
|
|
|
319
403
|
Optional[str],
|
|
320
404
|
typer.Option("--coordinator", help="coordinator UID for the selected Sonos group"),
|
|
321
405
|
] = None,
|
|
406
|
+
household: Annotated[
|
|
407
|
+
Optional[str],
|
|
408
|
+
typer.Option("--household", help="household ID to use for interactive Sonos speaker selection"),
|
|
409
|
+
] = None,
|
|
322
410
|
) -> None:
|
|
323
411
|
parsed_uids = (
|
|
324
412
|
None if uids is None else [uid.strip() for raw_uids in uids for uid in raw_uids.split(",") if uid.strip()]
|
|
325
413
|
)
|
|
326
414
|
try:
|
|
327
|
-
command = SonosSelectCommand(
|
|
415
|
+
command = SonosSelectCommand(
|
|
416
|
+
type="sonos_select",
|
|
417
|
+
uids=parsed_uids,
|
|
418
|
+
coordinator=coordinator,
|
|
419
|
+
household=household,
|
|
420
|
+
)
|
|
328
421
|
except ValidationError as err:
|
|
329
422
|
_exit_on_command_validation_error(err)
|
|
330
423
|
|
|
@@ -336,6 +429,33 @@ def sonos_show(ctx: typer.Context) -> None:
|
|
|
336
429
|
_run_command(ctx, SonosShowCommand(type="sonos_show"))
|
|
337
430
|
|
|
338
431
|
|
|
432
|
+
@pn532_app.command("profiles")
|
|
433
|
+
def pn532_profiles(ctx: typer.Context) -> None:
|
|
434
|
+
"""List available board profiles and their default GPIO pins."""
|
|
435
|
+
_run_command(ctx, Pn532ProfilesCommand(type="pn532_profiles"))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@pn532_app.command("select")
|
|
439
|
+
def pn532_select(
|
|
440
|
+
ctx: typer.Context,
|
|
441
|
+
profile: Annotated[
|
|
442
|
+
Optional[str],
|
|
443
|
+
typer.Option(
|
|
444
|
+
"--profile",
|
|
445
|
+
help="board profile to persist (waveshare_hat, hiletgo_v3, custom)",
|
|
446
|
+
),
|
|
447
|
+
] = None,
|
|
448
|
+
) -> None:
|
|
449
|
+
"""Select a board profile and persist it to settings."""
|
|
450
|
+
_run_command(ctx, Pn532SelectCommand(type="pn532_select", profile=profile))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@pn532_app.command("probe")
|
|
454
|
+
def pn532_probe(ctx: typer.Context) -> None:
|
|
455
|
+
"""Verify the PN532 is connected, show firmware version and attempt one tag read."""
|
|
456
|
+
_run_command(ctx, Pn532ProbeCommand(type="pn532_probe"))
|
|
457
|
+
|
|
458
|
+
|
|
339
459
|
@library_app.command("add")
|
|
340
460
|
def library_add(
|
|
341
461
|
ctx: typer.Context,
|