Fit direct OTA administration into RAK4631 Full
This commit is contained in:
1 parent
9d2f7a3e27
commit
7d413a6806
21 files changed
+1926
-61
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
Vendored
+29
@@ -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}) {
|
||||
|
||||
@@ -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))')))
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/>
|
||||
|
||||
Reference in new issue
Block a user