diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index 622761ca..a237e2fd 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -11,6 +11,19 @@ set(TRAIL_MATE_REPO_ROOT include("${TRAIL_MATE_REPO_ROOT}/cmake/TrailMateUxPacks.cmake") trailmate_add_ui_lvgl_ux_packs(trailmate_ui_lvgl_ux_packs) +set(TRAIL_MATE_MESHTASTIC_GENERATED_SOURCES + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/admin.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/channel.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/config.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/connection_status.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/device_ui.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/localonly.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/mesh.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/module_config.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/portnums.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/telemetry.pb.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated/meshtastic/xmodem.pb.cpp") + add_library(trailmate_linux_sim_shell src/linux_sim_app_shell.cpp src/linux_sim_runtime_entry.cpp @@ -120,7 +133,10 @@ if(BUILD_TESTING) COMMAND trailmate_mesh_adapter_router_core_smoke) add_executable(trailmate_protocol_effect_executor_smoke - "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_protocol_effect_executor.cpp") + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_protocol_effect_executor.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb/pb_common.c" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb/pb_encode.c" + ${TRAIL_MATE_MESHTASTIC_GENERATED_SOURCES}) target_include_directories(trailmate_protocol_effect_executor_smoke PRIVATE "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" @@ -131,6 +147,22 @@ if(BUILD_TESTING) add_test(NAME trailmate_protocol_effect_executor_smoke COMMAND trailmate_protocol_effect_executor_smoke) + add_executable(trailmate_meshtastic_runtime_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_meshtastic_runtime.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb/pb_common.c" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb/pb_decode.c" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb/pb_encode.c" + ${TRAIL_MATE_MESHTASTIC_GENERATED_SOURCES}) + target_include_directories(trailmate_meshtastic_runtime_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/generated" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/third_party/nanopb") + target_compile_features(trailmate_meshtastic_runtime_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_meshtastic_runtime_smoke + COMMAND trailmate_meshtastic_runtime_smoke) + add_executable(trailmate_meshcore_protocol_strategy_smoke "${TRAIL_MATE_REPO_ROOT}/modules/core_mesh/tests/test_meshcore_protocol_strategy.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_mesh/src/protocol/meshcore/meshcore_protocol_strategy.cpp" diff --git a/docs/audits/PROTOCOL_ADAPTER_DRIFT_AUDIT.md b/docs/audits/PROTOCOL_ADAPTER_DRIFT_AUDIT.md index d7693f33..70d93cc6 100644 --- a/docs/audits/PROTOCOL_ADAPTER_DRIFT_AUDIT.md +++ b/docs/audits/PROTOCOL_ADAPTER_DRIFT_AUDIT.md @@ -17,8 +17,8 @@ Primary root cause: | --- | --- | --- | --- | | Meshtastic NodeInfo peer reannounce | Shared policy in working tree | Medium | ESP32 and nRF now call the same reannounce gate; platform adapters still own the actual queue/send IO. | | Meshtastic broadcast `want_response` | Shared policy in working tree | Medium | ESP32 and nRF now share the app-data destination/ACK/response decision; broadcast air ACK is suppressed while request response intent is preserved. | -| Meshtastic request/reply core | Mostly shared | Medium | NodeInfo/Position reply gating, NodeInfo/Position payload construction, TraceRoute reply gating, TraceRoute payload mutation, and TraceRoute/Position action lifecycle tracking are shared; reply radio send still lives in adapters/UI. | -| Meshtastic duplicated policy ownership | Mostly shared | Medium | App-data send intent, NodeInfo reannounce gate, NodeInfo/Position reply gates, NodeInfo self-announcement packet construction, Position payload construction, TraceRoute reply gate, TraceRoute payload mutation, TraceRoute/Position result lifecycle, and PKI/NO_CHANNEL resync decisions now live in shared runtime/policy. Radio IO and local data sources still live in adapters. | +| Meshtastic request/reply core | Mostly shared | Medium | NodeInfo/Position reply gating, NodeInfo/Position payload construction, TraceRoute/Position outgoing request packet decisions, TraceRoute reply gating, TraceRoute payload mutation, and TraceRoute/Position action lifecycle tracking are shared; radio send still lives in adapters/UI executors. | +| Meshtastic duplicated policy ownership | Mostly shared | Medium | App-data send intent, NodeInfo reannounce gate, NodeInfo/Position reply gates, NodeInfo self-announcement packet construction, Position payload construction, TraceRoute/Position outgoing request packet decisions, TraceRoute reply gate, TraceRoute payload mutation, TraceRoute/Position result lifecycle, and PKI/NO_CHANNEL resync decisions now live in shared runtime/policy. Radio IO and local data sources still live in adapters. | | MeshCore NodeInfo query/reply | Shared runtime/effects | Medium | ESP32 and nRF now route `requestNodeInfo()` through `MeshCoreRuntime` and shared control payload codecs; platform adapters still own packet IO/projection. | | MeshCore trace | Shared lifecycle, platform-limited routing | Medium | ESP32 and nRF use native `PAYLOAD_TYPE_TRACE` and shared completion/timeout policy; nRF still uses a minimal one-hop hash route. | | MeshCore app-data ACK/capability | Shared lifecycle | Medium | ESP32 and nRF now declare ACK tracking only when runtime pending/completion handling is wired. ACK frame scheduling remains adapter IO. | @@ -189,9 +189,12 @@ Current state: - Shared `MeshtasticAppActionRuntime` now owns outgoing TraceRoute and Position Exchange lifecycle interpretation: pending, routing delivered where available, routing error, matching app response completed, and timeout. +- `MeshtasticRuntime::prepareOutgoing(...)` now maps nRF mono UI `TraceRouteIntent` and + `ExchangePositionIntent` into `SendPacketEffect`, so the UI no longer constructs + `TRACEROUTE_APP` / `POSITION_APP` packets or protobuf payloads directly. - Mono UI observes `ChatService::IncomingDataObserver` instead of polling the adapter queue directly, so TraceRoute result state does not steal app-data from BLE/other consumers. -- Adapter send mechanics still exist on ESP32 and nRF. +- Adapter/UI executor send mechanics still exist on ESP32 and nRF. Residual risk: diff --git a/docs/specification/PROTOCOL_RUNTIME_DESIGN_SPEC.md b/docs/specification/PROTOCOL_RUNTIME_DESIGN_SPEC.md index 6c028485..aac8ee0f 100644 --- a/docs/specification/PROTOCOL_RUNTIME_DESIGN_SPEC.md +++ b/docs/specification/PROTOCOL_RUNTIME_DESIGN_SPEC.md @@ -321,6 +321,18 @@ It does not own: - BLE phone protocol transport; - memory placement or ISR details. +Current C++17 migration state: + +- Meshtastic TraceRoute and Position Exchange outgoing user actions can now enter + `MeshtasticRuntime::prepareOutgoing(...)` as `TraceRouteIntent` / `ExchangePositionIntent`. + The runtime chooses Meshtastic portnum, request id fallback, ACK/response flags, and protobuf payload shape, + then emits `SendPacketEffect`. +- nRF mono UI executes those `SendPacketEffect`s through its platform mesh adapter and keeps UI-only lifecycle + projection in `MeshtasticAppActionRuntime`; it no longer constructs `TRACEROUTE_APP` / + `POSITION_APP` packets directly. +- Platform adapters still own physical radio send, local GPS source selection, BLE projection, queueing, and + adapter-side incoming packet execution until those can be represented as runtime effects/state. + ### Meshtastic PKI Resync State PKI resync must be a State object, not scattered if branches. diff --git a/modules/core_chat/include/chat/runtime/meshtastic_runtime.h b/modules/core_chat/include/chat/runtime/meshtastic_runtime.h index 1feb8da7..b457753e 100644 --- a/modules/core_chat/include/chat/runtime/meshtastic_runtime.h +++ b/modules/core_chat/include/chat/runtime/meshtastic_runtime.h @@ -2,8 +2,11 @@ #include "chat/runtime/protocol_runtime.h" #include "meshtastic/mesh.pb.h" +#include "meshtastic/portnums.pb.h" +#include "pb_encode.h" #include +#include namespace chat::runtime { @@ -86,10 +89,25 @@ class MeshtasticPkiResyncState class MeshtasticRuntime final : public IProtocolRuntime { public: - ProtocolEffects prepareOutgoing(const ProtocolIntent&, - const RuntimeContext&) override + ProtocolEffects prepareOutgoing(const ProtocolIntent& intent, + const RuntimeContext& context) override { - return ProtocolEffects{}; + ProtocolEffects effects{}; + std::visit( + [&effects, &context](const auto& item) + { + using Intent = std::decay_t; + if constexpr (std::is_same_v) + { + resolveTraceRoute(item, context, effects); + } + else if constexpr (std::is_same_v) + { + resolveExchangePosition(item, context, effects); + } + }, + intent); + return effects; } ProtocolEffects handleIncoming(const IncomingPacket&, @@ -115,6 +133,113 @@ class MeshtasticRuntime final : public IProtocolRuntime } private: + static constexpr uint32_t kTraceRouteRequestSalt = 0x4D545254UL; + static constexpr uint32_t kPositionRequestSalt = 0x4D54504FUL; + + static NodeId normalizePeer(NodeId peer) + { + return peer == 0xFFFFFFFFUL ? 0 : peer; + } + + static MessageId makeRequestId(MessageId requested, + NodeId peer, + const RuntimeContext& context, + uint32_t salt) + { + if (requested != 0) + { + return requested; + } + MessageId id = context.now_ms ^ context.self_node ^ peer ^ salt; + return id == 0 ? 1 : id; + } + + static EmitActionResultEffect buildFailedAction(ProtocolActionKind action, + NodeId peer, + MessageId request_id, + int32_t detail) + { + EmitActionResultEffect failed{}; + failed.protocol = MeshProtocol::Meshtastic; + failed.action = action; + failed.state = ProtocolActionState::Failed; + failed.peer = peer; + failed.request_id = request_id; + failed.detail = detail; + return failed; + } + + static void resolveTraceRoute(const TraceRouteIntent& intent, + const RuntimeContext& context, + ProtocolEffects& effects) + { + const NodeId peer = normalizePeer(intent.peer); + const MessageId request_id = makeRequestId(intent.request_id, + peer, + context, + kTraceRouteRequestSalt); + if (peer == 0 || peer == context.self_node) + { + effects.add(buildFailedAction(ProtocolActionKind::TraceRoute, + peer, + request_id, + -1)); + return; + } + + meshtastic_RouteDiscovery route = meshtastic_RouteDiscovery_init_zero; + uint8_t route_buf[96] = {}; + pb_ostream_t stream = pb_ostream_from_buffer(route_buf, sizeof(route_buf)); + if (!pb_encode(&stream, meshtastic_RouteDiscovery_fields, &route)) + { + effects.add(buildFailedAction(ProtocolActionKind::TraceRoute, + peer, + request_id, + -2)); + return; + } + + SendPacketEffect packet{}; + packet.protocol = MeshProtocol::Meshtastic; + packet.channel = intent.channel; + packet.dest = peer; + packet.portnum = meshtastic_PortNum_TRACEROUTE_APP; + packet.request_id = request_id; + packet.want_ack = true; + packet.want_response = true; + packet.payload.assign(route_buf, route_buf + stream.bytes_written); + effects.add(std::move(packet)); + } + + static void resolveExchangePosition(const ExchangePositionIntent& intent, + const RuntimeContext& context, + ProtocolEffects& effects) + { + const NodeId peer = normalizePeer(intent.peer); + const MessageId request_id = makeRequestId(intent.request_id, + peer, + context, + kPositionRequestSalt); + if (peer == 0 || peer == context.self_node) + { + effects.add(buildFailedAction(ProtocolActionKind::ExchangePosition, + peer, + request_id, + -1)); + return; + } + + SendPacketEffect packet{}; + packet.protocol = MeshProtocol::Meshtastic; + packet.channel = intent.channel; + packet.dest = peer; + packet.portnum = meshtastic_PortNum_POSITION_APP; + packet.request_id = request_id; + packet.want_ack = false; + packet.want_response = true; + effects.add(std::move(packet)); + } + MeshtasticPkiResyncState pki_resync_{}; }; diff --git a/modules/core_chat/include/chat/runtime/protocol_runtime.h b/modules/core_chat/include/chat/runtime/protocol_runtime.h index 4e3043c8..b8e8af85 100644 --- a/modules/core_chat/include/chat/runtime/protocol_runtime.h +++ b/modules/core_chat/include/chat/runtime/protocol_runtime.h @@ -55,6 +55,7 @@ struct RequestNodeInfoIntent struct TraceRouteIntent { + ChannelId channel = ChannelId::PRIMARY; NodeId peer = 0; MessageId request_id = 0; uint32_t auth = 0; @@ -64,7 +65,9 @@ struct TraceRouteIntent struct ExchangePositionIntent { + ChannelId channel = ChannelId::PRIMARY; NodeId peer = 0; + MessageId request_id = 0; }; struct DiscoverIntent diff --git a/modules/core_chat/tests/test_meshtastic_runtime.cpp b/modules/core_chat/tests/test_meshtastic_runtime.cpp index 134a6f7e..bc207f49 100644 --- a/modules/core_chat/tests/test_meshtastic_runtime.cpp +++ b/modules/core_chat/tests/test_meshtastic_runtime.cpp @@ -1,5 +1,7 @@ #include "chat/runtime/meshtastic_runtime.h" +#include "pb_decode.h" + #include namespace @@ -18,14 +20,86 @@ int main() { using chat::ChannelId; using chat::MeshProtocol; + using chat::runtime::EmitActionResultEffect; + using chat::runtime::ExchangePositionIntent; using chat::runtime::ForgetPeerKeyEffect; using chat::runtime::MeshtasticPkiResyncCause; using chat::runtime::MeshtasticPkiResyncInput; using chat::runtime::MeshtasticRuntime; + using chat::runtime::ProtocolActionKind; + using chat::runtime::ProtocolActionState; + using chat::runtime::RuntimeContext; using chat::runtime::SendNodeInfoEffect; + using chat::runtime::SendPacketEffect; using chat::runtime::SendRoutingErrorEffect; + using chat::runtime::TraceRouteIntent; MeshtasticRuntime runtime; + RuntimeContext context{}; + context.protocol = MeshProtocol::Meshtastic; + context.self_node = 0x11111111UL; + context.now_ms = 0x20240614UL; + + { + TraceRouteIntent intent{}; + intent.channel = ChannelId::SECONDARY; + intent.peer = 0x22222222UL; + intent.request_id = 0x01020304UL; + intent.timeout_ms = 9000; + + const auto effects = runtime.prepareOutgoing(intent, context); + assert(effects.items.size() == 1); + const auto* packet = effectAt(effects, 0); + assert(packet); + assert(packet->protocol == MeshProtocol::Meshtastic); + assert(packet->channel == intent.channel); + assert(packet->dest == intent.peer); + assert(packet->portnum == meshtastic_PortNum_TRACEROUTE_APP); + assert(packet->request_id == intent.request_id); + assert(packet->want_ack); + assert(packet->want_response); + + meshtastic_RouteDiscovery decoded = meshtastic_RouteDiscovery_init_zero; + pb_istream_t stream = pb_istream_from_buffer(packet->payload.data(), + packet->payload.size()); + assert(pb_decode(&stream, meshtastic_RouteDiscovery_fields, &decoded)); + } + + { + ExchangePositionIntent intent{}; + intent.channel = ChannelId::PRIMARY; + intent.peer = 0x33333333UL; + intent.request_id = 0x05060708UL; + + const auto effects = runtime.prepareOutgoing(intent, context); + assert(effects.items.size() == 1); + const auto* packet = effectAt(effects, 0); + assert(packet); + assert(packet->protocol == MeshProtocol::Meshtastic); + assert(packet->channel == intent.channel); + assert(packet->dest == intent.peer); + assert(packet->portnum == meshtastic_PortNum_POSITION_APP); + assert(packet->request_id == intent.request_id); + assert(!packet->want_ack); + assert(packet->want_response); + assert(packet->payload.empty()); + } + + { + TraceRouteIntent intent{}; + intent.peer = context.self_node; + intent.request_id = 0x0A0B0C0DUL; + + const auto effects = runtime.prepareOutgoing(intent, context); + assert(effects.items.size() == 1); + const auto* failed = effectAt(effects, 0); + assert(failed); + assert(failed->protocol == MeshProtocol::Meshtastic); + assert(failed->action == ProtocolActionKind::TraceRoute); + assert(failed->state == ProtocolActionState::Failed); + assert(failed->peer == context.self_node); + assert(failed->request_id == intent.request_id); + } { MeshtasticPkiResyncInput input{}; diff --git a/modules/ui_mono/src/runtime.cpp b/modules/ui_mono/src/runtime.cpp index d07d1f73..6dfd3e7d 100644 --- a/modules/ui_mono/src/runtime.cpp +++ b/modules/ui_mono/src/runtime.cpp @@ -6,12 +6,11 @@ #include "chat/infra/meshcore/mc_region_presets.h" #include "chat/infra/meshtastic/mt_protocol_helpers.h" #include "chat/infra/meshtastic/mt_region.h" +#include "chat/runtime/meshtastic_runtime.h" #include "chat/runtime/self_identity_policy.h" #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "generated/meshtastic/mesh.pb.h" -#include "generated/meshtastic/portnums.pb.h" -#include "pb_encode.h" #include "platform/ui/screen_runtime.h" #include "ui/mono/assets/trailmate_sleep_logo.h" #include "ui/mono/screens/screensaver_layout.h" @@ -176,6 +175,33 @@ uint16_t normalizedMeshtasticChannelNum(uint16_t channel_num) return std::min(channel_num, kMeshtasticChannelNumMax); } +const chat::runtime::SendPacketEffect* firstSendPacketEffect( + const chat::runtime::ProtocolEffects& effects) +{ + for (const auto& effect : effects.items) + { + if (const auto* packet = std::get_if(&effect)) + { + return packet; + } + } + return nullptr; +} + +bool executeSendPacketEffect(chat::IMeshAdapter& mesh, + const chat::runtime::SendPacketEffect& packet) +{ + const uint8_t* payload = packet.payload.empty() ? nullptr : packet.payload.data(); + return mesh.sendAppData(packet.channel, + packet.portnum, + payload, + packet.payload.size(), + packet.dest, + packet.want_ack, + packet.request_id, + packet.want_response); +} + void sanitizeMeshtasticChannelNum(app::AppConfig& cfg) { cfg.meshtastic_config.channel_num = normalizedMeshtasticChannelNum(cfg.meshtastic_config.channel_num); @@ -7388,25 +7414,21 @@ void Runtime::executeNodeAction() showTransientPopup("TRACE ROUTE", "UNAVAILABLE"); return; } - meshtastic_RouteDiscovery route = meshtastic_RouteDiscovery_init_zero; - uint8_t route_buf[96] = {}; - pb_ostream_t stream = pb_ostream_from_buffer(route_buf, sizeof(route_buf)); - if (!pb_encode(&stream, meshtastic_RouteDiscovery_fields, &route)) - { - appendBootLog("trace encode err"); - showTransientPopup("TRACE ROUTE", "ENCODE FAILED"); - return; - } + chat::runtime::MeshtasticRuntime protocol_runtime{}; + chat::runtime::RuntimeContext context{}; + context.protocol = chat::MeshProtocol::Meshtastic; + context.self_node = mesh->getNodeId(); + context.now_ms = nowMs(); - const chat::MessageId request_id = nextMeshtasticActionRequestId(node->node_id); - const bool ok = mesh->sendAppData(chat::ChannelId::PRIMARY, - meshtastic_PortNum_TRACEROUTE_APP, - route_buf, - stream.bytes_written, - node->node_id, - true, - request_id, - true); + chat::runtime::TraceRouteIntent intent{}; + intent.channel = chat::ChannelId::PRIMARY; + intent.peer = node->node_id; + intent.request_id = nextMeshtasticActionRequestId(node->node_id); + + const auto effects = protocol_runtime.prepareOutgoing(intent, context); + const auto* packet = firstSendPacketEffect(effects); + const chat::MessageId request_id = packet ? packet->request_id : intent.request_id; + const bool ok = packet && executeSendPacketEffect(*mesh, *packet); if (ok) { meshtastic_action_runtime_.startTraceRoute(request_id, node->node_id, nowMs()); @@ -7458,15 +7480,21 @@ void Runtime::requestNodePositionExchange() return; } - const chat::MessageId request_id = nextMeshtasticActionRequestId(node->node_id); - const bool ok = mesh->sendAppData(chat::ChannelId::PRIMARY, - meshtastic_PortNum_POSITION_APP, - nullptr, - 0, - node->node_id, - false, - request_id, - true); + chat::runtime::MeshtasticRuntime protocol_runtime{}; + chat::runtime::RuntimeContext context{}; + context.protocol = chat::MeshProtocol::Meshtastic; + context.self_node = mesh->getNodeId(); + context.now_ms = nowMs(); + + chat::runtime::ExchangePositionIntent intent{}; + intent.channel = chat::ChannelId::PRIMARY; + intent.peer = node->node_id; + intent.request_id = nextMeshtasticActionRequestId(node->node_id); + + const auto effects = protocol_runtime.prepareOutgoing(intent, context); + const auto* packet = firstSendPacketEffect(effects); + const chat::MessageId request_id = packet ? packet->request_id : intent.request_id; + const bool ok = packet && executeSendPacketEffect(*mesh, *packet); if (ok) { meshtastic_action_runtime_.startPositionExchange(request_id, node->node_id, nowMs());