gukebox 1.0.0.dev10__tar.gz → 1.0.0.dev12__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 (119) hide show
  1. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/PKG-INFO +1 -1
  2. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/json_library_adapter.py +3 -2
  3. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +5 -0
  4. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/admin/app.py +77 -0
  5. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/admin/cli_presentation.py +6 -19
  6. gukebox-1.0.0.dev12/jukebox/admin/pn532_command_handlers.py +222 -0
  7. gukebox-1.0.0.dev12/jukebox/admin/pn532_commands.py +20 -0
  8. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/di_container.py +7 -3
  9. gukebox-1.0.0.dev12/jukebox/pn532/__init__.py +19 -0
  10. gukebox-1.0.0.dev12/jukebox/pn532/profiles.py +70 -0
  11. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/entities.py +2 -3
  12. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/resolve.py +3 -2
  13. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/runtime_resolver.py +6 -10
  14. gukebox-1.0.0.dev12/jukebox/shared/terminal_ui.py +12 -0
  15. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/pyproject.toml +1 -1
  16. gukebox-1.0.0.dev10/jukebox/pn532/__init__.py +0 -8
  17. gukebox-1.0.0.dev10/jukebox/pn532/profiles.py +0 -32
  18. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/LICENSE +0 -0
  19. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/README.md +0 -0
  20. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/__init__.py +0 -0
  21. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/__init__.py +0 -0
  22. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/__init__.py +0 -0
  23. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/api/__init__.py +0 -0
  24. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/api/current_tag_router.py +0 -0
  25. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/api/discs_router.py +0 -0
  26. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/api/models.py +0 -0
  27. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/api/settings_router.py +0 -0
  28. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/api_controller.py +0 -0
  29. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/cli_controller.py +0 -0
  30. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/cli_display.py +0 -0
  31. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  32. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/ui_controller.py +0 -0
  33. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/ui_pages/__init__.py +0 -0
  34. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/ui_pages/library.py +0 -0
  35. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/ui_pages/settings.py +0 -0
  36. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/inbound/ui_pages/sonos.py +0 -0
  37. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/outbound/__init__.py +0 -0
  38. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  39. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  40. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/command_handlers.py +0 -0
  41. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/commands.py +0 -0
  42. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/di_container.py +0 -0
  43. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/__init__.py +0 -0
  44. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/entities/__init__.py +0 -0
  45. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/entities/current_tag_status.py +0 -0
  46. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/repositories/__init__.py +0 -0
  47. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/use_cases/__init__.py +0 -0
  48. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/use_cases/add_disc.py +0 -0
  49. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/use_cases/edit_disc.py +0 -0
  50. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  51. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/use_cases/get_disc.py +0 -0
  52. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/use_cases/list_discs.py +0 -0
  53. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/use_cases/remove_disc.py +0 -0
  54. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  55. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/discstore/domain/use_cases/search_discs.py +0 -0
  56. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/__init__.py +0 -0
  57. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/__init__.py +0 -0
  58. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/inbound/__init__.py +0 -0
  59. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/inbound/cli_controller.py +0 -0
  60. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/inbound/config.py +0 -0
  61. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/__init__.py +0 -0
  62. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/players/__init__.py +0 -0
  63. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  64. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  65. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  66. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  67. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
  68. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  69. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/admin/__init__.py +0 -0
  70. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/admin/command_handlers.py +0 -0
  71. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/admin/commands.py +0 -0
  72. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/admin/di_container.py +0 -0
  73. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/admin/services.py +0 -0
  74. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/admin/sonos_households.py +0 -0
  75. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/app.py +0 -0
  76. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/__init__.py +0 -0
  77. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/entities/__init__.py +0 -0
  78. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/entities/current_tag_action.py +0 -0
  79. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/entities/disc.py +0 -0
  80. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/entities/library.py +0 -0
  81. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/entities/playback_action.py +0 -0
  82. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/entities/playback_session.py +0 -0
  83. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/entities/tag_event.py +0 -0
  84. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/ports/__init__.py +0 -0
  85. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/ports/player_port.py +0 -0
  86. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/ports/reader_port.py +0 -0
  87. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/repositories/__init__.py +0 -0
  88. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  89. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/repositories/library_repository.py +0 -0
  90. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/use_cases/__init__.py +0 -0
  91. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/use_cases/determine_action.py +0 -0
  92. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  93. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  94. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/__init__.py +0 -0
  95. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/definitions.py +0 -0
  96. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/dict_utils.py +0 -0
  97. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/errors.py +0 -0
  98. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/file_settings_repository.py +0 -0
  99. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/migration.py +0 -0
  100. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/repositories.py +0 -0
  101. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/runtime_validation.py +0 -0
  102. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  103. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/service_protocols.py +0 -0
  104. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/timing_validation.py +0 -0
  105. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/types.py +0 -0
  106. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/validation_rules.py +0 -0
  107. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/settings/view_utils.py +0 -0
  108. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/shared/__init__.py +0 -0
  109. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/shared/config_utils.py +0 -0
  110. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/shared/dependency_messages.py +0 -0
  111. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/shared/logger.py +0 -0
  112. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/shared/timing.py +0 -0
  113. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/sonos/__init__.py +0 -0
  114. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/sonos/discovery.py +0 -0
  115. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/sonos/selection.py +0 -0
  116. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/jukebox/sonos/service.py +0 -0
  117. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/pn532/__init__.py +0 -0
  118. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/pn532/pn532.py +0 -0
  119. {gukebox-1.0.0.dev10 → gukebox-1.0.0.dev12}/pn532/spi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gukebox
