diff --git a/README.md b/README.md index c6049ed..ecee88f 100644 --- a/README.md +++ b/README.md @@ -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", @@ -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 | @@ -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-`, 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 diff --git a/pyproject.toml b/pyproject.toml index 094f33a..8f2d3d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/sendspin/audio_connector.py b/sendspin/audio_connector.py index fd82602..84b078f 100644 --- a/sendspin/audio_connector.py +++ b/sendspin/audio_connector.py @@ -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 @@ -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, ) diff --git a/sendspin/cli.py b/sendspin/cli.py index 0bead5b..bc06d57 100644 --- a/sendspin/cli.py +++ b/sendspin/cli.py @@ -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 ( @@ -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 @@ -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-)", + 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", @@ -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-)", - ) daemon_parser.add_argument( "--log-level", default=None, @@ -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", @@ -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( @@ -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, @@ -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 @@ -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: @@ -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, diff --git a/sendspin/daemon/daemon.py b/sendspin/daemon/daemon.py index 2faa15c..09dffda 100644 --- a/sendspin/daemon/daemon.py +++ b/sendspin/daemon/daemon.py @@ -11,13 +11,14 @@ from typing import TYPE_CHECKING from aiohttp import ClientError, web -from aiosendspin.client import ClientListener, SendspinClient +from aiosendspin.client import ClientListener, PairingSupport, SendspinClient from aiosendspin.models.core import GroupUpdateServerPayload, ServerCommandPayload from aiosendspin.models.player import ClientHelloPlayerSupport, SupportedAudioFormat from aiosendspin_mpris import MPRIS_AVAILABLE, SendspinMpris from aiosendspin.models.types import ( - ConnectionReason, + Activity, GoodbyeReason, + PairAbortReason, PlaybackStateType, PlayerCommand, Roles, @@ -30,6 +31,8 @@ from sendspin.utils import create_task, get_device_info if TYPE_CHECKING: + from aiosendspin.noise import ClientPairingStore, Identity + from sendspin.volume_controller import VolumeController logger = logging.getLogger(__name__) @@ -40,7 +43,8 @@ class DaemonArgs: """Configuration for the Sendspin daemon.""" audio_device: AudioDevice - client_id: str + identity: Identity + pairing_store: ClientPairingStore client_name: str settings: ClientSettings url: str | None = None @@ -81,6 +85,7 @@ def __init__(self, args: DaemonArgs) -> None: self._server_url: str | None = None self._group_update_unsubscribe: Callable[[], None] | None = None self._server_command_unsubscribe: Callable[[], None] | None = None + self._pairing_abort_unsubscribe: Callable[[], None] | None = None def _create_client(self) -> SendspinClient: """Create a new SendspinClient instance.""" @@ -95,9 +100,11 @@ def _create_client(self) -> SendspinClient: supported_formats.insert(0, self._args.preferred_format) return SendspinClient( - client_id=self._args.client_id, + identity=self._args.identity, client_name=self._args.client_name, roles=client_roles, + pairing_store=self._args.pairing_store, + pairing_support=self._build_pairing_support(), device_info=get_device_info( manufacturer=self._args.manufacturer, product_name=self._args.product_name, @@ -113,9 +120,25 @@ def _create_client(self) -> SendspinClient: initial_muted=self._audio_handler.muted, ) + def _build_pairing_support(self) -> PairingSupport: + """Build pairing-PIN out-channels for headless (log-based) display.""" + return PairingSupport( + pin_display=self._show_pairing_code, + offer_static_pin=True, + ) + + async def _show_pairing_code(self, code: str | None) -> None: + """Log (or clear) the derived dynamic pairing PIN.""" + if code is not None: + logger.info("Pairing required: enter PIN %s on the server.", code) + + def _handle_pairing_abort(self, reason: PairAbortReason) -> None: + """Log a non-closing pairing abort (e.g. a mismatched code).""" + logger.warning("Pairing failed: %s", reason.value) + async def run(self) -> int: """Run the daemon.""" - logger.info("Starting Sendspin daemon: %s", self._args.client_id) + logger.info("Starting Sendspin daemon: %s", self._args.identity.peer_id) loop = asyncio.get_running_loop() # Store reference to current task so it can be cancelled on shutdown @@ -205,7 +228,7 @@ async def _run_server_initiated(self) -> None: self._connection_lock = asyncio.Lock() self._listener = ClientListener( - client_id=self._args.client_id, + client_id=self._args.identity.peer_id, on_connection=self._handle_server_connection, port=self._args.listen_port, client_name=self._args.client_name, @@ -226,6 +249,9 @@ def _attach_client(self, client: SendspinClient) -> None: self._handle_server_command ) self._group_update_unsubscribe = client.add_group_update_listener(self._on_group_update) + self._pairing_abort_unsubscribe = client.add_pairing_abort_listener( + self._handle_pairing_abort + ) if MPRIS_AVAILABLE and self._args.use_mpris: self._mpris = SendspinMpris(client) self._mpris.start() @@ -238,6 +264,9 @@ def _detach_client(self) -> None: if self._group_update_unsubscribe is not None: self._group_update_unsubscribe() self._group_update_unsubscribe = None + if self._pairing_abort_unsubscribe is not None: + self._pairing_abort_unsubscribe() + self._pairing_abort_unsubscribe = None if self._mpris is not None: self._mpris.stop() self._mpris = None @@ -265,12 +294,12 @@ def _should_switch_to_new_server( if new_client.server_info.server_id == old_client.server_info.server_id: return True - new_reason = new_client.server_info.connection_reason - old_reason = old_client.server_info.connection_reason + new_is_playback = Activity.PLAYBACK in new_client.activities + old_is_playback = Activity.PLAYBACK in old_client.activities - if new_reason == ConnectionReason.PLAYBACK: + if new_is_playback: return True - if old_reason == ConnectionReason.PLAYBACK: + if old_is_playback: return False # Both 'discovery' — prefer last played server. @@ -290,68 +319,88 @@ def _on_group_update(self, payload: GroupUpdateServerPayload) -> None: self._settings.update(last_played_server_id=server_id) async def _handle_server_connection(self, ws: web.WebSocketResponse) -> None: - """Handle an incoming server connection.""" + """Handle an incoming server connection. + + ``SendspinClient.attach_websocket()`` now blocks for the connection's + entire lifetime once admitted (it only returns when the connection + closes), so it's driven as a background task here and ``client.connected`` + is polled to learn when admission completes — the same pattern + aiosendspin's own tests use for server-initiated dials. + """ logger.info("Server connected") assert self._audio_handler is not None assert self._connection_lock is not None assert self._settings is not None + client = self._create_client() + attach_task = create_task(client.attach_websocket(ws)) + + try: + async with asyncio.timeout(30): + while not client.connected and not attach_task.done(): + await asyncio.sleep(0.01) + except TimeoutError: + logger.warning("Handshake with server timed out") + attach_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await attach_task + return + + if not client.connected: + # attach_task finished without admitting — either rejected/failed bring-up + # (raises, logged below) or its own internal handshake timeout elapsed + # (aiosendspin catches that internally and returns normally, so there's no + # exception to report; without this the whole attempt is silent). + exc = attach_task.exception() if attach_task.done() else None + if exc is not None: + logger.warning("Handshake with server failed: %s", exc) + else: + logger.info( + "Incoming connection did not complete admission " + "(handshake timed out or was aborted); waiting for a retry." + ) + return + # Lock ensures we wait for any in-progress handshake to complete # before disconnecting the previous server async with self._connection_lock: old_client = self._client - # Per spec: always complete the handshake before deciding which - # server to keep. - client = self._create_client() - - try: - await client.attach_websocket(ws) - except TimeoutError: - logger.warning("Handshake with server timed out") - return - except Exception: - logger.exception("Error during server handshake") - return - # Decide which server to keep. if old_client is not None: if self._should_switch_to_new_server(old_client, client): assert client.server_info is not None logger.info( - "Switching to server '%s' (%s)", + "Switching to server '%s' (activities=%s)", client.server_info.name, - client.server_info.connection_reason.value, + [a.value for a in client.activities], ) self._detach_client() await self._handle_disconnect() - await old_client.send_goodbye(GoodbyeReason.ANOTHER_SERVER) - await old_client.disconnect() + await old_client.disconnect(GoodbyeReason.ANOTHER_SERVER) else: assert old_client.server_info is not None assert client.server_info is not None logger.info( - "Keeping server '%s', rejecting '%s' (%s)", + "Keeping server '%s', rejecting '%s' (activities=%s)", old_client.server_info.name, client.server_info.name, - client.server_info.connection_reason.value, + [a.value for a in client.activities], ) - await client.send_goodbye(GoodbyeReason.ANOTHER_SERVER) - await client.disconnect() + await client.disconnect(GoodbyeReason.ANOTHER_SERVER) + with contextlib.suppress(Exception): + await attach_task return self._attach_client(client) - # Handshake complete, release lock so new connections can proceed - # Now wait for disconnect (outside the lock) + # Handshake complete, release lock so new connections can proceed. + # attach_task only resolves once the connection actually closes. try: - disconnect_event = asyncio.Event() - unsubscribe = client.add_disconnect_listener(disconnect_event.set) - await disconnect_event.wait() - unsubscribe() + await attach_task logger.info("Server disconnected") except Exception: - logger.exception("Error waiting for server disconnect") + logger.exception("Error while connection was active") finally: # Only cleanup if we're still the active client (not replaced by new connection) if self._client is client: @@ -369,6 +418,7 @@ async def _connection_loop(self, url: str) -> None: while True: try: await self._client.connect(url) + logger.info("Connected to %s", url) error_backoff = 1.0 # Wait for disconnect @@ -451,7 +501,7 @@ def _on_stream_event(self, event: str) -> None: server_id=server_info.server_id if server_info else None, server_name=server_info.name if server_info else None, server_url=self._server_url, - client_id=self._args.client_id, + client_id=self._args.identity.peer_id, client_name=self._args.client_name, ) ) diff --git a/sendspin/serve/__init__.py b/sendspin/serve/__init__.py index 785067a..81f05c8 100644 --- a/sendspin/serve/__init__.py +++ b/sendspin/serve/__init__.py @@ -10,12 +10,12 @@ import signal import socket import sys -import uuid from contextlib import suppress from dataclasses import dataclass from typing import TYPE_CHECKING, Any import qrcode +from aiosendspin.noise import Identity, InMemoryServerPairingStore from aiosendspin.server import ( ClientAddedEvent, ClientRemovedEvent, @@ -120,12 +120,11 @@ async def run_server(config: ServeConfig) -> int: if sys.platform == "win32": event_loop.set_exception_handler(_windows_exception_handler) - server_id = f"sendspin-cli-{uuid.uuid4().hex[:8]}" - server = SendspinPlayerServer( loop=event_loop, - server_id=server_id, + identity=Identity.generate(), server_name=config.name, + pairing_store=InMemoryServerPairingStore(), ) client_connected = asyncio.Event() diff --git a/sendspin/serve/worker.py b/sendspin/serve/worker.py index 5bdbb84..8d7bc04 100644 --- a/sendspin/serve/worker.py +++ b/sendspin/serve/worker.py @@ -10,9 +10,9 @@ import logging import multiprocessing as mp from multiprocessing.sharedctypes import Synchronized -import uuid from contextlib import suppress +from aiosendspin.noise import Identity, InMemoryServerPairingStore from aiosendspin.server import ( ClientAddedEvent, ClientRemovedEvent, @@ -101,11 +101,11 @@ async def run(self) -> None: async def _start_server(self) -> None: """Start the SendspinPlayerServer on this worker's port.""" loop = asyncio.get_running_loop() - server_id = f"sendspin-worker-{self.worker_id}-{uuid.uuid4().hex[:8]}" self._server = SendspinPlayerServer( loop=loop, - server_id=server_id, + identity=Identity.generate(), server_name=f"Sendspin Worker {self.worker_id}", + pairing_store=InMemoryServerPairingStore(), total_listeners=self._total_listeners, ) self._server.add_event_listener(self._on_server_event) diff --git a/sendspin/settings.py b/sendspin/settings.py index cb2f669..04a6ca5 100644 --- a/sendspin/settings.py +++ b/sendspin/settings.py @@ -9,10 +9,14 @@ import asyncio import json import logging +import os +import stat from dataclasses import dataclass, field, fields from pathlib import Path from typing import Any, ClassVar, Literal +from aiosendspin.noise import FileClientPairingStore, Identity, b64url_decode, b64url_encode + logger = logging.getLogger(__name__) # Debounce delay for saving settings @@ -115,7 +119,6 @@ class ClientSettings(BaseSettings): player_muted: bool = False static_delay_ms: float = 0.0 last_server_url: str | None = None - client_id: str | None = None audio_device: str | None = None use_mpris: bool = True audio_format: str | None = None @@ -141,7 +144,6 @@ def update( static_delay_ms: float | None = None, last_server_url: str | None = None, name: str | None = None, - client_id: str | None = None, audio_device: str | None = None, log_level: str | None = None, listen_port: int | None = None, @@ -174,7 +176,6 @@ def update( "static_delay_ms": static_delay_ms, "last_server_url": last_server_url, "name": name, - "client_id": client_id, "audio_device": audio_device, "log_level": log_level, "listen_port": listen_port, @@ -217,7 +218,6 @@ def _load(self) -> bool: elif self.static_delay_ms > 5000: self.static_delay_ms = 5000.0 self.last_server_url = data.get("last_server_url") - self.client_id = data.get("client_id") self.audio_device = data.get("audio_device") self.use_mpris = data.get("use_mpris", True) self.audio_format = data.get("audio_format") @@ -313,6 +313,76 @@ async def get_client_settings( return settings +def _config_path(config_dir: str | None) -> Path: + return Path(config_dir) if config_dir else Path.home() / ".config" / "sendspin" + + +def _warn_if_upgrading_from_legacy_client_id(settings_path: Path) -> None: + """Warn once when a fresh identity replaces a pre-encryption install's client_id. + + Before pairing support, ``client_id`` was a stable, often user-chosen string + (``--id``); it's now derived from the generated identity's public key instead, + so an upgrade presents as a brand-new device to any server that keyed player + records/groups off the old client_id (see sendspin-python-cli#277). + """ + try: + data = json.loads(settings_path.read_text()) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return + old_client_id = data.get("client_id") + if old_client_id: + logger.warning( + "Generating a new client identity to replace the old client_id %r. " + "Servers (e.g. Music Assistant) will see this as a new/different player " + "than before this upgrade — you may need to re-add it to any zones, " + "groups, or Home Assistant entities that referenced the old one, and " + "pair it again if the server requires pairing.", + old_client_id, + ) + + +def _load_identity(path: Path, settings_path: Path) -> Identity: + """Load a persisted identity, generating and persisting a fresh one if absent.""" + try: + data = json.loads(path.read_text()) + return Identity.from_private_bytes(b64url_decode(data["private_key"])) + except (FileNotFoundError, json.JSONDecodeError, KeyError, OSError, ValueError) as e: + if not isinstance(e, FileNotFoundError): + logger.warning("Failed to load identity from %s, generating a new one: %s", path, e) + else: + _warn_if_upgrading_from_legacy_client_id(settings_path) + identity = Identity.generate() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"private_key": b64url_encode(identity.private_bytes)})) + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + return identity + + +async def get_client_identity( + mode: Literal["tui", "daemon"], config_dir: str | None = None +) -> Identity: + """Load this client's persistent Noise identity, generating one on first run. + + The identity's public key (``identity.peer_id``) is the protocol ``client_id`` + servers see, so it must stay stable across restarts for pairing records and + "last played server" arbitration to keep working. + """ + config_path = _config_path(config_dir) + path = config_path / f"identity-{mode}.json" + settings_path = config_path / f"settings-{mode}.json" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, _load_identity, path, settings_path) + + +async def get_client_pairing_store( + mode: Literal["tui", "daemon"], config_dir: str | None = None +) -> FileClientPairingStore: + """Open this client's persistent pairing-record store, seeding one on first run.""" + path = _config_path(config_dir) / f"pairing-store-{mode}.json" + path.parent.mkdir(parents=True, exist_ok=True) + return await FileClientPairingStore.open(path) + + async def get_serve_settings(config_dir: str | None = None) -> ServeSettings: """Create and load serve settings. diff --git a/sendspin/tui/app.py b/sendspin/tui/app.py index ad6eb63..49573c7 100644 --- a/sendspin/tui/app.py +++ b/sendspin/tui/app.py @@ -13,16 +13,16 @@ if TYPE_CHECKING: from aiosendspin.models.metadata import SessionUpdateMetadata + from aiosendspin.noise import ClientPairingStore, Identity from sendspin.volume_controller import VolumeController from aiohttp import ClientError -from aiosendspin.client import SendspinClient +from aiosendspin.client import PairingSupport, SendspinClient from aiosendspin.models.artwork import ArtworkChannel, ClientHelloArtworkSupport from aiosendspin.models.core import ( GroupUpdateServerPayload, ServerCommandPayload, - ServerHelloPayload, ServerStatePayload, StreamStartMessage, ) @@ -34,6 +34,7 @@ from aiosendspin.models.types import ( ArtworkSource, MediaCommand, + PairAbortReason, PictureFormat, PlaybackStateType, PlayerCommand, @@ -235,7 +236,8 @@ class AppArgs: """Configuration for the Sendspin application.""" audio_device: AudioDevice - client_id: str + identity: Identity + pairing_store: ClientPairingStore client_name: str settings: ClientSettings url: str | None = None @@ -334,9 +336,11 @@ def _create_client(self) -> SendspinClient: assert self._audio_handler is not None return SendspinClient( - client_id=args.client_id, + identity=args.identity, client_name=args.client_name, roles=roles, + pairing_store=args.pairing_store, + pairing_support=self._build_pairing_support(), device_info=get_device_info( manufacturer=args.manufacturer, product_name=args.product_name, @@ -354,6 +358,18 @@ def _create_client(self) -> SendspinClient: initial_muted=self._audio_handler.muted, ) + def _build_pairing_support(self) -> PairingSupport: + """Build pairing-PIN out-channels wired into the Rich UI.""" + return PairingSupport( + pin_display=self._show_pairing_code, + offer_static_pin=True, + ) + + async def _show_pairing_code(self, code: str | None) -> None: + """Render (or clear) the derived dynamic pairing PIN in the UI.""" + if self._ui is not None: + self._ui.show_pairing_code(code) + def _attach_client(self) -> None: """Attach listeners, audio handler, visualizer, and MPRIS to the current client.""" assert self._client is not None @@ -365,7 +381,7 @@ def _attach_client(self) -> None: self._client.add_controller_state_listener(self._handle_server_state), self._client.add_server_command_listener(self._handle_server_command), self._client.add_color_listener(self._handle_color_update), - self._client.add_server_hello_listener(self._handle_server_hello), + self._client.add_pairing_abort_listener(self._handle_pairing_abort), ] self._audio_handler.attach_client(self._client) @@ -502,7 +518,7 @@ def request_shutdown() -> None: on_color_mode_change=self._persist_color_mode, ) self._ui.start() - self._ui.add_event(f"Using client ID: {args.client_id}") + self._ui.add_event(f"Using client ID: {args.identity.peer_id}") self._ui.add_event(f"Using audio device: {args.audio_device.name}") await self._discovery.start() @@ -793,6 +809,8 @@ def _handle_metadata_update(self, payload: ServerStatePayload) -> None: assert self._ui is not None state = self._state ui = self._ui + if isinstance(payload.metadata, UndefinedField): + return if payload.metadata is None or not state.update_metadata(payload.metadata): return @@ -819,6 +837,8 @@ def _clear_visualizer_timelines(self) -> None: def _handle_color_update(self, payload: ServerStatePayload) -> None: """Forward a color@v1 palette payload to the UI.""" assert self._ui is not None + if isinstance(payload.color, UndefinedField): + return self._ui.update_palette(payload.color) def _persist_color_mode(self, mode: ColorMode) -> None: @@ -855,7 +875,7 @@ def _handle_server_state(self, payload: ServerStatePayload) -> None: assert self._ui is not None state = self._state ui = self._ui - if not payload.controller: + if payload.controller is None or isinstance(payload.controller, UndefinedField): return controller = payload.controller @@ -952,19 +972,10 @@ def _server_now_us(self) -> int: assert self._client is not None return self._client.compute_server_time(self._client.now_us()) - def _handle_server_hello(self, payload: ServerHelloPayload) -> None: - """Hide the visualizer panel when the server didn't activate visualizer@v1.""" - if not self._visualizer_enabled: - return - if Roles.VISUALIZER.value in payload.active_roles: - return - logger.warning( - "Server did not activate %s (active_roles=%s); hiding the visualizer panel.", - Roles.VISUALIZER.value, - payload.active_roles, - ) + def _handle_pairing_abort(self, reason: PairAbortReason) -> None: + """Surface a non-closing pairing abort (e.g. a mismatched code) to the user.""" if self._ui is not None: - self._ui.set_visualizer_enabled(False) + self._ui.add_event(f"Pairing failed: {reason.value}") def _handle_stream_start(self, message: StreamStartMessage) -> None: """Record which visualizer types the server negotiated for this stream.""" @@ -1031,7 +1042,7 @@ def _on_stream_event(self, event: str) -> None: server_id=server_info.server_id if server_info else None, server_name=server_info.name if server_info else None, server_url=server.url if server else None, - client_id=self._args.client_id, + client_id=self._args.identity.peer_id, client_name=self._args.client_name, ) ) diff --git a/sendspin/tui/ui.py b/sendspin/tui/ui.py index 1ecef8e..37a8cee 100644 --- a/sendspin/tui/ui.py +++ b/sendspin/tui/ui.py @@ -154,6 +154,10 @@ class UIState: highlighted_shortcut: str | None = None highlight_time: float = 0.0 + # Pairing: the derived dynamic PIN, shown while a pairing exchange is in + # progress. None when not pairing. + pairing_code: str | None = None + class SendspinUI: """Rich-based terminal UI for the Sendspin CLI.""" @@ -683,6 +687,25 @@ def _build_server_selector_panel(self) -> Panel: return self._make_panel(content, title="Select Server", default_border="cyan") + def _build_pairing_panel(self) -> Panel: + """Build the pairing panel: the derived dynamic PIN.""" + content = Table.grid() + content.add_column(justify="center") + + content.add_row(Text("Pairing with server", style=self._themed("bold"))) + content.add_row("") + + if self._state.pairing_code: + code = Text(self._state.pairing_code, style=self._themed("bold cyan")) + content.add_row(code) + content.add_row("") + + content.add_row( + Text("Approve this client on the server to finish pairing.", style=self._themed("dim")) + ) + + return self._make_panel(content, title="Pairing Required", default_border="cyan") + def _build_playback_panel(self, *, expand: bool = False, min_info_rows: int = 0) -> Panel: """Build the playback panel with repeat/shuffle status.""" info = Table.grid(padding=(0, 2)) @@ -1067,6 +1090,17 @@ def _build_layout(self) -> Table: layout.add_row(selector) return layout + # Pairing takes priority over the normal layout: it blocks playback + # until the operator approves this client on the server. + if self._state.pairing_code: + pairing = self._cached_panel( + "pairing", + (self._state.pairing_code,), + self._build_pairing_panel, + ) + layout.add_row(pairing) + return layout + narrow = width < 80 # Now Playing panel @@ -1417,6 +1451,11 @@ def is_server_selector_visible(self) -> bool: """Check if the server selector is currently visible.""" return self._state.show_server_selector + def show_pairing_code(self, code: str | None) -> None: + """Show (or clear, on ``None``) the derived dynamic pairing PIN.""" + self._state.pairing_code = code + self.refresh() + def move_server_selection(self, delta: int) -> None: """Move the server selection by delta (-1 for up, +1 for down).""" if not self._state.available_servers: diff --git a/sendspin/utils.py b/sendspin/utils.py index 90c726c..d259ed8 100644 --- a/sendspin/utils.py +++ b/sendspin/utils.py @@ -5,6 +5,7 @@ import asyncio import platform import sys +import uuid from collections.abc import Coroutine from importlib.metadata import version from pathlib import Path @@ -65,6 +66,19 @@ def create_task( return task +def _detect_mac_address() -> str | None: + """Return a stable hardware MAC address, or None if only a synthesized one is available. + + ``uuid.getnode()`` sets the multicast bit on the address it returns when it + couldn't find a real network interface MAC, falling back to a random value + instead; that's not a stable identifier, so treat it as unavailable. + """ + node = uuid.getnode() + if (node >> 40) & 0x01: + return None + return ":".join(f"{(node >> (8 * i)) & 0xFF:02x}" for i in reversed(range(6))) + + def get_device_info( *, manufacturer: str | None = None, @@ -120,4 +134,5 @@ def get_device_info( product_name=detected_product_name, manufacturer=manufacturer, software_version=software_version, + mac_address=_detect_mac_address(), ) diff --git a/tests/daemon/test_daemon.py b/tests/daemon/test_daemon.py index b781e2f..495d7f4 100644 --- a/tests/daemon/test_daemon.py +++ b/tests/daemon/test_daemon.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from aiosendspin.models.types import PlayerCommand +from aiosendspin.noise import Identity, InMemoryClientPairingStore from sendspin.daemon.daemon import DaemonArgs, SendspinDaemon from sendspin.settings import ClientSettings @@ -34,7 +35,8 @@ def _make_daemon(tmp_path: Path, *, settings_volume: int, settings_muted: bool) ) args = DaemonArgs( audio_device=SimpleNamespace(index=0, name="Fake Device"), - client_id="test-client", + identity=Identity.generate(), + pairing_store=InMemoryClientPairingStore(), client_name="Test Client", settings=settings, use_mpris=False, diff --git a/tests/tui/test_role_negotiation.py b/tests/tui/test_role_negotiation.py deleted file mode 100644 index 7ceffe6..0000000 --- a/tests/tui/test_role_negotiation.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace - -from aiosendspin.models.core import ServerHelloPayload -from aiosendspin.models.types import ConnectionReason, Roles - -from sendspin.settings import ClientSettings -from sendspin.tui.app import AppArgs, SendspinApp - - -class _FakeUI: - def __init__(self) -> None: - self.visualizer_enabled_calls: list[bool] = [] - - def set_visualizer_enabled(self, enabled: bool) -> None: - self.visualizer_enabled_calls.append(enabled) - - -def _make_app(tmp_path: Path) -> SendspinApp: - args = AppArgs( - audio_device=SimpleNamespace(index=0, name="Fake Device"), - client_id="test-client", - client_name="Test Client", - settings=ClientSettings(_settings_file=tmp_path / "settings.json"), - use_mpris=False, - ) - return SendspinApp(args) - - -def _payload(active_roles: list[str]) -> ServerHelloPayload: - return ServerHelloPayload( - server_id="srv", - name="srv", - version=1, - active_roles=active_roles, - connection_reason=ConnectionReason.DISCOVERY, - ) - - -def test_server_hello_without_visualizer_role_hides_panel(tmp_path: Path) -> None: - app = _make_app(tmp_path) - app._visualizer_enabled = True - app._ui = _FakeUI() - - app._handle_server_hello(_payload(active_roles=["player@v1", "controller@v1"])) - - assert app._ui.visualizer_enabled_calls == [False] - - -def test_server_hello_with_visualizer_role_leaves_panel(tmp_path: Path) -> None: - app = _make_app(tmp_path) - app._visualizer_enabled = True - app._ui = _FakeUI() - - app._handle_server_hello(_payload(active_roles=[Roles.VISUALIZER.value, "player@v1"])) - - assert app._ui.visualizer_enabled_calls == [] - - -def test_server_hello_ignored_when_visualizer_disabled(tmp_path: Path) -> None: - app = _make_app(tmp_path) - app._visualizer_enabled = False - app._ui = _FakeUI() - - app._handle_server_hello(_payload(active_roles=["player@v1"])) - - assert app._ui.visualizer_enabled_calls == [] diff --git a/tests/tui/test_volume_state.py b/tests/tui/test_volume_state.py index 61c6f12..a3a6a65 100644 --- a/tests/tui/test_volume_state.py +++ b/tests/tui/test_volume_state.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from aiosendspin.models.types import PlayerCommand +from aiosendspin.noise import Identity, InMemoryClientPairingStore from sendspin.settings import ClientSettings from sendspin.tui.app import AppArgs, AppState, SendspinApp @@ -50,7 +51,8 @@ def _make_settings(tmp_path: Path) -> ClientSettings: def _make_app(tmp_path: Path) -> SendspinApp: args = AppArgs( audio_device=SimpleNamespace(index=0, name="Fake Device"), - client_id="test-client", + identity=Identity.generate(), + pairing_store=InMemoryClientPairingStore(), client_name="Test Client", settings=_make_settings(tmp_path), use_mpris=False, diff --git a/uv.lock b/uv.lock index 0506d21..0848e1d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" [[package]] @@ -98,17 +98,20 @@ wheels = [ [[package]] name = "aiosendspin" -version = "6.0.1" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, + { name = "cpace" }, + { name = "cryptography" }, { name = "mashumaro" }, + { name = "noiseprotocol" }, { name = "orjson" }, { name = "zeroconf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/89/14c04309b2ee46095df4ce5fcd70554b26cf0c275526995a1c4aa9d59525/aiosendspin-6.0.1.tar.gz", hash = "sha256:a0c066fd7619113a643954aaa5265fc209d447589e7fe8ed09b19b070d0ed745", size = 160231, upload-time = "2026-05-31T14:54:18.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/f0/03f798813a0e09f4e1565691d2e5138f0750c98b36d93f522d06c44efe66/aiosendspin-9.1.1.tar.gz", hash = "sha256:1e9a33ec5b6dcbc16b4f03c07586195beaa80eb2e5ba4254b97979ae0e0ec4b7", size = 251694, upload-time = "2026-08-25T15:28:43.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/96/bf436baac06826d2ccf5d41d871aa81c6fbb5e85d14b2cc9dd2bc4783041/aiosendspin-6.0.1-py3-none-any.whl", hash = "sha256:4bfbb3bdd68dc27d4d14ff8a4c34cb87b7ba9664d50908e1f117c545d9c7e86c", size = 187968, upload-time = "2026-05-31T14:54:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c8/e6bb6ece16ef74a3867543675a153cb09b10eb907025e8d77ac4db547bc4/aiosendspin-9.1.1-py3-none-any.whl", hash = "sha256:cb7227f70a8c6cfea450fb92ad26ed6fe3d86e883620700e2cde9372ad8c995b", size = 278171, upload-time = "2026-08-25T15:28:42.58Z" }, ] [package.optional-dependencies] @@ -355,6 +358,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cpace" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/e9/0aa114fdf26b4112222ddbfd190197aa5d11bce21b4747cc1889998eead5/cpace-0.1.0.tar.gz", hash = "sha256:049d30b4389c965cb2d98551f2f7361382bfa342afc5d53b6dc16b84a5759ad2", size = 60404, upload-time = "2026-07-13T21:07:15.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/e8/949c45844ad0d65d0112c2c4fa11cc8a7c4359fe2a20667e00c930860bef/cpace-0.1.0-py3-none-any.whl", hash = "sha256:9fabb60a711a85934225be4081b3f5994e1ca4cd1335c5b9171770c8f1a2fda3", size = 9456, upload-time = "2026-07-13T21:07:13.703Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + [[package]] name = "dbus-next" version = "0.2.3" @@ -700,6 +765,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "noiseprotocol" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/17/fcf8a90dcf36fe00b475e395f34d92f42c41379c77b25a16066f63002f95/noiseprotocol-0.3.1.tar.gz", hash = "sha256:b092a871b60f6a8f07f17950dc9f7098c8fe7d715b049bd4c24ee3752b90d645", size = 16890, upload-time = "2020-11-25T19:06:48.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/e1/76e4694201d67b93a6f1644b2588b4a3d965419fe189416e3496cf415db5/noiseprotocol-0.3.1-py3-none-any.whl", hash = "sha256:2e1a603a38439636cf0ffd8b3e8b12cee27d368a28b41be7dbe568b2abb23111", size = 20546, upload-time = "2020-03-03T18:51:28.095Z" }, +] + [[package]] name = "numpy" version = "2.4.4" @@ -1305,7 +1382,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "aiosendspin", extras = ["server"], specifier = "~=6.0.1" }, + { name = "aiosendspin", extras = ["server"], specifier = "~=9.1.1" }, { name = "aiosendspin-mpris", specifier = "~=2.1.1" }, { name = "av", specifier = ">=15.0.0" }, { name = "codespell", marker = "extra == 'test'", specifier = "==2.4.1" },