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.
Files changed (115) hide show
  1. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/PKG-INFO +1 -1
  2. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api_controller.py +1 -1
  3. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/inbound/config.py +25 -0
  4. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +9 -3
  5. gukebox-1.0.0.dev10/jukebox/adapters/outbound/sonos_discovery_adapter.py +391 -0
  6. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/app.py +45 -2
  7. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/cli_presentation.py +32 -17
  8. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/command_handlers.py +60 -4
  9. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/commands.py +1 -0
  10. gukebox-1.0.0.dev10/jukebox/admin/sonos_households.py +31 -0
  11. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/app.py +15 -0
  12. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/di_container.py +9 -1
  13. gukebox-1.0.0.dev10/jukebox/pn532/__init__.py +8 -0
  14. gukebox-1.0.0.dev10/jukebox/pn532/profiles.py +32 -0
  15. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/definitions.py +46 -0
  16. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/entities.py +25 -0
  17. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/resolve.py +19 -1
  18. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/runtime_resolver.py +15 -2
  19. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/sonos/discovery.py +2 -0
  20. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/sonos/selection.py +20 -2
  21. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/sonos/service.py +34 -4
  22. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/pyproject.toml +1 -1
  23. gukebox-1.0.0.dev9/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -196
  24. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/LICENSE +0 -0
  25. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/README.md +0 -0
  26. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/__init__.py +0 -0
  27. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/__init__.py +0 -0
  28. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/__init__.py +0 -0
  29. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/__init__.py +0 -0
  30. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/current_tag_router.py +0 -0
  31. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/discs_router.py +0 -0
  32. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/models.py +0 -0
  33. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/api/settings_router.py +0 -0
  34. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/cli_controller.py +0 -0
  35. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/cli_display.py +0 -0
  36. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  37. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_controller.py +0 -0
  38. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_pages/__init__.py +0 -0
  39. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_pages/library.py +0 -0
  40. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_pages/settings.py +0 -0
  41. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/inbound/ui_pages/sonos.py +0 -0
  42. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/outbound/__init__.py +0 -0
  43. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  44. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  45. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/command_handlers.py +0 -0
  46. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/commands.py +0 -0
  47. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/di_container.py +0 -0
  48. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/__init__.py +0 -0
  49. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/entities/__init__.py +0 -0
  50. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/entities/current_tag_status.py +0 -0
  51. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/repositories/__init__.py +0 -0
  52. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/__init__.py +0 -0
  53. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/add_disc.py +0 -0
  54. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/edit_disc.py +0 -0
  55. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  56. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/get_disc.py +0 -0
  57. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/list_discs.py +0 -0
  58. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/remove_disc.py +0 -0
  59. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  60. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/discstore/domain/use_cases/search_discs.py +0 -0
  61. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/__init__.py +0 -0
  62. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/__init__.py +0 -0
  63. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/inbound/__init__.py +0 -0
  64. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/inbound/cli_controller.py +0 -0
  65. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/__init__.py +0 -0
  66. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  67. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/players/__init__.py +0 -0
  68. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  69. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  70. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  71. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  72. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  73. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/__init__.py +0 -0
  74. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/di_container.py +0 -0
  75. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/admin/services.py +0 -0
  76. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/__init__.py +0 -0
  77. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/__init__.py +0 -0
  78. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/current_tag_action.py +0 -0
  79. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/disc.py +0 -0
  80. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/library.py +0 -0
  81. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/playback_action.py +0 -0
  82. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/playback_session.py +0 -0
  83. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/entities/tag_event.py +0 -0
  84. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/ports/__init__.py +0 -0
  85. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/ports/player_port.py +0 -0
  86. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/ports/reader_port.py +0 -0
  87. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/repositories/__init__.py +0 -0
  88. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  89. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/repositories/library_repository.py +0 -0
  90. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/use_cases/__init__.py +0 -0
  91. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/use_cases/determine_action.py +0 -0
  92. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  93. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  94. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/__init__.py +0 -0
  95. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/dict_utils.py +0 -0
  96. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/errors.py +0 -0
  97. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/file_settings_repository.py +0 -0
  98. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/migration.py +0 -0
  99. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/repositories.py +0 -0
  100. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/runtime_validation.py +0 -0
  101. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  102. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/service_protocols.py +0 -0
  103. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/timing_validation.py +0 -0
  104. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/types.py +0 -0
  105. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/validation_rules.py +0 -0
  106. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/settings/view_utils.py +0 -0
  107. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/__init__.py +0 -0
  108. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/config_utils.py +0 -0
  109. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/dependency_messages.py +0 -0
  110. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/logger.py +0 -0
  111. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/shared/timing.py +0 -0
  112. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/jukebox/sonos/__init__.py +0 -0
  113. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/pn532/__init__.py +0 -0
  114. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/pn532/pn532.py +0 -0
  115. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev10}/pn532/spi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gukebox
3
- Version: 1.0.0.dev9
3
+ Version: 1.0.0.dev10
4
4
  Summary: A Jukebox to play music on speakers using 'CD' with NFC tag
5
5
  Keywords: jukebox,music,nfc
6
6
  Author: Gudsfile
@@ -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.list_available_speakers()
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
  )
@@ -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__(self, read_timeout_seconds: float = DEFAULT_NFC_READ_TIMEOUT_SECONDS):
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=20, cs=4)
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 build_sonos_speaker_choice_label, render_cli_error
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(type="sonos_select", uids=parsed_uids, coordinator=coordinator)
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(speakers: list[DiscoveredSonosSpeaker]) -> str:
51
- if not speakers:
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
- name_width = max(len(speaker.name) for speaker in speakers)
55
- host_width = max(len(speaker.host) for speaker in speakers)
56
- return "\n".join(
57
- "{index}. {name:<{name_width}} {host:<{host_width}} {uid}".format(
58
- index=index,
59
- name=speaker.name,
60
- name_width=name_width,
61
- host=speaker.host,
62
- host_width=host_width,
63
- uid=speaker.uid,
64
- )
65
- for index, speaker in enumerate(speakers, start=1)
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: