Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e03a9579c | ||
|
|
59dd837c0e | ||
|
|
472809b827 | ||
|
|
d7cbac0156 | ||
|
|
bc241481b1 | ||
|
|
ae62affeec | ||
|
|
fa89db17bb | ||
|
|
8e6525c4aa | ||
|
|
7aa004c1eb | ||
|
|
8e72b16693 | ||
|
|
3c68c2eed9 | ||
|
|
a43cd75bd3 | ||
|
|
41cbb45cdc | ||
|
|
434225eb01 | ||
|
|
b603ed0bbf | ||
|
|
245b45fff3 | ||
|
|
9e3a9c370d | ||
|
|
b93239bb0a | ||
|
|
fcfe329091 | ||
|
|
04545c7ba2 | ||
|
|
c4fdf374e1 | ||
|
|
3501f620a3 | ||
|
|
c51c01607d | ||
|
|
d8d92b7b71 | ||
|
|
161cd26519 | ||
|
|
dd1ec9d462 | ||
|
|
22072c5f4b | ||
|
|
ca7d82629d | ||
|
|
f2f23f8978 | ||
|
|
9358b2c549 |
Vendored
+2
-1
@@ -10,5 +10,6 @@
|
||||
},
|
||||
"[powershell]": {
|
||||
"editor.defaultFormatter": "ms-vscode.powershell"
|
||||
}
|
||||
},
|
||||
"idf.currentSetup": "C:/Espressif/frameworks/esp-idf-v5.3.1/"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/NebraHat
|
||||
# Use for 1 watt hat
|
||||
Meta:
|
||||
name: NebraHat 1W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Nebra SX1262 Pi Hat - 1W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
# CS: 8 # Newer version of MeshtasticD do not need this? If issues uncomment this line
|
||||
IRQ: 22
|
||||
Busy: 4
|
||||
Reset: 18
|
||||
RXen: 25
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -0,0 +1,20 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/NebraHat
|
||||
# Use for 2 watt hat
|
||||
Meta:
|
||||
name: NebraHat 2W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Nebra SX1262 Pi Hat - 2W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
SX126X_MAX_POWER: 8
|
||||
# CS: 8 # Newer version of MeshtasticD do not need this? If issues uncomment this line
|
||||
IRQ: 22
|
||||
Busy: 4
|
||||
Reset: 18
|
||||
RXen: 25
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -18,4 +18,4 @@ Lora:
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: 8
|
||||
# CS: 8
|
||||
|
||||
@@ -18,4 +18,4 @@ Lora:
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: 7
|
||||
# CS: 7
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/ZebraHAT
|
||||
# Use for 1 watt hat
|
||||
Meta:
|
||||
name: ZebraHat 1W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Zebra SX1262 Pi Hat - 1W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
SX126X_MAX_POWER: 18
|
||||
CS: 24
|
||||
IRQ: 22
|
||||
Busy: 27
|
||||
Reset: 17
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -0,0 +1,20 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/ZebraHAT
|
||||
# Use for 2 watt hat
|
||||
Meta:
|
||||
name: ZebraHat 2W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Zebra SX1262 Pi Hat - 2W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
SX126X_MAX_POWER: 8
|
||||
CS: 24
|
||||
IRQ: 22
|
||||
Busy: 27
|
||||
Reset: 17
|
||||
RXen: 25
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -29,6 +29,8 @@ Lora:
|
||||
- pin: 50 # GPIO1_C2 (physical 16)
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
|
||||
# pin: 7
|
||||
|
||||
@@ -29,6 +29,8 @@ Lora:
|
||||
- pin: 50 # GPIO1_C2 (physical 16)
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
|
||||
# pin: 7
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Station G3 + BQ35LORA900V1M Primary Slot
|
||||
# Board Doc: https://wiki.bqvoy.com/en/devkits/station-g3
|
||||
Meta:
|
||||
name: B&Q Station G3 + BQ35LORA900V1M Primary Slot
|
||||
support: official
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_A5 (physical 15)
|
||||
pin: 5
|
||||
gpiochip: 0
|
||||
line: 5
|
||||
Reset: # GPIO1_D1 (physical 36)
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
Busy: # GPIO0_B4 (physical 18)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: # GPIO0_B2 (physical 24)
|
||||
# pin: 10
|
||||
# gpiochip: 0
|
||||
# line: 10
|
||||
@@ -29,6 +29,8 @@ Lora:
|
||||
- pin: 13 # GPIO0_B5
|
||||
gpiochip: 0
|
||||
line: 13
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B1
|
||||
# pin: 9
|
||||
|
||||
@@ -29,6 +29,8 @@ Lora:
|
||||
- pin: 13 # GPIO0_B5
|
||||
gpiochip: 0
|
||||
line: 13
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B1
|
||||
# pin: 9
|
||||
|
||||
@@ -31,6 +31,8 @@ Lora:
|
||||
- pin: 103 # GPIO3_A7 (physical 16)
|
||||
gpiochip: 3
|
||||
line: 7
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
|
||||
# pin: 15
|
||||
|
||||
@@ -31,6 +31,8 @@ Lora:
|
||||
- pin: 103 # GPIO3_A7 (physical 16)
|
||||
gpiochip: 3
|
||||
line: 7
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
|
||||
# pin: 15
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Station G3 + BQ35LORA900V1M Primary Slot
|
||||
# Board Doc: https://wiki.bqvoy.com/en/devkits/station-g3
|
||||
Meta:
|
||||
name: B&Q Station G3 + BQ35LORA900V1M Primary Slot
|
||||
support: official
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: 22
|
||||
Reset: 16
|
||||
Busy: 24
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
#CS: 8
|
||||
@@ -1,3 +1,5 @@
|
||||
# This config works with all revisions of the Meshtoad USB radio.
|
||||
|
||||
Meta:
|
||||
name: meshtoad-e22
|
||||
support: official
|
||||
|
||||
+25
-29
@@ -219,41 +219,37 @@ with open(jsonLoc) as f:
|
||||
jsonStr = re.sub("//.*","", f.read(), flags=re.MULTILINE)
|
||||
userPrefs = json.loads(jsonStr)
|
||||
|
||||
pref_flags = []
|
||||
# Pre-process the userPrefs
|
||||
for pref in userPrefs:
|
||||
if userPrefs[pref].startswith("{"):
|
||||
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
|
||||
elif userPrefs[pref].lstrip("-").replace(".", "").isdigit():
|
||||
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
|
||||
elif userPrefs[pref] == "true" or userPrefs[pref] == "false":
|
||||
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
|
||||
elif userPrefs[pref].startswith("meshtastic_"):
|
||||
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
|
||||
# If the value is a string, we need to wrap it in quotes
|
||||
else:
|
||||
pref_flags.append("-D" + pref + "=" + env.StringifyMacro(userPrefs[pref]) + "")
|
||||
|
||||
# General options that are passed to the C and C++ compilers
|
||||
# Calculate unix epoch for current day (midnight)
|
||||
current_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
build_epoch = int(current_date.timestamp())
|
||||
|
||||
flags = [
|
||||
"-DAPP_VERSION=" + verObj["long"],
|
||||
"-DAPP_VERSION_SHORT=" + verObj["short"],
|
||||
"-DAPP_ENV=" + env.get("PIOENV"),
|
||||
"-DAPP_REPO=" + repo_owner,
|
||||
"-DBUILD_EPOCH=" + str(build_epoch),
|
||||
] + pref_flags
|
||||
cpp_defines = [
|
||||
("APP_VERSION", verObj["long"]),
|
||||
("APP_VERSION_SHORT", verObj["short"]),
|
||||
("APP_ENV", env.get("PIOENV")),
|
||||
("APP_REPO", repo_owner),
|
||||
("BUILD_EPOCH", str(build_epoch)),
|
||||
]
|
||||
|
||||
print("Using flags:")
|
||||
for flag in flags:
|
||||
print(flag)
|
||||
# Process userPrefs into CPPDEFINES
|
||||
for pref in userPrefs:
|
||||
val = userPrefs[pref]
|
||||
if val.startswith("{"):
|
||||
cpp_defines.append((pref, val))
|
||||
elif val.lstrip("-").replace(".", "").isdigit():
|
||||
cpp_defines.append((pref, val))
|
||||
elif val == "true" or val == "false":
|
||||
cpp_defines.append((pref, val))
|
||||
elif val.startswith("meshtastic_"):
|
||||
cpp_defines.append((pref, val))
|
||||
else:
|
||||
cpp_defines.append((pref, env.StringifyMacro(val)))
|
||||
|
||||
projenv.Append(
|
||||
CCFLAGS=flags,
|
||||
)
|
||||
print("Using CPPDEFINES:")
|
||||
for d in cpp_defines:
|
||||
print(f" -D{d[0]}={d[1]}")
|
||||
|
||||
projenv.Append(CPPDEFINES=cpp_defines)
|
||||
|
||||
for lb in env.GetLibBuilders():
|
||||
if lb.name == "meshtastic-device-ui":
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DARDUINO_NRF52840_T_IMPULSE_PLUS -DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [["0x239A", "0x8029"]],
|
||||
"usb_product": "T-Impulse-Plus-nRF52840",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "t-impulse-plus",
|
||||
"variants_dir": "variants",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B6"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"onboard_tools": ["jlink"],
|
||||
"svd_path": "nrf52840.svd"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "Lilygo T-Impulse-Plus-nRF52840",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"require_upload_port": true,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": [
|
||||
"jlink",
|
||||
"nrfjprog",
|
||||
"nrfutil",
|
||||
"stlink",
|
||||
"cmsis-dap",
|
||||
"blackmagic"
|
||||
]
|
||||
},
|
||||
"url": "https://www.lilygo.cc/",
|
||||
"vendor": "Lilygo"
|
||||
}
|
||||
Vendored
+1
-1
@@ -33,5 +33,5 @@ if [[ -n $GPG_KEY_ID ]]; then
|
||||
debuild -S -nc -k"$GPG_KEY_ID"
|
||||
else
|
||||
# Build the source deb without signing (forks)
|
||||
debuild -S -nc
|
||||
debuild -S -nc -us -uc
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
# LoRa Region → Preset Compatibility — Client Implementation Spec
|
||||
|
||||
**Status:** Draft for 2.8 · **Audience:** Meshtastic client app developers (Android first,
|
||||
Apple second, then web/python) · **Firmware side:** implemented in `firmware`
|
||||
(`FromRadio.region_presets`, see below).
|
||||
|
||||
> This document lives in the firmware repo while the feature is developed. It is meant to
|
||||
> graduate to `meshtastic/protobufs` (and/or the docs site) alongside the upstream protobuf
|
||||
> PR that reserves `FromRadio` field **19**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
For 2.8 the LoRa regions and modem presets were reworked. **Not every modem preset is legal
|
||||
in every region** — narrow EU SRD bands, the EU 868 "narrow" band, amateur/ham bands, and
|
||||
the 2.4 GHz band each accept only a specific subset of presets. The firmware already
|
||||
enforces this internally (it clamps or rejects illegal combinations), but until now a client
|
||||
had no way to _know_ the rules, so a user could pick an illegal region+preset pair in the UI
|
||||
and only discover the problem after the device silently corrected it.
|
||||
|
||||
This feature has the firmware **declare the legal region→preset combinations** to the client
|
||||
during the `want_config` handshake, so the client UI can constrain the preset picker to the
|
||||
valid set for the currently selected region (and warn about licensed-only bands). It is
|
||||
purely advisory metadata — the firmware remains the source of truth and still
|
||||
validates/clamps on its own.
|
||||
|
||||
---
|
||||
|
||||
## 2. Protocol additions
|
||||
|
||||
Three new messages in `meshtastic/mesh.proto`, plus one new `FromRadio` oneof variant.
|
||||
|
||||
### 2.1 `FromRadio.region_presets` (field 19)
|
||||
|
||||
```proto
|
||||
message FromRadio {
|
||||
uint32 id = 1;
|
||||
oneof payload_variant {
|
||||
// ... fields 2..18 unchanged ...
|
||||
LoRaRegionPresetMap region_presets = 19;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Messages
|
||||
|
||||
```proto
|
||||
// A distinct set of legal modem presets shared by one or more LoRa regions.
|
||||
message LoRaPresetGroup {
|
||||
repeated Config.LoRaConfig.ModemPreset presets = 1; // legal presets for this group
|
||||
Config.LoRaConfig.ModemPreset default_preset = 2; // always one of `presets`
|
||||
bool licensed_only = 3; // ham/amateur band → warn/gate
|
||||
}
|
||||
|
||||
// Associates a single LoRa region with its preset group (by index).
|
||||
message LoRaRegionPresets {
|
||||
Config.LoRaConfig.RegionCode region = 1;
|
||||
uint32 group_index = 2; // index into LoRaRegionPresetMap.groups
|
||||
}
|
||||
|
||||
// The full map, delivered grouped to fit one FromRadio packet.
|
||||
message LoRaRegionPresetMap {
|
||||
repeated LoRaPresetGroup groups = 1; // each distinct preset list
|
||||
repeated LoRaRegionPresets region_groups = 2; // every known region → a group index
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Why grouped (and the size envelope clients should respect)
|
||||
|
||||
A `FromRadio` packet is capped at **512 bytes** (`MAX_TO_FROM_RADIO_SIZE`). Most regions
|
||||
share one identical preset list (the "standard" 9-preset list), so the map is delivered
|
||||
**grouped**: `groups` holds each _distinct_ preset list once, and `region_groups` maps every
|
||||
known region to one of those groups by index. This keeps the encoded size additive
|
||||
(`groups` + `region_groups`) rather than multiplicative, well under the cap.
|
||||
|
||||
nanopb (firmware) array bounds — clients do **not** need to enforce these, but they bound
|
||||
what you can receive:
|
||||
|
||||
| field | max_count |
|
||||
| ----------------------------------- | ------------------------------------ |
|
||||
| `LoRaRegionPresetMap.groups` | 8 |
|
||||
| `LoRaRegionPresetMap.region_groups` | 38 (= number of `RegionCode` values) |
|
||||
| `LoRaPresetGroup.presets` | 11 |
|
||||
|
||||
---
|
||||
|
||||
## 3. When it is delivered
|
||||
|
||||
`region_presets` is sent **once** during the `want_config` handshake, as a single
|
||||
`FromRadio` message, in this position:
|
||||
|
||||
```text
|
||||
my_info → (deviceuiConfig) → node_info(self) → metadata → region_presets → channel… → config… → moduleConfig… → node_info(others)… → fileInfo… → config_complete_id → (live packets)
|
||||
```
|
||||
|
||||
i.e. **immediately after `metadata` and before the first `channel`**.
|
||||
|
||||
- It is included for a normal full `want_config` and for the **config-only** nonce.
|
||||
- It is **omitted** for the **nodes-only** nonce (that path skips metadata/config entirely).
|
||||
- A client must **not** assume it always arrives (see §5).
|
||||
|
||||
---
|
||||
|
||||
## 4. Decoding into a usable lookup
|
||||
|
||||
Flatten the grouped wire form into `Map<RegionCode, RegionPresetInfo>`:
|
||||
|
||||
```text
|
||||
struct RegionPresetInfo { Set<ModemPreset> presets; ModemPreset default; bool licensedOnly }
|
||||
|
||||
fun decode(map: LoRaRegionPresetMap): Map<RegionCode, RegionPresetInfo> {
|
||||
result = {}
|
||||
for (rg in map.region_groups) {
|
||||
if (rg.group_index >= map.groups.size) continue // defensive: malformed/forward data
|
||||
g = map.groups[rg.group_index]
|
||||
result[rg.region] = RegionPresetInfo(
|
||||
presets = g.presets.toSet(),
|
||||
default = g.default_preset,
|
||||
licensedOnly = g.licensed_only)
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Persist this map alongside the rest of the downloaded config so the LoRa config screen can
|
||||
read it synchronously.
|
||||
|
||||
---
|
||||
|
||||
## 5. Semantics & rules (the load-bearing part)
|
||||
|
||||
These rules are what keep the UX correct across firmware versions. Implement all of them.
|
||||
|
||||
1. **Absent region ⇒ no constraint.** If a `RegionCode` does not appear in `region_groups`,
|
||||
the client has _no_ compatibility info for it and **must not restrict** its preset
|
||||
choices (fall back to allowing the full `ModemPreset` list). This happens for a handful
|
||||
of `RegionCode` enum values that have no firmware band table entry (today: `EU_874`,
|
||||
`EU_917`, `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`).
|
||||
|
||||
2. **Absent message ⇒ no constraint.** Firmware older than 2.8 never sends `region_presets`.
|
||||
New clients **must** tolerate the message being absent entirely and keep their existing
|
||||
(unconstrained) behavior. Do not block the config screen waiting for it.
|
||||
|
||||
3. **`default_preset`** is always a member of that group's `presets`. Use it to pre-select a
|
||||
preset when the user switches to a region whose valid set does not include the currently
|
||||
selected preset (instead of leaving an illegal selection or guessing).
|
||||
|
||||
4. **`licensed_only`** marks ham/amateur bands. Surface a warning or gate (the firmware also
|
||||
requires the operator's `is_licensed` flag for these regions; coordinate the two so the
|
||||
user isn't allowed to pick a licensed band without acknowledging licensing).
|
||||
|
||||
5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions
|
||||
(`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a
|
||||
preset that belongs to a sibling's list, the firmware **swaps the region** rather than
|
||||
rejecting the preset. Consequence for clients: **do not assume the region is immutable
|
||||
across a preset change** — after an admin config write, re-read the resulting
|
||||
`LoRaConfig` and reflect the (possibly changed) region back into the UI.
|
||||
|
||||
6. **Use it as a UI guard, not a validator of truth.** The firmware still validates/clamps
|
||||
on its own. The map exists to prevent the user from _selecting_ an illegal combo; it is
|
||||
not a security or correctness boundary.
|
||||
|
||||
---
|
||||
|
||||
## 6. UI/UX recommendations
|
||||
|
||||
- In the LoRa config screen, when a region is selected, **filter/enable the modem-preset
|
||||
picker to that region's `presets`** (when `use_preset`/`use_modem_preset` is on).
|
||||
- If the current preset is not in the newly selected region's set, switch the selection to
|
||||
that region's `default_preset`.
|
||||
- Show a **licensed badge / confirmation** for regions where `licensed_only == true`.
|
||||
- If a region is absent from the map (rule §5.1) or the whole message is absent (§5.2),
|
||||
render the full preset list as before — never show an empty picker.
|
||||
|
||||
---
|
||||
|
||||
## 7. Forward / backward compatibility
|
||||
|
||||
- **Old clients, new firmware:** an unknown `FromRadio` oneof variant (field 19) is ignored
|
||||
by protobuf/nanopb decoders; the relative ordering of the known messages is unchanged, so
|
||||
existing apps are unaffected.
|
||||
- **New clients, old firmware:** message simply never arrives → treat as "no constraints"
|
||||
(§5.2).
|
||||
- **Enum growth:** new `RegionCode`/`ModemPreset` values may appear over time. Decoders
|
||||
should pass through unknown enum values rather than crashing; an unknown region in
|
||||
`region_groups` is harmless (the client just won't have a localized name for it).
|
||||
|
||||
---
|
||||
|
||||
## 8. Platform notes
|
||||
|
||||
> Verified against the `main` branch of each repo. Both have been refactored away from
|
||||
> older layouts; re-pin file paths against a specific commit if you need them durable.
|
||||
|
||||
### 8.1 Android — `meshtastic/Meshtastic-Android` (Kotlin / Compose, KMP)
|
||||
|
||||
- **Protobufs are a published Maven artifact, _not_ a submodule.** Declared in
|
||||
`gradle/libs.versions.toml` (`org.meshtastic:protobufs`, currently `2.7.25`); generated
|
||||
package is **`org.meshtastic.proto`**. **A `region_presets`-aware build requires a new
|
||||
published `org.meshtastic:protobufs` release**, then bumping that one version string.
|
||||
- **The protobufs are Wire-generated**, so the `FromRadio` oneof is **not** a
|
||||
`payloadVariantCase` enum — each arm is a **nullable field**. Handle the new variant in
|
||||
`FromRadioPacketHandlerImpl.handleFromRadio(...)`
|
||||
(`core/data/.../manager/FromRadioPacketHandlerImpl.kt`) by adding a
|
||||
`regionPresets != null -> …` arm to the existing `when { … }`, delegating to a handler
|
||||
(mirror `handleLocalMetadata` / `handleConfigComplete`).
|
||||
- **State holder:** expose the decoded map from `RadioConfigRepository` /
|
||||
`RadioConfigRepositoryImpl` as a `Flow` (mirroring `localConfigFlow`/`channelSetFlow`),
|
||||
consumed by `feature/settings/.../radio/RadioConfigViewModel.kt`.
|
||||
- **UI:** the region & preset dropdowns are `DropDownPreference`s in
|
||||
`feature/settings/.../radio/component/LoRaConfigItemList.kt` (public composable
|
||||
`LoRaConfigScreen`). Gate/filter the `ChannelOption` (preset) dropdown by the selected
|
||||
`RegionInfo`'s entry in the map.
|
||||
|
||||
### 8.2 Apple — `meshtastic/Meshtastic-Apple` (Swift / SwiftUI)
|
||||
|
||||
- **Protobufs are vendored** into a local Swift package `MeshtasticProtobufs`
|
||||
(`MeshtasticProtobufs/Sources/meshtastic/*.pb.swift`), generated from the `protobufs` git
|
||||
submodule via `scripts/gen_protos.sh`. **To get field 19:** advance the `protobufs`
|
||||
submodule, run `scripts/gen_protos.sh`, commit the regenerated `.pb.swift` + submodule
|
||||
pointer. (No published-artifact dependency — Apple can regenerate from any commit.)
|
||||
- **Dispatch:** `AccessoryManager.processFromRadio(_:)`
|
||||
(`Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift`) is a real
|
||||
`switch decodedInfo.payloadVariant { … }` — add a `.regionPresets` case, with the handler
|
||||
in `AccessoryManager+FromRadio.swift` (mirror `handleConfig` / `handleMetadata`).
|
||||
- **Persistence:** config is **SwiftData** (`@Model` entities), upserted via
|
||||
`MeshPackets`/`UpdateSwiftData.swift`. Store the decoded map (e.g. on a settings/connection
|
||||
model) so the LoRa view can read it.
|
||||
- **UI:** `Meshtastic/Views/Settings/Config/LoRaConfig.swift` (`struct LoRaConfig: View`)
|
||||
has the `Picker("Region", …)` (`RegionCodes.userSelectable`) and `Picker("Presets", …)`
|
||||
(`ModemPresets.userSelectable`, gated on `usePreset`). Filter the presets picker by the
|
||||
selected region's entry. Enums live in `Meshtastic/Enums/LoraConfigEnums.swift`.
|
||||
|
||||
### 8.3 Other clients
|
||||
|
||||
- **python (`meshtastic` / Meshtastic-python)** and **web** consume the published protobufs;
|
||||
they will see `region_presets` once their protobuf dependency includes field 19, and can
|
||||
ignore it until then (it decodes as an unknown field).
|
||||
|
||||
---
|
||||
|
||||
## 9. Reference payload (current firmware table)
|
||||
|
||||
For decoder unit tests. With the 2.8 region table, the firmware emits **6 groups**. Group
|
||||
indices are assigned in region-table order (first region to use a profile creates its group),
|
||||
so they are stable as listed here:
|
||||
|
||||
| group_index | default_preset | licensed_only | presets |
|
||||
| ----------------------- | -------------- | ------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO |
|
||||
| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE |
|
||||
| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW |
|
||||
| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW |
|
||||
| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW |
|
||||
| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW |
|
||||
|
||||
`region_groups` (region → group_index):
|
||||
|
||||
| group | regions |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 |
|
||||
| 1 | EU_868 |
|
||||
| 2 | EU_866 |
|
||||
| 3 | EU_N_868 |
|
||||
| 4 | ITU1_2M, ITU2_2M, ITU3_2M |
|
||||
| 5 | ITU2_125CM |
|
||||
|
||||
> Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups
|
||||
> because they differ in `licensed_only`. Decoders must key on the group, not on the preset
|
||||
> list, to preserve the licensing flag.
|
||||
>
|
||||
> Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`,
|
||||
> `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`.
|
||||
|
||||
This table is generated from the firmware's region table at runtime; treat the firmware as
|
||||
authoritative and these values as the expected snapshot for the 2.8 table.
|
||||
@@ -0,0 +1,456 @@
|
||||
# NextHop direct-message reliability on dense meshes — findings & plan
|
||||
|
||||
**Status:** Implemented — mitigations and tests in `PR3-tmm-nexthop`
|
||||
**Date:** 2026-06-13
|
||||
**Area:** `src/mesh` router stack (`NextHopRouter`, `ReliableRouter`, `FloodingRouter`, `Router`, `NodeDB`, `PacketHistory`)
|
||||
**Constraint:** No over-the-air / wire-format changes — `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only.
|
||||
|
||||
This document captures the analysis and the proposed mitigations so the work can be
|
||||
continued on this branch by anyone. It is intentionally code-grounded (file:line
|
||||
references throughout) and standalone — you should not need the original investigation
|
||||
context to pick it up.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
NextHop routing for direct messages (DMs) is unreliable on dense meshes. The headline
|
||||
cause is the **birthday problem**: `next_hop` and `relay_node` are each a single byte
|
||||
(the last byte of a 32-bit node number), so on a mesh of N nodes the probability that
|
||||
two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50–100. But
|
||||
there are **other, equally important issues**: that single byte is trusted blindly at
|
||||
five different code sites, learned routes **never decay**, routes are learned from the
|
||||
**reverse (ACK) path** (asymmetric-link hazard), and collision-driven spurious
|
||||
rebroadcasts **amplify congestion** exactly when the mesh is busy.
|
||||
|
||||
Because we can't widen the on-wire field, the fix is **interpretation-side** ("don't
|
||||
trust a byte that doesn't map to a unique reachable neighbor — flood instead") plus
|
||||
**recovery-side** ("decay stale/failing routes so they get re-discovered"). Four
|
||||
mitigations, M1–M4, all RAM-only. The net behavioral change: on dense/mobile meshes a
|
||||
DM that today silently misroutes or black-holes instead falls back to managed flooding
|
||||
(which still delivers) and re-learns a fresh route quickly. Sparse-mesh happy paths are
|
||||
unchanged.
|
||||
|
||||
---
|
||||
|
||||
## How NextHop routing works today (mechanics)
|
||||
|
||||
Inheritance chain: `Router` → `FloodingRouter` → `NextHopRouter` → `ReliableRouter`.
|
||||
|
||||
**The single-byte identifiers.** Both routing bytes come from one helper:
|
||||
|
||||
```cpp
|
||||
// src/mesh/NodeDB.h:255
|
||||
uint8_t getLastByteOfNodeNum(NodeNum num) { return (uint8_t)((num & 0xFF) ? (num & 0xFF) : 0xFF); }
|
||||
```
|
||||
|
||||
It projects a 32-bit node number onto 255 values (`0x00` is remapped to `0xFF` so it
|
||||
never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELAY_NODE`,
|
||||
`src/mesh/MeshTypes.h:44-46`). `next_hop` and `relay_node` in the packet header are
|
||||
`uint8_t` (`src/mesh/mesh.pb.h`, comments "Last byte of the node number…"). The learned
|
||||
route stored per destination, `meshtastic_NodeInfoLite::next_hop`, is also a single byte
|
||||
(`src/mesh/generated/meshtastic/deviceonly.pb.h:83`).
|
||||
|
||||
**Sending a DM** — `NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`):
|
||||
|
||||
1. `p->relay_node = getLastByteOfNodeNum(getNodeNum())` (mark ourselves as relayer).
|
||||
2. `p->next_hop = getNextHop(p->to, p->relay_node)` (`src/mesh/NextHopRouter.cpp:192`):
|
||||
look up `nodeDB->getMeshNode(to)->next_hop`; return it unless it equals the relayer
|
||||
byte; otherwise `NO_NEXT_HOP_PREFERENCE` (→ flood).
|
||||
|
||||
**Relaying** — `NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`):
|
||||
rebroadcast iff `next_hop == NO_NEXT_HOP_PREFERENCE` (flood) **or**
|
||||
`next_hop == getLastByteOfNodeNum(getNodeNum())` (we are the addressed next hop)
|
||||
(`:147`). Each node only ever compares against **its own** byte.
|
||||
|
||||
**Learning** — `NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an
|
||||
ACK/reply (`request_id`/`reply_id` set), if the relayer of the ACK was also a relayer of
|
||||
the original packet (validated via `PacketHistory::checkRelayers`), set
|
||||
`origTx->next_hop = p->relay_node` (`:114`). I.e. the **forward** next-hop is learned
|
||||
from the **reverse** path's relayer.
|
||||
|
||||
**Retransmission / fallback** — `NextHopRouter::doRetransmissions`
|
||||
(`src/mesh/NextHopRouter.cpp:284`). Budgets: `NUM_RELIABLE_RETX=3` (originator: initial
|
||||
|
||||
- 2 retries), `NUM_INTERMEDIATE_RETX=2` (relayer: 1 retry). On the **last** retry
|
||||
(`numRetransmissions==1`) it resets `next_hop` to `NO_NEXT_HOP_PREFERENCE` on the packet
|
||||
**and** clears `sentTo->next_hop` in NodeDB, then floods (`:313-321`). Retransmit timing
|
||||
comes from `iface->getRetransmissionMsec`, whose contention window **grows with channel
|
||||
utilization** (`src/mesh/RadioInterface.cpp` `getTxDelayMsec`/`getTxDelayMsecWeighted`).
|
||||
|
||||
**Dedup / relayer history** — `PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded
|
||||
ring (`PACKETHISTORY_MAX = max(MAX_NUM_NODES*2, 100)`, 20 B/record) keyed by
|
||||
`(sender,id)`, tracking up to `NUM_RELAYERS=6` relayer **bytes** per packet in
|
||||
`relayed_by[]`. `wasRelayer` (`:490`) and `checkRelayers` (`:517`) match bytes against
|
||||
that array.
|
||||
|
||||
---
|
||||
|
||||
## Root-cause analysis
|
||||
|
||||
### 1. The single byte is trusted blindly at five sites (the birthday problem)
|
||||
|
||||
| # | Site | File:line | Failure on collision |
|
||||
| --- | -------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Rebroadcast self-check | `NextHopRouter.cpp:147` | A remote "impostor" node sharing the intended next-hop's byte also rebroadcasts → wasted airtime / congestion. |
|
||||
| 2 | Route learning | `NextHopRouter.cpp:111-114` | Stores an ambiguous byte as the route; later resolves to the wrong physical node. |
|
||||
| 3 | Relayer validation | `PacketHistory.cpp:490-538` | `wasRelayer(byte)` returns true for the wrong node → mis-validated ACK / mis-learn. |
|
||||
| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins — non-deterministic; can preserve hops for the wrong relay (hop leak). |
|
||||
| 5 | Send-path lookup | `NextHopRouter.cpp:192-207` | Emits a byte that may address the wrong node; no check it still maps to a reachable neighbor. |
|
||||
|
||||
Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19 nodes,
|
||||
|
||||
> 99% by ~75 nodes. Dense meshes are squarely in the "always colliding" regime.
|
||||
|
||||
### 2. Stale routes never decay
|
||||
|
||||
The learned `next_hop` byte is cleared only on the **current DM's** last retry
|
||||
(`NextHopRouter.cpp:313-321`). A route learned hours ago that has since gone dead is
|
||||
still trusted on the **next** DM's first attempt — which on a congested mesh is also the
|
||||
slowest attempt. Result: silent black-hole at a dead hop until the retransmission budget
|
||||
drains, then a late flood. Intermediate nodes hold stale routes indefinitely.
|
||||
|
||||
### 3. Reverse-path (asymmetric-link) learning
|
||||
|
||||
`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) — the
|
||||
**reverse** direction. RF links are frequently asymmetric, so the best reverse relay can
|
||||
be a poor forward relay. Worse, the next reverse ACK immediately re-learns the same bad
|
||||
hop, so the route **flaps** back to the bad value even after a failure reset.
|
||||
|
||||
### 4. Congestion amplification
|
||||
|
||||
Collision-driven impostor rebroadcasts (issue 1) add airtime; the contention window
|
||||
grows with channel utilization, so retransmit intervals **lengthen** exactly when the
|
||||
mesh is busy. The 3-try reliable budget can then expire before delivery. On dense
|
||||
meshes, efficiency _is_ reliability.
|
||||
|
||||
### Note: pubkey-derived node numbers (develop / 2.8) — does not change the plan
|
||||
|
||||
develop derives the node number from the public key:
|
||||
`my_node_num = crc32Buffer(public_key)` (`src/mesh/NodeDB.cpp:481`), re-derived on key
|
||||
change in `createNewIdentity()` (`src/mesh/NodeDB.cpp:3113`). This **reinforces** the
|
||||
plan rather than changing it:
|
||||
|
||||
- **Birthday problem unchanged and now textbook-exact.** CRC32 mixes well → the last
|
||||
byte is uniformly distributed over 256 values. Derivation adds no wire bits.
|
||||
- **Node numbers are now immutable / identity-bound.** Pre-2.8 `pickNewNodeNum()` could
|
||||
renumber a node to dodge a conflict; now the number is fixed by the key, so a last-byte
|
||||
collision **cannot be resolved operationally by renumbering** → M1/M2/M3 become _more_
|
||||
necessary.
|
||||
- **Resolver gets cleaner inputs.** Stable node numbers keep a learned byte bound to one
|
||||
identity (good for M3 freshness). `createNewIdentity()` retires the old entry by marking
|
||||
it **ignored** and clearing its pubkey (`src/mesh/NodeDB.cpp:3123-3125`), which M1's
|
||||
candidate gate already skips — so key rotation can't pollute resolution.
|
||||
- **No wire-free disambiguation unlocked.** A receiver still gets only 1 byte and cannot
|
||||
recover which full node number a colliding value meant — so "detect ambiguity → flood"
|
||||
remains the correct strategy.
|
||||
|
||||
---
|
||||
|
||||
## Proposed mitigations
|
||||
|
||||
Key insight for all of M1/M2: **a 1-byte ID only needs to be unique among a node's
|
||||
direct neighbors / plausible relays, not the whole mesh.** That candidate set is small
|
||||
(typically 5–15), so a byte usually resolves unambiguously there; when it doesn't, fall
|
||||
back to the _safe_ behavior (flood / decrement / don't-learn).
|
||||
|
||||
### M1 — Ambiguity-aware last-byte resolution (new NodeDB primitive)
|
||||
|
||||
New types + methods in `src/mesh/NodeDB.h` (near line 255) / `src/mesh/NodeDB.cpp`
|
||||
(near `getMeshNode`, ~2936):
|
||||
|
||||
```cpp
|
||||
enum class LastByteResolution : uint8_t { None, Unique, Ambiguous };
|
||||
struct ResolvedNode { LastByteResolution status = LastByteResolution::None; NodeNum num = 0; };
|
||||
|
||||
// Resolve a single on-wire last-byte to a unique full NodeNum among relevant candidates.
|
||||
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
|
||||
// Convenience: true iff exactly one relevant candidate (Ambiguous and None both -> false = SAFE).
|
||||
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
|
||||
```
|
||||
|
||||
- **One linear pass** over `meshNodes`, reusing `getNumMeshNodes()`/`getMeshNodeByIndex()`,
|
||||
the bitfield helpers (`nodeInfoLiteIsFavorite/HasUser/IsIgnored`), `sinceLastSeen()`,
|
||||
and `getLastByteOfNodeNum()`. **Early-exit** on the 2nd match (return `Ambiguous`).
|
||||
- **Guard:** `if (lastByte == 0) return {None, 0};` (covers `NO_RELAY_NODE` / MQTT-invalid).
|
||||
- **Candidate gate** (skip): `num == getNodeNum()` (never resolve to ourselves), `num == 0`,
|
||||
`num == NODENUM_BROADCAST`, `nodeInfoLiteIsIgnored`. Then match
|
||||
`getLastByteOfNodeNum(node->num) == lastByte` (cheapest test last, mirroring `Router.cpp:119`).
|
||||
- **Relevance gate:**
|
||||
- `requireDirectNeighbor == true` (strict, for SEND): `has_hops_away && hops_away == 0`
|
||||
**and** `sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS`.
|
||||
- `requireDirectNeighbor == false` (lenient, for learn / hop-preserve): accept if direct
|
||||
neighbor **or** `nodeInfoLiteIsFavorite` **or** role ∈ {ROUTER, ROUTER_LATE, CLIENT_BASE}.
|
||||
- **No tie-break.** A collision must return `Ambiguous` — picking "best SNR" would
|
||||
resurrect the silent-misroute bug. (Deliberate non-goal; document in code.)
|
||||
|
||||
New constant in `src/mesh/MeshTypes.h` (near line 44):
|
||||
`#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2)` (mirrors `NUM_ONLINE_SECS`).
|
||||
|
||||
### M2 — Only route on bytes that resolve to a unique, reachable neighbor
|
||||
|
||||
In `getNextHop` (`src/mesh/NextHopRouter.cpp:192-207`), after the existing split-horizon
|
||||
check (`node->next_hop != relay_node`), require the stored byte to resolve to a **unique,
|
||||
currently-fresh direct neighbor**; else flood:
|
||||
|
||||
```cpp
|
||||
if (node->next_hop != relay_node) {
|
||||
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
|
||||
if (r.status == LastByteResolution::Unique) return node->next_hop;
|
||||
LOG_WARN("Next hop 0x%x for 0x%x %s -> flood", node->next_hop, to,
|
||||
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "no longer a neighbor");
|
||||
return std::nullopt;
|
||||
}
|
||||
```
|
||||
|
||||
This self-heals when a neighbor goes away (unicast-into-a-void becomes a flood). It
|
||||
applies to originating, relaying, and retrying, since all route through `getNextHop`.
|
||||
|
||||
Apply M1's safe fallback at the other sites:
|
||||
|
||||
- **Learning** (`NextHopRouter.cpp:111-114`): gate `origTx->next_hop = p->relay_node` on
|
||||
`resolveUniqueLastByte(p->relay_node, /*direct=*/false)`. Ambiguous/unknown → don't
|
||||
learn (leave route unset → flood).
|
||||
- **Favorite-router preservation** (`Router.cpp:120-145`): replace the "first match wins"
|
||||
loop with `resolveUniqueLastByte(p->relay_node, /*direct=*/false)` + a re-check that the
|
||||
resolved node is favorite/has_user/router. Ambiguous/none/not-favorite → **decrement**
|
||||
(safe). Net: removes one full DB scan, adds one resolver scan (wash).
|
||||
|
||||
**Left unchanged, by design (document why in code):**
|
||||
|
||||
- **Site 1** rebroadcast self-check (`NextHopRouter.cpp:147`) and self-identity checks
|
||||
(`ReliableRouter.cpp:127`): a node matches its **own** byte — no DB resolution helps. A
|
||||
remote impostor sharing the intended next-hop's byte will still rebroadcast. M1/M2
|
||||
shrink the blast radius by reducing how often an ambiguous byte is ever stored or
|
||||
originated; a true fix needs a wider field (out of scope). **This is the one residual
|
||||
the plan cannot fully close.**
|
||||
- **Site 3** `wasRelayer`/`checkRelayers` (`PacketHistory.cpp:490-538`): intentionally
|
||||
byte-domain (both sides are on-wire bytes); the consumer (learning) is now hardened.
|
||||
Add a one-line comment; do not change.
|
||||
|
||||
### M3 — Route freshness / failure memory (RAM table on NextHopRouter)
|
||||
|
||||
A bounded, LRU-evicted table keyed by destination, mirroring `PacketHistory`'s
|
||||
reuse-oldest discipline (not an unbounded map) to cap RAM.
|
||||
|
||||
`src/mesh/NextHopRouter.h` (near `pending`, line 99):
|
||||
|
||||
```cpp
|
||||
struct RouteHealth {
|
||||
NodeNum dest = 0; // 0 == empty slot
|
||||
uint32_t learnedAtMsec = 0; // millis() at last (re)learn; rollover-aware
|
||||
uint8_t consecutiveFailures = 0;
|
||||
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; // byte this health refers to
|
||||
};
|
||||
static constexpr uint8_t ROUTE_HEALTH_MAX = 32; // ~384B; drop to 16 if RAM-tight
|
||||
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
|
||||
// Helpers take `now` (pure/testable): findRouteHealth, getOrAllocRouteHealth,
|
||||
// noteRouteLearned, noteRouteSuccess, noteRouteFailure, isRouteStale, clearRouteHealth
|
||||
```
|
||||
|
||||
Policy:
|
||||
|
||||
| Constant | Value | Rationale |
|
||||
| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ROUTE_TTL_MSEC` | 30 min | Survives a normal conversation; re-discovers a moved node within a telemetry interval. |
|
||||
| `ROUTE_FAILURE_THRESHOLD` | 3 | 1–2 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). |
|
||||
|
||||
`isRouteStale(h, now)` = `(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC || h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD`.
|
||||
All age math uses **unsigned subtraction** (rollover-safe, matching
|
||||
`PacketHistory.cpp:364`); treat `learnedAtMsec == 0` as "set now".
|
||||
|
||||
Wiring (as built — `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`):
|
||||
|
||||
- `getNextHop`: if a health record matches the stored byte and `isRouteStale`, clear
|
||||
`node->next_hop` (NodeDB) **and** `clearRouteHealth`, return `nullopt` (flood). No
|
||||
record yet (cold path, first DM after boot) → trust NodeDB, but the M2 strict-neighbor
|
||||
gate still applies.
|
||||
- `sniffReceived` learn: gate the write through `resolveUniqueLastByte` (M2), then
|
||||
`noteRouteLearned(p->from, p->relay_node, millis())` — resets `consecutiveFailures`
|
||||
**only if the hop changed** (anti-flap for asymmetric re-learn); otherwise just refreshes
|
||||
`learnedAtMsec`. (No success signal is taken on the intermediate reverse-pass: an ACK
|
||||
merely passing through us is not proof that _we_ delivered, and resetting failures there
|
||||
would reintroduce the asymmetric flap.)
|
||||
- `doRetransmissions`: on the last-retransmission branch (`numRetransmissions == 1`, the
|
||||
point a directed delivery has gone un-ACKed for both originator and intermediate) →
|
||||
`noteRouteFailure(to)`, then the existing NodeDB `next_hop` reset + flood. We deliberately
|
||||
do **not** `clearRouteHealth` here: keeping the record is what lets the failure count
|
||||
accumulate across DMs so a flapping reverse-path-relearned dead hop eventually ages out.
|
||||
- `ReliableRouter::sniffReceived` ACK path → `noteRouteSuccess(getFrom(p), millis())`
|
||||
(an end-to-end ACK addressed to us is genuine forward-delivery proof; clears failures and
|
||||
refreshes freshness). `noteRouteSuccess`/`noteRouteFailure` are no-ops when no record
|
||||
exists, so flood-only destinations never pollute the table.
|
||||
|
||||
**Reconciliation (no double-handling):** `doRetransmissions` owns _in-flight_ failure of
|
||||
the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure counter);
|
||||
`getNextHop` owns _between-DM_ staleness (TTL or failure-threshold → flood + clear). The
|
||||
only place that erases a health record is the `getNextHop` decay path; the retransmission
|
||||
path leaves it intact so the counter survives a reverse-path re-learn.
|
||||
|
||||
### M4 — Earlier flood for unverified routes (gated, off by default)
|
||||
|
||||
Compile-gated so healthy sparse meshes are untouched. **Default is off** — the define
|
||||
lives in `NextHopRouter.h` and must be flipped to measure:
|
||||
`#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 1`.
|
||||
|
||||
In `doRetransmissions`, the directed-retry `else` branch: if the route is **not verified**
|
||||
(`!findRouteHealth(to) || consecutiveFailures > 0 || isRouteStale`), reset `next_hop` and
|
||||
flood on this attempt instead of spending another directed try. A **verified** route
|
||||
(record present, `consecutiveFailures == 0`, within TTL — i.e. recently ACKed) takes the
|
||||
unchanged directed-retry path, so the sparse-mesh happy path is untouched. Trade-off:
|
||||
airtime ↔ latency; the gate ensures we never pay the flood cost on a proven route, only on
|
||||
one we already distrust. Off by default precisely so it can be A/B-measured on the
|
||||
simulator before broad enable.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/mesh/MeshTypes.h` | `NEXTHOP_NEIGHBOR_FRESH_SECS`, `ROUTE_TTL_MSEC`, `ROUTE_FAILURE_THRESHOLD`, `NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED` |
|
||||
| `src/mesh/NodeDB.h` / `src/mesh/NodeDB.cpp` | `LastByteResolution`, `ResolvedNode`, `resolveLastByte`, `resolveUniqueLastByte` |
|
||||
| `src/mesh/NextHopRouter.h` | `RouteHealth` + array + helpers; `#ifdef PIO_UNIT_TESTING public:` for helpers and `getNextHop` |
|
||||
| `src/mesh/NextHopRouter.cpp` | `getNextHop` (M2 gate + M3 decay); `sniffReceived` (learn gate + health seed + success); `doRetransmissions` (failure counting + M4); comment site 1 |
|
||||
| `src/mesh/Router.cpp` | `shouldDecrementHopLimit` → resolver + favorite/router re-check |
|
||||
| `src/mesh/ReliableRouter.cpp` | ACK path → `noteRouteSuccess` |
|
||||
| `test/test_nexthop_routing/test_main.cpp` | **new** unit suite (auto-built under `[env:native]`) |
|
||||
|
||||
**Reuse, don't reinvent:** `getLastByteOfNodeNum`, `sinceLastSeen`, the bitfield helpers,
|
||||
`getMeshNodeByIndex`/`getNumMeshNodes`, PacketHistory's reuse-oldest eviction shape, and
|
||||
`MockNodeDB::addTestNode` (from `test/test_hop_scaling/test_main.cpp`).
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **`0x00`↔`0xFF` projection:** the resolver compares via `getLastByteOfNodeNum` on both
|
||||
sides, so a `…00` node and a `…FF` node correctly collide on `0xFF` → `Ambiguous`. Test
|
||||
explicitly.
|
||||
- **MQTT packets:** `relay_node`/`next_hop` are forced invalid when `hop_start == 0`
|
||||
(`src/mesh/RadioLibInterface.cpp:603-605`) → byte 0 → resolver `None` → don't learn
|
||||
(correct).
|
||||
- **`has_hops_away == false`** nodes are excluded from the strict gate (never fabricate a
|
||||
Unique neighbor for M2); admitted to the lenient gate only via favorite/router role.
|
||||
Safe; self-corrects once `hops_away` is learned.
|
||||
- **Self / broadcast:** the resolver skips `getNodeNum()` and `NODENUM_BROADCAST`;
|
||||
`getNextHop` already early-returns for broadcast.
|
||||
- **Perf:** M2 adds one O(N) resolver scan per directed send/relay (early-exit on the 2nd
|
||||
match), cheaper than the crypto already on that path; site-4 is a wash. If ever hot, a
|
||||
future 256-entry last-byte index is the optimization (not now — RAM).
|
||||
|
||||
---
|
||||
|
||||
## Verification (all tiers)
|
||||
|
||||
### 1. Native unit tests — new `test/test_nexthop_routing/test_main.cpp`
|
||||
|
||||
`pio test -e native -f test_nexthop_routing`; on macOS `./bin/test-native-docker.sh -f test_nexthop_routing`.
|
||||
Design the RouteHealth helpers to take `now` as a parameter so the 30-min TTL logic is
|
||||
testable without a clock mock.
|
||||
|
||||
- **Resolver:** None / Unique / **Ambiguous (birthday collision)** / strict-excludes-stale /
|
||||
strict-excludes-far / lenient-includes-favorite-router / lenient-collision / skips-self /
|
||||
skips-ignored / **`0x00`↔`0xFF` collision** / early-exit.
|
||||
- **`getNextHop`:** unique→byte, **ambiguous→nullopt**, stale-neighbor→nullopt,
|
||||
split-horizon (relay==next_hop)→nullopt, broadcast→nullopt.
|
||||
- **RouteHealth:** TTL boundary, **rollover** (learn near `0xFFFFFFFF`, check after wrap),
|
||||
failure threshold, success-resets, **re-learn-same-hop keeps fails (anti-flap)**,
|
||||
re-learn-new-hop resets, LRU eviction bound, clear.
|
||||
- **Site-4:** preserve on unique favorite router; **decrement on two colliding favorites**;
|
||||
decrement when the resolved node is not a favorite.
|
||||
- **Sparse-mesh regression:** all-distinct last bytes → every resolve Unique, `getNextHop`
|
||||
returns the stored byte unchanged (proves no happy-path change).
|
||||
- Re-run `test_packet_history` and `test_hop_scaling` for no regression.
|
||||
|
||||
### 2. portduino SimRadio simulator
|
||||
|
||||
`pio run -e native && ./bin/test-simulator.sh`. Best vehicle for the **intermediate-node**
|
||||
path the 2-device bench can't reach. Line topology A — B — C: establish A→C (B learns a
|
||||
directed route), stop B relaying that dest, confirm A re-discovers via flood within
|
||||
`ROUTE_FAILURE_THRESHOLD` and that B's `noteRouteFailure`/`clearRouteHealth` fires (visible
|
||||
via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this to A/B M4
|
||||
(attempts-to-delivery, total airtime).
|
||||
|
||||
### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop)
|
||||
|
||||
- `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py` — **the multi-hop validator
|
||||
for this work** (added on this branch). Self-discovers an A — relay — C line, asserts a
|
||||
directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts
|
||||
delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true
|
||||
multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range).
|
||||
- `mcp-server/tests/mesh/test_direct_with_ack.py` — happy-path regression: a fresh/unique
|
||||
route still delivers a want_ack DM on the first/second try (M4's gate must keep this
|
||||
green).
|
||||
- `mcp-server/tests/mesh/test_peer_offline_recovery.py` — 2-device recovery validator: peer
|
||||
off mid-conversation then back. Must stay green and ideally recover in fewer attempts.
|
||||
|
||||
### 4. Build / format sanity
|
||||
|
||||
native-macos **and** Docker both ways; trunk clang-format@16.0.3; a release `pio run` to
|
||||
confirm the `#ifdef PIO_UNIT_TESTING` visibility widening does **not** leak into
|
||||
production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B table.
|
||||
|
||||
---
|
||||
|
||||
## Verification status (as built on `nexthop-redux`)
|
||||
|
||||
| Tier | What ran | Result |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- | ------------------- |
|
||||
| Unit (native-macos) | `test_nexthop_routing` (31 cases) | ✅ 31/31 |
|
||||
| Unit (Docker / Linux, CI parity) | `test_nexthop_routing` | ✅ 31/31 |
|
||||
| Regression | `test_packet_history`, `test_hop_scaling`, `test_mqtt`, `test_traffic_management` | ✅ 105/105 |
|
||||
| Build | `pio run -e native-macos` (M4 off) and with `-DNEXTHOP_EARLY_FLOOD_ON_UNVERIFIED=1` | ✅ both link |
|
||||
| Format | trunk `clang-format@16.0.3` | ✅ no issues |
|
||||
| Simulator (CI `simulator-tests`) | `meshtasticd -s` + `meshtastic.test.testSimulator()` on native-macos | ✅ exit 0, no crash |
|
||||
|
||||
**Pending (environment-blocked, not yet run):**
|
||||
|
||||
- **Multi-hop A–B–C recovery sim** — the `simulator/` broker hub is **not git-tracked**
|
||||
(only stale local `.pyc`), and two `meshtasticd -s` instances can't hear each other
|
||||
without it. The intermediate-node failure-count path and the M4 A/B therefore have unit
|
||||
coverage of their logic but no end-to-end multi-node run yet.
|
||||
- **Hardware / multi-hop tier** — a committable bench test now exists:
|
||||
`mcp-server/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real
|
||||
multi-hop pair (A — relay — C), asserts a directed DM is delivered across the relay, and
|
||||
asserts delivery recovers after the relay is power-cycled (the M3 path). It
|
||||
`pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF
|
||||
range (≥3 roles via `--hub-profile`), so it's safe to commit and only asserts when the
|
||||
NextHop path is genuinely exercised. Collected + verified to skip without hardware;
|
||||
not yet run on a bench. `test_direct_with_ack.py` / `test_peer_offline_recovery.py`
|
||||
remain the 2-device happy-path/recovery regressions.
|
||||
|
||||
---
|
||||
|
||||
## Risks & limitations
|
||||
|
||||
- **Site-1 impostor rebroadcast** is unfixable without a wider field — documented; M1/M2
|
||||
only shrink its frequency.
|
||||
- **Dense meshes flood DMs more often** — intended (a flooded DM arrives; a mis-unicast one
|
||||
black-holes). Call out in the PR so reviewers expect a slightly higher DM flood rate on
|
||||
very dense meshes.
|
||||
- **M4 airtime** if the gate is too loose → default conservative + compile-gated +
|
||||
simulator A/B before broad enable.
|
||||
- **RAM** ~384 B (32 slots); 16 slots (~192 B) with graceful LRU degradation if tight.
|
||||
- **Asymmetric flap** not fully closed (a _new_ bad hop resets the counter); the TTL
|
||||
backstop bounds it. Per-hop failure history is future work (more RAM).
|
||||
|
||||
---
|
||||
|
||||
## How to continue this work (commit sequencing)
|
||||
|
||||
Each step is independently testable; land them as separate commits.
|
||||
|
||||
1. **M1 resolver + unit tests** — `NodeDB` only; no behavior change until wired. Lands the
|
||||
`resolveLastByte`/`resolveUniqueLastByte` primitive and its full unit-test matrix.
|
||||
2. **M2 + wiring + tests** — `getNextHop` strict gate, learning gate, favorite-router
|
||||
preservation rewrite. Adds the `getNextHop` and site-4 tests.
|
||||
3. **M3 health table + decay + tests** — RAM `RouteHealth` table, decay-on-read, failure/
|
||||
success accounting, reconciliation with the existing last-retry reset. Adds the
|
||||
route-health unit tests and the simulator recovery check.
|
||||
4. **M4 gated tuning** — early-flood-on-unverified behind the compile flag; simulator A/B
|
||||
and hardware regression.
|
||||
|
||||
Reference plan (with the same content) was developed at
|
||||
`~/.claude/plans/nexthop-routing-for-direct-lexical-shell.md` on the author's machine; this
|
||||
in-repo doc is the canonical handoff copy.
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Multi-hop NextHop directed-message delivery + relay-recovery (bench test).
|
||||
|
||||
This is the hardware/tier-3 validator for the NextHop DM reliability work
|
||||
(see `docs/nexthop-routing-reliability.md`). The unit suite
|
||||
`test/test_nexthop_routing` covers the routing *logic* exhaustively; this test
|
||||
covers the *end-to-end* multi-hop behavior that only a real (or RF-separated)
|
||||
mesh exercises:
|
||||
|
||||
* a directed DM that must traverse a relay is delivered (next_hop routing +
|
||||
the M1/M2 ambiguity gate + M3 route learning all engage), and
|
||||
* when the established relay drops and returns, delivery recovers rather than
|
||||
black-holing (the M3 stale-route decay / re-learn path).
|
||||
|
||||
TOPOLOGY REQUIREMENT — why this usually SKIPS:
|
||||
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
|
||||
Three co-located radios all hear each other, so A→C is a single direct hop and
|
||||
next_hop never engages. To run this test the bench must be a *line* — A — B — C
|
||||
— with the endpoints out of each other's direct RF range (physical distance or
|
||||
attenuators). The `multihop_topology` fixture detects this automatically: it
|
||||
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
|
||||
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
|
||||
file is safe to commit and run anywhere — it only *asserts* when the topology
|
||||
genuinely requires a relay.
|
||||
|
||||
REQUIREMENTS:
|
||||
* ≥3 baked devices. The default hub profile is 2 roles (nrf52, esp32s3); add a
|
||||
third via `--hub-profile=path/to/hub.yaml` (see conftest `hub_profile`).
|
||||
* The relay-recovery test additionally needs uhubctl + a power-controllable
|
||||
relay port (same gate the other power tests use).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.connection import connect
|
||||
from tests import _power
|
||||
from tests._port_discovery import resolve_port_by_role
|
||||
|
||||
from ._receive import ReceiveCollector, nudge_nodeinfo, nudge_nodeinfo_port
|
||||
|
||||
|
||||
def _hops_away(rec: dict[str, Any]) -> int | None:
|
||||
"""Read a node's hop distance from a `nodesByNum` entry, tolerating either
|
||||
the camelCase (`hopsAway`) or snake_case (`hops_away`) spelling depending on
|
||||
the meshtastic-python version."""
|
||||
for key in ("hopsAway", "hops_away"):
|
||||
val = rec.get(key)
|
||||
if isinstance(val, int):
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
|
||||
"""Flood a fresh NodeInfo from every node so the whole mesh (including
|
||||
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
|
||||
distances. Best-effort — a single node failing to nudge shouldn't abort."""
|
||||
for _ in range(rounds):
|
||||
for port in ports:
|
||||
try:
|
||||
nudge_nodeinfo_port(port)
|
||||
except Exception: # noqa: BLE001 — warmup is best-effort
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
time.sleep(settle)
|
||||
|
||||
|
||||
def _wait_for_pubkey(
|
||||
tx_iface: Any, rx_num: int, rx_port: str, deadline_s: float = 90.0
|
||||
) -> bool:
|
||||
"""Block until `tx_iface` holds `rx_num`'s public key (directed PKI sends
|
||||
NAK without it). Re-nudges both sides periodically; multi-hop warmup is
|
||||
slower than the 2-device case because NodeInfo must be relayed, hence the
|
||||
longer default deadline."""
|
||||
deadline = time.monotonic() + deadline_s
|
||||
last_nudge = time.monotonic()
|
||||
while time.monotonic() < deadline:
|
||||
rec = (tx_iface.nodesByNum or {}).get(rx_num, {})
|
||||
if rec.get("user", {}).get("publicKey"):
|
||||
return True
|
||||
if time.monotonic() - last_nudge > 20.0:
|
||||
nudge_nodeinfo_port(rx_port)
|
||||
nudge_nodeinfo(tx_iface)
|
||||
last_nudge = time.monotonic()
|
||||
time.sleep(1.0)
|
||||
return False
|
||||
|
||||
|
||||
def _traceroute_route(tx_port: str, rx_num: int, rx_port: str) -> list[int] | None:
|
||||
"""Run a traceroute TX→RX and return the forward `route` (list of relay node
|
||||
numbers), or None if it couldn't be obtained. Mirrors test_traceroute's
|
||||
request/PKI/retry pattern."""
|
||||
from meshtastic.mesh_interface import MeshInterface
|
||||
|
||||
with ReceiveCollector(tx_port, topic="meshtastic.receive.traceroute") as tx:
|
||||
nudge_nodeinfo_port(rx_port)
|
||||
tx.broadcast_nodeinfo_ping()
|
||||
if not _wait_for_pubkey(tx._iface, rx_num, rx_port, 60.0):
|
||||
return None
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
tx._iface.sendTraceRoute(dest=rx_num, hopLimit=5)
|
||||
break
|
||||
except MeshInterface.MeshInterfaceError:
|
||||
time.sleep(5.0)
|
||||
else:
|
||||
return None
|
||||
pkt = tx.wait_for(lambda p: p.get("from") == rx_num, timeout=8.0)
|
||||
if pkt is None:
|
||||
return None
|
||||
tr = (pkt.get("decoded", {}) or {}).get("traceroute") or {}
|
||||
return [int(n) for n in (tr.get("route") or [])]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Discover a real multi-hop pier (tx → relay → rx) on the bench, or skip.
|
||||
|
||||
Returns {tx_role, tx_port, rx_role, rx_port, rx_num, relay_role, relay_num}.
|
||||
"""
|
||||
roles = sorted(baked_mesh)
|
||||
if len(roles) < 3:
|
||||
pytest.skip(
|
||||
"multi-hop NextHop test needs ≥3 baked devices arranged as a line "
|
||||
"(endpoints out of direct RF range). Add a third role via "
|
||||
f"--hub-profile. Detected roles: {roles}"
|
||||
)
|
||||
|
||||
by_role = {r: (baked_mesh[r]["port"], baked_mesh[r]["my_node_num"]) for r in roles}
|
||||
if any(num is None for _, num in by_role.values()):
|
||||
pytest.skip("a baked device is missing my_node_num; can't map the topology")
|
||||
|
||||
_warm_mesh([port for port, _ in by_role.values()])
|
||||
|
||||
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
|
||||
# (cheap — no traceroute yet). On an all-direct bench nothing qualifies.
|
||||
multihop_pair: tuple[str, str] | None = None
|
||||
for a_role in roles:
|
||||
a_port, _ = by_role[a_role]
|
||||
try:
|
||||
with connect(port=a_port) as a_iface:
|
||||
nodes = a_iface.nodesByNum or {}
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for c_role in roles:
|
||||
if c_role == a_role:
|
||||
continue
|
||||
_, c_num = by_role[c_role]
|
||||
hops = _hops_away(nodes.get(c_num, {}))
|
||||
if hops is not None and hops >= 1:
|
||||
multihop_pair = (a_role, c_role)
|
||||
break
|
||||
if multihop_pair:
|
||||
break
|
||||
|
||||
if not multihop_pair:
|
||||
pytest.skip(
|
||||
"no multi-hop pair found — every device appears to be a direct "
|
||||
"neighbor. Arrange the bench as a line (A — B — C) with the "
|
||||
"endpoints out of direct RF range (distance or attenuators) so a "
|
||||
"relay is actually required, then re-run."
|
||||
)
|
||||
|
||||
a_role, c_role = multihop_pair
|
||||
a_port, _ = by_role[a_role]
|
||||
c_port, c_num = by_role[c_role]
|
||||
|
||||
route = _traceroute_route(a_port, c_num, c_port)
|
||||
if not route:
|
||||
pytest.skip(
|
||||
f"{a_role}→{c_role} looked multi-hop but traceroute returned no "
|
||||
"intermediate relay; can't identify the relay node to drive the "
|
||||
"recovery test"
|
||||
)
|
||||
|
||||
relay_num = route[0]
|
||||
relay_role = next((r for r in roles if by_role[r][1] == relay_num), None)
|
||||
return {
|
||||
"tx_role": a_role,
|
||||
"tx_port": a_port,
|
||||
"rx_role": c_role,
|
||||
"rx_port": c_port,
|
||||
"rx_num": c_num,
|
||||
"relay_role": relay_role,
|
||||
"relay_num": relay_num,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.timeout(300)
|
||||
def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
|
||||
"""A directed wantAck DM that must traverse the relay is delivered.
|
||||
|
||||
Exercises the NextHop routing path end-to-end: TX picks a next hop toward
|
||||
RX (M2 gate), the relay resolves the next_hop byte and forwards (M1), and
|
||||
the route is learned from the returning ACK (M3). Retries absorb transient
|
||||
LoRa loss; the assertion is on eventual delivery.
|
||||
"""
|
||||
tx_port = multihop_topology["tx_port"]
|
||||
rx_port = multihop_topology["rx_port"]
|
||||
rx_num = multihop_topology["rx_num"]
|
||||
tx_role = multihop_topology["tx_role"]
|
||||
rx_role = multihop_topology["rx_role"]
|
||||
relay_role = multihop_topology["relay_role"]
|
||||
|
||||
unique = f"nexthop-mh-{tx_role}-to-{rx_role}-{int(time.time())}"
|
||||
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
|
||||
pytest.skip(
|
||||
f"{tx_role} never learned {rx_role}'s pubkey over the relay; "
|
||||
"multi-hop PKI warmup didn't complete"
|
||||
)
|
||||
got = None
|
||||
for _attempt in range(3):
|
||||
pkt = tx_iface.sendText(unique, destinationId=rx_num, wantAck=True)
|
||||
assert pkt is not None
|
||||
got = rx.wait_for(
|
||||
lambda p: p.get("decoded", {}).get("text") == unique,
|
||||
timeout=45,
|
||||
)
|
||||
if got is not None:
|
||||
break
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
time.sleep(5.0)
|
||||
|
||||
assert got is not None, (
|
||||
f"multi-hop directed DM {tx_role}→{rx_role} via relay "
|
||||
f"{relay_role!r} never landed — NextHop multi-hop delivery is broken"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(600)
|
||||
def test_multihop_relay_recovery(
|
||||
multihop_topology: dict[str, Any],
|
||||
power_cycle, # noqa: ARG001 — forces the uhubctl-availability skip
|
||||
) -> None:
|
||||
"""Delivery recovers after the established relay drops and returns.
|
||||
|
||||
Establishes a baseline DM (route via relay learned), powers the relay OFF
|
||||
(confirming TX survives sending across a downed relay), then powers it back
|
||||
ON and asserts directed delivery resumes — the M3 stale-route decay /
|
||||
re-learn path. With a strict A — B — C line there is no path while B is down,
|
||||
so we only assert TX doesn't crash during the outage; the delivery assertion
|
||||
is after B returns.
|
||||
"""
|
||||
relay_role = multihop_topology["relay_role"]
|
||||
if not relay_role:
|
||||
pytest.skip(
|
||||
"relay node isn't one of the baked hub roles, so it can't be "
|
||||
"power-cycled; recovery test needs a controllable relay"
|
||||
)
|
||||
|
||||
tx_port = multihop_topology["tx_port"]
|
||||
rx_port = multihop_topology["rx_port"]
|
||||
rx_num = multihop_topology["rx_num"]
|
||||
tx_role = multihop_topology["tx_role"]
|
||||
rx_role = multihop_topology["rx_role"]
|
||||
|
||||
base = f"mh-recover-base-{int(time.time())}"
|
||||
post = f"mh-recover-post-{int(time.time())}"
|
||||
|
||||
# Baseline: confirm delivery works (so the route via the relay is learned)
|
||||
# before we perturb anything — otherwise a later failure is ambiguous.
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
|
||||
pytest.skip("multi-hop PKI warmup failed; can't run recovery test")
|
||||
tx_iface.sendText(base, destinationId=rx_num, wantAck=True)
|
||||
assert (
|
||||
rx.wait_for(
|
||||
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
|
||||
)
|
||||
is not None
|
||||
), "baseline multi-hop delivery failed — skipping recovery to avoid a false result"
|
||||
|
||||
# Power the relay OFF.
|
||||
try:
|
||||
_power.power_off(relay_role)
|
||||
_power.wait_for_absence(relay_role, timeout_s=15.0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
try:
|
||||
_power.power_on(relay_role)
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
pytest.skip(f"can't power-control relay {relay_role!r}: {exc}")
|
||||
|
||||
# With the only relay down there's no path; we just confirm TX accepts the
|
||||
# send and survives its internal retries (it must not crash / wedge).
|
||||
try:
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
pkt = tx_iface.sendText(
|
||||
f"mh-while-down-{int(time.time())}",
|
||||
destinationId=rx_num,
|
||||
wantAck=True,
|
||||
)
|
||||
assert pkt is not None
|
||||
time.sleep(8.0) # let retransmissions + route decay run
|
||||
except Exception as exc: # noqa: BLE001 — restore bench state before failing
|
||||
_power.power_on(relay_role)
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
raise AssertionError(
|
||||
f"TX crashed sending across a downed relay: {exc}"
|
||||
) from exc
|
||||
|
||||
# Power the relay back ON and let it re-enumerate + boot.
|
||||
_power.power_on(relay_role)
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
except Exception: # noqa: BLE001 — relay port isn't one we connect to directly
|
||||
pass
|
||||
time.sleep(8.0)
|
||||
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
|
||||
|
||||
# Delivery should resume once the relay is back (M3 re-learn / decay path).
|
||||
got = None
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
_wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0)
|
||||
for _attempt in range(4):
|
||||
pkt = tx_iface.sendText(post, destinationId=rx_num, wantAck=True)
|
||||
assert pkt is not None
|
||||
got = rx.wait_for(
|
||||
lambda p: p.get("decoded", {}).get("text") == post,
|
||||
timeout=45,
|
||||
)
|
||||
if got is not None:
|
||||
break
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
time.sleep(6.0)
|
||||
|
||||
assert got is not None, (
|
||||
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}→{rx_role} "
|
||||
"never resumed — stale-route recovery (M3) may be broken"
|
||||
)
|
||||
+12
-11
@@ -13,7 +13,7 @@ extra_configs =
|
||||
description = Meshtastic
|
||||
|
||||
[env]
|
||||
test_build_src = true
|
||||
; test_build_src = true ; disabled for faster incremental builds
|
||||
extra_scripts =
|
||||
pre:bin/platformio-pre.py
|
||||
bin/platformio-custom.py
|
||||
@@ -62,6 +62,7 @@ build_flags = -Wno-missing-field-initializers
|
||||
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
|
||||
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
|
||||
#-D OLED_PL=1
|
||||
#-D OLED_ZH=1 ; uncomment for compile-time Chinese font (runtime switching works without this on TFT)
|
||||
#-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs
|
||||
#-D DEBUG_LOOP_TIMING=1 ; uncomment to add main loop timing logs
|
||||
|
||||
@@ -200,6 +201,16 @@ lib_deps =
|
||||
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
|
||||
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
|
||||
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
|
||||
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
|
||||
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
|
||||
; Common environmental sensor libraries (not included in native / portduino)
|
||||
[environmental_extra_common]
|
||||
@@ -218,16 +229,6 @@ lib_deps =
|
||||
closedcube/ClosedCube OPT3001@1.1.2
|
||||
# renovate: datasource=git-refs depName=meshtastic-DFRobot_LarkWeatherStation packageName=https://github.com/meshtastic/DFRobot_LarkWeatherStation gitBranch=master
|
||||
https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
|
||||
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
|
||||
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
|
||||
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
|
||||
[environmental_extra]
|
||||
|
||||
+1
-1
Submodule protobufs updated: 3625166717...03314e6395
+118
-31
@@ -88,6 +88,9 @@ bool renameFile(const char *pathFrom, const char *pathTo)
|
||||
#endif
|
||||
}
|
||||
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
@@ -119,6 +122,93 @@ bool fsFormat()
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef FSCom
|
||||
namespace
|
||||
{
|
||||
bool pathEndsWithDot(const char *path)
|
||||
{
|
||||
if (!path)
|
||||
return false;
|
||||
|
||||
size_t length = strlen(path);
|
||||
return length > 0 && path[length - 1] == '.';
|
||||
}
|
||||
|
||||
bool copyFilePath(char *dest, size_t destSize, const char *path, bool *wasLimited)
|
||||
{
|
||||
if (!path || destSize == 0) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strlcpy(dest, path, destSize) >= destSize) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vector<meshtastic_FileInfo> &filenames,
|
||||
bool *wasLimited)
|
||||
{
|
||||
if (!dirname)
|
||||
return;
|
||||
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root)
|
||||
return;
|
||||
if (!root.isDirectory()) {
|
||||
root.close();
|
||||
return;
|
||||
}
|
||||
|
||||
File file = root.openNextFile();
|
||||
// file.name()[0] check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||
while (file && file.name()[0]) {
|
||||
if (filenames.size() >= maxCount) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
file.close();
|
||||
break;
|
||||
}
|
||||
const char *fileName = file.name();
|
||||
if (file.isDirectory() && !pathEndsWithDot(fileName)) {
|
||||
char pathBuffer[sizeof(((meshtastic_FileInfo *)nullptr)->file_name)] = {};
|
||||
#ifdef ARCH_ESP32
|
||||
const char *subDirPath = file.path();
|
||||
#else
|
||||
const char *subDirPath = fileName;
|
||||
#endif
|
||||
bool hasSubDirPath = copyFilePath(pathBuffer, sizeof(pathBuffer), subDirPath, wasLimited);
|
||||
file.close();
|
||||
|
||||
if (levels && hasSubDirPath) {
|
||||
collectFiles(pathBuffer, levels - 1, maxCount, filenames, wasLimited);
|
||||
} else if (wasLimited) {
|
||||
*wasLimited = true;
|
||||
}
|
||||
} else {
|
||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||
#ifdef ARCH_ESP32
|
||||
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.path(), wasLimited);
|
||||
#else
|
||||
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.name(), wasLimited);
|
||||
#endif
|
||||
if (hasFilePath && !pathEndsWithDot(fileInfo.file_name)) {
|
||||
filenames.push_back(fileInfo);
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
file = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
}
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Get the list of files in a directory.
|
||||
*
|
||||
@@ -127,43 +217,40 @@ bool fsFormat()
|
||||
*
|
||||
* @param dirname The name of the directory.
|
||||
* @param levels The number of levels of subdirectories to list.
|
||||
* @return A vector of strings containing the full path of each file in the directory.
|
||||
* @param maxCount The maximum number of files to collect before truncating the walk.
|
||||
* @param wasLimited Optional out-param, set to true if the listing was truncated (by maxCount or low memory).
|
||||
* @return A vector of meshtastic_FileInfo for each file in the directory.
|
||||
*/
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount, bool *wasLimited)
|
||||
{
|
||||
std::vector<meshtastic_FileInfo> filenames = {};
|
||||
if (wasLimited)
|
||||
*wasLimited = false;
|
||||
#ifdef FSCom
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root)
|
||||
return filenames;
|
||||
if (!root.isDirectory())
|
||||
return filenames;
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (file) {
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(filepath, levels - 1);
|
||||
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
||||
file.close();
|
||||
}
|
||||
} else {
|
||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||
strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1);
|
||||
fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0';
|
||||
if (!String(fileInfo.file_name).endsWith(".")) {
|
||||
filenames.push_back(fileInfo);
|
||||
}
|
||||
file.close();
|
||||
#if defined(__cpp_exceptions) || defined(__EXCEPTIONS)
|
||||
size_t reservedCount = maxCount;
|
||||
while (reservedCount > 0) {
|
||||
try {
|
||||
filenames.reserve(reservedCount);
|
||||
break;
|
||||
} catch (const std::bad_alloc &) {
|
||||
reservedCount /= 2;
|
||||
} catch (const std::length_error &) {
|
||||
reservedCount /= 2;
|
||||
}
|
||||
file = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
if (reservedCount == 0) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
return filenames;
|
||||
}
|
||||
if (reservedCount < maxCount) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
maxCount = reservedCount;
|
||||
}
|
||||
#endif
|
||||
collectFiles(dirname, levels, maxCount, filenames, wasLimited);
|
||||
#endif
|
||||
return filenames;
|
||||
}
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ void fsListFiles();
|
||||
bool copyFile(const char *from, const char *to);
|
||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||
bool fsFormat();
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr);
|
||||
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
||||
void rmDir(const char *dirname);
|
||||
void setupSDCard();
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "main.h"
|
||||
#include "meshUtils.h"
|
||||
#include "power/PowerHAL.h"
|
||||
#include "power/SGM41562.h"
|
||||
#include "sleep.h"
|
||||
#ifdef ARCH_ESP32
|
||||
// #include <driver/adc.h>
|
||||
@@ -545,6 +546,10 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
// lastly provide a fallback to indicate external power when fully charged.
|
||||
virtual bool isVbusIn() override
|
||||
{
|
||||
#ifdef HAS_SGM41562
|
||||
if (sgm41562 && sgm41562->refresh())
|
||||
return sgm41562->isInputPowerGood();
|
||||
#endif
|
||||
#ifdef EXT_PWR_DETECT
|
||||
return digitalRead(EXT_PWR_DETECT) == EXT_PWR_DETECT_VALUE;
|
||||
|
||||
@@ -561,6 +566,10 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
/// we can't be smart enough to say 'full'?
|
||||
virtual bool isCharging() override
|
||||
{
|
||||
#ifdef HAS_SGM41562
|
||||
if (sgm41562 && sgm41562->refresh())
|
||||
return sgm41562->isCharging();
|
||||
#endif
|
||||
#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT) && !defined(HAS_PMU)
|
||||
if (hasRAK()) {
|
||||
return (rak9154Sensor.isCharging()) ? OptTrue : OptFalse;
|
||||
@@ -767,6 +776,12 @@ bool Power::analogInit()
|
||||
*/
|
||||
bool Power::setup()
|
||||
{
|
||||
#ifdef HAS_SGM41562
|
||||
// Initialize the charger early so AnalogBatteryLevel can read charging
|
||||
// state from it. The charger does not provide battery voltage / percent —
|
||||
// those still come from the platform ADC via analogInit() below.
|
||||
initSGM41562(SGM41562_WIRE);
|
||||
#endif
|
||||
bool found = false;
|
||||
if (axpChipInit()) {
|
||||
found = true;
|
||||
|
||||
+87
-12
@@ -31,13 +31,63 @@ static uint32_t
|
||||
timeStartMsec; // Once we have a GPS lock, this is where we hold the initial msec clock that corresponds to that time
|
||||
static uint64_t zeroOffsetSecs; // GPS based time in secs since 1970 - only updated once on initial lock
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
// Test seam: unit tests can inject a fake system clock (e.g. the uptime seconds that
|
||||
// gettimeofday() returns on boards without a real RTC, like RP2040) and force readFromRTC()
|
||||
// down the no-hardware-RTC fallback even when a hardware-RTC branch is compiled in.
|
||||
static bool hasMockSystemTime = false;
|
||||
static bool forceSystemTimeFallback = false;
|
||||
static struct timeval mockSystemTime = {};
|
||||
#endif
|
||||
|
||||
// Reads the platform system clock (or the injected mock during unit tests). Used only by the
|
||||
// no-hardware-RTC fallback below, so it may be unused on builds with a hardware RTC.
|
||||
[[maybe_unused]] static bool readSystemTime(struct timeval *tv)
|
||||
{
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
if (hasMockSystemTime) {
|
||||
*tv = mockSystemTime;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return gettimeofday(tv, NULL) == 0;
|
||||
}
|
||||
|
||||
// Seeds the clock from the system time on boards without a hardware RTC. gettimeofday() can
|
||||
// return uptime rather than wall-clock time there (e.g. RP2040), so only adopt it when we have
|
||||
// nothing better yet -- never clobber a higher-quality GPS/NTP/phone source (issue #9828).
|
||||
[[maybe_unused]] static RTCSetResult readFromSystemTimeFallback()
|
||||
{
|
||||
struct timeval tv;
|
||||
if (readSystemTime(&tv)) {
|
||||
uint32_t now = millis();
|
||||
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
|
||||
if (currentQuality == RTCQualityNone) {
|
||||
LOG_DEBUG("Seed time from system clock: %lu", (unsigned long)printableEpoch);
|
||||
timeStartMsec = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
} else {
|
||||
LOG_DEBUG("Ignore system clock fallback (%lu); current RTC quality is %s", (unsigned long)printableEpoch,
|
||||
RtcName(currentQuality));
|
||||
}
|
||||
return RTCSetResultSuccess;
|
||||
}
|
||||
return RTCSetResultNotSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current date and time from the RTC module and updates the system time.
|
||||
* @return True if the RTC was successfully read and the system time was updated, false otherwise.
|
||||
* Reads date/time from the RTC module (or system-time fallback) and seeds internal timekeeping.
|
||||
* @return RTCSetResultSuccess if a time source was read successfully (even if an existing higher-quality time is retained).
|
||||
*/
|
||||
RTCSetResult readFromRTC()
|
||||
{
|
||||
struct timeval tv; /* btw settimeofday() is helpful here too*/
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
if (forceSystemTimeFallback) {
|
||||
return readFromSystemTimeFallback();
|
||||
}
|
||||
#endif
|
||||
|
||||
[[maybe_unused]] struct timeval tv; /* btw settimeofday() is helpful here too*/
|
||||
#ifdef RV3028_RTC
|
||||
if (rtc_found.address == RV3028_RTC) {
|
||||
uint32_t now = millis();
|
||||
@@ -162,14 +212,7 @@ RTCSetResult readFromRTC()
|
||||
}
|
||||
}
|
||||
#else
|
||||
if (!gettimeofday(&tv, NULL)) {
|
||||
uint32_t now = millis();
|
||||
uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms
|
||||
LOG_DEBUG("Read RTC time as %ld", printableEpoch);
|
||||
timeStartMsec = now;
|
||||
zeroOffsetSecs = tv.tv_sec;
|
||||
return RTCSetResultSuccess;
|
||||
}
|
||||
return readFromSystemTimeFallback();
|
||||
#endif
|
||||
return RTCSetResultNotSet;
|
||||
}
|
||||
@@ -292,7 +335,7 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
LOG_WARN("Failed to set time for RX8130CE");
|
||||
}
|
||||
}
|
||||
#elif defined(ARCH_ESP32)
|
||||
#elif defined(ARCH_ESP32) || defined(ARCH_RP2040)
|
||||
settimeofday(tv, NULL);
|
||||
#endif
|
||||
|
||||
@@ -423,6 +466,38 @@ void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot)
|
||||
lastSetFromPhoneNtpOrGps = 0;
|
||||
lastTimeValidationWarning = 0;
|
||||
}
|
||||
|
||||
void clearRTCSystemTimeForTests()
|
||||
{
|
||||
hasMockSystemTime = false;
|
||||
mockSystemTime = {};
|
||||
}
|
||||
|
||||
void setRTCSystemTimeForTests(const struct timeval *tv)
|
||||
{
|
||||
if (tv == NULL) {
|
||||
clearRTCSystemTimeForTests();
|
||||
return;
|
||||
}
|
||||
mockSystemTime = *tv;
|
||||
hasMockSystemTime = true;
|
||||
}
|
||||
|
||||
void setReadFromRTCUseSystemTimeForTests(bool enabled)
|
||||
{
|
||||
forceSystemTimeFallback = enabled;
|
||||
}
|
||||
|
||||
void resetRTCStateForTests()
|
||||
{
|
||||
currentQuality = RTCQualityNone;
|
||||
timeStartMsec = 0;
|
||||
zeroOffsetSecs = 0;
|
||||
lastSetFromPhoneNtpOrGps = 0;
|
||||
lastTimeValidationWarning = 0;
|
||||
setReadFromRTCUseSystemTimeForTests(false);
|
||||
clearRTCSystemTimeForTests();
|
||||
}
|
||||
#endif
|
||||
|
||||
time_t gm_mktime(const struct tm *tm)
|
||||
|
||||
@@ -56,6 +56,10 @@ RTCSetResult readFromRTC();
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
void setBootRelativeTimeForUnitTest(uint32_t secondsSinceBoot);
|
||||
void resetRTCStateForTests();
|
||||
void setRTCSystemTimeForTests(const struct timeval *tv);
|
||||
void clearRTCSystemTimeForTests();
|
||||
void setReadFromRTCUseSystemTimeForTests(bool enabled);
|
||||
#endif
|
||||
|
||||
time_t gm_mktime(const struct tm *tm);
|
||||
|
||||
+286
-5
@@ -39,6 +39,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "draw/NodeListRenderer.h"
|
||||
#include "draw/NotificationRenderer.h"
|
||||
#include "draw/UIRenderer.h"
|
||||
#include "graphics/fonts/OLEDDisplayFontsZH.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
#include "security/LockdownDisplay.h"
|
||||
@@ -60,6 +61,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include "graphics/images.h"
|
||||
#include "graphics/l10n/Strings.h"
|
||||
#include "input/TouchScreenImpl1.h"
|
||||
#include "main.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
@@ -140,10 +142,11 @@ static void drawLockdownLockScreenIntoBuffer(OLEDDisplay *display)
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->setFont(FONT_LARGE);
|
||||
display->drawString(w / 2, h / 2 - FONT_HEIGHT_LARGE, "LOCKED");
|
||||
display->drawString(w / 2, h / 2 - FONT_HEIGHT_LARGE, meshtastic::l10n::getString(meshtastic::l10n::StringKey::LOCKED));
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
char status[32] = "Connect to unlock";
|
||||
char status[32];
|
||||
strncpy(status, meshtastic::l10n::getString(meshtastic::l10n::StringKey::ConnectToUnlock), 31);
|
||||
if (powerStatus && powerStatus->getHasBattery()) {
|
||||
int pct = powerStatus->getBatteryChargePercent();
|
||||
snprintf(status, sizeof(status), "Battery %d%%", pct);
|
||||
@@ -731,7 +734,10 @@ void Screen::setup()
|
||||
// Enable display rendering
|
||||
useDisplay = true;
|
||||
|
||||
// Load saved brightness from UI config
|
||||
// Set the runtime display language from persisted config
|
||||
meshtastic::l10n::currentDisplayLanguage = uiconfig.language;
|
||||
|
||||
// Load brightness from UI config
|
||||
// For OLED displays (SSD1306), default brightness is 255 if not set
|
||||
if (uiconfig.screen_brightness == 0) {
|
||||
#if defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107)
|
||||
@@ -819,6 +825,9 @@ void Screen::setup()
|
||||
// Enable UTF-8 to display mapping
|
||||
dispdev->setFontTableLookupFunction(customFontTableLookup);
|
||||
|
||||
// Load CJK (Chinese) font if language requires it
|
||||
loadCJKFontIfNeeded();
|
||||
|
||||
#ifdef USERPREFS_OEM_TEXT
|
||||
logo_timeout *= 2; // Give more time for branded boot logos
|
||||
#endif
|
||||
@@ -828,7 +837,7 @@ void Screen::setup()
|
||||
alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) {
|
||||
#ifdef ARCH_ESP32
|
||||
if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1)
|
||||
graphics::UIRenderer::drawFrameText(display, state, x, y, "Resuming...");
|
||||
graphics::UIRenderer::drawFrameText(display, state, x, y, meshtastic::l10n::getString(meshtastic::l10n::StringKey::Resuming));
|
||||
else
|
||||
#endif
|
||||
{
|
||||
@@ -985,6 +994,13 @@ int32_t Screen::runOnce()
|
||||
return RUN_SAME;
|
||||
}
|
||||
|
||||
// Detect language changes from remote config (phone app)
|
||||
if (uiconfig.language != meshtastic::l10n::currentDisplayLanguage) {
|
||||
meshtastic::l10n::currentDisplayLanguage = uiconfig.language;
|
||||
loadCJKFontIfNeeded();
|
||||
setFrames(FOCUS_PRESERVE);
|
||||
}
|
||||
|
||||
if (displayHeight == 0) {
|
||||
displayHeight = dispdev->getHeight();
|
||||
}
|
||||
@@ -1052,7 +1068,7 @@ int32_t Screen::runOnce()
|
||||
}
|
||||
#endif
|
||||
if (!NotificationRenderer::isOverlayBannerShowing() && rebootAtMsec != 0 && !suppressRebootBanner) {
|
||||
showSimpleBanner("Rebooting...", 0);
|
||||
showSimpleBanner(meshtastic::l10n::getString(meshtastic::l10n::StringKey::Rebooting), 0);
|
||||
}
|
||||
|
||||
// Process incoming commands.
|
||||
@@ -2210,6 +2226,271 @@ bool Screen::isOverlayBannerShowing()
|
||||
return NotificationRenderer::isOverlayBannerShowing();
|
||||
}
|
||||
|
||||
char Screen::customFontTableLookup(const uint8_t ch)
|
||||
{
|
||||
static uint8_t LASTCHAR;
|
||||
static bool SKIPREST;
|
||||
static int8_t cjkRemaining = 0;
|
||||
|
||||
if (cjkRemaining > 0) {
|
||||
cjkRemaining--;
|
||||
LASTCHAR = ch;
|
||||
return (uint8_t)0;
|
||||
}
|
||||
|
||||
if (ch < 128) {
|
||||
LASTCHAR = 0;
|
||||
SKIPREST = false;
|
||||
cjkRemaining = 0;
|
||||
return ch;
|
||||
}
|
||||
|
||||
uint8_t last = LASTCHAR;
|
||||
LASTCHAR = ch;
|
||||
|
||||
switch (last) {
|
||||
case 0xC2: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)ch;
|
||||
}
|
||||
case 0xC3: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
}
|
||||
}
|
||||
|
||||
if (ch == 0xC2 || ch == 0xC3)
|
||||
return (uint8_t)0;
|
||||
|
||||
#if defined(OLED_PL)
|
||||
switch (last) {
|
||||
case 0xC3: {
|
||||
if (ch == 147)
|
||||
return (uint8_t)(ch);
|
||||
else if (ch == 179)
|
||||
return (uint8_t)(148);
|
||||
else
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
break;
|
||||
}
|
||||
case 0xC4: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch);
|
||||
}
|
||||
case 0xC5: {
|
||||
SKIPREST = false;
|
||||
if (ch == 132)
|
||||
return (uint8_t)(136);
|
||||
else if (ch == 186)
|
||||
return (uint8_t)(137);
|
||||
else
|
||||
return (uint8_t)(ch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5)
|
||||
return (uint8_t)0;
|
||||
#endif
|
||||
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
switch (last) {
|
||||
case 0xC3: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
}
|
||||
case 0xD0: {
|
||||
SKIPREST = false;
|
||||
if (ch == 132)
|
||||
return (uint8_t)(170);
|
||||
if (ch == 134)
|
||||
return (uint8_t)(178);
|
||||
if (ch == 135)
|
||||
return (uint8_t)(175);
|
||||
if (ch == 129)
|
||||
return (uint8_t)(168);
|
||||
if (ch > 143 && ch < 192)
|
||||
return (uint8_t)(ch + 48);
|
||||
break;
|
||||
}
|
||||
case 0xD1: {
|
||||
SKIPREST = false;
|
||||
if (ch == 148)
|
||||
return (uint8_t)(186);
|
||||
if (ch == 150)
|
||||
return (uint8_t)(179);
|
||||
if (ch == 151)
|
||||
return (uint8_t)(191);
|
||||
if (ch == 145)
|
||||
return (uint8_t)(184);
|
||||
if (ch > 127 && ch < 144)
|
||||
return (uint8_t)(ch + 112);
|
||||
break;
|
||||
}
|
||||
case 0xD2: {
|
||||
SKIPREST = false;
|
||||
if (ch == 144)
|
||||
return (uint8_t)(165);
|
||||
if (ch == 145)
|
||||
return (uint8_t)(180);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ch == 0xC2 || ch == 0xC3 || ch == 0x82 || ch == 0xD0 || ch == 0xD1)
|
||||
return (uint8_t)0;
|
||||
#endif
|
||||
|
||||
#if defined(OLED_CS)
|
||||
switch (last) {
|
||||
case 0xC2: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)ch;
|
||||
}
|
||||
case 0xC3: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
}
|
||||
case 0xC4: {
|
||||
SKIPREST = false;
|
||||
if (ch == 140)
|
||||
return (uint8_t)(129);
|
||||
if (ch == 141)
|
||||
return (uint8_t)(138);
|
||||
if (ch == 142)
|
||||
return (uint8_t)(130);
|
||||
if (ch == 143)
|
||||
return (uint8_t)(139);
|
||||
if (ch == 154)
|
||||
return (uint8_t)(131);
|
||||
if (ch == 155)
|
||||
return (uint8_t)(140);
|
||||
if (ch == 185)
|
||||
return (uint8_t)(147);
|
||||
if (ch == 186)
|
||||
return (uint8_t)(148);
|
||||
if (ch == 189)
|
||||
return (uint8_t)(149);
|
||||
if (ch == 190)
|
||||
return (uint8_t)(150);
|
||||
break;
|
||||
}
|
||||
case 0xC5: {
|
||||
SKIPREST = false;
|
||||
if (ch == 135)
|
||||
return (uint8_t)(132);
|
||||
if (ch == 136)
|
||||
return (uint8_t)(141);
|
||||
if (ch == 152)
|
||||
return (uint8_t)(133);
|
||||
if (ch == 153)
|
||||
return (uint8_t)(142);
|
||||
if (ch == 160)
|
||||
return (uint8_t)(134);
|
||||
if (ch == 161)
|
||||
return (uint8_t)(143);
|
||||
if (ch == 164)
|
||||
return (uint8_t)(135);
|
||||
if (ch == 165)
|
||||
return (uint8_t)(144);
|
||||
if (ch == 174)
|
||||
return (uint8_t)(136);
|
||||
if (ch == 175)
|
||||
return (uint8_t)(145);
|
||||
if (ch == 189)
|
||||
return (uint8_t)(137);
|
||||
if (ch == 190)
|
||||
return (uint8_t)(146);
|
||||
if (ch == 148)
|
||||
return (uint8_t)(151);
|
||||
if (ch == 149)
|
||||
return (uint8_t)(152);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5)
|
||||
return (uint8_t)0;
|
||||
#endif
|
||||
|
||||
#if defined(OLED_GR)
|
||||
switch (last) {
|
||||
case 0xC3: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
}
|
||||
case 0xCE: {
|
||||
SKIPREST = false;
|
||||
if (ch >= 145 && ch <= 161)
|
||||
return (uint8_t)(ch + 48);
|
||||
else if (ch >= 163 && ch <= 169)
|
||||
return (uint8_t)(ch + 48);
|
||||
else if (ch >= 177 && ch <= 193)
|
||||
return (uint8_t)(ch + 48);
|
||||
break;
|
||||
}
|
||||
case 0xCF: {
|
||||
SKIPREST = false;
|
||||
if (ch >= 130 && ch <= 137)
|
||||
return (uint8_t)(ch + 112);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ch == 0xC2 || ch == 0xC3 || ch == 0xCE || ch == 0xCF)
|
||||
return (uint8_t)0;
|
||||
#endif
|
||||
|
||||
if (ch >= 0xE2 && ch <= 0xEF) {
|
||||
cjkRemaining = 2;
|
||||
return (uint8_t)0;
|
||||
}
|
||||
|
||||
if (SKIPREST)
|
||||
return (uint8_t)0;
|
||||
SKIPREST = true;
|
||||
|
||||
return (uint8_t)191;
|
||||
}
|
||||
|
||||
void Screen::drawTextL10n(int16_t x, int16_t y, const char *text, OLEDDISPLAY_TEXT_ALIGNMENT alignment)
|
||||
{
|
||||
if (!dispdev || !text)
|
||||
return;
|
||||
|
||||
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
|
||||
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014)
|
||||
if (meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_SIMPLIFIED_CHINESE ||
|
||||
meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_TRADITIONAL_CHINESE) {
|
||||
TFTDisplay *tftDisp = static_cast<TFTDisplay *>(dispdev);
|
||||
if (tftDisp->hasCJKFont()) {
|
||||
tftDisp->drawStringCJK(x, y, text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
dispdev->setTextAlignment(alignment);
|
||||
dispdev->drawString(x, y, text);
|
||||
}
|
||||
|
||||
void Screen::setFontL10n(const uint8_t *fontData)
|
||||
{
|
||||
if (!dispdev)
|
||||
return;
|
||||
dispdev->setFont(fontData);
|
||||
}
|
||||
|
||||
void Screen::loadCJKFontIfNeeded()
|
||||
{
|
||||
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
|
||||
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014)
|
||||
if (meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_SIMPLIFIED_CHINESE ||
|
||||
meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_TRADITIONAL_CHINESE) {
|
||||
TFTDisplay *tftDisp = static_cast<TFTDisplay *>(dispdev);
|
||||
if (!tftDisp->hasCJKFont()) {
|
||||
tftDisp->loadCJKFont(FontZH_CN_16, FontZH_CN_16_size);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
|
||||
#else
|
||||
|
||||
+5
-247
@@ -366,255 +366,13 @@ class Screen : public concurrency::OSThread
|
||||
enqueueCmd(ScreenCmd{.cmd = Cmd::NOOP});
|
||||
}
|
||||
|
||||
/// Overrides the default utf8 character conversion, to replace empty space with question marks
|
||||
static char customFontTableLookup(const uint8_t ch)
|
||||
{
|
||||
// UTF-8 to font table index converter
|
||||
// Code from http://playground.arduino.cc/Main/Utf8ascii
|
||||
static uint8_t LASTCHAR;
|
||||
static bool SKIPREST; // Only display a single unconvertable-character symbol per sequence of unconvertable characters
|
||||
static char customFontTableLookup(const uint8_t ch);
|
||||
|
||||
if (ch < 128) { // Standard ASCII-set 0..0x7F handling
|
||||
LASTCHAR = 0;
|
||||
SKIPREST = false;
|
||||
return ch;
|
||||
}
|
||||
void drawTextL10n(int16_t x, int16_t y, const char *text, OLEDDISPLAY_TEXT_ALIGNMENT alignment = TEXT_ALIGN_LEFT);
|
||||
void setFontL10n(const uint8_t *fontData);
|
||||
|
||||
uint8_t last = LASTCHAR; // get last char
|
||||
LASTCHAR = ch;
|
||||
|
||||
switch (last) {
|
||||
case 0xC2: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)ch;
|
||||
}
|
||||
|
||||
case 0xC3: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
}
|
||||
}
|
||||
|
||||
// We want to strip out prefix chars for two-byte char formats
|
||||
if (ch == 0xC2 || ch == 0xC3)
|
||||
return (uint8_t)0;
|
||||
|
||||
#if defined(OLED_PL)
|
||||
|
||||
switch (last) {
|
||||
case 0xC3: {
|
||||
|
||||
if (ch == 147)
|
||||
return (uint8_t)(ch); // Ó
|
||||
else if (ch == 179)
|
||||
return (uint8_t)(148); // ó
|
||||
else
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
break;
|
||||
}
|
||||
|
||||
case 0xC4: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch);
|
||||
}
|
||||
|
||||
case 0xC5: {
|
||||
SKIPREST = false;
|
||||
if (ch == 132)
|
||||
return (uint8_t)(136); // ń
|
||||
else if (ch == 186)
|
||||
return (uint8_t)(137); // ź
|
||||
else
|
||||
return (uint8_t)(ch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// We want to strip out prefix chars for two-byte char formats
|
||||
if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5)
|
||||
return (uint8_t)0;
|
||||
|
||||
#endif
|
||||
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
|
||||
switch (last) {
|
||||
case 0xC3: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
}
|
||||
// map UTF-8 cyrillic chars to it Windows-1251 (CP-1251) ASCII codes
|
||||
// note: in this case we must use compatible font - provided ArialMT_Plain_10/16/24 by 'ThingPulse/esp8266-oled-ssd1306'
|
||||
// library have empty chars for non-latin ASCII symbols
|
||||
case 0xD0: {
|
||||
SKIPREST = false;
|
||||
if (ch == 132)
|
||||
return (uint8_t)(170); // Є
|
||||
if (ch == 134)
|
||||
return (uint8_t)(178); // І
|
||||
if (ch == 135)
|
||||
return (uint8_t)(175); // Ї
|
||||
if (ch == 129)
|
||||
return (uint8_t)(168); // Ё
|
||||
if (ch > 143 && ch < 192)
|
||||
return (uint8_t)(ch + 48);
|
||||
break;
|
||||
}
|
||||
case 0xD1: {
|
||||
SKIPREST = false;
|
||||
if (ch == 148)
|
||||
return (uint8_t)(186); // є
|
||||
if (ch == 150)
|
||||
return (uint8_t)(179); // і
|
||||
if (ch == 151)
|
||||
return (uint8_t)(191); // ї
|
||||
if (ch == 145)
|
||||
return (uint8_t)(184); // ё
|
||||
if (ch > 127 && ch < 144)
|
||||
return (uint8_t)(ch + 112);
|
||||
break;
|
||||
}
|
||||
case 0xD2: {
|
||||
SKIPREST = false;
|
||||
if (ch == 144)
|
||||
return (uint8_t)(165); // Ґ
|
||||
if (ch == 145)
|
||||
return (uint8_t)(180); // ґ
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// We want to strip out prefix chars for two-byte char formats
|
||||
if (ch == 0xC2 || ch == 0xC3 || ch == 0x82 || ch == 0xD0 || ch == 0xD1)
|
||||
return (uint8_t)0;
|
||||
|
||||
#endif
|
||||
|
||||
#if defined(OLED_CS)
|
||||
|
||||
switch (last) {
|
||||
case 0xC2: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)ch;
|
||||
}
|
||||
|
||||
case 0xC3: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
}
|
||||
|
||||
case 0xC4: {
|
||||
SKIPREST = false;
|
||||
if (ch == 140)
|
||||
return (uint8_t)(129); // Č
|
||||
if (ch == 141)
|
||||
return (uint8_t)(138); // č
|
||||
if (ch == 142)
|
||||
return (uint8_t)(130); // Ď
|
||||
if (ch == 143)
|
||||
return (uint8_t)(139); // ď
|
||||
if (ch == 154)
|
||||
return (uint8_t)(131); // Ě
|
||||
if (ch == 155)
|
||||
return (uint8_t)(140); // ě
|
||||
// Slovak specific glyphs
|
||||
if (ch == 185)
|
||||
return (uint8_t)(147); // Ĺ
|
||||
if (ch == 186)
|
||||
return (uint8_t)(148); // ĺ
|
||||
if (ch == 189)
|
||||
return (uint8_t)(149); // Ľ
|
||||
if (ch == 190)
|
||||
return (uint8_t)(150); // ľ
|
||||
break;
|
||||
}
|
||||
|
||||
case 0xC5: {
|
||||
SKIPREST = false;
|
||||
if (ch == 135)
|
||||
return (uint8_t)(132); // Ň
|
||||
if (ch == 136)
|
||||
return (uint8_t)(141); // ň
|
||||
if (ch == 152)
|
||||
return (uint8_t)(133); // Ř
|
||||
if (ch == 153)
|
||||
return (uint8_t)(142); // ř
|
||||
if (ch == 160)
|
||||
return (uint8_t)(134); // Š
|
||||
if (ch == 161)
|
||||
return (uint8_t)(143); // š
|
||||
if (ch == 164)
|
||||
return (uint8_t)(135); // Ť
|
||||
if (ch == 165)
|
||||
return (uint8_t)(144); // ť
|
||||
if (ch == 174)
|
||||
return (uint8_t)(136); // Ů
|
||||
if (ch == 175)
|
||||
return (uint8_t)(145); // ů
|
||||
if (ch == 189)
|
||||
return (uint8_t)(137); // Ž
|
||||
if (ch == 190)
|
||||
return (uint8_t)(146); // ž
|
||||
// Slovak specific glyphs
|
||||
if (ch == 148)
|
||||
return (uint8_t)(151); // Ŕ
|
||||
if (ch == 149)
|
||||
return (uint8_t)(152); // ŕ
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// We want to strip out prefix chars for two-byte char formats
|
||||
if (ch == 0xC2 || ch == 0xC3 || ch == 0xC4 || ch == 0xC5)
|
||||
return (uint8_t)0;
|
||||
|
||||
#endif
|
||||
|
||||
#if defined(OLED_GR)
|
||||
|
||||
switch (last) {
|
||||
case 0xC3: {
|
||||
SKIPREST = false;
|
||||
return (uint8_t)(ch | 0xC0);
|
||||
}
|
||||
// Map UTF-8 Greek chars to Windows-1253 (CP-1253) ASCII codes
|
||||
case 0xCE: {
|
||||
SKIPREST = false;
|
||||
// Uppercase Greek: Α-Ρ (U+0391-U+03A1) -> CP-1253 193-209
|
||||
if (ch >= 145 && ch <= 161)
|
||||
return (uint8_t)(ch + 48);
|
||||
// Uppercase Greek: Σ-Ω (U+03A3-U+03A9) -> CP-1253 211-217
|
||||
else if (ch >= 163 && ch <= 169)
|
||||
return (uint8_t)(ch + 48);
|
||||
// Lowercase Greek: α-ρ (U+03B1-U+03C1) -> CP-1253 225-241
|
||||
else if (ch >= 177 && ch <= 193)
|
||||
return (uint8_t)(ch + 48);
|
||||
break;
|
||||
}
|
||||
case 0xCF: {
|
||||
SKIPREST = false;
|
||||
// Lowercase Greek: ς-ω (U+03C2-U+03C9) -> CP-1253 242-249
|
||||
if (ch >= 130 && ch <= 137)
|
||||
return (uint8_t)(ch + 112);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// We want to strip out prefix chars for two-byte Greek char formats
|
||||
if (ch == 0xC2 || ch == 0xC3 || ch == 0xCE || ch == 0xCF)
|
||||
return (uint8_t)0;
|
||||
|
||||
#endif
|
||||
|
||||
// If we already returned an unconvertable-character symbol for this unconvertable-character sequence, return NULs for the
|
||||
// rest of it
|
||||
if (SKIPREST)
|
||||
return (uint8_t)0;
|
||||
SKIPREST = true;
|
||||
|
||||
return (uint8_t)191; // otherwise: return ¿ if character can't be converted (note that the font map we're using doesn't
|
||||
// stick to standard EASCII codes)
|
||||
}
|
||||
// Load CJK font if language requires it (called from setup & on language change)
|
||||
void loadCJKFontIfNeeded();
|
||||
|
||||
/// Returns a handle to the DebugInfo screen.
|
||||
//
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
#include "graphics/fonts/OLEDDisplayFontsGR.h"
|
||||
#endif
|
||||
|
||||
#ifdef OLED_ZH
|
||||
#include "graphics/fonts/OLEDDisplayFontsZH.h"
|
||||
#endif
|
||||
|
||||
#if (defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(T5_S3_EPAPER_PRO)) && defined(USE_EINK)
|
||||
#include "graphics/fonts/EinkDisplayFonts.h"
|
||||
#endif
|
||||
|
||||
@@ -1636,4 +1636,35 @@ bool TFTDisplay::connect()
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TFTDisplay::loadCJKFont(const uint8_t *vlwData, uint32_t vlwDataLen)
|
||||
{
|
||||
if (!tft || !vlwData || vlwDataLen == 0)
|
||||
return false;
|
||||
cjkFontLoaded = tft->loadFont(vlwData);
|
||||
if (cjkFontLoaded) {
|
||||
LOG_INFO("CJK font loaded (%u bytes)", vlwDataLen);
|
||||
} else {
|
||||
LOG_WARN("Failed to load CJK font");
|
||||
}
|
||||
return cjkFontLoaded;
|
||||
}
|
||||
|
||||
void TFTDisplay::drawStringCJK(int16_t x, int16_t y, const char *text)
|
||||
{
|
||||
if (!tft || !text || !cjkFontLoaded)
|
||||
return;
|
||||
concurrency::LockGuard g(spiLock);
|
||||
uint16_t fgColor = getThemeDefaultOnColor();
|
||||
tft->setTextColor(fgColor, getThemeDefaultOffColor());
|
||||
tft->setCursor(x, y);
|
||||
tft->print(text);
|
||||
}
|
||||
|
||||
int16_t TFTDisplay::getStringWidthCJK(const char *text)
|
||||
{
|
||||
if (!tft || !text || !cjkFontLoaded)
|
||||
return 0;
|
||||
return tft->textWidth(text);
|
||||
}
|
||||
|
||||
#endif // USE_TFTDISPLAY
|
||||
|
||||
@@ -38,6 +38,12 @@ class TFTDisplay : public OLEDDisplay
|
||||
// Functions for changing display brightness
|
||||
void setDisplayBrightness(uint8_t);
|
||||
|
||||
// CJK (Chinese/Japanese/Korean) text rendering via LovyanGFX
|
||||
bool loadCJKFont(const uint8_t *vlwData, uint32_t vlwDataLen);
|
||||
void drawStringCJK(int16_t x, int16_t y, const char *text);
|
||||
int16_t getStringWidthCJK(const char *text);
|
||||
bool hasCJKFont() const { return cjkFontLoaded; }
|
||||
|
||||
/**
|
||||
* shim to make the abstraction happy
|
||||
*
|
||||
@@ -64,4 +70,5 @@ class TFTDisplay : public OLEDDisplay
|
||||
|
||||
uint16_t *linePixelBuffer = nullptr;
|
||||
uint16_t *repaintChunkBuffer = nullptr;
|
||||
bool cjkFontLoaded = false;
|
||||
};
|
||||
|
||||
+288
-132
@@ -13,6 +13,7 @@
|
||||
#include "graphics/Screen.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/l10n/Strings.h"
|
||||
#include "graphics/draw/MessageRenderer.h"
|
||||
#include "graphics/draw/UIRenderer.h"
|
||||
#include "input/RotaryEncoderInterruptImpl1.h"
|
||||
@@ -35,6 +36,9 @@
|
||||
namespace graphics
|
||||
{
|
||||
|
||||
using namespace meshtastic::l10n;
|
||||
using meshtastic::l10n::StringKey;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
@@ -132,10 +136,15 @@ uint8_t test_count = 0;
|
||||
|
||||
void menuHandler::loraMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Device Role", "Radio Preset", "Frequency Slot", "LoRa Region"};
|
||||
static const char *optionsArray[5];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = getString(StringKey::DeviceRole);
|
||||
optionsArray[2] = getString(StringKey::RadioPreset);
|
||||
optionsArray[3] = getString(StringKey::FrequencySlot);
|
||||
optionsArray[4] = getString(StringKey::LoRaRegion);
|
||||
enum optionsNumbers { Back = 0, DeviceRolePicker = 1, RadioPresetPicker = 2, FrequencySlot = 3, LoraPicker = 4 };
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "LoRa Actions";
|
||||
bannerOptions.message = getString(StringKey::LoRaActions);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 5;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
@@ -156,7 +165,9 @@ void menuHandler::loraMenu()
|
||||
|
||||
void menuHandler::OnboardMessage()
|
||||
{
|
||||
static const char *optionsArray[] = {"OK", "Got it!"};
|
||||
static const char *optionsArray[2];
|
||||
optionsArray[0] = getString(StringKey::OK);
|
||||
optionsArray[1] = getString(StringKey::GotIt);
|
||||
enum optionsNumbers { OK, got };
|
||||
BannerOverlayOptions bannerOptions;
|
||||
#if HAS_TFT
|
||||
@@ -220,7 +231,7 @@ static void applyLoraRegion(meshtastic_Config_LoRaConfig_RegionCode region, bool
|
||||
void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
{
|
||||
static const LoraRegionOption regionOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"US", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_US},
|
||||
{"EU_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_433},
|
||||
{"EU_868", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_EU_868},
|
||||
@@ -261,7 +272,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
|
||||
|
||||
const char *bannerMessage = "Set the LoRa region";
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
bannerMessage = "LoRa Region";
|
||||
bannerMessage = getString(StringKey::LoRaRegion);
|
||||
}
|
||||
|
||||
auto bannerOptions =
|
||||
@@ -353,7 +364,12 @@ void menuHandler::licensedToNormalConfirmMenu()
|
||||
|
||||
void menuHandler::deviceRolePicker()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Client", "Client Mute", "Lost and Found", "Tracker"};
|
||||
static const char *optionsArray[5];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = getString(StringKey::Client);
|
||||
optionsArray[2] = getString(StringKey::ClientMute);
|
||||
optionsArray[3] = getString(StringKey::LostAndFound);
|
||||
optionsArray[4] = getString(StringKey::Tracker);
|
||||
enum optionsNumbers {
|
||||
Back = 0,
|
||||
devicerole_client = 1,
|
||||
@@ -362,7 +378,7 @@ void menuHandler::deviceRolePicker()
|
||||
devicerole_tracker = 4
|
||||
};
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Device Role";
|
||||
bannerOptions.message = getString(StringKey::DeviceRole);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 5;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
@@ -394,9 +410,9 @@ void menuHandler::FrequencySlotPicker()
|
||||
static int optionsEnumArray[MAX_CHANNEL_OPTIONS];
|
||||
static char channelText[MAX_CHANNEL_OPTIONS - 1][12];
|
||||
int options = 0;
|
||||
optionsArray[options] = "Back";
|
||||
optionsArray[options] = getString(StringKey::Back);
|
||||
optionsEnumArray[options++] = Back;
|
||||
optionsArray[options] = "Slot 0 (Auto)";
|
||||
optionsArray[options] = getString(StringKey::Slot0);
|
||||
optionsEnumArray[options++] = 0;
|
||||
|
||||
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
|
||||
@@ -433,7 +449,7 @@ void menuHandler::FrequencySlotPicker()
|
||||
}
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Frequency Slot";
|
||||
bannerOptions.message = getString(StringKey::FrequencySlot);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
||||
bannerOptions.optionsCount = options;
|
||||
@@ -469,7 +485,7 @@ static BannerOverlayOptions buildRegionPresetBanner()
|
||||
static char presetLabelBuf[MAX_PRESET_OPTIONS][12]; // scratch space for name copies
|
||||
int count = 0;
|
||||
|
||||
optionsArray[count] = "Back";
|
||||
optionsArray[count] = getString(StringKey::Back);
|
||||
optionsEnumArray[count++] = -1;
|
||||
|
||||
if (myRegion && myRegion->profile) {
|
||||
@@ -493,7 +509,7 @@ static BannerOverlayOptions buildRegionPresetBanner()
|
||||
}
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Radio Preset";
|
||||
bannerOptions.message = getString(StringKey::RadioPreset);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
||||
bannerOptions.optionsCount = static_cast<uint8_t>(count);
|
||||
@@ -520,10 +536,13 @@ void menuHandler::radioPresetPicker()
|
||||
|
||||
void menuHandler::twelveHourPicker()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "12-hour", "24-hour"};
|
||||
static const char *optionsArray[3];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = getString(StringKey::Hour12);
|
||||
optionsArray[2] = getString(StringKey::Hour24);
|
||||
enum optionsNumbers { Back = 0, twelve = 1, twentyfour = 2 };
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Time Format";
|
||||
bannerOptions.message = getString(StringKey::TimeFormat);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 3;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
@@ -559,7 +578,7 @@ void menuHandler::showConfirmationBanner(const char *message, std::function<void
|
||||
void menuHandler::clockFacePicker()
|
||||
{
|
||||
static const ClockFaceOption clockFaceOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"Digital", OptionsAction::Select, false},
|
||||
{"Analog", OptionsAction::Select, true},
|
||||
};
|
||||
@@ -595,7 +614,7 @@ void menuHandler::clockFacePicker()
|
||||
void menuHandler::TZPicker()
|
||||
{
|
||||
static const TimezoneOption timezoneOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"US/Hawaii", OptionsAction::Select, "HST10"},
|
||||
{"US/Alaska", OptionsAction::Select, "AKST9AKDT,M3.2.0,M11.1.0"},
|
||||
{"US/Pacific", OptionsAction::Select, "PST8PDT,M3.2.0,M11.1.0"},
|
||||
@@ -658,13 +677,20 @@ void menuHandler::TZPicker()
|
||||
void menuHandler::clockMenu()
|
||||
{
|
||||
#if defined(OLED_TINY)
|
||||
static const char *optionsArray[] = {"Back", "Time Format", "Timezone"};
|
||||
static const char *optionsArray[3];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = getString(StringKey::TimeFormat);
|
||||
optionsArray[2] = getString(StringKey::Timezone);
|
||||
#else
|
||||
static const char *optionsArray[] = {"Back", "Clock Face", "Time Format", "Timezone"};
|
||||
static const char *optionsArray[4];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = getString(StringKey::ClockFace);
|
||||
optionsArray[2] = getString(StringKey::TimeFormat);
|
||||
optionsArray[3] = getString(StringKey::Timezone);
|
||||
#endif
|
||||
enum optionsNumbers { Back = 0, Clock = 1, Time = 2, Timezone = 3 };
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Clock Action";
|
||||
bannerOptions.message = getString(StringKey::ClockAction);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 4;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
@@ -692,14 +718,14 @@ void menuHandler::messageResponseMenu()
|
||||
auto mode = graphics::MessageRenderer::getThreadMode();
|
||||
int threadChannel = graphics::MessageRenderer::getThreadChannel();
|
||||
|
||||
optionsArray[options] = "Back";
|
||||
optionsArray[options] = getString(StringKey::Back);
|
||||
optionsEnumArray[options++] = Back;
|
||||
|
||||
// New Reply submenu (replaces Preset and Freetext directly in this menu)
|
||||
optionsArray[options] = "Reply";
|
||||
optionsArray[options] = getString(StringKey::Reply);
|
||||
optionsEnumArray[options++] = ReplyMenu;
|
||||
|
||||
optionsArray[options] = "View Chats";
|
||||
optionsArray[options] = getString(StringKey::ViewChats);
|
||||
optionsEnumArray[options++] = ViewMode;
|
||||
|
||||
// If viewing ALL chats, hide “Mute Chat”
|
||||
@@ -707,21 +733,21 @@ void menuHandler::messageResponseMenu()
|
||||
const uint8_t chIndex = (threadChannel != 0) ? (uint8_t)threadChannel : channels.getPrimaryIndex();
|
||||
const auto &chan = channels.getByIndex(chIndex);
|
||||
|
||||
optionsArray[options] = chan.settings.module_settings.is_muted ? "Unmute Channel" : "Mute Channel";
|
||||
optionsArray[options] = chan.settings.module_settings.is_muted ? getString(StringKey::UnmuteChannel) : getString(StringKey::MuteChannel);
|
||||
optionsEnumArray[options++] = MuteChannel;
|
||||
}
|
||||
|
||||
// Delete submenu
|
||||
optionsArray[options] = "Delete";
|
||||
optionsArray[options] = getString(StringKey::Delete);
|
||||
optionsEnumArray[options++] = DeleteMenu;
|
||||
|
||||
#ifdef HAS_I2S
|
||||
optionsArray[options] = "Read Aloud";
|
||||
optionsArray[options] = getString(StringKey::ReadAloud);
|
||||
optionsEnumArray[options++] = Aloud;
|
||||
#endif
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Message Action";
|
||||
bannerOptions.message = getString(StringKey::MessageAction);
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
bannerOptions.message = "Message";
|
||||
}
|
||||
@@ -781,16 +807,16 @@ void menuHandler::replyMenu()
|
||||
int options = 0;
|
||||
|
||||
// Back
|
||||
optionsArray[options] = "Back";
|
||||
optionsArray[options] = getString(StringKey::Back);
|
||||
optionsEnumArray[options++] = Back;
|
||||
|
||||
// Preset reply
|
||||
optionsArray[options] = "With Preset";
|
||||
optionsArray[options] = getString(StringKey::WithPreset);
|
||||
optionsEnumArray[options++] = ReplyPreset;
|
||||
|
||||
// Freetext reply (only when keyboard exists)
|
||||
if (kb_found) {
|
||||
optionsArray[options] = "With Freetext";
|
||||
optionsArray[options] = getString(StringKey::WithFreetext);
|
||||
optionsEnumArray[options++] = ReplyFreetext;
|
||||
}
|
||||
|
||||
@@ -861,21 +887,21 @@ void menuHandler::deleteMessagesMenu()
|
||||
|
||||
auto mode = graphics::MessageRenderer::getThreadMode();
|
||||
|
||||
optionsArray[options] = "Back";
|
||||
optionsArray[options] = getString(StringKey::Back);
|
||||
optionsEnumArray[options++] = Back;
|
||||
|
||||
optionsArray[options] = "Delete Oldest";
|
||||
optionsArray[options] = getString(StringKey::DeleteOldest);
|
||||
optionsEnumArray[options++] = DeleteOldest;
|
||||
|
||||
// If viewing ALL chats → hide “Delete This Chat”
|
||||
if (mode != graphics::MessageRenderer::ThreadMode::ALL) {
|
||||
optionsArray[options] = "Delete This Chat";
|
||||
optionsArray[options] = getString(StringKey::DeleteThisChat);
|
||||
optionsEnumArray[options++] = DeleteThis;
|
||||
}
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
optionsArray[options] = "Delete All";
|
||||
optionsArray[options] = getString(StringKey::DeleteAll);
|
||||
} else {
|
||||
optionsArray[options] = "Delete All Chats";
|
||||
optionsArray[options] = getString(StringKey::DeleteAllChats);
|
||||
}
|
||||
optionsEnumArray[options++] = DeleteAll;
|
||||
|
||||
@@ -945,7 +971,7 @@ void menuHandler::messageViewModeMenu()
|
||||
ids.clear();
|
||||
idToPeer.clear();
|
||||
|
||||
labels.push_back("Back");
|
||||
labels.push_back(getString(StringKey::Back));
|
||||
ids.push_back(-1);
|
||||
labels.push_back("View All Chats");
|
||||
ids.push_back(-2);
|
||||
@@ -1076,30 +1102,31 @@ void menuHandler::homeBaseMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, Mute, Backlight, Position, Preset, Freetext, Sleep, enumEnd };
|
||||
|
||||
static const char *optionsArray[enumEnd] = {"Back"};
|
||||
static const char *optionsArray[enumEnd];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[enumEnd] = {Back};
|
||||
int options = 1;
|
||||
|
||||
if (moduleConfig.external_notification.enabled && externalNotificationModule &&
|
||||
config.device.buzzer_mode != meshtastic_Config_DeviceConfig_BuzzerMode_DISABLED) {
|
||||
if (!externalNotificationModule->getMute()) {
|
||||
optionsArray[options] = "Temporarily Mute";
|
||||
optionsArray[options] = getString(StringKey::TemporarilyMute);
|
||||
} else {
|
||||
optionsArray[options] = "Unmute";
|
||||
optionsArray[options] = getString(StringKey::Unmute);
|
||||
}
|
||||
optionsEnumArray[options++] = Mute;
|
||||
}
|
||||
#if defined(PIN_EINK_EN) || defined(PCA_PIN_EINK_EN)
|
||||
optionsArray[options] = "Toggle Backlight";
|
||||
optionsArray[options] = getString(StringKey::ToggleBacklight);
|
||||
optionsEnumArray[options++] = Backlight;
|
||||
#else
|
||||
optionsArray[options] = "Sleep Screen";
|
||||
optionsArray[options] = getString(StringKey::SleepScreen);
|
||||
optionsEnumArray[options++] = Sleep;
|
||||
#endif
|
||||
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
||||
optionsArray[options] = "Send Position";
|
||||
optionsArray[options] = getString(StringKey::SendPosition);
|
||||
} else {
|
||||
optionsArray[options] = "Send Node Info";
|
||||
optionsArray[options] = getString(StringKey::SendNodeInfo);
|
||||
}
|
||||
optionsEnumArray[options++] = Position;
|
||||
|
||||
@@ -1165,18 +1192,19 @@ void menuHandler::textMessageBaseMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, Preset, Freetext, enumEnd };
|
||||
|
||||
static const char *optionsArray[enumEnd] = {"Back"};
|
||||
static const char *optionsArray[enumEnd];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[enumEnd] = {Back};
|
||||
int options = 1;
|
||||
optionsArray[options] = "New Preset Msg";
|
||||
optionsArray[options] = getString(StringKey::NewPresetMsg);
|
||||
optionsEnumArray[options++] = Preset;
|
||||
if (kb_found) {
|
||||
optionsArray[options] = "New Freetext Msg";
|
||||
optionsArray[options] = getString(StringKey::NewFreetextMsg);
|
||||
optionsEnumArray[options++] = Freetext;
|
||||
}
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Message Action";
|
||||
bannerOptions.message = getString(StringKey::MessageAction);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
||||
bannerOptions.optionsCount = options;
|
||||
@@ -1193,36 +1221,37 @@ void menuHandler::textMessageBaseMenu()
|
||||
void menuHandler::systemBaseMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, Notifications, ScreenOptions, Bluetooth, WiFiToggle, PowerMenu, Test, enumEnd };
|
||||
static const char *optionsArray[enumEnd] = {"Back"};
|
||||
static const char *optionsArray[enumEnd];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[enumEnd] = {Back};
|
||||
int options = 1;
|
||||
|
||||
optionsArray[options] = "Notifications";
|
||||
optionsArray[options] = getString(StringKey::Notifications);
|
||||
optionsEnumArray[options++] = Notifications;
|
||||
|
||||
optionsArray[options] = "Display Options";
|
||||
optionsArray[options] = getString(StringKey::DisplayOptions);
|
||||
optionsEnumArray[options++] = ScreenOptions;
|
||||
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
optionsArray[options] = "Bluetooth";
|
||||
optionsArray[options] = getString(StringKey::Bluetooth);
|
||||
} else {
|
||||
optionsArray[options] = "Bluetooth Toggle";
|
||||
optionsArray[options] = getString(StringKey::BluetoothToggle);
|
||||
}
|
||||
optionsEnumArray[options++] = Bluetooth;
|
||||
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
|
||||
optionsArray[options] = "WiFi Toggle";
|
||||
optionsArray[options] = getString(StringKey::WifiToggle);
|
||||
optionsEnumArray[options++] = WiFiToggle;
|
||||
#endif
|
||||
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
optionsArray[options] = "Power";
|
||||
optionsArray[options] = getString(StringKey::Power);
|
||||
} else {
|
||||
optionsArray[options] = "Reboot/Shutdown";
|
||||
optionsArray[options] = getString(StringKey::RebootShutdown);
|
||||
}
|
||||
optionsEnumArray[options++] = PowerMenu;
|
||||
|
||||
if (test_enabled) {
|
||||
optionsArray[options] = "Test Menu";
|
||||
optionsArray[options] = getString(StringKey::TestMenu);
|
||||
optionsEnumArray[options++] = Test;
|
||||
}
|
||||
|
||||
@@ -1269,7 +1298,8 @@ void menuHandler::favoriteBaseMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, Preset, Freetext, GoToChat, Remove, TraceRoute, enumEnd };
|
||||
|
||||
static const char *optionsArray[enumEnd] = {"Back"};
|
||||
static const char *optionsArray[enumEnd];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[enumEnd] = {Back};
|
||||
int options = 1;
|
||||
|
||||
@@ -1283,26 +1313,26 @@ void menuHandler::favoriteBaseMenu()
|
||||
}
|
||||
}
|
||||
if (hasConversation) {
|
||||
optionsArray[options] = "Go To Chat";
|
||||
optionsArray[options] = getString(StringKey::GoToChat);
|
||||
optionsEnumArray[options++] = GoToChat;
|
||||
}
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
optionsArray[options] = "New Preset";
|
||||
optionsArray[options] = getString(StringKey::NewPreset);
|
||||
} else {
|
||||
optionsArray[options] = "New Preset Msg";
|
||||
optionsArray[options] = getString(StringKey::NewPresetMsg);
|
||||
}
|
||||
optionsEnumArray[options++] = Preset;
|
||||
|
||||
if (kb_found) {
|
||||
optionsArray[options] = "New Freetext Msg";
|
||||
optionsArray[options] = getString(StringKey::NewFreetextMsg);
|
||||
optionsEnumArray[options++] = Freetext;
|
||||
}
|
||||
|
||||
if (currentResolution != ScreenResolution::UltraLow) {
|
||||
optionsArray[options] = "Trace Route";
|
||||
optionsArray[options] = getString(StringKey::TraceRoute);
|
||||
optionsEnumArray[options++] = TraceRoute;
|
||||
}
|
||||
optionsArray[options] = "Remove Favorite";
|
||||
optionsArray[options] = getString(StringKey::RemoveFavorite);
|
||||
optionsEnumArray[options++] = Remove;
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
@@ -1354,7 +1384,7 @@ void menuHandler::positionBaseMenu()
|
||||
};
|
||||
|
||||
static const PositionMenuOption baseOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"On/Off Toggle", OptionsAction::Select, static_cast<int>(PositionAction::GpsToggle)},
|
||||
{"Format", OptionsAction::Select, static_cast<int>(PositionAction::GpsFormat)},
|
||||
{"Smart Position", OptionsAction::Select, static_cast<int>(PositionAction::GPSSmartPosition)},
|
||||
@@ -1364,7 +1394,7 @@ void menuHandler::positionBaseMenu()
|
||||
};
|
||||
|
||||
static const PositionMenuOption calibrateOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"On/Off Toggle", OptionsAction::Select, static_cast<int>(PositionAction::GpsToggle)},
|
||||
{"Format", OptionsAction::Select, static_cast<int>(PositionAction::GpsFormat)},
|
||||
{"Smart Position", OptionsAction::Select, static_cast<int>(PositionAction::GPSSmartPosition)},
|
||||
@@ -1448,18 +1478,19 @@ void menuHandler::positionBaseMenu()
|
||||
void menuHandler::nodeListMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, NodePicker, TraceRoute, Verify, Reset, NodeNameLength, enumEnd };
|
||||
static const char *optionsArray[enumEnd] = {"Back"};
|
||||
static const char *optionsArray[enumEnd];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[enumEnd] = {Back};
|
||||
int options = 1;
|
||||
|
||||
optionsArray[options] = "Node Actions / Settings";
|
||||
optionsArray[options] = getString(StringKey::NodeActionsSettings);
|
||||
optionsEnumArray[options++] = NodePicker;
|
||||
|
||||
if (currentResolution != ScreenResolution::UltraLow) {
|
||||
optionsArray[options] = "Show Long/Short Name";
|
||||
optionsArray[options] = getString(StringKey::ShowNameLength);
|
||||
optionsEnumArray[options++] = NodeNameLength;
|
||||
}
|
||||
optionsArray[options] = "Reset NodeDB";
|
||||
optionsArray[options] = getString(StringKey::NodeDbReset);
|
||||
optionsEnumArray[options++] = Reset;
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
@@ -1509,35 +1540,36 @@ void menuHandler::manageNodeMenu()
|
||||
return;
|
||||
}
|
||||
enum optionsNumbers { Back, Favorite, Mute, TraceRoute, KeyVerification, Ignore, enumEnd };
|
||||
static const char *optionsArray[enumEnd] = {"Back"};
|
||||
static const char *optionsArray[enumEnd];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[enumEnd] = {Back};
|
||||
int options = 1;
|
||||
|
||||
if (nodeInfoLiteIsFavorite(node)) {
|
||||
optionsArray[options] = "Unfavorite";
|
||||
optionsArray[options] = getString(StringKey::RemoveFavorite);
|
||||
} else {
|
||||
optionsArray[options] = "Favorite";
|
||||
optionsArray[options] = getString(StringKey::Favorite);
|
||||
}
|
||||
optionsEnumArray[options++] = Favorite;
|
||||
|
||||
bool isMuted = nodeInfoLiteIsMuted(node);
|
||||
if (isMuted) {
|
||||
optionsArray[options] = "Unmute Notifications";
|
||||
optionsArray[options] = getString(StringKey::UnmuteNotifications);
|
||||
} else {
|
||||
optionsArray[options] = "Mute Notifications";
|
||||
optionsArray[options] = getString(StringKey::MuteNotifications);
|
||||
}
|
||||
optionsEnumArray[options++] = Mute;
|
||||
|
||||
optionsArray[options] = "Trace Route";
|
||||
optionsArray[options] = getString(StringKey::TraceRoute);
|
||||
optionsEnumArray[options++] = TraceRoute;
|
||||
|
||||
optionsArray[options] = "Key Verification";
|
||||
optionsArray[options] = getString(StringKey::KeyVerification);
|
||||
optionsEnumArray[options++] = KeyVerification;
|
||||
|
||||
if (nodeInfoLiteIsIgnored(node)) {
|
||||
optionsArray[options] = "Unignore Node";
|
||||
optionsArray[options] = getString(StringKey::UnignoreNode);
|
||||
} else {
|
||||
optionsArray[options] = "Ignore Node";
|
||||
optionsArray[options] = getString(StringKey::IgnoreNode);
|
||||
}
|
||||
optionsEnumArray[options++] = Ignore;
|
||||
|
||||
@@ -1643,7 +1675,7 @@ void menuHandler::manageNodeMenu()
|
||||
void menuHandler::nodeNameLengthMenu()
|
||||
{
|
||||
static const NodeNameOption nodeNameOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"Long", OptionsAction::Select, true},
|
||||
{"Short", OptionsAction::Select, false},
|
||||
};
|
||||
@@ -1681,7 +1713,10 @@ void menuHandler::nodeNameLengthMenu()
|
||||
|
||||
void menuHandler::resetNodeDBMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Reset All", "Preserve Favorites"};
|
||||
static const char *optionsArray[3];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "Reset All";
|
||||
optionsArray[2] = "Preserve Favorites";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Confirm Reset NodeDB";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -1710,7 +1745,7 @@ void menuHandler::resetNodeDBMenu()
|
||||
void menuHandler::compassNorthMenu()
|
||||
{
|
||||
static const CompassOption compassOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"Dynamic", OptionsAction::Select, meshtastic_CompassMode_DYNAMIC},
|
||||
{"Fixed Ring", OptionsAction::Select, meshtastic_CompassMode_FIXED_RING},
|
||||
{"Freeze Heading", OptionsAction::Select, meshtastic_CompassMode_FREEZE_HEADING},
|
||||
@@ -1756,7 +1791,7 @@ void menuHandler::compassNorthMenu()
|
||||
void menuHandler::GPSToggleMenu()
|
||||
{
|
||||
static const GPSToggleOption gpsToggleOptions[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"Enabled", OptionsAction::Select, meshtastic_Config_PositionConfig_GpsMode_ENABLED},
|
||||
{"Disabled", OptionsAction::Select, meshtastic_Config_PositionConfig_GpsMode_DISABLED},
|
||||
};
|
||||
@@ -1805,7 +1840,7 @@ void menuHandler::GPSToggleMenu()
|
||||
void menuHandler::GPSFormatMenu()
|
||||
{
|
||||
static const GPSFormatOption formatOptionsHigh[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"Decimal Degrees", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_DEC},
|
||||
{"Degrees Minutes Seconds", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS},
|
||||
{"Universal Transverse Mercator", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_UTM},
|
||||
@@ -1816,7 +1851,7 @@ void menuHandler::GPSFormatMenu()
|
||||
};
|
||||
|
||||
static const GPSFormatOption formatOptionsLow[] = {
|
||||
{"Back", OptionsAction::Back},
|
||||
{getString(StringKey::Back), OptionsAction::Back},
|
||||
{"DEC", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_DEC},
|
||||
{"DMS", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS},
|
||||
{"UTM", OptionsAction::Select, meshtastic_DeviceUIConfig_GpsCoordinateFormat_UTM},
|
||||
@@ -1854,7 +1889,7 @@ void menuHandler::GPSFormatMenu()
|
||||
int initialSelection = 0;
|
||||
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
bannerOptions = createStaticBannerOptions("GPS Format", formatOptionsHigh, formatLabelsHigh, onSelection);
|
||||
bannerOptions = createStaticBannerOptions(getString(StringKey::GPSFormat), formatOptionsHigh, formatLabelsHigh, onSelection);
|
||||
for (size_t i = 0; i < formatCount; ++i) {
|
||||
if (formatOptionsHigh[i].hasValue && uiconfig.gps_format == formatOptionsHigh[i].value) {
|
||||
initialSelection = static_cast<int>(i);
|
||||
@@ -1862,7 +1897,7 @@ void menuHandler::GPSFormatMenu()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bannerOptions = createStaticBannerOptions("GPS Format", formatOptionsLow, formatLabelsLow, onSelection);
|
||||
bannerOptions = createStaticBannerOptions(getString(StringKey::GPSFormat), formatOptionsLow, formatLabelsLow, onSelection);
|
||||
for (size_t i = 0; i < formatCount; ++i) {
|
||||
if (formatOptionsLow[i].hasValue && uiconfig.gps_format == formatOptionsLow[i].value) {
|
||||
initialSelection = static_cast<int>(i);
|
||||
@@ -1877,7 +1912,10 @@ void menuHandler::GPSFormatMenu()
|
||||
|
||||
void menuHandler::GPSSmartPositionMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Enabled", "Disabled"};
|
||||
static const char *optionsArray[3];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "Enabled";
|
||||
optionsArray[2] = "Disabled";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Toggle Smart Position";
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
@@ -1907,9 +1945,23 @@ void menuHandler::GPSSmartPositionMenu()
|
||||
|
||||
void menuHandler::GPSUpdateIntervalMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "8 seconds", "20 seconds", "40 seconds", "1 minute", "80 seconds",
|
||||
"2 minutes", "5 minutes", "10 minutes", "15 minutes", "30 minutes", "1 hour",
|
||||
"6 hours", "12 hours", "24 hours", "At Boot Only"};
|
||||
static const char *optionsArray[16];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "8 seconds";
|
||||
optionsArray[2] = "20 seconds";
|
||||
optionsArray[3] = "40 seconds";
|
||||
optionsArray[4] = "1 minute";
|
||||
optionsArray[5] = "80 seconds";
|
||||
optionsArray[6] = "2 minutes";
|
||||
optionsArray[7] = "5 minutes";
|
||||
optionsArray[8] = "10 minutes";
|
||||
optionsArray[9] = "15 minutes";
|
||||
optionsArray[10] = "30 minutes";
|
||||
optionsArray[11] = "1 hour";
|
||||
optionsArray[12] = "6 hours";
|
||||
optionsArray[13] = "12 hours";
|
||||
optionsArray[14] = "24 hours";
|
||||
optionsArray[15] = "At Boot Only";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Update Interval";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -1995,9 +2047,24 @@ void menuHandler::GPSUpdateIntervalMenu()
|
||||
|
||||
void menuHandler::GPSPositionBroadcastMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "1 minute", "90 seconds", "5 minutes", "15 minutes", "1 hour",
|
||||
"2 hours", "3 hours", "4 hours", "5 hours", "6 hours", "12 hours",
|
||||
"18 hours", "24 hours", "36 hours", "48 hours", "72 hours"};
|
||||
static const char *optionsArray[17];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "1 minute";
|
||||
optionsArray[2] = "90 seconds";
|
||||
optionsArray[3] = "5 minutes";
|
||||
optionsArray[4] = "15 minutes";
|
||||
optionsArray[5] = "1 hour";
|
||||
optionsArray[6] = "2 hours";
|
||||
optionsArray[7] = "3 hours";
|
||||
optionsArray[8] = "4 hours";
|
||||
optionsArray[9] = "5 hours";
|
||||
optionsArray[10] = "6 hours";
|
||||
optionsArray[11] = "12 hours";
|
||||
optionsArray[12] = "18 hours";
|
||||
optionsArray[13] = "24 hours";
|
||||
optionsArray[14] = "36 hours";
|
||||
optionsArray[15] = "48 hours";
|
||||
optionsArray[16] = "72 hours";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Broadcast Interval";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -2089,11 +2156,14 @@ void menuHandler::GPSPositionBroadcastMenu()
|
||||
|
||||
void menuHandler::bluetoothToggleMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Enabled", "Disabled"};
|
||||
static const char *optionsArray[3];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "Enabled";
|
||||
optionsArray[2] = "Disabled";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Toggle Bluetooth";
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
bannerOptions.message = "Bluetooth";
|
||||
bannerOptions.message = getString(StringKey::Bluetooth);
|
||||
}
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 3;
|
||||
@@ -2111,7 +2181,12 @@ void menuHandler::bluetoothToggleMenu()
|
||||
|
||||
void menuHandler::BuzzerModeMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"All Enabled", "All Disabled", "Notifications", "System Only", "DMs Only"};
|
||||
static const char *optionsArray[5];
|
||||
optionsArray[0] = "All Enabled";
|
||||
optionsArray[1] = "All Disabled";
|
||||
optionsArray[2] = getString(StringKey::Notifications);
|
||||
optionsArray[3] = "System Only";
|
||||
optionsArray[4] = "DMs Only";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Notification Sounds";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -2126,7 +2201,11 @@ void menuHandler::BuzzerModeMenu()
|
||||
|
||||
void menuHandler::BrightnessPickerMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Low", "Medium", "High"};
|
||||
static const char *optionsArray[4];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "Low";
|
||||
optionsArray[2] = "Medium";
|
||||
optionsArray[3] = "High";
|
||||
|
||||
// Get current brightness level to set initial selection
|
||||
int currentSelection = 1; // Default to Medium
|
||||
@@ -2139,7 +2218,7 @@ void menuHandler::BrightnessPickerMenu()
|
||||
}
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Brightness";
|
||||
bannerOptions.message = getString(StringKey::Brightness);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 4;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
@@ -2151,7 +2230,7 @@ void menuHandler::BrightnessPickerMenu()
|
||||
uiconfig.screen_brightness = 255;
|
||||
}
|
||||
|
||||
if (selected != 0) { // Not "Back"
|
||||
if (selected != 0) { // Not getString(StringKey::Back)
|
||||
// Apply brightness immediately
|
||||
#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190)
|
||||
// For HELTEC devices, use analogWrite to control backlight
|
||||
@@ -2174,7 +2253,9 @@ void menuHandler::BrightnessPickerMenu()
|
||||
|
||||
void menuHandler::switchToMUIMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"No", "Yes"};
|
||||
static const char *optionsArray[2];
|
||||
optionsArray[0] = "No";
|
||||
optionsArray[1] = "Yes";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Switch to MUI?";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -2192,11 +2273,13 @@ void menuHandler::switchToMUIMenu()
|
||||
|
||||
void menuHandler::rebootMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Confirm"};
|
||||
static const char *optionsArray[2];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "Confirm";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Reboot Device?";
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
bannerOptions.message = "Reboot";
|
||||
bannerOptions.message = getString(StringKey::Reboot);
|
||||
}
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 2;
|
||||
@@ -2216,11 +2299,13 @@ void menuHandler::rebootMenu()
|
||||
|
||||
void menuHandler::shutdownMenu()
|
||||
{
|
||||
static const char *optionsArray[] = {"Back", "Confirm"};
|
||||
static const char *optionsArray[2];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "Confirm";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Shutdown Device?";
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
bannerOptions.message = "Shutdown";
|
||||
bannerOptions.message = getString(StringKey::Shutdown);
|
||||
}
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 2;
|
||||
@@ -2239,7 +2324,9 @@ void menuHandler::shutdownMenu()
|
||||
void menuHandler::removeFavoriteMenu()
|
||||
{
|
||||
|
||||
static const char *optionsArray[] = {"Back", "Yes"};
|
||||
static const char *optionsArray[2];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "Yes";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
std::string message = "Unfavorite This Node?\n";
|
||||
const auto *node = nodeDB->getMeshNode(graphics::UIRenderer::currentFavoriteNodeNum);
|
||||
@@ -2273,17 +2360,18 @@ void menuHandler::testMenu()
|
||||
{
|
||||
|
||||
enum optionsNumbers { Back, NumberPicker, ShowChirpy, TestAnnounce };
|
||||
static const char *optionsArray[5] = {"Back"};
|
||||
static const char *optionsArray[5];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[5] = {Back};
|
||||
int options = 1;
|
||||
|
||||
optionsArray[options] = "Number Picker";
|
||||
optionsArray[options] = getString(StringKey::NumberPicker);
|
||||
optionsEnumArray[options++] = NumberPicker;
|
||||
|
||||
optionsArray[options] = screen->isFrameHidden("chirpy") ? "Show Chirpy" : "Hide Chirpy";
|
||||
optionsEnumArray[options++] = ShowChirpy;
|
||||
#ifdef HAS_I2S
|
||||
optionsArray[options] = "Test Announce";
|
||||
optionsArray[options] = getString(StringKey::TestAnnounce);
|
||||
optionsEnumArray[options++] = TestAnnounce;
|
||||
#endif
|
||||
|
||||
@@ -2322,7 +2410,9 @@ void menuHandler::wifiBaseMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, Wifi_toggle };
|
||||
|
||||
static const char *optionsArray[] = {"Back", "WiFi Toggle"};
|
||||
static const char *optionsArray[2];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = getString(StringKey::WifiToggle);
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "WiFi Menu";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -2340,7 +2430,10 @@ void menuHandler::wifiToggleMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, Wifi_disable, Wifi_enable };
|
||||
|
||||
static const char *optionsArray[] = {"Back", "WiFi Disabled", "WiFi Enabled"};
|
||||
static const char *optionsArray[3];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "WiFi Disabled";
|
||||
optionsArray[2] = "WiFi Enabled";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "WiFi Actions";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -2377,33 +2470,40 @@ void menuHandler::screenOptionsMenu()
|
||||
bool hasSupportBrightness = false;
|
||||
#endif
|
||||
|
||||
enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme };
|
||||
static const char *optionsArray[7] = {"Back"};
|
||||
static int optionsEnumArray[7] = {Back};
|
||||
enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme, LangPicker };
|
||||
static const char *optionsArray[8];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[8] = {Back};
|
||||
int options = 1;
|
||||
|
||||
// Only show brightness for B&W displays
|
||||
if (hasSupportBrightness) {
|
||||
optionsArray[options] = "Brightness";
|
||||
optionsArray[options] = getString(StringKey::Brightness);
|
||||
optionsEnumArray[options++] = Brightness;
|
||||
}
|
||||
|
||||
optionsArray[options] = "Frame Visibility";
|
||||
optionsArray[options] = getString(StringKey::FrameVisibility);
|
||||
optionsEnumArray[options++] = FrameToggles;
|
||||
|
||||
optionsArray[options] = "Display Units";
|
||||
optionsArray[options] = getString(StringKey::DisplayUnits);
|
||||
optionsEnumArray[options++] = DisplayUnits;
|
||||
|
||||
optionsArray[options] = "Message Bubbles";
|
||||
optionsArray[options] = getString(StringKey::MessageBubbles);
|
||||
optionsEnumArray[options++] = MessageBubbles;
|
||||
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
optionsArray[options] = "Theme";
|
||||
optionsArray[options] = getString(StringKey::Theme);
|
||||
optionsEnumArray[options++] = Theme;
|
||||
#endif
|
||||
|
||||
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
|
||||
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014)
|
||||
optionsArray[options] = getString(StringKey::Language);
|
||||
optionsEnumArray[options++] = LangPicker;
|
||||
#endif
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Display Options";
|
||||
bannerOptions.message = getString(StringKey::DisplayOptions);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = options;
|
||||
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
||||
@@ -2423,6 +2523,9 @@ void menuHandler::screenOptionsMenu()
|
||||
} else if (selected == Theme) {
|
||||
menuHandler::menuQueue = menuHandler::ThemeMenu;
|
||||
screen->runNow();
|
||||
} else if (selected == LangPicker) {
|
||||
menuHandler::menuQueue = menuHandler::LanguagePicker;
|
||||
screen->runNow();
|
||||
} else {
|
||||
menuQueue = SystemBaseMenu;
|
||||
screen->runNow();
|
||||
@@ -2435,25 +2538,26 @@ void menuHandler::powerMenu()
|
||||
{
|
||||
|
||||
enum optionsNumbers { Back, Reboot, Shutdown, MUI };
|
||||
static const char *optionsArray[4] = {"Back"};
|
||||
static const char *optionsArray[4];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[4] = {Back};
|
||||
int options = 1;
|
||||
|
||||
optionsArray[options] = "Reboot";
|
||||
optionsArray[options] = getString(StringKey::Reboot);
|
||||
optionsEnumArray[options++] = Reboot;
|
||||
|
||||
optionsArray[options] = "Shutdown";
|
||||
optionsArray[options] = getString(StringKey::Shutdown);
|
||||
optionsEnumArray[options++] = Shutdown;
|
||||
|
||||
#if HAS_TFT
|
||||
optionsArray[options] = "Switch to MUI";
|
||||
optionsArray[options] = getString(StringKey::SwitchToMUI);
|
||||
optionsEnumArray[options++] = MUI;
|
||||
#endif
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Reboot / Shutdown";
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
bannerOptions.message = "Power";
|
||||
bannerOptions.message = getString(StringKey::Power);
|
||||
}
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = options;
|
||||
@@ -2490,7 +2594,9 @@ void menuHandler::keyVerificationFinalPrompt()
|
||||
keyVerificationModule->generateVerificationCode(message + 15); // send the toPhone packet
|
||||
|
||||
if (screen) {
|
||||
static const char *optionsArray[] = {"Reject", "Accept"};
|
||||
static const char *optionsArray[2];
|
||||
optionsArray[0] = "Reject";
|
||||
optionsArray[1] = "Accept";
|
||||
graphics::BannerOverlayOptions options;
|
||||
options.message = message;
|
||||
options.durationMs = 30000;
|
||||
@@ -2526,7 +2632,8 @@ void menuHandler::frameTogglesMenu()
|
||||
show_power,
|
||||
enumEnd
|
||||
};
|
||||
static const char *optionsArray[enumEnd] = {"Finish"};
|
||||
static const char *optionsArray[enumEnd];
|
||||
optionsArray[0] = "Finish";
|
||||
static int optionsEnumArray[enumEnd] = {Finish};
|
||||
int options = 1;
|
||||
|
||||
@@ -2655,7 +2762,10 @@ void menuHandler::displayUnitsMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, MetricUnits, ImperialUnits };
|
||||
|
||||
static const char *optionsArray[] = {"Back", "Metric", "Imperial"};
|
||||
static const char *optionsArray[3];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "Metric";
|
||||
optionsArray[2] = "Imperial";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = " Select display units";
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
@@ -2683,9 +2793,12 @@ void menuHandler::messageBubblesMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, ShowBubbles, HideBubbles };
|
||||
|
||||
static const char *optionsArray[] = {"Back", "Show Bubbles", "Hide Bubbles"};
|
||||
static const char *optionsArray[3];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
optionsArray[1] = "Show Bubbles";
|
||||
optionsArray[2] = "Hide Bubbles";
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Message Bubbles";
|
||||
bannerOptions.message = getString(StringKey::MessageBubbles);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = 3;
|
||||
bannerOptions.InitialSelected = config.display.enable_message_bubbles ? 1 : 2;
|
||||
@@ -2710,11 +2823,12 @@ void menuHandler::themeMenu()
|
||||
{
|
||||
// Build menu dynamically from the theme table.
|
||||
// Only visible themes appear!
|
||||
// Slot budget: 1 for "Back" + up to kMaxThemesInMenu visible themes.
|
||||
// Slot budget: 1 for getString(StringKey::Back) + up to kMaxThemesInMenu visible themes.
|
||||
// Bump kMaxThemesInMenu if you add more themes than will fit here.
|
||||
constexpr size_t kMaxThemesInMenu = 15;
|
||||
const size_t visibleCount = getVisibleThemeCount();
|
||||
static const char *optionsArray[kMaxThemesInMenu + 1] = {"Back"};
|
||||
static const char *optionsArray[kMaxThemesInMenu + 1];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
const size_t shownCount = (visibleCount < kMaxThemesInMenu) ? visibleCount : kMaxThemesInMenu;
|
||||
const int options = static_cast<int>(shownCount) + 1; // +1 for Back
|
||||
|
||||
@@ -2723,12 +2837,12 @@ void menuHandler::themeMenu()
|
||||
}
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = "Theme";
|
||||
bannerOptions.message = getString(StringKey::Theme);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = options;
|
||||
|
||||
// Highlight the currently active theme (visible index + 1 for the Back
|
||||
// offset). If the active theme is hidden, leave selection on "Back".
|
||||
// offset). If the active theme is hidden, leave selection on getString(StringKey::Back).
|
||||
const size_t activeVisible = getActiveVisibleThemeIndex();
|
||||
bannerOptions.InitialSelected = (activeVisible == SIZE_MAX) ? 0 : static_cast<int>(activeVisible) + 1;
|
||||
|
||||
@@ -2903,6 +3017,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
case ThemeMenu:
|
||||
themeMenu();
|
||||
break;
|
||||
case LanguagePicker:
|
||||
languagePickerMenu();
|
||||
break;
|
||||
case HamModeConfirm:
|
||||
hamModeConfirmMenu();
|
||||
break;
|
||||
@@ -2913,6 +3030,45 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
|
||||
menuQueue = MenuNone;
|
||||
}
|
||||
|
||||
void menuHandler::languagePickerMenu()
|
||||
{
|
||||
enum optionsNumbers { Back, English, Chinese };
|
||||
static const char *optionsArray[3];
|
||||
optionsArray[0] = getString(StringKey::Back);
|
||||
static int optionsEnumArray[3] = {Back};
|
||||
int options = 1;
|
||||
|
||||
optionsArray[options] = getString(StringKey::English);
|
||||
optionsEnumArray[options++] = English;
|
||||
|
||||
optionsArray[options] = getString(StringKey::Chinese);
|
||||
optionsEnumArray[options++] = Chinese;
|
||||
|
||||
BannerOverlayOptions bannerOptions;
|
||||
bannerOptions.message = getString(StringKey::Language);
|
||||
bannerOptions.optionsArrayPtr = optionsArray;
|
||||
bannerOptions.optionsCount = options;
|
||||
bannerOptions.optionsEnumPtr = optionsEnumArray;
|
||||
bannerOptions.bannerCallback = [](int selected) -> void {
|
||||
if (selected == English) {
|
||||
uiconfig.language = meshtastic_Language_ENGLISH;
|
||||
meshtastic::l10n::currentDisplayLanguage = meshtastic_Language_ENGLISH;
|
||||
saveUIConfig();
|
||||
} else if (selected == Chinese) {
|
||||
uiconfig.language = meshtastic_Language_SIMPLIFIED_CHINESE;
|
||||
meshtastic::l10n::currentDisplayLanguage = meshtastic_Language_SIMPLIFIED_CHINESE;
|
||||
saveUIConfig();
|
||||
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
|
||||
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014)
|
||||
screen->loadCJKFontIfNeeded();
|
||||
#endif
|
||||
}
|
||||
menuQueue = ScreenOptionsMenu;
|
||||
screen->runNow();
|
||||
};
|
||||
screen->showOverlayBanner(bannerOptions);
|
||||
}
|
||||
|
||||
void menuHandler::saveUIConfig()
|
||||
{
|
||||
nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uiconfig);
|
||||
|
||||
@@ -56,6 +56,7 @@ class menuHandler
|
||||
DisplayUnits,
|
||||
MessageBubblesMenu,
|
||||
ThemeMenu,
|
||||
LanguagePicker,
|
||||
HamModeConfirm,
|
||||
LicensedToNormalConfirm
|
||||
};
|
||||
@@ -107,6 +108,7 @@ class menuHandler
|
||||
static void wifiBaseMenu();
|
||||
static void wifiToggleMenu();
|
||||
static void screenOptionsMenu();
|
||||
static void languagePickerMenu();
|
||||
static void powerMenu();
|
||||
static void nodeNameLengthMenu();
|
||||
static void frameTogglesMenu();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/l10n/Strings.h"
|
||||
#include "graphics/TimeFormatters.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include "main.h"
|
||||
@@ -75,6 +76,15 @@ void scrollDown()
|
||||
|
||||
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
|
||||
{
|
||||
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
|
||||
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014) || defined(USE_ST7789) || \
|
||||
defined(USE_ST7796)
|
||||
if (meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_SIMPLIFIED_CHINESE ||
|
||||
meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_TRADITIONAL_CHINESE) {
|
||||
screen->drawTextL10n(x, y, line.c_str());
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount);
|
||||
}
|
||||
|
||||
@@ -482,7 +492,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
// Still in ALL mode and no messages at all → show placeholder
|
||||
graphics::drawCommonHeader(display, x, y, titleStr);
|
||||
didReset = false;
|
||||
const char *messageString = "No messages";
|
||||
const char *messageString = meshtastic::l10n::getString(meshtastic::l10n::StringKey::NoMessages);
|
||||
int center_text = (SCREEN_WIDTH / 2) - (display->getStringWidth(messageString) / 2);
|
||||
display->drawString(center_text, getTextPositions(display)[2], messageString);
|
||||
graphics::drawCommonFooter(display, x, y);
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
#include "graphics/TFTColorRegions.h"
|
||||
#include "graphics/TFTPalette.h"
|
||||
#include "graphics/images.h"
|
||||
#include "graphics/l10n/Strings.h"
|
||||
#include "graphics/Screen.h"
|
||||
#include "input/RotaryEncoderInterruptImpl1.h"
|
||||
#include "input/UpDownInterruptImpl1.h"
|
||||
#if HAS_BUTTON
|
||||
@@ -39,6 +41,27 @@ extern std::vector<std::string> functionSymbol;
|
||||
extern std::string functionSymbolString;
|
||||
extern bool hasUnreadMessage;
|
||||
|
||||
namespace
|
||||
{
|
||||
inline bool isCJKLanguage()
|
||||
{
|
||||
return meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_SIMPLIFIED_CHINESE ||
|
||||
meshtastic::l10n::currentDisplayLanguage == meshtastic_Language_TRADITIONAL_CHINESE;
|
||||
}
|
||||
|
||||
inline void drawBannerText(OLEDDisplay *display, int16_t x, int16_t y, const char *text)
|
||||
{
|
||||
#if defined(ST7735_CS) || defined(ST7789_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || \
|
||||
defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(RAK14014)
|
||||
if (isCJKLanguage() && ::screen) {
|
||||
::screen->drawTextL10n(x, y, text);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
display->drawString(x, y, text);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
int bannerSignalBars = -1;
|
||||
@@ -184,7 +207,7 @@ void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiStat
|
||||
{
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->setFont(FONT_SMALL);
|
||||
display->drawString(64 + x, y, "Creating SSL certificate");
|
||||
display->drawString(64 + x, y, meshtastic::l10n::getString(meshtastic::l10n::StringKey::CreatingSSLCert));
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
yield();
|
||||
@@ -193,9 +216,9 @@ void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiStat
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
if ((millis() / 1000) % 2) {
|
||||
display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . .");
|
||||
display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, meshtastic::l10n::getString(meshtastic::l10n::StringKey::PleaseWait));
|
||||
} else {
|
||||
display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ");
|
||||
display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, meshtastic::l10n::getString(meshtastic::l10n::StringKey::PleaseWait));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,9 +942,12 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
display->setColor(BLACK);
|
||||
int yOffset = 3;
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (isCJKLanguage())
|
||||
drawBannerText(display, textX, lineY - yOffset, lineBuffer);
|
||||
else
|
||||
UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
} else {
|
||||
display->drawString(textX, lineY - yOffset, lineBuffer);
|
||||
drawBannerText(display, textX, lineY - yOffset, lineBuffer);
|
||||
}
|
||||
display->setColor(WHITE);
|
||||
lineY += (thisLineHeight - 2 - background_yOffset);
|
||||
@@ -943,9 +969,12 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
int groupStartX = boxLeft + (boxWidth - totalWidth) / 2;
|
||||
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (isCJKLanguage())
|
||||
drawBannerText(display, groupStartX, lineY, lineBuffer);
|
||||
else
|
||||
UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
} else {
|
||||
display->drawString(groupStartX, lineY, lineBuffer);
|
||||
drawBannerText(display, groupStartX, lineY, lineBuffer);
|
||||
}
|
||||
|
||||
int baseX = groupStartX + textWidth + gap;
|
||||
@@ -977,9 +1006,12 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
}
|
||||
} else {
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (isCJKLanguage())
|
||||
drawBannerText(display, textX, lineY, lineBuffer);
|
||||
else
|
||||
UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
} else {
|
||||
display->drawString(textX, lineY, lineBuffer);
|
||||
drawBannerText(display, textX, lineY, lineBuffer);
|
||||
}
|
||||
}
|
||||
lineY += thisLineHeight;
|
||||
@@ -1009,24 +1041,24 @@ void NotificationRenderer::drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisp
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_MEDIUM);
|
||||
|
||||
char tempBuf[24];
|
||||
snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code);
|
||||
char tempBuf[40];
|
||||
snprintf(tempBuf, sizeof(tempBuf), "%s #%d", meshtastic::l10n::getString(meshtastic::l10n::StringKey::CriticalFault), error_code);
|
||||
display->drawString(0 + x, 0 + y, tempBuf);
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org");
|
||||
display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, meshtastic::l10n::getString(meshtastic::l10n::StringKey::ForHelpVisit));
|
||||
}
|
||||
|
||||
void NotificationRenderer::drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->setFont(FONT_MEDIUM);
|
||||
display->drawString(64 + x, y, "Updating");
|
||||
display->drawString(64 + x, y, meshtastic::l10n::getString(meshtastic::l10n::StringKey::Updating));
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(),
|
||||
"Please be patient and do not power off.");
|
||||
meshtastic::l10n::getString(meshtastic::l10n::StringKey::PleaseBePatient));
|
||||
}
|
||||
|
||||
void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Chinese font data (VLW format) for OLEDDisplay-based TFT rendering via LovyanGFX.
|
||||
//
|
||||
// HOW TO GENERATE FONT DATA:
|
||||
//
|
||||
// Option 1: Adafruit GFX fontconvert (simpler, fewer glyphs)
|
||||
// brew install adafruitnrf52-gfx-fontconvert # macOS
|
||||
// fontconvert NotoSansSC-Regular.ttf 16 > zh_cn_16.h
|
||||
// The output is a GFXfont struct. LovyanGFX can use this via ft_gfx type.
|
||||
//
|
||||
// Option 2: LovyanGFX VLW format (recommended, more features)
|
||||
// Use the LovyanGFX font converter script to generate a .vlw file from TTF
|
||||
// Then embed the .vlw binary as a byte array using xxd:
|
||||
// xxd -i zh_font_16.vlw > zh_font_16_data.h
|
||||
//
|
||||
// Option 3: Use BDF fonts (free, text-based)
|
||||
// WenQuanYi bitmap fonts: http://wenq.org/
|
||||
// Convert BDF to VLW using LovyanGFX tools
|
||||
//
|
||||
// FONT SIZE GUIDELINES:
|
||||
// - 16px: Minimum readable Chinese on 320x240 TFT (~20 chars per line)
|
||||
// - 20px: Better readability, ~16 chars per line
|
||||
// - 24px: Title/header text, ~13 chars per line
|
||||
//
|
||||
// CHARACTER SET RECOMMENDATIONS:
|
||||
// - GB2312 Level 1: 3755 commonly used simplified Chinese characters
|
||||
// - Plus ASCII 0x20-0x7E for punctuation and Latin characters
|
||||
//
|
||||
// PLACEHOLDER DATA - Replace with generated font binary:
|
||||
|
||||
#include "OLEDDisplayFontsZH.h"
|
||||
|
||||
// Placeholder font data.
|
||||
// Replace with actual generated VLW/Gfx font binary from a Chinese TTF file.
|
||||
// The array below is intentionally minimal - it won't render any characters
|
||||
// until real font data is provided.
|
||||
const uint8_t FontZH_CN_16[] = {
|
||||
// VLW font file magic header would go here
|
||||
// Format: 0x00 0x00 ...
|
||||
// Replace with output from: xxd -i your_chinese_font.vlw
|
||||
};
|
||||
const uint32_t FontZH_CN_16_size = sizeof(FontZH_CN_16);
|
||||
|
||||
const uint8_t FontZH_TW_16[] = {
|
||||
// Traditional Chinese VLW font data placeholder
|
||||
};
|
||||
const uint32_t FontZH_TW_16_size = sizeof(FontZH_TW_16);
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Chinese Simplified font - VLW format (to be generated from TTF)
|
||||
//
|
||||
// HOW TO GENERATE:
|
||||
// 1. Download NotoSansSC-Regular.ttf (or any open-source Chinese font)
|
||||
// 2. Use Adafruit fontconvert tool:
|
||||
// ./fontconvert NotoSansSC-Regular.ttf 16 > zh_cn_16_pt.h
|
||||
// 3. Copy the resulting binary data (GFXfont struct bytes) into the array below
|
||||
//
|
||||
// For LovyanGFX VLW font format, use:
|
||||
// - The binary VLW file can be embedded as a const uint8_t[] array
|
||||
// - Loaded with tft->loadFont(zh_font_data_array)
|
||||
|
||||
extern const uint8_t FontZH_CN_16[];
|
||||
extern const uint32_t FontZH_CN_16_size;
|
||||
|
||||
// Chinese Traditional font (VLW format)
|
||||
extern const uint8_t FontZH_TW_16[];
|
||||
extern const uint32_t FontZH_TW_16_size;
|
||||
@@ -0,0 +1,20 @@
|
||||
#include "Strings.h"
|
||||
#include "Strings_EN.h"
|
||||
#include "Strings_ZH.h"
|
||||
|
||||
namespace meshtastic
|
||||
{
|
||||
namespace l10n
|
||||
{
|
||||
|
||||
meshtastic_Language currentDisplayLanguage = meshtastic_Language_ENGLISH;
|
||||
|
||||
const char *getString(StringKey key, meshtastic_Language lang)
|
||||
{
|
||||
if (lang == meshtastic_Language_SIMPLIFIED_CHINESE || lang == meshtastic_Language_TRADITIONAL_CHINESE)
|
||||
return getStringZH(key);
|
||||
return getStringEN(key);
|
||||
}
|
||||
|
||||
} // namespace l10n
|
||||
} // namespace meshtastic
|
||||
@@ -0,0 +1,170 @@
|
||||
#pragma once
|
||||
|
||||
#include "mesh/generated/meshtastic/device_ui.pb.h"
|
||||
|
||||
namespace meshtastic
|
||||
{
|
||||
namespace l10n
|
||||
{
|
||||
|
||||
enum class StringKey : uint8_t {
|
||||
Back,
|
||||
OK,
|
||||
GotIt,
|
||||
WelcomeToMeshtastic,
|
||||
SwipeToNavigate,
|
||||
ClickToNavigate,
|
||||
UseSelectButton,
|
||||
DeviceRole,
|
||||
RadioPreset,
|
||||
FrequencySlot,
|
||||
LoRaRegion,
|
||||
Client,
|
||||
ClientMute,
|
||||
LostAndFound,
|
||||
Tracker,
|
||||
Hour12,
|
||||
Hour24,
|
||||
ClockAction,
|
||||
ClockFace,
|
||||
TimeFormat,
|
||||
Timezone,
|
||||
MessageAction,
|
||||
Reply,
|
||||
ViewChats,
|
||||
UnmuteChannel,
|
||||
MuteChannel,
|
||||
Delete,
|
||||
ReadAloud,
|
||||
Brightness,
|
||||
FrameVisibility,
|
||||
DisplayUnits,
|
||||
MessageBubbles,
|
||||
Theme,
|
||||
Reboot,
|
||||
Shutdown,
|
||||
SwitchToMUI,
|
||||
DisplayOptions,
|
||||
Language,
|
||||
Chinese,
|
||||
English,
|
||||
NoGPS,
|
||||
GPSoff,
|
||||
GPSnotPresent,
|
||||
GPSisDisabled,
|
||||
Node,
|
||||
Hop,
|
||||
USB,
|
||||
BT_off,
|
||||
WiFi,
|
||||
WiFiNotConnected,
|
||||
WiFiConnected,
|
||||
SSID,
|
||||
LoRaInfo,
|
||||
LoRa,
|
||||
Role,
|
||||
Freq,
|
||||
ChUtil,
|
||||
System,
|
||||
Heap,
|
||||
PSRAM,
|
||||
Flash,
|
||||
SD,
|
||||
Ver,
|
||||
Up,
|
||||
ClientApp,
|
||||
NoConnected,
|
||||
LOCKED,
|
||||
ConnectToUnlock,
|
||||
Battery,
|
||||
Resuming,
|
||||
LastHeard,
|
||||
HopsSig,
|
||||
HopsSignal,
|
||||
Nodes,
|
||||
Distance,
|
||||
Bearings,
|
||||
CreatingSSLCert,
|
||||
PleaseWait,
|
||||
CriticalFault,
|
||||
ForHelpVisit,
|
||||
Updating,
|
||||
PleaseBePatient,
|
||||
RebootInProgress,
|
||||
PowerMenu,
|
||||
SystemMenu,
|
||||
ScreenOptions,
|
||||
WifiToggle,
|
||||
BluetoothToggle,
|
||||
NodeDbReset,
|
||||
ManageNode,
|
||||
RemoveFavorite,
|
||||
AddFavorite,
|
||||
TestMenu,
|
||||
TraceRoute,
|
||||
ThrottleMessage,
|
||||
MessageViewMode,
|
||||
DeleteMessages,
|
||||
NodeNameLength,
|
||||
GPSMenu,
|
||||
GPSToggle,
|
||||
GPSFormat,
|
||||
GPSSmartPosition,
|
||||
GPSUpdateInterval,
|
||||
GPSPositionBroadcast,
|
||||
CompassNorth,
|
||||
BuzzerMode,
|
||||
HamModeConfirm,
|
||||
LicensedToNormal,
|
||||
KeyVerification,
|
||||
FinalPrompt,
|
||||
RegionUnset,
|
||||
SelectRegionMessage,
|
||||
Rebooting,
|
||||
LoRaActions,
|
||||
Slot0,
|
||||
WithPreset,
|
||||
WithFreetext,
|
||||
DeleteOldest,
|
||||
DeleteThisChat,
|
||||
DeleteAll,
|
||||
DeleteAllChats,
|
||||
TemporarilyMute,
|
||||
Unmute,
|
||||
ToggleBacklight,
|
||||
SleepScreen,
|
||||
SendPosition,
|
||||
SendNodeInfo,
|
||||
NewPresetMsg,
|
||||
NewFreetextMsg,
|
||||
Notifications,
|
||||
Power,
|
||||
RebootShutdown,
|
||||
GoToChat,
|
||||
NewPreset,
|
||||
Favorite,
|
||||
UnmuteNotifications,
|
||||
MuteNotifications,
|
||||
UnignoreNode,
|
||||
IgnoreNode,
|
||||
NumberPicker,
|
||||
TestAnnounce,
|
||||
NodeActionsSettings,
|
||||
ShowNameLength,
|
||||
Bluetooth,
|
||||
NoMessages,
|
||||
COUNT
|
||||
};
|
||||
|
||||
extern meshtastic_Language currentDisplayLanguage;
|
||||
|
||||
const char *getString(StringKey key, meshtastic_Language lang);
|
||||
|
||||
inline const char *getString(StringKey key)
|
||||
{
|
||||
extern meshtastic_Language currentDisplayLanguage;
|
||||
return getString(key, currentDisplayLanguage);
|
||||
}
|
||||
|
||||
} // namespace l10n
|
||||
} // namespace meshtastic
|
||||
@@ -0,0 +1,456 @@
|
||||
#pragma once
|
||||
|
||||
#include "Strings.h"
|
||||
|
||||
namespace meshtastic
|
||||
{
|
||||
namespace l10n
|
||||
{
|
||||
|
||||
// English string table
|
||||
// The strings are stored as const arrays to save RAM on constrained devices.
|
||||
// Each locale file defines the same set of strings in its own language.
|
||||
|
||||
static const char STR_BACK_EN[] = "Back";
|
||||
static const char STR_OK_EN[] = "OK";
|
||||
static const char STR_GOT_IT_EN[] = "Got it!";
|
||||
static const char STR_WELCOME_TO_MESHTASTIC_EN[] = "Welcome to Meshtastic!";
|
||||
static const char STR_SWIPE_TO_NAVIGATE_EN[] = "Swipe to navigate.";
|
||||
static const char STR_CLICK_TO_NAVIGATE_EN[] = "Click to navigate.";
|
||||
static const char STR_USE_SELECT_BUTTON_EN[] = "Use the Select button.";
|
||||
static const char STR_DEVICE_ROLE_EN[] = "Device Role";
|
||||
static const char STR_RADIO_PRESET_EN[] = "Radio Preset";
|
||||
static const char STR_FREQUENCY_SLOT_EN[] = "Frequency Slot";
|
||||
static const char STR_LORA_REGION_EN[] = "LoRa Region";
|
||||
static const char STR_CLIENT_EN[] = "Client";
|
||||
static const char STR_CLIENT_MUTE_EN[] = "Client Mute";
|
||||
static const char STR_LOST_AND_FOUND_EN[] = "Lost and Found";
|
||||
static const char STR_TRACKER_EN[] = "Tracker";
|
||||
static const char STR_12HOUR_EN[] = "12-hour";
|
||||
static const char STR_24HOUR_EN[] = "24-hour";
|
||||
static const char STR_CLOCK_ACTION_EN[] = "Clock Action";
|
||||
static const char STR_CLOCK_FACE_EN[] = "Clock Face";
|
||||
static const char STR_TIME_FORMAT_EN[] = "Time Format";
|
||||
static const char STR_TIMEZONE_EN[] = "Timezone";
|
||||
static const char STR_MESSAGE_ACTION_EN[] = "Message Action";
|
||||
static const char STR_REPLY_EN[] = "Reply";
|
||||
static const char STR_VIEW_CHATS_EN[] = "View Chats";
|
||||
static const char STR_UNMUTE_CHANNEL_EN[] = "Unmute Channel";
|
||||
static const char STR_MUTE_CHANNEL_EN[] = "Mute Channel";
|
||||
static const char STR_DELETE_EN[] = "Delete";
|
||||
static const char STR_READ_ALOUD_EN[] = "Read Aloud";
|
||||
static const char STR_BRIGHTNESS_EN[] = "Brightness";
|
||||
static const char STR_FRAME_VISIBILITY_EN[] = "Frame Visibility";
|
||||
static const char STR_DISPLAY_UNITS_EN[] = "Display Units";
|
||||
static const char STR_MESSAGE_BUBBLES_EN[] = "Message Bubbles";
|
||||
static const char STR_THEME_EN[] = "Theme";
|
||||
static const char STR_REBOOT_EN[] = "Reboot";
|
||||
static const char STR_SHUTDOWN_EN[] = "Shutdown";
|
||||
static const char STR_SWITCH_TO_MUI_EN[] = "Switch to MUI";
|
||||
static const char STR_DISPLAY_OPTIONS_EN[] = "Display Options";
|
||||
static const char STR_LANGUAGE_EN[] = "Language";
|
||||
static const char STR_CHINESE_EN[] = "Chinese";
|
||||
static const char STR_ENGLISH_EN[] = "English";
|
||||
static const char STR_NO_GPS_EN[] = "No GPS";
|
||||
static const char STR_GPS_OFF_EN[] = "GPS off";
|
||||
static const char STR_GPS_NOT_PRESENT_EN[] = "GPS not present";
|
||||
static const char STR_GPS_IS_DISABLED_EN[] = "GPS is disabled";
|
||||
static const char STR_NODE_EN[] = "Node";
|
||||
static const char STR_HOP_EN[] = "Hop:";
|
||||
static const char STR_USB_EN[] = "USB";
|
||||
static const char STR_BT_OFF_EN[] = "BT off";
|
||||
static const char STR_WIFI_EN[] = "WiFi";
|
||||
static const char STR_WIFI_NOT_CONNECTED_EN[] = "WiFi: Not Connected";
|
||||
static const char STR_WIFI_CONNECTED_EN[] = "WiFi: Connected";
|
||||
static const char STR_SSID_EN[] = "SSID:";
|
||||
static const char STR_LORA_INFO_EN[] = "LoRa Info";
|
||||
static const char STR_LORA_EN[] = "LoRa";
|
||||
static const char STR_ROLE_EN[] = "Role:";
|
||||
static const char STR_FREQ_EN[] = "Freq:";
|
||||
static const char STR_CHUTIL_EN[] = "ChUtil";
|
||||
static const char STR_SYSTEM_EN[] = "System";
|
||||
static const char STR_HEAP_EN[] = "Heap:";
|
||||
static const char STR_PSRAM_EN[] = "PSRAM:";
|
||||
static const char STR_FLASH_EN[] = "Flash:";
|
||||
static const char STR_SD_EN[] = "SD:";
|
||||
static const char STR_VER_EN[] = "Ver:";
|
||||
static const char STR_UP_EN[] = "Up:";
|
||||
static const char STR_CLIENT_APP_EN[] = "Client";
|
||||
static const char STR_NO_CONNECTED_EN[] = "No Connected";
|
||||
static const char STR_LOCKED_EN[] = "LOCKED";
|
||||
static const char STR_CONNECT_TO_UNLOCK_EN[] = "Connect to unlock";
|
||||
static const char STR_BATTERY_EN[] = "Battery";
|
||||
static const char STR_RESUMING_EN[] = "Resuming...";
|
||||
static const char STR_LAST_HEARD_EN[] = "Last Heard";
|
||||
static const char STR_HOPS_SIG_EN[] = "Hops/Sig";
|
||||
static const char STR_HOPS_SIGNAL_EN[] = "Hops/Signal";
|
||||
static const char STR_NODES_EN[] = "Nodes";
|
||||
static const char STR_DISTANCE_EN[] = "Distance";
|
||||
static const char STR_BEARINGS_EN[] = "Bearings";
|
||||
static const char STR_CREATING_SSL_EN[] = "Creating SSL certificate";
|
||||
static const char STR_PLEASE_WAIT_EN[] = "Please wait . . .";
|
||||
static const char STR_CRITICAL_FAULT_EN[] = "Critical fault";
|
||||
static const char STR_FOR_HELP_VISIT_EN[] = "For help, please visit";
|
||||
static const char STR_UPDATING_EN[] = "Updating";
|
||||
static const char STR_PLEASE_BE_PATIENT_EN[] = "Please be patient and do not power off.";
|
||||
static const char STR_REBOOT_IN_PROGRESS_EN[] = "Reboot in progress...";
|
||||
static const char STR_POWER_MENU_EN[] = "Power";
|
||||
static const char STR_SYSTEM_MENU_EN[] = "System";
|
||||
static const char STR_SCREEN_OPTIONS_EN[] = "Screen";
|
||||
static const char STR_WIFI_TOGGLE_EN[] = "WiFi";
|
||||
static const char STR_BLUETOOTH_TOGGLE_EN[] = "Bluetooth";
|
||||
static const char STR_NODE_DB_RESET_EN[] = "Reset NodeDB";
|
||||
static const char STR_MANAGE_NODE_EN[] = "Manage Node";
|
||||
static const char STR_REMOVE_FAVORITE_EN[] = "Remove Favorite";
|
||||
static const char STR_ADD_FAVORITE_EN[] = "Add Favorite";
|
||||
static const char STR_TEST_MENU_EN[] = "Test";
|
||||
static const char STR_TRACE_ROUTE_EN[] = "Trace Route";
|
||||
static const char STR_THROTTLE_MSG_EN[] = "Message Throttled";
|
||||
static const char STR_MSG_VIEW_MODE_EN[] = "View Mode";
|
||||
static const char STR_DELETE_MESSAGES_EN[] = "Delete Messages";
|
||||
static const char STR_NODE_NAME_LENGTH_EN[] = "Name Length";
|
||||
static const char STR_GPS_MENU_EN[] = "GPS";
|
||||
static const char STR_GPS_TOGGLE_EN[] = "GPS Toggle";
|
||||
static const char STR_GPS_FORMAT_EN[] = "GPS Format";
|
||||
static const char STR_GPS_SMART_POS_EN[] = "Smart Position";
|
||||
static const char STR_GPS_UPDATE_INTERVAL_EN[] = "Update Interval";
|
||||
static const char STR_GPS_POS_BCAST_EN[] = "Position Broadcast";
|
||||
static const char STR_COMPASS_NORTH_EN[] = "Compass North";
|
||||
static const char STR_BUZZER_MODE_EN[] = "Buzzer";
|
||||
static const char STR_HAM_MODE_EN[] = "Ham Mode";
|
||||
static const char STR_LICENSED_NORMAL_EN[] = "Normal Mode";
|
||||
static const char STR_KEY_VERIFY_EN[] = "Verify Key";
|
||||
static const char STR_REGION_UNSET_EN[] = "Select a Region";
|
||||
static const char STR_SELECT_REGION_EN[] = "Please select your LoRa region:";
|
||||
static const char STR_REBOOTING_EN[] = "Rebooting...";
|
||||
static const char STR_LORA_ACTIONS_EN[] = "LoRa Actions";
|
||||
static const char STR_SLOT0_EN[] = "Slot 0 (Auto)";
|
||||
static const char STR_WITH_PRESET_EN[] = "With Preset";
|
||||
static const char STR_WITH_FREETEXT_EN[] = "With Freetext";
|
||||
static const char STR_DELETE_OLDEST_EN[] = "Delete Oldest";
|
||||
static const char STR_DELETE_THIS_CHAT_EN[] = "Delete This Chat";
|
||||
static const char STR_DELETE_ALL_EN[] = "Delete All";
|
||||
static const char STR_DELETE_ALL_CHATS_EN[] = "Delete All Chats";
|
||||
static const char STR_TEMPORARILY_MUTE_EN[] = "Temporarily Mute";
|
||||
static const char STR_UNMUTE_EN[] = "Unmute";
|
||||
static const char STR_TOGGLE_BACKLIGHT_EN[] = "Toggle Backlight";
|
||||
static const char STR_SLEEP_SCREEN_EN[] = "Sleep Screen";
|
||||
static const char STR_SEND_POSITION_EN[] = "Send Position";
|
||||
static const char STR_SEND_NODE_INFO_EN[] = "Send Node Info";
|
||||
static const char STR_NEW_PRESET_MSG_EN[] = "New Preset Msg";
|
||||
static const char STR_NEW_FREETEXT_MSG_EN[] = "New Freetext Msg";
|
||||
static const char STR_NOTIFICATIONS_EN[] = "Notifications";
|
||||
static const char STR_POWER_EN[] = "Power";
|
||||
static const char STR_REBOOT_SHUTDOWN_EN[] = "Reboot/Shutdown";
|
||||
static const char STR_GO_TO_CHAT_EN[] = "Go To Chat";
|
||||
static const char STR_NEW_PRESET_EN[] = "New Preset";
|
||||
static const char STR_FAVORITE_EN[] = "Favorite";
|
||||
static const char STR_UNMUTE_NOTIFICATIONS_EN[] = "Unmute Notifications";
|
||||
static const char STR_MUTE_NOTIFICATIONS_EN[] = "Mute Notifications";
|
||||
static const char STR_UNIGNORE_NODE_EN[] = "Unignore Node";
|
||||
static const char STR_IGNORE_NODE_EN[] = "Ignore Node";
|
||||
static const char STR_NUMBER_PICKER_EN[] = "Number Picker";
|
||||
static const char STR_TEST_ANNOUNCE_EN[] = "Test Announce";
|
||||
static const char STR_NODE_ACTIONS_EN[] = "Node Actions / Settings";
|
||||
static const char STR_SHOW_NAME_LENGTH_EN[] = "Show Long/Short Name";
|
||||
static const char STR_BLUETOOTH_EN[] = "Bluetooth";
|
||||
static const char STR_NO_MESSAGES_EN[] = "No messages";
|
||||
|
||||
inline const char *getStringEN(StringKey key)
|
||||
{
|
||||
switch (key) {
|
||||
case StringKey::Back:
|
||||
return STR_BACK_EN;
|
||||
case StringKey::OK:
|
||||
return STR_OK_EN;
|
||||
case StringKey::GotIt:
|
||||
return STR_GOT_IT_EN;
|
||||
case StringKey::WelcomeToMeshtastic:
|
||||
return STR_WELCOME_TO_MESHTASTIC_EN;
|
||||
case StringKey::SwipeToNavigate:
|
||||
return STR_SWIPE_TO_NAVIGATE_EN;
|
||||
case StringKey::ClickToNavigate:
|
||||
return STR_CLICK_TO_NAVIGATE_EN;
|
||||
case StringKey::UseSelectButton:
|
||||
return STR_USE_SELECT_BUTTON_EN;
|
||||
case StringKey::DeviceRole:
|
||||
return STR_DEVICE_ROLE_EN;
|
||||
case StringKey::RadioPreset:
|
||||
return STR_RADIO_PRESET_EN;
|
||||
case StringKey::FrequencySlot:
|
||||
return STR_FREQUENCY_SLOT_EN;
|
||||
case StringKey::LoRaRegion:
|
||||
return STR_LORA_REGION_EN;
|
||||
case StringKey::Client:
|
||||
return STR_CLIENT_EN;
|
||||
case StringKey::ClientMute:
|
||||
return STR_CLIENT_MUTE_EN;
|
||||
case StringKey::LostAndFound:
|
||||
return STR_LOST_AND_FOUND_EN;
|
||||
case StringKey::Tracker:
|
||||
return STR_TRACKER_EN;
|
||||
case StringKey::Hour12:
|
||||
return STR_12HOUR_EN;
|
||||
case StringKey::Hour24:
|
||||
return STR_24HOUR_EN;
|
||||
case StringKey::ClockAction:
|
||||
return STR_CLOCK_ACTION_EN;
|
||||
case StringKey::ClockFace:
|
||||
return STR_CLOCK_FACE_EN;
|
||||
case StringKey::TimeFormat:
|
||||
return STR_TIME_FORMAT_EN;
|
||||
case StringKey::Timezone:
|
||||
return STR_TIMEZONE_EN;
|
||||
case StringKey::MessageAction:
|
||||
return STR_MESSAGE_ACTION_EN;
|
||||
case StringKey::Reply:
|
||||
return STR_REPLY_EN;
|
||||
case StringKey::ViewChats:
|
||||
return STR_VIEW_CHATS_EN;
|
||||
case StringKey::UnmuteChannel:
|
||||
return STR_UNMUTE_CHANNEL_EN;
|
||||
case StringKey::MuteChannel:
|
||||
return STR_MUTE_CHANNEL_EN;
|
||||
case StringKey::Delete:
|
||||
return STR_DELETE_EN;
|
||||
case StringKey::ReadAloud:
|
||||
return STR_READ_ALOUD_EN;
|
||||
case StringKey::Brightness:
|
||||
return STR_BRIGHTNESS_EN;
|
||||
case StringKey::FrameVisibility:
|
||||
return STR_FRAME_VISIBILITY_EN;
|
||||
case StringKey::DisplayUnits:
|
||||
return STR_DISPLAY_UNITS_EN;
|
||||
case StringKey::MessageBubbles:
|
||||
return STR_MESSAGE_BUBBLES_EN;
|
||||
case StringKey::Theme:
|
||||
return STR_THEME_EN;
|
||||
case StringKey::Reboot:
|
||||
return STR_REBOOT_EN;
|
||||
case StringKey::Shutdown:
|
||||
return STR_SHUTDOWN_EN;
|
||||
case StringKey::SwitchToMUI:
|
||||
return STR_SWITCH_TO_MUI_EN;
|
||||
case StringKey::DisplayOptions:
|
||||
return STR_DISPLAY_OPTIONS_EN;
|
||||
case StringKey::Language:
|
||||
return STR_LANGUAGE_EN;
|
||||
case StringKey::Chinese:
|
||||
return STR_CHINESE_EN;
|
||||
case StringKey::English:
|
||||
return STR_ENGLISH_EN;
|
||||
case StringKey::NoGPS:
|
||||
return STR_NO_GPS_EN;
|
||||
case StringKey::GPSoff:
|
||||
return STR_GPS_OFF_EN;
|
||||
case StringKey::GPSnotPresent:
|
||||
return STR_GPS_NOT_PRESENT_EN;
|
||||
case StringKey::GPSisDisabled:
|
||||
return STR_GPS_IS_DISABLED_EN;
|
||||
case StringKey::Node:
|
||||
return STR_NODE_EN;
|
||||
case StringKey::Hop:
|
||||
return STR_HOP_EN;
|
||||
case StringKey::USB:
|
||||
return STR_USB_EN;
|
||||
case StringKey::BT_off:
|
||||
return STR_BT_OFF_EN;
|
||||
case StringKey::WiFi:
|
||||
return STR_WIFI_EN;
|
||||
case StringKey::WiFiNotConnected:
|
||||
return STR_WIFI_NOT_CONNECTED_EN;
|
||||
case StringKey::WiFiConnected:
|
||||
return STR_WIFI_CONNECTED_EN;
|
||||
case StringKey::SSID:
|
||||
return STR_SSID_EN;
|
||||
case StringKey::LoRaInfo:
|
||||
return STR_LORA_INFO_EN;
|
||||
case StringKey::LoRa:
|
||||
return STR_LORA_EN;
|
||||
case StringKey::Role:
|
||||
return STR_ROLE_EN;
|
||||
case StringKey::Freq:
|
||||
return STR_FREQ_EN;
|
||||
case StringKey::ChUtil:
|
||||
return STR_CHUTIL_EN;
|
||||
case StringKey::System:
|
||||
return STR_SYSTEM_EN;
|
||||
case StringKey::Heap:
|
||||
return STR_HEAP_EN;
|
||||
case StringKey::PSRAM:
|
||||
return STR_PSRAM_EN;
|
||||
case StringKey::Flash:
|
||||
return STR_FLASH_EN;
|
||||
case StringKey::SD:
|
||||
return STR_SD_EN;
|
||||
case StringKey::Ver:
|
||||
return STR_VER_EN;
|
||||
case StringKey::Up:
|
||||
return STR_UP_EN;
|
||||
case StringKey::ClientApp:
|
||||
return STR_CLIENT_APP_EN;
|
||||
case StringKey::NoConnected:
|
||||
return STR_NO_CONNECTED_EN;
|
||||
case StringKey::LOCKED:
|
||||
return STR_LOCKED_EN;
|
||||
case StringKey::ConnectToUnlock:
|
||||
return STR_CONNECT_TO_UNLOCK_EN;
|
||||
case StringKey::Battery:
|
||||
return STR_BATTERY_EN;
|
||||
case StringKey::Resuming:
|
||||
return STR_RESUMING_EN;
|
||||
case StringKey::LastHeard:
|
||||
return STR_LAST_HEARD_EN;
|
||||
case StringKey::HopsSig:
|
||||
return STR_HOPS_SIG_EN;
|
||||
case StringKey::HopsSignal:
|
||||
return STR_HOPS_SIGNAL_EN;
|
||||
case StringKey::Nodes:
|
||||
return STR_NODES_EN;
|
||||
case StringKey::Distance:
|
||||
return STR_DISTANCE_EN;
|
||||
case StringKey::Bearings:
|
||||
return STR_BEARINGS_EN;
|
||||
case StringKey::CreatingSSLCert:
|
||||
return STR_CREATING_SSL_EN;
|
||||
case StringKey::PleaseWait:
|
||||
return STR_PLEASE_WAIT_EN;
|
||||
case StringKey::CriticalFault:
|
||||
return STR_CRITICAL_FAULT_EN;
|
||||
case StringKey::ForHelpVisit:
|
||||
return STR_FOR_HELP_VISIT_EN;
|
||||
case StringKey::Updating:
|
||||
return STR_UPDATING_EN;
|
||||
case StringKey::PleaseBePatient:
|
||||
return STR_PLEASE_BE_PATIENT_EN;
|
||||
case StringKey::RebootInProgress:
|
||||
return STR_REBOOT_IN_PROGRESS_EN;
|
||||
case StringKey::PowerMenu:
|
||||
return STR_POWER_MENU_EN;
|
||||
case StringKey::SystemMenu:
|
||||
return STR_SYSTEM_MENU_EN;
|
||||
case StringKey::ScreenOptions:
|
||||
return STR_SCREEN_OPTIONS_EN;
|
||||
case StringKey::WifiToggle:
|
||||
return STR_WIFI_TOGGLE_EN;
|
||||
case StringKey::BluetoothToggle:
|
||||
return STR_BLUETOOTH_TOGGLE_EN;
|
||||
case StringKey::NodeDbReset:
|
||||
return STR_NODE_DB_RESET_EN;
|
||||
case StringKey::ManageNode:
|
||||
return STR_MANAGE_NODE_EN;
|
||||
case StringKey::RemoveFavorite:
|
||||
return STR_REMOVE_FAVORITE_EN;
|
||||
case StringKey::AddFavorite:
|
||||
return STR_ADD_FAVORITE_EN;
|
||||
case StringKey::TestMenu:
|
||||
return STR_TEST_MENU_EN;
|
||||
case StringKey::TraceRoute:
|
||||
return STR_TRACE_ROUTE_EN;
|
||||
case StringKey::ThrottleMessage:
|
||||
return STR_THROTTLE_MSG_EN;
|
||||
case StringKey::MessageViewMode:
|
||||
return STR_MSG_VIEW_MODE_EN;
|
||||
case StringKey::DeleteMessages:
|
||||
return STR_DELETE_MESSAGES_EN;
|
||||
case StringKey::NodeNameLength:
|
||||
return STR_NODE_NAME_LENGTH_EN;
|
||||
case StringKey::GPSMenu:
|
||||
return STR_GPS_MENU_EN;
|
||||
case StringKey::GPSToggle:
|
||||
return STR_GPS_TOGGLE_EN;
|
||||
case StringKey::GPSFormat:
|
||||
return STR_GPS_FORMAT_EN;
|
||||
case StringKey::GPSSmartPosition:
|
||||
return STR_GPS_SMART_POS_EN;
|
||||
case StringKey::GPSUpdateInterval:
|
||||
return STR_GPS_UPDATE_INTERVAL_EN;
|
||||
case StringKey::GPSPositionBroadcast:
|
||||
return STR_GPS_POS_BCAST_EN;
|
||||
case StringKey::CompassNorth:
|
||||
return STR_COMPASS_NORTH_EN;
|
||||
case StringKey::BuzzerMode:
|
||||
return STR_BUZZER_MODE_EN;
|
||||
case StringKey::HamModeConfirm:
|
||||
return STR_HAM_MODE_EN;
|
||||
case StringKey::LicensedToNormal:
|
||||
return STR_LICENSED_NORMAL_EN;
|
||||
case StringKey::KeyVerification:
|
||||
return STR_KEY_VERIFY_EN;
|
||||
case StringKey::RegionUnset:
|
||||
return STR_REGION_UNSET_EN;
|
||||
case StringKey::SelectRegionMessage:
|
||||
return STR_SELECT_REGION_EN;
|
||||
case StringKey::Rebooting:
|
||||
return STR_REBOOTING_EN;
|
||||
case StringKey::LoRaActions:
|
||||
return STR_LORA_ACTIONS_EN;
|
||||
case StringKey::Slot0:
|
||||
return STR_SLOT0_EN;
|
||||
case StringKey::WithPreset:
|
||||
return STR_WITH_PRESET_EN;
|
||||
case StringKey::WithFreetext:
|
||||
return STR_WITH_FREETEXT_EN;
|
||||
case StringKey::DeleteOldest:
|
||||
return STR_DELETE_OLDEST_EN;
|
||||
case StringKey::DeleteThisChat:
|
||||
return STR_DELETE_THIS_CHAT_EN;
|
||||
case StringKey::DeleteAll:
|
||||
return STR_DELETE_ALL_EN;
|
||||
case StringKey::DeleteAllChats:
|
||||
return STR_DELETE_ALL_CHATS_EN;
|
||||
case StringKey::TemporarilyMute:
|
||||
return STR_TEMPORARILY_MUTE_EN;
|
||||
case StringKey::Unmute:
|
||||
return STR_UNMUTE_EN;
|
||||
case StringKey::ToggleBacklight:
|
||||
return STR_TOGGLE_BACKLIGHT_EN;
|
||||
case StringKey::SleepScreen:
|
||||
return STR_SLEEP_SCREEN_EN;
|
||||
case StringKey::SendPosition:
|
||||
return STR_SEND_POSITION_EN;
|
||||
case StringKey::SendNodeInfo:
|
||||
return STR_SEND_NODE_INFO_EN;
|
||||
case StringKey::NewPresetMsg:
|
||||
return STR_NEW_PRESET_MSG_EN;
|
||||
case StringKey::NewFreetextMsg:
|
||||
return STR_NEW_FREETEXT_MSG_EN;
|
||||
case StringKey::Notifications:
|
||||
return STR_NOTIFICATIONS_EN;
|
||||
case StringKey::Power:
|
||||
return STR_POWER_EN;
|
||||
case StringKey::RebootShutdown:
|
||||
return STR_REBOOT_SHUTDOWN_EN;
|
||||
case StringKey::GoToChat:
|
||||
return STR_GO_TO_CHAT_EN;
|
||||
case StringKey::NewPreset:
|
||||
return STR_NEW_PRESET_EN;
|
||||
case StringKey::Favorite:
|
||||
return STR_FAVORITE_EN;
|
||||
case StringKey::UnmuteNotifications:
|
||||
return STR_UNMUTE_NOTIFICATIONS_EN;
|
||||
case StringKey::MuteNotifications:
|
||||
return STR_MUTE_NOTIFICATIONS_EN;
|
||||
case StringKey::UnignoreNode:
|
||||
return STR_UNIGNORE_NODE_EN;
|
||||
case StringKey::IgnoreNode:
|
||||
return STR_IGNORE_NODE_EN;
|
||||
case StringKey::NumberPicker:
|
||||
return STR_NUMBER_PICKER_EN;
|
||||
case StringKey::TestAnnounce:
|
||||
return STR_TEST_ANNOUNCE_EN;
|
||||
case StringKey::NodeActionsSettings:
|
||||
return STR_NODE_ACTIONS_EN;
|
||||
case StringKey::ShowNameLength:
|
||||
return STR_SHOW_NAME_LENGTH_EN;
|
||||
case StringKey::Bluetooth:
|
||||
return STR_BLUETOOTH_EN;
|
||||
case StringKey::NoMessages:
|
||||
return STR_NO_MESSAGES_EN;
|
||||
default:
|
||||
return "?";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace l10n
|
||||
} // namespace meshtastic
|
||||
@@ -0,0 +1,452 @@
|
||||
#pragma once
|
||||
|
||||
#include "Strings.h"
|
||||
|
||||
namespace meshtastic
|
||||
{
|
||||
namespace l10n
|
||||
{
|
||||
|
||||
static const char STR_BACK_ZH[] = "\xe8\xbf\x94\xe5\x9b\x9e";
|
||||
static const char STR_OK_ZH[] = "\xe7\xa1\xae\xe5\xae\x9a";
|
||||
static const char STR_GOT_IT_ZH[] = "\xe7\x9f\xa5\xe9\x81\x93\xe4\xba\x86";
|
||||
static const char STR_WELCOME_TO_MESHTASTIC_ZH[] = "\xe6\xac\xa2\xe8\xbf\x8e\xe4\xbd\xbf\xe7\x94\xa8 Meshtastic\xef\xbc\x81";
|
||||
static const char STR_SWIPE_TO_NAVIGATE_ZH[] = "\xe6\xbb\x91\xe5\x8a\xa8\xe4\xbb\xa5\xe5\xaf\xbc\xe8\x88\xaa\xe3\x80\x82";
|
||||
static const char STR_CLICK_TO_NAVIGATE_ZH[] = "\xe7\x82\xb9\xe5\x87\xbb\xe4\xbb\xa5\xe5\xaf\xbc\xe8\x88\xaa\xe3\x80\x82";
|
||||
static const char STR_USE_SELECT_BUTTON_ZH[] = "\xe4\xbd\xbf\xe7\x94\xa8\xe9\x80\x89\xe6\x8b\xa9\xe6\x8c\x89\xe9\x92\xae\xe3\x80\x82";
|
||||
static const char STR_DEVICE_ROLE_ZH[] = "\xe8\xae\xbe\xe5\xa4\x87\xe8\xa7\x92\xe8\x89\xb2";
|
||||
static const char STR_RADIO_PRESET_ZH[] = "\xe6\x97\xa0\xe7\xba\xbf\xe9\xa2\x84\xe8\xae\xbe";
|
||||
static const char STR_FREQUENCY_SLOT_ZH[] = "\xe9\xa2\x91\xe7\x8e\x87\xe6\xa7\xbd\xe4\xbd\x8d";
|
||||
static const char STR_LORA_REGION_ZH[] = "LoRa \xe5\x9c\xb0\xe5\x8c\xba";
|
||||
static const char STR_CLIENT_ZH[] = "\xe5\xae\xa2\xe6\x88\xb7\xe7\xab\xaf";
|
||||
static const char STR_CLIENT_MUTE_ZH[] = "\xe9\x9d\x99\xe9\x9f\xb3\xe5\xae\xa2\xe6\x88\xb7\xe7\xab\xaf";
|
||||
static const char STR_LOST_AND_FOUND_ZH[] = "\xe5\xa4\xb1\xe7\x89\xa9\xe6\x8b\x9b\xe9\xa2\x86";
|
||||
static const char STR_TRACKER_ZH[] = "\xe8\xbf\xbd\xe8\xb8\xaa\xe5\x99\xa8";
|
||||
static const char STR_12HOUR_ZH[] = "12 \xe5\xb0\x8f\xe6\x97\xb6\xe5\x88\xb6";
|
||||
static const char STR_24HOUR_ZH[] = "24 \xe5\xb0\x8f\xe6\x97\xb6\xe5\x88\xb6";
|
||||
static const char STR_CLOCK_ACTION_ZH[] = "\xe6\x97\xb6\xe9\x92\x9f\xe6\x93\x8d\xe4\xbd\x9c";
|
||||
static const char STR_CLOCK_FACE_ZH[] = "\xe6\x97\xb6\xe9\x92\x9f\xe6\xa0\xb7\xe5\xbc\x8f";
|
||||
static const char STR_TIME_FORMAT_ZH[] = "\xe6\x97\xb6\xe9\x97\xb4\xe6\xa0\xbc\xe5\xbc\x8f";
|
||||
static const char STR_TIMEZONE_ZH[] = "\xe6\x97\xb6\xe5\x8c\xba";
|
||||
static const char STR_MESSAGE_ACTION_ZH[] = "\xe6\xb6\x88\xe6\x81\xaf\xe6\x93\x8d\xe4\xbd\x9c";
|
||||
static const char STR_REPLY_ZH[] = "\xe5\x9b\x9e\xe5\xa4\x8d";
|
||||
static const char STR_VIEW_CHATS_ZH[] = "\xe6\x9f\xa5\xe7\x9c\x8b\xe8\x81\x8a\xe5\xa4\xa9";
|
||||
static const char STR_UNMUTE_CHANNEL_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe9\x9d\x99\xe9\x9f\xb3";
|
||||
static const char STR_MUTE_CHANNEL_ZH[] = "\xe9\x9d\x99\xe9\x9f\xb3\xe9\xa2\x91\xe9\x81\x93";
|
||||
static const char STR_DELETE_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4";
|
||||
static const char STR_READ_ALOUD_ZH[] = "\xe5\xa4\xa7\xe5\xa3\xb0\xe6\x9c\x97\xe8\xaf\xbb";
|
||||
static const char STR_BRIGHTNESS_ZH[] = "\xe4\xba\xae\xe5\xba\xa6";
|
||||
static const char STR_FRAME_VISIBILITY_ZH[] = "\xe9\xa1\xb5\xe9\x9d\xa2\xe5\x8f\xaf\xe8\xa7\x81\xe6\x80\xa7";
|
||||
static const char STR_DISPLAY_UNITS_ZH[] = "\xe6\x98\xbe\xe7\xa4\xba\xe5\x8d\x95\xe4\xbd\x8d";
|
||||
static const char STR_MESSAGE_BUBBLES_ZH[] = "\xe6\xb6\x88\xe6\x81\xaf\xe6\xb0\x94\xe6\xb3\xa1";
|
||||
static const char STR_THEME_ZH[] = "\xe4\xb8\xbb\xe9\xa2\x98";
|
||||
static const char STR_REBOOT_ZH[] = "\xe9\x87\x8d\xe5\x90\xaf";
|
||||
static const char STR_SHUTDOWN_ZH[] = "\xe5\x85\xb3\xe6\x9c\xba";
|
||||
static const char STR_SWITCH_TO_MUI_ZH[] = "\xe5\x88\x87\xe6\x8d\xa2\xe5\x88\xb0 MUI";
|
||||
static const char STR_DISPLAY_OPTIONS_ZH[] = "\xe6\x98\xbe\xe7\xa4\xba\xe9\x80\x89\xe9\xa1\xb9";
|
||||
static const char STR_LANGUAGE_ZH[] = "\xe8\xaf\xad\xe8\xa8\x80";
|
||||
static const char STR_CHINESE_ZH[] = "\xe4\xb8\xad\xe6\x96\x87";
|
||||
static const char STR_ENGLISH_ZH[] = "\xe8\x8b\xb1\xe6\x96\x87";
|
||||
static const char STR_NO_GPS_ZH[] = "\xe6\x97\xa0 GPS";
|
||||
static const char STR_GPS_OFF_ZH[] = "GPS \xe5\xb7\xb2\xe5\x85\xb3\xe9\x97\xad";
|
||||
static const char STR_GPS_NOT_PRESENT_ZH[] = "GPS \xe4\xb8\x8d\xe5\xad\x98\xe5\x9c\xa8";
|
||||
static const char STR_GPS_IS_DISABLED_ZH[] = "GPS \xe5\xb7\xb2\xe7\xa6\x81\xe7\x94\xa8";
|
||||
static const char STR_NODE_ZH[] = "\xe8\x8a\x82\xe7\x82\xb9";
|
||||
static const char STR_HOP_ZH[] = "\xe8\xb7\xb3\xe6\x95\xb0:";
|
||||
static const char STR_USB_ZH[] = "USB";
|
||||
static const char STR_BT_OFF_ZH[] = "\xe8\x93\x9d\xe7\x89\x99\xe5\xb7\xb2\xe5\x85\xb3";
|
||||
static const char STR_WIFI_ZH[] = "WiFi";
|
||||
static const char STR_WIFI_NOT_CONNECTED_ZH[] = "WiFi: \xe6\x9c\xaa\xe8\xbf\x9e\xe6\x8e\xa5";
|
||||
static const char STR_WIFI_CONNECTED_ZH[] = "WiFi: \xe5\xb7\xb2\xe8\xbf\x9e\xe6\x8e\xa5";
|
||||
static const char STR_SSID_ZH[] = "SSID:";
|
||||
static const char STR_LORA_INFO_ZH[] = "LoRa \xe4\xbf\xa1\xe6\x81\xaf";
|
||||
static const char STR_LORA_ZH[] = "LoRa";
|
||||
static const char STR_ROLE_ZH[] = "\xe8\xa7\x92\xe8\x89\xb2:";
|
||||
static const char STR_FREQ_ZH[] = "\xe9\xa2\x91\xe7\x8e\x87:";
|
||||
static const char STR_CHUTIL_ZH[] = "\xe4\xbf\xa1\xe9\x81\x93\xe5\x88\xa9\xe7\x94\xa8";
|
||||
static const char STR_SYSTEM_ZH[] = "\xe7\xb3\xbb\xe7\xbb\x9f";
|
||||
static const char STR_HEAP_ZH[] = "\xe5\xa0\x86:";
|
||||
static const char STR_PSRAM_ZH[] = "PSRAM:";
|
||||
static const char STR_FLASH_ZH[] = "Flash:";
|
||||
static const char STR_SD_ZH[] = "SD:";
|
||||
static const char STR_VER_ZH[] = "\xe7\x89\x88\xe6\x9c\xac:";
|
||||
static const char STR_UP_ZH[] = "\xe8\xbf\x90\xe8\xa1\x8c:";
|
||||
static const char STR_CLIENT_APP_ZH[] = "\xe5\xae\xa2\xe6\x88\xb7\xe7\xab\xaf";
|
||||
static const char STR_NO_CONNECTED_ZH[] = "\xe6\x9c\xaa\xe8\xbf\x9e\xe6\x8e\xa5";
|
||||
static const char STR_LOCKED_ZH[] = "\xe5\xb7\xb2\xe9\x94\x81\xe5\xae\x9a";
|
||||
static const char STR_CONNECT_TO_UNLOCK_ZH[] = "\xe8\xbf\x9e\xe6\x8e\xa5\xe4\xbb\xa5\xe8\xa7\xa3\xe9\x94\x81";
|
||||
static const char STR_BATTERY_ZH[] = "\xe7\x94\xb5\xe6\xb1\xa0";
|
||||
static const char STR_RESUMING_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe6\x81\xa2\xe5\xa4\x8d...";
|
||||
static const char STR_LAST_HEARD_ZH[] = "\xe6\x9c\x80\xe5\x90\x8e\xe5\x90\xac\xe5\x88\xb0";
|
||||
static const char STR_HOPS_SIG_ZH[] = "\xe8\xb7\xb3\xe6\x95\xb0/\xe4\xbf\xa1\xe5\x8f\xb7";
|
||||
static const char STR_HOPS_SIGNAL_ZH[] = "\xe8\xb7\xb3\xe6\x95\xb0/\xe4\xbf\xa1\xe5\x8f\xb7";
|
||||
static const char STR_NODES_ZH[] = "\xe8\x8a\x82\xe7\x82\xb9";
|
||||
static const char STR_DISTANCE_ZH[] = "\xe8\xb7\x9d\xe7\xa6\xbb";
|
||||
static const char STR_BEARINGS_ZH[] = "\xe6\x96\xb9\xe4\xbd\x8d";
|
||||
static const char STR_CREATING_SSL_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe5\x88\x9b\xe5\xbb\xba SSL \xe8\xaf\x81\xe4\xb9\xa6";
|
||||
static const char STR_PLEASE_WAIT_ZH[] = "\xe8\xaf\xb7\xe7\xa8\x8d\xe5\x80\x99 . . .";
|
||||
static const char STR_CRITICAL_FAULT_ZH[] = "\xe4\xb8\xa5\xe9\x87\x8d\xe6\x95\x85\xe9\x9a\x9c";
|
||||
static const char STR_FOR_HELP_VISIT_ZH[] = "\xe8\xaf\xb7\xe8\xae\xbf\xe9\x97\xae\x4e\xe5\xb8\xae\xe5\x8a\xa9";
|
||||
static const char STR_UPDATING_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe6\x9b\xb4\xe6\x96\xb0";
|
||||
static const char STR_PLEASE_BE_PATIENT_ZH[] = "\xe8\xaf\xb7\xe8\x80\x90\xe5\xbf\x83\xe7\xad\x89\xe5\xbe\x85\xef\xbc\x8c\xe4\xb8\x8d\xe8\xa6\x81\xe5\x85\xb3\xe9\x97\xad\xe7\x94\xb5\xe6\xba\x90\xe3\x80\x82";
|
||||
static const char STR_REBOOT_IN_PROGRESS_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe9\x87\x8d\xe5\x90\xaf...";
|
||||
static const char STR_POWER_MENU_ZH[] = "\xe7\x94\xb5\xe6\xba\x90";
|
||||
static const char STR_SYSTEM_MENU_ZH[] = "\xe7\xb3\xbb\xe7\xbb\x9f";
|
||||
static const char STR_SCREEN_OPTIONS_ZH[] = "\xe5\xb1\x8f\xe5\xb9\x95";
|
||||
static const char STR_WIFI_TOGGLE_ZH[] = "WiFi";
|
||||
static const char STR_BLUETOOTH_TOGGLE_ZH[] = "\xe8\x93\x9d\xe7\x89\x99";
|
||||
static const char STR_NODE_DB_RESET_ZH[] = "\xe9\x87\x8d\xe7\xbd\xae\xe8\x8a\x82\xe7\x82\xb9\xe5\xba\x93";
|
||||
static const char STR_MANAGE_NODE_ZH[] = "\xe7\xae\xa1\xe7\x90\x86\xe8\x8a\x82\xe7\x82\xb9";
|
||||
static const char STR_REMOVE_FAVORITE_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe6\x94\xb6\xe8\x97\x8f";
|
||||
static const char STR_ADD_FAVORITE_ZH[] = "\xe6\xb7\xbb\xe5\x8a\xa0\xe6\x94\xb6\xe8\x97\x8f";
|
||||
static const char STR_TEST_MENU_ZH[] = "\xe6\xb5\x8b\xe8\xaf\x95";
|
||||
static const char STR_TRACE_ROUTE_ZH[] = "\xe8\xb7\xaf\xe7\x94\xb1\xe8\xbf\xbd\xe8\xb8\xaa";
|
||||
static const char STR_THROTTLE_MSG_ZH[] = "\xe6\xb6\x88\xe6\x81\xaf\xe5\xb7\xb2\xe9\x99\x90\xe6\xb5\x81";
|
||||
static const char STR_MSG_VIEW_MODE_ZH[] = "\xe6\x9f\xa5\xe7\x9c\x8b\xe6\xa8\xa1\xe5\xbc\x8f";
|
||||
static const char STR_DELETE_MESSAGES_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe6\xb6\x88\xe6\x81\xaf";
|
||||
static const char STR_NODE_NAME_LENGTH_ZH[] = "\xe5\x90\x8d\xe7\xa7\xb0\xe9\x95\xbf\xe5\xba\xa6";
|
||||
static const char STR_GPS_MENU_ZH[] = "GPS";
|
||||
static const char STR_GPS_TOGGLE_ZH[] = "GPS \xe5\xbc\x80\xe5\x85\xb3";
|
||||
static const char STR_GPS_FORMAT_ZH[] = "GPS \xe6\xa0\xbc\xe5\xbc\x8f";
|
||||
static const char STR_GPS_SMART_POS_ZH[] = "\xe6\x99\xba\xe8\x83\xbd\xe5\xae\x9a\xe4\xbd\x8d";
|
||||
static const char STR_GPS_UPDATE_INTERVAL_ZH[] = "\xe6\x9b\xb4\xe6\x96\xb0\xe9\x97\xb4\xe9\x9a\x94";
|
||||
static const char STR_GPS_POS_BCAST_ZH[] = "\xe4\xbd\x8d\xe7\xbd\xae\xe5\xb9\xbf\xe6\x92\xad";
|
||||
static const char STR_COMPASS_NORTH_ZH[] = "\xe6\x8c\x87\xe5\x8d\x97\xe9\x92\x88";
|
||||
static const char STR_BUZZER_MODE_ZH[] = "\xe8\x9c\x82\xe9\xb8\xa3\xe5\x99\xa8";
|
||||
static const char STR_HAM_MODE_ZH[] = "Ham \xe6\xa8\xa1\xe5\xbc\x8f";
|
||||
static const char STR_LICENSED_NORMAL_ZH[] = "\xe6\x99\xae\xe9\x80\x9a\xe6\xa8\xa1\xe5\xbc\x8f";
|
||||
static const char STR_KEY_VERIFY_ZH[] = "\xe9\xaa\x8c\xe8\xaf\x81\xe5\xaf\x86\xe9\x92\xa5";
|
||||
static const char STR_REGION_UNSET_ZH[] = "\xe8\xaf\xb7\xe9\x80\x89\xe6\x8b\xa9\xe5\x9c\xb0\xe5\x8c\xba";
|
||||
static const char STR_SELECT_REGION_ZH[] = "\xe8\xaf\xb7\xe9\x80\x89\xe6\x8b\xa9\xe6\x82\xa8\xe7\x9a\x84 LoRa \xe5\x8c\xba\xe5\x9f\x9f:";
|
||||
static const char STR_REBOOTING_ZH[] = "\xe6\xad\xa3\xe5\x9c\xa8\xe9\x87\x8d\xe5\x90\xaf...";
|
||||
static const char STR_LORA_ACTIONS_ZH[] = "LoRa \xe6\x93\x8d\xe4\xbd\x9c";
|
||||
static const char STR_SLOT0_ZH[] = "\xe6\xa7\xbd\xe4\xbd\x8d 0 (\xe8\x87\xaa\xe5\x8a\xa8)";
|
||||
static const char STR_WITH_PRESET_ZH[] = "\xe9\xa2\x84\xe8\xae\xbe\xe6\xb6\x88\xe6\x81\xaf";
|
||||
static const char STR_WITH_FREETEXT_ZH[] = "\xe8\x87\xaa\xe5\xae\x9a\xe4\xb9\x89";
|
||||
static const char STR_DELETE_OLDEST_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe6\x9c\x80\xe6\x97\xa7";
|
||||
static const char STR_DELETE_THIS_CHAT_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe6\xad\xa4\xe8\x81\x8a\xe5\xa4\xa9";
|
||||
static const char STR_DELETE_ALL_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe5\x85\xa8\xe9\x83\xa8";
|
||||
static const char STR_DELETE_ALL_CHATS_ZH[] = "\xe5\x88\xa0\xe9\x99\xa4\xe6\x89\x80\xe6\x9c\x89\xe8\x81\x8a\xe5\xa4\xa9";
|
||||
static const char STR_TEMPORARILY_MUTE_ZH[] = "\xe4\xb8\xb4\xe6\x97\xb6\xe9\x9d\x99\xe9\x9f\xb3";
|
||||
static const char STR_UNMUTE_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe9\x9d\x99\xe9\x9f\xb3";
|
||||
static const char STR_TOGGLE_BACKLIGHT_ZH[] = "\xe8\x83\x8c\xe5\x85\x89\xe5\xbc\x80\xe5\x85\xb3";
|
||||
static const char STR_SLEEP_SCREEN_ZH[] = "\xe4\xbc\x91\xe7\x9c\xa0\xe5\xb1\x8f\xe5\xb9\x95";
|
||||
static const char STR_SEND_POSITION_ZH[] = "\xe5\x8f\x91\xe9\x80\x81\xe4\xbd\x8d\xe7\xbd\xae";
|
||||
static const char STR_SEND_NODE_INFO_ZH[] = "\xe5\x8f\x91\xe9\x80\x81\xe8\x8a\x82\xe7\x82\xb9\xe4\xbf\xa1\xe6\x81\xaf";
|
||||
static const char STR_NEW_PRESET_MSG_ZH[] = "\xe6\x96\xb0\xe9\xa2\x84\xe8\xae\xbe\xe6\xb6\x88\xe6\x81\xaf";
|
||||
static const char STR_NEW_FREETEXT_MSG_ZH[] = "\xe6\x96\xb0\xe8\x87\xaa\xe5\xae\x9a\xe4\xb9\x89\xe6\xb6\x88\xe6\x81\xaf";
|
||||
static const char STR_NOTIFICATIONS_ZH[] = "\xe9\x80\x9a\xe7\x9f\xa5";
|
||||
static const char STR_POWER_ZH[] = "\xe7\x94\xb5\xe6\xba\x90";
|
||||
static const char STR_REBOOT_SHUTDOWN_ZH[] = "\xe9\x87\x8d\xe5\x90\xaf/\xe5\x85\xb3\xe6\x9c\xba";
|
||||
static const char STR_GO_TO_CHAT_ZH[] = "\xe8\xbf\x9b\xe5\x85\xa5\xe8\x81\x8a\xe5\xa4\xa9";
|
||||
static const char STR_NEW_PRESET_ZH[] = "\xe6\x96\xb0\xe9\xa2\x84\xe8\xae\xbe";
|
||||
static const char STR_FAVORITE_ZH[] = "\xe6\x94\xb6\xe8\x97\x8f";
|
||||
static const char STR_UNMUTE_NOTIFICATIONS_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe9\x80\x9a\xe7\x9f\xa5\xe9\x9d\x99\xe9\x9f\xb3";
|
||||
static const char STR_MUTE_NOTIFICATIONS_ZH[] = "\xe9\x80\x9a\xe7\x9f\xa5\xe9\x9d\x99\xe9\x9f\xb3";
|
||||
static const char STR_UNIGNORE_NODE_ZH[] = "\xe5\x8f\x96\xe6\xb6\x88\xe5\xbf\xbd\xe7\x95\xa5";
|
||||
static const char STR_IGNORE_NODE_ZH[] = "\xe5\xbf\xbd\xe7\x95\xa5\xe8\x8a\x82\xe7\x82\xb9";
|
||||
static const char STR_NUMBER_PICKER_ZH[] = "\xe6\x95\xb0\xe5\xad\x97\xe9\x80\x89\xe6\x8b\xa9";
|
||||
static const char STR_TEST_ANNOUNCE_ZH[] = "\xe6\xb5\x8b\xe8\xaf\x95\xe5\x85\xac\xe5\x91\x8a";
|
||||
static const char STR_NODE_ACTIONS_ZH[] = "\xe8\x8a\x82\xe7\x82\xb9\xe6\x93\x8d\xe4\xbd\x9c/\xe8\xae\xbe\xe7\xbd\xae";
|
||||
static const char STR_SHOW_NAME_LENGTH_ZH[] = "\xe6\x98\xbe\xe7\xa4\xba\xe9\x95\xbf/\xe7\x9f\xad\xe5\x90\x8d";
|
||||
static const char STR_BLUETOOTH_ZH[] = "\xe8\x93\x9d\xe7\x89\x99";
|
||||
static const char STR_NO_MESSAGES_ZH[] = "\xe6\x97\xa0\xe6\xb6\x88\xe6\x81\xaf";
|
||||
|
||||
inline const char *getStringZH(StringKey key)
|
||||
{
|
||||
switch (key) {
|
||||
case StringKey::Back:
|
||||
return STR_BACK_ZH;
|
||||
case StringKey::OK:
|
||||
return STR_OK_ZH;
|
||||
case StringKey::GotIt:
|
||||
return STR_GOT_IT_ZH;
|
||||
case StringKey::WelcomeToMeshtastic:
|
||||
return STR_WELCOME_TO_MESHTASTIC_ZH;
|
||||
case StringKey::SwipeToNavigate:
|
||||
return STR_SWIPE_TO_NAVIGATE_ZH;
|
||||
case StringKey::ClickToNavigate:
|
||||
return STR_CLICK_TO_NAVIGATE_ZH;
|
||||
case StringKey::UseSelectButton:
|
||||
return STR_USE_SELECT_BUTTON_ZH;
|
||||
case StringKey::DeviceRole:
|
||||
return STR_DEVICE_ROLE_ZH;
|
||||
case StringKey::RadioPreset:
|
||||
return STR_RADIO_PRESET_ZH;
|
||||
case StringKey::FrequencySlot:
|
||||
return STR_FREQUENCY_SLOT_ZH;
|
||||
case StringKey::LoRaRegion:
|
||||
return STR_LORA_REGION_ZH;
|
||||
case StringKey::Client:
|
||||
return STR_CLIENT_ZH;
|
||||
case StringKey::ClientMute:
|
||||
return STR_CLIENT_MUTE_ZH;
|
||||
case StringKey::LostAndFound:
|
||||
return STR_LOST_AND_FOUND_ZH;
|
||||
case StringKey::Tracker:
|
||||
return STR_TRACKER_ZH;
|
||||
case StringKey::Hour12:
|
||||
return STR_12HOUR_ZH;
|
||||
case StringKey::Hour24:
|
||||
return STR_24HOUR_ZH;
|
||||
case StringKey::ClockAction:
|
||||
return STR_CLOCK_ACTION_ZH;
|
||||
case StringKey::ClockFace:
|
||||
return STR_CLOCK_FACE_ZH;
|
||||
case StringKey::TimeFormat:
|
||||
return STR_TIME_FORMAT_ZH;
|
||||
case StringKey::Timezone:
|
||||
return STR_TIMEZONE_ZH;
|
||||
case StringKey::MessageAction:
|
||||
return STR_MESSAGE_ACTION_ZH;
|
||||
case StringKey::Reply:
|
||||
return STR_REPLY_ZH;
|
||||
case StringKey::ViewChats:
|
||||
return STR_VIEW_CHATS_ZH;
|
||||
case StringKey::UnmuteChannel:
|
||||
return STR_UNMUTE_CHANNEL_ZH;
|
||||
case StringKey::MuteChannel:
|
||||
return STR_MUTE_CHANNEL_ZH;
|
||||
case StringKey::Delete:
|
||||
return STR_DELETE_ZH;
|
||||
case StringKey::ReadAloud:
|
||||
return STR_READ_ALOUD_ZH;
|
||||
case StringKey::Brightness:
|
||||
return STR_BRIGHTNESS_ZH;
|
||||
case StringKey::FrameVisibility:
|
||||
return STR_FRAME_VISIBILITY_ZH;
|
||||
case StringKey::DisplayUnits:
|
||||
return STR_DISPLAY_UNITS_ZH;
|
||||
case StringKey::MessageBubbles:
|
||||
return STR_MESSAGE_BUBBLES_ZH;
|
||||
case StringKey::Theme:
|
||||
return STR_THEME_ZH;
|
||||
case StringKey::Reboot:
|
||||
return STR_REBOOT_ZH;
|
||||
case StringKey::Shutdown:
|
||||
return STR_SHUTDOWN_ZH;
|
||||
case StringKey::SwitchToMUI:
|
||||
return STR_SWITCH_TO_MUI_ZH;
|
||||
case StringKey::DisplayOptions:
|
||||
return STR_DISPLAY_OPTIONS_ZH;
|
||||
case StringKey::Language:
|
||||
return STR_LANGUAGE_ZH;
|
||||
case StringKey::Chinese:
|
||||
return STR_CHINESE_ZH;
|
||||
case StringKey::English:
|
||||
return STR_ENGLISH_ZH;
|
||||
case StringKey::NoGPS:
|
||||
return STR_NO_GPS_ZH;
|
||||
case StringKey::GPSoff:
|
||||
return STR_GPS_OFF_ZH;
|
||||
case StringKey::GPSnotPresent:
|
||||
return STR_GPS_NOT_PRESENT_ZH;
|
||||
case StringKey::GPSisDisabled:
|
||||
return STR_GPS_IS_DISABLED_ZH;
|
||||
case StringKey::Node:
|
||||
return STR_NODE_ZH;
|
||||
case StringKey::Hop:
|
||||
return STR_HOP_ZH;
|
||||
case StringKey::USB:
|
||||
return STR_USB_ZH;
|
||||
case StringKey::BT_off:
|
||||
return STR_BT_OFF_ZH;
|
||||
case StringKey::WiFi:
|
||||
return STR_WIFI_ZH;
|
||||
case StringKey::WiFiNotConnected:
|
||||
return STR_WIFI_NOT_CONNECTED_ZH;
|
||||
case StringKey::WiFiConnected:
|
||||
return STR_WIFI_CONNECTED_ZH;
|
||||
case StringKey::SSID:
|
||||
return STR_SSID_ZH;
|
||||
case StringKey::LoRaInfo:
|
||||
return STR_LORA_INFO_ZH;
|
||||
case StringKey::LoRa:
|
||||
return STR_LORA_ZH;
|
||||
case StringKey::Role:
|
||||
return STR_ROLE_ZH;
|
||||
case StringKey::Freq:
|
||||
return STR_FREQ_ZH;
|
||||
case StringKey::ChUtil:
|
||||
return STR_CHUTIL_ZH;
|
||||
case StringKey::System:
|
||||
return STR_SYSTEM_ZH;
|
||||
case StringKey::Heap:
|
||||
return STR_HEAP_ZH;
|
||||
case StringKey::PSRAM:
|
||||
return STR_PSRAM_ZH;
|
||||
case StringKey::Flash:
|
||||
return STR_FLASH_ZH;
|
||||
case StringKey::SD:
|
||||
return STR_SD_ZH;
|
||||
case StringKey::Ver:
|
||||
return STR_VER_ZH;
|
||||
case StringKey::Up:
|
||||
return STR_UP_ZH;
|
||||
case StringKey::ClientApp:
|
||||
return STR_CLIENT_APP_ZH;
|
||||
case StringKey::NoConnected:
|
||||
return STR_NO_CONNECTED_ZH;
|
||||
case StringKey::LOCKED:
|
||||
return STR_LOCKED_ZH;
|
||||
case StringKey::ConnectToUnlock:
|
||||
return STR_CONNECT_TO_UNLOCK_ZH;
|
||||
case StringKey::Battery:
|
||||
return STR_BATTERY_ZH;
|
||||
case StringKey::Resuming:
|
||||
return STR_RESUMING_ZH;
|
||||
case StringKey::LastHeard:
|
||||
return STR_LAST_HEARD_ZH;
|
||||
case StringKey::HopsSig:
|
||||
return STR_HOPS_SIG_ZH;
|
||||
case StringKey::HopsSignal:
|
||||
return STR_HOPS_SIGNAL_ZH;
|
||||
case StringKey::Nodes:
|
||||
return STR_NODES_ZH;
|
||||
case StringKey::Distance:
|
||||
return STR_DISTANCE_ZH;
|
||||
case StringKey::Bearings:
|
||||
return STR_BEARINGS_ZH;
|
||||
case StringKey::CreatingSSLCert:
|
||||
return STR_CREATING_SSL_ZH;
|
||||
case StringKey::PleaseWait:
|
||||
return STR_PLEASE_WAIT_ZH;
|
||||
case StringKey::CriticalFault:
|
||||
return STR_CRITICAL_FAULT_ZH;
|
||||
case StringKey::ForHelpVisit:
|
||||
return STR_FOR_HELP_VISIT_ZH;
|
||||
case StringKey::Updating:
|
||||
return STR_UPDATING_ZH;
|
||||
case StringKey::PleaseBePatient:
|
||||
return STR_PLEASE_BE_PATIENT_ZH;
|
||||
case StringKey::RebootInProgress:
|
||||
return STR_REBOOT_IN_PROGRESS_ZH;
|
||||
case StringKey::PowerMenu:
|
||||
return STR_POWER_MENU_ZH;
|
||||
case StringKey::SystemMenu:
|
||||
return STR_SYSTEM_MENU_ZH;
|
||||
case StringKey::ScreenOptions:
|
||||
return STR_SCREEN_OPTIONS_ZH;
|
||||
case StringKey::WifiToggle:
|
||||
return STR_WIFI_TOGGLE_ZH;
|
||||
case StringKey::BluetoothToggle:
|
||||
return STR_BLUETOOTH_TOGGLE_ZH;
|
||||
case StringKey::NodeDbReset:
|
||||
return STR_NODE_DB_RESET_ZH;
|
||||
case StringKey::ManageNode:
|
||||
return STR_MANAGE_NODE_ZH;
|
||||
case StringKey::RemoveFavorite:
|
||||
return STR_REMOVE_FAVORITE_ZH;
|
||||
case StringKey::AddFavorite:
|
||||
return STR_ADD_FAVORITE_ZH;
|
||||
case StringKey::TestMenu:
|
||||
return STR_TEST_MENU_ZH;
|
||||
case StringKey::TraceRoute:
|
||||
return STR_TRACE_ROUTE_ZH;
|
||||
case StringKey::ThrottleMessage:
|
||||
return STR_THROTTLE_MSG_ZH;
|
||||
case StringKey::MessageViewMode:
|
||||
return STR_MSG_VIEW_MODE_ZH;
|
||||
case StringKey::DeleteMessages:
|
||||
return STR_DELETE_MESSAGES_ZH;
|
||||
case StringKey::NodeNameLength:
|
||||
return STR_NODE_NAME_LENGTH_ZH;
|
||||
case StringKey::GPSMenu:
|
||||
return STR_GPS_MENU_ZH;
|
||||
case StringKey::GPSToggle:
|
||||
return STR_GPS_TOGGLE_ZH;
|
||||
case StringKey::GPSFormat:
|
||||
return STR_GPS_FORMAT_ZH;
|
||||
case StringKey::GPSSmartPosition:
|
||||
return STR_GPS_SMART_POS_ZH;
|
||||
case StringKey::GPSUpdateInterval:
|
||||
return STR_GPS_UPDATE_INTERVAL_ZH;
|
||||
case StringKey::GPSPositionBroadcast:
|
||||
return STR_GPS_POS_BCAST_ZH;
|
||||
case StringKey::CompassNorth:
|
||||
return STR_COMPASS_NORTH_ZH;
|
||||
case StringKey::BuzzerMode:
|
||||
return STR_BUZZER_MODE_ZH;
|
||||
case StringKey::HamModeConfirm:
|
||||
return STR_HAM_MODE_ZH;
|
||||
case StringKey::LicensedToNormal:
|
||||
return STR_LICENSED_NORMAL_ZH;
|
||||
case StringKey::KeyVerification:
|
||||
return STR_KEY_VERIFY_ZH;
|
||||
case StringKey::RegionUnset:
|
||||
return STR_REGION_UNSET_ZH;
|
||||
case StringKey::SelectRegionMessage:
|
||||
return STR_SELECT_REGION_ZH;
|
||||
case StringKey::Rebooting:
|
||||
return STR_REBOOTING_ZH;
|
||||
case StringKey::LoRaActions:
|
||||
return STR_LORA_ACTIONS_ZH;
|
||||
case StringKey::Slot0:
|
||||
return STR_SLOT0_ZH;
|
||||
case StringKey::WithPreset:
|
||||
return STR_WITH_PRESET_ZH;
|
||||
case StringKey::WithFreetext:
|
||||
return STR_WITH_FREETEXT_ZH;
|
||||
case StringKey::DeleteOldest:
|
||||
return STR_DELETE_OLDEST_ZH;
|
||||
case StringKey::DeleteThisChat:
|
||||
return STR_DELETE_THIS_CHAT_ZH;
|
||||
case StringKey::DeleteAll:
|
||||
return STR_DELETE_ALL_ZH;
|
||||
case StringKey::DeleteAllChats:
|
||||
return STR_DELETE_ALL_CHATS_ZH;
|
||||
case StringKey::TemporarilyMute:
|
||||
return STR_TEMPORARILY_MUTE_ZH;
|
||||
case StringKey::Unmute:
|
||||
return STR_UNMUTE_ZH;
|
||||
case StringKey::ToggleBacklight:
|
||||
return STR_TOGGLE_BACKLIGHT_ZH;
|
||||
case StringKey::SleepScreen:
|
||||
return STR_SLEEP_SCREEN_ZH;
|
||||
case StringKey::SendPosition:
|
||||
return STR_SEND_POSITION_ZH;
|
||||
case StringKey::SendNodeInfo:
|
||||
return STR_SEND_NODE_INFO_ZH;
|
||||
case StringKey::NewPresetMsg:
|
||||
return STR_NEW_PRESET_MSG_ZH;
|
||||
case StringKey::NewFreetextMsg:
|
||||
return STR_NEW_FREETEXT_MSG_ZH;
|
||||
case StringKey::Notifications:
|
||||
return STR_NOTIFICATIONS_ZH;
|
||||
case StringKey::Power:
|
||||
return STR_POWER_ZH;
|
||||
case StringKey::RebootShutdown:
|
||||
return STR_REBOOT_SHUTDOWN_ZH;
|
||||
case StringKey::GoToChat:
|
||||
return STR_GO_TO_CHAT_ZH;
|
||||
case StringKey::NewPreset:
|
||||
return STR_NEW_PRESET_ZH;
|
||||
case StringKey::Favorite:
|
||||
return STR_FAVORITE_ZH;
|
||||
case StringKey::UnmuteNotifications:
|
||||
return STR_UNMUTE_NOTIFICATIONS_ZH;
|
||||
case StringKey::MuteNotifications:
|
||||
return STR_MUTE_NOTIFICATIONS_ZH;
|
||||
case StringKey::UnignoreNode:
|
||||
return STR_UNIGNORE_NODE_ZH;
|
||||
case StringKey::IgnoreNode:
|
||||
return STR_IGNORE_NODE_ZH;
|
||||
case StringKey::NumberPicker:
|
||||
return STR_NUMBER_PICKER_ZH;
|
||||
case StringKey::TestAnnounce:
|
||||
return STR_TEST_ANNOUNCE_ZH;
|
||||
case StringKey::NodeActionsSettings:
|
||||
return STR_NODE_ACTIONS_ZH;
|
||||
case StringKey::ShowNameLength:
|
||||
return STR_SHOW_NAME_LENGTH_ZH;
|
||||
case StringKey::Bluetooth:
|
||||
return STR_BLUETOOTH_ZH;
|
||||
case StringKey::NoMessages:
|
||||
return STR_NO_MESSAGES_ZH;
|
||||
default:
|
||||
return "?";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace l10n
|
||||
} // namespace meshtastic
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "HapticFeedback.h"
|
||||
|
||||
#ifdef HAPTIC_FEEDBACK_PIN
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#ifdef HAPTIC_FEEDBACK_ACTIVE_LOW
|
||||
#define HAPTIC_FEEDBACK_ON_STATE LOW
|
||||
#define HAPTIC_FEEDBACK_OFF_STATE HIGH
|
||||
#else
|
||||
#define HAPTIC_FEEDBACK_ON_STATE HIGH
|
||||
#define HAPTIC_FEEDBACK_OFF_STATE LOW
|
||||
#endif
|
||||
|
||||
HapticFeedback *hapticFeedback = nullptr;
|
||||
|
||||
void initHapticFeedback()
|
||||
{
|
||||
if (!hapticFeedback)
|
||||
hapticFeedback = new HapticFeedback();
|
||||
}
|
||||
|
||||
HapticFeedback::HapticFeedback() : concurrency::OSThread("Haptic")
|
||||
{
|
||||
pinMode(HAPTIC_FEEDBACK_PIN, OUTPUT);
|
||||
digitalWrite(HAPTIC_FEEDBACK_PIN, HAPTIC_FEEDBACK_OFF_STATE);
|
||||
}
|
||||
|
||||
void HapticFeedback::motorWrite(bool on)
|
||||
{
|
||||
digitalWrite(HAPTIC_FEEDBACK_PIN, on ? HAPTIC_FEEDBACK_ON_STATE : HAPTIC_FEEDBACK_OFF_STATE);
|
||||
}
|
||||
|
||||
void HapticFeedback::pulse(uint16_t durationMs)
|
||||
{
|
||||
motorWrite(true);
|
||||
pulseOffAt = millis() + durationMs;
|
||||
if (pulseOffAt == 0) // 0 is the "no pulse" sentinel
|
||||
pulseOffAt = 1;
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
void HapticFeedback::armDelayedPulse(uint16_t delayMs, uint16_t durationMs)
|
||||
{
|
||||
delayedPulseAt = millis() + delayMs;
|
||||
if (delayedPulseAt == 0)
|
||||
delayedPulseAt = 1;
|
||||
delayedPulseDuration = durationMs;
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
void HapticFeedback::cancelDelayedPulse()
|
||||
{
|
||||
delayedPulseAt = 0;
|
||||
}
|
||||
|
||||
void HapticFeedback::scheduleNext()
|
||||
{
|
||||
uint32_t now = millis();
|
||||
uint32_t next = 0;
|
||||
if (pulseOffAt != 0)
|
||||
next = pulseOffAt;
|
||||
if (delayedPulseAt != 0 && (next == 0 || (int32_t)(delayedPulseAt - next) < 0))
|
||||
next = delayedPulseAt;
|
||||
if (next == 0)
|
||||
return;
|
||||
int32_t delay = (int32_t)(next - now);
|
||||
setIntervalFromNow(delay > 0 ? (unsigned long)delay : 0);
|
||||
}
|
||||
|
||||
int32_t HapticFeedback::runOnce()
|
||||
{
|
||||
uint32_t now = millis();
|
||||
|
||||
if (pulseOffAt != 0 && (int32_t)(now - pulseOffAt) >= 0) {
|
||||
motorWrite(false);
|
||||
pulseOffAt = 0;
|
||||
}
|
||||
|
||||
if (delayedPulseAt != 0 && (int32_t)(now - delayedPulseAt) >= 0) {
|
||||
uint16_t dur = delayedPulseDuration;
|
||||
delayedPulseAt = 0;
|
||||
pulse(dur);
|
||||
}
|
||||
|
||||
uint32_t next = 0;
|
||||
if (pulseOffAt != 0)
|
||||
next = pulseOffAt;
|
||||
if (delayedPulseAt != 0 && (next == 0 || (int32_t)(delayedPulseAt - next) < 0))
|
||||
next = delayedPulseAt;
|
||||
if (next == 0)
|
||||
return 60 * 1000;
|
||||
int32_t delay = (int32_t)(next - now);
|
||||
return delay > 0 ? delay : 0;
|
||||
}
|
||||
|
||||
#endif // HAPTIC_FEEDBACK_PIN
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#ifdef HAPTIC_FEEDBACK_PIN
|
||||
|
||||
#include "concurrency/OSThread.h"
|
||||
#include <stdint.h>
|
||||
|
||||
// Non-blocking pulses on a GPIO vibration motor. HAPTIC_FEEDBACK_ACTIVE_LOW inverts polarity.
|
||||
class HapticFeedback : public concurrency::OSThread
|
||||
{
|
||||
public:
|
||||
HapticFeedback();
|
||||
void pulse(uint16_t durationMs = 30);
|
||||
void armDelayedPulse(uint16_t delayMs, uint16_t durationMs = 30);
|
||||
void cancelDelayedPulse();
|
||||
|
||||
protected:
|
||||
int32_t runOnce() override;
|
||||
|
||||
private:
|
||||
uint32_t pulseOffAt = 0;
|
||||
uint32_t delayedPulseAt = 0;
|
||||
uint16_t delayedPulseDuration = 0;
|
||||
|
||||
void motorWrite(bool on);
|
||||
// Reschedule to the soonest pending event so later arms don't clobber earlier wakes.
|
||||
void scheduleNext();
|
||||
};
|
||||
|
||||
extern HapticFeedback *hapticFeedback;
|
||||
void initHapticFeedback();
|
||||
|
||||
#endif // HAPTIC_FEEDBACK_PIN
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "PowerFSM.h" // needed for event trigger
|
||||
#include "configuration.h"
|
||||
#include "graphics/Screen.h"
|
||||
#include "input/HapticFeedback.h"
|
||||
#include "modules/ExternalNotificationModule.h"
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
#include "security/LockdownDisplay.h"
|
||||
@@ -256,6 +257,16 @@ void InputBroker::Init()
|
||||
}
|
||||
touchBacklightActive = false;
|
||||
};
|
||||
#endif
|
||||
#if defined(HAPTIC_FEEDBACK_PIN)
|
||||
// Blip on touch, second blip when long-press fires (500 ms = touchConfig.longPressTime default).
|
||||
touchConfig.suppressLeadUpSound = true;
|
||||
initHapticFeedback();
|
||||
touchConfig.onPress = []() {
|
||||
hapticFeedback->pulse(80);
|
||||
hapticFeedback->armDelayedPulse(500, 80);
|
||||
};
|
||||
touchConfig.onRelease = []() { hapticFeedback->cancelDelayedPulse(); };
|
||||
#endif
|
||||
TouchButtonThread->initButton(touchConfig);
|
||||
#endif
|
||||
|
||||
+16
-13
@@ -1071,19 +1071,6 @@ void setup()
|
||||
digitalWrite(RF95_FAN_EN, LOW ^ 0);
|
||||
#endif
|
||||
|
||||
#ifndef ARCH_PORTDUINO
|
||||
|
||||
// Initialize Wifi
|
||||
#if HAS_WIFI
|
||||
initWifi();
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET
|
||||
// Initialize Ethernet
|
||||
initEthernet();
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WEBSERVER
|
||||
// Start web server thread.
|
||||
webServerThread = new WebServerThread();
|
||||
@@ -1121,6 +1108,22 @@ void setup()
|
||||
setCPUFast(false); // 80MHz is fine for our slow peripherals
|
||||
#endif
|
||||
|
||||
#ifndef ARCH_PORTDUINO
|
||||
// Force BT init before WiFi so NimBLE gets clean heap
|
||||
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_BLUETOOTH
|
||||
setBluetoothEnable(true);
|
||||
#endif
|
||||
// Initialize Wifi after PowerFSM so BT gets clean heap during init
|
||||
#if HAS_WIFI
|
||||
initWifi();
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET
|
||||
// Initialize Ethernet
|
||||
initEthernet();
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef ARDUINO_ARCH_ESP32
|
||||
LOG_DEBUG("Free heap : %7d bytes", ESP.getFreeHeap());
|
||||
LOG_DEBUG("Free PSRAM : %7d bytes", ESP.getFreePsram());
|
||||
|
||||
@@ -440,6 +440,24 @@ bool Channels::usesPublicKey(ChannelIndex chIndex)
|
||||
return (psk.size == sizeof(defaultpsk) && memcmp(psk.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0);
|
||||
}
|
||||
|
||||
bool Channels::isWellKnownChannel(ChannelIndex chIndex)
|
||||
{
|
||||
const auto &ch = getByIndex(chIndex);
|
||||
// Absent (unencrypted) or single-byte PSK — all the well-known key indexes
|
||||
if (ch.settings.psk.size > 1)
|
||||
return false;
|
||||
|
||||
const char *name = getName(chIndex);
|
||||
for (int p = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; p <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; p++) {
|
||||
const char *presetName =
|
||||
DisplayFormatters::getModemPresetDisplayName(static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(p), false, true);
|
||||
// Presets without a display name fall through to "Invalid" — never a match
|
||||
if (strcmp(presetName, "Invalid") != 0 && strcmp(name, presetName) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Channels::hasDefaultChannel()
|
||||
{
|
||||
// If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel
|
||||
|
||||
@@ -88,6 +88,12 @@ class Channels
|
||||
|
||||
// Returns true if this channel's effective key is publicly decryptable (open or well-known/default PSK).
|
||||
bool usesPublicKey(ChannelIndex chIndex);
|
||||
// Returns true if the channel is "well known": its PSK is absent or a
|
||||
// single-byte well-known key index, AND its name is any modem-preset
|
||||
// display name (e.g. a channel named "LongFast" counts even while the
|
||||
// radio runs MediumFast). Broader than isDefaultChannel, which only
|
||||
// matches the current preset's name and PSK byte 1.
|
||||
bool isWellKnownChannel(ChannelIndex chIndex);
|
||||
|
||||
// Returns true if we can be reached via a channel with the default settings given a region and modem preset
|
||||
bool hasDefaultChannel();
|
||||
|
||||
+11
-2
@@ -18,6 +18,9 @@
|
||||
#define default_telemetry_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
|
||||
#define default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
|
||||
#define default_broadcast_smart_minimum_interval_secs 5 * 60
|
||||
// Floor for our own position broadcasts when stationary (unchanged beyond the broadcast
|
||||
// precision) or fixed_position: identical positions get deduped by traffic management anyway.
|
||||
#define default_position_stationary_broadcast_secs (12 * 60 * 60)
|
||||
#define min_default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
|
||||
#define min_default_broadcast_smart_minimum_interval_secs 5 * 60
|
||||
#define default_wait_bluetooth_secs IF_ROUTER(1, 60)
|
||||
@@ -34,8 +37,14 @@
|
||||
enum class TrafficType { POSITION, TELEMETRY };
|
||||
|
||||
// Traffic management defaults
|
||||
#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells
|
||||
#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions
|
||||
#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m)
|
||||
#define default_traffic_mgmt_position_min_interval_secs (11 * 60 * 60) // 11 hours between identical positions
|
||||
// Role cap: tracker-role origins may refresh a duplicate position this often (vs the 11h default).
|
||||
#define default_traffic_mgmt_tracker_position_min_interval_secs (60 * 60) // 1 hour
|
||||
// Role cap: lost-and-found origins may refresh a duplicate position this often, so a lost
|
||||
// device updates frequently without flooding. (Quantised to the dedup tick: ~2 ticks.)
|
||||
// Unlike before, lost-and-found is NOT exempt from the relayed precision clamp.
|
||||
#define default_traffic_mgmt_lost_and_found_position_min_interval_secs (15 * 60) // 15 minutes
|
||||
|
||||
// Hop scaling defaults
|
||||
#define default_hop_scaling_min_target_nodes 40 // walk threshold: first hop reaching this cumulative count
|
||||
|
||||
@@ -88,6 +88,11 @@ extern const RegionInfo *myRegion;
|
||||
extern void initRegion();
|
||||
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
|
||||
|
||||
// Fill `map` with the region->valid-preset table, grouped so regions sharing a
|
||||
// preset list reference the same group. Sent to clients during want_config so
|
||||
// their UI can block illegal region+preset combinations.
|
||||
extern void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map);
|
||||
|
||||
// Valid LoRa spread factor range and defaults
|
||||
constexpr uint8_t LORA_SF_MIN = 5;
|
||||
constexpr uint8_t LORA_SF_MAX = 12;
|
||||
|
||||
@@ -45,6 +45,11 @@ enum RxSource {
|
||||
// For old firmware there is no relay node set
|
||||
#define NO_RELAY_NODE 0
|
||||
|
||||
// How recently we must have heard a direct neighbor for its single-byte relay id to be trusted as a
|
||||
// unique next hop. Mirrors NUM_ONLINE_SECS (NodeDB.cpp). Used by NodeDB::resolveLastByte() to scope
|
||||
// last-byte collision resolution to currently-reachable neighbors.
|
||||
#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2) // 2 hrs
|
||||
|
||||
typedef int ErrorCode;
|
||||
|
||||
/// Alloc and free packets to our global, ISR safe pool
|
||||
|
||||
+203
-14
@@ -98,21 +98,38 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast
|
||||
// destination
|
||||
if (p->from != 0) {
|
||||
meshtastic_NodeInfoLite *origTx = nodeDB->getMeshNode(p->from);
|
||||
if (origTx) {
|
||||
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
|
||||
// directly from the destination
|
||||
// Single lookup for both relayer checks on the same (request_id, to) pair
|
||||
bool wasAlreadyRelayer = false;
|
||||
bool weWereSoleRelayer = false;
|
||||
bool weWereRelayer = false;
|
||||
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
|
||||
&weWereSoleRelayer);
|
||||
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
|
||||
if (origTx->next_hop != p->relay_node) { // Not already set
|
||||
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
|
||||
// directly from the destination. checkRelayers is read-only on PacketHistory and O(1), so we run it even
|
||||
// when origTx is absent — that lets us still capture the confirmed hop into the TMM overflow cache below.
|
||||
// Single lookup for both relayer checks on the same (request_id, to) pair
|
||||
bool wasAlreadyRelayer = false;
|
||||
bool weWereSoleRelayer = false;
|
||||
bool weWereRelayer = false;
|
||||
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
|
||||
&weWereSoleRelayer);
|
||||
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
|
||||
// M1/M2: only learn a next hop whose last byte maps to a single plausible relay. On a dense
|
||||
// mesh the byte may be ambiguous; storing it would aim future DMs at the wrong node. This gate
|
||||
// now protects BOTH the hot-store route (NodeInfoLite.next_hop) AND the TMM overflow cache —
|
||||
// the overflow cache deliberately holds many more next-hop bytes (long-tail nodes), so it is
|
||||
// even more collision-prone and must never store an ambiguous byte either. Ambiguous/unknown
|
||||
// -> store nothing and keep flooding (safe).
|
||||
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false)) {
|
||||
if (origTx && origTx->next_hop != p->relay_node) { // Not already set
|
||||
LOG_INFO("Update next hop of 0x%x to 0x%x based on ACK/reply (was relayer %d we were sole %d)", p->from,
|
||||
p->relay_node, wasAlreadyRelayer, weWereSoleRelayer);
|
||||
origTx->next_hop = p->relay_node;
|
||||
}
|
||||
noteRouteLearned(p->from, p->relay_node, millis()); // M3: anchor freshness (hot or overflow route)
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it
|
||||
// survives even when the source isn't (or is no longer) in the hot NodeDB.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->setNextHop(p->from, p->relay_node);
|
||||
#endif
|
||||
} else {
|
||||
LOG_DEBUG("Not learning next hop for 0x%x: relay byte 0x%x ambiguous/unknown; keep flooding", p->from,
|
||||
p->relay_node);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +161,11 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
|
||||
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
|
||||
if (p->id != 0) {
|
||||
if (isRebroadcaster()) {
|
||||
// NOTE: this is a self-identity match (is the addressed next_hop OUR last byte?), so it
|
||||
// cannot be hardened with resolveLastByte() — a remote node that legitimately shares our
|
||||
// last byte will also match here and rebroadcast. That residual collision needs a wider
|
||||
// on-wire field to fix. M1/M2 instead shrink the blast radius by reducing how often an
|
||||
// ambiguous next_hop byte is ever learned (sniffReceived) or originated (getNextHop).
|
||||
if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) {
|
||||
meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it
|
||||
LOG_INFO("Rebroadcast received message coming from %x", p->relay_node);
|
||||
@@ -194,15 +216,63 @@ std::optional<uint8_t> NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
|
||||
if (isBroadcast(to))
|
||||
return std::nullopt;
|
||||
|
||||
// Hot store first: a direct array hit on the live NodeDB entry.
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(to);
|
||||
if (node && node->next_hop) {
|
||||
// M3: proactively decay a stale or repeatedly-failing route back to flooding, so a dead hop
|
||||
// isn't trusted on the next DM's first (and on dense meshes, slowest) attempt. We only act on
|
||||
// a health record that still matches the stored byte; a next_hop set by another path (e.g.
|
||||
// TraceRouteModule) with no matching record is left authoritative.
|
||||
const RouteHealth *h = findRouteHealth(to);
|
||||
if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, millis())) {
|
||||
LOG_INFO("Next hop 0x%x for 0x%x is stale (age/fails); flood and clear", node->next_hop, to);
|
||||
node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route
|
||||
clearRouteHealth(to); // clear RAM health
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// We are careful not to return the relay node as the next hop
|
||||
if (node->next_hop != relay_node) {
|
||||
// LOG_DEBUG("Next hop for 0x%x is 0x%x", to, node->next_hop);
|
||||
return node->next_hop;
|
||||
// M1/M2: only emit a stored next_hop if its last byte still maps to a UNIQUE, currently
|
||||
// reachable direct neighbor. On a dense mesh the last byte collides, so an ambiguous byte
|
||||
// would unicast a hint toward the wrong physical node; if the neighbor has gone away we'd
|
||||
// unicast into a void. In both cases flood instead (managed flooding still delivers).
|
||||
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
|
||||
if (r.status == LastByteResolution::Unique)
|
||||
return node->next_hop;
|
||||
LOG_WARN("Next hop 0x%x for 0x%x %s; set no pref", node->next_hop, to,
|
||||
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
|
||||
} else
|
||||
LOG_WARN("Next hop for 0x%x is 0x%x, same as relayer; set no pref", to, node->next_hop);
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Fallback: TMM overflow cache holds confirmed hops for nodes that have aged out of the hot store.
|
||||
// It is the same byte source/confidence as NodeInfoLite.next_hop, so it gets the same M1/M2/M3
|
||||
// protection: decay a stale/failing route, then only emit a byte that still resolves to a unique
|
||||
// reachable neighbor. Without this the overflow cache (which holds MORE bytes for MORE nodes) would
|
||||
// reintroduce exactly the silent-misroute that M1/M2 closes on the hot path.
|
||||
if (trafficManagementModule) {
|
||||
uint8_t hint = trafficManagementModule->getNextHopHint(to);
|
||||
if (hint && hint != relay_node) {
|
||||
const RouteHealth *h = findRouteHealth(to);
|
||||
if (h && h->lastNextHop == hint && isRouteStale(*h, millis())) {
|
||||
LOG_INFO("TMM next hop 0x%x for 0x%x is stale (age/fails); flood and clear", hint, to);
|
||||
trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0)
|
||||
clearRouteHealth(to); // clear RAM health
|
||||
return std::nullopt;
|
||||
}
|
||||
ResolvedNode r = nodeDB->resolveLastByte(hint, /*requireDirectNeighbor=*/true);
|
||||
if (r.status == LastByteResolution::Unique) {
|
||||
LOG_DEBUG("Next hop for 0x%x is 0x%x (TMM cache)", to, hint);
|
||||
return hint;
|
||||
}
|
||||
LOG_WARN("TMM next hop 0x%x for 0x%x %s; set no pref", hint, to,
|
||||
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -311,7 +381,10 @@ int32_t NextHopRouter::doRetransmissions()
|
||||
|
||||
if (!isBroadcast(p.packet->to)) {
|
||||
if (p.numRetransmissions == 1) {
|
||||
// Last retransmission, reset next_hop (fallback to FloodingRouter)
|
||||
// Last retransmission: this directed delivery went un-ACKed. Record the failure
|
||||
// (M3 — accumulates across DMs to age out a flapping/dead route) and reset
|
||||
// next_hop so the final try falls back to FloodingRouter.
|
||||
noteRouteFailure(p.packet->to);
|
||||
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
|
||||
// Also reset it in the nodeDB
|
||||
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
|
||||
@@ -319,9 +392,32 @@ int32_t NextHopRouter::doRetransmissions()
|
||||
LOG_INFO("Resetting next hop for packet with dest 0x%x\n", p.packet->to);
|
||||
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
|
||||
}
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
if (trafficManagementModule) {
|
||||
trafficManagementModule->clearNextHop(p.packet->to);
|
||||
}
|
||||
#endif
|
||||
FloodingRouter::send(packetPool.allocCopy(*p.packet));
|
||||
} else {
|
||||
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
|
||||
// M4 (gated): if the route isn't proven healthy, don't spend a second directed
|
||||
// attempt — start flooding one retry sooner to cut recovery latency. A verified
|
||||
// route (fresh, zero recent failures) keeps the unchanged directed-retry path so
|
||||
// the sparse-mesh happy path is untouched.
|
||||
RouteHealth *h = findRouteHealth(p.packet->to);
|
||||
bool verified = h && h->consecutiveFailures == 0 && !isRouteStale(*h, now);
|
||||
if (!verified) {
|
||||
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
|
||||
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
|
||||
if (sentTo)
|
||||
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
|
||||
FloodingRouter::send(packetPool.allocCopy(*p.packet));
|
||||
} else {
|
||||
NextHopRouter::send(packetPool.allocCopy(*p.packet));
|
||||
}
|
||||
#else
|
||||
NextHopRouter::send(packetPool.allocCopy(*p.packet));
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
// Note: we call the superclass version because we don't want to have our version of send() add a new
|
||||
@@ -355,3 +451,96 @@ void NextHopRouter::setNextTx(PendingPacket *pending)
|
||||
printPacket("", pending->packet);
|
||||
setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M3: RAM route-health table. Bounded array with reuse-oldest eviction (same discipline as
|
||||
// PacketHistory). All age comparisons use unsigned subtraction so they survive the 49.7-day millis()
|
||||
// rollover. dest == 0 marks an empty slot; learnedAtMsec is normalized to 1 on write so an occupied
|
||||
// slot is never read as infinitely old.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
RouteHealth *NextHopRouter::findRouteHealth(NodeNum dest)
|
||||
{
|
||||
if (dest == 0)
|
||||
return nullptr;
|
||||
for (auto &h : routeHealth)
|
||||
if (h.dest == dest)
|
||||
return &h;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
RouteHealth *NextHopRouter::getOrAllocRouteHealth(NodeNum dest, uint32_t now)
|
||||
{
|
||||
if (dest == 0)
|
||||
return nullptr;
|
||||
|
||||
RouteHealth *oldest = &routeHealth[0];
|
||||
RouteHealth *freeSlot = nullptr;
|
||||
for (auto &h : routeHealth) {
|
||||
if (h.dest == dest)
|
||||
return &h; // existing record
|
||||
if (h.dest == 0) {
|
||||
if (!freeSlot)
|
||||
freeSlot = &h; // remember the first free slot; prefer it over evicting
|
||||
continue;
|
||||
}
|
||||
// Track the oldest occupied slot in case the table is full (rollover-safe).
|
||||
if ((uint32_t)(now - h.learnedAtMsec) > (uint32_t)(now - oldest->learnedAtMsec))
|
||||
oldest = &h;
|
||||
}
|
||||
// Claim the free slot if there is one, else reuse the oldest. Reset before use and stamp the dest
|
||||
// so the record is findable.
|
||||
RouteHealth *slot = freeSlot ? freeSlot : oldest;
|
||||
*slot = RouteHealth{};
|
||||
slot->dest = dest;
|
||||
return slot;
|
||||
}
|
||||
|
||||
void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now)
|
||||
{
|
||||
if (dest == 0 || nextHop == NO_NEXT_HOP_PREFERENCE)
|
||||
return;
|
||||
RouteHealth *h = getOrAllocRouteHealth(dest, now);
|
||||
if (!h)
|
||||
return;
|
||||
// A genuinely new next hop earns a clean slate; re-learning the SAME hop keeps the accumulated
|
||||
// failure count so an asymmetric reverse path that keeps re-teaching a dead forward hop still ages
|
||||
// out instead of resetting the counter every time.
|
||||
if (h->lastNextHop != nextHop) {
|
||||
h->lastNextHop = nextHop;
|
||||
h->consecutiveFailures = 0;
|
||||
}
|
||||
h->learnedAtMsec = now ? now : 1;
|
||||
}
|
||||
|
||||
void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now)
|
||||
{
|
||||
RouteHealth *h = findRouteHealth(dest);
|
||||
if (!h)
|
||||
return; // only routes we actually learned have health to refresh
|
||||
h->consecutiveFailures = 0;
|
||||
h->learnedAtMsec = now ? now : 1;
|
||||
}
|
||||
|
||||
void NextHopRouter::noteRouteFailure(NodeNum dest)
|
||||
{
|
||||
RouteHealth *h = findRouteHealth(dest);
|
||||
if (!h)
|
||||
return; // nothing to penalize (we were flooding, or never learned a route here)
|
||||
if (h->consecutiveFailures < 255)
|
||||
h->consecutiveFailures++;
|
||||
}
|
||||
|
||||
bool NextHopRouter::isRouteStale(const RouteHealth &h, uint32_t now) const
|
||||
{
|
||||
if (h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD)
|
||||
return true;
|
||||
return (uint32_t)(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC;
|
||||
}
|
||||
|
||||
void NextHopRouter::clearRouteHealth(NodeNum dest)
|
||||
{
|
||||
RouteHealth *h = findRouteHealth(dest);
|
||||
if (h)
|
||||
*h = RouteHealth{};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,28 @@ struct PendingPacket {
|
||||
explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions);
|
||||
};
|
||||
|
||||
/**
|
||||
* RAM-only per-destination route health. Tracks how fresh a learned next_hop is and how many
|
||||
* consecutive directed deliveries to it have failed, so getNextHop() can proactively decay a stale or
|
||||
* repeatedly-failing route back to flooding instead of trusting a dead hop on the next (and on dense
|
||||
* meshes, slowest) attempt. Not persisted: the learned next_hop itself lives in NodeInfoLite; this is
|
||||
* just freshness/failure metadata.
|
||||
*/
|
||||
struct RouteHealth {
|
||||
NodeNum dest = 0; ///< destination this record describes; 0 == empty slot
|
||||
uint32_t learnedAtMsec = 0; ///< millis() when next_hop was last (re)learned (rollover-aware)
|
||||
uint8_t consecutiveFailures = 0; ///< directed deliveries to `dest` that went un-ACKed
|
||||
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; ///< the relay byte this health refers to
|
||||
};
|
||||
|
||||
// M4 (optional, off by default): when a route is not proven healthy, fall back to flooding one retry
|
||||
// earlier instead of spending a second directed attempt. Trades airtime for recovery latency on dense
|
||||
// meshes; leaves the sparse-mesh happy path (fresh, verified routes) unchanged. Measure on the
|
||||
// simulator before enabling broadly.
|
||||
#ifndef NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
|
||||
#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 0
|
||||
#endif
|
||||
|
||||
class GlobalPacketIdHashFunction
|
||||
{
|
||||
public:
|
||||
@@ -92,12 +114,22 @@ class NextHopRouter : public FloodingRouter
|
||||
// The number of retransmissions the original sender will do
|
||||
constexpr static uint8_t NUM_RELIABLE_RETX = 3;
|
||||
|
||||
// M3: bounded RAM route-health table (reuse-oldest eviction, like PacketHistory)
|
||||
constexpr static uint8_t ROUTE_HEALTH_MAX = 32; // ~12B/slot -> ~384B
|
||||
constexpr static uint32_t ROUTE_TTL_MSEC = 30UL * 60 * 1000; // re-discover a route unconfirmed for 30 min
|
||||
constexpr static uint8_t ROUTE_FAILURE_THRESHOLD = 3; // consecutive un-ACKed directed deliveries -> dead
|
||||
|
||||
protected:
|
||||
/**
|
||||
* Pending retransmissions
|
||||
*/
|
||||
std::unordered_map<GlobalPacketId, PendingPacket, GlobalPacketIdHashFunction> pending;
|
||||
|
||||
/**
|
||||
* Per-destination route health (M3). Bounded array, reuse-oldest eviction. RAM-only.
|
||||
*/
|
||||
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
|
||||
|
||||
/**
|
||||
* Should this incoming filter be dropped?
|
||||
*
|
||||
@@ -142,13 +174,38 @@ class NextHopRouter : public FloodingRouter
|
||||
|
||||
void setNextTx(PendingPacket *pending);
|
||||
|
||||
// --- M3 route-health helpers (RAM-only). Protected so ReliableRouter (a subclass) can record
|
||||
// delivery success, and so the unit-test shim can reach them via `using`. All take `now` where
|
||||
// time matters so the decay logic is pure and testable without a clock mock. ---
|
||||
|
||||
/// @return the health record for `dest`, or nullptr if we hold none.
|
||||
RouteHealth *findRouteHealth(NodeNum dest);
|
||||
/// @return an existing record for `dest`, else a freshly claimed slot (reuse-oldest on overflow).
|
||||
RouteHealth *getOrAllocRouteHealth(NodeNum dest, uint32_t now);
|
||||
/// Record that we (re)learned `nextHop` for `dest`. Resets the failure count only when the hop
|
||||
/// changed (so a flapping reverse-path re-learn of the same dead hop still ages out).
|
||||
void noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now);
|
||||
/// Record an end-to-end delivery success to `dest` (clears failures, refreshes freshness).
|
||||
void noteRouteSuccess(NodeNum dest, uint32_t now);
|
||||
/// Record that a directed delivery to `dest` went un-ACKed (no-op if we hold no record).
|
||||
void noteRouteFailure(NodeNum dest);
|
||||
/// @return true if the route is too old (TTL) or has failed too many times in a row.
|
||||
bool isRouteStale(const RouteHealth &h, uint32_t now) const;
|
||||
/// Forget any health record for `dest`.
|
||||
void clearRouteHealth(NodeNum dest);
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
public: // expose getNextHop to the test shim without widening production visibility
|
||||
#else
|
||||
private:
|
||||
#endif
|
||||
/**
|
||||
* Get the next hop for a destination, given the relay node
|
||||
* @return the node number of the next hop, 0 if no preference (fallback to FloodingRouter)
|
||||
*/
|
||||
std::optional<uint8_t> getNextHop(NodeNum to, uint8_t relay_node);
|
||||
|
||||
private:
|
||||
/** Check if we should be rebroadcasting this packet if so, do so.
|
||||
* @return true if we did rebroadcast */
|
||||
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
|
||||
|
||||
+174
-7
@@ -66,6 +66,10 @@
|
||||
#include <utility/bonding.h>
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_RP2040
|
||||
#include <hardware/watchdog.h>
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
|
||||
#include <MeshtasticOTA.h>
|
||||
#endif
|
||||
@@ -590,6 +594,16 @@ NodeDB::NodeDB()
|
||||
moduleConfig.mqtt.map_report_settings.publish_interval_secs = default_map_publish_interval_secs;
|
||||
}
|
||||
|
||||
// If a fixed position is configured, restore the persisted position into localPosition at boot.
|
||||
// This keeps position broadcasts / MQTT map reports working after reboot on GPS-less nodes.
|
||||
if (config.position.fixed_position) {
|
||||
meshtastic_PositionLite fixedPos;
|
||||
if (copyNodePosition(getNodeNum(), fixedPos) && (fixedPos.latitude_i != 0 || fixedPos.longitude_i != 0)) {
|
||||
setLocalPosition(TypeConversions::ConvertToPosition(fixedPos));
|
||||
LOG_INFO("Restored fixed position to localPosition: lat=%d lon=%d", fixedPos.latitude_i, fixedPos.longitude_i);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that the neighbor info update interval is coerced to the minimum
|
||||
moduleConfig.neighbor_info.update_interval =
|
||||
Default::getConfiguredOrMinimumValue(moduleConfig.neighbor_info.update_interval, min_neighbor_info_broadcast_secs);
|
||||
@@ -1145,6 +1159,24 @@ void NodeDB::initConfigIntervals()
|
||||
#endif
|
||||
}
|
||||
|
||||
// Always-on traffic management defaults. Only booleans are written; every
|
||||
// numeric field stays 0 and resolves to its default_traffic_mgmt_* macro at
|
||||
// use (e.g. position dedup precision/interval), so fork-wide tuning changes
|
||||
// take effect without another migration. Rate limiting and the features that
|
||||
// exhaust or reshape relayed traffic (exhaust_hop_*, drop_unknown_enabled,
|
||||
// nodeinfo_direct_response) stay opt-in.
|
||||
static void installTrafficManagementDefaults(meshtastic_LocalModuleConfig &mc)
|
||||
{
|
||||
mc.has_traffic_management = true;
|
||||
mc.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero;
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Position dedup ships enabled at the 11-hour default window on all supported targets.
|
||||
// STM32WL is excluded at compile time (HAS_TRAFFIC_MANAGEMENT=0 in mesh-pb-constants.h).
|
||||
// Set position_min_interval_secs=0 at runtime to disable dedup.
|
||||
mc.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
|
||||
#endif
|
||||
}
|
||||
|
||||
void NodeDB::installDefaultModuleConfig()
|
||||
{
|
||||
LOG_INFO("Install default ModuleConfig");
|
||||
@@ -1262,6 +1294,8 @@ void NodeDB::installDefaultModuleConfig()
|
||||
moduleConfig.has_neighbor_info = true;
|
||||
moduleConfig.neighbor_info.enabled = false;
|
||||
|
||||
installTrafficManagementDefaults(moduleConfig);
|
||||
|
||||
moduleConfig.has_detection_sensor = true;
|
||||
moduleConfig.detection_sensor.enabled = false;
|
||||
moduleConfig.detection_sensor.detection_trigger_type = meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_LOGIC_HIGH;
|
||||
@@ -1613,6 +1647,27 @@ bool NodeDB::enforceSatelliteCaps()
|
||||
return trimmedAny;
|
||||
}
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
// Classify an evicted node's hop-protected category for the warm tier. Favorite/ignored/
|
||||
// verified are local flags (rarely reach warm — they're eviction-protected — but classify
|
||||
// them if they do); otherwise tracker/sensor/tak_tracker are role-protected.
|
||||
static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n)
|
||||
{
|
||||
if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK |
|
||||
NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK))
|
||||
return static_cast<uint8_t>(WarmProtected::Flag);
|
||||
if (IS_ONE_OF(n.role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_SENSOR,
|
||||
meshtastic_Config_DeviceConfig_Role_TAK_TRACKER))
|
||||
return static_cast<uint8_t>(WarmProtected::Role);
|
||||
return static_cast<uint8_t>(WarmProtected::None);
|
||||
}
|
||||
|
||||
// The warm tier packs the device role into a 4-bit field (WARM_ROLE_MASK). Fail the build
|
||||
// loudly if a new role outgrows it, rather than silently truncating role on eviction.
|
||||
static_assert(_meshtastic_Config_DeviceConfig_Role_MAX <= WARM_ROLE_MASK,
|
||||
"device role no longer fits the 4-bit warm metadata field");
|
||||
#endif // WARM_NODE_COUNT > 0
|
||||
|
||||
void NodeDB::cleanupMeshDB()
|
||||
{
|
||||
int newPos = 0, removed = 0;
|
||||
@@ -1639,7 +1694,7 @@ void NodeDB::cleanupMeshDB()
|
||||
// Keep any key we learned (e.g. via a DM before the NodeInfo
|
||||
// exchange completed) rather than losing it with the purge.
|
||||
if (n.public_key.size == 32)
|
||||
warmStore.absorb(gone, n.last_heard, n.public_key.bytes);
|
||||
warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n));
|
||||
#endif
|
||||
|
||||
eraseNodeSatellites(gone);
|
||||
@@ -1822,7 +1877,8 @@ void NodeDB::demoteOldestHotNodesToWarm()
|
||||
continue;
|
||||
// Keep the public key if we have one (40 B warm record); keyless nodes
|
||||
// still get a placeholder so re-admission restores last_heard.
|
||||
warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr);
|
||||
warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role,
|
||||
warmProtectedCategory(n));
|
||||
// Demotion drops the node from the header table, so drop its satellites
|
||||
// too (the eviction chokepoint) — they'd otherwise orphan until the next
|
||||
// enforceSatelliteCaps pass.
|
||||
@@ -2226,6 +2282,16 @@ void NodeDB::loadFromDisk()
|
||||
}
|
||||
}
|
||||
|
||||
// Always-on traffic management: a device that has NEVER configured TMM
|
||||
// (has_traffic_management false — AdminModule always sets the has_ flag on
|
||||
// write, even when disabling) gets the fork defaults. Explicitly configured
|
||||
// devices keep their exact settings.
|
||||
if (!moduleConfig.has_traffic_management) {
|
||||
LOG_INFO("Traffic management never configured, installing always-on defaults");
|
||||
installTrafficManagementDefaults(moduleConfig);
|
||||
saveToDisk(SEGMENT_MODULECONFIG);
|
||||
}
|
||||
|
||||
state = loadProto(channelFileName, meshtastic_ChannelFile_size, sizeof(meshtastic_ChannelFile), &meshtastic_ChannelFile_msg,
|
||||
&channelFile);
|
||||
if (state != LoadFileResult::LOAD_SUCCESS) {
|
||||
@@ -2620,6 +2686,11 @@ bool NodeDB::saveNodeDatabaseToDisk()
|
||||
nodeDatabase.status.clear();
|
||||
nodeDatabase.status.shrink_to_fit();
|
||||
#if WARM_NODE_COUNT > 0
|
||||
#ifdef ARCH_RP2040
|
||||
// nodes.proto + warm.dat are written back-to-back without the loop running between them;
|
||||
// reset the 8s HW watchdog so the second write gets a full budget (issue #10746).
|
||||
watchdog_update();
|
||||
#endif
|
||||
// Same cadence as the node DB; failure is logged but must not propagate —
|
||||
// a false return from here would trigger saveToDisk()'s fsFormat() path.
|
||||
warmStore.saveIfDirty();
|
||||
@@ -2773,6 +2844,10 @@ HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p)
|
||||
return HopStartStatus::INVALID;
|
||||
|
||||
if (p.hop_start == 0) {
|
||||
// hop_start == hop_limit == 0: intentional zero-hop broadcast (e.g. beacon). Valid by definition —
|
||||
// the packet was never meant to travel any hops, so no hop_start ambiguity applies.
|
||||
if (p.hop_limit == 0)
|
||||
return HopStartStatus::VALID;
|
||||
// Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a
|
||||
// bitfield that is always present. Use the presence of the bitfield to determine if the origin's firmware
|
||||
// version is guaranteed to have hop_start populated. Note that this can only be done for decoded packets as
|
||||
@@ -3113,8 +3188,14 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
|
||||
mp.via_mqtt); // Store if we received this packet via MQTT
|
||||
|
||||
#if HAS_VARIABLE_HOPS
|
||||
// Only sample packets that arrived over LoRa.
|
||||
if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && hopScalingModule) {
|
||||
// Only sample genuine RF-origin packets. The transport check excludes packets received
|
||||
// directly from the broker (TRANSPORT_MQTT), but an MQTT-origin packet rebroadcast onto
|
||||
// LoRa by a gateway arrives as TRANSPORT_LORA with via_mqtt set — count those would
|
||||
// inflate the local mesh-size estimate with non-RF nodes (and they usually carry
|
||||
// hop_start==0, landing in the hop-0 bucket that pulls the recommendation lowest), so
|
||||
// exclude via_mqtt too.
|
||||
if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt &&
|
||||
hopScalingModule) {
|
||||
uint8_t hopCount = std::max(int8_t(0), getHopsAway(mp));
|
||||
hopScalingModule->samplePacketForHistogram(mp.from, hopCount);
|
||||
}
|
||||
@@ -3295,6 +3376,73 @@ meshtastic_NodeInfoLite *NodeDB::getMeshNode(NodeNum n)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
ResolvedNode NodeDB::resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor)
|
||||
{
|
||||
ResolvedNode result; // defaults to {None, 0}
|
||||
|
||||
// 0 is the NO_RELAY_NODE / NO_NEXT_HOP_PREFERENCE sentinel (also what MQTT-sourced packets carry
|
||||
// when hop_start==0). getLastByteOfNodeNum() never yields 0, so nothing can legitimately match.
|
||||
if (lastByte == 0)
|
||||
return result;
|
||||
|
||||
const NodeNum self = getNodeNum();
|
||||
NodeNum firstMatch = 0;
|
||||
uint8_t matches = 0;
|
||||
|
||||
for (size_t i = 0; i < numMeshNodes; i++) {
|
||||
const meshtastic_NodeInfoLite *node = &meshNodes->at(i);
|
||||
|
||||
// Candidate gate: never resolve to ourselves, the sentinels, or an ignored node.
|
||||
if (node->num == self || node->num == 0 || node->num == NODENUM_BROADCAST)
|
||||
continue;
|
||||
if (nodeInfoLiteIsIgnored(node))
|
||||
continue;
|
||||
if (getLastByteOfNodeNum(node->num) != lastByte) // cheapest discriminator last
|
||||
continue;
|
||||
|
||||
// Relevance gate: is this node a plausible relay for the requested scope?
|
||||
bool relevant;
|
||||
if (requireDirectNeighbor) {
|
||||
relevant = node->has_hops_away && node->hops_away == 0 && sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS;
|
||||
} else {
|
||||
const bool directNeighbor = node->has_hops_away && node->hops_away == 0;
|
||||
const bool routerRole =
|
||||
IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
|
||||
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE);
|
||||
relevant = directNeighbor || nodeInfoLiteIsFavorite(node) || routerRole;
|
||||
}
|
||||
if (!relevant)
|
||||
continue;
|
||||
|
||||
if (++matches == 1) {
|
||||
firstMatch = node->num;
|
||||
} else {
|
||||
// A second relevant candidate shares this byte: ambiguous. No further scanning can
|
||||
// change that, so stop early and report the collision.
|
||||
result.status = LastByteResolution::Ambiguous;
|
||||
result.num = 0;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches == 1) {
|
||||
result.status = LastByteResolution::Unique;
|
||||
result.num = firstMatch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool NodeDB::resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum)
|
||||
{
|
||||
ResolvedNode r = resolveLastByte(lastByte, requireDirectNeighbor);
|
||||
if (r.status == LastByteResolution::Unique) {
|
||||
if (outNum)
|
||||
*outNum = r.num;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// returns true if the maximum number of nodes is reached or we are running low on memory
|
||||
bool NodeDB::isFull()
|
||||
{
|
||||
@@ -3325,6 +3473,20 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
|
||||
return false;
|
||||
}
|
||||
|
||||
meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n)
|
||||
{
|
||||
const meshtastic_NodeInfoLite *info = getMeshNode(n);
|
||||
if (nodeInfoLiteHasUser(info))
|
||||
return info->role;
|
||||
#if WARM_NODE_COUNT > 0
|
||||
// Hot-store miss: fall back to the role the warm tier cached at eviction.
|
||||
uint8_t role = 0, prot = 0;
|
||||
if (warmStore.lookupMeta(n, role, prot))
|
||||
return static_cast<meshtastic_Config_DeviceConfig_Role>(role);
|
||||
#endif
|
||||
return meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
}
|
||||
|
||||
/// Find a node in our DB, create an empty NodeInfo if missing
|
||||
meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
||||
{
|
||||
@@ -3365,8 +3527,8 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
||||
#if WARM_NODE_COUNT > 0
|
||||
// Demote to the warm tier so the identity (and crucially the
|
||||
// PKI key) outlives the hot-store slot.
|
||||
warmStore.absorb(evicted.num, evicted.last_heard,
|
||||
evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL);
|
||||
warmStore.absorb(evicted.num, evicted.last_heard, evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL,
|
||||
evicted.role, warmProtectedCategory(evicted));
|
||||
#endif
|
||||
eraseNodeSatellites(evicted.num);
|
||||
// Shove the remaining nodes down the chain
|
||||
@@ -3395,7 +3557,10 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
|
||||
// Re-admission: restore what the warm tier kept for this node
|
||||
WarmNodeEntry warm;
|
||||
if (warmStore.take(n, warm)) {
|
||||
lite->last_heard = warm.last_heard;
|
||||
lite->last_heard = warmTimeOf(warm); // mask off the stolen role/protected metadata bits
|
||||
// Restore the role the warm tier cached, so re-admission isn't stuck at CLIENT
|
||||
// until the next NodeInfo arrives.
|
||||
lite->role = static_cast<meshtastic_Config_DeviceConfig_Role>(warmRoleOf(warm));
|
||||
if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) {
|
||||
lite->public_key.size = 32;
|
||||
memcpy(lite->public_key.bytes, warm.public_key, 32);
|
||||
@@ -3544,6 +3709,8 @@ bool NodeDB::createNewIdentity()
|
||||
myNodeInfo.my_node_num = newNodeNum;
|
||||
|
||||
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum());
|
||||
if (!info)
|
||||
return false;
|
||||
TypeConversions::CopyUserToNodeInfoLite(info, owner);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -115,6 +115,20 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n);
|
||||
/// Given a packet, return how many seconds in the past (vs now) it was received
|
||||
uint32_t sinceReceived(const meshtastic_MeshPacket *p);
|
||||
|
||||
/// Outcome of mapping a single on-wire last-byte (next_hop / relay_node) back to a full NodeNum.
|
||||
/// Because the wire only carries the last byte of a 32-bit node number, the mapping is ambiguous on
|
||||
/// dense meshes (the "birthday problem"). Callers must treat Ambiguous and None as "don't trust it".
|
||||
enum class LastByteResolution : uint8_t {
|
||||
None, ///< no relevant candidate node has this last byte
|
||||
Unique, ///< exactly one relevant candidate -> `num` is valid
|
||||
Ambiguous, ///< two or more relevant candidates collide on this byte
|
||||
};
|
||||
|
||||
struct ResolvedNode {
|
||||
LastByteResolution status = LastByteResolution::None;
|
||||
NodeNum num = 0; ///< valid only when status == Unique
|
||||
};
|
||||
|
||||
/// Given a packet, return the number of hops used to reach this node.
|
||||
/// Returns defaultIfUnknown if the number of hops couldn't be determined.
|
||||
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1);
|
||||
@@ -327,10 +341,32 @@ class NodeDB
|
||||
/// tier. Returns false if we don't know a key for n.
|
||||
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// Resolve a node's device role — hot store (with user) first, then the role
|
||||
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
|
||||
/// nodes that have aged out of the hot store.
|
||||
meshtastic_Config_DeviceConfig_Role getNodeRole(NodeNum n);
|
||||
|
||||
/// last_heard of a hot-store node, or 0 if absent. Plain scan of meshNodes
|
||||
/// with no allocation side effects (unlike getOrCreateMeshNode).
|
||||
uint32_t hotNodeLastHeard(NodeNum n) const;
|
||||
|
||||
/**
|
||||
* Resolve a single on-wire last-byte (e.g. next_hop / relay_node) back to a unique full NodeNum,
|
||||
* detecting last-byte collisions instead of silently picking the first match. A 1-byte id only
|
||||
* needs to be unique among a node's plausible relays, not the whole mesh, so we scope the search:
|
||||
* - requireDirectNeighbor == true : candidates are direct neighbors (hops_away==0) heard within
|
||||
* NEXTHOP_NEIGHBOR_FRESH_SECS. Use on the SEND path.
|
||||
* - requireDirectNeighbor == false : also accept favorites and router-role nodes (unknown hop
|
||||
* distance allowed). Use when learning / preserving hops.
|
||||
* Ignored nodes, our own node, and the broadcast/0 sentinels are never candidates. On a tie the
|
||||
* result is Ambiguous (no tie-break) so callers fall back to flooding rather than misroute.
|
||||
*/
|
||||
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
|
||||
|
||||
/// Convenience wrapper around resolveLastByte(): true iff exactly one relevant candidate matches.
|
||||
/// Ambiguous and None both return false (the safe answer for learning / hop preservation).
|
||||
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
|
||||
|
||||
// Thread-safe satellite-map accessors. Return false if absent or the
|
||||
// corresponding DB is compiled out.
|
||||
bool copyNodePosition(NodeNum n, meshtastic_PositionLite &out) const;
|
||||
|
||||
@@ -486,7 +486,11 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const N
|
||||
}
|
||||
|
||||
/* Check if a certain node was a relayer of a packet in the history given iterator
|
||||
* @return true if node was indeed a relayer, false if not */
|
||||
* @return true if node was indeed a relayer, false if not
|
||||
* NOTE: intentionally byte-domain. Both `relayer` and relayed_by[] are on-wire last bytes, so this
|
||||
* answers "did a relayer with this byte touch the packet" — correct without resolving to a NodeNum.
|
||||
* The collision risk is neutralized where the result is consumed (route learning in
|
||||
* NextHopRouter::sniffReceived now gates the write through NodeDB::resolveUniqueLastByte). */
|
||||
bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, bool *wasSole)
|
||||
{
|
||||
bool found = false;
|
||||
|
||||
+49
-10
@@ -12,6 +12,7 @@
|
||||
#include "Channels.h"
|
||||
#include "Default.h"
|
||||
#include "FSCommon.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PacketHistory.h"
|
||||
@@ -39,6 +40,17 @@
|
||||
#include "Throttle.h"
|
||||
#include <RTC.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uint8_t FILES_MANIFEST_LEVELS = 3;
|
||||
constexpr size_t FILES_MANIFEST_MAX_COUNT = 64;
|
||||
|
||||
void releaseFilesManifest(std::vector<meshtastic_FileInfo> &filesManifest)
|
||||
{
|
||||
std::vector<meshtastic_FileInfo>().swap(filesManifest);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Flag to indicate a heartbeat was received and we should send queue status
|
||||
bool heartbeatReceived = false;
|
||||
|
||||
@@ -297,18 +309,31 @@ void PhoneAPI::handleStartConfig()
|
||||
state = STATE_SEND_MY_INFO;
|
||||
}
|
||||
pauseBluetoothLogging = true;
|
||||
spiLock->lock();
|
||||
#if defined(MESHTASTIC_EXCLUDE_FILES_MANIFEST)
|
||||
// Skip the recursive FS walk. Used by platforms whose Zephyr LittleFS
|
||||
// backend can't safely traverse a deep tree (e.g. nRF54L15) and platforms
|
||||
// that don't support OTA browsing — the manifest is only consumed by
|
||||
// companion apps for those flows.
|
||||
filesManifest.clear();
|
||||
releaseFilesManifest(filesManifest);
|
||||
#else
|
||||
filesManifest = getFiles("/", 10);
|
||||
// Manifest is never read on the node-info-only path (STATE_SEND_FILEMANIFEST
|
||||
// short-circuits to sendConfigComplete), so skip the SPI lock + FS walk.
|
||||
if (config_nonce != SPECIAL_NONCE_ONLY_NODES) {
|
||||
bool filesManifestLimited = false;
|
||||
{
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
filesManifest = getFiles("/", FILES_MANIFEST_LEVELS, FILES_MANIFEST_MAX_COUNT, &filesManifestLimited);
|
||||
}
|
||||
if (filesManifestLimited) {
|
||||
LOG_WARN("Got %zu files in manifest (limited to %zu entries/depth %u)", filesManifest.size(),
|
||||
FILES_MANIFEST_MAX_COUNT, static_cast<unsigned>(FILES_MANIFEST_LEVELS));
|
||||
} else {
|
||||
LOG_DEBUG("Got %zu files in manifest", filesManifest.size());
|
||||
}
|
||||
} else {
|
||||
releaseFilesManifest(filesManifest);
|
||||
}
|
||||
#endif
|
||||
spiLock->unlock();
|
||||
LOG_DEBUG("Got %d files in manifest", filesManifest.size());
|
||||
|
||||
LOG_INFO("Start API client config millis=%u", millis());
|
||||
// Protect against concurrent BLE callbacks: they run in NimBLE's FreeRTOS task and also touch nodeInfoQueue.
|
||||
@@ -376,8 +401,7 @@ void PhoneAPI::close()
|
||||
replayPhase = REPLAY_PHASE_IDLE;
|
||||
}
|
||||
packetForPhone = NULL;
|
||||
filesManifest.clear();
|
||||
filesManifest.shrink_to_fit();
|
||||
releaseFilesManifest(filesManifest);
|
||||
lastPortNumToRadio.clear();
|
||||
fromRadioNum = 0;
|
||||
config_nonce = 0;
|
||||
@@ -516,9 +540,10 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
|
||||
STATE_SEND_UIDATA,
|
||||
STATE_SEND_OWN_NODEINFO,
|
||||
STATE_SEND_METADATA,
|
||||
STATE_SEND_CHANNELS
|
||||
STATE_SEND_REGION_PRESETS, // region -> valid modem presets (one message)
|
||||
STATE_SEND_CHANNELS,
|
||||
STATE_SEND_CONFIG,
|
||||
STATE_SEND_MODULE_CONFIG,
|
||||
STATE_SEND_MODULECONFIG,
|
||||
STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to the client
|
||||
STATE_SEND_FILEMANIFEST,
|
||||
STATE_SEND_COMPLETE_ID,
|
||||
@@ -636,7 +661,20 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
memset(&fromRadioScratch.metadata, 0, sizeof(fromRadioScratch.metadata));
|
||||
}
|
||||
#endif
|
||||
state = STATE_SEND_REGION_PRESETS;
|
||||
break;
|
||||
|
||||
case STATE_SEND_REGION_PRESETS:
|
||||
// Tell the client which modem presets are legal in each region so its UI
|
||||
// can block illegal region+preset combinations. This is public RF /
|
||||
// regulatory information (region and modem_preset are already in the
|
||||
// unauthenticated LoRa whitelist below), so it is sent unconditionally —
|
||||
// even an unauthorized/locked-down client can render a correct picker.
|
||||
LOG_DEBUG("Send region preset map");
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_region_presets_tag;
|
||||
getRegionPresetMap(fromRadioScratch.region_presets);
|
||||
state = STATE_SEND_CHANNELS;
|
||||
config_state = 0; // STATE_SEND_CHANNELS indexes channels starting at 0
|
||||
break;
|
||||
|
||||
case STATE_SEND_CHANNELS:
|
||||
@@ -928,7 +966,7 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
// ONLY_NODES variants skip the manifest.
|
||||
if (config_state == filesManifest.size() || config_nonce == SPECIAL_NONCE_ONLY_NODES) {
|
||||
config_state = 0;
|
||||
filesManifest.clear();
|
||||
releaseFilesManifest(filesManifest);
|
||||
// Skip to complete packet
|
||||
sendConfigComplete();
|
||||
} else {
|
||||
@@ -1517,6 +1555,7 @@ bool PhoneAPI::available()
|
||||
case STATE_SEND_CONFIG:
|
||||
case STATE_SEND_MODULECONFIG:
|
||||
case STATE_SEND_METADATA:
|
||||
case STATE_SEND_REGION_PRESETS:
|
||||
case STATE_SEND_OWN_NODEINFO:
|
||||
case STATE_SEND_FILEMANIFEST:
|
||||
case STATE_SEND_COMPLETE_ID:
|
||||
|
||||
@@ -46,6 +46,7 @@ class PhoneAPI
|
||||
STATE_SEND_MY_INFO, // send our my info record
|
||||
STATE_SEND_OWN_NODEINFO,
|
||||
STATE_SEND_METADATA,
|
||||
STATE_SEND_REGION_PRESETS, // Send the region->valid-preset map (one message)
|
||||
STATE_SEND_CHANNELS, // Send all channels
|
||||
STATE_SEND_CONFIG, // Replacement for the old Radioconfig
|
||||
STATE_SEND_MODULECONFIG, // Send Module specific config
|
||||
|
||||
@@ -28,8 +28,11 @@ uint32_t getPositionPrecisionForChannel(uint8_t channelIndex)
|
||||
return precision;
|
||||
}
|
||||
|
||||
static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
|
||||
{
|
||||
if (precision == 0 || precision >= 32)
|
||||
return coordinate;
|
||||
|
||||
uint32_t coordinateBits = static_cast<uint32_t>(coordinate);
|
||||
uint32_t truncated = coordinateBits & (UINT32_MAX << (32 - precision));
|
||||
|
||||
@@ -39,6 +42,11 @@ static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
|
||||
return static_cast<int32_t>(truncated);
|
||||
}
|
||||
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint8_t precision)
|
||||
{
|
||||
return truncateCoordinate(coordinate, static_cast<uint32_t>(precision));
|
||||
}
|
||||
|
||||
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision)
|
||||
{
|
||||
if (precision == 0) {
|
||||
|
||||
@@ -15,6 +15,12 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel);
|
||||
|
||||
// Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable.
|
||||
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex);
|
||||
|
||||
// Truncate a single latitude_i/longitude_i to `precision` significant bits, centered in the
|
||||
// resulting grid cell (stable under GPS jitter). precision 0 or >=32 returns the value unchanged.
|
||||
// The return is the coordinate (int32_t); the uint8_t overload only narrows the precision arg.
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision);
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint8_t precision);
|
||||
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision);
|
||||
bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision);
|
||||
bool applyPositionPrecisionForChannel(meshtastic_MeshPacket &packet, uint8_t channelIndex);
|
||||
|
||||
@@ -605,6 +605,62 @@ const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code)
|
||||
return r;
|
||||
}
|
||||
|
||||
void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map)
|
||||
{
|
||||
map = meshtastic_LoRaRegionPresetMap_init_zero;
|
||||
|
||||
const size_t maxGroups = sizeof(map.groups) / sizeof(map.groups[0]);
|
||||
const size_t maxRegions = sizeof(map.region_groups) / sizeof(map.region_groups[0]);
|
||||
const size_t maxPresets = sizeof(map.groups[0].presets) / sizeof(map.groups[0].presets[0]);
|
||||
|
||||
// Coalesce regions that share an identical preset list into one group. Two
|
||||
// regions belong to the same group when they share the same RegionProfile
|
||||
// (which owns the preset list + licensing) AND the same default preset.
|
||||
// Keyed by profile pointer, not the preset-array pointer: PROFILE_NARROW and
|
||||
// PROFILE_HAM_100KHZ share PRESETS_NARROW but differ in licensedOnly.
|
||||
const RegionProfile *groupProfile[sizeof(map.groups) / sizeof(map.groups[0])] = {};
|
||||
|
||||
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) {
|
||||
// No room left to map any further region; once full we can't add more, so
|
||||
// log once and stop. An incomplete map means clients won't constrain the
|
||||
// omitted regions, so this must be discoverable rather than silent.
|
||||
if (map.region_groups_count >= maxRegions) {
|
||||
LOG_ERROR("Region preset map full at %u regions; remaining regions omitted", (unsigned)maxRegions);
|
||||
break;
|
||||
}
|
||||
|
||||
// Find the group this region belongs to, or create it.
|
||||
int gi = -1;
|
||||
for (pb_size_t g = 0; g < map.groups_count; g++) {
|
||||
if (groupProfile[g] == r->profile && map.groups[g].default_preset == r->getDefaultPreset()) {
|
||||
gi = g;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (gi < 0) {
|
||||
if (map.groups_count >= maxGroups) {
|
||||
// Out of group slots (should not happen for the current table). The
|
||||
// region can't be advertised; skip it but make the gap visible.
|
||||
LOG_ERROR("Region preset map out of group slots (%u); region %d omitted", (unsigned)maxGroups, r->code);
|
||||
continue;
|
||||
}
|
||||
gi = map.groups_count++;
|
||||
groupProfile[gi] = r->profile;
|
||||
meshtastic_LoRaPresetGroup &grp = map.groups[gi];
|
||||
grp.default_preset = r->getDefaultPreset();
|
||||
grp.licensed_only = r->profile->licensedOnly;
|
||||
grp.presets_count = 0;
|
||||
for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++)
|
||||
grp.presets[grp.presets_count++] = r->profile->presets[i];
|
||||
}
|
||||
|
||||
// Map this region to its group (capacity checked at the top of the loop).
|
||||
meshtastic_LoRaRegionPresets &rg = map.region_groups[map.region_groups_count++];
|
||||
rg.region = r->code;
|
||||
rg.group_index = (uint8_t)gi;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duty cycle for current region. EU_866: 10% for routers, 2.5% for mobile.
|
||||
*/
|
||||
|
||||
@@ -151,6 +151,10 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas
|
||||
LOG_DEBUG("Received a %s for 0x%x, stopping retransmissions", ackId ? "ACK" : "NAK", ackId);
|
||||
if (ackId) {
|
||||
stopRetransmission(p->to, ackId);
|
||||
// M3: an end-to-end ACK proves the directed route to the ACK's sender currently works,
|
||||
// so clear its failure count and refresh freshness (keeps a good route pinned).
|
||||
if (!isBroadcast(getFrom(p)))
|
||||
noteRouteSuccess(getFrom(p), millis());
|
||||
} else {
|
||||
stopRetransmission(p->to, nakId);
|
||||
}
|
||||
|
||||
+20
-40
@@ -100,51 +100,31 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
|
||||
return true;
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// When router_preserve_hops is enabled, preserve hops for decoded packets that are not
|
||||
// position or telemetry (those have their own exhaust_hop controls).
|
||||
if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled &&
|
||||
moduleConfig.traffic_management.router_preserve_hops && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
p->decoded.portnum != meshtastic_PortNum_POSITION_APP && p->decoded.portnum != meshtastic_PortNum_TELEMETRY_APP) {
|
||||
LOG_DEBUG("Router hop preserved: port=%d from=0x%08x (traffic_management)", p->decoded.portnum, getFrom(p));
|
||||
if (trafficManagementModule) {
|
||||
trafficManagementModule->recordRouterHopPreserved();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
// router_preserve_hops: not suitable right now — removed from config until
|
||||
// the right heuristics for when to preserve vs. exhaust hops are established.
|
||||
// #if HAS_TRAFFIC_MANAGEMENT
|
||||
// if (moduleConfig.has_traffic_management &&
|
||||
// moduleConfig.traffic_management.router_preserve_hops && ...) { ... }
|
||||
// #endif
|
||||
|
||||
// For subsequent hops, check if previous relay is a favorite router
|
||||
// Optimized search for favorite routers with matching last byte
|
||||
// Check ordering optimized for IoT devices (cheapest checks first)
|
||||
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
||||
if (!node)
|
||||
continue;
|
||||
|
||||
// Check 1: is_favorite (cheapest - single bit test)
|
||||
if (!nodeInfoLiteIsFavorite(node))
|
||||
continue;
|
||||
|
||||
// Check 2: has_user (cheap - single bit test)
|
||||
if (!nodeInfoLiteHasUser(node))
|
||||
continue;
|
||||
|
||||
// Check 3: role check (moderate cost - multiple comparisons)
|
||||
if (!IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
|
||||
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check 4: last byte extraction and comparison (most expensive)
|
||||
if (nodeDB->getLastByteOfNodeNum(node->num) == p->relay_node) {
|
||||
// Found a favorite router match
|
||||
LOG_DEBUG("Identified favorite relay router 0x%x from last byte 0x%x", node->num, p->relay_node);
|
||||
// For subsequent hops, preserve hop_limit only when the previous relay is UNAMBIGUOUSLY a favorite
|
||||
// router. The relay_node byte is just the last byte of a 32-bit node number, so on a dense mesh it
|
||||
// collides; the old "first matching node wins" scan could preserve hops for the wrong node
|
||||
// (non-deterministic, depends on NodeDB order). resolveLastByte() reports a collision instead, and
|
||||
// we re-check the favorite/router predicate on the single resolved node. On ambiguity/none we
|
||||
// decrement (the safe default).
|
||||
NodeNum resolved = 0;
|
||||
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false, &resolved)) {
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(resolved);
|
||||
if (node && nodeInfoLiteIsFavorite(node) && nodeInfoLiteHasUser(node) &&
|
||||
IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
|
||||
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
|
||||
LOG_DEBUG("Identified unique favorite relay router 0x%x from last byte 0x%x", resolved, p->relay_node);
|
||||
return false; // Don't decrement hop_limit
|
||||
}
|
||||
}
|
||||
|
||||
// No favorite router match found, decrement hop_limit
|
||||
// No unambiguous favorite router match found, decrement hop_limit
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+30
-27
@@ -52,7 +52,8 @@ void StreamAPI::writeStream()
|
||||
do {
|
||||
// Send every packet we can
|
||||
len = getFromRadio(txBuf + HEADER_LEN);
|
||||
emitTxBuffer(len);
|
||||
if (len != 0 && !emitTxBuffer(len))
|
||||
break;
|
||||
} while (len);
|
||||
}
|
||||
}
|
||||
@@ -169,21 +170,36 @@ int32_t StreamAPI::readStream()
|
||||
/**
|
||||
* Send the current txBuffer over our stream
|
||||
*/
|
||||
void StreamAPI::emitTxBuffer(size_t len)
|
||||
bool StreamAPI::writeFrame(uint8_t *buf, size_t len)
|
||||
{
|
||||
if (len != 0) {
|
||||
txBuf[0] = START1;
|
||||
txBuf[1] = START2;
|
||||
txBuf[2] = (len >> 8) & 0xff;
|
||||
txBuf[3] = len & 0xff;
|
||||
if (len == 0 || !canWrite)
|
||||
return false;
|
||||
|
||||
auto totalLen = len + HEADER_LEN;
|
||||
// Serialize stream writes against `emitLogRecord` so a LOG_ firing
|
||||
// mid-packet-emission can't interleave bytes on the wire.
|
||||
concurrency::LockGuard guard(&streamLock);
|
||||
stream->write(txBuf, totalLen);
|
||||
buf[0] = START1;
|
||||
buf[1] = START2;
|
||||
buf[2] = (len >> 8) & 0xff;
|
||||
buf[3] = len & 0xff;
|
||||
|
||||
auto totalLen = len + HEADER_LEN;
|
||||
// Serialize write-readiness checks, writes and write-failure handling
|
||||
// against concurrent stream writes/close.
|
||||
concurrency::LockGuard guard(&streamLock);
|
||||
if (!canWriteFrame(totalLen))
|
||||
return false;
|
||||
|
||||
size_t written = stream->write(buf, totalLen);
|
||||
if (written == totalLen) {
|
||||
stream->flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
onFrameWriteFailed(totalLen, written);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool StreamAPI::emitTxBuffer(size_t len)
|
||||
{
|
||||
return writeFrame(txBuf, len);
|
||||
}
|
||||
|
||||
void StreamAPI::emitRebooted()
|
||||
@@ -221,20 +237,7 @@ void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src,
|
||||
|
||||
size_t len =
|
||||
pb_encode_to_bytes(txBufLog + HEADER_LEN, meshtastic_FromRadio_size, &meshtastic_FromRadio_msg, &fromRadioScratchLog);
|
||||
if (len != 0) {
|
||||
txBufLog[0] = START1;
|
||||
txBufLog[1] = START2;
|
||||
txBufLog[2] = (len >> 8) & 0xff;
|
||||
txBufLog[3] = len & 0xff;
|
||||
|
||||
auto totalLen = len + HEADER_LEN;
|
||||
// Serialize stream writes against `emitTxBuffer` so a packet
|
||||
// emission in flight on another task doesn't interleave bytes
|
||||
// with this log record.
|
||||
concurrency::LockGuard guard(&streamLock);
|
||||
stream->write(txBufLog, totalLen);
|
||||
stream->flush();
|
||||
}
|
||||
writeFrame(txBufLog, len);
|
||||
}
|
||||
|
||||
/// Hookable to find out when connection changes
|
||||
@@ -249,4 +252,4 @@ void StreamAPI::onConnectionChanged(bool connected)
|
||||
// received a packet in a while
|
||||
powerFSM.trigger(EVENT_SERIAL_DISCONNECTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class StreamAPI : public PhoneAPI
|
||||
/**
|
||||
* Send the current txBuffer over our stream
|
||||
*/
|
||||
void emitTxBuffer(size_t len);
|
||||
bool emitTxBuffer(size_t len);
|
||||
|
||||
/// Are we allowed to write packets to our output stream (subclasses can turn this off - i.e. SerialConsole)
|
||||
bool canWrite = true;
|
||||
@@ -91,7 +91,12 @@ class StreamAPI : public PhoneAPI
|
||||
/// Low level function to emit a protobuf encapsulated log record
|
||||
void emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg);
|
||||
|
||||
virtual bool canWriteFrame(size_t frameLen) { return true; }
|
||||
virtual void onFrameWriteFailed(size_t frameLen, size_t writtenLen) {}
|
||||
|
||||
private:
|
||||
bool writeFrame(uint8_t *buf, size_t len);
|
||||
|
||||
/// Dedicated scratch + tx buffer for LogRecord emission.
|
||||
///
|
||||
/// The main packet emission path (`writeStream` -> `getFromRadio` ->
|
||||
@@ -113,4 +118,4 @@ class StreamAPI : public PhoneAPI
|
||||
meshtastic_FromRadio fromRadioScratchLog = {};
|
||||
uint8_t txBufLog[MAX_STREAM_BUF_SIZE] = {0};
|
||||
concurrency::Lock streamLock;
|
||||
};
|
||||
};
|
||||
|
||||
+83
-14
@@ -12,7 +12,12 @@
|
||||
|
||||
#if defined(NRF52840_XXAA)
|
||||
#include "flash/flash_nrf5x.h"
|
||||
#define WARM_RING_MAGIC 0x474E5257u // "WRNG"
|
||||
#define WARM_RING_MAGIC 0x324E5257u // "WRN2" — v2: last_heard low bits carry role + protected category
|
||||
#define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" — v1: last_heard was a plain timestamp.
|
||||
// v1 pages are still read on upgrade: we keep each record's identity + public key but
|
||||
// DISCARD its last_heard (the old timestamp would be misread as role/protected bits).
|
||||
// Records re-rank and re-learn their role on the next contact. Legacy pages convert to
|
||||
// v2 naturally as the ring rotates.
|
||||
// A tombstone is an entry record whose last_heard is all-ones — getTime()
|
||||
// (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is
|
||||
// detected via num == 0xFFFFFFFF before last_heard is ever inspected.
|
||||
@@ -28,7 +33,10 @@ struct WarmStoreHeader {
|
||||
};
|
||||
static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format");
|
||||
|
||||
#define WARM_STORE_MAGIC 0x314D5257u // "WRM1"
|
||||
#define WARM_STORE_MAGIC 0x324D5257u // "WRM2" — v2: last_heard low bits carry role + protected category
|
||||
#define WARM_STORE_MAGIC_V1 \
|
||||
0x314D5257u // "WRM1" — v1: last_heard was a plain timestamp. On upgrade we keep
|
||||
// identity + key but discard last_heard, then rewrite as v2.
|
||||
|
||||
#ifdef FSCom
|
||||
static const char *warmFileName = "/prefs/warm.dat";
|
||||
@@ -96,11 +104,13 @@ WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8
|
||||
slot = &e;
|
||||
break;
|
||||
}
|
||||
// Compare on the time bits only — the low metadata bits (role/protected) must
|
||||
// not perturb LRU victim selection.
|
||||
if (keyIsSet(e.public_key)) {
|
||||
if (!oldestKeyed || e.last_heard < oldestKeyed->last_heard)
|
||||
if (!oldestKeyed || warmTimeOf(e) < warmTimeOf(*oldestKeyed))
|
||||
oldestKeyed = &e;
|
||||
} else {
|
||||
if (!oldestKeyless || e.last_heard < oldestKeyless->last_heard)
|
||||
if (!oldestKeyless || warmTimeOf(e) < warmTimeOf(*oldestKeyless))
|
||||
oldestKeyless = &e;
|
||||
}
|
||||
}
|
||||
@@ -121,14 +131,28 @@ WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8
|
||||
return slot;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32)
|
||||
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat)
|
||||
{
|
||||
const WarmNodeEntry *slot = place(num, lastHeard, key32);
|
||||
// Pack role + protected category into the low bits of last_heard. place() and ring
|
||||
// replay store the raw word verbatim, so the metadata round-trips through flash.
|
||||
const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat);
|
||||
const WarmNodeEntry *slot = place(num, packed, key32);
|
||||
if (!slot)
|
||||
return false;
|
||||
persistEntry(*slot);
|
||||
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u (now %u/%u)", (unsigned)num, keyIsSet(slot->public_key) ? 1 : 0,
|
||||
(unsigned)lastHeard, (unsigned)count(), (unsigned)capacity());
|
||||
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u (now %u/%u)", (unsigned)num,
|
||||
keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat,
|
||||
(unsigned)count(), (unsigned)capacity());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const
|
||||
{
|
||||
const WarmNodeEntry *e = find(num);
|
||||
if (!e)
|
||||
return false;
|
||||
role = warmRoleOf(*e);
|
||||
protectedCat = warmProtOf(*e);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -231,10 +255,22 @@ bool WarmNodeStore::saveIfDirty()
|
||||
// (stranded live entries re-appended, then erased). Flash access holds spiLock —
|
||||
// the page cache is shared with InternalFS/LittleFS.
|
||||
|
||||
bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h) const
|
||||
bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy) const
|
||||
{
|
||||
flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h));
|
||||
return h.magic == WARM_RING_MAGIC && h.seq != 0xFFFFFFFFu;
|
||||
if (h.seq == 0xFFFFFFFFu)
|
||||
return false; // erased page
|
||||
if (h.magic == WARM_RING_MAGIC) {
|
||||
if (legacy)
|
||||
*legacy = false;
|
||||
return true;
|
||||
}
|
||||
if (h.magic == WARM_RING_MAGIC_V1) {
|
||||
if (legacy)
|
||||
*legacy = true; // v1 page: replay it, but discard last_heard (see WARM_RING_MAGIC_V1)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Caller holds spiLock.
|
||||
@@ -347,11 +383,13 @@ void WarmNodeStore::load()
|
||||
// Order valid pages by ascending seq so replay applies oldest first
|
||||
uint8_t order[WARM_FLASH_PAGES] = {};
|
||||
uint32_t seqs[WARM_FLASH_PAGES] = {};
|
||||
bool legacyOf[WARM_FLASH_PAGES] = {}; // per-page: v1 (WRNG) → discard last_heard on replay
|
||||
uint8_t nValid = 0;
|
||||
uint8_t nCorrupt = 0;
|
||||
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
|
||||
WarmPageHeader h;
|
||||
if (!ringReadHeader(p, h)) {
|
||||
bool legacy = false;
|
||||
if (!ringReadHeader(p, h, &legacy)) {
|
||||
// An erased page reads back all-ones; any other magic is a
|
||||
// partially-written or bit-rotted header we're dropping, so flag it
|
||||
// rather than silently treating the loss as a clean empty ring.
|
||||
@@ -359,6 +397,7 @@ void WarmNodeStore::load()
|
||||
nCorrupt++;
|
||||
continue;
|
||||
}
|
||||
legacyOf[p] = legacy;
|
||||
uint8_t pos = nValid;
|
||||
while (pos > 0 && static_cast<int32_t>(h.seq - seqs[pos - 1]) < 0) {
|
||||
order[pos] = order[pos - 1];
|
||||
@@ -382,8 +421,10 @@ void WarmNodeStore::load()
|
||||
}
|
||||
|
||||
uint32_t replayed = 0;
|
||||
uint32_t migrated = 0;
|
||||
for (uint8_t k = 0; k < nValid; k++) {
|
||||
const uint8_t p = order[k];
|
||||
const bool legacy = legacyOf[p];
|
||||
uint16_t slot = 0;
|
||||
for (; slot < kRecordsPerPage; slot++) {
|
||||
WarmNodeEntry rec;
|
||||
@@ -400,7 +441,14 @@ void WarmNodeStore::load()
|
||||
memset(e, 0, sizeof(*e));
|
||||
}
|
||||
} else {
|
||||
const WarmNodeEntry *e = place(rec.num, rec.last_heard, rec.public_key);
|
||||
// v1 (legacy) record: keep identity + key, but discard the old timestamp —
|
||||
// its low bits would otherwise be misread as role/protected metadata.
|
||||
uint32_t lh = rec.last_heard;
|
||||
if (legacy) {
|
||||
lh = 0;
|
||||
migrated++;
|
||||
}
|
||||
const WarmNodeEntry *e = place(rec.num, lh, rec.public_key);
|
||||
if (e)
|
||||
pageOf[e - entries] = p;
|
||||
}
|
||||
@@ -409,10 +457,17 @@ void WarmNodeStore::load()
|
||||
activePage = p;
|
||||
writeSlot = slot;
|
||||
nextSeq = seqs[k] + 1;
|
||||
// If the head is a v1 page, force the next append to rotate into a fresh v2 page,
|
||||
// so new (v2) records never land in a page whose header says v1 (which would make
|
||||
// a later load discard their last_heard — including the role/protected we just set).
|
||||
if (legacy)
|
||||
writeSlot = kRecordsPerPage;
|
||||
}
|
||||
}
|
||||
if (nCorrupt)
|
||||
LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt);
|
||||
if (migrated)
|
||||
LOG_INFO("WarmStore: migrated %u v1 record(s) (kept key, discarded last_heard)", (unsigned)migrated);
|
||||
LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(),
|
||||
activePage, writeSlot);
|
||||
}
|
||||
@@ -479,7 +534,10 @@ void WarmNodeStore::load()
|
||||
LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName);
|
||||
return;
|
||||
}
|
||||
if (h.magic != WARM_STORE_MAGIC || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
|
||||
// v1 (WRM1) is still accepted: same record size, but its last_heard was a plain
|
||||
// timestamp. We keep identity + key and discard last_heard on load (see below).
|
||||
const bool legacy = (h.magic == WARM_STORE_MAGIC_V1);
|
||||
if ((h.magic != WARM_STORE_MAGIC && !legacy) || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
|
||||
f.close();
|
||||
LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic,
|
||||
h.entrySize, h.count);
|
||||
@@ -493,14 +551,25 @@ void WarmNodeStore::load()
|
||||
LOG_WARN("WarmStore: %s entries read failed, starting empty", warmFileName);
|
||||
return;
|
||||
}
|
||||
// CRC covers the bytes as written (v1 still has the old last_heard), so check before migrating.
|
||||
if (crc32Buffer(entries, len) != h.crc) {
|
||||
LOG_WARN("WarmStore: %s CRC mismatch, starting empty", warmFileName);
|
||||
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
|
||||
return;
|
||||
}
|
||||
if (legacy) {
|
||||
// Migrate v1 → v2: discard the old last_heard (its low bits would be misread as
|
||||
// role/protected); keep num + public_key. Mark dirty so save() rewrites as v2.
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
|
||||
if (entries[i].num)
|
||||
entries[i].last_heard = 0;
|
||||
dirty = true;
|
||||
}
|
||||
} else {
|
||||
f.close();
|
||||
}
|
||||
LOG_INFO("WarmStore: loaded %u warm nodes from %s", h.count, warmFileName);
|
||||
LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName,
|
||||
legacy ? " (v1 migrated: discarded last_heard)" : "");
|
||||
}
|
||||
|
||||
bool WarmNodeStore::save()
|
||||
|
||||
@@ -34,11 +34,50 @@
|
||||
*/
|
||||
struct WarmNodeEntry {
|
||||
NodeNum num; // 0 = empty slot
|
||||
uint32_t last_heard; // recency for LRU ordering
|
||||
uint32_t last_heard; // recency for LRU ordering — see the metadata steal below
|
||||
uint8_t public_key[32]; // all-zero = no key (a real key is never all-zero)
|
||||
};
|
||||
static_assert(sizeof(WarmNodeEntry) == 40, "WarmNodeEntry must stay 40 B — persistence format depends on it");
|
||||
|
||||
// Metadata packed into the low bits of last_heard.
|
||||
//
|
||||
// The warm tier only uses last_heard to LRU-rank evicted (long-tail) nodes, so ~minute
|
||||
// recency resolution is plenty. We reclaim the low WARM_META_BITS of that field to carry
|
||||
// the evicted node's device role + a protected category, at zero cost to record size
|
||||
// (entry stays 40 B; no RAM/flash growth). The high bits remain a real unix-seconds
|
||||
// timestamp quantised to (1 << WARM_META_BITS) seconds.
|
||||
//
|
||||
// Safe because: a real timestamp can never be all-ones (the tombstone sentinel) before
|
||||
// 2106, and tombstones/erased flash are detected via num before last_heard is read. Only
|
||||
// the LOW bits are stolen — the high (era) bits are untouched, so the time range is intact.
|
||||
static constexpr uint32_t WARM_META_BITS = 6; // role(4) + protected(2)
|
||||
static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x3F → 64 s quantum
|
||||
static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFFC0
|
||||
static constexpr uint32_t WARM_ROLE_MASK = 0x0Fu; // bits [3:0] device role (0..12)
|
||||
static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [5:4] protected category
|
||||
static constexpr uint32_t WARM_PROT_MASK = 0x03u;
|
||||
|
||||
// Protected category cached alongside role so consumers needn't re-derive the mapping.
|
||||
enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 };
|
||||
|
||||
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot)
|
||||
{
|
||||
return (lastHeard & WARM_TIME_MASK) | (static_cast<uint32_t>(role) & WARM_ROLE_MASK) |
|
||||
((static_cast<uint32_t>(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT);
|
||||
}
|
||||
inline uint32_t warmTimeOf(const WarmNodeEntry &e)
|
||||
{
|
||||
return e.last_heard & WARM_TIME_MASK;
|
||||
}
|
||||
inline uint8_t warmRoleOf(const WarmNodeEntry &e)
|
||||
{
|
||||
return static_cast<uint8_t>(e.last_heard & WARM_ROLE_MASK);
|
||||
}
|
||||
inline uint8_t warmProtOf(const WarmNodeEntry &e)
|
||||
{
|
||||
return static_cast<uint8_t>((e.last_heard >> WARM_PROT_SHIFT) & WARM_PROT_MASK);
|
||||
}
|
||||
|
||||
// Gated on NRF52840_XXAA: the ring sits at 0xEA000
|
||||
// valid only on the 1 MB-flash nRF52840.
|
||||
#if defined(NRF52840_XXAA)
|
||||
@@ -58,8 +97,15 @@ class WarmNodeStore
|
||||
|
||||
/// Remember an evicted hot node. Keyless candidates never displace keyed
|
||||
/// entries; otherwise the oldest (keyless-first) entry is replaced.
|
||||
/// @param role the node's device role (meshtastic_Config_DeviceConfig_Role, 0..12)
|
||||
/// @param protectedCat WarmProtected category cached for the hop-trim path
|
||||
/// @return true if the node was stored or updated
|
||||
bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */);
|
||||
bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */, uint8_t role = 0,
|
||||
uint8_t protectedCat = 0);
|
||||
|
||||
/// Look up the cached device role + protected category for a warm node.
|
||||
/// @return false if the node is not in the warm tier.
|
||||
bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const;
|
||||
|
||||
/// Find and remove an entry (used when the node is re-admitted to the hot store).
|
||||
bool take(NodeNum num, WarmNodeEntry &out);
|
||||
@@ -121,7 +167,7 @@ class WarmNodeStore
|
||||
void ringAppend(const WarmNodeEntry &rec, int storeSlot /* -1 for tombstones */);
|
||||
void ringRotate(); // reclaim oldest page, compacting stranded live entries
|
||||
void ringOpenPage(uint8_t page); // erase + write header (seq = nextSeq++)
|
||||
bool ringReadHeader(uint8_t page, WarmPageHeader &h) const;
|
||||
bool ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy = nullptr) const;
|
||||
#endif
|
||||
|
||||
bool save();
|
||||
|
||||
@@ -22,12 +22,38 @@ template <typename T> void ServerAPI<T>::close()
|
||||
StreamAPI::close();
|
||||
}
|
||||
|
||||
/// Check the current underlying physical link to see if the client is currently connected
|
||||
/// Check the current underlying physical link to see if the client is currently
|
||||
/// connected
|
||||
template <typename T> bool ServerAPI<T>::checkIsConnected()
|
||||
{
|
||||
return client.connected();
|
||||
}
|
||||
|
||||
template <typename T> bool ServerAPI<T>::canWriteFrame(size_t)
|
||||
{
|
||||
// Only a dropped link is a reason to refuse a write up front. A full transmit
|
||||
// buffer (availableForWrite() == 0) is normal backpressure, not a dead socket,
|
||||
// so we must not close the connection on it. A genuinely failed write is
|
||||
// detected after the fact in onFrameWriteFailed().
|
||||
if (!client.connected()) {
|
||||
canWrite = false;
|
||||
enabled = false;
|
||||
LOG_WARN("TCP client disconnected before write, closing API service");
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
template <typename T> void ServerAPI<T>::onFrameWriteFailed(size_t frameLen, size_t writtenLen)
|
||||
{
|
||||
canWrite = false;
|
||||
enabled = false;
|
||||
LOG_WARN("TCP client write short (%lu/%lu bytes), closing API service", (unsigned long)writtenLen, (unsigned long)frameLen);
|
||||
close();
|
||||
}
|
||||
|
||||
template <class T> int32_t ServerAPI<T>::runOnce()
|
||||
{
|
||||
if (client.connected()) {
|
||||
@@ -95,4 +121,4 @@ template <class T, class U> int32_t APIServerPort<T, U>::runOnce()
|
||||
waitTime = 100;
|
||||
#endif
|
||||
return 100; // only check occasionally for incoming connections
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ template <class T> class ServerAPI : public StreamAPI, private concurrency::OSTh
|
||||
/// We override this method to prevent publishing EVENT_SERIAL_CONNECTED/DISCONNECTED for wifi links (we want the board to
|
||||
/// stay in the POWERED state to prevent disabling wifi)
|
||||
virtual void onConnectionChanged(bool connected) override {}
|
||||
virtual bool canWriteFrame(size_t frameLen) override;
|
||||
virtual void onFrameWriteFailed(size_t frameLen, size_t writtenLen) override;
|
||||
|
||||
virtual int32_t runOnce() override; // Check for dropped client connections
|
||||
};
|
||||
|
||||
@@ -452,7 +452,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg;
|
||||
/* Maximum encoded size of messages (where known) */
|
||||
/* meshtastic_NodeDatabase_size depends on runtime parameters */
|
||||
#define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size
|
||||
#define meshtastic_BackupPreferences_size 2432
|
||||
#define meshtastic_BackupPreferences_size 2410
|
||||
#define meshtastic_ChannelFile_size 718
|
||||
#define meshtastic_DeviceState_size 1944
|
||||
#define meshtastic_NodeEnvironmentEntry_size 170
|
||||
|
||||
@@ -206,7 +206,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg;
|
||||
/* Maximum encoded size of messages (where known) */
|
||||
#define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size
|
||||
#define meshtastic_LocalConfig_size 757
|
||||
#define meshtastic_LocalModuleConfig_size 820
|
||||
#define meshtastic_LocalModuleConfig_size 798
|
||||
|
||||
#ifdef __cplusplus
|
||||
} /* extern "C" */
|
||||
|
||||
@@ -96,6 +96,15 @@ PB_BIND(meshtastic_Neighbor, meshtastic_Neighbor, AUTO)
|
||||
PB_BIND(meshtastic_DeviceMetadata, meshtastic_DeviceMetadata, AUTO)
|
||||
|
||||
|
||||
PB_BIND(meshtastic_LoRaPresetGroup, meshtastic_LoRaPresetGroup, AUTO)
|
||||
|
||||
|
||||
PB_BIND(meshtastic_LoRaRegionPresets, meshtastic_LoRaRegionPresets, AUTO)
|
||||
|
||||
|
||||
PB_BIND(meshtastic_LoRaRegionPresetMap, meshtastic_LoRaRegionPresetMap, 2)
|
||||
|
||||
|
||||
PB_BIND(meshtastic_Heartbeat, meshtastic_Heartbeat, AUTO)
|
||||
|
||||
|
||||
|
||||
@@ -1355,6 +1355,53 @@ typedef struct _meshtastic_DeviceMetadata {
|
||||
uint32_t excluded_modules;
|
||||
} meshtastic_DeviceMetadata;
|
||||
|
||||
/* A distinct set of legal modem presets shared by one or more LoRa regions.
|
||||
Regions that have an identical preset list / default / licensing reference
|
||||
the same group (by index) via LoRaRegionPresetMap.region_groups. This keeps
|
||||
the whole map small enough to fit in a single FromRadio packet, since most
|
||||
regions share the one standard preset list. */
|
||||
typedef struct _meshtastic_LoRaPresetGroup {
|
||||
/* The modem presets that are legal for every region referencing this group. */
|
||||
pb_size_t presets_count;
|
||||
meshtastic_Config_LoRaConfig_ModemPreset presets[11];
|
||||
/* The firmware's default modem preset for regions in this group.
|
||||
Always one of `presets`. Clients should select this when switching to one
|
||||
of these regions, or when the current preset is not legal in the new region. */
|
||||
meshtastic_Config_LoRaConfig_ModemPreset default_preset;
|
||||
/* True if regions referencing this group are for licensed operators only
|
||||
(e.g. amateur / ham radio bands). Clients should warn or gate accordingly. */
|
||||
bool licensed_only;
|
||||
} meshtastic_LoRaPresetGroup;
|
||||
|
||||
/* Associates a single LoRa region with its preset group. */
|
||||
typedef struct _meshtastic_LoRaRegionPresets {
|
||||
/* The LoRa region this entry describes. */
|
||||
meshtastic_Config_LoRaConfig_RegionCode region;
|
||||
/* Index into LoRaRegionPresetMap.groups for the preset list that is legal
|
||||
in `region`. */
|
||||
uint8_t group_index;
|
||||
} meshtastic_LoRaRegionPresets;
|
||||
|
||||
/* Map describing which modem presets are valid for each LoRa region. Sent by
|
||||
the firmware during the want_config handshake (as FromRadio.region_presets)
|
||||
so that client UIs can prevent illegal region+preset selections.
|
||||
|
||||
Delivery is grouped to save space: `groups` holds each distinct preset list,
|
||||
and `region_groups` maps every known region to one of those groups by index.
|
||||
A region that does NOT appear in `region_groups` carries no constraint
|
||||
information and should not be restricted by the client (e.g. firmware that
|
||||
predates this message, or a region with no firmware table entry). Clients
|
||||
must also tolerate this whole message being absent. */
|
||||
typedef struct _meshtastic_LoRaRegionPresetMap {
|
||||
/* One entry per distinct (preset-list, default, licensing) combination.
|
||||
Referenced by index from `region_groups`. */
|
||||
pb_size_t groups_count;
|
||||
meshtastic_LoRaPresetGroup groups[8];
|
||||
/* One entry per known LoRa region, pointing at its preset group. */
|
||||
pb_size_t region_groups_count;
|
||||
meshtastic_LoRaRegionPresets region_groups[38];
|
||||
} meshtastic_LoRaRegionPresetMap;
|
||||
|
||||
/* Packets from the radio to the phone will appear on the fromRadio characteristic.
|
||||
It will support READ and NOTIFY. When a new packet arrives the device will BLE notify?
|
||||
It will sit in that descriptor until consumed by the phone,
|
||||
@@ -1411,6 +1458,12 @@ typedef struct _meshtastic_FromRadio {
|
||||
to report success or failure. Replaces the earlier scheme of
|
||||
encoding state as magic-string prefixes inside ClientNotification. */
|
||||
meshtastic_LockdownStatus lockdown_status;
|
||||
/* Map of which modem presets are legal in each LoRa region. Sent once
|
||||
during the want_config handshake (right after `metadata`, before the
|
||||
first `channel`) so client UIs can prevent the user from selecting an
|
||||
illegal region+preset combination. A region that does not appear in
|
||||
any group carries no constraint info and should not be restricted. */
|
||||
meshtastic_LoRaRegionPresetMap region_presets;
|
||||
};
|
||||
} meshtastic_FromRadio;
|
||||
|
||||
@@ -1604,6 +1657,12 @@ extern "C" {
|
||||
#define meshtastic_DeviceMetadata_role_ENUMTYPE meshtastic_Config_DeviceConfig_Role
|
||||
#define meshtastic_DeviceMetadata_hw_model_ENUMTYPE meshtastic_HardwareModel
|
||||
|
||||
#define meshtastic_LoRaPresetGroup_presets_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset
|
||||
#define meshtastic_LoRaPresetGroup_default_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset
|
||||
|
||||
#define meshtastic_LoRaRegionPresets_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1641,6 +1700,9 @@ extern "C" {
|
||||
#define meshtastic_NeighborInfo_init_default {0, 0, 0, 0, {meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default}}
|
||||
#define meshtastic_Neighbor_init_default {0, 0, 0, 0}
|
||||
#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0}
|
||||
#define meshtastic_LoRaPresetGroup_init_default {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0}
|
||||
#define meshtastic_LoRaRegionPresets_init_default {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0}
|
||||
#define meshtastic_LoRaRegionPresetMap_init_default {0, {meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default}, 0, {meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default}}
|
||||
#define meshtastic_Heartbeat_init_default {0}
|
||||
#define meshtastic_NodeRemoteHardwarePin_init_default {0, false, meshtastic_RemoteHardwarePin_init_default}
|
||||
#define meshtastic_ChunkedPayload_init_default {0, 0, 0, {0, {0}}}
|
||||
@@ -1676,6 +1738,9 @@ extern "C" {
|
||||
#define meshtastic_NeighborInfo_init_zero {0, 0, 0, 0, {meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero}}
|
||||
#define meshtastic_Neighbor_init_zero {0, 0, 0, 0}
|
||||
#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0}
|
||||
#define meshtastic_LoRaPresetGroup_init_zero {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0}
|
||||
#define meshtastic_LoRaRegionPresets_init_zero {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0}
|
||||
#define meshtastic_LoRaRegionPresetMap_init_zero {0, {meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero}, 0, {meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero}}
|
||||
#define meshtastic_Heartbeat_init_zero {0}
|
||||
#define meshtastic_NodeRemoteHardwarePin_init_zero {0, false, meshtastic_RemoteHardwarePin_init_zero}
|
||||
#define meshtastic_ChunkedPayload_init_zero {0, 0, 0, {0, {0}}}
|
||||
@@ -1866,6 +1931,13 @@ extern "C" {
|
||||
#define meshtastic_DeviceMetadata_hasRemoteHardware_tag 10
|
||||
#define meshtastic_DeviceMetadata_hasPKC_tag 11
|
||||
#define meshtastic_DeviceMetadata_excluded_modules_tag 12
|
||||
#define meshtastic_LoRaPresetGroup_presets_tag 1
|
||||
#define meshtastic_LoRaPresetGroup_default_preset_tag 2
|
||||
#define meshtastic_LoRaPresetGroup_licensed_only_tag 3
|
||||
#define meshtastic_LoRaRegionPresets_region_tag 1
|
||||
#define meshtastic_LoRaRegionPresets_group_index_tag 2
|
||||
#define meshtastic_LoRaRegionPresetMap_groups_tag 1
|
||||
#define meshtastic_LoRaRegionPresetMap_region_groups_tag 2
|
||||
#define meshtastic_FromRadio_id_tag 1
|
||||
#define meshtastic_FromRadio_packet_tag 2
|
||||
#define meshtastic_FromRadio_my_info_tag 3
|
||||
@@ -1884,6 +1956,7 @@ extern "C" {
|
||||
#define meshtastic_FromRadio_clientNotification_tag 16
|
||||
#define meshtastic_FromRadio_deviceuiConfig_tag 17
|
||||
#define meshtastic_FromRadio_lockdown_status_tag 18
|
||||
#define meshtastic_FromRadio_region_presets_tag 19
|
||||
#define meshtastic_Heartbeat_nonce_tag 1
|
||||
#define meshtastic_ToRadio_packet_tag 1
|
||||
#define meshtastic_ToRadio_want_config_id_tag 3
|
||||
@@ -2128,7 +2201,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,mqttClientProxyMessage,mqttC
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,fileInfo,fileInfo), 15) \
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,clientNotification,clientNotification), 16) \
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17) \
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18)
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18) \
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,region_presets,region_presets), 19)
|
||||
#define meshtastic_FromRadio_CALLBACK NULL
|
||||
#define meshtastic_FromRadio_DEFAULT NULL
|
||||
#define meshtastic_FromRadio_payload_variant_packet_MSGTYPE meshtastic_MeshPacket
|
||||
@@ -2146,6 +2220,7 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_sta
|
||||
#define meshtastic_FromRadio_payload_variant_clientNotification_MSGTYPE meshtastic_ClientNotification
|
||||
#define meshtastic_FromRadio_payload_variant_deviceuiConfig_MSGTYPE meshtastic_DeviceUIConfig
|
||||
#define meshtastic_FromRadio_payload_variant_lockdown_status_MSGTYPE meshtastic_LockdownStatus
|
||||
#define meshtastic_FromRadio_payload_variant_region_presets_MSGTYPE meshtastic_LoRaRegionPresetMap
|
||||
|
||||
#define meshtastic_LockdownStatus_FIELDLIST(X, a) \
|
||||
X(a, STATIC, SINGULAR, UENUM, state, 1) \
|
||||
@@ -2264,6 +2339,27 @@ X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12)
|
||||
#define meshtastic_DeviceMetadata_CALLBACK NULL
|
||||
#define meshtastic_DeviceMetadata_DEFAULT NULL
|
||||
|
||||
#define meshtastic_LoRaPresetGroup_FIELDLIST(X, a) \
|
||||
X(a, STATIC, REPEATED, UENUM, presets, 1) \
|
||||
X(a, STATIC, SINGULAR, UENUM, default_preset, 2) \
|
||||
X(a, STATIC, SINGULAR, BOOL, licensed_only, 3)
|
||||
#define meshtastic_LoRaPresetGroup_CALLBACK NULL
|
||||
#define meshtastic_LoRaPresetGroup_DEFAULT NULL
|
||||
|
||||
#define meshtastic_LoRaRegionPresets_FIELDLIST(X, a) \
|
||||
X(a, STATIC, SINGULAR, UENUM, region, 1) \
|
||||
X(a, STATIC, SINGULAR, UINT32, group_index, 2)
|
||||
#define meshtastic_LoRaRegionPresets_CALLBACK NULL
|
||||
#define meshtastic_LoRaRegionPresets_DEFAULT NULL
|
||||
|
||||
#define meshtastic_LoRaRegionPresetMap_FIELDLIST(X, a) \
|
||||
X(a, STATIC, REPEATED, MESSAGE, groups, 1) \
|
||||
X(a, STATIC, REPEATED, MESSAGE, region_groups, 2)
|
||||
#define meshtastic_LoRaRegionPresetMap_CALLBACK NULL
|
||||
#define meshtastic_LoRaRegionPresetMap_DEFAULT NULL
|
||||
#define meshtastic_LoRaRegionPresetMap_groups_MSGTYPE meshtastic_LoRaPresetGroup
|
||||
#define meshtastic_LoRaRegionPresetMap_region_groups_MSGTYPE meshtastic_LoRaRegionPresets
|
||||
|
||||
#define meshtastic_Heartbeat_FIELDLIST(X, a) \
|
||||
X(a, STATIC, SINGULAR, UINT32, nonce, 1)
|
||||
#define meshtastic_Heartbeat_CALLBACK NULL
|
||||
@@ -2328,6 +2424,9 @@ extern const pb_msgdesc_t meshtastic_Compressed_msg;
|
||||
extern const pb_msgdesc_t meshtastic_NeighborInfo_msg;
|
||||
extern const pb_msgdesc_t meshtastic_Neighbor_msg;
|
||||
extern const pb_msgdesc_t meshtastic_DeviceMetadata_msg;
|
||||
extern const pb_msgdesc_t meshtastic_LoRaPresetGroup_msg;
|
||||
extern const pb_msgdesc_t meshtastic_LoRaRegionPresets_msg;
|
||||
extern const pb_msgdesc_t meshtastic_LoRaRegionPresetMap_msg;
|
||||
extern const pb_msgdesc_t meshtastic_Heartbeat_msg;
|
||||
extern const pb_msgdesc_t meshtastic_NodeRemoteHardwarePin_msg;
|
||||
extern const pb_msgdesc_t meshtastic_ChunkedPayload_msg;
|
||||
@@ -2365,6 +2464,9 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg;
|
||||
#define meshtastic_NeighborInfo_fields &meshtastic_NeighborInfo_msg
|
||||
#define meshtastic_Neighbor_fields &meshtastic_Neighbor_msg
|
||||
#define meshtastic_DeviceMetadata_fields &meshtastic_DeviceMetadata_msg
|
||||
#define meshtastic_LoRaPresetGroup_fields &meshtastic_LoRaPresetGroup_msg
|
||||
#define meshtastic_LoRaRegionPresets_fields &meshtastic_LoRaRegionPresets_msg
|
||||
#define meshtastic_LoRaRegionPresetMap_fields &meshtastic_LoRaRegionPresetMap_msg
|
||||
#define meshtastic_Heartbeat_fields &meshtastic_Heartbeat_msg
|
||||
#define meshtastic_NodeRemoteHardwarePin_fields &meshtastic_NodeRemoteHardwarePin_msg
|
||||
#define meshtastic_ChunkedPayload_fields &meshtastic_ChunkedPayload_msg
|
||||
@@ -2388,6 +2490,9 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg;
|
||||
#define meshtastic_KeyVerificationNumberInform_size 58
|
||||
#define meshtastic_KeyVerificationNumberRequest_size 52
|
||||
#define meshtastic_KeyVerification_size 79
|
||||
#define meshtastic_LoRaPresetGroup_size 26
|
||||
#define meshtastic_LoRaRegionPresetMap_size 490
|
||||
#define meshtastic_LoRaRegionPresets_size 5
|
||||
#define meshtastic_LockdownStatus_size 53
|
||||
#define meshtastic_LogRecord_size 426
|
||||
#define meshtastic_LowEntropyKey_size 0
|
||||
|
||||
@@ -232,34 +232,23 @@ typedef struct _meshtastic_ModuleConfig_PaxcounterConfig {
|
||||
/* Config for the Traffic Management module.
|
||||
Provides packet inspection and traffic shaping to help reduce channel utilization */
|
||||
typedef struct _meshtastic_ModuleConfig_TrafficManagementConfig {
|
||||
/* Master enable for traffic management module */
|
||||
bool enabled;
|
||||
/* Enable position deduplication to drop redundant position broadcasts */
|
||||
bool position_dedup_enabled;
|
||||
/* Number of bits of precision for position deduplication (0-32) */
|
||||
uint32_t position_precision_bits;
|
||||
/* Minimum interval in seconds between position updates from the same node */
|
||||
/* Minimum interval in seconds between position updates from the same node.
|
||||
A non-zero value implicitly enables the suppression window; 0 disables it. */
|
||||
uint32_t position_min_interval_secs;
|
||||
/* Enable direct response to NodeInfo requests from local cache */
|
||||
bool nodeinfo_direct_response;
|
||||
/* Minimum hop distance from requestor before responding to NodeInfo requests */
|
||||
/* Maximum hop distance from the requestor at which direct NodeInfo responses
|
||||
are served from the local cache. A non-zero value implicitly enables direct
|
||||
response; 0 disables it. */
|
||||
uint32_t nodeinfo_direct_response_max_hops;
|
||||
/* Enable per-node rate limiting to throttle chatty nodes */
|
||||
bool rate_limit_enabled;
|
||||
/* Time window in seconds for rate limiting calculations */
|
||||
/* Time window in seconds for per-node rate limiting.
|
||||
A non-zero value implicitly enables rate limiting; 0 disables it. */
|
||||
uint32_t rate_limit_window_secs;
|
||||
/* Maximum packets allowed per node within the rate limit window */
|
||||
/* Maximum packets allowed per node within the rate limit window.
|
||||
A non-zero value implicitly enables rate limiting; 0 disables it. */
|
||||
uint32_t rate_limit_max_packets;
|
||||
/* Enable dropping of unknown/undecryptable packets per rate_limit_window_secs */
|
||||
bool drop_unknown_enabled;
|
||||
/* Number of unknown packets before dropping from a node */
|
||||
/* Maximum unknown/undecryptable packets per rate window before the source
|
||||
is dropped. A non-zero value implicitly enables unknown-packet filtering;
|
||||
0 disables it. */
|
||||
uint32_t unknown_packet_threshold;
|
||||
/* Set hop_limit to 0 for relayed telemetry broadcasts (own packets unaffected) */
|
||||
bool exhaust_hop_telemetry;
|
||||
/* Set hop_limit to 0 for relayed position broadcasts (own packets unaffected) */
|
||||
bool exhaust_hop_position;
|
||||
/* Preserve hop_limit for router-to-router traffic */
|
||||
bool router_preserve_hops;
|
||||
} meshtastic_ModuleConfig_TrafficManagementConfig;
|
||||
|
||||
/* Serial Config */
|
||||
@@ -588,7 +577,7 @@ extern "C" {
|
||||
#define meshtastic_ModuleConfig_DetectionSensorConfig_init_default {0, 0, 0, 0, "", 0, _meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_MIN, 0}
|
||||
#define meshtastic_ModuleConfig_AudioConfig_init_default {0, 0, _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MIN, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_init_default {0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_init_default {0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_SerialConfig_init_default {0, 0, 0, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MIN, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Mode_MIN, 0}
|
||||
#define meshtastic_ModuleConfig_ExternalNotificationConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_StoreForwardConfig_init_default {0, 0, 0, 0, 0, 0}
|
||||
@@ -607,7 +596,7 @@ extern "C" {
|
||||
#define meshtastic_ModuleConfig_DetectionSensorConfig_init_zero {0, 0, 0, 0, "", 0, _meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_MIN, 0}
|
||||
#define meshtastic_ModuleConfig_AudioConfig_init_zero {0, 0, _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MIN, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_init_zero {0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_init_zero {0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_SerialConfig_init_zero {0, 0, 0, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MIN, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Mode_MIN, 0}
|
||||
#define meshtastic_ModuleConfig_ExternalNotificationConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_StoreForwardConfig_init_zero {0, 0, 0, 0, 0, 0}
|
||||
@@ -656,20 +645,11 @@ extern "C" {
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_paxcounter_update_interval_tag 2
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_wifi_threshold_tag 3
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_ble_threshold_tag 4
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_enabled_tag 1
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_position_dedup_enabled_tag 2
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_position_precision_bits_tag 3
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_position_min_interval_secs_tag 4
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_nodeinfo_direct_response_tag 5
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_nodeinfo_direct_response_max_hops_tag 6
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_rate_limit_enabled_tag 7
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_rate_limit_window_secs_tag 8
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_rate_limit_max_packets_tag 9
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_drop_unknown_enabled_tag 10
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_unknown_packet_threshold_tag 11
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_exhaust_hop_telemetry_tag 12
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_exhaust_hop_position_tag 13
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_router_preserve_hops_tag 14
|
||||
#define meshtastic_ModuleConfig_SerialConfig_enabled_tag 1
|
||||
#define meshtastic_ModuleConfig_SerialConfig_echo_tag 2
|
||||
#define meshtastic_ModuleConfig_SerialConfig_rxd_tag 3
|
||||
@@ -867,20 +847,11 @@ X(a, STATIC, SINGULAR, INT32, ble_threshold, 4)
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_DEFAULT NULL
|
||||
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_FIELDLIST(X, a) \
|
||||
X(a, STATIC, SINGULAR, BOOL, enabled, 1) \
|
||||
X(a, STATIC, SINGULAR, BOOL, position_dedup_enabled, 2) \
|
||||
X(a, STATIC, SINGULAR, UINT32, position_precision_bits, 3) \
|
||||
X(a, STATIC, SINGULAR, UINT32, position_min_interval_secs, 4) \
|
||||
X(a, STATIC, SINGULAR, BOOL, nodeinfo_direct_response, 5) \
|
||||
X(a, STATIC, SINGULAR, UINT32, nodeinfo_direct_response_max_hops, 6) \
|
||||
X(a, STATIC, SINGULAR, BOOL, rate_limit_enabled, 7) \
|
||||
X(a, STATIC, SINGULAR, UINT32, rate_limit_window_secs, 8) \
|
||||
X(a, STATIC, SINGULAR, UINT32, rate_limit_max_packets, 9) \
|
||||
X(a, STATIC, SINGULAR, BOOL, drop_unknown_enabled, 10) \
|
||||
X(a, STATIC, SINGULAR, UINT32, unknown_packet_threshold, 11) \
|
||||
X(a, STATIC, SINGULAR, BOOL, exhaust_hop_telemetry, 12) \
|
||||
X(a, STATIC, SINGULAR, BOOL, exhaust_hop_position, 13) \
|
||||
X(a, STATIC, SINGULAR, BOOL, router_preserve_hops, 14)
|
||||
X(a, STATIC, SINGULAR, UINT32, unknown_packet_threshold, 11)
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_CALLBACK NULL
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_DEFAULT NULL
|
||||
|
||||
@@ -1053,7 +1024,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg;
|
||||
#define meshtastic_ModuleConfig_StoreForwardConfig_size 24
|
||||
#define meshtastic_ModuleConfig_TAKConfig_size 4
|
||||
#define meshtastic_ModuleConfig_TelemetryConfig_size 50
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_size 52
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_size 30
|
||||
#define meshtastic_ModuleConfig_size 227
|
||||
#define meshtastic_RemoteHardwarePin_size 21
|
||||
|
||||
|
||||
@@ -99,23 +99,20 @@ static inline int get_max_num_nodes()
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
#define MAX_NUM_NODES 250 // native host: no flash/RAM constraint; match the ESP32-S3 top tier
|
||||
#else
|
||||
#define MAX_NUM_NODES 120 // nRF52840 (28 KB LittleFS) and generic ESP32
|
||||
#define MAX_NUM_NODES 120 // nRF52840 and generic ESP32 (inc. ESP32C3 etc.)
|
||||
#endif // platform
|
||||
#endif // MAX_NUM_NODES
|
||||
|
||||
/// Per-map cap (position/telemetry/environment/status): only the freshest
|
||||
/// MAX_SATELLITE_NODES nodes keep satellite payloads, the rest just the
|
||||
/// NodeInfoLite header. RAM-bound: the four maps live in internal SRAM (not
|
||||
/// PSRAM). PSRAM-equipped ESP32-S3 (and native) keep the full 250; other ESP32
|
||||
/// (no-PSRAM, incl. S3) get 80 -- ~32 KB worst case, affordable now the warm
|
||||
/// tier is trimmed; nRF52840 and other tight parts stay at 40.
|
||||
/// NodeInfoLite header. RAM-bound (the maps are internal-SRAM, not PSRAM), so
|
||||
/// flash-rich hosts get a cap >= their hot store (satellites for every node, as
|
||||
/// before the cap existed) while constrained parts stay at 40.
|
||||
#ifndef MAX_SATELLITE_NODES
|
||||
#if defined(ARCH_PORTDUINO) || (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM))
|
||||
#define MAX_SATELLITE_NODES 250 // native / PSRAM-equipped ESP32-S3
|
||||
#elif defined(ARCH_ESP32)
|
||||
#define MAX_SATELLITE_NODES 80 // no-PSRAM ESP32 (incl. ESP32-S3)
|
||||
#if (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO)
|
||||
#define MAX_SATELLITE_NODES 250
|
||||
#else
|
||||
#define MAX_SATELLITE_NODES 40 // nRF52840 (28 KB LittleFS) and other constrained parts
|
||||
#define MAX_SATELLITE_NODES 40 // nRF52840, generic ESP32, and ESP32-S3 without PSRAM
|
||||
#endif // platform
|
||||
#endif // MAX_SATELLITE_NODES
|
||||
|
||||
@@ -130,41 +127,62 @@ static inline int get_max_num_nodes()
|
||||
// architecture.h via configuration.h) isn't defined this early in every include
|
||||
// chain. Backed by the raw-flash ring below LittleFS — see WarmNodeStore.h.
|
||||
#define WARM_NODE_COUNT 200
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)
|
||||
#define WARM_NODE_COUNT 2000 // ESP32-S3 with PSRAM (external); warm.dat ~80 KB
|
||||
#elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO)
|
||||
#define WARM_NODE_COUNT 2000 // PSRAM-equipped ESP32-S3 / native host; warm cache in PSRAM (~80 KB)
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32P4)
|
||||
#define WARM_NODE_COUNT 150 // 512 KB+ SRAM, no PSRAM (S3/C6/P4): ~6 KB heap (#10705)
|
||||
#elif defined(ARCH_ESP32)
|
||||
#define WARM_NODE_COUNT 100 // classic ESP32 (520 KB) / S2 (320 KB) / C3 (400 KB): tightest free heap w/ BLE+WiFi, ~4 KB (#10705)
|
||||
#elif defined(ARCH_RP2040)
|
||||
#define WARM_NODE_COUNT 150 // RP2040 (264 KB) / RP2350 (520 KB): bounded so warm.dat write fits the 8s watchdog (#10746)
|
||||
#else
|
||||
// generic ESP32 and no-PSRAM ESP32-S3: ~12.5 KB in internal heap (calloc fallback in
|
||||
// WarmNodeStore), leaving room for the BLE controller. PSRAM-equipped S3 takes the 2000 case above.
|
||||
#define WARM_NODE_COUNT 320
|
||||
#endif // platform
|
||||
#endif // WARM_NODE_COUNT
|
||||
// nRF52840 is handled explicitly above (200, raw-flash ring). Any other nRF52 (non-XXAA) and any
|
||||
// future non-ESP32/non-RP LittleFS part fall through to this 320 default — flag for review if such a
|
||||
// RAM-constrained nRF52 target is ever added.
|
||||
#define WARM_NODE_COUNT 320 // other LittleFS-backed parts (e.g. non-nRF52840 nRF52)
|
||||
#endif // platform
|
||||
#endif // WARM_NODE_COUNT
|
||||
|
||||
/// Max number of channels allowed
|
||||
#define MAX_NUM_CHANNELS (member_size(meshtastic_ChannelFile, channels) / member_size(meshtastic_ChannelFile, channels[0]))
|
||||
|
||||
// Traffic Management module configuration
|
||||
// Enable per-variant by defining HAS_TRAFFIC_MANAGEMENT=1 in variant.h
|
||||
#ifndef HAS_TRAFFIC_MANAGEMENT
|
||||
// Enabled by default; STM32WL is excluded due to RAM constraints (MAX_NUM_NODES=10).
|
||||
// Disable per-variant by defining HAS_TRAFFIC_MANAGEMENT=0 in variant.h
|
||||
#ifdef ARCH_STM32WL
|
||||
#define HAS_TRAFFIC_MANAGEMENT 0
|
||||
#endif
|
||||
#ifndef HAS_TRAFFIC_MANAGEMENT
|
||||
#define HAS_TRAFFIC_MANAGEMENT 1
|
||||
#endif
|
||||
|
||||
// HopScalingModule - variable hop module: dynamically adjusts broadcast hop_limit based on mesh density
|
||||
// Enable per-variant by defining HAS_VARIABLE_HOPS=1 in variant.h
|
||||
#ifdef ARCH_STM32WL
|
||||
#define HAS_VARIABLE_HOPS 0
|
||||
#endif
|
||||
|
||||
#ifndef HAS_VARIABLE_HOPS
|
||||
#define HAS_VARIABLE_HOPS 1
|
||||
#endif
|
||||
|
||||
// Cache size for traffic management (number of nodes to track)
|
||||
// Can be overridden per-variant based on available memory
|
||||
// Can be overridden per-variant by defining before this header is included.
|
||||
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000
|
||||
#else
|
||||
#if !HAS_TRAFFIC_MANAGEMENT
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 0
|
||||
#endif // HAS_TRAFFIC_MANAGEMENT
|
||||
#elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO)
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 // PSRAM-equipped ESP32-S3 / native host
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32P4)
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 500 // 512 KB+ SRAM, no PSRAM (S3/C6/P4): ~5 KB heap (#10705)
|
||||
#elif defined(ARCH_ESP32)
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 400 // classic ESP32 / S2 / C3: tightest free heap, ~4 KB (#10705)
|
||||
#else
|
||||
// nRF52 (incl. nRF52840) and RP2040/RP2350 fall through here — there is no nRF/RP branch above,
|
||||
// by design. These parts have no ESP32-style WiFi+BLE coexistence eating the heap, so the larger
|
||||
// 1000-entry (~10 KB) cache fits: nRF52840 is BLE-only on 256 KB RAM; RP2040/RP2350 have 264/520 KB.
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000 // nRF52 / RP2040 / RP2350 / other non-ESP32
|
||||
#endif
|
||||
#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
|
||||
/// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic
|
||||
|
||||
@@ -481,6 +481,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
|
||||
} else if (mp.from == 0) { // local request from the phone — tell the user why it didn't take
|
||||
sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "favorite", r->set_favorite_node, MAX_NUM_NODES - 2);
|
||||
} else {
|
||||
LOG_WARN("Remote set_favorite_node for 0x%x refused: protected-node cap", r->set_favorite_node);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -508,6 +510,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||
} else if (mp.from == 0) { // local request from the phone — tell the user why it didn't take
|
||||
sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", r->set_ignored_node, MAX_NUM_NODES - 2);
|
||||
} else {
|
||||
LOG_WARN("Remote set_ignored_node for 0x%x refused: protected-node cap", r->set_ignored_node);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -128,8 +128,7 @@ void setupModules()
|
||||
#endif
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT
|
||||
// Instantiate only when enabled to avoid extra memory use and background work.
|
||||
if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled) {
|
||||
if (moduleConfig.has_traffic_management) {
|
||||
trafficManagementModule = new TrafficManagementModule();
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -432,6 +432,42 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha
|
||||
|
||||
#define RUNONCE_INTERVAL 5000;
|
||||
|
||||
bool PositionModule::positionUnchangedSinceLastSend(const meshtastic_PositionLite &selfPos, bool useConfiguredPrecision)
|
||||
{
|
||||
if (lastGpsLatitude == 0 && lastGpsLongitude == 0)
|
||||
return false; // no prior broadcast to compare against
|
||||
|
||||
// Broadcast channel = the one sendOurPosition() would pick (first with non-zero on-wire
|
||||
// precision). Default nodes gauge movement at that on-wire (public-clamped) resolution;
|
||||
// trackers use their own configured (unclamped) precision so finer moves still count.
|
||||
uint32_t precisionBits = 0;
|
||||
for (uint8_t ch = 0; ch < 8; ch++) {
|
||||
if (getPositionPrecisionForChannel(ch) == 0)
|
||||
continue;
|
||||
precisionBits =
|
||||
useConfiguredPrecision ? getPositionPrecisionForChannel(channels.getByIndex(ch)) : getPositionPrecisionForChannel(ch);
|
||||
break;
|
||||
}
|
||||
|
||||
return positionWithinPrecisionCell(selfPos.latitude_i, selfPos.longitude_i, lastGpsLatitude, lastGpsLongitude, precisionBits);
|
||||
}
|
||||
|
||||
bool PositionModule::positionWithinPrecisionCell(int32_t aLat, int32_t aLon, int32_t bLat, int32_t bLon, uint32_t precision)
|
||||
{
|
||||
if (precision == 0 || precision >= 32)
|
||||
return false; // sharing disabled or full precision: no coarse cell to hold within
|
||||
|
||||
return truncateCoordinate(aLat, precision) == truncateCoordinate(bLat, precision) &&
|
||||
truncateCoordinate(aLon, precision) == truncateCoordinate(bLon, precision);
|
||||
}
|
||||
|
||||
uint32_t PositionModule::effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs)
|
||||
{
|
||||
if (stationary && stationaryFloorMs > configuredIntervalMs)
|
||||
return stationaryFloorMs;
|
||||
return configuredIntervalMs;
|
||||
}
|
||||
|
||||
int32_t PositionModule::runOnce()
|
||||
{
|
||||
if (sleepOnNextExecution == true) {
|
||||
@@ -458,7 +494,23 @@ int32_t PositionModule::runOnce()
|
||||
|
||||
bool waitingForFreshPosition = (lastGpsSend == 0) && !config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot();
|
||||
|
||||
if (lastGpsSend == 0 || msSinceLastSend >= intervalMs) {
|
||||
// Hold to the 12h floor when fixed_position (every role: pinning yourself forfeits the
|
||||
// exception) or when stationary. A real move still goes out early via smart-broadcast below.
|
||||
// Not-fixed exceptions: lost-and-found broadcasts freely; trackers judge movement at their
|
||||
// own (unclamped) precision rather than the on-wire one (useConfiguredPrecision).
|
||||
const auto role = config.device.role;
|
||||
bool stationary = config.position.fixed_position;
|
||||
if (!stationary && role != meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND && nodeDB->hasValidPosition(node)) {
|
||||
const bool isTracker =
|
||||
IS_ONE_OF(role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_TAK_TRACKER);
|
||||
meshtastic_PositionLite selfPos;
|
||||
if (nodeDB->copyNodePosition(node->num, selfPos))
|
||||
stationary = positionUnchangedSinceLastSend(selfPos, /*useConfiguredPrecision=*/isTracker);
|
||||
}
|
||||
uint32_t effectiveIntervalMs =
|
||||
effectiveBroadcastIntervalMs(intervalMs, stationary, (uint32_t)default_position_stationary_broadcast_secs * 1000UL);
|
||||
|
||||
if (lastGpsSend == 0 || msSinceLastSend >= effectiveIntervalMs) {
|
||||
if (waitingForFreshPosition) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("Skip initial position send; no fresh position since boot");
|
||||
|
||||
@@ -38,6 +38,14 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
|
||||
|
||||
void handleNewPosition();
|
||||
|
||||
// Pure broadcast-policy helpers, split out so they're unit-testable without the module.
|
||||
// True when two coordinates truncate to the same precision cell (so a re-broadcast would be a
|
||||
// duplicate). precision 0 or >=32 returns false: no coarse cell to hold within, never suppress.
|
||||
static bool positionWithinPrecisionCell(int32_t aLat, int32_t aLon, int32_t bLat, int32_t bLon, uint32_t precision);
|
||||
// Effective min interval: stationary positions are held to stationaryFloorMs (when that is the
|
||||
// longer of the two); otherwise the normal configured interval.
|
||||
static uint32_t effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs);
|
||||
|
||||
protected:
|
||||
/** Called to handle a particular incoming message
|
||||
|
||||
@@ -57,6 +65,12 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
|
||||
private:
|
||||
meshtastic_MeshPacket *allocPositionPacket();
|
||||
struct SmartPosition getDistanceTraveledSinceLastSend(meshtastic_PositionLite currentPosition);
|
||||
// True when our position is unchanged since the last broadcast: it truncates to the same
|
||||
// precision grid cell, so re-sending would be a duplicate that traffic management dedups
|
||||
// downstream anyway. Used to hold stationary broadcasts to a 12h floor. useConfiguredPrecision
|
||||
// gauges movement at our own configured (unclamped) precision rather than the on-wire
|
||||
// (public-clamped) precision — trackers report finer movement.
|
||||
bool positionUnchangedSinceLastSend(const meshtastic_PositionLite &selfPos, bool useConfiguredPrecision);
|
||||
meshtastic_MeshPacket *allocAtakPli();
|
||||
void trySetRtc(meshtastic_Position p, bool isLocal, bool forceUpdate = false);
|
||||
uint32_t precision;
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
#include "meshUtils.h"
|
||||
#include <vector>
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
|
||||
extern graphics::Screen *screen;
|
||||
|
||||
TraceRouteModule *traceRouteModule;
|
||||
@@ -323,6 +327,14 @@ void TraceRouteModule::maybeSetNextHop(NodeNum target, uint8_t nextHopByte)
|
||||
LOG_INFO("Updating next-hop for 0x%08x to 0x%02x based on traceroute", target, nextHopByte);
|
||||
node->next_hop = nextHopByte;
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Mirror into the TMM overflow cache. Traceroute is the highest-confidence
|
||||
// source (full known route), and this captures the target even when it isn't
|
||||
// in the hot NodeDB — same rationale as the ACK-confirmed path in NextHopRouter.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->setNextHop(target, nextHopByte);
|
||||
#endif
|
||||
}
|
||||
|
||||
void TraceRouteModule::processUpgradedPacket(const meshtastic_MeshPacket &mp)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,9 +20,12 @@
|
||||
* - Router hop preservation (maintain hop_limit for router-to-router traffic)
|
||||
*
|
||||
* Memory Optimization:
|
||||
* Uses a unified cache with cuckoo hashing for O(1) lookups and 56% memory reduction
|
||||
* compared to separate per-feature caches. Timestamps are stored as 8-bit relative
|
||||
* offsets from a rolling epoch to further reduce memory footprint.
|
||||
* Uses one flat unified cache (plain array, linear scan) shared by all
|
||||
* per-node features instead of separate per-feature caches. Timestamps are
|
||||
* stored as free-running modular tick counters (pos: 8-bit 360 s/tick;
|
||||
* rate+unknown: paired 4-bit nibbles in one byte) for a 10-byte entry.
|
||||
* LoRa packet rates are low enough that an O(n) scan of ~1000 entries is
|
||||
* negligible next to packet processing.
|
||||
*/
|
||||
class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
{
|
||||
@@ -38,6 +41,24 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
void resetStats();
|
||||
void recordRouterHopPreserved();
|
||||
|
||||
// Next-hop overflow cache (routing hint).
|
||||
// setNextHop: store a confirmed last-byte next hop for `dest`. Called by
|
||||
// NextHopRouter from its ACK-confirmed decision (see sniffReceived). The
|
||||
// byte must come from a bidirectionally-verified relay, not one-way inference.
|
||||
// getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown.
|
||||
// clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store
|
||||
// 0, so this is the way NextHopRouter decays a stale/failing overflow route).
|
||||
void setNextHop(NodeNum dest, uint8_t nextHopByte);
|
||||
uint8_t getNextHopHint(NodeNum dest);
|
||||
void clearNextHop(NodeNum dest);
|
||||
|
||||
// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed
|
||||
// hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after
|
||||
// nodeDB is populated (lazily on first maintenance pass).
|
||||
// @return true if it actually ran (prereqs met / nothing to do); false if
|
||||
// prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry.
|
||||
bool preloadNextHopsFromNodeDB();
|
||||
|
||||
/**
|
||||
* Check if this packet should have its hops exhausted.
|
||||
* Called from perhapsRebroadcast() to force hop_limit = 0 regardless of
|
||||
@@ -48,182 +69,126 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id;
|
||||
}
|
||||
|
||||
// Injectable monotonic clock (ms). All TMM time reads go through clockMs() so unit tests can
|
||||
// advance a virtual timebase instead of sleeping real seconds across the 6 min/360 s tick.
|
||||
// Mirrors HopScalingModule::s_testNowMs. Writable from tests as TrafficManagementModule::s_testNowMs;
|
||||
// ignored in production (clockMs() returns millis()).
|
||||
inline static uint32_t s_testNowMs = 0;
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
static uint32_t clockMs() { return s_testNowMs; }
|
||||
#else
|
||||
static uint32_t clockMs() { return millis(); }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||
bool wantPacket(const meshtastic_MeshPacket *p) override { return true; }
|
||||
void alterReceived(meshtastic_MeshPacket &mp) override;
|
||||
int32_t runOnce() override;
|
||||
// Protected so test shims can force epoch rollover behavior.
|
||||
void resetEpoch(uint32_t nowMs);
|
||||
// Protected so test shims can flush per-node traffic state.
|
||||
void flushCache();
|
||||
// Introspection for tests: the cached device role for a node, or -1 if the node has
|
||||
// no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0).
|
||||
int peekCachedRole(NodeNum node);
|
||||
|
||||
private:
|
||||
// =========================================================================
|
||||
// Unified Cache Entry (10 bytes) - Same for ALL platforms
|
||||
// =========================================================================
|
||||
//
|
||||
// A single compact structure used across ESP32, NRF52, and all other platforms.
|
||||
// Memory: 10 bytes × 2048 entries = 20KB
|
||||
//
|
||||
// Position Fingerprinting:
|
||||
// Instead of storing full coordinates (8 bytes) or a computed hash,
|
||||
// we store an 8-bit fingerprint derived deterministically from the
|
||||
// truncated lat/lon. This extracts the lower 4 significant bits from
|
||||
// each coordinate: fingerprint = (lat_low4 << 4) | lon_low4
|
||||
//
|
||||
// Benefits over hash:
|
||||
// - Adjacent grid cells have sequential fingerprints (no collision)
|
||||
// - Two positions only collide if 16+ grid cells apart in BOTH dimensions
|
||||
// - Deterministic: same input always produces same output
|
||||
//
|
||||
// Adaptive Timestamp Resolution:
|
||||
// All timestamps use 8-bit values with adaptive resolution calculated
|
||||
// from config at startup. Resolution = max(60, min(339, interval/2)).
|
||||
// - Min 60 seconds ensures reasonable precision
|
||||
// - Max 339 seconds allows ~24 hour range (255 * 339 = 86445 sec)
|
||||
// - interval/2 ensures at least 2 ticks per configured interval
|
||||
//
|
||||
// Layout:
|
||||
// [0-3] node - NodeNum (4 bytes)
|
||||
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (1 byte)
|
||||
// [5] rate_count - Packets in current window (1 byte)
|
||||
// [6] unknown_count - Unknown packets count (1 byte)
|
||||
// [7] pos_time - Position timestamp (1 byte, adaptive resolution)
|
||||
// [8] rate_time - Rate window start (1 byte, adaptive resolution)
|
||||
// [9] unknown_time - Unknown tracking start (1 byte, adaptive resolution)
|
||||
// [0-3] node - NodeNum (4 bytes, 0 = empty slot)
|
||||
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (0 = no position seen)
|
||||
// [5] rate_count - [7:6] role[3:2] | [5:0] packets in rate window (0 = no window active)
|
||||
// [6] unknown_count - [7:6] role[1:0] | [5:0] unknown packets in window (0 = no window active)
|
||||
// [7] pos_time - Position tick (uint8, free-running 360 s/tick)
|
||||
// [8] rate_unknown_time - [7:4] rate nibble (300 s/tick) | [3:0] unknown nibble (60 s/tick)
|
||||
// [9] next_hop - Last-byte relay to reach `node` (0 = none)
|
||||
//
|
||||
// The 4-bit device role (bits [7:6] of rate_count paired with [7:6] of unknown_count)
|
||||
// caches the sender's meshtastic_Config_DeviceConfig_Role as a third fallback after the
|
||||
// hot store and warm store, for nodes evicted from both. Read/written via
|
||||
// resolveSenderRole(). Max encodable value is 15.
|
||||
//
|
||||
// Presence sentinels (no epoch, no +1 offset needed):
|
||||
// pos active: pos_fingerprint != 0
|
||||
// rate active: getRateCount() != 0 (low 6 bits only)
|
||||
// unknown active: getUnknownCount() != 0 (low 6 bits only)
|
||||
//
|
||||
// next_hop: routing hint written only from ACK-confirmed NextHopRouter decisions.
|
||||
// No TTL — keeps the slot alive across maintenance sweeps.
|
||||
//
|
||||
#if _meshtastic_Config_DeviceConfig_Role_MAX > 15
|
||||
#warning "Device role enum max exceeds 15 — TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values"
|
||||
#endif
|
||||
struct __attribute__((packed)) UnifiedCacheEntry {
|
||||
NodeNum node; // 4 bytes - Node identifier (0 = empty slot)
|
||||
uint8_t pos_fingerprint; // 1 byte - Lower 4 bits of lat + lon
|
||||
uint8_t rate_count; // 1 byte - Packet count (saturates at 255)
|
||||
uint8_t unknown_count; // 1 byte - Unknown packet count (saturates at 255)
|
||||
uint8_t pos_time; // 1 byte - Position timestamp (adaptive resolution)
|
||||
uint8_t rate_time; // 1 byte - Rate window start (adaptive resolution)
|
||||
uint8_t unknown_time; // 1 byte - Unknown tracking start (adaptive resolution)
|
||||
NodeNum node;
|
||||
uint8_t pos_fingerprint;
|
||||
uint8_t rate_count; // [7:6] = role[3:2], [5:0] = count (max 63)
|
||||
uint8_t unknown_count; // [7:6] = role[1:0], [5:0] = count (max 63)
|
||||
uint8_t pos_time;
|
||||
uint8_t rate_unknown_time;
|
||||
uint8_t next_hop;
|
||||
|
||||
uint8_t getRateCount() const { return rate_count & 0x3F; }
|
||||
void setRateCount(uint8_t c) { rate_count = static_cast<uint8_t>((rate_count & 0xC0) | (c & 0x3F)); }
|
||||
uint8_t getUnknownCount() const { return unknown_count & 0x3F; }
|
||||
void setUnknownCount(uint8_t c) { unknown_count = static_cast<uint8_t>((unknown_count & 0xC0) | (c & 0x3F)); }
|
||||
uint8_t getCachedRole() const { return static_cast<uint8_t>(((rate_count >> 6) << 2) | (unknown_count >> 6)); }
|
||||
void setCachedRole(uint8_t role)
|
||||
{
|
||||
rate_count = static_cast<uint8_t>((rate_count & 0x3F) | ((role >> 2) << 6));
|
||||
unknown_count = static_cast<uint8_t>((unknown_count & 0x3F) | ((role & 0x03) << 6));
|
||||
}
|
||||
uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; }
|
||||
uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; }
|
||||
void setRateTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); }
|
||||
void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0xF0) | (t & 0x0F)); }
|
||||
};
|
||||
static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes");
|
||||
|
||||
// =========================================================================
|
||||
// Cuckoo Hash Table Implementation
|
||||
// Flat unified cache
|
||||
// =========================================================================
|
||||
//
|
||||
// Cuckoo hashing provides O(1) worst-case lookup time using two hash functions.
|
||||
// Each key can be in one of two possible locations (h1 or h2). On collision,
|
||||
// the existing entry is "kicked" to its alternate location.
|
||||
// Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at
|
||||
// most cacheSize() × 10 B — microseconds at LoRa packet rates, not worth a
|
||||
// hash table. Insertion on a full cache evicts the stalest entry,
|
||||
// preferring entries without a next_hop hint (those are the long-tail
|
||||
// routing state this cache exists to keep).
|
||||
//
|
||||
// Benefits over linear scan:
|
||||
// - O(1) lookup vs O(n) - critical at packet processing rates
|
||||
// - O(1) insertion (amortized) with simple eviction on cycles
|
||||
// - ~95% load factor achievable
|
||||
//
|
||||
// Cache size rounds to power-of-2 for fast modulo via bitmask.
|
||||
// TRAFFIC_MANAGEMENT_CACHE_SIZE=2000 → cacheSize()=2048
|
||||
//
|
||||
static constexpr uint16_t cacheSize();
|
||||
static constexpr uint16_t cacheMask();
|
||||
static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; }
|
||||
|
||||
// Hash functions for cuckoo hashing
|
||||
inline uint16_t cuckooHash1(NodeNum node) const { return node & cacheMask(); }
|
||||
inline uint16_t cuckooHash2(NodeNum node) const { return ((node * 2654435769u) >> (32 - cuckooHashBits())) & cacheMask(); }
|
||||
static constexpr uint8_t cuckooHashBits();
|
||||
|
||||
// NodeInfo cache configuration (PSRAM path):
|
||||
// - Payload lives in PSRAM
|
||||
// - DRAM keeps packed 12-bit tags with 4-way bucketed cuckoo hashing
|
||||
// (Fan et al., CoNEXT 2014). Tag value 0 is reserved as "empty".
|
||||
static constexpr uint16_t kNodeInfoIndexMetadataBudgetBytes = 3072; // 3KB DRAM tag store
|
||||
static constexpr uint8_t kNodeInfoTargetOccupancyPercent = 95;
|
||||
static constexpr uint8_t kNodeInfoBucketSize = 4;
|
||||
static constexpr uint8_t kNodeInfoTagBits = 12;
|
||||
static constexpr uint16_t kNodeInfoTagMask = static_cast<uint16_t>((1u << kNodeInfoTagBits) - 1u);
|
||||
static constexpr uint16_t kNodeInfoIndexSlotsRaw =
|
||||
static_cast<uint16_t>((kNodeInfoIndexMetadataBudgetBytes * 8u) / kNodeInfoTagBits);
|
||||
static constexpr uint16_t kNodeInfoIndexSlots =
|
||||
static_cast<uint16_t>(kNodeInfoIndexSlotsRaw - (kNodeInfoIndexSlotsRaw % kNodeInfoBucketSize));
|
||||
static constexpr uint16_t kNodeInfoTargetEntries =
|
||||
static_cast<uint16_t>((kNodeInfoIndexSlots * kNodeInfoTargetOccupancyPercent) / 100u);
|
||||
static_assert((kNodeInfoIndexSlots % kNodeInfoBucketSize) == 0, "NodeInfo slot count must align to bucket size");
|
||||
static_assert(kNodeInfoTargetEntries < (1u << kNodeInfoTagBits), "NodeInfo tag bits must encode payload index");
|
||||
|
||||
static constexpr uint16_t nodeInfoTargetEntries();
|
||||
static constexpr uint16_t nodeInfoIndexMetadataBudgetBytes();
|
||||
static constexpr uint8_t nodeInfoTargetOccupancyPercent();
|
||||
static constexpr uint8_t nodeInfoBucketSize();
|
||||
static constexpr uint8_t nodeInfoTagBits();
|
||||
static constexpr uint16_t nodeInfoTagMask();
|
||||
static constexpr uint16_t nodeInfoIndexSlots();
|
||||
static constexpr uint16_t nodeInfoBucketCount();
|
||||
static constexpr uint16_t nodeInfoBucketMask();
|
||||
static constexpr uint8_t nodeInfoBucketHashBits();
|
||||
inline uint16_t nodeInfoHash1(NodeNum node) const { return node & nodeInfoBucketMask(); }
|
||||
inline uint16_t nodeInfoHash2(NodeNum node) const
|
||||
{
|
||||
return ((node * 2246822519u) >> (32 - nodeInfoBucketHashBits())) & nodeInfoBucketMask();
|
||||
}
|
||||
// NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload
|
||||
// entries, linear scan keyed by `node`, LRU eviction by lastObservedMs.
|
||||
// NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine.
|
||||
static constexpr uint16_t kNodeInfoCacheEntries = 2000;
|
||||
static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; }
|
||||
|
||||
// =========================================================================
|
||||
// Adaptive Timestamp Resolution
|
||||
// Free-Running Tick Counters
|
||||
// =========================================================================
|
||||
//
|
||||
// All timestamps use 8-bit values with adaptive resolution calculated from
|
||||
// config at startup. This allows ~24 hour range while maintaining precision.
|
||||
// Timestamps are stored as free-running modular tick counters derived from
|
||||
// millis(). No epoch anchor needed: modular subtraction gives correct age
|
||||
// as long as the true age stays below the counter period.
|
||||
//
|
||||
// Resolution formula: max(60, min(339, interval/2))
|
||||
// - 60 sec minimum ensures reasonable precision
|
||||
// - 339 sec maximum allows 24 hour range (255 * 339 ≈ 86400 sec)
|
||||
// - interval/2 ensures at least 2 ticks per configured interval
|
||||
// pos_time : uint8 (256 ticks × 360 s = 25.6 h period; max window 12 h = 120 ticks)
|
||||
// rate_time : nibble (16 ticks × 300 s = 80 min period; max window 1 h = 12 ticks)
|
||||
// unknown_time: nibble (16 ticks × 60 s = 16 min period; max window 12 min = 12 ticks)
|
||||
//
|
||||
// Since config changes require reboot, resolution is calculated once.
|
||||
// Presence sentinels (no +1 offset needed; count fields serve as guards):
|
||||
// pos active: pos_fingerprint != 0 (0 is reserved sentinel; computePositionFingerprint() remaps computed-0 → 0xFF)
|
||||
// rate active: getRateCount() != 0 (low 6 bits; high 2 bits are cached role)
|
||||
// unknown active: getUnknownCount() != 0
|
||||
//
|
||||
uint32_t cacheEpochMs = 0;
|
||||
uint16_t posTimeResolution = 60; // Seconds per tick for position
|
||||
uint16_t rateTimeResolution = 60; // Seconds per tick for rate limiting
|
||||
uint16_t unknownTimeResolution = 60; // Seconds per tick for unknown tracking
|
||||
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick
|
||||
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick
|
||||
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick
|
||||
|
||||
// Calculate resolution from configured interval (called once at startup)
|
||||
static uint16_t calcTimeResolution(uint32_t intervalSecs)
|
||||
{
|
||||
// Resolution = interval/2 to ensure at least 2 ticks per interval
|
||||
// Clamped to [60, 339] for min precision and max 24h range
|
||||
uint32_t res = (intervalSecs > 0) ? (intervalSecs / 2) : 60;
|
||||
if (res < 60)
|
||||
res = 60;
|
||||
if (res > 339)
|
||||
res = 339;
|
||||
return static_cast<uint16_t>(res);
|
||||
}
|
||||
|
||||
// Convert to/from 8-bit relative timestamps with given resolution
|
||||
uint8_t toRelativeTime(uint32_t nowMs, uint16_t resolutionSecs) const
|
||||
{
|
||||
uint32_t ticks = (nowMs - cacheEpochMs) / (resolutionSecs * 1000UL);
|
||||
return (ticks > UINT8_MAX) ? UINT8_MAX : static_cast<uint8_t>(ticks);
|
||||
}
|
||||
uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const
|
||||
{
|
||||
return cacheEpochMs + (static_cast<uint32_t>(ticks) * resolutionSecs * 1000UL);
|
||||
}
|
||||
|
||||
// Convenience wrappers for each timestamp type
|
||||
uint8_t toRelativePosTime(uint32_t nowMs) const { return toRelativeTime(nowMs, posTimeResolution); }
|
||||
uint32_t fromRelativePosTime(uint8_t t) const { return fromRelativeTime(t, posTimeResolution); }
|
||||
|
||||
uint8_t toRelativeRateTime(uint32_t nowMs) const { return toRelativeTime(nowMs, rateTimeResolution); }
|
||||
uint32_t fromRelativeRateTime(uint8_t t) const { return fromRelativeTime(t, rateTimeResolution); }
|
||||
|
||||
uint8_t toRelativeUnknownTime(uint32_t nowMs) const { return toRelativeTime(nowMs, unknownTimeResolution); }
|
||||
uint32_t fromRelativeUnknownTime(uint8_t t) const { return fromRelativeTime(t, unknownTimeResolution); }
|
||||
|
||||
// Epoch reset when any timestamp approaches overflow
|
||||
// With max resolution of 339 sec, 200 ticks = ~19 hours (safe margin for 24h max)
|
||||
bool needsEpochReset(uint32_t nowMs) const
|
||||
{
|
||||
uint16_t maxRes = posTimeResolution;
|
||||
if (rateTimeResolution > maxRes)
|
||||
maxRes = rateTimeResolution;
|
||||
if (unknownTimeResolution > maxRes)
|
||||
maxRes = unknownTimeResolution;
|
||||
return (nowMs - cacheEpochMs) > (200UL * maxRes * 1000UL);
|
||||
}
|
||||
static uint8_t currentPosTick() { return static_cast<uint8_t>(clockMs() / kPosTimeTickMs); }
|
||||
static uint8_t currentRateTick() { return static_cast<uint8_t>((clockMs() / kRateTimeTickMs) & 0x0F); }
|
||||
static uint8_t currentUnknownTick() { return static_cast<uint8_t>((clockMs() / kUnknownTimeTickMs) & 0x0F); }
|
||||
// =========================================================================
|
||||
// Position Fingerprint
|
||||
// =========================================================================
|
||||
@@ -246,7 +211,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
// =========================================================================
|
||||
|
||||
mutable concurrency::Lock cacheLock; // Protects all cache access
|
||||
UnifiedCacheEntry *cache = nullptr; // Cuckoo hash table (unified for all platforms)
|
||||
UnifiedCacheEntry *cache = nullptr; // Flat unified cache (linear scan; all platforms)
|
||||
bool cacheFromPsram = false; // Tracks allocator for correct deallocation
|
||||
|
||||
struct NodeInfoPayloadEntry {
|
||||
@@ -278,11 +243,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
uint8_t decodedBitfield;
|
||||
};
|
||||
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan)
|
||||
bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation
|
||||
uint8_t *nodeInfoIndex = nullptr; // Packed 12-bit NodeInfo tags in DRAM
|
||||
uint16_t nodeInfoAllocHint = 0;
|
||||
uint16_t nodeInfoEvictCursor = 0;
|
||||
|
||||
meshtastic_TrafficManagementStats stats;
|
||||
|
||||
@@ -293,29 +255,37 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
NodeNum exhaustRequestedFrom = 0;
|
||||
PacketId exhaustRequestedId = 0;
|
||||
|
||||
// One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass.
|
||||
bool nextHopPreloaded = false;
|
||||
|
||||
// =========================================================================
|
||||
// Cache Operations
|
||||
// =========================================================================
|
||||
|
||||
// Find or create entry for node using cuckoo hashing
|
||||
// Returns nullptr if cache is full and eviction fails
|
||||
// Find or create entry for node (linear scan; stalest-first eviction when full)
|
||||
UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew);
|
||||
|
||||
// Find existing entry (no creation)
|
||||
UnifiedCacheEntry *findEntry(NodeNum node);
|
||||
|
||||
// NodeInfo cache operations (bucketed cuckoo index + PSRAM payloads)
|
||||
// Resolve a sender's advertised device role for the position hot path. The tier-3
|
||||
// cache (this entry's getCachedRole) is authoritative and is kept fresh by
|
||||
// updateCachedRoleFromNodeInfo() — updated when NodeDB learns a role, not re-derived
|
||||
// per packet. Only on first tracking (isNew) do we scan NodeDB (hot store → warm
|
||||
// store, via getNodeRole) to seed the cache, so a resident special-role node is
|
||||
// correct from its first position; after that the read is O(1) and survives the node
|
||||
// aging out of both NodeDB stores. Caller must hold cacheLock; entry may be null
|
||||
// (→ NodeDB scan only).
|
||||
meshtastic_Config_DeviceConfig_Role resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew);
|
||||
|
||||
// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
|
||||
// NodeDB's role). Reads role from the packet's User payload; updates only nodes already
|
||||
// tracked (no entry creation). Takes cacheLock.
|
||||
void updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp);
|
||||
|
||||
// NodeInfo cache operations (flat PSRAM payload array, linear scan)
|
||||
const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const;
|
||||
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot);
|
||||
uint16_t findNodeInfoPayloadIndex(NodeNum node) const;
|
||||
bool removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex);
|
||||
uint16_t allocateNodeInfoPayloadSlot();
|
||||
uint16_t evictNodeInfoPayloadSlot();
|
||||
bool tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag);
|
||||
uint16_t encodeNodeInfoTag(uint16_t payloadIndex) const;
|
||||
uint16_t decodeNodeInfoPayloadIndex(uint16_t tag) const;
|
||||
uint16_t getNodeInfoTag(uint16_t slot) const;
|
||||
void setNodeInfoTag(uint16_t slot, uint16_t tag);
|
||||
uint16_t countNodeInfoEntriesLocked() const;
|
||||
void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp);
|
||||
|
||||
@@ -333,101 +303,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
void incrementStat(uint32_t *field);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Compile-time Cache Size Calculations
|
||||
// =========================================================================
|
||||
//
|
||||
// Round TRAFFIC_MANAGEMENT_CACHE_SIZE up to next power of 2 for efficient
|
||||
// cuckoo hash indexing (allows bitmask instead of modulo).
|
||||
//
|
||||
// These use C++11-compatible constexpr (single return statement).
|
||||
//
|
||||
|
||||
namespace detail
|
||||
{
|
||||
// Helper: round up to next power of 2 using bit manipulation
|
||||
constexpr uint16_t nextPow2(uint16_t n)
|
||||
{
|
||||
return n == 0 ? 0 : (((n - 1) | ((n - 1) >> 1) | ((n - 1) >> 2) | ((n - 1) >> 4) | ((n - 1) >> 8)) + 1);
|
||||
}
|
||||
|
||||
// Helper: floor(log2(n)) for n >= 0, C++11-compatible constexpr.
|
||||
constexpr uint8_t log2Floor(uint16_t n)
|
||||
{
|
||||
return n <= 1 ? 0 : static_cast<uint8_t>(1 + log2Floor(static_cast<uint16_t>(n >> 1)));
|
||||
}
|
||||
|
||||
// Helper: ceil(log2(n)) for n >= 1, C++11-compatible constexpr.
|
||||
constexpr uint8_t log2Ceil(uint16_t n)
|
||||
{
|
||||
return n <= 1 ? 0 : static_cast<uint8_t>(1 + log2Floor(static_cast<uint16_t>(n - 1)));
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::cacheSize()
|
||||
{
|
||||
return detail::nextPow2(TRAFFIC_MANAGEMENT_CACHE_SIZE);
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::cacheMask()
|
||||
{
|
||||
return cacheSize() > 0 ? cacheSize() - 1 : 0;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::cuckooHashBits()
|
||||
{
|
||||
return detail::log2Floor(cacheSize());
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoTargetEntries()
|
||||
{
|
||||
return kNodeInfoTargetEntries;
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoIndexMetadataBudgetBytes()
|
||||
{
|
||||
return kNodeInfoIndexMetadataBudgetBytes;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::nodeInfoTargetOccupancyPercent()
|
||||
{
|
||||
return kNodeInfoTargetOccupancyPercent;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::nodeInfoBucketSize()
|
||||
{
|
||||
return kNodeInfoBucketSize;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::nodeInfoTagBits()
|
||||
{
|
||||
return kNodeInfoTagBits;
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoTagMask()
|
||||
{
|
||||
return kNodeInfoTagMask;
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoIndexSlots()
|
||||
{
|
||||
return kNodeInfoIndexSlots;
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoBucketCount()
|
||||
{
|
||||
return static_cast<uint16_t>(nodeInfoIndexSlots() / nodeInfoBucketSize());
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoBucketMask()
|
||||
{
|
||||
return nodeInfoBucketCount() > 0 ? nodeInfoBucketCount() - 1 : 0;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::nodeInfoBucketHashBits()
|
||||
{
|
||||
return detail::log2Floor(nodeInfoBucketCount());
|
||||
}
|
||||
static_assert(TRAFFIC_MANAGEMENT_CACHE_SIZE <= UINT16_MAX, "cacheSize() returns uint16_t");
|
||||
|
||||
extern TrafficManagementModule *trafficManagementModule;
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Free up some precious space in the iram0_0_seg memory segment
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include <esp_attr.h>
|
||||
#include <esp_flash.h>
|
||||
#include <spi_flash_chip_driver.h>
|
||||
|
||||
#define IRAM_SECTION section(".iram1.stub")
|
||||
|
||||
IRAM_ATTR esp_err_t stub_probe(esp_flash_t *chip, uint32_t flash_id)
|
||||
{
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
const spi_flash_chip_t stub_flash_chip __attribute__((IRAM_SECTION)) = {
|
||||
.name = "stub",
|
||||
.probe = stub_probe,
|
||||
};
|
||||
|
||||
extern const spi_flash_chip_t __wrap_esp_flash_chip_gd __attribute__((IRAM_SECTION, alias("stub_flash_chip")));
|
||||
extern const spi_flash_chip_t __wrap_esp_flash_chip_issi __attribute__((IRAM_SECTION, alias("stub_flash_chip")));
|
||||
extern const spi_flash_chip_t __wrap_esp_flash_chip_winbond __attribute__((IRAM_SECTION, alias("stub_flash_chip")));
|
||||
@@ -15,6 +15,7 @@
|
||||
#endif
|
||||
|
||||
#include "esp_mac.h"
|
||||
#include "freertosinc.h"
|
||||
#include "meshUtils.h"
|
||||
#include "sleep.h"
|
||||
#include "soc/rtc.h"
|
||||
@@ -32,13 +33,7 @@ void variant_shutdown() {}
|
||||
#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH
|
||||
void setBluetoothEnable(bool enable)
|
||||
{
|
||||
#if defined(USE_WS5500) || defined(USE_CH390D)
|
||||
if ((config.bluetooth.enabled == true) && (config.network.wifi_enabled == false))
|
||||
#elif HAS_WIFI
|
||||
if (!isWifiAvailable() && config.bluetooth.enabled == true)
|
||||
#else
|
||||
if (config.bluetooth.enabled == true)
|
||||
#endif
|
||||
{
|
||||
if (!nimbleBluetooth) {
|
||||
nimbleBluetooth = new NimbleBluetooth();
|
||||
@@ -47,9 +42,6 @@ void setBluetoothEnable(bool enable)
|
||||
powerMon->setState(meshtastic_PowerMon_State_BT_On);
|
||||
nimbleBluetooth->setup();
|
||||
}
|
||||
// For ESP32, no way to recover from bluetooth shutdown without reboot
|
||||
// BLE advertising automatically stops when MCU enters light-sleep(?)
|
||||
// For deep-sleep, shutdown hardware with nimbleBluetooth->deinit(). Requires reboot to reverse
|
||||
}
|
||||
}
|
||||
#else
|
||||
@@ -276,6 +268,8 @@ void cpuDeepSleep(uint32_t msecToWake)
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
#endif
|
||||
|
||||
esp_sleep_enable_timer_wakeup(msecToWake * 1000ULL); // call expects usecs
|
||||
esp_deep_sleep_start(); // TBD mA sleep current (battery)
|
||||
// User shutdown (DELAY_FOREVER / portMAX_DELAY): no RTC timer — align with nRF52 system_off semantics.
|
||||
if (msecToWake != portMAX_DELAY)
|
||||
esp_sleep_enable_timer_wakeup(msecToWake * 1000ULL); // call expects usecs
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
#define HW_VENDOR meshtastic_HardwareModel_T_ECHO_CARD
|
||||
#elif defined(TTGO_T_ECHO_PLUS)
|
||||
#define HW_VENDOR meshtastic_HardwareModel_T_ECHO_PLUS
|
||||
#elif defined(T_IMPULSE_PLUS)
|
||||
#define HW_VENDOR meshtastic_HardwareModel_T_IMPULSE_PLUS
|
||||
#elif defined(ELECROW_ThinkNode_M1)
|
||||
#define HW_VENDOR meshtastic_HardwareModel_THINKNODE_M1
|
||||
#elif defined(ELECROW_ThinkNode_M3)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#include "SGM41562.h"
|
||||
|
||||
#ifdef HAS_SGM41562
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
SGM41562 *sgm41562 = nullptr;
|
||||
|
||||
bool initSGM41562(TwoWire &wire)
|
||||
{
|
||||
if (sgm41562)
|
||||
return true;
|
||||
sgm41562 = new SGM41562();
|
||||
if (!sgm41562->begin(wire)) {
|
||||
delete sgm41562;
|
||||
sgm41562 = nullptr;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SGM41562::readReg(uint8_t reg, uint8_t &value)
|
||||
{
|
||||
wire_->beginTransmission(address_);
|
||||
wire_->write(reg);
|
||||
if (wire_->endTransmission(false) != 0)
|
||||
return false;
|
||||
if (wire_->requestFrom((int)address_, 1) != 1)
|
||||
return false;
|
||||
value = wire_->read();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SGM41562::writeReg(uint8_t reg, uint8_t value)
|
||||
{
|
||||
wire_->beginTransmission(address_);
|
||||
wire_->write(reg);
|
||||
wire_->write(value);
|
||||
return wire_->endTransmission() == 0;
|
||||
}
|
||||
|
||||
bool SGM41562::updateReg(uint8_t reg, uint8_t mask, uint8_t value)
|
||||
{
|
||||
uint8_t cur;
|
||||
if (!readReg(reg, cur))
|
||||
return false;
|
||||
cur = (cur & ~mask) | (value & mask);
|
||||
return writeReg(reg, cur);
|
||||
}
|
||||
|
||||
bool SGM41562::begin(TwoWire &wire, uint8_t address)
|
||||
{
|
||||
wire_ = &wire;
|
||||
address_ = address;
|
||||
|
||||
uint8_t id;
|
||||
if (!readReg(REG_DEVICE_ID, id)) {
|
||||
LOG_WARN("SGM41562: I2C read failed at 0x%02X", address_);
|
||||
return false;
|
||||
}
|
||||
if (id != DEVICE_ID_EXPECTED) {
|
||||
LOG_WARN("SGM41562: unexpected device ID 0x%02X (expected 0x%02X)", id, DEVICE_ID_EXPECTED);
|
||||
return false;
|
||||
}
|
||||
LOG_INFO("SGM41562: detected at 0x%02X (id 0x%02X)", address_, id);
|
||||
|
||||
// Mirror the vendor reference init sequence: PCB OTP off, NTC off,
|
||||
// watchdog off, charger enabled. These match LilyGo's stock firmware
|
||||
// for the T-Impulse Plus.
|
||||
delay(120);
|
||||
writeReg(REG_SYS_VOLTAGE_REG, 0xB7);
|
||||
writeReg(REG_MISC_OP_CONTROL, 0x40);
|
||||
writeReg(REG_CHARGE_TERM_TIMER, 0x1A);
|
||||
writeReg(REG_POWER_ON_CFG, 0xA4);
|
||||
|
||||
return refresh();
|
||||
}
|
||||
|
||||
bool SGM41562::refresh()
|
||||
{
|
||||
uint32_t now = millis();
|
||||
if (lastRefreshMs_ != 0 && (now - lastRefreshMs_) < 250)
|
||||
return true; // cached
|
||||
lastRefreshMs_ = now == 0 ? 1 : now;
|
||||
|
||||
uint8_t status, fault;
|
||||
if (!readReg(REG_SYSTEM_STATUS, status))
|
||||
return false;
|
||||
if (!readReg(REG_FAULT, fault))
|
||||
return false;
|
||||
|
||||
chargeStatus_ = static_cast<ChargeStatus>((status >> SYS_STATUS_CHRG_SHIFT) & SYS_STATUS_CHRG_MASK);
|
||||
inputPowerGood_ = (status & SYS_STATUS_PG) != 0;
|
||||
thermalReg_ = (status & SYS_STATUS_THERM_REG) != 0;
|
||||
faultMask_ = fault & 0x3F; // bits [7:6] are enter_ship_time config, not faults
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SGM41562::setChargeEnable(bool enable)
|
||||
{
|
||||
return updateReg(REG_POWER_ON_CFG, POWER_ON_CFG_CHG_DISABLE, enable ? 0x00 : POWER_ON_CFG_CHG_DISABLE);
|
||||
}
|
||||
|
||||
bool SGM41562::setShippingModeEnable(bool enable)
|
||||
{
|
||||
return updateReg(REG_MISC_OP_CONTROL, MISC_OP_SHIPPING_MODE, enable ? MISC_OP_SHIPPING_MODE : 0x00);
|
||||
}
|
||||
|
||||
#endif // HAS_SGM41562
|
||||
@@ -0,0 +1,102 @@
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#ifdef HAS_SGM41562
|
||||
|
||||
#include <Wire.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// SG Micro SGM41562 — single-cell Li-ion buck charger, I²C-controlled, no
|
||||
// fuel gauge. This driver exposes status (charging / input good / fault),
|
||||
// charge enable, and shipping-mode control. Battery voltage/percent still
|
||||
// come from the platform ADC path; the charger is plumbed in as a
|
||||
// side-channel for isCharging()/isVbusIn() in AnalogBatteryLevel.
|
||||
//
|
||||
// Reference: SGM41562 datasheet (Cmd map + bit fields cross-verified against
|
||||
// LilyGo's `Cpp_Bus_Driver::Sgm41562xx` driver, which is what their vendor
|
||||
// example for this board uses).
|
||||
|
||||
#ifndef SGM41562_ADDR
|
||||
#define SGM41562_ADDR 0x03 // Per datasheet — unusual but correct
|
||||
#endif
|
||||
|
||||
#ifndef SGM41562_WIRE
|
||||
#define SGM41562_WIRE Wire1 // Most boards put the PMU on the secondary bus
|
||||
#endif
|
||||
|
||||
class SGM41562
|
||||
{
|
||||
public:
|
||||
enum class ChargeStatus : uint8_t {
|
||||
NotCharging = 0b00,
|
||||
Precharge = 0b01,
|
||||
FastCharge = 0b10,
|
||||
ChargeDone = 0b11,
|
||||
};
|
||||
|
||||
bool begin(TwoWire &wire, uint8_t address = SGM41562_ADDR);
|
||||
|
||||
// Re-read the system status + fault registers. Throttled internally to
|
||||
// at most one I²C transaction per 250 ms — call as often as you like.
|
||||
bool refresh();
|
||||
|
||||
// Status — cached from the most recent refresh().
|
||||
ChargeStatus chargeStatus() const { return chargeStatus_; }
|
||||
bool isCharging() const { return chargeStatus_ == ChargeStatus::Precharge || chargeStatus_ == ChargeStatus::FastCharge; }
|
||||
bool isChargeDone() const { return chargeStatus_ == ChargeStatus::ChargeDone; }
|
||||
bool isInputPowerGood() const { return inputPowerGood_; }
|
||||
bool isThermalRegulation() const { return thermalReg_; }
|
||||
uint8_t faultMask() const { return faultMask_; }
|
||||
|
||||
// Control.
|
||||
bool setChargeEnable(bool enable);
|
||||
bool setShippingModeEnable(bool enable);
|
||||
|
||||
private:
|
||||
TwoWire *wire_ = nullptr;
|
||||
uint8_t address_ = SGM41562_ADDR;
|
||||
uint32_t lastRefreshMs_ = 0;
|
||||
|
||||
ChargeStatus chargeStatus_ = ChargeStatus::NotCharging;
|
||||
bool inputPowerGood_ = false;
|
||||
bool thermalReg_ = false;
|
||||
uint8_t faultMask_ = 0;
|
||||
|
||||
bool readReg(uint8_t reg, uint8_t &value);
|
||||
bool writeReg(uint8_t reg, uint8_t value);
|
||||
bool updateReg(uint8_t reg, uint8_t mask, uint8_t value);
|
||||
|
||||
// SGM41562 register addresses
|
||||
static constexpr uint8_t REG_INPUT_SOURCE = 0x00;
|
||||
static constexpr uint8_t REG_POWER_ON_CFG = 0x01;
|
||||
static constexpr uint8_t REG_CHARGE_CURRENT = 0x02;
|
||||
static constexpr uint8_t REG_DISCHARGE_TERM_CURRENT = 0x03;
|
||||
static constexpr uint8_t REG_CHARGE_VOLTAGE = 0x04;
|
||||
static constexpr uint8_t REG_CHARGE_TERM_TIMER = 0x05;
|
||||
static constexpr uint8_t REG_MISC_OP_CONTROL = 0x06;
|
||||
static constexpr uint8_t REG_SYS_VOLTAGE_REG = 0x07;
|
||||
static constexpr uint8_t REG_SYSTEM_STATUS = 0x08;
|
||||
static constexpr uint8_t REG_FAULT = 0x09;
|
||||
static constexpr uint8_t REG_I2C_ADDR_MISC = 0x0A;
|
||||
static constexpr uint8_t REG_DEVICE_ID = 0x0B;
|
||||
|
||||
// Bit positions in REG_POWER_ON_CFG.
|
||||
static constexpr uint8_t POWER_ON_CFG_CHG_DISABLE = 0x08; // bit 3: 1 = charging disabled
|
||||
// Bit positions in REG_MISC_OP_CONTROL.
|
||||
static constexpr uint8_t MISC_OP_SHIPPING_MODE = 0x20; // bit 5: 1 = enter shipping mode
|
||||
// Bit positions in REG_SYSTEM_STATUS.
|
||||
static constexpr uint8_t SYS_STATUS_CHRG_SHIFT = 3;
|
||||
static constexpr uint8_t SYS_STATUS_CHRG_MASK = 0x03;
|
||||
static constexpr uint8_t SYS_STATUS_PG = 0x02; // bit 1: input power good
|
||||
static constexpr uint8_t SYS_STATUS_THERM_REG = 0x01; // bit 0: thermal regulation
|
||||
|
||||
static constexpr uint8_t DEVICE_ID_EXPECTED = 0x04;
|
||||
};
|
||||
|
||||
extern SGM41562 *sgm41562;
|
||||
|
||||
// Lazy-instantiate the global on the supplied wire. Returns true on success.
|
||||
bool initSGM41562(TwoWire &wire);
|
||||
|
||||
#endif // HAS_SGM41562
|
||||
@@ -0,0 +1,465 @@
|
||||
// Unit tests for NextHop direct-message reliability mitigations (see docs/nexthop-routing-reliability.md):
|
||||
// M1 - NodeDB::resolveLastByte / resolveUniqueLastByte (ambiguity-aware last-byte resolution)
|
||||
// M2 - NextHopRouter::getNextHop strict-neighbor gate + Router::shouldDecrementHopLimit favorite check
|
||||
// M3 - NextHopRouter route-health freshness / failure decay
|
||||
//
|
||||
// Time handling: the route-health helpers take `now` as a parameter so the 30-minute TTL logic is
|
||||
// pure and testable without a clock mock. getNextHop()/sinceLastSeen() use the real native clock;
|
||||
// we back-date timestamps relative to it, and the unsigned-subtraction age math is rollover-safe.
|
||||
|
||||
#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc.
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#include "configuration.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "mesh/NextHopRouter.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#define MSG_BUF_LEN 200
|
||||
#define TEST_MSG_FMT(fmt, ...) \
|
||||
do { \
|
||||
char _buf[MSG_BUF_LEN]; \
|
||||
snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
|
||||
TEST_MESSAGE(_buf); \
|
||||
} while (0)
|
||||
|
||||
static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockNodeDB — inject nodes with controlled last byte, hop distance, age, role, favorite flag.
|
||||
// ---------------------------------------------------------------------------
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
void clearTestNodes()
|
||||
{
|
||||
testNodes.clear();
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
// ageSecs is how long ago we last heard the node; getTime() returns a large Unix timestamp on
|
||||
// native, so getTime()-ageSecs does not underflow for the ranges used here.
|
||||
void addNode(NodeNum num, uint8_t hopsAway, bool hasHops, uint32_t ageSecs,
|
||||
meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT, bool favorite = false,
|
||||
bool ignored = false, uint8_t nextHop = NO_NEXT_HOP_PREFERENCE)
|
||||
{
|
||||
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
||||
node.num = num;
|
||||
node.has_hops_away = hasHops;
|
||||
node.hops_away = hopsAway;
|
||||
node.role = role;
|
||||
node.next_hop = nextHop;
|
||||
node.last_heard = getTime() - ageSecs;
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, favorite);
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_IS_IGNORED_MASK, ignored);
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
||||
testNodes.push_back(node);
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = testNodes.size();
|
||||
}
|
||||
|
||||
std::vector<meshtastic_NodeInfoLite> testNodes;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test shim — expose getNextHop and the route-health helpers; reset health between tests.
|
||||
// Nulls cryptLock so the Router base can be (re)constructed (same pattern as test_mqtt MockRouter).
|
||||
// ---------------------------------------------------------------------------
|
||||
class NextHopRouterTestShim : public NextHopRouter
|
||||
{
|
||||
public:
|
||||
NextHopRouterTestShim() : NextHopRouter()
|
||||
{
|
||||
delete cryptLock;
|
||||
cryptLock = nullptr;
|
||||
}
|
||||
|
||||
using NextHopRouter::clearRouteHealth;
|
||||
using NextHopRouter::findRouteHealth;
|
||||
using NextHopRouter::getNextHop;
|
||||
using NextHopRouter::getOrAllocRouteHealth;
|
||||
using NextHopRouter::isRouteStale;
|
||||
using NextHopRouter::noteRouteFailure;
|
||||
using NextHopRouter::noteRouteLearned;
|
||||
using NextHopRouter::noteRouteSuccess;
|
||||
using Router::shouldDecrementHopLimit; // protected in Router
|
||||
|
||||
void resetRouteHealthForTest()
|
||||
{
|
||||
for (auto &h : routeHealth)
|
||||
h = RouteHealth{};
|
||||
}
|
||||
};
|
||||
|
||||
static MockNodeDB *mockNodeDB = nullptr;
|
||||
static NextHopRouterTestShim *shim = nullptr;
|
||||
|
||||
static constexpr uint32_t TTL = NextHopRouter::ROUTE_TTL_MSEC;
|
||||
static constexpr uint8_t THRESH = NextHopRouter::ROUTE_FAILURE_THRESHOLD;
|
||||
static constexpr uint8_t HEALTH_MAX = NextHopRouter::ROUTE_HEALTH_MAX;
|
||||
|
||||
// Helper: a decoded packet whose hops-away is `hopsAway`, relayed by last byte `relay`.
|
||||
static meshtastic_MeshPacket makeRelayedPacket(uint8_t relay, uint8_t hopsAway)
|
||||
{
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
p.relay_node = relay;
|
||||
p.hop_start = 4;
|
||||
p.hop_limit = 4 - hopsAway; // getHopsAway() == hop_start - hop_limit
|
||||
return p;
|
||||
}
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
myNodeInfo.my_node_num = kLocalNode;
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
mockNodeDB->clearTestNodes();
|
||||
shim->resetRouteHealthForTest();
|
||||
}
|
||||
|
||||
void tearDown(void) {}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 1 — resolveLastByte (M1)
|
||||
// ===========================================================================
|
||||
|
||||
void test_resolve_none_when_empty(void)
|
||||
{
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, r.status);
|
||||
}
|
||||
|
||||
void test_resolve_zero_byte_is_none(void)
|
||||
{
|
||||
// 0 is the NO_RELAY_NODE / NO_NEXT_HOP_PREFERENCE sentinel — never resolves.
|
||||
mockNodeDB->addNode(0x22222200, 0, true, 60); // last byte maps to 0xFF, not 0
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x00, true).status);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x00, false).status);
|
||||
}
|
||||
|
||||
void test_resolve_unique_neighbor(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct, fresh, last byte 0xAB
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
|
||||
}
|
||||
|
||||
void test_resolve_collision_is_ambiguous(void)
|
||||
{
|
||||
// Birthday collision: two fresh direct neighbors share last byte 0xAB.
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, 60);
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0, r.num); // never silently picks one
|
||||
}
|
||||
|
||||
void test_resolve_strict_excludes_stale(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // fresh
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, NEXTHOP_NEIGHBOR_FRESH_SECS + 100); // stale
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
|
||||
}
|
||||
|
||||
void test_resolve_strict_excludes_far(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct neighbor
|
||||
mockNodeDB->addNode(0x000006AB, 2, true, 60); // 2 hops away -> not a direct neighbor
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
|
||||
}
|
||||
|
||||
void test_resolve_lenient_includes_favorite_router(void)
|
||||
{
|
||||
// Unknown hop distance, but a favorite ROUTER: lenient gate accepts, strict gate does not.
|
||||
mockNodeDB->addNode(0x000007AB, 0, false, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, mockNodeDB->resolveLastByte(0xAB, false).status);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0xAB, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_lenient_collision_favorite_plus_neighbor(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000007AB, 0, false, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct neighbor, same byte
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, mockNodeDB->resolveLastByte(0xAB, false).status);
|
||||
}
|
||||
|
||||
void test_resolve_skips_self(void)
|
||||
{
|
||||
// A node equal to us (last byte 0x11) must never resolve to ourselves.
|
||||
mockNodeDB->addNode(kLocalNode, 0, true, 60);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x11, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_skips_ignored(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, /*ignored=*/true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0xAB, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_0x00_maps_to_0xFF_and_collides(void)
|
||||
{
|
||||
// getLastByteOfNodeNum() maps ...00 -> 0xFF, so a ...00 node and a ...FF node collide on 0xFF.
|
||||
TEST_ASSERT_EQUAL_HEX8(0xFF, mockNodeDB->getLastByteOfNodeNum(0x11111100));
|
||||
mockNodeDB->addNode(0x11111100, 0, true, 60); // last byte 0xFF (remapped)
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, mockNodeDB->resolveLastByte(0xFF, true).status);
|
||||
mockNodeDB->addNode(0x222222FF, 0, true, 60); // genuine ...FF
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, mockNodeDB->resolveLastByte(0xFF, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_unique_helper(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
NodeNum out = 0;
|
||||
TEST_ASSERT_TRUE(mockNodeDB->resolveUniqueLastByte(0xAB, true, &out));
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, out);
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, 60); // create collision
|
||||
TEST_ASSERT_FALSE(mockNodeDB->resolveUniqueLastByte(0xAB, true));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 2 — getNextHop (M2 send-path gate + M3 decay)
|
||||
// ===========================================================================
|
||||
|
||||
static constexpr NodeNum DEST = 0x000000B0; // DM destination (last byte 0xB0, distinct from 0xAB)
|
||||
|
||||
void test_getnexthop_unique_returns_byte(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // unique fresh neighbor with byte 0xAB
|
||||
auto nh = shim->getNextHop(DEST, /*relay=*/0x11);
|
||||
TEST_ASSERT_TRUE(nh.has_value());
|
||||
TEST_ASSERT_EQUAL_HEX8(0xAB, nh.value());
|
||||
}
|
||||
|
||||
void test_getnexthop_ambiguous_floods(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, 60); // collision -> ambiguous
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_vanished_neighbor_floods(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
// The only 0xAB node is stale -> strict gate yields None -> flood.
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, NEXTHOP_NEIGHBOR_FRESH_SECS + 100);
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_split_horizon_floods(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
// relay_node == stored next_hop -> don't send it back the way it came.
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, /*relay=*/0xAB).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_broadcast_is_nullopt(void)
|
||||
{
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(NODENUM_BROADCAST, 0x11).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_decays_stale_route(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // a valid unique neighbor exists...
|
||||
// ...but the health record is older than the TTL, so the route should decay to flooding.
|
||||
shim->noteRouteLearned(DEST, 0xAB, millis() - (TTL + 5000));
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
|
||||
// Decay also clears the persisted next_hop and the RAM health record.
|
||||
TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockNodeDB->getMeshNode(DEST)->next_hop);
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 3 — route-health helpers (M3)
|
||||
// ===========================================================================
|
||||
|
||||
void test_health_fresh_not_stale(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_NOT_NULL(h);
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 1000 + TTL - 1));
|
||||
}
|
||||
|
||||
void test_health_ttl_expiry(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_TRUE(shim->isRouteStale(*h, 1000 + TTL)); // boundary is inclusive (>=)
|
||||
}
|
||||
|
||||
void test_health_ttl_rollover_safe(void)
|
||||
{
|
||||
const uint32_t learnAt = 0xFFFFFFFFu - 1000; // learned just before the millis() rollover
|
||||
shim->noteRouteLearned(DEST, 0xAB, learnAt);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
// 1500 ms later (wrapped to now=500): unsigned subtraction yields ~1500 ms, not "stale".
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 500));
|
||||
}
|
||||
|
||||
void test_health_failure_threshold(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
for (uint8_t i = 1; i < THRESH; i++)
|
||||
shim->noteRouteFailure(DEST);
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*shim->findRouteHealth(DEST), 1000)); // THRESH-1 failures: ok
|
||||
shim->noteRouteFailure(DEST);
|
||||
TEST_ASSERT_TRUE(shim->isRouteStale(*shim->findRouteHealth(DEST), 1000)); // THRESH failures: stale
|
||||
}
|
||||
|
||||
void test_health_success_resets_failures(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteSuccess(DEST, 2000);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures);
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 2000));
|
||||
}
|
||||
|
||||
void test_health_relearn_same_hop_keeps_failures(void)
|
||||
{
|
||||
// Anti-flap: an asymmetric reverse path re-teaching the same dead hop must not reset failures.
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteLearned(DEST, 0xAB, 2000); // same hop
|
||||
TEST_ASSERT_EQUAL_UINT8(2, shim->findRouteHealth(DEST)->consecutiveFailures);
|
||||
}
|
||||
|
||||
void test_health_relearn_new_hop_resets_failures(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteLearned(DEST, 0xCD, 2000); // genuinely new hop -> clean slate
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures);
|
||||
TEST_ASSERT_EQUAL_HEX8(0xCD, h->lastNextHop);
|
||||
}
|
||||
|
||||
void test_health_failure_without_record_is_noop(void)
|
||||
{
|
||||
shim->noteRouteFailure(DEST); // no record yet
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
|
||||
}
|
||||
|
||||
void test_health_clear(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(DEST));
|
||||
shim->clearRouteHealth(DEST);
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
|
||||
}
|
||||
|
||||
void test_health_lru_eviction_bounds_table(void)
|
||||
{
|
||||
// Fill every slot with increasing learn times, then add one more: the oldest must be evicted.
|
||||
for (uint8_t i = 0; i < HEALTH_MAX; i++)
|
||||
shim->noteRouteLearned(0x1000 + i, 0xAB, 1000 + (uint32_t)i * 1000);
|
||||
NodeNum oldest = 0x1000;
|
||||
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(oldest));
|
||||
shim->noteRouteLearned(0x2000, 0xAB, 1000 + (uint32_t)HEALTH_MAX * 1000); // overflow
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(oldest)); // evicted
|
||||
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(0x2000)); // newest present
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 4 — shouldDecrementHopLimit favorite-router resolution (M2, site 4)
|
||||
// ===========================================================================
|
||||
|
||||
void test_hoplimit_preserve_unique_favorite_router(void)
|
||||
{
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
meshtastic_MeshPacket p = makeRelayedPacket(/*relay=*/0xAB, /*hopsAway=*/1);
|
||||
TEST_ASSERT_FALSE(shim->shouldDecrementHopLimit(&p)); // preserve
|
||||
}
|
||||
|
||||
void test_hoplimit_decrement_on_colliding_favorites(void)
|
||||
{
|
||||
// Headline regression: two favorite routers share the relay byte -> ambiguous -> decrement
|
||||
// (the old "first NodeDB match wins" scan would non-deterministically preserve).
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
mockNodeDB->addNode(0x000008AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
meshtastic_MeshPacket p = makeRelayedPacket(0xAB, 1);
|
||||
TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // decrement
|
||||
}
|
||||
|
||||
void test_hoplimit_decrement_when_resolved_not_favorite(void)
|
||||
{
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/false);
|
||||
meshtastic_MeshPacket p = makeRelayedPacket(0xAB, 1);
|
||||
TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // unique but not a favorite -> decrement
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
|
||||
mockNodeDB = new MockNodeDB();
|
||||
shim = new NextHopRouterTestShim();
|
||||
nodeDB = mockNodeDB;
|
||||
|
||||
printf("\n=== resolveLastByte (M1) ===\n");
|
||||
RUN_TEST(test_resolve_none_when_empty);
|
||||
RUN_TEST(test_resolve_zero_byte_is_none);
|
||||
RUN_TEST(test_resolve_unique_neighbor);
|
||||
RUN_TEST(test_resolve_collision_is_ambiguous);
|
||||
RUN_TEST(test_resolve_strict_excludes_stale);
|
||||
RUN_TEST(test_resolve_strict_excludes_far);
|
||||
RUN_TEST(test_resolve_lenient_includes_favorite_router);
|
||||
RUN_TEST(test_resolve_lenient_collision_favorite_plus_neighbor);
|
||||
RUN_TEST(test_resolve_skips_self);
|
||||
RUN_TEST(test_resolve_skips_ignored);
|
||||
RUN_TEST(test_resolve_0x00_maps_to_0xFF_and_collides);
|
||||
RUN_TEST(test_resolve_unique_helper);
|
||||
|
||||
printf("\n=== getNextHop (M2 + M3 decay) ===\n");
|
||||
RUN_TEST(test_getnexthop_unique_returns_byte);
|
||||
RUN_TEST(test_getnexthop_ambiguous_floods);
|
||||
RUN_TEST(test_getnexthop_vanished_neighbor_floods);
|
||||
RUN_TEST(test_getnexthop_split_horizon_floods);
|
||||
RUN_TEST(test_getnexthop_broadcast_is_nullopt);
|
||||
RUN_TEST(test_getnexthop_decays_stale_route);
|
||||
|
||||
printf("\n=== route-health helpers (M3) ===\n");
|
||||
RUN_TEST(test_health_fresh_not_stale);
|
||||
RUN_TEST(test_health_ttl_expiry);
|
||||
RUN_TEST(test_health_ttl_rollover_safe);
|
||||
RUN_TEST(test_health_failure_threshold);
|
||||
RUN_TEST(test_health_success_resets_failures);
|
||||
RUN_TEST(test_health_relearn_same_hop_keeps_failures);
|
||||
RUN_TEST(test_health_relearn_new_hop_resets_failures);
|
||||
RUN_TEST(test_health_failure_without_record_is_noop);
|
||||
RUN_TEST(test_health_clear);
|
||||
RUN_TEST(test_health_lru_eviction_bounds_table);
|
||||
|
||||
printf("\n=== shouldDecrementHopLimit (M2 site 4) ===\n");
|
||||
RUN_TEST(test_hoplimit_preserve_unique_favorite_router);
|
||||
RUN_TEST(test_hoplimit_decrement_on_colliding_favorites);
|
||||
RUN_TEST(test_hoplimit_decrement_when_resolved_not_favorite);
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
@@ -26,17 +26,22 @@ class NodeDBTestShim : public NodeDB
|
||||
void runDemote() { demoteOldestHotNodesToWarm(); }
|
||||
void runCleanup() { cleanupMeshDB(); }
|
||||
|
||||
// Read back the role + protected category the warm tier cached for a node.
|
||||
bool warmMeta(NodeNum n, uint8_t &role, uint8_t &prot) { return warmStore.lookupMeta(n, role, prot); }
|
||||
|
||||
void clearHot()
|
||||
{
|
||||
meshNodes->clear();
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey)
|
||||
void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey,
|
||||
meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT)
|
||||
{
|
||||
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
|
||||
n.num = num;
|
||||
n.last_heard = lastHeard;
|
||||
n.role = role;
|
||||
if (favorite)
|
||||
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
||||
if (ignored)
|
||||
@@ -99,6 +104,34 @@ static void test_migration_demotesOldestKeepsKeepersAndSelf(void)
|
||||
TEST_ASSERT_TRUE(warmHasKey(2000 + 3)); // ...but its key kept in the warm tier
|
||||
}
|
||||
|
||||
// Eviction carries the device role + protected category into the warm tier. A TRACKER is
|
||||
// hop-protected but NOT eviction-protected, so it gets demoted with its key; the warm
|
||||
// record must report role=TRACKER / category=Role. A plain CLIENT carries role=CLIENT/None.
|
||||
static void test_migration_carriesRoleAndProtectedIntoWarm(void)
|
||||
{
|
||||
db->seedSelf();
|
||||
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted
|
||||
for (int i = 1; i <= extra; i++) {
|
||||
const auto role = (i == 3) ? meshtastic_Config_DeviceConfig_Role_TRACKER : meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true,
|
||||
/*withKey=*/true, role);
|
||||
}
|
||||
|
||||
db->runDemote();
|
||||
|
||||
uint8_t role = 0xFF, prot = 0xFF;
|
||||
// TRACKER (i=3): demoted out of hot, key kept, role + protected carried into warm.
|
||||
TEST_ASSERT_NULL(db->getMeshNode(2000 + 3));
|
||||
TEST_ASSERT_TRUE(warmHasKey(2000 + 3));
|
||||
TEST_ASSERT_TRUE(db->warmMeta(2000 + 3, role, prot));
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_TRACKER, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot);
|
||||
// CLIENT (i=4): also demoted, carries role=CLIENT / category=None.
|
||||
TEST_ASSERT_TRUE(db->warmMeta(2000 + 4, role, prot));
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_CLIENT, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
}
|
||||
|
||||
// Favourite handling: a favourite is never the eviction victim, even when it is
|
||||
// the oldest node in a full hot store.
|
||||
static void test_eviction_preservesFavorite(void)
|
||||
@@ -172,6 +205,7 @@ NDB_TEST_ENTRY void setup()
|
||||
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf);
|
||||
RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm);
|
||||
RUN_TEST(test_eviction_preservesFavorite);
|
||||
RUN_TEST(test_ignored_survivesEvictionAndCleanup);
|
||||
RUN_TEST(test_protectedCap_refusesBeyondLimit);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "TestUtil.h"
|
||||
#include "modules/PositionModule.h"
|
||||
#include <unity.h>
|
||||
|
||||
// These exercise PositionModule's pure broadcast-policy helpers (stationary detection and the
|
||||
// interval floor). They take plain values, so no device globals or fake clock are needed.
|
||||
|
||||
// Coordinates sharing the top `precision` bits land in the same grid cell.
|
||||
static void test_withinPrecisionCell_jitterStaysInCell()
|
||||
{
|
||||
// At precision 16 the top 16 bits define the cell; the low 16 bits are GPS jitter.
|
||||
TEST_ASSERT_TRUE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x1234ABCD, 0x2234EF01, 16));
|
||||
}
|
||||
|
||||
static void test_withinPrecisionCell_movingLatLeavesCell()
|
||||
{
|
||||
TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12350000, 0x22340000, 16));
|
||||
}
|
||||
|
||||
static void test_withinPrecisionCell_movingLonLeavesCell()
|
||||
{
|
||||
TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12340000, 0x22350000, 16));
|
||||
}
|
||||
|
||||
// precision 0 means position sharing is off — never treat as stationary/suppressible.
|
||||
static void test_withinPrecisionCell_zeroPrecisionNeverSuppresses()
|
||||
{
|
||||
TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12340000, 0x22340000, 0));
|
||||
}
|
||||
|
||||
// Full precision (>=32): any difference matters, and identical full-precision coords still aren't
|
||||
// "stationary" because there's no coarse cell to hold within.
|
||||
static void test_withinPrecisionCell_fullPrecisionNeverSuppresses()
|
||||
{
|
||||
TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12340000, 0x22340000, 32));
|
||||
}
|
||||
|
||||
static void test_effectiveInterval_stationaryRaisesToFloor()
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT32(43200000U, PositionModule::effectiveBroadcastIntervalMs(60000U, true, 43200000U));
|
||||
}
|
||||
|
||||
static void test_effectiveInterval_movingKeepsConfigured()
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT32(60000U, PositionModule::effectiveBroadcastIntervalMs(60000U, false, 43200000U));
|
||||
}
|
||||
|
||||
// A configured interval already longer than the floor is never shortened.
|
||||
static void test_effectiveInterval_longConfiguredWinsOverFloor()
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT32(50000000U, PositionModule::effectiveBroadcastIntervalMs(50000000U, true, 43200000U));
|
||||
}
|
||||
|
||||
static void test_effectiveInterval_zeroFloorIsNoOp()
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT32(60000U, PositionModule::effectiveBroadcastIntervalMs(60000U, true, 0U));
|
||||
}
|
||||
|
||||
void setUp(void) {}
|
||||
|
||||
void tearDown(void) {}
|
||||
|
||||
extern "C" {
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_withinPrecisionCell_jitterStaysInCell);
|
||||
RUN_TEST(test_withinPrecisionCell_movingLatLeavesCell);
|
||||
RUN_TEST(test_withinPrecisionCell_movingLonLeavesCell);
|
||||
RUN_TEST(test_withinPrecisionCell_zeroPrecisionNeverSuppresses);
|
||||
RUN_TEST(test_withinPrecisionCell_fullPrecisionNeverSuppresses);
|
||||
RUN_TEST(test_effectiveInterval_stationaryRaisesToFloor);
|
||||
RUN_TEST(test_effectiveInterval_movingKeepsConfigured);
|
||||
RUN_TEST(test_effectiveInterval_longConfiguredWinsOverFloor);
|
||||
RUN_TEST(test_effectiveInterval_zeroFloorIsNoOp);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
}
|
||||
@@ -200,6 +200,87 @@ static void test_applyModemConfig_customCodingRateLowerThanPreset()
|
||||
TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getRegionPresetMap() — region->valid-preset map sent to clients during want_config
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static size_t countKnownRegions()
|
||||
{
|
||||
size_t n = 0;
|
||||
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++)
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Every region in the firmware table (except the UNSET sentinel) must appear
|
||||
// exactly once in the map, and all counts must stay within the mesh.options bounds
|
||||
// (exceeding them would mean nanopb silently truncates the wire message).
|
||||
static void test_regionPresetMap_coversAllRegionsWithinBounds()
|
||||
{
|
||||
meshtastic_LoRaRegionPresetMap map;
|
||||
getRegionPresetMap(map);
|
||||
|
||||
const size_t known = countKnownRegions();
|
||||
TEST_ASSERT_EQUAL_UINT((unsigned)known, (unsigned)map.region_groups_count);
|
||||
|
||||
// Bounds derived from the generated nanopb arrays (mesh.options max_count), so
|
||||
// this stays correct if those bounds change.
|
||||
const size_t maxGroups = sizeof(map.groups) / sizeof(map.groups[0]);
|
||||
const size_t maxRegions = sizeof(map.region_groups) / sizeof(map.region_groups[0]);
|
||||
TEST_ASSERT_GREATER_THAN_UINT(0, map.groups_count);
|
||||
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxGroups, map.groups_count);
|
||||
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxRegions, map.region_groups_count);
|
||||
|
||||
// Each known region appears exactly once.
|
||||
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) {
|
||||
int hits = 0;
|
||||
for (pb_size_t i = 0; i < map.region_groups_count; i++)
|
||||
if (map.region_groups[i].region == r->code)
|
||||
hits++;
|
||||
TEST_ASSERT_EQUAL_INT(1, hits);
|
||||
}
|
||||
}
|
||||
|
||||
// The advertised presets must agree with the live region table: every preset is
|
||||
// legal in its region, the default is among them, and the licensed flag matches.
|
||||
static void test_regionPresetMap_matchesRegionTable()
|
||||
{
|
||||
meshtastic_LoRaRegionPresetMap map;
|
||||
getRegionPresetMap(map);
|
||||
|
||||
for (pb_size_t i = 0; i < map.region_groups_count; i++) {
|
||||
meshtastic_Config_LoRaConfig_RegionCode code = map.region_groups[i].region;
|
||||
uint8_t gi = map.region_groups[i].group_index;
|
||||
TEST_ASSERT_LESS_THAN_UINT(map.groups_count, gi);
|
||||
|
||||
const meshtastic_LoRaPresetGroup &grp = map.groups[gi];
|
||||
const RegionInfo *r = getRegion(code);
|
||||
|
||||
// Group's list is non-empty, within the generated array bound, and is the
|
||||
// region's full list.
|
||||
const size_t maxPresets = sizeof(grp.presets) / sizeof(grp.presets[0]);
|
||||
TEST_ASSERT_GREATER_THAN_UINT(0, grp.presets_count);
|
||||
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxPresets, grp.presets_count);
|
||||
TEST_ASSERT_EQUAL_UINT((unsigned)r->getNumPresets(), (unsigned)grp.presets_count);
|
||||
|
||||
// Every advertised preset is legal in this region.
|
||||
for (pb_size_t p = 0; p < grp.presets_count; p++)
|
||||
TEST_ASSERT_TRUE(r->supportsPreset(grp.presets[p]));
|
||||
|
||||
// Default preset matches the table, is legal, and is present in the list.
|
||||
TEST_ASSERT_EQUAL(r->getDefaultPreset(), grp.default_preset);
|
||||
TEST_ASSERT_TRUE(r->supportsPreset(grp.default_preset));
|
||||
bool defaultInList = false;
|
||||
for (pb_size_t p = 0; p < grp.presets_count; p++)
|
||||
if (grp.presets[p] == grp.default_preset)
|
||||
defaultInList = true;
|
||||
TEST_ASSERT_TRUE(defaultInList);
|
||||
|
||||
// Licensed flag matches the region's profile.
|
||||
TEST_ASSERT_EQUAL(r->profile->licensedOnly, grp.licensed_only);
|
||||
}
|
||||
}
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
mockMeshService = new MockMeshService();
|
||||
@@ -241,6 +322,8 @@ void setup()
|
||||
RUN_TEST(test_applyModemConfig_codingRateMatchesPreset);
|
||||
RUN_TEST(test_applyModemConfig_customCodingRateHigherThanPreset);
|
||||
RUN_TEST(test_applyModemConfig_customCodingRateLowerThanPreset);
|
||||
RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds);
|
||||
RUN_TEST(test_regionPresetMap_matchesRegionTable);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "TestUtil.h"
|
||||
#include "gps/RTC.h"
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
#include <unity.h>
|
||||
|
||||
// Regression coverage for issue #9828: on boards without a hardware RTC (e.g. RP2040),
|
||||
// gettimeofday() can return uptime seconds rather than wall-clock time. A later readFromRTC()
|
||||
// must not overwrite a higher-quality network/GPS time with that value, but it should still seed
|
||||
// the clock when nothing better exists yet.
|
||||
//
|
||||
// The native test build compiles the RV3028 hardware-RTC branch (variants/native/portduino
|
||||
// defines RV3028_RTC), so these tests use setReadFromRTCUseSystemTimeForTests() to force the
|
||||
// no-hardware-RTC fallback path and setRTCSystemTimeForTests() to inject a deterministic clock.
|
||||
|
||||
static const uint32_t kAllowedDriftSeconds = 2;
|
||||
static const time_t kUptimeSeconds = 21; // what gettimeofday() returns on RP2040 without a real clock
|
||||
|
||||
// A clearly-valid wall-clock epoch, safely inside any BUILD_EPOCH validity window.
|
||||
static time_t makeValidEpoch()
|
||||
{
|
||||
return time(NULL) + SEC_PER_DAY;
|
||||
}
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
resetRTCStateForTests();
|
||||
}
|
||||
|
||||
void tearDown(void)
|
||||
{
|
||||
resetRTCStateForTests();
|
||||
}
|
||||
|
||||
// A higher-quality network time must survive a later system-time read that only knows uptime.
|
||||
static void test_readFromRTC_preserves_better_network_time(void)
|
||||
{
|
||||
const time_t networkEpoch = makeValidEpoch();
|
||||
struct timeval networkTime;
|
||||
networkTime.tv_sec = networkEpoch;
|
||||
networkTime.tv_usec = 0;
|
||||
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, perhapsSetRTC(RTCQualityFromNet, &networkTime));
|
||||
|
||||
// Simulate a later readFromRTC() falling back to a system clock that only knows uptime.
|
||||
struct timeval uptime;
|
||||
uptime.tv_sec = kUptimeSeconds;
|
||||
uptime.tv_usec = 0;
|
||||
setRTCSystemTimeForTests(&uptime);
|
||||
setReadFromRTCUseSystemTimeForTests(true);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, readFromRTC());
|
||||
TEST_ASSERT_EQUAL_INT(RTCQualityFromNet, getRTCQuality());
|
||||
TEST_ASSERT_UINT32_WITHIN(kAllowedDriftSeconds, (uint32_t)networkEpoch, getValidTime(RTCQualityFromNet));
|
||||
}
|
||||
|
||||
// Before any higher-quality source exists, the fallback should still seed the clock.
|
||||
static void test_readFromRTC_initializes_time_when_no_better_source(void)
|
||||
{
|
||||
const time_t systemEpoch = makeValidEpoch();
|
||||
struct timeval systemTime;
|
||||
systemTime.tv_sec = systemEpoch;
|
||||
systemTime.tv_usec = 0;
|
||||
setRTCSystemTimeForTests(&systemTime);
|
||||
setReadFromRTCUseSystemTimeForTests(true);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(RTCSetResultSuccess, readFromRTC());
|
||||
TEST_ASSERT_EQUAL_INT(RTCQualityNone, getRTCQuality());
|
||||
TEST_ASSERT_UINT32_WITHIN(kAllowedDriftSeconds, (uint32_t)systemEpoch, getTime());
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
delay(10);
|
||||
initializeTestEnvironment();
|
||||
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_readFromRTC_preserves_better_network_time);
|
||||
RUN_TEST(test_readFromRTC_initializes_time_when_no_better_source);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,10 @@
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
|
||||
#include "FSCommon.h"
|
||||
#include "mesh/WarmNodeStore.h"
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -76,12 +78,15 @@ void test_ws_take_removesEntry()
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
makeKey(key, 3);
|
||||
ws.absorb(0x400, 1234, key);
|
||||
ws.absorb(0x400, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role);
|
||||
|
||||
WarmNodeEntry e;
|
||||
TEST_ASSERT_TRUE(ws.take(0x400, e));
|
||||
TEST_ASSERT_EQUAL(0x400, e.num);
|
||||
TEST_ASSERT_EQUAL(1234, e.last_heard);
|
||||
// last_heard is quantised to the metadata quantum; role/protected ride the low bits.
|
||||
TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e));
|
||||
TEST_ASSERT_EQUAL(5, warmRoleOf(e));
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e));
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, e.public_key, 32);
|
||||
TEST_ASSERT_FALSE(ws.contains(0x400));
|
||||
TEST_ASSERT_FALSE(ws.take(0x400, e));
|
||||
@@ -111,13 +116,15 @@ void test_ws_keyedCandidate_evictsOldestKeylessFirst()
|
||||
// Fill with keyed entries except two keyless ones in the middle
|
||||
for (size_t i = 0; i < ws.capacity(); i++) {
|
||||
const bool keyless = (i == 5 || i == 10);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, keyless ? (i == 10 ? 50 : 60) : 10, keyless ? NULL : key));
|
||||
// Timestamps spaced by the 64 s warm metadata quantum (<<6) so LRU order survives
|
||||
// quantisation: keyless i=10 is oldest (50), i=5 next (60), keyed all older (10).
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << 6, keyless ? NULL : key));
|
||||
}
|
||||
// Keyed candidate must displace the OLDEST KEYLESS entry (0x100A, ts=50),
|
||||
// even though every keyed entry is older (ts=10)
|
||||
uint8_t k2[32];
|
||||
makeKey(k2, 0x43);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x8888, 70, k2));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << 6, k2));
|
||||
TEST_ASSERT_FALSE(ws.contains(0x1000 + 10));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x1000 + 5));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x8888));
|
||||
@@ -139,6 +146,31 @@ void test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless()
|
||||
TEST_ASSERT_EQUAL(ws.capacity(), ws.count());
|
||||
}
|
||||
|
||||
void test_ws_meta_roundTrip()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
makeKey(key, 0x77);
|
||||
// Keyed TRACKER(5)/Role-protected and keyless SENSOR(6)/unprotected.
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x700, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x701, 5678, NULL, 6 /* SENSOR */, (uint8_t)WarmProtected::None));
|
||||
|
||||
uint8_t role = 0xFF, prot = 0xFF;
|
||||
TEST_ASSERT_TRUE(ws.lookupMeta(0x700, role, prot));
|
||||
TEST_ASSERT_EQUAL(5, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot);
|
||||
TEST_ASSERT_TRUE(ws.lookupMeta(0x701, role, prot));
|
||||
TEST_ASSERT_EQUAL(6, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
// Absent node yields false and leaves outputs untouched-by-contract (just check return).
|
||||
TEST_ASSERT_FALSE(ws.lookupMeta(0x999, role, prot));
|
||||
// Default args still compile (role/protected = 0 = CLIENT/None).
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x702, 9999, NULL));
|
||||
TEST_ASSERT_TRUE(ws.lookupMeta(0x702, role, prot));
|
||||
TEST_ASSERT_EQUAL(0, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
}
|
||||
|
||||
void test_ws_remove_and_clear()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
@@ -175,6 +207,56 @@ void test_ws_persistence_roundTrip()
|
||||
b.saveIfDirty();
|
||||
}
|
||||
|
||||
// Migration: a v1 (WRM1) warm.dat must keep identity + key but discard last_heard
|
||||
// (so its low bits aren't misread as role/protected). File backend only.
|
||||
void test_ws_v1_migration_discardsLastHeard()
|
||||
{
|
||||
WarmNodeStore a;
|
||||
uint8_t key[32], got[32];
|
||||
makeKey(key, 0x66);
|
||||
a.absorb(0x900, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role);
|
||||
if (!a.saveIfDirty()) {
|
||||
TEST_IGNORE_MESSAGE("Filesystem not available in this test environment");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the whole v2 file, flip the 4-byte header magic to v1 ("WRM1"), write it back.
|
||||
// (CRC covers only the entry bytes, so patching the header magic keeps it valid.)
|
||||
std::vector<uint8_t> buf;
|
||||
{
|
||||
auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ);
|
||||
if (!f) {
|
||||
TEST_IGNORE_MESSAGE("warm.dat not readable in this environment");
|
||||
return;
|
||||
}
|
||||
buf.resize(f.size());
|
||||
f.read(buf.data(), buf.size());
|
||||
f.close();
|
||||
}
|
||||
TEST_ASSERT_TRUE(buf.size() >= 4);
|
||||
const uint32_t v1magic = 0x314D5257u; // "WRM1"
|
||||
memcpy(buf.data(), &v1magic, sizeof(v1magic));
|
||||
{
|
||||
auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE);
|
||||
TEST_ASSERT_TRUE((bool)f);
|
||||
f.write(buf.data(), buf.size());
|
||||
f.close();
|
||||
}
|
||||
|
||||
WarmNodeStore b;
|
||||
b.load();
|
||||
TEST_ASSERT_TRUE(b.contains(0x900)); // identity survived migration
|
||||
TEST_ASSERT_TRUE(b.copyKey(0x900, got)); // public key survived
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, got, 32);
|
||||
uint8_t role = 0xFF, prot = 0xFF;
|
||||
TEST_ASSERT_TRUE(b.lookupMeta(0x900, role, prot));
|
||||
TEST_ASSERT_EQUAL(0, role); // last_heard discarded → role/protected reset
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
|
||||
b.clear();
|
||||
b.saveIfDirty();
|
||||
}
|
||||
|
||||
WS_TEST_ENTRY void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
@@ -187,8 +269,10 @@ WS_TEST_ENTRY void setup()
|
||||
RUN_TEST(test_ws_keylessCandidate_neverEvictsKeyedEntries);
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst);
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless);
|
||||
RUN_TEST(test_ws_meta_roundTrip);
|
||||
RUN_TEST(test_ws_remove_and_clear);
|
||||
RUN_TEST(test_ws_persistence_roundTrip);
|
||||
RUN_TEST(test_ws_v1_migration_discardsLastHeard);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
// "USERPREFS_CONFIG_OWNER_SHORT_NAME": "MLN",
|
||||
// "USERPREFS_CONFIG_DEVICE_ROLE": "meshtastic_Config_DeviceConfig_Role_CLIENT", // Defaults to CLIENT. ROUTER*, and LOST AND FOUND roles are restricted.
|
||||
// "USERPREFS_EVENT_MODE": "1",
|
||||
// "USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS": "1", // Extend TMM position dedup and precision clamping to private/custom-key channels (default: well-known channels only)
|
||||
// "USERPREFS_FIRMWARE_EDITION": "meshtastic_FirmwareEdition_BURNING_MAN",
|
||||
// "USERPREFS_FIXED_BLUETOOTH": "121212",
|
||||
// "USERPREFS_FIXED_GPS": "",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#ifndef Pins_Arduino_h
|
||||
#define Pins_Arduino_h
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
static const uint8_t TX = 21;
|
||||
static const uint8_t RX = 20;
|
||||
|
||||
static const uint8_t SDA = 12;
|
||||
static const uint8_t SCL = 13;
|
||||
|
||||
static const uint8_t SS = 8;
|
||||
static const uint8_t MOSI = 7;
|
||||
static const uint8_t MISO = 6;
|
||||
static const uint8_t SCK = 10;
|
||||
|
||||
static const uint8_t A0 = 0;
|
||||
static const uint8_t A1 = 1;
|
||||
static const uint8_t A2 = 2;
|
||||
static const uint8_t A3 = 3;
|
||||
static const uint8_t A4 = 4;
|
||||
static const uint8_t A5 = 5;
|
||||
|
||||
#endif /* Pins_Arduino_h */
|
||||
@@ -0,0 +1,29 @@
|
||||
; ESP32 C3 Super Mini Development Board
|
||||
; https://www.espboards.dev/esp32/esp32-c3-super-mini/
|
||||
[env:esp32c3_moonshine]
|
||||
|
||||
extends = esp32c3_base
|
||||
board = esp32-c3-devkitm-1
|
||||
board_level = pr
|
||||
build_flags =
|
||||
${esp32c3_base.build_flags}
|
||||
-D PRIVATE_HW
|
||||
-I variants/esp32c3/diy/esp32c3_moonshine
|
||||
-D ARDUINO_USB_MODE=1
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
monitor_speed = 115200
|
||||
upload_protocol = esptool
|
||||
;upload_port = /dev/ttyUSB0
|
||||
upload_speed = 921600
|
||||
|
||||
|
||||
; ------------------- Flash 核心配置 -------------------
|
||||
; 1. Flash 容量(根据硬件实际值改,常见 4MB/8MB)
|
||||
board_build.flash_size = 4MB
|
||||
; 2. Flash 模式(ESP32-C3 推荐 dio)
|
||||
board_build.flash_mode = dio
|
||||
; 3. Flash 时钟频率(ESP32-C3 推荐 80MHz)
|
||||
board_build.flash_freq = 80MHz
|
||||
|
||||
; (可选)上传时的 Flash 配置(和上面保持一致即可)
|
||||
board_upload.flash_size = 4MB
|
||||
@@ -0,0 +1,63 @@
|
||||
#define BUTTON_PIN 9
|
||||
|
||||
#define HAS_SCREEN 0
|
||||
|
||||
#define HAS_I2C 0
|
||||
#define WIRE_INTERFACES_COUNT (0)
|
||||
#define I2C_SDA 12
|
||||
#define I2C_SCL 13
|
||||
|
||||
|
||||
#define BATTERY_PIN 1
|
||||
#define ADC_CHANNEL ADC_CHANNEL_1
|
||||
#define ADC_MULTIPLIER 2.0f
|
||||
|
||||
#define LED_POWER 0 // LED
|
||||
#define LED_STATE_ON 1 // State when LED is lit
|
||||
|
||||
|
||||
#define HAS_GPS 0
|
||||
#undef GPS_RX_PIN
|
||||
#undef GPS_TX_PIN
|
||||
|
||||
//#define E220_400M30S
|
||||
//#define E220_400M33S
|
||||
#define E22_400M33S
|
||||
|
||||
#ifdef E220_400M30S
|
||||
#define SETTING_MAX_POWER 30
|
||||
#define TX_GAIN_LORA 8
|
||||
#define SX126X_MAX_POWER 22
|
||||
#endif
|
||||
|
||||
#ifdef E22_400M33S
|
||||
#define SETTING_MAX_POWER 22
|
||||
#define NUM_PA_POINTS 1
|
||||
#define TX_GAIN_LORA 0
|
||||
#define SX126X_MAX_POWER 22
|
||||
//#define HAS_LORA_FEM 1
|
||||
#endif
|
||||
|
||||
#define USE_LLCC68
|
||||
#define USE_SX1262
|
||||
#define USE_SX1268
|
||||
|
||||
#define LORA_SCK 10
|
||||
#define LORA_MISO 6
|
||||
#define LORA_MOSI 7
|
||||
#define LORA_CS 8
|
||||
#define LORA_DIO0 RADIOLIB_NC
|
||||
#define LORA_RESET 5
|
||||
#define LORA_DIO1 3
|
||||
#define LORA_DIO2 RADIOLIB_NC
|
||||
#define LORA_BUSY 4
|
||||
|
||||
#define SX126X_RXEN 2
|
||||
#define SX126X_CS LORA_CS
|
||||
#define SX126X_DIO1 LORA_DIO1
|
||||
#define SX126X_BUSY LORA_BUSY
|
||||
#define SX126X_RESET LORA_RESET
|
||||
#define SX126X_DIO2_AS_RF_SWITCH
|
||||
#define SX126X_DIO3_TCXO_VOLTAGE 1.8
|
||||
|
||||
#define TCXO_OPTIONAL
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user