Support reader commands through local Bluetooth and USB CLI
This commit is contained in:
1 parent
dbcb400c74
commit
8c164934bc
5 files changed
+274
-4
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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++")
|
||||
|
||||
Reference in new issue
Block a user