Fit direct OTA administration into RAK4631 Full

This commit is contained in:
mikecarper committed 2026-10-26 23:41:20 -07:00
1 parent 9d2f7a3e27
commit 7d413a6806
21 files changed
+1926 -61

No files matched your search

+1
View File
@@ -1142,6 +1142,7 @@ jobs:
- name: Verify nRF52 infrastructure trims and unchanged Companion scope
run: |
python3 -B test/test_nrf52_flash_trim.py -v
python3 -B test/test_nrf52_gnss_version_trim.py -v
python3 -B test/test_nrf52_program_lifetime.py -v
python3 -B test/test_nrf52_tft_font_trim.py -v
python3 -B test/test_ota_sd_cache.py -v
-4
View File
@@ -35,10 +35,6 @@ saved contact routes. Some Full Companion boards borrow queue space for OTA and
retain up to 128 unread messages during an update. If more are waiting, sync them
with an app first; the wizard refuses to discard them to make room.
For this wizard, use an ESP32 source instead of RAK4631 Full. The RAK image's
current flash limit prevents adding the new direct admin helper. RAK4631
Repeaters and Room Servers remain supported destinations.
The source and destination must already use the same main-channel settings
and hear each other directly. The destination needs working LoRa OTA support
and its admin password. A stock nRF52 bootloader may need a matching local
+2 -7
View File
@@ -74,14 +74,9 @@
#ifndef COMPANION_FEATURE_OTA_CLI
#define COMPANION_FEATURE_OTA_CLI 0
#endif
// Keep RAK4631 Full's existing feature set inside its application boundary.
// Other Full boards expose the new USB OTA admin helper by default.
// Full builds expose the USB OTA admin helper with their existing OTA CLI.
#ifndef COMPANION_FEATURE_DIRECT_OTA_ADMIN
#if defined(NRF52_PLATFORM) && defined(RAK_4631)
#define COMPANION_FEATURE_DIRECT_OTA_ADMIN 0
#else
#define COMPANION_FEATURE_DIRECT_OTA_ADMIN COMPANION_FEATURE_OTA_CLI
#endif
#define COMPANION_FEATURE_DIRECT_OTA_ADMIN COMPANION_FEATURE_OTA_CLI
#endif
#ifndef COMPANION_FEATURE_NETWORK_TERMINAL
#define COMPANION_FEATURE_NETWORK_TERMINAL 0
+3 -10
View File
@@ -6312,10 +6312,7 @@ __attribute__((noinline, cold)) bool MyMesh::handleDirectOtaFrame(size_t len) {
memcpy(peer.pub_key, key, PUB_KEY_SIZE);
if (!_ota_direct_admin.matches(peer, _ms->getMillis())) {
closeDirectOtaAdmin();
ContactInfo* contact = lookupContactByPubKey(key, PUB_KEY_SIZE);
if (contact) {
_ota_direct_contact = *contact;
} else {
if (!lookupContactByPubKey(key, PUB_KEY_SIZE)) {
// Empty anonymous slots are RAM-only. Never evict another request,
// add an accepted contact, or schedule a contact-store write.
for (unsigned i = 0; i < MAX_ANON_CONTACTS; ++i) {
@@ -6333,14 +6330,10 @@ __attribute__((noinline, cold)) bool MyMesh::handleDirectOtaFrame(size_t len) {
resetContactValue(*_ota_direct_transient);
_ota_direct_transient->id = peer;
_ota_direct_transient->out_path_len = 0;
_ota_direct_contact.id = peer;
}
if (!_ota_direct_contact.setPath(nullptr, 0)) {
closeDirectOtaAdmin();
writeErrFrame(ERR_CODE_BAD_STATE);
return true;
}
_ota_direct_contact.id = peer;
_ota_direct_contact.type = type;
_ota_direct_contact.out_path_len = 0;
_ota_direct_contact.sync_since = 0;
_ota_direct_contact.tx_radio = mesh::RADIO_TX_PRIMARY;
}
+5
View File
@@ -247,6 +247,11 @@ def install(build_env):
print("nRF52 infrastructure flash trim: compact crypto, unused USB device classes/GPS cache/vendor splash, compact OTA target names")
else:
print("nRF52 Companion flash trim: small crypto table, unused USB device classes/GPS cache/vendor splash; fonts and Reader retained")
hook_path = build_env.subst("$PROJECT_DIR/scripts/nrf52_gnss_version_trim.py")
hook_spec = importlib.util.spec_from_file_location("mesh_nrf52_gnss_trim", hook_path)
hook = importlib.util.module_from_spec(hook_spec)
hook_spec.loader.exec_module(hook)
hook.install(build_env)
framework = platform.get_package_dir("framework-arduinoadafruitnrf52")
values = flag_defines(flags, cppdefines)
if "MESH_NRF52_NO_PROGRAM_EXIT" not in values and supported_program_lifetime(framework):
+79
View File
@@ -0,0 +1,79 @@
"""Keep the reviewed GNSS version parser without linking the scanf engine.
Only the qualified nRF52 Companion trim installs this middleware. Compile a
private copy of the reviewed SparkFun GNSS sources; never change downloaded dependencies.
Unknown sources or headers retain their ordinary implementation.
"""
import hashlib
from pathlib import Path
SOURCE_NAME = "SparkFun_u-blox_GNSS_Arduino_Library.cpp"
SOURCE_SHA256 = "8ed1d313177df864b6821b3cb3e4fbcc59d850d09e2460269d571f70cd2b9f1c"
HEADERS = {
"SparkFun_u-blox_GNSS_Arduino_Library.h": "5da7a95ddd4b24e26ca474d017a6a5347137a31aee58fb7b450a4ce85bbcc07a",
"u-blox_config_keys.h": "86cae35ec200871f748be21d6d59583c48db82621f4c5504e257e8358a241499",
"u-blox_structs.h": "59c0a7db1e3fe8eb78692a3ef9411f12048573a6513c41937e51e9d5ede06498",
}
ORIGINAL_CONVERSION = ''' int scanned = sscanf(ptr, "%d.%d", &protHi, &protLo);'''
VERSION_CONVERSION = ''' // Preserve %d.%d conversion, including signs and leading whitespace.
// strtol is already used by the application; scanf needs a separate engine.
char *versionEnd;
protHi = static_cast<int>(strtol(ptr, &versionEnd, 10));
int scanned = 0;
if (versionEnd != ptr && *versionEnd == '.')
{
const char *minor = versionEnd + 1;
protLo = static_cast<int>(strtol(minor, &versionEnd, 10));
if (versionEnd != minor)
scanned = 2;
}'''
INCLUDE = '#include "SparkFun_u-blox_GNSS_Arduino_Library.h"'
def digest(source):
return hashlib.sha256(source.encode("utf-8")).hexdigest()
def patched_source(source):
source = source.replace("\r\n", "\n")
if (digest(source) != SOURCE_SHA256 or source.count(ORIGINAL_CONVERSION) != 1
or source.count(INCLUDE) != 1):
raise RuntimeError("changed reviewed SparkFun GNSS source")
return source.replace(INCLUDE, "#include <stdlib.h>\n" + INCLUDE, 1).replace(
ORIGINAL_CONVERSION, VERSION_CONVERSION, 1)
def write_if_changed(path, content):
if not path.exists() or path.read_text(encoding="utf-8") != content:
path.write_text(content, encoding="utf-8")
def replace_gnss_source(build_env, node):
source = Path(node.srcnode().get_abspath())
if source.name != SOURCE_NAME or source.parent.name != "src":
return node
try:
patched = patched_source(source.read_text(encoding="utf-8"))
headers = {}
for name, expected in HEADERS.items():
header = (source.parent / name).read_text(encoding="utf-8").replace("\r\n", "\n")
if digest(header) != expected:
raise RuntimeError("changed reviewed SparkFun GNSS header: " + name)
headers[name] = header
except (OSError, UnicodeError, RuntimeError) as error:
print("nRF52 Companion GNSS trim: ordinary source retained (" + str(error) + ")")
return node
destination = Path(build_env.subst("$BUILD_DIR")) / "trimmed-nrf52-gnss" / source.name
destination.parent.mkdir(parents=True, exist_ok=True)
# Keep its quoted sibling includes bound to the same reviewed library.
for name, header in headers.items():
write_if_changed(destination.parent / name, header)
write_if_changed(destination, patched)
print("nRF52 Companion GNSS trim: reviewed version conversion uses strtol")
return build_env.File(str(destination))
def install(build_env):
build_env.AddBuildMiddleware(replace_gnss_source, "*" + SOURCE_NAME)
+3 -1
View File
@@ -4,7 +4,9 @@
#include <Arduino.h>
#include "bible/ReaderLookup.h"
#if defined(NRF52_PLATFORM)
#if defined(NRF52_PLATFORM) && COMPANION_READER_COMPACT_STREAM
#include "bible/ReaderCompactStreamData.generated.h"
#elif defined(NRF52_PLATFORM)
#include "bible/ReaderStreamData.generated.h"
#else
#include "bible/ReaderData.generated.h"
+4
View File
@@ -9,6 +9,10 @@
#endif
#endif
#ifndef COMPANION_READER_COMPACT_STREAM
#define COMPANION_READER_COMPACT_STREAM 0
#endif
#if COMPANION_FEATURE_READER \
&& !(defined(COMPANION_RADIO_FULL) && defined(ENABLE_USB_INTERFACE) \
&& (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)))
File diff suppressed because it is too large. Load diff
+25 -4
View File
@@ -19,6 +19,27 @@
namespace mesh {
namespace ota {
// Read complete whitespace-delimited words, never truncated scanf fields.
// Three also denotes an overlong word, so callers reject the whole command.
static __attribute__((noinline)) int parse_two_words(const char* text,
char* first, size_t first_capacity, char* second, size_t second_capacity) {
int count = 0;
for (;;) {
while (*text == ' ' || (*text >= '\t' && *text <= '\r')) ++text;
if (!*text) return count;
if (count == 2) return 3;
char* word = count ? second : first;
const size_t capacity = count ? second_capacity : first_capacity;
size_t length = 0;
do {
if (length + 1 >= capacity) return 3;
word[length++] = *text++;
} while (*text && *text != ' ' && (*text < '\t' || *text > '\r'));
word[length] = 0;
++count;
}
}
static bool parse_page(const char* s, uint16_t& page) {
while (*s == ' ') s++;
if (*s == 0) { page = 1; return true; }
@@ -573,8 +594,8 @@ static __attribute__((noinline)) bool handle_control_command(
if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; }
// Parse exact destination/options. Prefix matches used to accept typos such as "flashgarbage" and an
// ignored third token, which is especially unsafe for the explicit no-EndF rescue acknowledgement.
char destination[8] = {0}, option[10] = {0}, extra[2] = {0};
int parts = sscanf(dst, "%7s %9s %1s", destination, option, extra);
char destination[8] = {0}, option[10] = {0};
int parts = parse_two_words(dst, destination, sizeof(destination), option, sizeof(option));
if (parts < 1 || parts > 2) {
snprintf(reply, 160, "ERR %s", pull_usage);
return true;
@@ -1093,8 +1114,8 @@ static __attribute__((noinline)) bool handle_control_command(
if (!is_cmd(rest, "install", &confirm)) {
strcpy(reply, "ERR usage: ota bootloader install <MID8> <HASH16>"); return true;
}
char midtxt[9] = {0}, hashtxt[17] = {0}, extra[2] = {0};
if (sscanf(confirm, "%8s %16s %1s", midtxt, hashtxt, extra) != 2) {
char midtxt[9] = {0}, hashtxt[17] = {0};
if (parse_two_words(confirm, midtxt, sizeof(midtxt), hashtxt, sizeof(hashtxt)) != 2) {
strcpy(reply, "ERR usage: ota bootloader install <MID8> <HASH16>"); return true;
}
uint8_t operator_mid[4], operator_hash8[8];
+29
View File
@@ -319,7 +319,36 @@ static void established_capture_still_refuses_replacement_file() {
assert(f.host.begins == 0 && f.flight.manifests == 0);
}
static void pull_parser_accepts_only_complete_destination_words() {
for (const char* text : {"pull 1 folder", "pull 1 folder ",
"pull 1 \tfolder\v\fvalidate\r\n", "pull 1 folder validate"}) {
Fixture f;
char reply[160] = {};
assert(pull_command(text, reply, f.context));
assert(strncmp(reply, "OK ", 3) == 0);
}
// Reaching the seeder policy proves both flash syntaxes parsed successfully.
for (const char* text : {"pull 1 flash", "pull 1 \tflash\v\frescue\r\n"}) {
Fixture f;
char reply[160] = {};
assert(pull_command(text, reply, f.context));
assert(strcmp(reply, "ERR seeder-only build cannot stage or install firmware; use `folder`") == 0);
}
for (const char* text : {"pull 1 \t\r\n", "pull 1 flashgarbage",
"pull 1 folders", "pull 1 folderss", "pull 1 folder validates",
"pull 1 folder validatess", "pull 1 folder validate extra",
"pull 1 flash rescue extra", "pull 1 folder rescue", "pull 1 flash validate",
"pull 1 foldervalidate"}) {
Fixture f;
char reply[160] = {};
assert(pull_command(text, reply, f.context));
assert(strncmp(reply, "ERR usage:", 10) == 0);
assert(f.host.begins == 0 && f.flight.manifests == 0);
}
}
int main() {
pull_parser_accepts_only_complete_destination_words();
for (auto failure : {HostStream::Timeout, HostStream::Truncated, HostStream::BadChecksum,
HostStream::Error, HostStream::ShortWrite, HostStream::WrongOp}) {
for (bool retry_by_pull : {false, true}) {
+24 -1
View File
@@ -157,6 +157,28 @@ int main() {
expect("bootloader install 12345678 0123456789ABCDEF extra",
"ERR usage: ota bootloader install <MID8> <HASH16>", c);
assert(c.staged_installs == 1);
for (const char* text : {
"bootloader install 12345678 0123456789ABCDEF ",
"bootloader install \t12345678\v\f0123456789ABCDEF\r\n"}) {
expect(text, "OK | staged install", c);
}
assert(c.staged_installs == 3);
for (const char* text : {
"bootloader install \t\n", "bootloader install 12345678",
"bootloader install 123456789 0123456789ABCDEF",
"bootloader install 12345678 0123456789ABCDEFF",
"bootloader install 123456780123456789ABCDEF",
"bootloader install 12345678 0123456789ABCDEF \t extra"}) {
expect(text, "ERR usage: ota bootloader install <MID8> <HASH16>", c);
}
for (const char* text : {
"bootloader install 1234567 0123456789ABCDEF",
"bootloader install 12345678 0123456789ABCDE",
"bootloader install 1234567Z 0123456789ABCDEF",
"bootloader install 12345678 0123456789ABCDEZ"}) {
expect(text, "ERR MID must be 8 hex and HASH must be 16 hex characters", c);
}
assert(c.staged_installs == 3);
#else
expect("bootloader install 12345678 0123456789ABCDEF",
"ERR this build cannot update its bootloader over LoRa", c);
@@ -171,7 +193,8 @@ def harness():
context = (ROOT / "src/helpers/ota/OtaContext.h").read_text(encoding="utf-8")
return (SOURCE.replace("@IDLE@", body(context, "bool embeddedBootloaderIdle() const"))
.replace("@HELPERS@", "\n".join(body(cli, signature) for signature in (
"static bool is_cmd(", "static bool parse_hex_exact(")))
"static bool is_cmd(", "static bool parse_hex_exact(",
"static __attribute__((noinline)) int parse_two_words(")))
.replace("@COMMAND@", body(cli, 'if (is_cmd(a, "bootloader|blupdate", &rest))')))
+47 -10
View File
@@ -25,19 +25,23 @@ HARNESS = r'''
#define TXT_TYPE_CLI_DATA 1
struct ContactInfo {
mesh::Identity id;
uint8_t type=0, out_path_len=0, tx_radio=0;
uint8_t type=0, out_path_len=0, tx_radio=0, flags=0;
char name[32]{};
uint32_t sync_since=0;
uint8_t out_path[64]{},secret[32]{};
const uint8_t* getSharedSecret(const mesh::LocalIdentity&) {memset(secret,0x44,32);return secret;}
uint8_t out_path[64]{};
mutable uint8_t secret[32]{};
const uint8_t* getSharedSecret(const mesh::LocalIdentity&) const {memset(secret,0x44,32);return secret;}
bool setPath(const uint8_t*,uint8_t length){out_path_len=length;memset(out_path,0,sizeof(out_path));return true;}
};
struct BaseSerialInterface {BaseSerialInterface* route=this;BaseSerialInterface* captureReplyRoute(){return route;}};
struct Timer { uint32_t now=100;uint32_t getMillis()const{return now;} };
struct BaseChatMesh:mesh::Mesh {
ContactInfo contact,anonymous[MAX_ANON_CONTACTS];
int data_calls=0,path_calls=0,login_calls=0,clear_calls=0;
BaseChatMesh(){memset(contact.id.pub_key,0x11,32);contact.type=2;contact.out_path_len=2;contact.out_path[0]=0xAA;}
int data_calls=0,path_calls=0,login_calls=0,clear_calls=0,lookup_length=0;
BaseChatMesh(){memset(contact.id.pub_key,0x11,32);contact.type=2;contact.out_path_len=2;contact.out_path[0]=0xAA;
contact.flags=31;contact.sync_since=987;contact.tx_radio=2;strcpy(contact.name,"Saved contact");}
ContactInfo* lookupContactByPubKey(const uint8_t* key,int n){
lookup_length=n;
for(auto& p:anonymous) if(!memcmp(key,p.id.pub_key,n)) return &p;
return !memcmp(key,contact.id.pub_key,n)?&contact:nullptr;
}
@@ -45,7 +49,13 @@ struct BaseChatMesh:mesh::Mesh {
bool isTransientContact(const ContactInfo& p){for(auto& a:anonymous)if(&a==&p)return true;return false;}
void resetContactValue(ContactInfo& c){c=ContactInfo();}
bool clearTransientContact(ContactInfo& c){assert(isTransientContact(c));resetContactValue(c);++clear_calls;return true;}
uint8_t getContactTxRadio(const mesh::Identity&)const{return 2;}
virtual uint8_t getContactTxRadio(const mesh::Identity&)const{return 2;}
int sendLogin(const ContactInfo&,const char*,uint32_t&);
int sendContactPacket(const ContactInfo& peer,mesh::Packet* packet,uint32_t& timeout){
assert(peer.out_path_len==0);timeout=0;
packet->tx_radio=getContactTxRadio(peer.id);
return sendDirect(packet,nullptr,peer.out_path_len)?1:0;
}
void onPeerDataRecv(mesh::Packet*,uint8_t,int,const uint8_t*,uint8_t*,size_t){++data_calls;}
bool onPeerPathRecv(mesh::Packet*,int,const uint8_t*,uint8_t*,uint8_t,uint8_t,uint8_t*,uint8_t){++path_calls;return true;}
void onContactResponse(const ContactInfo&,const uint8_t*,uint8_t){++login_calls;}
@@ -71,9 +81,9 @@ struct MyMesh:BaseChatMesh {
bool handleDirectOtaFrame(size_t len);
bool handleDirectOtaCommand(const char*,uint32_t,char*,size_t);
void closeDirectOtaAdmin();
void mode(int value,const mesh::Identity* peer=nullptr){
void mode(int value,const mesh::Identity* peer=nullptr,uint8_t type=ADV_TYPE_REPEATER){
cmd_frame[0]=52;cmd_frame[1]=value;memcpy(cmd_frame+2,(peer?peer->pub_key:contact.id.pub_key),32);
cmd_frame[34]=2;dispatch(value==1?35:34);
cmd_frame[34]=type;dispatch(value==1?35:34);
}
bool command(const char* command,uint32_t stamp,char* reply){
#if FULL
@@ -84,7 +94,11 @@ struct MyMesh:BaseChatMesh {
}
void expect(const char* prefix){cmd_frame[0]=52;cmd_frame[1]=4;memcpy(cmd_frame+2,contact.id.pub_key,32);memcpy(cmd_frame+34,prefix,3);dispatch(37);assert(response==0);}
void onPeerDataRecv(mesh::Packet*,uint8_t,int,const uint8_t*,uint8_t*,size_t);
#if FULL
uint8_t getContactTxRadio(const mesh::Identity&)const;
#else
uint8_t getContactTxRadio(const mesh::Identity& peer)const override{return BaseChatMesh::getContactTxRadio(peer);}
#endif
bool onPeerPathRecv(mesh::Packet*,int,const uint8_t*,uint8_t*,uint8_t,uint8_t,uint8_t*,uint8_t);
void rawRoute(mesh::Packet* pkt){
#if FULL
@@ -96,6 +110,7 @@ struct MyMesh:BaseChatMesh {
};
@CALLBACKS@
@BINARY@
@SEND_LOGIN@
static void repeaterPrefix(char*& command,char*& reply){@REPEATER_PREFIX@}
static void roomPrefix(char*& command,char*& reply){@ROOM_PREFIX@}
[[maybe_unused]] static void body(uint8_t* data,uint32_t stamp,const char* prefix,const char* text){
@@ -119,7 +134,12 @@ int main(){
#if FULL
assert(m.response==0&&m.calls==0); // capability negotiation has no RF
assert(m.contact.out_path_len==2&&m.contact.out_path[0]==0xAA);
assert(m.lookup_length==PUB_KEY_SIZE&&m._ota_direct_contact.id.matches(m.contact.id));
assert(m._ota_direct_contact.out_path_len==0&&m._ota_direct_contact.tx_radio==1);
assert(m._ota_direct_contact.type==ADV_TYPE_REPEATER&&m._ota_direct_contact.sync_since==0);
assert(m._ota_direct_contact.flags==0&&m._ota_direct_contact.name[0]==0);
assert(m.contact.flags==31&&m.contact.sync_since==987&&m.contact.tx_radio==2);
assert(!strcmp(m.contact.name,"Saved contact"));
assert(m.getContactTxRadio(m.contact.id)==mesh::RADIO_TX_PRIMARY);
mesh::Identity another;assert(m.getContactTxRadio(another)==2);
mesh::Packet ota;ota.payload_type=12;m.rawRoute(&ota);assert(ota.tx_radio==mesh::RADIO_TX_PRIMARY);
@@ -181,8 +201,22 @@ int main(){
m.mode(3);assert(m.response==0&&!m._ota_direct_admin.active(m.timer.now));
assert(m.getContactTxRadio(m.contact.id)==2);
m.mode(3);assert(m.response==0); // cleanup is idempotent
m.contact.type=ADV_TYPE_ROOM;
m.mode(1,nullptr,ADV_TYPE_ROOM);
assert(m.response==0&&m._ota_direct_contact.type==ADV_TYPE_ROOM);
assert(m._ota_direct_contact.sync_since==0&&m._ota_direct_contact.id.matches(m.contact.id));
uint32_t timeout=0;
assert(m.sendLogin(m._ota_direct_contact,"secret",timeout)==1);
assert(m.type==PAYLOAD_TYPE_ANON_REQ&&m.packet.tx_radio==mesh::RADIO_TX_PRIMARY);
assert(m.sent.size()==14&&!memcmp(m.sent.data()+8,"secret",6));
for(unsigned i=4;i<8;++i)assert(m.sent[i]==0); // Room login requests no historical cursor.
assert(m.contact.type==ADV_TYPE_ROOM&&m.contact.sync_since==987);
assert(m.contact.out_path_len==2&&m.contact.out_path[0]==0xAA&&m.contact.tx_radio==2);
m.mode(3);assert(m.response==0);
mesh::Identity fresh;memset(fresh.pub_key,0x22,32);m.mode(1,&fresh);
assert(m.response==0&&m._ota_direct_transient);
assert(m._ota_direct_contact.id.matches(fresh)&&m._ota_direct_contact.type==ADV_TYPE_REPEATER);
assert(m._ota_direct_contact.out_path_len==0&&m._ota_direct_contact.tx_radio==mesh::RADIO_TX_PRIMARY);
assert(m._ota_direct_transient->type==ADV_TYPE_NONE&&m.contact.out_path_len==2);
m.mode(3,&fresh);assert(m.response==0&&m.clear_calls==1);
for(auto& slot:m.anonymous){memset(slot.id.pub_key,0x33,32);slot.type=ADV_TYPE_NONE;}
@@ -206,6 +240,8 @@ class CompanionDirectAdminGuardTest(unittest.TestCase):
binary = extract_braced(source, "bool MyMesh::handleDirectOtaFrame(")
getter = extract_braced(source, "bool MyMesh::handleDirectOtaCommand(")
closer = extract_braced(source, "void MyMesh::closeDirectOtaAdmin(")
base = (ROOT / "src/helpers/BaseChatMesh.cpp").read_text()
send_login = extract_braced(base, "int BaseChatMesh::sendLogin(")
callbacks = "\n".join(extract_braced(source, signature) for signature in (
"uint8_t MyMesh::getContactTxRadio(", "void MyMesh::onPeerDataRecv(", "bool MyMesh::onPeerPathRecv("))
raw = extract_braced(source, "if (pkt->getPayloadType() == PAYLOAD_TYPE_OTA\n && _ota_direct_admin.active")
@@ -233,6 +269,7 @@ class CompanionDirectAdminGuardTest(unittest.TestCase):
code = HARNESS.replace("@BINARY@", (binary + "\n" + getter + "\n" + closer) if full else "")
code = code.replace("@RAW_ROUTE@", raw)
code = code.replace("@CALLBACKS@", callbacks if full else "")
code = code.replace("@SEND_LOGIN@", send_login if full else "")
code = code.replace("@REPEATER_PREFIX@", prefix_blocks[0])
code = code.replace("@ROOM_PREFIX@", prefix_blocks[1])
(folder / "test.cpp").write_text(code)
@@ -255,9 +292,9 @@ class CompanionDirectAdminGuardTest(unittest.TestCase):
full = ["COMPANION_RADIO_FULL=1", "ENABLE_OTA=1", "ENABLE_USB_INTERFACE=1", "BLE_PIN_CODE=123456"]
cases = (("esp32", full + ["ESP32_PLATFORM=1", "WIFI_SSID=fixture"], 1, 1),
("nrf52", full + ["NRF52_PLATFORM=1"], 1, 1),
("rak4631", full + ["NRF52_PLATFORM=1", "RAK_4631=1"], 0, 1),
("rak4631", full + ["NRF52_PLATFORM=1", "RAK_4631=1"], 1, 1),
("reduced", ["NRF52_PLATFORM=1", "RAK_4631=1"], 0, 0),
("rak override", full + ["NRF52_PLATFORM=1", "RAK_4631=1", "COMPANION_FEATURE_DIRECT_OTA_ADMIN=1"], 1, 1))
("rak opt-out", full + ["NRF52_PLATFORM=1", "RAK_4631=1", "COMPANION_FEATURE_DIRECT_OTA_ADMIN=0"], 0, 1))
code = ('#include "examples/companion_radio/CompanionFeatures.h"\n'
'DIRECT_HELPER COMPANION_FEATURE_DIRECT_OTA_ADMIN\n'
'EXISTING_OTA COMPANION_FEATURE_OTA_CLI\n'
+33 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Compare the real firmware decoder and CLI output against every source verse."""
import copy
import configparser
import importlib.util
import json
import os
@@ -55,6 +56,12 @@ class CompanionReaderTest(unittest.TestCase):
self.assertEqual(actual, list(self.expected_verses.values()))
def test_arm_reader_decode_frames_fit_existing_loop_stack(self):
self.check_arm_reader_stack()
def test_compact_arm_reader_decode_frames_fit_existing_loop_stack(self):
self.check_arm_reader_stack(compact=True)
def check_arm_reader_stack(self, compact=False):
# Use the actual Cortex-M4 compiler when installed. This does not
# invoke PlatformIO or reserve decode buffers in ordinary commands.
tool = shutil.which("arm-none-eabi-g++")
@@ -74,6 +81,8 @@ class CompanionReaderTest(unittest.TestCase):
"-fstack-usage", "-DNRF52_PLATFORM=1", "-DCOMPANION_RADIO_FULL=1",
"-DENABLE_USB_INTERFACE=1", "-I" + str(directory),
"-I" + str(ROOT / "src"), "-I" + str(FIXTURE)]
if compact:
flags.append("-DCOMPANION_READER_COMPACT_STREAM=1")
self.run_checked([tool, "-std=gnu++17", "-fno-exceptions", "-fno-rtti", *flags,
"-c", str(ROOT / "src/helpers/CompanionReader.cpp"), "-o", str(directory / "reader.o")])
self.run_checked([cc, "-std=gnu11", *flags, "-c",
@@ -229,8 +238,10 @@ class CompanionReaderTest(unittest.TestCase):
self.skipTest("host GCC and G++ required")
with tempfile.TemporaryDirectory(prefix="meshcore-reader-") as directory:
directory = Path(directory)
for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM"):
for platform, compact in (("ESP32_PLATFORM", False), ("NRF52_PLATFORM", False), ("NRF52_PLATFORM", True)):
flags = [f"-D{platform}=1", "-DCOMPANION_RADIO_FULL=1", "-DENABLE_USB_INTERFACE=1"]
if compact:
flags.append("-DCOMPANION_READER_COMPACT_STREAM=1")
obj, binary = directory / "tinf.o", directory / "reader.exe"
self.run_checked([cc, "-std=c11", "-Os", *flags, "-c",
str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(obj)])
@@ -242,6 +253,27 @@ class CompanionReaderTest(unittest.TestCase):
actual = dict(line.split("\t", 1) for line in result.splitlines())
self.assertEqual(actual, self.expected_verses)
def test_compact_asset_is_opted_into_by_rak4631_full_only(self):
config = configparser.ConfigParser(interpolation=None)
config.read(ROOT / "variants/rak4631/platformio.ini")
opted_in = [section for section in config.sections()
if "-D COMPANION_READER_COMPACT_STREAM=1" in config.get(section, "build_flags", fallback="")]
self.assertEqual(opted_in, ["env:RAK_4631_companion_radio_full"])
cxx = shutil.which("g++")
if not cxx:
self.skipTest("host G++ required")
for platform, compact, expected in (("NRF52_PLATFORM", False, 3), ("NRF52_PLATFORM", True, 0),
("ESP32_PLATFORM", True, None)):
flags = [f"-D{platform}=1", "-DCOMPANION_RADIO_FULL=1", "-DENABLE_USB_INTERFACE=1"]
if compact:
flags.append("-DCOMPANION_READER_COMPACT_STREAM=1")
result = self.run_checked([cxx, "-E", "-P", *flags,
"-I" + str(FIXTURE), str(ROOT / "src/helpers/CompanionReader.cpp")])
if expected is None:
self.assertIn("using ReaderCorpus = TokenCorpusT<uint16_t>;", result)
else:
self.assertIn(f"128, kVerseCount, {expected}", result)
def test_other_profiles_exclude_corpus(self):
cxx = shutil.which("g++")
if not cxx:
+189
View File
@@ -0,0 +1,189 @@
"""Qualify the private GNSS transform and native scanf replacement behavior."""
import contextlib
import importlib.util
import io
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from test_ota_cli_listing import function
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("gnss_trim", ROOT / "scripts/nrf52_gnss_version_trim.py")
TRIM = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(TRIM)
SOURCE = TRIM.INCLUDE + "\nvoid version(const char* ptr) {\n" + TRIM.ORIGINAL_CONVERSION + "\n}\n"
HEADERS = {name: "// fixture: " + name + "\n" for name in TRIM.HEADERS}
class Node:
def __init__(self, path):
self.path = Path(path)
def srcnode(self):
return self
def get_abspath(self):
return str(self.path)
class BuildEnv:
def __init__(self, path):
self.path = Path(path)
self.registrations = []
def subst(self, value):
assert value == "$BUILD_DIR"
return str(self.path)
def File(self, path):
return Path(path)
def AddBuildMiddleware(self, callback, pattern):
self.registrations.append((callback, pattern))
class GnssVersionTrimTests(unittest.TestCase):
def test_private_copy_checks_source_and_all_headers_and_preserves_downloads(self):
# Small synthetic dependencies test the transform mechanics; production
# fingerprints remain fixed to the complete reviewed library files.
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "libdeps/gnss/src" / TRIM.SOURCE_NAME
source.parent.mkdir(parents=True)
source.write_bytes(SOURCE.replace("\n", "\r\n").encode())
for name, text in HEADERS.items():
(source.parent / name).write_text(text)
original = {path.name: path.read_bytes() for path in source.parent.iterdir()}
env = BuildEnv(root / "build")
with patch.object(TRIM, "SOURCE_SHA256", TRIM.digest(SOURCE)), patch.object(
TRIM, "HEADERS", {name: TRIM.digest(text) for name, text in HEADERS.items()}):
destination = TRIM.replace_gnss_source(env, Node(source))
self.assertEqual(destination, env.path / "trimmed-nrf52-gnss" / TRIM.SOURCE_NAME)
output = destination.read_text()
self.assertNotIn("sscanf", output)
self.assertIn(TRIM.VERSION_CONVERSION, output)
times = {path.name: path.stat().st_mtime_ns for path in destination.parent.iterdir()}
self.assertEqual(TRIM.replace_gnss_source(env, Node(source)), destination)
self.assertEqual(times, {path.name: path.stat().st_mtime_ns for path in destination.parent.iterdir()})
self.assertEqual(original, {path.name: path.read_bytes() for path in source.parent.iterdir()})
for name, text in HEADERS.items():
self.assertEqual((destination.parent / name).read_text(), text)
for change in ("source", *HEADERS):
with self.subTest(change=change), patch.object(TRIM, "SOURCE_SHA256", TRIM.digest(SOURCE)), patch.object(
TRIM, "HEADERS", {name: TRIM.digest(text) for name, text in HEADERS.items()}):
target = source if change == "source" else source.parent / change
before = target.read_bytes()
target.write_bytes(before + b"// unreviewed\n")
other_env = BuildEnv(root / ("unknown-" + change))
notice = io.StringIO()
node = Node(source)
with contextlib.redirect_stdout(notice):
self.assertIs(TRIM.replace_gnss_source(other_env, node), node)
self.assertIn("ordinary source retained", notice.getvalue())
self.assertFalse(other_env.path.exists())
target.write_bytes(before)
def test_only_qualified_companion_installs_middleware(self):
spec = importlib.util.spec_from_file_location("flash_trim", ROOT / "scripts/nrf52_flash_trim.py")
policy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(policy)
class Env(dict):
def PioPlatform(self):
class Platform:
name = "nordicnrf52"
def get_package_dir(self, _name): return ""
return Platform()
def BoardConfig(self): return {"build.mcu": "nrf52840"}
def AppendUnique(self, **kwargs):
for name, value in kwargs.items(): self.setdefault(name, []).extend(value)
def subst(self, value): return value.replace("$PROJECT_DIR", str(ROOT))
def AddBuildMiddleware(self, callback, pattern): self.setdefault("middleware", []).append(pattern)
for role, flags, expected in (("companion_radio", "", True),
("companion_radio", "-DMESH_NRF52_COMPANION_TRIM=0", False),
("simple_repeater", "", False), ("custom", "", False)):
with self.subTest(role=role, flags=flags):
env = Env(BUILD_FLAGS=flags, CPPDEFINES=[], PIOENV="custom",
SRC_FILTER="+<../examples/" + role + ">")
policy.install(env)
self.assertEqual("*" + TRIM.SOURCE_NAME in env.get("middleware", []), expected)
def test_native_gnss_conversion_parity_and_bounded_cli_tokens(self):
compiler = shutil.which("g++") or shutil.which("clang++")
if not compiler:
self.skipTest("a native C++ compiler is required")
cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text()
native = r'''
#include <cassert>
#include <climits>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
struct Version { bool valid; uint8_t high, low; };
static Version original(const char* ptr) {
int protHi = 0, protLo = 0;
@ORIGINAL@
return {scanned == 2, uint8_t(protHi), uint8_t(protLo)};
}
static Version trimmed(const char* ptr) {
int protHi = 0, protLo = 0;
@CONVERSION@
return {scanned == 2, uint8_t(protHi), uint8_t(protLo)};
}
static void parity(const char* text) {
const Version before = original(text), after = trimmed(text);
assert(before.valid == after.valid);
if (before.valid) assert(before.high == after.high && before.low == after.low);
}
@TOKENS@
int main() {
for (const char* text : {"18.00", "27.12", "0.0", "255.255", "256.256",
" +27.\t+12 extra", "\t\r\n-1.\v\f-2 trailing", "0018.0000",
"", " ", "18", "18.", ".12", "18 .12", "18.x", "x18.12",
"+.12", "18.+", "18.12.34", "18.0x20", "0x18.12",
"2147483647.2147483647", "-2147483648.-2147483648",
"2147483648.2147483648", "-2147483649.-2147483649",
"9999999999999999999999999999999.12",
"18.-9999999999999999999999999999999"}) parity(text);
uint32_t random = 0xB43A1123;
for (unsigned n = 0; n < 3000; ++n) {
random = random * 1664525U + 1013904223U;
char text[96];
snprintf(text, sizeof(text), "\t%ld. %ld extra", long(int32_t(random)), long(int32_t(random ^ 0x91D43AA5U)));
parity(text);
}
struct { char first[8]; uint8_t guard1; char second[10]; uint8_t guard2; } words = {};
words.guard1 = words.guard2 = 0xA5;
assert(parse_two_words("\t1234567\v\fabcdefghi\r\n", words.first, sizeof(words.first), words.second, sizeof(words.second)) == 2);
assert(!strcmp(words.first, "1234567") && !strcmp(words.second, "abcdefghi"));
for (const char* text : {"12345678 x", "x abcdefghij", "x y z"})
assert(parse_two_words(text, words.first, sizeof(words.first), words.second, sizeof(words.second)) == 3);
assert(words.guard1 == 0xA5 && words.guard2 == 0xA5);
assert(parse_two_words(" \t\r\n", words.first, sizeof(words.first), words.second, sizeof(words.second)) == 0);
}
'''.replace("@ORIGINAL@", TRIM.ORIGINAL_CONVERSION).replace("@CONVERSION@", TRIM.VERSION_CONVERSION).replace(
"@TOKENS@", function(cli, "parse_two_words"))
native = "#include <initializer_list>\n" + native
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "parity.cpp"
source.write_text(native)
binary = Path(directory) / ("parity.exe" if os.name == "nt" else "parity")
built = subprocess.run([compiler, "-std=c++11", "-O1", "-Wall", "-Wextra", "-Werror",
*sanitizers, str(source), "-o", str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -20,7 +20,7 @@ class OtaFolderResumeTest(unittest.TestCase):
start = cli.index(' } else if (is_cmd(a, "pull|get|download", &rest)) {')
end = cli.index(' // ---- discard the current session', start)
harness = (ROOT / "test/fixtures/ota_folder_resume.cpp").read_text()
helpers = "\n\n".join(function(cli, name) for name in ("is_cmd", "state_word"))
helpers = "\n\n".join(function(cli, name) for name in ("is_cmd", "state_word", "parse_two_words"))
harness = harness.replace("// @CLI_HELPERS@", helpers).replace("// @CLI_PULL@", cli[start:end])
with tempfile.TemporaryDirectory(prefix="meshcore-ota-folder-resume-") as temporary:
work = Path(temporary)
+22 -7
View File
@@ -116,6 +116,13 @@ class ReaderStreamTest(unittest.TestCase):
spec.loader.exec_module(packer)
document = json.loads(packer.blocks.SOURCE.read_text(encoding="utf-8"))
self.assertEqual(packer.HEADER.read_text(encoding="ascii"), packer.render(document))
self.assertEqual(packer.COMPACT_HEADER.read_text(encoding="ascii"), packer.render(document, compact=True))
normal, plain, _, normal_chunks = packer.encode(document)
compact, compact_plain, _, compact_chunks = packer.encode(document, compact=True)
self.assertEqual((normal_chunks, compact_chunks), (3, 0))
self.assertEqual(plain, compact_plain)
self.assertEqual(zlib.decompress(compact, wbits=-12), plain)
self.assertEqual(len(normal) - len(compact), 661)
pinned = json.loads(packer.TOKENS.read_text(encoding="ascii"))
self.assertLessEqual(len(pinned), 128)
self.assertEqual(len(pinned), len(set(pinned)))
@@ -126,8 +133,8 @@ class ReaderStreamTest(unittest.TestCase):
sys.path.remove(path)
def test_all_879_verses_match_independently_normalized_source(self):
binary = self.compile(r"""
#include <helpers/bible/ReaderStreamData.generated.h>
body = r"""
#include <helpers/bible/@HEADER@>
#include <cassert>
#include <cstdio>
#include <cstring>
@@ -148,13 +155,16 @@ int main() {
}
}
}
""", "corpus")
"""
document = json.loads((ROOT / "tools/bible/reader-web.json").read_text())
typography = str.maketrans({"\u00a0": " ", "\u2013": "-", "\u2014": "-",
"\u2018": "'", "\u2019": "'", "\u201c": '\"', "\u201d": '\"'})
expected = b"".join(text.translate(typography).encode("ascii") + b"\0"
for text in document["verses"].values())
self.assertEqual(self.run_binary(binary), expected)
for name in ("ReaderStreamData.generated.h", "ReaderCompactStreamData.generated.h"):
with self.subTest(header=name):
binary = self.compile(body.replace("@HEADER@", name), "corpus" + name.split("Data")[0])
self.assertEqual(self.run_binary(binary), expected)
def test_ring_bounds_truncation_distances_and_deterministic_mutations(self):
cases = []
@@ -247,7 +257,7 @@ int main() {
document = json.loads(packer.blocks.SOURCE.read_text())
document["verses"] = dict.fromkeys(packer.blocks.references(), None)
document["verses"]["1:1"] = "q" * 4095
rendered = packer.render(document)
rendered = packer.render(document, compact=True)
finally:
sys.path.remove(path)
header = self.directory / "literal.h"
@@ -260,6 +270,7 @@ int main() {
using namespace mesh::bible;
char scratch[kBlockSize];const char* text=nullptr;
const auto& c=generated::readerCorpus;
assert(c.chunk_count==0);
assert(lookup(c,{1,1},scratch,sizeof(scratch),text)==LookupResult::Found);
assert(strlen(text)==4095);for(unsigned n=0;n<4095;++n) assert(text[n]=='q');
assert(lookup(c,{1,2},scratch,sizeof(scratch),text)==LookupResult::Missing);assert(!text);
@@ -386,6 +397,9 @@ void check(const uint8_t* data,unsigned data_size,unsigned plain_size,const uint
# against the unchanged block decoder using identical host options.
binary = self.compile(r"""
#include <helpers/bible/ReaderStreamData.generated.h>
#define generated compact_generated
#include <helpers/bible/ReaderCompactStreamData.generated.h>
#undef generated
#define generated old_generated
#include <helpers/bible/ReaderData.generated.h>
#undef generated
@@ -405,8 +419,9 @@ template<class Corpus> double bench(const Corpus& corpus) {
assert(sum>0);
return std::chrono::duration<double,std::milli>(std::chrono::steady_clock::now()-start).count()/80;
}
int main() { printf("Reader last verse host ms: block=%.4f stream=%.4f\n",
bench(mesh::bible::old_generated::readerCorpus),bench(mesh::bible::generated::readerCorpus)); }
int main() { printf("Reader last verse host ms: block=%.4f stream=%.4f compact=%.4f\n",
bench(mesh::bible::old_generated::readerCorpus),bench(mesh::bible::generated::readerCorpus),
bench(mesh::bible::compact_generated::readerCorpus)); }
""", "benchmark")
print(self.run_binary(binary).decode().strip())
+9
View File
@@ -321,6 +321,14 @@ independent blocks. Native tests compare every verse and output byte, exercise
malformed inputs, and check the actual ARM stack against the existing 8 KiB
loop stack. Linked-ELF tests retain the same text and reply checks.
RAK4631 Full opts into `COMPANION_READER_COMPACT_STREAM=1` and a separate
single-stream asset. Its data and vocabulary occupy 28,126 bytes, saving another
661 bytes while retaining every verse, font and the same 4 KiB scratch.
The tradeoff is a cold lookup that may decode up to 50,732 encoded bytes before
the selected verse. Other nRF52 boards retain the three-stream asset and its
shorter cold lookup. The single stream uses the existing unframed format;
larger custom corpora still use bounded, independently framed streams.
`reader-stream-tokens.json` is the pinned compression vocabulary. It substitutes
only exact source byte strings, including substrings of words. Host-only Google
Zopfli uses 1,000 iterations with a 4 KiB match window; its Apache-2.0 source,
@@ -330,5 +338,6 @@ committed header and require no extra downloads.
```sh
python tools/bible/pack_reader_stream.py --check
python tools/bible/pack_reader_stream.py --compact --check
python test/test_reader_stream.py
```
+20 -13
View File
@@ -14,9 +14,11 @@ import zlib
import pack_reader as blocks
HEADER = blocks.ROOT / "src/helpers/bible/ReaderStreamData.generated.h"
COMPACT_HEADER = blocks.ROOT / "src/helpers/bible/ReaderCompactStreamData.generated.h"
TOKENS = Path(__file__).with_name("reader-stream-tokens.json")
COMPRESSOR = Path(__file__).with_name("zopfli4k")
STREAM_LIMIT = 17000
COMPACT_STREAM_LIMIT = 60000
@lru_cache(maxsize=1)
@@ -55,7 +57,7 @@ def token_dictionary(verses):
return [word for word in words if any(word in verse for verse in verses)]
def encode(document):
def encode(document, *, compact=False):
verses = blocks.source_verses(document)
words = token_dictionary(verses)
tokens = {word: bytes([128 + index]) for index, word in enumerate(words)}
@@ -72,13 +74,15 @@ def encode(document):
records = [verse + b"\0" for verse in encoded_verses]
chunks, first, current = [], 0, b""
for index, record in enumerate(records):
if current and len(current) + len(record) > STREAM_LIMIT:
if current and len(current) + len(record) > (COMPACT_STREAM_LIMIT if compact else STREAM_LIMIT):
chunks.append((first, current))
first, current = index, b""
current += record
chunks.append((first, current))
# Independent 12-bit histories bound cold lookup work without adding a
# buffer. Headers keep both compressed and decoded lengths bounded.
# A single stream needs no framing. Larger corpora retain independently
# bounded 12-bit histories and six-byte headers, without another buffer.
if compact and len(chunks) == 1:
return compress_4k(chunks[0][1]), b"".join(records), words, 0
packed = bytearray()
for first, plain in chunks:
compressed = compress_4k(plain)
@@ -87,13 +91,14 @@ def encode(document):
return bytes(packed), b"".join(records), words, len(chunks)
def render(document):
packed, plain, words, chunk_count = encode(document)
def render(document, *, compact=False):
packed, plain, words, chunk_count = encode(document, compact=compact)
dictionary_plain = b"\0".join(words) + b"\0" if words else b""
dictionary = compress_4k(dictionary_plain) if words else b""
digest = hashlib.sha256(json.dumps(document, ensure_ascii=False, sort_keys=True,
separators=(",", ":")).encode()).hexdigest()
lines = ["// Generated by tools/bible/pack_reader_stream.py; do not edit.",
command = "tools/bible/pack_reader_stream.py" + (" --compact" if compact else "")
lines = [f"// Generated by {command}; do not edit.",
"// Offline Reader corpus. Public domain; see tools/bible/README.md.",
"// Google Zopfli, 1000 iterations, 4096-byte history; exact ASCII text retained.",
f"// Canonical source SHA-256: {digest}",
@@ -120,22 +125,24 @@ def render(document):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=blocks.SOURCE)
parser.add_argument("--output", type=Path, default=HEADER)
parser.add_argument("--output", type=Path)
parser.add_argument("--compact", action="store_true", help="RAK4631 Full: smaller asset with slower cold lookup")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
document = json.loads(args.source.read_text(encoding="utf-8"))
header = render(document)
output = args.output or (COMPACT_HEADER if args.compact else HEADER)
header = render(document, compact=args.compact)
if args.check:
if args.output.read_text(encoding="ascii") != header:
if output.read_text(encoding="ascii") != header:
raise SystemExit("Reader stream header differs; regenerate with tools/bible/pack_reader_stream.py")
else:
args.output.write_text(header, encoding="ascii", newline="\n")
packed, plain, words, chunk_count = encode(document)
output.write_text(header, encoding="ascii", newline="\n")
packed, plain, words, chunk_count = encode(document, compact=args.compact)
dictionary_plain = b"\0".join(words) + b"\0" if words else b""
dictionary_size = len(compress_4k(dictionary_plain)) if words else 0
print(f"Reader stream: {len(blocks.references())} unchanged verses, "
f"{len(plain)} encoded bytes -> {len(packed)} DEFLATE + "
f"{dictionary_size} dictionary bytes; {chunk_count} independent streams, 4096-byte history")
f"{dictionary_size} dictionary bytes; {chunk_count or 1} independent streams, 4096-byte history")
if __name__ == "__main__":
+1 -2
View File
@@ -374,8 +374,7 @@ class CompanionTransport:
await self._guard_mode(key, 1, bytes([contact["type"]]))
except CompanionError as exc:
raise CompanionError(
"The USB radio needs current Full Companion firmware with the zero-hop OTA admin guard. "
"RAK4631 Full cannot fit this helper; use an ESP32 Full Companion or USB Repeater.") from exc
"The USB radio needs current Full Companion firmware with the version 2 zero-hop OTA admin guard.") from exc
self._guarded_key = key
if key not in self._authenticated:
# The library's send_login_sync accepts any login-success
+1
View File
@@ -482,6 +482,7 @@ build_flags =
-D ETHERNET_USE_RAK13800
-D ETHERNET_CLASS=RAK13800EthernetInterface
-D MESHCORE_REQUIRES_COMPANION_RADIO_FULL=1
-D COMPANION_READER_COMPACT_STREAM=1
build_src_filter = ${env:RAK_4631_companion_radio_usb.build_src_filter}
+<helpers/ethernet/*.cpp>
+<helpers/ethernet/RAK13800/>