NodeDB: hold environment satellites protobuf-encoded

Each environment satellite held a full meshtastic_EnvironmentMetrics,
304 B for 38 optional fields, of which a real sensor sets a handful. At
the nRF52 cap of 40 that is ~12 KB of heap holding about 1 KB of data.

Store the encoded bytes instead (~15-40 B per entry) and decode on read:
copyNodeEnvironment() keeps its signature. The save writes the stored
bytes straight out as the entry's environment_metrics, so nodes.proto
is unchanged; the load packs each decoded entry. hasNodeEnvironment()
becomes a key lookup, and MemAudit counts the real blob sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jonathan BennettandClaude Opus 5.5 committed 2026-11-03 10:58:19 -06:00
1 parent f8d60d82a9
commit ebda44c677
3 files changed
+156 -20

No files matched your search

+61 -14
View File
@@ -253,7 +253,7 @@ std::map<NodeNum, meshtastic_PositionLite> *s_decodePositionsTarget = nullptr;
std::map<NodeNum, meshtastic_DeviceMetrics> *s_decodeTelemetryTarget = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
std::map<NodeNum, meshtastic_EnvironmentMetrics> *s_decodeEnvironmentTarget = nullptr;
std::map<NodeNum, PackedEnvironmentMetrics> *s_decodeEnvironmentTarget = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
std::map<NodeNum, meshtastic_StatusMessage> *s_decodeStatusTarget = nullptr;
@@ -268,7 +268,7 @@ const std::map<NodeNum, meshtastic_PositionLite> *s_encodePositionsSource = null
const std::map<NodeNum, meshtastic_DeviceMetrics> *s_encodeTelemetrySource = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
const std::map<NodeNum, meshtastic_EnvironmentMetrics> *s_encodeEnvironmentSource = nullptr;
const std::map<NodeNum, PackedEnvironmentMetrics> *s_encodeEnvironmentSource = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
const std::map<NodeNum, meshtastic_StatusMessage> *s_encodeStatusSource = nullptr;
@@ -293,6 +293,18 @@ template <typename Entry, typename Value, typename Fill>
}
return true;
}
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
// One NodeEnvironmentEntry body: num, then the stored environment_metrics bytes as-is (what pb_encode would write)
bool encodePackedEnvironmentEntryBody(pb_ostream_t *ostream, NodeNum num, const PackedEnvironmentMetrics &packed)
{
if (num &&
(!pb_encode_tag(ostream, PB_WT_VARINT, meshtastic_NodeEnvironmentEntry_num_tag) || !pb_encode_varint(ostream, num)))
return false;
return pb_encode_tag(ostream, PB_WT_STRING, meshtastic_NodeEnvironmentEntry_environment_metrics_tag) &&
pb_encode_string(ostream, packed.bytes.data(), packed.bytes.size());
}
#endif
} // namespace
bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field)
@@ -442,14 +454,16 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre
case meshtastic_NodeDatabase_environment_tag: {
if (ostream) {
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
if (s_encodeEnvironmentSource)
return encodeSatelliteMap<meshtastic_NodeEnvironmentEntry>(
ostream, iter, meshtastic_NodeEnvironmentEntry_fields, *s_encodeEnvironmentSource,
[](meshtastic_NodeEnvironmentEntry &e, NodeNum num, const meshtastic_EnvironmentMetrics &v) {
e.num = num;
e.has_environment_metrics = true;
e.environment_metrics = v;
});
if (s_encodeEnvironmentSource) {
for (const auto &kv : *s_encodeEnvironmentSource) {
pb_ostream_t sizing = PB_OSTREAM_SIZING;
if (!encodePackedEnvironmentEntryBody(&sizing, kv.first, kv.second) ||
!pb_encode_tag_for_field(ostream, iter) || !pb_encode_varint(ostream, sizing.bytes_written) ||
!encodePackedEnvironmentEntryBody(ostream, kv.first, kv.second))
return false;
}
return true;
}
#endif
const auto *vec = static_cast<const std::vector<meshtastic_NodeEnvironmentEntry> *>(iter->pData);
for (const auto &item : *vec) {
@@ -465,7 +479,7 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
if (s_decodeEnvironmentTarget) {
if (entry.has_environment_metrics && isUsableSatelliteKey(entry.num))
(*s_decodeEnvironmentTarget)[entry.num] = entry.environment_metrics;
(*s_decodeEnvironmentTarget)[entry.num].pack(entry.environment_metrics);
return true;
}
#endif
@@ -2076,10 +2090,41 @@ bool NodeDB::copyNodeEnvironment(NodeNum n, meshtastic_EnvironmentMetrics &out)
return false;
#else
concurrency::LockGuard guard(&satelliteMutex);
return copySatelliteEntry(nodeEnvironment, n, out);
auto it = nodeEnvironment.find(n);
return it != nodeEnvironment.end() && it->second.unpack(out);
#endif
}
bool NodeDB::hasNodeEnvironment(NodeNum n) const
{
#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB
(void)n;
return false;
#else
concurrency::LockGuard guard(&satelliteMutex);
return nodeEnvironment.count(n) != 0;
#endif
}
bool PackedEnvironmentMetrics::pack(const meshtastic_EnvironmentMetrics &metrics)
{
uint8_t buf[meshtastic_EnvironmentMetrics_size];
pb_ostream_t stream = pb_ostream_from_buffer(buf, sizeof(buf));
if (!pb_encode(&stream, meshtastic_EnvironmentMetrics_fields, &metrics)) {
bytes.clear();
return false;
}
std::vector<uint8_t>(buf, buf + stream.bytes_written).swap(bytes); // exact capacity, no growth slack
return true;
}
bool PackedEnvironmentMetrics::unpack(meshtastic_EnvironmentMetrics &out) const
{
out = meshtastic_EnvironmentMetrics_init_zero;
pb_istream_t stream = pb_istream_from_buffer(bytes.data(), bytes.size());
return pb_decode(&stream, meshtastic_EnvironmentMetrics_fields, &out);
}
bool NodeDB::copyNodeStatus(NodeNum n, meshtastic_StatusMessage &out) const
{
#if MESHTASTIC_EXCLUDE_STATUSDB
@@ -2244,7 +2289,9 @@ bool NodeDB::enforceSatelliteCaps()
satBytes += nodeTelemetry.size() * (sizeof(decltype(nodeTelemetry)::value_type) + 44);
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
satBytes += nodeEnvironment.size() * (sizeof(decltype(nodeEnvironment)::value_type) + 44);
// plus each entry's own encoded bytes, a separate allocation (~8 B allocator header)
for (const auto &kv : nodeEnvironment)
satBytes += sizeof(decltype(nodeEnvironment)::value_type) + 44 + kv.second.bytes.capacity() + 8;
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
satBytes += nodeStatus.size() * (sizeof(decltype(nodeStatus)::value_type) + 44);
@@ -3838,7 +3885,7 @@ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxS
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
concurrency::LockGuard guard(&satelliteMutex);
evictSatelliteOverCap(*this, nodeEnvironment, nodeId);
nodeEnvironment[nodeId] = t.variant.environment_metrics;
nodeEnvironment[nodeId].pack(t.variant.environment_metrics);
#endif
} else {
+11 -6
View File
@@ -297,6 +297,15 @@ struct LoraSlotSnapshot {
/// Normalises to the modem fields actually in force, so editing a dormant one is not a slot change.
LoraSlotSnapshot loraSlotSnapshotFrom(const meshtastic_Config_LoRaConfig &lora, const char *primaryChannelName);
/// An EnvironmentMetrics held protobuf-encoded. A real sensor sets a handful of its 38 optional fields, so this is
/// ~15-40 B where the decoded struct is 304 B. The bytes are exactly what nodes.proto stores for the submessage.
struct PackedEnvironmentMetrics {
std::vector<uint8_t> bytes;
bool pack(const meshtastic_EnvironmentMetrics &metrics);
bool unpack(meshtastic_EnvironmentMetrics &out) const;
};
class NodeDB
{
// NodeNum provisionalNodeNum; // if we are trying to find a node num this is our current attempt
@@ -322,7 +331,7 @@ class NodeDB
std::map<NodeNum, meshtastic_DeviceMetrics> nodeTelemetry;
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
std::map<NodeNum, meshtastic_EnvironmentMetrics> nodeEnvironment;
std::map<NodeNum, PackedEnvironmentMetrics> nodeEnvironment;
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
std::map<NodeNum, meshtastic_StatusMessage> nodeStatus;
@@ -579,11 +588,7 @@ class NodeDB
meshtastic_DeviceMetrics scratch;
return copyNodeTelemetry(n, scratch);
}
bool hasNodeEnvironment(NodeNum n) const
{
meshtastic_EnvironmentMetrics scratch;
return copyNodeEnvironment(n, scratch);
}
bool hasNodeEnvironment(NodeNum n) const;
bool hasNodeStatus(NodeNum n) const
{
meshtastic_StatusMessage scratch;
@@ -443,6 +443,84 @@ static void test_fullRoundTrip_headerAndSatelliteFidelity(void)
#endif
}
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
// Environment satellites are held protobuf-encoded rather than as the 304 B nanopb struct: a real sensor
// sets a handful of EnvironmentMetrics' 38 optional fields. At the nRF52 cap of 40 entries the struct
// form cost ~12 KB of heap and ran T114/T096 sensor nodes out of memory. Pins that a BME680-shaped
// reading packs to a few dozen bytes, unpacks exactly, and that unset fields stay unset (has_* false)
// rather than coming back as present zeros.
static void test_packedEnvironment_compactAndLossless(void)
{
meshtastic_EnvironmentMetrics in = meshtastic_EnvironmentMetrics_init_zero;
in.has_temperature = true;
in.temperature = 9.97f;
in.has_relative_humidity = true;
in.relative_humidity = 31.9f;
in.has_barometric_pressure = true;
in.barometric_pressure = 1019.5f;
in.has_gas_resistance = true;
in.gas_resistance = 598.2f;
in.has_iaq = true;
in.iaq = 75;
PackedEnvironmentMetrics packed;
TEST_ASSERT_TRUE(packed.pack(in));
TEST_ASSERT_LESS_OR_EQUAL_UINT32(40, packed.bytes.size());
TEST_ASSERT_EQUAL_UINT32(packed.bytes.size(), packed.bytes.capacity());
meshtastic_EnvironmentMetrics out;
TEST_ASSERT_TRUE(packed.unpack(out));
TEST_ASSERT_TRUE(out.has_temperature);
TEST_ASSERT_EQUAL_FLOAT(9.97f, out.temperature);
TEST_ASSERT_TRUE(out.has_relative_humidity);
TEST_ASSERT_EQUAL_FLOAT(31.9f, out.relative_humidity);
TEST_ASSERT_TRUE(out.has_barometric_pressure);
TEST_ASSERT_EQUAL_FLOAT(1019.5f, out.barometric_pressure);
TEST_ASSERT_TRUE(out.has_gas_resistance);
TEST_ASSERT_EQUAL_FLOAT(598.2f, out.gas_resistance);
TEST_ASSERT_TRUE(out.has_iaq);
TEST_ASSERT_EQUAL_UINT16(75, out.iaq);
TEST_ASSERT_FALSE(out.has_lux);
TEST_ASSERT_FALSE(out.has_voltage);
TEST_ASSERT_FALSE(out.has_one_wire_temperature_ch7);
}
// The save writes each environment entry by hand from the stored bytes instead of through
// pb_encode_submessage. Pins that the result is still ordinary protobuf: the plain nanopb decoder
// (decode targets disarmed, as an older firmware or a backup reader would decode it) recovers the
// node number and every set field. A framing slip here would corrupt nodes.proto for every reader.
static void test_packedEnvironment_savedEntryDecodesAsPlainProtobuf(void)
{
const uint32_t E = 0x54000103;
addUserNode(E, 0x23);
meshtastic_Telemetry env = meshtastic_Telemetry_init_zero;
env.which_variant = meshtastic_Telemetry_environment_metrics_tag;
env.variant.environment_metrics.has_temperature = true;
env.variant.environment_metrics.temperature = -4.25f;
env.variant.environment_metrics.has_iaq = true;
env.variant.environment_metrics.iaq = 312;
nodeDB->updateTelemetry(E, env);
TEST_ASSERT_TRUE(db->saveDatabase());
meshtastic_NodeDatabase disk;
decodeNodesFile(disk);
const meshtastic_NodeEnvironmentEntry *found = nullptr;
for (const auto &entry : disk.environment) {
if (entry.num == E)
found = &entry;
}
TEST_ASSERT_NOT_NULL_MESSAGE(found, "environment entry missing from nodes.proto");
TEST_ASSERT_TRUE(found->has_environment_metrics);
TEST_ASSERT_TRUE(found->environment_metrics.has_temperature);
TEST_ASSERT_EQUAL_FLOAT(-4.25f, found->environment_metrics.temperature);
TEST_ASSERT_TRUE(found->environment_metrics.has_iaq);
TEST_ASSERT_EQUAL_UINT16(312, found->environment_metrics.iaq);
TEST_ASSERT_FALSE(found->environment_metrics.has_relative_humidity);
nodeDB->eraseNodeSatellites(E);
}
#endif
// --- Keyless-save skip (part of the PKI-DM key-amnesia diagnosis) ---
static void test_keylessDevice_skipsNodesProtoWrite(void)
@@ -728,6 +806,12 @@ NDBR_TEST_ENTRY void setup()
printf("\n=== Satellite projection/rehydration ===\n");
RUN_TEST(test_fullRoundTrip_headerAndSatelliteFidelity);
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
printf("\n=== Packed environment satellites ===\n");
RUN_TEST(test_packedEnvironment_compactAndLossless);
RUN_TEST(test_packedEnvironment_savedEntryDecodesAsPlainProtobuf);
#endif
printf("\n=== Keyless-save gate ===\n");
RUN_TEST(test_keylessDevice_skipsNodesProtoWrite);