3
- Version: 1.0.0.dev10
3
+ Version: 1.0.0.dev12
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
@@ -26,8 +26,8 @@ class JsonLibraryAdapter(LibraryRepository):
26
26
  with open(self.filepath, "r", encoding="utf-8") as f:
27
27
  data = json.load(f)
28
28
  return Library.model_validate(data)
29
- except FileNotFoundError as err:
30
- LOGGER.warning(f"File not found, continuing with an empty library: filepath: {self.filepath}, error: {err}")
29
+ except FileNotFoundError:
30
+ LOGGER.warning(f"No library file found, starting with an empty library: {self.filepath}")
31
31
  return Library()
32
32
  except (json.JSONDecodeError, ValidationError) as err:
33
33
  LOGGER.warning(
@@ -37,6 +37,7 @@ class JsonLibraryAdapter(LibraryRepository):
37
37
 
38
38
  def _write_library(self, library: Library) -> None:
39
39
  directory = os.path.dirname(self.filepath) or "."
40
+ os.makedirs(directory, exist_ok=True)
40
41
  temp_fd, temp_path = tempfile.mkstemp(dir=directory, prefix=".library-", suffix=".json")
41
42
 
42
43
  try:
@@ -47,8 +47,13 @@ class Pn532ReaderAdapter(ReaderPort):
47
47
  self.read_timeout_seconds = read_timeout_seconds
48
48
  ic, ver, rev, support = self.pn532.get_firmware_version()
49
49
  LOGGER.info(f"Found PN532 with firmware version: {ver}.{rev}")
50
+ self._firmware_version: tuple[int, int] = (ver, rev)
50
51
  self.pn532.SAM_configuration()
51
52
 
53
+ @property
54
+ def firmware_version(self) -> tuple[int, int]:
55
+ return self._firmware_version
56
+
52
57
  def read(self) -> Union[str, None]:
53
58
  rawuid = self.pn532.read_passive_target(timeout=self.read_timeout_seconds)
54
59
  if rawuid is None:
@@ -42,6 +42,8 @@ from .commands import (
42
42
  is_sonos_command,
43
43
  )
44
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
45
47
  from .sonos_households import GroupedSonosHousehold
46
48
 
47
49
 
@@ -88,6 +90,15 @@ def _run_command(ctx: typer.Context, command: object) -> None:
88
90
  coordinator_prompt_fn=_prompt_for_sonos_group_coordinator,
89
91
  status_fn=_emit_cli_status,
90
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,
101
+ )
91
102
  else:
92
103
  execute_server_command(
93
104
  verbose=state.verbose,
@@ -98,6 +109,8 @@ def _run_command(ctx: typer.Context, command: object) -> None:
98
109
  source_command="jukebox-admin",
99
110
  )
100
111
  except RuntimeError as err:
112
+ if state.verbose:
113
+ raise
101
114
  typer.echo(str(err), err=True)
102
115
  raise typer.Exit(code=1)
103
116
  except SystemExit as err:
@@ -110,6 +123,9 @@ def _run_command(ctx: typer.Context, command: object) -> None:
110
123
  except SettingsError as err:
