mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
feat(companion): on-device scope + repeater scope filtering; fix CAD, UTF-8 truncation, Public channel, Nodes list, keyboard cursor
- Settings > Radio > Scope: type a community/region name on-device (derives the shared key the same "#name" -> SHA256 way as DEFAULT_FLOOD_SCOPE_NAME), previously only settable from a connected app. - Tools > Repeater > Scope only + Extra scopes: only relay flood traffic matching the device's own scope or a comma-separated list of additional scopes, without changing what scope the device's own messages send under. No-op while unconfigured. - getCADEnabled()/getInterferenceThreshold() were hardcoded off on companion_radio; CAD now auto-enables whenever RX power-save (duty-cycle) is active, since the noise floor isn't kept fresh during duty-cycle sleep. - Message truncation to fit the send frame could split a multi-byte UTF-8 character in half; now stops at the last complete character. - The default "Public" channel was unconditionally re-added at every boot before the saved channel list was loaded, so deleting it never stuck. Only seeded now on a genuinely fresh device (no channel file yet). - Tools > Nodes read contacts from the wrong starting offset, landing on internally-reserved bookkeeping slots instead of real contacts -- showed as blank "Unknown" rows and silently dropped that many real contacts off the end of the list. - resetContacts() only cleared the first few reserved slots, not the whole contact table, contrary to its own comment; only reachable today via private-key import, fixed to match stated intent regardless. - Keyboard's multi-line text preview could render the cursor on an empty line below short typed text instead of right after it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,7 @@ Lists all available home screen pages. For each entry:
|
|||||||
| CR | 5–8 | LEFT/RIGHT. Coding rate (4/5–4/8). |
|
| CR | 5–8 | LEFT/RIGHT. Coding rate (4/5–4/8). |
|
||||||
| Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive (SX126x only): cycles RX↔sleep, wakes on preamble, cuts average RX current at the cost of some latency. **Forced off (`--`) while the repeater is on** — restored once it's switched off. A background watchdog auto-recovers if the sequencer gets stuck (soft re-arm, then a full reset) — see Tools › Diagnostics for the counts. |
|
| Pwr save | ON / OFF | **Battery saver.** Hardware duty-cycle receive (SX126x only): cycles RX↔sleep, wakes on preamble, cuts average RX current at the cost of some latency. **Forced off (`--`) while the repeater is on** — restored once it's switched off. A background watchdog auto-recovers if the sequencer gets stuck (soft re-arm, then a full reset) — see Tools › Diagnostics for the counts. |
|
||||||
| Auto pwr | ON / OFF | **Adaptive Power Control.** Lowers TX power on strong links, ramps back to the **TX Pwr** ceiling on weak/lost ones. Link quality from DM ACK SNR, or — for channels (no ACK) — a repeater's rebroadcast. Live power shown on the radio page/name bar. Default OFF. **Suppressed (`--`) while the repeater is on** — restored once it's switched off. |
|
| Auto pwr | ON / OFF | **Adaptive Power Control.** Lowers TX power on strong links, ramps back to the **TX Pwr** ceiling on weak/lost ones. Link quality from DM ACK SNR, or — for channels (no ACK) — a repeater's rebroadcast. Live power shown on the radio page/name bar. Default OFF. **Suppressed (`--`) while the repeater is on** — restored once it's switched off. |
|
||||||
|
| Scope | text | Names your device's radio "community" (e.g. `pl`) — typing a name derives a shared key the same way on every device, so any device that types the same name lands on the same scope automatically, no key exchange needed. Tags this device's own DM/channel sends so repeaters can tell your community's flood traffic apart from others sharing the same frequency; paired with **Tools › Repeater › Scope only**, it's also what this device relays for in repeater mode. Not encryption — message content is unaffected either way. **Enter** opens the keyboard; empty clears it (unscoped, the previous default). |
|
||||||
|
|
||||||
| OLED | E-Ink |
|
| OLED | E-Ink |
|
||||||
| :-----------------------: | :-----------------------: |
|
| :-----------------------: | :-----------------------: |
|
||||||
@@ -79,7 +80,7 @@ Lists all available home screen pages. For each entry:
|
|||||||
|
|
||||||
<!-- screenshot pending: Radio — preset popup (pick/save/delete) and/or the digit-by-digit frequency editor -->
|
<!-- screenshot pending: Radio — preset popup (pick/save/delete) and/or the digit-by-digit frequency editor -->
|
||||||
|
|
||||||
The **repeater** mode and its flood filters live on their own screen — see **Tools › Repeater**.
|
The **repeater** mode and its flood filters live on their own screen — see **Tools › Repeater**, including how it uses **Scope** above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -540,8 +540,10 @@ Navigate with **UP/DOWN**; change a value with **LEFT/RIGHT** (or **Enter** for
|
|||||||
| Yield | OFF / x2–x9 | Scales the retransmit delay for **forwarded** floods only (your own sends are unaffected), so a mobile companion defers to better-sited fixed repeaters. Widens the window for **Suppress dup**. |
|
| Yield | OFF / x2–x9 | Scales the retransmit delay for **forwarded** floods only (your own sends are unaffected), so a mobile companion defers to better-sited fixed repeaters. Widens the window for **Suppress dup**. |
|
||||||
| Min SNR | OFF / −20…10 dB | Drop a flood copy received below this signal-to-noise threshold, so marginal fringe traffic isn't re-flooded. |
|
| Min SNR | OFF / −20…10 dB | Drop a flood copy received below this signal-to-noise threshold, so marginal fringe traffic isn't re-flooded. |
|
||||||
| Suppress dup | ON / OFF | If the same flood packet is overheard from another node while still queued to retransmit, cancel our copy — a peer already relayed it. Cuts redundant airtime in dense meshes; pairs with **Yield**. |
|
| Suppress dup | ON / OFF | If the same flood packet is overheard from another node while still queued to retransmit, cancel our copy — a peer already relayed it. Cuts redundant airtime in dense meshes; pairs with **Yield**. |
|
||||||
|
| Scope only | ON / OFF | Only forward flood traffic matching this device's own **Scope** (Settings › Radio) or one of the **Extra scopes** below — drops unscoped floods and floods tagged for a different community. No-op while neither is set, so switching this on with no scope configured can't silently stop all relaying. |
|
||||||
|
| Extra scopes | text | Comma-separated extra region names this repeater also relays for (e.g. `eu,de`), on top of its own Settings › Radio › **Scope** — relay-only, never changes what scope this device's own messages send under. **Enter** opens the keyboard. |
|
||||||
|
|
||||||
The five flood filters are **opt-in** (default OFF, so a plain repeater is unaffected) and act on **flood** traffic only — on a direct route this node is the named next hop, so it never drops those.
|
The flood filters (**Skip advert** through **Scope only**) are **opt-in** (default OFF, so a plain repeater is unaffected) and act on **flood** traffic only — on a direct route this node is the named next hop, so it never drops those.
|
||||||
|
|
||||||
**Same network vs. separate network.** With **Network = Current** (or a Custom profile matching your companion settings) the repeater stays on your own network — you keep messaging while relaying. A *different* Custom profile moves the device entirely onto that network while relaying (one radio can't be on two at once), returning to your own network when switched off. The profile re-applies after a reboot if the repeater was left on.
|
**Same network vs. separate network.** With **Network = Current** (or a Custom profile matching your companion settings) the repeater stays on your own network — you keep messaging while relaying. A *different* Custom profile moves the device entirely onto that network while relaying (one radio can't be on two at once), returning to your own network when switched off. The profile re-applies after a reboot if the repeater was left on.
|
||||||
|
|
||||||
|
|||||||
@@ -383,6 +383,18 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
(_prefs.repeat_min_snr < -20 || _prefs.repeat_min_snr > 10))
|
(_prefs.repeat_min_snr < -20 || _prefs.repeat_min_snr > 10))
|
||||||
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; // match the UI's -20..10 range
|
_prefs.repeat_min_snr = NodePrefs::REPEAT_SNR_DISABLED; // match the UI's -20..10 range
|
||||||
if (_prefs.repeat_suppress_dup > 1) _prefs.repeat_suppress_dup = 0;
|
if (_prefs.repeat_suppress_dup > 1) _prefs.repeat_suppress_dup = 0;
|
||||||
|
|
||||||
|
// → 0xC0DE0025: append repeat_scope_only right after the other repeater
|
||||||
|
// forwarding filters. A pre-0x25 file has no byte here; clamp to 0 (off,
|
||||||
|
// unchanged forwarding behaviour for upgraders).
|
||||||
|
rd(&_prefs.repeat_scope_only, sizeof(_prefs.repeat_scope_only));
|
||||||
|
if (_prefs.repeat_scope_only > 1) _prefs.repeat_scope_only = 0;
|
||||||
|
|
||||||
|
// → 0xC0DE0026: append repeat_extra_scopes right after it. A pre-0x26 file
|
||||||
|
// has no bytes here; rd() zero-inits, which is already an empty string.
|
||||||
|
rd(_prefs.repeat_extra_scopes, sizeof(_prefs.repeat_extra_scopes));
|
||||||
|
_prefs.repeat_extra_scopes[sizeof(_prefs.repeat_extra_scopes) - 1] = '\0';
|
||||||
|
|
||||||
rd(&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
|
rd(&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
|
||||||
rd(&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));
|
rd(&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));
|
||||||
rd(&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
|
rd(&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
|
||||||
@@ -569,6 +581,13 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
rd(&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
|
rd(&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
|
||||||
if (_prefs.keyboard_cardkb_compact > 1) _prefs.keyboard_cardkb_compact = 0;
|
if (_prefs.keyboard_cardkb_compact > 1) _prefs.keyboard_cardkb_compact = 0;
|
||||||
|
|
||||||
|
// → 0xC0DE0024: append interference_threshold + cad_enabled at the tail.
|
||||||
|
// A pre-0x24 file has no bytes here; clamp to 0 (both off, matching the
|
||||||
|
// getters' previous hardcoded behaviour for upgraders).
|
||||||
|
rd(&_prefs.interference_threshold, sizeof(_prefs.interference_threshold));
|
||||||
|
rd(&_prefs.cad_enabled, sizeof(_prefs.cad_enabled));
|
||||||
|
if (_prefs.cad_enabled > 1) _prefs.cad_enabled = 0;
|
||||||
|
|
||||||
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
||||||
// (or a different schema); rd() already zero-inits any fields not present,
|
// (or a different schema); rd() already zero-inits any fields not present,
|
||||||
// so we just log it — next savePrefs writes the current sentinel.
|
// so we just log it — next savePrefs writes the current sentinel.
|
||||||
@@ -729,6 +748,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
|||||||
file.write((uint8_t *)&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost));
|
file.write((uint8_t *)&_prefs.repeat_delay_boost, sizeof(_prefs.repeat_delay_boost));
|
||||||
file.write((uint8_t *)&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr));
|
file.write((uint8_t *)&_prefs.repeat_min_snr, sizeof(_prefs.repeat_min_snr));
|
||||||
file.write((uint8_t *)&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup));
|
file.write((uint8_t *)&_prefs.repeat_suppress_dup, sizeof(_prefs.repeat_suppress_dup));
|
||||||
|
file.write((uint8_t *)&_prefs.repeat_scope_only, sizeof(_prefs.repeat_scope_only));
|
||||||
|
file.write((uint8_t *)_prefs.repeat_extra_scopes, sizeof(_prefs.repeat_extra_scopes));
|
||||||
file.write((uint8_t *)&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
|
file.write((uint8_t *)&_prefs.repeater_use_profile, sizeof(_prefs.repeater_use_profile));
|
||||||
file.write((uint8_t *)&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));
|
file.write((uint8_t *)&_prefs.repeater_freq, sizeof(_prefs.repeater_freq));
|
||||||
file.write((uint8_t *)&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
|
file.write((uint8_t *)&_prefs.repeater_bw, sizeof(_prefs.repeater_bw));
|
||||||
@@ -781,6 +802,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
|||||||
file.write((uint8_t *)&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode));
|
file.write((uint8_t *)&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode));
|
||||||
file.write((uint8_t *)&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode));
|
file.write((uint8_t *)&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode));
|
||||||
file.write((uint8_t *)&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
|
file.write((uint8_t *)&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact));
|
||||||
|
file.write((uint8_t *)&_prefs.interference_threshold, sizeof(_prefs.interference_threshold));
|
||||||
|
file.write((uint8_t *)&_prefs.cad_enabled, sizeof(_prefs.cad_enabled));
|
||||||
|
|
||||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
|
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
|
||||||
// the one we check: once the flash fills, writes return 0, so a good
|
// the one we check: once the flash fills, writes return 0, so a good
|
||||||
@@ -894,7 +917,7 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void DataStore::loadChannels(DataStoreHost* host) {
|
bool DataStore::loadChannels(DataStoreHost* host) {
|
||||||
FILESYSTEM* fs = _getContactsChannelsFS();
|
FILESYSTEM* fs = _getContactsChannelsFS();
|
||||||
File file = openRead(fs, "/channels3");
|
File file = openRead(fs, "/channels3");
|
||||||
if (file) {
|
if (file) {
|
||||||
@@ -942,7 +965,7 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
|||||||
MESH_DEBUG_PRINTLN("loadChannels: skipped %u corrupted/empty channel entr%s",
|
MESH_DEBUG_PRINTLN("loadChannels: skipped %u corrupted/empty channel entr%s",
|
||||||
(unsigned)skipped, skipped == 1 ? "y" : "ies");
|
(unsigned)skipped, skipped == 1 ? "y" : "ies");
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time migration from the old /channels2 format (sequential index,
|
// One-time migration from the old /channels2 format (sequential index,
|
||||||
@@ -972,7 +995,10 @@ void DataStore::loadChannels(DataStoreHost* host) {
|
|||||||
}
|
}
|
||||||
file.close();
|
file.close();
|
||||||
saveChannels(host); // write /channels3 so the migration runs only once
|
saveChannels(host); // write /channels3 so the migration runs only once
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false; // neither file exists -- genuinely fresh device
|
||||||
}
|
}
|
||||||
|
|
||||||
void DataStore::saveChannels(DataStoreHost* host) {
|
void DataStore::saveChannels(DataStoreHost* host) {
|
||||||
|
|||||||
@@ -37,7 +37,13 @@ public:
|
|||||||
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
|
void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon);
|
||||||
void loadContacts(DataStoreHost* host);
|
void loadContacts(DataStoreHost* host);
|
||||||
void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL);
|
void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL);
|
||||||
void loadChannels(DataStoreHost* host);
|
// Returns true if a channels file (current or legacy) existed and was
|
||||||
|
// loaded -- false only on a genuinely fresh device with neither file, so
|
||||||
|
// the caller knows whether it's safe to seed default channels (see
|
||||||
|
// MyMesh::begin()'s addChannel("Public", ...) -- seeding unconditionally
|
||||||
|
// would resurrect a channel the user had deliberately deleted, since a
|
||||||
|
// deleted slot is simply absent from the file, not written as empty).
|
||||||
|
bool loadChannels(DataStoreHost* host);
|
||||||
void saveChannels(DataStoreHost* host);
|
void saveChannels(DataStoreHost* host);
|
||||||
void migrateToSecondaryFS();
|
void migrateToSecondaryFS();
|
||||||
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
|
uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#ifdef DISPLAY_CLASS
|
#ifdef DISPLAY_CLASS
|
||||||
#include "helpers/ui/DisplayDriver.h"
|
#include "helpers/ui/DisplayDriver.h"
|
||||||
#include "UITask.h"
|
#include "UITask.h"
|
||||||
|
#include <helpers/UTF8Helpers.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#define CMD_APP_START 1
|
#define CMD_APP_START 1
|
||||||
@@ -277,10 +278,16 @@ float MyMesh::getAirtimeBudgetFactor() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int MyMesh::getInterferenceThreshold() const {
|
int MyMesh::getInterferenceThreshold() const {
|
||||||
return 0; // disabled for now, until currentRSSI() problem is resolved
|
return _prefs.interference_threshold;
|
||||||
}
|
}
|
||||||
bool MyMesh::getCADEnabled() const {
|
bool MyMesh::getCADEnabled() const {
|
||||||
return false; // hardware CAD before TX (disabled by default, until configurable)
|
// RSSI-threshold interference detection relies on _noise_floor, which is only
|
||||||
|
// kept fresh by continuous RX — stale during RX duty-cycle sleep. Auto-enable
|
||||||
|
// hardware CAD (a fresh explicit scan) whenever power-save is actually active.
|
||||||
|
// _prefs.cad_enabled itself has no UI/CLI exposure yet on companion_radio
|
||||||
|
// (unlike simple_repeater's CommonCLI `cad` command) — it's wired and
|
||||||
|
// persisted for a future manual override, but always 0 today.
|
||||||
|
return _prefs.cad_enabled || (_prefs.rx_powersave && !_prefs.client_repeat);
|
||||||
}
|
}
|
||||||
|
|
||||||
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
|
int MyMesh::calcRxDelay(float score, uint32_t air_time) const {
|
||||||
@@ -512,9 +519,9 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
|||||||
memcpy(&out_frame[i], extra, extra_len);
|
memcpy(&out_frame[i], extra, extra_len);
|
||||||
i += extra_len;
|
i += extra_len;
|
||||||
}
|
}
|
||||||
int tlen = strlen(text); // TODO: UTF-8 ??
|
int tlen = strlen(text);
|
||||||
if (i + tlen > MAX_FRAME_SIZE) {
|
if (i + tlen > MAX_FRAME_SIZE) {
|
||||||
tlen = MAX_FRAME_SIZE - i;
|
tlen = mesh::validUtf8PrefixLength(text, MAX_FRAME_SIZE - i); // don't split a multi-byte char
|
||||||
}
|
}
|
||||||
memcpy(&out_frame[i], text, tlen);
|
memcpy(&out_frame[i], text, tlen);
|
||||||
i += tlen;
|
i += tlen;
|
||||||
@@ -634,11 +641,62 @@ bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
|
|||||||
if (_prefs.repeat_skip_adverts && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) return false;
|
if (_prefs.repeat_skip_adverts && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT) return false;
|
||||||
if (_prefs.repeat_max_hops > 0 && packet->getPathHashCount() >= _prefs.repeat_max_hops) return false;
|
if (_prefs.repeat_max_hops > 0 && packet->getPathHashCount() >= _prefs.repeat_max_hops) return false;
|
||||||
if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= REPEAT_MAX_ADVERT_HOPS) return false;
|
if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && packet->getPathHashCount() >= REPEAT_MAX_ADVERT_HOPS) return false;
|
||||||
|
if (_prefs.repeat_scope_only && repeat_scope_count > 0) { // no-op while unconfigured
|
||||||
|
if (!packet->hasTransportCodes()) return false; // unscoped flood, we only want our own scope(s)
|
||||||
|
bool matched = false;
|
||||||
|
for (uint8_t i = 0; i < repeat_scope_count && !matched; i++) {
|
||||||
|
matched = repeat_scopes[i].calcTransportCode(packet) == packet->transport_codes[0];
|
||||||
|
}
|
||||||
|
if (!matched) return false;
|
||||||
|
}
|
||||||
if (isRepeatLooped(packet)) return false;
|
if (isRepeatLooped(packet)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MyMesh::setPrimaryScope(const char* name) {
|
||||||
|
strncpy(_prefs.default_scope_name, name, sizeof(_prefs.default_scope_name) - 1);
|
||||||
|
_prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0';
|
||||||
|
if (_prefs.default_scope_name[0] == '\0') {
|
||||||
|
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
|
||||||
|
} else {
|
||||||
|
char hashtag[1 + sizeof(_prefs.default_scope_name)];
|
||||||
|
snprintf(hashtag, sizeof(hashtag), "#%s", _prefs.default_scope_name);
|
||||||
|
TransportKeyStore temp;
|
||||||
|
TransportKey key;
|
||||||
|
temp.getAutoKeyFor(0, hashtag, key);
|
||||||
|
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
|
||||||
|
}
|
||||||
|
rebuildRepeatScopes();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MyMesh::rebuildRepeatScopes() {
|
||||||
|
repeat_scope_count = 0;
|
||||||
|
|
||||||
|
TransportKey primary;
|
||||||
|
memcpy(primary.key, _prefs.default_scope_key, sizeof(primary.key));
|
||||||
|
if (!primary.isNull()) repeat_scopes[repeat_scope_count++] = primary;
|
||||||
|
|
||||||
|
TransportKeyStore temp;
|
||||||
|
char names[sizeof(_prefs.repeat_extra_scopes)];
|
||||||
|
strncpy(names, _prefs.repeat_extra_scopes, sizeof(names));
|
||||||
|
names[sizeof(names) - 1] = '\0';
|
||||||
|
|
||||||
|
char* tok = strtok(names, ",");
|
||||||
|
while (tok != NULL && repeat_scope_count < MAX_REPEAT_SCOPES) {
|
||||||
|
while (*tok == ' ') tok++; // trim leading spaces
|
||||||
|
char* end = tok + strlen(tok);
|
||||||
|
while (end > tok && end[-1] == ' ') *(--end) = '\0'; // trim trailing spaces
|
||||||
|
|
||||||
|
if (*tok != '\0') {
|
||||||
|
char hashtag[1 + sizeof(_prefs.repeat_extra_scopes)];
|
||||||
|
snprintf(hashtag, sizeof(hashtag), "#%s", tok);
|
||||||
|
temp.getAutoKeyFor(0, hashtag, repeat_scopes[repeat_scope_count++]);
|
||||||
|
}
|
||||||
|
tok = strtok(NULL, ",");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) {
|
void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||||
if (scope.isNull()) {
|
if (scope.isNull()) {
|
||||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||||
@@ -769,9 +827,9 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
|||||||
out_frame[i++] = TXT_TYPE_PLAIN;
|
out_frame[i++] = TXT_TYPE_PLAIN;
|
||||||
memcpy(&out_frame[i], ×tamp, 4);
|
memcpy(&out_frame[i], ×tamp, 4);
|
||||||
i += 4;
|
i += 4;
|
||||||
int tlen = strlen(text); // TODO: UTF-8 ??
|
int tlen = strlen(text);
|
||||||
if (i + tlen > MAX_FRAME_SIZE) {
|
if (i + tlen > MAX_FRAME_SIZE) {
|
||||||
tlen = MAX_FRAME_SIZE - i;
|
tlen = mesh::validUtf8PrefixLength(text, MAX_FRAME_SIZE - i); // don't split a multi-byte char
|
||||||
}
|
}
|
||||||
memcpy(&out_frame[i], text, tlen);
|
memcpy(&out_frame[i], text, tlen);
|
||||||
i += tlen;
|
i += tlen;
|
||||||
@@ -1592,6 +1650,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
|||||||
memset(_ping_results, 0, sizeof(_ping_results));
|
memset(_ping_results, 0, sizeof(_ping_results));
|
||||||
|
|
||||||
send_unscoped = false;
|
send_unscoped = false;
|
||||||
|
repeat_scope_count = 0;
|
||||||
|
|
||||||
// defaults
|
// defaults
|
||||||
memset(&_prefs, 0, sizeof(_prefs));
|
memset(&_prefs, 0, sizeof(_prefs));
|
||||||
@@ -1683,6 +1742,7 @@ void MyMesh::begin(bool has_display) {
|
|||||||
|
|
||||||
// load persisted prefs
|
// load persisted prefs
|
||||||
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
|
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
|
||||||
|
rebuildRepeatScopes();
|
||||||
|
|
||||||
// sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain
|
// sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain
|
||||||
// is a min/max macro and NaN compares false against both bounds, so it would
|
// is a min/max macro and NaN compares false against both bounds, so it would
|
||||||
@@ -1724,8 +1784,14 @@ void MyMesh::begin(bool has_display) {
|
|||||||
resetContacts();
|
resetContacts();
|
||||||
_store->loadContacts(this);
|
_store->loadContacts(this);
|
||||||
bootstrapRTCfromContacts();
|
bootstrapRTCfromContacts();
|
||||||
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
|
// Only seed the default Public channel on a genuinely fresh device (no
|
||||||
_store->loadChannels(this);
|
// channels file at all yet) -- a deleted channel is simply absent from
|
||||||
|
// /channels3, not written back as an empty record (see saveChannels()), so
|
||||||
|
// seeding unconditionally here would silently resurrect Public every boot
|
||||||
|
// even after the user explicitly deleted it.
|
||||||
|
if (!_store->loadChannels(this)) {
|
||||||
|
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
|
||||||
|
}
|
||||||
|
|
||||||
applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
|
applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
|
||||||
applyApc(); // sets TX power to the ceiling and arms APC if enabled
|
applyApc(); // sets TX power to the ceiling and arms APC if enabled
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class UITask;
|
|||||||
#define FIRMWARE_VER_CODE 13
|
#define FIRMWARE_VER_CODE 13
|
||||||
|
|
||||||
#ifndef FIRMWARE_BUILD_DATE
|
#ifndef FIRMWARE_BUILD_DATE
|
||||||
#define FIRMWARE_BUILD_DATE "14 Aug 2026"
|
#define FIRMWARE_BUILD_DATE "19 Aug 2026"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Fallback only -- every real build (local or CI) goes through build.sh, which
|
// Fallback only -- every real build (local or CI) goes through build.sh, which
|
||||||
@@ -20,7 +20,7 @@ class UITask;
|
|||||||
// "dev-<commit>" otherwise; see build-solo-firmwares.yml). This default only
|
// "dev-<commit>" otherwise; see build-solo-firmwares.yml). This default only
|
||||||
// shows up for a `pio run` invoked directly, bypassing build.sh entirely.
|
// shows up for a `pio run` invoked directly, bypassing build.sh entirely.
|
||||||
#ifndef FIRMWARE_VERSION
|
#ifndef FIRMWARE_VERSION
|
||||||
#define FIRMWARE_VERSION "v1.25-dev"
|
#define FIRMWARE_VERSION "v1.26-dev"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||||
@@ -301,6 +301,19 @@ public:
|
|||||||
// the repeater toggle / network / profile changes.
|
// the repeater toggle / network / profile changes.
|
||||||
void applyRepeaterRadio();
|
void applyRepeaterRadio();
|
||||||
|
|
||||||
|
// Sets this device's own scope (Settings > Radio > Scope) from a single
|
||||||
|
// typed region name, deriving default_scope_key the same "#name" -> SHA256
|
||||||
|
// way as DEFAULT_FLOOD_SCOPE_NAME (see begin()). Empty name clears the
|
||||||
|
// scope. Governs what scope the companion's own messages send under, and
|
||||||
|
// (as scope[0]) what repeat_scope_only accepts. Calls rebuildRepeatScopes().
|
||||||
|
void setPrimaryScope(const char* name);
|
||||||
|
|
||||||
|
// Rebuilds repeat_scopes[]/repeat_scope_count from default_scope_key (slot 0,
|
||||||
|
// if configured) plus the comma-separated repeat_extra_scopes (Tools >
|
||||||
|
// Repeater > Extra scopes). Call after loading prefs at boot and whenever
|
||||||
|
// either scope setting is edited.
|
||||||
|
void rebuildRepeatScopes();
|
||||||
|
|
||||||
bool isAckPending(uint32_t expected_ack) const {
|
bool isAckPending(uint32_t expected_ack) const {
|
||||||
if (expected_ack == 0) return false; // 0 marks an empty/cleared slot, not a real ACK
|
if (expected_ack == 0) return false; // 0 marks an empty/cleared slot, not a real ACK
|
||||||
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
|
for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++)
|
||||||
@@ -498,6 +511,14 @@ private:
|
|||||||
|
|
||||||
TransportKey send_scope;
|
TransportKey send_scope;
|
||||||
|
|
||||||
|
// Runtime-only (not persisted) cache of scopes accepted by repeat_scope_only:
|
||||||
|
// slot 0 is default_scope_key (if configured), the rest are derived from the
|
||||||
|
// comma-separated repeat_extra_scopes. Rebuilt by rebuildRepeatScopes() (see
|
||||||
|
// the public section below) whenever either scope setting changes.
|
||||||
|
static const uint8_t MAX_REPEAT_SCOPES = 4;
|
||||||
|
TransportKey repeat_scopes[MAX_REPEAT_SCOPES];
|
||||||
|
uint8_t repeat_scope_count;
|
||||||
|
|
||||||
uint8_t cmd_frame[MAX_FRAME_SIZE + 1];
|
uint8_t cmd_frame[MAX_FRAME_SIZE + 1];
|
||||||
uint8_t out_frame[MAX_FRAME_SIZE + 1];
|
uint8_t out_frame[MAX_FRAME_SIZE + 1];
|
||||||
CayenneLPP telemetry;
|
CayenneLPP telemetry;
|
||||||
|
|||||||
@@ -208,12 +208,24 @@ struct NodePrefs { // persisted to file
|
|||||||
// fringe traffic isn't re-flooded. REPEAT_SNR_DISABLED (-128) = off.
|
// fringe traffic isn't re-flooded. REPEAT_SNR_DISABLED (-128) = off.
|
||||||
// repeat_suppress_dup: 1 = cancel a queued retransmit when the same flood is
|
// repeat_suppress_dup: 1 = cancel a queued retransmit when the same flood is
|
||||||
// overheard from another node first (less redundant airtime in dense mesh).
|
// overheard from another node first (less redundant airtime in dense mesh).
|
||||||
|
// repeat_scope_only: 1 = only forward flood packets matching this device's
|
||||||
|
// own scope (Settings > Radio > Scope, default_scope_key) or one of the
|
||||||
|
// repeat_extra_scopes below — drops unscoped floods and floods tagged for
|
||||||
|
// a different community. A no-op (forwards everything, unchanged) while
|
||||||
|
// no scope is configured at all, so enabling this on an unconfigured
|
||||||
|
// device can't silently blackhole all flood traffic.
|
||||||
uint8_t repeat_skip_adverts;
|
uint8_t repeat_skip_adverts;
|
||||||
uint8_t repeat_max_hops;
|
uint8_t repeat_max_hops;
|
||||||
uint8_t repeat_delay_boost;
|
uint8_t repeat_delay_boost;
|
||||||
int8_t repeat_min_snr;
|
int8_t repeat_min_snr;
|
||||||
static const int8_t REPEAT_SNR_DISABLED = -128;
|
static const int8_t REPEAT_SNR_DISABLED = -128;
|
||||||
uint8_t repeat_suppress_dup;
|
uint8_t repeat_suppress_dup;
|
||||||
|
uint8_t repeat_scope_only;
|
||||||
|
// Extra region names this repeater also relays for, beyond its own
|
||||||
|
// Settings > Radio > Scope (comma-separated, e.g. "eu,de") — see
|
||||||
|
// MyMesh::rebuildRepeatScopes(). Relay-only: never affects what scope the
|
||||||
|
// companion's own messages send under, only what repeat_scope_only accepts.
|
||||||
|
char repeat_extra_scopes[24];
|
||||||
|
|
||||||
// Optional dedicated radio profile for repeater mode. When repeater_use_profile
|
// Optional dedicated radio profile for repeater mode. When repeater_use_profile
|
||||||
// is 1, enabling the repeater switches the radio to repeater_freq/bw/sf/cr and
|
// is 1, enabling the repeater switches the radio to repeater_freq/bw/sf/cr and
|
||||||
@@ -441,6 +453,16 @@ struct NodePrefs { // persisted to file
|
|||||||
// unchanged behaviour) on upgrade.
|
// unchanged behaviour) on upgrade.
|
||||||
uint8_t keyboard_cardkb_compact;
|
uint8_t keyboard_cardkb_compact;
|
||||||
|
|
||||||
|
// RSSI-based interference detection (relative to the radio's own noise
|
||||||
|
// floor) and hardware Channel Activity Detection before TX. See
|
||||||
|
// MyMesh::getInterferenceThreshold()/getCADEnabled() — CAD is also
|
||||||
|
// auto-enabled whenever rx_powersave is active, regardless of this flag,
|
||||||
|
// since duty-cycle sleep leaves the noise floor stale. Both default 0/off,
|
||||||
|
// and neither has a Settings/CLI toggle yet on companion_radio — persisted
|
||||||
|
// and wired for a future manual override.
|
||||||
|
uint8_t interference_threshold;
|
||||||
|
uint8_t cad_enabled;
|
||||||
|
|
||||||
// Single source of truth for the live-share option tables (shared by the Map
|
// Single source of truth for the live-share option tables (shared by the Map
|
||||||
// UI labels and the auto-send engine in UITask).
|
// UI labels and the auto-send engine in UITask).
|
||||||
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
|
static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
|
||||||
@@ -503,7 +525,7 @@ struct NodePrefs { // persisted to file
|
|||||||
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
||||||
// older saves are detected on load and skipped (zero-init defaults kept).
|
// older saves are detected on load and skipped (zero-init defaults kept).
|
||||||
// High 24 bits identify the file format; low byte is the schema revision.
|
// High 24 bits identify the file format; low byte is the schema revision.
|
||||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0023;
|
static const uint32_t SCHEMA_SENTINEL = 0xC0DE0026;
|
||||||
|
|
||||||
// Bit-index for each home page. Used by page_order (entries store bit+1) and
|
// 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
|
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
|
||||||
@@ -608,7 +630,14 @@ struct NodePrefs { // persisted to file
|
|||||||
// bumps, 7 more uint8_t total) added 8 bytes, not 7 -- one byte of tail
|
// bumps, 7 more uint8_t total) added 8 bytes, not 7 -- one byte of tail
|
||||||
// padding got consumed along the way. 2720 confirmed via a real
|
// padding got consumed along the way. 2720 confirmed via a real
|
||||||
// WioTrackerL1Eink_companion_solo_dual build.
|
// WioTrackerL1Eink_companion_solo_dual build.
|
||||||
static_assert(sizeof(NodePrefs) == 2720,
|
// interference_threshold + cad_enabled (0xC0DE0024) are the new struct tail --
|
||||||
|
// added 8 bytes, not 2 -- the 2 real bytes rounded the struct up to its next
|
||||||
|
// alignment boundary. Confirmed via a real Heltec_v3_companion_radio_ble build.
|
||||||
|
// repeat_scope_only (0xC0DE0025) landed in the padding left over from the
|
||||||
|
// 0xC0DE0024 bump -- confirmed via a real build, sizeof unchanged at 2728.
|
||||||
|
// repeat_extra_scopes[24] (0xC0DE0026) added exactly 24 bytes, no leftover
|
||||||
|
// padding this time -- confirmed via a real build, sizeof now 2752.
|
||||||
|
static_assert(sizeof(NodePrefs) == 2752,
|
||||||
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
|
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
|
||||||
"SCHEMA_SENTINEL, then update this size (see steps above).");
|
"SCHEMA_SENTINEL, then update this size (see steps above).");
|
||||||
|
|
||||||
|
|||||||
@@ -577,10 +577,18 @@ struct KeyboardWidget {
|
|||||||
// ...and the byte offset that line starts at.
|
// ...and the byte offset that line starts at.
|
||||||
int ps = 0;
|
int ps = 0;
|
||||||
for (int n = first_line * cpl; n > 0 && ps < len; n--) ps += kbUtf8CharBytesAt(buf, ps, len);
|
for (int n = first_line * cpl; n > 0 && ps < len; n--) ps += kbUtf8CharBytesAt(buf, ps, len);
|
||||||
|
bool cursor_drawn = false; // draw it on exactly one line, even once the text itself runs out
|
||||||
for (int pl = 0; pl < prev_lines; pl++) {
|
for (int pl = 0; pl < prev_lines; pl++) {
|
||||||
int pe = ps; // byte offset cpl codepoints further along (or end of text)
|
int pe = ps; // byte offset cpl codepoints further along (or end of text)
|
||||||
for (int k = 0; k < cpl && pe < len; k++) pe += kbUtf8CharBytesAt(buf, pe, len);
|
for (int k = 0; k < cpl && pe < len; k++) pe += kbUtf8CharBytesAt(buf, pe, len);
|
||||||
bool cursor_here = (ps <= cursor_pos && (cursor_pos < pe || pl == prev_lines - 1));
|
// cursor_pos == len == pe is the common "typing at the end" case: that's
|
||||||
|
// this line's cursor only if THIS is where the text actually ends (pe ==
|
||||||
|
// len), not just whichever line happens to be the bottom of the preview
|
||||||
|
// area -- short text (fitting in fewer than prev_lines rows) would
|
||||||
|
// otherwise always show the cursor stranded on the last blank row
|
||||||
|
// instead of right after what was just typed.
|
||||||
|
bool cursor_here = !cursor_drawn && ps <= cursor_pos && (cursor_pos < pe || pe == len);
|
||||||
|
if (cursor_here) cursor_drawn = true;
|
||||||
int line_end = (len < pe) ? len : pe;
|
int line_end = (len < pe) ? len : pe;
|
||||||
char linebuf[KB_PREVIEW_BYTES + 2]; // cpl codepoints + cursor '_' + NUL
|
char linebuf[KB_PREVIEW_BYTES + 2]; // cpl codepoints + cursor '_' + NUL
|
||||||
if (cursor_here) {
|
if (cursor_here) {
|
||||||
|
|||||||
@@ -173,7 +173,16 @@ class NearbyScreen : public UIScreen {
|
|||||||
int nc = the_mesh.getNumContacts();
|
int nc = the_mesh.getNumContacts();
|
||||||
for (int i = 0; i < nc && _count < MAX_NEARBY; i++) {
|
for (int i = 0; i < nc && _count < MAX_NEARBY; i++) {
|
||||||
ContactInfo ci;
|
ContactInfo ci;
|
||||||
if (!the_mesh.getContactByIdx(i, ci)) continue;
|
// getContactByIdx() indexes the RAW contact table, whose first
|
||||||
|
// MAX_ANON_CONTACTS slots are reserved for anon-request bookkeeping
|
||||||
|
// (see BaseChatMesh::resetContacts()/ContactsIterator) -- getNumContacts()
|
||||||
|
// already excludes them from the count, so real contact 0 lives at raw
|
||||||
|
// index MAX_ANON_CONTACTS, not 0. Reading from 0 pulled those reserved
|
||||||
|
// (blank, type=ADV_TYPE_NONE) slots into the list as bogus "Unknown"
|
||||||
|
// rows, and silently dropped the same number of real contacts off the
|
||||||
|
// end -- while never touching the anon slots themselves, so it always
|
||||||
|
// reproduced the same way regardless of the auto-add overwrite setting.
|
||||||
|
if (!the_mesh.getContactByIdx(i + MAX_ANON_CONTACTS, ci)) continue;
|
||||||
if (!typeMatchesFilter(ci.type, ci.flags, true)) continue;
|
if (!typeMatchesFilter(ci.type, ci.flags, true)) continue;
|
||||||
|
|
||||||
Entry& e = _entries[_count++];
|
Entry& e = _entries[_count++];
|
||||||
@@ -188,7 +197,7 @@ class NearbyScreen : public UIScreen {
|
|||||||
? geo::haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon)
|
? geo::haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon)
|
||||||
: -1.0f;
|
: -1.0f;
|
||||||
e.type = ci.type;
|
e.type = ci.type;
|
||||||
e.contact_idx = i;
|
e.contact_idx = i + MAX_ANON_CONTACTS; // raw index -- other lookups re-key off this directly
|
||||||
e.lastmod = ci.lastmod;
|
e.lastmod = ci.lastmod;
|
||||||
e.is_known = true;
|
e.is_known = true;
|
||||||
e.is_live = false;
|
e.is_live = false;
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ class RepeaterScreen : public UIScreen {
|
|||||||
enum Item {
|
enum Item {
|
||||||
IT_REPEATER, IT_NETWORK,
|
IT_REPEATER, IT_NETWORK,
|
||||||
IT_RPRESET, IT_RFREQ, IT_RSF, IT_RBW, IT_RCR, // dedicated profile (Custom network)
|
IT_RPRESET, IT_RFREQ, IT_RSF, IT_RBW, IT_RCR, // dedicated profile (Custom network)
|
||||||
IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS
|
IT_SKIP, IT_HOPS, IT_YIELD, IT_SNR, IT_SUPPRESS, IT_SCOPE, IT_SCOPE_EXTRA
|
||||||
};
|
};
|
||||||
uint8_t _items[12];
|
uint8_t _items[14];
|
||||||
|
bool _editing_scope; // keyboard is entering/editing the extra scopes
|
||||||
int _item_count;
|
int _item_count;
|
||||||
|
|
||||||
RadioPresetPicker _picker;
|
RadioPresetPicker _picker;
|
||||||
@@ -66,6 +67,8 @@ class RepeaterScreen : public UIScreen {
|
|||||||
_items[_item_count++] = IT_YIELD;
|
_items[_item_count++] = IT_YIELD;
|
||||||
_items[_item_count++] = IT_SNR;
|
_items[_item_count++] = IT_SNR;
|
||||||
_items[_item_count++] = IT_SUPPRESS;
|
_items[_item_count++] = IT_SUPPRESS;
|
||||||
|
_items[_item_count++] = IT_SCOPE;
|
||||||
|
_items[_item_count++] = IT_SCOPE_EXTRA;
|
||||||
}
|
}
|
||||||
if (_sel >= _item_count) _sel = _item_count - 1;
|
if (_sel >= _item_count) _sel = _item_count - 1;
|
||||||
if (_sel < 0) _sel = 0;
|
if (_sel < 0) _sel = 0;
|
||||||
@@ -84,7 +87,9 @@ class RepeaterScreen : public UIScreen {
|
|||||||
case IT_HOPS: return "Max hops";
|
case IT_HOPS: return "Max hops";
|
||||||
case IT_YIELD: return "Yield";
|
case IT_YIELD: return "Yield";
|
||||||
case IT_SNR: return "Min SNR";
|
case IT_SNR: return "Min SNR";
|
||||||
case IT_SUPPRESS: return "Suppress dup";
|
case IT_SUPPRESS: return "Suppress dup";
|
||||||
|
case IT_SCOPE: return "Scope only";
|
||||||
|
case IT_SCOPE_EXTRA: return "Extra scopes";
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -113,6 +118,10 @@ class RepeaterScreen : public UIScreen {
|
|||||||
else strncpy(buf, "OFF", n);
|
else strncpy(buf, "OFF", n);
|
||||||
break;
|
break;
|
||||||
case IT_SUPPRESS: strncpy(buf, p->repeat_suppress_dup ? "ON" : "OFF", n); break;
|
case IT_SUPPRESS: strncpy(buf, p->repeat_suppress_dup ? "ON" : "OFF", n); break;
|
||||||
|
case IT_SCOPE: strncpy(buf, p->repeat_scope_only ? "ON" : "OFF", n); break;
|
||||||
|
case IT_SCOPE_EXTRA:
|
||||||
|
strncpy(buf, p->repeat_extra_scopes[0] ? p->repeat_extra_scopes : "(none)", n);
|
||||||
|
break;
|
||||||
default: strncpy(buf, "", n); break;
|
default: strncpy(buf, "", n); break;
|
||||||
}
|
}
|
||||||
buf[n - 1] = '\0';
|
buf[n - 1] = '\0';
|
||||||
@@ -126,16 +135,17 @@ class RepeaterScreen : public UIScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1) {}
|
RepeaterScreen(UITask* task) : _task(task), _dirty(false), _sel(0), _scroll(0), _item_count(1), _editing_scope(false) {}
|
||||||
|
|
||||||
void onShow() override {
|
void onShow() override {
|
||||||
_dirty = false; _sel = 0; _scroll = 0;
|
_dirty = false; _sel = 0; _scroll = 0;
|
||||||
_picker.menu.active = false; _editor.freq.active = false;
|
_picker.menu.active = false; _editor.freq.active = false;
|
||||||
_picker.saving = false; _picker.deleting = false;
|
_picker.saving = false; _picker.deleting = false;
|
||||||
|
_editing_scope = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int render(DisplayDriver& display) override {
|
int render(DisplayDriver& display) override {
|
||||||
if (_picker.saving) return _task->keyboard().render(display);
|
if (_picker.saving || _editing_scope) return _task->keyboard().render(display);
|
||||||
|
|
||||||
NodePrefs* p = _task->getNodePrefs();
|
NodePrefs* p = _task->getNodePrefs();
|
||||||
buildItems(p);
|
buildItems(p);
|
||||||
@@ -181,6 +191,23 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyboard editing mode for the extra (relay-only) scopes
|
||||||
|
if (_editing_scope) {
|
||||||
|
auto res = _task->keyboard().handleInput(c);
|
||||||
|
if (res == KeyboardWidget::DONE) {
|
||||||
|
if (p) {
|
||||||
|
strncpy(p->repeat_extra_scopes, _task->keyboard().buf, sizeof(p->repeat_extra_scopes) - 1);
|
||||||
|
p->repeat_extra_scopes[sizeof(p->repeat_extra_scopes) - 1] = '\0';
|
||||||
|
the_mesh.rebuildRepeatScopes();
|
||||||
|
_dirty = true;
|
||||||
|
}
|
||||||
|
_editing_scope = false;
|
||||||
|
} else if (res == KeyboardWidget::CANCELLED) {
|
||||||
|
_editing_scope = false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Modal overlays first.
|
// Modal overlays first.
|
||||||
if (_picker.menu.active) {
|
if (_picker.menu.active) {
|
||||||
auto res = _picker.menu.handleInput(c);
|
auto res = _picker.menu.handleInput(c);
|
||||||
@@ -279,6 +306,14 @@ public:
|
|||||||
if (item == IT_SUPPRESS && (left || right || enter)) {
|
if (item == IT_SUPPRESS && (left || right || enter)) {
|
||||||
p->repeat_suppress_dup ^= 1; _dirty = true; return true;
|
p->repeat_suppress_dup ^= 1; _dirty = true; return true;
|
||||||
}
|
}
|
||||||
|
if (item == IT_SCOPE && (left || right || enter)) {
|
||||||
|
p->repeat_scope_only ^= 1; _dirty = true; return true;
|
||||||
|
}
|
||||||
|
if (item == IT_SCOPE_EXTRA && enter) {
|
||||||
|
_task->keyboard().begin(p->repeat_extra_scopes, (int)sizeof(p->repeat_extra_scopes) - 1);
|
||||||
|
_editing_scope = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class SettingsScreen : public UIScreen {
|
|||||||
CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR,
|
CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR,
|
||||||
POWER_SAVE,
|
POWER_SAVE,
|
||||||
TX_APC,
|
TX_APC,
|
||||||
|
SCOPE_NAME,
|
||||||
// System section
|
// System section
|
||||||
SECTION_SYSTEM,
|
SECTION_SYSTEM,
|
||||||
DEVICE_NAME,
|
DEVICE_NAME,
|
||||||
@@ -555,6 +556,11 @@ class SettingsScreen : public UIScreen {
|
|||||||
// Suppressed (and locked) while repeating — a repeater holds full TX power.
|
// Suppressed (and locked) while repeating — a repeater holds full TX power.
|
||||||
if (p && p->client_repeat) display.print("--");
|
if (p && p->client_repeat) display.print("--");
|
||||||
else display.print((p && p->tx_apc) ? "ON" : "OFF");
|
else display.print((p && p->tx_apc) ? "ON" : "OFF");
|
||||||
|
} else if (item == SCOPE_NAME) {
|
||||||
|
display.print("Scope");
|
||||||
|
int vx = valCol(display);
|
||||||
|
display.drawTextEllipsized(vx, y, display.width() - vx - _reserve,
|
||||||
|
(p && p->default_scope_name[0]) ? p->default_scope_name : "(none)");
|
||||||
#if AUTO_OFF_MILLIS > 0
|
#if AUTO_OFF_MILLIS > 0
|
||||||
} else if (item == AUTO_OFF) {
|
} else if (item == AUTO_OFF) {
|
||||||
display.print("AutoOff");
|
display.print("AutoOff");
|
||||||
@@ -678,6 +684,7 @@ class SettingsScreen : public UIScreen {
|
|||||||
// Keyboard state for editing message slots
|
// Keyboard state for editing message slots
|
||||||
int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited
|
int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited
|
||||||
bool _edit_name = false; // editing DEVICE_NAME via the keyboard
|
bool _edit_name = false; // editing DEVICE_NAME via the keyboard
|
||||||
|
bool _edit_scope = false; // editing SCOPE_NAME via the keyboard
|
||||||
KeyboardWidget* _kb;
|
KeyboardWidget* _kb;
|
||||||
|
|
||||||
// Radio preset picker — names are too long for the value column, so Enter on
|
// Radio preset picker — names are too long for the value column, so Enter on
|
||||||
@@ -701,6 +708,7 @@ public:
|
|||||||
void onShow() override {
|
void onShow() override {
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
_edit_name = false;
|
_edit_name = false;
|
||||||
|
_edit_scope = false;
|
||||||
resetList();
|
resetList();
|
||||||
_editor.freq.active = false;
|
_editor.freq.active = false;
|
||||||
}
|
}
|
||||||
@@ -708,7 +716,7 @@ public:
|
|||||||
int render(DisplayDriver& display) override {
|
int render(DisplayDriver& display) override {
|
||||||
display.setTextSize(1);
|
display.setTextSize(1);
|
||||||
|
|
||||||
if (_edit_slot >= 0 || _edit_name || _picker.saving) {
|
if (_edit_slot >= 0 || _edit_name || _edit_scope || _picker.saving) {
|
||||||
return _kb->render(display);
|
return _kb->render(display);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,6 +779,19 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyboard editing mode for the scope name
|
||||||
|
if (_edit_scope) {
|
||||||
|
auto res = _kb->handleInput(c);
|
||||||
|
if (res == KeyboardWidget::DONE) {
|
||||||
|
the_mesh.setPrimaryScope(_kb->buf);
|
||||||
|
_dirty = true;
|
||||||
|
_edit_scope = false;
|
||||||
|
} else if (res == KeyboardWidget::CANCELLED) {
|
||||||
|
_edit_scope = false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Digit-by-digit Freq editor
|
// Digit-by-digit Freq editor
|
||||||
if (_editor.active()) {
|
if (_editor.active()) {
|
||||||
if (_editor.handleFreqInput(c) && p) { _task->applyRadioParams(); _dirty = true; }
|
if (_editor.handleFreqInput(c) && p) { _task->applyRadioParams(); _dirty = true; }
|
||||||
@@ -966,6 +987,12 @@ public:
|
|||||||
_kb->clearPlaceholders(); // a device name is literal, not a message
|
_kb->clearPlaceholders(); // a device name is literal, not a message
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (_selected == SCOPE_NAME && p && enter) {
|
||||||
|
_edit_scope = true;
|
||||||
|
_kb->begin(p->default_scope_name, (int)sizeof(p->default_scope_name) - 1);
|
||||||
|
_kb->clearPlaceholders(); // a scope name is literal, not a message
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (_selected == REBOOT && enter) {
|
if (_selected == REBOOT && enter) {
|
||||||
_task->savePrefsIfDirty(_dirty); // don't lose pending edits across the restart
|
_task->savePrefsIfDirty(_dirty); // don't lose pending edits across the restart
|
||||||
_task->showAlert("Rebooting...", 800);
|
_task->showAlert("Rebooting...", 800);
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
|
## MeshCore Solo Companion Firmware v1.26
|
||||||
|
|
||||||
|
### What's new
|
||||||
|
|
||||||
|
- **On-device community scope, and repeater-side scope filtering.** Settings › Radio gets a **Scope** field — type a region/community name (e.g. `pl`) and every device typing the same name derives the same shared tag, no key exchange needed; it's what your own DM/channel sends carry, previously only settable from a connected app. Tools › Repeater gains **Scope only** (only relay flood traffic matching your own scope, or one of the new **Extra scopes** below — a no-op until a scope is actually set, so it can't silently blackhole all forwarding) and **Extra scopes** (comma-separated additional regions to relay for, without changing what scope this device's own messages send under).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **Hardware CAD and RSSI interference-threshold detection were silently hardcoded off on companion_radio**, regardless of any setting. CAD now auto-enables whenever RX power-save (duty-cycle) is active — the noise floor isn't kept fresh during duty-cycle sleep, so a fresh hardware channel scan before TX is needed instead of the RSSI-threshold check.
|
||||||
|
- **A long message could split a multi-byte UTF-8 character in half when truncated to fit the send frame** (e.g. a Polish/accented character landing right at the cut-off), delivering a mangled trailing byte to the app. Truncation now stops at the last complete character.
|
||||||
|
- **Deleting the default "Public" channel didn't stick — it came back on every reboot.** A deleted channel is correctly left out of the saved channel file (to avoid rewriting it every save), but the Public channel was unconditionally re-added at every boot *before* the saved list was loaded, so its absence from the file never got a chance to matter. Now only seeded on a genuinely fresh device (no channel file yet) — once you've saved any channel state at all, your own list is authoritative.
|
||||||
|
- **Tools › Nodes could show a handful of blank "Unknown" entries** — always the first ones in the list, regardless of any auto-add setting. The list read from the wrong starting offset into the contact table, landing on internally-reserved bookkeeping slots instead of your real contacts; with 8 or fewer saved contacts, every row shown could end up blank, and any device with more had that same number of real contacts silently missing off the end of the list. Fixed.
|
||||||
|
- **The keyboard's multi-line text preview could show the cursor stranded on an empty line below whatever you'd just typed**, rather than right after it — most noticeable composing a short message, since it showed up as soon as the preview area had room for more lines than your text needed. The cursor now always renders on the line where the text actually ends.
|
||||||
|
- **`resetContacts()` only cleared the first few reserved bookkeeping slots, not the whole contact table**, despite its own comment describing the opposite. Only reachable today via private-key import (which reloads contacts to invalidate cached shared secrets) and left over stale entries if the reload produced fewer contacts than were already in memory. Fixed regardless, to match the stated intent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MeshCore Solo Companion Firmware v1.25
|
## MeshCore Solo Companion Firmware v1.25
|
||||||
|
|
||||||
### What's new
|
### What's new
|
||||||
|
|||||||
@@ -95,7 +95,16 @@ protected:
|
|||||||
void bootstrapRTCfromContacts();
|
void bootstrapRTCfromContacts();
|
||||||
|
|
||||||
void resetContacts() {
|
void resetContacts() {
|
||||||
memset(contacts, 0, sizeof(contacts[0])*MAX_ANON_CONTACTS); // set all to have type = ADV_TYPE_NONE(0)
|
// Clears the WHOLE table, not just the anon-reserved range -- this can run
|
||||||
|
// mid-session (CMD_IMPORT_PRIVATE_KEY re-loads contacts after a key
|
||||||
|
// change), and previously only zeroed slots 0..MAX_ANON_CONTACTS-1 despite
|
||||||
|
// the comment's intent, leaving every real contact slot
|
||||||
|
// (MAX_ANON_CONTACTS..) holding whatever it had before the reset.
|
||||||
|
// loadContacts() only overwrites as many slots as /contacts3 actually has,
|
||||||
|
// so if that reload ever yields fewer contacts than were in RAM, the
|
||||||
|
// leftover slots keep their stale pre-reset contents instead of going
|
||||||
|
// back to empty.
|
||||||
|
memset(contacts, 0, sizeof(contacts)); // set all to have type = ADV_TYPE_NONE(0)
|
||||||
num_contacts = MAX_ANON_CONTACTS; // seed the first contacts for anon requests
|
num_contacts = MAX_ANON_CONTACTS; // seed the first contacts for anon requests
|
||||||
}
|
}
|
||||||
void populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp);
|
void populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp);
|
||||||
|
|||||||
Reference in New Issue
Block a user