Skip to content
Open
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
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ Settings are stored in `~/.config/sendspin/`:
"static_delay_ms": 0,
"last_server_url": "ws://192.168.1.100:8927/sendspin",
"name": "Living Room",
"client_id": "sendspin-living-room",
"audio_device": "2",
"audio_format": "flac:48000:24:2",
"log_level": "INFO",
Expand Down Expand Up @@ -142,7 +141,6 @@ Settings are stored in `~/.config/sendspin/`:
| `static_delay_ms` | float | TUI/daemon | Extra playback delay in milliseconds |
| `last_server_url` | string | TUI/daemon | Server URL (used as default for `--url`) |
| `name` | string | All | Friendly name for client or server (`--name`) |
| `client_id` | string | TUI/daemon | Unique client identifier (`--id`) |
| `audio_device` | string | TUI/daemon | Audio device index, name prefix, or ALSA device name (`--audio-device`) |
| `audio_format` | string | TUI/daemon | Preferred audio format (`--audio-format`, e.g., `flac:48000:24:2`) |
| `log_level` | string | All | Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL |
Expand Down Expand Up @@ -175,16 +173,45 @@ sendspin --url ws://192.168.1.100:8080/sendspin
sendspin servers list
```

### Pairing

Sendspin connections are end-to-end encrypted. The first time a client connects to a
server that requires pairing, the server picks a pairing method and the client displays
a short PIN — enter it on the server to approve the client. Once paired, the client's
credentials are persisted (see `--settings-dir` below), so subsequent connections to the
same server don't require pairing again.

- **TUI**: the PIN appears in a "Pairing Required" panel that takes over the screen until
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.

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

**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
makes it look like a brand-new device to any server it talks to. You'll likely need to
re-add it to zones/groups (and re-pair, if the server requires it) after the first
upgrade. The client logs a one-time warning when this happens.

### Client Identification

If you want to run multiple players on the **same computer**, you can specify unique identifiers:
Each client's cryptographic identity (used for encryption and pairing) and settings are
stored per `--settings-dir` (default: `~/.config/sendspin`). If you want to run multiple
players on the **same computer** as distinct clients — each with its own identity,
pairing records, and settings — give each one a separate settings directory:

```bash
sendspin --id my-client-1 --name "Kitchen"
sendspin --id my-client-2 --name "Bedroom"
sendspin --settings-dir ~/.config/sendspin-kitchen --name "Kitchen"
sendspin --settings-dir ~/.config/sendspin-bedroom --name "Bedroom"
```

- `--id`: A unique identifier for this client (optional; defaults to `sendspin-<hostname>`, useful for running multiple instances on one computer)
- `--settings-dir`: Directory for this client's settings, identity, and pairing records (optional; defaults to `~/.config/sendspin`)
- `--name`: A friendly name displayed on the server (optional; defaults to hostname)

### Audio Output Device Selection
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"aiosendspin[server]~=6.0.1",
"aiosendspin[server]~=9.1.1",
"aiosendspin-mpris~=2.1.1",
"av>=15.0.0",
"numpy>=1.26.0",
Expand Down
4 changes: 2 additions & 2 deletions sendspin/audio_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from typing import TYPE_CHECKING, cast

from aiosendspin.models.core import StreamStartMessage
from aiosendspin.models.types import AudioCodec, ClientStateType
from aiosendspin.models.types import AudioCodec

from sendspin.audio import AudioPlayer
from sendspin.audio_devices import AudioDevice
Expand Down Expand Up @@ -454,7 +454,7 @@ def send_player_volume(self) -> None:
if self._client is not None and self._client.connected:
create_task(
self._client.send_player_state(
state=ClientStateType.SYNCHRONIZED,
available=True,
volume=self._volume,
muted=self._muted,
)
Expand Down
69 changes: 42 additions & 27 deletions sendspin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import traceback
from collections.abc import Sequence
from importlib.metadata import version
from typing import TYPE_CHECKING, Any, Protocol
from typing import TYPE_CHECKING, Any, Literal, Protocol

from sendspin.alsa_volume import AVAILABLE as ALSA_AVAILABLE
from sendspin.alsa_volume import (
Expand All @@ -23,11 +23,18 @@
from sendspin.hardware_volume import UNAVAILABLE_REASON as HW_VOLUME_UNAVAILABLE_REASON
from sendspin.hardware_volume import async_check_available as hw_volume_check_available
from sendspin.hook_volume import HookVolumeController
from sendspin.settings import ClientSettings, get_client_settings, get_serve_settings
from sendspin.settings import (
ClientSettings,
get_client_identity,
get_client_pairing_store,
get_client_settings,
get_serve_settings,
)
from sendspin.volume_controller import VolumeController

if TYPE_CHECKING:
from aiosendspin.models.player import SupportedAudioFormat
from aiosendspin.noise import ClientPairingStore, Identity

from sendspin.audio_devices import AudioDevice

Expand Down Expand Up @@ -155,9 +162,14 @@ def _add_player_runtime_options(target: ArgumentTarget, *, suppress_defaults: bo
help="Friendly name for this client (defaults to hostname)",
)
target.add_argument(
"--id",
"--settings-dir",
type=str,
default=default,
help="Unique identifier for this client (defaults to sendspin-cli-<hostname>)",
help=(
"Directory to store settings, identity, and pairing records "
"(default: ~/.config/sendspin). Use a distinct directory to run "
"multiple instances on the same computer as separate clients."
),
)
target.add_argument(
"--log-level",
Expand Down Expand Up @@ -386,11 +398,6 @@ def _build_parser() -> argparse.ArgumentParser:
default=None,
help="Friendly name for this client (defaults to hostname)",
)
daemon_parser.add_argument(
"--id",
default=None,
help="Unique identifier for this client (defaults to sendspin-cli-<hostname>)",
)
daemon_parser.add_argument(
"--log-level",
default=None,
Expand Down Expand Up @@ -427,7 +434,11 @@ def _build_parser() -> argparse.ArgumentParser:
"--settings-dir",
type=str,
default=None,
help="Directory to store settings (default: ~/.config/sendspin)",
help=(
"Directory to store settings, identity, and pairing records "
"(default: ~/.config/sendspin). Use a distinct directory to run "
"multiple instances on the same computer as separate clients."
),
)
daemon_parser.add_argument(
"--disable-mpris",
Expand Down Expand Up @@ -605,19 +616,16 @@ def __init__(self, message: str, exit_code: int = 1) -> None:
self.exit_code = exit_code


def _resolve_client_info(client_id: str | None, client_name: str | None) -> tuple[str, str]:
"""Determine client ID and name, using hostname as fallback."""
if client_id is not None and client_name is not None:
return client_id, client_name
def _resolve_client_name(client_name: str | None) -> str:
"""Determine the client's friendly name, using hostname as fallback."""
if client_name is not None:
return client_name

hostname = socket.gethostname()
if not hostname:
raise CLIError("Unable to determine hostname. Please specify --id and/or --name", 1)
raise CLIError("Unable to determine hostname. Please specify --name", 1)

return (
client_id or f"sendspin-cli-{hostname}",
client_name or hostname,
)
return hostname


def _resolve_preferred_format(
Expand Down Expand Up @@ -698,16 +706,19 @@ async def _run_daemon_mode(
settings: ClientSettings,
audio_device: AudioDevice,
volume_controller: VolumeController | None,
identity: Identity,
pairing_store: ClientPairingStore,
) -> int:
"""Run the client in daemon mode (no UI)."""
from sendspin.daemon.daemon import DaemonArgs, SendspinDaemon

client_id, client_name = _resolve_client_info(args.id, args.name)
client_name = _resolve_client_name(args.name)

daemon_args = DaemonArgs(
audio_device=audio_device,
url=args.url,
client_id=client_id,
identity=identity,
pairing_store=pairing_store,
client_name=client_name,
settings=settings,
static_delay_ms=args.static_delay_ms,
Expand Down Expand Up @@ -805,8 +816,11 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
args.command = "daemon"

is_daemon = args.command == "daemon"
mode: Literal["tui", "daemon"] = "daemon" if is_daemon else "tui"
settings_dir = getattr(args, "settings_dir", None)
settings = await get_client_settings("daemon" if is_daemon else "tui", settings_dir)
settings = await get_client_settings(mode, settings_dir)
identity = await get_client_identity(mode, settings_dir)
pairing_store = await get_client_pairing_store(mode, settings_dir)

# Apply settings as defaults for CLI arguments (CLI > settings > hard-coded)
url_from_settings = False
Expand All @@ -815,8 +829,6 @@ async def _run_client_mode(args: argparse.Namespace) -> int:
url_from_settings = True
if args.name is None:
args.name = settings.name
if args.id is None:
args.id = settings.client_id
if args.audio_device is None:
args.audio_device = settings.audio_device
if args.static_delay_ms is None and settings.static_delay_ms != 0.0:
Expand Down Expand Up @@ -897,17 +909,20 @@ async def _run_client_mode(args: argparse.Namespace) -> int:

# Handle daemon subcommand
if args.command == "daemon":
return await _run_daemon_mode(args, settings, audio_device, volume_controller)
return await _run_daemon_mode(
args, settings, audio_device, volume_controller, identity, pairing_store
)

from sendspin.tui.app import AppArgs, SendspinApp

client_id, client_name = _resolve_client_info(args.id, args.name)
client_name = _resolve_client_name(args.name)

app_args = AppArgs(
audio_device=audio_device,
url=args.url,
url_from_settings=url_from_settings,
client_id=client_id,
identity=identity,
pairing_store=pairing_store,
client_name=client_name,
settings=settings,
static_delay_ms=args.static_delay_ms,
Expand Down
Loading