v1.1.0: UI polish, LXMF messaging fixes, keyboard cleanup

- Remove boot display test (color bars + 1.5s delay)
- Show identity hashes on NodesScreen (colon-formatted, matching own identity display)
- Show node names in MessagesScreen and MessageView instead of raw hex
- Add message status indicators in chat (*/!/~ for sent/failed/queued)
- Fix LXMF send: retry Identity::recall() up to 5 times before failing
- Add LXMF logging throughout send/receive/queue drain
- Remove Alt+IJML arrow key mapping (obsolete with trackball)
- Filter own node from discovered nodes list
- Update troubleshooting docs with SetModulationParams STDBY fix
This commit is contained in:
DeFiDude
2026-03-06 13:34:15 -07:00
parent 1255f0db51
commit 80d4cd45f6
14 changed files with 117 additions and 41 deletions

View File

@@ -138,6 +138,18 @@ On first boot with a new SD card, the `/ratputer/` directory tree doesn't exist.
- **Ratputer TX confirmed**: All SX1262 registers verified correct (SF7, BW 500kHz, CR 4/5, sync 0x1424, CRC on)
- **Heltec V3 RNode receive path**: Has never decoded a single LoRa packet from any source. Shows Ratputer RF as interference (-50 to -81 dBm) but can't decode. This is a Heltec issue, not Ratputer.
### SetModulationParams must be called from STDBY mode
The SX1262 silently rejects `SetModulationParams` (0x8B) when issued from RX or TX mode. Only STDBY_RC and STDBY_XOSC are valid. The command appears to succeed (no error, BUSY goes low), but the hardware ignores the new SF/BW/CR values.
**Symptom**: Radio logs show correct SF/BW/CR (read from software variables), but actual TX airtime is wrong. For example, software says SF9 (expected ~900ms for 168 bytes) but actual TX completes in ~280ms (SF7). Both devices see each other's RF (RSSI visible) but every packet fails CRC. SNR is consistently -17 to -20 dB despite short range.
**Diagnosis**: Add timing instrumentation to `endPacket()` — measure `millis()` from `OP_TX_6X` to `TX_DONE` IRQ. Compare against expected airtime for the configured SF. If actual << expected, the SF didn't apply.
**Root cause**: `setSpreadingFactor()` / `setSignalBandwidth()` / `setCodingRate4()` were called from `main.cpp` after `begin()` had already entered RX mode via `receive()`. The SX1262 silently dropped the `SetModulationParams` command.
**Fix**: `setModulationParams()` now calls `standby()` before issuing the SPI command, ensuring the radio is in a valid mode to accept configuration changes.
### SX1262 calibration must run after TCXO is enabled
Per SX1262 datasheet Section 13.1.12, if a TCXO is used, it **must** be enabled before calling `calibrate()` or `calibrate_image()`. Calibration locks to whichever oscillator is active. If TCXO isn't enabled yet, calibration uses the internal RC oscillator (~13MHz, ±3% tolerance). Each chip's RC has a different offset, so two devices end up synthesizing slightly different actual frequencies. The combined error can exceed the LoRa demodulation window.

View File

@@ -9,17 +9,6 @@ bool Display::begin() {
Serial.printf("[DISPLAY] Initialized: %dx%d (rotation=1, LovyanGFX direct)\n",
_gfx.width(), _gfx.height());
// Quick visual test: draw colored rectangles directly
_gfx.fillRect(0, 0, 107, 120, TFT_RED);
_gfx.fillRect(107, 0, 107, 120, TFT_GREEN);
_gfx.fillRect(214, 0, 106, 120, TFT_BLUE);
_gfx.setTextColor(TFT_WHITE, TFT_BLACK);
_gfx.setTextSize(2);
_gfx.setCursor(60, 140);
_gfx.print("RATDECK DISPLAY TEST");
delay(1500);
_gfx.fillScreen(TFT_BLACK);
return true;
}

View File

