Support reader commands through local Bluetooth and USB CLI

This commit is contained in:
mikecarper committed 2026-10-04 02:16:04 -07:00
1 parent dbcb400c74
commit 8c164934bc
5 files changed
+274 -4

No files matched your search

+10
View File
@@ -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();
+139 -2
View File
@@ -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<unsigned>(ref.chapter), static_cast<unsigned>(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<unsigned>(ref.chapter), static_cast<unsigned>(ref.verse),
static_cast<unsigned>(page), parts);
char next_command[48] = {};
if (page < parts) {
snprintf(next_command, sizeof(next_command), "\nNext: get reader %u:%u %u",
static_cast<unsigned>(ref.chapter), static_cast<unsigned>(ref.verse),
static_cast<unsigned>(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 <chapter>:<verse> (example: get reader 3:16)\r\n");
output.print(" ERROR: use get reader <chapter>:<verse> [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 <chapter>:<verse> [page] (example: get reader 3:16)");
} else {
replyVerse(ref, page == 0 ? 1 : page, reply, capacity);
}
return true;
}
} // namespace mesh
#endif
+4 -1
View File
@@ -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
+59
View File
@@ -43,6 +43,39 @@ int main(int argc, char**) {
assert(output.text.find(expected) != std::string::npos);
for (const unsigned char* c = reinterpret_cast<const unsigned char*>(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");
}
+62 -1
View File
@@ -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 <chapter>:<verse> (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 <Arduino.h>
#include <helpers/CompanionReader.h>
#include <cassert>
#include <cstring>
#include <string>
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++")