Compare commits
11 Commits
text-entry
...
basic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac5570ebb | ||
|
|
e194f6d307 | ||
|
|
af9f41a541 | ||
|
|
0a746cdca5 | ||
|
|
3a5c48f440 | ||
|
|
e40d9ced4a | ||
|
|
b8de2d0d16 | ||
|
|
9fbc3202f6 | ||
|
|
9d91f48797 | ||
|
|
21eb385763 | ||
|
|
4b81e596d2 |
@@ -43,4 +43,6 @@ public:
|
|||||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
|
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
|
||||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||||
virtual void loop() = 0;
|
virtual void loop() = 0;
|
||||||
};
|
virtual void showAlert(const char* text, int duration_millis) {}
|
||||||
|
virtual void forceRefresh() {}
|
||||||
|
};
|
||||||
@@ -446,9 +446,37 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
||||||
// REVISIT: try to determine which Region (from transport_codes[1]) that Sender is indicating for replies/responses
|
// Check if this incoming flood packet is a repeat of a message we recently sent
|
||||||
// if unknown, fallback to finding Region from transport_codes[0], the 'scope' used by Sender
|
if (packet->payload_len >= SENT_FINGERPRINT_SIZE) {
|
||||||
return false;
|
unsigned long now = millis();
|
||||||
|
for (int i = 0; i < SENT_TRACK_SIZE; i++) {
|
||||||
|
SentMsgTrack* t = &_sent_track[i];
|
||||||
|
if (!t->active) continue;
|
||||||
|
|
||||||
|
// Expire old entries
|
||||||
|
if ((now - t->sent_millis) > SENT_TRACK_EXPIRY_MS) {
|
||||||
|
t->active = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare payload fingerprint
|
||||||
|
if (memcmp(packet->payload, t->fingerprint, SENT_FINGERPRINT_SIZE) == 0) {
|
||||||
|
t->repeat_count++;
|
||||||
|
MESH_DEBUG_PRINTLN("SentTrack: heard repeat #%d (SNR=%.1f)", t->repeat_count, packet->getSNR());
|
||||||
|
|
||||||
|
#ifdef DISPLAY_CLASS
|
||||||
|
if (_ui) {
|
||||||
|
char buf[40];
|
||||||
|
snprintf(buf, sizeof(buf), "Sent! (%d)", t->repeat_count);
|
||||||
|
_ui->showAlert(buf, 2000); // show/extend alert with updated count
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
break; // found match, no need to check other entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // never filter — let normal processing continue
|
||||||
}
|
}
|
||||||
|
|
||||||
void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||||
@@ -463,6 +491,17 @@ void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, ui
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||||
|
// Capture payload fingerprint for repeat tracking before sending
|
||||||
|
if (pkt->payload_len >= SENT_FINGERPRINT_SIZE) {
|
||||||
|
SentMsgTrack* t = &_sent_track[_sent_track_idx];
|
||||||
|
memcpy(t->fingerprint, pkt->payload, SENT_FINGERPRINT_SIZE);
|
||||||
|
t->repeat_count = 0;
|
||||||
|
t->sent_millis = millis();
|
||||||
|
t->active = true;
|
||||||
|
_sent_track_idx = (_sent_track_idx + 1) % SENT_TRACK_SIZE;
|
||||||
|
MESH_DEBUG_PRINTLN("SentTrack: captured fingerprint for channel msg");
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: have per-channel send_scope
|
// TODO: have per-channel send_scope
|
||||||
if (send_scope.isNull()) {
|
if (send_scope.isNull()) {
|
||||||
sendFlood(pkt, delay_millis);
|
sendFlood(pkt, delay_millis);
|
||||||
@@ -541,6 +580,46 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MyMesh::queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text) {
|
||||||
|
// Format message the same way as onChannelMessageRecv for BLE app sync
|
||||||
|
// This allows sent messages from device keyboard to appear in the app
|
||||||
|
int i = 0;
|
||||||
|
if (app_target_ver >= 3) {
|
||||||
|
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV_V3;
|
||||||
|
out_frame[i++] = 0; // SNR not applicable for sent messages
|
||||||
|
out_frame[i++] = 0; // reserved1
|
||||||
|
out_frame[i++] = 0; // reserved2
|
||||||
|
} else {
|
||||||
|
out_frame[i++] = RESP_CODE_CHANNEL_MSG_RECV;
|
||||||
|
}
|
||||||
|
|
||||||
|
out_frame[i++] = channel_idx;
|
||||||
|
out_frame[i++] = 0; // path_len = 0 indicates local/sent message
|
||||||
|
|
||||||
|
out_frame[i++] = TXT_TYPE_PLAIN;
|
||||||
|
memcpy(&out_frame[i], ×tamp, 4);
|
||||||
|
i += 4;
|
||||||
|
|
||||||
|
// Format as "sender: text" like the app expects
|
||||||
|
char formatted[MAX_FRAME_SIZE];
|
||||||
|
snprintf(formatted, sizeof(formatted), "%s: %s", sender, text);
|
||||||
|
int tlen = strlen(formatted);
|
||||||
|
if (i + tlen > MAX_FRAME_SIZE) {
|
||||||
|
tlen = MAX_FRAME_SIZE - i;
|
||||||
|
}
|
||||||
|
memcpy(&out_frame[i], formatted, tlen);
|
||||||
|
i += tlen;
|
||||||
|
|
||||||
|
addToOfflineQueue(out_frame, i);
|
||||||
|
|
||||||
|
// If app is connected, send push notification
|
||||||
|
if (_serial->isConnected()) {
|
||||||
|
uint8_t frame[1];
|
||||||
|
frame[0] = PUSH_CODE_MSG_WAITING;
|
||||||
|
_serial->writeFrame(frame, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
|
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
|
||||||
uint8_t len, uint8_t *reply) {
|
uint8_t len, uint8_t *reply) {
|
||||||
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
|
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
|
||||||
@@ -786,6 +865,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
|||||||
dirty_contacts_expiry = 0;
|
dirty_contacts_expiry = 0;
|
||||||
memset(advert_paths, 0, sizeof(advert_paths));
|
memset(advert_paths, 0, sizeof(advert_paths));
|
||||||
memset(send_scope.key, 0, sizeof(send_scope.key));
|
memset(send_scope.key, 0, sizeof(send_scope.key));
|
||||||
|
memset(_sent_track, 0, sizeof(_sent_track));
|
||||||
|
_sent_track_idx = 0;
|
||||||
|
|
||||||
// defaults
|
// defaults
|
||||||
memset(&_prefs, 0, sizeof(_prefs));
|
memset(&_prefs, 0, sizeof(_prefs));
|
||||||
@@ -1979,4 +2060,4 @@ bool MyMesh::advert() {
|
|||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,11 +8,11 @@
|
|||||||
#define FIRMWARE_VER_CODE 8
|
#define FIRMWARE_VER_CODE 8
|
||||||
|
|
||||||
#ifndef FIRMWARE_BUILD_DATE
|
#ifndef FIRMWARE_BUILD_DATE
|
||||||
#define FIRMWARE_BUILD_DATE "1 Feb 2026"
|
#define FIRMWARE_BUILD_DATE "7 Feb 2026"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef FIRMWARE_VERSION
|
#ifndef FIRMWARE_VERSION
|
||||||
#define FIRMWARE_VERSION "Meck v0.6"
|
#define FIRMWARE_VERSION "Meck v0.6.4"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||||
@@ -101,6 +101,9 @@ public:
|
|||||||
void enterCLIRescue();
|
void enterCLIRescue();
|
||||||
|
|
||||||
int getRecentlyHeard(AdvertPath dest[], int max_num);
|
int getRecentlyHeard(AdvertPath dest[], int max_num);
|
||||||
|
|
||||||
|
// Queue a sent channel message for BLE app sync
|
||||||
|
void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
float getAirtimeBudgetFactor() const override;
|
float getAirtimeBudgetFactor() const override;
|
||||||
@@ -228,6 +231,19 @@ private:
|
|||||||
|
|
||||||
#define ADVERT_PATH_TABLE_SIZE 16
|
#define ADVERT_PATH_TABLE_SIZE 16
|
||||||
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
|
AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table
|
||||||
|
|
||||||
|
// Sent message repeat tracking
|
||||||
|
#define SENT_TRACK_SIZE 4
|
||||||
|
#define SENT_FINGERPRINT_SIZE 12
|
||||||
|
#define SENT_TRACK_EXPIRY_MS 30000 // stop tracking after 30 seconds
|
||||||
|
struct SentMsgTrack {
|
||||||
|
uint8_t fingerprint[SENT_FINGERPRINT_SIZE];
|
||||||
|
uint8_t repeat_count;
|
||||||
|
unsigned long sent_millis;
|
||||||
|
bool active;
|
||||||
|
};
|
||||||
|
SentMsgTrack _sent_track[SENT_TRACK_SIZE];
|
||||||
|
int _sent_track_idx; // next slot in circular buffer
|
||||||
};
|
};
|
||||||
|
|
||||||
extern MyMesh the_mesh;
|
extern MyMesh the_mesh;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
static uint8_t composeChannelIdx = 0; // Which channel to send to
|
static uint8_t composeChannelIdx = 0; // Which channel to send to
|
||||||
static unsigned long lastComposeRefresh = 0;
|
static unsigned long lastComposeRefresh = 0;
|
||||||
static bool composeNeedsRefresh = false;
|
static bool composeNeedsRefresh = false;
|
||||||
#define COMPOSE_REFRESH_INTERVAL 250 // ms between e-ink refreshes while typing
|
#define COMPOSE_REFRESH_INTERVAL 600 // ms between e-ink refreshes while typing (refresh takes ~650ms)
|
||||||
|
|
||||||
void initKeyboard();
|
void initKeyboard();
|
||||||
void handleKeyboardInput();
|
void handleKeyboardInput();
|
||||||
@@ -646,8 +646,6 @@ void drawComposeScreen() {
|
|||||||
void sendComposedMessage() {
|
void sendComposedMessage() {
|
||||||
if (composePos == 0) return;
|
if (composePos == 0) return;
|
||||||
|
|
||||||
MESH_DEBUG_PRINTLN("Sending message to channel %d: %s", composeChannelIdx, composeBuffer);
|
|
||||||
|
|
||||||
// Get the selected channel
|
// Get the selected channel
|
||||||
ChannelDetails channel;
|
ChannelDetails channel;
|
||||||
if (the_mesh.getChannel(composeChannelIdx, channel)) {
|
if (the_mesh.getChannel(composeChannelIdx, channel)) {
|
||||||
@@ -657,20 +655,21 @@ void sendComposedMessage() {
|
|||||||
if (the_mesh.sendGroupMessage(timestamp, channel.channel,
|
if (the_mesh.sendGroupMessage(timestamp, channel.channel,
|
||||||
the_mesh.getNodePrefs()->node_name,
|
the_mesh.getNodePrefs()->node_name,
|
||||||
composeBuffer, composePos)) {
|
composeBuffer, composePos)) {
|
||||||
MESH_DEBUG_PRINTLN("Message sent to channel %s", channel.name);
|
|
||||||
|
|
||||||
// Add the sent message to local channel history so we can see what we sent
|
// Add the sent message to local channel history so we can see what we sent
|
||||||
ui_task.addSentChannelMessage(composeChannelIdx,
|
ui_task.addSentChannelMessage(composeChannelIdx,
|
||||||
the_mesh.getNodePrefs()->node_name,
|
the_mesh.getNodePrefs()->node_name,
|
||||||
composeBuffer);
|
composeBuffer);
|
||||||
|
|
||||||
|
// Queue message for BLE app sync (so sent messages appear in companion app)
|
||||||
|
the_mesh.queueSentChannelMessage(composeChannelIdx, timestamp,
|
||||||
|
the_mesh.getNodePrefs()->node_name,
|
||||||
|
composeBuffer);
|
||||||
|
|
||||||
ui_task.showAlert("Sent!", 1500);
|
ui_task.showAlert("Sent!", 1500);
|
||||||
} else {
|
} else {
|
||||||
MESH_DEBUG_PRINTLN("Failed to send message");
|
|
||||||
ui_task.showAlert("Send failed!", 1500);
|
ui_task.showAlert("Send failed!", 1500);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
MESH_DEBUG_PRINTLN("Could not get channel %d", composeChannelIdx);
|
|
||||||
ui_task.showAlert("No channel!", 1500);
|
ui_task.showAlert("No channel!", 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,30 +103,52 @@ class HomeScreen : public UIScreen {
|
|||||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||||
|
|
||||||
|
|
||||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
||||||
// Convert millivolts to percentage
|
// Use voltage-based estimation to match BLE app readings
|
||||||
const int minMilliVolts = 3000; // Minimum voltage (e.g., 3.0V)
|
uint8_t batteryPercentage = 0;
|
||||||
const int maxMilliVolts = 4200; // Maximum voltage (e.g., 4.2V)
|
if (batteryMilliVolts > 0) {
|
||||||
int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
const int minMilliVolts = 3000;
|
||||||
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
|
const int maxMilliVolts = 4200;
|
||||||
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
|
int pct = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
||||||
|
if (pct < 0) pct = 0;
|
||||||
|
if (pct > 100) pct = 100;
|
||||||
|
batteryPercentage = (uint8_t)pct;
|
||||||
|
}
|
||||||
|
|
||||||
// battery icon
|
|
||||||
int iconWidth = 22;
|
|
||||||
int iconHeight = 8;
|
|
||||||
int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner
|
|
||||||
int iconY = 0;
|
|
||||||
display.setColor(DisplayDriver::GREEN);
|
display.setColor(DisplayDriver::GREEN);
|
||||||
|
|
||||||
|
// battery icon dimensions (smaller to match tiny percentage text)
|
||||||
|
int iconWidth = 16;
|
||||||
|
int iconHeight = 6;
|
||||||
|
|
||||||
|
// measure percentage text width to position icon + text together at right edge
|
||||||
|
display.setTextSize(0);
|
||||||
|
char pctStr[5];
|
||||||
|
sprintf(pctStr, "%d%%", batteryPercentage);
|
||||||
|
uint16_t textWidth = display.getTextWidth(pctStr);
|
||||||
|
|
||||||
|
// layout: [icon 16px][cap 2px][gap 2px][text][margin 2px]
|
||||||
|
int totalWidth = iconWidth + 2 + 2 + textWidth + 2;
|
||||||
|
int iconX = display.width() - totalWidth;
|
||||||
|
int iconY = 0; // vertically align with node name text
|
||||||
|
|
||||||
// battery outline
|
// battery outline
|
||||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||||
|
|
||||||
// battery "cap"
|
// battery "cap"
|
||||||
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
|
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 2, iconHeight / 2);
|
||||||
|
|
||||||
// fill the battery based on the percentage
|
// fill the battery based on the percentage
|
||||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||||
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||||
|
|
||||||
|
// draw percentage text after the battery cap, offset upward to center with icon
|
||||||
|
// (setCursor adds +5 internally for baseline, so compensate for the tiny font)
|
||||||
|
int textX = iconX + iconWidth + 2 + 2; // after cap + gap
|
||||||
|
int textY = iconY - 3; // offset up to vertically center with icon
|
||||||
|
display.setCursor(textX, textY);
|
||||||
|
display.print(pctStr);
|
||||||
|
display.setTextSize(1); // restore default text size
|
||||||
}
|
}
|
||||||
|
|
||||||
CayenneLPP sensors_lpp;
|
CayenneLPP sensors_lpp;
|
||||||
@@ -588,6 +610,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
|||||||
void UITask::showAlert(const char* text, int duration_millis) {
|
void UITask::showAlert(const char* text, int duration_millis) {
|
||||||
strcpy(_alert, text);
|
strcpy(_alert, text);
|
||||||
_alert_expiry = millis() + duration_millis;
|
_alert_expiry = millis() + duration_millis;
|
||||||
|
_next_refresh = millis() + 100; // trigger re-render to show updated text
|
||||||
}
|
}
|
||||||
|
|
||||||
void UITask::notify(UIEventType t) {
|
void UITask::notify(UIEventType t) {
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ public:
|
|||||||
|
|
||||||
void gotoHomeScreen() { setCurrScreen(home); }
|
void gotoHomeScreen() { setCurrScreen(home); }
|
||||||
void gotoChannelScreen(); // Navigate to channel message screen
|
void gotoChannelScreen(); // Navigate to channel message screen
|
||||||
void showAlert(const char* text, int duration_millis);
|
void showAlert(const char* text, int duration_millis) override;
|
||||||
|
void forceRefresh() override { _next_refresh = 100; }
|
||||||
int getMsgCount() const { return _msgcount; }
|
int getMsgCount() const { return _msgcount; }
|
||||||
bool hasDisplay() const { return _display != NULL; }
|
bool hasDisplay() const { return _display != NULL; }
|
||||||
bool isButtonPressed() const;
|
bool isButtonPressed() const;
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ void GxEPDDisplay::startFrame(Color bkg) {
|
|||||||
void GxEPDDisplay::setTextSize(int sz) {
|
void GxEPDDisplay::setTextSize(int sz) {
|
||||||
display_crc.update<int>(sz);
|
display_crc.update<int>(sz);
|
||||||
switch(sz) {
|
switch(sz) {
|
||||||
|
case 0: // Tiny - built-in 6x8 pixel font
|
||||||
|
display.setFont(NULL);
|
||||||
|
display.setTextSize(1);
|
||||||
|
break;
|
||||||
case 1: // Small - use 9pt (was 9pt)
|
case 1: // Small - use 9pt (was 9pt)
|
||||||
display.setFont(&FreeSans9pt7b);
|
display.setFont(&FreeSans9pt7b);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ void TDeckBoard::begin() {
|
|||||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
|
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - GPS Serial2 initialized at %d baud", GPS_BAUDRATE);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Disable 4G modem power (only present on 4G version, not audio version)
|
||||||
|
// This turns off the red status LED on the modem module
|
||||||
|
#ifdef MODEM_POWER_EN
|
||||||
|
pinMode(MODEM_POWER_EN, OUTPUT);
|
||||||
|
digitalWrite(MODEM_POWER_EN, LOW); // Cut power to modem
|
||||||
|
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - 4G modem power disabled");
|
||||||
|
#endif
|
||||||
|
|
||||||
// Configure user button
|
// Configure user button
|
||||||
pinMode(PIN_USER_BTN, INPUT);
|
pinMode(PIN_USER_BTN, INPUT);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user