diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 6350c27c..db7a8054 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -218,25 +218,40 @@ bool DataStore::saveMainIdentity(const mesh::LocalIdentity &identity) { } // ── Prefs ── -// /prefs: a header, the node's position, then NodePrefs as it is in memory -// (see the comment at the top of NodePrefs.h). Only the build that wrote it -// reads it back, so the struct's padding and alignment are no concern. -struct PrefsHeader { - uint32_t magic; // PREFS_MAGIC - uint16_t version; // NodePrefs::PREFS_VERSION - uint16_t size; // sizeof(NodePrefs) when written - uint32_t crc; // CRC-32 of the position and the struct's bytes -}; -static const uint32_t PREFS_MAGIC = 0x534F4C4F; // "SOLO" - -static uint32_t crc32(uint32_t crc, const uint8_t* p, size_t n) { - crc = ~crc; - while (n--) { - crc ^= *p++; - for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1))); +// /prefs.json (NodePrefs::structure()). The JSON goes through a buffer: the +// serializer reads and writes it a character at a time, each a file call. +class BufferedFile : public Stream { + File& _f; + uint8_t _buf[256]; + size_t _n = 0, _at = 0; + bool _ok = true; +public: + explicit BufferedFile(File& f) : _f(f) { } + // Reading (a file opened to read). + int available() override { fill(); return (int)(_n - _at); } + int read() override { fill(); return _at < _n ? _buf[_at++] : -1; } + int peek() override { fill(); return _at < _n ? _buf[_at] : -1; } + void fill() { + if (_at < _n) return; + int got = (int)_f.read(_buf, sizeof(_buf)); + _n = got > 0 ? (size_t)got : 0; + _at = 0; } - return ~crc; -} + // Writing (a file opened to write): flush() at the end. + size_t write(uint8_t c) override { return write(&c, 1); } + size_t write(const uint8_t* p, size_t len) override { + for (size_t i = 0; i < len; i++) { + if (_n == sizeof(_buf)) flush(); + _buf[_n++] = p[i]; + } + return len; + } + void flush() override { + if (_n && _f.write(_buf, _n) != _n) _ok = false; + _n = 0; + } + bool ok() const { return _ok; } +}; // Every value inside its range, whatever the file held: a damaged byte reads // as the default rather than as a setting no screen can show. @@ -425,37 +440,18 @@ static void removeStrayDir(FILESYSTEM* fs, const char* path) { // `prefs` comes in holding the defaults; what the file holds replaces them. void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) { removeStrayDir(_fs, "/prefs"); - File file = openRead(_fs, "/prefs"); + if (_fs->exists("/prefs")) _fs->remove("/prefs"); // 2.0-rc's binary settings: 2.0 starts from the defaults + File file = openRead(_fs, "/prefs.json"); if (file) { - PrefsHeader h; - NodePrefs* tmp = new NodePrefs(prefs); // a field the file is too short for keeps its default - double lat = 0, lon = 0; - bool ok = file.read((uint8_t*)&h, sizeof(h)) == sizeof(h) - && h.magic == PREFS_MAGIC && h.version == NodePrefs::PREFS_VERSION - && file.read((uint8_t*)&lat, sizeof(lat)) == sizeof(lat) - && file.read((uint8_t*)&lon, sizeof(lon)) == sizeof(lon); - if (ok) { - const size_t n = h.size < sizeof(NodePrefs) ? h.size : sizeof(NodePrefs); - ok = file.read((uint8_t*)tmp, n) == (int)n; - uint32_t crc = crc32(0, (const uint8_t*)&lat, sizeof(lat)); - crc = crc32(crc, (const uint8_t*)&lon, sizeof(lon)); - crc = crc32(crc, (const uint8_t*)tmp, n); - // Written by a newer build (a downgrade): its extra fields are skipped, - // but the CRC covers them, so read them through it rather than losing - // every setting to a mismatch. - for (size_t left = h.size - n; ok && left > 0; ) { - uint8_t buf[32]; - size_t k = left < sizeof(buf) ? left : sizeof(buf); - ok = file.read(buf, k) == (int)k; - crc = crc32(crc, buf, k); - left -= k; - } - ok = ok && crc == h.crc; - } + BufferedFile in(file); + prefs.node_lat = node_lat; + prefs.node_lon = node_lon; + // Saved whole (a temp file, then renamed): a file that doesn't parse to + // the end was damaged since. What was read of it stays. + if (!prefs.loadSerial(in)) MESH_DEBUG_PRINTLN("prefs: /prefs.json damaged, the rest from the defaults"); file.close(); - if (ok) { prefs = *tmp; node_lat = lat; node_lon = lon; } - else MESH_DEBUG_PRINTLN("prefs: /prefs unreadable (another version or damaged), defaults kept"); - delete tmp; + node_lat = prefs.node_lat; + node_lon = prefs.node_lon; } else { // first boot after an update from before 2.0 (/node_prefs: older still, the same layout) for (const char* old : { "/new_prefs", "/node_prefs" }) { if (!_fs->exists(old)) continue; @@ -468,25 +464,23 @@ void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) break; } } + prefs.clearDirty(); sanitize(prefs); } -void DataStore::savePrefs(const NodePrefs& prefs, double node_lat, double node_lon) { +void DataStore::savePrefs(NodePrefs& prefs, double node_lat, double node_lon) { // Atomic temp-then-rename (see commitTempFile): an interrupted save leaves - // the previous /prefs in place. + // the previous /prefs.json in place. File file = ::openWrite(_fs, "/prefs.tmp"); if (!file) return; - PrefsHeader h = { PREFS_MAGIC, NodePrefs::PREFS_VERSION, (uint16_t)sizeof(NodePrefs), 0 }; - h.crc = crc32(0, (const uint8_t*)&node_lat, sizeof(node_lat)); - h.crc = crc32(h.crc, (const uint8_t*)&node_lon, sizeof(node_lon)); - h.crc = crc32(h.crc, (const uint8_t*)&prefs, sizeof(NodePrefs)); - // Once the flash fills, writes come back short: only a whole record replaces the old one. - bool ok = file.write((const uint8_t*)&h, sizeof(h)) == sizeof(h) - && file.write((const uint8_t*)&node_lat, sizeof(node_lat)) == sizeof(node_lat) - && file.write((const uint8_t*)&node_lon, sizeof(node_lon)) == sizeof(node_lon) - && file.write((const uint8_t*)&prefs, sizeof(NodePrefs)) == sizeof(NodePrefs); + prefs.node_lat = node_lat; + prefs.node_lon = node_lon; + BufferedFile out(file); + bool ok = prefs.saveSerial(out); + out.flush(); + ok = ok && out.ok(); // once the flash fills, writes come back short file.close(); - if (ok) commitTempFile(_fs, "/prefs.tmp", "/prefs"); + if (ok) { commitTempFile(_fs, "/prefs.tmp", "/prefs.json"); prefs.clearDirty(); } else _fs->remove("/prefs.tmp"); } diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 5549460b..e8330961 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -34,7 +34,7 @@ public: bool loadMainIdentity(mesh::LocalIdentity &identity); bool saveMainIdentity(const mesh::LocalIdentity &identity); void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon); - void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); + void savePrefs(NodePrefs& prefs, double node_lat, double node_lon); void loadContacts(DataStoreHost* host); void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL); // Returns true if a channels file (current or legacy) existed and was diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 338a6191..58b73ba3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -311,7 +311,7 @@ int MyMesh::calcRxDelay(float score, uint32_t air_time) const { } uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.5f); + uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); uint32_t d = getRNG()->nextInt(0, 5*t + 1); // Yield filter (Tools > Repeater): scale the flood retransmit delay so a // mobile companion waits longer and lets better-sited fixed repeaters win the @@ -320,7 +320,7 @@ uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { return d * (1 + _prefs.repeat_delay_boost); } uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.2f); + uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } @@ -1316,29 +1316,6 @@ void MyMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { if (_listener) _listener->onACKRecv(ack_crc); } -// The board's own settings (upstream's dynamic prefs, a key-value store), over -// the NodePrefs fields that hold them here: a board with a LoRa front end -// reads "fem_rxgain" / "fem_txgain" at begin() and writes them from its CLI. -bool MyMesh::BoardPrefs::getByKey(const char* key, char* value, size_t max_len) { - uint8_t* f = field(key); - if (!f || max_len < 2) return false; - value[0] = *f ? '1' : '0'; - value[1] = 0; - return true; -} -bool MyMesh::BoardPrefs::setByKey(const char* key, const char* value) { - uint8_t* f = field(key); - if (!f) return false; - *f = strcmp(value, "1") == 0 ? 1 : 0; - _mesh->savePrefs(); - return true; -} -uint8_t* MyMesh::BoardPrefs::field(const char* key) { - if (strcmp(key, "fem_rxgain") == 0) return &_mesh->_prefs.radio_fem_rxgain; - if (strcmp(key, "fem_txgain") == 0) return &_mesh->_prefs.radio_fem_txgain; - return nullptr; -} - MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store) // Sized to match simple_repeater's pool (32), not the old client-only 16: with the // on-device Repeater toggle, queued retransmits (adverts/channel flood from @@ -1347,7 +1324,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe // silently drops every incoming packet — DMs and channels included — until a slot // frees up. : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(32), tables), - _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _listener(NULL), _iter(0), _board_prefs(this) { + _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _listener(NULL), _iter(0) { _iter_started = false; _cli_rescue = false; cli_command[0] = 0; @@ -1392,9 +1369,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe repeat_scope_count = 0; // defaults - memset(&_prefs, 0, sizeof(_prefs)); + _prefs.clearFields(); memset(_prefs.ch_scope_idx, NodePrefs::CH_SCOPE_DEFAULT, sizeof(_prefs.ch_scope_idx)); _prefs.airtime_factor = 1.0; + _prefs.tx_delay_factor = 0.5f; + _prefs.direct_tx_delay_factor = 0.2f; strcpy(_prefs.node_name, "NONAME"); _prefs.freq = LORA_FREQ; _prefs.sf = LORA_SF; @@ -1507,6 +1486,8 @@ void MyMesh::begin() { if (isnan(_prefs.airtime_factor) || isinf(_prefs.airtime_factor)) _prefs.airtime_factor = 0; if (isnan(_prefs.rx_delay_base) || isinf(_prefs.rx_delay_base)) _prefs.rx_delay_base = 0; _prefs.rx_delay_base = constrain(_prefs.rx_delay_base, 0, 20.0f); + _prefs.tx_delay_factor = constrain(_prefs.tx_delay_factor, 0, 2.0f); + _prefs.direct_tx_delay_factor = constrain(_prefs.direct_tx_delay_factor, 0, 2.0f); _prefs.airtime_factor = constrain(_prefs.airtime_factor, 0, 9.0f); _prefs.freq = constrain(_prefs.freq, 150.0f, 2500.0f); _prefs.bw = constrain(_prefs.bw, 7.8f, 500.0f); @@ -1540,7 +1521,7 @@ void MyMesh::begin() { #else radio_driver.setPowerSaving(false); // see Features.h FEAT_RX_POWERSAVE -- ignore any stale persisted rx_powersave byte #endif - board.attachDynamicPrefs(&_board_prefs); // FEM gains: the board reads them now, and its CLI sets them + board.attachDynamicPrefs(_prefs.getCustom()); // the board's own settings (FEM gains): read now, set by its CLI MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 75d8c89d..cad385f5 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -4,7 +4,6 @@ #include #include #include "Features.h" // FEAT_RX_POWERSAVE, used by MyMesh.cpp -#include // Forward declaration for UITask class UITask; @@ -285,6 +284,9 @@ protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; bool getCADEnabled() const override; + int getAGCResetInterval() const override { + return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds + } int calcRxDelay(float score, uint32_t air_time) const override; uint32_t getRetransmitDelay(const mesh::Packet *packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override; @@ -699,15 +701,6 @@ private: bool send_unscoped; // force un-scoped flood (instead of using send_scope) char cli_command[80]; char reply_buf[166]; - class BoardPrefs : public KeyValueStore { // MyMesh.cpp - MyMesh* _mesh; - uint8_t* field(const char* key); - public: - BoardPrefs(MyMesh* mesh) : _mesh(mesh) { } - bool setByKey(const char* key, const char* value) override; - bool getByKey(const char* key, char* value, size_t max_len) override; - }; - BoardPrefs _board_prefs; uint8_t app_target_ver; uint8_t *sign_data; uint32_t sign_data_len; diff --git a/examples/companion_radio/MyMeshCli.h b/examples/companion_radio/MyMeshCli.h index 3bc23a25..85aa15ea 100644 --- a/examples/companion_radio/MyMeshCli.h +++ b/examples/companion_radio/MyMeshCli.h @@ -21,7 +21,10 @@ bool MyMesh::handleCliCommand(const char* command, char* reply, int n) { command += 3; } if (cliRadioCommand(command, reply, n)) return true; - if (n >= 64 && board.handleCommand(command, 0, reply)) return true; // the board's own (radio.fem.*); short replies + if (n >= 64 && board.handleCommand(command, 0, reply)) { // the board's own (radio.fem.*); short replies + if (_prefs.isDirty()) savePrefs(); + return true; + } if (strcmp(command, "reboot") == 0 || strcmp(command, "poweroff") == 0 || strcmp(command, "shutdown") == 0) { bool restart = command[0] == 'r'; @@ -185,6 +188,25 @@ bool MyMesh::cliRadioCommand(const char* command, char* reply, int n) { else { _prefs.rx_delay_base = d; savePrefs(); snprintf(reply, n, "OK"); } return true; } + if (strcmp(command, "get txdelay") == 0) { snprintf(reply, n, "> %s", StrHelper::ftoa(_prefs.tx_delay_factor)); return true; } + if (strcmp(command, "get direct.txdelay") == 0) { snprintf(reply, n, "> %s", StrHelper::ftoa(_prefs.direct_tx_delay_factor)); return true; } + if (memcmp(command, "set txdelay ", 12) == 0 || memcmp(command, "set direct.txdelay ", 19) == 0) { + const bool direct = command[4] == 'd'; + float f = atof(&command[direct ? 19 : 12]); + if (!(f >= 0 && f <= 2.0f)) { snprintf(reply, n, "Error, must be 0-2"); return true; } + (direct ? _prefs.direct_tx_delay_factor : _prefs.tx_delay_factor) = f; + savePrefs(); + snprintf(reply, n, "OK"); + return true; + } + if (strcmp(command, "get agc.reset.interval") == 0) { snprintf(reply, n, "> %d", _prefs.agc_reset_interval * 4); return true; } + if (memcmp(command, "set agc.reset.interval ", 23) == 0) { + int secs = constrain(atoi(&command[23]), 0, 255 * 4); + _prefs.agc_reset_interval = (uint8_t)(secs / 4); + savePrefs(); + snprintf(reply, n, "OK - interval rounded to %d", _prefs.agc_reset_interval * 4); + return true; + } if (strcmp(command, "get path.hash.mode") == 0) { snprintf(reply, n, "> %d", (int)_prefs.path_hash_mode); return true; } if (memcmp(command, "set path.hash.mode ", 19) == 0) { int m = atoi(&command[19]); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index da23988f..ef4bfd19 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -2,6 +2,8 @@ #include #include #include +#include +#include // Firmware's own boot-default radio params — used both as the companion's // initial freq/sf/bw/cr (MyMesh.cpp) and, here, as the seed for a never- @@ -44,12 +46,11 @@ static inline float defaultRepeaterFreqForBand(float companion_freq) { #define ADVERT_SOUND_SCOPE_ALL 0 #define ADVERT_SOUND_SCOPE_ZERO_HOP 1 -struct NodePrefs { // persisted to file - // Saved to /prefs as it is in memory (DataStore::savePrefs): a new field - // goes at the END of the struct and needs nothing else -- an older file is - // shorter, so the field keeps its default (MyMesh's constructor). Moving, - // resizing or removing a field shifts the ones after it: bump PREFS_VERSION - // then, and every device starts again from the defaults. +// The settings' fields. NodePrefs (below) adds how they're saved: /prefs.json, +// one key per field (NodePrefs::structure()). A new field goes anywhere here +// and gets a key there; a file without it leaves the default (MyMesh's +// constructor), and a key no field has any more is skipped. +struct NodePrefsFields { // ── Identity & scope ────────────────────────────────────────────────── char node_name[32]; @@ -95,6 +96,12 @@ struct NodePrefs { // persisted to file // these yet: they keep the constructor default (MyMesh.cpp). uint8_t radio_fem_rxgain; uint8_t radio_fem_txgain; + // Retransmit delay factors (x airtime, the random window for a forwarded + // flood / direct packet) and the AGC reset interval (seconds / 4, 0 = off), + // as upstream's (CLI txdelay / direct.txdelay / agc.reset.interval). + float tx_delay_factor; + float direct_tx_delay_factor; + uint8_t agc_reset_interval; // User-saved radio presets, written by the "Save current..." entry in the // shared preset picker (Settings > Radio and Tools > Repeater both populate // these same slots). name[0] == '\0' marks an empty slot. @@ -624,8 +631,6 @@ struct NodePrefs { // persisted to file // /prefs' layout (see the comment at the top). A file of another version // isn't read: the defaults stand. - static const uint16_t PREFS_VERSION = 2; // 2: 2.0 layout, rc1-rc3 files start from the defaults - // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage // and SettingsScreen::homePageBitIndex route their per-enum lookups through these. @@ -696,6 +701,264 @@ struct NodePrefs { // persisted to file } }; +// The settings, saved as /prefs.json (DataStore). What upstream's companion +// has is under its keys -- "radio", "gps", "repeat", "comp", "custom" -- so +// either firmware reads what the other wrote of it; the rest is under "solo". +// Keys are at most 15 characters (ConfigSerializer). Byte arrays and 64-bit +// masks go as hex. +class NodePrefs : public NodePrefsFields, public ConfigSerializer { + // A section: a ConfigSerializer whose structure() is one of NodePrefs's. + class Section : public ConfigSerializer { + NodePrefs* _p; + void (NodePrefs::*_fn)(Section&); + protected: + void structure() override { (_p->*_fn)(*this); } + public: + Section(NodePrefs* p, void (NodePrefs::*fn)(Section&)) : _p(p), _fn(fn) { } + using ConfigSerializer::def; + template void hex(const char* key, T& v) { def(key, (void*)&v, sizeof(v)); } + void sub(const char* key, Section& s) { def(key, s); } + }; + + // The board's own settings (Board::attachDynamicPrefs()): the LoRa front + // end's gains are fields here; any other key a board has is kept in + // `custom` as it is upstream. + class FemPrefs : public KeyValueStore { + NodePrefs* _p; + uint8_t* field(const char* key) { + if (strcmp(key, "fem_rxgain") == 0) return &_p->radio_fem_rxgain; + if (strcmp(key, "fem_txgain") == 0) return &_p->radio_fem_txgain; + return nullptr; + } + public: + FemPrefs(NodePrefs* p) : _p(p) { } + bool getByKey(const char* key, char* value, size_t max_len) override { + uint8_t* f = field(key); + if (!f || max_len < 2) return false; + value[0] = *f ? '1' : '0'; + value[1] = 0; + return true; + } + bool setByKey(const char* key, const char* value) override { + uint8_t* f = field(key); + if (!f) return false; + *f = atoi(value) ? 1 : 0; + _p->markDirty(); + return true; + } + }; + + // ── upstream's sections ── + void radioKeys(Section& s) { + s.def("freq", freq); s.def("bw", bw); s.def("sf", sf); s.def("cr", cr); + s.def("cad", cad_enabled); s.def("int_thr", interference_threshold); + s.def("rxgain", rx_boosted_gain); + s.def("fem_rxgain", radio_fem_rxgain); s.def("fem_txgain", radio_fem_txgain); + s.def("tx", tx_power_dbm); s.def("af", airtime_factor); s.def("rxdelay", rx_delay_base); + s.def("f_txdelay", tx_delay_factor); s.def("d_txdelay", direct_tx_delay_factor); + s.def("agc_int", agc_reset_interval); + s.def("hash_mode", path_hash_mode); s.def("multi_ack", multi_acks); + } + void gpsKeys(Section& s) { + s.def("en", gps_enabled); s.def("int", gps_interval); s.def("adv_loc", advert_loc_policy); + } + void repeatKeys(Section& s) { // upstream keeps "disable", the inverse + uint8_t off = client_repeat ? 0 : 1; + s.def("disable", off); + client_repeat = off ? 0 : 1; + } + void compKeys(Section& s) { + s.def("auto_max", autoadd_max_hops); + s.def("defs_nm", default_scope_name, sizeof(default_scope_name)); + s.hex("defs_key", default_scope_key); + s.def("pin", ble_pin); s.def("buzz_q", buzzer_quiet); + s.def("auto_add", autoadd_config); s.def("man_add", manual_add_contacts); + s.def("tel_base", telemetry_mode_base); s.def("tel_loc", telemetry_mode_loc); + s.def("tel_env", telemetry_mode_env); + s.def("tz_offset", tz_offset_hours); + } + + // ── this firmware's ("solo") ── + void radioSolo(Section& s) { + s.def("rx_ps", rx_powersave); s.def("apc", tx_apc); s.def("dm_resend", dm_resend_count); + char k[4] = "p0"; + for (int i = 0; i < USER_RADIO_PRESET_MAX; i++) { + k[1] = '0' + i; + s.sub(k, preset[i]); + } + } + void presetKeys(Section& s) { + for (int i = 0; i < USER_RADIO_PRESET_MAX; i++) { + if (&s != &preset[i]) continue; + UserRadioPreset& p = user_radio_presets[i]; + s.def("name", p.name, sizeof(p.name)); + s.def("freq", p.freq); s.def("bw", p.bw); s.def("sf", p.sf); s.def("cr", p.cr); + } + } + void repeaterSolo(Section& s) { + s.def("skip_adv", repeat_skip_adverts); s.def("max_hops", repeat_max_hops); + s.def("delay_boost", repeat_delay_boost); s.def("min_snr", repeat_min_snr); + s.def("sup_dup", repeat_suppress_dup); s.def("scope_only", repeat_scope_only); + s.def("extra_scopes", repeat_extra_scope_mask); + s.def("use_prof", repeater_use_profile); + s.def("freq", repeater_freq); s.def("bw", repeater_bw); + s.def("sf", repeater_sf); s.def("cr", repeater_cr); + } + void powerSolo(Section& s) { + s.def("ble_off", ble_off); + s.def("low_mv", low_batt_mv); s.def("batt_disp", batt_display_mode); + s.hex("curve", batt_curve_mv); + } + void displaySolo(Section& s) { + s.def("bright", display_brightness); s.def("bright_pct", display_brightness_pct); + s.def("auto_off", auto_off_secs); s.def("auto_lock", auto_lock); + s.def("lock_compact", lock_compact); + s.def("rot", display_rotation); s.def("joy_rot", joystick_rotation); + s.def("eink_full", eink_full_refresh_every); + s.def("text_scale", ui_text_scale); s.def("icon_scale", ui_icon_scale); + s.def("pages", home_pages_mask); s.hex("dash", dashboard_fields); + s.hex("order", page_order); s.def("order_set", page_order_set); + s.def("ch_sort", ch_sort); s.def("flow", flow_show); + s.def("kb", keyboard_type); s.def("kb_alt", keyboard_alt_alphabet); + s.def("kb_main", keyboard_main_alphabet); s.def("kb_cardkb", keyboard_cardkb_compact); + } + void clockSolo(Section& s) { + s.def("hide_sec", clock_hide_seconds); s.def("h12", clock_12h); + s.def("alarm", alarm_on); s.def("alarm_h", alarm_hour); s.def("alarm_m", alarm_min); + s.def("alarm_rep", alarm_repeat_mask); + } + void soundSolo(Section& s) { + s.def("vol", buzzer_volume); s.def("auto", buzzer_auto); + s.def("quiet", quiet_hours); s.def("quiet_from", quiet_from); s.def("quiet_to", quiet_to); + s.def("msg_wake", msg_wake); s.def("msg_alert", msg_alert); + s.def("rt_bpm", ringtone_bpm_idx); s.def("rt_len", ringtone_len); s.hex("rt", ringtone_notes); + s.def("rt2_bpm", ringtone2_bpm_idx); s.def("rt2_len", ringtone2_len); s.hex("rt2", ringtone2_notes); + s.def("mel_dm", notif_melody_dm); s.def("mel_ch", notif_melody_ch); s.def("mel_ad", notif_melody_ad); + s.def("adv_scope", advert_sound_scope); + s.hex("ch_over", ch_notif_override); s.hex("ch_muted", ch_notif_muted); + s.hex("ch_mel_set", ch_notif_melody_set); s.hex("ch_mel_2", ch_notif_melody_2); + s.hex("dm", dm_notif); s.hex("dm_mel", dm_melody); + } + void scopeSolo(Section& s) { s.hex("ch", ch_scope_idx); } + void botSolo(Section& s) { + s.def("dm_en", bot_enabled); s.def("dm_trig", bot_trigger, sizeof(bot_trigger)); + s.def("dm_reply", bot_reply_dm, sizeof(bot_reply_dm)); + s.def("dm_cmd", bot_commands_enabled); s.def("dm_act", bot_actions_dm); s.def("dm_scope", bot_dm_scope); + s.def("ch_en", bot_channel_enabled); s.def("ch_idx", bot_channel_idx); + s.def("ch_trig", bot_trigger_ch, sizeof(bot_trigger_ch)); + s.def("ch_reply", bot_reply_ch, sizeof(bot_reply_ch)); + s.def("ch_cmd", bot_commands_ch); s.def("ch_act", bot_actions_ch); + s.def("rm_en", bot_room_enabled); s.hex("rm_pfx", bot_room_prefix); + s.def("rm_trig", bot_trigger_room, sizeof(bot_trigger_room)); + s.def("rm_reply", bot_reply_room, sizeof(bot_reply_room)); + s.def("rm_cmd", bot_commands_room); s.def("rm_act", bot_actions_room); + s.def("q_start", bot_quiet_start); s.def("q_end", bot_quiet_end); + } + void gpsSolo(Section& s) { + s.def("imperial", units_imperial); + s.def("tr_delta", trail_min_delta_idx); s.def("tr_pace", trail_show_pace); + s.def("tr_pause", trail_autopause_idx); s.def("tr_lowbatt", trail_autosave_lowbatt); + s.def("avg", gps_avg_idx); s.def("trk_shared", track_shared_loc); + s.def("adv_auto", advert_auto_interval_sec); s.def("adv_prec", advert_loc_prec); + } + void shareSolo(Section& s) { + s.def("en", loc_share_enabled); s.def("tgt", loc_share_target_type); + s.def("ch", loc_share_channel_idx); s.hex("dm", loc_share_dm_prefix); + s.def("move", loc_share_move_idx); s.def("int", loc_share_interval_idx); + s.def("hb", loc_share_heartbeat_idx); s.def("scope", loc_share_scope); + s.def("dur", loc_share_duration_idx); + } + void locatorSolo(Section& s) { + s.def("en", locator_enabled); s.def("has", locator_has_target); + s.def("radius", locator_radius_idx); s.def("mode", locator_mode); + s.def("lat", locator_lat_1e6); s.def("lon", locator_lon_1e6); + s.def("label", locator_label, sizeof(locator_label)); + s.def("kind", locator_target_kind); s.hex("key", locator_key); + s.def("beep", locator_beeper); + } + void listsSolo(Section& s) { + s.def("dm_all", dm_show_all); s.def("room_fav", room_fav_only); + s.hex("ch_fav", ch_fav_bitmask); s.def("ch_fav_only", ch_fav_only); + s.hex("favs", favourite_contacts); s.hex("fav_kinds", favourite_kinds); + s.def("fav_sort", fav_sort); s.def("expiry", contact_expiry_idx); + } + void gpioSolo(Section& s) { + s.def("m1", gpio1_mode); s.def("m2", gpio2_mode); s.def("m3", gpio3_mode); s.def("m4", gpio4_mode); + } + void msgsSolo(Section& s) { + char k[4] = "m0"; + for (int i = 0; i < 10; i++) { + k[1] = '0' + i; + s.def(k, custom_msgs[i], sizeof(custom_msgs[i])); + } + } + void lockSolo(Section& s) { s.hex("hash", lock_screen_password); s.hex("salt", lock_screen_password_salt); } + void soloKeys(Section& s) { + s.sub("radio", radio_solo); s.sub("repeater", repeater_solo); s.sub("power", power_solo); + s.sub("display", display_solo); s.sub("clock", clock_solo); s.sub("sound", sound_solo); + s.sub("scope", scope_solo); s.sub("bot", bot_solo); s.sub("gps", gps_solo); + s.sub("share", share_solo); s.sub("locator", locator_solo); s.sub("lists", lists_solo); + s.sub("gpio", gpio_solo); s.sub("msgs", msgs_solo); s.sub("lock", lock_solo); + } + + Section radio, gps, repeat, comp; + Section solo, radio_solo, preset[USER_RADIO_PRESET_MAX], repeater_solo, power_solo, display_solo, + clock_solo, sound_solo, scope_solo, bot_solo, gps_solo, share_solo, locator_solo, + lists_solo, gpio_solo, msgs_solo, lock_solo; + FemPrefs fem; + DynamicConfigSerializer custom; + +protected: + void structure() override { + def("name", node_name, sizeof(node_name)); + def("lat", node_lat); + def("lon", node_lon); + def("radio", radio); + def("gps", gps); + def("repeat", repeat); + def("comp", comp); + def("custom", custom); + def("solo", solo); + } + +public: + // The node's position as the file holds it (MyMesh keeps it in `sensors`): + // DataStore sets it before a save and reads it after a load. + double node_lat = 0, node_lon = 0; + + NodePrefs() + : radio(this, &NodePrefs::radioKeys), gps(this, &NodePrefs::gpsKeys), + repeat(this, &NodePrefs::repeatKeys), comp(this, &NodePrefs::compKeys), + solo(this, &NodePrefs::soloKeys), radio_solo(this, &NodePrefs::radioSolo), + preset{ {this, &NodePrefs::presetKeys}, {this, &NodePrefs::presetKeys}, + {this, &NodePrefs::presetKeys}, {this, &NodePrefs::presetKeys} }, + repeater_solo(this, &NodePrefs::repeaterSolo), power_solo(this, &NodePrefs::powerSolo), + display_solo(this, &NodePrefs::displaySolo), clock_solo(this, &NodePrefs::clockSolo), + sound_solo(this, &NodePrefs::soundSolo), scope_solo(this, &NodePrefs::scopeSolo), + bot_solo(this, &NodePrefs::botSolo), gps_solo(this, &NodePrefs::gpsSolo), + share_solo(this, &NodePrefs::shareSolo), locator_solo(this, &NodePrefs::locatorSolo), + lists_solo(this, &NodePrefs::listsSolo), gpio_solo(this, &NodePrefs::gpioSolo), + msgs_solo(this, &NodePrefs::msgsSolo), lock_solo(this, &NodePrefs::lockSolo), + fem(this), custom(&fem) { + clearFields(); + } + // The sections point back at this object: it isn't copied. + NodePrefs(const NodePrefs&) = delete; + NodePrefs& operator=(const NodePrefs&) = delete; + + // Every field zero (MyMesh then sets the defaults). + void clearFields() { memset(static_cast(this), 0, sizeof(NodePrefsFields)); } + + // The board's settings (upstream's getCustom()), for Board::attachDynamicPrefs(). + KeyValueStore* getCustom() { return &custom; } + bool isRepeatEn() const { return client_repeat != 0; } + void setRepeatEn(bool en) { client_repeat = en ? 1 : 0; } + + bool isDirty() const override { return ConfigSerializer::isDirty() || custom.isDirty(); } + void clearDirty() override { ConfigSerializer::clearDirty(); custom.clearDirty(); } + using ConfigSerializer::markDirty; +}; + // Local hour `h` inside [start, end): a window with start > end wraps past // midnight; start == end is no window. Shared by the bot's quiet hours and the // sound's. diff --git a/src/helpers/ConfigSerializer.h b/src/helpers/ConfigSerializer.h index e55b1b12..b9977d94 100644 --- a/src/helpers/ConfigSerializer.h +++ b/src/helpers/ConfigSerializer.h @@ -11,7 +11,9 @@ #endif #ifndef CONFIG_MAX_TOKEN_LEN - #define CONFIG_MAX_TOKEN_LEN 128 + // A value read whole: the companion's longest are 140-byte texts and an + // 80-byte table in hex (160). + #define CONFIG_MAX_TOKEN_LEN 192 #endif class ConfigSerializer { diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index 102e4fd0..11eb4adb 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -77,14 +77,14 @@ TEST(AlarmRepeatRoundTrip, AnArbitraryNonPresetMaskReadsAsOff) { } TEST(AlarmRepeatLabel, MatchesEachPresetIndex) { - EXPECT_STREQ("OFF", NodePrefs::alarmRepeatLabel(0)); + EXPECT_STREQ("Once", NodePrefs::alarmRepeatLabel(0)); EXPECT_STREQ("Daily", NodePrefs::alarmRepeatLabel(1)); EXPECT_STREQ("Weekdays", NodePrefs::alarmRepeatLabel(2)); EXPECT_STREQ("Weekends", NodePrefs::alarmRepeatLabel(3)); } -TEST(AlarmRepeatLabel, OutOfRangeIndexClampsToOff) { - EXPECT_STREQ("OFF", NodePrefs::alarmRepeatLabel(99)); +TEST(AlarmRepeatLabel, OutOfRangeIndexClampsToOnce) { + EXPECT_STREQ("Once", NodePrefs::alarmRepeatLabel(99)); } TEST(KeyboardAlphabetLabel, MatchesEachAlphabetIndex) { diff --git a/test/test_companion_prefs_json/test_companion_prefs_json.cpp b/test/test_companion_prefs_json/test_companion_prefs_json.cpp new file mode 100644 index 00000000..f636d0b3 --- /dev/null +++ b/test/test_companion_prefs_json/test_companion_prefs_json.cpp @@ -0,0 +1,139 @@ +#include + +#include +#include + +// NodePrefs saved as /prefs.json (NodePrefs::structure()) and read back: every +// field comes back as it was -- one without a key would come back zero. The +// fields are packed here (this test only), so that comparing them byte for +// byte has no padding in it, which no key holds. The serializer's own headers +// first, unpacked, as the library they're compiled into has them. +#include +#include +#pragma pack(push, 1) +#include "../../examples/companion_radio/NodePrefs.h" +#pragma pack(pop) + +namespace { + +class StringOut : public Stream { +public: + std::string text; + size_t write(uint8_t b) override { text += (char)b; return 1; } + size_t print(unsigned char v, int) override { return out(std::to_string(v)); } + size_t print(int v, int) override { return out(std::to_string(v)); } + size_t print(unsigned int v, int) override { return out(std::to_string(v)); } + size_t print(long v, int) override { return out(std::to_string(v)); } + size_t print(unsigned long v, int) override { return out(std::to_string(v)); } + size_t print(long long v, int) override { return out(std::to_string(v)); } + size_t print(unsigned long long v, int) override { return out(std::to_string(v)); } + size_t print(double v, int p) override { + char t[48]; + snprintf(t, sizeof(t), "%.*f", p, v); + return out(t); + } +private: + size_t out(const std::string& s) { text += s; return s.size(); } +}; + +class StringIn : public Stream { + const std::string& _s; + size_t _at = 0; +public: + explicit StringIn(const std::string& s) : _s(s) { } + int available() override { return (int)(_s.size() - _at); } + int read() override { return _at < _s.size() ? (uint8_t)_s[_at++] : -1; } + int peek() override { return _at < _s.size() ? (uint8_t)_s[_at] : -1; } +}; + +// Every byte of the fields a different value; texts printable (quotes and +// backslashes among them) and terminated; floats ones the file's four +// decimals keep exactly. +void fill(NodePrefs& p) { + uint8_t* b = (uint8_t*)static_cast(&p); + for (size_t i = 0; i < sizeof(NodePrefsFields); i++) b[i] = (uint8_t)(33 + (i * 7) % 90); + auto text = [](char* s, size_t n) { s[n - 1] = 0; }; + text(p.node_name, sizeof(p.node_name)); + text(p.default_scope_name, sizeof(p.default_scope_name)); + text(p.bot_trigger, sizeof(p.bot_trigger)); + text(p.bot_reply_dm, sizeof(p.bot_reply_dm)); + text(p.bot_reply_ch, sizeof(p.bot_reply_ch)); + text(p.bot_trigger_ch, sizeof(p.bot_trigger_ch)); + text(p.bot_trigger_room, sizeof(p.bot_trigger_room)); + text(p.bot_reply_room, sizeof(p.bot_reply_room)); + text(p.locator_label, sizeof(p.locator_label)); + for (auto& m : p.custom_msgs) text(m, sizeof(m)); + for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) { + auto& r = p.user_radio_presets[i]; + text(r.name, sizeof(r.name)); + r.freq = 869.525f + i; r.bw = 62.5f; + } + p.airtime_factor = 1.5f; p.freq = 869.618f; p.bw = 62.5f; p.rx_delay_base = 2.25f; + p.tx_delay_factor = 0.75f; p.direct_tx_delay_factor = 0.125f; + p.repeater_freq = 433.875f; p.repeater_bw = 125.0f; + p.client_repeat = 1; // a flag in the file (as upstream's "disable") +} + +} // namespace + +TEST(NodePrefsJson, EveryFieldRoundTrips) { + NodePrefs a; + fill(a); + a.node_lat = 50.061234; a.node_lon = 19.937654; + StringOut out; + ASSERT_TRUE(a.saveSerial(out)); + + NodePrefs b; + StringIn in(out.text); + ASSERT_TRUE(b.loadSerial(in)) << out.text; + const uint8_t* pa = (const uint8_t*)static_cast(&a); + const uint8_t* pb = (const uint8_t*)static_cast(&b); + for (size_t i = 0; i < sizeof(NodePrefsFields); i++) + ASSERT_EQ(pa[i], pb[i]) << "first difference at byte " << i << " of NodePrefsFields"; + EXPECT_DOUBLE_EQ(a.node_lat, b.node_lat); + EXPECT_DOUBLE_EQ(a.node_lon, b.node_lon); +} + +TEST(NodePrefsJson, UpstreamKeysWhereUpstreamHasThem) { + NodePrefs a; + strcpy(a.node_name, "Kraków 1"); + a.freq = 869.618f; a.sf = 8; a.tx_power_dbm = 22; a.client_repeat = 1; a.tz_offset_hours = 2; + StringOut out; + ASSERT_TRUE(a.saveSerial(out)); + const std::string& t = out.text; + EXPECT_NE(std::string::npos, t.find("name:\"Kraków 1\"")); + EXPECT_NE(std::string::npos, t.find("radio:{freq:869.6180")); + EXPECT_NE(std::string::npos, t.find("sf:8")); + EXPECT_NE(std::string::npos, t.find("tx:22")); + EXPECT_NE(std::string::npos, t.find("repeat:{disable:0}")); + EXPECT_NE(std::string::npos, t.find("tz_offset:2")); +} + +TEST(NodePrefsJson, AMissingKeyKeepsItsDefault) { + NodePrefs b; + b.sf = 9; b.buzzer_volume = 3; + StringIn in(std::string("{radio:{sf:11}}")); + ASSERT_TRUE(b.loadSerial(in)); + EXPECT_EQ(11, b.sf); + EXPECT_EQ(3, b.buzzer_volume); +} + +TEST(NodePrefsJson, BoardSettingsGoToTheFemFields) { + NodePrefs p; + KeyValueStore* kv = p.getCustom(); + ASSERT_TRUE(kv->setByKey("fem_rxgain", "1")); + EXPECT_EQ(1, p.radio_fem_rxgain); + EXPECT_TRUE(p.isDirty()); + char v[8]; + ASSERT_TRUE(kv->getByKey("fem_rxgain", v, sizeof(v) - 1)); + EXPECT_STREQ("1", v); + ASSERT_TRUE(kv->setByKey("fan", "2")); // a key with no field: kept in "custom" + StringOut out; + ASSERT_TRUE(p.saveSerial(out)); + EXPECT_NE(std::string::npos, out.text.find("custom:{fan:\"2\"}")); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}