diff --git a/src/config/Config.h b/src/config/Config.h index 0bf3134..9c30d50 100644 --- a/src/config/Config.h +++ b/src/config/Config.h @@ -5,9 +5,9 @@ // ============================================================================= #define RATDECK_VERSION_MAJOR 1 -#define RATDECK_VERSION_MINOR 0 -#define RATDECK_VERSION_PATCH 0 -#define RATDECK_VERSION_STRING "1.0.0" +#define RATDECK_VERSION_MINOR 2 +#define RATDECK_VERSION_PATCH 2 +#define RATDECK_VERSION_STRING "1.2.2" // --- Feature Flags --- #define HAS_DISPLAY true diff --git a/src/hal/Trackball.cpp b/src/hal/Trackball.cpp index 4721b32..266a82b 100644 --- a/src/hal/Trackball.cpp +++ b/src/hal/Trackball.cpp @@ -1,5 +1,4 @@ #include "Trackball.h" -#include volatile int8_t Trackball::_deltaX = 0; volatile int8_t Trackball::_deltaY = 0; @@ -9,20 +8,6 @@ Trackball* Trackball::_instance = nullptr; bool Trackball::begin() { _instance = this; - // Detach any stale interrupts (GPIO state may persist after USB download mode reset) - detachInterrupt(digitalPinToInterrupt(TBALL_UP)); - detachInterrupt(digitalPinToInterrupt(TBALL_DOWN)); - detachInterrupt(digitalPinToInterrupt(TBALL_LEFT)); - detachInterrupt(digitalPinToInterrupt(TBALL_RIGHT)); - detachInterrupt(digitalPinToInterrupt(TBALL_CLICK)); - - // Reset pins to a known state before configuring - gpio_reset_pin((gpio_num_t)TBALL_UP); - gpio_reset_pin((gpio_num_t)TBALL_DOWN); - gpio_reset_pin((gpio_num_t)TBALL_LEFT); - gpio_reset_pin((gpio_num_t)TBALL_RIGHT); - gpio_reset_pin((gpio_num_t)TBALL_CLICK); - // Configure trackball GPIOs as inputs with pullup pinMode(TBALL_UP, INPUT_PULLUP); pinMode(TBALL_DOWN, INPUT_PULLUP); @@ -30,9 +15,6 @@ bool Trackball::begin() { pinMode(TBALL_RIGHT, INPUT_PULLUP); pinMode(TBALL_CLICK, INPUT_PULLUP); - // Small delay to let pullups stabilize - delay(5); - // Attach interrupts for movement detection attachInterrupt(digitalPinToInterrupt(TBALL_UP), isrUp, FALLING); attachInterrupt(digitalPinToInterrupt(TBALL_DOWN), isrRight, FALLING); // Physical down pin = rightward @@ -40,7 +22,7 @@ bool Trackball::begin() { attachInterrupt(digitalPinToInterrupt(TBALL_RIGHT), isrDown, FALLING); // Physical right pin = downward attachInterrupt(digitalPinToInterrupt(TBALL_CLICK), isrClick, FALLING); - Serial.println("[TRACKBALL] Initialized (GPIO reset + interrupts attached)"); + Serial.println("[TRACKBALL] Initialized"); return true; } diff --git a/src/main.cpp b/src/main.cpp index a9098a8..9d2f7ab 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -26,6 +26,7 @@ #include "ui/screens/SettingsScreen.h" #include "ui/screens/HelpOverlay.h" #include "ui/screens/MapScreen.h" +#include "ui/screens/NameInputScreen.h" #include "storage/FlashStore.h" #include "storage/SDStore.h" #include "storage/MessageStore.h" @@ -90,6 +91,7 @@ MessageView messageView; SettingsScreen settingsScreen; HelpOverlay helpOverlay; MapScreen mapScreen; +NameInputScreen nameInputScreen; // Tab-screen mapping (5 tabs) Screen* tabScreens[5] = {nullptr, nullptr, nullptr, nullptr, nullptr}; @@ -110,6 +112,29 @@ constexpr unsigned long HEARTBEAT_INTERVAL_MS = 5000; unsigned long loopCycleStart = 0; unsigned long maxLoopTime = 0; +// ============================================================================= +// Announce with display name (MessagePack-encoded app_data) +// ============================================================================= + +static RNS::Bytes encodeAnnounceName(const String& name) { + if (name.isEmpty()) return {}; + size_t len = name.length(); + if (len > 31) len = 31; + uint8_t buf[2 + 31]; + buf[0] = 0x91; // msgpack fixarray(1) + buf[1] = 0xA0 | (uint8_t)len; // msgpack fixstr(len) + memcpy(buf + 2, name.c_str(), len); + return RNS::Bytes(buf, 2 + len); +} + +static void announceWithName() { + RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); + rns.announce(appData); + ui.statusBar().flashAnnounce(); + ui.statusBar().showToast("Announce sent!"); + Serial.println("[ANNOUNCE] Sent with display name"); +} + // ============================================================================= // Hotkey callbacks // ============================================================================= @@ -131,9 +156,7 @@ void onHotkeySettings() { ui.setScreen(&settingsScreen); } void onHotkeyAnnounce() { - rns.announce(); - ui.statusBar().flashAnnounce(); - ui.statusBar().showToast("Announce sent!"); + announceWithName(); } void onHotkeyDiag() { Serial.println("=== DIAGNOSTIC DUMP ==="); @@ -530,13 +553,15 @@ void setup() { delay(400); bootComplete = true; - ui.setBootMode(false); ui.statusBar().setTransportMode("Ratdeck"); // Wire up screen dependencies homeScreen.setReticulumManager(&rns); homeScreen.setRadio(&radio); homeScreen.setUserConfig(&userConfig); + homeScreen.setAnnounceCallback([]() { + announceWithName(); + }); nodesScreen.setAnnounceManager(announceManager); nodesScreen.setNodeSelectedCallback([](const std::string& peerHex) { @@ -586,13 +611,41 @@ void setup() { if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); }); - ui.setScreen(&homeScreen); - ui.tabBar().setActiveTab(TabBar::TAB_HOME); + // Name input screen (first boot only — when no display name is set) + nameInputScreen.setDoneCallback([](const String& name) { + userConfig.settings().displayName = name; + userConfig.save(sdStore, flash); + Serial.printf("[BOOT] Display name set: '%s'\n", name.c_str()); - // Initial announce - rns.announce(); - lastAutoAnnounce = millis(); - Serial.println("[BOOT] Initial announce sent"); + // Transition to home screen + ui.setBootMode(false); + ui.setScreen(&homeScreen); + ui.tabBar().setActiveTab(TabBar::TAB_HOME); + + // Initial announce with name + RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); + rns.announce(appData); + lastAutoAnnounce = millis(); + ui.statusBar().flashAnnounce(); + Serial.println("[BOOT] Initial announce sent"); + }); + + if (userConfig.settings().displayName.isEmpty()) { + // Show name input screen (boot mode keeps status/tab bars hidden) + ui.setScreen(&nameInputScreen); + Serial.println("[BOOT] Showing name input screen"); + } else { + // Name already set — go straight to home + ui.setBootMode(false); + ui.setScreen(&homeScreen); + ui.tabBar().setActiveTab(TabBar::TAB_HOME); + + // Initial announce with name + RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); + rns.announce(appData); + lastAutoAnnounce = millis(); + Serial.println("[BOOT] Initial announce sent"); + } // Clear boot loop counter — we survived! { @@ -635,14 +688,16 @@ void loop() { // Screen gets the key next bool consumed = ui.handleKey(evt); - // Tab cycling: ,=left /=right (only if screen didn't consume) + // Tab cycling: ,=left /=right OR trackball left/right (only if screen didn't consume) if (!consumed && !evt.ctrl) { - if (evt.character == ',') { + bool tabLeft = (evt.character == ',') || evt.left; + bool tabRight = (evt.character == '/') || evt.right; + if (tabLeft) { ui.tabBar().cycleTab(-1); int tab = ui.tabBar().getActiveTab(); if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); } - if (evt.character == '/') { + if (tabRight) { ui.tabBar().cycleTab(1); int tab = ui.tabBar().getActiveTab(); if (tabScreens[tab]) ui.setScreen(tabScreens[tab]); @@ -657,7 +712,8 @@ void loop() { // 4. Auto-announce every 5 minutes if (bootComplete && millis() - lastAutoAnnounce >= ANNOUNCE_INTERVAL_MS) { lastAutoAnnounce = millis(); - rns.announce(); + RNS::Bytes appData = encodeAnnounceName(userConfig.settings().displayName); + rns.announce(appData); ui.statusBar().flashAnnounce(); Serial.println("[AUTO] Periodic announce"); } diff --git a/src/ui/screens/HomeScreen.cpp b/src/ui/screens/HomeScreen.cpp index 708d798..8ec3b89 100644 --- a/src/ui/screens/HomeScreen.cpp +++ b/src/ui/screens/HomeScreen.cpp @@ -18,6 +18,14 @@ void HomeScreen::update() { } } +bool HomeScreen::handleKey(const KeyEvent& event) { + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_announceCb) _announceCb(); + return true; + } + return false; +} + void HomeScreen::draw(LGFX_TDeck& gfx) { int x = 4; int y = Theme::CONTENT_Y + 4; diff --git a/src/ui/screens/HomeScreen.h b/src/ui/screens/HomeScreen.h index fe3b8d6..ff1616d 100644 --- a/src/ui/screens/HomeScreen.h +++ b/src/ui/screens/HomeScreen.h @@ -1,6 +1,7 @@ #pragma once #include "ui/UIManager.h" +#include class ReticulumManager; class SX1262; @@ -9,10 +10,12 @@ class UserConfig; class HomeScreen : public Screen { public: void update() override; + bool handleKey(const KeyEvent& event) override; void setReticulumManager(ReticulumManager* rns) { _rns = rns; } void setRadio(SX1262* radio) { _radio = radio; } void setUserConfig(UserConfig* cfg) { _cfg = cfg; } + void setAnnounceCallback(std::function cb) { _announceCb = cb; } const char* title() const override { return "Home"; } void draw(LGFX_TDeck& gfx) override; @@ -23,4 +26,5 @@ private: UserConfig* _cfg = nullptr; unsigned long _lastUptime = 0; uint32_t _lastHeap = 0; + std::function _announceCb; }; diff --git a/src/ui/screens/NameInputScreen.cpp b/src/ui/screens/NameInputScreen.cpp new file mode 100644 index 0000000..069e6dd --- /dev/null +++ b/src/ui/screens/NameInputScreen.cpp @@ -0,0 +1,102 @@ +#include "NameInputScreen.h" +#include "ui/Theme.h" +#include "config/Config.h" +#include "hal/Display.h" + +void NameInputScreen::draw(LGFX_TDeck& gfx) { + int cx = Theme::SCREEN_W / 2; + + // Ratspeak branding + gfx.setTextSize(2); + gfx.setTextColor(Theme::PRIMARY, Theme::BG); + const char* brand = "RATSPEAK"; + int bw = strlen(brand) * 12; + gfx.setCursor(cx - bw / 2, 30); + gfx.print(brand); + + // .org subtitle + gfx.setTextSize(1); + gfx.setTextColor(Theme::ACCENT, Theme::BG); + const char* sub = "ratspeak.org"; + int sw = strlen(sub) * 6; + gfx.setCursor(cx - sw / 2, 52); + gfx.print(sub); + + // Prompt + gfx.setTextColor(Theme::SECONDARY, Theme::BG); + const char* prompt = "Enter your display name"; + int pw = strlen(prompt) * 6; + gfx.setCursor(cx - pw / 2, 85); + gfx.print(prompt); + + gfx.setTextColor(Theme::MUTED, Theme::BG); + const char* opt = "(Optional - press Enter to skip)"; + int ow = strlen(opt) * 6; + gfx.setCursor(cx - ow / 2, 100); + gfx.print(opt); + + // Text input field + int fieldW = 200; + int fieldH = 20; + int fieldX = cx - fieldW / 2; + int fieldY = 125; + gfx.drawRect(fieldX, fieldY, fieldW, fieldH, Theme::PRIMARY); + gfx.fillRect(fieldX + 1, fieldY + 1, fieldW - 2, fieldH - 2, Theme::SELECTION_BG); + + // Name text + gfx.setTextSize(1); + gfx.setTextColor(Theme::PRIMARY, Theme::SELECTION_BG); + gfx.setCursor(fieldX + 6, fieldY + 6); + gfx.print(_name); + + // Cursor blink (simple solid cursor) + int cursorX = fieldX + 6 + _nameLen * 6; + if (cursorX < fieldX + fieldW - 8) { + bool blink = (millis() / 500) % 2 == 0; + if (blink) { + gfx.fillRect(cursorX, fieldY + 4, 6, 12, Theme::PRIMARY); + } + } + + // OK hint + gfx.setTextColor(Theme::ACCENT, Theme::BG); + const char* hint = "[Enter] OK"; + int hw = strlen(hint) * 6; + gfx.setCursor(cx - hw / 2, 160); + gfx.print(hint); + + // Version at bottom + gfx.setTextColor(Theme::MUTED, Theme::BG); + char ver[32]; + snprintf(ver, sizeof(ver), "Ratdeck v%s", RATDECK_VERSION_STRING); + int vw = strlen(ver) * 6; + gfx.setCursor(cx - vw / 2, 190); + gfx.print(ver); +} + +bool NameInputScreen::handleKey(const KeyEvent& event) { + if (event.enter || event.character == '\n' || event.character == '\r') { + if (_doneCb) _doneCb(String(_name)); + return true; + } + + if (event.del || event.character == 8) { + if (_nameLen > 0) { + _nameLen--; + _name[_nameLen] = '\0'; + markDirty(); + } + return true; + } + + // Printable characters + if (event.character >= 0x20 && event.character <= 0x7E && _nameLen < MAX_NAME_LEN) { + _name[_nameLen] = event.character; + _nameLen++; + _name[_nameLen] = '\0'; + markDirty(); + return true; + } + + return true; // Consume all keys on this screen +} diff --git a/src/ui/screens/NameInputScreen.h b/src/ui/screens/NameInputScreen.h new file mode 100644 index 0000000..8f94fca --- /dev/null +++ b/src/ui/screens/NameInputScreen.h @@ -0,0 +1,20 @@ +#pragma once + +#include "ui/UIManager.h" +#include + +class NameInputScreen : public Screen { +public: + bool handleKey(const KeyEvent& event) override; + const char* title() const override { return "Setup"; } + void draw(LGFX_TDeck& gfx) override; + + void setDoneCallback(std::function cb) { _doneCb = cb; } + + static constexpr int MAX_NAME_LEN = 16; + +private: + char _name[MAX_NAME_LEN + 1] = {0}; + int _nameLen = 0; + std::function _doneCb; +}; diff --git a/src/ui/screens/SettingsScreen.cpp b/src/ui/screens/SettingsScreen.cpp index e7de1a3..0de383a 100644 --- a/src/ui/screens/SettingsScreen.cpp +++ b/src/ui/screens/SettingsScreen.cpp @@ -34,7 +34,8 @@ bool SettingsScreen::isEditable(int idx) const { if (idx < 0 || idx >= (int)_items.size()) return false; auto t = _items[idx].type; return t == SettingType::INTEGER || t == SettingType::TOGGLE - || t == SettingType::ENUM_CHOICE || t == SettingType::ACTION; + || t == SettingType::ENUM_CHOICE || t == SettingType::ACTION + || t == SettingType::TEXT_INPUT; } void SettingsScreen::skipToNextEditable(int dir) { @@ -85,6 +86,15 @@ void SettingsScreen::buildItems() { [](int) { return String(RATDECK_VERSION_STRING); }}); _items.push_back({"Identity", SettingType::READONLY, nullptr, nullptr, [this](int) { return _identityHash.substring(0, 16); }}); + { + SettingItem nameItem; + nameItem.label = "Display Name"; + nameItem.type = SettingType::TEXT_INPUT; + nameItem.textGetter = [&s]() { return s.displayName; }; + nameItem.textSetter = [&s](const String& v) { s.displayName = v; }; + nameItem.maxTextLen = 16; + _items.push_back(nameItem); + } // --- Display --- _items.push_back({"-- Display --", SettingType::HEADER, nullptr, nullptr, nullptr}); @@ -168,8 +178,20 @@ void SettingsScreen::buildItems() { announceItem.type = SettingType::ACTION; announceItem.formatter = [](int) { return String("[Press Enter]"); }; announceItem.action = [this]() { - if (_rns) { - _rns->announce(); + if (_rns && _cfg) { + // Encode display name as msgpack app_data + const String& name = _cfg->settings().displayName; + RNS::Bytes appData; + if (!name.isEmpty()) { + size_t len = name.length(); + if (len > 31) len = 31; + uint8_t buf[2 + 31]; + buf[0] = 0x91; + buf[1] = 0xA0 | (uint8_t)len; + memcpy(buf + 2, name.c_str(), len); + appData = RNS::Bytes(buf, 2 + len); + } + _rns->announce(appData); if (_ui) { _ui->statusBar().flashAnnounce(); _ui->statusBar().showToast("Announce sent!"); @@ -268,6 +290,7 @@ void SettingsScreen::onEnter() { _selectedIdx = 0; _scrollOffset = 0; _editing = false; + _textEditing = false; // Skip to first editable item if (!isEditable(_selectedIdx)) { skipToNextEditable(1); @@ -318,6 +341,27 @@ void SettingsScreen::draw(LGFX_TDeck& gfx) { gfx.setCursor(valX, y + 3); gfx.print(hint.c_str()); } + } else if (item.type == SettingType::TEXT_INPUT) { + // Text input field + gfx.setTextColor(Theme::SECONDARY, bgCol); + gfx.setCursor(4, y + 3); + gfx.print(item.label); + + if (_textEditing && selected) { + // Editing: show text with cursor + gfx.setTextColor(Theme::WARNING_CLR, bgCol); + gfx.setCursor(valX, y + 3); + gfx.print(_editText.c_str()); + int curX = valX + _editText.length() * 6; + if ((millis() / 500) % 2 == 0) { + gfx.fillRect(curX, y + 3, 6, 8, Theme::WARNING_CLR); + } + } else { + String val = item.textGetter ? item.textGetter() : ""; + gfx.setTextColor(val.isEmpty() ? Theme::MUTED : Theme::PRIMARY, bgCol); + gfx.setCursor(valX, y + 3); + gfx.print(val.isEmpty() ? "(not set)" : val.c_str()); + } } else { // Label gfx.setTextColor(Theme::SECONDARY, bgCol); @@ -378,6 +422,32 @@ void SettingsScreen::draw(LGFX_TDeck& gfx) { bool SettingsScreen::handleKey(const KeyEvent& event) { if (_items.empty()) return false; + if (_textEditing) { + // Text edit mode + auto& item = _items[_selectedIdx]; + if (event.enter || event.character == '\n' || event.character == '\r') { + if (item.textSetter) item.textSetter(_editText); + _textEditing = false; + applyAndSave(); + markDirty(); + return true; + } + if (event.del || event.character == 8) { + if (_editText.length() > 0) { + _editText.remove(_editText.length() - 1); + markDirty(); + } + return true; + } + if (event.character >= 0x20 && event.character <= 0x7E + && (int)_editText.length() < item.maxTextLen) { + _editText += (char)event.character; + markDirty(); + return true; + } + return true; // Consume all keys in text edit mode + } + if (_editing) { // Edit mode: left/right change value, enter confirms, backspace/del cancels auto& item = _items[_selectedIdx]; @@ -432,6 +502,11 @@ bool SettingsScreen::handleKey(const KeyEvent& event) { // Execute action callback if (item.action) item.action(); markDirty(); + } else if (item.type == SettingType::TEXT_INPUT) { + // Enter text edit mode + _textEditing = true; + _editText = item.textGetter ? item.textGetter() : ""; + markDirty(); } else if (item.type == SettingType::TOGGLE) { // Toggle immediately int val = item.getter ? item.getter() : 0; diff --git a/src/ui/screens/SettingsScreen.h b/src/ui/screens/SettingsScreen.h index 243493c..e5ea053 100644 --- a/src/ui/screens/SettingsScreen.h +++ b/src/ui/screens/SettingsScreen.h @@ -21,7 +21,8 @@ enum class SettingType : uint8_t { INTEGER, TOGGLE, ENUM_CHOICE, - ACTION // Button — triggers callback on Enter + ACTION, // Button — triggers callback on Enter + TEXT_INPUT // Editable text field }; struct SettingItem { @@ -37,6 +38,10 @@ struct SettingItem { std::vector enumLabels; // For ACTION: callback on Enter std::function action; + // For TEXT_INPUT: string getter/setter + std::function textGetter; + std::function textSetter; + int maxTextLen = 16; }; class SettingsScreen : public Screen { @@ -87,4 +92,7 @@ private: int _scrollOffset = 0; bool _editing = false; int _editValue = 0; + // For TEXT_INPUT editing + bool _textEditing = false; + String _editText; };