mirror of
https://github.com/pelgraine/Meck.git
synced 2026-03-28 17:42:44 +01:00
Compare commits
7 Commits
v1.0
...
audio-upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d1b99b77 | ||
|
|
e056ea3c2c | ||
|
|
b2967fc1a7 | ||
|
|
addcbcd00e | ||
|
|
8cdf19a848 | ||
|
|
5019d12fb0 | ||
|
|
306e9815b4 |
@@ -523,6 +523,13 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
|
||||
const char *text) {
|
||||
markConnectionActive(from); // in case this is from a server, and we have a connection
|
||||
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text);
|
||||
|
||||
// Forward CLI response to UI admin screen if admin session is active
|
||||
#ifdef DISPLAY_CLASS
|
||||
if (_admin_contact_idx >= 0 && _ui) {
|
||||
_ui->onAdminCliResponse(from.name, text);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
@@ -650,6 +657,53 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
uint32_t est_timeout;
|
||||
int result = sendLogin(*recipient, password, est_timeout);
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: Admin login send failed to %s", recipient->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
clearPendingReqs();
|
||||
memcpy(&pending_login, recipient->id.pub_key, 4);
|
||||
_admin_contact_idx = contact_idx;
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: Admin login sent to %s (%s), timeout=%dms",
|
||||
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
|
||||
est_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MyMesh::uiSendCliCommand(uint32_t contact_idx, const char* command) {
|
||||
ContactInfo contact;
|
||||
if (!getContactByIdx(contact_idx, contact)) return false;
|
||||
|
||||
ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE);
|
||||
if (!recipient) return false;
|
||||
|
||||
uint32_t timestamp = getRTCClock()->getCurrentTimeUnique();
|
||||
uint32_t est_timeout;
|
||||
int result = sendCommandData(*recipient, timestamp, 0, command, est_timeout);
|
||||
if (result == MSG_SEND_FAILED) {
|
||||
MESH_DEBUG_PRINTLN("UI: CLI command send failed to %s: %s", recipient->name, command);
|
||||
return false;
|
||||
}
|
||||
|
||||
_admin_contact_idx = contact_idx;
|
||||
|
||||
MESH_DEBUG_PRINTLN("UI: CLI command sent to %s (%s): %s, timeout=%dms",
|
||||
recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct",
|
||||
command, est_timeout);
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data,
|
||||
uint8_t len, uint8_t *reply) {
|
||||
if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) {
|
||||
@@ -708,6 +762,11 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
out_frame[i++] = 0; // legacy: is_admin = false
|
||||
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
||||
i += 6; // pub_key_prefix
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Notify UI of successful legacy login
|
||||
if (_ui) _ui->onAdminLoginResult(true, 0, tag);
|
||||
#endif
|
||||
} else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response
|
||||
uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16;
|
||||
if (keep_alive_secs > 0) {
|
||||
@@ -721,11 +780,21 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data,
|
||||
i += 4; // NEW: include server timestamp
|
||||
out_frame[i++] = data[7]; // NEW (v7): ACL permissions
|
||||
out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Notify UI of successful login
|
||||
if (_ui) _ui->onAdminLoginResult(true, data[6], tag);
|
||||
#endif
|
||||
} else {
|
||||
out_frame[i++] = PUSH_CODE_LOGIN_FAIL;
|
||||
out_frame[i++] = 0; // reserved
|
||||
memcpy(&out_frame[i], contact.id.pub_key, 6);
|
||||
i += 6; // pub_key_prefix
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Notify UI of login failure
|
||||
if (_ui) _ui->onAdminLoginResult(false, 0, 0);
|
||||
#endif
|
||||
}
|
||||
_serial->writeFrame(out_frame, i);
|
||||
} else if (len > 4 && // check for status response
|
||||
@@ -897,6 +966,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
memset(send_scope.key, 0, sizeof(send_scope.key));
|
||||
memset(_sent_track, 0, sizeof(_sent_track));
|
||||
_sent_track_idx = 0;
|
||||
_admin_contact_idx = -1;
|
||||
|
||||
// defaults
|
||||
memset(&_prefs, 0, sizeof(_prefs));
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
#define FIRMWARE_VER_CODE 8
|
||||
|
||||
#ifndef FIRMWARE_BUILD_DATE
|
||||
#define FIRMWARE_BUILD_DATE "13 Feb 2026"
|
||||
#define FIRMWARE_BUILD_DATE "15 Feb 2026"
|
||||
#endif
|
||||
|
||||
#ifndef FIRMWARE_VERSION
|
||||
#define FIRMWARE_VERSION "Meck v0.8.6"
|
||||
#define FIRMWARE_VERSION "Meck v0.8.9"
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "ContactsScreen.h"
|
||||
#include "ChannelScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus
|
||||
|
||||
TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire);
|
||||
@@ -320,9 +321,6 @@ void setup() {
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("setup() - radio_init() done");
|
||||
|
||||
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()");
|
||||
fast_rng.begin(radio_get_rng_seed());
|
||||
MESH_DEBUG_PRINTLN("setup() - fast_rng.begin() done");
|
||||
@@ -407,7 +405,7 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Early SD card init  needed BEFORE the_mesh.begin() so we can restore
|
||||
// Early SD card init  needed BEFORE the_mesh.begin() so we can restore
|
||||
// settings from a previous firmware flash. The display SPI bus is already
|
||||
// up (display.begin() ran earlier), so SD can share it now.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -543,7 +541,7 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// GPS duty cycle — honour saved pref, default to enabled on first boot
|
||||
// GPS duty cycle  honour saved pref, default to enabled on first boot
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
|
||||
@@ -558,6 +556,9 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// CPU frequency scaling  drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
// T-Deck Pro: BLE starts disabled for standalone-first operation
|
||||
// User can toggle it on from the Bluetooth home page (Enter or long-press)
|
||||
#if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE)
|
||||
@@ -571,7 +572,7 @@ void setup() {
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
|
||||
// GPS duty cycle — check for fix and manage power state
|
||||
// GPS duty cycle  check for fix and manage power state
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_hw_on = gpsDuty.loop();
|
||||
@@ -982,11 +983,61 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// All other keys → settings screen via injectKey
|
||||
// All other keys → settings screen via injectKey
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// *** REPEATER ADMIN MODE ***
|
||||
if (ui_task.isOnRepeaterAdmin()) {
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
|
||||
RepeaterAdminScreen::AdminState astate = admin->getState();
|
||||
bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed());
|
||||
|
||||
// In password entry: Shift+Del exits, all other keys pass through normally
|
||||
if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) {
|
||||
if (shiftDel) {
|
||||
Serial.println("Nav: Back to contacts from admin login");
|
||||
ui_task.gotoContactsScreen();
|
||||
} else {
|
||||
ui_task.injectKey(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In menu state: Shift+Del exits to contacts, C opens compose
|
||||
if (astate == RepeaterAdminScreen::STATE_MENU) {
|
||||
if (shiftDel) {
|
||||
Serial.println("Nav: Back to contacts from admin menu");
|
||||
ui_task.gotoContactsScreen();
|
||||
return;
|
||||
}
|
||||
// C key: allow entering compose mode from admin menu
|
||||
if (key == 'c' || key == 'C') {
|
||||
composeDM = false;
|
||||
composeDMContactIdx = -1;
|
||||
composeMode = true;
|
||||
composeBuffer[0] = '\0';
|
||||
composePos = 0;
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
return;
|
||||
}
|
||||
// All other keys pass to admin screen
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// In waiting/response/error states: convert Shift+Del to exit signal,
|
||||
// pass all other keys through
|
||||
if (shiftDel) {
|
||||
ui_task.injectKey(KEY_ADMIN_EXIT);
|
||||
} else {
|
||||
ui_task.injectKey(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode - not composing
|
||||
switch (key) {
|
||||
case 'c':
|
||||
@@ -1022,8 +1073,8 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case 's':
|
||||
// Open settings (from home), or navigate down on channel/contacts
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
// Open settings (from home), or navigate down on channel/contacts/admin
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
|
||||
ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling
|
||||
} else {
|
||||
Serial.println("Opening settings");
|
||||
@@ -1033,7 +1084,7 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'w':
|
||||
// Navigate up/previous (scroll on channel screen)
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) {
|
||||
ui_task.injectKey('w'); // Pass directly for channel/contacts switching
|
||||
} else {
|
||||
Serial.println("Nav: Previous");
|
||||
@@ -1062,7 +1113,8 @@ void handleKeyboardInput() {
|
||||
break;
|
||||
|
||||
case '\r':
|
||||
// Enter = compose (only from channel or contacts screen)
|
||||
// Select/Enter - if on contacts screen, enter DM compose for chat contacts
|
||||
// or repeater admin for repeater contacts
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||||
int idx = cs->getSelectedContactIdx();
|
||||
@@ -1077,8 +1129,15 @@ void handleKeyboardInput() {
|
||||
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
|
||||
drawComposeScreen();
|
||||
lastComposeRefresh = millis();
|
||||
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
|
||||
// Open repeater admin screen
|
||||
char rname[32];
|
||||
cs->getSelectedContactName(rname, sizeof(rname));
|
||||
Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx);
|
||||
ui_task.gotoRepeaterAdmin(idx);
|
||||
} else if (idx >= 0) {
|
||||
Serial.printf("Selected non-chat contact type=%d idx=%d\n", ctype, idx);
|
||||
// Non-chat, non-repeater contact (room, sensor, etc.) - future use
|
||||
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
|
||||
}
|
||||
} else if (ui_task.isOnChannelScreen()) {
|
||||
composeDM = false;
|
||||
@@ -1098,7 +1157,7 @@ void handleKeyboardInput() {
|
||||
|
||||
case 'q':
|
||||
case '\b':
|
||||
// Go back to home screen
|
||||
// Go back to home screen (admin mode handled above)
|
||||
Serial.println("Nav: Back to home");
|
||||
ui_task.gotoHomeScreen();
|
||||
break;
|
||||
|
||||
@@ -12,6 +12,7 @@ extern MyMesh the_mesh;
|
||||
#define ADMIN_PASSWORD_MAX 32
|
||||
#define ADMIN_RESPONSE_MAX 512 // CLI responses can be multi-line
|
||||
#define ADMIN_TIMEOUT_MS 15000 // 15s timeout for login/commands
|
||||
#define KEY_ADMIN_EXIT 0xFE // Special key: Shift+Backspace exit (injected by main.cpp)
|
||||
|
||||
class RepeaterAdminScreen : public UIScreen {
|
||||
public:
|
||||
@@ -280,31 +281,34 @@ public:
|
||||
|
||||
switch (_state) {
|
||||
case STATE_PASSWORD_ENTRY:
|
||||
display.print("Q:Back");
|
||||
display.print("Sh+Del:Exit");
|
||||
{
|
||||
const char* right = "Enter:Login";
|
||||
const char* right = "Ent:Login";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
display.print("Q:Cancel");
|
||||
display.print("Sh+Del:Cancel");
|
||||
break;
|
||||
case STATE_MENU:
|
||||
display.print("Q:Back");
|
||||
display.print("Sh+Del:Exit");
|
||||
{
|
||||
const char* mid = "W/S:Sel";
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
const char* right = "Ent:Run";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
int leftEnd = display.getTextWidth("Sh+Del:Exit") + 2;
|
||||
int rightStart = display.width() - display.getTextWidth(right) - 2;
|
||||
int midX = leftEnd + (rightStart - leftEnd - display.getTextWidth(mid)) / 2;
|
||||
display.setCursor(midX, footerY);
|
||||
display.print(mid);
|
||||
display.setCursor(rightStart, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
break;
|
||||
case STATE_RESPONSE_VIEW:
|
||||
case STATE_ERROR:
|
||||
display.print("Q:Menu");
|
||||
display.print("Sh+Del:Menu");
|
||||
if (_responseLen > bodyHeight / 9) { // if scrollable
|
||||
const char* right = "W/S:Scrll";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
@@ -327,8 +331,8 @@ public:
|
||||
return handlePasswordInput(c);
|
||||
case STATE_LOGGING_IN:
|
||||
case STATE_COMMAND_PENDING:
|
||||
// Q to cancel and go back
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Shift+Del to cancel and go back
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
_state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU;
|
||||
return true;
|
||||
}
|
||||
@@ -370,9 +374,9 @@ private:
|
||||
}
|
||||
|
||||
bool handlePasswordInput(char c) {
|
||||
// Q without any password typed = go back (return false to signal "not handled")
|
||||
if ((c == 'q' || c == 'Q') && _pwdLen == 0) {
|
||||
return false;
|
||||
// Shift+Del = exit (always, regardless of password content)
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
return false; // signal main.cpp to navigate back
|
||||
}
|
||||
|
||||
// Enter to submit
|
||||
@@ -472,8 +476,8 @@ private:
|
||||
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
|
||||
return executeMenuCommand((MenuItem)_menuSel);
|
||||
}
|
||||
// Q - back to contacts
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Shift+Del - back to contacts
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
return false; // let UITask handle back navigation
|
||||
}
|
||||
// Number keys for quick selection
|
||||
@@ -535,8 +539,8 @@ private:
|
||||
_responseScroll++;
|
||||
return true;
|
||||
}
|
||||
// Q - back to menu (or back to password on error)
|
||||
if (c == 'q' || c == 'Q') {
|
||||
// Shift+Del - back to menu (or back to password on error)
|
||||
if (c == KEY_ADMIN_EXIT) {
|
||||
if (_state == STATE_ERROR && _permissions == 0) {
|
||||
// Not yet logged in, go back to password
|
||||
_state = STATE_PASSWORD_ENTRY;
|
||||
|
||||
@@ -24,10 +24,22 @@ struct RadioPreset {
|
||||
};
|
||||
|
||||
static const RadioPreset RADIO_PRESETS[] = {
|
||||
{ "MeshCore Default", 915.0f, 250.0f, 10, 5, 20 },
|
||||
{ "Long Range", 915.0f, 125.0f, 12, 8, 20 },
|
||||
{ "Fast/Short", 915.0f, 500.0f, 7, 5, 20 },
|
||||
{ "EU Default", 869.4f, 250.0f, 10, 5, 14 },
|
||||
{ "Australia", 915.800f, 250.0f, 10, 5, 22 },
|
||||
{ "Australia (Narrow)", 916.575f, 62.5f, 7, 8, 22 },
|
||||
{ "Australia: SA, WA", 923.125f, 62.5f, 8, 8, 22 },
|
||||
{ "Australia: QLD", 923.125f, 62.5f, 8, 5, 22 },
|
||||
{ "EU/UK (Narrow)", 869.618f, 62.5f, 8, 8, 14 },
|
||||
{ "EU/UK (Long Range)", 869.525f, 250.0f, 11, 5, 14 },
|
||||
{ "EU/UK (Medium Range)", 869.525f, 250.0f, 10, 5, 14 },
|
||||
{ "Czech Republic (Narrow)",869.432f, 62.5f, 7, 5, 14 },
|
||||
{ "EU 433 (Long Range)", 433.650f, 250.0f, 11, 5, 14 },
|
||||
{ "New Zealand", 917.375f, 250.0f, 11, 5, 22 },
|
||||
{ "New Zealand (Narrow)", 917.375f, 62.5f, 7, 5, 22 },
|
||||
{ "Portugal 433", 433.375f, 62.5f, 9, 6, 14 },
|
||||
{ "Portugal 868", 869.618f, 62.5f, 7, 6, 14 },
|
||||
{ "Switzerland", 869.618f, 62.5f, 8, 8, 14 },
|
||||
{ "USA/Canada (Recommended)",910.525f, 62.5f, 7, 5, 22 },
|
||||
{ "Vietnam", 920.250f, 250.0f, 11, 5, 22 },
|
||||
};
|
||||
#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0]))
|
||||
|
||||
@@ -72,7 +84,7 @@ private:
|
||||
mesh::RTCClock* _rtc;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
// Row table — rebuilt whenever channels change
|
||||
// Row table — rebuilt whenever channels change
|
||||
struct Row {
|
||||
SettingsRowType type;
|
||||
uint8_t param; // channel index for ROW_CHANNEL, preset index for ROW_RADIO_PRESET
|
||||
@@ -96,7 +108,7 @@ private:
|
||||
// Onboarding mode
|
||||
bool _onboarding;
|
||||
|
||||
// Dirty flag for radio params — prompt to apply
|
||||
// Dirty flag for radio params — prompt to apply
|
||||
bool _radioChanged;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -198,11 +210,11 @@ private:
|
||||
strncpy(newCh.name, chanName, sizeof(newCh.name));
|
||||
newCh.name[31] = '\0';
|
||||
|
||||
// SHA-256 the channel name → first 16 bytes become the secret
|
||||
// SHA-256 the channel name → first 16 bytes become the secret
|
||||
uint8_t hash[32];
|
||||
mesh::Utils::sha256(hash, 32, (const uint8_t*)chanName, strlen(chanName));
|
||||
memcpy(newCh.channel.secret, hash, 16);
|
||||
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
|
||||
// Upper 16 bytes left as zero → setChannel uses 128-bit mode
|
||||
|
||||
// Find next empty slot
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
@@ -400,8 +412,8 @@ public:
|
||||
}
|
||||
|
||||
case ROW_FREQ:
|
||||
if (editing && _editMode == EDIT_NUMBER) {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %.3f <W/S>", _editFloat);
|
||||
if (editing && _editMode == EDIT_TEXT) {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %s_ MHz", _editBuf);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Freq: %.3f MHz", _prefs->freq);
|
||||
}
|
||||
@@ -611,6 +623,15 @@ public:
|
||||
_cursor = 1; // ROW_RADIO_PRESET
|
||||
startEditPicker(max(0, detectCurrentPreset()));
|
||||
}
|
||||
} else if (type == ROW_FREQ) {
|
||||
if (_editPos > 0) {
|
||||
float f = strtof(_editBuf, nullptr);
|
||||
f = constrain(f, 400.0f, 2500.0f);
|
||||
_prefs->freq = f;
|
||||
_radioChanged = true;
|
||||
Serial.printf("Settings: Freq typed to %.3f\n", f);
|
||||
}
|
||||
_editMode = EDIT_NONE;
|
||||
} else if (type == ROW_ADD_CHANNEL) {
|
||||
if (_editPos > 0) {
|
||||
createHashtagChannel(_editBuf);
|
||||
@@ -684,7 +705,6 @@ public:
|
||||
|
||||
if (c == 'w' || c == 'W') {
|
||||
switch (type) {
|
||||
case ROW_FREQ: _editFloat += 0.1f; break;
|
||||
case ROW_BW:
|
||||
// Cycle through common bandwidths
|
||||
if (_editFloat < 31.25f) _editFloat = 31.25f;
|
||||
@@ -703,7 +723,6 @@ public:
|
||||
}
|
||||
if (c == 's' || c == 'S') {
|
||||
switch (type) {
|
||||
case ROW_FREQ: _editFloat -= 0.1f; break;
|
||||
case ROW_BW:
|
||||
if (_editFloat > 250.0f) _editFloat = 250.0f;
|
||||
else if (_editFloat > 125.0f) _editFloat = 125.0f;
|
||||
@@ -721,10 +740,6 @@ public:
|
||||
if (c == '\r' || c == 13) {
|
||||
// Confirm number edit
|
||||
switch (type) {
|
||||
case ROW_FREQ:
|
||||
_prefs->freq = constrain(_editFloat, 400.0f, 2500.0f);
|
||||
_radioChanged = true;
|
||||
break;
|
||||
case ROW_BW:
|
||||
_prefs->bw = _editFloat;
|
||||
_radioChanged = true;
|
||||
@@ -787,9 +802,13 @@ public:
|
||||
case ROW_RADIO_PRESET:
|
||||
startEditPicker(max(0, detectCurrentPreset()));
|
||||
break;
|
||||
case ROW_FREQ:
|
||||
startEditFloat(_prefs->freq);
|
||||
case ROW_FREQ: {
|
||||
// Use text input so user can type exact frequencies like 916.575
|
||||
char freqStr[16];
|
||||
snprintf(freqStr, sizeof(freqStr), "%.3f", _prefs->freq);
|
||||
startEditText(freqStr);
|
||||
break;
|
||||
}
|
||||
case ROW_BW:
|
||||
startEditFloat(_prefs->bw);
|
||||
break;
|
||||
@@ -828,7 +847,7 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// Q: back — if radio changed, prompt to apply first
|
||||
// Q: back — if radio changed, prompt to apply first
|
||||
if (c == 'q' || c == 'Q') {
|
||||
if (_radioChanged) {
|
||||
_editMode = EDIT_CONFIRM;
|
||||
|
||||
@@ -36,11 +36,12 @@
|
||||
#include "ContactsScreen.h"
|
||||
#include "TextReaderScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
|
||||
class SplashScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
unsigned long dismiss_after;
|
||||
char _version_info[12];
|
||||
char _version_info[24];
|
||||
|
||||
public:
|
||||
SplashScreen(UITask* task) : _task(task) {
|
||||
@@ -87,13 +88,18 @@ class HomeScreen : public UIScreen {
|
||||
FIRST,
|
||||
RECENT,
|
||||
RADIO,
|
||||
#ifdef BLE_PIN_CODE
|
||||
BLUETOOTH,
|
||||
#endif
|
||||
ADVERT,
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
GPS,
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
SENSORS,
|
||||
#endif
|
||||
#if HAS_BQ27220
|
||||
BATTERY,
|
||||
#endif
|
||||
SHUTDOWN,
|
||||
Count // keep as last
|
||||
@@ -111,22 +117,21 @@ class HomeScreen : public UIScreen {
|
||||
|
||||
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts, int* outIconX = nullptr) {
|
||||
// Use the BQ27220 fuel gauge SOC register for accurate percentage.
|
||||
// Falls back to voltage estimation if the fuel gauge is uncalibrated.
|
||||
uint8_t batteryPercentage = board.getBatteryPercent();
|
||||
|
||||
// Sanity check: if voltage says full but gauge disagrees significantly,
|
||||
// the gauge hasn't calibrated yet — fall back to voltage estimate
|
||||
int voltagePct = 0;
|
||||
uint8_t batteryPercentage = 0;
|
||||
#if HAS_BQ27220
|
||||
// Use fuel gauge SOC directly — accurate across the full discharge curve
|
||||
batteryPercentage = board.getBatteryPercent();
|
||||
#else
|
||||
// Fallback: voltage-based linear estimation for boards without fuel gauge
|
||||
if (batteryMilliVolts > 0) {
|
||||
voltagePct = ((batteryMilliVolts - 3000) * 100) / (4200 - 3000);
|
||||
if (voltagePct < 0) voltagePct = 0;
|
||||
if (voltagePct > 100) voltagePct = 100;
|
||||
}
|
||||
|
||||
if (batteryPercentage == 0 || abs((int)batteryPercentage - voltagePct) > 30) {
|
||||
batteryPercentage = (uint8_t)voltagePct;
|
||||
const int minMilliVolts = 3000;
|
||||
const int maxMilliVolts = 4200;
|
||||
int pct = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
batteryPercentage = (uint8_t)pct;
|
||||
}
|
||||
#endif
|
||||
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
|
||||
@@ -212,12 +217,12 @@ public:
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[80];
|
||||
// node name
|
||||
display.setTextSize(1);
|
||||
// node name (tinyfont to avoid overlapping clock)
|
||||
display.setTextSize(0);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
char filtered_name[sizeof(_node_prefs->node_name)];
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
display.setCursor(0, 0);
|
||||
display.setCursor(0, -3);
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
@@ -258,28 +263,54 @@ public:
|
||||
}
|
||||
|
||||
if (_page == HomePage::FIRST) {
|
||||
int y = 20;
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "MSG: %d", _task->getMsgCount());
|
||||
display.drawTextCentered(display.width() / 2, 20, tmp);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 18;
|
||||
|
||||
#ifdef WIFI_SSID
|
||||
IPAddress ip = WiFi.localIP();
|
||||
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 54, tmp);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 12;
|
||||
#endif
|
||||
#if defined(BLE_PIN_CODE) || defined(WIFI_SSID)
|
||||
if (_task->hasConnection()) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 43, "< Connected >");
|
||||
|
||||
} else if (the_mesh.getBLEPin() != 0) { // BT pin
|
||||
display.drawTextCentered(display.width() / 2, y, "< Connected >");
|
||||
y += 12;
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_task->isSerialEnabled() && the_mesh.getBLEPin() != 0) {
|
||||
display.setColor(DisplayDriver::RED);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "Pin:%d", the_mesh.getBLEPin());
|
||||
display.drawTextCentered(display.width() / 2, 43, tmp);
|
||||
display.drawTextCentered(display.width() / 2, y, tmp);
|
||||
y += 18;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// Menu shortcuts - tinyfont monospaced grid
|
||||
y += 6;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(0); // tinyfont 6x8 monospaced
|
||||
display.drawTextCentered(display.width() / 2, y, "Press:");
|
||||
y += 12;
|
||||
display.drawTextCentered(display.width() / 2, y, "[M] Messages [C] Contacts");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[N] Notes [S] Settings");
|
||||
y += 10;
|
||||
display.drawTextCentered(display.width() / 2, y, "[E] Reader ");
|
||||
y += 14;
|
||||
|
||||
// Nav hint
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Press A/D to cycle home views");
|
||||
display.setTextSize(1); // restore
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -324,6 +355,7 @@ public:
|
||||
display.setCursor(0, 53);
|
||||
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
|
||||
display.print(tmp);
|
||||
#ifdef BLE_PIN_CODE
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18,
|
||||
@@ -331,6 +363,7 @@ public:
|
||||
32, 32);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL);
|
||||
#endif
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32);
|
||||
@@ -380,7 +413,7 @@ public:
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
display.drawTextLeftAlign(0, y, "sentences");
|
||||
if (gpsDuty.isHardwareOn()) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
@@ -510,6 +543,51 @@ public:
|
||||
}
|
||||
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
|
||||
else sensors_scroll_offset = 0;
|
||||
#endif
|
||||
#if HAS_BQ27220
|
||||
} else if (_page == HomePage::BATTERY) {
|
||||
char buf[30];
|
||||
int y = 18;
|
||||
|
||||
// Title
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Battery Gauge");
|
||||
y += 12;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Time to empty
|
||||
uint16_t tte = board.getTimeToEmpty();
|
||||
display.drawTextLeftAlign(0, y, "remaining");
|
||||
if (tte == 0xFFFF || tte == 0) {
|
||||
strcpy(buf, tte == 0 ? "depleted" : "charging");
|
||||
} else if (tte >= 60) {
|
||||
sprintf(buf, "%dh %dm", tte / 60, tte % 60);
|
||||
} else {
|
||||
sprintf(buf, "%d min", tte);
|
||||
}
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Average current
|
||||
int16_t avgCur = board.getAvgCurrent();
|
||||
display.drawTextLeftAlign(0, y, "avg current");
|
||||
sprintf(buf, "%d mA", avgCur);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Average power
|
||||
int16_t avgPow = board.getAvgPower();
|
||||
display.drawTextLeftAlign(0, y, "avg power");
|
||||
sprintf(buf, "%d mW", avgPow);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y += 10;
|
||||
|
||||
// Voltage (already available)
|
||||
uint16_t mv = board.getBattMilliVolts();
|
||||
display.drawTextLeftAlign(0, y, "voltage");
|
||||
sprintf(buf, "%d.%03d V", mv / 1000, mv % 1000);
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
#endif
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
@@ -570,6 +648,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#ifdef BLE_PIN_CODE
|
||||
if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) {
|
||||
if (_task->isSerialEnabled()) { // toggle Bluetooth on/off
|
||||
_task->disableSerial();
|
||||
@@ -578,6 +657,7 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
@@ -756,6 +836,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
text_reader = new TextReaderScreen(this);
|
||||
notes_screen = new NotesScreen(this);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
repeater_admin = new RepeaterAdminScreen(this, &rtc_clock);
|
||||
audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
|
||||
@@ -797,7 +879,7 @@ switch(t){
|
||||
|
||||
void UITask::msgRead(int msgcount) {
|
||||
_msgcount = msgcount;
|
||||
if (msgcount == 0) {
|
||||
if (msgcount == 0 && curr == msg_preview) {
|
||||
gotoHomeScreen();
|
||||
}
|
||||
}
|
||||
@@ -826,9 +908,12 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
// T-Deck Pro: Don't interrupt user with popup - just show brief notification
|
||||
// Messages are stored in channel history, accessible via 'M' key
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
// Suppress alert entirely on admin screen - it needs focused interaction
|
||||
if (!isOnRepeaterAdmin()) {
|
||||
char alertBuf[40];
|
||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||
showAlert(alertBuf, 2000);
|
||||
}
|
||||
#else
|
||||
// Other devices: Show full preview screen (legacy behavior)
|
||||
setCurrScreen(msg_preview);
|
||||
@@ -1088,13 +1173,13 @@ void UITask::toggleGPS() {
|
||||
|
||||
if (_sensors != NULL) {
|
||||
if (_node_prefs->gps_enabled) {
|
||||
// Disable GPS  cut hardware power
|
||||
// Disable GPS  cut hardware power
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
gpsDuty.disable();
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
// Enable GPS  start duty cycle
|
||||
// Enable GPS  start duty cycle
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
gpsDuty.enable();
|
||||
@@ -1225,6 +1310,36 @@ void UITask::gotoOnboarding() {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoAudiobookPlayer() {
|
||||
if (audiobook_screen == nullptr) return; // No audio hardware
|
||||
setCurrScreen(audiobook_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoRepeaterAdmin(int contactIdx) {
|
||||
// Get contact name for the screen header
|
||||
ContactInfo contact;
|
||||
char name[32] = "Unknown";
|
||||
if (the_mesh.getContactByIdx(contactIdx, contact)) {
|
||||
strncpy(name, contact.name, sizeof(name) - 1);
|
||||
name[sizeof(name) - 1] = '\0';
|
||||
}
|
||||
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin;
|
||||
admin->openForContact(contactIdx, name);
|
||||
setCurrScreen(repeater_admin);
|
||||
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
uint8_t UITask::getChannelScreenViewIdx() const {
|
||||
return ((ChannelScreen *) channel_screen)->getViewChannelIdx();
|
||||
}
|
||||
@@ -1236,4 +1351,18 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons
|
||||
|
||||
// Add to channel history with path_len=0 (local message)
|
||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg);
|
||||
}
|
||||
|
||||
void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
|
||||
if (isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::onAdminCliResponse(const char* from_name, const char* text) {
|
||||
if (isOnRepeaterAdmin()) {
|
||||
((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text);
|
||||
_next_refresh = 100; // trigger re-render
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* text_reader; // *** NEW: Text reader screen ***
|
||||
UIScreen* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* repeater_admin; // Repeater admin screen
|
||||
UIScreen* audiobook_screen; // Audiobook player screen (null if not available)
|
||||
UIScreen* curr;
|
||||
|
||||
void userLedHandler();
|
||||
@@ -84,6 +86,8 @@ public:
|
||||
void gotoNotesScreen(); // Navigate to notes editor
|
||||
void gotoSettingsScreen(); // Navigate to settings
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void gotoAudiobookPlayer(); // Navigate to audiobook player
|
||||
void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
void forceRefresh() override { _next_refresh = 100; }
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
@@ -94,6 +98,8 @@ public:
|
||||
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
|
||||
bool isOnNotesScreen() const { return curr == notes_screen; }
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
bool isOnAudiobookPlayer() const { return curr == audiobook_screen; }
|
||||
bool isOnRepeaterAdmin() const { return curr == repeater_admin; }
|
||||
uint8_t getChannelScreenViewIdx() const;
|
||||
|
||||
void toggleBuzzer();
|
||||
@@ -117,6 +123,9 @@ public:
|
||||
UIScreen* getContactsScreen() const { return contacts_screen; }
|
||||
UIScreen* getChannelScreen() const { return channel_screen; }
|
||||
UIScreen* getSettingsScreen() const { return settings_screen; }
|
||||
UIScreen* getAudiobookScreen() const { return audiobook_screen; }
|
||||
UIScreen* getRepeaterAdminScreen() const { return repeater_admin; }
|
||||
void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; }
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
@@ -124,5 +133,9 @@ public:
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
// Repeater admin callbacks (from MyMesh via AbstractUITask)
|
||||
void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override;
|
||||
void onAdminCliResponse(const char* from_name, const char* text) override;
|
||||
|
||||
void shutdown(bool restart = false);
|
||||
};
|
||||
@@ -72,10 +72,11 @@ void TDeckBoard::begin() {
|
||||
rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1);
|
||||
}
|
||||
|
||||
// Test BQ27220 communication
|
||||
// Test BQ27220 communication and configure design capacity
|
||||
#if HAS_BQ27220
|
||||
uint16_t voltage = getBattMilliVolts();
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - Battery voltage: %d mV", voltage);
|
||||
configureFuelGauge();
|
||||
#endif
|
||||
|
||||
MESH_DEBUG_PRINTLN("TDeckBoard::begin() - complete");
|
||||
@@ -123,4 +124,233 @@ uint8_t TDeckBoard::getBatteryPercent() {
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---- BQ27220 extended register helpers ----
|
||||
|
||||
#if HAS_BQ27220
|
||||
// Read a 16-bit register from BQ27220. Returns 0 on I2C error.
|
||||
static uint16_t bq27220_read16(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)2) != 2) return 0;
|
||||
uint16_t val = Wire.read();
|
||||
val |= (Wire.read() << 8);
|
||||
return val;
|
||||
}
|
||||
|
||||
// Read a single byte from BQ27220 register.
|
||||
static uint8_t bq27220_read8(uint8_t reg) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) return 0;
|
||||
if (Wire.requestFrom((uint8_t)BQ27220_I2C_ADDR, (uint8_t)1) != 1) return 0;
|
||||
return Wire.read();
|
||||
}
|
||||
|
||||
// Write a 16-bit subcommand to BQ27220 Control register (0x00).
|
||||
// Subcommands control unsealing, config mode, sealing, etc.
|
||||
static bool bq27220_writeControl(uint16_t subcmd) {
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x00); // Control register
|
||||
Wire.write(subcmd & 0xFF); // LSB first
|
||||
Wire.write((subcmd >> 8) & 0xFF); // MSB
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---- BQ27220 Design Capacity configuration ----
|
||||
// The BQ27220 ships with a 3000 mAh default. The T-Deck Pro uses a 1400 mAh
|
||||
// cell. This function checks on boot and writes the correct value via the
|
||||
// MAC Data Memory interface if needed. The value persists in battery-backed
|
||||
// RAM, so this typically only writes once (or after a full battery disconnect).
|
||||
//
|
||||
// Procedure follows TI TRM SLUUBD4A Section 6.1:
|
||||
// 1. Unseal → 2. Full Access → 3. Enter CFG_UPDATE
|
||||
// 4. Write Design Capacity via MAC → 5. Exit CFG_UPDATE → 6. Seal
|
||||
|
||||
bool TDeckBoard::configureFuelGauge(uint16_t designCapacity_mAh) {
|
||||
#if HAS_BQ27220
|
||||
// Read current design capacity from standard command register
|
||||
uint16_t currentDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity = %d mAh (target %d)\n", currentDC, designCapacity_mAh);
|
||||
|
||||
if (currentDC == designCapacity_mAh) {
|
||||
Serial.println("BQ27220: Design Capacity already correct, skipping");
|
||||
return true;
|
||||
}
|
||||
|
||||
Serial.printf("BQ27220: Updating Design Capacity from %d to %d mAh\n", currentDC, designCapacity_mAh);
|
||||
|
||||
// Step 1: Unseal (default unseal keys)
|
||||
bq27220_writeControl(0x0414);
|
||||
delay(2);
|
||||
bq27220_writeControl(0x3672);
|
||||
delay(2);
|
||||
|
||||
// Step 2: Enter Full Access mode
|
||||
bq27220_writeControl(0xFFFF);
|
||||
delay(2);
|
||||
bq27220_writeControl(0xFFFF);
|
||||
delay(2);
|
||||
|
||||
// Step 3: Enter CFG_UPDATE mode
|
||||
bq27220_writeControl(0x0090);
|
||||
|
||||
// Wait for CFGUPMODE bit (bit 10) in OperationStatus register
|
||||
bool cfgReady = false;
|
||||
for (int i = 0; i < 50; i++) {
|
||||
delay(20);
|
||||
uint16_t opStatus = bq27220_read16(BQ27220_REG_OP_STATUS);
|
||||
Serial.printf("BQ27220: OperationStatus = 0x%04X (attempt %d)\n", opStatus, i);
|
||||
if (opStatus & 0x0400) { // CFGUPMODE is bit 10
|
||||
cfgReady = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cfgReady) {
|
||||
Serial.println("BQ27220: ERROR - Timeout waiting for CFGUPDATE mode");
|
||||
bq27220_writeControl(0x0092); // Try to exit cleanly
|
||||
bq27220_writeControl(0x0030); // Re-seal
|
||||
return false;
|
||||
}
|
||||
Serial.println("BQ27220: Entered CFGUPDATE mode");
|
||||
|
||||
// Step 4: Write Design Capacity via MAC Data Memory interface
|
||||
// Design Capacity mAh lives at data memory address 0x929F
|
||||
|
||||
// 4a. Select the data memory block by writing address to 0x3E-0x3F
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); // MACDataControl register
|
||||
Wire.write(0x9F); // Address low byte
|
||||
Wire.write(0x92); // Address high byte
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
|
||||
// 4b. Read old data (MSB, LSB) and checksum for differential update
|
||||
uint8_t oldMSB = bq27220_read8(0x40);
|
||||
uint8_t oldLSB = bq27220_read8(0x41);
|
||||
uint8_t oldChksum = bq27220_read8(0x60);
|
||||
uint8_t dataLen = bq27220_read8(0x61);
|
||||
|
||||
Serial.printf("BQ27220: Old DC bytes=0x%02X 0x%02X chk=0x%02X len=%d\n",
|
||||
oldMSB, oldLSB, oldChksum, dataLen);
|
||||
|
||||
// 4c. Compute new values (BQ27220 stores big-endian in data memory)
|
||||
uint8_t newMSB = (designCapacity_mAh >> 8) & 0xFF;
|
||||
uint8_t newLSB = designCapacity_mAh & 0xFF;
|
||||
|
||||
// Differential checksum: remove old bytes, add new bytes
|
||||
uint8_t temp = (255 - oldChksum - oldMSB - oldLSB);
|
||||
uint8_t newChksum = 255 - ((temp + newMSB + newLSB) & 0xFF);
|
||||
|
||||
Serial.printf("BQ27220: New DC bytes=0x%02X 0x%02X chk=0x%02X\n",
|
||||
newMSB, newLSB, newChksum);
|
||||
|
||||
// 4d. Write address + new data as a single block transaction
|
||||
// BQ27220 MAC requires: [0x3E] [addr_lo] [addr_hi] [data...]
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E); // Start at MACDataControl
|
||||
Wire.write(0x9F); // Address low byte
|
||||
Wire.write(0x92); // Address high byte
|
||||
Wire.write(newMSB); // Data byte 0 (at 0x40)
|
||||
Wire.write(newLSB); // Data byte 1 (at 0x41)
|
||||
uint8_t writeResult = Wire.endTransmission();
|
||||
Serial.printf("BQ27220: Write block result = %d\n", writeResult);
|
||||
|
||||
// 4e. Write updated checksum and length
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x60);
|
||||
Wire.write(newChksum);
|
||||
Wire.write(dataLen);
|
||||
writeResult = Wire.endTransmission();
|
||||
Serial.printf("BQ27220: Write checksum result = %d\n", writeResult);
|
||||
delay(10);
|
||||
|
||||
// 4f. Verify the write took effect before exiting config mode
|
||||
// Re-read the block to confirm
|
||||
Wire.beginTransmission(BQ27220_I2C_ADDR);
|
||||
Wire.write(0x3E);
|
||||
Wire.write(0x9F);
|
||||
Wire.write(0x92);
|
||||
Wire.endTransmission();
|
||||
delay(10);
|
||||
uint8_t verMSB = bq27220_read8(0x40);
|
||||
uint8_t verLSB = bq27220_read8(0x41);
|
||||
Serial.printf("BQ27220: Verify in CFGUPDATE: DC bytes=0x%02X 0x%02X (%d mAh)\n",
|
||||
verMSB, verLSB, (verMSB << 8) | verLSB);
|
||||
|
||||
// Step 5: Exit CFG_UPDATE (with reinit to apply changes immediately)
|
||||
bq27220_writeControl(0x0091); // EXIT_CFG_UPDATE_REINIT
|
||||
Serial.println("BQ27220: Sent EXIT_CFG_UPDATE_REINIT, waiting...");
|
||||
delay(200); // Allow gauge to reinitialize
|
||||
|
||||
// Verify
|
||||
uint16_t verifyDC = bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
Serial.printf("BQ27220: Design Capacity now reads %d mAh (expected %d)\n",
|
||||
verifyDC, designCapacity_mAh);
|
||||
|
||||
if (verifyDC == designCapacity_mAh) {
|
||||
Serial.println("BQ27220: Configuration SUCCESS");
|
||||
} else {
|
||||
Serial.println("BQ27220: Configuration FAILED");
|
||||
}
|
||||
|
||||
// Step 7: Seal the device
|
||||
bq27220_writeControl(0x0030);
|
||||
delay(5);
|
||||
|
||||
return verifyDC == designCapacity_mAh;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t TDeckBoard::getAvgCurrent() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_CURRENT);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
int16_t TDeckBoard::getAvgPower() {
|
||||
#if HAS_BQ27220
|
||||
return (int16_t)bq27220_read16(BQ27220_REG_AVG_POWER);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getTimeToEmpty() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_TIME_TO_EMPTY);
|
||||
#else
|
||||
return 0xFFFF;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getRemainingCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_REMAIN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getFullChargeCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_FULL_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
uint16_t TDeckBoard::getDesignCapacity() {
|
||||
#if HAS_BQ27220
|
||||
return bq27220_read16(BQ27220_REG_DESIGN_CAP);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
@@ -7,11 +7,23 @@
|
||||
#include <driver/rtc_io.h>
|
||||
|
||||
// BQ27220 Fuel Gauge Registers
|
||||
#define BQ27220_REG_VOLTAGE 0x08
|
||||
#define BQ27220_REG_CURRENT 0x0C
|
||||
#define BQ27220_REG_SOC 0x2C
|
||||
#define BQ27220_REG_VOLTAGE 0x08
|
||||
#define BQ27220_REG_CURRENT 0x0C // Instantaneous current (mA, signed)
|
||||
#define BQ27220_REG_SOC 0x2C
|
||||
#define BQ27220_REG_REMAIN_CAP 0x10 // Remaining capacity (mAh)
|
||||
#define BQ27220_REG_FULL_CAP 0x12 // Full charge capacity (mAh)
|
||||
#define BQ27220_REG_AVG_CURRENT 0x14 // Average current (mA, signed)
|
||||
#define BQ27220_REG_TIME_TO_EMPTY 0x16 // Minutes until empty
|
||||
#define BQ27220_REG_AVG_POWER 0x24 // Average power (mW, signed)
|
||||
#define BQ27220_REG_DESIGN_CAP 0x3C // Design capacity (mAh, read-only standard cmd)
|
||||
#define BQ27220_REG_OP_STATUS 0x3A // Operation status
|
||||
#define BQ27220_I2C_ADDR 0x55
|
||||
|
||||
// T-Deck Pro battery capacity (all variants use 1400 mAh cell)
|
||||
#ifndef BQ27220_DESIGN_CAPACITY_MAH
|
||||
#define BQ27220_DESIGN_CAPACITY_MAH 1400
|
||||
#endif
|
||||
|
||||
class TDeckBoard : public ESP32Board {
|
||||
public:
|
||||
void begin();
|
||||
@@ -52,6 +64,27 @@ public:
|
||||
// Read state of charge percentage from BQ27220
|
||||
uint8_t getBatteryPercent();
|
||||
|
||||
// Read average current in mA (negative = discharging, positive = charging)
|
||||
int16_t getAvgCurrent();
|
||||
|
||||
// Read average power in mW (negative = discharging, positive = charging)
|
||||
int16_t getAvgPower();
|
||||
|
||||
// Read time-to-empty in minutes (0xFFFF if charging/unavailable)
|
||||
uint16_t getTimeToEmpty();
|
||||
|
||||
// Read remaining capacity in mAh
|
||||
uint16_t getRemainingCapacity();
|
||||
|
||||
// Read full charge capacity in mAh (learned value, may need cycling to update)
|
||||
uint16_t getFullChargeCapacity();
|
||||
|
||||
// Read design capacity in mAh (the configured battery size)
|
||||
uint16_t getDesignCapacity();
|
||||
|
||||
// Configure BQ27220 design capacity (checks on boot, writes only if wrong)
|
||||
bool configureFuelGauge(uint16_t designCapacity_mAh = BQ27220_DESIGN_CAPACITY_MAH);
|
||||
|
||||
const char* getManufacturerName() const {
|
||||
return "LilyGo T-Deck Pro";
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ build_flags =
|
||||
-D PIN_DISPLAY_BL=45
|
||||
-D PIN_USER_BTN=0
|
||||
-D CST328_PIN_RST=38
|
||||
-D FIRMWARE_VERSION='"Meck v0.8.6"'
|
||||
-D FIRMWARE_VERSION='"Meck v0.8.9"'
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/LilyGo_TDeck_Pro>
|
||||
+<helpers/sensors/*.cpp>
|
||||
|
||||
Reference in New Issue
Block a user