From 56b10f70a1ca51ee848b5fdd9ebc969ac2a128f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steve=20Calv=C3=A1rio?= <12759754+Calvario@users.noreply.github.com> Date: Wed, 20 May 2026 20:40:58 +0100 Subject: [PATCH] Init --- zephcore/CMakeLists.txt | 34 +- zephcore/Kconfig | 27 + .../adapters/datastore/ZephyrDataStore.cpp | 40 +- zephcore/app/CompanionMesh.cpp | 133 +- zephcore/app/CompanionMesh.h | 14 + zephcore/app/RepeaterMesh.cpp | 3 - .../wio_tracker_l1/Kconfig.wio_tracker_l1 | 1 + zephcore/helpers/AdvertDataHelpers.h | 4 + zephcore/helpers/BaseChatMesh.cpp | 8 +- zephcore/helpers/BaseChatMesh.h | 3 + zephcore/helpers/ContactInfo.h | 1 + zephcore/helpers/NodePrefs.h | 5 + zephcore/helpers/{ui => ui-button}/ui_pages.c | 79 +- zephcore/helpers/{ui => ui-button}/ui_pages.h | 6 - zephcore/helpers/{ui => ui-button}/ui_task.c | 196 +- zephcore/helpers/ui-joystick/joystick_defs.h | 49 + .../helpers/ui-joystick/joystick_display.h | 156 ++ .../helpers/ui-joystick/joystick_screens.h | 484 +++++ .../helpers/ui-joystick/joystick_ui_hooks.cpp | 288 +++ .../helpers/ui-joystick/joystick_ui_hooks.h | 110 ++ .../helpers/ui-joystick/joystick_ui_task.cpp | 1061 +++++++++++ .../helpers/ui-joystick/joystick_ui_task.h | 299 ++++ .../helpers/ui-joystick/screens/contacts.cpp | 1576 +++++++++++++++++ zephcore/helpers/ui-joystick/screens/home.cpp | 243 +++ .../helpers/ui-joystick/screens/input.cpp | 151 ++ .../helpers/ui-joystick/screens/messaging.cpp | 556 ++++++ .../ui-joystick/screens/screen_helpers.h | 547 ++++++ .../helpers/ui-joystick/screens/system.cpp | 885 +++++++++ .../helpers/ui-joystick/screens/tools.cpp | 591 +++++++ zephcore/helpers/ui/display.c | 65 +- zephcore/helpers/ui/display.h | 22 + zephcore/helpers/ui/ui_common.c | 196 ++ zephcore/helpers/ui/ui_mesh_actions.cpp | 94 +- zephcore/helpers/ui/ui_mesh_actions.h | 4 + zephcore/helpers/ui/ui_task.h | 40 + zephcore/src/Mesh.cpp | 11 +- zephcore/src/main_companion.cpp | 45 +- 37 files changed, 7769 insertions(+), 258 deletions(-) rename zephcore/helpers/{ui => ui-button}/ui_pages.c (90%) rename zephcore/helpers/{ui => ui-button}/ui_pages.h (94%) rename zephcore/helpers/{ui => ui-button}/ui_task.c (84%) create mode 100644 zephcore/helpers/ui-joystick/joystick_defs.h create mode 100644 zephcore/helpers/ui-joystick/joystick_display.h create mode 100644 zephcore/helpers/ui-joystick/joystick_screens.h create mode 100644 zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp create mode 100644 zephcore/helpers/ui-joystick/joystick_ui_hooks.h create mode 100644 zephcore/helpers/ui-joystick/joystick_ui_task.cpp create mode 100644 zephcore/helpers/ui-joystick/joystick_ui_task.h create mode 100644 zephcore/helpers/ui-joystick/screens/contacts.cpp create mode 100644 zephcore/helpers/ui-joystick/screens/home.cpp create mode 100644 zephcore/helpers/ui-joystick/screens/input.cpp create mode 100644 zephcore/helpers/ui-joystick/screens/messaging.cpp create mode 100644 zephcore/helpers/ui-joystick/screens/screen_helpers.h create mode 100644 zephcore/helpers/ui-joystick/screens/system.cpp create mode 100644 zephcore/helpers/ui-joystick/screens/tools.cpp create mode 100644 zephcore/helpers/ui/ui_common.c diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 85c5661..6cde237 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -535,7 +535,6 @@ endif() if(CONFIG_ZEPHCORE_UI_DISPLAY) target_sources(app PRIVATE helpers/ui/display.c - helpers/ui/ui_pages.c helpers/ui/cfb_font_0608.c ) endif() @@ -548,7 +547,7 @@ endif() if(CONFIG_ZEPHCORE_UI_BUTTONS OR CONFIG_ZEPHCORE_UI_BUZZER OR CONFIG_ZEPHCORE_UI_DISPLAY) target_sources(app PRIVATE - helpers/ui/ui_task.c + helpers/ui/ui_common.c ) target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/helpers/ui @@ -561,6 +560,37 @@ if(CONFIG_ZEPHCORE_UI_BUTTONS OR CONFIG_ZEPHCORE_UI_BUZZER OR CONFIG_ZEPHCORE_UI endif() endif() +if(CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) + target_sources(app PRIVATE + helpers/ui-button/ui_task.c + ) + target_include_directories(app PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/helpers/ui-button + ) +endif() + +if(CONFIG_ZEPHCORE_UI_DISPLAY AND CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) + target_sources(app PRIVATE + helpers/ui-button/ui_pages.c + ) +endif() + +if(CONFIG_ZEPHCORE_UI_DISPLAY AND CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + target_sources(app PRIVATE + helpers/ui-joystick/joystick_ui_task.cpp + helpers/ui-joystick/joystick_ui_hooks.cpp + helpers/ui-joystick/screens/home.cpp + helpers/ui-joystick/screens/contacts.cpp + helpers/ui-joystick/screens/messaging.cpp + helpers/ui-joystick/screens/tools.cpp + helpers/ui-joystick/screens/system.cpp + helpers/ui-joystick/screens/input.cpp + ) + target_include_directories(app PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/helpers/ui-joystick + ) +endif() + if(CONFIG_ZEPHCORE_EASTER_EGG_DOOM) target_sources(app PRIVATE helpers/ui/doom_game.c diff --git a/zephcore/Kconfig b/zephcore/Kconfig index b0e3b39..a31d357 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -604,6 +604,33 @@ config ZEPHCORE_EASTER_EGG_DOOM on the first page. Double-click to exit. Adds ~5KB flash and ~1.7KB RAM when enabled. +config ZEPHCORE_UI_JOYSTICK + bool + help + Set by board-level Kconfig when the hardware has a 5-way joystick or + D-pad. Not user-selectable; set by the board configuration. + Follows the same pattern as ZEPHCORE_UI_BUTTONS and ZEPHCORE_UI_DISPLAY. + +config ZEPHCORE_UI_DESIGN_BUTTON + bool "Button-based page UI" + default y if (ZEPHCORE_UI_BUTTONS || ZEPHCORE_UI_DISPLAY || ZEPHCORE_UI_BUZZER) && !ZEPHCORE_UI_DESIGN_JOYSTICK + depends on (ZEPHCORE_UI_BUTTONS || ZEPHCORE_UI_DISPLAY || ZEPHCORE_UI_BUZZER) && !ZEPHCORE_UI_DESIGN_JOYSTICK + help + Compile the default page-based button UI (helpers/ui-button/). + Auto-selected when any UI hardware is present and the joystick UI + design is not active. Disable to supply a custom ui_task.h + implementation without touching this Kconfig. + +config ZEPHCORE_UI_DESIGN_JOYSTICK + bool "Joystick-based companion UI" + default y if ZEPHCORE_ROLE_COMPANION && ZEPHCORE_UI_DISPLAY && ZEPHCORE_UI_BUTTONS && ZEPHCORE_UI_JOYSTICK + depends on ZEPHCORE_ROLE_COMPANION && ZEPHCORE_UI_DISPLAY && ZEPHCORE_UI_BUTTONS && ZEPHCORE_UI_JOYSTICK + help + Full joystick-driven companion UI (helpers/ui-joystick/). Provides GPS, + contacts, channels, snake game, repeater admin, and all other companion + screens via a 5-way joystick or D-pad. Replaces ZEPHCORE_UI_DESIGN_BUTTON + for companion builds on boards with joystick hardware. + endmenu endmenu diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 39c37f1..1076e68 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -493,6 +493,35 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) } else { memset(prefs.default_scope_key, 0, sizeof(prefs.default_scope_key)); } + + /* Offset 143: ble_disabled (ZephCore extension) */ + if (off < len) { + prefs.ble_disabled = buf[off++]; + } else { + prefs.ble_disabled = 0; + } + + /* Offset 144: display_brightness (ZephCore extension, 0 = default 100%) */ + if (off < len) { + prefs.display_brightness = buf[off++]; + } else { + prefs.display_brightness = 0; + } + + /* Offset 145: wake_on_msg (ZephCore extension, 0 = don't wake, 1 = wake on message) */ + if (off < len) { + prefs.wake_on_msg = buf[off++]; + } else { + prefs.wake_on_msg = 1; + } + + /* Offset 146: screen_off_secs (ZephCore extension, 2 bytes LE, 0 = Kconfig default) */ + if (off + 2 <= len) { + prefs.screen_off_secs = (uint16_t)buf[off] | ((uint16_t)buf[off + 1] << 8); + off += 2; + } else { + prefs.screen_off_secs = 0; + } } void ZephyrDataStore::savePrefs(const NodePrefs &prefs) @@ -553,7 +582,16 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) /* Offset 127: default_scope_key (16 bytes) */ memcpy(&buf[off], prefs.default_scope_key, 16); off += 16; - /* Total: 143 bytes */ + /* Offset 143: ble_disabled (ZephCore extension) */ + buf[off++] = prefs.ble_disabled; + /* Offset 144: display_brightness (ZephCore extension) */ + buf[off++] = prefs.display_brightness; + /* Offset 145: wake_on_msg (ZephCore extension) */ + buf[off++] = prefs.wake_on_msg; + /* Offset 146: screen_off_secs (ZephCore extension, 2 bytes LE) */ + buf[off++] = prefs.screen_off_secs & 0xFF; + buf[off++] = (prefs.screen_off_secs >> 8) & 0xFF; + /* Total: 148 bytes */ bool ok = atomicReplaceFile(PREFS_FILE, buf, off); LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 3f6ab0c..f174637 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -16,10 +16,13 @@ #include #include #include -#if IS_ENABLED(CONFIG_ZEPHCORE_UI_BUTTONS) || IS_ENABLED(CONFIG_ZEPHCORE_UI_BUZZER) || IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) || IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) #include #define ZEPHCORE_HAS_UI_TASK 1 #endif +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) +#include +#endif LOG_MODULE_REGISTER(zephcore_companion, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); /* Protocol commands (matches Arduino companion_radio) - sorted by opcode */ @@ -131,6 +134,16 @@ static inline void put_le16(uint8_t *p, uint16_t v) { p[0] = v & 0xFF; p[1] = (v static inline void put_le32(uint8_t *p, uint32_t v) { p[0] = v & 0xFF; p[1] = (v >> 8) & 0xFF; p[2] = (v >> 16) & 0xFF; p[3] = (v >> 24) & 0xFF; } static inline uint32_t get_le32(const uint8_t *p) { return p[0] | (p[1] << 8) | (p[2] << 16) | (p[3] << 24); } +/* UI notification helper => path_len extraction + uniform call through ui_task.h */ +#if ZEPHCORE_HAS_UI_TASK +static void notify_contact_msg_ui( + const ContactInfo &contact, mesh::Packet *pkt, const char *text, int queue_count +){ + uint8_t path_len = (pkt && pkt->isRouteFlood()) ? pkt->path_len : OUT_PATH_UNKNOWN; + ui_notify_contact_msg(path_len, contact.name, text, (uint16_t)queue_count); +} +#endif + /* Allowed client repeat frequency ranges (matches Arduino) */ struct FreqRange { uint32_t lower_freq, upper_freq; @@ -182,6 +195,8 @@ CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mes _pending_telemetry = 0; _pending_discovery = 0; _pending_req = 0; + _pending_joystick_ping_tag = 0; + _pending_joystick_admin_tag = 0; _pending_channel_head = 0; _pending_channel_tail = 0; _pending_channel_count = 0; @@ -309,6 +324,16 @@ void CompanionMesh::loop() } } +void CompanionMesh::onLoginSent(const ContactInfo &contact) +{ + memcpy(&_pending_login, contact.id.pub_key, 4); +} + +void CompanionMesh::onChannelAdded(ChannelDetails *) +{ + markChannelsDirty(); +} + void CompanionMesh::markContactsDirty() { /* Only set the timer on first dirty — don't keep pushing @@ -687,8 +712,7 @@ void CompanionMesh::onMessageRecv(const ContactInfo &contact, mesh::Packet *pkt, queueContactMessage(contact, pkt, TXT_TYPE_PLAIN, sender_timestamp, nullptr, 0, text); sendPush(PUSH_CODE_MSG_WAITING); #if ZEPHCORE_HAS_UI_TASK - ui_set_msg_count((uint16_t)_offline_queue_count); - ui_notify(UI_EVENT_CONTACT_MSG); + notify_contact_msg_ui(contact, pkt, text, _offline_queue_count); #endif } @@ -714,8 +738,7 @@ void CompanionMesh::queueContactMessage(const ContactInfo &contact, mesh::Packet memcpy(&frame[i], contact.id.pub_key, 6); i += 6; - // path_len: 0xFF if direct, else actual path_len - frame[i++] = (pkt && pkt->isRouteFlood()) ? pkt->path_len : 0xFF; + frame[i++] = (pkt && pkt->isRouteFlood()) ? pkt->path_len : OUT_PATH_UNKNOWN; // txt_type frame[i++] = txt_type; @@ -751,6 +774,12 @@ void CompanionMesh::onCommandDataRecv(const ContactInfo &contact, mesh::Packet * markConnectionActive(contact); queueContactMessage(contact, pkt, TXT_TYPE_CLI_DATA, sender_timestamp, nullptr, 0, text); sendPush(PUSH_CODE_MSG_WAITING); +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + { + struct ui_joystick_cli_data cli = { text }; + ui_notify_joystick_event(UI_JOYSTICK_CLI_RESPONSE, contact.id.pub_key, &cli); + } +#endif } void CompanionMesh::onSignedMessageRecv(const ContactInfo &contact, mesh::Packet *pkt, @@ -765,8 +794,7 @@ void CompanionMesh::onSignedMessageRecv(const ContactInfo &contact, mesh::Packet queueContactMessage(contact, pkt, TXT_TYPE_SIGNED_PLAIN, sender_timestamp, sender_prefix, 4, text); sendPush(PUSH_CODE_MSG_WAITING); #if ZEPHCORE_HAS_UI_TASK - ui_set_msg_count((uint16_t)_offline_queue_count); - ui_notify(UI_EVENT_CONTACT_MSG); + notify_contact_msg_ui(contact, pkt, text, _offline_queue_count); #endif } @@ -809,8 +837,8 @@ void CompanionMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh uint8_t channel_idx = findChannelIdx(channel); frame[i++] = channel_idx; - // path_len: 0xFF if direct, else actual path_len - frame[i++] = (pkt && pkt->isRouteFlood()) ? pkt->path_len : 0xFF; + uint8_t path_len = (pkt && pkt->isRouteFlood()) ? pkt->path_len : OUT_PATH_UNKNOWN; + frame[i++] = path_len; // txt_type frame[i++] = TXT_TYPE_PLAIN; @@ -831,8 +859,14 @@ void CompanionMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh queueOfflineMessage(frame, i); sendPush(PUSH_CODE_MSG_WAITING); #if ZEPHCORE_HAS_UI_TASK - ui_set_msg_count((uint16_t)_offline_queue_count); - ui_notify(UI_EVENT_CHANNEL_MSG); + { + ChannelDetails ch; + const char *ch_name = ""; + if (getChannel(channel_idx, ch)) ch_name = ch.name; + ui_notify_channel_msg( + ch_name, text, timestamp, path_len, (uint16_t)_offline_queue_count + ); + } #endif } @@ -854,7 +888,7 @@ void CompanionMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::P uint8_t channel_idx = findChannelIdx(channel); frame[i++] = channel_idx; - frame[i++] = (pkt && pkt->isRouteFlood()) ? pkt->path_len : 0xFF; + frame[i++] = (pkt && pkt->isRouteFlood()) ? pkt->path_len : OUT_PATH_UNKNOWN; frame[i++] = (uint8_t)(data_type & 0xFF); frame[i++] = (uint8_t)(data_type >> 8); frame[i++] = (uint8_t)data_len; @@ -1041,6 +1075,13 @@ uint8_t CompanionMesh::onContactRequest(const ContactInfo &contact, uint32_t sen return 0; // Unknown request or denied } +void CompanionMesh::logTx(mesh::Packet *, int) +{ +#if ZEPHCORE_HAS_UI_TASK + ui_notify_packet_sent(); +#endif +} + void CompanionMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) { LOG_DBG("onContactResponse: len=%d from contact", len); @@ -1089,9 +1130,45 @@ void CompanionMesh::onContactResponse(const ContactInfo &contact, const uint8_t i += 6; } sendPush(rsp[0], &rsp[1], i - 1); +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + { + struct ui_joystick_login_data login = { + rsp[0] == PUSH_CODE_LOGIN_SUCCESS, + rsp[1], /* permissions (0 for fail/legacy) */ + tag, /* server_time */ + }; + ui_notify_joystick_event(UI_JOYSTICK_LOGIN_RESULT, contact.id.pub_key, &login); + } +#endif return; } +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + /* Joystick-originated requests (ping and admin binary): tag-based match takes priority + * over the pubkey-based _pending_status check below. Without this, a stale + * _pending_status for the same repeater would intercept any response and forward it + * to BLE, causing a joystick timeout. */ + if ((_pending_joystick_ping_tag && tag == _pending_joystick_ping_tag) || + (_pending_joystick_admin_tag && tag == _pending_joystick_admin_tag)) { + _pending_joystick_ping_tag = 0; + _pending_joystick_admin_tag = 0; + int8_t snr_remote = INT8_MIN; + if (len >= 48) { + int16_t rs; + memcpy(&rs, &data[46], 2); + snr_remote = (int8_t)(rs / 4); + } + struct ui_joystick_req_response_data rr = { + (int8_t)_radio->getLastSNR(), + snr_remote, + len > 4 ? &data[4] : nullptr, + (uint8_t)(len > 4 ? len - 4 : 0), + }; + ui_notify_joystick_event(UI_JOYSTICK_REQ_RESPONSE, contact.id.pub_key, &rr); + return; + } +#endif + // Check for status response if (_pending_status && len > 4 && memcmp(&_pending_status, contact.id.pub_key, 4) == 0) { _pending_status = 0; @@ -1149,6 +1226,27 @@ void CompanionMesh::onContactResponse(const ContactInfo &contact, const uint8_t sendPush(rsp[0], &rsp[1], i - 1); return; } + +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + /* Unmatched response: forward to joystick so it can check for a pending ping. + * RepeaterStats.last_snr sits at byte offset 42 in the struct payload (after the + * 4-byte tag prefix), so data[46]. Only valid when the response is long enough. */ + { + int8_t snr_remote = INT8_MIN; + if (len >= 48) { + int16_t rs; + memcpy(&rs, &data[46], 2); + snr_remote = (int8_t)(rs / 4); + } + struct ui_joystick_req_response_data rr = { + (int8_t)_radio->getLastSNR(), + snr_remote, + len > 4 ? &data[4] : nullptr, + (uint8_t)(len > 4 ? len - 4 : 0), + }; + ui_notify_joystick_event(UI_JOYSTICK_REQ_RESPONSE, contact.id.pub_key, &rr); + } +#endif } /* Raw packet logging - sends all RX packets to app for "heard X repeats" etc */ @@ -1207,6 +1305,17 @@ void CompanionMesh::onControlDataRecv(mesh::Packet *packet) return; } +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + if (packet->payload_len >= 6 + PUB_KEY_SIZE && + (packet->payload[0] & 0xF0) == CTL_TYPE_NODE_DISCOVER_RESP && + (packet->payload[0] & 0x0F) == ADV_TYPE_REPEATER) { + struct ui_joystick_discover_data dd = { + (int8_t)packet->getSNR(), (int8_t)((int8_t)packet->payload[1] / 4), packet->path_len + }; + ui_notify_joystick_event(UI_JOYSTICK_DISCOVER_RESP, &packet->payload[6], &dd); + } +#endif + uint8_t buf[MAX_FRAME_SIZE]; int i = 0; buf[i++] = PUSH_CODE_CONTROL_DATA; diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 2b0c887..eb79ad9 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -219,6 +219,7 @@ protected: /* Raw packet logging for app RX log */ void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; + void logTx(mesh::Packet *pkt, int len) override; /* Trace path response */ void onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, @@ -317,12 +318,16 @@ private: uint32_t _pending_telemetry; uint32_t _pending_discovery; uint32_t _pending_req; + uint32_t _pending_joystick_ping_tag; /* tag-based match, checked before pubkey-based _pending_status */ + uint32_t _pending_joystick_admin_tag; /* same protection for admin binary requests */ /* Lazy contacts/channels write - batches rapid updates */ int64_t _dirty_contacts_expiry; int64_t _dirty_channels_expiry; static constexpr int64_t LAZY_WRITE_DELAY_MS = 5000; /* 5 seconds, matches Arduino */ + void onLoginSent(const ContactInfo &contact) override; + void onChannelAdded(ChannelDetails *ch) override; void markContactsDirty(); void markChannelsDirty(); void flushDirtyContacts(); @@ -337,8 +342,17 @@ private: void clearPendingReqs() { _pending_login = _pending_status = _pending_telemetry = _pending_discovery = _pending_req = 0; + /* _pending_joystick_ping_tag is intentionally NOT cleared here + * joystick ping is independent of BLE request state. */ } +public: + void setJoystickPingTag(uint32_t tag) { _pending_joystick_ping_tag = tag; } + void clearJoystickPingTag() { _pending_joystick_ping_tag = 0; } + void setJoystickAdminTag(uint32_t tag) { _pending_joystick_admin_tag = tag; } + void clearJoystickAdminTag() { _pending_joystick_admin_tag = 0; } +private: + bool writeFrame(const uint8_t *data, size_t len); void sendPacketOk(); void sendPacketError(uint8_t code); diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index b8cb3e3..3fd3ed2 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -78,9 +78,6 @@ static void uplink_time_sync_cb(uint32_t unix_ts) #define SERVER_RESPONSE_DELAY 300 #define TXT_ACK_DELAY 200 -#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 -#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 - /* Helper: futureMillis */ static inline unsigned long futureMillis(uint32_t delta_ms) { return k_uptime_get() + delta_ms; diff --git a/zephcore/boards/nrf52840/wio_tracker_l1/Kconfig.wio_tracker_l1 b/zephcore/boards/nrf52840/wio_tracker_l1/Kconfig.wio_tracker_l1 index 6783394..21c82c5 100644 --- a/zephcore/boards/nrf52840/wio_tracker_l1/Kconfig.wio_tracker_l1 +++ b/zephcore/boards/nrf52840/wio_tracker_l1/Kconfig.wio_tracker_l1 @@ -4,3 +4,4 @@ config BOARD_WIO_TRACKER_L1 select SOC_NRF52840_QIAA + select ZEPHCORE_UI_JOYSTICK diff --git a/zephcore/helpers/AdvertDataHelpers.h b/zephcore/helpers/AdvertDataHelpers.h index a076ca0..70d0867 100644 --- a/zephcore/helpers/AdvertDataHelpers.h +++ b/zephcore/helpers/AdvertDataHelpers.h @@ -15,6 +15,10 @@ #define ADV_TYPE_ROOM 3 #define ADV_TYPE_SENSOR 4 +/* Control-packet type nibbles (upper nibble of payload[0]) */ +#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 +#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 + #define ADV_LATLON_MASK 0x10 #define ADV_FEAT1_MASK 0x20 #define ADV_FEAT2_MASK 0x40 diff --git a/zephcore/helpers/BaseChatMesh.cpp b/zephcore/helpers/BaseChatMesh.cpp index 530e379..b375b69 100644 --- a/zephcore/helpers/BaseChatMesh.cpp +++ b/zephcore/helpers/BaseChatMesh.cpp @@ -616,15 +616,18 @@ int BaseChatMesh::sendLogin(const ContactInfo &recipient, const char *password, } if (pkt) { uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); + int result; if (recipient.out_path_len == OUT_PATH_UNKNOWN) { sendFloodScoped(recipient, pkt); est_timeout = calcFloodTimeoutMillisFor(t); - return MSG_SEND_SENT_FLOOD; + result = MSG_SEND_SENT_FLOOD; } else { sendDirect(pkt, recipient.out_path, recipient.out_path_len); est_timeout = calcDirectTimeoutMillisFor(t, recipient.out_path_len); - return MSG_SEND_SENT_DIRECT; + result = MSG_SEND_SENT_DIRECT; } + onLoginSent(recipient); + return result; } return MSG_SEND_FAILED; } @@ -805,6 +808,7 @@ ChannelDetails *BaseChatMesh::addChannel(const char *name, const uint8_t *psk, s mesh::Utils::sha256(dest->channel.hash, sizeof(dest->channel.hash), dest->channel.secret, psk_len); StrHelper::strncpy(dest->name, name, sizeof(dest->name)); num_channels++; + onChannelAdded(dest); return dest; } } diff --git a/zephcore/helpers/BaseChatMesh.h b/zephcore/helpers/BaseChatMesh.h index 78a8ea5..bd3a9cf 100644 --- a/zephcore/helpers/BaseChatMesh.h +++ b/zephcore/helpers/BaseChatMesh.h @@ -143,6 +143,9 @@ protected: virtual void sendFloodScoped(const ContactInfo &recipient, mesh::Packet *pkt, uint32_t delay_millis = 0); virtual void sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis = 0); + virtual void onLoginSent(const ContactInfo &contact) {} + virtual void onChannelAdded(ChannelDetails *ch) {} + // Storage concepts for subclasses to override virtual int getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) { return 0; } virtual bool putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], int len) { return false; } diff --git a/zephcore/helpers/ContactInfo.h b/zephcore/helpers/ContactInfo.h index 73d8745..4ff707a 100644 --- a/zephcore/helpers/ContactInfo.h +++ b/zephcore/helpers/ContactInfo.h @@ -9,6 +9,7 @@ #include #define OUT_PATH_UNKNOWN 0xFF +#define OUT_PATH_SENT 0xFE /* local-only marker */ struct ContactInfo { mesh::Identity id; diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 07727f9..538cbac 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -78,6 +78,10 @@ struct NodePrefs { uint8_t leds_disabled; // 1 = LEDs off char default_scope_name[31]; // companion: default flood scope region name ("" = null) uint8_t default_scope_key[16]; // companion: default flood scope TransportKey + uint8_t ble_disabled; // 1 = BLE advertising off + uint8_t display_brightness; // 0 = default (100%), else 10–100 + uint8_t wake_on_msg; // 0 = don't wake display on message, 1 = wake (default) + uint16_t screen_off_secs; // 0 = default (Kconfig), else 5–300 }; /* Default prefs -- must match LoRaConfig.h defaults for radio interop. */ @@ -124,4 +128,5 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->rx_duty_cycle = 0; // Default OFF — continuous RX for best reliability prefs->apc_enabled = 0; // Default OFF — fixed TX power prefs->apc_margin = 16; // Default 16 dB target link margin + prefs->wake_on_msg = 1; // Default ON — wake display when message arrives } diff --git a/zephcore/helpers/ui/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c similarity index 90% rename from zephcore/helpers/ui/ui_pages.c rename to zephcore/helpers/ui-button/ui_pages.c index 778276d..5563f30 100644 --- a/zephcore/helpers/ui/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -55,69 +55,6 @@ static const uint8_t zephcore_logo[] = { 0x3c, 0x01, 0xe0, 0x70, 0x7b, 0xff, 0x00, 0x00, }; -/* ========== UTF-8 to Latin-1 Sanitizer ========== */ -/* Node names may contain UTF-8 emojis or accented chars. - * Our font covers 32-255 (ASCII + Latin-1 Supplement). - * This function: - * - Passes ASCII (0x00-0x7F) unchanged - * - Converts 2-byte Latin-1 sequences (U+00A0-U+00FF) to raw bytes - * - Strips all other multi-byte sequences (emojis, CJK, etc.) - * - Strips leading/trailing whitespace from removed chars - */ -void utf8_to_latin1(char *dst, const char *src, size_t dst_size) -{ - size_t di = 0; - size_t si = 0; - - while (src[si] && di < dst_size - 1) { - uint8_t c = (uint8_t)src[si]; - - if (c < 0x80) { - /* ASCII byte — pass through */ - dst[di++] = (char)c; - si++; - } else if ((c & 0xE0) == 0xC0) { - /* 2-byte UTF-8: 110xxxxx 10xxxxxx */ - uint8_t c2 = (uint8_t)src[si + 1]; - - if ((c2 & 0xC0) != 0x80) { - /* Invalid continuation — skip lead byte */ - si++; - continue; - } - - uint16_t cp = ((c & 0x1F) << 6) | (c2 & 0x3F); - - if (cp >= 0xA0 && cp <= 0xFF) { - /* Latin-1 Supplement — fits in our font */ - dst[di++] = (char)(uint8_t)cp; - } - /* else: Latin Extended etc — skip */ - si += 2; - } else if ((c & 0xF0) == 0xE0) { - /* 3-byte UTF-8 — skip (BMP: CJK, symbols) */ - si += 3; - } else if ((c & 0xF8) == 0xF0) { - /* 4-byte UTF-8 — skip (emojis, supplementary) */ - si += 4; - } else { - /* Invalid/continuation byte — skip */ - si++; - } - } - dst[di] = '\0'; - - /* Strip leading spaces (from removed emoji prefix) */ - size_t start = 0; - - while (dst[start] == ' ') { - start++; - } - if (start > 0) { - memmove(dst, dst + start, di - start + 1); - } -} - /* ========== Layout ========== */ /* All layout is derived from the actual display and font dimensions * queried at runtime, so the UI adapts to any resolution/font size. @@ -525,7 +462,7 @@ static void render_buzzer(void) y += LINE_H; draw_centered(y + 8, - state.buzzer_quiet ? "Press to Enable" : "Press to Disable"); + state.buzzer_quiet ? "Press to Enable" : "Press to Disable"); } static void render_leds(void) @@ -539,7 +476,7 @@ static void render_leds(void) y += LINE_H; draw_centered(y + 8, - state.leds_disabled ? "Press to Enable" : "Press to Disable"); + state.leds_disabled ? "Press to Enable" : "Press to Disable"); } static void render_sensors(void) @@ -582,7 +519,7 @@ static void render_sensors(void) /* Battery at bottom */ snprintf(buf, sizeof(buf), "Batt: %u%% (%umV)", state.battery_pct > 0 ? state.battery_pct - : calc_battery_pct(state.battery_mv), + : calc_battery_pct(state.battery_mv), state.battery_mv); mc_display_text(0, y, buf, false); } @@ -591,7 +528,7 @@ static void render_offgrid(void) { /* Check if confirmation has expired */ if (state.offgrid_confirm_time != 0 && - (k_uptime_get_32() - state.offgrid_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { + (k_uptime_get_32() - state.offgrid_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { state.offgrid_confirm_time = 0; } @@ -607,8 +544,8 @@ static void render_offgrid(void) draw_centered(centered_row(2, 3), "Press to confirm"); } else { draw_centered(centered_row(2, 3), - state.offgrid_enabled ? "Press to Disable" - : "Press to Enable"); + state.offgrid_enabled ? "Press to Disable" + : "Press to Enable"); } } @@ -616,7 +553,7 @@ static void render_dfu(void) { /* Check if confirmation has expired */ if (state.dfu_confirm_time != 0 && - (k_uptime_get_32() - state.dfu_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { + (k_uptime_get_32() - state.dfu_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { state.dfu_confirm_time = 0; } @@ -632,7 +569,7 @@ static void render_shutdown(void) { /* Check if confirmation has expired */ if (state.shutdown_confirm_time != 0 && - (k_uptime_get_32() - state.shutdown_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { + (k_uptime_get_32() - state.shutdown_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { state.shutdown_confirm_time = 0; } diff --git a/zephcore/helpers/ui/ui_pages.h b/zephcore/helpers/ui-button/ui_pages.h similarity index 94% rename from zephcore/helpers/ui/ui_pages.h rename to zephcore/helpers/ui-button/ui_pages.h index f8525ed..cee0d55 100644 --- a/zephcore/helpers/ui/ui_pages.h +++ b/zephcore/helpers/ui-button/ui_pages.h @@ -153,12 +153,6 @@ enum ui_page ui_pages_current(void); */ void ui_pages_set_node_name(const char *name); -/** - * Convert UTF-8 text to Latin-1 for display rendering. - * Passes ASCII unchanged, converts 2-byte Latin-1 (U+00A0-U+00FF), - * strips everything else (emojis, CJK). Trims leading/trailing spaces. - */ -void utf8_to_latin1(char *dst, const char *src, size_t dst_size); #ifdef __cplusplus } diff --git a/zephcore/helpers/ui/ui_task.c b/zephcore/helpers/ui-button/ui_task.c similarity index 84% rename from zephcore/helpers/ui/ui_task.c rename to zephcore/helpers/ui-button/ui_task.c index 058037f..90f8263 100644 --- a/zephcore/helpers/ui/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -60,6 +60,9 @@ /* Mesh action wrappers (deferred to mesh event loop thread) */ #include "ui_mesh_actions.h" +/* LED helpers defined in ui_common.c */ +extern void ui_led_flash_msg(void); + #include LOG_MODULE_REGISTER(ui_task, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); @@ -83,39 +86,6 @@ LOG_MODULE_REGISTER(ui_task, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); #define MELODY_LED_ON "lon:d=16,o=7,b=200:c,p,c,p,c,p,c,p,c,p,p,8e" #define MELODY_LED_OFF "lof:d=16,o=7,b=200:c,p,c,p,c,p,c,p,c,p,p,8g5" -/* ========== LED Heartbeat ========== */ -/* Match Arduino: 4s cycle, 20ms pulse (normal) or 200ms (unread messages). - * Uses led0 or led1 alias — whichever exists in the board's DTS. - * Disabled when OLED display is present (display makes LED redundant). */ -/* Heartbeat LED — subtle pulse every 4s on led0 (or led1 fallback). - * Works alongside displays; boards that want to disable it can - * remove the led0 alias or override this with a Kconfig guard. */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) -static const struct gpio_dt_spec heartbeat_led = - GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios); -#define HAS_HEARTBEAT_LED 1 -#elif DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) -static const struct gpio_dt_spec heartbeat_led = - GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); -#define HAS_HEARTBEAT_LED 1 -#else -#define HAS_HEARTBEAT_LED 0 -#endif - -/* Second LED: unread pulse (companion only). Repeaters use led1 for LoRa TX via - * lora-tx-led alias — no offline queue, so ui_task leaves led1 alone. */ -#if HAS_HEARTBEAT_LED && DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && \ - DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) && !defined(ZEPHCORE_REPEATER) -static const struct gpio_dt_spec msg_led = - GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); -#define HAS_MSG_LED 1 -#else -#define HAS_MSG_LED 0 -#endif - -#define LED_CYCLE_MS 4000 /* Total heartbeat period */ -#define LED_ON_MS 20 /* Normal pulse width */ -#define LED_ON_MSG_MS 200 /* Pulse width when unread messages */ /* ========== Deep Sleep / System OFF ========== */ /* On nRF52840, sys_poweroff() = System OFF (~1µA). @@ -176,52 +146,8 @@ static struct k_work_delayable splash_work; /* Deferred zero-hop advert — waits for possible double-press upgrade to flood */ static struct k_work_delayable advert_defer_work; -/* LED heartbeat only on boards with LED but no OLED (display makes it redundant) */ -#if HAS_HEARTBEAT_LED -/* LED heartbeat: two one-shot works form a self-rescheduling cycle. - * led_on_work turns LED on and schedules led_off_work after pulse width. - * led_off_work turns LED off and schedules led_on_work after remainder. */ -static struct k_work_delayable led_on_work; -static struct k_work_delayable led_off_work; -#endif - /* ========== Work Handlers ========== */ -#if HAS_HEARTBEAT_LED -static void led_off_work_handler(struct k_work *work) -{ - ARG_UNUSED(work); - gpio_pin_set_dt(&heartbeat_led, 0); -#if HAS_MSG_LED - gpio_pin_set_dt(&msg_led, 0); -#endif - - /* Schedule next ON after remainder of cycle */ - struct ui_state *s = get_state(); - uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; - - k_work_reschedule(&led_on_work, K_MSEC(LED_CYCLE_MS - on_ms)); -} - -static void led_on_work_handler(struct k_work *work) -{ - ARG_UNUSED(work); - gpio_pin_set_dt(&heartbeat_led, 1); -#if HAS_MSG_LED - struct ui_state *s = get_state(); - if (s->msg_count > 0) { - gpio_pin_set_dt(&msg_led, 1); - } - uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; -#else - struct ui_state *s = get_state(); - uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; -#endif - - k_work_reschedule(&led_off_work, K_MSEC(on_ms)); -} -#endif - static void render_work_handler(struct k_work *work) { ARG_UNUSED(work); @@ -360,7 +286,7 @@ static void action_page_enter(void) uint32_t now_og = k_uptime_get_32(); if (st_og->offgrid_confirm_time != 0 && - (now_og - st_og->offgrid_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { + (now_og - st_og->offgrid_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { /* Confirmed — toggle offgrid mode */ bool new_state = !st_og->offgrid_enabled; st_og->offgrid_enabled = new_state; @@ -381,7 +307,7 @@ static void action_page_enter(void) uint32_t now_dfu = k_uptime_get_32(); if (st_dfu->dfu_confirm_time != 0 && - (now_dfu - st_dfu->dfu_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { + (now_dfu - st_dfu->dfu_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { /* Confirmed — reboot into BLE DFU */ action_enter_dfu(); } else { @@ -398,7 +324,7 @@ static void action_page_enter(void) uint32_t now = k_uptime_get_32(); if (st->shutdown_confirm_time != 0 && - (now - st->shutdown_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { + (now - st->shutdown_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { /* Confirmed — shut down */ action_deep_sleep(); } else { @@ -501,8 +427,7 @@ static void action_leds_toggle(void) struct ui_state *s = get_state(); bool new_disabled = !s->leds_disabled; - s->leds_disabled = new_disabled; - ui_set_heartbeat_led(!new_disabled); + ui_set_leds_disabled(new_disabled); mesh_set_leds_disabled(new_disabled); #ifdef CONFIG_ZEPHCORE_UI_BUZZER buzzer_play(new_disabled ? MELODY_LED_OFF : MELODY_LED_ON); @@ -571,14 +496,7 @@ static void action_deep_sleep(void) LOG_INF("deep sleep: shutting down..."); /* 1. Stop LED heartbeat and msg indicator */ -#if HAS_HEARTBEAT_LED - k_work_cancel_delayable(&led_on_work); - k_work_cancel_delayable(&led_off_work); - gpio_pin_set_dt(&heartbeat_led, 0); -#endif -#if HAS_MSG_LED - gpio_pin_set_dt(&msg_led, 0); -#endif + ui_set_heartbeat_led(false); /* 2. Play shutdown melody (blocking wait) */ #ifdef CONFIG_ZEPHCORE_UI_BUZZER @@ -652,9 +570,9 @@ static void action_deep_sleep(void) NRF_GPIO_PIN_MAP(_SW0_PORT, _SW0_PIN), (_SW0_FLAGS & GPIO_PULL_UP) ? NRF_GPIO_PIN_PULLUP : (_SW0_FLAGS & GPIO_PULL_DOWN) ? NRF_GPIO_PIN_PULLDOWN : - NRF_GPIO_PIN_NOPULL, + NRF_GPIO_PIN_NOPULL, (_SW0_FLAGS & GPIO_ACTIVE_LOW) ? NRF_GPIO_PIN_SENSE_LOW - : NRF_GPIO_PIN_SENSE_HIGH); + : NRF_GPIO_PIN_SENSE_HIGH); #undef _SW0_NODE #undef _SW0_PORT #undef _SW0_PIN @@ -689,7 +607,7 @@ static void ui_input_cb(struct input_event *evt, void *user_data) uint32_t now = k_uptime_get_32(); if (doom_last_enter != 0 && - (now - doom_last_enter) <= 500) { + (now - doom_last_enter) <= 500) { /* Double-click — exit Doom */ doom_last_enter = 0; doom_game_stop(); @@ -871,24 +789,7 @@ int ui_init(void) } #endif - /* Initialize LED heartbeat — only on boards WITHOUT a display. - * If there's an OLED, the heartbeat LED is redundant and wastes power. */ -#if HAS_HEARTBEAT_LED - if (gpio_is_ready_dt(&heartbeat_led)) { - gpio_pin_configure_dt(&heartbeat_led, GPIO_OUTPUT_INACTIVE); - k_work_init_delayable(&led_on_work, led_on_work_handler); - k_work_init_delayable(&led_off_work, led_off_work_handler); - /* Start heartbeat cycle */ - k_work_reschedule(&led_on_work, K_NO_WAIT); - LOG_INF("LED heartbeat started"); - } -#endif -#if HAS_MSG_LED - if (gpio_is_ready_dt(&msg_led)) { - gpio_pin_configure_dt(&msg_led, GPIO_OUTPUT_INACTIVE); - LOG_INF("msg LED ready"); - } -#endif + ui_led_heartbeat_init(); /* NOTE: startup chime is NOT played here. It's played from main() * after loadPrefs() so we can respect the persisted buzzer_quiet setting. @@ -903,13 +804,25 @@ int ui_init(void) return 0; } -void ui_play_startup_chime(void) +void ui_notify_contact_msg(uint8_t path_len, const char *from_name, + const char *text, uint16_t msg_count) { -#ifdef CONFIG_ZEPHCORE_UI_BUZZER - if (!buzzer_is_quiet()) { - buzzer_play(MELODY_STARTUP); - } -#endif + (void)path_len; (void)from_name; (void)text; + ui_set_msg_count(msg_count); + ui_notify(UI_EVENT_CONTACT_MSG); +} + +void ui_notify_channel_msg(const char *channel_name, const char *text, + uint32_t ts, uint8_t path_len, uint16_t msg_count) +{ + (void)channel_name; (void)text; (void)ts; (void)path_len; + ui_set_msg_count(msg_count); + ui_notify(UI_EVENT_CHANNEL_MSG); +} + +void ui_notify_packet_sent(void) +{ + /* Button UI has no per-packet tracking */ } void ui_notify(enum ui_event event) @@ -967,18 +880,9 @@ void ui_notify(enum ui_event event) break; } - /* On message events: flash heartbeat LED immediately (if not BLE connected - * and LEDs are enabled). Cancel the current cycle, turn on now, let - * led_off_work resume the normal heartbeat after LED_ON_MSG_MS. */ -#if HAS_HEARTBEAT_LED - if (is_msg_event && !get_state()->ble_connected && !get_state()->leds_disabled - && gpio_is_ready_dt(&heartbeat_led)) { - k_work_cancel_delayable(&led_on_work); - k_work_cancel_delayable(&led_off_work); - gpio_pin_set_dt(&heartbeat_led, 1); - k_work_reschedule(&led_off_work, K_MSEC(LED_ON_MSG_MS)); + if (is_msg_event && !get_state()->ble_connected) { + ui_led_flash_msg(); } -#endif /* Wake display on non-message notifications (BLE connect/disconnect etc). * Message notifications use buzzer + LED flash instead of waking the display. */ @@ -1054,7 +958,7 @@ void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, uint16_t bw_khz_x10, } void ui_set_gps_data(bool has_fix, uint8_t sats, - int32_t lat_mdeg, int32_t lon_mdeg, int32_t alt_mm) + int32_t lat_mdeg, int32_t lon_mdeg, int32_t alt_mm) { struct ui_state *s = get_state(); @@ -1182,28 +1086,6 @@ void ui_set_offgrid_mode(bool enabled) s->offgrid_enabled = enabled; } -void ui_set_leds_disabled(bool disabled) -{ - struct ui_state *s = get_state(); - - s->leds_disabled = disabled; -} - -void ui_set_heartbeat_led(bool enabled) -{ -#if HAS_HEARTBEAT_LED - if (enabled) { - if (gpio_is_ready_dt(&heartbeat_led)) { - k_work_reschedule(&led_on_work, K_NO_WAIT); - } - } else { - k_work_cancel_delayable(&led_on_work); - k_work_cancel_delayable(&led_off_work); - gpio_pin_set_dt(&heartbeat_led, 0); - } -#endif -} - void ui_refresh_display(void) { if (!ui_initialized) { @@ -1224,3 +1106,15 @@ void ui_refresh_display(void) schedule_render(); #endif } + +/* ========== LED heartbeat overrides (ui_common.c weak functions) ========== */ + +uint16_t ui_led_get_msg_count(void) +{ + return get_state()->msg_count; +} + +void ui_led_on_disabled_changed(bool disabled) +{ + get_state()->leds_disabled = disabled; +} diff --git a/zephcore/helpers/ui-joystick/joystick_defs.h b/zephcore/helpers/ui-joystick/joystick_defs.h new file mode 100644 index 0000000..59b7b5f --- /dev/null +++ b/zephcore/helpers/ui-joystick/joystick_defs.h @@ -0,0 +1,49 @@ +/* + * ZephCore - Joystick UI Definitions + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * Key codes and layout constants for the joystick UI. + */ + +#pragma once + +/* ===== Key codes ===== */ +#define KEY_ENTER 0x0D /* center/OK button click */ +#define KEY_LEFT 0x01 /* joystick left */ +#define KEY_RIGHT 0x02 /* joystick right */ +#define KEY_UP 0x03 /* joystick up */ +#define KEY_DOWN 0x04 /* joystick down */ +#define KEY_CANCEL 0x1B /* back/ESC button */ +#define KEY_NEXT 0x05 /* single button next page */ +#define KEY_PREV 0x06 /* single button prev page */ +#define KEY_HOME 0x07 /* go to home screen */ +#define KEY_SELECT 0x08 /* triple click / special */ +#define KEY_ENTER_LONG 0xF1 /* long press enter */ +#define KEY_TO_TOP 0xF2 /* long press up → page up */ +#define KEY_TO_BOTTOM 0xF3 /* long press down → page down */ + +/* Global action keys emitted by multi tap filter, handled in loop() */ +#define KEY_FLOOD_ADVERT 0x42 /* INPUT_KEY_B: 2 taps → flood advert */ +#define KEY_BUZZ_TOGGLE 0x44 /* INPUT_KEY_D: 3 taps → buzzer mute toggle */ +#define KEY_GPS_TOGGLE 0x43 /* INPUT_KEY_C: 4 taps → GPS on/off */ +#define KEY_LED_TOGGLE 0x45 /* INPUT_KEY_E: 5 taps → LED heartbeat toggle */ + +/* ===== Layout constants (calibrated for 128x64 OLED, 6x8 font) ===== */ +/* All hard-coded offsets from old Arduino code are preserved here. + * Resolution aware screens should use mc_display_width/height() directly. */ +#define kHeaderSepY 11 /* y position of header separator line */ +#define kContentY 14 /* y position where page content starts */ +#define kMenuLineH 11 /* vertical spacing between menu items */ +#define kBodyY 14 /* synonym for kContentY */ +#define kLineH 9 /* compact line height */ + +/* Battery range for percentage calculation */ +#define kBattMinMv 3000 +#define kBattMaxMv 4200 + +/* Auto off timeout */ +#define AUTO_OFF_MILLIS 30000UL /* 30 seconds */ + +/* List view constants */ +#define UI_RECENT_LIST_SIZE 4 /* max items visible in a scrollable list */ diff --git a/zephcore/helpers/ui-joystick/joystick_display.h b/zephcore/helpers/ui-joystick/joystick_display.h new file mode 100644 index 0000000..cb9026d --- /dev/null +++ b/zephcore/helpers/ui-joystick/joystick_display.h @@ -0,0 +1,156 @@ +/* + * ZephCore - Joystick Display Adapter + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * JoystickDisplay: DisplayDriver-compatible adapter over mc_display_*. + * + * Provides a C++ API compatible with the old Arduino DisplayDriver class, + * implemented on top of ZephCore's mc_display_* (Zephyr CFB) primitives. + * + * Font is fixed-width (6x8 by default, actual size queried at runtime). + * Colors map to monochrome: + * GREEN / LIGHT / WHITE → normal (white text on black) + * YELLOW → selected highlight (fill_rect + inverted text) + * DARK → inverted text (black text on white background) + */ + +#pragma once + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif +#include "display.h" +#ifdef __cplusplus +} +#endif + +class JoystickDisplay { +public: + enum Color { GREEN = 0, YELLOW = 1, DARK = 2, LIGHT = 3, WHITE = 4, NONE = 5 }; + + JoystickDisplay() : _color(GREEN), _cx(0), _cy(0) {} + + /* ===== Color / size ===== */ + void setColor(Color c) { _color = c; } + void setTextSize(int) { /* only one font size supported */ } + Color getColor() const { return _color; } + + /* ===== Metrics ===== */ + int width() const { return (int)mc_display_width(); } + int height() const { return (int)mc_display_height(); } + int fontW() const { int w = mc_display_font_width(); return w ? w : 6; } + int fontH() const { int h = mc_display_font_height(); return h ? h : 8; } + int getTextWidth(const char *text) const { + return text ? (int)strlen(text) * fontW() : 0; + } + + /* ===== Text drawing ===== */ + void drawTextLeftAlign(int x, int y, const char *text) { + if (!text) return; + if (_color == YELLOW) { + int w = getTextWidth(text); + mc_display_fill_rect(x, y, w, fontH()); + mc_display_text(x, y, text, true); + } else { + mc_display_text(x, y, text, _color == DARK); + } + } + + void drawTextRightAlign(int right_x, int y, const char *text) { + if (!text) return; + int x = right_x - getTextWidth(text); + if (x < 0) x = 0; + drawTextLeftAlign(x, y, text); + } + + void drawTextCentered(int cx, int cy, const char *text) { + if (!text) return; + int x = cx - getTextWidth(text) / 2; + int y = cy - fontH() / 2; + if (x < 0) x = 0; + if (y < 0) y = 0; + drawTextLeftAlign(x, y, text); + } + + /* Draw text at (x, y) truncating with "..." if wider than max_w pixels */ + void drawTextEllipsized(int x, int y, int max_w, const char *text) { + if (!text || max_w <= 0) return; + int fw = fontW(); + int avail_chars = max_w / fw; + if (avail_chars <= 0) return; + + int len = (int)strlen(text); + if (len <= avail_chars) { + drawTextLeftAlign(x, y, text); + } else { + /* Truncate with "…" (3 dots, but we only have ASCII) */ + int truncate_at = avail_chars - 3; + if (truncate_at < 0) truncate_at = 0; + char buf[128]; + int n = truncate_at < 127 ? truncate_at : 127; + memcpy(buf, text, n); + buf[n] = '.'; buf[n+1] = '.'; buf[n+2] = '.'; + buf[n+3] = '\0'; + drawTextLeftAlign(x, y, buf); + } + } + + /* Cursor based printing (for compatibility with print() pattern) */ + void setCursor(int x, int y) { _cx = x; _cy = y; } + void print(const char *text) { + if (!text) return; + drawTextLeftAlign(_cx, _cy, text); + _cx += getTextWidth(text); + } + void print(int n) { + char buf[20]; + snprintf(buf, sizeof(buf), "%d", n); + print(buf); + } + + /* ===== Shapes ===== */ + void fillRect(int x, int y, int w, int h) { + if (_color == DARK) return; /* DARK fill is equivalent to clearing, already black */ + mc_display_fill_rect(x, y, w, h); + } + + /* drawRect: outline rect, or horizontal line when h equals 1 */ + void drawRect(int x, int y, int w, int h) { + if (h <= 1) { + mc_display_hline(x, y, w); + } else { + mc_display_hline(x, y, w); /* top */ + mc_display_hline(x, y + h - 1, w); /* bottom */ + mc_display_fill_rect(x, y, 1, h); /* left */ + mc_display_fill_rect(x + w - 1, y, 1, h); /* right */ + } + } + + /* ===== Frame management ===== */ + void startFrame() { + mc_display_clear(); + } + void endFrame() { + mc_display_finalize(); + } + + /* ===== Display power ===== */ + bool isOn() { return mc_display_is_on(); } + void turnOn() { mc_display_on(); } + void turnOff() { mc_display_off(); } + + /* UTF8 utility */ + void translateUTF8ToBlocks(char *dst, const char *src, int size) { + utf8_to_latin1(dst, src, (size_t)size); + } + +private: + Color _color; + int _cx, _cy; +}; diff --git a/zephcore/helpers/ui-joystick/joystick_screens.h b/zephcore/helpers/ui-joystick/joystick_screens.h new file mode 100644 index 0000000..8188db3 --- /dev/null +++ b/zephcore/helpers/ui-joystick/joystick_screens.h @@ -0,0 +1,484 @@ +/* + * ZephCore - Joystick UI Screens + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * All screen class declarations for the joystick UI. + */ + +#pragma once + +#include "joystick_display.h" +#include "joystick_defs.h" +#include +#include +#include +#include +#include "CompanionMesh.h" + +class JoystickUITask; + +/* ===== UIScreen base class ===== */ +class UIScreen { +public: + virtual ~UIScreen() {} + virtual int render(JoystickDisplay &display) = 0; + virtual bool handleInput(char c) { (void)c; return false; } + virtual void poll() {} +}; + +/* ===== Admin command size limits ===== */ +#ifndef ADMIN_PASSWORD_MAX +#define ADMIN_PASSWORD_MAX 32 +#endif +#define ADMIN_CMD_MAX 48 +#define ADMIN_RESP_MAX 256 +#define ADMIN_HIST_MAX 6 + +/* ===== SplashScreen ===== */ +class SplashScreen : public UIScreen { + JoystickUITask *_task; + uint32_t _dismiss_after; +public: + SplashScreen(JoystickUITask *task); + int render(JoystickDisplay &display) override; + void poll() override; +}; + +/* ===== HomeScreen ===== */ +class HomeScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + int _selected; +public: + HomeScreen(JoystickUITask *task, mesh::RTCClock *rtc); + void poll() override; + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== AdvertScreen ===== */ +class AdvertScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + int _selected; + int _last_rendered_selected; + uint32_t _marquee_start_ms; + bool _settings_open; + struct AdvertPath _recent_adverts[16]; + int _recent_advert_count; + uint32_t _recent_refresh_at; + + void refreshRecentAdverts(); + int getRecentAdvertCount() const; + bool getRecentAdvertByIndex(int idx, struct AdvertPath &path) const; +public: + AdvertScreen(JoystickUITask *task, mesh::RTCClock *rtc); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== GPSSettingsScreen ===== */ +class GPSSettingsScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + int _selected; + bool _have_prev_fix; + int32_t _prev_lat_e6, _prev_lon_e6; + uint32_t _last_sample_ms; + float _speed_kmh, _heading_deg; + bool _heading_valid; + uint32_t _heading_hold_until; +public: + GPSSettingsScreen(JoystickUITask *task, mesh::RTCClock *rtc); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; + void poll() override; +}; + +/* ===== SystemScreen ===== */ +enum SystemMode { SYSMODE_TOP=0, SYSMODE_DEVICE, SYSMODE_DISPLAY, SYSMODE_INFO, SYSMODE_POWER, SYSMODE_OFFGRID_CONFIRM }; +class SystemScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + int _selected, _cat_selected; + SystemMode _mode; + uint32_t _dfu_confirm_time; +public: + SystemScreen(JoystickUITask *task, mesh::RTCClock *rtc); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== SystemTimeScreen ===== */ +class SystemTimeScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; +public: + SystemTimeScreen(JoystickUITask *task, mesh::RTCClock *rtc); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== TelemetryScreen ===== */ +class TelemetryScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + int _selected; +public: + TelemetryScreen(JoystickUITask *task, mesh::RTCClock *rtc); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== ToolsScreen ===== */ +class ToolsScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + int _selected; +public: + ToolsScreen(JoystickUITask *task, mesh::RTCClock *rtc); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== RadioStatsScreen ===== */ +class RadioStatsScreen : public UIScreen { + JoystickUITask *_task; +public: + RadioStatsScreen(JoystickUITask *task); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== RepeatersScreen ===== */ +class RepeatersScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + int _selected; + int _mode; + int _last_sel; + uint32_t _marquee_start_ms; + int _modal_idx; +public: + RepeatersScreen(JoystickUITask *task, mesh::RTCClock *rtc); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; + void poll() override; +}; + +/* ===== ChannelsScreen ===== */ +class ChannelsScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + int _selected; + bool _show_msgs; + char _msg_channel[32]; + int _msg_channel_idx; + int _msg_scroll; + bool _msg_details; + int _msg_detail_scroll; + + int getChannelCount() const; + bool getChannelByListIndex(int listIdx, ChannelDetails &ch, int *slot_out = nullptr) const; +public: + ChannelsScreen(JoystickUITask *task, mesh::RTCClock *rtc); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== T9InputScreen ===== */ +class T9InputScreen : public UIScreen { + JoystickUITask *_task; + char _input[256]; + int _cursor; + int _selected_key; + uint32_t _last_press_time; + int _letter_index; + int _last_key; + bool _confirm_exit; + int _confirm_selected; + bool _kb_mode_letters; + + void addLetter(char ch); + void backspace(); +public: + T9InputScreen(JoystickUITask *task); + void clearInput() { memset(_input, 0, sizeof(_input)); _cursor = 0; } + const char *getInput() const { return _input; } + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== RenameNodeScreen ===== */ +class RenameNodeScreen : public UIScreen { + JoystickUITask *_task; + char _input[32]; + bool _loaded; + int _cursor; + int _selected_key; + uint32_t _last_press_time; + int _letter_index; + int _last_key; + bool _kb_mode_letters; +public: + RenameNodeScreen(JoystickUITask *task); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== BLECodeScreen ===== */ +class BLECodeScreen : public UIScreen { + JoystickUITask *_task; + char _pin_buf[8]; + bool _editing; + int _digit_sel; +public: + BLECodeScreen(JoystickUITask *task); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== StatsScreen ===== */ +class StatsScreen : public UIScreen { + JoystickUITask *_task; +public: + StatsScreen(JoystickUITask *task); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; +}; + +/* ===== StopwatchScreen ===== */ +class StopwatchScreen : public UIScreen { + JoystickUITask *_task; + bool _running; + uint32_t _start_ms; + uint32_t _elapsed_ms; + int _laps; + uint32_t _lap_times[10]; + int _scroll; +public: + StopwatchScreen(JoystickUITask *task); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; + void poll() override; +}; + +/* ===== CountdownScreen ===== */ +class CountdownScreen : public UIScreen { + JoystickUITask *_task; + bool _running; + uint32_t _end_ms; + int _set_seconds; + int _edit_field; + bool _alarmed; +public: + CountdownScreen(JoystickUITask *task); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; + void poll() override; +}; + +/* ===== SnakeScreen ===== */ +class SnakeScreen : public UIScreen { + JoystickUITask *_task; + static const int GRID_W = 21; + static const int GRID_H = 5; + int8_t _snake_x[GRID_W * GRID_H]; + int8_t _snake_y[GRID_W * GRID_H]; + int _snake_len; + int8_t _food_x, _food_y; + uint32_t _next_move; + int _score; + + void placeFood(); + void reset(); +public: + SnakeScreen(JoystickUITask *task); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; + void poll() override; +}; + +/* ===== DoomScreen ===== */ +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM +class DoomScreen : public UIScreen { + JoystickUITask *_task; +public: + DoomScreen(JoystickUITask *task); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; + void poll() override; +}; +#endif + +/* ===== ContactsScreen ===== */ +class ContactsScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + int _selected; + int _filter; + int _mode; + int _submenu_selected; + int _chat_selected; + bool _chat_details; + int _chat_detail_scroll; + ContactInfo _active_contact; + bool _active_contact_valid; + int _idx_send_message; + int _idx_edit_path; + int _idx_reset_path; + int _idx_favorite; + int _idx_delete; + int _idx_repeater_admin; + int _idx_ping_zerohop; + + /* Edit path T9 keyboard state */ + char _editpath_hexbuf[MAX_PATH_SIZE * 2 + 1]; + int _editpath_cursor; + int _editpath_t9_sel; + int _editpath_t9_last_key; + int _editpath_t9_letter_index; + uint32_t _editpath_t9_last_press; + bool _editpath_kb_letters; + bool _editpath_confirm_exit; + int _editpath_confirm_sel; + uint32_t _header_marquee_ms; + + /* Ping state (zero hop) */ + uint32_t _ping_sent_at; /* k_uptime_get_32() when ping was sent, 0=idle */ + uint32_t _ping_timeout_ms; /* est_timeout from sendRequest (ms), 0=unknown */ + int8_t _ping_snr_local; /* SNR (dB) of reply as received by us */ + int8_t _ping_snr_remote; /* SNR of our ping as received by repeater, INT8_MIN if unknown */ + uint32_t _ping_rtt_ms; /* RTT: 0=no result yet, UINT32_MAX=timeout, else ms */ + bool _ping_modal_active; /* modal overlay visible */ + + int clampStart(int contactCount) const; + int getFilteredContactCount() const; + bool getFilteredContactByIndex(int listIndex, ContactInfo &contact) const; + bool refreshActiveContact(); + int buildSubmenuItems(const char *items[], char text[][48], int max_items); + +public: + ContactsScreen(JoystickUITask *task, mesh::RTCClock *rtc); + void openForPubKey(const uint8_t *prefix, int prefix_len); + void onPingResponse(int8_t snr_local, int8_t snr_remote, uint32_t rtt_ms); + void onPacketSent(); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; + void poll() override; +}; + +/* ===== UnreadScreen ===== */ +class UnreadScreen : public UIScreen { + JoystickUITask *_task; + mesh::RTCClock *_rtc; + + struct MsgEntry { + uint32_t timestamp; + char origin[80]; + char msg[240]; + bool read; + }; + + static const int MAX_UNREAD_MSGS = 32; + MsgEntry _entries[MAX_UNREAD_MSGS]; + int _entry_count; + int _visible_unread_count; + int _head_index; + int _selected; + int _list_scroll; + bool _details; + int _detail_scroll; + bool _transient_preview; + uint32_t _preview_expiry; + + void normalizeUnreadState(); + const MsgEntry *getByListIndex(int idx) const; + void markCurrentRead(); + +public: + UnreadScreen(JoystickUITask *task, mesh::RTCClock *rtc); + void activatePreview(bool transient, uint32_t timeout_ms = 10000); + int getUnreadCount(); + int getStoredMsgCount() const; + void addPreview(uint8_t path_len, const char *from_name, const char *msg); + bool getStoredMessageForSourceAt(const char *source, uint32_t ts, const char *&out_msg) const; + bool getLatestStoredMessageForSource(const char *source, const char *&out_msg, uint32_t *out_ts = nullptr) const; + int getContactMsgCount(const char *contact_name) const; + bool getContactMsgAt(const char *contact_name, int idx, const char *&out_msg, + uint32_t &out_ts, uint8_t *out_path = nullptr) const; + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; + void poll() override; +}; + +/* ===== RepeaterAdminScreen ===== */ +class RepeaterAdminScreen : public UIScreen { +public: + enum AdminState { STATE_PASSWORD_ENTRY, STATE_LOGGING_IN, STATE_SUBMENU, STATE_MAIN, STATE_CMD_INPUT }; + enum PendingKind { PENDING_NONE, PENDING_CMD, PENDING_BINARY_STATUS, PENDING_BINARY_NEIGHBOURS, + PENDING_BINARY_TELEMETRY, PENDING_BINARY_OWNER_INFO }; + + RepeaterAdminScreen(JoystickUITask *task, mesh::RTCClock *rtc); + void openForContact(const uint8_t *pub_key, const char *name, bool from_contacts = false); + void onLoginResult(bool success, uint8_t permissions, uint32_t server_time); + void onCliResponse(const char *text); + void onReqResponse(const uint8_t *pub_key_prefix, const uint8_t *data, uint8_t data_len); + void onPacketSent(); + int render(JoystickDisplay &display) override; + bool handleInput(char c) override; + void poll() override; + +private: + JoystickUITask *_task; + mesh::RTCClock *_rtc; + AdminState _state; + uint8_t _contact_pubkey[PUB_KEY_SIZE]; + bool _from_contacts; + char _repeater_name[32]; + uint32_t _admin_header_marquee_ms; + uint8_t _permissions; + uint32_t _server_time; + + /* Password entry */ + char _password[ADMIN_PASSWORD_MAX]; + int _pwd_len; + int _pwd_t9_sel, _pwd_t9_last_key, _pwd_t9_letter_index; + uint32_t _pwd_t9_last_press; + bool _pwd_kb_letters; + + /* Submenu state */ + int _submenu_sel; + + /* Main state */ + PendingKind _pending; + uint32_t _last_sent_at; + uint32_t _cmd_timeout_ms; + bool _awaiting_tx; + + /* Command history ring buffer */ + struct CmdEntry { + char cmd[ADMIN_CMD_MAX]; + char resp[ADMIN_RESP_MAX]; + bool has_resp; + }; + CmdEntry _hist[ADMIN_HIST_MAX]; + int _hist_head; + int _hist_count; + int _hist_scroll; + int _resp_line_scroll; + int _resp_max_line_scroll; + + /* Command compose (STATE_CMD_INPUT) */ + char _cmd_buf[ADMIN_CMD_MAX]; + int _cmd_len; + int _cmd_t9_sel, _cmd_t9_last_key, _cmd_t9_letter_index; + uint32_t _cmd_t9_last_press; + bool _cmd_kb_letters; + + bool sendCLI(const char *cmd); + void goBack(); + CmdEntry &histAt(int i) { return _hist[(_hist_head + i) % ADMIN_HIST_MAX]; } + const CmdEntry &histAt(int i) const { return _hist[(_hist_head + i) % ADMIN_HIST_MAX]; } +}; diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp new file mode 100644 index 0000000..fd4c06a --- /dev/null +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.cpp @@ -0,0 +1,288 @@ +/* + * ZephCore - Joystick UI Hooks + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * JoystickUIHooks: ui_task.h implementation for the joystick companion UI. + * + * Replaces helpers/ui/ui_task.c when CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK=y. + * CompanionMesh and ui_mesh_actions call the same C API unchanged. + * This file forwards relevant calls to the registered JoystickUITask instance. + * + * Hardware initialization (display, buzzer, LED heartbeat) still happens in + * ui_init() so that main_companion.cpp requires no extra #ifdefs. + * Input handling is owned by JoystickUITask's INPUT_CALLBACK_DEFINE, + * so no second input callback is registered here. + */ + +#include "joystick_ui_hooks.h" +#include "joystick_ui_task.h" +#include + +#ifdef CONFIG_ZEPHCORE_UI_BUZZER +#include "buzzer.h" +#endif + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY +#include "display.h" +#endif + +#include +#include +#include + +LOG_MODULE_REGISTER(joystick_ui_hooks, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); + +/* ===== Registered task and signal callbacks ===== */ + +static JoystickUITask *s_task; +static ui_signal_fn s_signal_refresh; +static ui_signal_fn s_signal_tx; + +static void joystick_render_timer_fn(struct k_timer *timer) +{ + ARG_UNUSED(timer); + if (s_signal_refresh) s_signal_refresh(); +} + +static void joystick_heartbeat_timer_fn(struct k_timer *timer) +{ + ARG_UNUSED(timer); + if (s_signal_refresh) s_signal_refresh(); +} + +K_TIMER_DEFINE(s_joystick_render_timer, joystick_render_timer_fn, NULL); +K_TIMER_DEFINE(s_joystick_heartbeat_timer, joystick_heartbeat_timer_fn, NULL); + +static void joystick_schedule_render(uint32_t delay_ms) +{ + k_timer_start(&s_joystick_render_timer, K_MSEC(delay_ms), K_NO_WAIT); +} + +static void joystick_start_heartbeat(void) +{ + k_timer_start(&s_joystick_heartbeat_timer, K_MSEC(2000), K_MSEC(2000)); +} + +static void joystick_stop_heartbeat(void) +{ + k_timer_stop(&s_joystick_heartbeat_timer); +} + +extern "C" void joystick_ui_hooks_register( + void *task, + ui_signal_fn signal_refresh, + ui_signal_fn signal_tx +){ + s_task = static_cast(task); + s_signal_refresh = signal_refresh; + s_signal_tx = signal_tx; + JoystickUITask::setSignalFn(signal_refresh); + JoystickUITask::setScheduleRenderFn(joystick_schedule_render); + JoystickUITask::setHeartbeatFns(joystick_start_heartbeat, joystick_stop_heartbeat); + joystick_start_heartbeat(); +} + +extern "C" void ui_signal_refresh(void) +{ + if (s_signal_refresh) { + s_signal_refresh(); + } +} + +extern "C" void ui_signal_tx(void) +{ + if (s_signal_tx) { + s_signal_tx(); + } +} + +/* ===== ui_task.h implementation ===== */ + +extern "C" int ui_init(void) +{ +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + int ret = buzzer_init(); + if (ret == 0) { + LOG_INF("buzzer ready"); + } else if (ret == -ENODEV) { + LOG_INF("no buzzer hardware"); + } else { + LOG_WRN("buzzer init failed: %d", ret); + } +#endif + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY + int dret = mc_display_init(); + if (dret == 0) { + LOG_INF("display ready"); + mc_display_reset_auto_off(); + } else if (dret == -ENODEV) { + LOG_INF("no display hardware"); + } else { + LOG_WRN("display init failed: %d", dret); + } +#endif + + ui_led_heartbeat_init(); + LOG_INF("joystick UI hooks initialized"); + return 0; +} + +extern "C" void ui_notify(enum ui_event event) +{ + if (!s_task) { + return; + } + + switch (event) { + case UI_EVENT_BLE_CONNECTED: + s_task->setBLEConnected(true); + s_task->notify(); + break; + + case UI_EVENT_BLE_DISCONNECTED: + s_task->setBLEConnected(false); + s_task->notify(); + break; + + case UI_EVENT_ACK: +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + if (!buzzer_is_quiet()) { + buzzer_play("ack:d=32,o=6,b=200:e,g"); + } +#endif + s_task->notify(); + break; + + default: + break; + } +} + +extern "C" void ui_notify_contact_msg(uint8_t path_len, const char *from_name, + const char *text, uint16_t msg_count) +{ + if (s_task) { + s_task->newMsg(path_len, from_name, text, (int)msg_count); + } +} + +extern "C" void ui_notify_channel_msg(const char *channel_name, const char *text, + uint32_t ts, uint8_t path_len, uint16_t msg_count) +{ + (void)msg_count; + if (s_task) { + s_task->newChannelMsg(channel_name, text, ts, path_len); + } +} + +extern "C" void ui_notify_joystick_event( + enum ui_joystick_event_type type, + const uint8_t *pub_key_prefix, + const void *payload +){ + if (!s_task) { + return; + } + switch (type) { + case UI_JOYSTICK_LOGIN_RESULT: { + const auto *d = static_cast(payload); + s_task->onRepeaterAdminLoginResult(pub_key_prefix, d->success, d->permissions, d->server_time); + break; + } + case UI_JOYSTICK_CLI_RESPONSE: { + const auto *d = static_cast(payload); + s_task->onRepeaterAdminCliResponse(pub_key_prefix, d->text); + break; + } + case UI_JOYSTICK_REQ_RESPONSE: { + const auto *d = static_cast(payload); + s_task->onRepeaterReqResponse(pub_key_prefix, d->snr_local, d->snr_remote, d->data, d->data_len); + break; + } + case UI_JOYSTICK_DISCOVER_RESP: { + const auto *d = static_cast(payload); + s_task->onRepeaterDiscoverResp(pub_key_prefix, d->snr, d->snr_remote, d->path_len); + break; + } + } +} + +extern "C" void ui_notify_packet_sent(void) +{ + if (s_task) { + s_task->notifyPacketSent(); + } +} + +extern "C" void ui_set_msg_count(uint16_t count) +{ + if (s_task) { + s_task->msgRead((int)count); + } +} + +extern "C" void ui_set_ble_status(bool connected, const char *name) +{ + (void)name; + if (s_task) { + s_task->setBLEConnected(connected); + s_task->notify(); + } +} + +extern "C" void ui_set_radio_params( + uint32_t freq_hz, + uint8_t sf, + uint16_t bw_khz_x10, + uint8_t cr, int8_t tx_power, int16_t noise_floor) +{ + (void)freq_hz; (void)sf; (void)bw_khz_x10; (void)cr; (void)tx_power; + if (s_task) { + s_task->setNoiseFloor(noise_floor); + } +} + +extern "C" void ui_notify_radio_stats(uint32_t pkt_recv, uint32_t pkt_sent, uint32_t pkt_errors) +{ + if (s_task) { + s_task->setPacketStats(pkt_recv, pkt_sent, pkt_errors); + } +} + +/* ===== Pull-model no-ops ===== + * The joystick UI reads all hardware state on demand at render time, so these + * push-model hooks from the old button UI have no work to do. */ +extern "C" void ui_set_gps_data(bool, uint8_t, int32_t, int32_t, int32_t) {} +extern "C" void ui_set_clock(uint32_t) {} +extern "C" void ui_add_recent(const char *, int16_t, uint32_t) {} +extern "C" void ui_set_node_name(const char *) {} +extern "C" void ui_clear_recent(void) {} +extern "C" void ui_set_sensor_data(int16_t, uint32_t, uint16_t, uint16_t) {} +extern "C" void ui_set_gps_available(bool) {} +extern "C" void ui_set_gps_enabled(bool) {} +extern "C" void ui_set_gps_state(uint8_t, uint32_t, uint32_t) {} +extern "C" void ui_set_buzzer_quiet(bool) {} +extern "C" void ui_set_offgrid_mode(bool) {} + +extern "C" void ui_set_battery(uint16_t mv, uint8_t /*pct*/) +{ + if (s_task) { + s_task->setCachedBattMilliVolts(mv); + } +} + +extern "C" void ui_set_ble_enabled(bool enabled) +{ + if (s_task) { + s_task->setBLEEnabled(enabled); + } +} + +extern "C" void ui_refresh_display(void) +{ + if (s_task) { + s_task->forceRefresh(); + } +} diff --git a/zephcore/helpers/ui-joystick/joystick_ui_hooks.h b/zephcore/helpers/ui-joystick/joystick_ui_hooks.h new file mode 100644 index 0000000..9835e65 --- /dev/null +++ b/zephcore/helpers/ui-joystick/joystick_ui_hooks.h @@ -0,0 +1,110 @@ +/* + * ZephCore - Joystick UI Hooks + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * Bridges the ui_task.h C API to JoystickUITask, and declares the + * joystick-only extension API used by CompanionMesh when + * CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK=y. + * + * Non-joystick builds never include this header; guards in CompanionMesh.cpp + * and main_companion.cpp wrap every include and call site. + */ + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Signal callback type, passed during registration so joystick_ui_hooks.cpp + * can post events to the mesh loop without depending on main_companion internals. + */ +typedef void (*ui_signal_fn)(void); + +/** + * Register the JoystickUITask instance and event signal callbacks. + * Must be called (from main_companion.cpp) before the mesh event loop starts. + * + * @param task Pointer to JoystickUITask instance + * @param signal_refresh Callback to wake the joystick UI loop tick + * @param signal_tx Callback to wake the mesh TX path immediately + */ +void joystick_ui_hooks_register(void *task, + ui_signal_fn signal_refresh, + ui_signal_fn signal_tx); + +/* + * Joystick UI events: extend by adding a new ui_joystick_event_type value + * and payload struct rather than adding another function. + * Covers ping, login, CLI, discovery, and future joystick features. + * Only compiled in when CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK is enabled. + */ + +enum ui_joystick_event_type { + UI_JOYSTICK_LOGIN_RESULT = 0, /* login accepted/rejected */ + UI_JOYSTICK_CLI_RESPONSE = 1, /* admin CLI command response */ + UI_JOYSTICK_REQ_RESPONSE = 2, /* unmatched contact REQ response, joystick checks for ping */ + UI_JOYSTICK_DISCOVER_RESP = 3, /* CTL_TYPE_NODE_DISCOVER_RESP received */ +}; + +struct ui_joystick_login_data { + bool success; + uint8_t permissions; + uint32_t server_time; +}; + +struct ui_joystick_cli_data { + const char *text; +}; + +struct ui_joystick_req_response_data { + int8_t snr_local; /* SNR of reply as received by us */ + int8_t snr_remote; /* SNR of our REQ as seen by repeater, INT8_MIN if unavailable */ + const uint8_t *data; /* response payload after the 4 byte tag (may be NULL) */ + uint8_t data_len; +}; + +struct ui_joystick_discover_data { + int8_t snr; /* SNR of response as received by us (FROM repeater) */ + int8_t snr_remote; /* SNR of our request as received by repeater (TO repeater) */ + uint8_t path_len; +}; + +/** + * Dispatch a joystick UI event from the mesh layer. + * + * @param type Event discriminator + * @param pub_key_prefix First 4 bytes of the contact's public key + * @param payload Pointer to the matching ui_joystick_*_data struct + */ +void ui_notify_joystick_event(enum ui_joystick_event_type type, + const uint8_t *pub_key_prefix, + const void *payload); + +/** + * Update radio packet counters (Rx, Tx, errors) for the joystick stats screen. + * Not part of ui_task.h, call only when CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK=y. + */ +void ui_notify_radio_stats(uint32_t pkt_recv, uint32_t pkt_sent, uint32_t pkt_errors); + +/** + * Wake the joystick UI loop immediately (before the next render timer fires), + * call after a time sensitive event (ping response, login). + */ +void ui_signal_refresh(void); + +/** + * Wake the mesh TX path immediately so a packet queued by the joystick UI + * (ping, login, CLI command, message) is sent without waiting for the next + * radio event. + */ +void ui_signal_tx(void); + +#ifdef __cplusplus +} +#endif diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp new file mode 100644 index 0000000..b5ac80f --- /dev/null +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp @@ -0,0 +1,1061 @@ +/* + * ZephCore - Joystick UI Task + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * Runs in the mesh event loop thread (main_companion.cpp). + * Input events are queued from the Zephyr input subsystem callback. + */ + +#include "joystick_ui_task.h" +#include "joystick_defs.h" +#include "joystick_ui_hooks.h" +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#ifdef CONFIG_POWEROFF +#include +#endif +#include +#include + +#include +#include + +#ifdef CONFIG_ZEPHCORE_UI_BUZZER +#include "buzzer.h" +#endif + +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM +#include +#endif + +#include +#include +#include + +LOG_MODULE_REGISTER(joystick_ui, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); + +/* ===== Key event queue ===== */ +char JoystickUITask::_key_buf[JOYSTICK_KEY_QUEUE_DEPTH * sizeof(char)]; +struct k_msgq JoystickUITask::_key_queue; + +void (*JoystickUITask::s_signal_fn)(void) = nullptr; +void (*JoystickUITask::s_schedule_render_fn)(uint32_t) = nullptr; +void (*JoystickUITask::s_start_heartbeat_fn)(void) = nullptr; +void (*JoystickUITask::s_stop_heartbeat_fn)(void) = nullptr; + +void JoystickUITask::setSignalFn(void (*fn)(void)) { s_signal_fn = fn; } +void JoystickUITask::setScheduleRenderFn(void (*fn)(uint32_t)) { s_schedule_render_fn = fn; } +void JoystickUITask::setHeartbeatFns(void (*start_fn)(void), void (*stop_fn)(void)) +{ + s_start_heartbeat_fn = start_fn; + s_stop_heartbeat_fn = stop_fn; +} + +static bool joystick_queue_initialized; + +#define ENTER_LONG_PRESS_MS 500 + +static uint32_t s_enter_press_ms; +static uint32_t s_up_press_ms; +static uint32_t s_down_press_ms; + +static void joystick_ui_input_cb(struct input_event *evt, void *user_data) +{ + ARG_UNUSED(user_data); + + if (evt->type != INPUT_EV_KEY) { + return; + } + +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM + /* When Doom is running, forward all events to it. + * BACK/ESC/KEY_1 release also enqueues KEY_CANCEL so DoomScreen can stop it. */ + if (doom_game_is_running()) { + doom_game_input(evt->code, evt->value); + if (!evt->value && + (evt->code == INPUT_KEY_BACK || evt->code == INPUT_KEY_ESC || + evt->code == INPUT_KEY_1)) { + if (joystick_queue_initialized) { + JoystickUITask::enqueueKey(KEY_CANCEL); + } + } + return; + } +#endif + + /* Long press fires on RELEASE so hold duration is known */ + if (evt->code == INPUT_KEY_ENTER) { + if (evt->value) { + s_enter_press_ms = k_uptime_get_32(); + } else { + uint32_t held = k_uptime_get_32() - s_enter_press_ms; + char key = (held >= ENTER_LONG_PRESS_MS) ? KEY_ENTER_LONG : KEY_ENTER; + if (joystick_queue_initialized) { + JoystickUITask::enqueueKey(key); + } + } + return; + } + if (evt->code == INPUT_KEY_UP) { + if (evt->value) { + s_up_press_ms = k_uptime_get_32(); + } else { + uint32_t held = k_uptime_get_32() - s_up_press_ms; + char key = (held >= ENTER_LONG_PRESS_MS) ? KEY_TO_TOP : KEY_UP; + if (joystick_queue_initialized) { + JoystickUITask::enqueueKey(key); + } + } + return; + } + if (evt->code == INPUT_KEY_DOWN) { + if (evt->value) { + s_down_press_ms = k_uptime_get_32(); + } else { + uint32_t held = k_uptime_get_32() - s_down_press_ms; + char key = (held >= ENTER_LONG_PRESS_MS) ? KEY_TO_BOTTOM : KEY_DOWN; + if (joystick_queue_initialized) { + JoystickUITask::enqueueKey(key); + } + } + return; + } + + /* All other keys: fire on press, ignore release */ + if (!evt->value) return; + + char key = 0; + switch (evt->code) { + case INPUT_KEY_LEFT: key = KEY_LEFT; break; + case INPUT_KEY_RIGHT: key = KEY_RIGHT; break; + case INPUT_KEY_BACK: + case INPUT_KEY_ESC: key = KEY_CANCEL; break; + case INPUT_KEY_1: key = KEY_CANCEL; break; + /* Multi tap outputs from input_multi_tap filter */ + case INPUT_KEY_D: key = KEY_BUZZ_TOGGLE; break; /* 3 taps */ + case INPUT_KEY_C: key = KEY_GPS_TOGGLE; break; /* 4 taps */ + case INPUT_KEY_E: key = KEY_LED_TOGGLE; break; /* 5 taps */ + default: break; + } + + if (key && joystick_queue_initialized) { + JoystickUITask::enqueueKey(key); + } +} + +INPUT_CALLBACK_DEFINE(NULL, joystick_ui_input_cb, NULL); + +void JoystickUITask::enqueueKey(char key) +{ + k_msgq_put(&_key_queue, &key, K_NO_WAIT); + if (s_signal_fn) s_signal_fn(); +} + +/* ===== Constructor ===== */ +JoystickUITask::JoystickUITask() + : _mesh(nullptr), _rtc(nullptr), _prefs(nullptr), + _splash(nullptr), _home(nullptr), _contacts(nullptr), _unread(nullptr), + _advert(nullptr), _gps(nullptr), _system(nullptr), _system_time(nullptr), + _telemetry(nullptr), _tools(nullptr), _radio_stats(nullptr), + _repeaters(nullptr), _channels(nullptr), _t9_input(nullptr), + _rename_node(nullptr), _ble_code(nullptr), _stats(nullptr), + _stopwatch(nullptr), _countdown(nullptr), _snake(nullptr), +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM + _doom(nullptr), +#endif + _repeater_admin(nullptr), _curr(nullptr), + _next_refresh(0), _auto_off(0), _screen_off_ms(AUTO_OFF_MILLIS), + _next_batt_refresh(0), _cached_batt_mv(0), _battery_display_mode(0), + _brightness(100), _wake_on_msg(true), _ble_connected(false), + _ble_enabled(true), _msgcount(0), _noise_floor(-120), + _pkt_recv(0), _pkt_sent(0), _pkt_errors(0), _alert_expiry(0), + _locked(false), _lock_at(0), _lock_step(0), + _compose_is_contact(false), _compose_channel_idx(-1), + _ch_preview_count(0), _ch_preview_head(JOYSTICK_OFFLINE_QUEUE_SIZE - 1), + _started_at(0), _initialized(false) +{ + memset(_alert, 0, sizeof(_alert)); + memset(_compose_channel_name, 0, sizeof(_compose_channel_name)); + memset(_compose_contact_name, 0, sizeof(_compose_contact_name)); + memset(_compose_contact_pubkey, 0, sizeof(_compose_contact_pubkey)); + memset(_ch_previews, 0, sizeof(_ch_previews)); + memset(_pending_ping_pubkey, 0, sizeof(_pending_ping_pubkey)); + _pending_ping_sent_ms = 0; + memset(_discover_signals, 0, sizeof(_discover_signals)); +} + +/* ===== begin() ===== */ +void JoystickUITask::begin(BaseChatMesh *mesh, mesh::ZephyrRTCClock *rtc, NodePrefs *prefs) +{ + _mesh = mesh; + _rtc = rtc; + _prefs = prefs; + _started_at = k_uptime_get_32(); + + if (prefs && prefs->display_brightness >= 10) { + _brightness = prefs->display_brightness; + } + if (prefs) { + _ble_enabled = !prefs->ble_disabled; + _wake_on_msg = prefs->wake_on_msg != 0; + if (prefs->screen_off_secs > 0) { + _screen_off_ms = (uint32_t)prefs->screen_off_secs * 1000; + mc_display_set_auto_off_ms(_screen_off_ms); + } + } + applyBrightness(); + _auto_off = _started_at + _screen_off_ms; + _lock_at = _started_at + LOCK_AFTER_MS; + + /* Init key queue */ + k_msgq_init(&_key_queue, _key_buf, sizeof(char), JOYSTICK_KEY_QUEUE_DEPTH); + joystick_queue_initialized = true; + + /* Allocate screens */ + _splash = new SplashScreen(this); + _home = new HomeScreen(this, rtc); + _contacts = new ContactsScreen(this, rtc); + _unread = new UnreadScreen(this, rtc); + _advert = new AdvertScreen(this, rtc); + _gps = new GPSSettingsScreen(this, rtc); + _system = new SystemScreen(this, rtc); + _system_time = new SystemTimeScreen(this, rtc); + _telemetry = new TelemetryScreen(this, rtc); + _tools = new ToolsScreen(this, rtc); + _radio_stats = new RadioStatsScreen(this); + _repeaters = new RepeatersScreen(this, rtc); + _channels = new ChannelsScreen(this, rtc); + _t9_input = new T9InputScreen(this); + _rename_node = new RenameNodeScreen(this); + _ble_code = new BLECodeScreen(this); + _stats = new StatsScreen(this); + _stopwatch = new StopwatchScreen(this); + _countdown = new CountdownScreen(this); + _snake = new SnakeScreen(this); +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM + _doom = new DoomScreen(this); +#endif + _repeater_admin = new RepeaterAdminScreen(this, rtc); + + setCurrScreen(_splash); + _initialized = true; + if (s_schedule_render_fn) s_schedule_render_fn(50); + + LOG_INF("joystick UI initialized"); +} + +/* ===== setCurrScreen / navigation ===== */ +void JoystickUITask::setCurrScreen(UIScreen *s) +{ + _curr = s; + _next_refresh = 0; +} + +void JoystickUITask::gotoHomeScreen() { setCurrScreen(_home); } +void JoystickUITask::gotoContactsScreen() { setCurrScreen(_contacts); } +void JoystickUITask::gotoAdvertScreen() { setCurrScreen(_advert); } +void JoystickUITask::gotoGPSScreen() { setCurrScreen(_gps); } +void JoystickUITask::gotoSystemScreen() { setCurrScreen(_system); } +void JoystickUITask::gotoSystemTimeScreen() { setCurrScreen(_system_time); } +void JoystickUITask::gotoTelemetryScreen() { setCurrScreen(_telemetry); } +void JoystickUITask::gotoToolsScreen() { setCurrScreen(_tools); } +void JoystickUITask::gotoRadioStatsScreen() { setCurrScreen(_radio_stats); } +void JoystickUITask::gotoRepeatersScreen() { setCurrScreen(_repeaters); } +void JoystickUITask::gotoChannelsScreen() { setCurrScreen(_channels); } +void JoystickUITask::gotoT9InputScreen() { setCurrScreen(_t9_input); } +void JoystickUITask::gotoRenameNodeScreen() { setCurrScreen(_rename_node); } +void JoystickUITask::gotoBLECodeScreen() { setCurrScreen(_ble_code); } +void JoystickUITask::gotoStatsScreen() { setCurrScreen(_stats); } +void JoystickUITask::gotoStopwatchScreen() { setCurrScreen(_stopwatch); } +void JoystickUITask::gotoCountdownScreen() { setCurrScreen(_countdown); } +void JoystickUITask::gotoSnakeScreen() { setCurrScreen(_snake); } +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM +void JoystickUITask::gotoDoomScreen() { setCurrScreen(_doom); } +#endif + +void JoystickUITask::gotoUnreadScreen() +{ + if (!_unread) return; + static_cast(_unread)->activatePreview(false, 0); + setCurrScreen(_unread); +} + +void JoystickUITask::gotoContactForAdvert(const uint8_t *prefix, int prefix_len) +{ + if (!_contacts) return; + static_cast(_contacts)->openForPubKey(prefix, prefix_len); + setCurrScreen(_contacts); +} + +void JoystickUITask::gotoRepeaterAdminScreen(const uint8_t *pub_key, const char *name, + bool from_contacts) +{ + if (!_repeater_admin) return; + static_cast(_repeater_admin)->openForContact(pub_key, name, + from_contacts); + setCurrScreen(_repeater_admin); +} + +/* ===== Alert ===== */ +void JoystickUITask::showAlert(const char *text, int duration_ms) +{ + strncpy(_alert, text, sizeof(_alert) - 1); + _alert[sizeof(_alert) - 1] = '\0'; + _alert_expiry = k_uptime_get_32() + (uint32_t)duration_ms; +} + +void JoystickUITask::renderAlertOverlay() +{ + int fw = _display.fontW(); + int fh = _display.fontH(); + int w = _display.width(); + int h = _display.height(); + int pad = 5; + int box_h = fh + 8; + int box_w = w - 2 * pad; + int box_x = pad; + int box_y = (h - box_h) / 2; + + /* Fill the area white so the invert below produces clean black background */ + mc_display_fill_rect(box_x, box_y, box_w, box_h); + /* Invert the filled area → clean black background (erases page content) */ + mc_display_invert_rect(box_x, box_y, box_w, box_h); + /* White border */ + _display.setColor(JoystickDisplay::WHITE); + _display.drawRect(box_x, box_y, box_w, box_h); + /* White text centered inside */ + int text_len = (int)strlen(_alert); + int text_w = text_len * fw; + int text_x = box_x + (box_w - text_w) / 2; + if (text_x < box_x + 2) text_x = box_x + 2; + mc_display_text(text_x, box_y + (box_h - fh) / 2, _alert, false); +} + +void JoystickUITask::handleLockInput(char key, uint32_t now) +{ + switch (_lock_step) { + case 0: + if (key == KEY_CANCEL) _lock_step = 1; + else _lock_step = 0; + break; + case 1: + if (key == KEY_ENTER || key == KEY_ENTER_LONG) _lock_step = 2; + else _lock_step = 0; + break; + case 2: + if (key == KEY_CANCEL) { + _locked = false; + _lock_step = 0; + _lock_at = now + LOCK_AFTER_MS; + if (s_start_heartbeat_fn) s_start_heartbeat_fn(); + } else { + _lock_step = 0; + } + break; + default: + _lock_step = 0; + break; + } + _next_refresh = 0; +} + +void JoystickUITask::renderLockOverlay() +{ + int fw = _display.fontW(); + int fh = _display.fontH(); + int w = _display.width(); + int h = _display.height(); + + /* Full black background */ + mc_display_fill_rect(0, 0, w, h); + mc_display_invert_rect(0, 0, w, h); + _display.setColor(JoystickDisplay::WHITE); + _display.drawRect(0, 0, w, h); + + /* Title */ + const char *title = "LOCKED"; + int title_w = (int)strlen(title) * fw; + mc_display_text((w - title_w) / 2, 6, title, false); + mc_display_hline(2, 6 + fh + 2, w - 4); + + /* Three step unlock sequence */ + const char *toks[3] = { "Back", "OK", "Back" }; + const int gap = 6; + int total_w = 0; + for (int i = 0; i < 3; i++) total_w += (int)strlen(toks[i]) * fw; + total_w += gap * 2; + int seq_y = h - fh - 8; + int x = (w - total_w) / 2; + + for (int i = 0; i < 3; i++) { + int tw = (int)strlen(toks[i]) * fw; + bool active = (_lock_step == (uint8_t)i); + if (active) { + mc_display_fill_rect(x - 1, seq_y - 1, tw + 2, fh + 2); + mc_display_invert_rect(x - 1, seq_y - 1, tw + 2, fh + 2); + mc_display_text(x, seq_y, toks[i], true); + } else { + mc_display_text(x, seq_y, toks[i], false); + } + x += tw + gap; + } +} + +/* ===== loop() ===== */ +void JoystickUITask::loop() +{ + if (!_initialized) return; + + uint32_t now = k_uptime_get_32(); + + /* Auto off */ + if (_display.isOn() && now >= _auto_off) { + _display.turnOff(); + } + + /* Auto lock */ + if (!_locked && now >= _lock_at) { + _locked = true; + _lock_step = 0; + _next_refresh = 0; + if (s_stop_heartbeat_fn) s_stop_heartbeat_fn(); + } + + /* Dequeue key events */ + char key = 0; + if (k_msgq_get(&_key_queue, &key, K_NO_WAIT) == 0) { + if (!_display.isOn()) { + /* Wake display, consume the key press */ + _display.turnOn(); /* mc_display_on() already calls mc_display_reset_auto_off() */ + _auto_off = now + _screen_off_ms; + _next_refresh = 0; + goto do_render; + } + + /* Reset auto off on any keypress */ + mc_display_reset_auto_off(); + _auto_off = now + _screen_off_ms; + + if (_locked) { + handleLockInput(key, now); + } else { + _lock_at = now + LOCK_AFTER_MS; + bool consumed = false; + if (key == KEY_BUZZ_TOGGLE) { + toggleBuzzer(); + consumed = true; + } else if (key == KEY_GPS_TOGGLE) { + toggleGPS(); + consumed = true; + } else if (key == KEY_LED_TOGGLE) { + toggleLeds(); + consumed = true; + } + if (!consumed && _curr) { + _curr->handleInput(key); + } + _next_refresh = now; + } + } + + /* Poll current screen */ + if (_curr) { + _curr->poll(); + } + +do_render: + /* Render if due */ + if (_display.isOn() && now >= _next_refresh && _curr) { +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM + if (doom_game_is_running()) { + _next_refresh = now + 500; + if (s_schedule_render_fn) s_schedule_render_fn(500); + } else +#endif + { + _display.startFrame(); + int delay_ms; + if (_locked) { + renderLockOverlay(); + delay_ms = 200; + } else { + delay_ms = _curr->render(_display); + if (now < _alert_expiry) { + renderAlertOverlay(); + } + if (delay_ms <= 0) delay_ms = 100; + } + _next_refresh = now + (uint32_t)delay_ms; + if (s_schedule_render_fn) s_schedule_render_fn((uint32_t)delay_ms); + _display.endFrame(); + } + } +} + +/* ===== Buzzer / GPS helpers ===== */ +bool JoystickUITask::isBuzzerQuiet() const +{ +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + return buzzer_is_quiet(); +#else + return true; +#endif +} + +void JoystickUITask::toggleBuzzer() +{ +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + bool was_quiet = buzzer_is_quiet(); + if (was_quiet) { + buzzer_set_quiet(false); + buzzer_play("bon:d=16,o=7,b=200:c,p,c,p,c,p,p,8e"); + } else { + buzzer_play("bof:d=16,o=7,b=200:c,p,c,p,c,p,p,8g5"); + buzzer_set_quiet_deferred(true); + } + mesh_set_buzzer_quiet(!was_quiet); +#endif +} + +bool JoystickUITask::isGPSAvailable() const +{ + return gps_is_available(); +} + +bool JoystickUITask::getGPSState() const +{ + return gps_is_enabled(); +} + +void JoystickUITask::toggleGPS() +{ + if (!gps_is_available()) return; + mesh_gps_set_enabled(!gps_is_enabled()); +} + +void JoystickUITask::playCountdownAlarm() +{ +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + bool was_quiet = buzzer_is_quiet(); + buzzer_set_quiet(false); + buzzer_play("alarm:d=8,o=6,b=180:c,p,c,p,c,p,c"); + if (was_quiet) { + buzzer_set_quiet_deferred(true); + } +#endif +} + +/* ===== BLE serial toggle ===== */ +void JoystickUITask::enableSerial() { _ble_enabled = true; mesh_ble_set_enabled(true); } +void JoystickUITask::disableSerial() { _ble_enabled = false; mesh_ble_set_enabled(false); } + +/* ===== Offgrid / LEDs / DFU ===== */ +void JoystickUITask::setOffgridMode(bool enable) +{ + if (_prefs) _prefs->client_repeat = enable ? 1 : 0; + mesh_set_offgrid_mode(enable); +} + +void JoystickUITask::toggleLeds() +{ + bool new_disabled = !isLedsDisabled(); + if (_prefs) _prefs->leds_disabled = new_disabled ? 1 : 0; + ui_set_heartbeat_led(!new_disabled); + mesh_set_leds_disabled(new_disabled); +} + +void JoystickUITask::rebootToDFU() +{ + mesh_reboot_to_ota_dfu(); +} + +/* ===== Display brightness ===== */ +void JoystickUITask::applyBrightness() +{ + const struct device *dev = mc_display_get_device(); + if (dev) { + uint8_t level = (uint8_t)(((uint32_t)_brightness * 255U) / 100U); + /* SH1106/SSD1306 OLEDs expose luminance via the contrast register. + * display_set_brightness() is not implemented by those drivers, + * display_set_contrast() maps directly to the 0x81 contrast command. */ + if (display_set_contrast(dev, level) != 0) { + display_set_brightness(dev, level); + } + } +} + +void JoystickUITask::adjustBrightness(int delta) +{ + int v = (int)_brightness + delta; + if (v < 10) v = 10; + if (v > 100) v = 100; + _brightness = (uint8_t)v; + applyBrightness(); + mesh_save_brightness(_brightness); +} + +void JoystickUITask::adjustScreenOff(int32_t delta_ms) +{ + int32_t v = (int32_t)_screen_off_ms + delta_ms; + if (v < 5000) v = 5000; + if (v > 300000) v = 300000; + _screen_off_ms = (uint32_t)v; + _auto_off = k_uptime_get_32() + _screen_off_ms; + mc_display_set_auto_off_ms(_screen_off_ms); + mc_display_reset_auto_off(); + mesh_save_screen_off_secs((uint16_t)(_screen_off_ms / 1000)); +} + +void JoystickUITask::toggleWakeOnMsg() +{ + _wake_on_msg = !_wake_on_msg; + mesh_set_wake_on_msg(_wake_on_msg); +} + +/* ===== Notifications from mesh ===== */ +void JoystickUITask::newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount) +{ + _msgcount = msgcount; + if (_unread) { + static_cast(_unread)->addPreview(path_len, from_name, text); + } + /* Wake display if wake on msg is enabled and no BLE peer is connected */ + if (_wake_on_msg && !_ble_connected && !_display.isOn()) { + _display.turnOn(); /* mc_display_on() already calls mc_display_reset_auto_off() */ + _auto_off = k_uptime_get_32() + _screen_off_ms; + } + _next_refresh = 0; +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + if (!_ble_connected) { + buzzer_play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + } +#endif +} + +void JoystickUITask::newChannelMsg(const char *channel_name, const char *text, + uint32_t ts, uint8_t path_len) +{ + /* Store in channel preview ring */ + _ch_preview_head = (_ch_preview_head + 1) % JOYSTICK_OFFLINE_QUEUE_SIZE; + ChannelMsgPreview &p = _ch_previews[_ch_preview_head]; + strncpy(p.channel, channel_name, sizeof(p.channel) - 1); + p.channel[sizeof(p.channel) - 1] = '\0'; + strncpy(p.text, text, sizeof(p.text) - 1); + p.text[sizeof(p.text) - 1] = '\0'; + p.timestamp = ts; + p.path_len = path_len; + if (_ch_preview_count < JOYSTICK_OFFLINE_QUEUE_SIZE) { + _ch_preview_count++; + } + if (_unread) { + char ch_from[48]; + snprintf(ch_from, sizeof(ch_from), "#%s", channel_name ? channel_name : "?"); + static_cast(_unread)->addPreview(path_len, ch_from, text); + } + if (_wake_on_msg && !_ble_connected && !_display.isOn()) { + _display.turnOn(); + _auto_off = k_uptime_get_32() + _screen_off_ms; + } + _next_refresh = 0; +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + if (!_ble_connected) { + buzzer_play("kerplop:d=16,o=6,b=120:32g#,32c#"); + } +#endif +} + +void JoystickUITask::msgRead(int msgcount) +{ + _msgcount = msgcount; + if (msgcount == 0 && _curr == _unread) { + gotoHomeScreen(); + } +} + +void JoystickUITask::notify() +{ + _next_refresh = 0; + if (s_signal_fn) s_signal_fn(); +} + +/* ===== Message accessors ===== */ +int JoystickUITask::getUnreadCount() +{ + if (!_unread) return 0; + return static_cast(_unread)->getUnreadCount(); +} + +int JoystickUITask::getStoredMsgCount() const +{ + if (!_unread) return 0; + return static_cast(_unread)->getStoredMsgCount(); +} + +int JoystickUITask::getContactMsgCount(const char *contact_name) const +{ + if (!_unread) return 0; + return static_cast(_unread)->getContactMsgCount(contact_name); +} + +bool JoystickUITask::getContactMsgAt(const char *contact_name, int idx, + const char *&out_msg, uint32_t &out_ts, + uint8_t *out_path) const +{ + if (!_unread) return false; + return static_cast(_unread)->getContactMsgAt( + contact_name, idx, out_msg, out_ts, out_path); +} + +int JoystickUITask::getChannelPreviewCountFor(const char *channel) const +{ + if (!channel || !channel[0]) return 0; + int count = 0; + for (int i = 0; i < _ch_preview_count; i++) { + int pos = (_ch_preview_head - i + JOYSTICK_OFFLINE_QUEUE_SIZE) % JOYSTICK_OFFLINE_QUEUE_SIZE; + if (strcmp(_ch_previews[pos].channel, channel) == 0) count++; + } + return count; +} + +bool JoystickUITask::getChannelPreviewFor(const char *channel, int idx, + const char *&text, uint32_t &ts, + uint8_t *path_len) const +{ + if (!channel) return false; + int match = 0; + for (int i = 0; i < _ch_preview_count; i++) { + int pos = (_ch_preview_head - i + JOYSTICK_OFFLINE_QUEUE_SIZE) % JOYSTICK_OFFLINE_QUEUE_SIZE; + if (strcmp(_ch_previews[pos].channel, channel) != 0) continue; + if (match == idx) { + text = _ch_previews[pos].text; + ts = _ch_previews[pos].timestamp; + if (path_len) *path_len = _ch_previews[pos].path_len; + return true; + } + match++; + } + return false; +} + +/* ===== Compose helpers ===== */ +void JoystickUITask::setComposeChannel(int idx, const char *name) +{ + _compose_is_contact = false; + _compose_channel_idx = idx; + if (name) { + strncpy(_compose_channel_name, name, sizeof(_compose_channel_name) - 1); + _compose_channel_name[sizeof(_compose_channel_name) - 1] = '\0'; + } else { + _compose_channel_name[0] = '\0'; + } +} + +void JoystickUITask::setComposeContact(const ContactInfo &contact) +{ + _compose_is_contact = true; + _compose_channel_idx = -1; + _compose_channel_name[0] = '\0'; + strncpy(_compose_contact_name, contact.name, sizeof(_compose_contact_name) - 1); + _compose_contact_name[sizeof(_compose_contact_name) - 1] = '\0'; + memcpy(_compose_contact_pubkey, contact.id.pub_key, PUB_KEY_SIZE); +} + +bool JoystickUITask::sendComposedMessage(const char *text) +{ + if (!text || !text[0] || !_mesh) return false; + + if (_compose_is_contact) { + ContactInfo *recipient = _mesh->lookupContactByPubKey(_compose_contact_pubkey, PUB_KEY_SIZE); + if (!recipient) return false; + uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); + uint32_t expected_ack = 0, est_timeout = 0; + int result = _mesh->sendMessage(*recipient, ts, 0, text, expected_ack, est_timeout); + if (result == 0) return false; + ui_signal_tx(); + if (_unread) { + static_cast(_unread)->addPreview(OUT_PATH_SENT, _compose_contact_name, text); + } + return true; + } + + if (_compose_channel_idx == -2) { + /* Join public #channel: PSK = SHA256(name)[0..15] */ + uint8_t psk[16]; + mesh::Utils::sha256(psk, sizeof(psk), (const uint8_t *)text, (int)strlen(text)); + if (_mesh->addChannel(text, psk, sizeof(psk))) { + return true; + } + return false; + } + + if (_compose_channel_idx >= 0) { + ChannelDetails ch; + if (!_mesh->getChannel(_compose_channel_idx, ch)) return false; + uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); + bool ok = _mesh->sendGroupMessage(ts, ch.channel, + _prefs ? _prefs->node_name : "", text, (int)strlen(text)); + if (ok) { + ui_signal_tx(); + _ch_preview_head = (_ch_preview_head + 1) % JOYSTICK_OFFLINE_QUEUE_SIZE; + ChannelMsgPreview &p = _ch_previews[_ch_preview_head]; + strncpy(p.channel, ch.name, sizeof(p.channel) - 1); + p.channel[sizeof(p.channel) - 1] = '\0'; + strncpy(p.text, text, sizeof(p.text) - 1); + p.text[sizeof(p.text) - 1] = '\0'; + p.timestamp = ts; + p.path_len = OUT_PATH_SENT; + if (_ch_preview_count < JOYSTICK_OFFLINE_QUEUE_SIZE) _ch_preview_count++; + } + return ok; + } + + return false; +} + +bool JoystickUITask::sendChannelMessage(const char *text) +{ + if (!text || !text[0] || _compose_channel_idx < 0 || !_mesh) return false; + ChannelDetails ch; + if (!_mesh->getChannel(_compose_channel_idx, ch)) return false; + uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); + bool ok = _mesh->sendGroupMessage(ts, ch.channel, + _prefs ? _prefs->node_name : "", text, (int)strlen(text)); + if (ok) ui_signal_tx(); + return ok; +} + +bool JoystickUITask::findContactByName(const char *name, ContactInfo &contact) +{ + if (!name || !_mesh) return false; + int n = _mesh->getNumContacts(); + for (int i = 0; i < n; i++) { + ContactInfo c; + if (_mesh->getContactByIdx(i, c) && strcmp(c.name, name) == 0) { + contact = c; + return true; + } + } + return false; +} + +/* ===== RepeaterAdmin callbacks ===== */ +void JoystickUITask::onRepeaterAdminLoginResult(const uint8_t *pub_key_prefix, + bool success, uint8_t permissions, uint32_t server_time) +{ + if (!_repeater_admin || _curr != _repeater_admin) return; + auto *s = static_cast(_repeater_admin); + (void)pub_key_prefix; + s->onLoginResult(success, permissions, server_time); + _next_refresh = 0; + ui_signal_refresh(); +} + +void JoystickUITask::onRepeaterAdminCliResponse(const uint8_t *pub_key_prefix, + const char *text) +{ + if (!_repeater_admin || _curr != _repeater_admin) return; + (void)pub_key_prefix; + static_cast(_repeater_admin)->onCliResponse(text); + _next_refresh = 0; + ui_signal_refresh(); +} + +/* ===== Repeater events ===== */ + +void JoystickUITask::onRepeaterReqResponse(const uint8_t *pub_key_prefix, + int8_t snr_local, int8_t snr_remote, + const uint8_t *data, uint8_t data_len) +{ + /* Route binary response to repeater admin screen if it's active and waiting */ + if (_repeater_admin && _curr == _repeater_admin && data && data_len > 0) { + static_cast(_repeater_admin)->onReqResponse(pub_key_prefix, + data, data_len); + _next_refresh = 0; + ui_signal_refresh(); + } + + if (memcmp(_pending_ping_pubkey, pub_key_prefix, 4) != 0) return; + uint32_t rtt_ms = _pending_ping_sent_ms > 0 + ? k_uptime_get_32() - _pending_ping_sent_ms : 0; + memset(_pending_ping_pubkey, 0, sizeof(_pending_ping_pubkey)); + _pending_ping_sent_ms = 0; + if (!_contacts || _curr != _contacts) return; + static_cast(_contacts)->onPingResponse(snr_local, snr_remote, rtt_ms); + _next_refresh = 0; + ui_signal_refresh(); +} + +void JoystickUITask::onRepeaterDiscoverResp(const uint8_t *pub_key, + int8_t snr, int8_t snr_remote, uint8_t path_len) +{ + DiscoverSignal *slot = nullptr; + DiscoverSignal *empty_slot = nullptr; + for (int i = 0; i < DISCOVER_SIGNAL_TABLE_SIZE; i++) { + if (_discover_signals[i].valid) { + if (memcmp(_discover_signals[i].pubkey, pub_key, PUB_KEY_SIZE) == 0) { + slot = &_discover_signals[i]; + break; + } + } else if (!empty_slot) { + empty_slot = &_discover_signals[i]; + } + } + if (!slot) slot = empty_slot; + if (!slot) return; // table full, drop + memcpy(slot->pubkey, pub_key, PUB_KEY_SIZE); + slot->snr = snr; + slot->snr_remote = snr_remote; + slot->path_len = path_len; + slot->valid = true; +} + +void JoystickUITask::notifyPacketSent() +{ + /* Stamp RTT start on first TX after a ping was queued */ + if (_pending_ping_sent_ms == 0 && + memcmp(_pending_ping_pubkey, "\0\0\0\0", 4) != 0) { + _pending_ping_sent_ms = k_uptime_get_32(); + } + if (_contacts && _curr == _contacts) { + static_cast(_contacts)->onPacketSent(); + } + if (_repeater_admin && _curr == _repeater_admin) { + static_cast(_repeater_admin)->onPacketSent(); + } +} + +int JoystickUITask::getRecentlyHeard(AdvertPath *dest, int max) const +{ + auto *cm = static_cast(_mesh); + return cm ? cm->getRecentlyHeard(dest, max) : 0; +} + +bool JoystickUITask::getDiscoverSignal(const uint8_t *pubkey, int8_t &snr_out, + uint8_t *path_len_out) const +{ + for (int i = 0; i < DISCOVER_SIGNAL_TABLE_SIZE; i++) { + if (_discover_signals[i].valid && + memcmp(_discover_signals[i].pubkey, pubkey, PUB_KEY_SIZE) == 0) { + snr_out = _discover_signals[i].snr; + if (path_len_out) *path_len_out = _discover_signals[i].path_len; + return true; + } + } + return false; +} + +void JoystickUITask::clearDiscoverSignals() +{ + memset(_discover_signals, 0, sizeof(_discover_signals)); +} + +int JoystickUITask::getDiscoverCount() const +{ + int n = 0; + for (int i = 0; i < DISCOVER_SIGNAL_TABLE_SIZE; i++) { + if (_discover_signals[i].valid) n++; + } + return n; +} + +bool JoystickUITask::getDiscoverByIdx(int idx, uint8_t *pubkey_out, + int8_t &snr_out, int8_t &snr_remote_out, + uint8_t *path_len_out) const +{ + int found = 0; + for (int i = 0; i < DISCOVER_SIGNAL_TABLE_SIZE; i++) { + if (!_discover_signals[i].valid) continue; + if (found == idx) { + if (pubkey_out) memcpy(pubkey_out, _discover_signals[i].pubkey, PUB_KEY_SIZE); + snr_out = _discover_signals[i].snr; + snr_remote_out = _discover_signals[i].snr_remote; + if (path_len_out) *path_len_out = _discover_signals[i].path_len; + return true; + } + found++; + } + return false; +} + +bool JoystickUITask::addRepeaterContact(const uint8_t *pubkey) +{ + if (!_mesh) return false; + if (_mesh->lookupContactByPubKey(pubkey, PUB_KEY_SIZE)) return false; // already exists + ContactInfo c = {}; + memcpy(c.id.pub_key, pubkey, PUB_KEY_SIZE); + c.type = ADV_TYPE_REPEATER; + c.out_path_len = OUT_PATH_UNKNOWN; + c.lastmod = (uint32_t)_rtc->getCurrentTime(); + snprintf(c.name, sizeof(c.name), "!%02X%02X%02X%02X", + pubkey[0], pubkey[1], pubkey[2], pubkey[3]); + return _mesh->addContact(c); +} + +void JoystickUITask::registerAdminReqTag(uint32_t tag) +{ + auto *cm = static_cast(_mesh); + if (cm) cm->setJoystickAdminTag(tag); +} + +void JoystickUITask::clearAdminReqTag() +{ + auto *cm = static_cast(_mesh); + if (cm) cm->clearJoystickAdminTag(); +} + +void JoystickUITask::cancelContactPing() +{ + auto *cm = static_cast(_mesh); + if (cm) cm->clearJoystickPingTag(); + memset(_pending_ping_pubkey, 0, sizeof(_pending_ping_pubkey)); + _pending_ping_sent_ms = 0; +} + +bool JoystickUITask::sendContactPingZeroHop(const ContactInfo &contact, uint32_t &est_timeout) +{ + auto *cm = static_cast(_mesh); + if (!cm) return false; + ContactInfo temp = contact; + temp.out_path_len = 0; + uint32_t tag = 0; + est_timeout = 0; + int res = cm->sendRequest(temp, REQ_TYPE_GET_STATUS, tag, est_timeout); + if (res != MSG_SEND_FAILED) { + memcpy(_pending_ping_pubkey, contact.id.pub_key, 4); + _pending_ping_sent_ms = 0; + cm->setJoystickPingTag(tag); + ui_signal_tx(); + } + return res != MSG_SEND_FAILED; +} + +/* ===== Shutdown ===== */ +void JoystickUITask::shutdown(bool restart) +{ + LOG_INF("joystick UI: shutdown (restart=%d)", restart); +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + buzzer_play("shutdown:d=16,o=5,b=120:c,p,a4,p,f4"); + /* Brief wait for melody */ + k_sleep(K_MSEC(400)); +#endif + _display.turnOff(); + if (restart) { + sys_reboot(SYS_REBOOT_COLD); + } else { +#ifdef CONFIG_POWEROFF + sys_poweroff(); +#else + sys_reboot(SYS_REBOOT_COLD); +#endif + } +} diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.h b/zephcore/helpers/ui-joystick/joystick_ui_task.h new file mode 100644 index 0000000..271cff5 --- /dev/null +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.h @@ -0,0 +1,299 @@ +/* + * ZephCore - Joystick UI Task + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * JoystickUITask: joystick-based UI coordinator for the ZephCore companion. + * + * Input thread (Zephyr INPUT_CALLBACK_DEFINE): + * → enqueues key chars to _key_queue (k_msgq) + * + * Mesh event loop thread (main_companion.cpp): + * → calls loop() periodically + * → loop() dequeues keys, dispatches to current screen + * → renders when needed + */ + +#pragma once + +#include "joystick_display.h" +#include "joystick_defs.h" +#include "joystick_screens.h" + +#include +#include +#include +#include +#include + +#include +#include + +/* Message preview ring for channel messages */ +#ifndef JOYSTICK_OFFLINE_QUEUE_SIZE +#define JOYSTICK_OFFLINE_QUEUE_SIZE 16 +#endif + +/* Key event queue depth */ +#define JOYSTICK_KEY_QUEUE_DEPTH 8 + +class JoystickUITask { +public: + JoystickUITask(); + + /* Call once after mesh/prefs are loaded */ + void begin(BaseChatMesh *mesh, mesh::ZephyrRTCClock *rtc, NodePrefs *prefs); + + /* Call from mesh event loop: processes input, renders, calls poll() */ + void loop(); + + /* Called from CompanionMesh callbacks (mesh thread context) */ + void newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount); + void newChannelMsg(const char *channel_name, const char *text, uint32_t ts, uint8_t path_len); + void msgRead(int msgcount); + void notify(); /* BLE connect/disconnect, ACK, etc. */ + + /* Alert overlay */ + void showAlert(const char *text, int duration_ms); + + /* Screen navigation (called from screen handleInput()) */ + void gotoHomeScreen(); + void gotoContactsScreen(); + void gotoAdvertScreen(); + void gotoGPSScreen(); + void gotoSystemScreen(); + void gotoSystemTimeScreen(); + void gotoTelemetryScreen(); + void gotoToolsScreen(); + void gotoRadioStatsScreen(); + void gotoRepeatersScreen(); + void gotoChannelsScreen(); + void gotoT9InputScreen(); + void gotoRenameNodeScreen(); + void gotoBLECodeScreen(); + void gotoStatsScreen(); + void gotoStopwatchScreen(); + void gotoCountdownScreen(); + void gotoSnakeScreen(); +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM + void gotoDoomScreen(); +#endif + void gotoUnreadScreen(); + void gotoContactForAdvert(const uint8_t *prefix, int prefix_len); + void gotoRepeaterAdminScreen(const uint8_t *pub_key, const char *name, bool from_contacts = false); + + void forceRefresh() { _next_refresh = 0; } + + /* State accessors for screens */ + uint16_t getCachedBattMilliVolts() const { return _cached_batt_mv; } + int getBatteryDisplayMode() const { return _battery_display_mode; } + void toggleBatteryDisplayMode() { _battery_display_mode = (_battery_display_mode + 1) % 2; } + uint32_t getScreenOffMillis() const { return _screen_off_ms; } + void adjustScreenOff(int32_t delta_ms); + uint8_t getBrightness() const { return _brightness; } + void adjustBrightness(int delta); + bool getWakeOnMsg() const { return _wake_on_msg; } + void toggleWakeOnMsg(); + bool isBuzzerQuiet() const; + void toggleBuzzer(); + bool isGPSAvailable() const; + bool getGPSState() const; + void toggleGPS(); + bool isSerialEnabled() const { return _ble_enabled; } + void disableSerial(); + void enableSerial(); + bool isOffgridEnabled() const { return _prefs && _prefs->client_repeat != 0; } + void setOffgridMode(bool enable); + bool isLedsDisabled() const { return _prefs && _prefs->leds_disabled != 0; } + void toggleLeds(); + void rebootToDFU(); + void shutdown(bool restart = false); + void playCountdownAlarm(); + + /* Message / compose accessors */ + int getUnreadCount(); + int getStoredMsgCount() const; + int getMsgCount() const { return _msgcount; } + int getContactMsgCount(const char *contact_name) const; + bool getContactMsgAt(const char *contact_name, int idx, const char *&out_msg, + uint32_t &out_ts, uint8_t *out_path = nullptr) const; + int getChannelPreviewCountFor(const char *channel) const; + bool getChannelPreviewFor(const char *channel, int idx, const char *&text, + uint32_t &ts, uint8_t *path_len = nullptr) const; + bool isComposeContact() const { return _compose_is_contact; } + int getComposeChannelIndex() const { return _compose_channel_idx; } + const char *getComposeChannelName() const { return _compose_channel_name; } + const char *getComposeContactName() const { return _compose_contact_name; } + void setComposeChannel(int idx, const char *name); + void setComposeContact(const ContactInfo &contact); + bool sendComposedMessage(const char *text); + bool sendChannelMessage(const char *text); + bool findContactByName(const char *name, ContactInfo &contact); + int getRecentlyHeard(AdvertPath *dest, int max) const; + bool getDiscoverSignal(const uint8_t *pubkey, int8_t &snr_out, + uint8_t *path_len_out = nullptr) const; + void clearDiscoverSignals(); + int getDiscoverCount() const; + bool getDiscoverByIdx(int idx, uint8_t *pubkey_out, int8_t &snr_out, int8_t &snr_remote_out, + uint8_t *path_len_out = nullptr) const; + bool addRepeaterContact(const uint8_t *pubkey); + void cancelContactPing(); + + /* Mesh access (for screens, safe in mesh thread) */ + BaseChatMesh *getMesh() const { return _mesh; } + mesh::ZephyrRTCClock *getRTC() const { return _rtc; } + NodePrefs *getPrefs() const { return _prefs; } + JoystickDisplay &getDisplay() { return _display; } + + /* Battery (updated via ui_set_battery from housekeeping) */ + void setCachedBattMilliVolts(uint16_t mv) { _cached_batt_mv = mv; } + + /* Radio stats (fed from main companion loop) */ + void setNoiseFloor(int16_t nf) { _noise_floor = nf; } + void setPacketStats(uint32_t recv, uint32_t sent, uint32_t errors) { + _pkt_recv = recv; _pkt_sent = sent; _pkt_errors = errors; + } + int16_t getNoiseFloor() const { return _noise_floor; } + uint32_t getPktRecv() const { return _pkt_recv; } + uint32_t getPktSent() const { return _pkt_sent; } + uint32_t getPktErrors() const { return _pkt_errors; } + + /* BLE status (fed from main companion loop) */ + void setBLEConnected(bool connected) { _ble_connected = connected; } + bool isBLEConnected() const { return _ble_connected; } + void setBLEEnabled(bool enabled) { _ble_enabled = enabled; } + + /* RepeaterAdmin callbacks */ + void onRepeaterAdminLoginResult(const uint8_t *pub_key_prefix, bool success, + uint8_t permissions, uint32_t server_time); + void onRepeaterAdminCliResponse(const uint8_t *pub_key_prefix, const char *text); + + /* Repeater events (dispatched from joystick_ui_hooks.cpp) */ + void onRepeaterReqResponse(const uint8_t *pub_key_prefix, + int8_t snr_local, int8_t snr_remote, const uint8_t *data = nullptr, uint8_t data_len = 0); + void onRepeaterDiscoverResp(const uint8_t *pub_key, int8_t snr, int8_t snr_remote, uint8_t path_len); + + bool sendContactPingZeroHop(const ContactInfo &contact, uint32_t &est_timeout); + void registerAdminReqTag(uint32_t tag); + void clearAdminReqTag(); + void notifyPacketSent(); + + /* Key event enqueue (called from input callback, ISR/input thread) */ + static void enqueueKey(char key); + + /* Set by joystick_ui_hooks after registration */ + static void setSignalFn(void (*fn)(void)); + static void setScheduleRenderFn(void (*fn)(uint32_t delay_ms)); + static void setHeartbeatFns(void (*start_fn)(void), void (*stop_fn)(void)); + +private: + static void (*s_signal_fn)(void); + static void (*s_schedule_render_fn)(uint32_t); + static void (*s_start_heartbeat_fn)(void); + static void (*s_stop_heartbeat_fn)(void); + JoystickDisplay _display; + BaseChatMesh *_mesh; + mesh::ZephyrRTCClock *_rtc; + NodePrefs *_prefs; + + /* Screen instances */ + UIScreen *_splash; + UIScreen *_home; + UIScreen *_contacts; + UIScreen *_unread; + UIScreen *_advert; + UIScreen *_gps; + UIScreen *_system; + UIScreen *_system_time; + UIScreen *_telemetry; + UIScreen *_tools; + UIScreen *_radio_stats; + UIScreen *_repeaters; + UIScreen *_channels; + UIScreen *_t9_input; + UIScreen *_rename_node; + UIScreen *_ble_code; + UIScreen *_stats; + UIScreen *_stopwatch; + UIScreen *_countdown; + UIScreen *_snake; +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM + UIScreen *_doom; +#endif + UIScreen *_repeater_admin; + UIScreen *_curr; + + void setCurrScreen(UIScreen *s); + void applyBrightness(); + + /* Timing */ + uint32_t _next_refresh; + uint32_t _auto_off; + uint32_t _screen_off_ms; + uint32_t _next_batt_refresh; + + /* State */ + uint16_t _cached_batt_mv; + int _battery_display_mode; + uint8_t _brightness; + bool _wake_on_msg; + bool _ble_connected; + bool _ble_enabled; + int _msgcount; + int16_t _noise_floor; + uint32_t _pkt_recv, _pkt_sent, _pkt_errors; + + /* Ping state, owned here not in CompanionMesh */ + uint8_t _pending_ping_pubkey[4]; + uint32_t _pending_ping_sent_ms; + + /* Discover signal cache, owned here, populated via onRepeaterDiscoverResp */ + static constexpr int DISCOVER_SIGNAL_TABLE_SIZE = 16; + struct DiscoverSignal { + uint8_t pubkey[PUB_KEY_SIZE]; + uint8_t path_len; + int8_t snr; /* SNR FROM repeater (response as received by us) */ + int8_t snr_remote; /* SNR TO repeater (request as received by repeater) */ + bool valid; + }; + DiscoverSignal _discover_signals[DISCOVER_SIGNAL_TABLE_SIZE]; + + /* Alert overlay */ + char _alert[80]; + uint32_t _alert_expiry; + void renderAlertOverlay(); + + /* Screen lock */ + static constexpr uint32_t LOCK_AFTER_MS = 60000UL; + bool _locked; + uint32_t _lock_at; + uint8_t _lock_step; + void renderLockOverlay(); + void handleLockInput(char key, uint32_t now); + + /* Compose state */ + bool _compose_is_contact; + int _compose_channel_idx; + char _compose_channel_name[32]; + char _compose_contact_name[32]; + uint8_t _compose_contact_pubkey[PUB_KEY_SIZE]; + + /* Channel message previews (ring buffer) */ + struct ChannelMsgPreview { + char channel[32]; + char text[80]; + uint32_t timestamp; + uint8_t path_len; + }; + ChannelMsgPreview _ch_previews[JOYSTICK_OFFLINE_QUEUE_SIZE]; + int _ch_preview_count; + int _ch_preview_head; + + /* Key event queue (k_msgq backed) */ + static char _key_buf[JOYSTICK_KEY_QUEUE_DEPTH * sizeof(char)]; + static struct k_msgq _key_queue; + + /* Startup */ + uint32_t _started_at; + bool _initialized; +}; diff --git a/zephcore/helpers/ui-joystick/screens/contacts.cpp b/zephcore/helpers/ui-joystick/screens/contacts.cpp new file mode 100644 index 0000000..0d7aa01 --- /dev/null +++ b/zephcore/helpers/ui-joystick/screens/contacts.cpp @@ -0,0 +1,1576 @@ +/* + * ZephCore - Joystick UI Contacts Screen + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "../joystick_screens.h" +#include "../joystick_ui_task.h" +#include "../joystick_ui_hooks.h" +#include "screen_helpers.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const int CMODE_LIST = 0; +static const int CMODE_SUBMENU = 1; +static const int CMODE_MSGVIEW = 2; +static const int CMODE_EDITPATH = 3; + +ContactsScreen::ContactsScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), + _selected(0), _filter(0), + _mode(CMODE_LIST), + _submenu_selected(0), + _chat_selected(0), _chat_details(false), _chat_detail_scroll(0), + _active_contact_valid(false), + _idx_send_message(-1), _idx_edit_path(-1), _idx_reset_path(-1), _idx_favorite(-1), + _idx_delete(-1), _idx_repeater_admin(-1), _idx_ping_zerohop(-1), + _editpath_cursor(0), + _editpath_t9_sel(0), _editpath_t9_last_key(-1), + _editpath_t9_letter_index(0), _editpath_t9_last_press(0), + _editpath_kb_letters(true), + _editpath_confirm_exit(false), _editpath_confirm_sel(0), + _header_marquee_ms(0), + _ping_sent_at(0), _ping_timeout_ms(0), + _ping_snr_local(0), _ping_snr_remote(INT8_MIN), _ping_rtt_ms(0), + _ping_modal_active(false) +{ + _active_contact = ContactInfo{}; + memset(_editpath_hexbuf, 0, sizeof(_editpath_hexbuf)); +} + +/* Filter helpers */ + +static const char * const kFilterLabels[4] = {"Favorite", "Users", "Repeaters", "Room"}; + +static bool matchesFilter(const ContactInfo &c, int filter) +{ + switch (filter & 3) { + case 0: return (c.flags & 0x01) != 0; + case 1: return c.type == ADV_TYPE_CHAT; + case 2: return c.type == ADV_TYPE_REPEATER; + default: return c.type == ADV_TYPE_ROOM; + } +} + +int ContactsScreen::getFilteredContactCount() const +{ + int count = 0; + int total = _task->getMesh()->getNumContacts(); + ContactInfo c; + for (int i = 0; i < total; i++) { + if (!_task->getMesh()->getContactByIdx(i, c)) continue; + if (matchesFilter(c, _filter)) count++; + } + return count; +} + +bool ContactsScreen::getFilteredContactByIndex(int listIndex, ContactInfo &contact) const +{ + int matched = 0; + int total = _task->getMesh()->getNumContacts(); + for (int i = 0; i < total; i++) { + if (!_task->getMesh()->getContactByIdx(i, contact)) continue; + if (!matchesFilter(contact, _filter)) continue; + if (matched == listIndex) return true; + matched++; + } + return false; +} + +bool ContactsScreen::refreshActiveContact() +{ + if (!_active_contact_valid) return false; + ContactInfo *live = _task->getMesh()->lookupContactByPubKey(_active_contact.id.pub_key, PUB_KEY_SIZE); + if (!live) { _active_contact_valid = false; return false; } + _active_contact = *live; + return true; +} + +int ContactsScreen::clampStart(int contactCount) const +{ + return computeListStart(_selected, contactCount); +} + +void ContactsScreen::openForPubKey(const uint8_t *prefix, int prefix_len) +{ + int total = _task->getMesh()->getNumContacts(); + + /* Determine the right filter from the contact's type */ + _filter = 1; /* default to Users */ + for (int i = 0; i < total; i++) { + ContactInfo c; + if (!_task->getMesh()->getContactByIdx(i, c)) continue; + if (memcmp(c.id.pub_key, prefix, prefix_len) != 0) continue; + if (c.type == ADV_TYPE_REPEATER) _filter = 2; + else if (c.type == ADV_TYPE_ROOM) _filter = 3; + else _filter = 1; + break; + } + + _mode = CMODE_LIST; + _selected = 0; + _ping_modal_active = false; + _ping_sent_at = 0; + _ping_rtt_ms = 0; + + /* Find the list index under the chosen filter */ + int list_idx = 0; + for (int i = 0; i < total; i++) { + ContactInfo c; + if (!_task->getMesh()->getContactByIdx(i, c)) continue; + if (!matchesFilter(c, _filter)) continue; + if (memcmp(c.id.pub_key, prefix, prefix_len) == 0) { + _selected = list_idx + 2; + break; + } + list_idx++; + } +} + +/* Submenu builder */ + +int ContactsScreen::buildSubmenuItems(const char *items[], char text[][48], int max_items) +{ + int n = 0; + _idx_send_message = _idx_repeater_admin = _idx_reset_path = -1; + _idx_edit_path = _idx_favorite = _idx_delete = _idx_ping_zerohop = -1; + + /* GPS location row — shown only when contact has a known position */ + bool contact_has_pos = (_active_contact.gps_lat != 0 || _active_contact.gps_lon != 0); + if (contact_has_pos && n < max_items) { + struct gps_position pos; + gps_get_position(&pos); + bool own_fix = _task->getGPSState() && pos.valid; + + if (own_fix) { + int32_t own_lat = (int32_t)(pos.latitude_ndeg / 1000LL); + int32_t own_lon = (int32_t)(pos.longitude_ndeg / 1000LL); + float dist_m = gpsDistanceM(own_lat, own_lon, + _active_contact.gps_lat, _active_contact.gps_lon); + float bearing = gpsBearingDeg(own_lat, own_lon, + _active_contact.gps_lat, _active_contact.gps_lon); + const char *dir = compassDir(bearing); + if (dist_m < 1000.0f) { + snprintf(text[n], 48, "%s %dm", dir, (int)(dist_m + 0.5f)); + } else { + int km10 = (int)((dist_m + 50.0f) / 100.0f); + snprintf(text[n], 48, "%s %d.%dkm", dir, km10 / 10, km10 % 10); + } + } else { + int32_t lat = _active_contact.gps_lat; + int32_t lon = _active_contact.gps_lon; + char ns = (lat < 0) ? 'S' : 'N'; + char ew = (lon < 0) ? 'W' : 'E'; + if (lat < 0) lat = -lat; + if (lon < 0) lon = -lon; + snprintf(text[n], 48, "%c%ld.%03ld %c%ld.%03ld", + ns, (long)(lat / 1000000L), (long)((lat % 1000000L) / 1000), + ew, (long)(lon / 1000000L), (long)((lon % 1000000L) / 1000)); + } + items[n] = text[n]; + n++; + } + + /* Public key prefix — display only */ + if (n < max_items) { + snprintf(text[n], 48, "Key: %02X%02X%02X", + _active_contact.id.pub_key[0], + _active_contact.id.pub_key[1], + _active_contact.id.pub_key[2]); + items[n] = text[n]; + n++; + } + + if (_active_contact.type == ADV_TYPE_CHAT) { + if (n < max_items) { _idx_send_message = n; items[n++] = "[>] Messages"; } + } else if (_active_contact.type == ADV_TYPE_REPEATER) { + if (n < max_items) { _idx_ping_zerohop = n; items[n++] = "[>] Ping (0 hop)"; } + if (n < max_items) { _idx_repeater_admin = n; items[n++] = "[>] Admin"; } + } + + if (n < max_items) { _idx_edit_path = n; items[n++] = "[~] Edit path"; } + if (n < max_items) { _idx_reset_path = n; items[n++] = "[>] Reset path"; } + if (n < max_items) { + _idx_favorite = n; + snprintf(text[n], 48, "[~] Favourite: %s", (_active_contact.flags & 0x01) ? "yes" : "no"); + items[n] = text[n]; + n++; + } + if (n < max_items) { _idx_delete = n; items[n++] = "[x] Delete contact"; } + return n; +} + +/* Ping modal overlay */ + +static void drawPingModal(JoystickDisplay &display, + uint32_t ping_sent_at, uint32_t ping_rtt_ms, + int8_t snr_local, int8_t snr_remote) +{ + int fw = display.fontW(), fh = display.fontH(); + int w = display.width(), h = display.height(); + int pad = 4; + int box_h = 2 * fh + 10; + int box_w = w - 2 * pad; + int box_x = pad, box_y = (h - box_h) / 2; + + mc_display_fill_rect(box_x, box_y, box_w, box_h); + mc_display_invert_rect(box_x, box_y, box_w, box_h); + display.setColor(JoystickDisplay::WHITE); + display.drawRect(box_x, box_y, box_w, box_h); + + int y0 = box_y + 4; + int cx = box_x + box_w / 2; + + if (ping_sent_at > 0) { + uint32_t elapsed = k_uptime_get_32() - ping_sent_at; + const char *spin[] = { "Pinging.", "Pinging..", "Pinging..." }; + const char *s = spin[(elapsed / 333) % 3]; + mc_display_text(cx - (int)strlen(s) * fw / 2, y0, s, false); + const char *hint = "CANCEL to abort"; + mc_display_text(cx - (int)strlen(hint) * fw / 2, y0 + fh + 3, hint, false); + } else if (ping_rtt_ms == 0) { + /* Packet queued but RF TX not yet complete — show static first frame */ + const char *s = "Pinging."; + mc_display_text(cx - (int)strlen(s) * fw / 2, y0, s, false); + const char *hint = "CANCEL to abort"; + mc_display_text(cx - (int)strlen(hint) * fw / 2, y0 + fh + 3, hint, false); + } else if (ping_rtt_ms == UINT32_MAX) { + mc_display_text(cx - 3 * fw, y0, "TIMEOUT", false); + const char *hint = "OK to close"; + mc_display_text(cx - (int)strlen(hint) * fw / 2, y0 + fh + 3, hint, false); + } else { + char line1[24], line2[20]; + if (snr_remote != INT8_MIN) { + snprintf(line1, sizeof(line1), ">%+ddB <%+ddB", + (int)snr_remote, (int)snr_local); + } else { + snprintf(line1, sizeof(line1), "<%+ddB", (int)snr_local); + } + snprintf(line2, sizeof(line2), "RTT: %ums", (unsigned)ping_rtt_ms); + mc_display_text(cx - (int)strlen(line1) * fw / 2, y0, line1, false); + mc_display_text(cx - (int)strlen(line2) * fw / 2, y0 + fh + 3, line2, false); + } +} + +int ContactsScreen::render(JoystickDisplay &display) +{ + if (_mode == CMODE_SUBMENU || _mode == CMODE_MSGVIEW) { + if (!refreshActiveContact()) { + _mode = CMODE_LIST; + _task->showAlert("Contact removed", 800); + } + } + + /* Message view */ + if (_mode == CMODE_MSGVIEW && _active_contact_valid) { + int total = _task->getContactMsgCount(_active_contact.name); + + if (total <= 0) { + renderScrollingScreenHeader(display, _active_contact.name, 0, 0, _header_marquee_ms); + display.setColor(JoystickDisplay::YELLOW); + display.drawTextCentered(display.width() / 2, 24, "No messages"); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, 38, "Long ENTER to send"); + display.drawTextCentered(display.width() / 2, 49, "CANCEL back"); + return 1000; + } + + if (_chat_selected >= total) _chat_selected = total - 1; + if (_chat_selected < 0) _chat_selected = 0; + + if (_chat_details) { + const char *msg = nullptr; + uint32_t ts = 0; + uint8_t path_len = OUT_PATH_UNKNOWN; + if (!_task->getContactMsgAt(_active_contact.name, _chat_selected, msg, ts, &path_len)) { + _chat_details = false; + return 200; + } + char msg_safe[480]; + sanitizeForDisplay(msg, msg_safe, sizeof(msg_safe)); + char route_line[48]; + buildMessageRouteLineForName("contact", _active_contact.name, path_len, + route_line, sizeof(route_line)); + return renderMessageDetailView(display, _chat_selected, total, ts, + _active_contact.name, route_line, msg_safe, + _chat_detail_scroll); + } + + renderScrollingScreenHeader(display, _active_contact.name, _chat_selected, total, + _header_marquee_ms); + display.setTextSize(1); + const int ROW_H = 10, LINES_PER_MSG = 2; + int visible = getMessagePreviewVisibleCount(display, ROW_H, LINES_PER_MSG, kContentY); + int list_scroll = getCenteredMessagePreviewStart(_chat_selected, total, visible); + int y = kContentY; + for (int i = list_scroll; i < total && y < display.height() - 2; i++) { + const char *msg = nullptr; + uint32_t ts = 0; + uint8_t path_len = OUT_PATH_UNKNOWN; + if (!_task->getContactMsgAt(_active_contact.name, i, msg, ts, &path_len)) continue; + char time_buf[12], msg_safe[192]; + formatClockHM(ts, time_buf, sizeof(time_buf)); + sanitizeForDisplay(msg, msg_safe, sizeof(msg_safe)); + y = renderMessagePreviewEntry(display, y, i == _chat_selected, + time_buf, msg_safe, ROW_H, LINES_PER_MSG, 20, 10, 4); + } + return 400; + } + + /* Submenu */ + if (_mode == CMODE_SUBMENU && _active_contact_valid) { + const char *items[12]; + char text[12][48]; + int n = buildSubmenuItems(items, text, 12); + if (n <= 0) { _mode = CMODE_LIST; return 300; } + + if (_submenu_selected >= n) _submenu_selected = n - 1; + if (_submenu_selected < 0) _submenu_selected = 0; + + renderScrollingScreenHeader(display, _active_contact.name, _submenu_selected, n, + _header_marquee_ms); + int start = computeListStart(_submenu_selected, n); + int y = kContentY + 2; + for (int i = start; i < n && i < start + UI_RECENT_LIST_SIZE; i++) { + char safe[48]; + sanitizeForDisplay(items[i], safe, sizeof(safe)); + renderMenuListText(display, y, i == _submenu_selected, safe); + y += kMenuLineH; + } + if (_ping_modal_active) { + drawPingModal(display, _ping_sent_at, _ping_rtt_ms, + _ping_snr_local, _ping_snr_remote); + return (_ping_sent_at > 0) ? 150 : 400; + } + return 400; + } + + /* Edit path */ + if (_mode == CMODE_EDITPATH && _active_contact_valid) { + if (_editpath_confirm_exit) { + renderScreenHeader(display, "Edit path", _editpath_confirm_sel, 2); + display.setTextSize(1); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(0, kContentY + 2, "Save changes?"); + static const char * const kOpts[2] = { "save", "discard" }; + int y = kContentY + 14; + for (int i = 0; i < 2; i++) { + if (i == _editpath_confirm_sel) { + display.setColor(JoystickDisplay::YELLOW); + display.drawTextLeftAlign(0, y, "> "); + display.drawTextLeftAlign(10, y, kOpts[i]); + } else { + display.setColor(JoystickDisplay::GREEN); + display.drawTextLeftAlign(0, y, kOpts[i]); + } + y += kMenuLineH; + } + return 300; + } + renderScreenHeader(display, "Edit path", 0, 0); + display.setTextSize(1); + char disp_buf[MAX_PATH_SIZE * 2 + 3]; + snprintf(disp_buf, sizeof(disp_buf), "%s|", _editpath_hexbuf); + display.setColor(JoystickDisplay::YELLOW); + display.drawTextEllipsized(0, kContentY, display.width(), disp_buf); + renderT9Keypad(display, getT9KeyLabels(_editpath_kb_letters, true), + _editpath_t9_sel, kContentY + 12); + return 300; + } + + /* Contact list */ + int contactCount = getFilteredContactCount(); + int itemCount = 2 + contactCount; + + renderScreenHeader(display, "Contacts", _selected, itemCount); + + if (_selected >= itemCount) _selected = itemCount - 1; + if (_selected < 0) _selected = 0; + + int start = clampStart(itemCount); + int y = kContentY + 2; + for (int i = start; i < itemCount && i < start + UI_RECENT_LIST_SIZE; i++) { + char line[48]; + if (i == 0) { + snprintf(line, sizeof(line), "Settings"); + } else if (i == 1) { + snprintf(line, sizeof(line), "Filter: %s", kFilterLabels[_filter & 3]); + } else { + ContactInfo c; + if (!getFilteredContactByIndex(i - 2, c)) continue; + display.translateUTF8ToBlocks(line, c.name, sizeof(line)); + } + renderMenuListText(display, y, i == _selected, line, i >= 2, 450, _header_marquee_ms); + if (i == 1) { + display.setColor(JoystickDisplay::LIGHT); + display.drawRect(0, y + kLineH, display.width(), 1); + } + y += kMenuLineH; + } + + if (contactCount == 0) { + display.setColor(JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, display.height() - 10, "No match"); + } + return 500; +} + +bool ContactsScreen::handleInput(char key) +{ + /* Message view input */ + if (_mode == CMODE_MSGVIEW) { + if (key == KEY_ENTER_LONG && _active_contact_valid && + _active_contact.type == ADV_TYPE_CHAT) { + _task->setComposeContact(_active_contact); + _task->gotoT9InputScreen(); + return true; + } + if (_chat_details) { + const char *msg = nullptr; + uint32_t ts = 0; + int max_scroll = 0; + if (_active_contact_valid && + _task->getContactMsgAt(_active_contact.name, _chat_selected, msg, ts)) { + JoystickDisplay &disp = _task->getDisplay(); + max_scroll = getMessageDetailMaxScrollSanitized(disp, msg, 20, 5, 4); + } + if (handleDetailScrollNavigation(key, _chat_detail_scroll, max_scroll)) return true; + if (key == KEY_ENTER || key == KEY_CANCEL || key == KEY_HOME) { + _chat_details = false; + _chat_detail_scroll = 0; + return true; + } + return false; + } + + int total = _task->getContactMsgCount(_active_contact.name); + if (total > 0 && handleCommonListNavigation(key, _chat_selected, total)) return true; + if (key == KEY_ENTER && total > 0) { _chat_details = true; _chat_detail_scroll = 0; return true; } + if (key == KEY_CANCEL || key == KEY_HOME) { _mode = CMODE_SUBMENU; return true; } + return false; + } + + /* Submenu input */ + if (_mode == CMODE_SUBMENU) { + /* Ping modal intercepts all input when active */ + if (_ping_modal_active) { + if (_ping_sent_at > 0 || _ping_rtt_ms == 0) { + /* In flight (TX done or still queued): CANCEL aborts */ + if (key == KEY_CANCEL || key == KEY_HOME) { + _task->cancelContactPing(); + _ping_sent_at = 0; + _ping_rtt_ms = 0; + _ping_modal_active = false; + _task->forceRefresh(); + } + } else { + /* Result/timeout: any key dismisses */ + _ping_modal_active = false; + _task->forceRefresh(); + } + return true; + } + + const char *items[12]; + char text[12][48]; + int n = buildSubmenuItems(items, text, 12); + + if (handleCommonListNavigation(key, _submenu_selected, n)) return true; + + if (key == KEY_ENTER) { + if (_submenu_selected == _idx_send_message) { + _chat_selected = 0; + _chat_details = false; + _chat_detail_scroll = 0; + _header_marquee_ms = k_uptime_get_32(); + _mode = CMODE_MSGVIEW; + return true; + } + if (_submenu_selected == _idx_repeater_admin) { + _task->gotoRepeaterAdminScreen(_active_contact.id.pub_key, + _active_contact.name, true); + return true; + } + if (_submenu_selected == _idx_ping_zerohop) { + uint32_t est_timeout = 0; + _ping_rtt_ms = 0; + _ping_snr_remote = INT8_MIN; + if (_task->sendContactPingZeroHop(_active_contact, est_timeout)) { + _ping_sent_at = 0; /* set in onPacketSent() after RF TX */ + _ping_timeout_ms = 5000; + _ping_modal_active = true; + } else { + _task->showAlert("Ping failed", 700); + } + return true; + } + if (_submenu_selected == _idx_edit_path) { + ContactInfo *live = _task->getMesh()->lookupContactByPubKey( + _active_contact.id.pub_key, PUB_KEY_SIZE); + const ContactInfo *src = live ? live : &_active_contact; + memset(_editpath_hexbuf, 0, sizeof(_editpath_hexbuf)); + int hlen = 0; + int nbytes = src->out_path_len < MAX_PATH_SIZE ? src->out_path_len : MAX_PATH_SIZE; + for (int i = 0; i < nbytes && hlen < (int)sizeof(_editpath_hexbuf) - 2; i++) { + snprintf(_editpath_hexbuf + hlen, 3, "%02X", src->out_path[i]); + hlen += 2; + } + _editpath_cursor = hlen; + _editpath_t9_sel = 0; + _editpath_t9_last_key = -1; + _editpath_t9_letter_index = 0; + _editpath_t9_last_press = 0; + _editpath_kb_letters = true; + _editpath_confirm_exit = false; + _editpath_confirm_sel = 0; + _mode = CMODE_EDITPATH; + return true; + } + if (_submenu_selected == _idx_reset_path) { + ContactInfo *live = _task->getMesh()->lookupContactByPubKey( + _active_contact.id.pub_key, PUB_KEY_SIZE); + if (live) { live->out_path_len = 0; memset(live->out_path, 0, sizeof(live->out_path)); } + _task->showAlert("Path reset", 800); + return true; + } + if (_submenu_selected == _idx_favorite) { + ContactInfo c = _active_contact; + c.flags ^= 0x01; + ContactInfo old = _active_contact; + auto *mesh = _task->getMesh(); + if (mesh->removeContact(old)) { + mesh->addContact(c); + _active_contact = c; + } + return true; + } + if (_submenu_selected == _idx_delete) { + ContactInfo c = _active_contact; + _task->getMesh()->removeContact(c); + _active_contact_valid = false; + _mode = CMODE_LIST; + _task->showAlert("Deleted", 800); + return true; + } + } + if (key == KEY_CANCEL || key == KEY_HOME) { _mode = CMODE_LIST; return true; } + return false; + } + + /* Edit path input */ + if (_mode == CMODE_EDITPATH) { + if (_editpath_confirm_exit) { + if (key == KEY_UP || key == KEY_DOWN) { + _editpath_confirm_sel = (_editpath_confirm_sel + 1) % 2; + return true; + } + if (key == KEY_ENTER) { + if (_editpath_confirm_sel == 0) { + /* save */ + ContactInfo *live = _task->getMesh()->lookupContactByPubKey( + _active_contact.id.pub_key, PUB_KEY_SIZE); + if (live) { + int hlen = (int)strlen(_editpath_hexbuf); + int nbytes = hlen / 2; + if (nbytes > MAX_PATH_SIZE) nbytes = MAX_PATH_SIZE; + for (int i = 0; i < nbytes; i++) { + char b[3] = { _editpath_hexbuf[i*2], _editpath_hexbuf[i*2+1], '\0' }; + live->out_path[i] = (uint8_t)strtol(b, nullptr, 16); + } + live->out_path_len = (uint8_t)nbytes; + _active_contact = *live; + _task->showAlert(nbytes == 0 ? "Path cleared" : "Path saved", 800); + } + } + _editpath_confirm_exit = false; + _mode = CMODE_SUBMENU; + return true; + } + if (key == KEY_CANCEL || key == KEY_HOME) { + _editpath_confirm_exit = false; + return true; + } + return false; + } + if (handleT9DirectionalInput(key, _editpath_t9_sel)) return true; + if (key == KEY_ENTER) { + if (_editpath_t9_sel == 3) { /* del */ + if (_editpath_cursor > 0) _editpath_hexbuf[--_editpath_cursor] = '\0'; + resetT9State(_editpath_t9_last_key, _editpath_t9_letter_index, _editpath_t9_last_press); + return true; + } + if (_editpath_t9_sel == 11) { /* save */ + ContactInfo *live = _task->getMesh()->lookupContactByPubKey( + _active_contact.id.pub_key, PUB_KEY_SIZE); + if (live) { + int hlen = (int)strlen(_editpath_hexbuf); + int nbytes = hlen / 2; + if (nbytes > MAX_PATH_SIZE) nbytes = MAX_PATH_SIZE; + for (int i = 0; i < nbytes; i++) { + char b[3] = { _editpath_hexbuf[i*2], _editpath_hexbuf[i*2+1], '\0' }; + live->out_path[i] = (uint8_t)strtol(b, nullptr, 16); + } + live->out_path_len = (uint8_t)nbytes; + _active_contact = *live; + _task->showAlert(nbytes == 0 ? "Path cleared" : "Path saved", 800); + } + _mode = CMODE_SUBMENU; + return true; + } + if (_editpath_t9_sel == 12) { /* mode toggle */ + _editpath_kb_letters = !_editpath_kb_letters; + resetT9State(_editpath_t9_last_key, _editpath_t9_letter_index, _editpath_t9_last_press); + return true; + } + if (_editpath_t9_sel == 7 || _editpath_t9_sel == 15) return true; /* spc/unused */ + const char *letters = getT9KeyLetters(_editpath_kb_letters)[_editpath_t9_sel]; + if (letters && letters[0]) { + appendOrCycleT9Char(_editpath_hexbuf, _editpath_cursor, + sizeof(_editpath_hexbuf), letters, + _editpath_t9_sel, _editpath_t9_last_key, + _editpath_t9_letter_index, _editpath_t9_last_press); + } + return true; + } + if (key == KEY_CANCEL || key == KEY_HOME) { + if (_editpath_cursor > 0) { + _editpath_confirm_exit = true; + _editpath_confirm_sel = 0; + } else { + _mode = CMODE_SUBMENU; + } + return true; + } + return false; + } + + /* Contact list input */ + int contactCount = getFilteredContactCount(); + int itemCount = 2 + contactCount; + + if (handleCommonListNavigation(key, _selected, itemCount)) return true; + + if (key == KEY_ENTER) { + if (_selected == 0) { return true; } /* Settings placeholder — no action yet */ + if (_selected == 1) { + _filter = (_filter + 1) % 4; + _selected = 1; + return true; + } + ContactInfo c; + if (getFilteredContactByIndex(_selected - 2, c)) { + _active_contact = c; + _active_contact_valid = true; + _submenu_selected = 0; + _header_marquee_ms = k_uptime_get_32(); + _mode = CMODE_SUBMENU; + } + return true; + } + if (key == KEY_CANCEL || key == KEY_HOME) { + _task->gotoHomeScreen(); + return true; + } + return false; +} + +void ContactsScreen::onPingResponse(int8_t snr_local, int8_t snr_remote, uint32_t rtt_ms) +{ + _ping_snr_local = snr_local; + _ping_snr_remote = snr_remote; + _ping_rtt_ms = (rtt_ms == 0) ? 1 : rtt_ms; + _ping_sent_at = 0; + _ping_modal_active = true; + _task->forceRefresh(); +} + +void ContactsScreen::onPacketSent() +{ + /* RF TX just completed — start the ping timeout clock now. */ + if (_ping_modal_active && _ping_sent_at == 0) { + _ping_sent_at = k_uptime_get_32(); + _task->forceRefresh(); + } +} + +void ContactsScreen::poll() +{ + if (_ping_sent_at > 0 && _ping_timeout_ms > 0) { + uint32_t elapsed = k_uptime_get_32() - _ping_sent_at; + if (elapsed >= _ping_timeout_ms) { + _ping_sent_at = 0; + _ping_rtt_ms = UINT32_MAX; + _task->forceRefresh(); + } + } +} + +/* ===== RepeaterAdminScreen ===== */ + +#define ADMIN_TIMEOUT_MS 12000 + +static const int kAdminContentY = 23; +static const int kAdminVisible = 4; + +/* ===== Constructor ===== */ + +RepeaterAdminScreen::RepeaterAdminScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), + _state(STATE_PASSWORD_ENTRY), _from_contacts(false), + _permissions(0), _server_time(0), + _pwd_len(0), + _pwd_t9_sel(0), _pwd_t9_last_key(-1), _pwd_t9_letter_index(0), _pwd_t9_last_press(0), + _pwd_kb_letters(true), + _submenu_sel(0), + _pending(PENDING_NONE), + _last_sent_at(0), _cmd_timeout_ms(ADMIN_TIMEOUT_MS), _awaiting_tx(false), + _hist_head(0), _hist_count(0), _hist_scroll(0), _resp_line_scroll(0), _resp_max_line_scroll(0), + _cmd_len(0), + _cmd_t9_sel(0), _cmd_t9_last_key(-1), _cmd_t9_letter_index(0), _cmd_t9_last_press(0), + _cmd_kb_letters(true) +{ + memset(_contact_pubkey, 0, sizeof(_contact_pubkey)); + memset(_repeater_name, 0, sizeof(_repeater_name)); + memset(_password, 0, sizeof(_password)); + memset(_hist, 0, sizeof(_hist)); + memset(_cmd_buf, 0, sizeof(_cmd_buf)); +} + +/* ===== openForContact ===== */ + +void RepeaterAdminScreen::openForContact(const uint8_t *pub_key, const char *name, + bool from_contacts) +{ + if (pub_key) memcpy(_contact_pubkey, pub_key, PUB_KEY_SIZE); + else memset(_contact_pubkey, 0, PUB_KEY_SIZE); + _from_contacts = from_contacts; + strncpy(_repeater_name, name ? name : "?", sizeof(_repeater_name) - 1); + _repeater_name[sizeof(_repeater_name) - 1] = '\0'; + _admin_header_marquee_ms = k_uptime_get_32(); + + _state = STATE_PASSWORD_ENTRY; + _pwd_len = 0; + memset(_password, 0, sizeof(_password)); + _pwd_t9_sel = 0; _pwd_t9_last_key = -1; + _pwd_t9_letter_index = 0; _pwd_t9_last_press = 0; + _pwd_kb_letters = true; + + _submenu_sel = 0; + _pending = PENDING_NONE; + _cmd_timeout_ms = ADMIN_TIMEOUT_MS; + _awaiting_tx = false; + _hist_head = 0; _hist_count = 0; _hist_scroll = 0; _resp_line_scroll = 0; _resp_max_line_scroll = 0; + memset(_hist, 0, sizeof(_hist)); +} + +/* ===== sendBinaryReq — used for guest-accessible requests ===== */ + +static bool sendBinaryReqHelper(JoystickUITask *task, const uint8_t *contact_pubkey, + const uint8_t *req_data, uint8_t req_len, + uint32_t &cmd_timeout_ms, bool &awaiting_tx, + uint32_t &last_sent_at) +{ + BaseChatMesh *mesh = task->getMesh(); + if (!mesh) return false; + ContactInfo *contact = mesh->lookupContactByPubKey(contact_pubkey, PUB_KEY_SIZE); + if (!contact) { + task->showAlert("Contact lost", 1500); + return false; + } + uint32_t tag, est_timeout = 0; + int result = mesh->sendRequest(*contact, req_data, req_len, tag, est_timeout); + if (result == MSG_SEND_FAILED) { + task->showAlert("Send failed", 1500); + return false; + } + task->registerAdminReqTag(tag); + ui_signal_tx(); + last_sent_at = k_uptime_get_32(); + awaiting_tx = true; + cmd_timeout_ms = ADMIN_TIMEOUT_MS; + if (est_timeout > 0) { + uint32_t rt = est_timeout * 2 + 3000; + if (rt > cmd_timeout_ms) cmd_timeout_ms = rt; + } + return true; +} + +/* ===== onReqResponse — parse binary REQ responses for guest shortcuts ===== */ + +void RepeaterAdminScreen::onReqResponse(const uint8_t *pub_key_prefix, + const uint8_t *data, uint8_t data_len) +{ + if (memcmp(_contact_pubkey, pub_key_prefix, 4) != 0) return; + if (_pending != PENDING_BINARY_STATUS && _pending != PENDING_BINARY_NEIGHBOURS && + _pending != PENDING_BINARY_TELEMETRY && _pending != PENDING_BINARY_OWNER_INFO) return; + + char text[ADMIN_RESP_MAX]; + text[0] = '\0'; + + if (_pending == PENDING_BINARY_STATUS && data_len >= 4) { + /* RepeaterStats struct layout (byte offsets from start of payload): + * 0: batt_milli_volts (u16) 2: curr_tx_queue_len (u16) + * 4: noise_floor (i16) 6: last_rssi (i16) + * 8: n_packets_recv (u32) 12: n_packets_sent (u32) + * 16: total_air_time_secs (u32) 20: total_up_time_secs (u32) + * 24: n_sent_flood (u32) 28: n_sent_direct (u32) + * 32: n_recv_flood (u32) 36: n_recv_direct (u32) + * 40: err_events (u16) 42: last_snr (i16, raw×4) + * 44: n_direct_dups (u16) 46: n_flood_dups (u16) + * 48: total_rx_air_time_secs (u32) + * 52: n_recv_errors (u32) */ + uint16_t batt = 0, txq = 0, errf = 0, dup_d = 0, dup_f = 0; + int16_t noise = 0, rssi = 0, lsnr = 0; + uint32_t air_tx = 0, rx_air = 0, uptime = 0; + uint32_t ptx = 0, prx = 0; + uint32_t sent = 0, recvd = 0, sent_d = 0, recvd_d = 0, errors = 0; + if (data_len >= 2) memcpy(&batt, &data[0], 2); + if (data_len >= 4) memcpy(&txq, &data[2], 2); + if (data_len >= 6) memcpy(&noise, &data[4], 2); + if (data_len >= 8) memcpy(&rssi, &data[6], 2); + if (data_len >= 12) memcpy(&prx, &data[8], 4); + if (data_len >= 16) memcpy(&ptx, &data[12], 4); + if (data_len >= 20) memcpy(&air_tx, &data[16], 4); + if (data_len >= 24) memcpy(&uptime, &data[20], 4); + if (data_len >= 28) memcpy(&sent, &data[24], 4); + if (data_len >= 32) memcpy(&sent_d, &data[28], 4); + if (data_len >= 36) memcpy(&recvd, &data[32], 4); + if (data_len >= 40) memcpy(&recvd_d, &data[36], 4); + if (data_len >= 42) memcpy(&errf, &data[40], 2); + if (data_len >= 44) memcpy(&lsnr, &data[42], 2); + if (data_len >= 46) memcpy(&dup_d, &data[44], 2); + if (data_len >= 48) memcpy(&dup_f, &data[46], 2); + if (data_len >= 52) memcpy(&rx_air, &data[48], 4); + if (data_len >= 56) memcpy(&errors, &data[52], 4); + char up_buf[12], air_buf[12], rair_buf[12]; + formatAge(uptime, up_buf, sizeof(up_buf)); + formatAge(air_tx, air_buf, sizeof(air_buf)); + formatAge(rx_air, rair_buf, sizeof(rair_buf)); + snprintf(text, sizeof(text), + "up:%s\nbatt:%umV\ntxq:%u\nnf:%ddB\nrssi:%ddBm\nsnr:%ddB\n" + "ptx:%u\nprx:%u\nftx:%u\nfrx:%u\ndtx:%u\ndrx:%u\n" + "err:%u\nef:%04X\ndup f:%u d:%u\nair:%s\nrair:%s", + up_buf, + (unsigned)batt, (unsigned)txq, + (int)noise, (int)rssi, + (int)(lsnr / 4), + (unsigned)ptx, (unsigned)prx, + (unsigned)sent, (unsigned)recvd, + (unsigned)sent_d, (unsigned)recvd_d, + (unsigned)errors, (unsigned)errf, + (unsigned)dup_f, (unsigned)dup_d, + air_buf, rair_buf); + } else if (_pending == PENDING_BINARY_NEIGHBOURS && data_len >= 4) { + /* Neighbours response layout (after tag): + * 0..1: total_count (i16) + * 2..3: returned_count (i16) + * 4+: per entry = 4 bytes pubkey prefix + 4 bytes secs_ago + 1 byte snr */ + int16_t total = 0, returned = 0; + memcpy(&total, &data[0], 2); + memcpy(&returned, &data[2], 2); + if (returned == 0) { + snprintf(text, sizeof(text), "-none-"); + } else { + char *dp = text; + char *end = text + sizeof(text) - 1; + int offset = 4; + for (int i = 0; i < returned && offset + 9 <= data_len && dp < end - 20; i++) { + if (i > 0) *dp++ = '\n'; + char hex[12]; + snprintf(hex, sizeof(hex), "!%02X%02X%02X%02X", + data[offset], data[offset+1], data[offset+2], data[offset+3]); + uint32_t secs_ago = 0; + int8_t snr = 0; + memcpy(&secs_ago, &data[offset + 4], 4); + memcpy(&snr, &data[offset + 8], 1); + ContactInfo *nc = _task->getMesh()->lookupContactByPubKey(&data[offset], 4); + char name_buf[32]; + const char *label; + if (nc && nc->name[0]) { + _task->getDisplay().translateUTF8ToBlocks(name_buf, nc->name, sizeof(name_buf)); + label = name_buf; + } else { + label = hex; + } + char age_buf[8]; + formatAge(secs_ago, age_buf, sizeof(age_buf)); + dp += snprintf(dp, end - dp, "%s %s %+ddB", label, age_buf, (int)(snr / 4)); + offset += 9; + } + *dp = '\0'; + } + } else if (_pending == PENDING_BINARY_TELEMETRY && data_len >= 3) { + /* CayenneLPP tuples: [channel:1][type:1][data:N big endian] */ + char *dp = text; + char *end = text + sizeof(text) - 1; + int offset = 0; + bool parse_ok = true; + while (parse_ok && offset + 2 <= data_len && dp < end - 22) { + uint8_t type = data[offset + 1]; + offset += 2; + char *entry_start = dp; + if (dp > text) *dp++ = '\n'; + bool entry_ok = false; + switch (type) { + case 116: { /* VOLTAGE: 2B unsigned BE ÷100 = V */ + if (offset + 2 > data_len) break; + uint16_t raw = ((uint16_t)data[offset] << 8) | data[offset + 1]; + offset += 2; + dp += snprintf(dp, end - dp, "batt:%u.%02uV", raw / 100, raw % 100); + entry_ok = true; + break; + } + case 103: { /* TEMPERATURE: 2B signed BE ÷10 = °C */ + if (offset + 2 > data_len) break; + int16_t raw = ((int16_t)data[offset] << 8) | data[offset + 1]; + offset += 2; + int abst = (raw < 0) ? -(int)raw : (int)raw; + dp += snprintf(dp, end - dp, "temp:%s%d.%dC", (raw < 0 ? "-" : ""), abst / 10, abst % 10); + entry_ok = true; + break; + } + case 104: { /* HUMIDITY: 1B ÷2 = % */ + if (offset + 1 > data_len) break; + dp += snprintf(dp, end - dp, "hum:%u%%", (unsigned)(data[offset] / 2)); + offset += 1; + entry_ok = true; + break; + } + case 115: { /* PRESSURE: 2B unsigned BE ÷10 = hPa */ + if (offset + 2 > data_len) break; + uint16_t raw = ((uint16_t)data[offset] << 8) | data[offset + 1]; + offset += 2; + dp += snprintf(dp, end - dp, "pres:%u.%uhPa", raw / 10, raw % 10); + entry_ok = true; + break; + } + case 117: { /* CURRENT: 2B unsigned BE (value in mA) */ + if (offset + 2 > data_len) break; + uint16_t raw = ((uint16_t)data[offset] << 8) | data[offset + 1]; + offset += 2; + dp += snprintf(dp, end - dp, "cur:%umA", (unsigned)raw); + entry_ok = true; + break; + } + case 128: { /* POWER: 2B unsigned BE = W */ + if (offset + 2 > data_len) break; + uint16_t raw = ((uint16_t)data[offset] << 8) | data[offset + 1]; + offset += 2; + dp += snprintf(dp, end - dp, "pwr:%uW", (unsigned)raw); + entry_ok = true; + break; + } + default: + parse_ok = false; + break; + } + if (!entry_ok) { + dp = entry_start; /* rollback partial newline */ + parse_ok = false; + } + } + *dp = '\0'; + if (dp == text) snprintf(text, sizeof(text), "no telemetry"); + } else if (_pending == PENDING_BINARY_OWNER_INFO && data_len > 0) { + /* Plain text from repeater: firmware_version\nnode_name\nowner_info */ + char raw[ADMIN_RESP_MAX]; + int len = data_len < (int)(sizeof(raw) - 1) ? data_len : (int)(sizeof(raw) - 1); + memcpy(raw, data, len); + raw[len] = '\0'; + const char *fields[3] = { raw, "", "" }; + int fi = 0; + for (char *p = raw; *p && fi < 2; p++) { + if (*p == '\n') { *p = '\0'; fields[++fi] = p + 1; } + } + /* labels add ~19 chars, so format into a larger buffer then truncate */ + char fmt[ADMIN_RESP_MAX + 24]; + snprintf(fmt, sizeof(fmt), "FW: %s\nNode: %s\nOwner: %s", fields[0], fields[1], fields[2]); + strncpy(text, fmt, sizeof(text) - 1); + text[sizeof(text) - 1] = '\0'; + } + + if (text[0] && _hist_count > 0) { + CmdEntry &newest = histAt(_hist_count - 1); + if (!newest.has_resp) { + int len = (int)strlen(text); + if (len >= ADMIN_RESP_MAX) len = ADMIN_RESP_MAX - 1; + memcpy(newest.resp, text, len); + newest.resp[len] = '\0'; + newest.has_resp = true; + } + } + _pending = PENDING_NONE; + _task->clearAdminReqTag(); +} + +/* ===== sendCLI ===== */ + +bool RepeaterAdminScreen::sendCLI(const char *cmd) +{ + BaseChatMesh *mesh = _task->getMesh(); + if (!mesh) return false; + ContactInfo *contact = mesh->lookupContactByPubKey(_contact_pubkey, PUB_KEY_SIZE); + if (!contact) { + _task->showAlert("Contact lost", 1500); + return false; + } + uint32_t ts = _rtc ? _rtc->getCurrentTimeUnique() : k_uptime_get_32(); + uint32_t est_timeout = 0; + int result = mesh->sendCommandData(*contact, ts, 0, cmd, est_timeout); + if (result == MSG_SEND_FAILED) { + _task->showAlert("Send failed", 1500); + return false; + } + ui_signal_tx(); + _last_sent_at = k_uptime_get_32(); + _awaiting_tx = true; + /* est_timeout is one way, CLI response takes a second packet */ + _cmd_timeout_ms = ADMIN_TIMEOUT_MS; + if (est_timeout > 0) { + uint32_t rt = est_timeout * 2 + 3000; + if (rt > _cmd_timeout_ms) _cmd_timeout_ms = rt; + } + return true; +} + +/* ===== Callbacks ===== */ + +void RepeaterAdminScreen::onLoginResult(bool success, uint8_t permissions, uint32_t server_time) +{ + if (_state != STATE_LOGGING_IN) return; + if (success) { + _permissions = permissions; + _server_time = server_time; + _state = STATE_SUBMENU; + _submenu_sel = 0; + _pending = PENDING_NONE; + _hist_scroll = 0; _resp_line_scroll = 0; + _task->showAlert("Login OK", 600); + } else { + _state = STATE_PASSWORD_ENTRY; + _task->showAlert("Login failed", 1500); + } +} + +void RepeaterAdminScreen::onCliResponse(const char *text) +{ + if (!text || _pending != PENDING_CMD) return; + if (_state != STATE_MAIN && _state != STATE_CMD_INPUT && _state != STATE_SUBMENU) return; + if (_hist_count > 0) { + CmdEntry &newest = histAt(_hist_count - 1); + if (!newest.has_resp) { + int len = (int)strlen(text); + if (len >= ADMIN_RESP_MAX) len = ADMIN_RESP_MAX - 1; + memcpy(newest.resp, text, len); + newest.resp[len] = '\0'; + newest.has_resp = true; + } + } + _pending = PENDING_NONE; +} + +void RepeaterAdminScreen::onPacketSent() +{ + /* RF TX completed — reset timeout clock so LBT/queue delay is excluded */ + if (_awaiting_tx) { + _last_sent_at = k_uptime_get_32(); + _awaiting_tx = false; + } +} + +/* ===== poll ===== */ + +void RepeaterAdminScreen::poll() +{ + if (_state == STATE_LOGGING_IN) { + if (_last_sent_at > 0 && + k_uptime_get_32() - _last_sent_at > _cmd_timeout_ms) { + _awaiting_tx = false; + _state = STATE_PASSWORD_ENTRY; + _task->showAlert("Login timeout", 1500); + } + } else if ((_state == STATE_SUBMENU || _state == STATE_MAIN || _state == STATE_CMD_INPUT) && _pending != PENDING_NONE) { + if (_last_sent_at > 0 && + k_uptime_get_32() - _last_sent_at > _cmd_timeout_ms) { + _awaiting_tx = false; + if (_hist_count > 0) { + CmdEntry &newest = histAt(_hist_count - 1); + if (!newest.has_resp) { + snprintf(newest.resp, sizeof(newest.resp), "(no response)"); + newest.has_resp = true; + } + } + _pending = PENDING_NONE; + _task->clearAdminReqTag(); + } + } +} + +/* ===== goBack ===== */ + +void RepeaterAdminScreen::goBack() +{ + if (_from_contacts) _task->gotoContactsScreen(); + else _task->gotoRepeatersScreen(); +} + +/* ===== Render helpers ===== */ + +static void renderAdminHeader(JoystickDisplay &display, const char *name, bool is_admin, + uint32_t marquee_ms) +{ + int name_w = display.width(); + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + if (name_w > 0 && display.getTextWidth(name) > name_w) { + int text_len = (int)strlen(name); + if (text_len > 0) { + static const uint32_t kPauseMs = 1500; + uint32_t elapsed = k_uptime_get_32() - marquee_ms; + uint32_t scroll_elapsed = (elapsed > kPauseMs) ? (elapsed - kPauseMs) : 0; + int offset = (int)((scroll_elapsed / 450U) % (uint32_t)text_len); + display.drawTextEllipsized(0, 0, name_w, name + offset); + } + } else { + display.drawTextEllipsized(0, 0, name_w, name); + } + display.drawRect(0, kHeaderSepY, display.width(), 1); +} + +/* ===== render ===== */ + +int RepeaterAdminScreen::render(JoystickDisplay &display) +{ + switch (_state) { + + case STATE_PASSWORD_ENTRY: { + /* Header: "Password: " on one line */ + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + const char *prefix = "Password: "; + int prefix_w = display.getTextWidth(prefix); + display.drawTextLeftAlign(0, 0, prefix); + int name_avail = display.width() - prefix_w; + if (name_avail > 0) { + int name_len = (int)strlen(_repeater_name); + if (name_len > 0 && display.getTextWidth(_repeater_name) > name_avail) { + static const uint32_t kPauseMs = 1500; + uint32_t elapsed = k_uptime_get_32() - _admin_header_marquee_ms; + uint32_t scroll_elapsed = (elapsed > kPauseMs) ? (elapsed - kPauseMs) : 0; + int offset = (int)((scroll_elapsed / 450U) % (uint32_t)name_len); + display.drawTextEllipsized(prefix_w, 0, name_avail, _repeater_name + offset); + } else { + display.drawTextEllipsized(prefix_w, 0, name_avail, _repeater_name); + } + } + display.drawRect(0, kHeaderSepY, display.width(), 1); + /* Password input */ + char pwd_display[ADMIN_PASSWORD_MAX + 2]; + int n = (_pwd_len < ADMIN_PASSWORD_MAX) ? _pwd_len : ADMIN_PASSWORD_MAX; + memcpy(pwd_display, _password, n); + pwd_display[n] = '|'; pwd_display[n + 1] = '\0'; + display.setColor(JoystickDisplay::YELLOW); + display.drawTextLeftAlign(0, kContentY, pwd_display); + renderT9Keypad(display, getT9KeyLabels(_pwd_kb_letters, true), _pwd_t9_sel, kContentY + kMenuLineH); + return 300; + } + + case STATE_LOGGING_IN: { + renderScreenHeader(display, _repeater_name, 0, 0); + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + display.drawTextCentered(display.width() / 2, 28, "Logging in..."); + uint32_t d = (k_uptime_get_32() / 333) % 3; + const char *dots[] = { ".", "..", "..." }; + display.drawTextCentered(display.width() / 2, 38, dots[d]); + return 333; + } + + case STATE_SUBMENU: { + renderAdminHeader(display, _repeater_name, _permissions != 0, _admin_header_marquee_ms); + display.setTextSize(1); + display.setColor(_permissions != 0 ? JoystickDisplay::YELLOW : JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, kHeaderSepY + 2, + _permissions != 0 ? "Admin" : "Guest"); + static const char * const kItems[] = { "Neighbours", "Stats", "Telemetry", "Info", "Commands" }; + int count = (_permissions != 0) ? 5 : 4; + int max_vis = (display.height() - kAdminContentY) / kMenuLineH; + if (max_vis < 1) max_vis = 1; + int start = computeListStart(_submenu_sel, count, max_vis); + int y = kAdminContentY; + for (int i = start; i < count && i < start + max_vis; i++) { + renderMenuListText(display, y, i == _submenu_sel, kItems[i]); + y += kMenuLineH; + } + return 500; + } + + case STATE_MAIN: { + renderAdminHeader(display, _repeater_name, _permissions != 0, _admin_header_marquee_ms); + display.setTextSize(1); + display.setColor(_permissions != 0 ? JoystickDisplay::YELLOW : JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, kHeaderSepY + 2, + _permissions != 0 ? "Admin" : "Guest"); + if (_hist_count == 0) { + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(0, kAdminContentY, "(no history)"); + display.drawTextLeftAlign(0, kAdminContentY + kLineH, "Long ENTER: send cmd"); + } else { + int logical = _hist_count - 1 - _hist_scroll; + if (logical < 0) logical = 0; + const CmdEntry &e = histAt(logical); + int y = kAdminContentY; + + /* Command line */ + char cmd_line[ADMIN_CMD_MAX + 12]; + if (_hist_count > 1) + snprintf(cmd_line, sizeof(cmd_line), "[%d/%d] > %s", + logical + 1, _hist_count, e.cmd); + else + snprintf(cmd_line, sizeof(cmd_line), "> %s", e.cmd); + display.setColor(JoystickDisplay::YELLOW); + display.drawTextEllipsized(0, y, display.width(), cmd_line); + y += kLineH; + + /* Response — show all lines until display bottom */ + if (!e.has_resp && _pending != PENDING_NONE && logical == _hist_count - 1) { + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(2, y, "(waiting...)"); + } else if (e.has_resp && e.resp[0]) { + /* Count lines and compute vertical scroll limits */ + int resp_lines = 1; + for (const char *p = e.resp; *p; p++) if (*p == '\n') resp_lines++; + int vis_lines = (display.height() - y) / kLineH; + _resp_max_line_scroll = (resp_lines > vis_lines) ? resp_lines - vis_lines : 0; + if (_resp_line_scroll > _resp_max_line_scroll) _resp_line_scroll = _resp_max_line_scroll; + + /* Skip lines above the scroll offset */ + const char *rp = e.resp; + for (int skip = 0; skip < _resp_line_scroll && rp; skip++) { + const char *nl = strchr(rp, '\n'); + rp = nl ? nl + 1 : nullptr; + } + + bool is_scrollable = (strcmp(e.cmd, "neighbors") == 0 || strcmp(e.cmd, "info") == 0); + uint32_t scroll_elapsed = 0; + if (is_scrollable) { + static const uint32_t kNbPauseMs = 1500; + uint32_t elapsed = k_uptime_get_32() - _admin_header_marquee_ms; + scroll_elapsed = (elapsed > kNbPauseMs) ? (elapsed - kNbPauseMs) : 0; + } + while (rp && *rp && y + kLineH <= display.height()) { + const char *nl = strchr(rp, '\n'); + int rlen = nl ? (int)(nl - rp) : (int)strlen(rp); + display.setColor(JoystickDisplay::GREEN); + if (is_scrollable && rlen > 0) { + char line_buf[ADMIN_RESP_MAX]; + int copy = rlen < (int)(sizeof(line_buf) - 1) ? rlen : (int)(sizeof(line_buf) - 1); + memcpy(line_buf, rp, copy); + line_buf[copy] = '\0'; + int line_w = display.width(); + if (display.getTextWidth(line_buf) > line_w) { + int offset = (int)((scroll_elapsed / 450U) % (uint32_t)copy); + display.drawTextEllipsized(0, y, line_w, line_buf + offset); + } else { + char prefixed[ADMIN_RESP_MAX + 2]; + prefixed[0] = ' '; prefixed[1] = ' '; + memcpy(prefixed + 2, line_buf, copy); + prefixed[copy + 2] = '\0'; + display.drawTextEllipsized(0, y, line_w, prefixed); + } + } else { + char resp_line[42]; + int copy = rlen < 39 ? rlen : 39; + resp_line[0] = ' '; resp_line[1] = ' '; + memcpy(resp_line + 2, rp, copy); + resp_line[copy + 2] = '\0'; + display.drawTextEllipsized(0, y, display.width(), resp_line); + } + y += kLineH; + rp = nl ? nl + 1 : nullptr; + } + } else { + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(2, y, "-"); + } + } + return (_pending != PENDING_NONE) ? 333 : 500; + } + + case STATE_CMD_INPUT: { + renderScreenHeader(display, "Command", 0, 0); + display.setTextSize(1); + char buf[ADMIN_CMD_MAX + 2]; + int pn = (_cmd_len < ADMIN_CMD_MAX) ? _cmd_len : ADMIN_CMD_MAX; + memcpy(buf, _cmd_buf, pn); + buf[pn] = '|'; buf[pn + 1] = '\0'; + display.setColor(JoystickDisplay::YELLOW); + display.drawTextEllipsized(0, kContentY, display.width(), buf); + renderT9Keypad(display, getT9KeyLabels(_cmd_kb_letters, false), + _cmd_t9_sel, kContentY + 12); + return 300; + } + + } /* switch */ + return 500; +} + +/* ===== handleInput ===== */ + +bool RepeaterAdminScreen::handleInput(char c) +{ + switch (_state) { + + case STATE_PASSWORD_ENTRY: { + if (handleT9DirectionalInput(c, _pwd_t9_sel)) return true; + if (c == KEY_ENTER) { + if (_pwd_t9_sel == 3) { + if (_pwd_len > 0) _password[--_pwd_len] = '\0'; + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + return true; + } + if (_pwd_t9_sel == 7) { + if (_pwd_len < ADMIN_PASSWORD_MAX - 1) { + _password[_pwd_len++] = ' '; + _password[_pwd_len] = '\0'; + } + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + return true; + } + if (_pwd_t9_sel == 11) { + BaseChatMesh *mesh = _task->getMesh(); + if (!mesh) { _task->showAlert("No mesh", 1500); return true; } + ContactInfo *contact = mesh->lookupContactByPubKey(_contact_pubkey, PUB_KEY_SIZE); + if (!contact) { _task->showAlert("Contact not found", 1500); return true; } + uint32_t est_timeout = 0; + int result = mesh->sendLogin(*contact, _password, est_timeout); + if (result != MSG_SEND_FAILED) { + ui_signal_tx(); + _state = STATE_LOGGING_IN; + _last_sent_at = k_uptime_get_32(); + _awaiting_tx = true; + _cmd_timeout_ms = ADMIN_TIMEOUT_MS; + if (est_timeout > 0) { + uint32_t rt = est_timeout * 2 + 3000; + if (rt > _cmd_timeout_ms) _cmd_timeout_ms = rt; + } + } else { + _task->showAlert("Send failed", 1500); + } + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + return true; + } + if (_pwd_t9_sel == 12) { + _pwd_kb_letters = !_pwd_kb_letters; + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + return true; + } + if (_pwd_t9_sel == 15) { + resetT9State(_pwd_t9_last_key, _pwd_t9_letter_index, _pwd_t9_last_press); + goBack(); + return true; + } + const char *letters = getT9KeyLetters(_pwd_kb_letters)[_pwd_t9_sel]; + if (letters && letters[0]) { + appendOrCycleT9Char(_password, _pwd_len, ADMIN_PASSWORD_MAX, letters, + _pwd_t9_sel, _pwd_t9_last_key, _pwd_t9_letter_index, + _pwd_t9_last_press); + } + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { goBack(); return true; } + return false; + } + + case STATE_LOGGING_IN: + if (c == KEY_CANCEL || c == KEY_HOME) { + _awaiting_tx = false; + _state = STATE_PASSWORD_ENTRY; + return true; + } + return false; + + case STATE_SUBMENU: { + bool is_admin = (_permissions != 0); + int count = is_admin ? 5 : 4; + if (handleCommonListNavigation(c, _submenu_sel, count)) return true; + if (c == KEY_ENTER) { + if (_submenu_sel == 4 && is_admin) { + /* Commands: open free form CLI input directly */ + _cmd_len = 0; + memset(_cmd_buf, 0, sizeof(_cmd_buf)); + _cmd_t9_sel = 0; _cmd_t9_last_key = -1; + _cmd_t9_letter_index = 0; _cmd_t9_last_press = 0; + _cmd_kb_letters = true; + _hist_scroll = 0; _resp_line_scroll = 0; + _state = STATE_CMD_INPUT; + return true; + } + + /* Items 0 through 3: Neighbours, Stats, Telemetry, Info */ + struct { + const char *label; + uint8_t req_type; /* 0 = CLI only */ + PendingKind pending; + const char *cli_cmd; /* non null means admin uses CLI */ + } dispatch[] = { + { "neighbors", 0x06, PENDING_BINARY_NEIGHBOURS, nullptr }, + { "stats", 0x01, PENDING_BINARY_STATUS, nullptr }, + { "telemetry", 0x03, PENDING_BINARY_TELEMETRY, nullptr }, + { "info", 0x07, PENDING_BINARY_OWNER_INFO, nullptr }, + }; + if (_submenu_sel < 0 || _submenu_sel > 3) { + _state = STATE_MAIN; _hist_scroll = 0; return true; + } + const auto &d = dispatch[_submenu_sel]; + + int idx; + if (_hist_count < ADMIN_HIST_MAX) { + idx = (_hist_head + _hist_count) % ADMIN_HIST_MAX; + _hist_count++; + } else { + idx = _hist_head; + _hist_head = (_hist_head + 1) % ADMIN_HIST_MAX; + } + strncpy(_hist[idx].cmd, d.label, ADMIN_CMD_MAX - 1); + _hist[idx].cmd[ADMIN_CMD_MAX - 1] = '\0'; + _hist[idx].resp[0] = '\0'; + _hist[idx].has_resp = false; + + bool sent = false; + if (is_admin && d.cli_cmd) { + sent = sendCLI(d.cli_cmd); + if (sent) _pending = PENDING_CMD; + } else { + /* binary request — used by guests always, and by admin for telemetry/info */ + uint8_t req[7]; int req_len; + if (d.req_type == 0x06) { + req[0] = 0x06; req[1] = 0; req[2] = 10; + req[3] = 0; req[4] = 0; req[5] = 0; req[6] = 4; + req_len = 7; + } else { + req[0] = d.req_type; req_len = 1; + } + sent = sendBinaryReqHelper(_task, _contact_pubkey, req, req_len, + _cmd_timeout_ms, _awaiting_tx, _last_sent_at); + if (sent) _pending = d.pending; + } + + if (!sent) { + snprintf(_hist[idx].resp, ADMIN_RESP_MAX, "(send failed)"); + _hist[idx].has_resp = true; + } + _state = STATE_MAIN; + _hist_scroll = 0; _resp_line_scroll = 0; + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { goBack(); return true; } + return false; + } + + case STATE_MAIN: { + int max_scroll = (_hist_count > 1) ? _hist_count - 1 : 0; + if (c == KEY_UP) { + if (_resp_line_scroll > 0) { _resp_line_scroll--; } + else if (_hist_scroll < max_scroll) { _hist_scroll++; _resp_line_scroll = 0; } + return true; + } + if (c == KEY_DOWN) { + if (_hist_scroll > 0) { _hist_scroll--; _resp_line_scroll = 0; } + else if (_resp_line_scroll < _resp_max_line_scroll) { _resp_line_scroll++; } + return true; + } + if (c == KEY_TO_TOP) { _hist_scroll = max_scroll; _resp_line_scroll = 0; return true; } + if (c == KEY_TO_BOTTOM) { _hist_scroll = 0; _resp_line_scroll = 0; return true; } + if (c == KEY_ENTER_LONG) { + _cmd_len = 0; + memset(_cmd_buf, 0, sizeof(_cmd_buf)); + _cmd_t9_sel = 0; _cmd_t9_last_key = -1; + _cmd_t9_letter_index = 0; _cmd_t9_last_press = 0; + _cmd_kb_letters = true; + _state = STATE_CMD_INPUT; + return true; + } + if (c == KEY_CANCEL) { _state = STATE_SUBMENU; return true; } + if (c == KEY_HOME) { goBack(); return true; } + return false; + } + + case STATE_CMD_INPUT: { + if (handleT9DirectionalInput(c, _cmd_t9_sel)) return true; + if (c == KEY_ENTER) { + if (_cmd_t9_sel == 3) { + if (_cmd_len > 0) _cmd_buf[--_cmd_len] = '\0'; + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + if (_cmd_t9_sel == 7) { + if (_cmd_len < ADMIN_CMD_MAX - 1) { + _cmd_buf[_cmd_len++] = ' '; + _cmd_buf[_cmd_len] = '\0'; + } + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + if (_cmd_t9_sel == 11) { + if (_cmd_len > 0) { + /* Add entry to ring buffer */ + int idx; + if (_hist_count < ADMIN_HIST_MAX) { + idx = (_hist_head + _hist_count) % ADMIN_HIST_MAX; + _hist_count++; + } else { + idx = _hist_head; + _hist_head = (_hist_head + 1) % ADMIN_HIST_MAX; + } + strncpy(_hist[idx].cmd, _cmd_buf, ADMIN_CMD_MAX - 1); + _hist[idx].cmd[ADMIN_CMD_MAX - 1] = '\0'; + _hist[idx].resp[0] = '\0'; + _hist[idx].has_resp = false; + if (sendCLI(_cmd_buf)) { + _pending = PENDING_CMD; + } else { + snprintf(_hist[idx].resp, ADMIN_RESP_MAX, "(send failed)"); + _hist[idx].has_resp = true; + } + } + _hist_scroll = 0; _resp_line_scroll = 0; + _state = STATE_MAIN; + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + if (_cmd_t9_sel == 12) { + _cmd_kb_letters = !_cmd_kb_letters; + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + if (_cmd_t9_sel == 15) { + _state = STATE_MAIN; + resetT9State(_cmd_t9_last_key, _cmd_t9_letter_index, _cmd_t9_last_press); + return true; + } + const char *letters = getT9KeyLetters(_cmd_kb_letters)[_cmd_t9_sel]; + if (letters && letters[0]) { + appendOrCycleT9Char(_cmd_buf, _cmd_len, ADMIN_CMD_MAX, letters, + _cmd_t9_sel, _cmd_t9_last_key, _cmd_t9_letter_index, + _cmd_t9_last_press); + } + return true; + } + if (c == KEY_CANCEL) { _state = STATE_MAIN; return true; } + if (c == KEY_HOME) { goBack(); return true; } + return false; + } + + } /* switch */ + return false; +} diff --git a/zephcore/helpers/ui-joystick/screens/home.cpp b/zephcore/helpers/ui-joystick/screens/home.cpp new file mode 100644 index 0000000..a4a35df --- /dev/null +++ b/zephcore/helpers/ui-joystick/screens/home.cpp @@ -0,0 +1,243 @@ +/* + * ZephCore - Joystick UI Home Screen + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "../joystick_screens.h" +#include "../joystick_ui_task.h" +#include "screen_helpers.h" +#include +#include +#include + +#define HOME_ITEM_COUNT 7 + +HomeScreen::HomeScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), _selected(0) +{ +} + +void HomeScreen::poll() +{ + /* Nothing periodic needed */ +} + +int HomeScreen::render(JoystickDisplay &display) +{ + static char unread_item[24]; + const char *items[HOME_ITEM_COUNT]; + int itemCount = 0; + + snprintf(unread_item, sizeof(unread_item), "Unread (%d)", _task->getUnreadCount()); + items[itemCount++] = unread_item; + items[itemCount++] = "Channels"; + items[itemCount++] = "Contacts"; + items[itemCount++] = "Advert"; + items[itemCount++] = _task->isGPSAvailable() ? "GPS" : "System"; + items[itemCount++] = _task->isGPSAvailable() ? "System" : "Tools"; + items[itemCount++] = _task->isGPSAvailable() ? "Tools" : nullptr; + if (!_task->isGPSAvailable()) itemCount--; + + if (_selected >= itemCount) _selected = itemCount - 1; + + display.setTextSize(1); + + /* Top bar: node name (left) + battery + icons (right) */ + char batt[16]; + uint16_t batt_mv = _task->getCachedBattMilliVolts(); + if (_task->getBatteryDisplayMode() == 1) { + snprintf(batt, sizeof(batt), "%.1fV", (double)batt_mv / 1000.0); + } else { + int pct = ((int)batt_mv - kBattMinMv) * 100 / (kBattMaxMv - kBattMinMv); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + snprintf(batt, sizeof(batt), "%d%%", pct); + } + + char icons[6] = {0}; + int ni = 0; + if (_task->isSerialEnabled()) icons[ni++] = 'B'; + if (_task->getGPSState()) icons[ni++] = 'G'; + if (!_task->isBuzzerQuiet()) icons[ni++] = '~'; + icons[ni] = '\0'; + + int batt_w = display.getTextWidth(batt); + int icons_w = (ni > 0) ? (display.getTextWidth(icons) + 2) : 0; + int right_w = batt_w + icons_w; + + display.setColor(JoystickDisplay::GREEN); + display.drawTextRightAlign(display.width(), 0, batt); + if (ni > 0) { + display.setColor(JoystickDisplay::YELLOW); + display.drawTextRightAlign(display.width() - batt_w - 2, 0, icons); + } + char nodeName[32]; + display.translateUTF8ToBlocks(nodeName, _task->getPrefs() ? _task->getPrefs()->node_name : "Node", sizeof(nodeName)); + display.setColor(JoystickDisplay::GREEN); + display.drawTextEllipsized(0, 0, display.width() - right_w - 4, nodeName); + display.drawRect(0, kHeaderSepY, display.width(), 1); + + /* Grid layout */ + const int top_y = kContentY; + const int rows = 1 + ((itemCount - 1 + 1) / 2); /* 1 full width + 2 col rows */ + int row_h = (display.height() - top_y) / rows; + if (row_h < 10) row_h = 10; + const int col_w = display.width() / 2; + const int text_off = (row_h - display.fontH()) / 2; + + /* Grid separators */ + display.setColor(JoystickDisplay::LIGHT); + for (int r = 1; r <= rows; r++) { + int y = top_y + r * row_h; + if (y <= display.height() - 1) display.drawRect(0, y, display.width(), 1); + } + for (int r = 1; r < rows; r++) { + int y = top_y + r * row_h; + display.drawRect(col_w, y, 1, row_h); + } + + /* Row 0: full width Unread (N) */ + { + int y0 = top_y; + char unread_count[12]; + snprintf(unread_count, sizeof(unread_count), "%d", _task->getUnreadCount()); + if (_selected == 0) { + display.setColor(JoystickDisplay::YELLOW); + display.drawTextLeftAlign(2, y0 + text_off, "> Unread"); + display.drawTextRightAlign(display.width() - 3, y0 + text_off, unread_count); + } else { + display.setColor(JoystickDisplay::GREEN); + display.drawTextLeftAlign(2, y0 + text_off, "Unread"); + display.drawTextRightAlign(display.width() - 3, y0 + text_off, unread_count); + } + } + + /* Remaining items: 2 column cells */ + for (int i = 1; i < itemCount; i++) { + if (!items[i]) continue; + int rr = 1 + ((i - 1) / 2); + int cc = (i - 1) % 2; + int x = cc * col_w; + int y = top_y + rr * row_h; + if (i == _selected) { + display.setColor(JoystickDisplay::YELLOW); + char sel_buf[48]; + snprintf(sel_buf, sizeof(sel_buf), "> %s", items[i]); + display.drawTextEllipsized(x + 2, y + text_off, col_w - 4, sel_buf); + } else { + display.setColor(JoystickDisplay::GREEN); + display.drawTextEllipsized(x + 2, y + text_off, col_w - 4, items[i]); + } + } + + return 500; +} + +bool HomeScreen::handleInput(char c) +{ + int itemCount = _task->isGPSAvailable() ? 7 : 6; + + if (_selected >= itemCount) _selected = itemCount - 1; + + if (c == KEY_TO_TOP) { _selected = 0; return true; } + if (c == KEY_TO_BOTTOM) { _selected = itemCount - 1; return true; } + + if (c == KEY_UP) { + if (_selected == 0) { + _selected = itemCount - 1; + } else { + int rr = 1 + ((_selected - 1) / 2); + int cc = (_selected - 1) % 2; + rr--; + if (rr == 0) { + _selected = 0; + } else { + int next = 1 + (rr - 1) * 2 + cc; + if (next >= itemCount) next = itemCount - 1; + _selected = next; + } + } + return true; + } + if (c == KEY_DOWN) { + if (_selected == 0) { + if (itemCount > 1) _selected = 1; + } else { + int rr = 1 + ((_selected - 1) / 2); + int cc = (_selected - 1) % 2; + rr++; + int next = 1 + (rr - 1) * 2 + cc; + if (next >= itemCount) { _selected = 0; } else { _selected = next; } + } + return true; + } + if (c == KEY_LEFT) { + if (_selected > 1 && ((_selected - 1) % 2) == 1) _selected--; + return true; + } + if (c == KEY_RIGHT) { + if (_selected > 0 && ((_selected - 1) % 2) == 0) { + int next = _selected + 1; + if (next < itemCount) _selected = next; + } + return true; + } + if (c == KEY_ENTER || c == KEY_NEXT) { + /* Map selected index to screen */ + /* Indices: 0=Unread, 1=Channels, 2=Contacts, 3=Advert, 4=GPS(if avail)|System, 5=System|Tools, 6=Tools */ + switch (_selected) { + case 0: _task->gotoUnreadScreen(); return true; + case 1: _task->gotoChannelsScreen(); return true; + case 2: _task->gotoContactsScreen(); return true; + case 3: _task->gotoAdvertScreen(); return true; + case 4: + if (_task->isGPSAvailable()) _task->gotoGPSScreen(); + else _task->gotoSystemScreen(); + return true; + case 5: + if (_task->isGPSAvailable()) _task->gotoSystemScreen(); + else _task->gotoToolsScreen(); + return true; + case 6: _task->gotoToolsScreen(); return true; + default: break; + } + } + return false; +} + +#ifndef BOOT_SCREEN_MILLIS +#define BOOT_SCREEN_MILLIS 3000 +#endif + +#ifndef FIRMWARE_VERSION +#define FIRMWARE_VERSION "ZephCore" +#endif + +#ifndef FIRMWARE_BUILD_DATE +#define FIRMWARE_BUILD_DATE "" +#endif + +SplashScreen::SplashScreen(JoystickUITask *task) + : _task(task) +{ + _dismiss_after = k_uptime_get_32() + BOOT_SCREEN_MILLIS; +} + +int SplashScreen::render(JoystickDisplay &display) +{ + int cx = display.width() / 2; + display.setColor(JoystickDisplay::GREEN); + display.drawTextCentered(cx, display.height() / 4, "MeshCore"); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextCentered(cx, display.height() / 2 + 4, FIRMWARE_VERSION); + display.drawTextCentered(cx, display.height() / 2 + 4 + display.fontH() + 2, FIRMWARE_BUILD_DATE); + return 250; +} + +void SplashScreen::poll() +{ + if (k_uptime_get_32() >= _dismiss_after) { + _task->gotoHomeScreen(); + } +} diff --git a/zephcore/helpers/ui-joystick/screens/input.cpp b/zephcore/helpers/ui-joystick/screens/input.cpp new file mode 100644 index 0000000..2557320 --- /dev/null +++ b/zephcore/helpers/ui-joystick/screens/input.cpp @@ -0,0 +1,151 @@ +/* + * ZephCore - Joystick UI Input Screen + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "../joystick_screens.h" +#include "../joystick_ui_task.h" +#include "screen_helpers.h" +#include +#include +#include + +T9InputScreen::T9InputScreen(JoystickUITask *task) + : _task(task), _cursor(0), _selected_key(0), + _last_press_time(0), _letter_index(0), _last_key(-1), + _confirm_exit(false), _confirm_selected(0), _kb_mode_letters(true) +{ + memset(_input, 0, sizeof(_input)); +} + +int T9InputScreen::render(JoystickDisplay &display) +{ + if (_confirm_exit) { + renderScreenHeader(display, "Draft", _confirm_selected, 2); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(0, kContentY + 2, "Keep current draft?"); + + static const char * const kOptions[2] = {"keep draft", "flush draft"}; + int y = 30; + for (int i = 0; i < 2; i++) { + if (i == _confirm_selected) { + display.setColor(JoystickDisplay::YELLOW); + display.drawTextLeftAlign(0, y, "> "); + display.drawTextLeftAlign(10, y, kOptions[i]); + } else { + display.setColor(JoystickDisplay::GREEN); + display.drawTextLeftAlign(0, y, kOptions[i]); + } + y += kMenuLineH; + } + return 300; + } + + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + if (_task->isComposeContact()) { + char to_line[40]; + snprintf(to_line, sizeof(to_line), "To: %s", _task->getComposeContactName()); + display.drawTextEllipsized(0, 0, display.width(), to_line); + } else { + char to_line[40]; + snprintf(to_line, sizeof(to_line), "Ch: %s", _task->getComposeChannelName()); + display.drawTextEllipsized(0, 0, display.width(), to_line); + } + + char display_text[258]; + snprintf(display_text, sizeof(display_text), "%s|", _input); + display.drawTextLeftAlign(0, 10, display_text); + + renderT9Keypad(display, getT9KeyLabels(_kb_mode_letters, false), _selected_key, 22); + display.setColor(JoystickDisplay::LIGHT); + return 500; +} + +bool T9InputScreen::handleInput(char c) +{ + if (_confirm_exit) { + if (c == KEY_UP || c == KEY_DOWN) { + _confirm_selected = (_confirm_selected + 1) % 2; + return true; + } + if (c == KEY_ENTER) { + bool flush = (_confirm_selected == 1); + if (flush) { memset(_input, 0, sizeof(_input)); _cursor = 0; } + resetT9State(_last_key, _letter_index, _last_press_time); + _confirm_exit = false; + leaveComposeInput(_task); + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { _confirm_exit = false; return true; } + return false; + } + + if (handleT9DirectionalInput(c, _selected_key)) return true; + + if (c == KEY_ENTER) { + if (_selected_key == 12) { /* mode toggle */ + _kb_mode_letters = !_kb_mode_letters; + resetT9State(_last_key, _letter_index, _last_press_time); + return true; + } + if (_selected_key == 3) { /* delete */ + backspace(); + resetT9State(_last_key, _letter_index, _last_press_time); + return true; + } + if (_selected_key == 7) { /* space */ + addLetter(' '); + resetT9State(_last_key, _letter_index, _last_press_time); + return true; + } + if (_selected_key == 11) { /* send */ + if (_cursor > 0) { + if (_task->sendComposedMessage(_input)) + _task->showAlert("Message sent", 1000); + else + _task->showAlert("Send failed", 1000); + memset(_input, 0, sizeof(_input)); + _cursor = 0; + } + resetT9State(_last_key, _letter_index, _last_press_time); + leaveComposeInput(_task); + return true; + } + if (_selected_key == 15) return false; /* unused key */ + + const char *letters = getT9KeyLetters(_kb_mode_letters)[_selected_key]; + appendOrCycleT9Char(_input, _cursor, sizeof(_input), letters, _selected_key, + _last_key, _letter_index, _last_press_time); + return true; + } + + if (c == KEY_CANCEL || c == KEY_HOME) { + if (_cursor > 0) { + _confirm_exit = true; + _confirm_selected = 0; + } else { + resetT9State(_last_key, _letter_index, _last_press_time); + leaveComposeInput(_task); + } + return true; + } + return false; +} + +void T9InputScreen::addLetter(char ch) +{ + if (_cursor < (int)sizeof(_input) - 1) { + _input[_cursor++] = ch; + _input[_cursor] = '\0'; + } +} + +void T9InputScreen::backspace() +{ + if (_cursor > 0) { + _cursor--; + _input[_cursor] = '\0'; + } +} diff --git a/zephcore/helpers/ui-joystick/screens/messaging.cpp b/zephcore/helpers/ui-joystick/screens/messaging.cpp new file mode 100644 index 0000000..d193f03 --- /dev/null +++ b/zephcore/helpers/ui-joystick/screens/messaging.cpp @@ -0,0 +1,556 @@ +/* + * ZephCore - Joystick UI Messaging Screens + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "../joystick_screens.h" +#include "../joystick_ui_task.h" +#include "screen_helpers.h" +#include +#include +#include +#include + +UnreadScreen::UnreadScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), + _entry_count(0), _visible_unread_count(0), + _head_index(MAX_UNREAD_MSGS - 1), + _selected(0), _list_scroll(0), + _details(false), _detail_scroll(0), + _transient_preview(false), _preview_expiry(0) +{ + for (int i = 0; i < MAX_UNREAD_MSGS; i++) { + _entries[i].timestamp = 0; + _entries[i].origin[0] = '\0'; + _entries[i].msg[0] = '\0'; + _entries[i].read = true; + } +} + +void UnreadScreen::normalizeUnreadState() +{ + if (_entry_count < 0) _entry_count = 0; + if (_entry_count > MAX_UNREAD_MSGS) _entry_count = MAX_UNREAD_MSGS; + if (_head_index < 0 || _head_index >= MAX_UNREAD_MSGS) _head_index = MAX_UNREAD_MSGS - 1; + + int actual = 0; + for (int i = 0; i < _entry_count; i++) { + int pos = (_head_index - i + MAX_UNREAD_MSGS) % MAX_UNREAD_MSGS; + if (!_entries[pos].read) actual++; + } + _visible_unread_count = actual; + + if (_selected < 0) _selected = 0; + if (_visible_unread_count == 0) { + _selected = 0; + } else if (_selected >= _visible_unread_count) { + _selected = _visible_unread_count - 1; + } +} + +int UnreadScreen::getUnreadCount() +{ + normalizeUnreadState(); + return _visible_unread_count; +} + +void UnreadScreen::activatePreview(bool transient, uint32_t timeout_ms) +{ + normalizeUnreadState(); + _transient_preview = transient; + _details = false; + _detail_scroll = 0; + _list_scroll = 0; + if (_selected >= _visible_unread_count) + _selected = (_visible_unread_count > 0) ? _visible_unread_count - 1 : 0; + _preview_expiry = transient ? (k_uptime_get_32() + timeout_ms) : 0; +} + +const UnreadScreen::MsgEntry *UnreadScreen::getByListIndex(int idx) const +{ + if (idx < 0 || idx >= _visible_unread_count) return nullptr; + int found = 0; + for (int i = 0; i < _entry_count; i++) { + int pos = (_head_index - i + MAX_UNREAD_MSGS) % MAX_UNREAD_MSGS; + if (!_entries[pos].read) { + if (found == idx) return &_entries[pos]; + found++; + } + } + return nullptr; +} + +void UnreadScreen::addPreview(uint8_t path_len, const char *from_name, const char *msg) +{ + normalizeUnreadState(); + _head_index = (_head_index + 1) % MAX_UNREAD_MSGS; + if (_entry_count < MAX_UNREAD_MSGS) { + _entry_count++; + } else if (!_entries[_head_index].read && _visible_unread_count > 0) { + _visible_unread_count--; + } + MsgEntry &entry = _entries[_head_index]; + entry.timestamp = _rtc->getCurrentTime(); + entry.read = false; + if (_visible_unread_count < MAX_UNREAD_MSGS) _visible_unread_count++; + + const char *safe_from = (from_name && from_name[0]) ? from_name : "unknown"; + const char *safe_msg = msg ? msg : ""; + if (path_len == OUT_PATH_UNKNOWN) + snprintf(entry.origin, sizeof(entry.origin), "(D) %s:", safe_from); + else if (path_len == OUT_PATH_SENT) + snprintf(entry.origin, sizeof(entry.origin), "(>>) %s:", safe_from); + else + snprintf(entry.origin, sizeof(entry.origin), "(%d) %s:", (int)path_len, safe_from); + strncpy(entry.msg, safe_msg, sizeof(entry.msg) - 1); + entry.msg[sizeof(entry.msg) - 1] = '\0'; + _selected = 0; +} + +int UnreadScreen::getStoredMsgCount() const +{ + return _entry_count; +} + +bool UnreadScreen::getStoredMessageForSourceAt(const char *source_name, uint32_t timestamp, + const char *&out_msg) const +{ + if (!source_name || !source_name[0]) return false; + for (int i = 0; i < _entry_count; i++) { + int pos = (_head_index - i + MAX_UNREAD_MSGS) % MAX_UNREAD_MSGS; + const MsgEntry &e = _entries[pos]; + if (e.timestamp != timestamp) continue; + char sender[32] = {}; + parseMessageOriginAndPath(e.origin, sender, sizeof(sender), nullptr); + if (strcmp(sender, source_name) != 0) continue; + out_msg = e.msg; + return true; + } + return false; +} + +bool UnreadScreen::getLatestStoredMessageForSource(const char *source_name, + const char *&out_msg, uint32_t *out_ts) const +{ + if (!source_name || !source_name[0]) return false; + for (int i = 0; i < _entry_count; i++) { + int pos = (_head_index - i + MAX_UNREAD_MSGS) % MAX_UNREAD_MSGS; + const MsgEntry &e = _entries[pos]; + char sender[32] = {}; + parseMessageOriginAndPath(e.origin, sender, sizeof(sender), nullptr); + if (strcmp(sender, source_name) != 0) continue; + out_msg = e.msg; + if (out_ts) *out_ts = e.timestamp; + return true; + } + return false; +} + +int UnreadScreen::getContactMsgCount(const char *contact_name) const +{ + if (!contact_name || !contact_name[0]) return 0; + int count = 0; + for (int i = 0; i < _entry_count; i++) { + int pos = (_head_index - i + MAX_UNREAD_MSGS) % MAX_UNREAD_MSGS; + char sender[32] = {}; + parseMessageOriginAndPath(_entries[pos].origin, sender, sizeof(sender), nullptr); + if (strcmp(sender, contact_name) == 0) count++; + } + return count; +} + +bool UnreadScreen::getContactMsgAt(const char *contact_name, int idx, + const char *&out_msg, uint32_t &out_ts, uint8_t *out_path) const +{ + if (!contact_name || !contact_name[0]) return false; + int count = 0; + for (int i = 0; i < _entry_count; i++) { + int pos = (_head_index - i + MAX_UNREAD_MSGS) % MAX_UNREAD_MSGS; + const MsgEntry &e = _entries[pos]; + uint8_t path_len = OUT_PATH_UNKNOWN; + char sender[32] = {}; + parseMessageOriginAndPath(e.origin, sender, sizeof(sender), &path_len); + if (strcmp(sender, contact_name) != 0) continue; + if (count == idx) { + out_msg = e.msg; + out_ts = e.timestamp; + if (out_path) *out_path = path_len; + return true; + } + count++; + } + return false; +} + +void UnreadScreen::markCurrentRead() +{ + normalizeUnreadState(); + if (_visible_unread_count <= 0) return; + int found = 0; + for (int i = 0; i < _entry_count; i++) { + int pos = (_head_index - i + MAX_UNREAD_MSGS) % MAX_UNREAD_MSGS; + if (!_entries[pos].read) { + if (found == _selected) { + _entries[pos].read = true; + _visible_unread_count--; + if (_selected >= _visible_unread_count && _selected > 0) _selected--; + return; + } + found++; + } + } +} + +int UnreadScreen::render(JoystickDisplay &display) +{ + normalizeUnreadState(); + if (_visible_unread_count == 0) { + renderScreenHeader(display, "Unread", 0, 0); + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + display.drawTextCentered(display.width() / 2, 30, "No unread msgs"); + return 1000; + } + + if (_selected >= _visible_unread_count) _selected = _visible_unread_count - 1; + if (_selected < 0) _selected = 0; + + if (_details) { + const MsgEntry *entry = getByListIndex(_selected); + if (!entry) { _details = false; return 200; } + + char sender_name[32] = {}; + uint8_t path_len = OUT_PATH_UNKNOWN; + parseMessageOriginAndPath(entry->origin, sender_name, sizeof(sender_name), &path_len); + + char msg_safe[sizeof(entry->msg)]; + sanitizeForDisplay(entry->msg, msg_safe, sizeof(msg_safe)); + + char route_line[48]; + buildMessageRouteLineForName("", sender_name, path_len, route_line, sizeof(route_line)); + + return renderMessageDetailView(display, _selected, _visible_unread_count, entry->timestamp, + sender_name[0] ? sender_name : "unknown", + route_line, msg_safe, _detail_scroll); + } + + renderScreenHeader(display, "Unread", _selected, _visible_unread_count); + display.setTextSize(1); + + const int ROW_H = 10; + const int LINES_PER_MSG = 2; + const int HEADER_H = 14; + int visible_count = getMessagePreviewVisibleCount(display, ROW_H, LINES_PER_MSG, HEADER_H); + _list_scroll = getCenteredMessagePreviewStart(_selected, _visible_unread_count, visible_count); + + int y = HEADER_H; + for (int li = _list_scroll; li < _visible_unread_count && y < display.height() - 2; li++) { + const MsgEntry *entry = getByListIndex(li); + if (!entry) continue; + char origin_safe[sizeof(entry->origin)]; + char msg_safe[sizeof(entry->msg)]; + sanitizeForDisplay(entry->origin, origin_safe, sizeof(origin_safe)); + sanitizeForDisplay(entry->msg, msg_safe, sizeof(msg_safe)); + y = renderMessagePreviewEntry(display, y, li == _selected, origin_safe, msg_safe, + ROW_H, LINES_PER_MSG, 20, 10, 4); + } + return 500; +} + +bool UnreadScreen::handleInput(char key) +{ + normalizeUnreadState(); + + if (_details) { + const MsgEntry *entry = getByListIndex(_selected); + int max_scroll = 0; + if (entry) { + JoystickDisplay &display = _task->getDisplay(); + max_scroll = getMessageDetailMaxScrollSanitized(display, entry->msg, 20, 5, 5); + } + if (handleDetailScrollNavigation(key, _detail_scroll, max_scroll)) return true; + if (key == KEY_CANCEL || key == KEY_HOME || key == KEY_ENTER) { + _details = false; + _detail_scroll = 0; + _list_scroll = 0; + return true; + } + if (key == KEY_ENTER_LONG) { + /* Reply to channel message if applicable */ + if (entry && strchr(entry->msg, '#') != nullptr) { + const char *hash_pos = strchr(entry->msg, '#'); + char channel_name[32] = {}; + const char *space_after = hash_pos ? strchr(hash_pos, ' ') : nullptr; + if (space_after) { + size_t len = space_after - hash_pos; + if (len < sizeof(channel_name)) { + memcpy(channel_name, hash_pos, len); + channel_name[len] = '\0'; + } + } + if (channel_name[0]) { + _task->setComposeChannel(-1, channel_name); + _task->gotoT9InputScreen(); + return true; + } + } + return true; + } + return false; + } + + if (_visible_unread_count > 0 && + handleCommonListNavigation(key, _selected, _visible_unread_count)) return true; + + if (key == KEY_RIGHT && _visible_unread_count > 0) { markCurrentRead(); return true; } + if (key == KEY_ENTER && _visible_unread_count > 0) { _details = true; _detail_scroll = 0; return true; } + if (key == KEY_CANCEL || key == KEY_HOME) { + _transient_preview = false; + _preview_expiry = 0; + _list_scroll = 0; + _task->gotoHomeScreen(); + return true; + } + return false; +} + +void UnreadScreen::poll() +{ + normalizeUnreadState(); + if (_transient_preview && _preview_expiry > 0 && k_uptime_get_32() >= _preview_expiry) { + _transient_preview = false; + _preview_expiry = 0; + _details = false; + _task->gotoHomeScreen(); + } +} + +ChannelsScreen::ChannelsScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), _selected(0), + _show_msgs(false), _msg_channel_idx(-1), _msg_scroll(0), + _msg_details(false), _msg_detail_scroll(0) +{ + _msg_channel[0] = '\0'; +} + +int ChannelsScreen::getChannelCount() const +{ + auto *mesh = _task->getMesh(); + int count = 0; + ChannelDetails ch; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + if (mesh->getChannel(i, ch) && ch.name[0]) count++; + } + return count; +} + +bool ChannelsScreen::getChannelByListIndex(int listIdx, ChannelDetails &ch, int *slot_out) const +{ + auto *mesh = _task->getMesh(); + int matched = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + if (!mesh->getChannel(i, ch) || !ch.name[0]) continue; + if (matched == listIdx) { if (slot_out) *slot_out = i; return true; } + matched++; + } + return false; +} + +int ChannelsScreen::render(JoystickDisplay &display) +{ + /* Message view */ + if (_show_msgs) { + int total = _task->getChannelPreviewCountFor(_msg_channel); + + if (total <= 0) { + renderScreenHeader(display, _msg_channel, 0, 0); + display.setColor(JoystickDisplay::YELLOW); + display.drawTextCentered(display.width() / 2, 24, "No queued messages"); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, 38, "Long ENTER compose"); + display.drawTextCentered(display.width() / 2, 49, "CANCEL back"); + return 400; + } + + if (_msg_scroll >= total) _msg_scroll = total - 1; + if (_msg_scroll < 0) _msg_scroll = 0; + + /* Message detail view */ + if (_msg_details) { + const char *msg = ""; + uint32_t ts = 0; + uint8_t path_len = OUT_PATH_UNKNOWN; + if (!_task->getChannelPreviewFor(_msg_channel, _msg_scroll, msg, ts, &path_len)) { + return 400; + } + + char msg_safe[96]; + sanitizeForDisplay(msg, msg_safe, sizeof(msg_safe)); + + char hop_buf[16], source_with_hops[52]; + formatHopCount(path_len, hop_buf, sizeof(hop_buf)); + snprintf(source_with_hops, sizeof(source_with_hops), "%s %s", _msg_channel, hop_buf); + + return renderMessageDetailView(display, _msg_scroll, total, ts, + source_with_hops, "", msg_safe, + _msg_detail_scroll); + } + + /* Message list */ + renderScreenHeader(display, _msg_channel, _msg_scroll, total); + + const int ROW_H = 10, LINES_PER_MSG = 2; + int visible = getMessagePreviewVisibleCount(display, ROW_H, LINES_PER_MSG, kContentY); + int start = getCenteredMessagePreviewStart(_msg_scroll, total, visible); + + int y = kContentY; + for (int i = start; i < total && y < display.height() - 2; i++) { + const char *msg = ""; + uint32_t ts = 0; + _task->getChannelPreviewFor(_msg_channel, i, msg, ts); + char msg_safe[96]; + sanitizeForDisplay(msg, msg_safe, sizeof(msg_safe)); + char time_buf[12]; + formatClockHM(ts, time_buf, sizeof(time_buf)); + y = renderMessagePreviewEntry(display, y, i == _msg_scroll, + time_buf, msg_safe, ROW_H, LINES_PER_MSG, 20, 10, 4); + } + return 400; + } + + /* Channel list */ + int ch_count = getChannelCount(); + int item_count = ch_count + 3; /* channels + Join #channel + Join private + Create private */ + + if (_selected >= item_count) _selected = item_count - 1; + if (_selected < 0) _selected = 0; + + renderScreenHeader(display, "Channels", _selected, item_count); + int start = computeListStart(_selected, item_count); + int y = kContentY; + for (int i = start; i < item_count && i < start + UI_RECENT_LIST_SIZE; i++) { + char line[40]; + if (i < ch_count) { + ChannelDetails ch; + if (!getChannelByListIndex(i, ch)) continue; + display.translateUTF8ToBlocks(line, ch.name, sizeof(line)); + } else if (i == ch_count) { + snprintf(line, sizeof(line), "+ Join #channel"); + } else if (i == ch_count + 1) { + snprintf(line, sizeof(line), "+ Join private"); + } else { + snprintf(line, sizeof(line), "+ Create private"); + } + renderMenuListText(display, y, i == _selected, line, true); + y += kMenuLineH; + } + + display.setTextSize(1); + display.setColor(JoystickDisplay::LIGHT); + return 500; +} + +bool ChannelsScreen::handleInput(char key) +{ + /* Message view input */ + if (_show_msgs) { + int total = _task->getChannelPreviewCountFor(_msg_channel); + + if (_msg_details) { + const char *msg = ""; + uint32_t ts = 0; + int max_scroll = 0; + if (_task->getChannelPreviewFor(_msg_channel, _msg_scroll, msg, ts)) { + max_scroll = getMessageDetailMaxScrollSanitized( + _task->getDisplay(), msg, 20, 5, 5); + } + if (handleDetailScrollNavigation(key, _msg_detail_scroll, max_scroll)) return true; + if (key == KEY_CANCEL || key == KEY_HOME || key == KEY_ENTER) { + _msg_details = false; + _msg_detail_scroll = 0; + return true; + } + if (key == KEY_ENTER_LONG) { + _task->setComposeChannel(_msg_channel_idx, _msg_channel); + _task->gotoT9InputScreen(); + return true; + } + return false; + } + + if (key == KEY_UP && total > 0) { + _msg_scroll = (_msg_scroll + total - 1) % total; + return true; + } + if (key == KEY_DOWN && total > 0) { + _msg_scroll = (_msg_scroll + 1) % total; + return true; + } + if (key == KEY_TO_TOP && total > 0) { _msg_scroll = 0; return true; } + if (key == KEY_TO_BOTTOM && total > 0) { _msg_scroll = total - 1; return true; } + if (key == KEY_ENTER && total > 0) { + _msg_details = true; + _msg_detail_scroll = 0; + return true; + } + if (key == KEY_ENTER_LONG) { + _task->setComposeChannel(_msg_channel_idx, _msg_channel); + _task->gotoT9InputScreen(); + return true; + } + if (key == KEY_CANCEL || key == KEY_HOME) { + _show_msgs = false; + _msg_details = false; + return true; + } + return false; + } + + /* Channel list */ + int ch_count = getChannelCount(); + int item_count = ch_count + 3; + + if (handleCommonListNavigation(key, _selected, item_count)) return true; + + if (key == KEY_ENTER_LONG && _selected < ch_count) { + ChannelDetails ch; + int slot = -1; + if (getChannelByListIndex(_selected, ch, &slot)) { + _task->setComposeChannel(slot, ch.name); + _task->gotoT9InputScreen(); + } + return true; + } + if (key == KEY_ENTER) { + if (_selected < ch_count) { + ChannelDetails ch; + int slot = -1; + if (getChannelByListIndex(_selected, ch, &slot)) { + strncpy(_msg_channel, ch.name, sizeof(_msg_channel) - 1); + _msg_channel[sizeof(_msg_channel) - 1] = '\0'; + _msg_channel_idx = slot; + _msg_scroll = 0; + _msg_details = false; + _msg_detail_scroll = 0; + _show_msgs = true; + } + return true; + } + if (_selected == ch_count) { + _task->setComposeChannel(-2, "+Join #channel"); + _task->gotoT9InputScreen(); + return true; + } + if (_selected == ch_count + 1) { + _task->setComposeChannel(-3, "+Join private"); + _task->gotoT9InputScreen(); + return true; + } + _task->setComposeChannel(-4, "+Create private"); + _task->gotoT9InputScreen(); + return true; + } + if (key == KEY_CANCEL || key == KEY_HOME) { _task->gotoHomeScreen(); return true; } + return false; +} diff --git a/zephcore/helpers/ui-joystick/screens/screen_helpers.h b/zephcore/helpers/ui-joystick/screens/screen_helpers.h new file mode 100644 index 0000000..f1ee4b2 --- /dev/null +++ b/zephcore/helpers/ui-joystick/screens/screen_helpers.h @@ -0,0 +1,547 @@ +/* + * ZephCore - Joystick UI Screen Helpers + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * Static helper functions shared by all joystick UI screens. + * Include this file once per .cpp file that needs it. + */ + +#pragma once + +#include "../joystick_display.h" +#include "../joystick_defs.h" +#include +#include +#include +#include +#include +#include + +/* ===== Header separator line Y ===== */ +/* (kHeaderSepY and kContentY from JoystickDefs.h) */ + +/* ===== List navigation helper ===== */ +static inline bool handleCommonListNavigation(char key, int &selected, int itemCount) +{ + if (itemCount <= 0) return false; + if (key == KEY_TO_TOP) { selected = 0; return true; } + if (key == KEY_TO_BOTTOM) { selected = itemCount - 1; return true; } + if (key == KEY_UP) { selected = (selected + itemCount - 1) % itemCount; return true; } + if (key == KEY_DOWN) { selected = (selected + 1) % itemCount; return true; } + return false; +} + +/* ===== computeListStart ===== */ +static inline int computeListStart(int selected_index, int item_count, + int visible_count = UI_RECENT_LIST_SIZE) +{ + int start = selected_index - visible_count / 2; + if (start < 0) start = 0; + if (start + visible_count > item_count) start = item_count - visible_count; + if (start < 0) start = 0; + return start; +} + +/* ===== renderScreenHeader ===== */ +static inline void renderScreenHeader(JoystickDisplay &display, const char *title, + int activeIndex, int totalItems) +{ + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + display.drawTextLeftAlign(0, 0, title); + if (totalItems > 0) { + char count[24]; + snprintf(count, sizeof(count), "%d/%d", activeIndex + 1, totalItems); + display.drawTextRightAlign(display.width() - 1, 0, count); + } + display.drawRect(0, kHeaderSepY, display.width(), 1); +} + +/* ===== renderScrollingScreenHeader ===== */ +static inline void renderScrollingScreenHeader(JoystickDisplay &display, const char *title, + int activeIndex, int totalItems, + uint32_t marquee_base_ms) +{ + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + + int title_max_w = display.width(); + if (totalItems > 0) { + char count[24]; + snprintf(count, sizeof(count), "%d/%d", activeIndex + 1, totalItems); + display.drawTextRightAlign(display.width() - 1, 0, count); + title_max_w = display.width() - display.getTextWidth(count) - 4; + } + + if (title_max_w > 0 && display.getTextWidth(title) > title_max_w) { + int text_len = (int)strlen(title); + if (text_len > 0) { + static const uint32_t kPauseMs = 1500; + uint32_t elapsed = k_uptime_get_32() - marquee_base_ms; + uint32_t scroll_elapsed = (elapsed > kPauseMs) ? (elapsed - kPauseMs) : 0; + int offset = (int)((scroll_elapsed / 450U) % (uint32_t)text_len); + display.drawTextEllipsized(0, 0, title_max_w, title + offset); + } + } else { + display.drawTextEllipsized(0, 0, title_max_w, title); + } + display.drawRect(0, kHeaderSepY, display.width(), 1); +} + +/* ===== renderMenuListText ===== */ +static inline void renderMenuListText(JoystickDisplay &display, int y, bool is_selected, + const char *text, bool enable_marquee = false, + uint32_t marquee_step_ms = 450, + uint32_t marquee_base_ms = 0) +{ + const char *safe = text ? text : ""; + const int text_x = is_selected ? 10 : 0; + const int max_w = display.width() - text_x; + + if (is_selected) { + display.setColor(JoystickDisplay::YELLOW); + display.drawTextLeftAlign(0, y, "> "); + } else { + display.setColor(JoystickDisplay::GREEN); + } + + if (enable_marquee && is_selected && max_w > 0 && display.getTextWidth(safe) > max_w) { + int tlen = (int)strlen(safe); + if (tlen > 0) { + const uint32_t step = marquee_step_ms ? marquee_step_ms : 1; + uint32_t elapsed = k_uptime_get_32() - marquee_base_ms; + static const uint32_t kPauseMs = 1500; + uint32_t scroll_elapsed = elapsed > kPauseMs ? elapsed - kPauseMs : 0; + int offset = (int)((scroll_elapsed / step) % (uint32_t)tlen); + display.drawTextEllipsized(text_x, y, max_w, safe + offset); + return; + } + } + display.drawTextEllipsized(text_x, y, max_w, safe); +} + +/* ===== sanitizeForDisplay ===== */ +static inline void sanitizeForDisplay(const char *src, char *dst, size_t dst_size) +{ + size_t si = 0, di = 0; + while (src[si] && di < dst_size - 1) { + unsigned char c = (unsigned char)src[si]; + if (c == '\n') { + dst[di++] = ' '; + } else if (c >= 0x20 || c == '\t') { + dst[di++] = (char)c; + } + si++; + } + dst[di] = '\0'; +} + +/* ===== formatAge helper ===== */ +static inline void formatAge(uint32_t age_s, char *buf, size_t buf_size) +{ + if (age_s < 60) { + snprintf(buf, buf_size, "%u sec", age_s); + } else if (age_s < 3600) { + snprintf(buf, buf_size, "%u min", age_s / 60); + } else if (age_s < 86400) { + snprintf(buf, buf_size, "%u hr", age_s / 3600); + } else { + snprintf(buf, buf_size, "%u day", age_s / 86400); + } +} + +/* ===== Message view constants ===== */ +static const int kWrapRowSize = 28; /* chars per wrapped line (incl NUL) */ +static const int kDetailMaxLines = 40; +static const int kDetailCPL = 20; /* chars per line in detail wrap */ +/* kLineH is defined in JoystickDefs.h */ + +/* ===== formatHopCount ===== */ +static inline void formatHopCount(uint8_t path_len, char *out, size_t out_len) +{ + if (out_len == 0) return; + if (path_len == OUT_PATH_UNKNOWN) { + snprintf(out, out_len, "flood"); + return; + } + if (path_len == OUT_PATH_SENT) { + snprintf(out, out_len, "sent"); + return; + } + int hops = (int)(path_len & 63); + if (hops <= 0) snprintf(out, out_len, "direct"); + else snprintf(out, out_len, "%dh", hops); +} + +/* ===== formatClockHM ===== */ +static inline void formatClockHM(uint32_t timestamp, char *out, size_t out_len) +{ + if (out_len == 0) return; + int hh = (int)((timestamp / 3600UL) % 24UL); + int mm = (int)((timestamp / 60UL) % 60UL); + snprintf(out, out_len, "%02dh%02d", hh, mm); +} + +/* ===== buildWrappedLines ===== */ +static inline int buildWrappedLines(const char *src, int chars_per_line, + char lines[][kWrapRowSize], int max_lines) +{ + if (!src || chars_per_line <= 1 || max_lines <= 0) return 0; + int total = 0, i = 0, n = (int)strlen(src); + while (i < n && total < max_lines) { + while (i < n && src[i] == ' ') i++; + if (i >= n) break; + int line_start = i, line_end = i + chars_per_line; + if (line_end >= n) { line_end = n; } + else { + int bp = line_end; + while (bp > line_start && src[bp] != ' ') bp--; + if (bp > line_start) line_end = bp; + } + int len = line_end - line_start; + if (len > kWrapRowSize - 1) len = kWrapRowSize - 1; + memcpy(lines[total], src + line_start, len); + lines[total][len] = '\0'; + total++; + i = line_end; + } + return total; +} + +/* ===== parseMessageOriginAndPath ===== */ +static inline void parseMessageOriginAndPath(const char *origin, + char *sender_out, size_t sender_out_len, + uint8_t *path_len_out) +{ + if (sender_out_len == 0) return; + sender_out[0] = '\0'; + if (path_len_out) *path_len_out = OUT_PATH_UNKNOWN; + if (!origin || !origin[0]) return; + + if (origin[1] == 'D') { + sscanf(origin, "(D) %31[^:]", sender_out); + return; + } + if (origin[1] == '>') { + sscanf(origin, "(>>) %31[^:]", sender_out); + if (path_len_out) *path_len_out = OUT_PATH_SENT; + return; + } + int hops = 0; + if (sscanf(origin, "(%d) %31[^:]", &hops, sender_out) >= 1) { + if (path_len_out) *path_len_out = (hops <= 0) ? 0 : (uint8_t)hops; + } +} + +/* ===== buildMessageRouteLineForName ===== */ +static inline void buildMessageRouteLineForName(const char *origin_label, + const char * /*contact_name*/, + uint8_t path_len, + char *out, size_t out_len) +{ + if (out_len == 0) return; + char hop_text[24]; + formatHopCount(path_len, hop_text, sizeof(hop_text)); + if (origin_label && origin_label[0]) { + strncpy(out, origin_label, out_len - 1); + out[out_len - 1] = '\0'; + size_t used = strlen(out); + if (used + 1 < out_len) { + out[used] = ' '; + strncpy(out + used + 1, hop_text, out_len - used - 2); + out[out_len - 1] = '\0'; + } + } else { + strncpy(out, hop_text, out_len - 1); + out[out_len - 1] = '\0'; + } +} + +/* ===== handleDetailScrollNavigation ===== */ +static inline bool handleDetailScrollNavigation(char key, int &scroll, int max_scroll) +{ + if (key == KEY_UP) { if (scroll > 0) scroll--; return true; } + if (key == KEY_DOWN) { if (scroll < max_scroll) scroll++; return true; } + if (key == KEY_TO_TOP) { scroll = 0; return true; } + if (key == KEY_TO_BOTTOM) { scroll = max_scroll; return true; } + return false; +} + +/* ===== getMessageDetailMaxScroll ===== */ +static inline int getMessageDetailMaxScroll(const char *msg, int chars_per_line, + int visible_lines, int prefix_lines = 0) +{ + if (!msg) return 0; + char wrapped[kDetailMaxLines][kWrapRowSize]; + int total_lines = prefix_lines + buildWrappedLines(msg, chars_per_line, wrapped, kDetailMaxLines); + return (total_lines > visible_lines) ? (total_lines - visible_lines) : 0; +} + +static inline int getMessageDetailMaxScrollSanitized(JoystickDisplay &display, + const char *msg, int chars_per_line, + int visible_lines, int prefix_lines = 0) +{ + (void)display; + char safe[400]; + sanitizeForDisplay(msg, safe, sizeof(safe)); + return getMessageDetailMaxScroll(safe, chars_per_line, visible_lines, prefix_lines); +} + +/* ===== renderMessageDetailView ===== */ +static inline int renderMessageDetailView(JoystickDisplay &display, + int active_index, int total_items, + uint32_t timestamp, + const char *source_line, const char *route_line, + const char *message, int &scroll) +{ + renderScreenHeader(display, "Msg detail", active_index, total_items); + display.setTextSize(1); + + char lines[kDetailMaxLines][kWrapRowSize]; + int total_lines = 0; + + char time_buf[12]; + formatClockHM(timestamp, time_buf, sizeof(time_buf)); + snprintf(lines[total_lines++], kWrapRowSize, "Time: %s", time_buf); + + if (source_line && source_line[0] && total_lines < kDetailMaxLines) { + strncpy(lines[total_lines], source_line, kWrapRowSize - 1); + lines[total_lines++][kWrapRowSize - 1] = '\0'; + } + if (route_line && route_line[0] && total_lines < kDetailMaxLines) { + strncpy(lines[total_lines], route_line, kWrapRowSize - 1); + lines[total_lines++][kWrapRowSize - 1] = '\0'; + } + if (total_lines < kDetailMaxLines) + snprintf(lines[total_lines++], kWrapRowSize, "---"); + if (message && message[0] && total_lines < kDetailMaxLines) + total_lines += buildWrappedLines(message, kDetailCPL, &lines[total_lines], kDetailMaxLines - total_lines); + + int visible = (display.height() - kContentY) / kLineH; + if (visible < 2) visible = 2; + if (visible > 6) visible = 6; + int max_scroll = (total_lines > visible) ? (total_lines - visible) : 0; + if (scroll < 0) scroll = 0; + if (scroll > max_scroll) scroll = max_scroll; + + int y = kContentY; + for (int i = scroll; i < total_lines && i < scroll + visible; i++) { + display.setColor(i < 3 ? JoystickDisplay::GREEN : JoystickDisplay::LIGHT); + display.drawTextEllipsized(0, y, display.width(), lines[i]); + y += kLineH; + } + return 300; +} + +/* ===== getMessagePreviewVisibleCount ===== */ +static inline int getMessagePreviewVisibleCount(JoystickDisplay &display, + int row_h, int lines_per_msg, int header_h = 14) +{ + int available_h = display.height() - header_h - 2; + int entry_h = row_h * (1 + lines_per_msg); + if (entry_h <= 0) return 1; + int count = available_h / entry_h; + return (count < 1) ? 1 : count; +} + +/* ===== getCenteredMessagePreviewStart ===== */ +static inline int getCenteredMessagePreviewStart(int selected, int total, int visible_count) +{ + if (visible_count < 1) visible_count = 1; + int start = selected - (visible_count / 2); + if (start < 0) start = 0; + if (start + visible_count > total) start = total - visible_count; + if (start < 0) start = 0; + return start; +} + +/* ===== renderMessagePreviewEntry ===== */ +static inline int renderMessagePreviewEntry(JoystickDisplay &display, int y, bool selected, + const char *header, const char *message, + int row_h, int lines_per_msg, int chars_per_line, + int selected_body_x, int unselected_body_x) +{ + char wrapped[6][kWrapRowSize]; + int line_count = buildWrappedLines(message, chars_per_line, wrapped, lines_per_msg); + if (selected) { + display.setColor(JoystickDisplay::YELLOW); + display.drawTextLeftAlign(0, y, "> "); + display.drawTextEllipsized(10, y, display.width() - 10, header); + y += row_h; + display.setColor(JoystickDisplay::GREEN); + for (int j = 0; j < line_count; j++) { display.drawTextLeftAlign(selected_body_x, y, wrapped[j]); y += row_h; } + } else { + display.setColor(JoystickDisplay::GREEN); + display.drawTextEllipsized(0, y, display.width(), header); + y += row_h; + display.setColor(JoystickDisplay::LIGHT); + for (int j = 0; j < line_count; j++) { display.drawTextLeftAlign(unselected_body_x, y, wrapped[j]); y += row_h; } + } + return y; +} + +/* ===== GPS coordinate math helpers ===== */ +static const float kScreenGpsDeg2Rad = 3.14159265f / 180.0f; + +[[maybe_unused]] static inline float gpsDistanceM(int32_t lat1_e6, int32_t lon1_e6, + int32_t lat2_e6, int32_t lon2_e6) +{ + float la1 = lat1_e6 * 1e-6f * kScreenGpsDeg2Rad; + float la2 = lat2_e6 * 1e-6f * kScreenGpsDeg2Rad; + float dlat = (lat2_e6 - lat1_e6) * 1e-6f * kScreenGpsDeg2Rad; + float dlon = (lon2_e6 - lon1_e6) * 1e-6f * kScreenGpsDeg2Rad; + float a = sinf(dlat / 2) * sinf(dlat / 2) + + cosf(la1) * cosf(la2) * sinf(dlon / 2) * sinf(dlon / 2); + return 6371000.0f * 2.0f * atan2f(sqrtf(a), sqrtf(1.0f - a)); +} + +[[maybe_unused]] static inline float gpsBearingDeg(int32_t lat1_e6, int32_t lon1_e6, + int32_t lat2_e6, int32_t lon2_e6) +{ + float la1 = lat1_e6 * 1e-6f * kScreenGpsDeg2Rad; + float la2 = lat2_e6 * 1e-6f * kScreenGpsDeg2Rad; + float dlon = (lon2_e6 - lon1_e6) * 1e-6f * kScreenGpsDeg2Rad; + float y = sinf(dlon) * cosf(la2); + float x = cosf(la1) * sinf(la2) - sinf(la1) * cosf(la2) * cosf(dlon); + float b = atan2f(y, x) / kScreenGpsDeg2Rad; + return fmodf(b + 360.0f, 360.0f); +} + +[[maybe_unused]] static inline const char *compassDir(float bearing) +{ + int idx = (int)((bearing + 22.5f) / 45.0f) % 8; + static const char * const dirs[8] = { "N","NE","E","SE","S","SW","W","NW" }; + return dirs[idx]; +} + +/* ===== T9 keypad helpers ===== */ +static const unsigned long T9_MULTI_TAP_MS = 1100; + +static const char * const T9_KEY_LETTERS_LETTER_MODE[16] = { + "abc", "def", "ghi", "", + "jkl", "mno", "pqr", "", + "stu", "vwx", "yz-", "", + "", ".,'", ":!?", "" +}; +static const char * const T9_KEY_LETTERS_NUMBER_MODE[16] = { + "12", "34", "56", "", + "78", "90", "@$", "", + "/\\", "+=", "()", "", + "", "_#", "^%", "" +}; + +static inline const char * const *getT9KeyLetters(bool letter_mode) +{ + return letter_mode ? T9_KEY_LETTERS_LETTER_MODE : T9_KEY_LETTERS_NUMBER_MODE; +} + +static const char * const T9_KEY_LABELS_LETTER_SPECIAL[16] = { + nullptr, nullptr, nullptr, "del", + nullptr, nullptr, nullptr, "spc", + nullptr, nullptr, nullptr, "send", + "123", nullptr, nullptr, " " +}; +static const char * const T9_KEY_LABELS_NUMBER_SPECIAL[16] = { + nullptr, nullptr, nullptr, "del", + nullptr, nullptr, nullptr, "spc", + nullptr, nullptr, nullptr, "send", + "abc", nullptr, nullptr, nullptr +}; + +[[maybe_unused]] static const char * const *getT9KeyLabels(bool letter_mode, bool save_action) +{ + static char label_storage[16][8]; + static const char *label_ptrs[16]; + + const char * const *overlay = letter_mode ? T9_KEY_LABELS_LETTER_SPECIAL : T9_KEY_LABELS_NUMBER_SPECIAL; + const char * const *letters = getT9KeyLetters(letter_mode); + + for (int i = 0; i < 16; i++) { + if (overlay[i]) { + label_ptrs[i] = overlay[i]; + } else { + size_t n = strlen(letters[i]); + if (n >= sizeof(label_storage[i])) n = sizeof(label_storage[i]) - 1; + memcpy(label_storage[i], letters[i], n); + label_storage[i][n] = '\0'; + label_ptrs[i] = label_storage[i]; + } + } + if (save_action) label_ptrs[11] = "save"; + return label_ptrs; +} + +static inline void resetT9State(int &last_key, int &letter_index, uint32_t &last_press_time) +{ + last_key = -1; + letter_index = 0; + last_press_time = 0; +} + +static inline void appendOrCycleT9Char(char *buf, int &cursor, size_t capacity, + const char *letters, int key, + int &last_key, int &letter_index, + uint32_t &last_press_time) +{ + size_t count = strlen(letters); + if (count == 0 || capacity < 2) return; + + uint32_t now = k_uptime_get_32(); + bool cycle = cursor > 0 && last_key == key && (now - last_press_time) <= T9_MULTI_TAP_MS; + if (cycle) { + letter_index = (letter_index + 1) % (int)count; + buf[cursor - 1] = letters[letter_index]; + } else if (cursor < (int)capacity - 1) { + letter_index = 0; + buf[cursor++] = letters[0]; + buf[cursor] = '\0'; + } + last_key = key; + last_press_time = now; +} + +static inline bool handleT9DirectionalInput(char c, int &selected_key) +{ + if (c == KEY_UP) { selected_key = (selected_key + 12) % 16; return true; } + if (c == KEY_DOWN) { selected_key = (selected_key + 4) % 16; return true; } + if (c == KEY_LEFT) { selected_key = (selected_key / 4) * 4 + ((selected_key + 3) % 4); return true; } + if (c == KEY_RIGHT) { selected_key = (selected_key / 4) * 4 + ((selected_key + 1) % 4); return true; } + return false; +} + +static inline void renderT9Keypad(JoystickDisplay &display, const char * const *labels, + int selected_key, int y_start, int key_height = 10) +{ + int key_width = display.width() / 4; + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + int key = row * 4 + col; + int x = col * key_width; + int y = y_start + row * key_height; + if (key == selected_key) { + display.setColor(JoystickDisplay::LIGHT); + display.fillRect(x, y, key_width, key_height); + display.setColor(JoystickDisplay::DARK); + } else { + display.setColor(JoystickDisplay::LIGHT); + } + display.drawTextCentered(x + key_width / 2, y + key_height / 2, labels[key]); + } + } +} + +/* Helper: leave compose input and return to appropriate screen */ +static inline void leaveComposeInput(class JoystickUITask *task); + +#include "../joystick_ui_task.h" +static inline void leaveComposeInput(JoystickUITask *task) +{ + if (task->isComposeContact()) { + task->gotoContactsScreen(); + } else if (task->getComposeChannelIndex() != -1) { + task->gotoChannelsScreen(); + } else { + task->gotoHomeScreen(); + } +} diff --git a/zephcore/helpers/ui-joystick/screens/system.cpp b/zephcore/helpers/ui-joystick/screens/system.cpp new file mode 100644 index 0000000..7e1e181 --- /dev/null +++ b/zephcore/helpers/ui-joystick/screens/system.cpp @@ -0,0 +1,885 @@ +/* + * ZephCore - Joystick UI System Screen + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "../joystick_screens.h" +#include "../joystick_ui_task.h" +#include "screen_helpers.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* Category labels */ +static const char * const kSysCatLabels[] = { "Device", "Display", "Info", "Power" }; +static const int kSysCatCount = 4; + +/* Device submenu items */ +enum SysDevItem { SYSDEV_BUZZER=0, SYSDEV_BLUETOOTH, SYSDEV_OFFGRID, SYSDEV_LEDS, SYSDEV_BLE_CODE, SYSDEV_DFU, SYSDEV_TELEMETRY, SYSDEV_RENAME, SYSDEV_COUNT }; + +#define DFU_CONFIRM_WINDOW_MS 3000 +/* Display submenu items */ +enum SysDspItem { SYSDSP_BRIGHT=0, SYSDSP_SCROFF, SYSDSP_BATT, SYSDSP_WAKE, SYSDSP_COUNT }; +/* Info submenu items */ +enum SysInfoItem { SYSINFO_TIME=0, SYSINFO_STATS, SYSINFO_RADIO, SYSINFO_COUNT }; +/* Power submenu items */ +enum SysPwrItem { SYSPWR_REBOOT=0, SYSPWR_SHUTDOWN, SYSPWR_COUNT }; + +static void renderSubMenu(JoystickDisplay &display, const char *title, + int selected, const char * const *items, int count) +{ + renderScreenHeader(display, title, selected, count); + int start = computeListStart(selected, count); + int y = kContentY + 2; + for (int i = start; i < count && i < start + UI_RECENT_LIST_SIZE; i++) { + renderMenuListText(display, y, i == selected, items[i]); + y += kMenuLineH; + } +} + +SystemScreen::SystemScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), _selected(0), _cat_selected(0), _mode(SYSMODE_TOP), + _dfu_confirm_time(0) +{ +} + +int SystemScreen::render(JoystickDisplay &display) +{ + if (_mode == SYSMODE_TOP) { + renderSubMenu(display, "System", _selected, kSysCatLabels, kSysCatCount); + return 500; + } + if (_mode == SYSMODE_DEVICE) { + char bt_label[24], buz_label[24], og_label[24], led_label[24], dfu_label[24]; + snprintf(buz_label, sizeof(buz_label), "Buzzer: %s", _task->isBuzzerQuiet() ? "OFF" : "ON"); + snprintf(bt_label, sizeof(bt_label), "Bluetooth: %s", _task->isSerialEnabled() ? "ON" : "OFF"); + snprintf(og_label, sizeof(og_label), "Offgrid: %s", _task->isOffgridEnabled() ? "ON" : "OFF"); + snprintf(led_label, sizeof(led_label), "LEDs: %s", _task->isLedsDisabled() ? "OFF" : "ON"); + uint32_t now = k_uptime_get_32(); + bool dfu_pending = (_dfu_confirm_time != 0 && + (now - _dfu_confirm_time) <= DFU_CONFIRM_WINDOW_MS); + snprintf(dfu_label, sizeof(dfu_label), "BLE DFU%s", dfu_pending ? " (confirm)" : ""); + const char *items[SYSDEV_COUNT]; + items[SYSDEV_BUZZER] = buz_label; + items[SYSDEV_BLUETOOTH] = bt_label; + items[SYSDEV_BLE_CODE] = "BLE Code"; + items[SYSDEV_TELEMETRY] = "Telemetry"; + items[SYSDEV_RENAME] = "Rename node"; + items[SYSDEV_OFFGRID] = og_label; + items[SYSDEV_LEDS] = led_label; + items[SYSDEV_DFU] = dfu_label; + renderSubMenu(display, "Device", _selected, items, SYSDEV_COUNT); + return 500; + } + if (_mode == SYSMODE_DISPLAY) { + char bright_label[24], scroff_label[24], batt_label[24]; + snprintf(bright_label, sizeof(bright_label), "Brightness: %d%%", (int)_task->getBrightness()); + snprintf(scroff_label, sizeof(scroff_label), "Screen off: %lus", + (unsigned long)(_task->getScreenOffMillis() / 1000UL)); + snprintf(batt_label, sizeof(batt_label), "Battery: %s", + _task->getBatteryDisplayMode() == 1 ? "Voltage" : "Percent"); + const char *items[SYSDSP_COUNT]; + items[SYSDSP_BRIGHT] = bright_label; + items[SYSDSP_SCROFF] = scroff_label; + items[SYSDSP_BATT] = batt_label; + items[SYSDSP_WAKE] = _task->getWakeOnMsg() ? "Wake on msg: ON" : "Wake on msg: OFF"; + renderSubMenu(display, "Display", _selected, items, SYSDSP_COUNT); + return 500; + } + if (_mode == SYSMODE_INFO) { + static const char * const kItems[SYSINFO_COUNT] = { "Time", "System Stats", "Radio Stats" }; + renderSubMenu(display, "Info", _selected, kItems, SYSINFO_COUNT); + return 500; + } + if (_mode == SYSMODE_POWER) { + static const char * const kItems[SYSPWR_COUNT] = { "Reboot", "Shutdown" }; + renderSubMenu(display, "Power", _selected, kItems, SYSPWR_COUNT); + return 500; + } + if (_mode == SYSMODE_OFFGRID_CONFIRM) { + renderScreenHeader(display, "Offgrid Mode", 0, 0); + int cx = display.width() / 2; + display.setColor(JoystickDisplay::LIGHT); + display.fillRect(0, kContentY + 2, display.width(), 18); + display.setColor(JoystickDisplay::DARK); + display.drawTextCentered(cx, kContentY + 10, "EMERGENCY USE ONLY"); + display.setColor(JoystickDisplay::GREEN); + display.drawTextCentered(cx, kContentY + 30, "ENTER: enable"); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextCentered(cx, kContentY + 41, "CANCEL: abort"); + return 500; + } + return 500; +} + +bool SystemScreen::handleInput(char c) +{ + if (_mode == SYSMODE_TOP) { + if (handleCommonListNavigation(c, _selected, kSysCatCount)) return true; + if (c == KEY_ENTER || c == KEY_RIGHT) { + _cat_selected = _selected; + _mode = (SystemMode)(_selected + 1); + _selected = 0; + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { _task->gotoHomeScreen(); return true; } + return false; + } + if (_mode == SYSMODE_DEVICE) { + if (handleCommonListNavigation(c, _selected, SYSDEV_COUNT)) return true; + if (c == KEY_ENTER) { + switch (_selected) { + case SYSDEV_BLUETOOTH: + if (_task->isSerialEnabled()) _task->disableSerial(); else _task->enableSerial(); + _task->showAlert(_task->isSerialEnabled() ? "BT enabled" : "BT disabled", 1000); + return true; + case SYSDEV_TELEMETRY: _task->gotoTelemetryScreen(); return true; + case SYSDEV_RENAME: _task->gotoRenameNodeScreen(); return true; + case SYSDEV_BLE_CODE: _task->gotoBLECodeScreen(); return true; + case SYSDEV_BUZZER: + _task->toggleBuzzer(); + _task->showAlert(_task->isBuzzerQuiet() ? "Buzzer off" : "Buzzer on", 1000); + return true; + case SYSDEV_OFFGRID: { + if (_task->isOffgridEnabled()) { + _task->setOffgridMode(false); + _task->showAlert("Offgrid: OFF", 1000); + } else { + _mode = SYSMODE_OFFGRID_CONFIRM; + } + return true; + } + case SYSDEV_LEDS: + _task->toggleLeds(); + _task->showAlert(_task->isLedsDisabled() ? "LEDs: OFF" : "LEDs: ON", 1000); + return true; + case SYSDEV_DFU: { + uint32_t now = k_uptime_get_32(); + if (_dfu_confirm_time != 0 && + (now - _dfu_confirm_time) <= DFU_CONFIRM_WINDOW_MS) { + _task->rebootToDFU(); + } else { + _dfu_confirm_time = now; + _task->showAlert("Press ENTER to confirm", 2500); + } + return true; + } + default: return false; + } + } + if (c == KEY_CANCEL || c == KEY_LEFT) { + _dfu_confirm_time = 0; + _selected = _cat_selected; + _mode = SYSMODE_TOP; + return true; + } + if (c == KEY_HOME) { _dfu_confirm_time = 0; _task->gotoHomeScreen(); return true; } + return false; + } + if (_mode == SYSMODE_DISPLAY) { + if (handleCommonListNavigation(c, _selected, SYSDSP_COUNT)) return true; + if (c == KEY_LEFT || c == KEY_RIGHT) { + int delta = (c == KEY_RIGHT) ? 1 : -1; + if (_selected == SYSDSP_BRIGHT) { _task->adjustBrightness(delta * 5); return true; } + if (_selected == SYSDSP_SCROFF) { _task->adjustScreenOff(delta * 5000L); return true; } + if (c == KEY_LEFT) { _selected = _cat_selected; _mode = SYSMODE_TOP; return true; } + return false; + } + if (c == KEY_ENTER) { + switch (_selected) { + case SYSDSP_BRIGHT: _task->adjustBrightness(5); return true; + case SYSDSP_SCROFF: _task->adjustScreenOff(5000L); return true; + case SYSDSP_BATT: + _task->toggleBatteryDisplayMode(); + _task->showAlert(_task->getBatteryDisplayMode() == 1 ? "Battery: Voltage" : "Battery: Percent", 1000); + return true; + case SYSDSP_WAKE: + _task->toggleWakeOnMsg(); + _task->showAlert(_task->getWakeOnMsg() ? "Wake on msg: ON" : "Wake on msg: OFF", 1000); + return true; + default: return false; + } + } + if (c == KEY_CANCEL) { _selected = _cat_selected; _mode = SYSMODE_TOP; return true; } + if (c == KEY_HOME) { _task->gotoHomeScreen(); return true; } + return false; + } + if (_mode == SYSMODE_INFO) { + if (handleCommonListNavigation(c, _selected, SYSINFO_COUNT)) return true; + if (c == KEY_ENTER) { + switch (_selected) { + case SYSINFO_TIME: _task->gotoSystemTimeScreen(); return true; + case SYSINFO_STATS: _task->gotoStatsScreen(); return true; + case SYSINFO_RADIO: _task->gotoRadioStatsScreen(); return true; + default: return false; + } + } + if (c == KEY_CANCEL || c == KEY_LEFT) { _selected = _cat_selected; _mode = SYSMODE_TOP; return true; } + if (c == KEY_HOME) { _task->gotoHomeScreen(); return true; } + return false; + } + if (_mode == SYSMODE_POWER) { + if (handleCommonListNavigation(c, _selected, SYSPWR_COUNT)) return true; + if (c == KEY_ENTER) { + switch (_selected) { + case SYSPWR_REBOOT: _task->showAlert("Rebooting...", 1500); _task->shutdown(true); return true; + case SYSPWR_SHUTDOWN: _task->showAlert("Shutting down...", 1500); _task->shutdown(false); return true; + default: return false; + } + } + if (c == KEY_CANCEL || c == KEY_LEFT) { _selected = _cat_selected; _mode = SYSMODE_TOP; return true; } + if (c == KEY_HOME) { _task->gotoHomeScreen(); return true; } + return false; + } + if (_mode == SYSMODE_OFFGRID_CONFIRM) { + if (c == KEY_ENTER) { + _task->setOffgridMode(true); + _task->showAlert("Offgrid: ON", 1000); + _mode = SYSMODE_DEVICE; + return true; + } + if (c == KEY_CANCEL || c == KEY_LEFT) { _mode = SYSMODE_DEVICE; return true; } + if (c == KEY_HOME) { _mode = SYSMODE_DEVICE; _task->gotoHomeScreen(); return true; } + return false; + } + return false; +} + +static void formatUnixDateTime(uint32_t t, char *timeBuf, size_t timeLen, + char *dateBuf, size_t dateLen) +{ + uint32_t seconds = t % 60; + uint32_t minutes = (t / 60) % 60; + uint32_t hours = (t / 3600) % 24; + uint32_t days = t / 86400; + uint16_t year = 1970; uint8_t month = 1, day = 1; + + while (true) { + bool leap = (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)); + uint16_t yd = leap ? 366 : 365; + if (days >= yd) { days -= yd; year++; } else break; + } + static const uint8_t kMD[12] = {31,28,31,30,31,30,31,31,30,31,30,31}; + for (int i = 0; i < 12; i++) { + uint8_t md = kMD[i]; + if (i == 1) { + bool leap = (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)); + if (leap) md = 29; + } + if (days >= md) { days -= md; month++; } else { day += (uint8_t)days; break; } + } + snprintf(timeBuf, timeLen, "%02u:%02u:%02u", (unsigned)hours, (unsigned)minutes, (unsigned)seconds); + snprintf(dateBuf, dateLen, "%04u-%02u-%02u", (unsigned)year, (unsigned)month, (unsigned)day); +} + +SystemTimeScreen::SystemTimeScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc) +{ +} + +int SystemTimeScreen::render(JoystickDisplay &display) +{ + renderScreenHeader(display, "Time", 0, 1); + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + + char timeText[16], dateText[16]; + formatUnixDateTime(_rtc->getCurrentTime(), timeText, sizeof(timeText), dateText, sizeof(dateText)); + + const char *labels[4] = { "Time:", "Date:", "TZ:", "Source:" }; + const char *values[4] = { timeText, dateText, "UTC", + _task->getGPSState() ? "GPS" : "App" }; + int y = kContentY + 2; + for (int i = 0; i < 4; i++) { + display.setColor(JoystickDisplay::GREEN); + display.drawTextLeftAlign(0, y, labels[i]); + display.drawTextRightAlign(display.width() - 1, y, values[i]); + y += kMenuLineH; + } + return 500; +} + +bool SystemTimeScreen::handleInput(char c) +{ + if (c == KEY_CANCEL || c == KEY_HOME) { _task->gotoSystemScreen(); return true; } + return false; +} + +RenameNodeScreen::RenameNodeScreen(JoystickUITask *task) + : _task(task), _loaded(false), _cursor(0), _selected_key(0), + _last_press_time(0), _letter_index(0), _last_key(-1), + _kb_mode_letters(true) +{ + memset(_input, 0, sizeof(_input)); +} + +int RenameNodeScreen::render(JoystickDisplay &display) +{ + if (!_loaded) { + NodePrefs *prefs = _task->getPrefs(); + if (prefs) { + strncpy(_input, prefs->node_name, sizeof(_input) - 1); + _input[sizeof(_input) - 1] = '\0'; + } + _cursor = strlen(_input); + _loaded = true; + } + + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + char display_text[36]; + snprintf(display_text, sizeof(display_text), "%s|", _input); + display.drawTextLeftAlign(0, 0, display_text); + + renderT9Keypad(display, getT9KeyLabels(true, true), _selected_key, 12); + return 400; +} + +bool RenameNodeScreen::handleInput(char c) +{ + if (handleT9DirectionalInput(c, _selected_key)) return true; + + if (c == KEY_ENTER) { + if (_selected_key == 3) { /* del */ + if (_cursor > 0) _input[--_cursor] = '\0'; + resetT9State(_last_key, _letter_index, _last_press_time); + return true; + } + if (_selected_key == 7) { /* space */ + if (_cursor < (int)sizeof(_input) - 1) { + _input[_cursor++] = ' '; + _input[_cursor] = '\0'; + } + resetT9State(_last_key, _letter_index, _last_press_time); + return true; + } + if (_selected_key == 11) { /* save */ + while (_cursor > 0 && _input[_cursor - 1] == ' ') _input[--_cursor] = '\0'; + NodePrefs *prefs = _task->getPrefs(); + if (prefs) { + strncpy(prefs->node_name, _input, sizeof(prefs->node_name) - 1); + prefs->node_name[sizeof(prefs->node_name) - 1] = '\0'; + } + resetT9State(_last_key, _letter_index, _last_press_time); + _loaded = false; + _task->showAlert("Node name saved", 1000); + _task->gotoSystemScreen(); + return true; + } + if (_selected_key == 15) { /* back */ + resetT9State(_last_key, _letter_index, _last_press_time); + _loaded = false; + _task->gotoSystemScreen(); + return true; + } + + const char *letters = getT9KeyLetters(true)[_selected_key]; + appendOrCycleT9Char(_input, _cursor, sizeof(_input), letters, _selected_key, + _last_key, _letter_index, _last_press_time); + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { + resetT9State(_last_key, _letter_index, _last_press_time); + _loaded = false; + _task->gotoSystemScreen(); + return true; + } + return false; +} + +BLECodeScreen::BLECodeScreen(JoystickUITask *task) + : _task(task), _editing(false), _digit_sel(0) +{ + memset(_pin_buf, 0, sizeof(_pin_buf)); + NodePrefs *prefs = task->getPrefs(); + if (prefs) snprintf(_pin_buf, sizeof(_pin_buf), "%06lu", (unsigned long)prefs->ble_pin); + else snprintf(_pin_buf, sizeof(_pin_buf), "000000"); +} + +int BLECodeScreen::render(JoystickDisplay &display) +{ + renderScreenHeader(display, "BLE Code", 0, 0); + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + display.drawTextCentered(display.width() / 2, 22, _pin_buf); + + if (_editing) { + /* Underline the selected digit */ + int char_w = display.fontW(); + int total_w = 6 * char_w; + int x = display.width() / 2 - total_w / 2 + _digit_sel * char_w; + display.setColor(JoystickDisplay::YELLOW); + display.drawRect(x, 31, char_w, 2); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, 41, "U/D digit L/R move"); + display.drawTextCentered(display.width() / 2, 52, "ENTER save"); + } else { + display.setColor(JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, 38, "ENTER edit LONG random"); + display.drawTextCentered(display.width() / 2, 49, "CANCEL back"); + } + return 500; +} + +bool BLECodeScreen::handleInput(char c) +{ + if (_editing) { + if (c == KEY_LEFT) { if (_digit_sel > 0) _digit_sel--; return true; } + if (c == KEY_RIGHT) { if (_digit_sel < 5) _digit_sel++; return true; } + if (c == KEY_UP || c == KEY_DOWN) { + char d = _pin_buf[_digit_sel]; + if (d < '0' || d > '9') d = '0'; + int v = d - '0'; + v = (c == KEY_UP) ? (v + 1) % 10 : (v + 9) % 10; + _pin_buf[_digit_sel] = (char)('0' + v); + return true; + } + if (c == KEY_ENTER) { + NodePrefs *prefs = _task->getPrefs(); + if (prefs) { + prefs->ble_pin = (uint32_t)atoi(_pin_buf); + } + _editing = false; + mesh_save_and_restart(); + _task->showAlert("Restarting...", 2000); + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { + _editing = false; + NodePrefs *prefs = _task->getPrefs(); + if (prefs) snprintf(_pin_buf, sizeof(_pin_buf), "%06lu", (unsigned long)prefs->ble_pin); + return true; + } + return false; + } + + if (c == KEY_ENTER_LONG) { + uint32_t pin = 100000UL + (sys_rand32_get() % 900000UL); + NodePrefs *prefs = _task->getPrefs(); + if (prefs) { prefs->ble_pin = pin; } + snprintf(_pin_buf, sizeof(_pin_buf), "%06lu", (unsigned long)pin); + mesh_save_and_restart(); + _task->showAlert("Restarting...", 2000); + return true; + } + if (c == KEY_ENTER || c == KEY_RIGHT || c == KEY_LEFT) { + _editing = true; + _digit_sel = 0; + NodePrefs *prefs = _task->getPrefs(); + if (prefs) snprintf(_pin_buf, sizeof(_pin_buf), "%06lu", (unsigned long)prefs->ble_pin); + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { _task->gotoSystemScreen(); return true; } + return false; +} + +static const int ADVERT_ITEM_SETTINGS = 0; +static const int ADVERT_ITEM_SEND_ZEROHOP = 1; +static const int ADVERT_ITEM_SEND_FLOOD = 2; +static const int ADVERT_ITEM_BASE_COUNT = 3; + +AdvertScreen::AdvertScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), _selected(0), _last_rendered_selected(-1), + _marquee_start_ms(0), _settings_open(false), + _recent_advert_count(0), _recent_refresh_at(0) +{ + memset(_recent_adverts, 0, sizeof(_recent_adverts)); +} + +void AdvertScreen::refreshRecentAdverts() +{ + uint32_t now = k_uptime_get_32(); + if (_recent_refresh_at != 0 && (now - _recent_refresh_at) < 700) return; + _recent_refresh_at = now; + + int total = _task->getRecentlyHeard(_recent_adverts, 16); + _recent_advert_count = 0; + for (int i = 0; i < total; i++) { + if (_recent_adverts[i].recv_timestamp == 0 || _recent_adverts[i].name[0] == '\0') continue; + if (i != _recent_advert_count) _recent_adverts[_recent_advert_count] = _recent_adverts[i]; + _recent_advert_count++; + } +} + +int AdvertScreen::getRecentAdvertCount() const { return _recent_advert_count; } + +bool AdvertScreen::getRecentAdvertByIndex(int idx, struct AdvertPath &path) const +{ + if (idx < 0 || idx >= _recent_advert_count) return false; + path = _recent_adverts[idx]; + return true; +} + +int AdvertScreen::render(JoystickDisplay &display) +{ + if (_settings_open) { + renderScreenHeader(display, "Advert settings", 0, 0); + display.setColor(JoystickDisplay::GREEN); + display.drawTextCentered(display.width() / 2, 28, "No settings yet"); + return 1000; + } + + refreshRecentAdverts(); + int recent_count = getRecentAdvertCount(); + int item_count = ADVERT_ITEM_BASE_COUNT + recent_count; + if (_selected >= item_count) _selected = item_count - 1; + if (_selected < 0) _selected = 0; + + if (_selected != _last_rendered_selected) { + _marquee_start_ms = k_uptime_get_32(); + _last_rendered_selected = _selected; + } + + renderScreenHeader(display, "Advert", _selected, item_count); + int start = computeListStart(_selected, item_count); + int y = kContentY + 1; + for (int i = start; i < item_count && i < start + UI_RECENT_LIST_SIZE; i++) { + char line[48]; + if (i == ADVERT_ITEM_SETTINGS) { + snprintf(line, sizeof(line), "Settings"); + } else if (i == ADVERT_ITEM_SEND_ZEROHOP) { + snprintf(line, sizeof(line), "Send advert 0hop"); + } else if (i == ADVERT_ITEM_SEND_FLOOD) { + snprintf(line, sizeof(line), "Send advert flood"); + } else { + AdvertPath path; + if (!getRecentAdvertByIndex(i - ADVERT_ITEM_BASE_COUNT, path)) continue; + int hops = (int)(path.path_len & 63); + char suffix[16]; + if (hops <= 0) snprintf(suffix, sizeof(suffix), " [direct]"); + else snprintf(suffix, sizeof(suffix), " [%dh]", hops); + snprintf(line, sizeof(line), "%s%s", path.name, suffix); + } + renderMenuListText(display, y, i == _selected, line, true, 450, _marquee_start_ms); + if (i == ADVERT_ITEM_SEND_FLOOD) + display.drawRect(0, y + kLineH, display.width(), 1); + y += kMenuLineH; + } + return 500; +} + +bool AdvertScreen::handleInput(char key) +{ + if (_settings_open) { + if (key == KEY_CANCEL || key == KEY_HOME || key == KEY_ENTER) { + _settings_open = false; + return true; + } + return false; + } + + int item_count = ADVERT_ITEM_BASE_COUNT + getRecentAdvertCount(); + if (item_count < ADVERT_ITEM_BASE_COUNT) item_count = ADVERT_ITEM_BASE_COUNT; + if (handleCommonListNavigation(key, _selected, item_count)) return true; + + if (key == KEY_ENTER) { + const char *node_name = _task->getPrefs() ? _task->getPrefs()->node_name : "Node"; + auto *mesh = _task->getMesh(); + + if (_selected == ADVERT_ITEM_SETTINGS) { + _settings_open = true; + return true; + } + if (_selected == ADVERT_ITEM_SEND_ZEROHOP) { + mesh::Packet *pkt = mesh->createSelfAdvert(node_name); + if (pkt) { mesh->sendZeroHop(pkt); _task->showAlert("Advert 0hop sent", 1000); } + else { _task->showAlert("Advert failed", 1000); } + return true; + } + if (_selected == ADVERT_ITEM_SEND_FLOOD) { + mesh::Packet *pkt = mesh->createSelfAdvert(node_name); + if (pkt) { mesh->sendFlood(pkt); _task->showAlert("Advert flood sent", 1000); } + else { _task->showAlert("Advert failed", 1000); } + return true; + } + + AdvertPath path; + if (getRecentAdvertByIndex(_selected - ADVERT_ITEM_BASE_COUNT, path)) { + ContactInfo *c = mesh->lookupContactByPubKey(path.pubkey_prefix, + sizeof(path.pubkey_prefix)); + if (c) _task->gotoContactForAdvert(path.pubkey_prefix, sizeof(path.pubkey_prefix)); + else _task->showAlert("Not in contacts", 1000); + } + return true; + } + if (key == KEY_CANCEL || key == KEY_HOME) { _task->gotoHomeScreen(); return true; } + return false; +} + +#define GPS_SPEED_MIN_M 2.0f /* min move to count as motion (m) */ +#define GPS_HEADING_MIN_M 5.0f /* min move to update heading (m) */ +#define GPS_MAX_SPEED_KMH 300.0f +#define GPS_HEADING_HOLD_MS 4000 + +GPSSettingsScreen::GPSSettingsScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), _selected(0), + _have_prev_fix(false), _prev_lat_e6(0), _prev_lon_e6(0), + _last_sample_ms(0), _speed_kmh(0.0f), _heading_deg(0.0f), + _heading_valid(false), _heading_hold_until(0) +{ +} + +void GPSSettingsScreen::poll() +{ + if (!_task->isGPSAvailable() || !_task->getGPSState()) return; + struct gps_position pos; + gps_get_position(&pos); + if (!pos.valid) return; + + uint32_t now = k_uptime_get_32(); + if (now - _last_sample_ms < 1000) return; + + int32_t lat = (int32_t)(pos.latitude_ndeg / 1000LL); + int32_t lon = (int32_t)(pos.longitude_ndeg / 1000LL); + + if (!_have_prev_fix) { + _prev_lat_e6 = lat; _prev_lon_e6 = lon; + _have_prev_fix = true; _last_sample_ms = now; + return; + } + + float dt = (now - _last_sample_ms) / 1000.0f; + float dist = gpsDistanceM(_prev_lat_e6, _prev_lon_e6, lat, lon); + + if (dist >= GPS_SPEED_MIN_M && dt > 0.0f) { + float v = (dist / dt) * 3.6f; + if (v <= GPS_MAX_SPEED_KMH) _speed_kmh = _speed_kmh * 0.7f + v * 0.3f; + } else { + _speed_kmh *= 0.85f; + if (_speed_kmh < 0.5f) _speed_kmh = 0.0f; + } + + if (dist >= GPS_HEADING_MIN_M) { + float bearing = gpsBearingDeg(_prev_lat_e6, _prev_lon_e6, lat, lon); + _heading_deg = _heading_deg * 0.65f + bearing * 0.35f; + _heading_valid = true; + _heading_hold_until = now + GPS_HEADING_HOLD_MS; + } else if (now > _heading_hold_until) { + _heading_valid = false; + } + + _prev_lat_e6 = lat; _prev_lon_e6 = lon; + _last_sample_ms = now; +} + +int GPSSettingsScreen::render(JoystickDisplay &display) +{ + bool gps_enabled = _task->getGPSState(); + + char gps_state_line[24]; + char satellites_line[32]; + char lat_lon_line[40]; + char speed_line[32]; + + snprintf(gps_state_line, sizeof(gps_state_line), "GPS: %s", + gps_enabled ? "ON" : "OFF"); + + bool has_fix = false; + + if (!_task->isGPSAvailable()) { + snprintf(satellites_line, sizeof(satellites_line), "No GPS"); + snprintf(lat_lon_line, sizeof(lat_lon_line), "Lat | Lon"); + snprintf(speed_line, sizeof(speed_line), "Speed | Compass"); + } else { + struct gps_position pos; + gps_get_position(&pos); + + int sat = (int)pos.satellites; + const char *fix_str; + if (gps_enabled && pos.valid) { + fix_str = (sat >= 4) ? "3D FIX" : (sat >= 3) ? "2D FIX" : "NO FIX"; + if (sat >= 3) has_fix = true; + } else { + fix_str = "NO FIX"; + } + + snprintf(satellites_line, sizeof(satellites_line), "Sat: %d (%s)", sat, fix_str); + + if (has_fix) { + double lat = (double)(pos.latitude_ndeg / 1000000LL) / 1000.0; + double lon = (double)(pos.longitude_ndeg / 1000000LL) / 1000.0; + snprintf(lat_lon_line, sizeof(lat_lon_line), "%.4f%c %.4f%c", + fabs(lat), lat >= 0 ? 'N' : 'S', + fabs(lon), lon >= 0 ? 'E' : 'W'); + + if (_heading_valid) { + snprintf(speed_line, sizeof(speed_line), "%.1f km/h | %s %.0f", + (double)_speed_kmh, compassDir(_heading_deg), (double)_heading_deg); + } else { + snprintf(speed_line, sizeof(speed_line), "%.1f km/h", (double)_speed_kmh); + } + } else { + snprintf(lat_lon_line, sizeof(lat_lon_line), "Lat | Lon"); + snprintf(speed_line, sizeof(speed_line), "Speed | Compass"); + } + } + + const char *lines[4] = { gps_state_line, satellites_line, lat_lon_line, speed_line }; + + renderScreenHeader(display, "GPS", 0, 1); + display.setTextSize(1); + + int y = kContentY + 2; + for (int i = 0; i < 4; i++) { + renderMenuListText(display, y, i == 0, lines[i]); + y += kMenuLineH; + } + + return has_fix ? 1000 : 2000; +} + +bool GPSSettingsScreen::handleInput(char key) +{ + if (key == KEY_ENTER) { + _task->toggleGPS(); + _task->showAlert(_task->getGPSState() ? "GPS enabled" : "GPS disabled", 1000); + return true; + } + if (key == KEY_CANCEL || key == KEY_HOME) { _task->gotoHomeScreen(); return true; } + return false; +} + +RadioStatsScreen::RadioStatsScreen(JoystickUITask *task) + : _task(task) +{ +} + +int RadioStatsScreen::render(JoystickDisplay &display) +{ + renderScreenHeader(display, "Radio Stats", 0, 0); + display.setTextSize(1); + + char line[40]; + int y = kContentY; + + snprintf(line, sizeof(line), "Floor: %d dBm", (int)_task->getNoiseFloor()); + display.setColor(JoystickDisplay::YELLOW); + display.drawTextLeftAlign(0, y, line); + y += kMenuLineH; + + snprintf(line, sizeof(line), "Rx: %lu Tx: %lu", + (unsigned long)_task->getPktRecv(), + (unsigned long)_task->getPktSent()); + display.setColor(JoystickDisplay::GREEN); + display.drawTextLeftAlign(0, y, line); + y += kMenuLineH; + + uint32_t errs = _task->getPktErrors(); + if (errs > 0) { + snprintf(line, sizeof(line), "Err: %lu", (unsigned long)errs); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(0, y, line); + } + return 1000; +} + +bool RadioStatsScreen::handleInput(char key) +{ + if (key == KEY_CANCEL || key == KEY_HOME) { _task->gotoSystemScreen(); return true; } + return false; +} + +StatsScreen::StatsScreen(JoystickUITask *task) + : _task(task) +{ +} + +int StatsScreen::render(JoystickDisplay &display) +{ + renderScreenHeader(display, "Stats", 0, 0); + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + + char line[32]; + int y = kContentY + 2; + + uint32_t uptime_s = k_uptime_get_32() / 1000UL; + if (uptime_s < 60) + snprintf(line, sizeof(line), "Uptime: %us", (unsigned)uptime_s); + else if (uptime_s < 3600) + snprintf(line, sizeof(line), "Uptime: %um", (unsigned)(uptime_s / 60)); + else + snprintf(line, sizeof(line), "Uptime: %uh", (unsigned)(uptime_s / 3600)); + display.drawTextLeftAlign(0, y, line); + y += kMenuLineH; + + snprintf(line, sizeof(line), "Contacts: %d/%d", + _task->getMesh()->getNumContacts(), MAX_CONTACTS); + display.drawTextLeftAlign(0, y, line); + y += kMenuLineH; + + snprintf(line, sizeof(line), "Msgs: %d", _task->getStoredMsgCount()); + display.drawTextLeftAlign(0, y, line); + + return 1000; +} + +bool StatsScreen::handleInput(char c) +{ + if (c == KEY_CANCEL || c == KEY_HOME) { _task->gotoSystemScreen(); return true; } + return false; +} + +TelemetryScreen::TelemetryScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), _selected(0) +{ +} + +int TelemetryScreen::render(JoystickDisplay &display) +{ + static const char * const kLabels[3] = { "Allow req", "Locations", "Env" }; + static const char * const kStates[3] = { "no", "contacts", "yes" }; + const int kItemCount = 3; + char line[32]; + + renderScreenHeader(display, "Telemetry", _selected, kItemCount); + NodePrefs *prefs = _task->getPrefs(); + uint8_t values[3] = { 0, 0, 0 }; + if (prefs) { + values[0] = prefs->telemetry_mode_base; + values[1] = prefs->telemetry_mode_loc; + values[2] = prefs->telemetry_mode_env; + } + + int y = kContentY + 2; + for (int i = 0; i < kItemCount; i++) { + snprintf(line, sizeof(line), "%s: %s", kLabels[i], kStates[values[i] % kItemCount]); + renderMenuListText(display, y, i == _selected, line); + y += kMenuLineH; + } + return 500; +} + +bool TelemetryScreen::handleInput(char c) +{ + const int kItemCount = 3; + static const char * const kStates[3] = { "no", "contacts", "yes" }; + + if (handleCommonListNavigation(c, _selected, kItemCount)) return true; + + if (c == KEY_ENTER) { + NodePrefs *prefs = _task->getPrefs(); + if (prefs) { + uint8_t *field = nullptr; + const char *label = ""; + if (_selected == 0) { field = &prefs->telemetry_mode_base; label = "Allow req"; } + else if (_selected == 1) { field = &prefs->telemetry_mode_loc; label = "Locations"; } + else if (_selected == 2) { field = &prefs->telemetry_mode_env; label = "Env"; } + if (field) { + *field = (*field + 1) % kItemCount; + char notice[32]; + snprintf(notice, sizeof(notice), "%s: %s", label, kStates[*field]); + _task->showAlert(notice, 1000); + } + } + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { _task->gotoSystemScreen(); return true; } + return false; +} diff --git a/zephcore/helpers/ui-joystick/screens/tools.cpp b/zephcore/helpers/ui-joystick/screens/tools.cpp new file mode 100644 index 0000000..a7bead5 --- /dev/null +++ b/zephcore/helpers/ui-joystick/screens/tools.cpp @@ -0,0 +1,591 @@ +/* + * ZephCore - Joystick UI Tools Screen + * Copyright (c) 2026 ZephCore + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "../joystick_screens.h" +#include "../joystick_ui_task.h" +#include "../joystick_ui_hooks.h" +#include "screen_helpers.h" +#include +#include +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM +#include +#endif +#include +#include +#include +#include + +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM +enum ToolsItem { TOOLS_REPEATERS=0, TOOLS_STOPWATCH, TOOLS_COUNTDOWN, TOOLS_SNAKE, TOOLS_DOOM, TOOLS_COUNT }; +static const char * const kToolsItems[TOOLS_COUNT] = { + "Discover repeaters", "Stopwatch", "Countdown", "Snake", "Doom" +}; +#else +enum ToolsItem { TOOLS_REPEATERS=0, TOOLS_STOPWATCH, TOOLS_COUNTDOWN, TOOLS_SNAKE, TOOLS_COUNT }; +static const char * const kToolsItems[TOOLS_COUNT] = { + "Discover repeaters", "Stopwatch", "Countdown", "Snake" +}; +#endif + +ToolsScreen::ToolsScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), _selected(0) +{ +} + +int ToolsScreen::render(JoystickDisplay &display) +{ + renderScreenHeader(display, "Tools", _selected, TOOLS_COUNT); + int start = computeListStart(_selected, TOOLS_COUNT); + int y = kContentY + 2; + for (int i = start; i < TOOLS_COUNT && i < start + UI_RECENT_LIST_SIZE; i++) { + renderMenuListText(display, y, i == _selected, kToolsItems[i]); + y += kMenuLineH; + } + return 500; +} + +bool ToolsScreen::handleInput(char key) +{ + if (handleCommonListNavigation(key, _selected, TOOLS_COUNT)) return true; + if (key == KEY_ENTER) { + switch (_selected) { + case TOOLS_REPEATERS: _task->gotoRepeatersScreen(); break; + case TOOLS_STOPWATCH: _task->gotoStopwatchScreen(); break; + case TOOLS_COUNTDOWN: _task->gotoCountdownScreen(); break; + case TOOLS_SNAKE: _task->gotoSnakeScreen(); break; +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM + case TOOLS_DOOM: _task->gotoDoomScreen(); break; +#endif + default: return false; + } + return true; + } + if (key == KEY_CANCEL || key == KEY_HOME) { _task->gotoHomeScreen(); return true; } + return false; +} + +#define REPEATER_SCAN_WINDOW_MS 30000 + +static const int RMODE_LIST = 0; +static const int RMODE_MODAL = 1; + +/* Statically allocated scan state shared across the one screen instance */ +static uint32_t s_scan_until; +static bool s_scan_sent; + +RepeatersScreen::RepeatersScreen(JoystickUITask *task, mesh::RTCClock *rtc) + : _task(task), _rtc(rtc), _selected(0), + _mode(RMODE_LIST), _last_sel(-1), _marquee_start_ms(0), _modal_idx(0) +{ + s_scan_until = 0; s_scan_sent = false; +} + +void RepeatersScreen::poll() +{ + if (s_scan_sent) return; + s_scan_sent = true; + s_scan_until = k_uptime_get_32() + REPEATER_SCAN_WINDOW_MS; + _task->clearDiscoverSignals(); + + uint8_t data[10]; + data[0] = CTL_TYPE_NODE_DISCOVER_REQ; + data[1] = (1 << ADV_TYPE_REPEATER); + _task->getMesh()->getRNG()->random(&data[2], 4); + uint32_t since = 0; + memcpy(&data[6], &since, 4); + + mesh::Packet *pkt = _task->getMesh()->createControlData(data, sizeof(data)); + if (pkt) { + _task->getMesh()->sendZeroHop(pkt); + ui_signal_tx(); + } +} + +int RepeatersScreen::render(JoystickDisplay &display) +{ + /* Modal overlay */ + if (_mode == RMODE_MODAL) { + int fw = display.fontW(), fh = display.fontH(); + int w = display.width(), h = display.height(); + + uint8_t pubkey[PUB_KEY_SIZE]; + int8_t snr, snr_remote; uint8_t path_len; + if (!_task->getDiscoverByIdx(_modal_idx, pubkey, snr, snr_remote, &path_len)) { + _mode = RMODE_LIST; + return 100; + } + auto *mesh = _task->getMesh(); + ContactInfo *ci = mesh->lookupContactByPubKey(pubkey, PUB_KEY_SIZE); + + char title[32]; + if (ci && ci->name[0]) { + display.translateUTF8ToBlocks(title, ci->name, sizeof(title)); + } else { + snprintf(title, sizeof(title), "!%02X%02X%02X%02X", + pubkey[0], pubkey[1], pubkey[2], pubkey[3]); + } + renderScrollingScreenHeader(display, title, 0, 0, _marquee_start_ms); + + int pad = 4; + int box_h = fh + 8; + int box_w = w - 2 * pad; + int box_x = pad, box_y = (h - box_h) / 2; + + mc_display_fill_rect(box_x, box_y, box_w, box_h); + mc_display_invert_rect(box_x, box_y, box_w, box_h); + display.setColor(JoystickDisplay::WHITE); + display.drawRect(box_x, box_y, box_w, box_h); + + int cx = box_x + box_w / 2; + int y0 = box_y + (box_h - fh) / 2; + + char line1[24]; + snprintf(line1, sizeof(line1), "RX %+ddB TX %+ddB", (int)snr, (int)snr_remote); + mc_display_text(cx - (int)strlen(line1) * fw / 2, y0, line1, false); + + (void)path_len; + return 1000; + } + + /* Repeater list */ + bool scanning = (k_uptime_get_32() < s_scan_until); + int count = _task->getDiscoverCount(); + + renderScreenHeader(display, "Repeaters", _selected, count); + if (count == 0) { + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + display.drawTextCentered(display.width() / 2, 24, + scanning ? "Listening..." : "None found"); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, display.height() - 10, + scanning ? "Scanning..." : "Long ENTER rescan CANCEL back"); + return 1000; + } + + if (_selected >= count) _selected = count - 1; + if (_selected < 0) _selected = 0; + + if (_selected != _last_sel) { + _marquee_start_ms = k_uptime_get_32(); + _last_sel = _selected; + } + + int footer_y = display.height() - 12; + int max_vis = (footer_y - kContentY) / kMenuLineH; + if (max_vis < 1) max_vis = 1; + + int start = computeListStart(_selected, count, max_vis); + auto *mesh = _task->getMesh(); + int y = kContentY; + + for (int i = start; i < count && i < start + max_vis; i++) { + uint8_t pubkey[PUB_KEY_SIZE]; + int8_t snr, snr_remote; uint8_t path_len; + if (!_task->getDiscoverByIdx(i, pubkey, snr, snr_remote, &path_len)) continue; + + ContactInfo *ci = mesh->lookupContactByPubKey(pubkey, PUB_KEY_SIZE); + char label[52]; + char hop_label[12]; + formatHopCount(path_len, hop_label, sizeof(hop_label)); + if (ci && ci->name[0]) { + char name[32]; + display.translateUTF8ToBlocks(name, ci->name, sizeof(name)); + snprintf(label, sizeof(label), "%s [%s]", name, hop_label); + } else { + snprintf(label, sizeof(label), "!%02X%02X%02X%02X [%s]", + pubkey[0], pubkey[1], pubkey[2], pubkey[3], hop_label); + } + renderMenuListText(display, y, i == _selected, label, true, 450, _marquee_start_ms); + y += kMenuLineH; + } + + display.setColor(JoystickDisplay::LIGHT); + display.drawTextCentered(display.width() / 2, display.height() - 10, + scanning ? "Scanning..." : ""); + return scanning ? 400 : 1000; +} + +bool RepeatersScreen::handleInput(char c) +{ + /* Modal input */ + if (_mode == RMODE_MODAL) { + if (c == KEY_ENTER) { + uint8_t pubkey[PUB_KEY_SIZE]; + int8_t snr_unused, snr_remote_unused; + if (_task->getDiscoverByIdx(_modal_idx, pubkey, snr_unused, snr_remote_unused)) { + ContactInfo *ci = _task->getMesh()->lookupContactByPubKey(pubkey, PUB_KEY_SIZE); + if (ci) { + _mode = RMODE_LIST; + _task->gotoContactForAdvert(pubkey, 4); + } else if (_task->addRepeaterContact(pubkey)) { + _mode = RMODE_LIST; + _task->showAlert("Added!", 1000); + } else { + _mode = RMODE_LIST; + _task->showAlert("Failed to add", 1000); + } + } else { + _mode = RMODE_LIST; + } + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { _mode = RMODE_LIST; return true; } + return true; + } + + /* List input */ + int count = _task->getDiscoverCount(); + if (c == KEY_TO_TOP) { _selected = 0; return true; } + if (c == KEY_TO_BOTTOM) { _selected = count > 0 ? count - 1 : 0; return true; } + if (c == KEY_UP) { if (_selected > 0) _selected--; return true; } + if (c == KEY_DOWN) { if (_selected < count - 1) _selected++; return true; } + if (c == KEY_ENTER) { + if (_selected < count) { + _modal_idx = _selected; + _marquee_start_ms = k_uptime_get_32(); + _mode = RMODE_MODAL; + } + return true; + } + if (c == KEY_ENTER_LONG) { + s_scan_sent = false; s_scan_until = 0; _selected = 0; + _task->showAlert("Rescanning...", 800); + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { _task->gotoToolsScreen(); return true; } + return false; +} + +CountdownScreen::CountdownScreen(JoystickUITask *task) + : _task(task), _running(false), _end_ms(0), _set_seconds(60), + _edit_field(0), _alarmed(false) +{ +} + +void CountdownScreen::poll() +{ + if (!_running) return; + if (k_uptime_get_32() >= _end_ms) { + _running = false; + _alarmed = true; + _task->playCountdownAlarm(); + _task->showAlert("Countdown done!", 1000); + } +} + +int CountdownScreen::render(JoystickDisplay &display) +{ + renderScreenHeader(display, "Countdown", 0, 0); + + int remaining = _set_seconds; + if (_running) { + uint32_t now = k_uptime_get_32(); + if (now < _end_ms) { + remaining = (int)((_end_ms - now) / 1000UL) + 1; + if (remaining < 0) remaining = 0; + } else { + remaining = 0; + } + } + + int mins = remaining / 60; + int secs = remaining % 60; + char time_buf[24]; + snprintf(time_buf, sizeof(time_buf), "%02d:%02d", mins, secs); + + display.setTextSize(1); + display.setColor(_alarmed ? JoystickDisplay::YELLOW : JoystickDisplay::GREEN); + display.drawTextCentered(display.width() / 2, 22, time_buf); + + return _running ? 200 : 500; +} + +bool CountdownScreen::handleInput(char c) +{ + if (c == KEY_ENTER) { + if (_running) { + _running = false; + } else { + _end_ms = k_uptime_get_32() + (uint32_t)_set_seconds * 1000UL; + _running = true; + _alarmed = false; + } + return true; + } + if (!_running && c == KEY_UP) { + _set_seconds += 10; + if (_set_seconds > 3600) _set_seconds = 3600; + _alarmed = false; + return true; + } + if (!_running && c == KEY_DOWN) { + _set_seconds -= 10; + if (_set_seconds < 10) _set_seconds = 10; + _alarmed = false; + return true; + } + if (c == KEY_ENTER_LONG) { + _running = false; + _alarmed = false; + _set_seconds = 60; + _end_ms = 0; + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { _task->gotoToolsScreen(); return true; } + return false; +} + +StopwatchScreen::StopwatchScreen(JoystickUITask *task) + : _task(task), _running(false), _start_ms(0), _elapsed_ms(0), + _laps(0), _scroll(0) +{ + memset(_lap_times, 0, sizeof(_lap_times)); +} + +void StopwatchScreen::poll() { /* no autonomous action needed */ } + +int StopwatchScreen::render(JoystickDisplay &display) +{ + renderScreenHeader(display, "Stopwatch", 0, 0); + + uint32_t elapsed_ms = _elapsed_ms; + if (_running) elapsed_ms += (k_uptime_get_32() - _start_ms); + + uint32_t total_secs = elapsed_ms / 1000UL; + uint32_t mins = total_secs / 60UL; + uint32_t secs = total_secs % 60UL; + + char time_buf[24]; + snprintf(time_buf, sizeof(time_buf), "%02lu:%02lu", (unsigned long)mins, (unsigned long)secs); + display.setTextSize(1); + display.setColor(JoystickDisplay::GREEN); + display.drawTextCentered(display.width() / 2, 20, time_buf); + + /* Show lap times */ + if (_laps > 0) { + int y = 32; + for (int i = _scroll; i < _laps && y < display.height() - 1; i++) { + char lap_buf[32]; + uint32_t lt = _lap_times[i] / 1000UL; + snprintf(lap_buf, sizeof(lap_buf), "Lap %d: %02lu:%02lu", i + 1, + (unsigned long)(lt / 60UL), (unsigned long)(lt % 60UL)); + display.setColor(JoystickDisplay::LIGHT); + display.drawTextLeftAlign(0, y, lap_buf); + y += kLineH; + } + } + return _running ? 100 : 500; +} + +bool StopwatchScreen::handleInput(char c) +{ + if (c == KEY_ENTER) { + if (_running) { + _elapsed_ms += k_uptime_get_32() - _start_ms; + _running = false; + } else { + _start_ms = k_uptime_get_32(); + _running = true; + } + return true; + } + if (c == KEY_ENTER_LONG) { + if (_running && _laps < 10) { + /* Record lap */ + _lap_times[_laps++] = _elapsed_ms + (k_uptime_get_32() - _start_ms); + if (_laps > 4) _scroll = _laps - 4; + } else { + _running = false; + _elapsed_ms = 0; + _start_ms = 0; + _laps = 0; + _scroll = 0; + } + return true; + } + if (c == KEY_UP && _scroll > 0) { _scroll--; return true; } + if (c == KEY_DOWN && _scroll < _laps - 1) { _scroll++; return true; } + if (c == KEY_CANCEL || c == KEY_HOME) { _task->gotoToolsScreen(); return true; } + return false; +} + +/* Game layout */ +#define SNAKE_HEADER 10 /* y offset for game area */ +#define SNAKE_CELL 6 /* pixels per cell */ +#define SNAKE_TICK_MS 200 /* move speed */ +#define SNAKE_MAX_LEN (SnakeScreen::GRID_W * SnakeScreen::GRID_H) + +/* Game state codes stored in _score (negative = special) */ +#define STATE_READY 0 +#define STATE_PLAYING 1 +#define STATE_PAUSED 2 +#define STATE_OVER 3 + +static int8_t s_dir_x = 1; /* current direction */ +static int8_t s_dir_y = 0; +static int8_t s_ndir_x = 1; /* queued next direction */ +static int8_t s_ndir_y = 0; +static int s_state = STATE_READY; + +SnakeScreen::SnakeScreen(JoystickUITask *task) + : _task(task), _snake_len(0), + _food_x(0), _food_y(0), + _next_move(0), _score(0) +{ + memset(_snake_x, 0, sizeof(_snake_x)); + memset(_snake_y, 0, sizeof(_snake_y)); + reset(); +} + +void SnakeScreen::reset() +{ + int cx = GRID_W / 2, cy = GRID_H / 2; + _snake_len = 3; + _snake_x[0] = cx; _snake_y[0] = cy; + _snake_x[1] = cx - 1; _snake_y[1] = cy; + _snake_x[2] = cx - 2; _snake_y[2] = cy; + s_dir_x = 1; s_dir_y = 0; + s_ndir_x = 1; s_ndir_y = 0; + _score = 0; + s_state = STATE_READY; + _next_move = 0; + placeFood(); +} + +void SnakeScreen::placeFood() +{ + for (int tries = 0; tries < 200; tries++) { + int8_t x = (int8_t)(sys_rand32_get() % GRID_W); + int8_t y = (int8_t)(sys_rand32_get() % GRID_H); + bool hit = false; + for (int i = 0; i < _snake_len; i++) { + if (_snake_x[i] == x && _snake_y[i] == y) { hit = true; break; } + } + if (!hit) { _food_x = x; _food_y = y; return; } + } + _food_x = 0; _food_y = 0; +} + +static bool headHitsBody(const int8_t *sx, const int8_t *sy, int len) +{ + for (int i = 1; i < len; i++) { + if (sx[0] == sx[i] && sy[0] == sy[i]) return true; + } + return false; +} + +void SnakeScreen::poll() +{ + if (s_state != STATE_PLAYING) return; + uint32_t now = k_uptime_get_32(); + if (now < _next_move) return; + _next_move = now + SNAKE_TICK_MS; + + s_dir_x = s_ndir_x; s_dir_y = s_ndir_y; + + int8_t nx = _snake_x[0] + s_dir_x; + int8_t ny = _snake_y[0] + s_dir_y; + + if (nx < 0 || ny < 0 || nx >= GRID_W || ny >= GRID_H) { + s_state = STATE_OVER; return; + } + + bool eat = (nx == _food_x && ny == _food_y); + if (eat && _snake_len < SNAKE_MAX_LEN) _snake_len++; + + for (int i = _snake_len - 1; i > 0; i--) { + _snake_x[i] = _snake_x[i - 1]; + _snake_y[i] = _snake_y[i - 1]; + } + _snake_x[0] = nx; _snake_y[0] = ny; + + if (headHitsBody(_snake_x, _snake_y, _snake_len)) { + s_state = STATE_OVER; return; + } + if (eat) { _score++; placeFood(); } +} + +int SnakeScreen::render(JoystickDisplay &display) +{ + char title[24]; + snprintf(title, sizeof(title), "SNAKE Score:%d", _score); + display.setColor(JoystickDisplay::GREEN); + display.drawTextLeftAlign(0, 0, title); + display.drawRect(0, SNAKE_HEADER - 1, display.width(), 1); + + /* Snake body */ + display.setColor(JoystickDisplay::GREEN); + for (int i = 0; i < _snake_len; i++) { + display.fillRect(_snake_x[i] * SNAKE_CELL, + SNAKE_HEADER + _snake_y[i] * SNAKE_CELL, + SNAKE_CELL, SNAKE_CELL); + } + /* Food (hollow center) */ + display.fillRect(_food_x * SNAKE_CELL + 1, + SNAKE_HEADER + _food_y * SNAKE_CELL + 1, + SNAKE_CELL - 2, SNAKE_CELL - 2); + + display.setColor(JoystickDisplay::LIGHT); + if (s_state == STATE_READY) { + display.drawTextCentered(display.width() / 2, 28, "ENTER to start"); + } else if (s_state == STATE_PAUSED) { + display.drawTextCentered(display.width() / 2, 28, "PAUSED"); + } else if (s_state == STATE_OVER) { + display.drawTextCentered(display.width() / 2, 28, "GAME OVER"); + display.drawTextCentered(display.width() / 2, 39, "ENTER restart"); + } + return 50; +} + +bool SnakeScreen::handleInput(char c) +{ + if (c == KEY_UP && s_dir_y == 0) { s_ndir_x = 0; s_ndir_y = -1; return true; } + if (c == KEY_DOWN && s_dir_y == 0) { s_ndir_x = 0; s_ndir_y = 1; return true; } + if (c == KEY_LEFT && s_dir_x == 0) { s_ndir_x = -1; s_ndir_y = 0; return true; } + if (c == KEY_RIGHT && s_dir_x == 0) { s_ndir_x = 1; s_ndir_y = 0; return true; } + if (c == KEY_ENTER) { + if (s_state == STATE_READY || s_state == STATE_OVER) { + reset(); s_state = STATE_PLAYING; _next_move = k_uptime_get_32() + SNAKE_TICK_MS; + } else if (s_state == STATE_PLAYING) { + s_state = STATE_PAUSED; + } else if (s_state == STATE_PAUSED) { + s_state = STATE_PLAYING; _next_move = k_uptime_get_32() + SNAKE_TICK_MS; + } + return true; + } + if (c == KEY_CANCEL || c == KEY_HOME) { _task->gotoToolsScreen(); return true; } + return false; +} + +#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM +/* ===== DoomScreen ===== */ + +DoomScreen::DoomScreen(JoystickUITask *task) : _task(task) {} + +void DoomScreen::poll() +{ + if (!doom_game_is_running()) { + doom_game_start(); + } +} + +int DoomScreen::render(JoystickDisplay &display) +{ + (void)display; + return 5000; +} + +bool DoomScreen::handleInput(char c) +{ + if (c == KEY_CANCEL || c == KEY_HOME) { + doom_game_stop(); + _task->gotoToolsScreen(); + return true; + } + return true; +} +#endif diff --git a/zephcore/helpers/ui/display.c b/zephcore/helpers/ui/display.c index 763e410..7ca721d 100644 --- a/zephcore/helpers/ui/display.c +++ b/zephcore/helpers/ui/display.c @@ -451,6 +451,17 @@ void mc_display_hline(int x, int y, int w) cfb_draw_line(disp_dev, &start, &end); } +void mc_display_invert_rect(int x, int y, int w, int h) +{ + if (!disp_initialized) { + return; + } + cfb_invert_area(disp_dev, + (uint16_t)(x + DISP_INSET), (uint16_t)(y + DISP_INSET), + (uint16_t)w, (uint16_t)h + ); +} + void mc_display_xbm(int x, int y, const uint8_t *data, int w, int h) { if (!disp_initialized || !data) { @@ -507,6 +518,13 @@ void mc_display_finalize(void) } } +static uint32_t auto_off_ms_override; + +void mc_display_set_auto_off_ms(uint32_t ms) +{ + auto_off_ms_override = ms; +} + void mc_display_reset_auto_off(void) { if (!disp_initialized) { @@ -514,7 +532,9 @@ void mc_display_reset_auto_off(void) } #ifdef CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS - uint32_t timeout = CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS; + uint32_t timeout = auto_off_ms_override + ? auto_off_ms_override + : CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS; if (timeout > 0) { k_work_reschedule(&auto_off_work, K_MSEC(timeout)); @@ -544,3 +564,46 @@ const struct device *mc_display_get_device(void) { return disp_initialized ? disp_dev : NULL; } + +/* ========== UTF-8 to Latin-1 Sanitizer ========== */ +void utf8_to_latin1(char *dst, const char *src, size_t dst_size) +{ + size_t di = 0; + size_t si = 0; + + while (src[si] && di < dst_size - 1) { + uint8_t c = (uint8_t)src[si]; + + if (c < 0x80) { + dst[di++] = (char)c; + si++; + } else if ((c & 0xE0) == 0xC0) { + uint8_t c2 = (uint8_t)src[si + 1]; + + if ((c2 & 0xC0) != 0x80) { + si++; + continue; + } + uint16_t cp = ((c & 0x1F) << 6) | (c2 & 0x3F); + if (cp >= 0xA0 && cp <= 0xFF) { + dst[di++] = (char)(uint8_t)cp; + } + si += 2; + } else if ((c & 0xF0) == 0xE0) { + si += 3; + } else if ((c & 0xF8) == 0xF0) { + si += 4; + } else { + si++; + } + } + dst[di] = '\0'; + + size_t start = 0; + while (dst[start] == ' ') { + start++; + } + if (start > 0) { + memmove(dst, dst + start, di - start + 1); + } +} diff --git a/zephcore/helpers/ui/display.h b/zephcore/helpers/ui/display.h index f4ae73e..100db11 100644 --- a/zephcore/helpers/ui/display.h +++ b/zephcore/helpers/ui/display.h @@ -17,6 +17,7 @@ #define ZEPHCORE_DISPLAY_H #include +#include #include #ifdef __cplusplus @@ -113,6 +114,13 @@ void mc_display_fill_rect(int x, int y, int w, int h); */ void mc_display_hline(int x, int y, int w); +/** + * Invert a rectangular region of the framebuffer. + * Pixels that are on (white) become off (black) and vice versa. + * Used to create clean dark-background modal overlays. + */ +void mc_display_invert_rect(int x, int y, int w, int h); + /** * Draw a monochrome bitmap (Adafruit/Arduino format). * MSB first, row-major, 1=foreground. @@ -137,6 +145,13 @@ void mc_display_finalize(void); */ void mc_display_reset_auto_off(void); +/** + * Override the auto-off timeout (0 = revert to Kconfig default). + * Call from the UI layer when the user changes the screen-off duration + * so the Kconfig-driven timer and the UI timer stay in sync. + */ +void mc_display_set_auto_off_ms(uint32_t ms); + /** * EPD-only: force a full panel reset cycle before normal page rendering. * No-op on non-EPD displays or when display is not initialized. @@ -150,6 +165,13 @@ void mc_display_epd_full_reset(void); */ const struct device *mc_display_get_device(void); +/** + * Convert UTF-8 text to Latin-1 for display rendering. + * Passes ASCII unchanged, converts 2-byte Latin-1 (U+00A0-U+00FF), + * strips everything else (emojis, CJK). Trims leading spaces left by stripped chars. + */ +void utf8_to_latin1(char *dst, const char *src, size_t dst_size); + #ifdef __cplusplus } #endif diff --git a/zephcore/helpers/ui/ui_common.c b/zephcore/helpers/ui/ui_common.c new file mode 100644 index 0000000..567a34d --- /dev/null +++ b/zephcore/helpers/ui/ui_common.c @@ -0,0 +1,196 @@ +/* + * ZephCore - UI Common + * Copyright (c) 2025 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * Shared ui_task.h implementations that are identical across all UI variants. + * Compiled for every build that includes any UI (button, joystick, or future). + */ + +#include "ui_task.h" + +#ifdef CONFIG_ZEPHCORE_UI_BUZZER +#include "buzzer.h" +#endif + +#include +#include +#include +LOG_MODULE_REGISTER(ui_led, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); + +/* ========== Startup Chime ========== */ + +void ui_play_startup_chime(void) +{ +#ifdef CONFIG_ZEPHCORE_UI_BUZZER + if (!buzzer_is_quiet()) { + buzzer_play(MELODY_STARTUP); + } +#endif +} + +/* ========== LED Heartbeat ========== */ +/* + * Uses led0 (or led1 fallback) as a heartbeat indicator. + * Pulse width extends to LED_ON_MSG_MS when there are unread messages, + * driven by ui_led_get_msg_count() which the button variant overrides. + * + * led1 is also claimed as a message indicator in non-repeater companion builds + * when both led0 and led1 are present. The heartbeat cycle turns led1 on + * only when msg count > 0, giving a visual unread-message reminder. + */ + +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +static const struct gpio_dt_spec s_heartbeat_led = + GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios); +#define HAS_HEARTBEAT_LED 1 +#elif DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) +static const struct gpio_dt_spec s_heartbeat_led = + GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); +#define HAS_HEARTBEAT_LED 1 +#else +#define HAS_HEARTBEAT_LED 0 +#endif + +/* Second LED for unread-message indication. Repeaters use led1 for LoRa TX + * (via lora-tx-led alias) — no offline queue, so this is companion-only. */ +#if HAS_HEARTBEAT_LED && DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && \ + DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) && !defined(ZEPHCORE_REPEATER) +static const struct gpio_dt_spec s_msg_led = + GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); +#define HAS_MSG_LED 1 +#else +#define HAS_MSG_LED 0 +#endif + +#define LED_CYCLE_MS 4000 /* Total heartbeat period */ +#define LED_ON_MS 20 /* Normal pulse width */ +#define LED_ON_MSG_MS 200 /* Pulse width when unread messages */ + +#if HAS_HEARTBEAT_LED +static struct k_work_delayable s_led_on_work; +static struct k_work_delayable s_led_off_work; +static bool s_leds_disabled; + +/* + * Weak: returns current unread message count for pulse-width adaptation. + * Overridden by ui_task.c (button UI) to read from ui_state. + * Joystick UI leaves this at 0 — it drives its own message display. + */ +__attribute__((weak)) uint16_t ui_led_get_msg_count(void) { return 0; } + +static void led_off_work_handler(struct k_work *work) +{ + ARG_UNUSED(work); + gpio_pin_set_dt(&s_heartbeat_led, 0); +#if HAS_MSG_LED + gpio_pin_set_dt(&s_msg_led, 0); +#endif + uint16_t on_ms = (ui_led_get_msg_count() > 0) ? LED_ON_MSG_MS : LED_ON_MS; + + k_work_reschedule(&s_led_on_work, K_MSEC(LED_CYCLE_MS - on_ms)); +} + +static void led_on_work_handler(struct k_work *work) +{ + ARG_UNUSED(work); + uint16_t mc = ui_led_get_msg_count(); + uint16_t on_ms = (mc > 0) ? LED_ON_MSG_MS : LED_ON_MS; + + if (!s_leds_disabled) { + gpio_pin_set_dt(&s_heartbeat_led, 1); +#if HAS_MSG_LED + if (mc > 0) { + gpio_pin_set_dt(&s_msg_led, 1); + } +#endif + } + k_work_reschedule(&s_led_off_work, K_MSEC(on_ms)); +} +#endif /* HAS_HEARTBEAT_LED */ + +/* + * Weak: called after s_leds_disabled changes so each UI variant can sync its + * own display state. Overridden by ui_task.c (button UI) to update + * ui_state->leds_disabled so the LEDs page shows the correct toggle state. + */ +__attribute__((weak)) void ui_led_on_disabled_changed(bool disabled) { ARG_UNUSED(disabled); } + +void ui_led_heartbeat_init(void) +{ +#if HAS_HEARTBEAT_LED + if (gpio_is_ready_dt(&s_heartbeat_led)) { + gpio_pin_configure_dt(&s_heartbeat_led, GPIO_OUTPUT_INACTIVE); + k_work_init_delayable(&s_led_on_work, led_on_work_handler); + k_work_init_delayable(&s_led_off_work, led_off_work_handler); + k_work_reschedule(&s_led_on_work, K_NO_WAIT); + LOG_INF("LED heartbeat started"); + } +#if HAS_MSG_LED + if (gpio_is_ready_dt(&s_msg_led)) { + gpio_pin_configure_dt(&s_msg_led, GPIO_OUTPUT_INACTIVE); + LOG_INF("msg LED ready"); + } +#endif +#endif +} + +void ui_set_heartbeat_led(bool enabled) +{ +#if HAS_HEARTBEAT_LED + if (enabled && !s_leds_disabled) { + if (gpio_is_ready_dt(&s_heartbeat_led)) { + k_work_reschedule(&s_led_on_work, K_NO_WAIT); + } + } else { + k_work_cancel_delayable(&s_led_on_work); + k_work_cancel_delayable(&s_led_off_work); + gpio_pin_set_dt(&s_heartbeat_led, 0); +#if HAS_MSG_LED + gpio_pin_set_dt(&s_msg_led, 0); +#endif + } +#else + (void)enabled; +#endif +} + +void ui_set_leds_disabled(bool disabled) +{ +#if HAS_HEARTBEAT_LED + s_leds_disabled = disabled; + if (disabled) { + k_work_cancel_delayable(&s_led_on_work); + k_work_cancel_delayable(&s_led_off_work); + gpio_pin_set_dt(&s_heartbeat_led, 0); +#if HAS_MSG_LED + gpio_pin_set_dt(&s_msg_led, 0); +#endif + } else if (!k_work_delayable_is_pending(&s_led_on_work) && + !k_work_delayable_is_pending(&s_led_off_work)) { + /* Restart heartbeat only if it was stopped (avoids spurious pulse) */ + if (gpio_is_ready_dt(&s_heartbeat_led)) { + k_work_reschedule(&s_led_on_work, K_NO_WAIT); + } + } +#else + (void)disabled; +#endif + ui_led_on_disabled_changed(disabled); +} + +/* Flash the heartbeat LED immediately on message receipt. + * Cancels the current cycle, pulses at LED_ON_MSG_MS width, then the + * work chain resumes the normal heartbeat automatically. + * No-op when LEDs are disabled or hardware is absent. */ +void ui_led_flash_msg(void) +{ +#if HAS_HEARTBEAT_LED + if (!s_leds_disabled && gpio_is_ready_dt(&s_heartbeat_led)) { + k_work_cancel_delayable(&s_led_on_work); + k_work_cancel_delayable(&s_led_off_work); + gpio_pin_set_dt(&s_heartbeat_led, 1); + k_work_reschedule(&s_led_off_work, K_MSEC(LED_ON_MSG_MS)); + } +#endif +} diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index 5545e51..0c4b3b8 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include LOG_MODULE_REGISTER(zephcore_ui_actions, CONFIG_ZEPHCORE_UI_ACTIONS_LOG_LEVEL); @@ -26,15 +27,23 @@ LOG_MODULE_REGISTER(zephcore_ui_actions, CONFIG_ZEPHCORE_UI_ACTIONS_LOG_LEVEL); #include #include #include "ui_task.h" +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) +#include +#endif #include "ui_mesh_actions.h" /* UI action bit flags — set from input thread, consumed by mesh event loop */ -#define UI_ACTION_FLOOD_ADVERT BIT(0) -#define UI_ACTION_GPS_TOGGLE BIT(1) -#define UI_ACTION_BUZZER_TOGGLE BIT(2) -#define UI_ACTION_ZEROHOP_ADVERT BIT(3) -#define UI_ACTION_OFFGRID_TOGGLE BIT(4) -#define UI_ACTION_LEDS_TOGGLE BIT(5) +#define UI_ACTION_FLOOD_ADVERT BIT(0) +#define UI_ACTION_GPS_TOGGLE BIT(1) +#define UI_ACTION_BUZZER_TOGGLE BIT(2) +#define UI_ACTION_ZEROHOP_ADVERT BIT(3) +#define UI_ACTION_OFFGRID_TOGGLE BIT(4) +#define UI_ACTION_LEDS_TOGGLE BIT(5) +#define UI_ACTION_BLE_TOGGLE BIT(6) +#define UI_ACTION_BRIGHTNESS_SAVE BIT(7) +#define UI_ACTION_SAVE_RESTART BIT(8) +#define UI_ACTION_WAKE_ON_MSG_SAVE BIT(9) +#define UI_ACTION_SCREEN_OFF_SAVE BIT(10) /* Module-local pointers, set by init */ static CompanionMesh *s_mesh; @@ -55,6 +64,10 @@ static atomic_t pending_gps_enabled; static atomic_t pending_buzzer_quiet; static atomic_t pending_offgrid_enabled; static atomic_t pending_leds_disabled; +static atomic_t pending_ble_disabled; +static atomic_t pending_brightness; +static atomic_t pending_wake_on_msg; +static atomic_t pending_screen_off_secs; extern "C" void ui_mesh_actions_init(struct k_event *mesh_events, uint32_t mesh_event_ui_action, @@ -99,6 +112,36 @@ extern "C" void mesh_gps_set_enabled(bool enable) extern "C" void mesh_ble_set_enabled(bool enable) { zephcore_ble_set_enabled(enable); + atomic_set(&pending_ble_disabled, enable ? 0 : 1); + atomic_or(&pending_ui_actions, UI_ACTION_BLE_TOGGLE); + k_event_post(s_mesh_events, s_mesh_event_ui_action); +} + +extern "C" void mesh_save_brightness(uint8_t brightness) +{ + atomic_set(&pending_brightness, (atomic_val_t)brightness); + atomic_or(&pending_ui_actions, UI_ACTION_BRIGHTNESS_SAVE); + k_event_post(s_mesh_events, s_mesh_event_ui_action); +} + +extern "C" void mesh_save_and_restart(void) +{ + atomic_or(&pending_ui_actions, UI_ACTION_SAVE_RESTART); + k_event_post(s_mesh_events, s_mesh_event_ui_action); +} + +extern "C" void mesh_set_wake_on_msg(bool enabled) +{ + atomic_set(&pending_wake_on_msg, enabled ? 1 : 0); + atomic_or(&pending_ui_actions, UI_ACTION_WAKE_ON_MSG_SAVE); + k_event_post(s_mesh_events, s_mesh_event_ui_action); +} + +extern "C" void mesh_save_screen_off_secs(uint16_t secs) +{ + atomic_set(&pending_screen_off_secs, (atomic_val_t)secs); + atomic_or(&pending_ui_actions, UI_ACTION_SCREEN_OFF_SAVE); + k_event_post(s_mesh_events, s_mesh_event_ui_action); } extern "C" void mesh_set_buzzer_quiet(bool quiet) @@ -217,10 +260,41 @@ extern "C" void mesh_handle_ui_actions(void) need_save = true; } - if (need_save) { + if (actions & UI_ACTION_BLE_TOGGLE) { + bool bd = atomic_get(&pending_ble_disabled) != 0; + s_mesh->prefs.ble_disabled = bd ? 1 : 0; + LOG_INF("ble_disabled=%d (button)", bd); + need_save = true; + } + + if (actions & UI_ACTION_BRIGHTNESS_SAVE) { + uint8_t br = (uint8_t)atomic_get(&pending_brightness); + s_mesh->prefs.display_brightness = br; + LOG_INF("display_brightness=%d (button)", br); + need_save = true; + } + + if (actions & UI_ACTION_WAKE_ON_MSG_SAVE) { + s_mesh->prefs.wake_on_msg = atomic_get(&pending_wake_on_msg) ? 1 : 0; + LOG_INF("wake_on_msg=%d (button)", s_mesh->prefs.wake_on_msg); + need_save = true; + } + + if (actions & UI_ACTION_SCREEN_OFF_SAVE) { + s_mesh->prefs.screen_off_secs = (uint16_t)atomic_get(&pending_screen_off_secs); + LOG_INF("screen_off_secs=%d (button)", s_mesh->prefs.screen_off_secs); + need_save = true; + } + + if (need_save || (actions & UI_ACTION_SAVE_RESTART)) { s_data_store->savePrefs(s_mesh->prefs); LOG_INF("prefs saved (button action)"); } + + if (actions & UI_ACTION_SAVE_RESTART) { + LOG_INF("rebooting (save+restart action)"); + sys_reboot(SYS_REBOOT_COLD); + } } /* ========== Periodic housekeeping UI refresh ========== */ @@ -249,6 +323,12 @@ extern "C" void mesh_housekeeping_ui_refresh(void) s_mesh->prefs.cr, s_mesh->prefs.tx_power_dbm, s_lora_radio->getNoiseFloor()); +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + ui_notify_radio_stats( + s_lora_radio->getPacketsRecv(), + s_lora_radio->getPacketsSent(), + s_lora_radio->getPacketsRecvErrors()); +#endif /* Update GPS satellite count even without fix */ if (gps_is_enabled()) { diff --git a/zephcore/helpers/ui/ui_mesh_actions.h b/zephcore/helpers/ui/ui_mesh_actions.h index 9b6f382..b3beff6 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.h +++ b/zephcore/helpers/ui/ui_mesh_actions.h @@ -60,6 +60,10 @@ void mesh_set_offgrid_mode(bool enable); void mesh_set_leds_disabled(bool disabled); void mesh_disable_power_regulators(void); void mesh_reboot_to_ota_dfu(void); +void mesh_save_brightness(uint8_t brightness); +void mesh_save_and_restart(void); +void mesh_set_wake_on_msg(bool enabled); +void mesh_save_screen_off_secs(uint16_t secs); #ifdef __cplusplus } diff --git a/zephcore/helpers/ui/ui_task.h b/zephcore/helpers/ui/ui_task.h index dd645d7..3690107 100644 --- a/zephcore/helpers/ui/ui_task.h +++ b/zephcore/helpers/ui/ui_task.h @@ -46,6 +46,14 @@ int ui_init(void); */ void ui_play_startup_chime(void); +/** + * Initialize the LED heartbeat hardware. + * Detects led0/led1 GPIO aliases, configures pins, and starts the + * self-rescheduling work chain. Safe to call when no LED is present. + * Must be called from ui_init() in every UI variant. + */ +void ui_led_heartbeat_init(void); + /** * Post a notification event to the UI. * Triggers buzzer melody and/or display wake depending on event type. @@ -168,6 +176,38 @@ void ui_set_offgrid_mode(bool enabled); */ void ui_refresh_display(void); +/** + * Notify UI of a received contact message. + * Rich UIs display the text and sender; simpler ones forward to + * ui_notify(UI_EVENT_CONTACT_MSG) + ui_set_msg_count(). + * + * @param path_len Hop count (OUT_PATH_UNKNOWN = direct/unknown) + * @param from_name Sender display name + * @param text Message text + * @param msg_count Updated offline queue message count + */ +void ui_notify_contact_msg(uint8_t path_len, const char *from_name, + const char *text, uint16_t msg_count); + +/** + * Notify UI of a received channel message. + * Rich UIs use all parameters; simpler ones fire ui_notify(UI_EVENT_CHANNEL_MSG). + * + * @param channel_name Human-readable channel name + * @param text Message text + * @param ts Sender timestamp (epoch) + * @param path_len Hop count (OUT_PATH_UNKNOWN = direct/unknown) + * @param msg_count Updated offline queue message count + */ +void ui_notify_channel_msg(const char *channel_name, const char *text, + uint32_t ts, uint8_t path_len, uint16_t msg_count); + +/** + * Notify UI that an outbound packet was transmitted. + * Rich UIs use this to start RTT timers; simpler ones ignore it. + */ +void ui_notify_packet_sent(void); + #ifdef __cplusplus } #endif diff --git a/zephcore/src/Mesh.cpp b/zephcore/src/Mesh.cpp index 2fda297..2334b62 100644 --- a/zephcore/src/Mesh.cpp +++ b/zephcore/src/Mesh.cpp @@ -92,7 +92,16 @@ DispatcherAction Mesh::routeRecvPacket(Packet *packet) if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit() && (n + 1)*packet->getPathHashSize() <= MAX_PATH_SIZE && allowPacketForward(packet)) { // append this node's hash to 'path' - self_id.copyHashTo(&packet->path[n * packet->getPathHashSize()], packet->getPathHashSize()); +#ifdef ZEPHCORE_COMPANION + uint8_t prefix[4] = {}; + /* Avoid confusions while using offgrid mode with real repeaters and force 01 (MOBILE node) */ + prefix[0] = 0x01; + memcpy(&packet->path[n * packet->getPathHashSize()], + prefix, + packet->getPathHashSize()); +#else + self_id.copyHashTo(&packet->path[n * packet->getPathHashSize()], packet->getPathHashSize()); +#endif packet->setPathHashCount(n + 1); uint32_t h = ContentionTracker::computePacketHash32(packet); _contention.trackRetransmit(h, (uint32_t)_ms->getMillis()); diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index c957a77..4a11fe0 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -36,6 +36,11 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #endif +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) +#include +#include +#endif + /* Radio + mesh includes (shared header selects LR1110 or SX126x) */ #include #ifdef ZEPHCORE_LORA @@ -70,7 +75,15 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #define MESH_EVENT_UI_ACTION BIT(4) /* Button action from UI (deferred to mesh thread) */ #define MESH_EVENT_GPS_ACTION BIT(5) /* GPS state change (must run on main thread!) */ #define MESH_EVENT_TX_DRAIN BIT(6) /* Outbound packet delay expired, run checkSend */ -#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_BLE_RX | MESH_EVENT_HOUSEKEEPING | MESH_EVENT_UI_ACTION | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN) +#define MESH_EVENT_BASE (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | \ + MESH_EVENT_BLE_RX | MESH_EVENT_HOUSEKEEPING | MESH_EVENT_UI_ACTION | \ + MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN) +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) +#define MESH_EVENT_JOYSTICK_LOOP BIT(7) /* Joystick UI loop tick (50 ms) */ +#define MESH_EVENT_ALL (MESH_EVENT_BASE | MESH_EVENT_JOYSTICK_LOOP) +#else +#define MESH_EVENT_ALL MESH_EVENT_BASE +#endif /* Housekeeping interval - infrequent to preserve power savings */ #define HOUSEKEEPING_INTERVAL_MS CONFIG_ZEPHCORE_HOUSEKEEPING_INTERVAL_MS @@ -83,6 +96,20 @@ static void rx_process_work_fn(struct k_work *work); static void contact_iter_work_fn(struct k_work *work); static void housekeeping_timer_fn(struct k_timer *timer); +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) +static JoystickUITask joystick_ui_task; + +static void joystick_signal_refresh(void) +{ + k_event_post(&mesh_events, MESH_EVENT_JOYSTICK_LOOP); +} + +static void joystick_signal_tx(void) +{ + k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN); +} +#endif + K_WORK_DEFINE(rx_process_work, rx_process_work_fn); K_WORK_DEFINE(contact_iter_work, contact_iter_work_fn); @@ -315,6 +342,12 @@ static void mesh_event_loop(void) mesh_housekeeping_ui_refresh(); } #endif + +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + if (events & MESH_EVENT_JOYSTICK_LOOP) { + joystick_ui_task.loop(); + } +#endif } } @@ -478,6 +511,9 @@ static void bt_ready(int err) #else zephcore_ble_start(NULL); #endif + if (companion_mesh.prefs.ble_disabled) { + zephcore_ble_set_enabled(false); + } } int main(void) @@ -516,6 +552,9 @@ int main(void) * If display is present, ui_init() handles display init + auto-off. * If no display, fall back to raw OLED sleep for power saving. */ ui_init(); +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + joystick_ui_hooks_register(&joystick_ui_task, joystick_signal_refresh, joystick_signal_tx); +#endif #if !IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) oled_sleep(); #endif @@ -689,6 +728,10 @@ int main(void) ui_mesh_actions_init(&mesh_events, MESH_EVENT_UI_ACTION, &companion_mesh, &data_store, &lora_radio, &zephyr_board, &rtc_clock); + +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) + joystick_ui_task.begin(&companion_mesh, &rtc_clock, &companion_mesh.prefs); +#endif #endif /* Initialize BLE adapter (registers auth callbacks) */