111
124
  typer.echo(render_cli_error(err, verbose=state.verbose), err=True)
112
125
  raise typer.Exit(code=1)
126
+ except ModuleNotFoundError as err:
127
+ typer.echo(str(err), err=True)
128
+ raise typer.Exit(code=1)
113
129
  except OSError as err:
114
130
  typer.echo(str(err), err=True)
115
131
  raise typer.Exit(code=1)
@@ -195,6 +211,38 @@ def _prompt_for_sonos_household_selection(households: list[GroupedSonosHousehold
195
211
  return None
196
212
 
197
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
+
198
246
  def _prompt_for_sonos_group_coordinator(speakers: list[DiscoveredSonosSpeaker]) -> Optional[str]:
199
247
  import questionary
200
248
 
@@ -223,9 +271,11 @@ app = typer.Typer(help="Admin CLI for jukebox")
223
271
  settings_app = typer.Typer(help="Inspect and manage application settings")
224
272
  library_app = typer.Typer(help="Manage the library")
225
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")
226
275
  app.add_typer(settings_app, name="settings")
227
276
  app.add_typer(library_app, name="library")
228
277
  app.add_typer(sonos_app, name="sonos")
278
+ app.add_typer(pn532_app, name="pn532")
229
279
 
230
280
 
231
281
  @app.callback()
@@ -379,6 +429,33 @@ def sonos_show(ctx: typer.Context) -> None:
379
429
  _run_command(ctx, SonosShowCommand(type="sonos_show"))
380
430
 
381
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
+
382
459
  @library_app.command("add")
383
460
  def library_add(
384
461
  ctx: typer.Context,
@@ -11,6 +11,7 @@ from jukebox.settings.errors import (
11
11
  )
12
12
  from jukebox.settings.types import JsonObject, JsonValue
13
13
  from jukebox.settings.view_utils import MISSING, lookup_object, lookup_optional_dotted_path, lookup_provenance_label
14
+ from jukebox.shared.terminal_ui import table
14
15
  from jukebox.sonos.discovery import DiscoveredSonosSpeaker
15
16
  from jukebox.sonos.selection import SonosSelectionResult, SonosSelectionStatus
16
17
 
@@ -51,26 +52,12 @@ def render_cli_error(err: BaseException, verbose: bool = False) -> str:
51
52
  def render_sonos_speakers_output(households: list[GroupedSonosHousehold]) -> str:
52
53
  if not households:
53
54
  return "No visible Sonos speakers found."
54
-
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 = []
55
+ headers = ["name", "host", "uid"]
56
+ sections = []
59
57
  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])
58
+ rows = [[s.name, s.host, s.uid] for s in household.speakers]
59
+ sections.append(f"Household: {household.household_id}\n\n" + table(headers, rows, indexed=True))
60
+ return "Sonos speakers:\n\n" + "\n\n".join(sections)
74
61
 
75
62
 
76
63
  def build_sonos_household_choice_label(household: GroupedSonosHousehold) -> str:
@@ -0,0 +1,222 @@
1
+ import dataclasses
2
+ from typing import Any, Callable, Optional, cast
3
+
4
+ from jukebox.pn532.profiles import (
5
+ PN532_PROFILES,
6
+ Pn532ConnectionParams,
7
+ Pn532Protocol,
8
+ SpiConnectionParams,
9
+ resolve_connection_params,
10
+ )
11
+ from jukebox.settings.service_protocols import SettingsService
12
+ from jukebox.shared.terminal_ui import table
13
+
14
+ from .pn532_commands import Pn532ProbeCommand, Pn532ProfilesCommand, Pn532SelectCommand
15
+
16
+
17
+ def _default_build_pn532_reader(
18
+ read_timeout_seconds: float,
19
+ protocol: Pn532Protocol,
20
+ connection: Pn532ConnectionParams,
21
+ ) -> Any:
22
+ from jukebox.adapters.outbound.readers.pn532_reader_adapter import Pn532ReaderAdapter
23
+
24
+ if protocol == "spi":
25
+ if not isinstance(connection, SpiConnectionParams):
26
+ raise ValueError(f"Expected SpiConnectionParams for protocol 'spi', got {type(connection).__name__}")
27
+ return Pn532ReaderAdapter(
28
+ read_timeout_seconds=read_timeout_seconds,
29
+ spi_reset=connection.reset,
30
+ spi_cs=connection.cs,
31
+ spi_irq=connection.irq,
32
+ )
33
+ raise ValueError(f"Unsupported PN532 protocol: {protocol}")
34
+
35
+
36
+ def _parse_pin(raw: Optional[str]) -> "tuple[bool, Optional[str]]":
37
+ """Returns (ok, value). ok=False means the user cancelled the prompt.
38
+ value=None means blank input (reset to profile default)."""
39
+ if raw is None:
40
+ return False, None
41
+ stripped = raw.strip()
42
+ return True, stripped if stripped else None
43
+
44
+
45
+ def execute_pn532_command(
46
+ command: object,
47
+ settings_service: SettingsService,
48
+ profile_prompt_fn: Optional[Callable[[list], Optional[str]]] = None,
49
+ protocol_prompt_fn: Optional[Callable[[list, str], Optional[str]]] = None,
50
+ pin_prompt_fn: Optional[Callable[[str, Optional[int]], Optional[str]]] = None,
51
+ build_pn532_reader: Callable[..., Any] = _default_build_pn532_reader,
52
+ stdout_fn: Callable[[str], None] = print,
53
+ ) -> None:
54
+ if isinstance(command, Pn532ProfilesCommand):
55
+ stdout_fn(render_pn532_profiles_output())
56
+ return
57
+
58
+ if isinstance(command, Pn532SelectCommand):
59
+ if command.profile is not None:
60
+ selected = command.profile
61
+ settings_service.set_persisted_value("jukebox.reader.pn532.board_profile", selected)
62
+ stdout_fn(render_pn532_select_output(selected))
63
+ return
64
+
65
+ # Interactive mode
66
+ if profile_prompt_fn is None:
67
+ raise RuntimeError("Interactive PN532 profile selection is not available in this context.")
68
+ selected_profile = profile_prompt_fn(list(PN532_PROFILES.keys()))
69
+ if selected_profile is None:
70
+ return
71
+
72
+ defaults = PN532_PROFILES.get(cast(Any, selected_profile))
73
+ if defaults is None:
74
+ raise RuntimeError(f"Unknown board profile: {selected_profile!r}")
75
+
76
+ selected_protocol = defaults.default_protocol
77
+ if protocol_prompt_fn is not None:
78
+ prompted = protocol_prompt_fn(list(defaults.connections.keys()), defaults.default_protocol)
79
+ if prompted is None:
80
+ return
81
+ selected_protocol = prompted
82
+
83
+ pin_defaults = defaults.connections.get(cast(Pn532Protocol, selected_protocol))
84
+ if pin_defaults is None:
85
+ raise RuntimeError(
86
+ f"Protocol '{selected_protocol}' is not supported by board profile '{selected_profile}'."
87
+ )
88
+
89
+ if pin_prompt_fn is not None:
90
+ field_values: dict[str, Optional[str]] = {}
91
+ for f in dataclasses.fields(pin_defaults):
92
+ default = getattr(pin_defaults, f.name)
93
+ raw = pin_prompt_fn(f.name, default)
94
+ ok, value = _parse_pin(raw)
95
+ if not ok:
96
+ return
97
+ field_values[f.name] = value
98
+
99
+ settings_service.set_persisted_value("jukebox.reader.pn532.board_profile", selected_profile)
100
+ if selected_protocol != defaults.default_protocol:
101
+ settings_service.set_persisted_value("jukebox.reader.pn532.protocol", selected_protocol)
102
+ else:
103
+ settings_service.reset_persisted_value("jukebox.reader.pn532.protocol")
104
+ for f in dataclasses.fields(pin_defaults):
105
+ path = f"jukebox.reader.pn532.{selected_protocol}.{f.name}"
106
+ value = field_values[f.name]
107
+ profile_default = getattr(pin_defaults, f.name)
108
+ try:
109
+ is_default = value is None or int(value) == profile_default
110
+ except ValueError:
111
+ is_default = False
112
+ if is_default:
113
+ settings_service.reset_persisted_value(path)
114
+ else:
115
+ assert value is not None
116
+ settings_service.set_persisted_value(path, value)
117
+ stdout_fn(render_pn532_configure_output(selected_profile, selected_protocol, pin_defaults, field_values))
118
+ else:
119
+ settings_service.set_persisted_value("jukebox.reader.pn532.board_profile", selected_profile)
120
+ stdout_fn(render_pn532_select_output(selected_profile))
121
+ return
122
+
123
+ if isinstance(command, Pn532ProbeCommand):
124
+ pn532 = settings_service.get_effective_settings().jukebox.reader.pn532
125
+ overrides = SpiConnectionParams(reset=pn532.spi.reset, cs=pn532.spi.cs, irq=pn532.spi.irq)
126
+ resolved = resolve_connection_params(pn532.board_profile, pn532.protocol, overrides)
127
+
128
+ stdout_fn(render_pn532_probe_setup_output(pn532.board_profile, pn532.protocol, resolved))
129
+
130
+ try:
131
+ reader = build_pn532_reader(
132
+ read_timeout_seconds=pn532.read_timeout_seconds,
133
+ protocol=pn532.protocol,
134
+ connection=resolved,
135
+ )
136
+ except (ModuleNotFoundError, RuntimeError):
137
+ raise
138
+ except Exception as err:
139
+ msg = str(err)
140
+ if any(s in msg.lower() for s in ("not permitted", "permission", "bad gpio")):
141
+ raise RuntimeError(
142
+ "GPIO error — your pin configuration may be incorrect.\n"
143
+ "Update it with: jukebox-admin pn532 select\n"
144
+ "Re-run with `--verbose` for details."
145
+ ) from err
146
+ raise RuntimeError(msg) from err
147
+
148
+ ver, rev = reader.firmware_version
149
+ stdout_fn(f"PN532 firmware version: {ver}.{rev}")
150
+
151
+ uid = reader.read()
152
+ stdout_fn(f"Tag UID: {uid}" if uid else "No tag detected")
153
+ return
154
+
155
+ raise TypeError("Unsupported PN532 command")
156
+
157
+
158
+ def render_pn532_probe_setup_output(
159
+ board_profile: str,
160
+ protocol: str,
161
+ connection: Pn532ConnectionParams,
162
+ ) -> str:
163
+ fields = " ".join(
164
+ f"{f.name}={getattr(connection, f.name) if getattr(connection, f.name) is not None else '-'}"
165
+ for f in dataclasses.fields(connection)
166
+ )
167
+ return f"Probing — profile={board_profile} protocol={protocol} {fields}"
168
+
169
+
170
+ def render_pn532_profiles_output() -> str:
171
+ by_protocol: dict[str, list[tuple[str, Any]]] = {}
172
+ for name, profile in PN532_PROFILES.items():
173
+ protocol = profile.default_protocol
174
+ by_protocol.setdefault(protocol, []).append((name, profile.connections[protocol]))
175
+ sections = []
176
+ for protocol, entries in by_protocol.items():
177
+ field_names = [f.name for f in dataclasses.fields(entries[0][1])]
178
+ headers = ["name", *field_names]
179
+ rows = [
180
+ [name, *("-" if getattr(conn, f) is None else getattr(conn, f) for f in field_names)]
181
+ for name, conn in entries
182
+ ]
183
+ sections.append(f"Protocol: {protocol}\n\n" + table(headers, rows, indexed=True))
184
+ return "Available predefined board profiles:\n\n" + "\n\n".join(sections)
185
+
186
+
187
+ def render_pn532_select_output(profile: str) -> str:
188
+ defaults = PN532_PROFILES.get(cast(Any, profile))
189
+ if defaults is None:
190
+ return f"Board profile saved: {profile}"
191
+ protocol = defaults.default_protocol
192
+ connection = defaults.connections[protocol]
193
+ fields = " ".join(
194
+ f"{f.name}={getattr(connection, f.name) if getattr(connection, f.name) is not None else '-'}"
195
+ for f in dataclasses.fields(connection)
196
+ )
197
+ return "\n".join(
198
+ [
199
+ f"Board profile saved: {profile}",
200
+ f"Protocol: {protocol}",
201
+ f"Default pins — {fields}",
202
+ ]
203
+ )
204
+
205
+
206
+ def render_pn532_configure_output(
207
+ profile: str,
208
+ protocol: str,
209
+ connection: Pn532ConnectionParams,
210
+ field_values: dict[str, Optional[str]],
211
+ ) -> str:
212
+ fields = " ".join(
213
+ f"{f.name}={field_values[f.name] if field_values[f.name] is not None else '-'}"
214
+ for f in dataclasses.fields(connection)
215
+ )
216
+ return "\n".join(
217
+ [
218
+ f"Board profile saved: {profile}",
219
+ f"Protocol: {protocol}",
220
+ f"Pins saved — {fields}",
221
+ ]
222
+ )
@@ -0,0 +1,20 @@
1
+ from typing import Literal, Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class Pn532ProfilesCommand(BaseModel):
7
+ type: Literal["pn532_profiles"]
8
+
9
+
10
+ class Pn532SelectCommand(BaseModel):
11
+ type: Literal["pn532_select"]
12
+ profile: Optional[str] = None
13
+
14
+
15
+ class Pn532ProbeCommand(BaseModel):
16
+ type: Literal["pn532_probe"]
17
+
18
+
19
+ def is_pn532_command(command: object) -> bool:
20
+ return isinstance(command, (Pn532ProfilesCommand, Pn532SelectCommand, Pn532ProbeCommand))
@@ -25,13 +25,17 @@ def build_jukebox(config: ResolvedJukeboxRuntimeConfig):
25
25
 
26
26
  if config.reader_type == "pn532":
27
27
  from jukebox.adapters.outbound.readers.pn532_reader_adapter import Pn532ReaderAdapter
28
+ from jukebox.pn532.profiles import SpiConnectionParams
28
29
 
29
30
  if config.pn532_protocol == "spi":
31
+ conn = config.pn532_connection
32
+ if not isinstance(conn, SpiConnectionParams):
33
+ raise ValueError(f"Expected SpiConnectionParams for protocol 'spi', got {type(conn).__name__}")
30
34
  reader = Pn532ReaderAdapter(
31
35
  read_timeout_seconds=config.pn532_read_timeout_seconds,
32
- spi_reset=config.pn532_spi_reset,
33
- spi_cs=config.pn532_spi_cs,
34
- spi_irq=config.pn532_spi_irq,
36
+ spi_reset=conn.reset,
37
+ spi_cs=conn.cs,
38
+ spi_irq=conn.irq,
35
39
  )
36
40
  else:
37
41
  raise ValueError(f"Unsupported PN532 protocol: {config.pn532_protocol}")
@@ -0,0 +1,19 @@
1
+ from .profiles import (
2
+ PN532_PROFILES,
3
+ Pn532BoardProfile,
4
+ Pn532BoardProfileDefaults,
5
+ Pn532ConnectionParams,
6
+ Pn532Protocol,
7
+ SpiConnectionParams,
8
+ resolve_connection_params,
9
+ )
10
+
11
+ __all__ = [
12
+ "PN532_PROFILES",
13
+ "Pn532BoardProfile",
14
+ "Pn532BoardProfileDefaults",
15
+ "Pn532ConnectionParams",
16
+ "Pn532Protocol",
17
+ "SpiConnectionParams",
18
+ "resolve_connection_params",
19
+ ]
@@ -0,0 +1,70 @@
1
+ import dataclasses
2
+ from dataclasses import dataclass
3
+ from typing import Literal, Optional
4
+
5
+ Pn532BoardProfile = Literal["waveshare_hat", "hiletgo_v3", "custom"]
6
+
7
+ Pn532Protocol = Literal["spi"]
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class SpiConnectionParams:
12
+ reset: Optional[int]
13
+ cs: Optional[int]
14
+ irq: Optional[int]
15
+
16
+
17
+ # Today SpiConnectionParams only; Union[SpiConnectionParams, UartConnectionParams, ...]
18
+ # will be introduced when additional protocols are added.
19
+ Pn532ConnectionParams = SpiConnectionParams
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Pn532BoardProfileDefaults:
24
+ default_protocol: Pn532Protocol
25
+ connections: dict[Pn532Protocol, Pn532ConnectionParams]
26
+
27
+
28
+ PN532_PROFILES: dict[Pn532BoardProfile, Pn532BoardProfileDefaults] = {
29
+ "waveshare_hat": Pn532BoardProfileDefaults(
30
+ default_protocol="spi",
31
+ connections={"spi": SpiConnectionParams(reset=20, cs=4, irq=None)},
32
+ ),
33
+ "hiletgo_v3": Pn532BoardProfileDefaults(
34
+ default_protocol="spi",
35
+ connections={"spi": SpiConnectionParams(reset=None, cs=8, irq=None)},
36
+ ),
37
+ "custom": Pn532BoardProfileDefaults(
38
+ default_protocol="spi",
39
+ connections={"spi": SpiConnectionParams(reset=None, cs=None, irq=None)},
40
+ ),
41
+ }
42
+
43
+
44
+ def resolve_connection_params(
45
+ board_profile: Pn532BoardProfile,
46
+ protocol: Pn532Protocol,
47
+ overrides: Pn532ConnectionParams,
48
+ ) -> Pn532ConnectionParams:
49
+ """Merge per-field overrides with the profile defaults for the given protocol.
50
+
51
+ A field value of None in *overrides* means "use the profile default".
52
+ The custom profile has None as its own default, so None is preserved.
53
+ """
54
+ profile = PN532_PROFILES[board_profile]
55
+ if protocol not in profile.connections:
56
+ supported = list(profile.connections.keys())
57
+ raise ValueError(
58
+ f"Protocol '{protocol}' is not supported by board profile '{board_profile}' (supported: {supported})"
59
+ )
60
+ defaults = profile.connections[protocol]
61
+ if not isinstance(overrides, type(defaults)):
62
+ raise ValueError(
63
+ f"Expected overrides of type {type(defaults).__name__} for protocol '{protocol}', "
64
+ f"got {type(overrides).__name__}"
65
+ )
66
+ merged = {
67
+ f.name: getattr(overrides, f.name) if getattr(overrides, f.name) is not None else getattr(defaults, f.name)
68
+ for f in dataclasses.fields(defaults)
69
+ }
70
+ return type(defaults)(**merged)
@@ -3,6 +3,7 @@ from typing import Literal, Optional
3
3
 
4
4
  from pydantic import BaseModel, ConfigDict, Field, model_validator
5
5
 
6
+ from jukebox.pn532.profiles import SpiConnectionParams
6
7
  from jukebox.shared.timing import MIN_PAUSE_DELAY_SECONDS
7
8
 
8
9
  from .runtime_validation import validate_resolved_jukebox_runtime_rules
@@ -272,9 +273,7 @@ class ResolvedJukeboxRuntimeConfig(StrictModel):
272
273
  pn532_read_timeout_seconds: float
273
274
  pn532_board_profile: Literal["waveshare_hat", "hiletgo_v3", "custom"]
274
275
  pn532_protocol: Literal["spi"] = "spi"
275
- pn532_spi_reset: Optional[int]
276
- pn532_spi_cs: Optional[int]
277
- pn532_spi_irq: Optional[int]
276
+ pn532_connection: SpiConnectionParams
278
277
  verbose: bool = False
279
278
 
280
279
  @model_validator(mode="after")
@@ -5,7 +5,7 @@ from typing import Optional, Union, cast
5
5
 
6
6
  from pydantic import ValidationError
7
7
 
8
- from jukebox.pn532.profiles import resolve_spi_pins
8
+ from jukebox.pn532.profiles import SpiConnectionParams, resolve_connection_params
9
9
  from jukebox.shared.config_utils import get_current_tag_path
10
10
 
11
11
  from .definitions import (
@@ -237,7 +237,8 @@ def _expand_path(path: str) -> str:
237
237
 
238
238
  def _derive_pn532(effective_settings: AppSettings) -> JsonObject:
239
239
  pn532 = effective_settings.jukebox.reader.pn532
240
- resolved = resolve_spi_pins(pn532.board_profile, pn532.spi.reset, pn532.spi.cs, pn532.spi.irq)
240
+ overrides = SpiConnectionParams(reset=pn532.spi.reset, cs=pn532.spi.cs, irq=pn532.spi.irq)
241
+ resolved = resolve_connection_params(pn532.board_profile, pn532.protocol, overrides)
241
242
  return {
242
243
  "reader": {
243
244
  "pn532": {
@@ -1,9 +1,9 @@
1
1
  import os
2
- from typing import Any, Optional, Tuple
2
+ from typing import Optional, Tuple
3
3
 
4
4
  from pydantic import ValidationError
5
5
 
6
- from jukebox.pn532.profiles import resolve_spi_pins
6
+ from jukebox.pn532.profiles import SpiConnectionParams, resolve_connection_params
7
7
  from jukebox.sonos.service import SonosService
8
8
 
9
9
  from .entities import AppSettings, Pn532ReaderSettings, ResolvedJukeboxRuntimeConfig, ResolvedSonosGroupRuntime
@@ -39,7 +39,7 @@ class JukeboxRuntimeResolver:
39
39
  pn532_read_timeout_seconds=effective_settings.jukebox.reader.pn532.read_timeout_seconds,
40
40
  pn532_board_profile=effective_settings.jukebox.reader.pn532.board_profile,
41
41
  pn532_protocol=effective_settings.jukebox.reader.pn532.protocol,
42
- **_resolve_pn532_spi_pins(effective_settings.jukebox.reader.pn532),
42
+ pn532_connection=_resolve_pn532_connection(effective_settings.jukebox.reader.pn532),
43
43
  verbose=verbose,
44
44
  )
45
45
  except (ValidationError, ValueError) as err:
@@ -63,10 +63,6 @@ class JukeboxRuntimeResolver:
63
63
  return None, None, None
64
64
 
65
65
 
66
- def _resolve_pn532_spi_pins(pn532: Pn532ReaderSettings) -> dict[str, Any]:
67
- resolved = resolve_spi_pins(pn532.board_profile, pn532.spi.reset, pn532.spi.cs, pn532.spi.irq)
68
- return {
69
- "pn532_spi_reset": resolved.reset,
70
- "pn532_spi_cs": resolved.cs,
71
- "pn532_spi_irq": resolved.irq,
72
- }
66
+ def _resolve_pn532_connection(pn532: Pn532ReaderSettings) -> SpiConnectionParams:
67
+ overrides = SpiConnectionParams(reset=pn532.spi.reset, cs=pn532.spi.cs, irq=pn532.spi.irq)
68
+ return resolve_connection_params(pn532.board_profile, pn532.protocol, overrides)
@@ -0,0 +1,12 @@
1
+ def table(headers, rows, indexed=False):
2
+ if indexed:
3
+ headers = ["#"] + headers
4
+ rows = [[i + 1] + list(row) for i, row in enumerate(rows)]
5
+
6
+ cols = list(zip(headers, *rows))
7
+ widths = [max(len(str(x)) for x in col) for col in cols]
8
+
9
+ def fmt(row):
10
+ return " ".join(f"{str(val):<{widths[i]}}" for i, val in enumerate(row))
11
+
12
+ return "\n".join([fmt(headers), *map(fmt, rows)])
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gukebox"
3
- version = "1.0.0.dev10"
3
+ version = "1.0.0.dev12"
4
4
  description = "A Jukebox to play music on speakers using 'CD' with NFC tag"
5
5
  authors = [{ name = "Gudsfile" }]
6
6
  readme = "README.md"
@@ -1,8 +0,0 @@
1
- from .profiles import PN532_PROFILES, Pn532BoardProfile, Pn532BoardProfileDefaults, resolve_spi_pins
2
-
3
- __all__ = [
4
- "PN532_PROFILES",
5
- "Pn532BoardProfile",
6
- "Pn532BoardProfileDefaults",
7
- "resolve_spi_pins",
8
- ]
@@ -1,32 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import Literal, Optional
3
-
4
- Pn532BoardProfile = Literal["waveshare_hat", "hiletgo_v3", "custom"]
5
-
6
-
7
- @dataclass(frozen=True)
8
- class Pn532BoardProfileDefaults:
9
- reset: Optional[int]
10
- cs: Optional[int]
11
- irq: Optional[int]
12
-
13
-
14
- PN532_PROFILES: dict[Pn532BoardProfile, Pn532BoardProfileDefaults] = {
15
- "waveshare_hat": Pn532BoardProfileDefaults(reset=20, cs=4, irq=None),
16
- "hiletgo_v3": Pn532BoardProfileDefaults(reset=None, cs=8, irq=None),
17
- "custom": Pn532BoardProfileDefaults(reset=None, cs=None, irq=None),
18
- }
19
-
20
-
21
- def resolve_spi_pins(
22
- board_profile: Pn532BoardProfile,
23
- reset: Optional[int],
24
- cs: Optional[int],
25
- irq: Optional[int],
26
- ) -> Pn532BoardProfileDefaults:
27
- profile = PN532_PROFILES[board_profile]
28
- return Pn532BoardProfileDefaults(
29
- reset=reset if reset is not None else profile.reset,
30
- cs=cs if cs is not None else profile.cs,
31
- irq=irq if irq is not None else profile.irq,
32
- )
File without changes
File without changes