#include #include #include "esphome/components/pn71xx/pn71xx.h" namespace esphome::pn71xx { namespace { // Stands in for the bus: records every frame written and replays queued frames on read. class FakePN71xx : public PN71xx { public: using PN71xx::card_emu_t4t_get_response_; using PN71xx::discovered_endpoint_; using PN71xx::erase_tag_; using PN71xx::find_or_add_tag_; using PN71xx::transceive_; std::deque> to_read; std::vector> written; uint8_t write_failures{0}; protected: uint8_t verify_reset(nfc::NciMessage &rx, bool reset_config) override { return nfc::STATUS_OK; } uint8_t process_init_response(nfc::NciMessage &rx) override { return nfc::STATUS_OK; } std::span pmu_config() const override { return {}; } std::span listen_mode_routing_config() const override { return {}; } uint8_t read_nfcc(nfc::NciMessage &rx, uint16_t timeout) override { if (this->to_read.empty()) return nfc::STATUS_FAILED; rx = nfc::NciMessage(this->to_read.front()); this->to_read.pop_front(); return nfc::STATUS_OK; } uint8_t write_nfcc(nfc::NciMessage &tx) override { if (this->write_failures > 0) { this->write_failures--; return nfc::STATUS_FAILED; } const auto encoded = tx.encode(); this->written.emplace_back(encoded.begin(), encoded.end()); return nfc::STATUS_OK; } }; std::vector apdu(std::initializer_list bytes) { std::vector msg = {nfc::NCI_PKT_MT_DATA, 0x00, static_cast(bytes.size())}; msg.insert(msg.end(), bytes); return msg; } std::vector respond(FakePN71xx &nfcc, std::initializer_list bytes) { CardEmuResponse response; nfcc.card_emu_t4t_get_response_(apdu(bytes), response); return {response.begin(), response.end()}; } void select_ndef_file(FakePN71xx &nfcc) { respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04}); } std::vector bytes_of(const nfc::NciMessage &msg) { return {msg.get_message().begin(), msg.get_message().end()}; } const std::vector SW_OK = {0x90, 0x00}; const std::vector SW_NOT_FOUND = {0x6A, 0x82}; } // namespace // A timed-out read must not cause the command to be sent again (NCI forbids a second command before the response). TEST(PN71xxTransceive, ReadTimeoutDoesNotResend) { FakePN71xx nfcc; nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); nfc::NciMessage rx; EXPECT_NE(nfcc.transceive_(tx, rx), nfc::STATUS_OK); EXPECT_EQ(nfcc.written.size(), 1u); } // A notification with the same GID/OID as the response (RF_DEACTIVATE_NTF) is not mistaken for it. TEST(PN71xxTransceive, SkipsNotificationAheadOfResponse) { FakePN71xx nfcc; nfcc.to_read.push_back({0x61, 0x06, 0x02, 0x00, 0x00}); nfcc.to_read.push_back({0x41, 0x06, 0x01, 0x00}); nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); nfc::NciMessage rx; EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); EXPECT_EQ(bytes_of(rx), (std::vector{0x41, 0x06, 0x01, 0x00})); EXPECT_EQ(nfcc.written.size(), 1u); } TEST(PN71xxTransceive, NotificationAloneIsNotAResponse) { FakePN71xx nfcc; nfcc.to_read.push_back({0x61, 0x06, 0x02, 0x00, 0x00}); nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); nfc::NciMessage rx; EXPECT_NE(nfcc.transceive_(tx, rx), nfc::STATUS_OK); } // A late response to an earlier, timed-out command must not be taken as the response to this one. TEST(PN71xxTransceive, SkipsStaleResponseFromEarlierCommand) { FakePN71xx nfcc; nfcc.to_read.push_back({0x41, 0x06, 0x01, 0x00}); // RF_DEACTIVATE_RSP, arriving late nfcc.to_read.push_back({0x41, 0x03, 0x01, 0x00}); // RF_DISCOVER_RSP nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DISCOVER_OID, {0x00}); nfc::NciMessage rx; EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); EXPECT_EQ(bytes_of(rx), (std::vector{0x41, 0x03, 0x01, 0x00})); EXPECT_EQ(nfcc.written.size(), 1u); } nfc::NfcTagUid uid_of(uint8_t last) { return {0x04, 0x00, 0x00, last}; } // Erasing an entry keeps the others in order and frees the tag of the slot that is dropped. TEST(PN71xxTagCache, EraseKeepsOrderAndFreesTail) { FakePN71xx nfcc; for (uint8_t i = 0; i < 3; i++) { nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i)); } nfcc.erase_tag_(1); ASSERT_EQ(nfcc.discovered_endpoint_.size(), 2u); EXPECT_EQ(nfcc.discovered_endpoint_[0].tag->get_uid()[3], 0); EXPECT_EQ(nfcc.discovered_endpoint_[1].tag->get_uid()[3], 2); EXPECT_EQ(nfcc.discovered_endpoint_.data()[2].tag, nullptr); nfcc.erase_tag_(1); ASSERT_EQ(nfcc.discovered_endpoint_.size(), 1u); EXPECT_EQ(nfcc.discovered_endpoint_.data()[1].tag, nullptr); } // A full cache evicts the entry seen longest ago instead of refusing the new tag. TEST(PN71xxTagCache, FullCacheEvictsOldest) { FakePN71xx nfcc; for (uint8_t i = 0; i < MAX_DISCOVERED_ENDPOINTS; i++) { const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(i)); nfcc.discovered_endpoint_[loc].last_seen = 100 + i; } nfcc.discovered_endpoint_[3].last_seen = 1; // seen longest ago const size_t loc = nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99)); ASSERT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS); EXPECT_EQ(nfcc.discovered_endpoint_[loc].tag->get_uid()[3], 0x99); for (const auto &endpoint : nfcc.discovered_endpoint_) { EXPECT_NE(endpoint.tag->get_uid()[3], 3); } // a known UID is found, not added again EXPECT_EQ(nfcc.find_or_add_tag_(nfc::PROT_T2T, uid_of(0x99)), loc); EXPECT_EQ(nfcc.discovered_endpoint_.size(), MAX_DISCOVERED_ENDPOINTS); } // Bytes that do not fit the packet are dropped and the length byte stays consistent. TEST(PN71xxNciMessage, AppendStopsAtPacketSize) { nfc::NciMessage msg(nfc::NCI_PKT_MT_DATA, {0x01}); std::vector big(300, 0xAA); msg.append(big); const auto encoded = msg.encode(); EXPECT_EQ(encoded.size(), nfc::NCI_PKT_MAX_SIZE); EXPECT_EQ(msg.get_payload_size(), nfc::NCI_PKT_MAX_PAYLOAD_SIZE); } // A read that could not fit the status bytes into one packet is refused. TEST(PN71xxCardEmulation, OversizedReadIsRejected) { FakePN71xx nfcc; nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/pulse_ce"); select_ndef_file(nfcc); EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0xFE}), SW_NOT_FOUND); } // A refused write (e.g. NFCC in standby) is sent again. TEST(PN71xxTransceive, RefusedWriteIsRetried) { FakePN71xx nfcc; nfcc.write_failures = 1; nfcc.to_read.push_back({0x41, 0x06, 0x01, 0x00}); nfc::NciMessage tx(nfc::NCI_PKT_MT_CTRL_COMMAND, nfc::RF_GID, nfc::RF_DEACTIVATE_OID, {0x00}); nfc::NciMessage rx; EXPECT_EQ(nfcc.transceive_(tx, rx), nfc::STATUS_OK); EXPECT_EQ(nfcc.written.size(), 1u); } TEST(PN71xxCardEmulation, CcReadOutOfRangeIsRejected) { FakePN71xx nfcc; nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}); // the CC file is 15 bytes; reading 17 or reading far past its end must not return memory beyond it EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0x11}), SW_NOT_FOUND); respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}); EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x01, 0x00, 0x0F}), SW_NOT_FOUND); } TEST(PN71xxCardEmulation, CcReadInRange) { FakePN71xx nfcc; nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); respond(nfcc, {0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00}); respond(nfcc, {0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03}); auto response = respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0x0F}); ASSERT_EQ(response.size(), sizeof(CARD_EMU_T4T_CC) + 2); EXPECT_TRUE(std::equal(std::begin(CARD_EMU_T4T_CC), std::end(CARD_EMU_T4T_CC), response.begin())); } // Reading the NDEF file in small chunks returns NLEN followed by the message, in order. TEST(PN71xxCardEmulation, ChunkedNdefReadMatchesFile) { FakePN71xx nfcc; auto message = std::make_shared(); message->add_uri_record("https://www.home-assistant.io/tag/0123456789abcdef"); const auto encoded = message->encode(); nfcc.set_tag_emulation_message(message); select_ndef_file(nfcc); std::vector expected = {static_cast(encoded.size() >> 8), static_cast(encoded.size() & 0xFF)}; expected.insert(expected.end(), encoded.begin(), encoded.end()); std::vector file; for (size_t offset = 0; offset < expected.size(); offset += 5) { const uint8_t length = std::min(5, expected.size() - offset); auto response = respond(nfcc, {0x00, 0xB0, static_cast(offset >> 8), static_cast(offset), length}); ASSERT_EQ(response.size(), length + 2u); EXPECT_EQ(std::vector(response.end() - 2, response.end()), SW_OK); file.insert(file.end(), response.begin(), response.end() - 2); } EXPECT_EQ(file, expected); } TEST(PN71xxCardEmulation, NdefReadPastEndIsRejected) { FakePN71xx nfcc; nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); select_ndef_file(nfcc); EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00, 0x02, 0xFD}), SW_NOT_FOUND); } TEST(PN71xxCardEmulation, TruncatedApdusAreRejected) { FakePN71xx nfcc; nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); select_ndef_file(nfcc); EXPECT_EQ(respond(nfcc, {0x00, 0xB0, 0x00}), SW_NOT_FOUND); select_ndef_file(nfcc); // UPDATE BINARY claiming 16 bytes of data but carrying only 2 EXPECT_EQ(respond(nfcc, {0x00, 0xD6, 0x00, 0x00, 0x10, 0x00, 0x00}), SW_NOT_FOUND); } // A message too large for the emulated NDEF file is refused when it is set, keeping the previous one. TEST(PN71xxCardEmulation, OversizedMessageRejectedWhenSet) { FakePN71xx nfcc; nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/test", false); nfcc.set_tag_emulation_message("https://www.home-assistant.io/tag/" + std::string(300, 'x'), false); select_ndef_file(nfcc); auto response = respond(nfcc, {0x00, 0xB0, 0x00, 0x00, 0x02}); ASSERT_EQ(response.size(), 4u); EXPECT_LT((response[0] << 8) | response[1], 0xFF - 2); EXPECT_EQ(std::vector(response.end() - 2, response.end()), SW_OK); } } // namespace esphome::pn71xx