Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Settings are stored in `~/.config/sendspin/`:
"listen_port": 8927,
"use_mpris": true,
"use_hardware_volume": true,
"unpaired_access": true,
"hook_set_volume": "/usr/local/bin/set-avr-volume",
"manufacturer": "Acme Corp",
"product_name": "Living Room Speaker",
Expand Down Expand Up @@ -154,6 +155,7 @@ Settings are stored in `~/.config/sendspin/`:
| `listen_port` | integer | daemon/serve | Listen port (`--port`, default: 8927) |
| `use_mpris` | boolean | TUI/daemon | Enable MPRIS integration (default: true) |
| `use_hardware_volume` | boolean | TUI/daemon | Control hardware/system output volume instead of software volume (`--hardware-volume true/false`). Default: on for daemon (if available), off for TUI |
| `unpaired_access` | boolean | TUI/daemon | Allow servers to play on this client without pairing (`--unpaired-access true/false`, default: true). See [Pairing](#pairing) |
| `hook_set_volume` | string | TUI/daemon | Script to run for external volume control (`--hook-set-volume`). Receives the effective volume 0-100 as the last argument |
| `hook_start` | string | TUI/daemon | Command to run when audio stream starts |
| `hook_stop` | string | TUI/daemon | Command to run when audio stream stops |
Expand Down Expand Up @@ -192,13 +194,19 @@ same server don't require pairing again.
pairing completes.
- **Daemon**: the PIN is only logged (`Pairing required: enter PIN ...`) — there's no other
surface for it. You need to be watching the daemon's output at the moment it first
connects to a new server. Under systemd, that means `journalctl -u sendspin -f`. There's
no unattended-pairing option today (no PIN written to a file, no QR code); for a headless
install, plan to watch the log during first setup for each new server.
connects to a new server. Under systemd, that means `journalctl -u sendspin -f`. For a
headless install with unpaired access turned off, plan to watch the log during first setup
for each new server.

If a pairing attempt fails (e.g. a mismatched PIN), the client logs the reason and you
can retry by reconnecting.

Unpaired access is on by default, so servers can play on this client without a PIN once
the server approves it (Music Assistant does this automatically). Unpaired connections are
still encrypted, but another device on the network could impersonate either side. To
require pairing, use `--unpaired-access false` (or `"unpaired_access": false` in the
settings file).

**Upgrading from a version before pairing support:** this client's protocol identity
(`client_id`) used to be a stable, often user-chosen string; it's now derived from a
generated cryptographic identity instead, so upgrading a previously-configured install
Expand Down
26 changes: 26 additions & 0 deletions sendspin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import sys
import traceback
from collections.abc import Sequence
from dataclasses import replace
from importlib.metadata import version
from typing import TYPE_CHECKING, Any, Literal, Protocol

Expand Down Expand Up @@ -216,6 +217,13 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo
metavar="{true,false}",
help="Enable or disable hardware/system volume control (daemon: on, TUI: off)",
)
target.add_argument(
"--unpaired-access",
default=default,
type=arg_str_to_bool,
metavar="{true,false}",
help="Allow servers to play on this client without pairing (default: true)",
)
target.add_argument(
"--manufacturer",
type=str,
Expand Down Expand Up @@ -452,6 +460,13 @@ def _build_parser() -> argparse.ArgumentParser:
metavar="{true,false}",
help="Enable or disable hardware/system volume control (daemon: on, TUI: off)",
)
daemon_parser.add_argument(
"--unpaired-access",
default=None,
type=arg_str_to_bool,
metavar="{true,false}",
help="Allow servers to play on this client without pairing (default: true)",
)
daemon_parser.add_argument(
"--hook-start",
type=str,
Expand Down Expand Up @@ -865,6 +880,10 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
args.product_name = settings.product_name
if args.interface is None:
args.interface = settings.interface
if args.unpaired_access is None:
args.unpaired_access = settings.unpaired_access
if not isinstance(args.unpaired_access, bool):
raise CLIError("unpaired_access in the settings file must be true or false", 1)

# Set up logging: daemon uses stderr, TUI writes to sendspin.log
# so log output doesn't interfere with the Rich display.
Expand All @@ -878,6 +897,13 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
logging.basicConfig(level=log_level, handlers=[handler])

pairing_config = await pairing_store.get_pairing_config()
if pairing_config.unpaired_access_enabled != args.unpaired_access:
await pairing_store.store_pairing_config(
replace(pairing_config, unpaired_access_enabled=args.unpaired_access)
)
LOGGER.info("Unpaired access %s", "enabled" if args.unpaired_access else "disabled")

audio_device = resolve_audio_device(args.audio_device)

volume_controller: VolumeController | None = None
Expand Down
2 changes: 2 additions & 0 deletions sendspin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class ClientSettings(BaseSettings):
use_mpris: bool = True
audio_format: str | None = None
use_hardware_volume: bool | None = None
unpaired_access: bool = True
hook_set_volume: str | None = None
hook_start: str | None = None
hook_stop: str | None = None
Expand Down Expand Up @@ -228,6 +229,7 @@ def _load(self) -> bool:
self.use_mpris = data.get("use_mpris", True)
self.audio_format = data.get("audio_format")
self.use_hardware_volume = data.get("use_hardware_volume")
self.unpaired_access = data.get("unpaired_access", True)
Comment thread
maximmaxim345 marked this conversation as resolved.
self.hook_set_volume = data.get("hook_set_volume")
self.hook_start = data.get("hook_start")
self.hook_stop = data.get("hook_stop")
Expand Down
19 changes: 18 additions & 1 deletion tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

from __future__ import annotations

import json
from pathlib import Path

from sendspin.settings import _load_identity
import pytest

from sendspin.settings import _load_identity, get_client_settings


def test_load_identity_creates_key_file_without_group_or_other_access(tmp_path: Path) -> None:
Expand All @@ -20,3 +23,17 @@ def test_load_identity_creates_key_file_without_group_or_other_access(tmp_path:
# The persisted key must still round-trip to the same client identity.
reloaded = _load_identity(path, settings_path)
assert reloaded.peer_id == identity.peer_id


@pytest.mark.parametrize(
("stored", "expected"),
[({"name": "Kitchen"}, True), ({"unpaired_access": False}, False)],
)
async def test_unpaired_access_from_settings_file(
tmp_path: Path, stored: dict[str, object], expected: bool
) -> None:
(tmp_path / "settings-daemon.json").write_text(json.dumps(stored))

settings = await get_client_settings("daemon", str(tmp_path))

assert settings.unpaired_access is expected
Loading