diff --git a/c_src/sendspin_player/CMakeLists.txt b/c_src/sendspin_player/CMakeLists.txt index 3997585..fd85fc3 100644 --- a/c_src/sendspin_player/CMakeLists.txt +++ b/c_src/sendspin_player/CMakeLists.txt @@ -21,7 +21,7 @@ set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG" CACHE STRING "" FORCE) # build dirs. This also means `-DSENDSPIN_CPP_REF=...` cannot override; # for scratch experiments against another ref use # `-DFETCHCONTENT_SOURCE_DIR_SENDSPIN-CPP=` instead. -set(SENDSPIN_CPP_REF "9331ace6428979982c934384702d289958ca125e" # v0.8.0 +set(SENDSPIN_CPP_REF "b95cf854a784ed308b1f8fe9697f414b02768ddd" # v0.9.5 CACHE STRING "sendspin-cpp commit SHA to pin" FORCE) # Trim sendspin-cpp to just the player role. Other roles ship dead code on a @@ -32,6 +32,13 @@ set(SENDSPIN_ENABLE_METADATA OFF CACHE BOOL "" FORCE) set(SENDSPIN_ENABLE_COLOR OFF CACHE BOOL "" FORCE) set(SENDSPIN_ENABLE_ARTWORK OFF CACHE BOOL "" FORCE) set(SENDSPIN_ENABLE_VISUALIZER OFF CACHE BOOL "" FORCE) +# Added upstream in v0.9.x and defaulted ON: the source (audio capture) role +# and its Opus encoder. We only play audio, so the role is off; the codec +# option stays ON because our advertised formats include Opus (see +# codec_name()/supported formats in src/main.cpp) and upstream made the +# decoder optional in v0.9.0. +set(SENDSPIN_ENABLE_SOURCE OFF CACHE BOOL "" FORCE) +set(SENDSPIN_ENABLE_OPUS ON CACHE BOOL "" FORCE) include(FetchContent) FetchContent_Declare( @@ -93,6 +100,7 @@ add_executable(sendspin_player src/main.cpp src/alsa_pipe_sink.cpp src/alsa_caps.cpp + src/file_persistence.cpp ) target_link_libraries(sendspin_player diff --git a/c_src/sendspin_player/README.md b/c_src/sendspin_player/README.md index 791bc2d..eded48d 100644 --- a/c_src/sendspin_player/README.md +++ b/c_src/sendspin_player/README.md @@ -65,12 +65,16 @@ Differences: - Sets `SendspinClientConfig::server_port` from `--mdns-port` so multiple binary instances can bind distinct ports (first-class upstream since sendspin-cpp #61) +- A file-backed `SendspinPersistenceProvider` (`src/file_persistence.*`) + keeps the library's identity keypair and pairing state under + `--state-dir`, and unpaired access is enabled (see *Identity and + admission* below) ## sendspin-cpp pin Pinned via `FetchContent` to a **commit SHA** in `CMakeLists.txt` -(`SENDSPIN_CPP_REF` cache var). Currently `9331ace6...`, upstream -`v0.8.0`. We pin to a SHA rather +(`SENDSPIN_CPP_REF` cache var). Currently `b95cf854...`, upstream +`v0.9.5`. We pin to a SHA rather than a tag because git tags are mutable server-side; a retagged upstream would otherwise silently flow into firmware. The friendly tag name (or nearest-release note) lives in a comment beside the SHA for traceability. @@ -142,7 +146,7 @@ Outside Mix, configure and build by hand: cd c_src/sendspin_player cmake -B _build -S . -DCMAKE_BUILD_TYPE=Release cmake --build _build -j -./_build/sendspin_player --name "Test" --client-id "test-uuid" +./_build/sendspin_player --name "Test" --state-dir /tmp/sendspin-test ``` ## CLI surface @@ -153,7 +157,7 @@ The flags must match what `UniversalProxy.Audio.Player` passes — see the | Flag | Required | Description | | -------------------------- | -------- | -------------------------------------------- | | `--name STR` | yes | Friendly display name | -| `--client-id STR` | yes | Stable unique client identifier (UUID) | +| `--state-dir PATH` | yes | Per-output directory for the persisted identity keypair and pairing state, created `0700`. The Sendspin `client_id` is derived from the keypair and reported on `listening` | | `--server URL` | no | Outbound Sendspin server WebSocket URL | | `--product STR` | no | Product id in `device_info` (node name, e.g. `universal-proxy-07507f`); vendor is fixed to `Universal Proxy`. Default `sendspin_player` | | `--mac STR` | no | MAC for `device_info`, lowercase colon-separated. Pre-normalized by the Elixir side; empty/absent = library auto-detects from the active interface | @@ -173,7 +177,7 @@ changes, and on errors. ```jsonc {"event":"started","version":"0.1.0","port":8928,"name":"Out 1","product":"universal-proxy-07507f","alsa_device":"plughw:0,0","formats":[{"codec":"flac","channels":2,"rate":48000,"bit_depth":16}]} -{"event":"listening","port":8928} // WebSocket listener confirmed bound +{"event":"listening","port":8928,"client_id":"E8oqWSUo4DZKok0mgH32tIvrxxyc_Fzn-YfXfYm_mBM"} // listener bound {"event":"connected","server":"ws://music.local:8927/sendspin"} {"event":"disconnected"} {"event":"stream_start","sample_rate":48000,"channels":2,"bit_depth":16,"codec":"opus"} @@ -200,7 +204,45 @@ imply the port accepts connections yet). `Audio.Player` gates its mDNS advertisement on this event: Music Assistant's discovery connect is one-shot (aiosendspin `retry_initial_connection=False`), so advertising before the listener is up hands MA a connection-refused that orphans the player until -the mDNS record next flaps. +the mDNS record next flaps. It also carries the Sendspin `client_id` (see +below), which `Audio.Player` advertises as the TXT `client_id` entry. + +## Identity and admission + +Since sendspin-cpp v0.9.0 (Sendspin 1.0.0-rc1) the `client_id` is not +configured. The library derives it as base64url (no padding) of a static +X25519 public key, generates that keypair on first start, and stores it +through the persistence provider. `FilePersistence` writes each library +blob as one file in `--state-dir` (dir `0700`, files `0600`, written via +temp file + fsync + rename), so the identity survives respawns and +reboots. A state dir that cannot be created fails the process before the +client starts, rather than letting it mint an identity it cannot keep. The +library only logs a failed save of a freshly generated keypair (a full or +read-only disk) and starts anyway, so the binary checks for that after +`start()` and exits with a `state_dir` error event before `listening`. A +`keypair` file that exists but cannot be read (permissions, I/O error) is +treated the same way: the library would mint a new identity, so +`FilePersistence` refuses to save over the unreadable file, leaving it +untouched, and the process exits with that error instead of changing the +`client_id`. + +The library's output-delay blob is deliberately not stored: the BEAM side +already owns that value (persisted from the `static_delay` event and +passed back via `--initial-static-delay-ms`), and a stored blob would +outrank the CLI value. + +The connection is Noise-encrypted. The player calls +`set_unpaired_access_enabled(true)` before `start()`, so a server that has +not paired with it can still admit it: Music Assistant approves a +player-only client that offers unpaired access without any setup step. + +The library always advertises the `pairing_psk` method in `client/hello`. +It backs it with a Pairing PSK it generates on first start and stores +under the `pairing_psk` key, so `--state-dir` holds pairing secrets (the +PSK and any pairing records) as well as the identity key. We do not offer +the pairing-code methods (dynamic or static code); those would need UI to +show the code (`on_display_pairing_code`) and to confirm the pairing +window. ## Stdin commands diff --git a/c_src/sendspin_player/patches/0003-listener-bound-hook.patch b/c_src/sendspin_player/patches/0003-listener-bound-hook.patch index 49169e4..7beb4df 100644 --- a/c_src/sendspin_player/patches/0003-listener-bound-hook.patch +++ b/c_src/sendspin_player/patches/0003-listener-bound-hook.patch @@ -1,13 +1,14 @@ diff --git a/src/host/ws_server.cpp b/src/host/ws_server.cpp -index 0586f63..1ab37db 100644 +index 7acef5f..f43e675 100644 --- a/src/host/ws_server.cpp +++ b/src/host/ws_server.cpp -@@ -20,6 +20,16 @@ - #include "server_connection.h" +@@ -26,8 +26,18 @@ #include + #include +#include -+ + #include + +// universal_proxy patch: listener-bound hook. The embedding binary may +// define this symbol to learn exactly when the WebSocket listener has +// successfully bound and started accepting connections. Declared weak so @@ -16,10 +17,11 @@ index 0586f63..1ab37db 100644 +// it fires only for THIS process's listener, never for a foreign or +// dying-predecessor socket holding the port. +extern "C" void sendspin_host_listener_bound(uint16_t port) __attribute__((weak)); - ++ namespace sendspin { -@@ -156,6 +166,13 @@ bool SendspinWsServer::start(SendspinClient* client, bool /*task_stack_in_psram* + static const char* const TAG = "sendspin.ws_server"; +@@ -164,6 +174,13 @@ bool SendspinWsServer::start(SendspinClient* client, bool /*task_stack_in_psram* } this->server_->start(); diff --git a/c_src/sendspin_player/src/file_persistence.cpp b/c_src/sendspin_player/src/file_persistence.cpp new file mode 100644 index 0000000..b511bd5 --- /dev/null +++ b/c_src/sendspin_player/src/file_persistence.cpp @@ -0,0 +1,197 @@ +#include "file_persistence.h" + +#include "sendspin/persistence_keys.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Larger than any library blob (the biggest, `rec_order`, is at most 255 +// bytes). A file past this is not something the library wrote. +constexpr size_t MAX_BLOB_BYTES = 4096; + +constexpr size_t MAX_KEY_LEN = 32; + +std::string errno_str(const char* what, const std::string& path) { + return std::string(what) + " " + path + ": " + std::strerror(errno); +} + +// mkdir -p, creating each missing component 0700. An existing component +// is left as it is, except the leaf, which is forced to 0700 because it +// holds the private key. +bool make_dirs(const std::string& dir, std::string& err) { + if (dir.empty()) { + err = "state dir is empty"; + return false; + } + for (size_t pos = 1; pos <= dir.size(); ++pos) { + if (pos != dir.size() && dir[pos] != '/') continue; + std::string part = dir.substr(0, pos); + if (::mkdir(part.c_str(), 0700) != 0 && errno != EEXIST) { + err = errno_str("mkdir", part); + return false; + } + } + struct stat st{}; + if (::stat(dir.c_str(), &st) != 0) { + err = errno_str("stat", dir); + return false; + } + if (!S_ISDIR(st.st_mode)) { + err = "state dir " + dir + " is not a directory"; + return false; + } + if (::chmod(dir.c_str(), 0700) != 0) { + err = errno_str("chmod", dir); + return false; + } + return true; +} + +bool write_all(int fd, const uint8_t* data, size_t len) { + while (len > 0) { + ssize_t n = ::write(fd, data, len); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + data += n; + len -= static_cast(n); + } + return true; +} + +} // namespace + +FilePersistence::FilePersistence(std::string dir) : dir_(std::move(dir)) { + while (dir_.size() > 1 && dir_.back() == '/') dir_.pop_back(); +} + +FilePersistence::~FilePersistence() { + if (dir_fd_ >= 0) ::close(dir_fd_); +} + +bool FilePersistence::init(std::string& err) { + if (!make_dirs(dir_, err)) return false; + dir_fd_ = ::open(dir_.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dir_fd_ < 0) { + err = errno_str("open", dir_); + return false; + } + return true; +} + +bool FilePersistence::valid_key(const std::string& key) { + if (key.empty() || key.size() > MAX_KEY_LEN) return false; + for (char c : key) { + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; + if (!ok) return false; + } + return true; +} + +std::string FilePersistence::path_for(const std::string& key) const { + return dir_ + "/" + key; +} + +std::optional> FilePersistence::load_blob(const std::string& key) { + if (key == sendspin::persistence_keys::OUTPUT_DELAY) return std::nullopt; + if (!valid_key(key)) { + std::fprintf(stderr, "sendspin_player: refusing persistence key '%s'\n", key.c_str()); + return std::nullopt; + } + const std::string path = path_for(key); + const bool is_keypair = key == sendspin::persistence_keys::KEYPAIR; + int fd = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) { + if (errno != ENOENT) { + std::fprintf(stderr, "sendspin_player: %s\n", errno_str("open", path).c_str()); + if (is_keypair) keypair_load_failed_ = true; + } + return std::nullopt; + } + std::vector out; + uint8_t buf[512]; + bool ok = true; + for (;;) { + ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n < 0) { + if (errno == EINTR) continue; + std::fprintf(stderr, "sendspin_player: %s\n", errno_str("read", path).c_str()); + if (is_keypair) keypair_load_failed_ = true; + ok = false; + break; + } + if (n == 0) break; + out.insert(out.end(), buf, buf + n); + if (out.size() > MAX_BLOB_BYTES) { + std::fprintf(stderr, "sendspin_player: %s exceeds %zu bytes, ignoring\n", + path.c_str(), MAX_BLOB_BYTES); + ok = false; + break; + } + } + ::close(fd); + if (!ok) return std::nullopt; + return out; +} + +bool FilePersistence::save_blob(const std::string& key, const uint8_t* data, size_t len) { + // Accepted and dropped: the BEAM side owns the output delay (see the + // header comment). + if (key == sendspin::persistence_keys::OUTPUT_DELAY) return true; + if (key != sendspin::persistence_keys::KEYPAIR) return write_blob(key, data, len); + // A keypair file that exists but could not be read still holds the + // identity; the library would replace it with the one it just minted. + if (keypair_load_failed_) { + std::fprintf(stderr, "sendspin_player: not overwriting unreadable %s\n", + path_for(key).c_str()); + keypair_save_failed_ = true; + return false; + } + keypair_save_failed_ = !write_blob(key, data, len); + return !keypair_save_failed_; +} + +bool FilePersistence::write_blob(const std::string& key, const uint8_t* data, size_t len) { + if (!valid_key(key) || dir_fd_ < 0) { + std::fprintf(stderr, "sendspin_player: refusing persistence key '%s'\n", key.c_str()); + return false; + } + const std::string path = path_for(key); + const std::string tmp = dir_ + "/." + key + ".tmp"; + + int fd = ::open(tmp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0600); + if (fd < 0) { + std::fprintf(stderr, "sendspin_player: %s\n", errno_str("open", tmp).c_str()); + return false; + } + bool ok = write_all(fd, data, len) && ::fsync(fd) == 0; + if (!ok) std::fprintf(stderr, "sendspin_player: %s\n", errno_str("write", tmp).c_str()); + if (::close(fd) != 0 && ok) { + std::fprintf(stderr, "sendspin_player: %s\n", errno_str("close", tmp).c_str()); + ok = false; + } + if (ok && ::rename(tmp.c_str(), path.c_str()) != 0) { + std::fprintf(stderr, "sendspin_player: %s\n", errno_str("rename", tmp).c_str()); + ok = false; + } + if (!ok) { + ::unlink(tmp.c_str()); + return false; + } + // Make the rename itself durable before reporting the write accepted; + // the library only calls commit() after pairing material, and writing + // through here keeps every key equally safe. + if (::fsync(dir_fd_) != 0) { + std::fprintf(stderr, "sendspin_player: %s\n", errno_str("fsync", dir_).c_str()); + return false; + } + return true; +} diff --git a/c_src/sendspin_player/src/file_persistence.h b/c_src/sendspin_player/src/file_persistence.h new file mode 100644 index 0000000..095b5f2 --- /dev/null +++ b/c_src/sendspin_player/src/file_persistence.h @@ -0,0 +1,70 @@ +#pragma once + +#include "sendspin/client.h" + +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// File-backed persistence provider +// --------------------------------------------------------------------------- +// +// Since sendspin-cpp v0.9.0 the player's identity is not configured: the +// library derives `client_id` from a static X25519 keypair it generates on +// first start and stores through a `SendspinPersistenceProvider`. Without a +// provider every respawn would mint a fresh identity, so this stores each +// library blob as one file under the per-output `--state-dir` the BEAM side +// hands us. +// +// The directory holds the private key, so it is created 0700 and every +// file 0600. Each save writes a temp file, fsyncs it and renames it over +// the old one, then fsyncs the directory, so a power cut leaves either the +// old blob or the new one and never a torn key. The library treats a blob +// of the wrong length as absent, so this stays a pure byte store and never +// interprets what it holds. +// +// The output delay key is deliberately not stored. The BEAM side already +// owns that value (persisted from the `static_delay` event and passed back +// as `--initial-static-delay-ms`), and a stored blob would take precedence +// over the CLI value, leaving two sources of truth. +class FilePersistence : public sendspin::SendspinPersistenceProvider { +public: + explicit FilePersistence(std::string dir); + ~FilePersistence() override; + + FilePersistence(const FilePersistence&) = delete; + FilePersistence& operator=(const FilePersistence&) = delete; + + /// Create the directory (and any missing parents) and open it for the + /// directory fsync. Must succeed before the provider is handed to the + /// client. On failure `err` names the step and errno. + bool init(std::string& err); + + std::optional> load_blob(const std::string& key) override; + bool save_blob(const std::string& key, const uint8_t* data, size_t len) override; + + /// True if the most recent write of the identity keypair failed. The + /// library only logs that failure and still starts, using a keypair the + /// next launch will not find, so the caller checks this after start(). + /// A keypair file that exists but could not be read (open() failing + /// with anything but ENOENT, or a read error) makes every later save of + /// it fail without touching the file, so an unreadable identity is + /// reported here rather than silently replaced. + bool keypair_save_failed() const { return keypair_save_failed_; } + +private: + /// Library keys are short lowercase identifiers (`keypair`, `rec_0`, + /// …). Anything else is refused rather than turned into a path. + static bool valid_key(const std::string& key); + std::string path_for(const std::string& key) const; + + bool write_blob(const std::string& key, const uint8_t* data, size_t len); + + std::string dir_; + int dir_fd_ = -1; + bool keypair_load_failed_ = false; + bool keypair_save_failed_ = false; +}; diff --git a/c_src/sendspin_player/src/main.cpp b/c_src/sendspin_player/src/main.cpp index 03518db..246edf7 100644 --- a/c_src/sendspin_player/src/main.cpp +++ b/c_src/sendspin_player/src/main.cpp @@ -13,6 +13,8 @@ // • JSON commands on stdin for runtime volume/mute/shutdown // • Sets SendspinClientConfig::server_port from --mdns-port to bind a // non-default WebSocket port so multiple players can coexist +// • Persists the library's identity and pairing state under --state-dir +// (file_persistence.h) and reports the derived client_id on stdout // // Licensed under the Apache License, Version 2.0. @@ -21,6 +23,7 @@ #include "alsa_pipe_sink.h" #include "alsa_caps.h" +#include "file_persistence.h" #include #include @@ -226,7 +229,7 @@ struct Options { std::string server; std::string alsa_device; std::string name; - std::string client_id; + std::string state_dir; std::string product; std::string mac; int mdns_port = DEFAULT_WS_PORT; @@ -240,7 +243,10 @@ static void print_usage(const char* prog) { "Usage: %s [options]\n\n" "Required:\n" " --name STR Friendly display name.\n" - " --client-id STR Stable unique client identifier (e.g. UUID).\n\n" + " --state-dir PATH Per-output directory for the persisted identity\n" + " keypair and pairing state (created 0700). The\n" + " Sendspin client_id is derived from the keypair\n" + " and reported in the `listening` event.\n\n" "Optional:\n" " --server URL Sendspin server WebSocket URL\n" " (e.g. ws://music.local:8927/sendspin).\n" @@ -278,7 +284,7 @@ static bool parse_args(int argc, char** argv, Options& opts) { {"server", required_argument, nullptr, 's'}, {"alsa-device", required_argument, nullptr, 'd'}, {"name", required_argument, nullptr, 'n'}, - {"client-id", required_argument, nullptr, 'i'}, + {"state-dir", required_argument, nullptr, 'S'}, {"product", required_argument, nullptr, 'P'}, {"mac", required_argument, nullptr, 'm'}, {"mdns-port", required_argument, nullptr, 'p'}, @@ -296,7 +302,7 @@ static bool parse_args(int argc, char** argv, Options& opts) { case 's': opts.server = optarg; break; case 'd': opts.alsa_device = optarg; break; case 'n': opts.name = optarg; break; - case 'i': opts.client_id = optarg; break; + case 'S': opts.state_dir = optarg; break; case 'P': opts.product = optarg; break; case 'm': opts.mac = optarg; break; case 'p': @@ -327,8 +333,8 @@ static bool parse_args(int argc, char** argv, Options& opts) { std::fprintf(stderr, "Error: --name is required\n"); return false; } - if (opts.client_id.empty()) { - std::fprintf(stderr, "Error: --client-id is required\n"); + if (opts.state_dir.empty()) { + std::fprintf(stderr, "Error: --state-dir is required\n"); return false; } if (opts.mdns_port <= 0 || opts.mdns_port > 65535) { @@ -345,7 +351,6 @@ static bool parse_args(int argc, char** argv, Options& opts) { } // UTF-8 gate before any field flows into a stdout JSON event. if (!require_utf8("--name", opts.name)) return false; - if (!require_utf8("--client-id", opts.client_id)) return false; if (!require_utf8("--product", opts.product)) return false; if (!require_utf8("--mac", opts.mac)) return false; if (!require_utf8("--alsa-device", opts.alsa_device)) return false; @@ -634,7 +639,7 @@ struct PlayerListener : PlayerRoleListener { // The server (e.g. Music Assistant) changed the static output delay. The // library already applies it to playout timing in the sync task; we just // report it so the Elixir side can persist it and re-apply on respawn. - void on_static_delay_changed(uint16_t delay_ms) override { + void on_output_delay_changed(uint16_t delay_ms) override { std::ostringstream os; os << "{\"event\":\"static_delay\",\"value\":" << static_cast(delay_ms) << "}"; emit_json(os.str()); @@ -708,8 +713,19 @@ int main(int argc, char* argv[]) { SendspinClient::set_log_level(log_level); + // Before the client exists, so a state dir we cannot create fails the + // process with a clear message instead of start() minting a keypair it + // cannot keep (a new client_id on every respawn). + FilePersistence persistence(opts.state_dir); + { + std::string err; + if (!persistence.init(err)) { + std::fprintf(stderr, "Error: --state-dir: %s\n", err.c_str()); + return 1; + } + } + SendspinClientConfig client_config; - client_config.client_id = opts.client_id; client_config.name = opts.name; // opts.mdns_port is validated 1–65535 in parse_args, so the narrowing // cast cannot truncate. @@ -757,7 +773,7 @@ int main(int argc, char* argv[]) { // respawns by the Elixir side via the `static_delay` stdout event + // `--initial-static-delay-ms`). The library applies it to playout timing // inside the sync task; we only enable + report it. - player_config.initial_static_delay_ms = static_cast(opts.initial_static_delay_ms); + player_config.initial_output_delay_ms = static_cast(opts.initial_static_delay_ms); auto& player = client.add_player(std::move(player_config)); AlsaPipeSink audio_sink; @@ -775,9 +791,18 @@ int main(int argc, char* argv[]) { player.set_listener(&player_listener); // Advertise the static delay as server-adjustable; without this the // player reports it as 0 and Music Assistant exposes no delay control. - player.set_static_delay_adjustable(true); + player.set_output_delay_adjustable(true); client.set_listener(&client_listener); client.set_network_provider(&network_provider); + client.set_persistence_provider(&persistence); + // Admit servers that have not paired with us. Music Assistant approves + // a player-only client that offers this without any setup step, which + // keeps outputs zero-config. The library always offers `pairing_psk` + // (its generated PSK is persisted like the keypair), but we have no UI + // for the pairing-code methods or for handing out the PSK token. The + // library never persists this flag, so it is set on every launch, + // before start(), so the first client/hello carries it. + client.set_unpaired_access_enabled(true); { std::ostringstream os; @@ -802,6 +827,21 @@ int main(int argc, char* argv[]) { return 1; } + // start() generates a keypair when none is stored, and only logs a + // failed save of it (a full or read-only /data, or an existing keypair + // file we could not read and so refuse to overwrite). Running on would + // advertise a client_id the next launch replaces, so every respawn + // would appear to Music Assistant as another new player. Exit before + // the listener binds instead; the BEAM side shows the error. + if (persistence.keypair_save_failed()) { + emit_json("{\"event\":\"error\",\"kind\":\"state_dir\"," + "\"msg\":\"failed to persist the identity keypair\"}"); + // stop() before returning, for the listener-lifetime reason given + // at the normal shutdown below. + client.stop(); + return 1; + } + { auto v = static_cast(opts.initial_volume); audio_sink.set_volume(v); @@ -816,7 +856,7 @@ int main(int argc, char* argv[]) { // any UI reflect it without waiting for a server change. std::ostringstream os; os << "{\"event\":\"static_delay\",\"value\":" - << static_cast(player.get_static_delay_ms()) << "}"; + << static_cast(player.get_output_delay_ms()) << "}"; emit_json(os.str()); } @@ -843,8 +883,11 @@ int main(int argc, char* argv[]) { std::ostringstream os; // The acquire above pairs with the hook's release store, so // this relaxed port load cannot observe a stale/zero value. + // client_id() is valid from start() on; the BEAM side + // advertises it in the mDNS TXT record. os << "{\"event\":\"listening\",\"port\":" - << g_listener_port.load(std::memory_order_relaxed) << "}"; + << g_listener_port.load(std::memory_order_relaxed) + << ",\"client_id\":\"" << json_escape(client.client_id()) << "\"}"; emit_json(os.str()); } diff --git a/lib/universal_proxy/audio/player.ex b/lib/universal_proxy/audio/player.ex index 6404153..6c1e666 100644 --- a/lib/universal_proxy/audio/player.ex +++ b/lib/universal_proxy/audio/player.ex @@ -38,7 +38,7 @@ defmodule UniversalProxy.Audio.Player do README and with `main.cpp`'s `emit_json` call sites. {"event":"started","version":"0.1.0","port":8928,"name":"Out 1","product":"universal-proxy-07507f","alsa_device":"plughw:0,0","formats":[{"codec":"flac","channels":2,"rate":48000,"bit_depth":16}]} - {"event":"listening","port":8928} + {"event":"listening","port":8928,"client_id":"E8oqWSUo4DZKok0mgH32tIvrxxyc_Fzn-YfXfYm_mBM"} {"event":"connected","server":"ws://music.local:8927/sendspin"} {"event":"disconnected"} {"event":"stream_start","sample_rate":48000,"channels":2,"bit_depth":16,"codec":"opus"} @@ -108,7 +108,9 @@ defmodule UniversalProxy.Audio.Player do :mdns_module, :device_name_fun, :mac_address_fun, + :state_dir, port: nil, + client_id: nil, mdns_id: nil, mdns_registered: false, mdns_refused: false, @@ -132,7 +134,9 @@ defmodule UniversalProxy.Audio.Player do # past the expected 0-arity function to match the runtime. device_name_fun: (-> String.t() | nil) | term(), mac_address_fun: (-> String.t() | nil) | term(), + state_dir: String.t(), port: port() | nil, + client_id: String.t() | nil, mdns_id: term(), mdns_registered: boolean(), mdns_refused: boolean(), @@ -215,6 +219,7 @@ defmodule UniversalProxy.Audio.Player do mdns_module = Keyword.get(opts, :mdns_module, MdnsLite) device_name_fun = Keyword.get(opts, :device_name_fun, &default_device_name/0) mac_address_fun = Keyword.get(opts, :mac_address_fun, &default_mac_address/0) + state_dir = Keyword.get(opts, :state_dir) || default_state_dir(config) state = %__MODULE__{ key: key, @@ -225,7 +230,8 @@ defmodule UniversalProxy.Audio.Player do pubsub: pubsub, mdns_module: mdns_module, device_name_fun: device_name_fun, - mac_address_fun: mac_address_fun + mac_address_fun: mac_address_fun, + state_dir: state_dir } if File.exists?(binary_path) do @@ -459,11 +465,15 @@ defmodule UniversalProxy.Audio.Player do # advertise — the mismatch clause below turns a silent wrong-port # advert (MA's discovery connect is one-shot; a dead-port record # orphans the player) into a loud refusal. + # + # The event also carries the Sendspin `client_id`, which the library + # derives from the keypair in `--state-dir`. It is kept on the state so + # the retry path advertises the same value. defp maybe_register_mdns( %__MODULE__{mdns_registered: false, mdns_port: port} = state, - %{event: "listening", port: port} + %{event: "listening", port: port} = event ) do - attempt_mdns_registration(state) + attempt_mdns_registration(%{state | client_id: binary_or_nil(event[:client_id])}) end defp maybe_register_mdns( @@ -539,8 +549,8 @@ defmodule UniversalProxy.Audio.Player do Map.fetch!(cfg, :alsa_device), "--name", Map.fetch!(cfg, :friendly_name), - "--client-id", - Map.fetch!(cfg, :client_id), + "--state-dir", + state.state_dir, "--mdns-port", Integer.to_string(state.mdns_port), "--initial-volume", @@ -656,12 +666,12 @@ defmodule UniversalProxy.Audio.Player do config: cfg, mdns_port: port, mdns_module: mod, - device_name_fun: device_name_fun + device_name_fun: device_name_fun, + client_id: client_id }, log_level ) do friendly_name = Map.fetch!(cfg, :friendly_name) - client_id = Map.fetch!(cfg, :client_id) # Set the mDNS *instance name* to the user-facing output name, suffixed # with the device node name (e.g. `bcm2835 Headphones @@ -686,11 +696,14 @@ defmodule UniversalProxy.Audio.Player do protocol: "sendspin", transport: "tcp", port: port, - txt_payload: [ - "path=/sendspin", - "name=#{display_name}", - "client_id=#{client_id}" - ] + # `client_id` is a non-spec extra (Music Assistant reads the id + # from client/hello, not TXT), so a `listening` event without one + # drops the entry rather than advertising a wrong id. + txt_payload: + [ + "path=/sendspin", + "name=#{display_name}" + ] ++ if(client_id, do: ["client_id=#{client_id}"], else: []) } case mod.add_mdns_service(service) do @@ -1007,6 +1020,35 @@ defmodule UniversalProxy.Audio.Player do # places the binary at `priv/sendspin_player//sendspin_player`. @target_dir to_string(Mix.target()) + defp binary_or_nil(v) when is_binary(v) and v != "", do: v + defp binary_or_nil(_), do: nil + + # One directory per output for the binary's keypair and pairing state, + # on the writable data partition on Nerves and under `_build/` on the + # host, like `Audio.Store`'s DETS file. Named by the Store's per-output + # `client_id`, which is generated once and never changes, so the + # Sendspin identity derived from the keypair is stable across respawns + # and reboots. That value is always generated hex today; anything else + # (a corrupted DETS row) is hashed so it can never escape the base dir. + defp default_state_dir(cfg) do + base = + if File.dir?("/data") do + "/data/sendspin_players" + else + Path.join([File.cwd!(), "_build", "sendspin_players"]) + end + + Path.join(base, state_dir_name(Map.fetch!(cfg, :client_id))) + end + + defp state_dir_name(id) when is_binary(id) do + if id =~ ~r/\A[0-9A-Za-z_-]{1,64}\z/ do + id + else + :crypto.hash(:sha256, id) |> Base.encode16(case: :lower) + end + end + defp default_binary_path do Path.join([ :code.priv_dir(:universal_proxy) |> List.to_string(), diff --git a/lib/universal_proxy/audio/server.ex b/lib/universal_proxy/audio/server.ex index e7bc0ca..fae2f38 100644 --- a/lib/universal_proxy/audio/server.ex +++ b/lib/universal_proxy/audio/server.ex @@ -191,7 +191,8 @@ defmodule UniversalProxy.Audio.Server do # learn that the player is already connected and streaming. # %{key => %{connection: :connected | :disconnected | :unknown, # stream: %{codec, sample_rate, bit_depth, channels} | nil, - # last_error: String.t() | nil}} + # last_error: String.t() | nil, + # sendspin_client_id: String.t() | nil}} connection_state: %{}, # Debounce flag: a `sound` uevent schedules one delayed # `:check_hotplug` and sets this; the burst of sub-device uevents a @@ -458,6 +459,28 @@ defmodule UniversalProxy.Audio.Server do {:noreply, update_connection_state(state, key, %{last_error: msg})} end + # The Sendspin `client_id` the binary derived from its keypair (not the + # Store's per-output `client_id`, which only names the state dir). It is + # stable per output, so a respawn's `shutdown` reset leaves it alone. + # + # On hotplug the player starts before `output_added` is broadcast, so a + # subscriber can see this one-shot event before it knows the card. A new + # value is therefore re-published as `%{sendspin_client_id: id}`, which + # reaches every subscriber after `output_added` (both come from this + # process) and which this server's own catch-all clause ignores. + # + # Only for a known output: a `listening` still queued when its output was + # removed falls through to the catch-all, so it cannot re-create the cache + # entry the removal dropped (which would suppress the re-publish on replug). + def handle_info({:sendspin_state, key, %{event: "listening", client_id: id}}, state) + when is_binary(id) and is_map_key(state.outputs, key) do + if get_in(state.connection_state, [key, :sendspin_client_id]) != id do + broadcast_state(key, %{sendspin_client_id: id}) + end + + {:noreply, update_connection_state(state, key, %{sendspin_client_id: id})} + end + def handle_info({:sendspin_state, key, %{event: "shutdown"}}, state) do {:noreply, update_connection_state(state, key, %{connection: :disconnected, stream: nil})} end @@ -963,7 +986,12 @@ defmodule UniversalProxy.Audio.Server do %{state | unusable_ports: MapSet.put(state.unusable_ports, port)} end - @default_live_state %{connection: :unknown, stream: nil, last_error: nil} + @default_live_state %{ + connection: :unknown, + stream: nil, + last_error: nil, + sendspin_client_id: nil + } defp update_connection_state(state, key, patch) do existing = Map.get(state.connection_state, key, @default_live_state) diff --git a/lib/universal_proxy/audio/store.ex b/lib/universal_proxy/audio/store.ex index 9d1ca2b..50ecc26 100644 --- a/lib/universal_proxy/audio/store.ex +++ b/lib/universal_proxy/audio/store.ex @@ -19,8 +19,12 @@ defmodule UniversalProxy.Audio.Store do } `client_id` is generated once per output the first time it is sighted - and persists across reboots so a paired Sendspin server keeps - recognising the player. Volume defaults to `50` and `muted` to + and persists across reboots. It is no longer the Sendspin `client_id`: + since sendspin-cpp v0.9.0 the library derives that from an X25519 + keypair it stores itself, and this value only names the per-output + directory holding that keypair (`Audio.Player`'s `--state-dir`), which + keeps the derived identity stable. The field keeps its name so existing + DETS rows need no migration. Volume defaults to `50` and `muted` to `false`. These defaults are applied at the `save_config/2` boundary so callers can pass partial maps. diff --git a/lib/universal_proxy_web/live/audio_live.ex b/lib/universal_proxy_web/live/audio_live.ex index 3bcda93..0ac4cdc 100644 --- a/lib/universal_proxy_web/live/audio_live.ex +++ b/lib/universal_proxy_web/live/audio_live.ex @@ -744,8 +744,9 @@ defmodule UniversalProxyWeb.AudioLive do {@output.card_index} Client ID - {@output.client_id} + {@output.client_id || "—"}