diff --git a/cmake/Portduino.cmake b/cmake/Portduino.cmake index befd1e50..c7b73264 100644 --- a/cmake/Portduino.cmake +++ b/cmake/Portduino.cmake @@ -6,6 +6,8 @@ FetchContent_Declare( GIT_TAG "ce0ed7e9c62f3082b30ee9a5c369f30c1e55194a" ) FetchContent_MakeAvailable(Portduino) +target_sources(Portduino PRIVATE ${portduino_SOURCE_DIR}/cores/portduino/PortduinoGPIO.cpp + ${portduino_SOURCE_DIR}/cores/portduino/logging.cpp) include_directories(${portduino_SOURCE_DIR}/cores/portduino) include_directories(${portduino_SOURCE_DIR}/cores/portduino/FS) include_directories(${portduino_SOURCE_DIR}/cores/arduino) diff --git a/cmake/protobuf.cmake b/cmake/protobuf.cmake index 86237314..933791cd 100644 --- a/cmake/protobuf.cmake +++ b/cmake/protobuf.cmake @@ -2,6 +2,6 @@ message(STATUS "Fetching protobufs ...") FetchContent_Declare( Protobuf GIT_REPOSITORY "https://github.com/meshtastic/protobufs" - GIT_TAG v2.7.8 + GIT_TAG v2.7.26 ) FetchContent_MakeAvailable(Protobuf) diff --git a/include/graphics/common/MessageStatus.h b/include/graphics/common/MessageStatus.h new file mode 100644 index 00000000..15bc750c --- /dev/null +++ b/include/graphics/common/MessageStatus.h @@ -0,0 +1,53 @@ +#pragma once + +#include "util/LogMessage.h" +#include + +namespace MessageStatus +{ +enum class State { + Sending, + ChannelImplicitAck, + DirectImplicitAck, + ExplicitAck, + NoAck, + NoChannel, + GenericEncryptedSendFailure, + RecipientKeyUnavailable, + RecipientNeedsSenderKey, + MessageTooLarge, + NoRadioInterface, + DutyCycleLimit, + RateLimited, + NoAppResponse, + InvalidRequest, + NotAuthorized, + AdminSessionExpired, + AdminKeyNotAuthorized, +}; + +enum class Tone { + Pending, + Success, + Warning, + RetryableFailure, + PermanentFailure, +}; + +struct Presentation { + const char *text; + const char *detail; + Tone tone; + bool retryable; +}; + +const Presentation &presentation(State state); +State deliveredState(bool channelMessage, bool explicitAck); +bool isImplicitDelivery(State state); +State preserveImplicitDelivery(std::optional pendingStatus, State fallbackStatus); +LogMessage::MsgStatus logStatusForState(State state); +uint32_t persistedLogState(State state); +std::optional stateFromPersistedLogState(uint32_t state); +std::optional inlineStateForLogStatus(LogMessage::MsgStatus status, bool livePending); +std::optional inlineStateForLogStatus(LogMessage::MsgStatus status, uint32_t persistedState, bool livePending); +} // namespace MessageStatus diff --git a/include/graphics/common/ResponseHandler.h b/include/graphics/common/ResponseHandler.h index b4a2c9bf..ebf2e851 100644 --- a/include/graphics/common/ResponseHandler.h +++ b/include/graphics/common/ResponseHandler.h @@ -20,6 +20,7 @@ class ResponseHandler using Callback = std::function; struct Request { + uint32_t requestId; uint32_t id; unsigned long timestamp; enum RequestType type; @@ -52,4 +53,4 @@ class ResponseHandler private: ResponseHandler(const ResponseHandler &) = delete; ResponseHandler &operator=(const ResponseHandler &) = delete; -}; \ No newline at end of file +}; diff --git a/include/graphics/common/ViewController.h b/include/graphics/common/ViewController.h index aa8209ae..2634dd45 100644 --- a/include/graphics/common/ViewController.h +++ b/include/graphics/common/ViewController.h @@ -2,9 +2,15 @@ #include "comms/IClientBase.h" #include "util/LogRotate.h" +#include #include +#include class MeshtasticView; +namespace MessageStatus +{ +enum class State; +} class ViewController { @@ -66,6 +72,8 @@ class ViewController virtual bool sendConfig(const char ringtone[231], uint32_t nodeId = 0); virtual void sendTextMessage(uint32_t to, uint8_t ch, uint8_t hopLimit, uint32_t msgTime, uint32_t requestId, bool usePkc, const char *textmsg); + virtual bool updateTextMessageStatus(uint32_t requestId, MessageStatus::State status, bool finalStatus); + virtual std::optional pendingTextMessageStatus(uint32_t requestId) const; virtual void removeTextMessages(uint32_t from, uint32_t to, uint8_t ch); virtual bool requestPosition(uint32_t to, uint8_t ch, uint32_t requestId); virtual void traceRoute(uint32_t to, uint8_t ch, uint8_t hopLimit, uint32_t requestId); @@ -105,6 +113,13 @@ class ViewController MeshtasticView *view; LogRotate log; IClientBase *client; + + struct PendingTextMessage { + LogRotate::EntryPosition logPosition; + std::optional status; + }; + std::unordered_map pendingTextMessages; + uint32_t sendId; uint32_t myNodeNum; time_t lastrun1; diff --git a/include/graphics/view/TFT/TFTView_320x240.h b/include/graphics/view/TFT/TFTView_320x240.h index c2787374..4362a4bc 100644 --- a/include/graphics/view/TFT/TFTView_320x240.h +++ b/include/graphics/view/TFT/TFTView_320x240.h @@ -1,6 +1,7 @@ #pragma once #include "graphics/common/MeshtasticView.h" +#include "graphics/common/MessageStatus.h" #include "meshtastic/clientonly.pb.h" #include @@ -159,7 +160,8 @@ class TFTView_320x240 : public MeshtasticView // own chat message virtual void handleAddMessage(char *msg); // add own message to current chat - virtual void addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t requestId, char *msg, LogMessage::MsgStatus status); + virtual void addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t requestId, char *msg, LogMessage::MsgStatus status, + uint32_t persistedStatus = 0); // add new message to container virtual void newMessage(uint32_t nodeNum, lv_obj_t *container, uint8_t channel, const char *msg); // create empty message container for node or group channel @@ -168,8 +170,9 @@ class TFTView_320x240 : public MeshtasticView virtual bool applyNodesFilter(uint32_t nodeNum, bool reset = false); // display message alert popup virtual void messageAlert(const char *alert, bool show); - // mark sent message as received - virtual void handleTextMessageResponse(uint32_t channelOrNode, uint32_t id, bool ack, bool err); + // mark sent message with its delivery status + virtual void handleTextMessageResponse(uint32_t channelOrNode, uint32_t id, MessageStatus::State status, + bool finalStatus = true); // set node image based on role virtual void setNodeImage(uint32_t nodeNum, eRole role, bool unmessagable, lv_obj_t *img); // apply filter and count number of filtered nodes @@ -285,6 +288,11 @@ class TFTView_320x240 : public MeshtasticView static void ui_event_ChatButton(lv_event_t *e); static void ui_event_ChatDelButton(lv_event_t *e); static void ui_event_MsgPopupButton(lv_event_t *e); + static void ui_event_MessageStatus(lv_event_t *e); + static void ui_event_MessageStatusClose(lv_event_t *e); + static void ui_event_MessageStatusRetry(lv_event_t *e); + static void ui_event_MessageStatusDialogDelete(lv_event_t *e); + static void ui_event_SentMessageDelete(lv_event_t *e); static void ui_event_MsgRestoreButton(lv_event_t *e); static void ui_event_AlertButton(lv_event_t *e); @@ -444,4 +452,4 @@ class TFTView_320x240 : public MeshtasticView }; meshtastic_DeviceProfile_full db{}; // full copy of the node's configuration db (except nodeinfos) plus ui data -}; \ No newline at end of file +}; diff --git a/include/util/LogMessage.h b/include/util/LogMessage.h index 84e3b382..c019225c 100644 --- a/include/util/LogMessage.h +++ b/include/util/LogMessage.h @@ -2,10 +2,17 @@ #include "ILogEntry.h" #include +#include #include #include constexpr uint32_t messagePayloadSize = 233; +constexpr uint32_t maxLogMessagePayloadLength = messagePayloadSize - 1; + +constexpr bool fitsLogMessagePayload(size_t len) +{ + return len <= maxLogMessagePayloadLength; +} /** * @brief Header for storing message logs containing the actual size of the payload @@ -42,7 +49,7 @@ class LogMessageEnv : public LogMessage LogMessageEnv(uint32_t _from, uint32_t _to, uint16_t _ch, time_t _time, MsgStatus _status, bool _trashFlag, uint32_t _len, const uint8_t *msg) { - assert(_len < messagePayloadSize); + assert(fitsLogMessagePayload(_len)); _size = (uint16_t)_len; time = _time; from = _from; @@ -64,7 +71,7 @@ class LogMessageEnv : public LogMessage virtual size_t deserialize(std::function read) override { size_t len = read((uint8_t *)&_size, sizeof(LogMessageHeader) - 8); - if (len && _size < messagePayloadSize) { + if (len && fitsLogMessagePayload(_size)) { len += read(bytes, _size); bytes[_size] = 0; } else { @@ -73,4 +80,4 @@ class LogMessageEnv : public LogMessage } return len; } -}; \ No newline at end of file +}; diff --git a/include/util/LogRotate.h b/include/util/LogRotate.h index e01ffe6c..cc8ccdc1 100644 --- a/include/util/LogRotate.h +++ b/include/util/LogRotate.h @@ -2,6 +2,7 @@ #include "FS.h" #include "ILogEntry.h" +#include #include /** @@ -21,6 +22,11 @@ class LogRotate { public: + struct EntryPosition { + uint32_t logNum = 0; + uint32_t offset = 0; + }; + LogRotate(fs::FS &fs, const char *logDir, uint32_t maxLen, uint32_t maxSize = 102400, uint32_t maxFiles = 25, uint32_t maxFileSize = 4000); // uint32_t maxSize = 4096, uint32_t maxFiles = 10, uint32_t maxFileSize = 400); @@ -29,6 +35,9 @@ class LogRotate void init(void); // write a log entry to fs bool write(const ILogEntry &entry); + bool write(const ILogEntry &entry, EntryPosition *position); + // update a previously written log entry in place + bool update(const EntryPosition &position, ILogEntry &entry, std::function update); // read the next log entry from fs bool readNext(ILogEntry &entry); // remove all logs from fs @@ -69,4 +78,4 @@ class LogRotate uint32_t currentLogWrite; // current log number (when writing) uint32_t currentSize; // size of current written log file uint32_t totalSize; // size of all logs -}; \ No newline at end of file +}; diff --git a/source/comms/serial/SerialClient.cpp b/source/comms/serial/SerialClient.cpp index b28f859b..9dcb79f8 100644 --- a/source/comms/serial/SerialClient.cpp +++ b/source/comms/serial/SerialClient.cpp @@ -42,7 +42,7 @@ void SerialClient::init(void) #elif defined(ARCH_PORTDUINO) new std::thread([] { #ifdef __APPLE__ - pthread_setname_np(threadName); + pthread_setname_np(instance->threadName); #else pthread_setname_np(pthread_self(), instance->threadName); #endif diff --git a/source/graphics/TFT/TFTView_320x240.cpp b/source/graphics/TFT/TFTView_320x240.cpp index 72f662e8..19698f3b 100644 --- a/source/graphics/TFT/TFTView_320x240.cpp +++ b/source/graphics/TFT/TFTView_320x240.cpp @@ -4,6 +4,7 @@ #include "Arduino.h" #include "graphics/common/BatteryLevel.h" #include "graphics/common/LoRaPresets.h" +#include "graphics/common/MessageStatus.h" #include "graphics/common/Ringtones.h" #include "graphics/common/ViewController.h" #include "graphics/driver/DisplayDriver.h" @@ -23,12 +24,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #if defined(ARCH_PORTDUINO) @@ -84,6 +87,38 @@ constexpr lv_color_t colorMidGray = LV_COLOR_HEX(0x808080); constexpr lv_color_t colorDarkGray = LV_COLOR_HEX(0x303030); constexpr lv_color_t colorMesh = LV_COLOR_HEX(0x67ea94); +static lv_color_t messageStatusColor(MessageStatus::Tone tone) +{ + switch (tone) { + case MessageStatus::Tone::Pending: + return colorOrange; + case MessageStatus::Tone::Success: + return colorBlueGreen; + case MessageStatus::Tone::Warning: + case MessageStatus::Tone::RetryableFailure: + return colorOrange; + case MessageStatus::Tone::PermanentFailure: + return colorRed; + } + return colorLightGray; +} + +struct SentMessageContext { + uint32_t requestId; + MessageStatus::State status; + std::string text; +}; + +struct MessageStatusDialogContext { + MessageStatus::State status; + std::string text; +}; + +static bool hasMessageStatusDetails(const MessageStatus::Presentation &status) +{ + return status.detail != nullptr && status.detail[0] != '\0'; +} + // children index of nodepanel lv objects (see addNode) enum NodePanelIdx { node_img_idx, @@ -1282,6 +1317,139 @@ void TFTView_320x240::ui_event_MsgPopupButton(lv_event_t *e) } } +void TFTView_320x240::ui_event_MessageStatus(lv_event_t *e) +{ + if (lv_event_get_code(e) != LV_EVENT_CLICKED) + return; + + auto *message = static_cast(lv_event_get_user_data(e)); + if (message == nullptr) + return; + + const MessageStatus::Presentation &status = MessageStatus::presentation(message->status); + if (!hasMessageStatusDetails(status)) + return; + + if (THIS->activeWidget != nullptr) { + lv_obj_delete(THIS->activeWidget); + THIS->activeWidget = nullptr; + } + + auto *dialog = new MessageStatusDialogContext{message->status, message->text}; + + lv_obj_t *panel = lv_obj_create(objects.main_screen); + THIS->activeWidget = panel; + lv_obj_set_pos(panel, 20, 32); + lv_obj_set_size(panel, 280, 170); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_layout(panel, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_left(panel, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(panel, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(panel, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(panel, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(panel, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_color(panel, colorDarkGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_color(panel, messageStatusColor(status.tone), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(panel, 2, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(panel, 7, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_event_cb(panel, ui_event_MessageStatusDialogDelete, LV_EVENT_DELETE, dialog); + + lv_obj_t *title = lv_label_create(panel); + lv_obj_set_width(title, LV_PCT(100)); + lv_label_set_long_mode(title, LV_LABEL_LONG_WRAP); + lv_label_set_text(title, _(status.text)); + lv_obj_set_style_text_color(title, messageStatusColor(status.tone), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(title, &ui_font_montserrat_14, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *detail = lv_label_create(panel); + lv_obj_set_width(detail, LV_PCT(100)); + lv_obj_set_height(detail, 64); + lv_label_set_long_mode(detail, LV_LABEL_LONG_WRAP); + lv_label_set_text(detail, _(status.detail)); + lv_obj_set_style_text_color(detail, colorLightGray, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(detail, &ui_font_montserrat_12, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(detail, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + + lv_obj_t *actions = lv_obj_create(panel); + lv_obj_remove_style_all(actions); + lv_obj_set_width(actions, LV_PCT(100)); + lv_obj_set_height(actions, 36); + lv_obj_set_layout(actions, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(actions, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(actions, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(actions, 8, LV_PART_MAIN | LV_STATE_DEFAULT); + + if (status.retryable) { + lv_obj_t *retry = lv_btn_create(actions); + lv_obj_set_size(retry, 88, 32); + lv_obj_set_style_bg_color(retry, colorBlue, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_shadow_width(retry, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(retry, 7, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_event_cb(retry, ui_event_MessageStatusRetry, LV_EVENT_CLICKED, dialog); + + lv_obj_t *retryLabel = lv_label_create(retry); + lv_label_set_text(retryLabel, _("Retry")); + lv_obj_center(retryLabel); + } + + lv_obj_t *close = lv_btn_create(actions); + lv_obj_set_size(close, 88, 32); + lv_obj_set_style_bg_color(close, colorBlue, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_shadow_width(close, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(close, 7, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_event_cb(close, ui_event_MessageStatusClose, LV_EVENT_CLICKED, nullptr); + + lv_obj_t *closeLabel = lv_label_create(close); + lv_label_set_text(closeLabel, _("Close")); + lv_obj_center(closeLabel); +} + +void TFTView_320x240::ui_event_MessageStatusClose(lv_event_t *e) +{ + if (lv_event_get_code(e) != LV_EVENT_CLICKED) + return; + + if (THIS->activeWidget != nullptr) { + lv_obj_delete(THIS->activeWidget); + THIS->activeWidget = nullptr; + } +} + +void TFTView_320x240::ui_event_MessageStatusRetry(lv_event_t *e) +{ + if (lv_event_get_code(e) != LV_EVENT_CLICKED) + return; + + auto *dialog = static_cast(lv_event_get_user_data(e)); + if (dialog == nullptr) + return; + + std::string retryText = dialog->text; + if (THIS->activeWidget != nullptr) { + lv_obj_delete(THIS->activeWidget); + THIS->activeWidget = nullptr; + } + + char buf[maxLogMessagePayloadLength + 1]; + snprintf(buf, sizeof(buf), "%s", retryText.c_str()); + THIS->handleAddMessage(buf); +} + +void TFTView_320x240::ui_event_MessageStatusDialogDelete(lv_event_t *e) +{ + if (lv_event_get_target_obj(e) == THIS->activeWidget) + THIS->activeWidget = nullptr; + delete static_cast(lv_event_get_user_data(e)); +} + +void TFTView_320x240::ui_event_SentMessageDelete(lv_event_t *e) +{ + delete static_cast(lv_event_get_user_data(e)); +} + /** * @brief hide msgRestorePanel on touch * @@ -4456,22 +4624,16 @@ void TFTView_320x240::handleAddMessage(char *msg) uint32_t to = UINT32_MAX; uint8_t ch = 0; uint8_t hopLimit = db.config.lora.hop_limit; - uint32_t requestId; + uint32_t requestId = 0; uint32_t channelOrNode = (unsigned long)activeMsgContainer->user_data; bool usePkc = false; - auto callback = [this](const ResponseHandler::Request &req, ResponseHandler::EventType evt, int32_t pass) { - this->onTextMessageCallback(req, evt, pass); - }; - if (channelOrNode < c_max_channels) { ch = (uint8_t)channelOrNode; - requestId = requests.addRequest(ch, ResponseHandler::TextMessageRequest, (void *)(long)ch, callback); } else { ch = (uint8_t)(unsigned long)nodes[channelOrNode]->user_data; to = channelOrNode; usePkc = (unsigned long)nodes[to]->LV_OBJ_IDX(node_bat_idx)->user_data; // hasKey - requestId = requests.addRequest(to, ResponseHandler::TextMessageRequest, (void *)to, callback); // trial: hoplimit optimization for direct text messages int8_t hopsAway = (signed long)nodes[to]->LV_OBJ_IDX(node_sig_idx)->user_data; if (hopsAway < 0) @@ -4480,10 +4642,28 @@ void TFTView_320x240::handleAddMessage(char *msg) } // tweak to allow multiple lines in single line text area - for (int i = 0; i < strlen(msg); i++) + const size_t msgLen = strlen(msg); + for (size_t i = 0; i < msgLen; i++) if (msg[i] == CR_REPLACEMENT) msg[i] = '\n'; + if (!fitsLogMessagePayload(msgLen)) { + char displayMsg[maxLogMessagePayloadLength + 1]; + const size_t displayLen = std::min(msgLen, static_cast(maxLogMessagePayloadLength)); + memcpy(displayMsg, msg, displayLen); + displayMsg[displayLen] = '\0'; + addMessage(activeMsgContainer, actTime, 0, displayMsg, LogMessage::eFailed, + MessageStatus::persistedLogState(MessageStatus::State::MessageTooLarge)); + messageAlert(_(MessageStatus::presentation(MessageStatus::State::MessageTooLarge).text), true); + return; + } + + auto callback = [this](const ResponseHandler::Request &req, ResponseHandler::EventType evt, int32_t pass) { + this->onTextMessageCallback(req, evt, pass); + }; + + requestId = requests.addRequest(channelOrNode < c_max_channels ? ch : to, ResponseHandler::TextMessageRequest, + (void *)(long)channelOrNode, callback); controller->sendTextMessage(to, ch, hopLimit, actTime, requestId, usePkc, msg); addMessage(activeMsgContainer, actTime, requestId, msg, LogMessage::eNone); } @@ -4492,13 +4672,16 @@ void TFTView_320x240::handleAddMessage(char *msg) * display message that has just been written and sent out */ void TFTView_320x240::addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t requestId, char *msg, - LogMessage::MsgStatus status) + LogMessage::MsgStatus status, uint32_t persistedStatus) { lv_obj_t *hiddenPanel = lv_obj_create(container); lv_obj_set_width(hiddenPanel, lv_pct(100)); lv_obj_set_height(hiddenPanel, LV_SIZE_CONTENT); lv_obj_set_align(hiddenPanel, LV_ALIGN_CENTER); lv_obj_clear_flag(hiddenPanel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_layout(hiddenPanel, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(hiddenPanel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(hiddenPanel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); lv_obj_set_style_radius(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); add_style_panel_style(hiddenPanel); @@ -4507,8 +4690,14 @@ void TFTView_320x240::addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t lv_obj_set_style_pad_right(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_pad_top(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); lv_obj_set_style_pad_bottom(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(hiddenPanel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); hiddenPanel->user_data = (void *)requestId; + const auto messageState = MessageStatus::inlineStateForLogStatus(status, persistedStatus, requestId != 0); + auto *messageContext = + new SentMessageContext{requestId, messageState.value_or(MessageStatus::State::Sending), std::string(msg)}; + lv_obj_add_event_cb(hiddenPanel, ui_event_SentMessageDelete, LV_EVENT_DELETE, messageContext); + // add timestamp char buf[284]; // 237 + 4 + 40 + 2 + 1 buf[0] = '\0'; @@ -4525,22 +4714,49 @@ void TFTView_320x240::addMessage(lv_obj_t *container, uint32_t msgTime, uint32_t lv_label_set_text(textLabel, buf); add_style_chat_message_style(textLabel); + textLabel->user_data = messageContext; + lv_obj_add_flag(textLabel, lv_obj_flag_t(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_CLICK_FOCUSABLE)); + lv_obj_add_event_cb(textLabel, ui_event_MessageStatus, LV_EVENT_CLICKED, messageContext); + + if (messageState.has_value()) { + lv_obj_t *statusLabel = lv_label_create(hiddenPanel); + lv_obj_set_width(statusLabel, 200); + lv_label_set_long_mode(statusLabel, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(statusLabel, LV_TEXT_ALIGN_RIGHT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_font(statusLabel, &lv_font_montserrat_10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(statusLabel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(statusLabel, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + statusLabel->user_data = messageContext; + lv_obj_add_flag(statusLabel, lv_obj_flag_t(LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_CLICK_FOCUSABLE)); + lv_obj_add_event_cb(statusLabel, ui_event_MessageStatus, LV_EVENT_CLICKED, messageContext); + const MessageStatus::Presentation &messageStatus = MessageStatus::presentation(*messageState); + lv_label_set_text(statusLabel, _(messageStatus.text)); + lv_obj_set_style_text_color(statusLabel, messageStatusColor(messageStatus.tone), LV_PART_MAIN | LV_STATE_DEFAULT); + } lv_obj_scroll_to_view(hiddenPanel, LV_ANIM_ON); lv_obj_move_foreground(objects.message_input_area); - switch (status) { - case LogMessage::eHeard: - lv_obj_set_style_border_color(textLabel, colorYellow, LV_PART_MAIN | LV_STATE_DEFAULT); - break; - case LogMessage::eAcked: - lv_obj_set_style_border_color(textLabel, colorBlueGreen, LV_PART_MAIN | LV_STATE_DEFAULT); - break; - case LogMessage::eFailed: - lv_obj_set_style_border_color(textLabel, colorRed, LV_PART_MAIN | LV_STATE_DEFAULT); - break; - default: - break; + if (const auto restoredState = MessageStatus::stateFromPersistedLogState(persistedStatus)) { + lv_obj_set_style_border_color(textLabel, messageStatusColor(MessageStatus::presentation(*restoredState).tone), + LV_PART_MAIN | LV_STATE_DEFAULT); + } else { + switch (status) { + case LogMessage::eHeard: + lv_obj_set_style_border_color(textLabel, messageStatusColor(MessageStatus::Tone::Warning), + LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case LogMessage::eAcked: + lv_obj_set_style_border_color(textLabel, messageStatusColor(MessageStatus::Tone::Success), + LV_PART_MAIN | LV_STATE_DEFAULT); + break; + case LogMessage::eFailed: + lv_obj_set_style_border_color(textLabel, messageStatusColor(MessageStatus::Tone::RetryableFailure), + LV_PART_MAIN | LV_STATE_DEFAULT); + break; + default: + break; + } } } @@ -5238,12 +5454,21 @@ void TFTView_320x240::updateConnectionStatus(const meshtastic_DeviceConnectionSt void TFTView_320x240::onTextMessageCallback(const ResponseHandler::Request &req, ResponseHandler::EventType evt, int32_t result) { ILOG_DEBUG("onTextMessageCallback: %d %d", evt, result); + const uint32_t requestId = req.requestId; + bool channelMessage = (unsigned long)req.cookie < c_max_channels; if (evt == ResponseHandler::found) { - handleTextMessageResponse((unsigned long)req.cookie, req.id, false, result); + handleTextMessageResponse((unsigned long)req.cookie, requestId, + result ? MessageStatus::State::NoAck : MessageStatus::deliveredState(channelMessage, false), + false); } else if (evt == ResponseHandler::removed) { - handleTextMessageResponse((unsigned long)req.cookie, req.id, true, result); + handleTextMessageResponse((unsigned long)req.cookie, requestId, + result ? MessageStatus::State::NoAck : MessageStatus::deliveredState(channelMessage, true), + true); } else { ILOG_DEBUG("onTextMessageCallback: timeout!"); + const MessageStatus::State status = + MessageStatus::preserveImplicitDelivery(controller->pendingTextMessageStatus(requestId), MessageStatus::State::NoAck); + handleTextMessageResponse((unsigned long)req.cookie, requestId, status, true); } } @@ -5278,27 +5503,75 @@ void TFTView_320x240::handleResponse(uint32_t from, const uint32_t id, const mes if (req.type == ResponseHandler::TraceRouteRequest) { handleTraceRouteResponse(routing); } else if (req.type == ResponseHandler::TextMessageRequest) { - handleTextMessageResponse((unsigned long)req.cookie, id, ack, false); + bool channelMessage = (unsigned long)req.cookie < c_max_channels; + handleTextMessageResponse((unsigned long)req.cookie, id, MessageStatus::deliveredState(channelMessage, ack), ack); } else if (req.type == ResponseHandler::PositionRequest) { handlePositionResponse(from, id, p.rx_rssi, p.rx_snr, p.hop_limit == p.hop_start); } } else if (routing.error_reason == meshtastic_Routing_Error_MAX_RETRANSMIT) { - ResponseHandler::Request req = requests.removeRequest(id); + if (!ack) + req = requests.removeRequest(id); if (req.type == ResponseHandler::TraceRouteRequest) { handleTraceRouteResponse(routing); } else if (req.type == ResponseHandler::TextMessageRequest) { - handleTextMessageResponse((unsigned long)req.cookie, id, ack, true); + const MessageStatus::State status = MessageStatus::preserveImplicitDelivery( + controller->pendingTextMessageStatus(id), MessageStatus::State::NoAck); + handleTextMessageResponse((unsigned long)req.cookie, id, status, true); } - } else if (routing.error_reason == meshtastic_Routing_Error_NO_RESPONSE) { - if (req.type == ResponseHandler::PositionRequest) { - handlePositionResponse(from, id, p.rx_rssi, p.rx_snr, p.hop_limit == p.hop_start); + } else if (routing.error_reason == meshtastic_Routing_Error_NO_RESPONSE && req.type == ResponseHandler::PositionRequest) { + handlePositionResponse(from, id, p.rx_rssi, p.rx_snr, p.hop_limit == p.hop_start); + } else if (routing.error_reason == meshtastic_Routing_Error_TOO_LARGE) { + if (!ack) + req = requests.removeRequest(id); + if (req.type == ResponseHandler::TextMessageRequest) { + handleTextMessageResponse((unsigned long)req.cookie, id, MessageStatus::State::MessageTooLarge, true); } } else if (routing.error_reason == meshtastic_Routing_Error_NO_CHANNEL || - routing.error_reason == meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY) { + routing.error_reason == meshtastic_Routing_Error_NO_INTERFACE || + routing.error_reason == meshtastic_Routing_Error_NO_RESPONSE || + routing.error_reason == meshtastic_Routing_Error_DUTY_CYCLE_LIMIT || + routing.error_reason == meshtastic_Routing_Error_RATE_LIMIT_EXCEEDED || + routing.error_reason == meshtastic_Routing_Error_BAD_REQUEST || + routing.error_reason == meshtastic_Routing_Error_NOT_AUTHORIZED || + routing.error_reason == meshtastic_Routing_Error_PKI_FAILED || + routing.error_reason == meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY || + routing.error_reason == meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY || + routing.error_reason == meshtastic_Routing_Error_ADMIN_BAD_SESSION_KEY || + routing.error_reason == meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED) { + if (!ack) + req = requests.removeRequest(id); if (req.type == ResponseHandler::TextMessageRequest) { - handleTextMessageResponse((unsigned long)req.cookie, id, ack, true); + MessageStatus::State status = MessageStatus::State::NoChannel; + if (routing.error_reason == meshtastic_Routing_Error_NO_INTERFACE) + status = MessageStatus::State::NoRadioInterface; + if (routing.error_reason == meshtastic_Routing_Error_NO_RESPONSE) + status = MessageStatus::State::NoAppResponse; + if (routing.error_reason == meshtastic_Routing_Error_DUTY_CYCLE_LIMIT) + status = MessageStatus::State::DutyCycleLimit; + if (routing.error_reason == meshtastic_Routing_Error_RATE_LIMIT_EXCEEDED) + status = MessageStatus::State::RateLimited; + if (routing.error_reason == meshtastic_Routing_Error_BAD_REQUEST) + status = MessageStatus::State::InvalidRequest; + if (routing.error_reason == meshtastic_Routing_Error_NOT_AUTHORIZED) + status = MessageStatus::State::NotAuthorized; + if (routing.error_reason == meshtastic_Routing_Error_PKI_FAILED) + status = MessageStatus::State::GenericEncryptedSendFailure; + if (routing.error_reason == meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY) + status = MessageStatus::State::RecipientKeyUnavailable; + if (routing.error_reason == meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY) + status = MessageStatus::State::RecipientNeedsSenderKey; + if (routing.error_reason == meshtastic_Routing_Error_ADMIN_BAD_SESSION_KEY) + status = MessageStatus::State::AdminSessionExpired; + if (routing.error_reason == meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED) + status = MessageStatus::State::AdminKeyNotAuthorized; + handleTextMessageResponse((unsigned long)req.cookie, id, status, true); // we probably have a wrong key; mark it as bad and don't use in future - if ((unsigned long)nodes[from]->LV_OBJ_IDX(node_bat_idx)->user_data == 1) { + const bool keyMismatch = routing.error_reason == meshtastic_Routing_Error_NO_CHANNEL || + routing.error_reason == meshtastic_Routing_Error_PKI_FAILED || + routing.error_reason == meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY || + routing.error_reason == meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY; + if (keyMismatch && nodes.find(from) != nodes.end() && + (unsigned long)nodes[from]->LV_OBJ_IDX(node_bat_idx)->user_data == 1) { ILOG_DEBUG("public key mismatch"); nodes[from]->LV_OBJ_IDX(node_bat_idx)->user_data = (void *)2; lv_obj_set_style_border_color(nodes[from]->LV_OBJ_IDX(node_img_idx), colorRed, @@ -5704,18 +5977,21 @@ void TFTView_320x240::messageAlert(const char *alert, bool show) } /** - * @brief mark the sent message as either heard or acknowledged or failed + * @brief mark the sent message with user-facing delivery status text * * @param channelOrNode * @param id - * @param ack + * @param status */ -void TFTView_320x240::handleTextMessageResponse(uint32_t channelOrNode, const uint32_t id, bool ack, bool err) +void TFTView_320x240::handleTextMessageResponse(uint32_t channelOrNode, const uint32_t id, MessageStatus::State status, + bool finalStatus) { + if (id != 0) + controller->updateTextMessageStatus(id, status, finalStatus); + lv_obj_t *msgContainer; if (channelOrNode < c_max_channels) { msgContainer = channelGroup[(uint8_t)channelOrNode]; - ack = true; // treat messages sent to group channel same as ack } else { msgContainer = messages[channelOrNode]; } @@ -5729,15 +6005,21 @@ void TFTView_320x240::handleTextMessageResponse(uint32_t channelOrNode, const ui lv_obj_t *panel = msgContainer->spec_attr->children[i]; uint32_t requestId = (unsigned long)panel->user_data; if (requestId == id) { + const MessageStatus::Presentation &messageStatus = MessageStatus::presentation(status); + // now give the textlabel border another color lv_obj_t *textLabel = panel->spec_attr->children[0]; - lv_obj_set_style_border_color(textLabel, - err ? colorRed - : ack ? colorBlueGreen - : colorYellow, - LV_PART_MAIN | LV_STATE_DEFAULT); + auto *messageContext = static_cast(textLabel->user_data); + if (messageContext != nullptr) + messageContext->status = status; + lv_obj_set_style_border_color(textLabel, messageStatusColor(messageStatus.tone), LV_PART_MAIN | LV_STATE_DEFAULT); + + if (panel->spec_attr->child_cnt > 1) { + lv_obj_t *statusLabel = panel->spec_attr->children[1]; + lv_label_set_text(statusLabel, _(messageStatus.text)); + lv_obj_set_style_text_color(statusLabel, messageStatusColor(messageStatus.tone), LV_PART_MAIN | LV_STATE_DEFAULT); + } - // store message break; } } @@ -6539,7 +6821,7 @@ void TFTView_320x240::restoreMessage(const LogMessage &msg) if (container) { if (container != activeMsgContainer) lv_obj_add_flag(container, LV_OBJ_FLAG_HIDDEN); - addMessage(container, msg.time, 0, (char *)msg.bytes, msg.status); + addMessage(container, msg.time, 0, (char *)msg.bytes, msg.status, msg.reserved); } } else if (nodes.find(msg.from) != nodes.end()) { if (msg.trashFlag && chats.find(msg.from) != chats.end()) { diff --git a/source/graphics/common/MessageStatus.cpp b/source/graphics/common/MessageStatus.cpp new file mode 100644 index 00000000..5f7a66be --- /dev/null +++ b/source/graphics/common/MessageStatus.cpp @@ -0,0 +1,195 @@ +#include "graphics/common/MessageStatus.h" + +namespace MessageStatus +{ +namespace +{ +constexpr Presentation sending{"Sending...", nullptr, Tone::Pending, false}; +constexpr Presentation channelImplicitAck{"Delivered to mesh", nullptr, Tone::Success, false}; +constexpr Presentation directImplicitAck{"Relayed, not confirmed by recipient", + "A node relayed this message, but the recipient has not confirmed it.", Tone::Warning, + true}; +constexpr Presentation explicitAck{"Delivered to recipient", nullptr, Tone::Success, false}; +constexpr Presentation noAck{"Failed to deliver to mesh", + "No node confirmed this message. Try again when you have better signal or more mesh coverage.", + Tone::RetryableFailure, true}; +constexpr Presentation noChannel{"Channel/key mismatch", + "The sender or recipient could not use a matching channel/key for this message.", + Tone::RetryableFailure, true}; +constexpr Presentation genericEncryptedSendFailure{"Could not send encrypted message", + "The radio could not send this as an encrypted message. Wait for key info " + "to sync, then try again.", + Tone::RetryableFailure, true}; +constexpr Presentation recipientKeyUnavailable{ + "Recipient key unavailable", + "Your node does not have the recipient's public key yet. Wait for node info to sync, then try again.", Tone::RetryableFailure, + true}; +constexpr Presentation recipientNeedsSenderKey{ + "Recipient needs your key", + "The recipient does not know your public key yet. Your node may share its info automatically; try again after it syncs.", + Tone::RetryableFailure, true}; +constexpr Presentation messageTooLarge{"Message is too large to send", "Shorten the message and send it again.", + Tone::PermanentFailure, false}; +constexpr Presentation noRadioInterface{"No radio interface", "No usable radio interface was available to transmit this message.", + Tone::RetryableFailure, true}; +constexpr Presentation dutyCycleLimit{"Duty cycle limit", "The radio hit its airtime duty-cycle limit. Try again later.", + Tone::RetryableFailure, true}; +constexpr Presentation rateLimited{"Rate limited", + "The app sent too quickly and the radio rate limited this request. Try again shortly.", + Tone::RetryableFailure, true}; +constexpr Presentation noAppResponse{"No app response", "The destination received the request, but no app or module responded.", + Tone::RetryableFailure, true}; +constexpr Presentation invalidRequest{"Invalid request", "The destination rejected this message request as invalid.", + Tone::RetryableFailure, true}; +constexpr Presentation notAuthorized{"Not authorized", "The destination refused this request.", Tone::RetryableFailure, true}; +constexpr Presentation adminSessionExpired{"Admin session expired", "The admin session expired. Try the request again.", + Tone::RetryableFailure, true}; +constexpr Presentation adminKeyNotAuthorized{"Admin key not authorized", "The destination refused this admin key.", + Tone::RetryableFailure, true}; +constexpr uint32_t persistedLogStateMagic = 0x4d535400; // "MST" + state byte +constexpr uint32_t persistedLogStateMask = 0xffffff00; +} // namespace + +const Presentation &presentation(State state) +{ + switch (state) { + case State::Sending: + return sending; + case State::ChannelImplicitAck: + return channelImplicitAck; + case State::DirectImplicitAck: + return directImplicitAck; + case State::ExplicitAck: + return explicitAck; + case State::NoAck: + return noAck; + case State::NoChannel: + return noChannel; + case State::GenericEncryptedSendFailure: + return genericEncryptedSendFailure; + case State::RecipientKeyUnavailable: + return recipientKeyUnavailable; + case State::RecipientNeedsSenderKey: + return recipientNeedsSenderKey; + case State::MessageTooLarge: + return messageTooLarge; + case State::NoRadioInterface: + return noRadioInterface; + case State::DutyCycleLimit: + return dutyCycleLimit; + case State::RateLimited: + return rateLimited; + case State::NoAppResponse: + return noAppResponse; + case State::InvalidRequest: + return invalidRequest; + case State::NotAuthorized: + return notAuthorized; + case State::AdminSessionExpired: + return adminSessionExpired; + case State::AdminKeyNotAuthorized: + return adminKeyNotAuthorized; + } + return noAck; +} + +State deliveredState(bool channelMessage, bool explicitAck) +{ + if (channelMessage) + return State::ChannelImplicitAck; + return explicitAck ? State::ExplicitAck : State::DirectImplicitAck; +} + +bool isImplicitDelivery(State state) +{ + return state == State::ChannelImplicitAck || state == State::DirectImplicitAck; +} + +State preserveImplicitDelivery(std::optional pendingStatus, State fallbackStatus) +{ + if (pendingStatus && isImplicitDelivery(*pendingStatus)) + return *pendingStatus; + return fallbackStatus; +} + +LogMessage::MsgStatus logStatusForState(State state) +{ + switch (state) { + case State::Sending: + return LogMessage::eNone; + case State::ChannelImplicitAck: + case State::DirectImplicitAck: + return LogMessage::eHeard; + case State::ExplicitAck: + return LogMessage::eAcked; + case State::NoAck: + return LogMessage::eNoResponse; + case State::NoChannel: + case State::GenericEncryptedSendFailure: + case State::RecipientKeyUnavailable: + case State::RecipientNeedsSenderKey: + case State::MessageTooLarge: + case State::NoRadioInterface: + case State::DutyCycleLimit: + case State::RateLimited: + case State::NoAppResponse: + case State::InvalidRequest: + case State::NotAuthorized: + case State::AdminSessionExpired: + case State::AdminKeyNotAuthorized: + return LogMessage::eFailed; + } + return LogMessage::eFailed; +} + +uint32_t persistedLogState(State state) +{ + return persistedLogStateMagic | (static_cast(state) + 1); +} + +std::optional stateFromPersistedLogState(uint32_t state) +{ + if ((state & persistedLogStateMask) != persistedLogStateMagic) + return std::nullopt; + + const uint32_t value = state & ~persistedLogStateMask; + if (value == 0 || value > static_cast(State::AdminKeyNotAuthorized) + 1) + return std::nullopt; + + return static_cast(value - 1); +} + +std::optional inlineStateForLogStatus(LogMessage::MsgStatus status, bool livePending) +{ + return inlineStateForLogStatus(status, 0, livePending); +} + +std::optional inlineStateForLogStatus(LogMessage::MsgStatus status, uint32_t persistedState, bool livePending) +{ + switch (status) { + case LogMessage::eNone: + return livePending ? std::optional(State::Sending) : std::nullopt; + case LogMessage::eHeard: + case LogMessage::eAcked: + case LogMessage::eNoResponse: + case LogMessage::eFailed: + if (const auto state = stateFromPersistedLogState(persistedState)) + return state; + break; + default: + break; + } + + switch (status) { + case LogMessage::eHeard: + return State::DirectImplicitAck; + case LogMessage::eAcked: + return State::ExplicitAck; + case LogMessage::eNoResponse: + case LogMessage::eFailed: + return State::NoAck; + default: + return std::nullopt; + } +} +} // namespace MessageStatus diff --git a/source/graphics/common/ResponseHandler.cpp b/source/graphics/common/ResponseHandler.cpp index 9cdc716c..3016ff7e 100644 --- a/source/graphics/common/ResponseHandler.cpp +++ b/source/graphics/common/ResponseHandler.cpp @@ -18,7 +18,8 @@ uint32_t ResponseHandler::addRequest(uint32_t id, RequestType type, void *cookie { requestIdCounter++; uint32_t requestId = generatePacketId(); - pendingRequest[requestId] = Request{.id = id, .timestamp = millis(), .type = type, .cookie = cookie, .cb = cb}; + pendingRequest[requestId] = + Request{.requestId = requestId, .id = id, .timestamp = millis(), .type = type, .cookie = cookie, .cb = cb}; return requestId; } diff --git a/source/graphics/common/ViewController.cpp b/source/graphics/common/ViewController.cpp index 451aa191..9f4d7b1c 100644 --- a/source/graphics/common/ViewController.cpp +++ b/source/graphics/common/ViewController.cpp @@ -1,6 +1,6 @@ #include "graphics/common/ViewController.h" -#include "assert.h" #include "graphics/common/MeshtasticView.h" +#include "graphics/common/MessageStatus.h" #include "util/ILog.h" #include "util/LogMessage.h" #include @@ -474,14 +474,60 @@ void ViewController::sendTextMessage(uint32_t to, uint8_t ch, uint8_t hopLimit, const char *textmsg) { size_t msgLen = strlen(textmsg); - assert(msgLen <= (size_t)DATA_PAYLOAD_LEN); + if (!fitsLogMessagePayload(msgLen)) { + ILOG_WARN("text message payload too large to persist: %u bytes", (unsigned)msgLen); + return; + } if (send(to, ch, hopLimit, requestId, meshtastic_PortNum_TEXT_MESSAGE_APP, false, usePkc, (const uint8_t *)textmsg, msgLen)) { // ILOG_DEBUG("storing msg to:0x%08x, ch:%d, time:%d, size:%d, '%s'", to, ch, msgTime, msgLen, textmsg); - log.write(LogMessageEnv(myNodeNum, to, ch, msgTime, LogMessage::eDefault, false, msgLen, (const uint8_t *)textmsg)); + LogRotate::EntryPosition position; + if (log.write(LogMessageEnv(myNodeNum, to, ch, msgTime, LogMessage::eDefault, false, msgLen, (const uint8_t *)textmsg), + &position) && + requestId != 0) { + PendingTextMessage pending{}; + pending.logPosition = position; + pendingTextMessages[requestId] = pending; + } } } +bool ViewController::updateTextMessageStatus(uint32_t requestId, MessageStatus::State status, bool finalStatus) +{ + const auto pendingIt = pendingTextMessages.find(requestId); + if (pendingIt == pendingTextMessages.end()) + return false; + + PendingTextMessage &pending = pendingIt->second; + LogMessageEnv msg; + const bool updated = log.update(pending.logPosition, msg, [status](ILogEntry &entry) { + LogMessage &candidate = static_cast(entry); + candidate.status = MessageStatus::logStatusForState(status); + candidate.reserved = MessageStatus::persistedLogState(status); + }); + + if (!updated) { + ILOG_WARN("failed to persist text message status for request id 0x%08x", requestId); + } + + if (updated && !finalStatus) + pending.status = status; + + if (finalStatus) + pendingTextMessages.erase(pendingIt); + + return updated; +} + +std::optional ViewController::pendingTextMessageStatus(uint32_t requestId) const +{ + const auto pendingIt = pendingTextMessages.find(requestId); + if (pendingIt == pendingTextMessages.end()) + return std::nullopt; + + return pendingIt->second.status; +} + bool ViewController::requestPosition(uint32_t to, uint8_t ch, uint32_t requestId) { ILOG_DEBUG("sending position request"); @@ -1034,17 +1080,19 @@ bool ViewController::packetReceived(const meshtastic_MeshPacket &p) case meshtastic_Routing_Error_MAX_RETRANSMIT: view->handleResponse(p.from, p.decoded.request_id, routing, p); break; - case meshtastic_Routing_Error_NO_RESPONSE: - ILOG_DEBUG("Routing error: no response"); - // this response is sent by the other node when position is not availble - // however, it contains valid rssi/snr, so use these - view->handlePositionResponse(p.from, p.decoded.request_id, p.rx_rssi, p.rx_snr, p.hop_limit == p.hop_start); - break; case meshtastic_Routing_Error_NO_INTERFACE: case meshtastic_Routing_Error_NO_CHANNEL: - // invalid channel or interface + case meshtastic_Routing_Error_NO_RESPONSE: + case meshtastic_Routing_Error_DUTY_CYCLE_LIMIT: + case meshtastic_Routing_Error_RATE_LIMIT_EXCEEDED: + case meshtastic_Routing_Error_TOO_LARGE: + case meshtastic_Routing_Error_BAD_REQUEST: + case meshtastic_Routing_Error_NOT_AUTHORIZED: + case meshtastic_Routing_Error_PKI_FAILED: + case meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY: case meshtastic_Routing_Error_PKI_UNKNOWN_PUBKEY: - // this response is sent by the other node when encryption keys differ (outdated) + case meshtastic_Routing_Error_ADMIN_BAD_SESSION_KEY: + case meshtastic_Routing_Error_ADMIN_PUBLIC_KEY_UNAUTHORIZED: view->handleResponse(p.from, p.decoded.request_id, routing, p); break; default: diff --git a/source/util/LogRotate.cpp b/source/util/LogRotate.cpp index 42765745..92320a48 100644 --- a/source/util/LogRotate.cpp +++ b/source/util/LogRotate.cpp @@ -83,32 +83,78 @@ bool LogRotate::readNext(ILogEntry &entry) bool LogRotate::write(const ILogEntry &entry) { - time_t start = millis(); - if (currentSize + entry.size() >= c_maxFileSize || totalSize + entry.size() >= c_maxSize) { + return write(entry, nullptr); +} + +bool LogRotate::write(const ILogEntry &entry, EntryPosition *position) +{ + const size_t entrySize = entry.size(); + if (currentSize + entrySize >= c_maxFileSize || totalSize + entrySize >= c_maxSize) { // log rotation - ILOG_DEBUG("LogRotation: %d >= %d || %d >= %d", currentSize + entry.size(), c_maxFileSize, totalSize + entry.size(), - c_maxSize); + ILOG_DEBUG("LogRotation: %d >= %d || %d >= %d", currentSize + entrySize, c_maxFileSize, totalSize + entrySize, c_maxSize); numFiles++; currentSize = 0; currentLogWrite++; currentLogName = logFileName(currentLogWrite); - while ((numFiles >= c_maxFiles || totalSize + entry.size() > c_maxSize) && removeLog()) + while ((numFiles >= c_maxFiles || totalSize + entrySize > c_maxSize) && removeLog()) ; } // elegant way to let the logentry do its work it knows best and pass just a temporary function for writing File file = _fs.open(currentLogName, FILE_APPEND); - entry.serialize([&file](const uint8_t *buf, size_t size) { return file.write(buf, size); }); + if (!file) { + ILOG_WARN("failed to open %s for append", currentLogName.c_str()); + return false; + } + + const EntryPosition writtenPosition{currentLogWrite, currentSize}; + const size_t written = entry.serialize([&file](const uint8_t *buf, size_t size) { return file.write(buf, size); }); file.close(); + if (written != entrySize) { + ILOG_WARN("failed to append complete log entry to %s", currentLogName.c_str()); + return false; + } + + if (position) + *position = writtenPosition; - currentSize += entry.size(); - totalSize += entry.size(); + currentSize += entrySize; + totalSize += entrySize; // ILOG_DEBUG("LogRotate: %d bytes written in %d ms to %s (%d/%d bytes, total: %d)", entry.size(), millis() - start, // currentLogName.c_str(), currentSize, c_maxFileSize, totalSize); return true; } +bool LogRotate::update(const EntryPosition &position, ILogEntry &entry, std::function update) +{ + if (position.logNum == 0) + return false; + + const String logName = logFileName(position.logNum); + File file = _fs.open(logName, "r+"); + if (!file) { + ILOG_WARN("failed to open %s for update", logName.c_str()); + return false; + } + + if (!file.seek(position.offset) || !entry.deserialize([&file](uint8_t *buf, size_t size) { return file.read(buf, size); })) { + file.close(); + return false; + } + + const size_t originalSize = entry.size(); + update(entry); + if (entry.size() != originalSize || !file.seek(position.offset)) { + file.close(); + return false; + } + + const size_t written = entry.serialize([&file](const uint8_t *buf, size_t size) { return file.write(buf, size); }); + file.close(); + return written == originalSize; +} + /** * remove all log files */ diff --git a/tests/test_LogMessage.cpp b/tests/test_LogMessage.cpp new file mode 100644 index 00000000..d0f5715d --- /dev/null +++ b/tests/test_LogMessage.cpp @@ -0,0 +1,35 @@ +#include "util/LogMessage.h" +#include +#include +#include +#include + +TEST_CASE("LogMessage text payload leaves room for deserialize terminator") +{ + CHECK(maxLogMessagePayloadLength == messagePayloadSize - 1); + CHECK(fitsLogMessagePayload(maxLogMessagePayloadLength)); + CHECK_FALSE(fitsLogMessagePayload(messagePayloadSize)); + + std::array payload{}; + payload.fill('x'); + + LogMessageEnv stored(1, 2, 0, 0, LogMessage::eDefault, false, maxLogMessagePayloadLength, payload.data()); + + std::vector serialized; + stored.serialize([&serialized](const uint8_t *data, size_t len) { + serialized.insert(serialized.end(), data, data + len); + return len; + }); + + size_t offset = 0; + LogMessageEnv restored; + restored.deserialize([&serialized, &offset](uint8_t *data, size_t len) { + std::copy(serialized.begin() + offset, serialized.begin() + offset + len, data); + offset += len; + return len; + }); + + CHECK(restored.length() == maxLogMessagePayloadLength); + CHECK(restored.bytes[maxLogMessagePayloadLength - 1] == 'x'); + CHECK(restored.bytes[maxLogMessagePayloadLength] == 0); +} diff --git a/tests/test_MessageStatus.cpp b/tests/test_MessageStatus.cpp new file mode 100644 index 00000000..95ae8fb5 --- /dev/null +++ b/tests/test_MessageStatus.cpp @@ -0,0 +1,129 @@ +#include "graphics/common/MessageStatus.h" +#include +#include +#include + +using MessageStatus::State; +using MessageStatus::Tone; + +TEST_CASE("MessageStatus canonical inline text") +{ + CHECK(MessageStatus::presentation(State::Sending).text == std::string("Sending...")); + CHECK(MessageStatus::presentation(State::ChannelImplicitAck).text == std::string("Delivered to mesh")); + CHECK(MessageStatus::presentation(State::DirectImplicitAck).text == std::string("Relayed, not confirmed by recipient")); + CHECK(MessageStatus::presentation(State::ExplicitAck).text == std::string("Delivered to recipient")); + CHECK(MessageStatus::presentation(State::NoAck).text == std::string("Failed to deliver to mesh")); + CHECK(MessageStatus::presentation(State::NoChannel).text == std::string("Channel/key mismatch")); + CHECK(MessageStatus::presentation(State::GenericEncryptedSendFailure).text == + std::string("Could not send encrypted message")); + CHECK(MessageStatus::presentation(State::RecipientKeyUnavailable).text == std::string("Recipient key unavailable")); + CHECK(MessageStatus::presentation(State::RecipientNeedsSenderKey).text == std::string("Recipient needs your key")); + CHECK(MessageStatus::presentation(State::MessageTooLarge).text == std::string("Message is too large to send")); + CHECK(MessageStatus::presentation(State::NoRadioInterface).text == std::string("No radio interface")); + CHECK(MessageStatus::presentation(State::DutyCycleLimit).text == std::string("Duty cycle limit")); + CHECK(MessageStatus::presentation(State::RateLimited).text == std::string("Rate limited")); + CHECK(MessageStatus::presentation(State::NoAppResponse).text == std::string("No app response")); + CHECK(MessageStatus::presentation(State::InvalidRequest).text == std::string("Invalid request")); + CHECK(MessageStatus::presentation(State::NotAuthorized).text == std::string("Not authorized")); + CHECK(MessageStatus::presentation(State::AdminSessionExpired).text == std::string("Admin session expired")); + CHECK(MessageStatus::presentation(State::AdminKeyNotAuthorized).text == std::string("Admin key not authorized")); +} + +TEST_CASE("MessageStatus distinguishes retryable and permanent failures") +{ + CHECK(MessageStatus::presentation(State::NoAck).retryable); + CHECK(MessageStatus::presentation(State::RecipientKeyUnavailable).retryable); + CHECK(MessageStatus::presentation(State::RecipientNeedsSenderKey).retryable); + CHECK(MessageStatus::presentation(State::NoRadioInterface).retryable); + CHECK(MessageStatus::presentation(State::DutyCycleLimit).retryable); + CHECK(MessageStatus::presentation(State::RateLimited).retryable); + CHECK(MessageStatus::presentation(State::NoAppResponse).retryable); + CHECK(MessageStatus::presentation(State::NoChannel).retryable); + CHECK(MessageStatus::presentation(State::InvalidRequest).retryable); + CHECK(MessageStatus::presentation(State::NotAuthorized).retryable); + CHECK(MessageStatus::presentation(State::AdminSessionExpired).retryable); + CHECK(MessageStatus::presentation(State::AdminKeyNotAuthorized).retryable); + CHECK_FALSE(MessageStatus::presentation(State::MessageTooLarge).retryable); + + CHECK(MessageStatus::presentation(State::NoAck).tone == Tone::RetryableFailure); + CHECK(MessageStatus::presentation(State::NoChannel).tone == Tone::RetryableFailure); + CHECK(MessageStatus::presentation(State::InvalidRequest).tone == Tone::RetryableFailure); + CHECK(MessageStatus::presentation(State::NotAuthorized).tone == Tone::RetryableFailure); + CHECK(MessageStatus::presentation(State::AdminKeyNotAuthorized).tone == Tone::RetryableFailure); + CHECK(MessageStatus::presentation(State::MessageTooLarge).tone == Tone::PermanentFailure); +} + +TEST_CASE("MessageStatus provides detail text for statuses that can open a popup") +{ + CHECK(MessageStatus::presentation(State::Sending).detail == nullptr); + CHECK(MessageStatus::presentation(State::ExplicitAck).detail == nullptr); + + CHECK(MessageStatus::presentation(State::DirectImplicitAck).detail == + std::string("A node relayed this message, but the recipient has not confirmed it.")); + CHECK(MessageStatus::presentation(State::NoAck).detail == + std::string("No node confirmed this message. Try again when you have better signal or more mesh coverage.")); + CHECK(MessageStatus::presentation(State::NoChannel).detail == + std::string("The sender or recipient could not use a matching channel/key for this message.")); + CHECK(MessageStatus::presentation(State::RecipientKeyUnavailable).detail == + std::string("Your node does not have the recipient's public key yet. Wait for node info to sync, then try again.")); + CHECK(MessageStatus::presentation(State::MessageTooLarge).detail == std::string("Shorten the message and send it again.")); +} + +TEST_CASE("MessageStatus maps delivery context") +{ + CHECK(MessageStatus::deliveredState(true, false) == State::ChannelImplicitAck); + CHECK(MessageStatus::deliveredState(true, true) == State::ChannelImplicitAck); + CHECK(MessageStatus::deliveredState(false, false) == State::DirectImplicitAck); + CHECK(MessageStatus::deliveredState(false, true) == State::ExplicitAck); + + CHECK(MessageStatus::isImplicitDelivery(State::ChannelImplicitAck)); + CHECK(MessageStatus::isImplicitDelivery(State::DirectImplicitAck)); + CHECK_FALSE(MessageStatus::isImplicitDelivery(State::ExplicitAck)); + CHECK_FALSE(MessageStatus::isImplicitDelivery(State::NoAck)); + + CHECK(MessageStatus::preserveImplicitDelivery(State::ChannelImplicitAck, State::NoAck) == State::ChannelImplicitAck); + CHECK(MessageStatus::preserveImplicitDelivery(State::DirectImplicitAck, State::NoAck) == State::DirectImplicitAck); + CHECK(MessageStatus::preserveImplicitDelivery(State::ExplicitAck, State::NoAck) == State::NoAck); + CHECK(MessageStatus::preserveImplicitDelivery(std::nullopt, State::NoAck) == State::NoAck); +} + +TEST_CASE("MessageStatus maps log status only when inline status is meaningful") +{ + CHECK(MessageStatus::inlineStateForLogStatus(LogMessage::eNone, true) == State::Sending); + CHECK_FALSE(MessageStatus::inlineStateForLogStatus(LogMessage::eNone, false).has_value()); + CHECK_FALSE(MessageStatus::inlineStateForLogStatus(LogMessage::eDefault, false).has_value()); + CHECK_FALSE(MessageStatus::inlineStateForLogStatus(LogMessage::eDeleted, false).has_value()); + CHECK_FALSE(MessageStatus::inlineStateForLogStatus(LogMessage::eUnread, false).has_value()); + + CHECK(MessageStatus::inlineStateForLogStatus(LogMessage::eHeard, false) == State::DirectImplicitAck); + CHECK(MessageStatus::inlineStateForLogStatus(LogMessage::eAcked, false) == State::ExplicitAck); + CHECK(MessageStatus::inlineStateForLogStatus(LogMessage::eNoResponse, false) == State::NoAck); + CHECK(MessageStatus::inlineStateForLogStatus(LogMessage::eFailed, false) == State::NoAck); +} + +TEST_CASE("MessageStatus persists final inline status") +{ + CHECK(MessageStatus::logStatusForState(State::ChannelImplicitAck) == LogMessage::eHeard); + CHECK(MessageStatus::logStatusForState(State::DirectImplicitAck) == LogMessage::eHeard); + CHECK(MessageStatus::logStatusForState(State::ExplicitAck) == LogMessage::eAcked); + CHECK(MessageStatus::logStatusForState(State::NoAck) == LogMessage::eNoResponse); + CHECK(MessageStatus::logStatusForState(State::RecipientKeyUnavailable) == LogMessage::eFailed); + CHECK(MessageStatus::logStatusForState(State::MessageTooLarge) == LogMessage::eFailed); + CHECK(MessageStatus::logStatusForState(State::NoRadioInterface) == LogMessage::eFailed); + + const uint32_t channelImplicitAck = MessageStatus::persistedLogState(State::ChannelImplicitAck); + const uint32_t recipientKeyUnavailable = MessageStatus::persistedLogState(State::RecipientKeyUnavailable); + const uint32_t messageTooLarge = MessageStatus::persistedLogState(State::MessageTooLarge); + const uint32_t adminKeyNotAuthorized = MessageStatus::persistedLogState(State::AdminKeyNotAuthorized); + + CHECK(MessageStatus::stateFromPersistedLogState(channelImplicitAck) == State::ChannelImplicitAck); + CHECK(MessageStatus::stateFromPersistedLogState(recipientKeyUnavailable) == State::RecipientKeyUnavailable); + CHECK(MessageStatus::stateFromPersistedLogState(messageTooLarge) == State::MessageTooLarge); + CHECK(MessageStatus::stateFromPersistedLogState(adminKeyNotAuthorized) == State::AdminKeyNotAuthorized); + CHECK_FALSE(MessageStatus::stateFromPersistedLogState(0).has_value()); + + CHECK(MessageStatus::inlineStateForLogStatus(LogMessage::eHeard, channelImplicitAck, false) == State::ChannelImplicitAck); + CHECK(MessageStatus::inlineStateForLogStatus(LogMessage::eFailed, recipientKeyUnavailable, false) == + State::RecipientKeyUnavailable); + CHECK(MessageStatus::inlineStateForLogStatus(LogMessage::eFailed, messageTooLarge, false) == State::MessageTooLarge); +} diff --git a/tests/test_ResponseHandler.cpp b/tests/test_ResponseHandler.cpp new file mode 100644 index 00000000..113368ce --- /dev/null +++ b/tests/test_ResponseHandler.cpp @@ -0,0 +1,42 @@ +#include "graphics/common/ResponseHandler.h" +#include + +namespace +{ +class FixedRequestIdResponseHandler : public ResponseHandler +{ + public: + FixedRequestIdResponseHandler() : ResponseHandler(1000) {} + + protected: + uint32_t generatePacketId(void) override { return generatedRequestId; } + + private: + static constexpr uint32_t generatedRequestId = 0x0badcafe; +}; +} // namespace + +TEST_CASE("ResponseHandler keeps generated request id separate from destination id") +{ + FixedRequestIdResponseHandler requests; + + uint32_t callbackRequestId = 0; + const uint32_t generatedRequestId = requests.addRequest( + 3, ResponseHandler::TextMessageRequest, reinterpret_cast(7), + [&callbackRequestId](const ResponseHandler::Request &req, ResponseHandler::EventType evt, int32_t pass) { + callbackRequestId = req.requestId; + CHECK(req.id == 3); + CHECK(req.cookie == reinterpret_cast(7)); + CHECK(evt == ResponseHandler::removed); + CHECK(pass == 42); + }); + + CHECK(generatedRequestId == 0x0badcafe); + + const ResponseHandler::Request found = requests.findRequest(generatedRequestId); + CHECK(found.requestId == generatedRequestId); + CHECK(found.id == 3); + + requests.removeRequest(generatedRequestId, ResponseHandler::TextMessageRequest, 42); + CHECK(callbackRequestId == generatedRequestId); +}