From 4cc28a2b45c17f6816782ae04e24ee8655175c7f Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 28 Oct 2026 08:21:01 -0500 Subject: [PATCH 1/2] Report how many servers are paired in status status's trust line describes only the server connected right now, so a script could not tell a player that has never been paired from one whose paired server is simply not connected yet. The installers need exactly that to offer pairing on a fresh install without nagging on an upgrade. 'paired servers: N' counts the stored pairing records, connected or not. --- docs/wiki/Controlling-the-Player.md | 1 + docs/wiki/Pairing-a-Player.md | 6 +++++- src/control.h | 1 + src/control_common.cpp | 1 + src/main.cpp | 10 +++++++--- src/state_store.cpp | 11 ++++++++--- src/state_store.h | 3 +++ tests/control_test.cpp | 10 ++++++++++ tests/state_store_test.cpp | 23 +++++++++++++++++++++++ 9 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/wiki/Controlling-the-Player.md b/docs/wiki/Controlling-the-Player.md index 1bd1cc3..a3b8089 100644 --- a/docs/wiki/Controlling-the-Player.md +++ b/docs/wiki/Controlling-the-Player.md @@ -10,6 +10,7 @@ name: living-room client id: fLNgT-h015The-sALW0IVza7J0FThgDU1leu1MLDww4 server: Music Assistant (connected) trust: paired +paired servers: 1 pairing: none state: playing stream: receiving diff --git a/docs/wiki/Pairing-a-Player.md b/docs/wiki/Pairing-a-Player.md index 9513e8b..ec074e9 100644 --- a/docs/wiki/Pairing-a-Player.md +++ b/docs/wiki/Pairing-a-Player.md @@ -9,11 +9,15 @@ There are three ways to pair. All of them work from a terminal on the player's o and all of them end the same way: ```console -$ sendspin-cli status | grep -E '^(trust|pairing)' +$ sendspin-cli status | grep -E '^(trust|paired|pairing)' trust: paired +paired servers: 1 pairing: none ``` +`trust` is about the server connected right now; `paired servers` counts every server this +player has paired with, connected or not. + | Method | You carry to the server | Player setup | Use it when | |---|---|---|---| | [Pairing token](#1-pairing-token) | a long `SP:…` string, pasted | none | you can copy and paste between the player and the server | diff --git a/src/control.h b/src/control.h index 31cd2ce..e391a40 100644 --- a/src/control.h +++ b/src/control.h @@ -173,6 +173,7 @@ struct StatusSnapshot { /// The connection's trust; absent before its handshake reports one. std::optional trust{}; + size_t paired_servers{0}; ///< servers with a stored pairing, connected or not std::string pairing_server_id; ///< the server a pairing is in progress with, else empty std::string pairing_code; ///< the dynamic code on show, else empty diff --git a/src/control_common.cpp b/src/control_common.cpp index 1a77d98..251dac0 100644 --- a/src/control_common.cpp +++ b/src/control_common.cpp @@ -550,6 +550,7 @@ std::string format_status(const StatusSnapshot& snapshot) { append_line(out, "trust", *snapshot.trust == sendspin::ConnectionTrust::USER ? "paired" : "unpaired"); } + append_line(out, "paired servers", std::to_string(snapshot.paired_servers)); append_line(out, "pairing", snapshot.pairing_server_id.empty() diff --git a/src/main.cpp b/src/main.cpp index b05b75e..9293e1f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -404,7 +404,8 @@ class ControlDispatcher final : public ControlHandler { const MetadataLogger& metadata_logger, const PairingListener& pairing_listener, const PlayerListener& player_listener, sendspin::PlayerRole& player, - const AudioSink& sink, const SourceCapture* capture) + const AudioSink& sink, const SourceCapture* capture, + const StateStore& state_store) : opts_(opts), client_(client), controller_(controller), @@ -414,7 +415,8 @@ class ControlDispatcher final : public ControlHandler { player_listener_(player_listener), player_(player), sink_(sink), - capture_(capture) {} + capture_(capture), + state_store_(state_store) {} std::string handle_control_request(const std::string& line) override { std::string name; @@ -495,6 +497,7 @@ class ControlDispatcher final : public ControlHandler { snapshot.client_id = this->client_.client_id(); snapshot.connected = this->client_.is_connected(); snapshot.trust = this->pairing_listener_.trust(); + snapshot.paired_servers = this->state_store_.pairing_record_count(); // A dropped connection ends its attempt without a callback. if (snapshot.connected) { snapshot.pairing_server_id = this->pairing_listener_.server_id(); @@ -560,6 +563,7 @@ class ControlDispatcher final : public ControlHandler { sendspin::PlayerRole& player_; const AudioSink& sink_; const SourceCapture* capture_; ///< null without --input + const StateStore& state_store_; }; /// Binds the control socket, or logs why this run has none. @@ -982,7 +986,7 @@ int main(int argc, char* argv[]) { ControlDispatcher control_dispatcher(opts, client, controller, metadata, metadata_logger, pairing_listener, player_listener, player, *sink, - capture.get()); + capture.get(), state_store); std::unique_ptr outbound; if (opts.was_given(Opt::Server)) { diff --git a/src/state_store.cpp b/src/state_store.cpp index aaa56d1..899a066 100644 --- a/src/state_store.cpp +++ b/src/state_store.cpp @@ -162,18 +162,23 @@ bool StateStore::set_blob(const std::string& key, const uint8_t* data, size_t le return this->set_all({{KEY_BLOB_PREFIX + key, to_hex(data, len)}}); } -bool StateStore::has_pairing_record() const { +size_t StateStore::pairing_record_count() const { const size_t slots = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS; + size_t count = 0; for (size_t slot = 0; slot < slots; ++slot) { const std::optional> record = this->blob(sendspin::persistence_keys::record_slot_key(slot)); // A freed slot is kept as zeroes. if (record.has_value() && std::any_of(record->begin(), record->end(), [](uint8_t byte) { return byte != 0; })) { - return true; + ++count; } } - return false; + return count; +} + +bool StateStore::has_pairing_record() const { + return this->pairing_record_count() > 0; } std::optional StateStore::volume() const { diff --git a/src/state_store.h b/src/state_store.h index 5d76fa3..92ad286 100644 --- a/src/state_store.h +++ b/src/state_store.h @@ -61,6 +61,9 @@ class StateStore { std::optional> blob(const std::string& key) const; bool set_blob(const std::string& key, const uint8_t* data, size_t len); + /// How many servers the library has a pairing record stored for. + size_t pairing_record_count() const; + /// True if the library has a pairing record stored for any server. bool has_pairing_record() const; diff --git a/tests/control_test.cpp b/tests/control_test.cpp index 9271662..ddef18b 100644 --- a/tests/control_test.cpp +++ b/tests/control_test.cpp @@ -799,6 +799,16 @@ TEST(FormatStatus, TrustFollowsTheConnection) { EXPECT_EQ(field(format_status(snapshot), "trust"), "not connected"); } +TEST(FormatStatus, PairedServersAreReportedEvenWhileDisconnected) { + StatusSnapshot snapshot; + EXPECT_EQ(field(format_status(snapshot), "paired servers"), "0"); + + // What an installer reads to tell "never paired" from "paired, server not here yet". + snapshot.paired_servers = 2; + EXPECT_EQ(field(format_status(snapshot), "trust"), "not connected"); + EXPECT_EQ(field(format_status(snapshot), "paired servers"), "2"); +} + TEST(FormatStatus, AnIdlePlayerReportsNoPairing) { const std::string block = format_status(playing_snapshot()); EXPECT_EQ(field(block, "pairing"), "none"); diff --git a/tests/state_store_test.cpp b/tests/state_store_test.cpp index a2d5170..a2de4e9 100644 --- a/tests/state_store_test.cpp +++ b/tests/state_store_test.cpp @@ -390,6 +390,29 @@ TEST(StateStore, APairingRecordIsAnOccupiedSlotNotAFreedOne) { EXPECT_TRUE(loaded(scratch.file()).has_pairing_record()); } +TEST(StateStore, CountsEachOccupiedPairingSlot) { + const ScratchDir scratch; + ASSERT_TRUE(scratch.created()); + StateStore store(scratch.file()); + EXPECT_EQ(store.pairing_record_count(), 0U); + + const std::vector record(sendspin::persistence_keys::RECORD_SLOT_SIZE, 0x5a); + const std::vector freed(sendspin::persistence_keys::RECORD_SLOT_SIZE, 0); + ASSERT_TRUE(store.set_blob(sendspin::persistence_keys::record_slot_key(0), record.data(), + record.size())); + ASSERT_TRUE( + store.set_blob(sendspin::persistence_keys::record_slot_key(1), freed.data(), freed.size())); + ASSERT_TRUE(store.set_blob(sendspin::persistence_keys::record_slot_key(2), record.data(), + record.size())); + EXPECT_EQ(store.pairing_record_count(), 2U); + EXPECT_EQ(loaded(scratch.file()).pairing_record_count(), 2U); + + // Unpairing zeroes the slot, which no longer counts. + ASSERT_TRUE( + store.set_blob(sendspin::persistence_keys::record_slot_key(0), freed.data(), freed.size())); + EXPECT_EQ(store.pairing_record_count(), 1U); +} + // Upgrading a pre-rc1 state file /// The output-delay blob as milliseconds, or nothing when absent or the wrong size. From 9ddc4dc9c5829667ad59324abad089af550564bb Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 28 Oct 2026 13:14:11 -0500 Subject: [PATCH 2/2] Count only the pairing records the library would load A slot of the wrong size, or with an all-zero PSK, was counted as a pairing because it held a non-zero byte. Ask decode_pairing_record(), the library's own rule, instead. --- docs/wiki/Pairing-a-Player.md | 4 ++-- src/state_store.cpp | 7 ++++--- tests/state_store_test.cpp | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/wiki/Pairing-a-Player.md b/docs/wiki/Pairing-a-Player.md index ec074e9..aa4d369 100644 --- a/docs/wiki/Pairing-a-Player.md +++ b/docs/wiki/Pairing-a-Player.md @@ -15,8 +15,8 @@ paired servers: 1 pairing: none ``` -`trust` is about the server connected right now; `paired servers` counts every server this -player has paired with, connected or not. +`trust` is about the server connected right now; `paired servers` counts the servers this +player holds a pairing for, connected or not. An unpaired server drops out of it. | Method | You carry to the server | Player setup | Use it when | |---|---|---|---| diff --git a/src/state_store.cpp b/src/state_store.cpp index 899a066..d636e3d 100644 --- a/src/state_store.cpp +++ b/src/state_store.cpp @@ -16,6 +16,7 @@ #include "key_value_file.h" #include "sendspin/config.h" +#include "sendspin/persistence_codec.h" #include "sendspin/persistence_keys.h" #ifdef _WIN32 @@ -31,7 +32,6 @@ #include #endif -#include #include #include #include @@ -168,9 +168,10 @@ size_t StateStore::pairing_record_count() const { for (size_t slot = 0; slot < slots; ++slot) { const std::optional> record = this->blob(sendspin::persistence_keys::record_slot_key(slot)); - // A freed slot is kept as zeroes. + // Counted as the library loads them: a freed slot is kept as zeroes, which it rejects + // along with any other blob it could not use. if (record.has_value() && - std::any_of(record->begin(), record->end(), [](uint8_t byte) { return byte != 0; })) { + sendspin::decode_pairing_record(record->data(), record->size()).has_value()) { ++count; } } diff --git a/tests/state_store_test.cpp b/tests/state_store_test.cpp index a2de4e9..b6a4997 100644 --- a/tests/state_store_test.cpp +++ b/tests/state_store_test.cpp @@ -413,6 +413,23 @@ TEST(StateStore, CountsEachOccupiedPairingSlot) { EXPECT_EQ(store.pairing_record_count(), 1U); } +TEST(StateStore, DoesNotCountASlotTheLibraryWouldNotLoad) { + const ScratchDir scratch; + ASSERT_TRUE(scratch.created()); + StateStore store(scratch.file()); + + // Too short to be a record, and a record whose PSK half is all zeroes. + const std::vector short_blob(sendspin::persistence_keys::RECORD_SLOT_SIZE / 2, 0x5a); + std::vector zero_psk(sendspin::persistence_keys::PAIRING_PSK_SIZE, 0); + zero_psk.resize(sendspin::persistence_keys::RECORD_SLOT_SIZE, 0x5a); + ASSERT_TRUE(store.set_blob(sendspin::persistence_keys::record_slot_key(0), short_blob.data(), + short_blob.size())); + ASSERT_TRUE(store.set_blob(sendspin::persistence_keys::record_slot_key(1), zero_psk.data(), + zero_psk.size())); + EXPECT_EQ(store.pairing_record_count(), 0U); + EXPECT_FALSE(store.has_pairing_record()); +} + // Upgrading a pre-rc1 state file /// The output-delay blob as milliseconds, or nothing when absent or the wrong size.