diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 111a47f7c..65f569831 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -219,6 +219,42 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t ui->update(); } +void Screen::showSignedDecimalPicker(const char *message, uint32_t durationMs, int initialValueTenths, int minValueTenths, + int maxValueTenths, std::function bannerCallback) +{ +#ifdef USE_EINK + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus +#endif + if (minValueTenths > maxValueTenths) { + int temp = minValueTenths; + minValueTenths = maxValueTenths; + maxValueTenths = temp; + } + + if (initialValueTenths < minValueTenths) { + initialValueTenths = minValueTenths; + } else if (initialValueTenths > maxValueTenths) { + initialValueTenths = maxValueTenths; + } + + strncpy(NotificationRenderer::alertBannerMessage, message, 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::alertBannerCallback = bannerCallback; + NotificationRenderer::pauseBanner = false; + NotificationRenderer::curSelected = 0; + NotificationRenderer::current_notification_type = notificationTypeEnum::signed_decimal_picker; + NotificationRenderer::signedDecimalValueTenths = static_cast(initialValueTenths); + NotificationRenderer::signedDecimalMinTenths = static_cast(minValueTenths); + NotificationRenderer::signedDecimalMaxTenths = static_cast(maxValueTenths); + NotificationRenderer::signedDecimalIsNegative = (initialValueTenths < 0); + + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setTargetFPS(60); + ui->update(); +} + void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function textCallback) { @@ -1155,6 +1191,8 @@ void Screen::setFrames(FrameFocus focus) fsi.positions.focusedModule = numframes; if (m && m == waypointModule) fsi.positions.waypoint = numframes; + if (m && strcmp(m->getName(), "EnvironmentTelemetry") == 0) + fsi.positions.environment = numframes; indicatorIcons.push_back(icon_module); numframes++; @@ -1783,6 +1821,8 @@ int Screen::handleInputEvent(const InputEvent *event) menuHandler::textMessageBaseMenu(); } } + } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.environment) { + menuHandler::environmentTelemetryBaseMenu(); } else if (framesetInfo.positions.firstFavorite != 255 && this->ui->getUiState()->currentFrame >= framesetInfo.positions.firstFavorite && this->ui->getUiState()->currentFrame <= framesetInfo.positions.lastFavorite) { diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 31ddf1c84..6d6d85e70 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -12,7 +12,7 @@ #define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) namespace graphics { -enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, text_input }; +enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, signed_decimal_picker, text_input }; struct BannerOverlayOptions { const char *message; @@ -312,6 +312,8 @@ class Screen : public concurrency::OSThread void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback); void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function bannerCallback); + void showSignedDecimalPicker(const char *message, uint32_t durationMs, int initialValueTenths, int minValueTenths, + int maxValueTenths, std::function bannerCallback); void showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function textCallback); @@ -701,6 +703,7 @@ class Screen : public concurrency::OSThread uint8_t firstFavorite = 255; uint8_t lastFavorite = 255; uint8_t lora = 255; + uint8_t environment = 255; } positions; uint8_t frameCount = 0; @@ -778,4 +781,4 @@ extern std::vector functionSymbol; extern std::string functionSymbolString; extern graphics::Screen *screen; -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index f57c39512..4530a8d54 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -56,6 +56,32 @@ BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOp return bannerOptions; } +constexpr float kTemperatureOffsetMinC = -20.0f; +constexpr float kTemperatureOffsetMaxC = 20.0f; +constexpr float kTemperatureOffsetDeltaFPerC = 1.8f; + +bool useImperialTemperatureOffsetUnits() +{ + return config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL || + moduleConfig.telemetry.environment_display_fahrenheit; +} + +float clampTemperatureOffsetC(float offsetC) +{ + if (offsetC < kTemperatureOffsetMinC) { + return kTemperatureOffsetMinC; + } + if (offsetC > kTemperatureOffsetMaxC) { + return kTemperatureOffsetMaxC; + } + return offsetC; +} + +int toTenthsRounded(float value) +{ + return (value >= 0.0f) ? static_cast(value * 10.0f + 0.5f) : static_cast(value * 10.0f - 0.5f); +} + } // namespace menuHandler::screenMenus menuHandler::menuQueue = MenuNone; @@ -1008,6 +1034,52 @@ void menuHandler::textMessageMenu() cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); } +void menuHandler::environmentTelemetryBaseMenu() +{ + enum optionsNumbers { Back, SetTempOffset }; + + static const char *optionsArray[] = {"Back", "Set Temp Offset"}; + static int optionsEnumArray[] = {Back, SetTempOffset}; + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Env Actions"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsEnumPtr = optionsEnumArray; + bannerOptions.optionsCount = 2; + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == SetTempOffset) { + menuQueue = EnvironmentTempOffsetPicker; + screen->runNow(); + } + }; + screen->showOverlayBanner(bannerOptions); +} + +void menuHandler::environmentTemperatureOffsetPicker() +{ + static char pickerTitle[40]; + + const bool useImperial = useImperialTemperatureOffsetUnits(); + const char displayUnit = useImperial ? 'F' : 'C'; + const float currentOffsetC = clampTemperatureOffsetC(moduleConfig.telemetry.environment_temperature_offset_c); + const float displayOffset = useImperial ? (currentOffsetC * kTemperatureOffsetDeltaFPerC) : currentOffsetC; + const float minDisplay = useImperial ? (kTemperatureOffsetMinC * kTemperatureOffsetDeltaFPerC) : kTemperatureOffsetMinC; + const float maxDisplay = useImperial ? (kTemperatureOffsetMaxC * kTemperatureOffsetDeltaFPerC) : kTemperatureOffsetMaxC; + + snprintf(pickerTitle, sizeof(pickerTitle), "Set Temp Offset (%c)", displayUnit); + screen->showSignedDecimalPicker(pickerTitle, 60000, toTenthsRounded(displayOffset), toTenthsRounded(minDisplay), + toTenthsRounded(maxDisplay), [useImperial](int pickedTenths) -> void { + float selectedOffset = static_cast(pickedTenths) / 10.0f; + if (useImperial) { + selectedOffset /= kTemperatureOffsetDeltaFPerC; + } + + moduleConfig.telemetry.environment_temperature_offset_c = + clampTemperatureOffsetC(selectedOffset); + nodeDB->saveToDisk(SEGMENT_MODULECONFIG); + }); +} + void menuHandler::textMessageBaseMenu() { enum optionsNumbers { Back, Preset, Freetext, enumEnd }; @@ -2204,14 +2276,11 @@ void menuHandler::traceRouteMenu() void menuHandler::testMenu() { - enum optionsNumbers { Back, NumberPicker, ShowChirpy }; - static const char *optionsArray[4] = {"Back"}; - static int optionsEnumArray[4] = {Back}; + enum optionsNumbers { Back, ShowChirpy }; + static const char *optionsArray[3] = {"Back"}; + static int optionsEnumArray[3] = {Back}; int options = 1; - optionsArray[options] = "Number Picker"; - optionsEnumArray[options++] = NumberPicker; - optionsArray[options] = screen->isFrameHidden("chirpy") ? "Show Chirpy" : "Hide Chirpy"; optionsEnumArray[options++] = ShowChirpy; @@ -2221,10 +2290,7 @@ void menuHandler::testMenu() bannerOptions.optionsCount = options; bannerOptions.optionsEnumPtr = optionsEnumArray; bannerOptions.bannerCallback = [](int selected) -> void { - if (selected == NumberPicker) { - menuQueue = NumberTest; - screen->runNow(); - } else if (selected == ShowChirpy) { + if (selected == ShowChirpy) { screen->toggleFrameVisibility("chirpy"); screen->setFrames(Screen::FOCUS_SYSTEM); @@ -2236,12 +2302,6 @@ void menuHandler::testMenu() screen->showOverlayBanner(bannerOptions); } -void menuHandler::numberTest() -{ - screen->showNumberPicker("Pick a number\n ", 30000, 4, - [](int number_picked) -> void { LOG_WARN("Nodenum: %u", number_picked); }); -} - void menuHandler::wifiBaseMenu() { enum optionsNumbers { Back, Wifi_toggle }; @@ -2737,8 +2797,8 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case TestMenu: testMenu(); break; - case NumberTest: - numberTest(); + case EnvironmentTempOffsetPicker: + environmentTemperatureOffsetPicker(); break; case WifiToggleMenu: wifiToggleMenu(); @@ -2793,4 +2853,4 @@ void menuHandler::saveUIConfig() } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 4a0360412..81cb88fff 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -38,7 +38,7 @@ class menuHandler ManageNodeMenu, RemoveFavorite, TestMenu, - NumberTest, + EnvironmentTempOffsetPicker, WifiToggleMenu, BluetoothToggleMenu, ScreenOptionsMenu, @@ -78,6 +78,7 @@ class menuHandler static void deleteMessagesMenu(); static void homeBaseMenu(); static void textMessageBaseMenu(); + static void environmentTelemetryBaseMenu(); static void systemBaseMenu(); static void favoriteBaseMenu(); static void positionBaseMenu(); @@ -101,7 +102,7 @@ class menuHandler static void removeFavoriteMenu(); static void traceRouteMenu(); static void testMenu(); - static void numberTest(); + static void environmentTemperatureOffsetPicker(); static void wifiBaseMenu(); static void wifiToggleMenu(); static void screenOptionsMenu(); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 8d76b4592..6ffc04390 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -14,6 +14,7 @@ #endif #include "main.h" #include +#include #include #include #if HAS_TRACKBALL @@ -51,16 +52,292 @@ bool NotificationRenderer::pauseBanner = false; notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none; uint32_t NotificationRenderer::numDigits = 0; uint32_t NotificationRenderer::currentNumber = 0; +int16_t NotificationRenderer::signedDecimalValueTenths = 0; +int16_t NotificationRenderer::signedDecimalMinTenths = -999; +int16_t NotificationRenderer::signedDecimalMaxTenths = 999; +bool NotificationRenderer::signedDecimalIsNegative = false; VirtualKeyboard *NotificationRenderer::virtualKeyboard = nullptr; std::function NotificationRenderer::textInputCallback = nullptr; -uint32_t pow_of_10(uint32_t n) +struct NumericSlotPickerState { + std::vector digits; + bool hasSign = false; + bool isNegative = false; + uint8_t decimalDigits = 0; + int32_t minValue = 0; + int32_t maxValue = 0; +}; + +int32_t maxValueForDigits(uint8_t digitCount) { - uint32_t ret = 1; - for (uint32_t i = 0; i < n; i++) { - ret *= 10; + int64_t value = 0; + for (uint8_t i = 0; i < digitCount; i++) { + value = (value * 10) + 9; + if (value > std::numeric_limits::max()) { + return std::numeric_limits::max(); + } } - return ret; + return static_cast(value); +} + +uint8_t pickerSlotCount(const NumericSlotPickerState &state) +{ + return static_cast(state.digits.size() + (state.hasSign ? 1 : 0)); +} + +int32_t clampPickerValue(int32_t value, int32_t minValue, int32_t maxValue) +{ + if (value < minValue) { + return minValue; + } + if (value > maxValue) { + return maxValue; + } + return value; +} + +uint32_t combinePickerDigits(const std::vector &digits) +{ + uint32_t value = 0; + for (const uint8_t digit : digits) { + value = (value * 10) + digit; + } + return value; +} + +void splitPickerDigits(uint32_t value, std::vector &digits) +{ + if (digits.empty()) { + return; + } + + for (int i = static_cast(digits.size()) - 1; i >= 0; i--) { + digits[static_cast(i)] = static_cast(value % 10); + value /= 10; + } +} + +int32_t composePickerValue(const NumericSlotPickerState &state) +{ + int64_t value = static_cast(combinePickerDigits(state.digits)); + if (state.hasSign && state.isNegative && value != 0) { + value = -value; + } + return clampPickerValue(static_cast(value), state.minValue, state.maxValue); +} + +void normalizePickerState(NumericSlotPickerState &state) +{ + const bool keepNegativeZero = state.isNegative; + const int32_t clampedValue = composePickerValue(state); + const uint32_t absValue = + (clampedValue < 0) ? static_cast(-static_cast(clampedValue)) : static_cast(clampedValue); + splitPickerDigits(absValue, state.digits); + + if (!state.hasSign) { + state.isNegative = false; + return; + } + + if (clampedValue < 0) { + state.isNegative = true; + } else if (clampedValue > 0) { + state.isNegative = false; + } else { + // Keep selected sign for zero so users can pick +/- before entering digits. + state.isNegative = keepNegativeZero; + } +} + +bool isPickerIncrementEvent(uint8_t eventType) +{ + return eventType == INPUT_BROKER_UP || eventType == INPUT_BROKER_ALT_PRESS || eventType == INPUT_BROKER_UP_LONG; +} + +bool isPickerDecrementEvent(uint8_t eventType) +{ + return eventType == INPUT_BROKER_DOWN || eventType == INPUT_BROKER_USER_PRESS || eventType == INPUT_BROKER_DOWN_LONG; +} + +void applyPickerDelta(NumericSlotPickerState &state, int8_t selectedSlot, int8_t delta) +{ + const uint8_t slotCount = pickerSlotCount(state); + if (selectedSlot < 0 || selectedSlot >= static_cast(slotCount)) { + return; + } + + if (state.hasSign && selectedSlot == 0) { + state.isNegative = !state.isNegative; + normalizePickerState(state); + return; + } + + const int8_t digitOffset = state.hasSign ? 1 : 0; + const int8_t digitIndex = selectedSlot - digitOffset; + if (digitIndex < 0 || digitIndex >= static_cast(state.digits.size())) { + return; + } + + uint8_t &digit = state.digits[static_cast(digitIndex)]; + if (delta > 0) { + digit = static_cast((digit + 1) % 10); + } else { + digit = static_cast((digit == 0) ? 9 : (digit - 1)); + } + normalizePickerState(state); +} + +bool applyPickerKeypress(NumericSlotPickerState &state, int8_t selectedSlot, char key) +{ + const uint8_t slotCount = pickerSlotCount(state); + if (selectedSlot < 0 || selectedSlot >= static_cast(slotCount)) { + return false; + } + + if (state.hasSign && selectedSlot == 0 && (key == '+' || key == '-')) { + state.isNegative = (key == '-'); + normalizePickerState(state); + return true; + } + + if (key < '0' || key > '9') { + return false; + } + + const int8_t digitOffset = state.hasSign ? 1 : 0; + const int8_t digitIndex = selectedSlot - digitOffset; + if (digitIndex < 0 || digitIndex >= static_cast(state.digits.size())) { + return false; + } + + state.digits[static_cast(digitIndex)] = static_cast(key - '0'); + normalizePickerState(state); + return true; +} + +void parseBannerLines(const char *lineStarts[MAX_LINES + 1], uint16_t &lineCount) +{ + lineCount = 0; + char *alertEnd = NotificationRenderer::alertBannerMessage + + strnlen(NotificationRenderer::alertBannerMessage, sizeof(NotificationRenderer::alertBannerMessage)); + lineStarts[lineCount] = NotificationRenderer::alertBannerMessage; + while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { + lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n'); + if (lineStarts[lineCount + 1][0] == '\n') { + lineStarts[lineCount + 1] += 1; + } + lineCount++; + } +} + +void buildNumericPickerDisplay(const NumericSlotPickerState &state, std::string &formattedValue, + std::vector &slotCharIndexBySlot) +{ + const uint8_t slotCount = pickerSlotCount(state); + slotCharIndexBySlot.assign(slotCount, -1); + + formattedValue = " "; + formattedValue.reserve(24); + int8_t slot = 0; + + if (state.hasSign) { + slotCharIndexBySlot[slot++] = static_cast(formattedValue.size()); + formattedValue += state.isNegative ? '-' : '+'; + formattedValue += ' '; + } + + const size_t digitCount = state.digits.size(); + const size_t decimalBreak = + (state.decimalDigits > 0 && state.decimalDigits < digitCount) ? (digitCount - state.decimalDigits) : digitCount; + for (size_t i = 0; i < digitCount; i++) { + slotCharIndexBySlot[slot++] = static_cast(formattedValue.size()); + formattedValue += static_cast('0' + state.digits[i]); + formattedValue += ' '; + + if (state.decimalDigits > 0 && i + 1 == decimalBreak) { + formattedValue += '.'; + formattedValue += ' '; + } + } +} + +// Shared picker renderer for any "editable slots in a formatted string" flow. +// Reuse by building `formattedValue` as displayed, and mapping each editable slot +// to its character index in that string via `slotCharIndexBySlot`. +void NumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lineStarts[MAX_LINES + 1], uint16_t lineCount, + const std::string &formattedValue, const int16_t *slotCharIndexBySlot, uint8_t slotCount, int8_t selectedSlot) +{ + if (formattedValue.empty() || slotCharIndexBySlot == nullptr || slotCount == 0) { + return; + } + + std::string spacer(formattedValue.size(), ' '); + uint16_t totalLines = lineCount + 3; + const char *linePointers[totalLines + 1] = {0}; + for (uint16_t i = 0; i < lineCount; i++) { + linePointers[i] = lineStarts[i]; + } + const uint16_t topGuideLineIndex = lineCount; + linePointers[lineCount++] = spacer.c_str(); + const uint16_t valueLineIndex = lineCount; + linePointers[lineCount++] = formattedValue.c_str(); + const uint16_t bottomGuideLineIndex = lineCount; + linePointers[lineCount++] = spacer.c_str(); + + NotificationRenderer::drawNotificationBox(display, state, linePointers, totalLines, 0); + + constexpr uint16_t hPadding = 5; + constexpr uint16_t vPadding = 2; + uint16_t maxWidth = 0; + uint16_t lineWidths[MAX_LINES + 3] = {0}; + for (uint16_t i = 0; i < totalLines; i++) { + const uint16_t lineLength = static_cast(strlen(linePointers[i])); + lineWidths[i] = display->getStringWidth(linePointers[i], lineLength, true); + if (lineWidths[i] > maxWidth) { + maxWidth = lineWidths[i]; + } + } + + uint16_t boxWidth = hPadding * 2 + maxWidth; + uint16_t screenHeight = display->height(); + uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; + uint8_t visibleTotalLines = std::min(totalLines, (screenHeight - vPadding * 2) / effectiveLineHeight); + uint16_t contentHeight = visibleTotalLines * effectiveLineHeight; + uint16_t boxHeight = contentHeight + vPadding * 2; + if (visibleTotalLines == 1) { + boxHeight += (currentResolution == ScreenResolution::High) ? 4 : 3; + } + int16_t boxLeft = (display->width() / 2) - (boxWidth / 2); + if (totalLines > visibleTotalLines) { + boxWidth += (currentResolution == ScreenResolution::High) ? 4 : 2; + } + int16_t boxTop = (display->height() / 2) - (boxHeight / 2); + + const int selectedSlotClamped = std::max(0, std::min(selectedSlot, static_cast(slotCount) - 1)); + const int selectedCharIndex = slotCharIndexBySlot[selectedSlotClamped]; + if (selectedCharIndex < 0 || selectedCharIndex >= static_cast(formattedValue.size())) { + return; + } + + int16_t valueTextX = boxLeft + (boxWidth - lineWidths[valueLineIndex]) / 2; + const uint16_t prefixWidth = display->getStringWidth(formattedValue.c_str(), selectedCharIndex, true); + const uint16_t slotCharWidth = display->getStringWidth(formattedValue.c_str() + selectedCharIndex, 1, true); + const int16_t slotCenterX = valueTextX + static_cast(prefixWidth + (slotCharWidth / 2)); + + int16_t topGuideY = boxTop + vPadding + (topGuideLineIndex * effectiveLineHeight); + int16_t bottomGuideY = boxTop + vPadding + (bottomGuideLineIndex * effectiveLineHeight); + const int16_t triHalfWidth = (currentResolution == ScreenResolution::High) ? 3 : 2; + const int16_t guideInsetY = 1; + const int16_t triHeight = std::max(2, static_cast((effectiveLineHeight - (guideInsetY * 2) - 1) / 2)); + const int16_t topBaseY = topGuideY + effectiveLineHeight - guideInsetY - 1; + const int16_t topApexY = topBaseY - triHeight; + const int16_t bottomBaseY = bottomGuideY + guideInsetY; + const int16_t bottomApexY = bottomBaseY + triHeight; + + display->setColor(WHITE); + display->fillTriangle(slotCenterX, topApexY, slotCenterX - triHalfWidth, topBaseY, slotCenterX + triHalfWidth, topBaseY); + display->fillTriangle(slotCenterX - triHalfWidth, bottomBaseY, slotCenterX + triHalfWidth, bottomBaseY, slotCenterX, + bottomApexY); } // Used on boot when a certificate is being created @@ -102,6 +379,10 @@ void NotificationRenderer::resetBanner() pauseBanner = false; numDigits = 0; currentNumber = 0; + signedDecimalValueTenths = 0; + signedDecimalMinTenths = -999; + signedDecimalMaxTenths = 999; + signedDecimalIsNegative = false; nodeDB->pause_sort(false); @@ -152,6 +433,9 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU case notificationTypeEnum::number_picker: drawNumberPicker(display, state); break; + case notificationTypeEnum::signed_decimal_picker: + drawSignedDecimalPicker(display, state); + break; } } @@ -159,83 +443,132 @@ void NotificationRenderer::drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiS { const char *lineStarts[MAX_LINES + 1] = {0}; uint16_t lineCount = 0; + parseBannerLines(lineStarts, lineCount); - // Parse lines - char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage)); - lineStarts[lineCount] = alertBannerMessage; + NumericSlotPickerState pickerState; + pickerState.hasSign = false; + pickerState.decimalDigits = 0; + pickerState.minValue = 0; + pickerState.maxValue = maxValueForDigits(static_cast(numDigits)); + pickerState.digits.assign(numDigits, 0); + splitPickerDigits(currentNumber, pickerState.digits); + normalizePickerState(pickerState); - // Find lines - while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { - lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n'); - if (lineStarts[lineCount + 1][0] == '\n') - lineStarts[lineCount + 1] += 1; - lineCount++; + const uint8_t slotCount = pickerSlotCount(pickerState); + if (curSelected < 0) { + curSelected = 0; + } else if (curSelected > static_cast(slotCount)) { + curSelected = static_cast(slotCount); } - // modulo to extract - uint8_t this_digit = (currentNumber % (pow_of_10(numDigits - curSelected))) / (pow_of_10(numDigits - curSelected - 1)); - // Handle input - if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS || - inEvent.inputEvent == INPUT_BROKER_UP_LONG) { - if (this_digit == 9) { - currentNumber -= 9 * (pow_of_10(numDigits - curSelected - 1)); - } else { - currentNumber += (pow_of_10(numDigits - curSelected - 1)); - } - } else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS || - inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) { - if (this_digit == 0) { - currentNumber += 9 * (pow_of_10(numDigits - curSelected - 1)); - } else { - currentNumber -= (pow_of_10(numDigits - curSelected - 1)); - } + + if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && alertBannerUntil != 0) { + resetBanner(); + return; + } + + if (isPickerIncrementEvent(inEvent.inputEvent)) { + applyPickerDelta(pickerState, curSelected, 1); + } else if (isPickerDecrementEvent(inEvent.inputEvent)) { + applyPickerDelta(pickerState, curSelected, -1); } else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) { - if (inEvent.kbchar > 47 && inEvent.kbchar < 58) { // have a digit - currentNumber -= this_digit * (pow_of_10(numDigits - curSelected - 1)); - currentNumber += (inEvent.kbchar - 48) * (pow_of_10(numDigits - curSelected - 1)); + if (applyPickerKeypress(pickerState, curSelected, inEvent.kbchar)) { curSelected++; } } else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) { curSelected++; } else if (inEvent.inputEvent == INPUT_BROKER_LEFT) { - curSelected--; - } else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && - alertBannerUntil != 0) { - resetBanner(); - return; + curSelected = std::max(0, curSelected - 1); } - if (curSelected == static_cast(numDigits)) { + + currentNumber = static_cast(std::max(0, composePickerValue(pickerState))); + if (curSelected == static_cast(slotCount)) { alertBannerCallback(currentNumber); resetBanner(); return; } inEvent.inputEvent = INPUT_BROKER_NONE; - if (alertBannerMessage[0] == '\0') + if (alertBannerMessage[0] == '\0') { return; - - uint16_t totalLines = lineCount + 2; - const char *linePointers[totalLines + 1] = {0}; // this is sort of a dynamic allocation - - // copy the linestarts to display to the linePointers holder - for (uint16_t i = 0; i < lineCount; i++) { - linePointers[i] = lineStarts[i]; } - std::string digits = " "; - std::string arrowPointer = " "; - for (uint16_t i = 0; i < numDigits; i++) { - // Modulo minus modulo to return just the current number - digits += std::to_string((currentNumber % (pow_of_10(numDigits - i))) / (pow_of_10(numDigits - i - 1))) + " "; - if (curSelected == i) { - arrowPointer += "^ "; - } else { - arrowPointer += "_ "; + + std::string formattedValue; + std::vector slotCharIndexBySlot; + buildNumericPickerDisplay(pickerState, formattedValue, slotCharIndexBySlot); + + NumberPicker(display, state, lineStarts, lineCount, formattedValue, slotCharIndexBySlot.data(), slotCount, curSelected); +} + +void NotificationRenderer::drawSignedDecimalPicker(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + const char *lineStarts[MAX_LINES + 1] = {0}; + uint16_t lineCount = 0; + parseBannerLines(lineStarts, lineCount); + + NumericSlotPickerState pickerState; + pickerState.hasSign = true; + pickerState.decimalDigits = 1; + pickerState.minValue = signedDecimalMinTenths; + pickerState.maxValue = signedDecimalMaxTenths; + pickerState.digits.assign(3, 0); // XX.X format + + const int32_t currentValue = static_cast(signedDecimalValueTenths); + if (currentValue < 0) { + pickerState.isNegative = true; + } else if (currentValue > 0) { + pickerState.isNegative = false; + } else { + pickerState.isNegative = signedDecimalIsNegative; + } + const uint32_t absValue = + (currentValue < 0) ? static_cast(-static_cast(currentValue)) : static_cast(currentValue); + splitPickerDigits(absValue, pickerState.digits); + normalizePickerState(pickerState); + + const uint8_t slotCount = pickerSlotCount(pickerState); + if (curSelected < 0) { + curSelected = 0; + } else if (curSelected > static_cast(slotCount)) { + curSelected = static_cast(slotCount); + } + + if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && alertBannerUntil != 0) { + resetBanner(); + return; + } + + if (isPickerIncrementEvent(inEvent.inputEvent)) { + applyPickerDelta(pickerState, curSelected, 1); + } else if (isPickerDecrementEvent(inEvent.inputEvent)) { + applyPickerDelta(pickerState, curSelected, -1); + } else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) { + if (applyPickerKeypress(pickerState, curSelected, inEvent.kbchar)) { + curSelected++; } + } else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) { + curSelected++; + } else if (inEvent.inputEvent == INPUT_BROKER_LEFT) { + curSelected = std::max(0, curSelected - 1); } - linePointers[lineCount++] = digits.c_str(); - linePointers[lineCount++] = arrowPointer.c_str(); + signedDecimalValueTenths = static_cast(composePickerValue(pickerState)); + signedDecimalIsNegative = pickerState.isNegative; + if (curSelected == static_cast(slotCount)) { + alertBannerCallback(signedDecimalValueTenths); + resetBanner(); + return; + } - drawNotificationBox(display, state, linePointers, totalLines, 0); + inEvent.inputEvent = INPUT_BROKER_NONE; + if (alertBannerMessage[0] == '\0') { + return; + } + + std::string formattedValue; + std::vector slotCharIndexBySlot; + buildNumericPickerDisplay(pickerState, formattedValue, slotCharIndexBySlot); + + NumberPicker(display, state, lineStarts, lineCount, formattedValue, slotCharIndexBySlot.data(), slotCount, curSelected); } void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state) @@ -781,4 +1114,4 @@ void NotificationRenderer::showKeyboardMessagePopupWithTitle(const char *title, } } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index e51bfa5ab..73c44e725 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -5,6 +5,7 @@ #include "graphics/Screen.h" #include "graphics/VirtualKeyboard.h" #include "modules/OnScreenKeyboardModule.h" +#include #include #include #define MAX_LINES 5 @@ -26,6 +27,10 @@ class NotificationRenderer static std::function alertBannerCallback; static uint32_t numDigits; static uint32_t currentNumber; + static int16_t signedDecimalValueTenths; + static int16_t signedDecimalMinTenths; + static int16_t signedDecimalMaxTenths; + static bool signedDecimalIsNegative; static VirtualKeyboard *virtualKeyboard; static std::function textInputCallback; @@ -36,6 +41,7 @@ class NotificationRenderer static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state); + static void drawSignedDecimalPicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1], diff --git a/src/mesh/MeshModule.h b/src/mesh/MeshModule.h index 63f401d18..8febe51bd 100644 --- a/src/mesh/MeshModule.h +++ b/src/mesh/MeshModule.h @@ -81,6 +81,7 @@ class MeshModule static AdminMessageHandleResult handleAdminMessageForAllModules(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, meshtastic_AdminMessage *response); + const char *getName() const { return name; } #if HAS_SCREEN virtual void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { return; } virtual bool isRequestingFocus(); // Checked by screen, when regenerating frameset diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index 67dbe06e7..9312b26af 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -397,6 +397,8 @@ typedef struct _meshtastic_ModuleConfig_TelemetryConfig { bool device_telemetry_enabled; /* Enable/Disable the air quality telemetry measurement module on-device display */ bool air_quality_screen_enabled; + /* Temperature offset in Celsius applied to local environment telemetry before it is sent. */ + float environment_temperature_offset_c; } meshtastic_ModuleConfig_TelemetryConfig; /* Canned Messages Module Config */ @@ -577,7 +579,7 @@ extern "C" { #define meshtastic_ModuleConfig_ExternalNotificationConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StoreForwardConfig_init_default {0, 0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_RangeTestConfig_init_default {0, 0, 0, 0} -#define meshtastic_ModuleConfig_TelemetryConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} +#define meshtastic_ModuleConfig_TelemetryConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_CannedMessageConfig_init_default {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_default {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_default {""} @@ -595,7 +597,7 @@ extern "C" { #define meshtastic_ModuleConfig_ExternalNotificationConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StoreForwardConfig_init_zero {0, 0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_RangeTestConfig_init_zero {0, 0, 0, 0} -#define meshtastic_ModuleConfig_TelemetryConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} +#define meshtastic_ModuleConfig_TelemetryConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_CannedMessageConfig_init_zero {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_zero {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_zero {""} @@ -700,6 +702,7 @@ extern "C" { #define meshtastic_ModuleConfig_TelemetryConfig_health_screen_enabled_tag 13 #define meshtastic_ModuleConfig_TelemetryConfig_device_telemetry_enabled_tag 14 #define meshtastic_ModuleConfig_TelemetryConfig_air_quality_screen_enabled_tag 15 +#define meshtastic_ModuleConfig_TelemetryConfig_environment_temperature_offset_c_tag 16 #define meshtastic_ModuleConfig_CannedMessageConfig_rotary1_enabled_tag 1 #define meshtastic_ModuleConfig_CannedMessageConfig_inputbroker_pin_a_tag 2 #define meshtastic_ModuleConfig_CannedMessageConfig_inputbroker_pin_b_tag 3 @@ -925,7 +928,8 @@ X(a, STATIC, SINGULAR, BOOL, health_measurement_enabled, 11) \ X(a, STATIC, SINGULAR, UINT32, health_update_interval, 12) \ X(a, STATIC, SINGULAR, BOOL, health_screen_enabled, 13) \ X(a, STATIC, SINGULAR, BOOL, device_telemetry_enabled, 14) \ -X(a, STATIC, SINGULAR, BOOL, air_quality_screen_enabled, 15) +X(a, STATIC, SINGULAR, BOOL, air_quality_screen_enabled, 15) \ +X(a, STATIC, SINGULAR, FLOAT, environment_temperature_offset_c, 16) #define meshtastic_ModuleConfig_TelemetryConfig_CALLBACK NULL #define meshtastic_ModuleConfig_TelemetryConfig_DEFAULT NULL @@ -1020,7 +1024,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_SerialConfig_size 28 #define meshtastic_ModuleConfig_StatusMessageConfig_size 81 #define meshtastic_ModuleConfig_StoreForwardConfig_size 24 -#define meshtastic_ModuleConfig_TelemetryConfig_size 50 +#define meshtastic_ModuleConfig_TelemetryConfig_size 55 #define meshtastic_ModuleConfig_TrafficManagementConfig_size 52 #define meshtastic_ModuleConfig_size 227 #define meshtastic_RemoteHardwarePin_size 21 diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 140c2c17e..b65d4a442 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -145,6 +145,33 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "graphics/ScreenFonts.h" #include +namespace +{ +constexpr float TEMPERATURE_OFFSET_MIN_C = -20.0f; +constexpr float TEMPERATURE_OFFSET_MAX_C = 20.0f; + +float clampTemperatureOffsetC(float offsetC) +{ + if (offsetC < TEMPERATURE_OFFSET_MIN_C) + return TEMPERATURE_OFFSET_MIN_C; + if (offsetC > TEMPERATURE_OFFSET_MAX_C) + return TEMPERATURE_OFFSET_MAX_C; + return offsetC; +} + +void applyTemperatureOffset(meshtastic_EnvironmentMetrics *metrics) +{ + const float offsetC = clampTemperatureOffsetC(moduleConfig.telemetry.environment_temperature_offset_c); + if (offsetC == 0.0f) + return; + + if (metrics->has_temperature) + metrics->temperature += offsetC; + if (metrics->has_soil_temperature) + metrics->soil_temperature += offsetC; +} +} // namespace + void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) { if (!moduleConfig.telemetry.environment_measurement_enabled && !ENVIRONMENTAL_TELEMETRY_MODULE_ENABLE) { @@ -571,6 +598,9 @@ bool EnvironmentTelemetryModule::getEnvironmentTelemetry(meshtastic_Telemetry *m valid = valid || get_metrics; hasSensor = true; #endif + if (valid) + applyTemperatureOffset(&m->variant.environment_metrics); + return valid && hasSensor; }