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.
Files changed (118) hide show
  1. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/PKG-INFO +1 -1
  2. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api_controller.py +1 -1
  3. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/inbound/config.py +25 -0
  4. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +14 -3
  5. gukebox-1.0.0.dev11/jukebox/adapters/outbound/sonos_discovery_adapter.py +391 -0
  6. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/app.py +122 -2
  7. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/cli_presentation.py +19 -17
  8. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/command_handlers.py +60 -4
  9. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/commands.py +1 -0
  10. gukebox-1.0.0.dev11/jukebox/admin/pn532_command_handlers.py +222 -0
  11. gukebox-1.0.0.dev11/jukebox/admin/pn532_commands.py +20 -0
  12. gukebox-1.0.0.dev11/jukebox/admin/sonos_households.py +31 -0
  13. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/app.py +15 -0
  14. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/di_container.py +14 -2
  15. gukebox-1.0.0.dev11/jukebox/pn532/__init__.py +19 -0
  16. gukebox-1.0.0.dev11/jukebox/pn532/profiles.py +70 -0
  17. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/definitions.py +46 -0
  18. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/entities.py +24 -0
  19. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/resolve.py +20 -1
  20. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/runtime_resolver.py +10 -1
  21. gukebox-1.0.0.dev11/jukebox/shared/terminal_ui.py +12 -0
  22. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/sonos/discovery.py +2 -0
  23. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/sonos/selection.py +20 -2
  24. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/sonos/service.py +34 -4
  25. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/pyproject.toml +1 -1
  26. gukebox-1.0.0.dev9/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -196
  27. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/LICENSE +0 -0
  28. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/README.md +0 -0
  29. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/__init__.py +0 -0
  30. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/__init__.py +0 -0
  31. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/__init__.py +0 -0
  32. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/__init__.py +0 -0
  33. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/current_tag_router.py +0 -0
  34. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/discs_router.py +0 -0
  35. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/models.py +0 -0
  36. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/api/settings_router.py +0 -0
  37. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/cli_controller.py +0 -0
  38. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/cli_display.py +0 -0
  39. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  40. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_controller.py +0 -0
  41. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_pages/__init__.py +0 -0
  42. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_pages/library.py +0 -0
  43. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_pages/settings.py +0 -0
  44. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/inbound/ui_pages/sonos.py +0 -0
  45. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/outbound/__init__.py +0 -0
  46. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  47. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  48. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/command_handlers.py +0 -0
  49. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/commands.py +0 -0
  50. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/di_container.py +0 -0
  51. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/__init__.py +0 -0
  52. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/entities/__init__.py +0 -0
  53. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/entities/current_tag_status.py +0 -0
  54. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/repositories/__init__.py +0 -0
  55. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/__init__.py +0 -0
  56. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/add_disc.py +0 -0
  57. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/edit_disc.py +0 -0
  58. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  59. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/get_disc.py +0 -0
  60. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/list_discs.py +0 -0
  61. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/remove_disc.py +0 -0
  62. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  63. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/discstore/domain/use_cases/search_discs.py +0 -0
  64. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/__init__.py +0 -0
  65. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/__init__.py +0 -0
  66. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/inbound/__init__.py +0 -0
  67. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/inbound/cli_controller.py +0 -0
  68. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/__init__.py +0 -0
  69. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  70. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/players/__init__.py +0 -0
  71. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  72. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  73. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  74. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  75. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  76. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/__init__.py +0 -0
  77. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/di_container.py +0 -0
  78. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/admin/services.py +0 -0
  79. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/__init__.py +0 -0
  80. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/__init__.py +0 -0
  81. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/current_tag_action.py +0 -0
  82. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/disc.py +0 -0
  83. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/library.py +0 -0
  84. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/playback_action.py +0 -0
  85. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/playback_session.py +0 -0
  86. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/entities/tag_event.py +0 -0
  87. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/ports/__init__.py +0 -0
  88. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/ports/player_port.py +0 -0
  89. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/ports/reader_port.py +0 -0
  90. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/repositories/__init__.py +0 -0
  91. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  92. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/repositories/library_repository.py +0 -0
  93. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/use_cases/__init__.py +0 -0
  94. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/use_cases/determine_action.py +0 -0
  95. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  96. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  97. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/__init__.py +0 -0
  98. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/dict_utils.py +0 -0
  99. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/errors.py +0 -0
  100. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/file_settings_repository.py +0 -0
  101. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/migration.py +0 -0
  102. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/repositories.py +0 -0
  103. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/runtime_validation.py +0 -0
  104. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  105. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/service_protocols.py +0 -0
  106. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/timing_validation.py +0 -0
  107. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/types.py +0 -0
  108. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/validation_rules.py +0 -0
  109. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/settings/view_utils.py +0 -0
  110. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/__init__.py +0 -0
  111. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/config_utils.py +0 -0
  112. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/dependency_messages.py +0 -0
  113. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/logger.py +0 -0
  114. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/shared/timing.py +0 -0
  115. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/jukebox/sonos/__init__.py +0 -0
  116. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/pn532/__init__.py +0 -0
  117. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/pn532/pn532.py +0 -0
  118. {gukebox-1.0.0.dev9 → gukebox-1.0.0.dev11}/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.dev11
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,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=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}")
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 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,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(type="sonos_select", uids=parsed_uids, coordinator=coordinator)
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,