upstream dev sync + bump to 1.18

This commit is contained in:
liquidraver
2026-10-15 20:14:53 +02:00
parent ec55eb0f4c
commit 38c59ee10f
14 changed files with 117 additions and 104 deletions
+2 -2
View File
@@ -233,7 +233,7 @@ Path_len byte:
**Flood routing**: Packet has no destination path. Each relay node appends its identity hash to `path[]` and retransmits. Priority decreases with hop count. `allowPacketForward()` is the gatekeeper.
**Direct routing**: Packet carries a source-routed `path[]`. Each relay node checks if the first path hash matches its own identity, removes itself, and forwards. Path is built from previous flood packets' accumulated hashes.
**Direct routing**: Packet carries a source-routed `path[]`. Each relay node checks if the first path hash matches its own identity, removes itself, and forwards. Path is built from previous flood packets' accumulated hashes. Every forwarded direct packet, ACKs included (upstream 1.18.0), waits `getDirectRetransmitDelay()`; extra multi-ACKs and the final ACK are 300 ms apart.
**Deduplication**: `SimpleMeshTables` maintains a circular buffer of 160 packet hashes (8 bytes each, SHA-256 truncated); ACKs are deduped through the same packet-hash path. `wasSeen()` is a pure query; call sites insert explicitly via `markSeen()` to prevent duplicate processing and retransmission.
@@ -727,7 +727,7 @@ default) is migrated to 3200 once (`zc.aoff_set`).
Autonomous operation features:
- **Authentication**: Password-based login with timestamp replay protection (120s window)
- **Permission levels**: GUEST(0), READ_ONLY(1), READ_WRITE(2), ADMIN(3)
- **Region filtering**: `RegionMap` with transport key matching per flood packet
- **Region filtering**: `RegionMap` with transport key matching per flood packet. Un-scoped floods (`mesh::isUnscopedFlood()`: plain floods, and transport floods with code 0 as upstream 1.18.0) go to the wildcard `*`, whose deny-flood flag and `flood.max.unscoped` apply to both; upstream exempts the code-0 case from both (LLD 02 section 16)
- **Rate limiting**: 4 requests per 120s (discovery), 4 per 180s (anonymous), 4 failed logins per 180s
- **Node discovery**: answers discovery requests only while forwarding is on. With `advert.interval` and `flood.advert.interval` both `0` the node is hidden and does not answer, as upstream (`isHiddenNode()`); the request still counts against the discovery rate limit
- **Neighbor tracking**: RSSI/SNR/name/timestamp table (`CONFIG_ZEPHCORE_MAX_NEIGHBOURS`, default 50 slots)
+4 -1
View File
@@ -102,6 +102,7 @@ Every clock set (these commands, GPS, the app, mesh time sync, SNTP) is also wri
| Command | Description |
|---------|-------------|
| `room.post <message>` | Post a message to the shared room as the server itself (system post). Pushed to clients like any other post. |
| `set repeat on` | Replies `ERR - repeat now deprecated`, as upstream (1.18.0): a room server never forwards other nodes' packets. `set repeat off` and `get repeat` are the common commands |
---
@@ -125,6 +126,8 @@ Regions control which flood packets the repeater forwards. The region tree is hi
| `region list allowed` | List all regions that allow floods |
| `region list denied` | List all regions that deny floods |
**Un-scoped floods:** plain floods, and transport floods whose first code is `0` (no region computes `0`; as upstream 1.18.0). The wildcard `*` decides them: `region denyf *` stops forwarding them, and `flood.max.unscoped` limits their hops. Upstream applies neither to the code-`0` case; ZephCore applies both. A reply to such a request is sent un-scoped.
**Region load format:** one region per line, indented with spaces to indicate depth. Append `F` after the name to mark flood-allowed (otherwise flood is denied by default).
**`region def` format:** space-separated tokens; a cursor starts at `*`. Each token is `name` (create child of cursor, advance cursor to it) or `name|jump` / `name,jump` (create child of cursor, then move cursor to the existing region `jump`). Does **not** auto-save — follow with `region save`. Reply is the updated region tree. Example — branched tree: `region def west pnw or pdx|pnw wa sw-wa`. Example — flat list: `region def west|* pnw|* or|* pdx|*`.
@@ -434,7 +437,7 @@ four radio parameters together, since they are one interop-critical set.
| `set direct.txdelay <value>` | | Accepted for prefs compatibility — **ignored** (direct.txdelay is adaptive) |
| `set backoff.multiplier <m>` | 0.0–2.0 | *(ZephCore only)* Per-dupe reactive backoff multiplier (0 = disable reactive backoff) |
| `set flood.max <count>` | 0–64 | Maximum flood retransmit hops |
| `set flood.max.unscoped <count>` | 0–64 | Hop limit for un-scoped floods only (default 64 = same as flood.max); scoped/transport floods still use flood.max |
| `set flood.max.unscoped <count>` | 0–64 | Hop limit for un-scoped floods only, including transport floods with code `0` (default 64 = same as flood.max); scoped/transport floods still use flood.max |
| `set flood.max.advert <count>` | 0–64 | Hop limit for ADVERT floods only (default 8); curbs advert churn independent of flood.max |
| `set flood.advert.interval <hours>` | `0` (off) or 3–168 | How often the repeater floods its own advertisement. `0` disables periodic flood adverts. With `advert.interval` also `0` the repeater is hidden: it stops answering node discovery requests (`discover.neighbors`, the app's discover) as well. |
| `set advert.interval <mins>` | `0` (off) or min–240 | How often the repeater sends local (zero-hop) advertisements. `0` — the default — disables them. Stored halved (the pref holds minutes/2), so odd values round down. |
+1 -1
View File
@@ -46,4 +46,4 @@ On a 2.4 GHz preset there is no off-grid frequency, and the display and the CLI
Up to 1.17.7 the display toggle turned forwarding on without changing the frequency, so a companion
could end up repeating on the live mesh. A node left in that state stops forwarding when it boots
1.17.8 or later, and shows off-grid mode as off.
1.18.0 or later, and shows off-grid mode as off.
@@ -1,80 +0,0 @@
# ZephCore 1.17.8-zephcore
Off-grid mode from the display no longer turns a companion into a repeater on the live mesh. Button
fixes on four boards with a display: the Station G2's button now drives the pages, and
Power Off, DFU and Offgrid can be confirmed on the Heltec Mesh Node T1 and both LR2021 LoRa Plus EVK
kits.
> [!NOTE]
> A normal upgrade keeps your identity, settings, contacts and phone pairing.
---
## Off-grid mode: only on the off-grid frequencies
Off-grid mode makes a companion forward packets for others, for a group of handhelds out of reach of
any repeater. It belongs on three frequencies set aside for it: 433.000, 869.495 and 918.000 MHz. The
app only allows it there, but the Offgrid toggle on the display switched forwarding on without
changing the frequency, so the node repeated everything it heard on the live mesh.
- **The display toggle now moves the node.** Turning Offgrid on goes to the off-grid frequency nearest
to the one you are on and starts forwarding; turning it off returns to the frequency you came from.
Bandwidth, spreading factor, coding rate and TX power stay as they are.
- **A companion never forwards on any other frequency.** If you had switched Offgrid on from the
display on an earlier version, the node stops repeating after this update and shows Offgrid as off.
Nothing else changes; turn it on again if you want off-grid mode.
- **`set repeat on` / `set repeat off` work on a companion** and do the same as the display toggle;
`get repeat` shows the real state. Before, they answered but did nothing.
- **The display follows the app.** Offgrid switched from the app or the CLI now shows on the display
page within a few seconds.
While off-grid mode is on, the node is off your normal mesh. How it works: [OFFGRID.md](../docs/OFFGRID.md).
Thanks to N8HOP for the report.
## Station G2: the user button drives the display
Until now a press of the user button only woke the display: the pages could not be turned, and
nothing on them could be run. The button now works as on the other one-button boards:
- **one press**: next page,
- **two presses**: previous page,
- **hold for one second**: run the item on the page (Bluetooth on/off, send an advert, GPS on/off,
and so on).
Offgrid, DFU and Power Off ask for confirmation: hold the button again within 3 seconds.
Reports of how it behaves on your Station G2 are welcome.
## Heltec Mesh Node T1 and LR2021 LoRa Plus EVK: Power Off, DFU and Offgrid can be confirmed
On these boards Power Off, DFU and Offgrid could not be confirmed from the display. The confirmation
is a second long press of the user button, and it was accepted only within half a second of the
first, which a long press cannot meet: every long press asked for the confirmation again. It now has
3 seconds, as on the other one-button boards.
The confirmation is a second **hold**. A short press pages on instead, which on the Power Off page
goes back to the first page.
## GPS: a fix could set the clock to an old time
On a repeater with a GPS, a fix could set the clock hours back. The next fix usually put it right
again, but until then the node's adverts and replies carried the old time, and mesh time sync was not
allowed to correct it.
Right after waking, a GPS receiver can report an estimated position for a few seconds: its last
position and its own clock, before any satellite has confirmed them. These were accepted as a fix.
If the receiver's clock had not kept time while it slept, that old time was written to the node.
- **Only positions solved from satellites count as a fix**, and five in a row are needed (was three).
A fix takes about two seconds longer.
- This applies to every board with a GPS.
It was reported on a SenseCAP Solar P1 with `set gps standby` raised to 48 hours, which keeps the
receiver powered while it sleeps. With the default (one hour) a repeater switches the receiver off
between its 48-hour fixes.
Good to know: a telemetry request from a logged-in client (anything above guest) wakes a repeater's
GPS for a fresh fix, so the GPS can run more often than the `gps duty` interval.
Thanks to **Innocent Killer 101** for the report and the measurements.
@@ -0,0 +1,50 @@
# ZephCore 1.18.0-zephcore
Safer off-grid mode, working buttons on four boards with a display, a GPS clock fix, and the
changes from upstream MeshCore 1.18.0.
> [!NOTE]
> A normal upgrade keeps your identity, settings, contacts and phone pairing.
---
## Off-grid mode stays off the live mesh
Off-grid mode makes a companion forward packets for nearby handhelds when no repeater is in reach. It
belongs on 433.000, 869.495 or 918.000 MHz, but the display's Offgrid toggle turned forwarding on
without changing the frequency, so the node repeated everything on your normal mesh.
- **Turning Offgrid on now moves the node** to the nearest off-grid frequency; turning it off returns
to the frequency you came from. Bandwidth, SF, CR and TX power are kept.
- **A companion never forwards anywhere else.** If you enabled Offgrid from the display before, it
shows as off after the update; turn it on again if you want it.
- **`set repeat on|off` works on a companion** and does the same as the toggle. The display also
follows changes made from the app.
More in [OFFGRID.md](../docs/OFFGRID.md). Thanks to **N8HOP** for the report.
## Buttons
- **Station G2:** the user button now works like on other one-button boards: one press for the next
page, two for the previous page, hold for a second to run the item.
- **Heltec Mesh Node T1, both LR2021 LoRa Plus EVK kits:** Power Off, DFU and Offgrid can be
confirmed again.
- On all four boards the confirmation is a second **hold** within 3 seconds.
## GPS no longer sets the clock to an old time
Just after waking, a GPS receiver can report its last position and its own, possibly stale, clock
before any satellite confirms them. That was taken as a fix and could set a repeater's clock hours
back. Now only satellite-solved positions count, five in a row. A fix takes about two seconds longer.
This applies to every board with a GPS. Thanks to **Innocent Killer 101** for the report and the
measurements.
## From upstream MeshCore 1.18.0
- **Acknowledgements on direct routes are relayed after a short random delay**, like other direct
packets, and with `multi.acks 1` the two copies go out 300 ms apart. This means fewer lost
acknowledgements.
- **Floods with transport code 0 are treated as un-scoped.** Unlike upstream, ZephCore applies your
`flood.max.unscoped` and `region denyf *` settings to them.
- **Room server:** `set repeat on` now answers `ERR - repeat now deprecated`. A room server never
forwarded packets anyway.
+5
View File
@@ -352,6 +352,11 @@
"name": "Scoped unscoped and advert hop boundaries",
"source": "tests/unit/policy_tests.cpp"
},
{
"id": "UNIT-POLICY-003",
"name": "Transport flood with code 0 is un-scoped, other codes and routes are not",
"source": "tests/unit/policy_tests.cpp"
},
{
"id": "UNIT-OFFGRID-001",
"name": "Off-grid frequency: the three allowed, nearest per band, none above sub-GHz",
+20
View File
@@ -18,6 +18,7 @@ TEST(reply_routes, "UNIT-POLICY-001", "Reply routing and scope truth tables") {
}
TEST(hop_limits, "UNIT-POLICY-002", "Scoped unscoped and advert hop boundaries") {
Packet p;
p.transport_codes[0] = 0x1234; p.transport_codes[1] = 0;
for (uint8_t route : {ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD}) {
for (uint8_t type : {PAYLOAD_TYPE_ADVERT, PAYLOAD_TYPE_TXT_MSG}) {
p.header = route | (type << 2);
@@ -31,6 +32,25 @@ TEST(hop_limits, "UNIT-POLICY-002", "Scoped unscoped and advert hop boundaries")
p.header = ROUTE_TYPE_FLOOD; p.path_len = 0;
CHECK(isFloodHopLimitExceeded(&p, 5, 0, 2));
}
TEST(code_zero_unscoped, "UNIT-POLICY-003", "Transport flood with code 0 is un-scoped, other codes and routes are not") {
Packet p;
p.transport_codes[1] = 0x5678;
for (uint16_t code : {0x0000, 0x0001, 0xFFFE}) {
p.transport_codes[0] = code;
p.header = ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_TXT_MSG << 2);
CHECK(isUnscopedFlood(&p) == (code == 0));
p.setPathHashSizeAndCount(1, 3);
CHECK(isFloodHopLimitExceeded(&p, 5, 3, 2) == (code == 0));
p.setPathHashSizeAndCount(1, 0);
CHECK(isFloodHopLimitExceeded(&p, 5, 0, 2) == (code == 0));
p.header = ROUTE_TYPE_TRANSPORT_DIRECT | (PAYLOAD_TYPE_TXT_MSG << 2);
CHECK(!isUnscopedFlood(&p));
p.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_TXT_MSG << 2);
CHECK(isUnscopedFlood(&p));
p.header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_TXT_MSG << 2);
CHECK(!isUnscopedFlood(&p));
}
}
TEST(rate_window, "UNIT-RATE-001", "Rate window threshold expiry and reset") {
RateLimiter limiter(2, 10);
CHECK(limiter.allow(100)); CHECK(limiter.allow(101));
+1 -1
View File
@@ -33,7 +33,7 @@ list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
# Service advertises (CONFIG_BT_DIS_FW_REV_STR, generated from it below), what the
# GitHub release is tagged/named, and what the Mesh America catalog uses as its
# version key. The release workflow reads this line directly — keep its form.
set(ZEPHCORE_FIRMWARE_VERSION "1.17.7-zephcore")
set(ZEPHCORE_FIRMWARE_VERSION "1.18.0-zephcore")
# ========== Helpers ==========
# Append FILE to the overlay list once, keeping its FIRST position. Position is
+6 -5
View File
@@ -674,20 +674,21 @@ uint32_t RepeaterMesh::getDirectRetransmitDelay(const mesh::Packet* packet) {
return computeAdaptiveDirectDelay(packet);
}
// ZEPHCORE: ours. Also records whether the packet was an unscoped flood.
// ZEPHCORE: ours. Also records whether the packet was an unscoped flood; a
// transport flood with code 0 is one, wildcard deny included (UPSTREAM_TRACKER 01f9293b).
mesh::DispatcherAction RepeaterMesh::onRecvPacket(mesh::Packet* pkt) {
// Determine the request packet's region so sendFloodReply() can echo the same
// scope. Runs for every packet (not just floods) so recv_pkt_region is cleared
// for direct packets instead of inheriting the last flood's region.
recv_pkt_unscoped_flood = (pkt->getRouteType() == ROUTE_TYPE_FLOOD);
if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD);
} else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) {
recv_pkt_unscoped_flood = mesh::isUnscopedFlood(pkt);
if (recv_pkt_unscoped_flood) {
if (region_map.getWildcard().flags & REGION_DENY_FLOOD) {
recv_pkt_region = nullptr;
} else {
recv_pkt_region = &region_map.getWildcard();
}
} else if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD);
} else {
recv_pkt_region = nullptr;
}
+8 -5
View File
@@ -354,20 +354,21 @@ bool RoomServerMesh::allowPacketForward(const mesh::Packet* /*packet*/) {
return false;
}
// ZEPHCORE: ours. Also records whether the packet was an unscoped flood.
// ZEPHCORE: ours. Also records whether the packet was an unscoped flood; a
// transport flood with code 0 is one, wildcard deny included (UPSTREAM_TRACKER 01f9293b).
mesh::DispatcherAction RoomServerMesh::onRecvPacket(mesh::Packet* pkt) {
// Determine the request packet's region so sendFloodReply() can echo the same
// scope. Runs for every packet (not just floods) so recv_pkt_region is cleared
// for direct packets instead of inheriting the last flood's region.
recv_pkt_unscoped_flood = (pkt->getRouteType() == ROUTE_TYPE_FLOOD);
if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD);
} else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) {
recv_pkt_unscoped_flood = mesh::isUnscopedFlood(pkt);
if (recv_pkt_unscoped_flood) {
if (region_map.getWildcard().flags & REGION_DENY_FLOOD) {
recv_pkt_region = nullptr;
} else {
recv_pkt_region = &region_map.getWildcard();
}
} else if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) {
recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD);
} else {
recv_pkt_region = nullptr;
}
@@ -1003,6 +1004,8 @@ void RoomServerMesh::handleCommand(uint32_t sender_timestamp, char* command, cha
addSystemPost(msg);
strcpy(reply, "OK");
}
} else if (memcmp(command, "set repeat on", 13) == 0) {
strcpy(reply, "ERR - repeat now deprecated"); // a room server never forwards (upstream 8579c667, 481f72ca)
} else {
_cli.handleCommand(sender_timestamp, command, reply);
}
+1
View File
@@ -189,6 +189,7 @@ int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[],
}
RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) {
if (packet->transport_codes[0] == 0) return &wildcard;
for (int i = 0; i < num_regions; i++) {
auto region = &regions[i];
if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param)
+12 -2
View File
@@ -14,12 +14,22 @@
namespace mesh {
/**
* An un-scoped flood: ROUTE_TYPE_FLOOD, or a transport flood whose first code
* is 0 (no Region computes 0; upstream 01f9293b treats it as un-scoped).
*/
inline bool isUnscopedFlood(const Packet *packet)
{
return packet->getRouteType() == ROUTE_TYPE_FLOOD
|| (packet->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD && packet->transport_codes[0] == 0);
}
/**
* Test a flood packet against the configured hop limits.
*
* @param packet inbound flood packet (caller has already checked isRouteFlood())
* @param flood_max hop ceiling for any flood packet
* @param flood_max_unscoped hop ceiling for un-scoped (ROUTE_TYPE_FLOOD) packets;
* @param flood_max_unscoped hop ceiling for un-scoped packets (isUnscopedFlood());
* may be clamped lower than scoped/transport floods
* @param flood_max_advert hop ceiling for ADVERT floods, typically tighter still
* so advert churn stays local
@@ -31,7 +41,7 @@ inline bool isFloodHopLimitExceeded(const Packet *packet, uint8_t flood_max,
uint8_t hops = packet->getPathHashCount();
if (hops >= flood_max) return true;
if (packet->getRouteType() == ROUTE_TYPE_FLOOD && hops >= flood_max_unscoped) return true;
if (isUnscopedFlood(packet) && hops >= flood_max_unscoped) return true;
if (packet->getPayloadType() == PAYLOAD_TYPE_ADVERT && hops >= flood_max_advert) return true;
return false;
}
+1 -1
View File
@@ -34,7 +34,7 @@ class Mesh : public Dispatcher {
void removeSelfFromPath(Packet* packet);
void routeDirectRecvAcks(Packet* packet, uint32_t delay_millis);
//void routeRecvAcks(Packet* packet, uint32_t delay_millis);
DispatcherAction forwardMultipartDirect(Packet* pkt);
DispatcherAction forwardMultipartDirect(Packet* pkt, uint32_t delay_millis);
protected:
// ZEPHCORE: adaptive contention window — dupe tracking, reactive backoff,
+6 -6
View File
@@ -142,13 +142,14 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
}
if (self_id.isHashMatch(pkt->path, pkt->getPathHashSize()) && allowPacketForward(pkt)) {
uint32_t d = getDirectRetransmitDelay(pkt);
if (pkt->getPayloadType() == PAYLOAD_TYPE_MULTIPART) {
return forwardMultipartDirect(pkt);
return forwardMultipartDirect(pkt, d);
} else if (pkt->getPayloadType() == PAYLOAD_TYPE_ACK) {
if (!_tables->wasSeen(pkt)) { // don't retransmit!
_tables->markSeen(pkt);
removeSelfFromPath(pkt);
routeDirectRecvAcks(pkt, 0);
routeDirectRecvAcks(pkt, d);
}
return ACTION_RELEASE;
}
@@ -157,7 +158,6 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
_tables->markSeen(pkt);
removeSelfFromPath(pkt);
uint32_t d = getDirectRetransmitDelay(pkt);
return ACTION_RETRANSMIT_DELAYED(0, d); // Routed traffic is HIGHEST priority
}
}
@@ -430,7 +430,7 @@ DispatcherAction Mesh::routeRecvPacket(Packet* packet) {
return ACTION_RELEASE;
}
DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) {
DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt, uint32_t delay_millis) {
uint8_t remaining = pkt->payload[0] >> 4; // num of packets in this multipart sequence still to be sent
uint8_t type = pkt->payload[0] & 0x0F;
@@ -444,7 +444,7 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) {
if (!_tables->wasSeen(&tmp)) { // don't retransmit!
_tables->markSeen(&tmp);
removeSelfFromPath(&tmp);
routeDirectRecvAcks(&tmp, ((uint32_t)remaining + 1) * 300); // expect multipart ACKs 300ms apart (x2)
routeDirectRecvAcks(&tmp, delay_millis + ((uint32_t)remaining + 1) * 300); // expect multipart ACKs 300ms apart (x2)
}
}
return ACTION_RELEASE;
@@ -454,13 +454,13 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) {
if (!packet->isMarkedDoNotRetransmit()) {
uint8_t extra = getExtraAckTransmitCount();
while (extra > 0) {
delay_millis += getDirectRetransmitDelay(packet) + 300;
auto a1 = createMultiAck(packet->payload, packet->payload_len, extra);
if (a1) {
a1->path_len = Packet::copyPath(a1->path, packet->path, packet->path_len);
a1->header &= ~PH_ROUTE_MASK;
a1->header |= ROUTE_TYPE_DIRECT;
sendPacket(a1, 0, delay_millis);
delay_millis += 300;
}
extra--;
}