diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index cebf0cfe5c..eed78a7d65 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -46,6 +46,8 @@ jobs: - wio_wm1110_repeater # SX1276 (ESP32) - Tbeam_SX1276_repeater + # ASR650x (CubeCell, 128 KB flash: catches size regressions) + - Heltec_CubeCell_AB02S_companion_radio_usb steps: - name: Clone Repo diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5d48f4c67b..88bebf9ee3 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -20,7 +20,7 @@ jobs: uses: ./.github/actions/setup-build-environment - name: Run Unit Tests - run: pio test -e native -e native_kiss_modem -vv + run: pio test -e native -e native_kiss_modem -e native_noop_config -e native_ed25519_small -vv - name: Upload Test Results # Upload test results even if the test step failed. diff --git a/build.sh b/build.sh index 80ff4cdf2d..126d34497c 100755 --- a/build.sh +++ b/build.sh @@ -107,7 +107,7 @@ for section, options in data: for key, value in options: if key == 'build_flags': for flag in value: - match = re.search(r'(ESP32_PLATFORM|NRF52_PLATFORM|STM32_PLATFORM|RP2040_PLATFORM)', flag) + match = re.search(r'(ESP32_PLATFORM|NRF52_PLATFORM|STM32_PLATFORM|RP2040_PLATFORM|ASR650X_PLATFORM)', flag) if match: print(match.group(1)) sys.exit(0) @@ -181,6 +181,12 @@ build_firmware() { cp .pio/build/$1/firmware.uf2 out/${FIRMWARE_FILENAME}.uf2 2>/dev/null || true fi + # for asr650x (CubeCell), copy .cyacd (CubeCellFlash / bootloader image) and .hex to out folder + if [ "$ENV_PLATFORM" == "ASR650X_PLATFORM" ]; then + cp .pio/build/$1/firmware.cyacd out/${FIRMWARE_FILENAME}.cyacd 2>/dev/null || true + cp .pio/build/$1/firmware.hex out/${FIRMWARE_FILENAME}.hex 2>/dev/null || true + fi + } # firmwares containing $1 will be built diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 06c56a7a44..77b9209b51 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -1,6 +1,10 @@ #include #include "DataStore.h" +#if CONTACT_OUT_PATH_SIZE < MAX_PATH_SIZE + #error "this file-based contact store writes 64-byte routes: build it with the default CONTACT_OUT_PATH_SIZE" +#endif + #if defined(EXTRAFS) || defined(QSPIFLASH) #define MAX_BLOBRECS 100 #else diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index e0a145caf1..dc52a31f3e 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -1,5 +1,9 @@ #pragma once +#if defined(ASR650X_PLATFORM) + #include "FlashDataStore.h" // no filesystem: identity + prefs in two SFLASH rows, contacts in RAM +#else + #include #include #include @@ -53,3 +57,5 @@ class DataStore { private: FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;}; }; + +#endif diff --git a/examples/companion_radio/FlashDataStore.cpp b/examples/companion_radio/FlashDataStore.cpp new file mode 100644 index 0000000000..f89f3eb4ed --- /dev/null +++ b/examples/companion_radio/FlashDataStore.cpp @@ -0,0 +1,220 @@ +#if defined(ASR650X_PLATFORM) // the other boards use DataStore.cpp (filesystem) +#include "FlashDataStore.h" + +#include "target.h" + +#include +#include + +namespace { +struct CommitArgs { + asr650x::PersistStore *store; + const asr650x::PersistState *cand; + bool ok; +}; +void runCommit(void *p) { + CommitArgs *a = (CommitArgs *)p; + a->ok = a->store->commit(*a->cand); +} +} // namespace + +bool DataStore::commitBig(const asr650x::PersistState &c) { + CommitArgs a = { &_store, &c, false }; + asr650xCallOnBigStack(runCommit, &a); + return a.ok; +} + +void DataStore::begin() { + _store.begin(); + asr650xSetTxAllowed(_store.state().has_identity); // never transmit as the unprovisioned placeholder +} + +// ---- pinned contacts (SFLASH row 2). Every operation runs on the crypto stack: the row, a PinSet and a +// ContactInfo together are ~600 bytes, too much for the 2 KB main stack, and none of it stays in RAM. +namespace { +const size_t PIN_ROW = 2 * asr650x::ROW_STRIDE; + +enum PinOp { PIN_LOAD, PIN_REFRESH, PIN_ADD, PIN_DEL, PIN_CLEAR }; + +struct PinArgs { + FlashRowBackend *be; + DataStoreHost *host; + bool (*filter)(const ContactInfo &); + const ContactInfo *contact; + const uint8_t *pub; + PinOp op; +}; + +void runPins(void *p) { + PinArgs *a = (PinArgs *)p; + asr650x::PinSet s; + uint8_t row[asr650x::PIN_SIZE]; + if (a->op != PIN_CLEAR) { + a->be->read(PIN_ROW, row, asr650x::PIN_SIZE); + asr650x::pinsDecode(row, s); // erased/corrupt -> empty set + } + bool changed = false; + if (a->op == PIN_LOAD) { + for (int i = 0; i < s.n; i++) { + ContactInfo ci; + memset(&ci, 0, sizeof(ci)); + ci.id = mesh::Identity(s.e[i].pub); + memcpy(ci.name, s.e[i].name, sizeof(ci.name)); + ci.type = s.e[i].type; + ci.flags = s.e[i].flags; + ci.out_path_len = OUT_PATH_UNKNOWN; // the route is learnt again (flood first) + ci.lastmod = 1; // non-zero: GET_CONTACTS only lists contacts with lastmod > since (0) + a->host->onContactLoaded(ci); + } + return; + } + if (a->op == PIN_REFRESH) { + ContactInfo ci; + for (uint32_t idx = 0; a->host->getContactForSave(idx, ci); idx++) { + if (a->filter && !a->filter(ci)) continue; + if (asr650x::pinRefresh(s, ci.id.pub_key, ci.name, ci.type, ci.flags) == asr650x::PIN_CHANGED) + changed = true; + } + } else if (a->op == PIN_ADD) { + const ContactInfo &c = *a->contact; + changed = asr650x::pinUpsert(s, c.id.pub_key, c.name, c.type, c.flags) == + asr650x::PIN_CHANGED; // FULL: RAM only + } else if (a->op == PIN_DEL) { + changed = asr650x::pinRemove(s, a->pub); + } else { + changed = true; // clear: write the empty record + } + if (changed) { + asr650x::pinsEncode(s, row); + a->be->write(PIN_ROW, row, asr650x::PIN_SIZE); + } +} +} // namespace + +void DataStore::loadContacts(DataStoreHost *host) { + PinArgs a = { &_be, host, NULL, NULL, NULL, PIN_LOAD }; + asr650xCallOnBigStack(runPins, &a); +} + +void DataStore::saveContacts(DataStoreHost *host, bool (*filter)(const ContactInfo &c)) { + PinArgs a = { &_be, host, filter, NULL, NULL, PIN_REFRESH }; + asr650xCallOnBigStack(runPins, &a); +} + +void DataStore::pinContact(const ContactInfo &c) { + PinArgs a = { &_be, NULL, NULL, &c, NULL, PIN_ADD }; + asr650xCallOnBigStack(runPins, &a); +} + +void DataStore::unpinContact(const uint8_t *pub_key) { + PinArgs a = { &_be, NULL, NULL, NULL, pub_key, PIN_DEL }; + asr650xCallOnBigStack(runPins, &a); +} + +bool DataStore::formatFileSystem() { + asr650x::PersistState empty; + memset(&empty, 0, sizeof(empty)); + bool ok = commitBig(empty); + if (ok) asr650xSetTxAllowed(false); + PinArgs a = { &_be, NULL, NULL, NULL, NULL, PIN_CLEAR }; // factory reset also forgets the pinned contacts + asr650xCallOnBigStack(runPins, &a); + return ok; +} + +bool DataStore::loadMainIdentity(mesh::LocalIdentity &identity) { + const asr650x::PersistState &s = _store.state(); + if (!s.has_identity) return false; + uint8_t raw[96]; + memcpy(raw, s.prv, 64); + memcpy(raw + 64, s.pub, 32); + identity.readFrom(raw, sizeof(raw)); + return true; +} + +bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) { + if (asr650xIdentityIsPlaceholder(identity)) { // never persist the placeholder ... + asr650xSetTxAllowed(false); // ... and never transmit as it (even if a real key was loaded before) + return true; + } + mesh::LocalIdentity copy = identity; + uint8_t raw[96]; + if (copy.writeTo(raw, sizeof(raw)) != sizeof(raw)) return false; + asr650x::PersistState c = _store.state(); + c.has_identity = true; + memcpy(c.prv, raw, 64); + memcpy(c.pub, raw + 64, 32); + bool ok = commitBig(c); + if (ok) asr650xSetTxAllowed(true); + return ok; +} + +void DataStore::loadPrefs(NodePrefs &prefs) { + const asr650x::PersistState &s = _store.state(); + if (!s.has_prefs) { + prefs.gps_enabled = 1; + return; + } // default: GPS on + prefs.gps_enabled = s.gps_off ? 0 : 1; + prefs.gps_interval = s.gps_interval; + strncpy(prefs.node_name, s.node_name, sizeof(prefs.node_name) - 1); + prefs.node_name[sizeof(prefs.node_name) - 1] = 0; + prefs.freq = s.freq; + prefs.bw = s.bw; + prefs.sf = s.sf; + prefs.cr = s.cr; + prefs.tx_power_dbm = s.tx_power_dbm; + if (!s.has_ext) return; // record from an older build: keep the defaults + prefs.airtime_factor = s.airtime_factor; + prefs.rx_delay_base = s.rx_delay_base; + prefs.tx_delay_factor = s.tx_delay_factor; + prefs.direct_tx_delay_factor = s.direct_tx_delay_factor; + prefs.multi_acks = s.multi_acks; + prefs.manual_add_contacts = s.manual_add_contacts; + prefs.telemetry_mode_base = s.telemetry_modes & 0x03; + prefs.telemetry_mode_loc = (s.telemetry_modes >> 2) & 0x03; + prefs.telemetry_mode_env = (s.telemetry_modes >> 4) & 0x03; + prefs.advert_loc_policy = s.advert_loc_policy; + prefs.autoadd_config = s.autoadd_config; + prefs.autoadd_max_hops = s.autoadd_max_hops; + prefs.path_hash_mode = s.path_hash_mode; + prefs.tz_offset = s.tz_offset; + prefs.cad_enabled = s.cad_enabled; + prefs.interference_threshold = s.interference_threshold; + prefs.agc_reset_interval = s.agc_reset_interval; + prefs.rx_boosted_gain = s.rx_boosted_gain; +} + +bool DataStore::savePrefs(NodePrefs &prefs) { + asr650x::PersistState c = _store.state(); + c.has_prefs = true; + c.gps_off = prefs.gps_enabled == 0; + c.gps_interval = prefs.gps_interval; + strncpy(c.node_name, prefs.node_name, sizeof(c.node_name) - 1); + c.node_name[sizeof(c.node_name) - 1] = 0; + c.freq = prefs.freq; + c.bw = prefs.bw; + c.sf = prefs.sf; + c.cr = prefs.cr; + c.tx_power_dbm = prefs.tx_power_dbm; + c.has_ext = true; + c.airtime_factor = prefs.airtime_factor; + c.rx_delay_base = prefs.rx_delay_base; + c.tx_delay_factor = prefs.tx_delay_factor; + c.direct_tx_delay_factor = prefs.direct_tx_delay_factor; + c.multi_acks = prefs.multi_acks; + c.manual_add_contacts = prefs.manual_add_contacts; + c.telemetry_modes = + (uint8_t)((prefs.telemetry_mode_base & 0x03) | ((prefs.telemetry_mode_loc & 0x03) << 2) | + ((prefs.telemetry_mode_env & 0x03) << 4)); + c.advert_loc_policy = prefs.advert_loc_policy; + c.autoadd_config = prefs.autoadd_config; + c.autoadd_max_hops = prefs.autoadd_max_hops; + c.path_hash_mode = prefs.path_hash_mode; + c.tz_offset = prefs.tz_offset; + c.cad_enabled = prefs.cad_enabled; + c.interference_threshold = prefs.interference_threshold; + c.agc_reset_interval = prefs.agc_reset_interval; + c.rx_boosted_gain = prefs.rx_boosted_gain; + return commitBig(c); +} +#endif // ASR650X_PLATFORM diff --git a/examples/companion_radio/FlashDataStore.h b/examples/companion_radio/FlashDataStore.h new file mode 100644 index 0000000000..2d56d827d7 --- /dev/null +++ b/examples/companion_radio/FlashDataStore.h @@ -0,0 +1,46 @@ +#pragma once +#include "NodePrefs.h" + +#include +#include +#include +#include +#include + +class DataStoreHost { +public: + virtual bool onContactLoaded(const ContactInfo &contact) = 0; + virtual bool getContactForSave(uint32_t idx, ContactInfo &contact) = 0; + virtual bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails &ch) = 0; + virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails &ch) = 0; +}; + +// Same surface MyMesh uses from MeshCore's file-based DataStore. Identity + name + radio params go to +// SFLASH rows 0/1 (FlashRecord, A/B). Up to 3 contacts added or edited by the app are pinned in SFLASH row 2 +// (PinnedContacts.h); all other contacts, channels and blobs are RAM-only. +class DataStore { + FlashRowBackend _be; + asr650x::PersistStore _store; + bool commitBig( + const asr650x::PersistState &c); // runs the flash commit on the crypto stack (main stack is only 2 KB) +public: + DataStore() : _store(_be) {} + void begin(); + bool formatFileSystem(); // factory reset: erases identity + prefs record and the pinned contacts + bool loadMainIdentity(mesh::LocalIdentity &identity); + bool saveMainIdentity(const mesh::LocalIdentity &identity); + void loadPrefs(NodePrefs &prefs); + bool savePrefs(NodePrefs &prefs); + void loadContacts(DataStoreHost *host); // pinned contacts back into the table at boot + void saveContacts(DataStoreHost *host, + bool (*filter)(const ContactInfo &c) = NULL); // refreshes pinned copies + void pinContact(const ContactInfo &c); // the app added or edited this contact + void unpinContact(const uint8_t *pub_key); // the app removed it + void loadChannels(DataStoreHost *) {} + void saveChannels(DataStoreHost *) {} + uint8_t getBlobByKey(const uint8_t[], int, uint8_t[]) { return 0; } + bool putBlobByKey(const uint8_t[], int, const uint8_t[], uint8_t) { return false; } + bool deleteBlobByKey(const uint8_t[], int) { return false; } + uint32_t getStorageUsedKb() const { return 0; } + uint32_t getStorageTotalKb() const { return 0; } +}; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f32ea67aec..dd1f8aa79c 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,7 @@ #include "MyMesh.h" +#ifdef RX_DELAY_FAST_POW + #include +#endif #include // needed for PlatformIO #ifdef ENABLE_WIFI_INTERFACE @@ -176,7 +179,7 @@ void MyMesh::writeContactRespFrame(uint8_t code, const ContactInfo &contact) { out_frame[i++] = contact.type; out_frame[i++] = contact.flags; out_frame[i++] = contact.out_path_len; - memcpy(&out_frame[i], contact.out_path, MAX_PATH_SIZE); + contactPathTo64(&out_frame[i], contact.out_path, contact.out_path_len); i += MAX_PATH_SIZE; StrHelper::strzcpy((char *)&out_frame[i], contact.name, 32); i += 32; @@ -198,8 +201,8 @@ void MyMesh::updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, co i += PUB_KEY_SIZE; contact.type = frame[i++]; contact.flags = frame[i++]; - contact.out_path_len = frame[i++]; - memcpy(contact.out_path, &frame[i], MAX_PATH_SIZE); + contact.out_path_len = contactPathFrom64(contact.out_path, &frame[i + 1], frame[i]); + i++; i += MAX_PATH_SIZE; memcpy(contact.name, &frame[i], 32); i += 32; @@ -272,7 +275,11 @@ bool MyMesh::getCADEnabled() const { int MyMesh::calcRxDelay(float score, uint32_t air_time) const { if (_prefs.rx_delay_base <= 0.0f) return 0; +#ifdef RX_DELAY_FAST_POW + return (int)((fastPowf(_prefs.rx_delay_base, 0.85f - score) - 1.0f) * air_time); // no libm pow() on small MCUs +#else return (int)((pow(_prefs.rx_delay_base, 0.85f - score) - 1.0) * air_time); +#endif } uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { @@ -898,7 +905,7 @@ uint32_t MyMesh::calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t void MyMesh::onSendTimeout() {} MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store) - : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables), + : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(COMPANION_PACKET_POOL_SIZE), tables), _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _listener(NULL), _iter(0) { _iter_started = false; _cli_rescue = false; @@ -1332,6 +1339,9 @@ void MyMesh::handleCmdFrame(size_t len) { if (recipient) { updateContactFromFrame(*recipient, last_mod, cmd_frame, len); recipient->lastmod = last_mod; +#ifdef CONTACT_PIN_STORE + _store->pinContact(*recipient); // contacts the app adds or edits survive a reboot (store-specific limit) +#endif dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); } else { @@ -1340,6 +1350,9 @@ void MyMesh::handleCmdFrame(size_t len) { contact.lastmod = last_mod; contact.sync_since = 0; if (addContact(contact)) { +#ifdef CONTACT_PIN_STORE + _store->pinContact(contact); +#endif dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); } else { @@ -1351,6 +1364,9 @@ void MyMesh::handleCmdFrame(size_t len) { ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (recipient && removeContact(*recipient)) { _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); +#ifdef CONTACT_PIN_STORE + _store->unpinContact(pub_key); +#endif dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); } else { @@ -2071,12 +2087,21 @@ void MyMesh::saveContacts() { } void MyMesh::enterCLIRescue() { +#ifndef DISABLE_CLI_RESCUE _cli_rescue = true; cli_command[0] = 0; Serial.println("========= CLI Rescue ========="); +#else + // rescue CLI disabled: it prints text on Serial and needs a filesystem +#endif } bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { +#ifdef DISABLE_COMPANION_CLI + // text CLI not built (flash): radio settings still work through CMD_SET_RADIO_PARAMS and friends + (void)command; (void)sender_timestamp; (void)reply; + return false; +#else while (*command == ' ') command++; // skip leading spaces if (strlen(command) > 4 && command[2] == '|') { // optional prefix (for companion radio CLI) @@ -2208,9 +2233,11 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } return false; // not handled +#endif } void MyMesh::checkCLIRescueCmd() { +#ifndef DISABLE_CLI_RESCUE int len = strlen(cli_command); // `cli_command` must stay NUL-terminated within its bounds. If it ever isn't, // strlen() above can return >= sizeof(cli_command) and the loop below would @@ -2383,6 +2410,9 @@ void MyMesh::checkCLIRescueCmd() { cli_command[0] = 0; // reset command buffer } +#else + // rescue CLI disabled (see enterCLIRescue) +#endif } void MyMesh::checkSerialInterface() { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 52a22cb419..d5c8764e60 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -62,6 +62,10 @@ #define OFFLINE_QUEUE_SIZE 16 #endif +#ifndef COMPANION_PACKET_POOL_SIZE +#define COMPANION_PACKET_POOL_SIZE 16 +#endif + #ifndef BLE_NAME_PREFIX #define BLE_NAME_PREFIX "MeshCore-" #endif @@ -286,7 +290,9 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table int next_ack_idx; - #define ADVERT_PATH_TABLE_SIZE 16 + #ifndef ADVERT_PATH_TABLE_SIZE + #define ADVERT_PATH_TABLE_SIZE 16 + #endif AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table }; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 839d495833..a28b9661fc 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -104,6 +104,8 @@ MultiSerialInterface interface_manager; #elif defined(ESP32) #include DataStore store(SPIFFS, rtc_clock); +#elif defined(ASR650X_PLATFORM) + DataStore store; // SFLASH rows, no filesystem #endif /* GLOBAL OBJECTS */ @@ -181,6 +183,9 @@ void setup() { SPIFFS.begin(true); store.begin(); the_mesh.begin(); +#elif defined(ASR650X_PLATFORM) + store.begin(); + the_mesh.begin(); #else #error "need to define filesystem" #endif diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index 2e6ba7126d..6bed916d03 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -42,6 +42,10 @@ #include +#if CONTACT_OUT_PATH_SIZE < MAX_PATH_SIZE + #error "this file-based contact store writes 64-byte routes: build it with the default CONTACT_OUT_PATH_SIZE" +#endif + #define SEND_TIMEOUT_BASE_MILLIS 500 #define FLOOD_SEND_TIMEOUT_FACTOR 16.0f #define DIRECT_SEND_PERHOP_FACTOR 6.0f diff --git a/lib/ed25519/ge.c b/lib/ed25519/ge.c index 87c691bff5..c9efd05378 100644 --- a/lib/ed25519/ge.c +++ b/lib/ed25519/ge.c @@ -384,6 +384,45 @@ B is the Ed25519 base point (x,4/5) with x positive. */ void ge_scalarmult_base(ge_p3 *h, const unsigned char *a) { +#ifdef ED25519_SMALL_BASE_TABLE + /* Horner over radix-16 digits with the first table row only (4 doublings per digit). + Same recoding and the same constant-time select as below. */ + signed char e[64]; + signed char carry; + ge_p1p1 r; + ge_p2 s; + ge_precomp t; + int i; + + for (i = 0; i < 32; ++i) { + e[2 * i + 0] = (a[i] >> 0) & 15; + e[2 * i + 1] = (a[i] >> 4) & 15; + } + + /* each e[i] is between 0 and 15; e[63] is between 0 and 7 */ + carry = 0; + + for (i = 0; i < 63; ++i) { + e[i] += carry; + carry = e[i] + 8; + carry >>= 4; + e[i] -= carry << 4; + } + + e[63] += carry; + /* each e[i] is between -8 and 8; a = sum e[i] * 16^i */ + ge_p3_0(h); + + for (i = 63; i >= 0; i--) { + ge_p3_dbl(&r, h); ge_p1p1_to_p2(&s, &r); /* 2h */ + ge_p2_dbl(&r, &s); ge_p1p1_to_p2(&s, &r); /* 4h */ + ge_p2_dbl(&r, &s); ge_p1p1_to_p2(&s, &r); /* 8h */ + ge_p2_dbl(&r, &s); ge_p1p1_to_p3(h, &r); /* 16h */ + select(&t, 0, e[i]); + ge_madd(&r, h, &t); + ge_p1p1_to_p3(h, &r); + } +#else signed char e[64]; signed char carry; ge_p1p1 r; @@ -431,6 +470,7 @@ void ge_scalarmult_base(ge_p3 *h, const unsigned char *a) { ge_madd(&r, h, &t); ge_p1p1_to_p3(h, &r); } +#endif } diff --git a/lib/ed25519/precomp_data.h b/lib/ed25519/precomp_data.h index ff23986c39..92df11ad7d 100644 --- a/lib/ed25519/precomp_data.h +++ b/lib/ed25519/precomp_data.h @@ -43,7 +43,12 @@ static const ge_precomp Bi[8] = { /* base[i][j] = (j+1)*256^i*B */ -static const ge_precomp base[32][8] = { +#ifdef ED25519_SMALL_BASE_TABLE + #define ED25519_BASE_ROWS 1 /* 1B..8B only: ge_scalarmult_base() uses Horner (ge.c), saves ~30 KB */ +#else + #define ED25519_BASE_ROWS 32 +#endif +static const ge_precomp base[ED25519_BASE_ROWS][8] = { { { { 25967493, -14356035, 29566456, 3660896, -12694345, 4014787, 27544626, -11754271, -6079156, 2047605 }, @@ -86,6 +91,7 @@ static const ge_precomp base[32][8] = { { 27431194, 8222322, 16448760, -3907995, -18707002, 11938355, -32961401, -2970515, 29551813, 10109425 }, }, }, +#ifndef ED25519_SMALL_BASE_TABLE { { { -13657040, -13155431, -31283750, 11777098, 21447386, 6519384, -2378284, -1627556, 10092783, -4764171 }, @@ -1388,4 +1394,5 @@ static const ge_precomp base[32][8] = { { -20430234, 14955537, -24126347, 8124619, -5369288, -5990470, 30468147, -13900640, 18423289, 4177476 }, }, }, +#endif }; diff --git a/platformio.ini b/platformio.ini index f296f83821..b2536c6505 100644 --- a/platformio.ini +++ b/platformio.ini @@ -166,14 +166,52 @@ test_framework = googletest build_flags = -std=c++17 -I src -I test/mocks + -I lib/ed25519 + -D ED25519_NO_SEED test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = + test_kiss_modem + test_config_serializer_noop build_src_filter = -<*> +<../src/Utils.cpp> +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../lib/ed25519/*.c> + -<../lib/ed25519/seed.c> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_ed25519_small] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I lib/ed25519 + -D ED25519_SMALL_BASE_TABLE + -D ED25519_NO_SEED +test_build_src = yes +test_filter = test_ed25519_vectors +build_src_filter = + -<*> + +<../lib/ed25519/*.c> + -<../lib/ed25519/seed.c> +lib_deps = + google/googletest @ 1.17.0 + +[env:native_noop_config] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I src + -I test/mocks + -D CONFIG_SERIALIZER_NOOP +test_build_src = yes +test_filter = test_config_serializer_noop +build_src_filter = + -<*> + +<../src/helpers/ConfigSerializer.cpp> + +<../src/helpers/DynamicConfigSerializer.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index c0610b7f8a..ee21f06f31 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -5,6 +5,9 @@ #endif #include +#ifdef RX_DELAY_FAST_POW + #include +#endif namespace mesh { @@ -53,7 +56,11 @@ void Dispatcher::updateTxBudget() { } int Dispatcher::calcRxDelay(float score, uint32_t air_time) const { +#ifdef RX_DELAY_FAST_POW + return (int) ((fastPow10f(0.85f - score) - 1.0f) * air_time); // no libm pow()/double maths on small MCUs +#else return (int) ((pow(10, 0.85f - score) - 1.0) * air_time); +#endif } uint32_t Dispatcher::getCADFailRetryDelay() const { diff --git a/src/Identity.cpp b/src/Identity.cpp index 51a01ae71d..d5a91ec567 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -4,6 +4,10 @@ #include #include +#ifdef IDENTITY_CRYPTO_ON_BIG_STACK +#include // routes ed25519_* through a larger heap-allocated stack +#endif + #ifdef USE_CC310_HW_CRYPTO #include #include "nrf_cc310/include/crys_ec_edw_api.h" @@ -31,6 +35,8 @@ bool Identity::verify(const uint8_t* sig, const uint8_t* message, int msg_len) c (uint8_t*)pub_key, CRYS_ECEDW_MOD_SIZE_IN_BYTES, (uint8_t*)message, (size_t)msg_len, &cc310_tmp); return rc == CRYS_OK; +#elif defined(IDENTITY_CRYPTO_ON_BIG_STACK) + return asr650xEd25519Verify(sig, this->pub_key, message, msg_len); #elif 0 // NOTE: memory corruption bug was found in this function!! return ed25519_verify(sig, message, msg_len, pub_key); diff --git a/src/helpers/ArduinoSerialInterface.cpp b/src/helpers/ArduinoSerialInterface.cpp index a01fa5866f..0761e0aad7 100644 --- a/src/helpers/ArduinoSerialInterface.cpp +++ b/src/helpers/ArduinoSerialInterface.cpp @@ -37,9 +37,17 @@ size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) { } size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) { +#ifdef SERIAL_FRAME_STRICT + // a frame that stalls mid-way (truncated by the sender) is abandoned instead of swallowing the next frame; + // only when nothing is waiting: a busy main loop must not drop a frame whose bytes are already buffered + if (_state != RECV_STATE_IDLE && !_serial->available() && (millis() - _last_byte_ms) > 100) _state = RECV_STATE_IDLE; +#endif while (_serial->available()) { int c = _serial->read(); if (c < 0) break; +#ifdef SERIAL_FRAME_STRICT + _last_byte_ms = millis(); +#endif switch (_state) { case RECV_STATE_IDLE: @@ -62,7 +70,11 @@ size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) { } rx_len++; if (rx_len >= _frame_len) { // received a complete frame? +#ifdef SERIAL_FRAME_STRICT + if (_frame_len > MAX_FRAME_SIZE) { _state = RECV_STATE_IDLE; continue; } // oversize: drop, do not execute +#else if (_frame_len > MAX_FRAME_SIZE) _frame_len = MAX_FRAME_SIZE; // truncate +#endif memcpy(dest, rx_buf, _frame_len); _state = RECV_STATE_IDLE; // reset state, for next frame return _frame_len; diff --git a/src/helpers/ArduinoSerialInterface.h b/src/helpers/ArduinoSerialInterface.h index c4086353aa..10ab0890b3 100644 --- a/src/helpers/ArduinoSerialInterface.h +++ b/src/helpers/ArduinoSerialInterface.h @@ -8,6 +8,9 @@ class ArduinoSerialInterface : public BaseSerialInterface { uint8_t _state; uint16_t _frame_len; uint16_t rx_len; +#ifdef SERIAL_FRAME_STRICT + unsigned long _last_byte_ms = 0; +#endif Stream* _serial; uint8_t rx_buf[MAX_FRAME_SIZE]; diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 28592415ca..2d1c626991 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -353,7 +353,7 @@ bool BaseChatMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const ui bool BaseChatMesh::onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) { // NOTE: default impl, we just replace the current 'out_path' regardless, whenever sender sends us a new out_path. // FUTURE: could store multiple out_paths per contact, and try to find which is the 'best'(?) - from.out_path_len = mesh::Packet::copyPath(from.out_path, out_path, out_path_len); // store a copy of path, for sendDirect() + from.out_path_len = contactPathFromPacket(from.out_path, out_path, out_path_len); // store a copy of path, for sendDirect() from.lastmod = getRTCClock()->getCurrentTime(); onContactPathUpdated(from); diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 835384b741..816fcd465d 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -38,7 +38,9 @@ class ContactsIterator { #define MAX_CONTACTS 32 #endif -#define MAX_ANON_CONTACTS 8 +#ifndef MAX_ANON_CONTACTS + #define MAX_ANON_CONTACTS 8 +#endif #ifndef MAX_CONNECTIONS #define MAX_CONNECTIONS 16 diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index daf834b2b1..814c7554f7 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -1,3 +1,4 @@ +#ifndef CONFIG_SERIALIZER_NOOP #include "ConfigSerializer.h" #include // atoi/atol/atof (Arduino.h pulls this in on-device, native builds do not) @@ -333,3 +334,4 @@ void ConfigSerializer::def(const char* key, ConfigSerializer& sub_obj) { } } } +#endif // CONFIG_SERIALIZER_NOOP diff --git a/src/helpers/ConfigSerializer.h b/src/helpers/ConfigSerializer.h index e55b1b120f..9d6820a793 100644 --- a/src/helpers/ConfigSerializer.h +++ b/src/helpers/ConfigSerializer.h @@ -2,6 +2,41 @@ #include +#ifdef CONFIG_SERIALIZER_NOOP +// Boards that persist their prefs as a fixed binary record (no filesystem) build this no-op variant: same +// interface, but def() does nothing and there is no text reader/writer (saves several KB of flash and RAM). +class ConfigSerializer { + bool _dirty = false; + +protected: + ConfigSerializer() { } + + void def(const char*, char*, size_t) { } + void def(const char*, void*, size_t) { } + void def(const char*, int32_t&) { } + void def(const char*, int16_t&) { } + void def(const char*, int8_t&) { } + void def(const char*, uint32_t&) { } + void def(const char*, uint16_t&) { } + void def(const char*, uint8_t&) { } + void def(const char*, float&) { } + void def(const char*, double&) { } + void def(const char*, bool&) { } + void def(const char*, ConfigSerializer&) { } + + virtual void structure() = 0; + + void markDirty() { _dirty = true; } + +public: + bool loadSerial(Stream&) { return false; } + bool saveSerial(Stream&) { return false; } + + virtual bool isDirty() const { return _dirty; } + virtual void clearDirty() { _dirty = false; } +}; +#else + #ifndef CONFIG_MAX_DEPTH #define CONFIG_MAX_DEPTH 8 #endif @@ -74,3 +109,4 @@ class ConfigSerializer { virtual bool isDirty() const { return _dirty; } virtual void clearDirty() { _dirty = false; } }; +#endif // CONFIG_SERIALIZER_NOOP diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index 5a156c8292..5b6819cae7 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -2,6 +2,7 @@ #include #include +#include "ContactPath.h" #define OUT_PATH_UNKNOWN 0xFF @@ -12,7 +13,7 @@ struct ContactInfo { uint8_t flags; uint8_t out_path_len; mutable bool shared_secret_valid; // flag to indicate if shared_secret has been calculated - uint8_t out_path[MAX_PATH_SIZE]; + uint8_t out_path[CONTACT_OUT_PATH_SIZE]; uint32_t last_advert_timestamp; // by THEIR clock uint32_t lastmod; // by OUR clock int32_t gps_lat, gps_lon; // 6 dec places diff --git a/src/helpers/ContactPath.h b/src/helpers/ContactPath.h new file mode 100644 index 0000000000..c92a571894 --- /dev/null +++ b/src/helpers/ContactPath.h @@ -0,0 +1,71 @@ +#pragma once + +#include // MAX_PATH_SIZE +#include +#include + +// Bytes reserved for a contact's stored direct route. Boards with very little RAM can trade long direct +// routes for contact table space: a route that does not fit is not stored (OUT_PATH_UNKNOWN), and messages to +// that contact go out flood instead. The default keeps the full MAX_PATH_SIZE. +#ifndef CONTACT_OUT_PATH_SIZE +#define CONTACT_OUT_PATH_SIZE MAX_PATH_SIZE +#endif + +#ifndef OUT_PATH_UNKNOWN +#define OUT_PATH_UNKNOWN 0xFF +#endif + +// Number of bytes of an encoded path_len (upper 2 bits: hash size - 1, lower 6 bits: hop count), +// or 0xFF for the reserved hash size 4. +inline uint8_t contactPathBytes(uint8_t path_len) { + uint8_t hash_size = (path_len >> 6) + 1; + if (hash_size == 4) return 0xFF; + return (uint8_t)((path_len & 63) * hash_size); +} + +// Stores a route into a contact's out_path. Returns the stored path_len, or OUT_PATH_UNKNOWN when it does not +// fit. +inline uint8_t contactStorePath(uint8_t *dest, const uint8_t *src, uint8_t path_len) { + if (path_len == OUT_PATH_UNKNOWN) return OUT_PATH_UNKNOWN; + uint8_t n = contactPathBytes(path_len); + if (n > CONTACT_OUT_PATH_SIZE || n > MAX_PATH_SIZE) return OUT_PATH_UNKNOWN; + memcpy(dest, src, n); + return path_len; +} + +// Protocol frames and files always carry MAX_PATH_SIZE path bytes: copies the stored route and zeroes the +// rest. +inline void contactWritePath64(uint8_t *dest64, const uint8_t *stored, uint8_t path_len) { + memset(dest64, 0, MAX_PATH_SIZE); + if (path_len == OUT_PATH_UNKNOWN) return; + uint8_t n = contactPathBytes(path_len); + if (n > CONTACT_OUT_PATH_SIZE) return; // never stored that way: nothing valid to copy + memcpy(dest64, stored, n); +} + +// Call-site helpers. With the default CONTACT_OUT_PATH_SIZE they compile to exactly the operation each call +// site used before (so other boards are unchanged); with a smaller size they clamp as above. +#if CONTACT_OUT_PATH_SIZE < MAX_PATH_SIZE +inline uint8_t contactPathFromPacket(uint8_t *dest, const uint8_t *src, uint8_t path_len) { + return contactStorePath(dest, src, path_len); +} +inline uint8_t contactPathFrom64(uint8_t *dest, const uint8_t *src64, uint8_t path_len) { + return contactStorePath(dest, src64, path_len); +} +inline void contactPathTo64(uint8_t *dest64, const uint8_t *stored, uint8_t path_len) { + contactWritePath64(dest64, stored, path_len); +} +#else +#include +inline uint8_t contactPathFromPacket(uint8_t *dest, const uint8_t *src, uint8_t path_len) { + return mesh::Packet::copyPath(dest, src, path_len); +} +inline uint8_t contactPathFrom64(uint8_t *dest, const uint8_t *src64, uint8_t path_len) { + memcpy(dest, src64, MAX_PATH_SIZE); + return path_len; +} +inline void contactPathTo64(uint8_t *dest64, const uint8_t *stored, uint8_t path_len) { + (void)path_len; + memcpy(dest64, stored, MAX_PATH_SIZE); +} +#endif diff --git a/src/helpers/DynamicConfigSerializer.cpp b/src/helpers/DynamicConfigSerializer.cpp index b9c6c267ef..0984965b07 100644 --- a/src/helpers/DynamicConfigSerializer.cpp +++ b/src/helpers/DynamicConfigSerializer.cpp @@ -1,3 +1,4 @@ +#ifndef CONFIG_SERIALIZER_NOOP #include "DynamicConfigSerializer.h" #include @@ -95,3 +96,4 @@ void DynamicConfigSerializer::structure() { setByKeyPrv(_context->getKey(getDepth()), _context->getToken()); } } +#endif // CONFIG_SERIALIZER_NOOP diff --git a/src/helpers/DynamicConfigSerializer.h b/src/helpers/DynamicConfigSerializer.h index e5dc4c7c95..e89bd550f6 100644 --- a/src/helpers/DynamicConfigSerializer.h +++ b/src/helpers/DynamicConfigSerializer.h @@ -6,6 +6,26 @@ #define MAX_DYNAMIC_CONFG 128 #endif +#ifdef CONFIG_SERIALIZER_NOOP +// No local text store: keys go to the fallback store only. +class DynamicConfigSerializer : public ConfigSerializer, public KeyValueStore { + KeyValueStore* _fallback; + +protected: + void structure() override { } + +public: + DynamicConfigSerializer(KeyValueStore* fallback = NULL) : _fallback(fallback) { } + + bool setByKey(const char* key, const char* value) override { + if (_fallback && _fallback->setByKey(key, value)) { markDirty(); return true; } + return false; + } + bool getByKey(const char* key, char* value, size_t max_len) override { + return _fallback && _fallback->getByKey(key, value, max_len); + } +}; +#else class DynamicConfigSerializer : public ConfigSerializer, public KeyValueStore { char _config[MAX_DYNAMIC_CONFG]; KeyValueStore* _fallback; @@ -21,3 +41,4 @@ class DynamicConfigSerializer : public ConfigSerializer, public KeyValueStore { bool setByKey(const char* key, const char* value) override; bool getByKey(const char* key, char* value, size_t max_len) override; }; +#endif // CONFIG_SERIALIZER_NOOP diff --git a/src/helpers/FastPow.h b/src/helpers/FastPow.h new file mode 100644 index 0000000000..8c2d73f824 --- /dev/null +++ b/src/helpers/FastPow.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +// Single-precision powers without libm, for calcRxDelay() on boards built with RX_DELAY_FAST_POW, where +// libm's pow() and the double-precision routines it pulls in do not fit in flash. Relative error is well +// under 1 %. + +// 2^y: integer power of two times a 4th-order polynomial for the fractional part +inline float fastPow2f(float y) { + if (y > 30.0f) y = 30.0f; + if (y < -30.0f) y = -30.0f; + int k = (int)y; + if ((float)k > y) k--; // floor + float f = y - (float)k; // [0, 1) + float p = 1.0f + f * (0.6931472f + f * (0.2402265f + f * (0.0555041f + f * 0.0096181f))); + return k >= 0 ? p * (float)(1u << k) : p / (float)(1u << (-k)); +} + +// log2(x) for x > 0: exponent from the IEEE-754 bits, mantissa m in [1, 2) through ln(m) = 2 +// atanh((m-1)/(m+1)) +inline float fastLog2f(float x) { + uint32_t bits; + memcpy(&bits, &x, 4); + int e = (int)((bits >> 23) & 0xFF) - 127; + bits = (bits & 0x007FFFFFu) | 0x3F800000u; // mantissa as a float in [1, 2) + float m; + memcpy(&m, &bits, 4); + float z = (m - 1.0f) / (m + 1.0f); // [0, 1/3) + float z2 = z * z; + float ln_m = 2.0f * z * (1.0f + z2 * (0.3333333f + z2 * (0.2f + z2 * 0.1428571f))); + return (float)e + ln_m * 1.4426950f; // 1 / ln(2) +} + +// base^x for base > 0 +inline float fastPowf(float base, float x) { + return fastPow2f(x * fastLog2f(base)); +} + +// 10^x +inline float fastPow10f(float x) { + return fastPow2f(x * 3.3219281f); +} diff --git a/src/helpers/IdentityStore.h b/src/helpers/IdentityStore.h index d0d7ee457e..2d9d2a43dd 100644 --- a/src/helpers/IdentityStore.h +++ b/src/helpers/IdentityStore.h @@ -1,5 +1,7 @@ #pragma once +#if !defined(ASR650X_PLATFORM) // ASR650x boards have no filesystem (identity is kept in SFLASH rows) + #if defined(ESP32) || defined(RP2040_PLATFORM) #include #define FILESYSTEM fs::FS @@ -24,3 +26,5 @@ class IdentityStore { bool save(const char *name, const mesh::LocalIdentity& id); bool save(const char *name, const mesh::LocalIdentity& id, const char display_name[]); }; + +#endif // !ASR650X_PLATFORM diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index 956f36faa6..75e25844fa 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -6,7 +6,9 @@ #include #endif -#define MAX_PACKET_HASHES (128+32) +#ifndef MAX_PACKET_HASHES + #define MAX_PACKET_HASHES (128+32) +#endif class SimpleMeshTables : public mesh::MeshTables { uint8_t _hashes[MAX_PACKET_HASHES*MAX_HASH_SIZE]; diff --git a/src/helpers/asr650x/BigStack.cpp b/src/helpers/asr650x/BigStack.cpp new file mode 100644 index 0000000000..184b7c3888 --- /dev/null +++ b/src/helpers/asr650x/BigStack.cpp @@ -0,0 +1,46 @@ +#include "BigStack.h" + +#include +#include + +static uint8_t *g_stack = 0; +static size_t g_size = 0; + +bool asr650xBigstackInit(size_t bytes) { + if (g_stack) return true; + g_stack = (uint8_t *)malloc(bytes); + if (!g_stack) return false; + g_size = bytes; + memset(g_stack, 0xA5, bytes); + return true; +} + +bool asr650xBigstackReady() { + return g_stack != 0; +} + +size_t asr650xBigstackHighwater() { + if (!g_stack) return 0; + size_t i = 0; + while (i < g_size && g_stack[i] == 0xA5) + i++; + return g_size - i; +} + +void asr650xCallOnBigStack(void (*fn)(void *), void *ctx) { + if (!g_stack) { + fn(ctx); + return; + } // not initialised: stay on the current stack + uint32_t top = ((uint32_t)(g_stack + g_size)) & ~7u; // 8-byte aligned top of stack + register void *r0 asm("r0") = ctx; + register void (*r1)(void *) asm("r1") = fn; + register uint32_t r2 asm("r2") = top; + asm volatile("mov r4, sp\n\t" + "mov sp, r2\n\t" + "blx r1\n\t" + "mov sp, r4\n\t" + : "+r"(r0), "+r"(r1), "+r"(r2) + : + : "r3", "r4", "r12", "lr", "memory", "cc"); +} diff --git a/src/helpers/asr650x/BigStack.h b/src/helpers/asr650x/BigStack.h new file mode 100644 index 0000000000..e06339a08f --- /dev/null +++ b/src/helpers/asr650x/BigStack.h @@ -0,0 +1,10 @@ +#pragma once +#include +#include + +// Runs memory-hungry crypto on a separate stack taken from the (mostly empty) heap, because the +// linker script fixes the main stack at 2 KB, and Ed25519 needs ~1.8 KB on its own. +bool asr650xBigstackInit(size_t bytes); +bool asr650xBigstackReady(); // false if the heap could not provide the stack +size_t asr650xBigstackHighwater(); // bytes of the big stack ever used +void asr650xCallOnBigStack(void (*fn)(void *), void *ctx); diff --git a/src/helpers/asr650x/Button.h b/src/helpers/asr650x/Button.h new file mode 100644 index 0000000000..bdff5632b2 --- /dev/null +++ b/src/helpers/asr650x/Button.h @@ -0,0 +1,57 @@ +#pragma once +#include + +namespace asr650x { + +// Debounced press detector for the USER key (active LOW, shared with the battery divider control). +class Button { + static const uint32_t DEBOUNCE_MS = 30; + bool _down; // raw level seen at the last update + bool _armed; // a press may start (the key was seen released after the last hold-off/press) + bool _fired; + uint32_t _t_down; + uint32_t _holdoff_until; + bool _holdoff_active; + + static bool before(uint32_t a, uint32_t b) { return (int32_t)(a - b) < 0; } // wrap-safe a < b + +public: + Button() + : _down(false), _armed(true), _fired(false), _t_down(0), _holdoff_until(0), _holdoff_active(false) {} + + void holdoff(uint32_t now_ms, uint32_t ms) { // e.g. right after getBatteryVoltage() released the pin + _holdoff_until = now_ms + ms; + _holdoff_active = true; + _armed = false; // anything pressed before/while held off must be released first + _down = false; + _fired = false; + } + + // true exactly once per accepted press + bool update(bool pressed, uint32_t now_ms) { + if (_holdoff_active) { + if (before(now_ms, _holdoff_until)) return false; + _holdoff_active = false; + } + if (!pressed) { + _armed = true; + _down = false; + _fired = false; + return false; + } + if (!_armed) return false; // still the same (or a held-off) press + if (!_down) { + _down = true; + _t_down = now_ms; + return false; + } + if (!_fired && (uint32_t)(now_ms - _t_down) >= DEBOUNCE_MS) { + _fired = true; + _armed = false; + return true; + } + return false; + } +}; + +} // namespace asr650x diff --git a/src/helpers/asr650x/CubeCellRTC.h b/src/helpers/asr650x/CubeCellRTC.h new file mode 100644 index 0000000000..b59367b15b --- /dev/null +++ b/src/helpers/asr650x/CubeCellRTC.h @@ -0,0 +1,14 @@ +#pragma once +#include "EpochClock.h" + +#include +#include + +class CubeCellRTC : public mesh::RTCClock { + asr650x::EpochClock _clock; + +public: + uint32_t getCurrentTime() override { return _clock.now(millis()); } + void setCurrentTime(uint32_t t) override { _clock.set(t, millis()); } + bool synced() const { return _clock.synced(); } +}; diff --git a/src/helpers/asr650x/CubeCellRadio.cpp b/src/helpers/asr650x/CubeCellRadio.cpp new file mode 100644 index 0000000000..49bf4a0b6e --- /dev/null +++ b/src/helpers/asr650x/CubeCellRadio.cpp @@ -0,0 +1,238 @@ +#include "CubeCellRadio.h" + +#include "LoRaWan_102.h" // LoraWan102 library headers only: ::Radio, RadioEvents_t, MODEM_LORA (LoRaWan_APP.h would also link the LoRa/AT/display libraries) +#include "RadioParams.h" +#include "TxGate.h" +extern "C" { // these board headers have no C++ guards; the functions are C +#include "gpio.h" // GpioSetInterrupt / GpioRemoveInterrupt / GpioRead +#include "sx126x-board.h" // extern SX126x (DIO1 pin) +} +#include + +extern "C" { +void RadioOnDioIrq(void); // radio.c: DIO1 handler (sets IrqFired and runs RadioIrqProcess) +unsigned char CyEnterCriticalSection(void); // CyLib.h +void CyExitCriticalSection(unsigned char saved); +} + +static RadioEvents_t g_events; +static CubeCellRadio *g_self = 0; + +static void cbRxDone(uint8_t *p, uint16_t n, int16_t rssi, int8_t snr) { + if (g_self) g_self->handleRxDone(p, n, rssi, snr); +} +static void cbRxErr() { + if (g_self) g_self->handleRxError(); +} +static void cbTxDone() { + if (g_self) g_self->handleTxDone(false); +} +static void cbTxTimeout() { + if (g_self) g_self->handleTxDone(true); +} + +// While main-context code talks to the SX1262 over SPI the DIO1 handler must not run (it would issue its own +// SPI commands in the middle of ours). The handler is detached for the duration; on release it is re-attached +// and, if DIO1 is already high (an edge arrived meanwhile and was not latched), serviced right away from this +// context. +struct RadioLock { + RadioLock() { GpioRemoveInterrupt(&SX126x.DIO1); } + ~RadioLock() { + GpioSetInterrupt(&SX126x.DIO1, RISING, IRQ_HIGH_PRIORITY, RadioOnDioIrq); + if (GpioRead(&SX126x.DIO1)) RadioOnDioIrq(); + } +}; + +struct CriticalSection { + unsigned char saved; + CriticalSection() : saved(CyEnterCriticalSection()) {} + ~CriticalSection() { CyExitCriticalSection(saved); } +}; + +CubeCellRadio::CubeCellRadio() + : _state(ST_IDLE), _rx_ready(false), _tx_done(false), _tx_timed_out(false), _rx_len(0), _last_rssi(0), + _last_snr(0), _freq(920.25f), _bw(62.5f), _sf(8), _cr(5), _pwr(14), _boost(true), _inited(false), + _noise_floor(0), _last_nf_sample(0), _n_recv(0), _n_sent(0), _n_err(0), _n_dropped(0), _n_txto(0), + _n_blocked(0) {} + +void CubeCellRadio::init() { + if (_inited) return; + g_self = this; + g_events.RxDone = cbRxDone; + g_events.RxError = cbRxErr; + g_events.RxTimeout = cbRxErr; + g_events.TxDone = cbTxDone; + g_events.TxTimeout = cbTxTimeout; + ::Radio.Init(&g_events); + _inited = true; +} + +uint32_t CubeCellRadio::random32() { + init(); + return ::Radio.Random(); +} + +uint8_t asr650x_boot_pool[4]; // copy of the seed fold (watch builds print it to compare boots) + +uint32_t CubeCellRadio::getRngSeed() { + uint32_t fold = 0; + for (int i = 0; i < 8; i++) { + uint32_t v = random32(); + fold ^= (v << i) | (v >> (32 - i)); + } + memcpy(asr650x_boot_pool, &fold, sizeof(asr650x_boot_pool)); + return fold; +} + +static uint32_t freqHz(float mhz) { + return ((uint32_t)(mhz * 1000.0f + 0.5f)) * 1000UL; +} + +void CubeCellRadio::applyRx() { + ::Radio.SetPublicNetwork(false); // private sync word 0x1424 (MeshCore) + ::Radio.SetChannel(freqHz(_freq)); + ::Radio.SetRxConfig(MODEM_LORA, asr650x::bwIndex(_bw), asr650x::sfClamp(_sf), asr650x::crIndex(_cr), 0, + asr650x::preambleForSf(asr650x::sfClamp(_sf)), 0, false, 0, true, 0, 0, false, true); +} + +void CubeCellRadio::applyTx(uint32_t timeout_ms) { + ::Radio.SetPublicNetwork(false); + ::Radio.SetChannel(freqHz(_freq)); + ::Radio.SetTxConfig(MODEM_LORA, _pwr, 0, asr650x::bwIndex(_bw), asr650x::sfClamp(_sf), + asr650x::crIndex(_cr), asr650x::preambleForSf(asr650x::sfClamp(_sf)), false, true, 0, 0, + false, timeout_ms); +} + +void CubeCellRadio::startRx() { + { + RadioLock lock; + applyRx(); + ::Radio.Rx(0); + } + _state = ST_RX; +} + +void CubeCellRadio::begin() { + init(); + _rx_ready = false; + _tx_done = false; + startRx(); +} + +void CubeCellRadio::loop() { + if (GpioRead(&SX126x.DIO1)) { + RadioLock service; + } // lost/late DIO1 edge: service it from here + if (_state == ST_IDLE && !_rx_ready) startRx(); + if (_state == ST_RX && !_rx_ready) { // slow noise-floor estimate while idle in RX + uint32_t now = millis(); + if (now - _last_nf_sample >= 100) { + _last_nf_sample = now; + int16_t r; + { + RadioLock lock; + r = ::Radio.Rssi(MODEM_LORA); + } + if (_noise_floor == 0) + _noise_floor = r; + else if (r < _noise_floor + 14) + _noise_floor = (int16_t)((_noise_floor * 15 + r) / 16); + } + } +} + +int CubeCellRadio::recvRaw(uint8_t *bytes, int sz) { + int len = 0; + if (_rx_ready) { + CriticalSection cs; // the callback (interrupt) may overwrite the buffer + len = _rx_len; + if (len > sz) len = sz; + if (len < 0) len = 0; + memcpy(bytes, _rx_buf, len); + _rx_ready = false; + if (len > 0) _n_recv++; + _state = ST_IDLE; // restart RX below + } + if (_state == ST_IDLE) startRx(); + return len; +} + +uint32_t CubeCellRadio::getEstAirtimeFor(int len_bytes) { + return asr650x::airtimeMs(len_bytes, _bw, _sf, _cr, asr650x::preambleForSf(asr650x::sfClamp(_sf))); +} + +float CubeCellRadio::packetScore(float snr, int packet_len) { + return asr650x::packetScore(snr, _sf, packet_len); +} + +bool CubeCellRadio::startSendRaw(const uint8_t *bytes, int len) { + if (!asr650xTxAllowed()) { + _n_blocked++; + return false; + } // never transmit as the unprovisioned placeholder + if (len <= 0 || len > 255) return false; + uint32_t timeout = getEstAirtimeFor(len) * 2 + 500; // the driver's timer must outlast the real air time + _tx_done = false; + _tx_timed_out = false; + _state = ST_TX; + { + RadioLock lock; + applyTx(timeout); + ::Radio.Send((uint8_t *)bytes, (uint8_t)len); + } + return true; +} + +bool CubeCellRadio::isSendComplete() { + if (_state == ST_TX && _tx_done) { + _tx_done = false; + if (!_tx_timed_out) _n_sent++; // a timed-out send is not a completed send + return true; + } + return false; +} + +void CubeCellRadio::onSendFinished() { + _state = ST_IDLE; +} // loop()/recvRaw() restart RX + +void CubeCellRadio::setParams(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr) { + _freq = freq_mhz; + _bw = asr650x::bwValue(asr650x::bwIndex(bw_khz)); // the bandwidth the chip really uses + _sf = asr650x::sfClamp(sf); + _cr = (uint8_t)(asr650x::crIndex(cr) + 4); + if (_inited && _state == ST_RX) _state = ST_IDLE; // re-apply on the next loop() +} + +void CubeCellRadio::setTxPower(int8_t dbm) { + _pwr = dbm < -9 ? -9 : (dbm > 22 ? 22 : dbm); +} + +void CubeCellRadio::handleRxDone(uint8_t *payload, uint16_t size, int16_t rssi, int8_t snr) { + if (_state == ST_TX) return; // not expected while transmitting + if (_rx_ready) { + _n_dropped++; + return; + } // previous packet not consumed yet: drop the new one, counted + if (size > sizeof(_rx_buf)) size = sizeof(_rx_buf); + memcpy(_rx_buf, payload, size); + _rx_len = size; + _last_rssi = rssi; + _last_snr = snr; + _rx_ready = true; + _state = ST_IDLE; +} + +void CubeCellRadio::handleRxError() { + if (_state == ST_TX) return; + _n_err++; + _state = ST_IDLE; +} + +void CubeCellRadio::handleTxDone(bool timeout) { + if (timeout) { + _n_txto++; + _tx_timed_out = true; + } + _tx_done = true; // a timed-out send still ends the TX state +} diff --git a/src/helpers/asr650x/CubeCellRadio.h b/src/helpers/asr650x/CubeCellRadio.h new file mode 100644 index 0000000000..4e5a0011c8 --- /dev/null +++ b/src/helpers/asr650x/CubeCellRadio.h @@ -0,0 +1,69 @@ +#pragma once +#include + +// SX1262 driver for the ASR6502 on top of the Heltec radio API (::Radio.*). The Heltec header is only +// included in CubeCellRadio.cpp. State machine: RX (continuous) <-> TX; every RxDone/RxError/TxDone/TxTimeout +// returns to RX. +// +// Threading: the Heltec driver runs RxDone/RxError/TxDone (DIO1 interrupt) and TxTimeout (timer interrupt) +// INSIDE interrupts. Fields written by the callbacks are volatile and are read/copied inside a critical +// section; every main-context ::Radio.* call runs with the DIO1 interrupt detached (RadioLock in the .cpp). +class CubeCellRadio : public mesh::Radio { + enum { ST_IDLE = 0, ST_RX = 1, ST_TX = 2 }; + volatile uint8_t _state; + volatile bool _rx_ready, _tx_done, _tx_timed_out; + uint8_t _rx_buf[255]; + volatile uint16_t _rx_len; + volatile int16_t _last_rssi; + volatile int8_t _last_snr; + float _freq, _bw; + uint8_t _sf, _cr; + int8_t _pwr; + bool _boost, _inited; + int16_t _noise_floor; + uint32_t _last_nf_sample; + uint32_t _n_recv, _n_sent; + volatile uint32_t _n_err, _n_dropped, _n_txto; + uint32_t _n_blocked; + + void applyRx(); + void applyTx(uint32_t timeout_ms); + void startRx(); + +public: + CubeCellRadio(); + void init(); // ::Radio.Init (idempotent) + uint32_t random32(); // ::Radio.Random(); only before begin() + uint32_t getRngSeed(); // fold of 8 Radio.Random() values (wideband RSSI noise); before begin() + void begin() override; + void loop() override; + int recvRaw(uint8_t *bytes, int sz) override; + uint32_t getEstAirtimeFor(int len_bytes) override; + float packetScore(float snr, int packet_len) override; + bool startSendRaw(const uint8_t *bytes, int len) override; + bool isSendComplete() override; + void onSendFinished() override; + bool isInRecvMode() const override { return _state == ST_RX; } + int getNoiseFloor() const override { return _noise_floor; } + float getLastRSSI() const override { return _last_rssi; } + float getLastSNR() const override { return _last_snr; } + + void setParams(float freq_mhz, float bw_khz, uint8_t sf, uint8_t cr); + void setTxPower(int8_t dbm); + bool setRxBoostedGainMode(bool en) { + _boost = en; + return false; + } // not supported: Radio.Rx() is used, not RxBoosted + bool getRxBoostedGainMode() const { return _boost; } + uint32_t getPacketsRecv() const { return _n_recv; } + uint32_t getPacketsSent() const { return _n_sent; } + uint32_t getPacketsRecvErrors() const { + return _n_err + _n_dropped; + } // CRC/header errors + packets lost to a busy buffer + uint32_t getPacketsBlocked() const { return _n_blocked; } + + // called from the static Heltec callbacks (interrupt context) + void handleRxDone(uint8_t *payload, uint16_t size, int16_t rssi, int8_t snr); + void handleRxError(); + void handleTxDone(bool timeout); +}; diff --git a/src/helpers/asr650x/EpochClock.h b/src/helpers/asr650x/EpochClock.h new file mode 100644 index 0000000000..2472e07b15 --- /dev/null +++ b/src/helpers/asr650x/EpochClock.h @@ -0,0 +1,37 @@ +#pragma once +#include + +namespace asr650x { + +// Wall clock = epoch + accumulated monotonic milliseconds. Lost on reset (the app or GPS sets it again). +// now() must be called at least once every ~49 days so a 32-bit millis() wrap is seen once. +class EpochClock { + uint32_t _epoch; + uint32_t _last_ms; + uint64_t _rem_ms; // sub-second remainder, < 1000 after each now() + bool _synced; // true once set() was called (by the app or by GPS) + +public: + explicit EpochClock(uint32_t epoch_at_boot = 1735689600u) // 2025-01-01 until the app sets time + : _epoch(epoch_at_boot), _last_ms(0), _rem_ms(0), _synced(false) {} + + uint32_t now(uint32_t ms) { + uint32_t delta = ms - _last_ms; // unsigned arithmetic handles one wrap + _last_ms = ms; + _rem_ms += delta; + _epoch += (uint32_t)(_rem_ms / 1000); + _rem_ms %= 1000; + return _epoch; + } + + void set(uint32_t epoch, uint32_t ms) { + _epoch = epoch; + _last_ms = ms; + _rem_ms = 0; + _synced = true; + } + + bool synced() const { return _synced; } +}; + +} // namespace asr650x diff --git a/src/helpers/asr650x/FlashRecord.h b/src/helpers/asr650x/FlashRecord.h new file mode 100644 index 0000000000..208254e0ba --- /dev/null +++ b/src/helpers/asr650x/FlashRecord.h @@ -0,0 +1,146 @@ +#pragma once +#include +#include +#include + +// On-flash record (SFLASH user row). Explicit little-endian layout: +// 0..3 "ASR1" ("KTM1" in records from early builds, still accepted) | 4 version | 5 flags(bit0 identity, +// bit1 prefs) | 6..7 0 8..71 prv | 72..103 pub | 104..135 name | 136..139 freq | 140..143 bw 144 sf | 145 +// cr | 146 tx_power | 147 gps_off | 148..151 seq | 152..155 gps_interval +// version 1 (160 bytes): 156..159 CRC-32 of bytes 0..155 +// version 2 (192 bytes): 156..159 airtime_factor | 160..163 rx_delay_base | 164..167 tx_delay_factor +// | 168..171 direct_tx_delay_factor | 172 multi_acks | 173 manual_add_contacts | 174 telemetry modes +// | 175 advert_loc_policy | 176 autoadd_config | 177 autoadd_max_hops | 178 path_hash_mode | 179 tz_offset +// | 180 cad_enabled | 181 interference_threshold | 182 agc_reset_interval | 183 rx_boosted_gain | 184..187 +// 0 | 188..191 CRC-32 of bytes 0..187 +namespace asr650x { + +const size_t PERSIST_SIZE = 192; // version 2; a version 1 record uses the first 160 bytes + +struct PersistState { + uint32_t seq; // record generation, newest valid row wins (see FlashRecordStore.h) + bool has_identity; + uint8_t prv[64]; + uint8_t pub[32]; + bool has_prefs; + char node_name[32]; + float freq, bw; + uint8_t sf, cr; + int8_t tx_power_dbm; + bool gps_off; // 1 = the user switched GPS off (0 = default: on, also for older records) + uint32_t gps_interval; // seconds between GPS refreshes, 0 = only at boot / on request + bool has_ext; // version 2 settings below are present (false for a version 1 record) + float airtime_factor, rx_delay_base, tx_delay_factor, direct_tx_delay_factor; + uint8_t multi_acks, manual_add_contacts, telemetry_modes, advert_loc_policy; + uint8_t autoadd_config, autoadd_max_hops, path_hash_mode; + int8_t tz_offset; + uint8_t cad_enabled, interference_threshold, agc_reset_interval, rx_boosted_gain; +}; + +inline uint32_t crc32(const uint8_t *d, size_t n) { + uint32_t c = 0xFFFFFFFFu; + for (size_t i = 0; i < n; i++) { + c ^= d[i]; + for (int k = 0; k < 8; k++) + c = (c >> 1) ^ (0xEDB88320u & (0u - (c & 1u))); + } + return ~c; +} + +inline void persistEncode(const PersistState &s, uint8_t out[PERSIST_SIZE]) { + memset(out, 0, PERSIST_SIZE); + memcpy(out, "ASR1", 4); + out[4] = 2; + out[5] = (s.has_identity ? 1 : 0) | (s.has_prefs ? 2 : 0); + if (s.has_identity) { + memcpy(out + 8, s.prv, 64); + memcpy(out + 72, s.pub, 32); + } + if (s.has_prefs) { + memcpy(out + 104, s.node_name, 32); + memcpy(out + 136, &s.freq, 4); + memcpy(out + 140, &s.bw, 4); + out[144] = s.sf; + out[145] = s.cr; + out[146] = (uint8_t)s.tx_power_dbm; + } + out[147] = s.gps_off ? 1 : 0; + for (int i = 0; i < 4; i++) + out[148 + i] = (uint8_t)(s.seq >> (8 * i)); + for (int i = 0; i < 4; i++) + out[152 + i] = (uint8_t)(s.gps_interval >> (8 * i)); + if (s.has_ext) { + memcpy(out + 156, &s.airtime_factor, 4); + memcpy(out + 160, &s.rx_delay_base, 4); + memcpy(out + 164, &s.tx_delay_factor, 4); + memcpy(out + 168, &s.direct_tx_delay_factor, 4); + out[172] = s.multi_acks; + out[173] = s.manual_add_contacts; + out[174] = s.telemetry_modes; + out[175] = s.advert_loc_policy; + out[176] = s.autoadd_config; + out[177] = s.autoadd_max_hops; + out[178] = s.path_hash_mode; + out[179] = (uint8_t)s.tz_offset; + out[180] = s.cad_enabled; + out[181] = s.interference_threshold; + out[182] = s.agc_reset_interval; + out[183] = s.rx_boosted_gain; + out[184] = 1; // marks the settings block as present + } + uint32_t crc = crc32(out, 188); + for (int i = 0; i < 4; i++) + out[188 + i] = (uint8_t)(crc >> (8 * i)); +} + +inline bool persistDecode(const uint8_t in[PERSIST_SIZE], PersistState &s) { + memset(&s, 0, sizeof(s)); + if ((memcmp(in, "ASR1", 4) != 0 && memcmp(in, "KTM1", 4) != 0) || (in[4] != 1 && in[4] != 2)) return false; + size_t crc_at = in[4] == 1 ? 156 : 188; + uint32_t want = 0; + for (int i = 0; i < 4; i++) + want |= (uint32_t)in[crc_at + i] << (8 * i); + if (crc32(in, crc_at) != want) return false; + if (in[4] == 2 && in[184] == 1) { + s.has_ext = true; + memcpy(&s.airtime_factor, in + 156, 4); + memcpy(&s.rx_delay_base, in + 160, 4); + memcpy(&s.tx_delay_factor, in + 164, 4); + memcpy(&s.direct_tx_delay_factor, in + 168, 4); + s.multi_acks = in[172]; + s.manual_add_contacts = in[173]; + s.telemetry_modes = in[174]; + s.advert_loc_policy = in[175]; + s.autoadd_config = in[176]; + s.autoadd_max_hops = in[177]; + s.path_hash_mode = in[178]; + s.tz_offset = (int8_t)in[179]; + s.cad_enabled = in[180]; + s.interference_threshold = in[181]; + s.agc_reset_interval = in[182]; + s.rx_boosted_gain = in[183]; + } + s.gps_off = in[147] != 0; + for (int i = 0; i < 4; i++) + s.seq |= (uint32_t)in[148 + i] << (8 * i); + for (int i = 0; i < 4; i++) + s.gps_interval |= (uint32_t)in[152 + i] << (8 * i); + s.has_identity = (in[5] & 1) != 0; + s.has_prefs = (in[5] & 2) != 0; + if (s.has_identity) { + memcpy(s.prv, in + 8, 64); + memcpy(s.pub, in + 72, 32); + } + if (s.has_prefs) { + memcpy(s.node_name, in + 104, 32); + s.node_name[31] = 0; + memcpy(&s.freq, in + 136, 4); + memcpy(&s.bw, in + 140, 4); + s.sf = in[144]; + s.cr = in[145]; + s.tx_power_dbm = (int8_t)in[146]; + } + return true; +} + +} // namespace asr650x diff --git a/src/helpers/asr650x/FlashRecordStore.h b/src/helpers/asr650x/FlashRecordStore.h new file mode 100644 index 0000000000..0991e91b15 --- /dev/null +++ b/src/helpers/asr650x/FlashRecordStore.h @@ -0,0 +1,81 @@ +#pragma once +#include "FlashRecord.h" + +// Two-row A/B store for the 160-byte FlashRecord. Each commit writes the OTHER row with seq+1, so a +// power loss while writing leaves the previous record intact (the SFLASH row erase+program is not atomic). +// commit() changes the in-RAM state only after the backend confirmed the write: a rejected change can +// never leak into a later commit. Rows are 256 bytes apart (one SFLASH user row each). +// +// Backend concept: +// void read(size_t offset, uint8_t* buf, size_t n); +// bool write(size_t offset, const uint8_t* buf, size_t n); // must persist exactly the rows touched +namespace asr650x { + +const size_t ROW_STRIDE = 256; + +inline bool seqNewer(uint32_t a, uint32_t b) { + return (int32_t)(a - b) > 0; +} + +template class PersistStore { + Backend &_be; + PersistState _state; + uint8_t _last[PERSIST_SIZE]; // bytes of the newest valid record (what is in flash now) + bool _have_last; + int _next_row; // row the next commit writes + +public: + explicit PersistStore(Backend &be) : _be(be), _have_last(false), _next_row(0) { + memset(&_state, 0, sizeof(_state)); + memset(_last, 0, sizeof(_last)); + } + + const PersistState &state() const { return _state; } + + void begin() { + uint8_t buf[2][PERSIST_SIZE]; + PersistState st[2]; + bool ok[2]; + for (int r = 0; r < 2; r++) { + _be.read(r * ROW_STRIDE, buf[r], PERSIST_SIZE); + ok[r] = persistDecode(buf[r], st[r]); + } + int pick = -1; + if (ok[0] && ok[1]) + pick = seqNewer(st[1].seq, st[0].seq) ? 1 : 0; + else if (ok[0]) + pick = 0; + else if (ok[1]) + pick = 1; + if (pick < 0) { + memset(&_state, 0, sizeof(_state)); + _have_last = false; + _next_row = 0; + return; + } + _state = st[pick]; + memcpy(_last, buf[pick], PERSIST_SIZE); + _have_last = true; + _next_row = pick ^ 1; + } + + bool commit(const PersistState &candidate) { + PersistState c = candidate; + uint8_t buf[PERSIST_SIZE]; + if (_have_last) { // unchanged content -> no flash write + c.seq = _state.seq; + persistEncode(c, buf); + if (memcmp(buf, _last, PERSIST_SIZE) == 0) return true; + } + c.seq = _state.seq + 1; + persistEncode(c, buf); + if (!_be.write(_next_row * ROW_STRIDE, buf, PERSIST_SIZE)) return false; // state untouched on failure + _state = c; + memcpy(_last, buf, PERSIST_SIZE); + _have_last = true; + _next_row ^= 1; + return true; + } +}; + +} // namespace asr650x diff --git a/src/helpers/asr650x/FlashRowBackend.h b/src/helpers/asr650x/FlashRowBackend.h new file mode 100644 index 0000000000..c028d94255 --- /dev/null +++ b/src/helpers/asr650x/FlashRowBackend.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include +#include + +// Writes straight to the SFLASH user rows through the core's FLASH_update(), one record per 256-byte row. +// (The EEPROM class would rewrite every row of its RAM mirror on each commit, which defeats the A/B scheme.) +extern "C" { +int FLASH_update(uint32_t dst_addr, const void *data, uint32_t size); +int FLASH_read_at(uint32_t address, uint8_t *pData, uint32_t len_bytes); +} + +struct FlashRowBackend { + void read(size_t off, uint8_t *buf, size_t n) { FLASH_read_at(CY_SFLASH_USERBASE + off, buf, n); } + + bool write(size_t off, const uint8_t *buf, size_t n) { + if (n > 255) return false; // one SFLASH user row + if (FLASH_update(CY_SFLASH_USERBASE + off, buf, n) != 0) return false; + uint8_t chk[255]; + FLASH_read_at(CY_SFLASH_USERBASE + off, chk, n); + return memcmp(chk, buf, n) == 0; // FLASH_update only prints on a failed row program and still returns 0 + } +}; diff --git a/src/helpers/asr650x/Font5x7.h b/src/helpers/asr650x/Font5x7.h new file mode 100644 index 0000000000..31fb7fd92c --- /dev/null +++ b/src/helpers/asr650x/Font5x7.h @@ -0,0 +1,48 @@ +#pragma once +#include + +namespace asr650x { + +// 5x7 ASCII font (0x20..0x7E), 5 column bytes per character, bit 0 = top pixel row; descenders use bit 7. +static const uint8_t FONT5X7[95][5] = { + { 0x00, 0x00, 0x00, 0x00, 0x00 }, { 0x00, 0x00, 0x5F, 0x00, 0x00 }, { 0x00, 0x07, 0x00, 0x07, 0x00 }, + { 0x14, 0x7F, 0x14, 0x7F, 0x14 }, { 0x24, 0x2A, 0x7F, 0x2A, 0x12 }, { 0x23, 0x13, 0x08, 0x64, 0x62 }, + { 0x36, 0x49, 0x56, 0x20, 0x50 }, { 0x00, 0x08, 0x07, 0x03, 0x00 }, { 0x00, 0x1C, 0x22, 0x41, 0x00 }, + { 0x00, 0x41, 0x22, 0x1C, 0x00 }, { 0x2A, 0x1C, 0x7F, 0x1C, 0x2A }, { 0x08, 0x08, 0x3E, 0x08, 0x08 }, + { 0x00, 0x80, 0x70, 0x30, 0x00 }, { 0x08, 0x08, 0x08, 0x08, 0x08 }, { 0x00, 0x00, 0x60, 0x60, 0x00 }, + { 0x20, 0x10, 0x08, 0x04, 0x02 }, { 0x3E, 0x51, 0x49, 0x45, 0x3E }, { 0x00, 0x42, 0x7F, 0x40, 0x00 }, + { 0x72, 0x49, 0x49, 0x49, 0x46 }, { 0x21, 0x41, 0x49, 0x4D, 0x33 }, { 0x18, 0x14, 0x12, 0x7F, 0x10 }, + { 0x27, 0x45, 0x45, 0x45, 0x39 }, { 0x3C, 0x4A, 0x49, 0x49, 0x31 }, { 0x41, 0x21, 0x11, 0x09, 0x07 }, + { 0x36, 0x49, 0x49, 0x49, 0x36 }, { 0x46, 0x49, 0x49, 0x29, 0x1E }, { 0x00, 0x00, 0x14, 0x00, 0x00 }, + { 0x00, 0x40, 0x34, 0x00, 0x00 }, { 0x00, 0x08, 0x14, 0x22, 0x41 }, { 0x14, 0x14, 0x14, 0x14, 0x14 }, + { 0x00, 0x41, 0x22, 0x14, 0x08 }, { 0x02, 0x01, 0x59, 0x09, 0x06 }, { 0x3E, 0x41, 0x5D, 0x59, 0x4E }, + { 0x7C, 0x12, 0x11, 0x12, 0x7C }, { 0x7F, 0x49, 0x49, 0x49, 0x36 }, { 0x3E, 0x41, 0x41, 0x41, 0x22 }, + { 0x7F, 0x41, 0x41, 0x41, 0x3E }, { 0x7F, 0x49, 0x49, 0x49, 0x41 }, { 0x7F, 0x09, 0x09, 0x09, 0x01 }, + { 0x3E, 0x41, 0x41, 0x51, 0x73 }, { 0x7F, 0x08, 0x08, 0x08, 0x7F }, { 0x00, 0x41, 0x7F, 0x41, 0x00 }, + { 0x20, 0x40, 0x41, 0x3F, 0x01 }, { 0x7F, 0x08, 0x14, 0x22, 0x41 }, { 0x7F, 0x40, 0x40, 0x40, 0x40 }, + { 0x7F, 0x02, 0x1C, 0x02, 0x7F }, { 0x7F, 0x04, 0x08, 0x10, 0x7F }, { 0x3E, 0x41, 0x41, 0x41, 0x3E }, + { 0x7F, 0x09, 0x09, 0x09, 0x06 }, { 0x3E, 0x41, 0x51, 0x21, 0x5E }, { 0x7F, 0x09, 0x19, 0x29, 0x46 }, + { 0x26, 0x49, 0x49, 0x49, 0x32 }, { 0x03, 0x01, 0x7F, 0x01, 0x03 }, { 0x3F, 0x40, 0x40, 0x40, 0x3F }, + { 0x1F, 0x20, 0x40, 0x20, 0x1F }, { 0x3F, 0x40, 0x38, 0x40, 0x3F }, { 0x63, 0x14, 0x08, 0x14, 0x63 }, + { 0x03, 0x04, 0x78, 0x04, 0x03 }, { 0x61, 0x59, 0x49, 0x4D, 0x43 }, { 0x00, 0x7F, 0x41, 0x41, 0x41 }, + { 0x02, 0x04, 0x08, 0x10, 0x20 }, { 0x00, 0x41, 0x41, 0x41, 0x7F }, { 0x04, 0x02, 0x01, 0x02, 0x04 }, + { 0x40, 0x40, 0x40, 0x40, 0x40 }, { 0x00, 0x03, 0x07, 0x08, 0x00 }, { 0x20, 0x54, 0x54, 0x78, 0x40 }, + { 0x7F, 0x28, 0x44, 0x44, 0x38 }, { 0x38, 0x44, 0x44, 0x44, 0x28 }, { 0x38, 0x44, 0x44, 0x28, 0x7F }, + { 0x38, 0x54, 0x54, 0x54, 0x18 }, { 0x00, 0x08, 0x7E, 0x09, 0x02 }, { 0x18, 0xA4, 0xA4, 0x9C, 0x78 }, + { 0x7F, 0x08, 0x04, 0x04, 0x78 }, { 0x00, 0x44, 0x7D, 0x40, 0x00 }, { 0x20, 0x40, 0x40, 0x3D, 0x00 }, + { 0x7F, 0x10, 0x28, 0x44, 0x00 }, { 0x00, 0x41, 0x7F, 0x40, 0x00 }, { 0x7C, 0x04, 0x78, 0x04, 0x78 }, + { 0x7C, 0x08, 0x04, 0x04, 0x78 }, { 0x38, 0x44, 0x44, 0x44, 0x38 }, { 0xFC, 0x18, 0x24, 0x24, 0x18 }, + { 0x18, 0x24, 0x24, 0x18, 0xFC }, { 0x7C, 0x08, 0x04, 0x04, 0x08 }, { 0x48, 0x54, 0x54, 0x54, 0x24 }, + { 0x04, 0x04, 0x3F, 0x44, 0x24 }, { 0x3C, 0x40, 0x40, 0x20, 0x7C }, { 0x1C, 0x20, 0x40, 0x20, 0x1C }, + { 0x3C, 0x40, 0x30, 0x40, 0x3C }, { 0x44, 0x28, 0x10, 0x28, 0x44 }, { 0x4C, 0x90, 0x90, 0x90, 0x7C }, + { 0x44, 0x64, 0x54, 0x4C, 0x44 }, { 0x00, 0x08, 0x36, 0x41, 0x00 }, { 0x00, 0x00, 0x77, 0x00, 0x00 }, + { 0x00, 0x41, 0x36, 0x08, 0x00 }, { 0x02, 0x01, 0x02, 0x04, 0x02 } +}; + +inline const uint8_t *font5x7Glyph(char c) { + unsigned char u = (unsigned char)c; + if (u < 0x20 || u > 0x7E) u = '?'; + return FONT5X7[u - 0x20]; +} + +} // namespace asr650x diff --git a/src/helpers/asr650x/GpsPolicy.h b/src/helpers/asr650x/GpsPolicy.h new file mode 100644 index 0000000000..54cf960f8f --- /dev/null +++ b/src/helpers/asr650x/GpsPolicy.h @@ -0,0 +1,83 @@ +#pragma once +#include + +namespace asr650x { + +enum GpsState { GPS_OFF = 0, GPS_WARMING = 1, GPS_DONE = 2 }; + +// Decides when the GPS module should be powered. Pure logic: time comes in as seconds from the caller. +class GpsPolicy { + bool _enabled; + uint32_t _interval, _timeout; + GpsState _state; + uint32_t _t_start, _t_done; + bool _timed_out; + uint32_t _fixes; + + // elapsed seconds modulo 2^32 (safe across the counter wrap); a "negative" difference means the clock went + // backwards and counts as 0 + static uint32_t since(uint32_t now, uint32_t then) { + uint32_t d = now - then; + return (int32_t)d < 0 ? 0 : d; + } + +public: + GpsPolicy() + : _enabled(false), _interval(0), _timeout(300), _state(GPS_OFF), _t_start(0), _t_done(0), + _timed_out(false), _fixes(0) {} + + void configure(bool enabled, uint32_t interval_s, uint32_t fix_timeout_s) { + _interval = interval_s; + _timeout = fix_timeout_s; + if (!enabled) { + _enabled = false; + _state = GPS_OFF; + return; + } + _enabled = true; + } + + void begin(uint32_t now_s) { + if (_enabled) { + _state = GPS_WARMING; + _t_start = now_s; + _timed_out = false; + } + } + + void request(uint32_t now_s) { // the app asked for a fresh position + _enabled = true; + _state = GPS_WARMING; + _t_start = now_s; + _timed_out = false; + } + + void tick(uint32_t now_s, bool fix_ok) { + if (!_enabled) return; + if (_state == GPS_WARMING) { + if (fix_ok) { + _state = GPS_DONE; + _t_done = now_s; + _fixes++; + _timed_out = false; + } else if (since(now_s, _t_start) >= _timeout) { + _state = GPS_DONE; + _t_done = now_s; + _timed_out = true; + } + } else if (_state == GPS_DONE) { + if (_interval > 0 && since(now_s, _t_done) >= _interval) { + _state = GPS_WARMING; + _t_start = now_s; + } + } + } + + bool powerWanted() const { return _state == GPS_WARMING; } + GpsState state() const { return _state; } + bool timedOut() const { return _timed_out; } + uint32_t fixCount() const { return _fixes; } + uint32_t warmingFor(uint32_t now_s) const { return _state == GPS_WARMING ? since(now_s, _t_start) : 0; } +}; + +} // namespace asr650x diff --git a/src/helpers/asr650x/IdentityCrypto.cpp b/src/helpers/asr650x/IdentityCrypto.cpp new file mode 100644 index 0000000000..6ca520740f --- /dev/null +++ b/src/helpers/asr650x/IdentityCrypto.cpp @@ -0,0 +1,62 @@ +#ifdef IDENTITY_CRYPTO_ON_BIG_STACK +#define ASR650X_IDENTITY_CRYPTO_IMPL +#include "IdentityCrypto.h" + +#include "BigStack.h" +#define ED25519_NO_SEED 1 +#include +#include + +namespace { +struct Args { + unsigned char *o1; + const unsigned char *a, *b, *c; + size_t len; + bool ok; +}; +void runKeypair(void *p) { + Args *x = (Args *)p; + ed25519_create_keypair(x->o1, (unsigned char *)x->a, x->b); +} +void runDerive(void *p) { + Args *x = (Args *)p; + ed25519_derive_pub(x->o1, x->a); +} +void runSign(void *p) { + Args *x = (Args *)p; + ed25519_sign(x->o1, x->a, x->len, x->b, x->c); +} +void runKeyex(void *p) { + Args *x = (Args *)p; + ed25519_key_exchange(x->o1, x->a, x->b); +} +void runVerify(void *p) { + Args *x = (Args *)p; + x->ok = Ed25519::verify(x->a, x->b, x->c, x->len); +} +} // namespace + +void asr650xEd25519CreateKeypair(unsigned char *pub, unsigned char *prv, const unsigned char *seed) { + Args a = { pub, prv, seed, 0, 0, false }; + asr650xCallOnBigStack(runKeypair, &a); +} +void asr650xEd25519DerivePub(unsigned char *pub, const unsigned char *prv) { + Args a = { pub, prv, 0, 0, 0, false }; + asr650xCallOnBigStack(runDerive, &a); +} +void asr650xEd25519Sign(unsigned char *sig, const unsigned char *msg, size_t len, const unsigned char *pub, + const unsigned char *prv) { + Args a = { sig, msg, pub, prv, len, false }; + asr650xCallOnBigStack(runSign, &a); +} +void asr650xEd25519KeyExchange(unsigned char *secret, const unsigned char *pub, const unsigned char *prv) { + Args a = { secret, pub, prv, 0, 0, false }; + asr650xCallOnBigStack(runKeyex, &a); +} +bool asr650xEd25519Verify(const unsigned char *sig, const unsigned char *pub, const unsigned char *msg, + size_t len) { + Args a = { 0, sig, pub, msg, len, false }; + asr650xCallOnBigStack(runVerify, &a); + return a.ok; +} +#endif diff --git a/src/helpers/asr650x/IdentityCrypto.h b/src/helpers/asr650x/IdentityCrypto.h new file mode 100644 index 0000000000..6d6cf4228c --- /dev/null +++ b/src/helpers/asr650x/IdentityCrypto.h @@ -0,0 +1,20 @@ +#pragma once +#include + +// Ed25519 on the big stack (BigStack.h): the software Ed25519 needs more than the ASR650x's fixed 2 KB main +// stack. Identity.cpp includes this header when IDENTITY_CRYPTO_ON_BIG_STACK is defined; the macros below +// then route its ed25519_* calls through these wrappers. +void asr650xEd25519CreateKeypair(unsigned char *pub, unsigned char *prv, const unsigned char *seed); +void asr650xEd25519DerivePub(unsigned char *pub, const unsigned char *prv); +void asr650xEd25519Sign(unsigned char *sig, const unsigned char *msg, size_t len, const unsigned char *pub, + const unsigned char *prv); +void asr650xEd25519KeyExchange(unsigned char *secret, const unsigned char *pub, const unsigned char *prv); +bool asr650xEd25519Verify(const unsigned char *sig, const unsigned char *pub, const unsigned char *msg, + size_t len); + +#ifndef ASR650X_IDENTITY_CRYPTO_IMPL +#define ed25519_create_keypair asr650xEd25519CreateKeypair +#define ed25519_derive_pub asr650xEd25519DerivePub +#define ed25519_sign asr650xEd25519Sign +#define ed25519_key_exchange asr650xEd25519KeyExchange +#endif diff --git a/src/helpers/asr650x/NmeaParser.h b/src/helpers/asr650x/NmeaParser.h new file mode 100644 index 0000000000..0a33747c97 --- /dev/null +++ b/src/helpers/asr650x/NmeaParser.h @@ -0,0 +1,278 @@ +#pragma once +#include +#include +#include + +// Integer-only NMEA 0183 reader for the Air530 (GGA + RMC). No float/double, fixed buffer, tolerant of noise. +namespace asr650x { + +enum NmeaEvent { NMEA_NONE = 0, NMEA_GGA = 1, NMEA_RMC = 2, NMEA_BAD = 3 }; + +struct GpsData { + bool pos_valid; // latest GGA/RMC carried a usable position + uint8_t fix_quality; // GGA field 6 (0 = none) + uint8_t sats; // GGA field 7 + int32_t lat_e6, lon_e6; // degrees x 1e6 + int32_t alt_dm; // decimetres + bool rmc_valid; // RMC status 'A' + uint32_t time_hhmmss; // UTC, from RMC + uint32_t date_ddmmyy; // from RMC +}; + +inline uint8_t nmeaChecksum(const char *s, size_t n) { + uint8_t c = 0; + for (size_t i = 0; i < n; i++) + c ^= (uint8_t)s[i]; + return c; +} + +// "$body*HH\r\n"; returns the length, or 0 if out is too small. +inline int nmeaMake(char *out, size_t out_size, const char *body) { + size_t n = strlen(body); + if (out_size < n + 7) return 0; // $ + body + * + 2 hex + \r\n + NUL + static const char H[] = "0123456789ABCDEF"; + uint8_t c = nmeaChecksum(body, n); + out[0] = '$'; + memcpy(out + 1, body, n); + out[1 + n] = '*'; + out[2 + n] = H[c >> 4]; + out[3 + n] = H[c & 15]; + out[4 + n] = '\r'; + out[5 + n] = '\n'; + out[6 + n] = 0; + return (int)(n + 6); +} + +inline bool nmeaIsLeap(int y) { + return (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; +} + +// UTC epoch from RMC date/time. 0 means invalid (year outside 2024..2060, bad month/day/hour/minute/second). +inline uint32_t gpsToEpoch(uint32_t date_ddmmyy, uint32_t time_hhmmss) { + int dd = (int)(date_ddmmyy / 10000), mm = (int)((date_ddmmyy / 100) % 100), yy = (int)(date_ddmmyy % 100); + int hh = (int)(time_hhmmss / 10000), mi = (int)((time_hhmmss / 100) % 100), ss = (int)(time_hhmmss % 100); + int year = 2000 + yy; + if (year < 2024 || year > 2060) return 0; + if (mm < 1 || mm > 12 || dd < 1) return 0; + static const int dim[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + int days_in_month = dim[mm - 1] + ((mm == 2 && nmeaIsLeap(year)) ? 1 : 0); + if (dd > days_in_month) return 0; + if (hh > 23 || mi > 59 || ss > 59) return 0; + uint32_t days = 0; + for (int y = 1970; y < year; y++) + days += nmeaIsLeap(y) ? 366 : 365; + for (int m = 1; m < mm; m++) + days += dim[m - 1] + ((m == 2 && nmeaIsLeap(year)) ? 1 : 0); + days += (uint32_t)(dd - 1); + return days * 86400u + (uint32_t)hh * 3600u + (uint32_t)mi * 60u + (uint32_t)ss; +} + +class NmeaParser { + char _buf[84]; + uint8_t _len; + bool _active; + GpsData _d; + uint32_t _ok, _bad; + + static bool parseUint(const char *s, uint32_t *out) { // digits only, non-empty + if (!*s) return false; + uint32_t v = 0; + for (; *s; s++) { + if (*s < '0' || *s > '9') return false; + v = v * 10 + (uint32_t)(*s - '0'); + if (v > 99999999u) return false; + } + *out = v; + return true; + } + + // "ddmm.mmmm" (or dddmm.mmmm) + hemisphere -> degrees x 1e6; false if malformed or empty + static bool parseLatlon(const char *s, char hemi, int deg_digits, int32_t *out) { + if (!*s) return false; + int n = 0; + while (s[n] >= '0' && s[n] <= '9') + n++; + if (n != deg_digits + 2) return false; + uint32_t deg = 0; + for (int i = 0; i < deg_digits; i++) + deg = deg * 10 + (uint32_t)(s[i] - '0'); + uint32_t whole = (uint32_t)(s[deg_digits] - '0') * 10 + (uint32_t)(s[deg_digits + 1] - '0'); + const char *p = s + n; + uint64_t frac = 0, scale = 1; // fractional minutes, up to 6 digits + if (*p == '.') { + p++; + int k = 0; + while (*p >= '0' && *p <= '9') { + if (k < 6) { + frac = frac * 10 + (uint64_t)(*p - '0'); + scale *= 10; + k++; + } + p++; + } + } + if (*p != 0) return false; + if (whole >= 60) return false; + uint64_t minutes_scaled = (uint64_t)whole * scale + frac; // minutes x scale + uint64_t frac_e6 = (minutes_scaled * 1000000u + 30u * scale) / (60u * scale); // rounded + int64_t v = (int64_t)deg * 1000000 + (int64_t)frac_e6; + if (v > (deg_digits == 2 ? 90000000LL : 180000000LL)) return false; // beyond the pole / the antimeridian + if (hemi == 'S' || hemi == 'W') + v = -v; + else if (hemi != 'N' && hemi != 'E') + return false; + *out = (int32_t)v; + return true; + } + + // "123.4" -> decimetres (one decimal, truncated); false if malformed + static bool parseDm(const char *s, int32_t *out) { + if (!*s) return false; + bool neg = false; + if (*s == '-') { + neg = true; + s++; + } + if (*s < '0' || *s > '9') return false; + int32_t v = 0; + while (*s >= '0' && *s <= '9') { + v = v * 10 + (*s - '0'); + s++; + if (v > 1000000) return false; + } + int32_t d = 0; + if (*s == '.') { + s++; + if (*s >= '0' && *s <= '9') d = *s - '0'; + while (*s >= '0' && *s <= '9') + s++; + } + if (*s != 0) return false; + v = v * 10 + d; + *out = neg ? -v : v; + return true; + } + + static int hex(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return -1; + } + + NmeaEvent parse() { + _buf[_len] = 0; + char *star = (char *)memchr(_buf, '*', _len); + if (!star || (_buf + _len) - star != 3) return NMEA_BAD; + int h1 = hex(star[1]), h2 = hex(star[2]); + if (h1 < 0 || h2 < 0) return NMEA_BAD; + if (nmeaChecksum(_buf, (size_t)(star - _buf)) != (uint8_t)(h1 * 16 + h2)) return NMEA_BAD; + *star = 0; + + char *f[20]; + int nf = 0; + f[nf++] = _buf; + for (char *p = _buf; *p && nf < 20; p++) + if (*p == ',') { + *p = 0; + f[nf++] = p + 1; + } + size_t idlen = strlen(f[0]); + if (idlen < 5) { + _ok++; + return NMEA_NONE; + } + const char *type = f[0] + idlen - 3; + + if (strcmp(type, "GGA") == 0 && nf >= 10) { + uint32_t q = 0, sats = 0; + parseUint(f[6], &q); + parseUint(f[7], &sats); + _d.fix_quality = (uint8_t)(q > 255 ? 255 : q); + _d.sats = (uint8_t)(sats > 255 ? 255 : sats); + int32_t lat = 0, lon = 0, alt = 0; + bool ok = + _d.fix_quality > 0 && parseLatlon(f[2], f[3][0], 2, &lat) && parseLatlon(f[4], f[5][0], 3, &lon); + if (ok) { + _d.lat_e6 = lat; + _d.lon_e6 = lon; + if (parseDm(f[9], &alt)) _d.alt_dm = alt; + } + _d.pos_valid = ok; + _ok++; + return NMEA_GGA; + } + if (strcmp(type, "RMC") == 0 && nf >= 10) { + bool a = f[2][0] == 'A' && f[2][1] == 0; + _d.rmc_valid = false; + uint32_t t = 0, dt = 0; + int32_t lat = 0, lon = 0; + bool ok = a && parseLatlon(f[3], f[4][0], 2, &lat) && parseLatlon(f[5], f[6][0], 3, &lon); + // time "hhmmss[.sss]": keep the integer part + char tb[8]; + size_t tl = 0; + while (f[1][tl] && f[1][tl] != '.' && tl < 7) { + tb[tl] = f[1][tl]; + tl++; + } + tb[tl] = 0; + bool tok = tl == 6 && parseUint(tb, &t); + bool dok = strlen(f[9]) == 6 && parseUint(f[9], &dt); + if (a && tok && dok) { + _d.rmc_valid = true; + _d.time_hhmmss = t; + _d.date_ddmmyy = dt; + } + if (ok) { + _d.lat_e6 = lat; + _d.lon_e6 = lon; + } + _d.pos_valid = ok; + _ok++; + return NMEA_RMC; + } + _ok++; + return NMEA_NONE; + } + +public: + NmeaParser() : _len(0), _active(false), _ok(0), _bad(0) { memset(&_d, 0, sizeof(_d)); } + + const GpsData &data() const { return _d; } + void resetFix() { + _d.pos_valid = false; + _d.rmc_valid = false; + _d.fix_quality = 0; + _d.sats = 0; + } // after a power cycle: old data is not a fix + uint32_t sentencesOk() const { return _ok; } + uint32_t sentencesBad() const { return _bad; } + + NmeaEvent feed(uint8_t c) { + if (c == '$') { // (re)start; a '$' inside a broken sentence is a resync + if (_active && _len > 0) _bad++; + _len = 0; + _active = true; + return NMEA_NONE; + } + if (!_active) return NMEA_NONE; + if (c == '\r' || c == '\n') { + if (_len == 0) return NMEA_NONE; + NmeaEvent e = parse(); + if (e == NMEA_BAD) _bad++; + _active = false; + _len = 0; + return e; + } + if (_len >= sizeof(_buf) - 1) { // overlong: drop it + _active = false; + _len = 0; + _bad++; + return NMEA_BAD; + } + _buf[_len++] = (char)c; + return NMEA_NONE; + } +}; + +} // namespace asr650x diff --git a/src/helpers/asr650x/OledText.h b/src/helpers/asr650x/OledText.h new file mode 100644 index 0000000000..8a70525805 --- /dev/null +++ b/src/helpers/asr650x/OledText.h @@ -0,0 +1,23 @@ +#pragma once +#include "Font5x7.h" + +#include +#include + +namespace asr650x { + +const int OLED_COLS = 128; +const int OLED_CHARS = 21; // 21 characters x 6 columns = 126 + +// One SSD1306 page (8 pixel rows, 128 columns) holding a line of text; bit 0 of each byte is the top pixel +// row. +inline void renderLine(const char *text, uint8_t page[OLED_COLS]) { + memset(page, 0, OLED_COLS); + if (!text) return; + for (int i = 0; i < OLED_CHARS && text[i]; i++) { + const uint8_t *g = font5x7Glyph(text[i]); + memcpy(page + i * 6, g, 5); + } +} + +} // namespace asr650x diff --git a/src/helpers/asr650x/PinnedContacts.h b/src/helpers/asr650x/PinnedContacts.h new file mode 100644 index 0000000000..0ad72a28fa --- /dev/null +++ b/src/helpers/asr650x/PinnedContacts.h @@ -0,0 +1,117 @@ +#pragma once + +#include "FlashRecord.h" // crc32 + +#include +#include + +// Pinned contacts: up to 3 contacts that the app added or edited survive a reboot (the rest of the contact +// table stays in RAM). Stored in ONE SFLASH user row: the core lets user code write rows 0..2, and rows 0/1 +// hold the A/B identity + prefs record, so there is no A/B copy here. A power loss while this row is written +// loses the pins (CRC fails -> empty set), never the identity. Layout (210 bytes, little-endian): 0..3 "PIN1" +// | 4 count | 5..7 0 | 8 + i*66: pub[32] name[32] type flags +// | 206..209 CRC-32 of bytes 0..205 +namespace asr650x { + +const int PIN_MAX = 3; +const size_t PIN_ENTRY = 66; +const size_t PIN_SIZE = 8 + PIN_MAX * PIN_ENTRY + 4; + +struct PinEntry { + uint8_t pub[32]; + char name[32]; // NUL-terminated, at most 31 characters + uint8_t type; + uint8_t flags; +}; + +struct PinSet { + uint8_t n; + PinEntry e[PIN_MAX]; + PinSet() : n(0) { memset(e, 0, sizeof(e)); } +}; + +enum PinResult { PIN_SAME = 0, PIN_CHANGED = 1, PIN_FULL = 2 }; + +inline int pinFind(const PinSet &s, const uint8_t pub[32]) { + for (int i = 0; i < s.n; i++) + if (memcmp(s.e[i].pub, pub, 32) == 0) return i; + return -1; +} + +inline PinResult pinSetFields(PinEntry &e, const char *name, uint8_t type, uint8_t flags) { + char nm[32]; + memset(nm, 0, sizeof(nm)); + if (name) strncpy(nm, name, 31); + if (memcmp(e.name, nm, 32) == 0 && e.type == type && e.flags == flags) return PIN_SAME; + memcpy(e.name, nm, 32); + e.type = type; + e.flags = flags; + return PIN_CHANGED; +} + +// pins (or refreshes) a contact; PIN_FULL when 3 others are pinned already +inline PinResult pinUpsert(PinSet &s, const uint8_t pub[32], const char *name, uint8_t type, uint8_t flags) { + int i = pinFind(s, pub); + if (i >= 0) return pinSetFields(s.e[i], name, type, flags); + if (s.n >= PIN_MAX) return PIN_FULL; + PinEntry &e = s.e[s.n++]; + memset(&e, 0, sizeof(e)); + memcpy(e.pub, pub, 32); + pinSetFields(e, name, type, flags); + return PIN_CHANGED; +} + +// updates the stored copy of an already pinned contact; never pins a new one +inline PinResult pinRefresh(PinSet &s, const uint8_t pub[32], const char *name, uint8_t type, uint8_t flags) { + int i = pinFind(s, pub); + return i < 0 ? PIN_SAME : pinSetFields(s.e[i], name, type, flags); +} + +inline bool pinRemove(PinSet &s, const uint8_t pub[32]) { + int i = pinFind(s, pub); + if (i < 0) return false; + for (int k = i; k + 1 < s.n; k++) + s.e[k] = s.e[k + 1]; + s.n--; + memset(&s.e[s.n], 0, sizeof(s.e[s.n])); + return true; +} + +inline void pinsEncode(const PinSet &s, uint8_t out[PIN_SIZE]) { + memset(out, 0, PIN_SIZE); + memcpy(out, "PIN1", 4); + out[4] = s.n; + for (int i = 0; i < s.n && i < PIN_MAX; i++) { + uint8_t *p = out + 8 + i * PIN_ENTRY; + memcpy(p, s.e[i].pub, 32); + memcpy(p + 32, s.e[i].name, 31); // byte 63 stays 0: always terminated + p[64] = s.e[i].type; + p[65] = s.e[i].flags; + } + uint32_t crc = crc32(out, PIN_SIZE - 4); + for (int k = 0; k < 4; k++) + out[PIN_SIZE - 4 + k] = (uint8_t)(crc >> (8 * k)); +} + +// false (and an empty set) for an erased, corrupt or foreign row +inline bool pinsDecode(const uint8_t in[PIN_SIZE], PinSet &s) { + s = PinSet(); + if (memcmp(in, "PIN1", 4) != 0) return false; + uint32_t want = 0; + for (int k = 0; k < 4; k++) + want |= (uint32_t)in[PIN_SIZE - 4 + k] << (8 * k); + if (crc32(in, PIN_SIZE - 4) != want) return false; + if (in[4] > PIN_MAX) return false; + s.n = in[4]; + for (int i = 0; i < s.n; i++) { + const uint8_t *p = in + 8 + i * PIN_ENTRY; + memcpy(s.e[i].pub, p, 32); + memcpy(s.e[i].name, p + 32, 31); + s.e[i].name[31] = 0; + s.e[i].type = p[64]; + s.e[i].flags = p[65]; + } + return true; +} + +} // namespace asr650x diff --git a/src/helpers/asr650x/RadioParams.h b/src/helpers/asr650x/RadioParams.h new file mode 100644 index 0000000000..5d4c6bb1b9 --- /dev/null +++ b/src/helpers/asr650x/RadioParams.h @@ -0,0 +1,79 @@ +#pragma once +#include +#include + +// Pure helpers for the SX1262 wrapper (host-testable). Bandwidth table order is the Heltec driver's +// `Bandwidths[]` (libraries/LoraWan102/src/radio/radio.c): 125, 250, +// 500, 62.5, 41.67, 31.25, 20.83, 15.63, 10.42, 7.81 kHz. +namespace asr650x { + +inline float bwValue(int index) { + static const float table[10] = { 125.0f, 250.0f, 500.0f, 62.5f, 41.67f, + 31.25f, 20.83f, 15.63f, 10.42f, 7.81f }; + if (index < 0) index = 0; + if (index > 9) index = 9; + return table[index]; +} + +// Nearest supported bandwidth by ratio (so 100 kHz maps to 125, not to 62.5). Never returns an invalid index. +inline int bwIndex(float bw_khz) { + if (!(bw_khz > 0.0f)) return 9; // zero, negative or NaN -> smallest + int best = 0; + float best_ratio = 1e9f; + for (int i = 0; i < 10; i++) { + float v = bwValue(i); + float r = bw_khz > v ? bw_khz / v : v / bw_khz; // >= 1, closer to 1 is nearer + if (r < best_ratio) { + best_ratio = r; + best = i; + } + } + return best; +} + +inline uint8_t crIndex(uint8_t cr) { // MeshCore 5..8 (4/5..4/8) -> Heltec 1..4 + if (cr < 5) cr = 5; + if (cr > 8) cr = 8; + return (uint8_t)(cr - 4); +} + +inline uint8_t sfClamp(uint8_t sf) { + return sf < 5 ? 5 : (sf > 12 ? 12 : sf); +} + +inline uint16_t preambleForSf(uint8_t sf) { + return sf <= 8 ? 32 : 16; +} // same rule as MeshCore's RadioLibWrapper + +// Semtech time-on-air, explicit header, CRC on, integer microsecond arithmetic. Returns milliseconds (rounded +// up). +inline uint32_t airtimeMs(int len, float bw_khz, uint8_t sf, uint8_t cr, uint16_t preamble) { + if (len < 0) len = 0; + sf = sfClamp(sf); + uint8_t crv = crIndex(cr); // 1..4 + uint32_t bw_hz = (uint32_t)(bw_khz * 1000.0f + 0.5f); + if (bw_hz == 0) bw_hz = 1; + uint32_t tsym_us = (uint32_t)(((uint64_t)1000000u << sf) / bw_hz); + int de = + tsym_us >= 16380u ? 1 : 0; // low data-rate optimisation: same threshold as the Heltec driver (16.38 ms) + int num = 8 * len - 4 * (int)sf + 28 + 16; // +16 CRC, explicit header (no -20) + int den = 4 * ((int)sf - 2 * de); + int blocks = num > 0 ? (num + den - 1) / den : 0; + uint32_t npay = 8u + (uint32_t)(blocks * (crv + 4)); + uint64_t us = + ((uint64_t)(preamble * 4 + 17) * tsym_us) / 4 + (uint64_t)npay * tsym_us; // (n_pre + 4.25) symbols + return (uint32_t)((us + 999u) / 1000u); +} + +// Same estimate MeshCore uses to weigh received packets (RadioLibWrapper::packetScoreInt). +inline float packetScore(float snr, int sf, int packet_len) { + static const float snr_threshold[] = { -7.5f, -10.0f, -12.5f, -15.0f, -17.5f, -20.0f }; // SF7..SF12 + if (sf < 7 || sf > 12) return 0.0f; + if (snr < snr_threshold[sf - 7]) return 0.0f; + float success = (snr - snr_threshold[sf - 7]) / 10.0f; + float collision_penalty = 1.0f - (packet_len / 256.0f); + float v = success * collision_penalty; + return v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v); +} + +} // namespace asr650x diff --git a/src/helpers/asr650x/Sbrk.cpp b/src/helpers/asr650x/Sbrk.cpp new file mode 100644 index 0000000000..512ef7213f --- /dev/null +++ b/src/helpers/asr650x/Sbrk.cpp @@ -0,0 +1,25 @@ +// Override of the core's weak _sbrk (Cm0plusStart.c). The core caps the heap at CYDEV_HEAP_SIZE (4096 B) +// although the linker leaves everything between .bss and the 2 KB stack unused. The crypto stack (2 KB), the +// packet pool, the UART buffer and the flash page cache (256 B, allocated during every SFLASH write) need +// more than 4 KB, so with the core's version malloc() fails. Here the heap may grow up to the bottom of the +// stack. +#include +#include + +extern "C" { +extern int end; // linker: first free byte after .bss +extern uint32_t __cy_stack_limit[]; // linker: bottom of the 2 KB stack region + +void *_sbrk(int nbytes) { + static uint8_t *heap_ptr = (uint8_t *)&end; + uint8_t *limit = (uint8_t *)__cy_stack_limit; + uint8_t *next = heap_ptr + nbytes; + if (next > limit || next < (uint8_t *)&end) { + errno = ENOMEM; + return (void *)-1; + } + void *prev = heap_ptr; + heap_ptr = next; + return prev; +} +} diff --git a/src/helpers/asr650x/SmallStrtod.cpp b/src/helpers/asr650x/SmallStrtod.cpp new file mode 100644 index 0000000000..65b2e393cc --- /dev/null +++ b/src/helpers/asr650x/SmallStrtod.cpp @@ -0,0 +1,17 @@ +#ifdef ASR650X_PLATFORM +// Linked before libc: these definitions replace newlib's strtod/strtof/atof (and the ~7 KB of gdtoa they pull +// in). +#include "SmallStrtod.h" + +extern "C" { +double strtod(const char *s, char **end) { + return asr650xStrtod(s, end); +} +float strtof(const char *s, char **end) { + return (float)asr650xStrtod(s, end); +} +double atof(const char *s) { + return asr650xStrtod(s, 0); +} +} +#endif diff --git a/src/helpers/asr650x/SmallStrtod.h b/src/helpers/asr650x/SmallStrtod.h new file mode 100644 index 0000000000..ad26c6ddb6 --- /dev/null +++ b/src/helpers/asr650x/SmallStrtod.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include + +// Decimal-only strtod() (optional sign, digits, optional fraction, optional exponent; no hex/inf/nan). +// newlib's strtod() pulls in ~7 KB of flash (gdtoa); the CLI only parses plain decimal numbers. +// SmallStrtod.cpp installs it as strtod/strtof/atof on ASR650x builds. +inline double asr650xStrtod(const char *s, char **end) { + const char *p = s; + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == '\f' || *p == '\v') + p++; + bool neg = false; + if (*p == '+' || *p == '-') { + neg = (*p == '-'); + p++; + } + uint64_t mant = 0; + int digits = 0, scale = 0; + bool any = false; + while (*p >= '0' && *p <= '9') { + any = true; + if (digits < 19) { + mant = mant * 10 + (uint64_t)(*p - '0'); + digits += (mant != 0); + } else + scale++; // more digits than fit: keep the magnitude only + p++; + } + if (*p == '.') { + const char *q = p + 1; + bool frac = false; + while (*q >= '0' && *q <= '9') { + frac = true; + if (digits < 19) { + mant = mant * 10 + (uint64_t)(*q - '0'); + digits += (mant != 0); + scale--; + } + q++; + } + if (any || frac) { + any = true; + p = q; + } + } + if (!any) { + if (end) *end = (char *)s; + return 0.0; + } + if (*p == 'e' || *p == 'E') { + const char *q = p + 1; + bool eneg = false; + if (*q == '+' || *q == '-') { + eneg = (*q == '-'); + q++; + } + if (*q >= '0' && *q <= '9') { + int e = 0; + while (*q >= '0' && *q <= '9') { + if (e < 400) e = e * 10 + (*q - '0'); + q++; + } + scale += eneg ? -e : e; + p = q; + } + } + double v = (double)mant; + while (scale > 0) { + v *= 10.0; + scale--; + } + while (scale < 0) { + v /= 10.0; + scale++; + } + if (end) *end = (char *)p; + return neg ? -v : v; +} diff --git a/src/helpers/asr650x/StatusLines.h b/src/helpers/asr650x/StatusLines.h new file mode 100644 index 0000000000..610e412bf8 --- /dev/null +++ b/src/helpers/asr650x/StatusLines.h @@ -0,0 +1,100 @@ +#pragma once +#include +#include +#include + +// NOTE: no %l length modifier anywhere: the core's wrapped snprintf printed "u" for "%lu" on the device. +namespace asr650x { + +struct StatusInput { + const char *name; + uint8_t id4[4]; // first bytes of the public key + bool time_valid; + uint32_t epoch; // UTC seconds + uint8_t gps_state; // 0 off, 1 searching, 2 fixed, 3 timeout + uint8_t sats; + bool pos_valid; + int32_t lat_e6, lon_e6; + uint16_t batt_mv; // 0 = not measured + uint8_t contacts; // contacts in the table + uint8_t max_contacts; // table size (MAX_CONTACTS) + uint32_t freq_khz; + uint8_t sf; + uint32_t warmingS; +}; + +// The text of screen line `index` (0..7), at most 21 characters (plus the NUL in out[22]). +inline void statusLine(int index, const StatusInput &in, char out[22]) { + char t[48]; + t[0] = 0; + switch (index) { + case 0: { + size_t n = 0; + if (in.name) { + for (; in.name[n] && n < 21; n++) { + unsigned char c = (unsigned char)in.name[n]; + t[n] = (c >= 0x20 && c < 0x7f) ? (char)c : '?'; + } + } + t[n] = 0; + break; + } + case 1: + snprintf(t, sizeof(t), "ID %02x%02x%02x%02x", (unsigned)in.id4[0], (unsigned)in.id4[1], + (unsigned)in.id4[2], (unsigned)in.id4[3]); + break; + case 2: + if (!in.time_valid) + snprintf(t, sizeof(t), "--:--:-- no time"); + else { + uint32_t s = in.epoch % 86400u; + snprintf(t, sizeof(t), "%02u:%02u:%02u UTC", (unsigned)(s / 3600), (unsigned)((s / 60) % 60), + (unsigned)(s % 60)); + } + break; + case 3: + if (in.gps_state == 1) + snprintf(t, sizeof(t), "GPS search %us", (unsigned)(in.warmingS > 999 ? 999 : in.warmingS)); + else if (in.gps_state == 2) + snprintf(t, sizeof(t), "GPS fix %u sats", (unsigned)in.sats); + else if (in.gps_state == 3) + snprintf(t, sizeof(t), "GPS timeout"); + else + snprintf(t, sizeof(t), "GPS off"); + break; + case 4: + case 5: { + if (!in.pos_valid) { + snprintf(t, sizeof(t), index == 4 ? "LAT --" : "LON --"); + break; + } + int32_t v = index == 4 ? in.lat_e6 : in.lon_e6; + bool neg = v < 0; + uint32_t a = neg ? (uint32_t)(-(int64_t)v) : (uint32_t)v; + // sign and whole degrees are right-aligned in a fixed field so the decimals line up + char whole[12]; + snprintf(whole, sizeof(whole), "%s%u", neg ? "-" : "", (unsigned)(a / 1000000u)); + snprintf(t, sizeof(t), "%s %3s.%06u", index == 4 ? "LAT" : "LON", whole, (unsigned)(a % 1000000u)); + break; + } + case 6: { + char b[10]; + if (in.batt_mv == 0) + snprintf(b, sizeof(b), "--"); + else + snprintf(b, sizeof(b), "%u.%02uV", (unsigned)(in.batt_mv / 1000), (unsigned)((in.batt_mv % 1000) / 10)); + snprintf(t, sizeof(t), "BAT %s CT %u/%u", b, (unsigned)in.contacts, (unsigned)in.max_contacts); + break; + } + case 7: + snprintf(t, sizeof(t), "%u.%03uMHz SF%u", (unsigned)(in.freq_khz / 1000), (unsigned)(in.freq_khz % 1000), + (unsigned)in.sf); + break; + default: + break; + } + strncpy(out, t, 21); + out[21] = 0; +} + +} // namespace asr650x diff --git a/src/helpers/asr650x/TxGate.cpp b/src/helpers/asr650x/TxGate.cpp new file mode 100644 index 0000000000..f6a34bc59b --- /dev/null +++ b/src/helpers/asr650x/TxGate.cpp @@ -0,0 +1,10 @@ +#include "TxGate.h" + +static bool g_tx_allowed = false; + +bool asr650xTxAllowed() { + return g_tx_allowed; +} +void asr650xSetTxAllowed(bool allowed) { + g_tx_allowed = allowed; +} diff --git a/src/helpers/asr650x/TxGate.h b/src/helpers/asr650x/TxGate.h new file mode 100644 index 0000000000..d75e64b281 --- /dev/null +++ b/src/helpers/asr650x/TxGate.h @@ -0,0 +1,6 @@ +#pragma once + +// The companion firmware for this board does not generate its own identity: the app imports a private key. +// Until then the node runs with a placeholder identity derived from the chip ID, which must never transmit. +bool asr650xTxAllowed(); +void asr650xSetTxAllowed(bool allowed); diff --git a/test/test_asr650x_button/test_asr650x_button.cpp b/test/test_asr650x_button/test_asr650x_button.cpp new file mode 100644 index 0000000000..eea3dfd07e --- /dev/null +++ b/test/test_asr650x_button/test_asr650x_button.cpp @@ -0,0 +1,60 @@ +#include "helpers/asr650x/Button.h" + +#include +#include + +TEST(Asr650xButton, Behaves) { + /* a clean press is reported once, after the debounce time, and not again while held */ + { + asr650x::Button b; + EXPECT_TRUE(!b.update(false, 0)); + EXPECT_TRUE(!b.update(true, 100)); + EXPECT_TRUE(!b.update(true, 120)); // 20 ms: still bouncing + EXPECT_TRUE(b.update(true, 135)); // >= 30 ms held: reported + EXPECT_TRUE(!b.update(true, 500)); // held: no repeat + EXPECT_TRUE(!b.update(false, 600)); + EXPECT_TRUE(!b.update(true, 700)); + EXPECT_TRUE(b.update(true, 731)); // second press needs the release in between + } + /* contact bounce shorter than the debounce time never fires */ + { + asr650x::Button b; + EXPECT_TRUE(!b.update(true, 0)); + EXPECT_TRUE(!b.update(false, 10)); + EXPECT_TRUE(!b.update(true, 20)); + EXPECT_TRUE(!b.update(false, 40)); + EXPECT_TRUE(!b.update(false, 1000)); + } + /* readings during the hold-off after a battery measurement are ignored, even a long LOW level */ + { + asr650x::Button b; + b.holdoff(1000, 50); + EXPECT_TRUE(!b.update(true, 1001)); + EXPECT_TRUE(!b.update(true, 1030)); + EXPECT_TRUE(!b.update(true, 1049)); + EXPECT_TRUE(!b.update( + true, 1060)); // pin still LOW after the hold-off: not a new press, it must be released first + EXPECT_TRUE(!b.update(false, 1100)); + EXPECT_TRUE(!b.update(true, 1200)); + EXPECT_TRUE(b.update(true, 1235)); + } + /* a press that began before the hold-off and is still held after it must not fire */ + { + asr650x::Button b; + EXPECT_TRUE(!b.update(true, 0)); + b.holdoff(10, 50); + EXPECT_TRUE(!b.update(true, 100)); + EXPECT_TRUE(!b.update(true, 200)); + } + /* millis() wrap-around */ + { + asr650x::Button b; + EXPECT_TRUE(!b.update(true, 0xFFFFFFF0u)); + EXPECT_TRUE(b.update(true, 0x00000020u)); // 48 ms later across the wrap + } +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_epoch_clock/test_asr650x_epoch_clock.cpp b/test/test_asr650x_epoch_clock/test_asr650x_epoch_clock.cpp new file mode 100644 index 0000000000..013712c962 --- /dev/null +++ b/test/test_asr650x_epoch_clock/test_asr650x_epoch_clock.cpp @@ -0,0 +1,34 @@ +#include "helpers/asr650x/EpochClock.h" + +#include +#include + +TEST(Asr650xEpochClock, Behaves) { + asr650x::EpochClock c(1000); + EXPECT_TRUE(c.now(0) == 1000); // starts at the boot epoch + EXPECT_TRUE(c.now(999) == 1000); // not yet a full second + EXPECT_TRUE(c.now(1000) == 1001); + EXPECT_TRUE(c.now(3500) == 1003); // remainder carried, not lost + c.set(5000, 3500); + EXPECT_TRUE(c.now(3500) == 5000); // set() is exact at that instant + EXPECT_TRUE(c.now(4499) == 5000); + EXPECT_TRUE(c.now(4500) == 5001); + c.set(2000, 4500); // going backwards is allowed + EXPECT_TRUE(c.now(5500) == 2001); + asr650x::EpochClock w(100); // millis() wrap at 2^32 + EXPECT_TRUE(w.now(0xFFFFFF00u) == 100 + 4294967u); + uint32_t before = w.now(0xFFFFFF00u); + uint32_t after = w.now(0x00000100u); // 0x200 ms later + EXPECT_TRUE(after >= before && after - before <= 1); + { + asr650x::EpochClock s(1000); + EXPECT_TRUE(!s.synced()); + s.set(2000, 0); + EXPECT_TRUE(s.synced()); + } +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_gps_policy/test_asr650x_gps_policy.cpp b/test/test_asr650x_gps_policy/test_asr650x_gps_policy.cpp new file mode 100644 index 0000000000..52eaccf8b8 --- /dev/null +++ b/test/test_asr650x_gps_policy/test_asr650x_gps_policy.cpp @@ -0,0 +1,117 @@ +#include "helpers/asr650x/GpsPolicy.h" + +#include +#include + +TEST(Asr650xGpsPolicy, Behaves) { + /* default: enabled, interval 0, timeout 300 s */ + { + asr650x::GpsPolicy p; + p.configure(true, 0, 300); + EXPECT_TRUE(p.state() == asr650x::GPS_OFF && !p.powerWanted()); // nothing happens before begin() + p.begin(100); + EXPECT_TRUE(p.state() == asr650x::GPS_WARMING && p.powerWanted()); + p.tick(110, false); + EXPECT_TRUE(p.state() == asr650x::GPS_WARMING && p.warmingFor(110) == 10); + p.tick(130, true); // fix -> done, power off + EXPECT_TRUE(p.state() == asr650x::GPS_DONE && !p.powerWanted() && p.fixCount() == 1 && !p.timedOut()); + p.tick(100000, false); // interval 0: stays off forever + EXPECT_TRUE(p.state() == asr650x::GPS_DONE && !p.powerWanted()); + } + /* timeout without a fix */ + { + asr650x::GpsPolicy p; + p.configure(true, 0, 300); + p.begin(0); + p.tick(299, false); + EXPECT_TRUE(p.state() == asr650x::GPS_WARMING); + p.tick(300, false); + EXPECT_TRUE(p.state() == asr650x::GPS_DONE && p.timedOut() && p.fixCount() == 0 && !p.powerWanted()); + } + /* periodic refresh */ + { + asr650x::GpsPolicy p; + p.configure(true, 600, 300); + p.begin(0); + p.tick(20, true); + EXPECT_TRUE(p.state() == asr650x::GPS_DONE); + p.tick(619, false); + EXPECT_TRUE(p.state() == asr650x::GPS_DONE); + p.tick(620, false); // 600 s after entering DONE at t=20 + EXPECT_TRUE(p.state() == asr650x::GPS_WARMING && p.powerWanted()); + p.tick(640, true); + EXPECT_TRUE(p.state() == asr650x::GPS_DONE && p.fixCount() == 2); + } + /* disabled: never powers, even on tick/begin */ + { + asr650x::GpsPolicy p; + p.configure(false, 60, 300); + p.begin(0); + p.tick(1000, true); + EXPECT_TRUE(p.state() == asr650x::GPS_OFF && !p.powerWanted() && p.fixCount() == 0); + } + /* the app turns GPS on later: starts warming immediately; turning it off stops at once */ + { + asr650x::GpsPolicy p; + p.configure(false, 0, 300); + p.begin(0); + p.configure(true, 0, 300); + p.request(50); + EXPECT_TRUE(p.state() == asr650x::GPS_WARMING && p.powerWanted() && p.warmingFor(60) == 10); + p.configure(false, 0, 300); + EXPECT_TRUE(p.state() == asr650x::GPS_OFF && !p.powerWanted()); + } + /* request() while already DONE re-acquires and clears the timeout flag */ + { + asr650x::GpsPolicy p; + p.configure(true, 0, 10); + p.begin(0); + p.tick(10, false); + EXPECT_TRUE(p.timedOut()); + p.request(20); + EXPECT_TRUE(p.state() == asr650x::GPS_WARMING && !p.timedOut()); + } + /* clock going backwards (RTC set by the app) must not wedge the machine */ + { + asr650x::GpsPolicy p; + p.configure(true, 100, 300); + p.begin(1000); + p.tick(500, false); // now < start: treated as 0 s elapsed, no underflow + EXPECT_TRUE(p.state() == asr650x::GPS_WARMING && p.warmingFor(500) == 0); + } + /* changing the interval while DONE applies to the next cycle */ + { + asr650x::GpsPolicy p; + p.configure(true, 0, 300); + p.begin(0); + p.tick(5, true); + p.configure(true, 60, 300); + p.tick(64, false); + EXPECT_TRUE(p.state() == asr650x::GPS_DONE); + p.tick(65, false); + EXPECT_TRUE(p.state() == asr650x::GPS_WARMING); + } +} + +// the seconds counter wraps after 2^32 s: timeouts and intervals still fire across the wrap +TEST(Asr650xGpsPolicy, TimeoutAndIntervalAcrossCounterWrap) { + asr650x::GpsPolicy p; + p.configure(true, 600, 300); + p.begin(0xFFFFFF00u); + p.tick(0xFFFFFF00u + 299u, false); + EXPECT_EQ(p.state(), asr650x::GPS_WARMING); + p.tick(0xFFFFFF00u + 300u, false); // wrapped: 0x2C + EXPECT_EQ(p.state(), asr650x::GPS_DONE); + EXPECT_TRUE(p.timedOut()); + uint32_t done = 0xFFFFFF00u + 300u; + p.tick(done + 599u, false); + EXPECT_EQ(p.state(), asr650x::GPS_DONE); + p.tick(done + 600u, false); + EXPECT_EQ(p.state(), asr650x::GPS_WARMING); + EXPECT_EQ(p.warmingFor(done + 610u), 10u); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_nmea/test_asr650x_nmea.cpp b/test/test_asr650x_nmea/test_asr650x_nmea.cpp new file mode 100644 index 0000000000..1f613655f8 --- /dev/null +++ b/test/test_asr650x_nmea/test_asr650x_nmea.cpp @@ -0,0 +1,183 @@ +#include "helpers/asr650x/NmeaParser.h" + +#include +#include +#include +#include + +static asr650x::NmeaEvent feed_str(asr650x::NmeaParser &p, const std::string &s) { + asr650x::NmeaEvent last = asr650x::NMEA_NONE; + for (unsigned char c : s) { + asr650x::NmeaEvent e = p.feed(c); + if (e != asr650x::NMEA_NONE) last = e; + } + return last; +} + +TEST(Asr650xNmeaParser, Behaves) { + /* classic GGA example: 48 07.038 N, 11 31.000 E, 8 sats, 545.4 m */ + { + asr650x::NmeaParser p; + EXPECT_TRUE(feed_str(p, "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\r\n") == + asr650x::NMEA_GGA); + const asr650x::GpsData &d = p.data(); + EXPECT_TRUE(d.pos_valid && d.fix_quality == 1 && d.sats == 8); + EXPECT_TRUE(d.lat_e6 == 48117300 && d.lon_e6 == 11516667 && d.alt_dm == 5454); + EXPECT_TRUE(p.sentencesOk() == 1 && p.sentencesBad() == 0); + } + /* 5-decimal minutes, western hemisphere (Air530 style, GN talker) */ + { + asr650x::NmeaParser p; + EXPECT_TRUE( + feed_str(p, "$GNGGA,092750.000,5321.68020,N,00630.33720,W,1,08,1.03,61.7,M,55.2,M,,*58\r\n") == + asr650x::NMEA_GGA); + EXPECT_TRUE(p.data().lat_e6 == 53361337 && p.data().lon_e6 == -6505620 && p.data().alt_dm == 617); + } + /* southern / eastern */ + { + asr650x::NmeaParser p; + EXPECT_TRUE(feed_str(p, "$GNGGA,000000.000,3351.60000,S,15112.00000,E,1,05,1.0,10.0,M,0.0,M,,*5E\r\n") == + asr650x::NMEA_GGA); + EXPECT_TRUE(p.data().lat_e6 == -33860000 && p.data().lon_e6 == 151200000 && p.data().sats == 5); + } + /* RMC with a 2026 date */ + { + asr650x::NmeaParser p; + EXPECT_TRUE(feed_str(p, "$GNRMC,092750.000,A,5321.68020,N,00630.33720,W,0.02,31.66,041026,,,A*53\r\n") == + asr650x::NMEA_RMC); + EXPECT_TRUE(p.data().rmc_valid && p.data().time_hhmmss == 92750 && p.data().date_ddmmyy == 41026); + EXPECT_TRUE(p.data().pos_valid && p.data().lat_e6 == 53361337); + EXPECT_TRUE(asr650x::gpsToEpoch(p.data().date_ddmmyy, p.data().time_hhmmss) == + 1791106070u); /* 2026-10-04 09:27:50 UTC */ + } + /* no fix: empty fields, quality 0 / status V: never a valid position */ + { + asr650x::NmeaParser p; + EXPECT_TRUE(feed_str(p, "$GNGGA,,,,,,0,00,99.99,,,,,,*56\r\n") == asr650x::NMEA_GGA); + EXPECT_TRUE(!p.data().pos_valid && p.data().fix_quality == 0 && p.data().sats == 0); + EXPECT_TRUE(feed_str(p, "$GNRMC,,V,,,,,,,,,,N*4D\r\n") == asr650x::NMEA_RMC); + EXPECT_TRUE(!p.data().rmc_valid && !p.data().pos_valid); + } + /* a fix followed by a no-fix sentence invalidates the position */ + { + asr650x::NmeaParser p; + feed_str(p, "$GNGGA,092750.000,5321.68020,N,00630.33720,W,1,08,1.03,61.7,M,55.2,M,,*58\r\n"); + EXPECT_TRUE(p.data().pos_valid); + feed_str(p, "$GNGGA,,,,,,0,00,99.99,,,,,,*56\r\n"); + EXPECT_TRUE(!p.data().pos_valid); + } + /* bad checksum, missing '*', truncated, no terminator, garbage, resync on '$' */ + { + asr650x::NmeaParser p; + EXPECT_TRUE(feed_str(p, "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*48\r\n") == + asr650x::NMEA_BAD); + EXPECT_TRUE(!p.data().pos_valid); + EXPECT_TRUE(feed_str(p, "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,\r\n") == + asr650x::NMEA_BAD); + EXPECT_TRUE(feed_str(p, "$GPGGA,1235") == asr650x::NMEA_NONE); /* truncated: nothing until a terminator */ + EXPECT_TRUE(feed_str(p, "\r\n") == asr650x::NMEA_BAD); /* the truncated sentence ends here: rejected */ + EXPECT_TRUE(feed_str(p, "\x01\x02\xff garbage \x80\r\n") == + asr650x::NMEA_NONE); /* noise outside a sentence is ignored */ + /* a '$' in the middle of a broken sentence restarts it */ + EXPECT_TRUE( + feed_str(p, "$GPGGA,12$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\r\n") == + asr650x::NMEA_GGA); + EXPECT_TRUE(p.data().pos_valid && p.data().lat_e6 == 48117300); + EXPECT_TRUE(p.sentencesBad() >= 2); + } + /* overlong sentence: dropped, parser recovers */ + { + asr650x::NmeaParser p; + std::string longs = "$GPGGA,"; + longs.append(300, 'A'); + longs += "*00\r\n"; + EXPECT_TRUE(feed_str(p, longs) == asr650x::NMEA_BAD); + EXPECT_TRUE(feed_str(p, "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\r\n") == + asr650x::NMEA_GGA); + } + /* other sentence types are valid but ignored */ + { + asr650x::NmeaParser p; + EXPECT_TRUE(feed_str(p, "$GPGSV,1,1,00*79\r\n") == asr650x::NMEA_NONE); + EXPECT_TRUE(p.sentencesOk() == 1); + } + /* sentences with non-numeric junk in numeric fields must not become a position */ + { + asr650x::NmeaParser p; + const char *body = "GPGGA,123519,48x7.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"; + char line[128]; + EXPECT_TRUE(asr650x::nmeaMake(line, sizeof(line), body) > 0); + feed_str(p, line); + EXPECT_TRUE(!p.data().pos_valid); + } + + /* checksum helper */ + { + char out[128]; + int n = asr650x::nmeaMake(out, sizeof(out), "PCAS03,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0"); + EXPECT_TRUE(n > 0 && std::strcmp(out, "$PCAS03,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0*02\r\n") == 0); + char small[8]; + EXPECT_TRUE(asr650x::nmeaMake(small, sizeof(small), "PCAS03,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0") == 0); + } + + /* epoch conversion */ + EXPECT_TRUE(asr650x::gpsToEpoch(41026, 123456) == 1791117296u); /* 2026-10-04 12:34:56 */ + EXPECT_TRUE(asr650x::gpsToEpoch(10125, 0) == 1735689600u); /* 2025-01-01 00:00:00 */ + EXPECT_TRUE(asr650x::gpsToEpoch(280226, 235959) == 1772323199u); /* 2026-02-28 23:59:59 */ + EXPECT_TRUE(asr650x::gpsToEpoch(290228, 0) == 1835395200u); /* 2028-02-29 (leap day) */ + EXPECT_TRUE(asr650x::gpsToEpoch(290227, 0) == 0); /* 2027-02-29 does not exist */ + EXPECT_TRUE(asr650x::gpsToEpoch(310226, 0) == 0); /* Feb 31 */ + EXPECT_TRUE(asr650x::gpsToEpoch(230394, 123519) == 0); /* 2094: outside the accepted window */ + EXPECT_TRUE(asr650x::gpsToEpoch(41023, 0) == 0); /* 2023: before the window */ + EXPECT_TRUE(asr650x::gpsToEpoch(41026, 250000) == 0 && asr650x::gpsToEpoch(41026, 126000) == 0 && + asr650x::gpsToEpoch(41026, 120060) == 0); + EXPECT_TRUE(asr650x::gpsToEpoch(0, 0) == 0 && asr650x::gpsToEpoch(41026, 0) != 0); + /* after a GPS power cycle the previous fix must not be reported (reviewer finding: stale RMC/GGA data) */ + { + asr650x::NmeaParser p; + EXPECT_TRUE( + feed_str(p, "$GNGGA,092750.000,5321.68020,N,00630.33720,W,1,08,1.03,61.7,M,55.2,M,,*58\r\n") == + asr650x::NMEA_GGA); + EXPECT_TRUE(p.data().pos_valid && p.data().fix_quality == 1); + p.resetFix(); + EXPECT_TRUE(!p.data().pos_valid && !p.data().rmc_valid && p.data().fix_quality == 0 && + p.data().sats == 0); + } +} + +// a sentence with a valid checksum but an impossible position must not be taken as a fix +TEST(Asr650xNmea, OutOfRangeLatLonRejected) { + const char *bodies[] = { + "GNGGA,092750.000,9530.00000,N,00630.33720,W,1,08,1.03,61.7,M,55.2,M,,", // latitude 95 deg + "GNGGA,092750.000,9000.00100,S,00630.33720,W,1,08,1.03,61.7,M,55.2,M,,", // just over 90 deg + "GNGGA,092750.000,5321.68020,N,18530.33720,E,1,08,1.03,61.7,M,55.2,M,,", // longitude 185 deg + }; + for (const char *b : bodies) { + asr650x::NmeaParser p; + char line[100]; + int n = asr650x::nmeaMake(line, sizeof(line), b); + ASSERT_GT(n, 0); + asr650x::NmeaEvent last = asr650x::NMEA_NONE; + for (int i = 0; i < n; i++) { + asr650x::NmeaEvent e = p.feed((uint8_t)line[i]); + if (e != asr650x::NMEA_NONE) last = e; + } + EXPECT_EQ(last, asr650x::NMEA_GGA) << b; + EXPECT_FALSE(p.data().pos_valid) << b; + } + // the limits themselves are valid + asr650x::NmeaParser p; + char line[100]; + int n = asr650x::nmeaMake(line, sizeof(line), + "GNGGA,092750.000,9000.00000,S,18000.00000,W,1,08,1.03,61.7,M,55.2,M,,"); + for (int i = 0; i < n; i++) + p.feed((uint8_t)line[i]); + EXPECT_TRUE(p.data().pos_valid); + EXPECT_EQ(p.data().lat_e6, -90000000); + EXPECT_EQ(p.data().lon_e6, -180000000); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_oled_text/test_asr650x_oled_text.cpp b/test/test_asr650x_oled_text/test_asr650x_oled_text.cpp new file mode 100644 index 0000000000..cde724868f --- /dev/null +++ b/test/test_asr650x_oled_text/test_asr650x_oled_text.cpp @@ -0,0 +1,60 @@ +#include "helpers/asr650x/OledText.h" + +#include +#include +#include + +TEST(Asr650xOledText, Behaves) { + /* glyph table: space is blank, every printable glyph is non-blank, unknown characters map to '?' */ + const uint8_t *sp = asr650x::font5x7Glyph(' '); + for (int i = 0; i < 5; i++) + EXPECT_TRUE(sp[i] == 0); + for (int c = 0x21; c <= 0x7E; c++) { + const uint8_t *g = asr650x::font5x7Glyph((char)c); + bool any = false; + for (int i = 0; i < 5; i++) + any = any || g[i] != 0; + EXPECT_TRUE(any); + } + EXPECT_TRUE(std::memcmp(asr650x::font5x7Glyph('\x01'), asr650x::font5x7Glyph('?'), 5) == 0); + EXPECT_TRUE(std::memcmp(asr650x::font5x7Glyph((char)0xC3), asr650x::font5x7Glyph('?'), 5) == + 0); /* UTF-8 bytes show as '?' */ + EXPECT_TRUE(std::memcmp(asr650x::font5x7Glyph('\x7f'), asr650x::font5x7Glyph('?'), 5) == 0); + /* distinct shapes for look-alikes */ + EXPECT_TRUE(std::memcmp(asr650x::font5x7Glyph('0'), asr650x::font5x7Glyph('O'), 5) != 0); + EXPECT_TRUE(std::memcmp(asr650x::font5x7Glyph('l'), asr650x::font5x7Glyph('I'), 5) != 0); + + /* one page = 128 columns: 5 glyph columns + 1 gap per character */ + uint8_t page[128]; + asr650x::renderLine("A", page); + for (int i = 0; i < 5; i++) + EXPECT_TRUE(page[i] == asr650x::font5x7Glyph('A')[i]); + EXPECT_TRUE(page[5] == 0); + for (int i = 6; i < 128; i++) + EXPECT_TRUE(page[i] == 0); + asr650x::renderLine("AB", page); + for (int i = 0; i < 5; i++) + EXPECT_TRUE(page[6 + i] == asr650x::font5x7Glyph('B')[i]); + /* empty and null text give a blank page */ + asr650x::renderLine("", page); + for (int i = 0; i < 128; i++) + EXPECT_TRUE(page[i] == 0); + asr650x::renderLine(nullptr, page); + for (int i = 0; i < 128; i++) + EXPECT_TRUE(page[i] == 0); + /* more than 21 characters: cut, last two columns stay blank */ + asr650x::renderLine("ABCDEFGHIJKLMNOPQRSTUVWXYZ", page); + EXPECT_TRUE(page[126] == 0 && page[127] == 0); + for (int i = 0; i < 5; i++) + EXPECT_TRUE(page[20 * 6 + i] == asr650x::font5x7Glyph('U')[i]); + /* a long run of UTF-8 does not overrun the buffer */ + asr650x::renderLine("\xe1\xbb\x87\xe1\xbb\x87\xe1\xbb\x87\xe1\xbb\x87\xe1\xbb\x87\xe1\xbb\x87\xe1\xbb\x87" + "\xe1\xbb\x87\xe1\xbb\x87", + page); + EXPECT_TRUE(page[127] == 0); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_persist/test_asr650x_persist.cpp b/test/test_asr650x_persist/test_asr650x_persist.cpp new file mode 100644 index 0000000000..dfdc4e3e98 --- /dev/null +++ b/test/test_asr650x_persist/test_asr650x_persist.cpp @@ -0,0 +1,180 @@ +#include "helpers/asr650x/FlashRecord.h" + +#include +#include +#include + +static asr650x::PersistState sample() { + asr650x::PersistState s; + memset(&s, 0, sizeof(s)); + s.has_identity = true; + for (int i = 0; i < 64; i++) + s.prv[i] = (uint8_t)(i + 1); + for (int i = 0; i < 32; i++) + s.pub[i] = (uint8_t)(0x80 + i); + s.has_prefs = true; + strcpy(s.node_name, "node-test"); + s.freq = 921.5f; + s.bw = 62.5f; + s.sf = 7; + s.cr = 5; + s.tx_power_dbm = 14; + return s; +} + +TEST(Asr650xFlashRecord, Behaves) { + uint8_t buf[asr650x::PERSIST_SIZE]; + EXPECT_TRUE(asr650x::crc32((const uint8_t *)"123456789", 9) == 0xCBF43926u); // standard CRC-32 check value + + asr650x::PersistState a = sample(), b; + asr650x::persistEncode(a, buf); + EXPECT_TRUE(asr650x::persistDecode(buf, b)); + EXPECT_TRUE(b.has_identity && memcmp(b.prv, a.prv, 64) == 0 && memcmp(b.pub, a.pub, 32) == 0); + EXPECT_TRUE(b.has_prefs && strcmp(b.node_name, "node-test") == 0); + EXPECT_TRUE(b.freq == 921.5f && b.bw == 62.5f && b.sf == 7 && b.cr == 5 && b.tx_power_dbm == 14); + + /* identity only / prefs only */ + a.has_prefs = false; + asr650x::persistEncode(a, buf); + EXPECT_TRUE(asr650x::persistDecode(buf, b) && b.has_identity && !b.has_prefs); + a = sample(); + a.has_identity = false; + asr650x::persistEncode(a, buf); + EXPECT_TRUE(asr650x::persistDecode(buf, b) && !b.has_identity && b.has_prefs); + + /* every single-byte corruption must be rejected and reset the state to empty */ + a = sample(); + asr650x::persistEncode(a, buf); + for (size_t i = 0; i < asr650x::PERSIST_SIZE; i++) { + uint8_t bad[asr650x::PERSIST_SIZE]; + memcpy(bad, buf, sizeof(bad)); + bad[i] ^= 0x01; + asr650x::PersistState c = sample(); + EXPECT_TRUE(!asr650x::persistDecode(bad, c)); + EXPECT_TRUE(!c.has_identity && !c.has_prefs); + } + /* erased flash (all 0x00 / all 0xFF) is not a valid record */ + uint8_t z[asr650x::PERSIST_SIZE]; + memset(z, 0x00, sizeof(z)); + EXPECT_TRUE(!asr650x::persistDecode(z, b)); + memset(z, 0xFF, sizeof(z)); + EXPECT_TRUE(!asr650x::persistDecode(z, b)); + /* a name without NUL terminator in the record is terminated on decode */ + a = sample(); + memset(a.node_name, 'A', 32); + asr650x::persistEncode(a, buf); + EXPECT_TRUE(asr650x::persistDecode(buf, b) && b.node_name[31] == 0); + /* GPS settings round-trip and are covered by the CRC */ + { + asr650x::PersistState g = sample(); + g.gps_off = true; + g.gps_interval = 3600; + asr650x::persistEncode(g, buf); + EXPECT_TRUE(buf[147] == 1); + EXPECT_TRUE(buf[152] == (3600 & 0xFF) && buf[153] == ((3600 >> 8) & 0xFF) && buf[154] == 0 && + buf[155] == 0); + asr650x::PersistState h; + EXPECT_TRUE(asr650x::persistDecode(buf, h) && h.gps_off && h.gps_interval == 3600); + buf[153] ^= 1; + EXPECT_TRUE(!asr650x::persistDecode(buf, h)); // corrupting the interval byte is detected + } + /* a legacy record (bytes 147 and 152..155 are zero) reads as 'GPS on, interval 0' */ + { + asr650x::PersistState legacy = sample(); + legacy.gps_off = false; + legacy.gps_interval = 0; + asr650x::persistEncode(legacy, buf); + asr650x::PersistState r; + EXPECT_TRUE(asr650x::persistDecode(buf, r) && !r.gps_off && r.gps_interval == 0); + } +} + +// version 2: the other companion settings the app can change (they used to be lost on reboot) +TEST(Asr650xFlashRecord, Version2KeepsCompanionSettings) { + asr650x::PersistState a = sample(), b; + a.has_ext = true; + a.airtime_factor = 1.5f; + a.rx_delay_base = 2.25f; + a.tx_delay_factor = 0.5f; + a.direct_tx_delay_factor = 0.25f; + a.multi_acks = 1; + a.manual_add_contacts = 1; + a.telemetry_modes = 0x26; + a.advert_loc_policy = 1; + a.autoadd_config = 0x0E; + a.autoadd_max_hops = 3; + a.path_hash_mode = 2; + a.tz_offset = -7; + a.cad_enabled = 1; + a.interference_threshold = 14; + a.agc_reset_interval = 5; + a.rx_boosted_gain = 1; + uint8_t buf[asr650x::PERSIST_SIZE]; + asr650x::persistEncode(a, buf); + ASSERT_TRUE(asr650x::persistDecode(buf, b)); + EXPECT_TRUE(b.has_ext); + EXPECT_EQ(b.airtime_factor, 1.5f); + EXPECT_EQ(b.rx_delay_base, 2.25f); + EXPECT_EQ(b.tx_delay_factor, 0.5f); + EXPECT_EQ(b.direct_tx_delay_factor, 0.25f); + EXPECT_EQ(b.multi_acks, 1); + EXPECT_EQ(b.manual_add_contacts, 1); + EXPECT_EQ(b.telemetry_modes, 0x26); + EXPECT_EQ(b.advert_loc_policy, 1); + EXPECT_EQ(b.autoadd_config, 0x0E); + EXPECT_EQ(b.autoadd_max_hops, 3); + EXPECT_EQ(b.path_hash_mode, 2); + EXPECT_EQ(b.tz_offset, -7); + EXPECT_EQ(b.cad_enabled, 1); + EXPECT_EQ(b.interference_threshold, 14); + EXPECT_EQ(b.agc_reset_interval, 5); + EXPECT_EQ(b.rx_boosted_gain, 1); + EXPECT_EQ(b.freq, 921.5f); // version 1 fields unchanged + buf[176] ^= 0x01; // a corrupted settings byte is detected + EXPECT_FALSE(asr650x::persistDecode(buf, b)); +} + +// a record written by the first firmware (version 1, 160 bytes, CRC at 156) still loads; the new settings are +// absent +TEST(Asr650xFlashRecord, Version1RecordStillLoads) { + asr650x::PersistState a = sample(); + uint8_t v2[asr650x::PERSIST_SIZE]; + asr650x::persistEncode(a, v2); + uint8_t v1[asr650x::PERSIST_SIZE]; + memset(v1, 0xFF, sizeof(v1)); // bytes after the old record: erased flash + memcpy(v1, v2, 156); + v1[4] = 1; + uint32_t crc = asr650x::crc32(v1, 156); + for (int i = 0; i < 4; i++) + v1[156 + i] = (uint8_t)(crc >> (8 * i)); + asr650x::PersistState b; + ASSERT_TRUE(asr650x::persistDecode(v1, b)); + EXPECT_FALSE(b.has_ext); + EXPECT_TRUE(b.has_identity && b.has_prefs); + EXPECT_EQ(memcmp(b.prv, a.prv, 64), 0); + EXPECT_STREQ(b.node_name, "node-test"); +} + +// records are written with the "ASR1" magic; records from early builds ("KTM1") still load +TEST(Asr650xFlashRecord, MagicAndLegacyMagic) { + asr650x::PersistState a = sample(), b; + uint8_t buf[asr650x::PERSIST_SIZE]; + asr650x::persistEncode(a, buf); + EXPECT_EQ(memcmp(buf, "ASR1", 4), 0); + memcpy(buf, "KTM1", 4); + uint32_t crc = asr650x::crc32(buf, 188); + for (int i = 0; i < 4; i++) + buf[188 + i] = (uint8_t)(crc >> (8 * i)); + ASSERT_TRUE(asr650x::persistDecode(buf, b)); + EXPECT_EQ(memcmp(b.prv, a.prv, 64), 0); + memcpy(buf, "XXX1", 4); + crc = asr650x::crc32(buf, 188); + for (int i = 0; i < 4; i++) + buf[188 + i] = (uint8_t)(crc >> (8 * i)); + EXPECT_FALSE(asr650x::persistDecode(buf, b)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_persist_store/test_asr650x_persist_store.cpp b/test/test_asr650x_persist_store/test_asr650x_persist_store.cpp new file mode 100644 index 0000000000..7929794ccc --- /dev/null +++ b/test/test_asr650x_persist_store/test_asr650x_persist_store.cpp @@ -0,0 +1,124 @@ +#include "helpers/asr650x/FlashRecordStore.h" + +#include +#include +#include + +// RAM-backed fake of the SFLASH user rows: two 256-byte rows, a record per row. +struct FakeBackend { + uint8_t mem[512]; + bool fail_next_write; + size_t torn_bytes; // if > 0: only this many bytes of the next write land (simulated power loss) + int writes; + size_t last_off; + FakeBackend() : fail_next_write(false), torn_bytes(0), writes(0), last_off(0) { + memset(mem, 0xFF, sizeof(mem)); + } + void read(size_t off, uint8_t *buf, size_t n) { memcpy(buf, mem + off, n); } + bool write(size_t off, const uint8_t *buf, size_t n) { + writes++; + last_off = off; + if (fail_next_write) { + fail_next_write = false; + return false; + } + if (torn_bytes) { + memcpy(mem + off, buf, torn_bytes); + torn_bytes = 0; + return false; + } + memcpy(mem + off, buf, n); + return true; + } +}; + +static asr650x::PersistState with_identity(uint8_t fill) { + asr650x::PersistState s; + memset(&s, 0, sizeof(s)); + s.has_identity = true; + memset(s.prv, fill, 64); + memset(s.pub, fill ^ 0x5A, 32); + return s; +} + +TEST(Asr650xFlashRecordStore, Behaves) { + FakeBackend be; + asr650x::PersistStore st(be); + st.begin(); + EXPECT_TRUE(!st.state().has_identity && !st.state().has_prefs); + + /* identity persists across a new store instance, alternating rows */ + EXPECT_TRUE(st.commit(with_identity(1))); + EXPECT_TRUE(be.last_off == 0); + asr650x::PersistState s2 = st.state(); + s2.has_prefs = true; + strcpy(s2.node_name, "one"); + EXPECT_TRUE(st.commit(s2)); + EXPECT_TRUE(be.last_off == 256); + { + asr650x::PersistStore again(be); + again.begin(); + EXPECT_TRUE(again.state().has_identity && again.state().prv[0] == 1); + EXPECT_TRUE(again.state().has_prefs && strcmp(again.state().node_name, "one") == 0); + } + + /* unchanged commit performs no write */ + int w = be.writes; + EXPECT_TRUE(st.commit(st.state())); + EXPECT_TRUE(be.writes == w); + + /* a failed commit must not change state, and must not leak into the next commit */ + asr650x::PersistState want_key2 = st.state(); + memset(want_key2.prv, 2, 64); + be.fail_next_write = true; + EXPECT_TRUE(!st.commit(want_key2)); + EXPECT_TRUE(st.state().prv[0] == 1); + asr650x::PersistState rename = st.state(); + strcpy(rename.node_name, "two"); + EXPECT_TRUE(st.commit(rename)); + { + asr650x::PersistStore again(be); + again.begin(); + EXPECT_TRUE(again.state().prv[0] == 1); // the rejected key never reached flash + EXPECT_TRUE(strcmp(again.state().node_name, "two") == 0); + } + + /* power loss while writing the newer row keeps the previous record intact */ + asr650x::PersistState before = st.state(); + asr650x::PersistState next = st.state(); + strcpy(next.node_name, "three"); + be.torn_bytes = 70; + EXPECT_TRUE(!st.commit(next)); + { + asr650x::PersistStore again(be); + again.begin(); + EXPECT_TRUE(again.state().has_identity && memcmp(again.state().prv, before.prv, 64) == 0); + EXPECT_TRUE(strcmp(again.state().node_name, "two") == 0); + /* and the store keeps working afterwards (writes the row that was torn) */ + EXPECT_TRUE(again.commit(next)); + asr650x::PersistStore third(be); + third.begin(); + EXPECT_TRUE(strcmp(third.state().node_name, "three") == 0); + } + + /* both rows corrupt -> empty */ + FakeBackend bad; + memset(bad.mem, 0xA5, sizeof(bad.mem)); + asr650x::PersistStore sb(bad); + sb.begin(); + EXPECT_TRUE(!sb.state().has_identity && !sb.state().has_prefs); + EXPECT_TRUE(sb.commit(with_identity(9))); + asr650x::PersistStore sb2(bad); + sb2.begin(); + EXPECT_TRUE(sb2.state().has_identity && sb2.state().prv[0] == 9); + + /* sequence number wrap: 0 is newer than 0xFFFFFFFF */ + EXPECT_TRUE(asr650x::seqNewer(0u, 0xFFFFFFFFu)); + EXPECT_TRUE(!asr650x::seqNewer(0xFFFFFFFFu, 0u)); + EXPECT_TRUE(asr650x::seqNewer(5u, 4u) && !asr650x::seqNewer(4u, 5u) && !asr650x::seqNewer(7u, 7u)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_pins/test_asr650x_pins.cpp b/test/test_asr650x_pins/test_asr650x_pins.cpp new file mode 100644 index 0000000000..8de7f48373 --- /dev/null +++ b/test/test_asr650x_pins/test_asr650x_pins.cpp @@ -0,0 +1,126 @@ +#include "helpers/asr650x/PinnedContacts.h" + +#include +#include + +static void key(uint8_t k[32], uint8_t v) { + for (int i = 0; i < 32; i++) + k[i] = (uint8_t)(v + i); +} + +TEST(Asr650xPins, ErasedOrGarbageRowIsEmpty) { + uint8_t row[asr650x::PIN_SIZE]; + asr650x::PinSet s; + memset(row, 0xFF, sizeof(row)); + EXPECT_FALSE(asr650x::pinsDecode(row, s)); + EXPECT_EQ(s.n, 0); + memset(row, 0x00, sizeof(row)); + EXPECT_FALSE(asr650x::pinsDecode(row, s)); + EXPECT_EQ(s.n, 0); +} + +TEST(Asr650xPins, AtMostThreeAndRepinOnlyRefreshes) { + uint8_t a[32], b[32], c[32], d[32]; + key(a, 0x10); + key(b, 0x40); + key(c, 0x70); + key(d, 0xA0); + asr650x::PinSet s; + EXPECT_EQ(asr650x::pinUpsert(s, a, "nRF-A", 1, 0), asr650x::PIN_CHANGED); + EXPECT_EQ(asr650x::pinUpsert(s, b, "nRF-B", 1, 0), asr650x::PIN_CHANGED); + EXPECT_EQ(asr650x::pinUpsert(s, c, "room", 3, 1), asr650x::PIN_CHANGED); + EXPECT_EQ(s.n, 3); + EXPECT_EQ(asr650x::pinUpsert(s, d, "fourth", 1, 0), asr650x::PIN_FULL); + EXPECT_EQ(s.n, 3); + EXPECT_EQ(asr650x::pinUpsert(s, b, "nRF-B", 1, 0), asr650x::PIN_SAME); + EXPECT_EQ(asr650x::pinUpsert(s, b, "nRF-B2", 1, 0), asr650x::PIN_CHANGED); + EXPECT_STREQ(s.e[1].name, "nRF-B2"); + EXPECT_LT(asr650x::pinFind(s, d), 0); + EXPECT_EQ(asr650x::pinFind(s, c), 2); +} + +TEST(Asr650xPins, RefreshNeverPinsANewContact) { + uint8_t a[32], d[32]; + key(a, 0x10); + key(d, 0xA0); + asr650x::PinSet s; + asr650x::pinUpsert(s, a, "A", 1, 0); + EXPECT_EQ(asr650x::pinRefresh(s, d, "D", 1, 0), asr650x::PIN_SAME); + EXPECT_EQ(s.n, 1); + EXPECT_EQ(asr650x::pinRefresh(s, a, "A-new", 2, 4), asr650x::PIN_CHANGED); + EXPECT_EQ(s.e[0].type, 2); + EXPECT_EQ(s.e[0].flags, 4); +} + +TEST(Asr650xPins, RemoveKeepsOrder) { + uint8_t a[32], b[32], c[32], d[32]; + key(a, 0x10); + key(b, 0x40); + key(c, 0x70); + key(d, 0xA0); + asr650x::PinSet s; + asr650x::pinUpsert(s, a, "A", 1, 0); + asr650x::pinUpsert(s, b, "B", 1, 0); + asr650x::pinUpsert(s, c, "C", 1, 0); + EXPECT_TRUE(asr650x::pinRemove(s, b)); + EXPECT_EQ(s.n, 2); + EXPECT_EQ(asr650x::pinFind(s, a), 0); + EXPECT_EQ(asr650x::pinFind(s, c), 1); + EXPECT_FALSE(asr650x::pinRemove(s, d)); + EXPECT_EQ(s.n, 2); +} + +TEST(Asr650xPins, RoundTripAndEveryByteCovered) { + uint8_t a[32], c[32]; + key(a, 0x10); + key(c, 0x70); + asr650x::PinSet s, t; + asr650x::pinUpsert(s, a, "A-VERY-LONG-NODE-NAME-THAT-DOES-NOT-FIT", 1, 2); + asr650x::pinUpsert(s, c, "C", 3, 0); + uint8_t row[asr650x::PIN_SIZE]; + asr650x::pinsEncode(s, row); + ASSERT_TRUE(asr650x::pinsDecode(row, t)); + EXPECT_EQ(t.n, 2); + EXPECT_EQ(memcmp(t.e[0].pub, a, 32), 0); + EXPECT_EQ(strlen(t.e[0].name), 31u); + EXPECT_EQ(t.e[0].type, 1); + EXPECT_EQ(t.e[0].flags, 2); + EXPECT_STREQ(t.e[1].name, "C"); + for (size_t i = 0; i < asr650x::PIN_SIZE; i++) { + uint8_t bad[asr650x::PIN_SIZE]; + memcpy(bad, row, sizeof(bad)); + bad[i] ^= 0x01; + asr650x::PinSet u; + EXPECT_FALSE(asr650x::pinsDecode(bad, u)) << i; + EXPECT_EQ(u.n, 0); + } +} + +TEST(Asr650xPins, CountAboveThreeRejectedEvenWithValidCrc) { + uint8_t a[32]; + key(a, 0x10); + asr650x::PinSet s; + asr650x::pinUpsert(s, a, "A", 1, 0); + uint8_t row[asr650x::PIN_SIZE]; + asr650x::pinsEncode(s, row); + row[4] = 4; + uint32_t crc = asr650x::crc32(row, asr650x::PIN_SIZE - 4); + for (int i = 0; i < 4; i++) + row[asr650x::PIN_SIZE - 4 + i] = (uint8_t)(crc >> (8 * i)); + asr650x::PinSet t; + EXPECT_FALSE(asr650x::pinsDecode(row, t)); + EXPECT_EQ(t.n, 0); +} + +TEST(Asr650xPins, EmptySetIsAValidRecord) { + asr650x::PinSet s, t; + uint8_t row[asr650x::PIN_SIZE]; + asr650x::pinsEncode(s, row); + EXPECT_TRUE(asr650x::pinsDecode(row, t)); + EXPECT_EQ(t.n, 0); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_radio_params/test_asr650x_radio_params.cpp b/test/test_asr650x_radio_params/test_asr650x_radio_params.cpp new file mode 100644 index 0000000000..95dfed761c --- /dev/null +++ b/test/test_asr650x_radio_params/test_asr650x_radio_params.cpp @@ -0,0 +1,74 @@ +#include "helpers/asr650x/RadioParams.h" + +#include +#include +#include +#include + +// Independent reference: Semtech SX1262 datasheet time-on-air (explicit header, CRC on), in microseconds. +static double ref_airtime_us(int len, double bw_khz, int sf, int cr, int preamble) { + double tsym = (double)(1 << sf) / (bw_khz * 1000.0); // seconds + int de = (tsym * 1000.0 >= 16.38) + ? 1 + : 0; // low data-rate optimisation, same rule as the Heltec driver (radio.c) + double tpre = (preamble + 4.25) * tsym; + double num = 8.0 * len - 4.0 * sf + 28 + 16 /*CRC*/ - 0 /*explicit header*/; + double den = 4.0 * (sf - 2 * de); + double npay = 8 + std::max(std::ceil(num / den) * cr, 0.0); + return (tpre + npay * tsym) * 1e6; +} + +TEST(Asr650xRadioParams, Behaves) { + /* bandwidth mapping: exact, nearest, extremes */ + EXPECT_TRUE(asr650x::bwIndex(62.5f) == 3 && asr650x::bwIndex(125.0f) == 0 && + asr650x::bwIndex(250.0f) == 1 && asr650x::bwIndex(500.0f) == 2); + EXPECT_TRUE(asr650x::bwIndex(41.67f) == 4 && asr650x::bwIndex(31.25f) == 5 && + asr650x::bwIndex(20.83f) == 6); + EXPECT_TRUE(asr650x::bwIndex(15.63f) == 7 && asr650x::bwIndex(10.42f) == 8 && asr650x::bwIndex(7.81f) == 9); + EXPECT_TRUE(asr650x::bwIndex(100.0f) == + 0); // nearest of {62.5, 125} by ratio is 125 (100/62.5 = 1.6 > 125/100 = 1.25) + EXPECT_TRUE(asr650x::bwIndex(7.0f) == 9); // below range -> smallest + EXPECT_TRUE(asr650x::bwIndex(900.0f) == 2); // above range -> largest + EXPECT_TRUE(asr650x::bwIndex(0.0f) == 9 && + asr650x::bwIndex(-5.0f) == 9); // nonsense never indexes out of the table + EXPECT_TRUE(std::fabs(asr650x::bwValue(3) - 62.5f) < 1e-3 && + std::fabs(asr650x::bwValue(0) - 125.0f) < 1e-3); + + /* coding rate / sf / preamble. cr arguments use the MeshCore convention 5..8, the result is the Heltec 1..4 + */ + EXPECT_TRUE(asr650x::crIndex(5) == 1 && asr650x::crIndex(8) == 4 && asr650x::crIndex(4) == 1 && + asr650x::crIndex(9) == 4 && asr650x::crIndex(0) == 1); + EXPECT_TRUE(asr650x::sfClamp(4) == 5 && asr650x::sfClamp(13) == 12 && asr650x::sfClamp(8) == 8); + EXPECT_TRUE(asr650x::preambleForSf(7) == 32 && asr650x::preambleForSf(8) == 32 && + asr650x::preambleForSf(9) == 16 && asr650x::preambleForSf(12) == 16); + + /* airtime within 1.5 ms of the independent reference, for the production setting and a spread of others */ + struct { + int len; + double bw; + int sf, cr, pre; + } cases[] = { + { 110, 62.5, 8, 5, 32 }, { 1, 62.5, 8, 5, 32 }, { 255, 62.5, 8, 5, 32 }, { 110, 125, 7, 5, 32 }, + { 110, 250, 10, 5, 16 }, { 50, 125, 12, 8, 16 }, { 255, 500, 7, 6, 32 }, { 20, 62.5, 11, 5, 16 }, + { 20, 15.63, 8, 5, 32 } /* 16.378 ms symbols: the driver keeps LDRO off */ + }; + for (auto &c : cases) { + double ref = ref_airtime_us(c.len, c.bw, c.sf, c.cr, c.pre) / 1000.0; // multiplier is CR+4 = 5..8 + double got = asr650x::airtimeMs(c.len, (float)c.bw, (uint8_t)c.sf, (uint8_t)c.cr, (uint16_t)c.pre); + EXPECT_NEAR(ref, got, 1.5) << "len=" << c.len << " bw=" << c.bw << " sf=" << c.sf << " cr=" << c.cr; + } + EXPECT_TRUE(asr650x::airtimeMs(0, 62.5f, 8, 5, 32) > 0); // an empty packet still has preamble + header + + /* packet score */ + EXPECT_TRUE(asr650x::packetScore(-20.0f, 8, 100) == 0.0f); // below the SF8 threshold (-10 dB) + EXPECT_TRUE(asr650x::packetScore(0.0f, 6, 100) == 0.0f); // unsupported SF + float good = asr650x::packetScore(9.0f, 8, 110); + EXPECT_TRUE(good > 0.0f && good <= 1.0f); + EXPECT_TRUE(asr650x::packetScore(9.0f, 8, 10) > + asr650x::packetScore(9.0f, 8, 200)); // longer packets score lower +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_status/test_asr650x_status.cpp b/test/test_asr650x_status/test_asr650x_status.cpp new file mode 100644 index 0000000000..cdb2027bfa --- /dev/null +++ b/test/test_asr650x_status/test_asr650x_status.cpp @@ -0,0 +1,103 @@ +#include "helpers/asr650x/StatusLines.h" + +#include +#include +#include +#include + +static asr650x::StatusInput base() { + asr650x::StatusInput in; + std::memset(&in, 0, sizeof(in)); + in.name = "AB02S-NODE"; + in.id4[0] = 0xab; + in.id4[1] = 0x26; + in.id4[2] = 0x86; + in.id4[3] = 0x31; + in.freq_khz = 920250; + in.sf = 8; + in.max_contacts = 12; + return in; +} +static std::string line(int i, const asr650x::StatusInput &in) { + char o[22]; + asr650x::statusLine(i, in, o); + return o; +} + +TEST(Asr650xStatusLines, Behaves) { + asr650x::StatusInput in = base(); + EXPECT_TRUE(line(0, in) == "AB02S-NODE"); + EXPECT_TRUE(line(1, in) == "ID ab268631"); + EXPECT_TRUE(line(2, in) == "--:--:-- no time"); /* clock never synced */ + EXPECT_TRUE(line(3, in) == "GPS off"); + EXPECT_TRUE(line(4, in) == "LAT --"); + EXPECT_TRUE(line(5, in) == "LON --"); + EXPECT_TRUE(line(6, in) == "BAT -- CT 0/12"); /* battery not measured yet */ + EXPECT_TRUE(line(7, in) == "920.250MHz SF8"); + + in.time_valid = true; + in.epoch = 1791117296u; /* 2026-10-04 12:34:56 UTC */ + EXPECT_TRUE(line(2, in) == "12:34:56 UTC"); + in.epoch = 1735689600u; /* midnight */ + EXPECT_TRUE(line(2, in) == "00:00:00 UTC"); + in.epoch = 1735689600u + 86399u; + EXPECT_TRUE(line(2, in) == "23:59:59 UTC"); + + in.gps_state = 1; + in.warmingS = 45; + EXPECT_TRUE(line(3, in) == "GPS search 45s"); + in.warmingS = 100000; /* absurd: still within 21 characters */ + EXPECT_TRUE(line(3, in).size() <= 21); + in.gps_state = 2; + in.sats = 8; + in.pos_valid = true; + EXPECT_TRUE(line(3, in) == "GPS fix 8 sats"); + in.lat_e6 = 21028511; + in.lon_e6 = 105804817; + EXPECT_TRUE(line(4, in) == "LAT 21.028511"); + EXPECT_TRUE(line(5, in) == "LON 105.804817"); + in.lat_e6 = -33860000; + in.lon_e6 = -6505620; + EXPECT_TRUE(line(4, in) == "LAT -33.860000"); + EXPECT_TRUE(line(5, in) == "LON -6.505620"); + in.lat_e6 = -50000; + in.lon_e6 = 5; /* -0.05 degrees keeps its sign, tiny values pad */ + EXPECT_TRUE(line(4, in) == "LAT -0.050000"); + EXPECT_TRUE(line(5, in) == "LON 0.000005"); + in.pos_valid = false; + EXPECT_TRUE(line(4, in) == "LAT --"); + in.gps_state = 3; + EXPECT_TRUE(line(3, in) == "GPS timeout"); + + in.batt_mv = 3981; + in.contacts = 3; + EXPECT_TRUE(line(6, in) == "BAT 3.98V CT 3/12"); + in.batt_mv = 5000; + in.contacts = 255; + in.max_contacts = 255; + EXPECT_TRUE(line(6, in) == "BAT 5.00V CT 255/255"); + in.batt_mv = 65535; + EXPECT_TRUE(line(6, in).size() <= 21); + + in.freq_khz = 915000; + in.sf = 12; + EXPECT_TRUE(line(7, in) == "915.000MHz SF12"); + + /* long or odd names are cut to 21 characters; control characters and a null name are safe */ + in.name = "A-VERY-LONG-NODE-NAME-THAT-DOES-NOT-FIT-ON-THE-SCREEN"; + EXPECT_TRUE(line(0, in) == "A-VERY-LONG-NODE-NAME"); /* exactly 21 characters */ + in.name = nullptr; + EXPECT_TRUE(line(0, in) == ""); + in.name = "tab\there\n"; + for (char c : line(0, in)) + EXPECT_TRUE((unsigned char)c >= 0x20 && (unsigned char)c < 0x7f); + /* out-of-range line index gives an empty line */ + EXPECT_TRUE(line(8, in) == "" && line(-1, in) == ""); + for (int i = 0; i < 8; i++) + EXPECT_TRUE(line(i, in).size() <= 21); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_asr650x_strtod/test_asr650x_strtod.cpp b/test/test_asr650x_strtod/test_asr650x_strtod.cpp new file mode 100644 index 0000000000..a09473135e --- /dev/null +++ b/test/test_asr650x_strtod/test_asr650x_strtod.cpp @@ -0,0 +1,43 @@ +#include "helpers/asr650x/SmallStrtod.h" + +#include +#include + +// The CLI parses numbers such as radio frequencies ("869.618"), bandwidths ("62.5"), dB values ("-3.5") and +// factors. +TEST(Asr650xStrtod, MatchesLibcOnCliStyleNumbers) { + const char *in[] = { "0", "1", "-1", "869.618", "62.5", "-3.5", "+7", "0.000001", + "123456789", "1e3", "2.5E-2", " 42", "920.25,62.5", ".5", "5.", "-0.0" }; + for (const char *s : in) { + char *e1, *e2; + double want = strtod(s, &e1); + double got = asr650xStrtod(s, &e2); + EXPECT_NEAR(got, want, 1e-9 * (want < 0 ? -want : want) + 1e-12) << s; + EXPECT_EQ(e2 - s, e1 - s) << s; + } +} + +TEST(Asr650xStrtod, NoDigitsConsumesNothing) { + const char *in[] = { "", "abc", "-", ".", "e5", "+." }; + for (const char *s : in) { + char *e; + EXPECT_EQ(asr650xStrtod(s, &e), 0.0) << s; + EXPECT_EQ(e, s) << s; + } +} + +TEST(Asr650xStrtod, ExponentWithoutDigitsIsNotConsumed) { + char *e; + const char *s = "12e"; + EXPECT_EQ(asr650xStrtod(s, &e), 12.0); + EXPECT_EQ(e - s, 2); +} + +TEST(Asr650xStrtod, NullEndPointerIsAllowed) { + EXPECT_EQ(asr650xStrtod("3.25", nullptr), 3.25); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_config_serializer_noop/test_config_serializer_noop.cpp b/test/test_config_serializer_noop/test_config_serializer_noop.cpp new file mode 100644 index 0000000000..b56cfe88a3 --- /dev/null +++ b/test/test_config_serializer_noop/test_config_serializer_noop.cpp @@ -0,0 +1,84 @@ +// Runs only in [env:native_noop_config] (CONFIG_SERIALIZER_NOOP): boards that keep prefs in a binary record. +#include "helpers/ConfigSerializer.h" +#include "helpers/DynamicConfigSerializer.h" + +#include +#include + +namespace { +class Prefs : public ConfigSerializer { +public: + char name[16]; + int32_t i32 = 1; + int16_t i16 = 2; + int8_t i8 = 3; + uint32_t u32 = 4; + uint16_t u16 = 5; + uint8_t u8 = 6; + float f = 7.0f; + double d = 8.0; + bool b = true; + uint8_t blob[4] = { 1, 2, 3, 4 }; + void touch() { markDirty(); } + +protected: + void structure() override { + def("name", name, sizeof(name)); + def("blob", blob, sizeof(blob)); + def("i32", i32); + def("i16", i16); + def("i8", i8); + def("u32", u32); + def("u16", u16); + def("u8", u8); + def("f", f); + def("d", d); + def("b", b); + } +}; + +class OneKey : public KeyValueStore { +public: + char v[8] = "old"; + bool setByKey(const char *key, const char *value) override { + if (strcmp(key, "known") != 0) return false; + strncpy(v, value, sizeof(v) - 1); + return true; + } + bool getByKey(const char *key, char *value, size_t max_len) override { + if (strcmp(key, "known") != 0) return false; + strncpy(value, v, max_len); + return true; + } +}; +} // namespace + +TEST(ConfigSerializerNoop, DirtyFlagStillWorks) { + Prefs p; + EXPECT_FALSE(p.isDirty()); + p.touch(); + EXPECT_TRUE(p.isDirty()); + p.clearDirty(); + EXPECT_FALSE(p.isDirty()); +} + +TEST(DynamicConfigSerializerNoop, OnlyTheFallbackStoresValues) { + OneKey fb; + DynamicConfigSerializer d(&fb); + char out[8]; + EXPECT_TRUE(d.setByKey("known", "new")); + EXPECT_STREQ(fb.v, "new"); + EXPECT_TRUE(d.getByKey("known", out, sizeof(out) - 1)); + EXPECT_STREQ(out, "new"); + EXPECT_FALSE(d.setByKey("other", "x")); // no local text store in this build + EXPECT_FALSE(d.getByKey("other", out, sizeof(out) - 1)); +} + +TEST(DynamicConfigSerializerNoop, HasNoTextBuffer) { + EXPECT_LE(sizeof(DynamicConfigSerializer), 4 * sizeof(void *)); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_contact_path/test_contact_path.cpp b/test/test_contact_path/test_contact_path.cpp new file mode 100644 index 0000000000..f756493314 --- /dev/null +++ b/test/test_contact_path/test_contact_path.cpp @@ -0,0 +1,93 @@ +#include +#include + +#define CONTACT_OUT_PATH_SIZE 16 +#include "helpers/ContactPath.h" + +// encoded path_len: upper 2 bits = hash size - 1, lower 6 bits = hop count +static uint8_t enc(uint8_t hash_size, uint8_t count) { + return (uint8_t)(((hash_size - 1) << 6) | count); +} + +TEST(ContactPath, ShortPathIsStored) { + uint8_t src[64], dst[CONTACT_OUT_PATH_SIZE + 1]; + for (int i = 0; i < 64; i++) + src[i] = (uint8_t)(i + 1); + dst[CONTACT_OUT_PATH_SIZE] = 0xAA; // guard byte + EXPECT_EQ(contactStorePath(dst, src, enc(1, 5)), enc(1, 5)); + EXPECT_EQ(memcmp(dst, src, 5), 0); + EXPECT_EQ(dst[CONTACT_OUT_PATH_SIZE], 0xAA); +} + +TEST(ContactPath, ExactlyFullIsStored) { + uint8_t src[64] = { 0 }, dst[CONTACT_OUT_PATH_SIZE + 1]; + dst[CONTACT_OUT_PATH_SIZE] = 0xAA; + EXPECT_EQ(contactStorePath(dst, src, enc(2, 8)), enc(2, 8)); // 8 hops x 2 bytes = 16 + EXPECT_EQ(dst[CONTACT_OUT_PATH_SIZE], 0xAA); +} + +TEST(ContactPath, TooLongBecomesUnknown) { + uint8_t src[64] = { 0 }, dst[CONTACT_OUT_PATH_SIZE + 1]; + dst[CONTACT_OUT_PATH_SIZE] = 0xAA; + for (uint8_t hops = 17; hops <= 63; hops++) { // 1-byte hashes + EXPECT_EQ(contactStorePath(dst, src, enc(1, hops)), OUT_PATH_UNKNOWN) << (int)hops; + } + EXPECT_EQ(contactStorePath(dst, src, enc(3, 6)), OUT_PATH_UNKNOWN); // 18 bytes + EXPECT_EQ(dst[CONTACT_OUT_PATH_SIZE], 0xAA); +} + +TEST(ContactPath, ReservedHashSizeBecomesUnknown) { + uint8_t src[64] = { 0 }, dst[CONTACT_OUT_PATH_SIZE + 1]; + dst[CONTACT_OUT_PATH_SIZE] = 0xAA; + EXPECT_EQ(contactStorePath(dst, src, enc(4, 2)), OUT_PATH_UNKNOWN); // hash size 4 is reserved + EXPECT_EQ(dst[CONTACT_OUT_PATH_SIZE], 0xAA); +} + +TEST(ContactPath, UnknownStaysUnknown) { + uint8_t src[64] = { 0 }, dst[CONTACT_OUT_PATH_SIZE]; + EXPECT_EQ(contactStorePath(dst, src, OUT_PATH_UNKNOWN), OUT_PATH_UNKNOWN); +} + +TEST(ContactPath, FrameAlwaysGets64Bytes) { + uint8_t stored[CONTACT_OUT_PATH_SIZE], frame[65]; + for (int i = 0; i < CONTACT_OUT_PATH_SIZE; i++) + stored[i] = (uint8_t)(0x10 + i); + memset(frame, 0xEE, sizeof(frame)); + contactWritePath64(frame, stored, enc(1, 4)); + EXPECT_EQ(memcmp(frame, stored, 4), 0); + for (int i = 4; i < 64; i++) + EXPECT_EQ(frame[i], 0) << i; // nothing stale after the route + EXPECT_EQ(frame[64], 0xEE); // nothing written past 64 +} + +TEST(ContactPath, FrameForUnknownRouteIsAllZero) { + uint8_t stored[CONTACT_OUT_PATH_SIZE], frame[64]; + memset(stored, 0x55, sizeof(stored)); + memset(frame, 0xEE, sizeof(frame)); + contactWritePath64(frame, stored, OUT_PATH_UNKNOWN); + for (int i = 0; i < 64; i++) + EXPECT_EQ(frame[i], 0) << i; +} + +// call-site wrappers (small mode): from a 64-byte frame/file field, from a packet route, and back to 64 bytes +TEST(ContactPath, WrappersClampInSmallMode) { + uint8_t src[64], dst[CONTACT_OUT_PATH_SIZE + 1], out[64]; + for (int i = 0; i < 64; i++) + src[i] = (uint8_t)(0x30 + i); + dst[CONTACT_OUT_PATH_SIZE] = 0xAA; + EXPECT_EQ(contactPathFrom64(dst, src, enc(1, 3)), enc(1, 3)); + EXPECT_EQ(memcmp(dst, src, 3), 0); + EXPECT_EQ(contactPathFrom64(dst, src, enc(1, 40)), OUT_PATH_UNKNOWN); + EXPECT_EQ(contactPathFromPacket(dst, src, enc(2, 9)), OUT_PATH_UNKNOWN); // 18 bytes + EXPECT_EQ(contactPathFromPacket(dst, src, enc(2, 8)), enc(2, 8)); + EXPECT_EQ(dst[CONTACT_OUT_PATH_SIZE], 0xAA); + contactPathTo64(out, dst, enc(2, 8)); + EXPECT_EQ(memcmp(out, src, 16), 0); + for (int i = 16; i < 64; i++) + EXPECT_EQ(out[i], 0) << i; +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_ed25519_vectors/test_ed25519_vectors.cpp b/test/test_ed25519_vectors/test_ed25519_vectors.cpp new file mode 100644 index 0000000000..f62fc50046 --- /dev/null +++ b/test/test_ed25519_vectors/test_ed25519_vectors.cpp @@ -0,0 +1,75 @@ +// Ed25519 known-answer tests (RFC 8032 section 7.1, TEST 1 and TEST 2). Run with the full fixed-base table +// ([env:native]) and with the small one ([env:native_ed25519_small], -D ED25519_SMALL_BASE_TABLE). +#include "ed_25519.h" + +#include +#include +extern "C" { +#include "ge.h" +#include "precomp_data.h" +} + +static void unhex(uint8_t *out, const char *hex) { + for (size_t i = 0; hex[2 * i]; i++) { + unsigned v; + sscanf(hex + 2 * i, "%2x", &v); + out[i] = (uint8_t)v; + } +} + +struct Vec { + const char *seed, *pub, *msg, *sig; +}; +static const Vec VECTORS[] = { + { "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0" + "595bbe24655141438e7a100b" }, + { "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eae" + "b4302aeeb00d291612bb0c00" }, +}; + +TEST(Ed25519Vectors, KeypairSignVerifyMatchRfc8032) { + for (const Vec &v : VECTORS) { + uint8_t seed[32], pub_want[32], sig_want[64], msg[8], pub[32], prv[64], sig[64]; + size_t msg_len = strlen(v.msg) / 2; + unhex(seed, v.seed); + unhex(pub_want, v.pub); + unhex(sig_want, v.sig); + unhex(msg, v.msg); + ed25519_create_keypair(pub, prv, seed); + EXPECT_EQ(memcmp(pub, pub_want, 32), 0); + uint8_t derived[32]; + ed25519_derive_pub(derived, prv); + EXPECT_EQ(memcmp(derived, pub_want, 32), 0); + ed25519_sign(sig, msg, msg_len, pub, prv); + EXPECT_EQ(memcmp(sig, sig_want, 64), 0); + EXPECT_EQ(ed25519_verify(sig, msg, msg_len, pub), 1); + sig[5] ^= 0x04; + EXPECT_EQ(ed25519_verify(sig, msg, msg_len, pub), 0); + } +} + +TEST(Ed25519Vectors, KeyExchangeIsSymmetric) { + uint8_t sa[32], sb[32], pa[32], pb[32], ka[64], kb[64], s1[32], s2[32]; + unhex(sa, VECTORS[0].seed); + unhex(sb, VECTORS[1].seed); + ed25519_create_keypair(pa, ka, sa); + ed25519_create_keypair(pb, kb, sb); + ed25519_key_exchange(s1, pb, ka); + ed25519_key_exchange(s2, pa, kb); + EXPECT_EQ(memcmp(s1, s2, 32), 0); +} + +#ifdef ED25519_SMALL_BASE_TABLE +TEST(Ed25519Vectors, SmallBuildKeepsOnlyOneTableRow) { + EXPECT_EQ(ED25519_BASE_ROWS, 1); +} +#endif + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_fast_pow/test_fast_pow.cpp b/test/test_fast_pow/test_fast_pow.cpp new file mode 100644 index 0000000000..4f92b768f6 --- /dev/null +++ b/test/test_fast_pow/test_fast_pow.cpp @@ -0,0 +1,36 @@ +#include "helpers/FastPow.h" + +#include +#include + +// calcRxDelay() uses 10^(0.85 - score); score is in [0, 1] in practice, the range below is wider on purpose +TEST(FastPow10, WithinOnePercentOfPow) { + for (int i = -300; i <= 300; i++) { + float x = i / 100.0f; + float want = powf(10.0f, x); + float got = fastPow10f(x); + EXPECT_NEAR(got / want, 1.0f, 0.01f) << "x=" << x; + } +} + +TEST(FastPow10, ExactAtIntegers) { + EXPECT_NEAR(fastPow10f(0.0f), 1.0f, 0.001f); + EXPECT_NEAR(fastPow10f(1.0f), 10.0f, 0.05f); + EXPECT_NEAR(fastPow10f(-1.0f), 0.1f, 0.0005f); +} + +// MyMesh::calcRxDelay() uses rx_delay_base^(0.85 - score) with a user-set base +TEST(FastPow, AnyBaseWithinOnePercentOfPow) { + const float bases[] = { 1.05f, 1.5f, 2.0f, 5.0f, 10.0f, 20.0f }; + for (float b : bases) { + for (int i = -200; i <= 200; i++) { + float x = i / 100.0f; + EXPECT_NEAR(fastPowf(b, x) / powf(b, x), 1.0f, 0.01f) << "b=" << b << " x=" << x; + } + } +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_serial_frame_strict/test_serial_frame_strict.cpp b/test/test_serial_frame_strict/test_serial_frame_strict.cpp new file mode 100644 index 0000000000..a01f0be1f4 --- /dev/null +++ b/test/test_serial_frame_strict/test_serial_frame_strict.cpp @@ -0,0 +1,86 @@ +// ArduinoSerialInterface built with SERIAL_FRAME_STRICT: a stalled (truncated) frame must not swallow the +// next frame, and a frame longer than MAX_FRAME_SIZE must be dropped, not executed truncated. +#define SERIAL_FRAME_STRICT +#include "../../src/helpers/ArduinoSerialInterface.cpp" + +#include +#include + +namespace { +class FakeSerial : public Stream { +public: + std::vector in; + size_t pos = 0; + int available() override { return (int)(in.size() - pos); } + int read() override { return pos < in.size() ? in[pos++] : -1; } + void push(const std::vector &b) { in.insert(in.end(), b.begin(), b.end()); } +}; + +std::vector frame(const std::vector &payload) { + std::vector f = { '<', (uint8_t)(payload.size() & 0xFF), (uint8_t)(payload.size() >> 8) }; + f.insert(f.end(), payload.begin(), payload.end()); + return f; +} +} // namespace + +TEST(SerialFrameStrict, CompleteFrameIsDelivered) { + FakeSerial s; + ArduinoSerialInterface sif; + sif.begin(s); + s.push(frame({ 22, 3 })); + uint8_t out[MAX_FRAME_SIZE]; + EXPECT_EQ(sif.checkRecvFrame(out), 2u); + EXPECT_EQ(out[0], 22); +} + +TEST(SerialFrameStrict, StalledFrameDoesNotSwallowTheNextOne) { + FakeSerial s; + ArduinoSerialInterface sif; + sif.begin(s); + uint8_t out[MAX_FRAME_SIZE]; + g_mock_millis = 1000; + s.push({ '<', 10, 0, 1, 2, 3 }); // header says 10 bytes, only 3 arrive + EXPECT_EQ(sif.checkRecvFrame(out), 0u); + g_mock_millis = 1150; // the main loop polls during the silence (nothing buffered): + EXPECT_EQ(sif.checkRecvFrame(out), 0u); // > 100 ms since the last byte -> the partial frame is abandoned + g_mock_millis = 1200; + s.push(frame({ 22, 3 })); + EXPECT_EQ(sif.checkRecvFrame(out), 2u); + EXPECT_EQ(out[0], 22); +} + +TEST(SerialFrameStrict, OversizeFrameIsDroppedNotExecuted) { + FakeSerial s; + ArduinoSerialInterface sif; + sif.begin(s); + uint8_t out[MAX_FRAME_SIZE]; + std::vector big(MAX_FRAME_SIZE + 20, 0x41); + big[0] = 22; + s.push(frame(big)); + EXPECT_EQ(sif.checkRecvFrame(out), 0u); // dropped + s.push(frame({ 5 })); + EXPECT_EQ(sif.checkRecvFrame(out), 1u); // and the next frame still parses + EXPECT_EQ(out[0], 5); +} + +// the device was busy for > 100 ms while the rest of the frame was already waiting in the UART buffer: +// the frame must still be delivered (only a sender that really stopped is abandoned) +TEST(SerialFrameStrict, BusyDeviceDoesNotDropAQueuedFrame) { + FakeSerial s; + ArduinoSerialInterface sif; + sif.begin(s); + uint8_t out[MAX_FRAME_SIZE]; + g_mock_millis = 5000; + s.push({ '<', 4, 0, 22, 1 }); // first part read + EXPECT_EQ(sif.checkRecvFrame(out), 0u); + s.push({ 2, 3 }); // rest arrived while the loop was busy + g_mock_millis = 5300; + EXPECT_EQ(sif.checkRecvFrame(out), 4u); + EXPECT_EQ(out[0], 22); + EXPECT_EQ(out[3], 3); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/heltec_cubecell_ab02s/CubeCellBoard.cpp b/variants/heltec_cubecell_ab02s/CubeCellBoard.cpp new file mode 100644 index 0000000000..a015aec5b6 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/CubeCellBoard.cpp @@ -0,0 +1,100 @@ +#include "CubeCellGPS.h" +#include "Oled.h" +#include "StatusScreen.h" +#include "target.h" + +#include +#include + +#ifndef BIGSTACK_SIZE +#define BIGSTACK_SIZE 2048 +#endif + +static OledPanel oled; +static StatusScreen screen; +static uint32_t last_input_ms = 0; + +void CubeCellBoard::begin() { + // Ed25519 needs more than the fixed 2 KB main stack: it runs on a stack taken from the heap (BigStack.h). + // Without it, signing would overflow the main stack, so stop here (the app sees no reply). + if (!asr650xBigstackInit(BIGSTACK_SIZE)) { + while (true) + delay(1000); + } + bool ok = oled.begin(); + sensors.setOledStatus(ok ? "ok" : "none"); + screen.begin(&oled); +#ifdef ASR650X_STACK_WATCH + asr650xStackPaint(); +#endif +} + +static void updateStatusInput(uint32_t now) { + asr650x::StatusInput in; + memset(&in, 0, sizeof(in)); + NodePrefs *prefs = the_mesh.getNodePrefs(); + const asr650x::GpsData &g = cubecell_gps.data(); + in.name = prefs->node_name; + memcpy(in.id4, the_mesh.self_id.pub_key, 4); + in.time_valid = rtc_clock.synced(); + in.epoch = rtc_clock.getCurrentTime(); + if (!cubecell_gps.enabled()) + in.gps_state = 0; + else if (cubecell_gps.state() == asr650x::GPS_WARMING) + in.gps_state = 1; + else if (g.pos_valid) + in.gps_state = 2; + else if (cubecell_gps.timedOut()) + in.gps_state = 3; + in.sats = g.sats; + in.pos_valid = g.pos_valid; + in.lat_e6 = g.lat_e6; + in.lon_e6 = g.lon_e6; + int n = the_mesh.getNumContacts(); + in.contacts = (uint8_t)(n < 0 ? 0 : (n > 255 ? 255 : n)); + in.max_contacts = (uint8_t)(MAX_CONTACTS > 255 ? 255 : MAX_CONTACTS); + in.freq_khz = (uint32_t)(prefs->freq * 1000.0f + 0.5f); + in.sf = prefs->sf; + in.warmingS = cubecell_gps.warmingS(); + screen.setInput(in); +} + +void CubeCellBoard::loop() { + uint32_t now = millis(); + if (now - last_input_ms >= 1000) { + last_input_ms = now; + updateStatusInput(now); + } + screen.loop(now); +} + +#ifdef ASR650X_STACK_WATCH +extern "C" { +extern uint32_t __cy_stack_limit[]; +extern uint32_t __cy_stack[]; +void *sbrk(int); +} + +void asr650xStackPaint() { + uint32_t *sp; + __asm volatile("mov %0, sp" : "=r"(sp)); + for (uint32_t *p = __cy_stack_limit; p < sp - 16; p++) + *p = 0xA5A5A5A5u; +} + +static uint32_t stackUsed() { + uint32_t *p = __cy_stack_limit; + while (p < __cy_stack && *p == 0xA5A5A5A5u) + p++; + return (uint32_t)((uint8_t *)__cy_stack - (uint8_t *)p); +} + +const char *asr650xWatchString() { + static char name[64]; + extern char _end; + snprintf(name, sizeof(name), "CC stk=%u big=%u heap=%u p=%02x%02x%02x", (unsigned)stackUsed(), + (unsigned)asr650xBigstackHighwater(), (unsigned)((uint32_t)sbrk(0) - (uint32_t)&_end), + (unsigned)asr650x_boot_pool[0], (unsigned)asr650x_boot_pool[1], (unsigned)asr650x_boot_pool[2]); + return name; +} +#endif diff --git a/variants/heltec_cubecell_ab02s/CubeCellBoard.h b/variants/heltec_cubecell_ab02s/CubeCellBoard.h new file mode 100644 index 0000000000..e1ba247b1c --- /dev/null +++ b/variants/heltec_cubecell_ab02s/CubeCellBoard.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +extern "C" void CySoftwareReset(void); + +#ifdef ASR650X_STACK_WATCH +// Measurement builds only (never released): DEVICE_QUERY reports stack/heap high-water marks in the +// manufacturer field. +void asr650xStackPaint(); +const char *asr650xWatchString(); +#endif + +class CubeCellBoard : public mesh::MainBoard { +public: + void begin(); // crypto stack, OLED status screen + void loop() override; // OLED status screen (one page per call) + void attachDynamicPrefs(KeyValueStore *) {} // no board-specific prefs + uint16_t getBattMilliVolts() override { return getBatteryVoltage(); } + const char *getManufacturerName() const override { +#ifdef ASR650X_STACK_WATCH + return asr650xWatchString(); +#else + return "Heltec CubeCell HTCC-AB02S"; +#endif + } + void reboot() override { CySoftwareReset(); } + uint8_t getStartupReason() const override { return BD_STARTUP_NORMAL; } +}; diff --git a/variants/heltec_cubecell_ab02s/CubeCellGPS.cpp b/variants/heltec_cubecell_ab02s/CubeCellGPS.cpp new file mode 100644 index 0000000000..033e7ed490 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/CubeCellGPS.cpp @@ -0,0 +1,100 @@ +#include "CubeCellGPS.h" + +#include "target.h" + +#include + +CubeCellGPS cubecell_gps; + +static const uint32_t FIX_TIMEOUT_S = 300; // give up on a fix after 5 minutes (GPS off again) + +CubeCellGPS::CubeCellGPS() + : _enabled(true), _interval(0), _powered(false), _began(false), _power_on_ms(0), _cfg_sent(0), + _uptime_s(0), _uptime_ms_mark(0), _last_tick_ms(0) {} + +uint32_t CubeCellGPS::uptime(uint32_t now_ms) { + uint32_t d = now_ms - _uptime_ms_mark; // unsigned: correct across the millis() wrap + _uptime_s += d / 1000; + _uptime_ms_mark += (d / 1000) * 1000; + return _uptime_s; +} + +void CubeCellGPS::power(bool on, uint32_t now_ms) { + if (on == _powered) return; + digitalWrite(GPIO14, on ? LOW : HIGH); // VGPS_Control: LOW = powered + _powered = on; + if (on) { + _power_on_ms = now_ms; + _cfg_sent = 0; + _nmea.resetFix(); + } +} + +void CubeCellGPS::begin() { + pinMode(GPIO14, OUTPUT); + digitalWrite(GPIO14, HIGH); // off + Serial1.begin(9600); // once + _began = true; + _policy.configure(_enabled, _interval, FIX_TIMEOUT_S); + _uptime_ms_mark = millis(); + _policy.begin(uptime(millis())); +} + +void CubeCellGPS::apply(bool enabled, uint32_t interval_s) { + bool turned_on = enabled && !_enabled; + _enabled = enabled; + _interval = interval_s > 86400 ? 86400 : interval_s; + _policy.configure(_enabled, _interval, FIX_TIMEOUT_S); + if (turned_on && _began) _policy.request(uptime(millis())); +} + +void CubeCellGPS::request() { + if (_enabled && _began) _policy.request(uptime(millis())); +} + +void CubeCellGPS::onFix(bool from_rmc) { + const asr650x::GpsData &d = _nmea.data(); + if (d.pos_valid) { + sensors.node_lat = d.lat_e6 / 1000000.0; + sensors.node_lon = d.lon_e6 / 1000000.0; + sensors.node_altitude = d.alt_dm / 10.0; + } + if (from_rmc && d.rmc_valid) { // time only from the RMC just parsed, never from a stale one + uint32_t epoch = asr650x::gpsToEpoch(d.date_ddmmyy, d.time_hhmmss); + if (epoch != 0) { + uint32_t now = rtc_clock.getCurrentTime(); + uint32_t diff = epoch > now ? epoch - now : now - epoch; + if (!rtc_clock.synced() || diff >= 2) rtc_clock.setCurrentTime(epoch); + } + } +} + +void CubeCellGPS::loop(uint32_t now_ms) { + if (!_began) return; + while (Serial1.available() > 0) { // always drain: the UART buffer must not fill up + int c = Serial1.read(); + if (c < 0) break; + if (!_powered) continue; + asr650x::NmeaEvent e = _nmea.feed((uint8_t)c); + if (e == asr650x::NMEA_GGA || e == asr650x::NMEA_RMC) { + const asr650x::GpsData &d = _nmea.data(); + if (d.pos_valid) onFix(e == asr650x::NMEA_RMC); + } + } + // Air530Z: $PCAS03 keeps only GGA and RMC (the Air530 PGKC242 command is ignored by this module). Sent at 1 + // s and again at 3 s after power-on, in case the module was not listening yet the first time. + if (_powered && _cfg_sent < 2 && now_ms - _power_on_ms >= (_cfg_sent == 0 ? 1000u : 3000u)) { + char line[64]; + int n = asr650x::nmeaMake(line, sizeof(line), "PCAS03,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0"); + if (n > 0) Serial1.write((const uint8_t *)line, (size_t)n); + _cfg_sent++; + } + if (now_ms - _last_tick_ms >= 1000) { + _last_tick_ms = now_ms; + const asr650x::GpsData &d = _nmea.data(); + bool fix_ok = + _powered && d.pos_valid && d.rmc_valid && asr650x::gpsToEpoch(d.date_ddmmyy, d.time_hhmmss) != 0; + _policy.tick(uptime(now_ms), fix_ok); + power(_policy.powerWanted(), now_ms); + } +} diff --git a/variants/heltec_cubecell_ab02s/CubeCellGPS.h b/variants/heltec_cubecell_ab02s/CubeCellGPS.h new file mode 100644 index 0000000000..483a477a32 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/CubeCellGPS.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include + +// Air530 on Serial1 (9600), power on GPIO14 (LOW = on). Serial1.begin() is called ONCE (each call leaks a 256 +// B buffer); the module is switched with the power pin only. Never prints to Serial (the protocol port). +class CubeCellGPS { + asr650x::NmeaParser _nmea; + asr650x::GpsPolicy _policy; + bool _enabled; + uint32_t _interval; + bool _powered; + bool _began; + uint32_t _power_on_ms; + uint8_t _cfg_sent; + uint32_t _uptime_s, _uptime_ms_mark; // monotonic seconds (wraps at 2^32 s, not at the millis() wrap) + uint32_t uptime(uint32_t now_ms); + uint32_t _last_tick_ms; + void power(bool on, uint32_t now_ms); + void onFix(bool from_rmc); + +public: + CubeCellGPS(); + void begin(); + void loop(uint32_t now_ms); + void apply(bool enabled, uint32_t interval_s); + void request(); + bool enabled() const { return _enabled; } + uint32_t interval() const { return _interval; } + bool powered() const { return _powered; } + asr650x::GpsState state() const { return _policy.state(); } + bool timedOut() const { return _policy.timedOut(); } + const asr650x::GpsData &data() const { return _nmea.data(); } + uint32_t sentencesOk() const { return _nmea.sentencesOk(); } + uint32_t warmingS() const { return _policy.warmingFor(_uptime_s); } +}; + +extern CubeCellGPS cubecell_gps; diff --git a/variants/heltec_cubecell_ab02s/CubeCellSensors.cpp b/variants/heltec_cubecell_ab02s/CubeCellSensors.cpp new file mode 100644 index 0000000000..70cc497a69 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/CubeCellSensors.cpp @@ -0,0 +1,71 @@ +#include "CubeCellSensors.h" + +#include "CubeCellGPS.h" + +#include +#include +#include + +bool CubeCellSensors::begin() { + cubecell_gps.begin(); + return true; +} +void CubeCellSensors::loop() { + cubecell_gps.loop(millis()); +} + +static const char *const NAMES[7] = { "gps", "gps_interval", "gps_fix", "gps_sats", + "gps_power", "gps_sent", "oled" }; + +const char *CubeCellSensors::getSettingName(int i) const { + return (i >= 0 && i < 7) ? NAMES[i] : NULL; +} + +const char *CubeCellSensors::getSettingValue(int i) const { + char *v = const_cast(_val); + switch (i) { + case 0: + snprintf(v, sizeof(_val), "%d", cubecell_gps.enabled() ? 1 : 0); + break; + case 1: + snprintf(v, sizeof(_val), "%u", (unsigned)cubecell_gps.interval()); + break; + case 2: + snprintf(v, sizeof(_val), "%d", cubecell_gps.data().pos_valid ? 1 : 0); + break; + case 3: + snprintf(v, sizeof(_val), "%u", (unsigned)cubecell_gps.data().sats); + break; + case 4: + snprintf(v, sizeof(_val), "%d", cubecell_gps.powered() ? 1 : 0); + break; + case 5: + snprintf(v, sizeof(_val), "%u", (unsigned)cubecell_gps.sentencesOk()); + break; + case 6: + return _oled; + default: + return NULL; + } + return _val; +} + +bool CubeCellSensors::setSettingValue(const char *name, const char *value) { + if (strcmp(name, "gps") == 0) { + if (strcmp(value, "0") != 0 && strcmp(value, "1") != 0) return false; + cubecell_gps.apply(value[0] == '1', cubecell_gps.interval()); + return true; + } + if (strcmp(name, "gps_interval") == 0) { + if (!*value) return false; + unsigned long v = 0; + for (const char *p = value; *p; p++) { + if (*p < '0' || *p > '9') return false; + v = v * 10 + (unsigned long)(*p - '0'); + if (v > 86400UL) return false; + } + cubecell_gps.apply(cubecell_gps.enabled(), (uint32_t)v); + return true; + } + return false; // read-only or unknown key +} diff --git a/variants/heltec_cubecell_ab02s/CubeCellSensors.h b/variants/heltec_cubecell_ab02s/CubeCellSensors.h new file mode 100644 index 0000000000..db83db1c47 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/CubeCellSensors.h @@ -0,0 +1,19 @@ +#pragma once +#include + +// GPS settings exposed through MeshCore's custom-variable commands (CMD_GET/SET_CUSTOM_VAR); read-only keys +// give the hardware tests visibility into GPS and OLED state. +class CubeCellSensors : public SensorManager { + char _val[12]; + const char *_oled; + +public: + CubeCellSensors() : _oled("none") { _val[0] = 0; } + void setOledStatus(const char *s) { _oled = s; } + bool begin() override; + void loop() override; + int getNumSettings() const override { return 7; } + const char *getSettingName(int i) const override; + const char *getSettingValue(int i) const override; + bool setSettingValue(const char *name, const char *value) override; +}; diff --git a/variants/heltec_cubecell_ab02s/Oled.cpp b/variants/heltec_cubecell_ab02s/Oled.cpp new file mode 100644 index 0000000000..27a8e2f323 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/Oled.cpp @@ -0,0 +1,121 @@ +#include "Oled.h" + +#include +#include +#include + +static const uint8_t ADDR = 0x3C; + +// Direct use of the PSoC I2C master block (what Wire does underneath): Wire.cpp holds 2 x 128 B buffers per +// instance, which this firmware cannot afford in RAM. Timeout 10 ms per byte, same as the core. +static const uint32_t I2C_TIMEOUT_MS = 10; + +static bool i2cWrite(const uint8_t *b, uint8_t n) { + I2C_I2CMasterClearStatus(); + if (I2C_I2CMasterSendStart(ADDR, I2C_I2C_WRITE_XFER_MODE, I2C_TIMEOUT_MS) != I2C_I2C_MSTR_NO_ERROR) { + I2C_I2CMasterSendStop(I2C_TIMEOUT_MS); + return false; + } + bool ok = true; + for (uint8_t i = 0; i < n && ok; i++) + ok = I2C_I2CMasterWriteByte(b[i], I2C_TIMEOUT_MS) == I2C_I2C_MSTR_NO_ERROR; + I2C_I2CMasterSendStop(I2C_TIMEOUT_MS); + return ok; +} + +bool OledPanel::cmd(uint8_t c) { + const uint8_t b[2] = { 0x80, c }; // control byte: single command + return i2cWrite(b, 2); +} + +bool OledPanel::cmds(const uint8_t *c, uint8_t n) { + for (uint8_t i = 0; i < n; i++) + if (!cmd(c[i])) return false; + return true; +} + +bool OledPanel::initPanel() { + static const uint8_t seq[] = { + 0xAE, // display off + 0xD5, 0xF0, // clock divide / oscillator + 0xA8, 63, // multiplex 1/64 + 0xD3, 0x00, // display offset + 0x40, // start line 0 + 0x8D, 0x14, // charge pump on + 0x20, 0x00, // horizontal addressing + 0xA1, // segment re-map + 0xC8, // COM scan direction + 0xDA, 0x12, // COM pins (128x64) + 0x81, 0xCF, // contrast + 0xD9, 0xF1, // pre-charge + 0xDB, 0x40, // VCOMH + 0xA4, // resume RAM content + 0xA6, // normal (not inverted) + 0x2E // stop scroll + }; + return cmds(seq, sizeof(seq)); +} + +bool OledPanel::begin() { + pinMode(Vext, OUTPUT); + digitalWrite(Vext, LOW); // panel power on + delay(50); + pinMode(GPIO10, OUTPUT); + digitalWrite(GPIO10, LOW); // reset pulse + delay(20); + digitalWrite(GPIO10, HIGH); + delay(20); + if (!_wire) { // once only + I2C_SCBCLK_DIV_REG = (uint32_t)(CYDEV_BCLK__HFCLK__HZ / 8 / 400000 / 3) << 8; + I2C_Start(); + _wire = true; + } + if (!i2cWrite(0, 0)) { // no acknowledge: panel absent or unpowered + _present = false; + digitalWrite(Vext, HIGH); + return false; + } + _present = initPanel(); + _on = false; + if (!_present) digitalWrite(Vext, HIGH); + return _present; +} + +void OledPanel::power(bool on) { + if (!_present) return; + if (on == _on) return; + if (on) { + digitalWrite(Vext, LOW); + delay(20); + digitalWrite(GPIO10, LOW); + delay(10); + digitalWrite(GPIO10, HIGH); + delay(10); + if (!initPanel()) { + _present = false; + digitalWrite(Vext, HIGH); + return; + } + cmd(0xAF); // display on + _on = true; + } else { + cmd(0xAE); // display off + cmd(0x8D); + cmd(0x10); // charge pump off + digitalWrite(Vext, HIGH); + _on = false; + } +} + +bool OledPanel::writePage(uint8_t page, const uint8_t data[128]) { + if (!_present || !_on || page > 7) return false; + if (!cmd(0x21) || !cmd(0) || !cmd(127)) return false; // column window 0..127 + if (!cmd(0x22) || !cmd(page) || !cmd(page)) return false; // page window + uint8_t b[17]; + b[0] = 0x40; // control byte: data + for (int i = 0; i < 128; i += 16) { + memcpy(b + 1, data + i, 16); + if (!i2cWrite(b, 17)) return false; + } + return true; +} diff --git a/variants/heltec_cubecell_ab02s/Oled.h b/variants/heltec_cubecell_ab02s/Oled.h new file mode 100644 index 0000000000..1f36f200b2 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/Oled.h @@ -0,0 +1,22 @@ +#pragma once +#include + +// Minimal SSD1306 128x64 driver over I2C (0x3C) with no frame buffer: the caller writes one 128-byte page at +// a time. Vext (P3_2, LOW = on) powers the panel; GPIO10 is its reset. If the panel does not acknowledge, +// begin() returns false. +class OledPanel { + bool _present; + bool _on; + bool _wire; + bool cmd(uint8_t c); + bool cmds(const uint8_t *c, uint8_t n); + bool initPanel(); + +public: + OledPanel() : _present(false), _on(false), _wire(false) {} + bool begin(); // power the panel, reset it, probe 0x3C, run the init sequence (display off) + bool present() const { return _present; } + bool isOn() const { return _on; } + void power(bool on); // on: Vext + init + DISPLAYON; off: DISPLAYOFF + charge pump off + Vext off + bool writePage(uint8_t page, const uint8_t data[128]); // false on I2C error +}; diff --git a/variants/heltec_cubecell_ab02s/StatusScreen.cpp b/variants/heltec_cubecell_ab02s/StatusScreen.cpp new file mode 100644 index 0000000000..0557779e16 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/StatusScreen.cpp @@ -0,0 +1,56 @@ +#include "StatusScreen.h" + +#include +#include + +void StatusScreen::begin(OledPanel *oled) { + _oled = oled; + pinMode(USER_KEY, INPUT); // external 10k pull-up; pressed = LOW + wake(millis()); // visible at boot +} + +void StatusScreen::wake(uint32_t now_ms) { + if (!_oled || !_oled->present()) return; + _oled->power(true); + _on = _oled->isOn(); + _on_since = now_ms; + _next_page = _on ? 0 : -1; + _last_refresh = now_ms; +} + +void StatusScreen::loop(uint32_t now_ms) { + if (!_oled || !_oled->present()) return; + if (_button.update(digitalRead(USER_KEY) == LOW, now_ms)) wake(now_ms); + + if (_on && now_ms - _on_since >= ON_MS) { // auto-off + _oled->power(false); + _on = false; + _next_page = -1; + return; + } + if (!_on) return; + + if (_next_page < 0 && now_ms - _last_refresh >= 1000) { // start a refresh: battery at most every 10 s + if (now_ms - _last_batt_ms >= 10000 || _batt_mv == 0) { + _batt_mv = getBatteryVoltage(); // drives the shared USER_KEY pin LOW for a moment + _last_batt_ms = now_ms; + _button.holdoff(millis(), 50); + } + _in.batt_mv = _batt_mv; + _next_page = 0; + _last_refresh = now_ms; + } + if (_next_page >= 0) { // one page per call + char line[22]; + asr650x::statusLine(_next_page, _in, line); + asr650x::renderLine(line, _page); + if (!_oled->writePage((uint8_t)_next_page, _page)) { + _error = true; + _on = false; + _oled->power(false); + _next_page = -1; + return; + } + _next_page = (_next_page == 7) ? -1 : (int8_t)(_next_page + 1); + } +} diff --git a/variants/heltec_cubecell_ab02s/StatusScreen.h b/variants/heltec_cubecell_ab02s/StatusScreen.h new file mode 100644 index 0000000000..335ff87c45 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/StatusScreen.h @@ -0,0 +1,39 @@ +#pragma once +#include "Oled.h" + +#include +#include +#include +#include + +// One status screen. Auto-off after ON_MS, USER key wakes it. Redraws once a second, ONE page per loop() call +// (about 3-4 ms of I2C each) so serial frames and radio events are never starved. +class StatusScreen { + OledPanel *_oled; + asr650x::Button _button; + asr650x::StatusInput _in; + uint8_t _page[128]; + bool _on; + uint32_t _on_since, _last_refresh; + int8_t _next_page; // -1 = idle, 0..7 = pages still to draw + uint32_t _last_batt_ms; + uint16_t _batt_mv; + bool _error; + +public: + static const uint32_t ON_MS = 30000; + StatusScreen() + : _oled(0), _on(false), _on_since(0), _last_refresh(0), _next_page(-1), _last_batt_ms(0), _batt_mv(0), + _error(false) { + memset(&_in, 0, sizeof(_in)); + } + void begin(OledPanel *oled); + void setInput(const asr650x::StatusInput &in) { + _in = in; + _in.batt_mv = _batt_mv; + } + void wake(uint32_t now_ms); + void loop(uint32_t now_ms); + bool isOn() const { return _on; } + bool failed() const { return _error; } +}; diff --git a/variants/heltec_cubecell_ab02s/c_include_order.py b/variants/heltec_cubecell_ab02s/c_include_order.py new file mode 100644 index 0000000000..1d4da7daec --- /dev/null +++ b/variants/heltec_cubecell_ab02s/c_include_order.py @@ -0,0 +1,8 @@ +# The CubeCell framework's LoRaWAN library (which provides the SX1262 driver used here) includes "aes.h" from C files. +# On case-insensitive filesystems that name also matches rweather/Crypto's C++ "AES.h". Put the framework's C header +# first for C sources only (CFLAGS are not passed to the C++ compiler, so MeshCore keeps Crypto's AES.h). +Import("env") +import os + +fw = env.PioPlatform().get_package_dir("framework-arduinocubecell") +env.Append(CFLAGS=["-I" + os.path.join(fw, "cores", "asr650x", "lora", "system", "crypto")]) diff --git a/variants/heltec_cubecell_ab02s/platformio.ini b/variants/heltec_cubecell_ab02s/platformio.ini new file mode 100644 index 0000000000..4b25cbc6b7 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/platformio.ini @@ -0,0 +1,72 @@ +; Heltec CubeCell GPS-6502 (HTCC-AB02S) - LEGACY board: ASR6502 (Cortex-M0+), 128 KB flash, 16 KB RAM, fixed 2 KB stack. +; Companion over USB/UART only (no BLE, no WiFi). The node does not generate its own identity: import a private key +; from the app first; until then it answers the app but never transmits. +; Small-board options used here (all default off elsewhere): see the build_flags below. + +[heltec_cubecell_ab02s] +extends = arduino_base +platform = heltecautomation/heltec-cubecell@1.1.0 +board = cubecell_gps +extra_scripts = pre:variants/heltec_cubecell_ab02s/c_include_order.py +build_unflags = -std=gnu++98 -std=gnu++11 -std=gnu++14 -std=gnu++17 +build_flags = ${arduino_base.build_flags} + -std=gnu++11 -fno-exceptions + -D ASR650X_PLATFORM + -I src/helpers/asr650x + -I variants/heltec_cubecell_ab02s + -I variants/heltec_cubecell_ab02s/shims + -I examples/companion_radio + -D ENABLE_PRIVATE_KEY_EXPORT=0 + -D LORA_TX_POWER=14 + -D MAX_CONTACTS=12 + -D OFFLINE_QUEUE_SIZE=3 + -D MAX_GROUP_CHANNELS=1 + -D MAX_ANON_CONTACTS=2 + -D MAX_PACKET_HASHES=32 + -D ADVERT_PATH_TABLE_SIZE=4 + -D COMPANION_PACKET_POOL_SIZE=5 + -D CONTACT_OUT_PATH_SIZE=16 + -D RX_DELAY_FAST_POW + -D DISABLE_CLI_RESCUE + -D DISABLE_COMPANION_CLI + -D SERIAL_FRAME_STRICT + -D CONTACT_PIN_STORE + -D CONFIG_SERIALIZER_NOOP + -D ED25519_SMALL_BASE_TABLE + -D IDENTITY_CRYPTO_ON_BIG_STACK + -D BIGSTACK_SIZE=2048 + -D ENV_INCLUDE_GPS=1 + ; the framework printf (mpaland) is built with these: nothing here prints %f/%e/%g/%ll (floats go through StrHelper::ftoa) + -D PRINTF_DISABLE_SUPPORT_FLOAT + -D PRINTF_DISABLE_SUPPORT_EXPONENTIAL + -D PRINTF_DISABLE_SUPPORT_LONG_LONG +; only the helpers a companion needs: the others expect a filesystem +build_src_filter = + +<*.cpp> + + + + + + + + + + + + + + + + + + + + + +<../variants/heltec_cubecell_ab02s/*.cpp> +lib_deps = + rweather/Crypto @ ^0.4.0 + densaugeo/base64 @ ~1.4.0 +lib_ignore = + RadioLib + RTClib + Melopero RV3028 + CayenneLPP + +[env:Heltec_CubeCell_AB02S_companion_radio_usb] +extends = heltec_cubecell_ab02s +build_flags = ${heltec_cubecell_ab02s.build_flags} + -D ENABLE_USB_INTERFACE +build_src_filter = ${heltec_cubecell_ab02s.build_src_filter} + +<../examples/companion_radio/*.cpp> + -<../examples/companion_radio/DataStore.cpp> diff --git a/variants/heltec_cubecell_ab02s/shims/CayenneLPP.h b/variants/heltec_cubecell_ab02s/shims/CayenneLPP.h new file mode 100644 index 0000000000..93a689c954 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/shims/CayenneLPP.h @@ -0,0 +1,34 @@ +#pragma once +#include +#include + +// Minimal subset used by MyMesh (voltage = type 116, uint16 0.01 V; temperature = type 103, int16 0.1 C; +// big-endian, as in the ElectronicCats CayenneLPP library). Not verified against upstream byte-for-byte. +class CayenneLPP { + uint8_t _buf[64]; + uint8_t _cursor; + +public: + explicit CayenneLPP(uint8_t) : _cursor(0) {} + void reset() { _cursor = 0; } + uint8_t getSize() const { return _cursor; } + uint8_t *getBuffer() { return _buf; } + uint8_t addVoltage(uint8_t channel, float volts) { + if (_cursor + 4 > sizeof(_buf)) return 0; + uint16_t v = (uint16_t)(volts * 100.0f + 0.5f); + _buf[_cursor++] = channel; + _buf[_cursor++] = 116; + _buf[_cursor++] = v >> 8; + _buf[_cursor++] = v & 0xFF; + return _cursor; + } + uint8_t addTemperature(uint8_t channel, float celsius) { + if (_cursor + 4 > sizeof(_buf)) return 0; + int16_t v = (int16_t)(celsius * 10.0f); + _buf[_cursor++] = channel; + _buf[_cursor++] = 103; + _buf[_cursor++] = (uint16_t)v >> 8; + _buf[_cursor++] = (uint16_t)v & 0xFF; + return _cursor; + } +}; diff --git a/variants/heltec_cubecell_ab02s/shims/RTClib.h b/variants/heltec_cubecell_ab02s/shims/RTClib.h new file mode 100644 index 0000000000..879a346820 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/shims/RTClib.h @@ -0,0 +1,2 @@ +#pragma once +// MyMesh.h includes but companion_radio never uses it; intentionally empty. diff --git a/variants/heltec_cubecell_ab02s/target.cpp b/variants/heltec_cubecell_ab02s/target.cpp new file mode 100644 index 0000000000..18fba75153 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/target.cpp @@ -0,0 +1,47 @@ +#include "target.h" + +#include +#include + +CubeCellBoard board; +CubeCellRadio radio_driver; +CubeCellSensors sensors; +CubeCellRTC rtc_clock; + +bool radio_init() { + radio_driver.init(); // Radio.Init first: getRngSeed() (called next by main.cpp) needs the radio + return true; +} + +namespace { +class SeedRNG : public mesh::RNG { + const uint8_t *_p; + +public: + explicit SeedRNG(const uint8_t *p) : _p(p) {} + void random(uint8_t *dest, size_t sz) override { memcpy(dest, _p, sz); } +}; + +// Deterministic, device-unique, NOT secret (derived from the public chip ID): it only exists so the +// firmware can answer the app before a key is imported. It is never persisted and never transmits +// (TxGate), so the app must import a private key before the node can be used. +void placeholderSeed(uint8_t seed[32]) { + uint64_t id = getID(); + uint8_t in[8 + 21]; + memcpy(in, &id, 8); + memcpy(in + 8, "asr650x-unprovisioned", 21); + mesh::Utils::sha256(seed, 32, in, sizeof(in)); +} +} // namespace + +mesh::LocalIdentity radio_new_identity() { + uint8_t seed[32]; + placeholderSeed(seed); + SeedRNG rng(seed); + return mesh::LocalIdentity(&rng); +} + +bool asr650xIdentityIsPlaceholder(const mesh::Identity &id) { + mesh::LocalIdentity p = radio_new_identity(); + return id.matches(p); +} diff --git a/variants/heltec_cubecell_ab02s/target.h b/variants/heltec_cubecell_ab02s/target.h new file mode 100644 index 0000000000..b92c667641 --- /dev/null +++ b/variants/heltec_cubecell_ab02s/target.h @@ -0,0 +1,27 @@ +#pragma once + +// Heltec CubeCell GPS-6502 (HTCC-AB02S): legacy ASR6502 board (ARM Cortex-M0+, 128 KB flash, 16 KB RAM, fixed +// 2 KB stack), SX1262, Air530Z GPS, 0.96" SSD1306 OLED. Companion over USB/UART only (no BLE, no WiFi). + +#include "CubeCellBoard.h" +#include "CubeCellSensors.h" + +#include +#include +#include +#include +#include + +#ifndef min +#define min(a, b) ((a) < (b) ? (a) : (b)) // the CubeCell core does not provide min() +#endif + +extern CubeCellBoard board; +extern CubeCellRadio radio_driver; +extern CubeCellSensors sensors; +extern CubeCellRTC rtc_clock; + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); // placeholder derived from the chip ID, NOT random (see target.cpp) +bool asr650xIdentityIsPlaceholder(const mesh::Identity &id); +extern uint8_t asr650x_boot_pool[4]; // fold of the boot-time Radio.Random() outputs (CubeCellRadio.cpp)