diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 7f5761f3c..422003c94 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -222,8 +222,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read(pad, 2); // 78 file.read((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80 file.read((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84 - file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85 - file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86 + file.read((uint8_t *)&_prefs.display_rotation, sizeof(_prefs.display_rotation)); // 85 file.close(); } @@ -256,8 +255,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write(pad, 2); // 78 file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80 file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84 - file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85 - file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86 + file.write((uint8_t *)&_prefs.display_rotation, sizeof(_prefs.display_rotation)); // 85 file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 56894ddeb..0d243ceb4 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -739,8 +739,6 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.bw = LORA_BW; _prefs.cr = LORA_CR; _prefs.tx_power_dbm = LORA_TX_POWER; - _prefs.gps_enabled = 0; // GPS disabled by default - _prefs.gps_interval = 0; // No automatic GPS updates by default //_prefs.rx_delay_base = 10.0f; enable once new algo fixed } @@ -778,8 +776,7 @@ void MyMesh::begin(bool has_display) { _prefs.sf = constrain(_prefs.sf, 5, 12); _prefs.cr = constrain(_prefs.cr, 5, 8); _prefs.tx_power_dbm = constrain(_prefs.tx_power_dbm, 1, MAX_LORA_TX_POWER); - _prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1 - _prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours + _prefs.display_rotation = constrain(_prefs.display_rotation, 0, 3); #ifdef BLE_PIN_CODE // 123456 by default if (_prefs.ble_pin == 0) { @@ -903,7 +900,6 @@ void MyMesh::handleCmdFrame(size_t len) { int result; uint32_t expected_ack; if (txt_type == TXT_TYPE_CLI_DATA) { - msg_timestamp = getRTCClock()->getCurrentTimeUnique(); // Use node's RTC instead of app timestamp to avoid tripping replay protection result = sendCommandData(*recipient, msg_timestamp, attempt, text, est_timeout); expected_ack = 0; // no Ack expected } else { @@ -1239,7 +1235,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (_store->saveMainIdentity(identity)) { self_id = identity; writeOKFrame(); - // re-load contacts, to invalidate ecdh shared_secrets + // re-load contacts, to recalc shared secrets resetContacts(); _store->loadContacts(this); } else { @@ -1526,17 +1522,6 @@ void MyMesh::handleCmdFrame(size_t len) { *np++ = 0; // modify 'cmd_frame', replace ':' with null bool success = sensors.setSettingValue(sp, np); if (success) { - #if ENV_INCLUDE_GPS == 1 - // Update node preferences for GPS settings - if (strcmp(sp, "gps") == 0) { - _prefs.gps_enabled = (np[0] == '1') ? 1 : 0; - savePrefs(); - } else if (strcmp(sp, "gps_interval") == 0) { - uint32_t interval_seconds = atoi(np); - _prefs.gps_interval = constrain(interval_seconds, 0, 86400); - savePrefs(); - } - #endif writeOKFrame(); } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -1881,4 +1866,4 @@ bool MyMesh::advert() { } else { return false; } -} +} \ No newline at end of file diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index e9db5444f..a2831f95a 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -25,6 +25,5 @@ struct NodePrefs { // persisted to file uint32_t ble_pin; uint8_t advert_loc_policy; uint8_t buzzer_quiet; - uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled) - uint32_t gps_interval; // GPS read interval in seconds + uint8_t display_rotation; // 0=landscape, 1=portrait, 2=landscape flipped, 3=portrait flipped }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8077627f8..cf094131f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2,9 +2,6 @@ #include #include "../MyMesh.h" #include "target.h" -#ifdef WIFI_SSID - #include -#endif #ifndef AUTO_OFF_MILLIS #define AUTO_OFF_MILLIS 15000 // 15 seconds @@ -20,9 +17,16 @@ #define LONG_PRESS_MILLIS 1200 #ifndef UI_RECENT_LIST_SIZE - #define UI_RECENT_LIST_SIZE 4 + #ifdef DISPLAY_PORTRAIT + #define UI_RECENT_LIST_SIZE 8 // Portrait can show more items vertically + #else + #define UI_RECENT_LIST_SIZE 4 + #endif #endif +// Helper to check if display is in portrait orientation +#define IS_PORTRAIT(d) ((d).width() < (d).height()) + #if UI_HAS_JOYSTICK #define PRESS_LABEL "press Enter" #else @@ -35,6 +39,8 @@ class SplashScreen : public UIScreen { UITask* _task; unsigned long dismiss_after; char _version_info[12]; + char _build_date[12]; // "Mon DD" portion + char _build_year[6]; // "YYYY" portion public: SplashScreen(UITask* task) : _task(task) { @@ -48,22 +54,47 @@ class SplashScreen : public UIScreen { memcpy(_version_info, ver, len); _version_info[len] = 0; + // Split build date for portrait mode (e.g., "Dec 31 2025" -> "Dec 31" + "2025") + const char* dateStr = FIRMWARE_BUILD_DATE; + int dateLen = strlen(dateStr); + if (dateLen >= 11) { // "Mon DD YYYY" format + // Copy "Mon DD" (first 6 chars) + memcpy(_build_date, dateStr, 6); + _build_date[6] = 0; + // Copy "YYYY" (last 4 chars) + memcpy(_build_year, dateStr + dateLen - 4, 4); + _build_year[4] = 0; + } else { + strncpy(_build_date, dateStr, sizeof(_build_date) - 1); + _build_date[sizeof(_build_date) - 1] = 0; + _build_year[0] = 0; + } + dismiss_after = millis() + BOOT_SCREEN_MILLIS; } int render(DisplayDriver& display) override { - // meshcore logo - display.setColor(DisplayDriver::BLUE); - int logoWidth = 128; - display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); + if (IS_PORTRAIT(display)) { + // Portrait layout - spread vertically across 128px height + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width()/2, 30, "MeshCore"); + display.drawTextCentered(display.width()/2, 50, _version_info); + display.drawTextCentered(display.width()/2, 75, _build_date); + display.drawTextCentered(display.width()/2, 90, _build_year); + } else { + // Landscape layout (original) + display.setColor(DisplayDriver::BLUE); + int logoWidth = 128; + display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); - // version info - display.setColor(DisplayDriver::LIGHT); - display.setTextSize(2); - display.drawTextCentered(display.width()/2, 22, _version_info); + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(2); + display.drawTextCentered(display.width()/2, 22, _version_info); - display.setTextSize(1); - display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE); + display.setTextSize(1); + display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE); + } return 1000; } @@ -88,6 +119,7 @@ class HomeScreen : public UIScreen { #if UI_SENSORS_PAGE == 1 SENSORS, #endif + FLIP, SHUTDOWN, Count // keep as last }; @@ -101,6 +133,7 @@ class HomeScreen : public UIScreen { AdvertPath recent[UI_RECENT_LIST_SIZE]; + void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { // Convert millivolts to percentage const int minMilliVolts = 3000; // Minimum voltage (e.g., 3.0V) @@ -109,22 +142,31 @@ class HomeScreen : public UIScreen { if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0% if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100% - // battery icon - int iconWidth = 24; - int iconHeight = 10; - int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner - int iconY = 0; display.setColor(DisplayDriver::GREEN); - // battery outline - display.drawRect(iconX, iconY, iconWidth, iconHeight); - - // battery "cap" - display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); - - // fill the battery based on the percentage - int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; - display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); + if (IS_PORTRAIT(display)) { + // Portrait: smaller battery icon (16x8) + int iconWidth = 16; + int iconHeight = 8; + int iconX = display.width() - iconWidth - 2; + int iconY = 1; + + display.drawRect(iconX, iconY, iconWidth, iconHeight); + display.fillRect(iconX + iconWidth, iconY + 2, 2, 4); // cap + int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; + display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); + } else { + // Landscape: original battery icon (24x10) + int iconWidth = 24; + int iconHeight = 10; + int iconX = display.width() - iconWidth - 5; + int iconY = 0; + + display.drawRect(iconX, iconY, iconWidth, iconHeight); + display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); + int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; + display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); + } } CayenneLPP sensors_lpp; @@ -132,7 +174,7 @@ class HomeScreen : public UIScreen { bool sensors_scroll = false; int sensors_scroll_offset = 0; int next_sensors_refresh = 0; - + void refresh_sensors() { if (millis() > next_sensors_refresh) { sensors_lpp.reset(); @@ -156,7 +198,7 @@ class HomeScreen : public UIScreen { public: HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, NodePrefs* node_prefs) - : _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0), + : _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0), _shutdown_init(false), sensors_lpp(200) { } void poll() override { @@ -167,16 +209,23 @@ class HomeScreen : public UIScreen { int render(DisplayDriver& display) override { char tmp[80]; - // node name + // node name and battery display.setTextSize(1); display.setColor(DisplayDriver::GREEN); char filtered_name[sizeof(_node_prefs->node_name)]; display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); - display.setCursor(0, 0); - display.print(filtered_name); - // battery voltage - renderBatteryIndicator(display, _task->getBattMilliVolts()); + if (IS_PORTRAIT(display)) { + // Portrait: truncate name to leave room for battery icon + int max_name_width = display.width() - 20; + display.drawTextEllipsized(0, 0, max_name_width, filtered_name); + renderBatteryIndicator(display, _task->getBattMilliVolts()); + } else { + // Landscape: name left, battery right on same line + display.setCursor(0, 0); + display.print(filtered_name); + renderBatteryIndicator(display, _task->getBattMilliVolts()); + } // curr page indicator int y = 14; @@ -190,120 +239,260 @@ class HomeScreen : public UIScreen { } if (_page == HomePage::FIRST) { - display.setColor(DisplayDriver::YELLOW); - display.setTextSize(2); - sprintf(tmp, "MSG: %d", _task->getMsgCount()); - display.drawTextCentered(display.width() / 2, 20, tmp); - - #ifdef WIFI_SSID - IPAddress ip = WiFi.localIP(); - snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + if (IS_PORTRAIT(display)) { + // Portrait: use vertical space, larger msg count + display.setColor(DisplayDriver::YELLOW); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 54, tmp); - #endif - if (_task->hasConnection()) { - display.setColor(DisplayDriver::GREEN); - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 43, "< Connected >"); - - } else if (the_mesh.getBLEPin() != 0) { // BT pin - display.setColor(DisplayDriver::RED); + display.drawTextCentered(display.width() / 2, 25, "MSG"); display.setTextSize(2); - sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); - display.drawTextCentered(display.width() / 2, 43, tmp); + sprintf(tmp, "%d", _task->getMsgCount()); + display.drawTextCentered(display.width() / 2, 45, tmp); + + if (_task->hasConnection()) { + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 75, "Connected"); + } else if (the_mesh.getBLEPin() != 0) { // BT pin + display.setColor(DisplayDriver::RED); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 70, "Pin:"); + display.setTextSize(2); + sprintf(tmp, "%d", the_mesh.getBLEPin()); + display.drawTextCentered(display.width() / 2, 85, tmp); + } + } else { + // Landscape: original layout + display.setColor(DisplayDriver::YELLOW); + display.setTextSize(2); + sprintf(tmp, "MSG: %d", _task->getMsgCount()); + display.drawTextCentered(display.width() / 2, 20, tmp); + + if (_task->hasConnection()) { + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 43, "Connected"); + } else if (the_mesh.getBLEPin() != 0) { // BT pin + display.setColor(DisplayDriver::RED); + display.setTextSize(2); + sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); + display.drawTextCentered(display.width() / 2, 43, tmp); + } } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); display.setColor(DisplayDriver::GREEN); - int y = 20; - for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { - auto a = &recent[i]; - if (a->name[0] == 0) continue; // empty slot - int secs = _rtc->getCurrentTime() - a->recv_timestamp; - if (secs < 60) { - sprintf(tmp, "%ds", secs); - } else if (secs < 60*60) { - sprintf(tmp, "%dm", secs / 60); - } else { - sprintf(tmp, "%dh", secs / (60*60)); + + if (IS_PORTRAIT(display)) { + // Portrait: two-line layout per entry (name on line 1, timestamp on line 2) + int y = 20; + int entries_shown = 0; + int max_entries = 5; // Fits nicely in 128px height with 2 lines per entry + + for (int i = 0; i < UI_RECENT_LIST_SIZE && entries_shown < max_entries; i++) { + auto a = &recent[i]; + if (a->name[0] == 0) continue; // empty slot + + int secs = _rtc->getCurrentTime() - a->recv_timestamp; + if (secs < 60) { + sprintf(tmp, "%ds ago", secs); + } else if (secs < 60*60) { + sprintf(tmp, "%dm ago", secs / 60); + } else { + sprintf(tmp, "%dh ago", secs / (60*60)); + } + + // Line 1: name (truncated to fit width) + char filtered_recent_name[sizeof(a->name)]; + display.translateUTF8ToBlocks(filtered_recent_name, a->name, sizeof(filtered_recent_name)); + display.drawTextEllipsized(0, y, display.width() - 2, filtered_recent_name); + + // Line 2: timestamp (right-aligned, slightly indented) + int timestamp_width = display.getTextWidth(tmp); + display.setCursor(display.width() - timestamp_width - 1, y + 10); + display.print(tmp); + + y += 22; // Move to next entry (2 lines × ~10px + 2px gap) + entries_shown++; + } + } else { + // Landscape: original single-line layout + int y = 20; + for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { + auto a = &recent[i]; + if (a->name[0] == 0) continue; // empty slot + int secs = _rtc->getCurrentTime() - a->recv_timestamp; + if (secs < 60) { + sprintf(tmp, "%ds", secs); + } else if (secs < 60*60) { + sprintf(tmp, "%dm", secs / 60); + } else { + sprintf(tmp, "%dh", secs / (60*60)); + } + + int timestamp_width = display.getTextWidth(tmp); + int max_name_width = display.width() - timestamp_width - 1; + + char filtered_recent_name[sizeof(a->name)]; + display.translateUTF8ToBlocks(filtered_recent_name, a->name, sizeof(filtered_recent_name)); + display.drawTextEllipsized(0, y, max_name_width, filtered_recent_name); + display.setCursor(display.width() - timestamp_width - 1, y); + display.print(tmp); } - - int timestamp_width = display.getTextWidth(tmp); - int max_name_width = display.width() - timestamp_width - 1; - - char filtered_recent_name[sizeof(a->name)]; - display.translateUTF8ToBlocks(filtered_recent_name, a->name, sizeof(filtered_recent_name)); - display.drawTextEllipsized(0, y, max_name_width, filtered_recent_name); - display.setCursor(display.width() - timestamp_width - 1, y); - display.print(tmp); } } else if (_page == HomePage::RADIO) { display.setColor(DisplayDriver::YELLOW); display.setTextSize(1); - // freq / sf - display.setCursor(0, 20); - sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf); - display.print(tmp); - - display.setCursor(0, 31); - sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); - display.print(tmp); - - // tx power, noise floor - display.setCursor(0, 42); - sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); - display.print(tmp); - display.setCursor(0, 53); - sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); - display.print(tmp); + + if (IS_PORTRAIT(display)) { + // Portrait: stack each value on its own line + int y = 20; + sprintf(tmp, "FQ: %.3f", _node_prefs->freq); + display.setCursor(0, y); display.print(tmp); + y += 12; + sprintf(tmp, "SF: %d", _node_prefs->sf); + display.setCursor(0, y); display.print(tmp); + y += 12; + sprintf(tmp, "BW: %.2f", _node_prefs->bw); + display.setCursor(0, y); display.print(tmp); + y += 12; + sprintf(tmp, "CR: %d", _node_prefs->cr); + display.setCursor(0, y); display.print(tmp); + y += 12; + sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); + display.setCursor(0, y); display.print(tmp); + y += 12; + sprintf(tmp, "NF: %d", radio_driver.getNoiseFloor()); + display.setCursor(0, y); display.print(tmp); + } else { + // Landscape: original layout + display.setCursor(0, 20); + sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf); + display.print(tmp); + + display.setCursor(0, 31); + sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); + display.print(tmp); + + display.setCursor(0, 42); + sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); + display.print(tmp); + display.setCursor(0, 53); + sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); + display.print(tmp); + } } else if (_page == HomePage::BLUETOOTH) { display.setColor(DisplayDriver::GREEN); - display.drawXbm((display.width() - 32) / 2, 18, - _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, - 32, 32); - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); + if (IS_PORTRAIT(display)) { + // Portrait: icon centered vertically, text below + display.drawXbm((display.width() - 32) / 2, 35, + _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, + 32, 32); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 75, "toggle:"); + display.drawTextCentered(display.width() / 2, 87, PRESS_LABEL); + } else { + display.drawXbm((display.width() - 32) / 2, 18, + _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, + 32, 32); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); + } } else if (_page == HomePage::ADVERT) { display.setColor(DisplayDriver::GREEN); - display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); - display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL); + if (IS_PORTRAIT(display)) { + // Portrait: icon centered vertically, text below + display.drawXbm((display.width() - 32) / 2, 35, advert_icon, 32, 32); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 75, "advert:"); + display.drawTextCentered(display.width() / 2, 87, PRESS_LABEL); + } else { + display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL); + } #if ENV_INCLUDE_GPS == 1 } else if (_page == HomePage::GPS) { LocationProvider* nmea = sensors.getLocationProvider(); char buf[50]; - int y = 18; bool gps_state = _task->getGPSState(); + + if (IS_PORTRAIT(display)) { + // Portrait: more vertical spacing, split lat/lon + int y = 20; + int lineSpacing = 14; + #ifdef PIN_GPS_SWITCH - bool hw_gps_state = digitalRead(PIN_GPS_SWITCH); - if (gps_state != hw_gps_state) { - strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)"); - } else { - strcpy(buf, gps_state ? "gps on" : "gps off"); - } + bool hw_gps_state = digitalRead(PIN_GPS_SWITCH); + if (gps_state != hw_gps_state) { + strcpy(buf, gps_state ? "off(hw)" : "off(sw)"); + } else { + strcpy(buf, gps_state ? "on" : "off"); + } #else - strcpy(buf, gps_state ? "gps on" : "gps off"); + strcpy(buf, gps_state ? "on" : "off"); #endif - display.drawTextLeftAlign(0, y, buf); - if (nmea == NULL) { - y = y + 12; - display.drawTextLeftAlign(0, y, "Can't access GPS"); - } else { - strcpy(buf, nmea->isValid()?"fix":"no fix"); - display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; - display.drawTextLeftAlign(0, y, "sat"); - sprintf(buf, "%d", nmea->satellitesCount()); - display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; - display.drawTextLeftAlign(0, y, "pos"); - sprintf(buf, "%.4f %.4f", - nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.); + display.drawTextLeftAlign(0, y, "GPS:"); display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; - display.drawTextLeftAlign(0, y, "alt"); - sprintf(buf, "%.2f", nmea->getAltitude()/1000.); - display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + + if (nmea == NULL) { + y += lineSpacing; + display.drawTextLeftAlign(0, y, "No GPS"); + } else { + y += lineSpacing; + display.drawTextLeftAlign(0, y, nmea->isValid() ? "Fix" : "No fix"); + sprintf(buf, "%d sat", nmea->satellitesCount()); + display.drawTextRightAlign(display.width()-1, y, buf); + + y += lineSpacing; + sprintf(buf, "%.4f", nmea->getLatitude()/1000000.); + display.drawTextLeftAlign(0, y, "Lat:"); + display.drawTextRightAlign(display.width()-1, y, buf); + + y += lineSpacing; + sprintf(buf, "%.4f", nmea->getLongitude()/1000000.); + display.drawTextLeftAlign(0, y, "Lon:"); + display.drawTextRightAlign(display.width()-1, y, buf); + + y += lineSpacing; + sprintf(buf, "%.1f", nmea->getAltitude()/1000.); + display.drawTextLeftAlign(0, y, "Alt:"); + display.drawTextRightAlign(display.width()-1, y, buf); + } + } else { + // Landscape: original layout + int y = 18; +#ifdef PIN_GPS_SWITCH + bool hw_gps_state = digitalRead(PIN_GPS_SWITCH); + if (gps_state != hw_gps_state) { + strcpy(buf, gps_state ? "gps off(hw)" : "gps off(sw)"); + } else { + strcpy(buf, gps_state ? "gps on" : "gps off"); + } +#else + strcpy(buf, gps_state ? "gps on" : "gps off"); +#endif + display.drawTextLeftAlign(0, y, buf); + if (nmea == NULL) { + y = y + 12; + display.drawTextLeftAlign(0, y, "Can't access GPS"); + } else { + strcpy(buf, nmea->isValid()?"fix":"no fix"); + display.drawTextRightAlign(display.width()-1, y, buf); + y = y + 12; + display.drawTextLeftAlign(0, y, "sat"); + sprintf(buf, "%d", nmea->satellitesCount()); + display.drawTextRightAlign(display.width()-1, y, buf); + y = y + 12; + display.drawTextLeftAlign(0, y, "pos"); + sprintf(buf, "%.4f %.4f", + nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.); + display.drawTextRightAlign(display.width()-1, y, buf); + y = y + 12; + display.drawTextLeftAlign(0, y, "alt"); + sprintf(buf, "%.2f", nmea->getAltitude()/1000.); + display.drawTextRightAlign(display.width()-1, y, buf); + y = y + 12; + } } #endif #if UI_SENSORS_PAGE == 1 @@ -378,14 +567,35 @@ class HomeScreen : public UIScreen { if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; else sensors_scroll_offset = 0; #endif + } else if (_page == HomePage::FLIP) { + display.setColor(DisplayDriver::GREEN); + if (IS_PORTRAIT(display)) { + // Portrait: icon centered vertically, text below + display.drawXbm((display.width() - 32) / 2, 35, flip_icon, 32, 32); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 75, "flip:"); + display.drawTextCentered(display.width() / 2, 87, PRESS_LABEL); + } else { + display.drawXbm((display.width() - 32) / 2, 18, flip_icon, 32, 32); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 64 - 11, "flip: " PRESS_LABEL); + } } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::GREEN); display.setTextSize(1); if (_shutdown_init) { - display.drawTextCentered(display.width() / 2, 34, "hibernating..."); + int textY = IS_PORTRAIT(display) ? 60 : 34; + display.drawTextCentered(display.width() / 2, textY, "hibernating..."); } else { - display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32); - display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); + if (IS_PORTRAIT(display)) { + // Portrait: icon centered vertically, text below + display.drawXbm((display.width() - 32) / 2, 35, power_icon, 32, 32); + display.drawTextCentered(display.width() / 2, 75, "hibernate:"); + display.drawTextCentered(display.width() / 2, 87, PRESS_LABEL); + } else { + display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32); + display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); + } } } return 5000; // next render after 5000 ms @@ -433,6 +643,10 @@ class HomeScreen : public UIScreen { return true; } #endif + if (c == KEY_ENTER && _page == HomePage::FLIP) { + _task->flipDisplay(); + return true; + } if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { _shutdown_init = true; // need to wait for button to be released return true; @@ -547,21 +761,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #endif _node_prefs = node_prefs; - -#if ENV_INCLUDE_GPS == 1 - // Apply GPS preferences from stored prefs - if (_sensors != NULL && _node_prefs != NULL) { - _sensors->setSettingValue("gps", _node_prefs->gps_enabled ? "1" : "0"); - if (_node_prefs->gps_interval > 0) { - char interval_str[12]; // Max: 24 hours = 86400 seconds (5 digits + null) - sprintf(interval_str, "%u", _node_prefs->gps_interval); - _sensors->setSettingValue("gps_interval", interval_str); - } - } -#endif - if (_display != NULL) { _display->turnOn(); + // Apply saved display rotation + _display->setRotation(_node_prefs->display_rotation); } #ifdef PIN_BUZZER @@ -631,13 +834,9 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i setCurrScreen(msg_preview); if (_display != NULL) { - if (!_display->isOn() && !hasConnection()) { - _display->turnOn(); - } - if (_display->isOn()) { + if (!_display->isOn()) _display->turnOn(); _auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer _next_refresh = 100; // trigger refresh - } } } @@ -786,13 +985,51 @@ void UITask::loop() { int delay_millis = curr->render(*_display); if (millis() < _alert_expiry) { // render alert popup _display->setTextSize(1); - int y = _display->height() / 3; - int p = _display->height() / 32; - _display->setColor(DisplayDriver::DARK); - _display->fillRect(p, y, _display->width() - p*2, y); - _display->setColor(DisplayDriver::LIGHT); // draw box border - _display->drawRect(p, y, _display->width() - p*2, y); - _display->drawTextCentered(_display->width() / 2, y + p*3, _alert); + + if (IS_PORTRAIT(*_display)) { + // Portrait: split alert text into words, one per line + char alertCopy[64]; + strncpy(alertCopy, _alert, sizeof(alertCopy) - 1); + alertCopy[sizeof(alertCopy) - 1] = 0; + + // Count words + int wordCount = 0; + char* words[4]; // Max 4 words + char* token = strtok(alertCopy, " "); + while (token != NULL && wordCount < 4) { + words[wordCount++] = token; + token = strtok(NULL, " "); + } + + int lineHeight = 12; + int boxHeight = wordCount * lineHeight + 8; + int boxY = (_display->height() - boxHeight) / 2; + int boxX = 2; + int boxWidth = _display->width() - 4; + + _display->setColor(DisplayDriver::DARK); + _display->fillRect(boxX, boxY, boxWidth, boxHeight); + _display->setColor(DisplayDriver::LIGHT); + _display->drawRect(boxX, boxY, boxWidth, boxHeight); + + // Draw each word centered + int textY = boxY + 4; + for (int i = 0; i < wordCount; i++) { + _display->drawTextCentered(_display->width() / 2, textY, words[i]); + textY += lineHeight; + } + } else { + // Landscape: single line alert + int boxHeight = 24; + int boxY = (_display->height() - boxHeight) / 2; + int boxX = 2; + int boxWidth = _display->width() - 4; + _display->setColor(DisplayDriver::DARK); + _display->fillRect(boxX, boxY, boxWidth, boxHeight); + _display->setColor(DisplayDriver::LIGHT); + _display->drawRect(boxX, boxY, boxWidth, boxHeight); + _display->drawTextCentered(_display->width() / 2, boxY + 8, _alert); + } _next_refresh = _alert_expiry; // will need refresh when alert is dismissed } else { _next_refresh = millis() + delay_millis; @@ -890,15 +1127,13 @@ void UITask::toggleGPS() { if (strcmp(_sensors->getSettingName(i), "gps") == 0) { if (strcmp(_sensors->getSettingValue(i), "1") == 0) { _sensors->setSettingValue("gps", "0"); - _node_prefs->gps_enabled = 0; notify(UIEventType::ack); + showAlert("GPS: Disabled", 800); } else { _sensors->setSettingValue("gps", "1"); - _node_prefs->gps_enabled = 1; notify(UIEventType::ack); + showAlert("GPS: Enabled", 800); } - the_mesh.savePrefs(); - showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); _next_refresh = 0; break; } @@ -912,12 +1147,24 @@ void UITask::toggleBuzzer() { if (buzzer.isQuiet()) { buzzer.quiet(false); notify(UIEventType::ack); + showAlert("Buzzer: ON", 800); } else { buzzer.quiet(true); + showAlert("Buzzer: OFF", 800); } _node_prefs->buzzer_quiet = buzzer.isQuiet(); the_mesh.savePrefs(); - showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); _next_refresh = 0; // trigger refresh #endif } + +void UITask::flipDisplay() { + if (_display != NULL) { + _display->flipOrientation(); + // Save the new rotation to prefs + _node_prefs->display_rotation = _display->getRotation(); + the_mesh.savePrefs(); + notify(UIEventType::ack); + _next_refresh = 0; // trigger immediate refresh + } +} diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 02c3cafbd..f805ab65b 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -81,6 +81,7 @@ class UITask : public AbstractUITask { void toggleBuzzer(); bool getGPSState(); void toggleGPS(); + void flipDisplay(); // from AbstractUITask diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 5220f4090..de9603f08 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -115,4 +115,19 @@ static const uint8_t advert_icon[] = { 0x38, 0x00, 0x00, 0x1C, 0x18, 0x00, 0x00, 0x18, 0x0C, 0x00, 0x00, 0x30, 0x04, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +// Circular arrow (rotate/flip), 32x32px +static const uint8_t flip_icon[] = { +0x00, 0x0F, 0xF0, 0x00, 0x00, 0x7F, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0x80, +0x03, 0xF0, 0x0F, 0xC0, 0x07, 0xC0, 0x03, 0xE0, 0x0F, 0x00, 0x00, 0xF0, +0x1E, 0x00, 0x00, 0x78, 0x1C, 0x00, 0x00, 0x38, 0x38, 0x00, 0x00, 0x1C, +0x38, 0x00, 0x07, 0x1C, 0x70, 0x00, 0x0F, 0x8E, 0x70, 0x00, 0x1F, 0xCE, +0x70, 0x00, 0x3F, 0xEE, 0x60, 0x00, 0x07, 0x06, 0x60, 0x00, 0x07, 0x06, +0x60, 0x00, 0x07, 0x06, 0x60, 0xE0, 0x00, 0x06, 0x77, 0xFC, 0x00, 0x0E, +0x73, 0xF8, 0x00, 0x0E, 0x71, 0xF0, 0x00, 0x0E, 0x38, 0xE0, 0x00, 0x1C, +0x38, 0x00, 0x00, 0x1C, 0x1C, 0x00, 0x00, 0x38, 0x1E, 0x00, 0x00, 0x78, +0x0F, 0x00, 0x00, 0xF0, 0x07, 0xC0, 0x03, 0xE0, 0x03, 0xF0, 0x0F, 0xC0, +0x01, 0xFF, 0xFF, 0x80, 0x00, 0x7F, 0xFE, 0x00, 0x00, 0x0F, 0xF0, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, }; \ No newline at end of file diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index ec63c1912..a9c2400c1 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -7,6 +7,7 @@ class DisplayDriver { int _w, _h; protected: DisplayDriver(int w, int h) { _w = w; _h = h; } + void setDimensions(int w, int h) { _w = w; _h = h; } public: enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light @@ -97,4 +98,9 @@ class DisplayDriver { } virtual void endFrame() = 0; + + // Runtime orientation support (optional overrides) + virtual void flipOrientation() { } + virtual void setRotation(uint8_t r) { } + virtual uint8_t getRotation() { return 0; } }; diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index c9da0cf8d..bcc918d88 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -75,3 +75,31 @@ uint16_t SSD1306Display::getTextWidth(const char* str) { void SSD1306Display::endFrame() { display.display(); } + +void SSD1306Display::setRotation(uint8_t r) { + display.setRotation(r); + // Update width/height based on rotation + if (r == 0 || r == 2) { + // Landscape: 128x64 + setDimensions(128, 64); + } else { + // Portrait: 64x128 + setDimensions(64, 128); + } +} + +uint8_t SSD1306Display::getRotation() { + return display.getRotation(); +} + +void SSD1306Display::flipOrientation() { + // Toggle between portrait (rotation 1) and landscape (rotation 0) + uint8_t currentRotation = display.getRotation(); + if (currentRotation == 0 || currentRotation == 2) { + // Currently landscape, switch to portrait + setRotation(1); + } else { + // Currently portrait, switch to landscape + setRotation(0); + } +} diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index 1a3a9602b..e0cb7bbe8 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -21,6 +21,7 @@ class SSD1306Display : public DisplayDriver { bool i2c_probe(TwoWire& wire, uint8_t addr); public: + // Always start in landscape mode (128x64) - user can flip to portrait at runtime SSD1306Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { _isOn = false; } bool begin(); @@ -38,4 +39,9 @@ class SSD1306Display : public DisplayDriver { void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; uint16_t getTextWidth(const char* str) override; void endFrame() override; + + // Runtime rotation support + void setRotation(uint8_t r) override; + uint8_t getRotation() override; + void flipOrientation() override; // Toggle between portrait and landscape }; diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index 36c6386f6..c8f437d50 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -178,6 +178,29 @@ lib_deps = ${Heltec_lora32_v3.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:Heltec_v3_companion_radio_ble_H1] +; Build for Muzi Works H1 case - supports runtime orientation flip +extends = Heltec_lora32_v3 +build_flags = + ${Heltec_lora32_v3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D BLE_PIN_CODE=123456 + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Heltec_lora32_v3.lib_deps} + densaugeo/base64 @ ~1.4.0 + [env:Heltec_v3_companion_radio_wifi] extends = Heltec_lora32_v3 build_flags =