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..aa4d369 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 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 | |---|---|---|---| | [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..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 @@ -162,18 +162,24 @@ 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. + // 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; })) { - return true; + sendspin::decode_pairing_record(record->data(), record->size()).has_value()) { + ++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..b6a4997 100644 --- a/tests/state_store_test.cpp +++ b/tests/state_store_test.cpp @@ -390,6 +390,46 @@ 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); +} + +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.