From 8c164934bc7e2384fae714f189f39e3b517f9fa5 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 4 Oct 2026 02:16:04 -0700 Subject: [PATCH] Support reader commands through local Bluetooth and USB CLI --- examples/companion_radio/MyMesh.cpp | 10 ++ src/helpers/CompanionReader.cpp | 141 +++++++++++++++++- src/helpers/CompanionReader.h | 5 +- .../companion_reader/test_reader_lookup.cpp | 59 ++++++++ test/test_companion_reader.py | 63 +++++++- 5 files changed, 274 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index cdc676152..40e497394 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -9634,6 +9634,11 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, while (*command == ' ' || *command == '\t') command++; } +#if COMPANION_FEATURE_READER + if (sender_timestamp == 0 + && mesh::handleReaderReply(command, reply, reply_capacity)) return true; +#endif + #if defined(NRF52_POWER_MANAGEMENT) if (mesh::power::handleVoltagePolicyCommand(command, reply, reply_capacity)) return true; @@ -9935,6 +9940,11 @@ void MyMesh::checkCLIRescueCmd() { cli_command[len - 1] = 0; // replace newline with C string null terminator reply_buf[0] = 0; +#if COMPANION_FEATURE_READER + if (mesh::handleReaderCommand(cli_command, output)) { + // USB rescue is a text stream and can print the complete verse. + } else +#endif if (handleCommand(cli_command, 0, reply_buf)) { // command was handled, print reply output output.print(" "); output.print(reply_buf); output.println(); diff --git a/src/helpers/CompanionReader.cpp b/src/helpers/CompanionReader.cpp index a3afd1629..9dad41a08 100644 --- a/src/helpers/CompanionReader.cpp +++ b/src/helpers/CompanionReader.cpp @@ -14,6 +14,126 @@ bible::LookupResult readReaderVerse(bible::Reference ref, char* scratch, namespace { +constexpr size_t kReplyPageTextBytes = 100; + +bible::ParseResult parseReaderRequest(const char* command, bible::Reference& ref, + uint8_t& page) { + page = 0; // No page argument keeps text terminals' full-verse behavior. + if (!command) return bible::ParseResult::NoMatch; + bible::skipSpace(command); + if (!bible::token(command, "get")) return bible::ParseResult::NoMatch; + bible::skipSpace(command); + if (!bible::token(command, "reader")) return bible::ParseResult::NoMatch; + bible::skipSpace(command); + if (!bible::number(command, ref.chapter) || *command != ':') { + return bible::ParseResult::Invalid; + } + ++command; + if (!bible::number(command, ref.verse)) return bible::ParseResult::Invalid; + uint16_t unused; + if (!bible::verseNumber(ref, unused)) return bible::ParseResult::Invalid; + if (*command) { + if (!bible::space(*command)) return bible::ParseResult::Invalid; + bible::skipSpace(command); + if (*command && !bible::number(command, page)) return bible::ParseResult::Invalid; + bible::skipSpace(command); + } + return *command == 0 ? bible::ParseResult::Valid : bible::ParseResult::Invalid; +} + +size_t nextReplyPage(const char* text, size_t start, size_t length) { + size_t count = length - start; + if (count > kReplyPageTextBytes) { + count = kReplyPageTextBytes; + // Keep the separating space in this page, so following every page + // reconstructs the supplied verse exactly without lost or repeated words. + for (size_t n = count; n > 0; --n) { + if (text[start + n - 1] == ' ') { + count = n; + break; + } + } + } + return start + count; +} + +void formatVerseReply(bible::Reference ref, uint8_t page, const char* text, + char* reply, size_t capacity) { + char header[40]; + snprintf(header, sizeof(header), "Reader %u:%u (WEB)\n", + static_cast(ref.chapter), static_cast(ref.verse)); + const size_t length = strlen(text); + const size_t header_len = strlen(header); + if (header_len + length < capacity) { + if (page != 1) { + snprintf(reply, capacity, "ERROR: Reader page must be 1-1"); + } else { + memcpy(reply, header, header_len); + memcpy(reply + header_len, text, length + 1); + } + return; + } + + unsigned parts = 0; + size_t start = 0, end = 0; + for (size_t offset = 0; offset < length;) { + const size_t next = nextReplyPage(text, offset, length); + if (++parts == page) { + start = offset; + end = next; + } + offset = next; + } + if (page > parts) { + snprintf(reply, capacity, "ERROR: Reader page must be 1-%u", parts); + return; + } + snprintf(header, sizeof(header), "Reader %u:%u [%u/%u]\n", + static_cast(ref.chapter), static_cast(ref.verse), + static_cast(page), parts); + char next_command[48] = {}; + if (page < parts) { + snprintf(next_command, sizeof(next_command), "\nNext: get reader %u:%u %u", + static_cast(ref.chapter), static_cast(ref.verse), + static_cast(page + 1)); + } + const size_t prefix_len = strlen(header); + const size_t text_len = end - start; + const size_t suffix_len = strlen(next_command); + if (prefix_len + text_len + suffix_len >= capacity) { + snprintf(reply, capacity, "ERROR: Reader reply buffer too small"); + return; + } + memcpy(reply, header, prefix_len); + memcpy(reply + prefix_len, text + start, text_len); + memcpy(reply + prefix_len + text_len, next_command, suffix_len + 1); +} + +// Only a recognized Reader request enters this 2 KiB decode frame. Normal +// framed commands must not reserve it, including with link-time optimization. +__attribute__((noinline)) void replyVerse(bible::Reference ref, uint8_t page, + char* reply, size_t capacity) { + char scratch[bible::kBlockSize]; + const char* text = nullptr; + const bible::LookupResult result = readReaderVerse(ref, scratch, sizeof(scratch), text); + if (result == bible::LookupResult::Found) { + formatVerseReply(ref, page, text, reply, capacity); + } else if (result == bible::LookupResult::Missing) { + snprintf(reply, capacity, "This verse is not present in the supplied translation."); + } else { + snprintf(reply, capacity, "ERROR: invalid compressed Reader data"); + } +} + +__attribute__((noinline)) void printReplyPage(bible::Reference ref, uint8_t page, + Stream& output) { + char reply[160]; + replyVerse(ref, page, reply, sizeof(reply)); + output.print(" "); + output.print(reply); + output.print("\r\n"); +} + // Keep the decode buffer out of the parser's frame, including under LTO: // unrelated terminal commands must not reserve another 2 KiB of stack. __attribute__((noinline)) void printVerse(bible::Reference ref, Stream& output) { @@ -39,15 +159,32 @@ __attribute__((noinline)) void printVerse(bible::Reference ref, Stream& output) bool handleReaderCommand(const char* command, Stream& output) { bible::Reference ref = {}; - const bible::ParseResult parsed = bible::parse(command, ref); + uint8_t page; + const bible::ParseResult parsed = parseReaderRequest(command, ref, page); if (parsed == bible::ParseResult::NoMatch) return false; if (parsed == bible::ParseResult::Invalid) { - output.print(" ERROR: use get reader : (example: get reader 3:16)\r\n"); + output.print(" ERROR: use get reader : [page] (example: get reader 3:16)\r\n"); + } else if (page != 0) { + printReplyPage(ref, page, output); } else { printVerse(ref, output); } return true; } +bool handleReaderReply(const char* command, char* reply, size_t capacity) { + if (reply == nullptr || capacity == 0) return false; + bible::Reference ref = {}; + uint8_t page; + const bible::ParseResult parsed = parseReaderRequest(command, ref, page); + if (parsed == bible::ParseResult::NoMatch) return false; + if (parsed == bible::ParseResult::Invalid) { + snprintf(reply, capacity, "ERROR: use get reader : [page] (example: get reader 3:16)"); + } else { + replyVerse(ref, page == 0 ? 1 : page, reply, capacity); + } + return true; +} + } // namespace mesh #endif diff --git a/src/helpers/CompanionReader.h b/src/helpers/CompanionReader.h index ff3d9cc01..cca906129 100644 --- a/src/helpers/CompanionReader.h +++ b/src/helpers/CompanionReader.h @@ -8,7 +8,10 @@ namespace mesh { // Caller-owned scratch lets the terminal and screen share one flash corpus. bible::LookupResult readReaderVerse(bible::Reference ref, char* scratch, size_t capacity, const char*& text); -// Terminal-only: verses can exceed the framed/remote CLI's 160-byte reply. +// Text terminals stream complete verses; an explicit page matches CLI replies. bool handleReaderCommand(const char* command, Stream& output); +// Existing USB/BLE CLI frames contain one bounded reply. Short verses are +// complete; longer verses include the command for their next page. +bool handleReaderReply(const char* command, char* reply, size_t capacity); } #endif diff --git a/test/fixtures/companion_reader/test_reader_lookup.cpp b/test/fixtures/companion_reader/test_reader_lookup.cpp index 7536888f2..a2e307f68 100644 --- a/test/fixtures/companion_reader/test_reader_lookup.cpp +++ b/test/fixtures/companion_reader/test_reader_lookup.cpp @@ -43,6 +43,39 @@ int main(int argc, char**) { assert(output.text.find(expected) != std::string::npos); for (const unsigned char* c = reinterpret_cast(text); *c; ++c) assert(*c >= 32 && *c < 127); + // Real CLI replies must fit both the untagged and AA|-tagged budgets. + // Following every advertised next command must reconstruct every byte, + // including word separators, with no truncation or duplicated text. + for (const size_t capacity : {size_t(160), size_t(157)}) { + struct ReplyGuarded { uint32_t pre; char reply[160]; uint32_t post; } response; + response.pre = 0x11223344; response.post = 0xaabbccdd; + std::string reconstructed; + unsigned page = 1; + for (;;) { + memset(response.reply, '!', sizeof(response.reply)); + assert(mesh::handleReaderReply(cmd, response.reply, capacity)); + assert(response.pre == 0x11223344 && response.post == 0xaabbccdd); + assert(strlen(response.reply) < capacity); + for (size_t i = capacity; i < sizeof(response.reply); ++i) + assert(response.reply[i] == '!'); + const std::string reply = response.reply; + for (const unsigned char c : reply) assert(c == '\n' || (c >= 32 && c < 127)); + const size_t body_start = reply.find('\n'); + assert(body_start != std::string::npos && reply.find("Reader ") == 0); + const size_t next = reply.find("\nNext: "); + reconstructed += reply.substr(body_start + 1, + next == std::string::npos ? next : next - body_start - 1); + if (next == std::string::npos) break; + snprintf(cmd, sizeof(cmd), "get reader %u:%u %u", chapter, verse, ++page); + assert(reply.substr(next + strlen("\nNext: ")) == cmd); + assert(page < 32); + } + assert(reconstructed == text); + snprintf(cmd, sizeof(cmd), "get reader %u:%u %u", chapter, verse, page + 1); + assert(mesh::handleReaderReply(cmd, response.reply, capacity)); + assert(std::string(response.reply).find("ERROR: Reader page must be 1-") == 0); + snprintf(cmd, sizeof(cmd), "get reader %u:%u", chapter, verse); + } // A machine-readable dump lets Python compare every decoded byte to // the independently ASCII-normalized pinned plaintext source. if (argc > 1) std::printf("%u:%u\t%s\n", chapter, verse, text); @@ -90,5 +123,31 @@ int main(int argc, char**) { Stream invalid; assert(mesh::handleReaderCommand("get reader 3:99", invalid)); assert(invalid.text.find("ERROR") != std::string::npos); + for (const char* cmd : {"get reader", "get reader 3:16 0", "get reader 3:16 -1", + "get reader 3:16 100", "get reader 3:16 99999999999", "get reader 3:16 1 junk", + "get reader 3:16 1:2", "get reader 3:16 1\x1b", "get reader 22:1"}) { + char reply[160]; + assert(mesh::handleReaderReply(cmd, reply, sizeof(reply))); + assert(std::string(reply).find("ERROR: use get reader") == 0); + } + for (const char* cmd : {"get name", "get readerny 3:16", "forget reader 3:16", ""}) { + char reply[160] = "untouched"; + assert(!mesh::handleReaderReply(cmd, reply, sizeof(reply))); + assert(strcmp(reply, "untouched") == 0); + } + char tiny[2] = {'!', '!'}; + assert(!mesh::handleReaderReply("get reader 3:16", tiny, 0) && tiny[0] == '!'); + assert(!mesh::handleReaderReply("get reader 3:16", nullptr, 160)); + assert(mesh::handleReaderReply("get reader 21:17", tiny, 1)); + assert(tiny[0] == 0 && tiny[1] == '!'); + char reply[160]; + assert(mesh::handleReaderReply(" GET\treader 03:16 01 \t", reply, sizeof(reply))); + assert(std::string(reply).find("For God so loved the world") != std::string::npos); + assert(std::string(reply).find("eternal life.") != std::string::npos); + assert(std::string(reply).find("Next:") == std::string::npos); + assert(mesh::handleReaderReply("get reader 21:17 2", reply, sizeof(reply))); + Stream page_output; + assert(mesh::handleReaderCommand("get reader 21:17 2", page_output)); + assert(page_output.text == " " + std::string(reply) + "\r\n"); if (argc == 1) std::puts("All 879 verses, terminal output, parser and corruption checks passed"); } diff --git a/test/test_companion_reader.py b/test/test_companion_reader.py index 5cf9c2470..a07faf886 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -116,11 +116,72 @@ class CompanionReaderTest(unittest.TestCase): start = source.index("void MyMesh::handleTerminalCommand(") end = source.index("\nvoid MyMesh::enterCLIRescue()", start) terminal = source[start:end] - self.assertEqual(source.count("mesh::handleReaderCommand("), 1) + self.assertEqual(source.count("mesh::handleReaderCommand("), 2) self.assertLess(terminal.index("mesh::handleReaderCommand("), terminal.index("char local_reply[160]")) self.assertIn("get reader : (World English Bible, offline)", terminal) self.assertEqual(terminal.count("#if COMPANION_FEATURE_READER"), 2) + rescue = extract_braced(source, "void MyMesh::checkCLIRescueCmd()") + self.assertLess(rescue.index("mesh::handleReaderCommand(cli_command, output)"), + rescue.index("handleCommand(cli_command, 0, reply_buf)")) + + def test_production_bounded_reader_dispatch_preserves_tags_and_local_access(self): + cc, cxx = shutil.which("gcc"), shutil.which("g++") + if not cc or not cxx: + self.skipTest("host GCC and G++ required") + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8") + handler = extract_braced(source, "bool MyMesh::handleCommand(") + # Compile the actual dispatch prefix, including its AA| correlation + # handling and local-console gate, against the real Reader handler. + handler = handler[:handler.index("#if defined(NRF52_POWER_MANAGEMENT)")] + handler += " return false;\n}\n" + harness = r''' +#include +#include +#include +#include +#include +struct MyMesh { + bool handleCommand(const char*, uint32_t, char*); +}; +@HANDLER@ +int main() { + MyMesh mesh; + struct Guarded { char reply[160]; uint32_t after; } out{}; + out.after = 0xaabbccdd; + assert(mesh.handleCommand("AA|get reader 3:16", 0, out.reply)); + assert(std::string(out.reply).find("AA|Reader 3:16 (WEB)\n") == 0); + assert(std::string(out.reply).find("eternal life.") != std::string::npos); + assert(std::string(out.reply).find("Next:") == std::string::npos); + assert(strlen(out.reply) < sizeof(out.reply) && out.after == 0xaabbccdd); + assert(mesh.handleCommand("B2|get reader 21:17", 0, out.reply)); + assert(std::string(out.reply).find("B2|Reader 21:17 [1/") == 0); + assert(std::string(out.reply).find("Next: get reader 21:17 2") != std::string::npos); + assert(strlen(out.reply) < sizeof(out.reply) && out.after == 0xaabbccdd); + assert(mesh.handleCommand("C3|get reader 21:17 2", 0, out.reply)); + assert(std::string(out.reply).find("C3|Reader 21:17 [2/") == 0); + assert(strlen(out.reply) < sizeof(out.reply) && out.after == 0xaabbccdd); + assert(mesh.handleCommand("D4|get reader", 0, out.reply)); + assert(std::string(out.reply).find("D4|ERROR: use get reader") == 0); + assert(!mesh.handleCommand("get reader 3:16", 123, out.reply)); + assert(!mesh.handleCommand("AA|get reader 3:16", 123, out.reply)); + assert(!mesh.handleCommand("get name", 0, out.reply)); +} +'''.replace("@HANDLER@", handler) + with tempfile.TemporaryDirectory(prefix="meshcore-reader-dispatch-") as directory: + directory = Path(directory) + generated = directory / "dispatch.cpp" + generated.write_text(harness) + for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM"): + flags = [f"-D{platform}=1", "-DCOMPANION_RADIO_FULL=1", "-DENABLE_USB_INTERFACE=1"] + obj, binary = directory / "tinf.o", directory / "dispatch.exe" + self.run_checked([cc, "-std=c11", "-Os", *flags, "-c", + str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(obj)]) + self.run_checked([cxx, "-std=c++17", "-Os", "-Wall", "-Wextra", "-Werror", + *flags, "-I" + str(ROOT / "src"), "-I" + str(FIXTURE), + str(generated), str(ROOT / "src/helpers/CompanionReader.cpp"), + str(obj), "-o", str(binary)]) + self.run_checked([str(binary)]) def test_reader_navigation_and_bookmark_recovery(self): cc, cxx = shutil.which("gcc"), shutil.which("g++")