/* * Copyright (C) 2019-2025 Roger Clark, VK3KYY / G4KYF * Daniel Caujolle-Bert, F1RMB * * * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions * are met: * * 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer * in the documentation and/or other materials provided with the distribution. * * 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived * from this software without specific prior written permission. * * 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation, * incorporation in a commercial product or incorporation into a product or project which allows commercial use. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE * USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. * */ #include "user_interface/uiGlobals.h" #include "user_interface/menuSystem.h" #include "user_interface/uiUtilities.h" #include "user_interface/uiLocalisation.h" #if defined(PLATFORM_RT84_DM1701) || defined(PLATFORM_MDUV380) || defined(PLATFORM_MD2017) #if defined(PLATFORM_VARIANT_DM1701) static const int DISPLAYED_LINES_MAX = 6; #else static const int DISPLAYED_LINES_MAX = 7; #endif #else static const int DISPLAYED_LINES_MAX = 3; #endif static bool displayLHDetails = false; static menuStatus_t menuLastHeardExitCode; static LinkItem_t *selectedItem; static int lastHeardCount; static int firstDisplayed; static void displayTalkerAlias(uint8_t y, char *text, uint32_t time, uint32_t now, uint32_t TGorPC, size_t maxLen, bool displayDetails, bool itemIsSelected, bool isFirstRun, LinkItem_t * item); static void promptsInit(bool isFirstRun); menuStatus_t menuLastHeard(uiEvent_t *ev, bool isFirstRun) { static uint32_t m = 0; if (isFirstRun) { menuLastHeardInit(); menuLastHeardUpdateScreen(true, displayLHDetails, true); m = ev->time; return (menuLastHeardExitCode | (uiDataGlobal.lastHeardCount ? MENU_STATUS_LIST_TYPE : 0)); } else { bool headHasChanged = (menuDataGlobal.startIndex != LinkHead->id); menuLastHeardExitCode = MENU_STATUS_SUCCESS; // do live update by checking if the item at the top of the list has changed if (headHasChanged || (uiDataGlobal.displayQSOState == QSO_DISPLAY_CALLER_DATA)) { bool backlightOn = false; // the LH list has grown if (lastHeardCount != uiDataGlobal.lastHeardCount) { backlightOn = true; lastHeardCount = uiDataGlobal.lastHeardCount; } else { // Do not turn the backlight ON on caller data updates if (headHasChanged) { backlightOn = true; } } menuDataGlobal.startIndex = LinkHead->id; if (backlightOn) { displayLightTrigger(false); } menuLastHeardUpdateScreen(true, displayLHDetails, false); } if (ev->hasEvent) { m = ev->time; menuLastHeardHandleEvent(ev); } else { // Just refresh the list while displaying details, it's all about elapsed time if (displayLHDetails && ((ev->time - m) > (1000U * 60U))) { m = ev->time; menuLastHeardUpdateScreen(true, displayLHDetails, false); } } } return menuLastHeardExitCode; } void menuLastHeardUpdateScreen(bool showTitleOrHeader, bool displayDetails, bool isFirstRun) { int numDisplayed = 0; LinkItem_t *item = LinkHead; uint32_t now = ticksGetMillis(); bool invertColour; bool displayTA; // Jumping here from + 3, with an empty heard list won't announce "Last Heard", handle this here if (isFirstRun && (uiDataGlobal.lastHeardCount == 0) && (nonVolatileSettings.audioPromptMode >= AUDIO_PROMPT_MODE_VOICE_THRESHOLD)) { promptsInit(isFirstRun); } displayClearBuf(); if (showTitleOrHeader) { menuDisplayTitle(currentLanguage->last_heard); } else { uiUtilityRenderHeader(false, false, false); } displayThemeApply(THEME_ITEM_FG_CHANNEL_CONTACT_INFO, THEME_ITEM_BG); // skip over the first menuDataGlobal.currentItemIndex in the listing for(int i = 0; i < firstDisplayed; i++) { item = item->next; } if (uiDataGlobal.lastHeardCount > 0) { while((item != NULL) && (item->id != 0) && (numDisplayed < DISPLAYED_LINES_MAX)) { displayTA = false; if (menuDataGlobal.currentItemIndex == (firstDisplayed + numDisplayed)) { invertColour = true; displayThemeApply(THEME_ITEM_FG_CHANNEL_CONTACT_INFO, THEME_ITEM_BG_MENU_ITEM_SELECTED); displayFillRect(0, 16 + (numDisplayed * MENU_ENTRY_HEIGHT), DISPLAY_SIZE_X, MENU_ENTRY_HEIGHT, #if defined(HAS_COLOURS) true #else false #endif ); displayThemeApply(THEME_ITEM_FG_CHANNEL_CONTACT_INFO, THEME_ITEM_BG); selectedItem = item; } else { invertColour = false; } switch (nonVolatileSettings.contactDisplayPriority) { case CONTACT_DISPLAY_PRIO_CC_DB_TA: case CONTACT_DISPLAY_PRIO_DB_CC_TA: // No contact found in codeplug and DMRIDs, use TA as fallback, if any. if ((strncmp(item->contact, "ID:", 3) == 0) && (item->talkerAlias[0] != 0x00)) { displayTA = true; } break; case CONTACT_DISPLAY_PRIO_TA_CC_DB: case CONTACT_DISPLAY_PRIO_TA_DB_CC: if (item->talkerAlias[0] != 0x00) { displayTA = true; } break; } if (displayTA) { displayTalkerAlias(16 + (numDisplayed * MENU_ENTRY_HEIGHT) + LH_ENTRY_V_OFFSET, item->talkerAlias, item->time, now, item->talkGroupOrPcId, 32, displayDetails, invertColour, isFirstRun, item); } else { displayTalkerAlias(16 + (numDisplayed * MENU_ENTRY_HEIGHT) + LH_ENTRY_V_OFFSET, item->contact, item->time, now, item->talkGroupOrPcId, MAX_DMR_ID_CONTACT_TEXT_LENGTH, displayDetails, invertColour, isFirstRun, item); } numDisplayed++; item = item->next; } } else { promptsPlayNotAfterTx(); } displayThemeResetToDefault(); displayRender(); uiDataGlobal.displayQSOState = QSO_DISPLAY_IDLE; } void menuLastHeardHandleEvent(uiEvent_t *ev) { bool isDirty = false; int currentMenu = menuSystemGetCurrentMenuNumber(); if (currentMenu == MENU_LAST_HEARD) { if (ev->events & BUTTON_EVENT) { if (repeatVoicePromptOnSK1(ev)) { return; } } if ((ev->events & FUNCTION_EVENT) && (ev->function == FUNC_REDRAW)) { menuLastHeardUpdateScreen(true, displayLHDetails, false); return; } } if (uiDataGlobal.lastHeardCount > 0) { if (KEYCHECK_PRESS(ev->keys, KEY_DOWN) && (BUTTONCHECK_DOWN(ev, BUTTON_SK2) == 0)) { if (menuDataGlobal.currentItemIndex < (uiDataGlobal.lastHeardCount - 1)) { isDirty = true; menuDataGlobal.currentItemIndex++; if (menuDataGlobal.currentItemIndex >= DISPLAYED_LINES_MAX) { if (((menuDataGlobal.currentItemIndex - DISPLAYED_LINES_MAX) == firstDisplayed) && (menuDataGlobal.currentItemIndex <= (uiDataGlobal.lastHeardCount - 1))) { firstDisplayed++; } } } if (menuDataGlobal.currentItemIndex == 0) { menuLastHeardExitCode |= MENU_STATUS_LIST_TYPE; } } else if (KEYCHECK_PRESS(ev->keys, KEY_DOWN) && BUTTONCHECK_DOWN(ev, BUTTON_SK2)) { isDirty = true; menuDataGlobal.currentItemIndex = (uiDataGlobal.lastHeardCount - 1); firstDisplayed = SAFE_MAX((menuDataGlobal.currentItemIndex - (DISPLAYED_LINES_MAX - 1)), 0); if (menuDataGlobal.currentItemIndex == 0) { menuLastHeardExitCode |= MENU_STATUS_LIST_TYPE; } } else if (KEYCHECK_PRESS(ev->keys, KEY_UP) && (BUTTONCHECK_DOWN(ev, BUTTON_SK2) == 0)) { if (menuDataGlobal.currentItemIndex > 0) { isDirty = true; menuDataGlobal.currentItemIndex--; if (firstDisplayed && (menuDataGlobal.currentItemIndex < firstDisplayed)) { firstDisplayed--; } } if (menuDataGlobal.currentItemIndex == 0) { menuLastHeardExitCode |= MENU_STATUS_LIST_TYPE; } } else if (KEYCHECK_PRESS(ev->keys, KEY_UP) && BUTTONCHECK_DOWN(ev, BUTTON_SK2)) { menuDataGlobal.currentItemIndex = 0; isDirty = true; firstDisplayed = 0; menuLastHeardExitCode |= MENU_STATUS_LIST_TYPE; } else if ((currentMenu == MENU_LAST_HEARD) && KEYCHECK_SHORTUP(ev->keys, KEY_GREEN)) { int timeslot = selectedItem->receivedTS; uint32_t tgOrPC = ((BUTTONCHECK_DOWN(ev, BUTTON_SK2) == 0) ? selectedItem->talkGroupOrPcId : ((PC_CALL_FLAG << 24) | selectedItem->id)); setOverrideTGorPC(tgOrPC, ((tgOrPC >> 24) == PC_CALL_FLAG)); if ((timeslot != -1) && (timeslot != trxGetDMRTimeSlot())) { trxSetDMRTimeSlot(timeslot, true); tsSetManualOverride(((menuSystemGetRootMenuNumber() == UI_CHANNEL_MODE) ? CHANNEL_CHANNEL : (CHANNEL_VFO_A + nonVolatileSettings.currentVFONumber)), (timeslot + 1)); } announceItem(PROMPT_SEQUENCE_CONTACT_TG_OR_PC, PROMPT_THRESHOLD_3); menuSystemPopAllAndDisplayRootMenu(); return; } else if (KEYCHECK_LONGDOWN(ev->keys, KEY_HASH) && (BUTTONCHECK_DOWN(ev, (BUTTON_SK1 | BUTTON_SK2)) == 0)) // Clear LH list { if (currentMenu == MENU_LAST_HEARD) // Only allowed within LH menu { lastHeardInitList(); lastHeardClearLastID(); menuLastHeardInit(); promptsInit(true); // Stack "Empty List" VP. isDirty = true; } } } if (currentMenu == MENU_LAST_HEARD) { if (KEYCHECK_SHORTUP(ev->keys, KEY_RED)) { menuSystemPopPreviousMenu(); return; } else if (KEYCHECK_SHORTUP_NUMBER(ev->keys) && (BUTTONCHECK_DOWN(ev, BUTTON_SK2))) { saveQuickkeyMenuIndex(ev->keys.key, menuSystemGetCurrentMenuNumber(), 0, 0); return; } // Toggles LH simple/detailed views on SK2 press if (!displayLHDetails && BUTTONCHECK_DOWN(ev, BUTTON_SK2)) { isDirty = true; displayLHDetails = true; } else if (displayLHDetails && (BUTTONCHECK_DOWN(ev, BUTTON_SK2) == 0)) { isDirty = true; displayLHDetails = false; } if (isDirty) { menuLastHeardUpdateScreen(true, displayLHDetails, false);// This will also setup the voice prompt if (nonVolatileSettings.audioPromptMode >= AUDIO_PROMPT_MODE_VOICE_THRESHOLD) { voicePromptsPlay(); } } } else { if (isDirty) { menuLastHeardUpdateScreen(false, false, false); } } } void menuLastHeardInit(void) { menuDataGlobal.startIndex = LinkHead->id;// reuse this global to store the ID of the first item in the list menuDataGlobal.currentItemIndex = 0; menuDataGlobal.numItems = uiDataGlobal.lastHeardCount; selectedItem = NULL; firstDisplayed = 0; displayLHDetails = false; lastHeardCount = uiDataGlobal.lastHeardCount; menuLastHeardExitCode = MENU_STATUS_SUCCESS; } static void promptsInit(bool isFirstRun) { voicePromptsInit(); if (isFirstRun) { voicePromptsAppendPrompt(PROMPT_SILENCE); voicePromptsAppendLanguageString(currentLanguage->last_heard); voicePromptsAppendLanguageString(currentLanguage->menu); if (uiDataGlobal.lastHeardCount == 0) { voicePromptsAppendPrompt(PROMPT_SILENCE); voicePromptsAppendLanguageString(currentLanguage->list_empty); } } } static void displayTalkerAlias(uint8_t y, char *text, uint32_t time, uint32_t now, uint32_t TGorPC, size_t maxLen, bool displayDetails, bool itemIsSelected, bool isFirstRun, LinkItem_t * item) { char timeBuffer[SCREEN_LINE_BUFFER_SIZE]; uint32_t tg = (TGorPC & 0xFFFFFF); bool isPC = ((TGorPC >> 24) == PC_CALL_FLAG); bool inHours = false; uint32_t heardSince = (((now - time) / 1000U) / 60U); // If last heard time is greater than 720 minutes (12h), switch to hours view to avoid mixup between TG and time. if (heardSince > 720U) { heardSince /= 60U; inHours = true; if (heardSince > 999) { heardSince = 999; } } snprintf(timeBuffer, 6, "%u", heardSince); // Time, without unit if (itemIsSelected && (nonVolatileSettings.audioPromptMode >= AUDIO_PROMPT_MODE_VOICE_THRESHOLD)) { promptsInit(isFirstRun); } if (!displayDetails) // search for callsign + first name { char buffer[37]; // Max: TA 27 (in 7bit format) + ' [' + 6 (Maidenhead) + ']' + NULL if (strlen(text) >= 5) { int32_t cpos; if ((cpos = getFirstSpacePos(text)) != -1) // Callsign found { // Check if second part is 'DMR ID:' // In this case, don't go further if (strncmp((text + cpos + 1), "DMR ID:", 7) == 0) { if (cpos > 15) { cpos = 16; } memcpy(buffer, text, cpos); buffer[cpos] = 0; displayPrintCore(0, y, chomp(buffer), FONT_SIZE_3, TEXT_ALIGN_CENTER, itemIsSelected); } else // Nope, look for first name { uint32_t npos; char nameBuf[SCREEN_LINE_BUFFER_SIZE]; char outputBuf[SCREEN_LINE_BUFFER_SIZE]; memset(nameBuf, 0, sizeof(nameBuf)); // Look for the second part, aka first name if ((npos = getFirstSpacePos(text + cpos + 1)) != -1) { // Store callsign memcpy(buffer, text, cpos); buffer[cpos] = 0; // Store 'first name' memcpy(nameBuf, (text + cpos + 1), npos); nameBuf[npos] = 0; snprintf(outputBuf, SCREEN_LINE_BUFFER_SIZE, "%s %s", chomp(buffer), chomp(nameBuf)); displayPrintCore(0, y, chomp(outputBuf), FONT_SIZE_3, TEXT_ALIGN_CENTER, itemIsSelected); } else { // Store callsign memcpy(buffer, text, cpos); buffer[cpos] = 0; memcpy(nameBuf, (text + cpos + 1), strlen(text) - cpos - 1); nameBuf[(SCREEN_LINE_BUFFER_SIZE - 1)] = 0; snprintf(outputBuf, SCREEN_LINE_BUFFER_SIZE, "%s %s", chomp(buffer), chomp(nameBuf)); displayPrintCore(0, y, chomp(outputBuf), FONT_SIZE_3, TEXT_ALIGN_CENTER, itemIsSelected); } } } else { // No space found, use a chainsaw memcpy(buffer, text, (SCREEN_LINE_BUFFER_SIZE - 1)); buffer[(SCREEN_LINE_BUFFER_SIZE - 1)] = 0; displayPrintCore(0, y, chomp(buffer), FONT_SIZE_3, TEXT_ALIGN_CENTER, itemIsSelected); } } else // short callsign { memcpy(buffer, text, strlen(text)); buffer[strlen(text)] = 0; displayPrintCore(0, y, chomp(buffer), FONT_SIZE_3, TEXT_ALIGN_CENTER, itemIsSelected); } if (itemIsSelected && (nonVolatileSettings.audioPromptMode >= AUDIO_PROMPT_MODE_VOICE_THRESHOLD)) { voicePromptsAppendString(chomp(buffer)); voicePromptsAppendString(" "); snprintf(buffer, 37, "%u ", tg); if (isPC) { voicePromptsAppendLanguageString(currentLanguage->private_call); if (tg != trxDMRID) { voicePromptsAppendString(buffer); } } else { voicePromptsAppendPrompt(PROMPT_TALKGROUP); voicePromptsAppendString(buffer); } voicePromptsAppendString(timeBuffer); if (inHours) { voicePromptsAppendLanguageString(currentLanguage->hours); } else { voicePromptsAppendPrompt(PROMPT_MINUTES); } voicePromptsAppendString(" ");// Add some blank sound at the end of the callsign, to allow time for follow-on scrolling } } else { char buffer[SCREEN_LINE_BUFFER_SIZE]; bool displayTS = (item->dmrMode == DMR_MODE_RMO); int16_t yTimeUnit = #if defined(PLATFORM_RD5R) y; #else (y + 6); #endif snprintf(buffer, SCREEN_LINE_BUFFER_SIZE, "%u", tg); displayPrintCore(0, y, (isPC ? currentLanguage->pc : currentLanguage->tg), FONT_SIZE_3, TEXT_ALIGN_LEFT, itemIsSelected); displayPrintCore((2 * 8) + 4, y, buffer, FONT_SIZE_3, TEXT_ALIGN_LEFT, itemIsSelected); // Display TS (if in RMO), in a reverse video box. if (displayTS) { int16_t yTS = #if defined(PLATFORM_RD5R) y; #else (y + 5); #endif snprintf(buffer, SCREEN_LINE_BUFFER_SIZE, "%d", (item->receivedTS + 1)); displayFillRect((DISPLAY_SIZE_X - (1 * 6) - (4 * 8) - 3), yTS - 1, (6 + 1), 9, itemIsSelected); displayPrintCore((DISPLAY_SIZE_X - (1 * 6) - (4 * 8) - 2), yTS, buffer, FONT_SIZE_1, TEXT_ALIGN_LEFT, !itemIsSelected); } displayPrintCore((DISPLAY_SIZE_X - (strlen(timeBuffer) * 8) - (1 * 6) - 1), y, timeBuffer, FONT_SIZE_3, TEXT_ALIGN_LEFT, itemIsSelected); // Time unit is displayed using the smallest font // 'm' for minutes is not SI, but using 3 characters "min" won't fit the screen displayPrintCore((DISPLAY_SIZE_X - (1 * 6)), yTimeUnit, (inHours ? "h" : "m"), FONT_SIZE_1, TEXT_ALIGN_LEFT, itemIsSelected); } if (isFirstRun) { promptsPlayNotAfterTx(); } }