Integrate sendspin-cli as a custom package using python3.withPackages. Packages aiosendspin from GitHub since it's only available in nixpkgs-unstable. Includes all required dependencies: aiohttp, av, numpy, qrcode, readchar, rich, sounddevice, and native libraries (portaudio, ffmpeg). Adds uv2nix flake inputs for future migration when sendspin-cli adds a uv.lock file.
23 KiB
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.nixand exposed aspkgs.custom.<name> - 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.withPackagesat 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
--headlessflag (sendspin-cli/sendspin/cli.py:115-117) - Device selection via
--audio-device <index|name>(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
--urlspecified (sendspin-cli/sendspin/cli.py:69-71) - No
uv.lockfile currently in repository (onlypyproject.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:
- Package available:
pkgs.custom.sendspin-clibuilds successfully with all dependencies - Role available:
roles.sendspin.enable = trueprovides sendspin with configurable service - Service template: Systemd user service runs as graphical session user with configurable audio device
- Audio device compatibility: Uses sendspin's native device specification (index or name prefix)
- Flexible user context: Service can run as kodi, johno, or any graphical session user
Verification Commands:
# Package builds successfully
nix build .#nixosConfigurations.zix790prors.config.environment.systemPackages --no-link | grep sendspin-cli
# Package contains working executable
$(nix-build -E '(import <nixpkgs> {}).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.tomlbut nouv.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.serviceandwireplumber.service - Auto-restart: Use
Restart=alwaysfor persistent background operation
Audio Device Handling
- Native format: Sendspin expects device index (0, 1, 2) or name prefix ("AmazonBasics")
- Discovery:
sendspin --list-audio-devicesshows 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:
- Not creating uv.lock: Using pyproject.toml directly; lock file can be added upstream later
- Not implementing multi-instance configuration: Providing single-instance template; machines can extend for multiple instances
- Not configuring specific machines: Template only; zix790prors multi-instance setup is future work
- Not creating home-manager module: Using system-level role with user services
- Not implementing server mode: Client-only integration;
sendspin servecan be added later - Not auto-detecting graphical user: Relying on systemd user service behavior; explicit user selection can be added later
- 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:
- uv2nix over python3.withPackages: Better maintainability, automatic dependency resolution, aligns with upstream development
- System-level role with user services: Follows existing patterns (virtual-surround.nix), enables per-user configuration
- Headless mode default: Services always use
--headless; TUI available via manualsendspincommand - mDNS discovery default: No
--urlby 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)
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)
# 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)
# 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
# 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:
- Flake evaluation succeeds:
nix flake check - Custom packages still build:
nix build .#nixosConfigurations.zix790prors.config.environment.systemPackages - No evaluation errors:
nixos-rebuild dry-build --flake .#zix790prors
Manual Verification:
- Flake inputs show pyproject-nix, uv2nix, and pyproject-build-systems:
nix flake metadata - 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
{ 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 <<EOF
#!/bin/sh
export PYTHONPATH="$out/lib:\$PYTHONPATH"
exec ${python}/bin/python3 -m sendspin.cli "\$@"
EOF
chmod +x $out/bin/sendspin
'';
meta = {
description = "Synchronized audio player for Sendspin servers (fallback build)";
homepage = "https://github.com/Sendspin/sendspin-cli";
license = lib.licenses.asl20;
};
}
else
let
# Fetch sendspin-cli source
src = fetchFromGitHub {
owner = "Sendspin";
repo = "sendspin-cli";
rev = "main"; # TODO: Pin to specific release tag
sha256 = lib.fakeSha256; # Replace with actual hash after first build
};
# Load workspace from pyproject.toml
workspace = uv2nix.lib.workspace.loadWorkspace {
workspaceRoot = src;
};
# Create overlay from pyproject.toml dependencies
overlay = workspace.mkPyprojectOverlay {
sourcePreference = "wheel"; # Prefer wheels for faster builds
};
# Build Python package set with native dependency overrides
pythonSet = (pkgs.callPackage pyproject-nix.build.packages {
python = pkgs.python312;
}).overrideScope (lib.composeManyExtensions [
pyproject-build-systems.overlays.default
overlay
# Override for packages with native dependencies
(final: prev: {
# sounddevice needs portaudio
sounddevice = prev.sounddevice.overrideAttrs (old: {
buildInputs = (old.buildInputs or []) ++ [ pkgs.portaudio ];
nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.portaudio ];
});
# av (PyAV) needs ffmpeg
av = prev.av.overrideAttrs (old: {
buildInputs = (old.buildInputs or []) ++ [ pkgs.ffmpeg ];
nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.pkg-config ];
});
})
]);
# Create virtual environment with all dependencies
venv = pythonSet.mkVirtualEnv "sendspin-cli-env" workspace.deps.default;
in
pkgs.stdenv.mkDerivation {
pname = "sendspin-cli";
version = "0.0.0";
inherit src;
buildInputs = [ venv pkgs.portaudio pkgs.ffmpeg ];
installPhase = ''
mkdir -p $out/bin
# Copy virtual environment
cp -r ${venv} $out/venv
# Create wrapper script
cat > $out/bin/sendspin <<EOF
#!/bin/sh
export LD_LIBRARY_PATH="${pkgs.portaudio}/lib:${pkgs.ffmpeg}/lib:\$LD_LIBRARY_PATH"
exec $out/venv/bin/sendspin "\$@"
EOF
chmod +x $out/bin/sendspin
'';
meta = {
description = "Synchronized audio player for Sendspin servers";
homepage = "https://github.com/Sendspin/sendspin-cli";
license = lib.licenses.asl20;
platforms = lib.platforms.linux;
};
}
2. Register Package
File: packages/default.nix
Changes: Add sendspin-cli to exports (after line 6)
{ pkgs, uv2nix ? null, pyproject-nix ? null, pyproject-build-systems ? null, ... }:
{
vulkanHDRLayer = pkgs.callPackage ./vulkan-hdr-layer {};
tea-rbw = pkgs.callPackage ./tea-rbw {};
app-launcher-server = pkgs.callPackage ./app-launcher-server {};
claude-code = pkgs.callPackage ./claude-code {};
sendspin-cli = pkgs.callPackage ./sendspin-cli { inherit uv2nix pyproject-nix pyproject-build-systems; };
}
Success Criteria
Automated Verification:
- Package builds successfully:
nix build .#nixosConfigurations.zix790prors.pkgs.custom.sendspin-cli - Binary exists in output:
nix path-info .#nixosConfigurations.zix790prors.pkgs.custom.sendspin-cli - No build errors in dry-run:
nixos-rebuild dry-build --flake .#zix790prors
Manual Verification:
- Help text displays correctly:
$(nix-build '<nixpkgs>' -A custom.sendspin-cli)/bin/sendspin --help - List audio devices works:
$(nix-build '<nixpkgs>' -A custom.sendspin-cli)/bin/sendspin --list-audio-devices - Version information is correct:
$(nix-build '<nixpkgs>' -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
{ 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-<hostname> 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)
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):
# 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):
# 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):
# 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:
- Enable role on a test machine:
roles.sendspin.enable = true; - Rebuild system:
make switch - Check service status:
systemctl --user status sendspin.service - Verify logs show connection attempts:
journalctl --user -u sendspin -f - Run server locally:
sendspin serve --demo - Verify client connects and plays audio
Multi-Device Test (Future - zix790prors):
- Create multiple service instances with different audio devices
- Verify each instance targets correct device
- Test audio sync between devices
User Context Test:
- Test on
boxyrunning askodiuser - Test on
zix790prorsrunning asjohnouser - Verify service runs in correct user session
Manual Testing Steps
-
Package Installation:
# As root nix-shell -p 'pkgs.custom.sendspin-cli' sendspin --list-audio-devices -
Service Functionality:
# After enabling role and rebuilding systemctl --user status sendspin journalctl --user -u sendspin -n 50 -
Audio Device Selection:
# Configure specific device roles.sendspin.audioDevice = "0"; # Rebuild and verify service uses correct device -
Server Discovery:
# Without serverUrl, verify mDNS discovery sendspin --list-servers -
Delay Calibration:
# 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:
- Add role to machine configuration
- Configure audio device if not using default
- Rebuild and enable service
- No data migration needed (stateless service)
For Multi-Instance Setups (Future): When implementing multiple instances for zix790prors:
- Disable default service:
systemd.user.services.sendspin.wantedBy = lib.mkForce []; - Create per-device service instances manually
- Each instance needs unique
--idand--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