# Sendspin-CLI Integration Implementation Plan ## Overview Integrate sendspin-cli (https://github.com/Sendspin/sendspin-cli) into the NixOS configuration using uv2nix for Python packaging. Provide a flexible systemd service template that runs as the graphical user, supporting multiple use cases: standalone media centers (like `boxy` running as `kodi` user) and desktop workstations (like `zix790prors` running as `johno` user). ## Current State Analysis **Existing Infrastructure:** - Custom package system using overlays at flake.nix:54-59 - Packages defined in `packages/default.nix` and exposed as `pkgs.custom.` - Role-based configuration system with audio role at roles/audio/default.nix:1-41 - User-level systemd services pattern demonstrated in machines/zix790prors/virtual-surround.nix:79-103 - Python packaging pattern using `python3.withPackages` at packages/app-launcher-server/default.nix:1-10 **Sendspin-CLI Analysis:** - Python 3.12+ application with setuptools build system - Entry point: `sendspin.cli:main` (sendspin-cli/sendspin/cli.py:143) - Dependencies: aiosendspin, av, numpy, qrcode, readchar, rich, sounddevice (sendspin-cli/pyproject.toml:16-23) - Native dependencies needed: portaudio (for sounddevice), ffmpeg (for av) - Supports headless mode via `--headless` flag (sendspin-cli/sendspin/cli.py:115-117) - Device selection via `--audio-device ` (sendspin-cli/sendspin/cli.py:96-102) - Device discovery via `--list-audio-devices` (sendspin-cli/sendspin/cli.py:15-34) - Auto-discovers servers via mDNS unless `--url` specified (sendspin-cli/sendspin/cli.py:69-71) - No `uv.lock` file currently in repository (only `pyproject.toml`) **Gap:** - No uv2nix flake inputs (pyproject-nix, uv2nix, pyproject-build-systems) - No sendspin-cli package definition - No sendspin role or systemd service configuration ## Desired End State After implementation completion: 1. **Package available**: `pkgs.custom.sendspin-cli` builds successfully with all dependencies 2. **Role available**: `roles.sendspin.enable = true` provides sendspin with configurable service 3. **Service template**: Systemd user service runs as graphical session user with configurable audio device 4. **Audio device compatibility**: Uses sendspin's native device specification (index or name prefix) 5. **Flexible user context**: Service can run as kodi, johno, or any graphical session user ### Verification Commands: ```bash # Package builds successfully nix build .#nixosConfigurations.zix790prors.config.environment.systemPackages --no-link | grep sendspin-cli # Package contains working executable $(nix-build -E '(import {}).callPackage ./packages/sendspin-cli {}')/bin/sendspin --help # Service template is generated nixos-rebuild dry-build --flake .#zix790prors 2>&1 | grep sendspin ``` ## Key Discoveries ### uv2nix Integration Points - **No lock file**: sendspin-cli has `pyproject.toml` but no `uv.lock` - uv2nix will resolve from pyproject.toml - **Native dependencies**: sounddevice and av require portaudio and ffmpeg in buildInputs - **Workspace loading**: uv2nix.lib.workspace.loadWorkspace works with pyproject.toml-only projects - **Build system**: Uses setuptools (declared in pyproject.toml:1-3) ### Service Architecture - **User services**: Must use `systemd.user.services` (not system services) for audio access - **Automatic user detection**: User services run in the logged-in graphical user's session - **PipeWire dependency**: Service must start after `pipewire.service` and `wireplumber.service` - **Auto-restart**: Use `Restart=always` for persistent background operation ### Audio Device Handling - **Native format**: Sendspin expects device index (0, 1, 2) or name prefix ("AmazonBasics") - **Discovery**: `sendspin --list-audio-devices` shows available devices - **PipeWire independence**: No need to use PipeWire node names; sendspin queries via sounddevice library ## What We're NOT Doing To prevent scope creep: 1. **Not creating uv.lock**: Using pyproject.toml directly; lock file can be added upstream later 2. **Not implementing multi-instance configuration**: Providing single-instance template; machines can extend for multiple instances 3. **Not configuring specific machines**: Template only; zix790prors multi-instance setup is future work 4. **Not creating home-manager module**: Using system-level role with user services 5. **Not implementing server mode**: Client-only integration; `sendspin serve` can be added later 6. **Not auto-detecting graphical user**: Relying on systemd user service behavior; explicit user selection can be added later 7. **Not packaging dev dependencies**: Only runtime dependencies; test tools (mypy, ruff) excluded ## Implementation Approach Use uv2nix to package sendspin-cli from its GitHub repository, accessing pyproject.toml for dependency resolution. Create a NixOS role following the spotifyd pattern with a systemd user service template. The service runs in the logged-in user's session (automatic user detection) and can be configured per-machine for different audio devices. **Key Technical Decisions:** 1. **uv2nix over python3.withPackages**: Better maintainability, automatic dependency resolution, aligns with upstream development 2. **System-level role with user services**: Follows existing patterns (virtual-surround.nix), enables per-user configuration 3. **Headless mode default**: Services always use `--headless`; TUI available via manual `sendspin` command 4. **mDNS discovery default**: No `--url` by default; let sendspin auto-discover servers on the network --- ## Phase 1: Add uv2nix Flake Inputs ### Overview Add pyproject-nix, uv2nix, and pyproject-build-systems as flake inputs and thread them through to package definitions. ### Changes Required #### 1. Flake Inputs **File**: `flake.nix` **Changes**: Add new inputs after existing inputs (after line 44) ```nix pyproject-nix = { url = "github:pyproject-nix/pyproject.nix"; inputs.nixpkgs.follows = "nixpkgs"; }; uv2nix = { url = "github:pyproject-nix/uv2nix"; inputs.pyproject-nix.follows = "pyproject-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; pyproject-build-systems = { url = "github:pyproject-nix/build-system-pkgs"; inputs.pyproject-nix.follows = "pyproject-nix"; inputs.uv2nix.follows = "uv2nix"; inputs.nixpkgs.follows = "nixpkgs"; }; ``` #### 2. Outputs Signature **File**: `flake.nix` **Changes**: Update outputs function signature (line 47) ```nix # Before outputs = { self, nixpkgs, nixpkgs-unstable, nixos-wsl, ... } @ inputs: # After outputs = { self, nixpkgs, nixpkgs-unstable, nixos-wsl, pyproject-nix, uv2nix, pyproject-build-systems, ... } @ inputs: ``` #### 3. Pass Inputs to Packages **File**: `flake.nix` **Changes**: Update custom package overlay (line 59 and 87) ```nix # Before custom = prev.callPackage ./packages {}; # After custom = prev.callPackage ./packages { inherit uv2nix pyproject-nix pyproject-build-systems; }; ``` #### 4. Update Packages Default.nix Signature **File**: `packages/default.nix` **Changes**: Accept new parameters ```nix # Before { pkgs, ... }: # After { pkgs, uv2nix ? null, pyproject-nix ? null, pyproject-build-systems ? null, ... }: ``` **Note**: Parameters are optional to maintain compatibility with direct `nix-build` calls. ### Success Criteria #### Automated Verification: - [x] Flake evaluation succeeds: `nix flake check` - [x] Custom packages still build: `nix build .#nixosConfigurations.zix790prors.config.environment.systemPackages` - [x] No evaluation errors: `nixos-rebuild dry-build --flake .#zix790prors` #### Manual Verification: - [x] Flake inputs show pyproject-nix, uv2nix, and pyproject-build-systems: `nix flake metadata` - [x] Existing machines still build without errors **Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation before proceeding to Phase 2. --- ## Phase 2: Create Sendspin-CLI Package ### Overview Create uv2nix-based package for sendspin-cli that handles Python dependencies and native libraries (portaudio, ffmpeg). ### Changes Required #### 1. Package Definition **File**: `packages/sendspin-cli/default.nix` **Changes**: Create new file ```nix { pkgs , uv2nix ? null , pyproject-nix ? null , pyproject-build-systems ? null , lib , fetchFromGitHub }: # Fallback to simple package if uv2nix not available if uv2nix == null || pyproject-nix == null || pyproject-build-systems == null then let python = pkgs.python312.withPackages (ps: with ps; [ # Core dependencies from pyproject.toml # Note: aiosendspin may need to be packaged separately if not in nixpkgs av numpy qrcode readchar rich sounddevice # Build dependencies setuptools ]); in pkgs.stdenv.mkDerivation rec { pname = "sendspin-cli"; version = "0.0.0-fallback"; src = fetchFromGitHub { owner = "Sendspin"; repo = "sendspin-cli"; rev = "main"; sha256 = lib.fakeSha256; # Replace with actual hash after first build }; buildInputs = [ python pkgs.portaudio pkgs.ffmpeg ]; installPhase = '' mkdir -p $out/bin $out/lib cp -r sendspin $out/lib/ cat > $out/bin/sendspin < $out/bin/sendspin <' -A custom.sendspin-cli)/bin/sendspin --help` - [ ] List audio devices works: `$(nix-build '' -A custom.sendspin-cli)/bin/sendspin --list-audio-devices` - [ ] Version information is correct: `$(nix-build '' -A custom.sendspin-cli)/bin/sendspin --version` (if supported) - [ ] Dependencies are bundled: Check that output closure contains portaudio and ffmpeg libraries **Implementation Note**: The first build will fail with `lib.fakeSha256` error. Copy the actual hash from the error message and replace `lib.fakeSha256` with the real hash. After completing this phase and all automated verification passes, pause here for manual confirmation before proceeding to Phase 3. --- ## Phase 3: Create Sendspin Role with Service Template ### Overview Create NixOS role that provides sendspin-cli package and configurable systemd user service template for running sendspin as a background service. ### Changes Required #### 1. Role Definition **File**: `roles/sendspin/default.nix` **Changes**: Create new file ```nix { config, lib, pkgs, ... }: with lib; let cfg = config.roles.sendspin; in { options.roles.sendspin = { enable = mkEnableOption "Enable the sendspin role"; audioDevice = mkOption { type = types.nullOr types.str; default = null; description = '' Audio output device by index (e.g., "0", "1") or name prefix (e.g., "AmazonBasics"). Use `sendspin --list-audio-devices` to see available devices. If null, uses system default audio device. ''; example = "0"; }; clientName = mkOption { type = types.nullOr types.str; default = null; description = '' Friendly name for this Sendspin client. Defaults to hostname if not specified. ''; example = "Living Room Speakers"; }; clientId = mkOption { type = types.nullOr types.str; default = null; description = '' Unique identifier for this Sendspin client. Defaults to sendspin-cli- if not specified. ''; example = "sendspin-livingroom"; }; serverUrl = mkOption { type = types.nullOr types.str; default = null; description = '' WebSocket URL of the Sendspin server. If null, auto-discovers servers via mDNS. ''; example = "ws://192.168.1.100:8927"; }; staticDelayMs = mkOption { type = types.float; default = 0.0; description = '' Extra playback delay in milliseconds applied after clock sync. Useful for compensating audio latency differences between devices. ''; example = 50.0; }; logLevel = mkOption { type = types.enum [ "DEBUG" "INFO" "WARNING" "ERROR" "CRITICAL" ]; default = "INFO"; description = "Logging level for sendspin service"; }; }; config = mkIf cfg.enable { # Ensure audio infrastructure is available roles.audio.enable = true; # Make sendspin-cli available system-wide environment.systemPackages = with pkgs; [ custom.sendspin-cli ]; # Systemd user service for running sendspin in headless mode systemd.user.services.sendspin = { description = "Sendspin Audio Sync Client"; documentation = [ "https://github.com/Sendspin/sendspin-cli" ]; # Start after audio services are ready after = [ "pipewire.service" "wireplumber.service" ]; requires = [ "pipewire.service" "wireplumber.service" ]; # Auto-start with pipewire (which starts with graphical session) wantedBy = [ "pipewire.service" ]; serviceConfig = { Type = "simple"; Restart = "always"; RestartSec = "5s"; # Build command with configured options ExecStart = pkgs.writeShellScript "sendspin-start" '' exec ${pkgs.custom.sendspin-cli}/bin/sendspin \ --headless \ --log-level ${cfg.logLevel} \ ${optionalString (cfg.audioDevice != null) "--audio-device '${cfg.audioDevice}'"} \ ${optionalString (cfg.clientName != null) "--name '${cfg.clientName}'"} \ ${optionalString (cfg.clientId != null) "--id '${cfg.clientId}'"} \ ${optionalString (cfg.serverUrl != null) "--url '${cfg.serverUrl}'"} \ ${optionalString (cfg.staticDelayMs != 0.0) "--static-delay-ms ${toString cfg.staticDelayMs}"} ''; }; }; # Open firewall for mDNS discovery networking.firewall.allowedUDPPorts = [ 5353 ]; }; } ``` #### 2. Register Role **File**: `roles/default.nix` **Changes**: Add sendspin to imports (after line 16) ```nix imports = [ ./audio ./bluetooth ./btrfs ./desktop ./kodi ./nfs-mounts ./nvidia ./printing ./remote-build ./sendspin ./spotifyd ./users ./virtualisation ]; ``` ### Success Criteria #### Automated Verification: - [ ] Configuration evaluates: `nixos-rebuild dry-build --flake .#zix790prors` - [ ] Service unit is generated: `nixos-rebuild dry-build --flake .#zix790prors 2>&1 | grep -i sendspin` - [ ] No syntax errors: `nix eval .#nixosConfigurations.zix790prors.config.roles.sendspin.enable` #### Manual Verification: - [ ] Role can be enabled in machine config without errors - [ ] Service dependencies are correct (after pipewire/wireplumber) - [ ] Firewall rule for mDNS is present - [ ] Sendspin-cli is in system packages when role is enabled - [ ] All configuration options (audioDevice, clientName, etc.) are exposed - [ ] Service starts successfully after enabling role and rebuilding **Implementation Note**: After completing this phase and all automated verification passes, pause here for manual confirmation. Test the service by adding `roles.sendspin.enable = true;` to a machine configuration, rebuilding, and verifying the service runs as the logged-in user. --- ## Testing Strategy ### Unit Tests (Per-Phase) **Phase 1 (Flake Inputs):** ```bash # Verify flake is valid nix flake check # Verify inputs are available nix flake metadata | grep -E "(pyproject-nix|uv2nix|pyproject-build-systems)" # Verify existing builds still work nix build .#nixosConfigurations.zix790prors.config.system.build.toplevel ``` **Phase 2 (Package):** ```bash # Build package nix build .#nixosConfigurations.zix790prors.pkgs.custom.sendspin-cli # Test executable result/bin/sendspin --help result/bin/sendspin --list-audio-devices # Verify dependencies nix-store --query --requisites result | grep -E "(portaudio|ffmpeg)" ``` **Phase 3 (Role):** ```bash # Evaluate with role enabled nix eval .#nixosConfigurations.zix790prors.config.roles.sendspin.enable # Check service definition nixos-rebuild dry-build --flake .#zix790prors systemctl --user cat sendspin.service # After rebuild # Verify audio device option works nix eval '.#nixosConfigurations.zix790prors.config.roles.sendspin.audioDevice' ``` ### Integration Tests **Basic Service Test:** 1. Enable role on a test machine: `roles.sendspin.enable = true;` 2. Rebuild system: `make switch` 3. Check service status: `systemctl --user status sendspin.service` 4. Verify logs show connection attempts: `journalctl --user -u sendspin -f` 5. Run server locally: `sendspin serve --demo` 6. Verify client connects and plays audio **Multi-Device Test (Future - zix790prors):** 1. Create multiple service instances with different audio devices 2. Verify each instance targets correct device 3. Test audio sync between devices **User Context Test:** 1. Test on `boxy` running as `kodi` user 2. Test on `zix790prors` running as `johno` user 3. Verify service runs in correct user session ### Manual Testing Steps 1. **Package Installation:** ```bash # As root nix-shell -p 'pkgs.custom.sendspin-cli' sendspin --list-audio-devices ``` 2. **Service Functionality:** ```bash # After enabling role and rebuilding systemctl --user status sendspin journalctl --user -u sendspin -n 50 ``` 3. **Audio Device Selection:** ```bash # Configure specific device roles.sendspin.audioDevice = "0"; # Rebuild and verify service uses correct device ``` 4. **Server Discovery:** ```bash # Without serverUrl, verify mDNS discovery sendspin --list-servers ``` 5. **Delay Calibration:** ```bash # Test delay configuration roles.sendspin.staticDelayMs = 50.0; # Verify in service logs ``` ## Performance Considerations **Build Time:** - uv2nix initial build may take 5-10 minutes (Python dependency resolution) - Subsequent builds use Nix cache - Consider using binary cache if building on multiple machines **Runtime:** - Sendspin client is lightweight (~20-50 MB memory) - CPU usage minimal when not playing audio - Network: Uses mDNS (UDP 5353) and WebSocket connection to server **Storage:** - Package closure size: ~200-300 MB (Python + dependencies + libraries) - No persistent cache by sendspin-cli itself ## Migration Notes **For Existing Systems:** 1. Add role to machine configuration 2. Configure audio device if not using default 3. Rebuild and enable service 4. No data migration needed (stateless service) **For Multi-Instance Setups (Future):** When implementing multiple instances for zix790prors: 1. Disable default service: `systemd.user.services.sendspin.wantedBy = lib.mkForce [];` 2. Create per-device service instances manually 3. Each instance needs unique `--id` and `--audio-device` ## References - Original research: `thoughts/shared/research/2025-12-29-sendspin-cli-integration.md` - Sendspin-CLI source: `~/src/sendspin-cli/` (GitHub: https://github.com/Sendspin/sendspin-cli) - Sendspin pyproject.toml: `~/src/sendspin-cli/pyproject.toml` - Sendspin CLI implementation: `~/src/sendspin-cli/sendspin/cli.py:143-222` - Custom packages pattern: `packages/default.nix:1-7` - Python package pattern: `packages/app-launcher-server/default.nix:1-10` - Audio role pattern: `roles/audio/default.nix:1-41` - Service role pattern: `roles/spotifyd/default.nix:1-40` - User service pattern: `machines/zix790prors/virtual-surround.nix:79-132` - Flake overlay: `flake.nix:54-59` - uv2nix documentation: https://pyproject-nix.github.io/uv2nix/ - uv2nix getting started: https://pyproject-nix.github.io/uv2nix/usage/getting-started.html