v1.2.2: Fix trackball, add display name support, announce with app_data

- Fix trackball completely dead: remove gpio_reset_pin() calls that
  prevented GPIO interrupts from registering on ESP32-S3
- Add trackball left/right tab cycling when screen doesn't consume event
- Add NameInputScreen: Ratspeak-branded first-boot name input
- Add display name to all announces as MessagePack app_data
- Add TEXT_INPUT setting type; expose Display Name in Settings
- HomeScreen Enter key sends announce with toast notification
- Bump version to 1.2.2
This commit is contained in:
DeFiDude
2026-03-06 16:28:18 -07:00
parent 5d038aec9e
commit d518921eed
9 changed files with 295 additions and 40 deletions
+3 -3
View File
@@ -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
+1 -19
View File
@@ -1,5 +1,4 @@
#include "Trackball.h"
#include <driver/gpio.h>
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;
}
+70 -14
View File
@@ -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");
}
+8
View File
@@ -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;
+4
View File
@@ -1,6 +1,7 @@
#pragma once
#include "ui/UIManager.h"
#include <functional>
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<void()> 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<void()> _announceCb;
};
+102
View File
@@ -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
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "ui/UIManager.h"
#include <functional>
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<void(const String&)> cb) { _doneCb = cb; }
static constexpr int MAX_NAME_LEN = 16;
private:
char _name[MAX_NAME_LEN + 1] = {0};
int _nameLen = 0;
std::function<void(const String&)> _doneCb;
};
+78 -3
View File
@@ -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;
+9 -1
View File
@@ -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<const char*> enumLabels;
// For ACTION: callback on Enter
std::function<void()> action;
// For TEXT_INPUT: string getter/setter
std::function<String()> textGetter;
std::function<void(const String&)> 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;
};