mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-09-01 21:08:19 +00:00
Init
This commit is contained in:
+32
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'",
|
||||
|
||||
+121
-12
@@ -16,10 +16,13 @@
|
||||
#include <ZephyrSensorManager.h>
|
||||
#include <adapters/sensors/SimpleLPP.h>
|
||||
#include <adapters/ble/ZephyrBLE.h>
|
||||
#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 <ui_task.h>
|
||||
#define ZEPHCORE_HAS_UI_TASK 1
|
||||
#endif
|
||||
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK)
|
||||
#include <joystick_ui_hooks.h>
|
||||
#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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
config BOARD_WIO_TRACKER_L1
|
||||
select SOC_NRF52840_QIAA
|
||||
select ZEPHCORE_UI_JOYSTICK
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <mesh/MeshCore.h>
|
||||
|
||||
#define OUT_PATH_UNKNOWN 0xFF
|
||||
#define OUT_PATH_SENT 0xFE /* local-only marker */
|
||||
|
||||
struct ContactInfo {
|
||||
mesh::Identity id;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <zephyr/logging/log.h>
|
||||
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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#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;
|
||||
};
|
||||
@@ -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 <helpers/ContactInfo.h>
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include <mesh/Mesh.h>
|
||||
#include <stdint.h>
|
||||
#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]; }
|
||||
};
|
||||
@@ -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 <helpers/ui/ui_task.h>
|
||||
|
||||
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
|
||||
#include "buzzer.h"
|
||||
#endif
|
||||
|
||||
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
|
||||
#include "display.h"
|
||||
#endif
|
||||
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/logging/log.h>
|
||||
#include <string.h>
|
||||
|
||||
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<JoystickUITask *>(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<const struct ui_joystick_login_data *>(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<const struct ui_joystick_cli_data *>(payload);
|
||||
s_task->onRepeaterAdminCliResponse(pub_key_prefix, d->text);
|
||||
break;
|
||||
}
|
||||
case UI_JOYSTICK_REQ_RESPONSE: {
|
||||
const auto *d = static_cast<const struct ui_joystick_req_response_data *>(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<const struct ui_joystick_discover_data *>(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();
|
||||
}
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <helpers/BaseChatMesh.h>
|
||||
#include <helpers/ContactInfo.h>
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include <helpers/NodePrefs.h>
|
||||
#include <adapters/clock/ZephyrRTCClock.h>
|
||||
|
||||
#include <zephyr/kernel.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* 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;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <zephyr/kernel.h>
|
||||
#include <stdio.h>
|
||||
#include <ZephyrSensorManager.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
}
|
||||
@@ -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 <zephyr/kernel.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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 <helpers/ChannelDetails.h>
|
||||
#include <zephyr/kernel.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <helpers/ContactInfo.h>
|
||||
#include <zephyr/kernel.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <math.h>
|
||||
|
||||
/* ===== 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();
|
||||
}
|
||||
}
|
||||
@@ -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 <adapters/gps/ZephyrGPSManager.h>
|
||||
#include <helpers/ui/ui_mesh_actions.h>
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/random/random.h>
|
||||
#include <math.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -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 <helpers/AdvertDataHelpers.h>
|
||||
#include <helpers/ui/ui_task.h>
|
||||
#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM
|
||||
#include <helpers/ui/doom_game.h>
|
||||
#endif
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/random/random.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#define ZEPHCORE_DISPLAY_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
|
||||
@@ -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 <zephyr/drivers/gpio.h>
|
||||
#include <zephyr/kernel.h>
|
||||
#include <zephyr/logging/log.h>
|
||||
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
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <zephyr/device.h>
|
||||
#include <zephyr/devicetree.h>
|
||||
#include <zephyr/drivers/regulator.h>
|
||||
#include <zephyr/sys/reboot.h>
|
||||
|
||||
#include <zephyr/logging/log.h>
|
||||
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 <ZephyrBLE.h>
|
||||
#include <ZephyrSensorManager.h>
|
||||
#include "ui_task.h"
|
||||
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK)
|
||||
#include <joystick_ui_hooks.h>
|
||||
#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()) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-1
@@ -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());
|
||||
|
||||
@@ -36,6 +36,11 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
|
||||
#include <ZephyrCompanionUSB.h>
|
||||
#endif
|
||||
|
||||
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK)
|
||||
#include <joystick_ui_task.h>
|
||||
#include <joystick_ui_hooks.h>
|
||||
#endif
|
||||
|
||||
/* Radio + mesh includes (shared header selects LR1110 or SX126x) */
|
||||
#include <mesh/RadioIncludes.h>
|
||||
#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) */
|
||||
|
||||
Reference in New Issue
Block a user