@@ -20,7 +20,6 @@ bool Keyboard::begin() {
}
Serial.println("[KEYBOARD] ESP32-C3 keyboard ready");
Serial.println("[KEYBOARD] Alt+I/M/J/L = Up/Down/Left/Right, Backspace = Back");
return true;
}
@@ -66,12 +65,6 @@ void Keyboard::update() {
if (altFromMod) {
_event.alt = true;
// Map Alt+IJKL/M to arrow keys
char lower = tolower(key);
if (lower == 'i') { _event.up = true; _hasEvent = true; return; }
if (lower == 'm') { _event.down = true; _hasEvent = true; return; }
if (lower == 'j') { _event.left = true; _hasEvent = true; return; }
if (lower == 'l') { _event.right = true; _hasEvent = true; return; }
}
// Standard key decoding

View File

@@ -22,7 +22,7 @@ struct KeyEvent {
bool del;
bool tab;
bool space;
// Directional arrows (from Alt+IJKL/M or trackball)
// Directional arrows (from trackball)
bool up;
bool down;
bool left;

View File

@@ -407,6 +407,7 @@ void setup() {
bootRender();
announceManager = new AnnounceManager();
announceManager->setStorage(&sdStore, &flash);
announceManager->setLocalDestHash(rns.destination().hash());
announceManager->loadContacts();
announceHandler = RNS::HAnnounceHandler(announceManager);
RNS::Transport::register_announce_handler(announceHandler);
@@ -545,12 +546,14 @@ void setup() {
});
messagesScreen.setLXMFManager(&lxmf);
messagesScreen.setAnnounceManager(announceManager);
messagesScreen.setOpenCallback([](const std::string& peerHex) {
messageView.setPeerHex(peerHex);
ui.setScreen(&messageView);
});
messageView.setLXMFManager(&lxmf);
messageView.setAnnounceManager(announceManager);
messageView.setBackCallback([]() {
ui.setScreen(&messagesScreen);
});
@@ -632,14 +635,14 @@ void loop() {
// Screen gets the key next
bool consumed = ui.handleKey(evt);
// Tab cycling: ,=left /=right or Alt+J/L arrows (only if screen didn't consume)
// Tab cycling: ,=left /=right (only if screen didn't consume)
if (!consumed && !evt.ctrl) {
if (evt.left || evt.character == ',') {
if (evt.character == ',') {
ui.tabBar().cycleTab(-1);
int tab = ui.tabBar().getActiveTab();
if (tabScreens[tab]) ui.setScreen(tabScreens[tab]);
}
if (evt.right || evt.character == '/') {
if (evt.character == '/') {
ui.tabBar().cycleTab(1);
int tab = ui.tabBar().getActiveTab();
if (tabScreens[tab]) ui.setScreen(tabScreens[tab]);

View File

@@ -55,11 +55,17 @@ void AnnounceManager::received_announce(
if (rawName.empty()) rawName = app_data.toString();
name = sanitizeName(rawName);
}
// Filter out own announces
if (_localDestHash.size() > 0 && destination_hash == _localDestHash) return;
Serial.printf("[ANNOUNCE] From: %s name=\"%s\"\n", destination_hash.toHex().c_str(), name.c_str());
std::string idHex = announced_identity.hexhash();
for (auto& node : _nodes) {
if (node.hash == destination_hash) {
if (!name.empty()) node.name = name;
if (!idHex.empty()) node.identityHex = idHex;
node.lastSeen = millis();
node.hops = RNS::Transport::hops_to(destination_hash);
if (node.saved) saveContact(node);
@@ -86,6 +92,7 @@ void AnnounceManager::received_announce(
DiscoveredNode node;
node.hash = destination_hash;
node.name = name.empty() ? destination_hash.toHex().substr(0, 12) : name;
node.identityHex = idHex;
node.lastSeen = millis();
node.hops = RNS::Transport::hops_to(destination_hash);
_nodes.push_back(node);
@@ -96,6 +103,11 @@ const DiscoveredNode* AnnounceManager::findNode(const RNS::Bytes& hash) const {
return nullptr;
}
const DiscoveredNode* AnnounceManager::findNodeByHex(const std::string& hexHash) const {
for (const auto& n : _nodes) { if (n.hash.toHex() == hexHash) return &n; }
return nullptr;
}
void AnnounceManager::addManualContact(const std::string& hexHash, const std::string& name) {
RNS::Bytes hash;
hash.assignHex(hexHash.c_str());

View File

@@ -12,6 +12,7 @@ class FlashStore;
struct DiscoveredNode {
RNS::Bytes hash;
std::string name;
std::string identityHex;
int rssi = 0;
float snr = 0;
uint8_t hops = 0;
@@ -30,12 +31,14 @@ public:
const RNS::Bytes& app_data) override;
void setStorage(SDStore* sd, FlashStore* flash);
void setLocalDestHash(const RNS::Bytes& hash) { _localDestHash = hash; }
void saveContacts();
void loadContacts();
const std::vector<DiscoveredNode>& nodes() const { return _nodes; }
int nodeCount() const { return _nodes.size(); }
const DiscoveredNode* findNode(const RNS::Bytes& hash) const;
const DiscoveredNode* findNodeByHex(const std::string& hexHash) const;
void addManualContact(const std::string& hexHash, const std::string& name);
void evictStale(unsigned long maxAgeMs = 3600000);
@@ -46,5 +49,6 @@ private:
std::vector<DiscoveredNode> _nodes;
SDStore* _sd = nullptr;
FlashStore* _flash = nullptr;
RNS::Bytes _localDestHash;
static constexpr int MAX_NODES = 200; // PSRAM allows more
};

View File

@@ -15,12 +15,13 @@ bool LXMFManager::begin(ReticulumManager* rns, MessageStore* store) {
}
void LXMFManager::loop() {
while (!_outQueue.empty()) {
LXMFMessage& msg = _outQueue.front();
if (sendDirect(msg)) {
if (_store) { _store->saveMessage(msg); }
_outQueue.pop_front();
} else { break; }
if (_outQueue.empty()) return;
LXMFMessage& msg = _outQueue.front();
if (sendDirect(msg)) {
Serial.printf("[LXMF] Queue drain: status=%s dest=%s\n",
msg.statusStr(), msg.destHash.toHex().substr(0, 8).c_str());
if (_store) { _store->saveMessage(msg); }
_outQueue.pop_front();
}
}
@@ -42,8 +43,16 @@ bool LXMFManager::sendMessage(const RNS::Bytes& destHash, const std::string& con
bool LXMFManager::sendDirect(LXMFMessage& msg) {
RNS::Identity recipientId = RNS::Identity::recall(msg.destHash);
if (!recipientId) {
msg.status = LXMFStatus::FAILED;
return true;
msg.retries++;
if (msg.retries >= 5) {
Serial.printf("[LXMF] recall failed for %s after %d retries — marking FAILED\n",
msg.destHash.toHex().substr(0, 8).c_str(), msg.retries);
msg.status = LXMFStatus::FAILED;
return true;
}
Serial.printf("[LXMF] recall failed for %s (retry %d/5) — keeping queued\n",
msg.destHash.toHex().substr(0, 8).c_str(), msg.retries);
return false; // keep in queue, retry next loop
}
RNS::Destination outDest(recipientId, RNS::Type::Destination::OUT,
RNS::Type::Destination::SINGLE, "lxmf", "delivery");
@@ -79,9 +88,14 @@ void LXMFManager::onLinkEstablished(RNS::Link& link) {
void LXMFManager::processIncoming(const uint8_t* data, size_t len, const RNS::Bytes& destHash) {
LXMFMessage msg;
if (!LXMFMessage::unpackFull(data, len, msg)) return;
if (!LXMFMessage::unpackFull(data, len, msg)) {
Serial.printf("[LXMF] Failed to unpack incoming message (%d bytes)\n", (int)len);
return;
}
if (_rns && msg.sourceHash == _rns->destination().hash()) return;
msg.destHash = destHash;
Serial.printf("[LXMF] Message from %s (%d bytes) content_len=%d\n",
msg.sourceHash.toHex().substr(0, 8).c_str(), (int)len, (int)msg.content.size());
if (_store) { _store->saveMessage(msg); }
std::string peerHex = msg.sourceHash.toHex();
_unread[peerHex]++;

View File

@@ -21,6 +21,7 @@ struct LXMFMessage {
LXMFStatus status = LXMFStatus::DRAFT;
bool incoming = false;
bool read = false;
int retries = 0;
RNS::Bytes messageId;
static std::vector<uint8_t> packContent(double timestamp, const std::string& content, const std::string& title);

View File

@@ -2,6 +2,7 @@
#include "ui/Theme.h"
#include "hal/Display.h"
#include "reticulum/LXMFManager.h"
#include "reticulum/AnnounceManager.h"
#include <Arduino.h>
void MessageView::onEnter() {
@@ -27,11 +28,17 @@ void MessageView::update() {
void MessageView::draw(LGFX_TDeck& gfx) {
gfx.setTextSize(1);
// Header
// Header — show node name if known
gfx.setTextColor(Theme::ACCENT, Theme::BG);
gfx.setCursor(4, Theme::CONTENT_Y + 2);
char header[32];
snprintf(header, sizeof(header), "< %s", _peerHex.substr(0, 12).c_str());
std::string headerName;
if (_am) {
const DiscoveredNode* node = _am->findNodeByHex(_peerHex);
if (node && !node->name.empty()) headerName = node->name;
}
if (headerName.empty()) headerName = _peerHex.substr(0, 12);
char header[48];
snprintf(header, sizeof(header), "< %s", headerName.c_str());
gfx.print(header);
// Divider under header
@@ -91,9 +98,15 @@ void MessageView::draw(LGFX_TDeck& gfx) {
gfx.setTextColor(Theme::ACCENT, Theme::BG);
gfx.setCursor(4, y);
} else {
// Status indicator: * = sent, ! = failed, ~ = queued/sending
const char* indicator = "~";
if (msg.status == LXMFStatus::SENT || msg.status == LXMFStatus::DELIVERED) indicator = "*";
else if (msg.status == LXMFStatus::FAILED) indicator = "!";
gfx.setTextColor(Theme::PRIMARY, Theme::BG);
// Right-align outgoing
int tw = std::min((int)msg.content.length(), 40) * 6;
// Right-align outgoing + status
int contentLen = std::min((int)msg.content.length(), 38);
int tw = (contentLen + 2) * 6; // +2 for space + indicator
gfx.setCursor(Theme::SCREEN_W - tw - 4, y);
}
@@ -104,6 +117,20 @@ void MessageView::draw(LGFX_TDeck& gfx) {
} else {
gfx.print(msg.content.c_str());
}
// Show status indicator for outgoing messages
if (!msg.incoming) {
const char* ind = "~";
uint32_t indColor = Theme::MUTED;
if (msg.status == LXMFStatus::SENT || msg.status == LXMFStatus::DELIVERED) {
ind = "*"; indColor = Theme::ACCENT;
} else if (msg.status == LXMFStatus::FAILED) {
ind = "!"; indColor = TFT_RED;
}
gfx.setTextColor(indColor, Theme::BG);
gfx.print(" ");
gfx.print(ind);
}
y += lineH;
}
}

View File

@@ -5,6 +5,7 @@
#include <string>
class LXMFManager;
class AnnounceManager;
class MessageView : public Screen {
public:
@@ -17,6 +18,7 @@ public:
void setPeerHex(const std::string& hex) { _peerHex = hex; }
void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; }
void setAnnounceManager(AnnounceManager* am) { _am = am; }
void setBackCallback(BackCallback cb) { _onBack = cb; }
const char* title() const override { return "Chat"; }
@@ -26,6 +28,7 @@ private:
void sendCurrentMessage();
LXMFManager* _lxmf = nullptr;
AnnounceManager* _am = nullptr;
BackCallback _onBack;
std::string _peerHex;
std::string _inputText;

View File

@@ -2,6 +2,7 @@
#include "ui/Theme.h"
#include "hal/Display.h"
#include "reticulum/LXMFManager.h"
#include "reticulum/AnnounceManager.h"
#include <Arduino.h>
void MessagesScreen::onEnter() {
@@ -46,10 +47,17 @@ void MessagesScreen::draw(LGFX_TDeck& gfx) {
gfx.fillRect(0, y, Theme::SCREEN_W, rowH, Theme::SELECTION_BG);
}
// Peer hash
// Peer name (lookup from AnnounceManager) or fallback to hex
std::string displayName;
if (_am) {
const DiscoveredNode* node = _am->findNodeByHex(peerHex);
if (node && !node->name.empty()) displayName = node->name;
}
if (displayName.empty()) displayName = peerHex.substr(0, 16);
gfx.setTextColor(Theme::PRIMARY, (int)i == _selectedIdx ? Theme::SELECTION_BG : Theme::BG);
gfx.setCursor(4, y + 6);
gfx.print(peerHex.substr(0, 16).c_str());
gfx.print(displayName.c_str());
// Unread badge
if (unread > 0) {

View File

@@ -5,6 +5,7 @@
#include <string>
class LXMFManager;
class AnnounceManager;
class MessagesScreen : public Screen {
public:
@@ -15,6 +16,7 @@ public:
bool handleKey(const KeyEvent& event) override;
void setLXMFManager(LXMFManager* lxmf) { _lxmf = lxmf; }
void setAnnounceManager(AnnounceManager* am) { _am = am; }
void setOpenCallback(OpenCallback cb) { _onOpen = cb; }
const char* title() const override { return "Messages"; }
@@ -22,6 +24,7 @@ public:
private:
LXMFManager* _lxmf = nullptr;
AnnounceManager* _am = nullptr;
OpenCallback _onOpen;
int _lastConvCount = -1;
int _selectedIdx = 0;

View File

@@ -46,10 +46,17 @@ void NodesScreen::draw(LGFX_TDeck& gfx) {
uint32_t bgCol = (int)i == _selectedIdx ? Theme::SELECTION_BG : Theme::BG;
// Name + hash
std::string hashHex = node.hash.toHex();
// Name + identity hash (formatted with colons like own identity)
std::string displayHash;
if (!node.identityHex.empty() && node.identityHex.size() >= 12) {
displayHash = node.identityHex.substr(0, 4) + ":" +
node.identityHex.substr(4, 4) + ":" +
node.identityHex.substr(8, 4);
} else {
displayHash = node.hash.toHex().substr(0, 8);
}
char buf[64];
snprintf(buf, sizeof(buf), "%s [%s]", node.name.c_str(), hashHex.substr(0, 8).c_str());
snprintf(buf, sizeof(buf), "%s [%s]", node.name.c_str(), displayHash.c_str());
gfx.setTextColor(node.saved ? Theme::ACCENT : Theme::PRIMARY, bgCol);
gfx.setCursor(4, y + 5);
gfx.print(buf);