Delete t-echo card and t-echo lite folders as no longer working on those; support json import & export config; new method for creating private and hashtag channels

This commit is contained in:
pelgraine
2026-05-23 04:04:45 +10:00
parent 47a7f2f9d1
commit 4cc15f7ab0
27 changed files with 335 additions and 2788 deletions
+29
View File
@@ -661,6 +661,34 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t
_voiceEnvHandler(from.name, text);
}
// Intercept channel share messages before BLE gets them
if (text && strncmp(text, MECK_CH_PREFIX, MECK_CH_PREFIX_LEN) == 0) {
const char* payload = text + MECK_CH_PREFIX_LEN;
const char* sep = strchr(payload, '|');
if (sep && (sep - payload) > 0 && (sep - payload) < 32) {
char chName[32];
int nameLen = sep - payload;
memcpy(chName, payload, nameLen);
chName[nameLen] = '\0';
// Parse hex secret (32 hex chars = 16 bytes)
const char* hexStr = sep + 1;
int hexLen = strlen(hexStr);
if (hexLen >= 32) {
uint8_t secret[16];
mesh::Utils::fromHex(secret, 16, hexStr);
addPendingInvite(chName, secret, from.name);
Serial.printf("Channel invite from %s: '%s'\n", from.name, chName);
}
// Sanitise display text
char sanitised[64];
snprintf(sanitised, sizeof(sanitised), "Shared channel: %s", chName);
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, sanitised);
return;
}
}
queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text);
}
@@ -1327,6 +1355,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;
memset(_pendingInvites, 0, sizeof(_pendingInvites));
_admin_contact_idx = -1;
_discoveredCount = 0;
_discoveryActive = false;
+56 -1
View File
@@ -96,10 +96,22 @@ struct AdvertPath {
struct DiscoveredNode {
ContactInfo contact;
uint8_t path_len;
int8_t snr; // SNR × 4 from active discovery response (0 if pre-seeded)
int8_t snr; // SNR x 4 from active discovery response (0 if pre-seeded)
bool already_in_contacts; // true if contact was auto-added or already known
};
// Channel invite received via DM -- stored in RAM until accepted/dismissed
#define MAX_PENDING_INVITES 8
#define MECK_CH_PREFIX "[MECK:CH]"
#define MECK_CH_PREFIX_LEN 9
struct PendingChannelInvite {
char name[32]; // channel name
uint8_t secret[16]; // channel secret (CIPHER_KEY_SIZE bytes)
char senderName[32]; // who shared it
bool active; // is this slot in use
};
class MyMesh : public BaseChatMesh, public DataStoreHost {
public:
MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL);
@@ -257,6 +269,48 @@ public:
_store->saveMainIdentity(self_id);
}
// --- Pending channel invites (received via DM) ---
int getPendingInviteCount() const {
int count = 0;
for (int i = 0; i < MAX_PENDING_INVITES; i++) {
if (_pendingInvites[i].active) count++;
}
return count;
}
const PendingChannelInvite* getPendingInvite(int idx) const {
int seen = 0;
for (int i = 0; i < MAX_PENDING_INVITES; i++) {
if (_pendingInvites[i].active) {
if (seen == idx) return &_pendingInvites[i];
seen++;
}
}
return nullptr;
}
bool addPendingInvite(const char* name, const uint8_t* secret, const char* senderName) {
for (int i = 0; i < MAX_PENDING_INVITES; i++) {
if (!_pendingInvites[i].active) {
strncpy(_pendingInvites[i].name, name, 31);
_pendingInvites[i].name[31] = '\0';
memcpy(_pendingInvites[i].secret, secret, 16);
strncpy(_pendingInvites[i].senderName, senderName, 31);
_pendingInvites[i].senderName[31] = '\0';
_pendingInvites[i].active = true;
return true;
}
}
return false; // no free slots
}
void removePendingInvite(int idx) {
int seen = 0;
for (int i = 0; i < MAX_PENDING_INVITES; i++) {
if (_pendingInvites[i].active) {
if (seen == idx) { _pendingInvites[i].active = false; return; }
seen++;
}
}
}
private:
void writeOKFrame();
void writeErrFrame(uint8_t err_code);
@@ -282,6 +336,7 @@ private:
mutable bool _forceNextImport = false;
bool _deferSaves = false;
unsigned long _lastUserInput = 0; // millis() of last keypress -- defer saves until idle
PendingChannelInvite _pendingInvites[MAX_PENDING_INVITES]; // RAM-only pending channel shares
uint32_t pending_login;
uint32_t pending_status;
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
+31
View File
@@ -4591,6 +4591,37 @@ void handleKeyboardInput() {
}
}
#endif
// Check for channel share request from the settings screen
if (settings->isShareRequested()) {
int contactIdx = settings->getShareContactIdx();
uint8_t channelIdx = settings->getShareChannelIdx();
settings->clearShareRequest();
ChannelDetails ch;
ContactInfo contact;
if (the_mesh.getChannel(channelIdx, ch) && ch.name[0] != '\0'
&& the_mesh.getContactByIdx(contactIdx, contact)) {
// Build share message: [MECK:CH]name|secret_hex
char shareMsg[128];
char hexSecret[33];
mesh::Utils::toHex(hexSecret, ch.channel.secret, 16);
snprintf(shareMsg, sizeof(shareMsg), "%s%s|%s",
MECK_CH_PREFIX, ch.name, hexSecret);
if (the_mesh.uiSendDirectMessage((uint32_t)contactIdx, shareMsg)) {
// Add sanitised version to DM conversation view
char displayMsg[64];
snprintf(displayMsg, sizeof(displayMsg), "Shared channel: %s", ch.name);
ui_task.addSentDM(contact.name, the_mesh.getNodePrefs()->node_name, displayMsg);
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "Shared with %s", contact.name);
ui_task.showAlert(alertBuf, 2000);
} else {
ui_task.showAlert("Share failed", 1500);
}
}
}
return;
}
+219 -24
View File
@@ -164,7 +164,8 @@ enum SettingsRowType : uint8_t {
ROW_CHANNELS_SUBMENU, // Folder row → enters Channels sub-screen
ROW_CH_HEADER, // "--- Channels ---" separator
ROW_CHANNEL, // A channel entry (dynamic, index stored separately)
ROW_ADD_CHANNEL, // "+ Add Hashtag Channel"
ROW_ADD_CHANNEL, // "+ Add Channel (# = public)"
ROW_PENDING_INVITE, // Pending channel invite (param = invite index)
#ifdef HAS_SDCARD
ROW_EXPORT_IMPORT_SUBMENU, // Folder row: "Export/Import >>"
ROW_EXPORT_TO_SD, // "Export to SD >>" (enters flags sub-screen)
@@ -201,6 +202,7 @@ enum EditMode : uint8_t {
EDIT_NUMBER, // W/S adjusts value (freq, BW, SF, CR, TX, UTC)
EDIT_CONFIRM, // Confirmation dialog (delete channel, apply radio)
EDIT_NOTIF_SOUND, // Sound picker for per-channel notification tone
EDIT_SHARE_PICK, // Contact picker for channel sharing
#ifdef MECK_WIFI_COMPANION
EDIT_WIFI, // WiFi scan/select/password flow
#endif
@@ -307,6 +309,16 @@ private:
bool _importRequested; // set by key handler, cleared by main.cpp after calling import
#endif
// Channel share picker state
#define SHARE_MAX_CONTACTS 32
uint8_t _shareChannelIdx; // channel being shared
int _shareContacts[SHARE_MAX_CONTACTS]; // indices of DM-capable contacts
int _shareContactCount; // number of entries in _shareContacts
int _sharePickerIdx; // highlighted item in picker
int _sharePickerScroll; // scroll offset in picker
bool _shareRequested; // flag for main.cpp
int _shareContactIdx; // selected contact index (for main.cpp)
// Dirty flag for radio params — prompt to apply
bool _radioChanged;
@@ -433,6 +445,10 @@ private:
}
}
addRow(ROW_ADD_CHANNEL);
// Pending channel invites (received via DM)
for (int pi = 0; pi < the_mesh.getPendingInviteCount(); pi++) {
addRow(ROW_PENDING_INVITE, pi);
}
#ifdef MECK_OTA_UPDATE
} else if (_subScreen == SUB_OTA_TOOLS) {
// --- OTA Tools sub-screen ---
@@ -564,28 +580,33 @@ private:
// Hashtag channel creation
// ---------------------------------------------------------------------------
void createHashtagChannel(const char* name) {
// Build channel name with # prefix if not already present
char chanName[32];
if (name[0] == '#') {
strncpy(chanName, name, sizeof(chanName));
} else {
chanName[0] = '#';
strncpy(&chanName[1], name, sizeof(chanName) - 1);
}
chanName[31] = '\0';
// Generate 128-bit PSK from SHA-256 of channel name
void createChannel(const char* name) {
ChannelDetails newCh;
memset(&newCh, 0, sizeof(newCh));
strncpy(newCh.name, chanName, sizeof(newCh.name));
newCh.name[31] = '\0';
// 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
if (name[0] == '#') {
// Public hashtag channel -- derive secret from SHA-256 of name
strncpy(newCh.name, name, sizeof(newCh.name));
newCh.name[31] = '\0';
uint8_t hash[32];
mesh::Utils::sha256(hash, 32, (const uint8_t*)name, strlen(name));
memcpy(newCh.channel.secret, hash, 16);
Serial.printf("Settings: Creating public channel '%s'\n", name);
} else {
// Private channel -- random 16-byte secret
strncpy(newCh.name, name, sizeof(newCh.name));
newCh.name[31] = '\0';
uint8_t secret[16];
uint32_t r;
for (int i = 0; i < 16; i++) {
if (i % 4 == 0) r = esp_random();
secret[i] = (r >> ((i % 4) * 8)) & 0xFF;
}
memcpy(newCh.channel.secret, secret, 16);
Serial.printf("Settings: Creating private channel '%s'\n", name);
}
// Find next empty slot
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
@@ -593,13 +614,14 @@ private:
if (!the_mesh.getChannel(i, existing) || existing.name[0] == '\0') {
if (the_mesh.setChannel(i, newCh)) {
the_mesh.saveChannels();
Serial.printf("Settings: Created hashtag channel '%s' at idx %d\n", chanName, i);
Serial.printf("Settings: Channel '%s' created at idx %d\n", newCh.name, i);
}
break;
}
}
}
void deleteChannel(uint8_t idx) {
// Clear the channel by writing an empty ChannelDetails
// Then compact: shift all channels above it down by one
@@ -656,6 +678,12 @@ public:
_exportRequested = false;
_importRequested = false;
#endif
_shareChannelIdx = 0;
_shareContactCount = 0;
_sharePickerIdx = 0;
_sharePickerScroll = 0;
_shareRequested = false;
_shareContactIdx = -1;
#ifdef MECK_OTA_UPDATE
_otaServer = nullptr;
_otaPhase = OTA_PHASE_CONFIRM;
@@ -686,6 +714,9 @@ public:
_exportRequested = false;
_importRequested = false;
#endif
_shareRequested = false;
_shareContactIdx = -1;
_shareContactCount = 0;
#ifdef HAS_4G_MODEM
_modemEnabled = ModemManager::loadEnabledConfig();
#endif
@@ -885,6 +916,12 @@ public:
void clearImportRequest() { _importRequested = false; }
#endif
// Channel share request -- checked and cleared by main.cpp
bool isShareRequested() const { return _shareRequested; }
int getShareContactIdx() const { return _shareContactIdx; }
uint8_t getShareChannelIdx() const { return _shareChannelIdx; }
void clearShareRequest() { _shareRequested = false; _shareContactIdx = -1; }
// ---------------------------------------------------------------------------
// OTA firmware update
// ---------------------------------------------------------------------------
@@ -2111,14 +2148,24 @@ public:
case ROW_ADD_CHANNEL:
if (editing && _editMode == EDIT_TEXT) {
snprintf(tmp, sizeof(tmp), "# %s_", _editBuf);
snprintf(tmp, sizeof(tmp), "> %s_", _editBuf);
} else {
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
strcpy(tmp, "+ Add Hashtag Channel");
strcpy(tmp, "+ Add Channel (# = public)");
}
display.print(tmp);
break;
case ROW_PENDING_INVITE: {
const PendingChannelInvite* inv = the_mesh.getPendingInvite(_rows[i].param);
if (inv) {
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::YELLOW);
snprintf(tmp, sizeof(tmp), "Pending: %s", inv->name);
display.print(tmp);
}
break;
}
#ifdef MECK_OTA_UPDATE
case ROW_OTA_TOOLS_SUBMENU:
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
@@ -2568,6 +2615,69 @@ public:
}
#endif
// === Share contact picker overlay ===
if (_editMode == EDIT_SHARE_PICK) {
int bx = 2, by = 14, bw = display.width() - 4;
int bh = display.height() - 28;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
display.setTextSize(_prefs->smallTextSize());
int lineH = _prefs->smallLineH();
// Header
display.setColor(DisplayDriver::GREEN);
display.setCursor(bx + 4, by + 3);
display.print("Share with contact:");
if (_shareContactCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(bx + 4, by + 16);
display.print("No contacts available");
} else {
int listTop = by + 14;
int listBot = by + bh - 14;
int maxVisible = (listBot - listTop) / lineH;
if (maxVisible < 3) maxVisible = 3;
// Scroll to keep selection visible
if (_sharePickerIdx < _sharePickerScroll) _sharePickerScroll = _sharePickerIdx;
if (_sharePickerIdx >= _sharePickerScroll + maxVisible) _sharePickerScroll = _sharePickerIdx - maxVisible + 1;
for (int vi = 0; vi < maxVisible && (_sharePickerScroll + vi) < _shareContactCount; vi++) {
int ci = _sharePickerScroll + vi;
int iy = listTop + vi * lineH;
bool sel = (ci == _sharePickerIdx);
if (sel) {
display.setColor(DisplayDriver::GREEN);
display.fillRect(bx + 1, iy, bw - 2, lineH);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
ContactInfo ci_info;
if (the_mesh.getContactByIdx(_shareContacts[ci], ci_info)) {
display.setCursor(bx + 4, iy + 1);
display.print(ci_info.name);
}
}
}
// Footer hint
display.setColor(DisplayDriver::YELLOW);
display.setCursor(bx + 4, by + bh - 12);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap:Send Boot:Cancel");
#else
display.print("Enter:Send Q:Cancel");
#endif
display.setTextSize(1);
}
// === Footer ===
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
@@ -2817,6 +2927,33 @@ public:
}
#endif
// --- Share contact picker ---
if (_editMode == EDIT_SHARE_PICK) {
if (c == 'w' || c == 'W' || c == 0xF2 || c == KEY_UP) {
if (_sharePickerIdx > 0) _sharePickerIdx--;
return true;
}
if (c == 's' || c == 'S' || c == 0xF1 || c == KEY_DOWN) {
if (_sharePickerIdx < _shareContactCount - 1) _sharePickerIdx++;
return true;
}
if (c == '\r' || c == 13) {
if (_shareContactCount > 0) {
_shareContactIdx = _shareContacts[_sharePickerIdx];
_shareRequested = true;
Serial.printf("Settings: share channel %d with contact %d\n",
_shareChannelIdx, _shareContactIdx);
}
_editMode = EDIT_NONE;
return true;
}
if (c == 'q' || c == 'Q' || c == '\b') {
_editMode = EDIT_NONE;
return true;
}
return true; // consume all keys in picker mode
}
#ifdef MECK_OTA_UPDATE
// --- OTA update flow ---
if (_editMode == EDIT_OTA) {
@@ -3015,7 +3152,7 @@ public:
_editMode = EDIT_NONE;
} else if (type == ROW_ADD_CHANNEL) {
if (_editPos > 0) {
createHashtagChannel(_editBuf);
createChannel(_editBuf);
rebuildRows();
}
_editMode = EDIT_NONE;
@@ -3520,6 +3657,35 @@ public:
case ROW_ADD_CHANNEL:
startEditText("");
break;
case ROW_PENDING_INVITE: {
// Accept pending channel invite
const PendingChannelInvite* inv = the_mesh.getPendingInvite(_rows[_cursor].param);
if (inv) {
ChannelDetails newCh;
memset(&newCh, 0, sizeof(newCh));
strncpy(newCh.name, inv->name, sizeof(newCh.name));
newCh.name[31] = '\0';
memcpy(newCh.channel.secret, inv->secret, 16);
// Find next empty slot
bool added = false;
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails existing;
if (!the_mesh.getChannel(i, existing) || existing.name[0] == '\0') {
if (the_mesh.setChannel(i, newCh)) {
the_mesh.saveChannels();
Serial.printf("Settings: Accepted channel '%s' at idx %d\n", inv->name, i);
added = true;
}
break;
}
}
the_mesh.removePendingInvite(_rows[_cursor].param);
rebuildRows();
}
break;
}
#ifdef MECK_OTA_UPDATE
case ROW_OTA_TOOLS_SUBMENU:
_savedTopCursor = _cursor;
@@ -3609,12 +3775,19 @@ public:
}
// X: delete channel (when on a channel row, idx > 0)
// dismiss pending invite (when on a pending invite row)
if (c == 'x' || c == 'X') {
if (_rows[_cursor].type == ROW_CHANNEL && _rows[_cursor].param > 0) {
_editMode = EDIT_CONFIRM;
_confirmAction = 1;
return true;
}
if (_rows[_cursor].type == ROW_PENDING_INVITE) {
the_mesh.removePendingInvite(_rows[_cursor].param);
rebuildRows();
Serial.println("Settings: dismissed pending channel invite");
return true;
}
}
// N: cycle notification preference (All -> Mentions -> None -> All)
@@ -3631,6 +3804,28 @@ public:
}
}
// S: share channel with a contact via DM
if (c == 's' || c == 'S') {
if (_rows[_cursor].type == ROW_CHANNEL) {
_shareChannelIdx = _rows[_cursor].param;
// Populate contact list with DM-capable contacts
_shareContactCount = 0;
int numContacts = the_mesh.getNumContacts();
for (int ci = 0; ci < numContacts && _shareContactCount < SHARE_MAX_CONTACTS; ci++) {
ContactInfo contact;
if (the_mesh.getContactByIdx(ci, contact) && contact.type == ADV_TYPE_CHAT) {
_shareContacts[_shareContactCount++] = ci;
}
}
_sharePickerIdx = 0;
_sharePickerScroll = 0;
_editMode = EDIT_SHARE_PICK;
Serial.printf("Settings: sharing channel %d, %d contacts available\n",
_shareChannelIdx, _shareContactCount);
return true;
}
}
// T: open notification tone picker
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
if (c == 't' || c == 'T') {
@@ -1,16 +0,0 @@
#pragma once
// =============================================================================
// CPUPowerManager — no-op stub for nRF52840
//
// The nRF52840 does not support runtime CPU frequency scaling.
// This stub satisfies the #include in main.cpp without any effect.
// =============================================================================
class CPUPowerManager {
public:
void begin() {}
void setHighPerformance() {}
void setLowPower() {}
void loop() {}
};
@@ -1,76 +0,0 @@
#pragma once
#include <Arduino.h>
// Transparent Stream wrapper that counts NMEA sentences (newline-delimited)
// flowing from the GPS serial port to the MicroNMEA parser.
//
// Usage: Instead of MicroNMEALocationProvider gps(Serial2, &rtc_clock);
// Use: GPSStreamCounter gpsStream(Serial2);
// MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
//
// Every read() call passes through to the underlying stream; when a '\n'
// is seen the sentence counter increments. This lets the UI display a
// live "nmea" count so users can confirm the baud rate is correct and
// the GPS module is actually sending data.
class GPSStreamCounter : public Stream {
public:
GPSStreamCounter(Stream& inner)
: _inner(inner), _sentences(0), _sentences_snapshot(0),
_last_snapshot(0), _sentences_per_sec(0) {}
// --- Stream read interface (passes through) ---
int available() override { return _inner.available(); }
int peek() override { return _inner.peek(); }
int read() override {
int c = _inner.read();
if (c == '\n') {
_sentences++;
}
return c;
}
// --- Stream write interface (pass through for NMEA commands if needed) ---
size_t write(uint8_t b) override { return _inner.write(b); }
// Required override on Adafruit nRF52 BSP where Stream::flush() is pure virtual.
// No-op equivalent on ESP32 cores that provide a default implementation.
void flush() override { _inner.flush(); }
// --- Sentence counting API ---
// Total sentences received since boot (or last reset)
uint32_t getSentenceCount() const { return _sentences; }
// Sentences received per second (updated each time you call it,
// with a 1-second rolling window)
uint16_t getSentencesPerSec() {
unsigned long now = millis();
unsigned long elapsed = now - _last_snapshot;
if (elapsed >= 1000) {
uint32_t delta = _sentences - _sentences_snapshot;
// Scale to per-second if interval wasn't exactly 1000ms
_sentences_per_sec = (uint16_t)((delta * 1000UL) / elapsed);
_sentences_snapshot = _sentences;
_last_snapshot = now;
}
return _sentences_per_sec;
}
// Reset all counters (e.g. when GPS hardware power cycles)
void resetCounters() {
_sentences = 0;
_sentences_snapshot = 0;
_sentences_per_sec = 0;
_last_snapshot = millis();
}
private:
Stream& _inner;
volatile uint32_t _sentences;
uint32_t _sentences_snapshot;
unsigned long _last_snapshot;
uint16_t _sentences_per_sec;
};
-34
View File
@@ -1,34 +0,0 @@
#pragma once
// ---------------------------------------------------------------------------
// Radio presets — shared between SettingsScreen (UI) and MyMesh (Serial CLI)
// ---------------------------------------------------------------------------
struct RadioPreset {
const char* name;
float freq;
float bw;
uint8_t sf;
uint8_t cr;
uint8_t tx_power;
};
static const RadioPreset RADIO_PRESETS[] = {
{ "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]))
@@ -1,339 +0,0 @@
#include "TechoCardBoard.h"
#include "variant.h"
#include <Wire.h>
#include <nrf_soc.h>
#include <InternalFileSystem.h>
using namespace Adafruit_LittleFS_Namespace;
void TechoCardBoard::begin() {
NRF52BoardDCDC::begin();
Serial.begin(115200);
// RT9080 3V3 rail: clean reset cycle (from Meshtastic PR #10267)
// Toggling EN HIGH→LOW→HIGH forces a clean power-on, preventing
// brown-out when LoRa TX fires at full power.
#if PIN_OLED_EN >= 0
pinMode(PIN_OLED_EN, OUTPUT);
digitalWrite(PIN_OLED_EN, HIGH);
delay(100);
digitalWrite(PIN_OLED_EN, LOW);
delay(100);
digitalWrite(PIN_OLED_EN, HIGH);
delay(100);
#endif
// Park peripheral enable pins LOW before setup runs
#if defined(HAS_GPS) && PIN_GPS_EN >= 0
pinMode(PIN_GPS_EN, OUTPUT);
digitalWrite(PIN_GPS_EN, LOW);
#endif
#if defined(HAS_GPS) && PIN_GPS_RF_EN >= 0
pinMode(PIN_GPS_RF_EN, OUTPUT);
digitalWrite(PIN_GPS_RF_EN, LOW);
#endif
#if defined(HAS_BUZZER) && PIN_BUZZER >= 0
pinMode(PIN_BUZZER, OUTPUT);
digitalWrite(PIN_BUZZER, LOW);
#endif
#if defined(HAS_SPEAKER)
pinMode(PIN_SPK_EN, OUTPUT);
digitalWrite(PIN_SPK_EN, LOW);
#if PIN_SPK_EN2 >= 0
pinMode(PIN_SPK_EN2, OUTPUT);
digitalWrite(PIN_SPK_EN2, LOW);
#endif
#endif
// Enable GPS power after rail stabilises
#if defined(HAS_GPS) && PIN_GPS_EN >= 0
delay(10);
digitalWrite(PIN_GPS_EN, HIGH);
#endif
#if defined(HAS_GPS) && PIN_GPS_RF_EN >= 0
digitalWrite(PIN_GPS_RF_EN, HIGH);
#endif
// Initialise GPS UART
#if defined(HAS_GPS)
Serial1.setPins(PIN_GPS_RX, PIN_GPS_TX);
Serial1.begin(GPS_BAUDRATE);
#endif
pinMode(PIN_VBAT_READ, INPUT);
pinMode(PIN_USER_BTN, INPUT);
// Initialise I2C -- must be done before display.begin() is called from main.cpp
Wire.begin();
Wire.setClock(400000);
// Initialise WS2812 NeoPixel chain (all off at boot)
// Force data line LOW before init to prevent stray HIGH latching green
#if defined(HAS_RGB_LED)
pinMode(PIN_RGB_LED_1, OUTPUT);
digitalWrite(PIN_RGB_LED_1, LOW);
delayMicroseconds(300); // WS2812 reset pulse is ~280us
_pixels.begin();
_pixels.clear();
_pixels.show();
#endif
}
void TechoCardBoard::enableGPS(bool enable) {
#if defined(HAS_GPS) && PIN_GPS_EN >= 0
digitalWrite(PIN_GPS_EN, enable ? HIGH : LOW);
#endif
#if defined(HAS_GPS) && PIN_GPS_RF_EN >= 0
digitalWrite(PIN_GPS_RF_EN, enable ? HIGH : LOW);
#endif
}
float TechoCardBoard::getMCUTemperature() {
// SoftDevice owns the TEMP peripheral -- direct register access hard faults.
// Use sd_temp_get() when SoftDevice is enabled.
int32_t temp;
uint8_t sd_en = 0;
sd_softdevice_is_enabled(&sd_en);
if (sd_en) {
if (sd_temp_get(&temp) == NRF_SUCCESS) {
return temp * 0.25f;
}
return NAN;
}
// SoftDevice off -- fall back to parent's direct register access
return NRF52Board::getMCUTemperature();
}
void TechoCardBoard::enableSpeaker(bool enable) {
#if defined(HAS_SPEAKER)
digitalWrite(PIN_SPK_EN, enable ? HIGH : LOW);
#if PIN_SPK_EN2 >= 0
digitalWrite(PIN_SPK_EN2, enable ? HIGH : LOW);
#endif
#endif
}
void TechoCardBoard::setLED(uint8_t r, uint8_t g, uint8_t b) {
#if defined(HAS_RGB_LED)
uint32_t color = Adafruit_NeoPixel::Color(r, g, b);
for (int i = 0; i < NUM_NEOPIXELS; i++) {
_pixels.setPixelColor(i, color);
}
_pixels.show();
#else
(void)r; (void)g; (void)b;
#endif
}
void TechoCardBoard::ledOff() {
setLED(0, 0, 0);
}
void TechoCardBoard::setStatusLED(uint8_t led_index, uint32_t color) {
#if defined(HAS_RGB_LED)
if (led_index < NUM_NEOPIXELS) {
_pixels.setPixelColor(led_index, color);
_pixels.show();
}
#else
(void)led_index; (void)color;
#endif
}
void TechoCardBoard::buzz(uint16_t freq_hz, uint16_t duration_ms) {
#if defined(HAS_BUZZER) && PIN_BUZZER >= 0
if (freq_hz == 0 || duration_ms == 0) {
noTone(PIN_BUZZER);
return;
}
tone(PIN_BUZZER, freq_hz, duration_ms);
#else
(void)freq_hz; (void)duration_ms;
#endif
}
// =============================================================================
// BQ25896 Charger IC (I2C address 0x6B)
// =============================================================================
#define BQ25896_ADDR 0x6B
bool TechoCardBoard::probeCharger() {
if (!_chargerProbed) {
Wire.beginTransmission(BQ25896_ADDR);
_chargerPresent = (Wire.endTransmission() == 0);
_chargerProbed = true;
if (!_chargerPresent) {
Serial.println("BQ25896: not found at 0x6B");
}
}
return _chargerPresent;
}
uint8_t TechoCardBoard::readChargerReg(uint8_t reg) {
if (!probeCharger()) return 0;
Wire.beginTransmission(BQ25896_ADDR);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return 0;
Wire.requestFrom((uint8_t)BQ25896_ADDR, (uint8_t)1);
return Wire.available() ? Wire.read() : 0;
}
void TechoCardBoard::writeChargerReg(uint8_t reg, uint8_t val) {
Wire.beginTransmission(BQ25896_ADDR);
Wire.write(reg);
Wire.write(val);
Wire.endTransmission();
}
void TechoCardBoard::enableChargerADC() {
uint8_t reg02 = readChargerReg(0x02);
reg02 |= 0xC0; // CONV_RATE=1 (continuous) + CONV_START=1
writeChargerReg(0x02, reg02);
}
uint8_t TechoCardBoard::getChargeStatus() {
return (readChargerReg(0x0B) >> 3) & 0x03;
}
uint16_t TechoCardBoard::getChargerBattMV() {
return 2304 + (readChargerReg(0x0E) & 0x7F) * 20;
}
uint8_t TechoCardBoard::getChargerTSPCT() {
return 21 + (readChargerReg(0x10) & 0x7F);
}
// =============================================================================
// ICM20948 / AK09916 Compass
//
// Enable I2C bypass on the ICM20948 so the AK09916 magnetometer at 0x0C
// appears directly on Wire. Then set continuous measurement mode.
// =============================================================================
#define ICM20948_ADDR 0x68
#define AK09916_ADDR 0x0C
static uint8_t _i2c_rd(uint8_t addr, uint8_t reg) {
Wire.beginTransmission(addr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return 0;
Wire.requestFrom(addr, (uint8_t)1);
return Wire.available() ? Wire.read() : 0;
}
static void _i2c_wr(uint8_t addr, uint8_t reg, uint8_t val) {
Wire.beginTransmission(addr);
Wire.write(reg);
Wire.write(val);
Wire.endTransmission();
}
bool TechoCardBoard::initCompass() {
if (_compassReady) return true;
// Bank 0
_i2c_wr(ICM20948_ADDR, 0x7F, 0x00);
// Check WHO_AM_I (expect 0xEA)
if (_i2c_rd(ICM20948_ADDR, 0x00) != 0xEA) return false;
// Wake up: auto clock, not sleep
_i2c_wr(ICM20948_ADDR, 0x06, 0x01);
delay(10);
// Enable I2C bypass so AK09916 is directly accessible
_i2c_wr(ICM20948_ADDR, 0x0F, 0x02);
delay(5);
// Check AK09916 WHO_AM_I (expect 0x09)
if (_i2c_rd(AK09916_ADDR, 0x01) != 0x09) return false;
// Leave in power-down -- readMag triggers single measurements on demand
_i2c_wr(AK09916_ADDR, 0x31, 0x00);
_compassReady = true;
return true;
}
bool TechoCardBoard::readMag(int16_t& mx, int16_t& my, int16_t& mz) {
if (!_compassReady) return false;
// Single-measurement mode: trigger one fresh measurement per call.
// Continuous mode gets disrupted by OLED I2C display writes sharing
// the bus through ICM20948 bypass, causing stale data.
_i2c_wr(AK09916_ADDR, 0x31, 0x01); // single measurement trigger
// Wait for data ready (measurement takes ~7.2ms)
for (int i = 0; i < 20; i++) {
if (_i2c_rd(AK09916_ADDR, 0x10) & 0x01) break;
delay(1);
}
// Burst read 6 data bytes + ST2 (must read ST2 to complete cycle)
Wire.beginTransmission(AK09916_ADDR);
Wire.write(0x11);
if (Wire.endTransmission(false) != 0) return false;
Wire.requestFrom((uint8_t)AK09916_ADDR, (uint8_t)7);
if (Wire.available() < 7) return false;
uint8_t buf[7];
for (int i = 0; i < 7; i++) buf[i] = Wire.read();
mx = (int16_t)(buf[1] << 8 | buf[0]);
my = (int16_t)(buf[3] << 8 | buf[2]);
mz = (int16_t)(buf[5] << 8 | buf[4]);
// buf[6] = ST2, read to unlatch
return true;
}
// Power down the AK09916 magnetometer and put the ICM20948 itself to sleep.
// Saves ~3-4mA when not actively viewing the compass page.
// Next call to initCompass() will fully re-initialise the chain.
void TechoCardBoard::sleepCompass() {
if (!_compassReady) return;
// Bank 0 (in case we drifted)
_i2c_wr(ICM20948_ADDR, 0x7F, 0x00);
// AK09916 CNTL2 = 0x00 -- power-down mode (stops continuous measurement)
_i2c_wr(AK09916_ADDR, 0x31, 0x00);
// ICM20948 PWR_MGMT_1 = 0x40 -- SLEEP bit set
_i2c_wr(ICM20948_ADDR, 0x06, 0x40);
_compassReady = false;
}
// =============================================================================
// Compass calibration persistence
// =============================================================================
#define COMPASS_CAL_FILE "/compass_cal"
bool TechoCardBoard::loadCalibration() {
// InternalFS must already be initialised (done in main.cpp setup)
File file = InternalFS.open(COMPASS_CAL_FILE, FILE_O_READ);
if (file) {
int n = file.read((uint8_t*)&_cal, sizeof(_cal));
file.close();
if (n == (int)sizeof(_cal) && _cal.magic == COMPASS_CAL_MAGIC) {
return true;
}
}
// No valid calibration -- reset to identity (no correction)
_cal = { 0, 0, 0, 1.0f, 1.0f, 1.0f, 0 };
return false;
}
bool TechoCardBoard::saveCalibration(const CompassCalibration& cal) {
_cal = cal;
_cal.magic = COMPASS_CAL_MAGIC;
// Direct-write pattern: remove then create (nRF52 LittleFS compatible)
InternalFS.remove(COMPASS_CAL_FILE);
File file = InternalFS.open(COMPASS_CAL_FILE, FILE_O_WRITE);
if (!file) return false;
file.write((const uint8_t*)&_cal, sizeof(_cal));
file.close();
return true;
}
-100
View File
@@ -1,100 +0,0 @@
#pragma once
#include <Arduino.h>
#include <MeshCore.h>
#include <helpers/NRF52Board.h>
#include "variant.h"
#if defined(HAS_RGB_LED)
#include <Adafruit_NeoPixel.h>
#endif
// Hard-iron offsets + soft-iron axis scaling.
// Computed by on-device calibration (rotate slowly for ~20 seconds).
// Persisted to /compass_cal on InternalFS.
#define COMPASS_CAL_MAGIC 0xCA1B0000
struct CompassCalibration {
int16_t off_x, off_y, off_z; // hard-iron offsets (raw ADC counts)
float scale_x, scale_y, scale_z; // soft-iron per-axis scale factors
uint32_t magic; // COMPASS_CAL_MAGIC when valid
};
class TechoCardBoard : public NRF52BoardDCDC {
private:
#if defined(HAS_RGB_LED)
Adafruit_NeoPixel _pixels = Adafruit_NeoPixel(NUM_NEOPIXELS, PIN_RGB_LED_1, NEO_GRB + NEO_KHZ800);
#endif
public:
TechoCardBoard() {}
void begin();
uint16_t getBattMilliVolts() override {
int adcvalue = 0;
analogReadResolution(12);
analogReference(AR_INTERNAL_3_0);
pinMode(PIN_BAT_CTL, OUTPUT);
pinMode(PIN_VBAT_READ, INPUT);
digitalWrite(PIN_BAT_CTL, HIGH);
delay(10);
adcvalue = analogRead(PIN_VBAT_READ);
digitalWrite(PIN_BAT_CTL, LOW);
return (uint16_t)((float)adcvalue * MV_LSB * ADC_MULTIPLIER);
}
const char* getManufacturerName() const override {
return "LilyGo T-Echo Card";
}
float getMCUTemperature() override;
void powerOff() override {
sd_power_system_off();
}
// GPS power control
void enableGPS(bool enable);
// Speaker power control
void enableSpeaker(bool enable);
// RGB LEDs -- all three to same colour
void setLED(uint8_t r, uint8_t g, uint8_t b);
void ledOff();
// Per-LED status control (0=power, 1=notify, 2=pairing)
void setStatusLED(uint8_t led_index, uint32_t color);
// Buzzer
void buzz(uint16_t freq_hz, uint16_t duration_ms);
// BQ25896 charger IC (0x6B)
bool probeCharger(); // check if BQ25896 responds on I2C
uint8_t readChargerReg(uint8_t reg);
void writeChargerReg(uint8_t reg, uint8_t val);
void enableChargerADC(); // start continuous ADC conversion
uint8_t getChargeStatus(); // 0=none, 1=pre, 2=fast, 3=done
uint16_t getChargerBattMV(); // battery voltage from charger ADC
uint8_t getChargerTSPCT(); // thermistor voltage as % of REGN
// ICM20948 / AK09916 compass (0x68 bypass to 0x0C)
bool initCompass();
bool readMag(int16_t& mx, int16_t& my, int16_t& mz);
void sleepCompass(); // power down magnetometer + put ICM20948 in sleep mode
// Compass calibration (persisted to InternalFS)
bool loadCalibration(); // call after InternalFS.begin()
bool saveCalibration(const CompassCalibration& cal);
bool isCalibrated() const { return _cal.magic == COMPASS_CAL_MAGIC; }
const CompassCalibration& getCalibration() const { return _cal; }
private:
bool _compassReady = false;
bool _chargerProbed = false;
bool _chargerPresent = false;
CompassCalibration _cal = { 0, 0, 0, 1.0f, 1.0f, 1.0f, 0 };
};
@@ -1,888 +0,0 @@
// =============================================================================
// TechoCardHomeScreen -- 72x40 OLED home screen for LilyGo T-Echo Card
//
// Four-line layout using U8g2's 4x6 tom_thumb font (18 chars x 4 lines).
// U8g2's native SSD1306_72X40_ER support handles all GDDRAM offset mapping.
//
// Two-button navigation: A (pin 42) = next page / long-press activate
// C (pin 24) = previous page
//
// Pages: STATUS -> RADIO -> BLE -> ADVERT -> GPS -> COMPASS -> BATTERY -> HIBERNATE
// =============================================================================
#pragma once
#include <math.h>
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/sensors/LocationProvider.h>
#include <target.h>
#include "MyMesh.h"
#include "UITask.h"
// =============================================================================
// Voice recording -- PDM mic -> Codec2 1200bps stream encoding
// =============================================================================
#if defined(HAS_MICROPHONE)
#include <PDM.h>
#include <codec2.h>
// VE3 protocol constants (wire-compatible with ESP32 VoiceMessageScreen)
#define VC_C2_MODE CODEC2_MODE_1200
#define VC_C2_MODE_ID 1 // Codec2 1200bps mode identifier
#define VC_C2_FRAME_MS 40 // 40ms per frame at 1200bps
#define VC_C2_FRAME_SAM 320 // 320 samples per frame at 8kHz
#define VC_C2_FRAME_BYTES 6 // 6 encoded bytes per frame
#define VC_MAX_SECONDS 5
#define VC_MAX_FRAMES (VC_MAX_SECONDS * 1000 / VC_C2_FRAME_MS) // 125
#define VC_MAX_BYTES (VC_MAX_FRAMES * VC_C2_FRAME_BYTES) // 750
#define VC_MESH_PAYLOAD 150 // Usable codec2 bytes per mesh packet
#define VC_PKT_MAGIC 0x56 // Voice packet magic byte
#define VC_PKT_HDR_SIZE 6 // magic(1) + sessionId(4) + pktIdx(1)
#define VC_PDM_RATE 16000
#define VC_PDM_FRAME (VC_C2_FRAME_SAM * 2) // 640 16kHz samples per codec frame
// PDM ring buffer -- 2 codec frames of headroom at 16kHz
#define VC_PDM_BUF_SAMPLES (VC_PDM_FRAME * 2) // 1280 samples = 2560 bytes
// Forward declaration for static callback
class TechoCardHomeScreen;
static TechoCardHomeScreen* _vcSelf = nullptr;
static void _vcPdmISR();
#endif
class TechoCardHomeScreen : public UIScreen {
enum Page {
STATUS,
RADIO,
#ifdef BLE_PIN_CODE
BLE,
#endif
ADVERT,
#if defined(HAS_MICROPHONE)
VOICE,
#endif
#if ENV_INCLUDE_GPS == 1
GPS,
#endif
COMPASS,
BATTERY,
HIBERNATE,
PAGE_COUNT
};
UITask* _task;
mesh::RTCClock* _rtc;
NodePrefs* _prefs;
uint8_t _page;
bool _shutdown_init;
unsigned long _shutdown_at;
// Compass state
bool _compassInitDone;
bool _compassOK;
float _lastHeading;
int16_t _lastMx, _lastMy, _lastMz;
// Compass calibration state
bool _calMode;
unsigned long _calStart;
uint16_t _calCount;
int16_t _calMinX, _calMaxX;
int16_t _calMinY, _calMaxY;
int16_t _calMinZ, _calMaxZ;
// Diagnostic counters (temporary)
uint16_t _magOk;
uint16_t _magFail;
#if defined(HAS_MICROPHONE)
// Voice recording state
enum VoiceState { V_IDLE, V_RECORDING, V_REVIEW };
VoiceState _vState;
struct CODEC2* _vCodec;
// PDM sample accumulator (filled by ISR, consumed by poll)
int16_t _vPdmBuf[VC_PDM_BUF_SAMPLES];
volatile int _vPdmCount;
volatile uint32_t _vIsrCount;
// Codec2 encoded output
uint8_t _vEncoded[VC_MAX_BYTES]; // 750 bytes max
uint16_t _vEncBytes;
uint16_t _vEncFrames;
unsigned long _vRecStart;
// VE3 outgoing session
uint32_t _vSessionId;
bool _vSessionActive;
#endif
// Four lines at 9px spacing within 40px display.
// U8g2 handles panel offset natively -- y=0 is the true visible top.
static const int Y0 = 2;
static const int Y1 = 11;
static const int Y2 = 20;
static const int Y3 = 29;
int battPercent() {
uint16_t mv = _task->getBattMilliVolts();
if (mv == 0) return 0;
int pct = ((int)mv - 3000) * 100 / 1160;
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
return pct;
}
const char* cardinal(float deg) {
if (deg >= 337.5f || deg < 22.5f) return "N";
if (deg < 67.5f) return "NE";
if (deg < 112.5f) return "E";
if (deg < 157.5f) return "SE";
if (deg < 202.5f) return "S";
if (deg < 247.5f) return "SW";
if (deg < 292.5f) return "W";
return "NW";
}
public:
TechoCardHomeScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* prefs)
: _task(task), _rtc(rtc), _prefs(prefs),
_page(STATUS), _shutdown_init(false), _shutdown_at(0),
_compassInitDone(false), _compassOK(false),
_lastHeading(0), _lastMx(0), _lastMy(0), _lastMz(0),
_calMode(false), _calStart(0), _calCount(0),
_calMinX(0), _calMaxX(0),
_calMinY(0), _calMaxY(0),
_calMinZ(0), _calMaxZ(0),
_magOk(0), _magFail(0)
#if defined(HAS_MICROPHONE)
, _vState(V_IDLE), _vCodec(nullptr), _vPdmCount(0), _vIsrCount(0),
_vEncBytes(0), _vEncFrames(0), _vRecStart(0),
_vSessionId(0), _vSessionActive(false)
#endif
{}
void cancelEditing() { _shutdown_init = false; }
#if defined(HAS_MICROPHONE)
// --- Voice recording helpers ---
void onPDMData() {
int avail = PDM.available();
if (avail <= 0) return;
int samples = avail / (int)sizeof(int16_t);
int space = VC_PDM_BUF_SAMPLES - _vPdmCount;
if (samples > space) samples = space;
if (samples > 0) {
PDM.read(&_vPdmBuf[_vPdmCount], samples * sizeof(int16_t));
_vPdmCount += samples;
_vIsrCount++;
}
}
bool voiceStartRecording() {
if (_vState == V_RECORDING) return false;
// Codec2 created lazily on first VOICE page visit (see render)
if (!_vCodec) {
Serial.println("Voice: no codec2 instance!");
return false;
}
// Reset buffers
_vPdmCount = 0;
_vIsrCount = 0;
_vEncBytes = 0;
_vEncFrames = 0;
// Enable speaker amp power rail (RT9080 powers both speaker and mic)
Serial.println("Voice: enabling speaker rail...");
board.enableSpeaker(true);
delay(50);
// Start PDM capture
Serial.println("Voice: starting PDM...");
_vcSelf = this;
PDM.setPins(PIN_MIC_DATA, PIN_MIC_CLK, -1);
PDM.onReceive(_vcPdmISR);
if (!PDM.begin(1, VC_PDM_RATE)) {
Serial.println("Voice: PDM.begin failed");
codec2_destroy(_vCodec);
_vCodec = nullptr;
board.enableSpeaker(false);
return false;
}
PDM.setGain(80);
Serial.println("Voice: PDM started OK");
_vState = V_RECORDING;
_vRecStart = millis();
Serial.println("Voice: Recording started");
return true;
}
void voiceStopRecording() {
if (_vState != V_RECORDING) return;
// Stop PDM
PDM.end();
_vcSelf = nullptr;
// Encode any remaining samples
voiceProcessSamples();
// Keep Codec2 alive -- don't destroy between recordings
// (destroying and re-creating causes heap fragmentation)
// if (_vCodec) { codec2_destroy(_vCodec); _vCodec = nullptr; }
// Power down mic/speaker rail
board.enableSpeaker(false);
unsigned long dur = millis() - _vRecStart;
Serial.printf("Voice: Stopped -- %d frames, %d bytes, %lums\n",
_vEncFrames, _vEncBytes, dur);
_vState = (_vEncFrames > 0) ? V_REVIEW : V_IDLE;
}
// Process accumulated PDM samples into Codec2 frames.
// Called from poll() during recording.
void voiceProcessSamples() {
if (_vPdmCount < VC_PDM_FRAME) return;
if (_vEncBytes + VC_C2_FRAME_BYTES > VC_MAX_BYTES) return;
// TEST MODE: drain PDM buffer WITHOUT Codec2 encoding
// If this works, PDM pipeline is fine and issue is Codec2
int remaining = _vPdmCount - VC_PDM_FRAME;
if (remaining > 0) {
memmove((void*)_vPdmBuf, (const void*)&_vPdmBuf[VC_PDM_FRAME],
remaining * sizeof(int16_t));
}
_vPdmCount = remaining;
_vEncFrames++;
_vEncBytes += VC_C2_FRAME_BYTES; // fake it for display
}
// VE3 base36 encoding (compact wire format)
static int toBase36(uint32_t val, char* buf, int bufLen) {
static const char digits[] = "0123456789abcdefghijklmnopqrstuvwxyz";
if (bufLen < 2) return 0;
if (val == 0) { buf[0] = '0'; buf[1] = '\0'; return 1; }
char tmp[12]; int pos = 0;
while (val > 0 && pos < 11) { tmp[pos++] = digits[val % 36]; val /= 36; }
if (pos >= bufLen) pos = bufLen - 1;
for (int i = 0; i < pos; i++) buf[i] = tmp[pos - 1 - i];
buf[pos] = '\0';
return pos;
}
// --- Public voice API for main.cpp ---
public:
// Codec2 must be created from main loop (shallow stack).
// render() call chain is too deep for codec2_create's 3KB+ stack needs.
bool needsCodec2() const {
#if defined(HAS_MICROPHONE)
return _page == VOICE && !_vCodec;
#else
return false;
#endif
}
void setCodec2Instance(struct CODEC2* c2) {
#if defined(HAS_MICROPHONE)
_vCodec = c2;
#endif
}
bool isVoiceReview() const {
#if defined(HAS_MICROPHONE)
return _vState == V_REVIEW && _vEncFrames > 0;
#else
return false;
#endif
}
// Format VE3 envelope: "VE3:{sid}:{mode}:{total}:{dur}"
void voiceFormatEnvelope(char* buf, int bufLen, uint32_t sessionId) {
int payloadPerPkt = VC_MESH_PAYLOAD;
uint8_t totalPkts = (_vEncBytes + payloadPerPkt - 1) / payloadPerPkt;
uint8_t durSec = (uint8_t)(_vEncFrames * VC_C2_FRAME_MS / 1000);
char sid[12], mode[4], total[4], dur[4];
toBase36(sessionId, sid, sizeof(sid));
toBase36(VC_C2_MODE_ID, mode, sizeof(mode));
toBase36(totalPkts, total, sizeof(total));
toBase36(durSec, dur, sizeof(dur));
snprintf(buf, bufLen, "VE3:%s:%s:%s:%s", sid, mode, total, dur);
// Cache session
_vSessionId = sessionId;
_vSessionActive = true;
}
int voiceBuildPacket(uint8_t* buf, int bufLen, uint32_t sessionId, uint8_t pktIdx) {
if (!_vSessionActive || _vSessionId != sessionId) return 0;
uint32_t offset = (uint32_t)pktIdx * VC_MESH_PAYLOAD;
if (offset >= _vEncBytes) return 0;
uint32_t chunkLen = _vEncBytes - offset;
if (chunkLen > VC_MESH_PAYLOAD) chunkLen = VC_MESH_PAYLOAD;
if ((int)(VC_PKT_HDR_SIZE + chunkLen) > bufLen) return 0;
buf[0] = VC_PKT_MAGIC;
memcpy(&buf[1], &sessionId, 4);
buf[5] = pktIdx;
memcpy(&buf[6], &_vEncoded[offset], chunkLen);
return VC_PKT_HDR_SIZE + chunkLen;
}
uint8_t voiceGetPacketCount() const {
if (!_vSessionActive) return 0;
return (_vEncBytes + VC_MESH_PAYLOAD - 1) / VC_MESH_PAYLOAD;
}
void voiceOnSendComplete() {
_vSessionActive = false;
_vState = V_IDLE;
_vEncBytes = 0;
_vEncFrames = 0;
}
void voiceDiscard() {
_vSessionActive = false;
_vState = V_IDLE;
_vEncBytes = 0;
_vEncFrames = 0;
}
private:
#endif // HAS_MICROPHONE
int render(DisplayDriver& display) override {
char tmp[32];
display.setTextSize(1);
switch (_page) {
// ----- STATUS -----
case STATUS: {
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
char filtered_name[sizeof(_prefs->node_name)];
display.translateUTF8ToBlocks(filtered_name, _prefs->node_name,
sizeof(filtered_name));
display.print(filtered_name);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
snprintf(tmp, sizeof(tmp), "MSG: %d", _task->getMsgCount());
display.print(tmp);
snprintf(tmp, sizeof(tmp), "%d%%", battPercent());
display.drawTextRightAlign(display.width() - 1, Y1, tmp);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
if (_task->hasConnection()) {
display.print("Connected");
} else if (_task->isSerialEnabled()) {
display.print("BLE: On");
} else {
display.print("BLE: Off");
}
break;
}
// ----- RADIO -----
case RADIO: {
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y0);
snprintf(tmp, sizeof(tmp), "%.1f MHz SF%d",
_prefs->freq, _prefs->sf);
display.print(tmp);
display.setCursor(0, Y1);
snprintf(tmp, sizeof(tmp), "BW %.0f CR %d",
_prefs->bw, _prefs->cr);
display.print(tmp);
display.setCursor(0, Y2);
snprintf(tmp, sizeof(tmp), "TX: %d dBm",
_prefs->tx_power_dbm);
display.print(tmp);
display.setCursor(0, Y3);
snprintf(tmp, sizeof(tmp), "NF: %d",
radio_driver.getNoiseFloor());
display.print(tmp);
break;
}
#ifdef BLE_PIN_CODE
// ----- BLE -----
case BLE: {
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
display.print(_task->isSerialEnabled() ? "BLE: ON" : "BLE: OFF");
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
snprintf(tmp, sizeof(tmp), "PIN: %lu",
(unsigned long)the_mesh.getBLEPin());
display.print(tmp);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y3);
display.print("Hold A: toggle");
break;
}
#endif
// ----- ADVERT -----
case ADVERT: {
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
display.print("Advert");
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
display.print("Hold A: send");
break;
}
#if defined(HAS_MICROPHONE)
// ----- VOICE -----
case VOICE: {
switch (_vState) {
case V_IDLE:
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
display.print("Voice");
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
display.print("Hold A: record");
break;
case V_RECORDING: {
unsigned long elapsed = (millis() - _vRecStart) / 1000;
int remaining = VC_MAX_SECONDS - (int)elapsed;
if (remaining < 0) remaining = 0;
display.setColor(DisplayDriver::RED);
display.setCursor(0, Y0);
display.print("RECORDING");
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
snprintf(tmp, sizeof(tmp), "%ds left", remaining);
display.print(tmp);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
snprintf(tmp, sizeof(tmp), "Frames: %d", _vEncFrames);
display.print(tmp);
display.setCursor(0, Y3);
snprintf(tmp, sizeof(tmp), "%d bytes", _vEncBytes);
display.print(tmp);
return 200; // Fast refresh during recording
}
case V_REVIEW: {
float durSec = _vEncFrames * VC_C2_FRAME_MS / 1000.0f;
int packets = (_vEncBytes + VC_MESH_PAYLOAD - 1) / VC_MESH_PAYLOAD;
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
display.print("Review");
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
snprintf(tmp, sizeof(tmp), "%.1fs %d pkt", durSec, packets);
display.print(tmp);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
snprintf(tmp, sizeof(tmp), "%d bytes", _vEncBytes);
display.print(tmp);
display.setCursor(0, Y3);
display.print("A:disc C:disc");
break;
}
} // voice state switch
break;
}
#endif
#if ENV_INCLUDE_GPS == 1
// ----- GPS -----
case GPS: {
LocationProvider* loc = sensors.getLocationProvider();
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
if (!_prefs->gps_enabled) {
display.print("GPS: OFF");
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
display.print("Hold A: toggle");
break;
}
display.print("GPS: ON");
if (loc) {
snprintf(tmp, sizeof(tmp), "S: %d",
loc->satellitesCount());
display.drawTextRightAlign(display.width() - 1, Y0, tmp);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
display.print(loc->isValid() ? "Fix: 3D" : "No fix");
if (loc->isValid()) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
snprintf(tmp, sizeof(tmp), "%.4f",
loc->getLatitude() / 1000000.0);
display.print(tmp);
display.setCursor(0, Y3);
snprintf(tmp, sizeof(tmp), "%.4f",
loc->getLongitude() / 1000000.0);
display.print(tmp);
} else {
// No fix yet -- show NMEA sentence rate to confirm the chip is talking.
// If this stays at 0, GPS is silent (baud rate wrong, RF off, etc).
display.setCursor(0, Y2);
snprintf(tmp, sizeof(tmp), "NMEA: %u/s",
(unsigned)gpsStream.getSentencesPerSec());
display.print(tmp);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y3);
display.print("Hold A: toggle");
}
}
break;
}
#endif
// ----- COMPASS -----
case COMPASS: {
if (!_compassInitDone) {
_compassOK = board.initCompass();
board.loadCalibration();
_compassInitDone = true;
}
// --- Calibration mode ---
if (_calMode) {
int16_t mx, my, mz;
if (_compassOK && board.readMag(mx, my, mz)) {
if (_calCount == 0) {
_calMinX = _calMaxX = mx;
_calMinY = _calMaxY = my;
_calMinZ = _calMaxZ = mz;
} else {
if (mx < _calMinX) _calMinX = mx;
if (mx > _calMaxX) _calMaxX = mx;
if (my < _calMinY) _calMinY = my;
if (my > _calMaxY) _calMaxY = my;
if (mz < _calMinZ) _calMinZ = mz;
if (mz > _calMaxZ) _calMaxZ = mz;
}
_calCount++;
}
int spreadX = _calMaxX - _calMinX;
int spreadY = _calMaxY - _calMinY;
int spreadZ = _calMaxZ - _calMinZ;
unsigned long elapsed = millis() - _calStart;
bool adequate = (spreadX >= 100 && spreadY >= 100 && _calCount >= 150);
bool timeout = (elapsed >= 30000);
if (adequate || (timeout && spreadX >= 50 && spreadY >= 50)) {
// Compute and save calibration
CompassCalibration cal;
cal.off_x = (_calMinX + _calMaxX) / 2;
cal.off_y = (_calMinY + _calMaxY) / 2;
cal.off_z = (_calMinZ + _calMaxZ) / 2;
float avgRange = ((float)spreadX + (float)spreadY) / 2.0f;
cal.scale_x = (spreadX > 0) ? avgRange / (float)spreadX : 1.0f;
cal.scale_y = (spreadY > 0) ? avgRange / (float)spreadY : 1.0f;
cal.scale_z = (spreadZ > 30) ? avgRange / (float)spreadZ : 1.0f;
cal.magic = COMPASS_CAL_MAGIC;
board.saveCalibration(cal);
_calMode = false;
_task->showAlert("Cal saved!", 800);
return 500;
}
if (timeout) {
_calMode = false;
_task->showAlert("Try again", 800);
return 500;
}
// Calibration progress display
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
display.print("Calibrate");
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
display.print("Rotate slowly...");
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
snprintf(tmp, sizeof(tmp), "Samples: %u", _calCount);
display.print(tmp);
display.setCursor(0, Y3);
snprintf(tmp, sizeof(tmp), "X:%d Y:%d", spreadX, spreadY);
display.print(tmp);
return 100; // fast sample collection
}
// --- Normal compass display ---
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
display.print("Compass");
if (board.isCalibrated()) {
display.drawTextRightAlign(display.width() - 1, Y0, "CAL");
}
if (!_compassOK) {
display.setColor(DisplayDriver::RED);
display.setCursor(0, Y2);
display.print("IMU not found");
break;
}
int16_t mx, my, mz;
if (board.readMag(mx, my, mz)) {
_magOk++;
// Exponential moving average: 7/8 old + 1/8 new (settles in ~2s)
if (_magOk == 1) {
_lastMx = mx; _lastMy = my; _lastMz = mz;
} else {
_lastMx = (_lastMx * 7 + mx + 4) >> 3;
_lastMy = (_lastMy * 7 + my + 4) >> 3;
_lastMz = (_lastMz * 7 + mz + 4) >> 3;
}
float cx = (float)_lastMx;
float cy = (float)_lastMy;
if (board.isCalibrated()) {
const CompassCalibration& cal = board.getCalibration();
cx = ((float)_lastMx - cal.off_x) * cal.scale_x;
cy = ((float)_lastMy - cal.off_y) * cal.scale_y;
}
// Y axis is inverted relative to compass convention on this PCB
_lastHeading = atan2f(-cy, cx) * 180.0f / (float)M_PI;
if (_lastHeading < 0) _lastHeading += 360.0f;
} else {
_magFail++;
}
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
snprintf(tmp, sizeof(tmp), "%.0f %s",
_lastHeading, cardinal(_lastHeading));
display.print(tmp);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
snprintf(tmp, sizeof(tmp), "X:%d Y:%d", _lastMx, _lastMy);
display.print(tmp);
display.setCursor(0, Y3);
snprintf(tmp, sizeof(tmp), "Z:%d", _lastMz);
display.print(tmp);
return 250; // smooth readable refresh
}
// ----- BATTERY -----
case BATTERY: {
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
display.print("Battery");
uint16_t mv = _task->getBattMilliVolts();
snprintf(tmp, sizeof(tmp), "%d%%", battPercent());
display.drawTextRightAlign(display.width() - 1, Y0, tmp);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
snprintf(tmp, sizeof(tmp), "%d.%02dV", mv / 1000, (mv % 1000) / 10);
display.print(tmp);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
{
float dieTemp = board.getMCUTemperature();
snprintf(tmp, sizeof(tmp), "Temp: %.0fC", dieTemp);
display.print(tmp);
}
break;
}
// ----- HIBERNATE -----
case HIBERNATE: {
if (_shutdown_init) {
display.setColor(DisplayDriver::RED);
display.setCursor(0, Y1);
display.print("Shutting down...");
return 200;
}
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y0);
display.print("Hibernate");
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
display.print("Hold A: sleep");
break;
}
} // switch
return 5000;
}
bool handleInput(char c) override {
if (_shutdown_init) {
_shutdown_init = false;
return true;
}
// Any input during calibration cancels it
if (_calMode) {
_calMode = false;
_task->showAlert("Cancelled", 500);
return true;
}
#if defined(HAS_MICROPHONE)
// During recording: any button press stops recording
if (_vState == V_RECORDING) {
voiceStopRecording();
return true;
}
#endif
if (c == KEY_NEXT || c == 'd') {
_page = (_page + 1) % PAGE_COUNT;
return true;
}
if (c == KEY_PREV || c == 'a') {
_page = (_page + PAGE_COUNT - 1) % PAGE_COUNT;
return true;
}
if (c == KEY_ENTER) {
switch (_page) {
#ifdef BLE_PIN_CODE
case BLE:
if (_task->isSerialEnabled()) {
_task->disableSerial();
_task->showAlert("BLE Off", 800);
} else {
_task->enableSerial();
_task->showAlert("BLE On", 800);
}
return true;
#endif
case ADVERT:
_task->notify(UIEventType::ack);
if (the_mesh.advert()) {
_task->showAlert("Sent!", 800);
} else {
_task->showAlert("Failed", 800);
}
return true;
#if defined(HAS_MICROPHONE)
case VOICE:
if (_vState == V_IDLE) {
if (voiceStartRecording()) {
return true;
} else {
_task->showAlert("Mic fail", 800);
return true;
}
} else if (_vState == V_RECORDING) {
voiceStopRecording();
return true;
} else if (_vState == V_REVIEW) {
voiceDiscard();
return true;
}
return false;
#endif
#if ENV_INCLUDE_GPS == 1
case GPS:
_task->toggleGPS();
return true;
#endif
case COMPASS:
if (!_compassOK) return false;
_calMode = true;
_calStart = millis();
_calCount = 0;
return true;
case HIBERNATE:
_shutdown_init = true;
_shutdown_at = millis() + 500;
return true;
default:
return false;
}
}
return false;
}
void poll() override {
if (_shutdown_init && millis() >= _shutdown_at) {
if (!_task->isButtonPressed()) {
_task->shutdown();
}
}
#if defined(HAS_MICROPHONE)
// Stream-encode PDM samples during recording
if (_vState == V_RECORDING) {
// Periodic diagnostic — is PDM delivering data?
static unsigned long lastDiag = 0;
static uint32_t pollCount = 0;
pollCount++;
if (millis() - lastDiag > 500) {
lastDiag = millis();
Serial.printf("Voice poll #%lu: isr=%lu pdm=%d frames=%d\n",
(unsigned long)pollCount, (unsigned long)_vIsrCount,
_vPdmCount, _vEncFrames);
}
voiceProcessSamples();
// Auto-stop at max duration
if (_vEncFrames >= VC_MAX_FRAMES ||
(millis() - _vRecStart) >= (unsigned long)(VC_MAX_SECONDS * 1000 + 200)) {
voiceStopRecording();
}
}
#endif
}
};
// Static PDM callback -- must be defined after class so onPDMData() is visible
#if defined(HAS_MICROPHONE)
static void _vcPdmISR() {
if (_vcSelf) _vcSelf->onPDMData();
}
#endif
-68
View File
@@ -1,68 +0,0 @@
#pragma once
// =============================================================================
// Arduino pin compatibility header for LilyGo T-Echo Card
//
// This file provides Arduino-standard pin name aliases for the nRF52840 GPIOs.
// Only needed if creating a custom board variant inside the Adafruit nRF52
// Arduino framework package. If using build flag overrides in platformio.ini,
// this file is optional.
//
// Pin mapping cross-referenced against:
// - LilyGo official: T-Echo-Card/libraries/private_library/t_echo_card_config.h
// - Meshtastic PR #10267 (caveman99 T-Echo-Card support)
// =============================================================================
// On nRF52840, Arduino digital pin numbers map 1:1 to nRF GPIO numbers
// (047 for port 0 and port 1).
// LED — WS2812 addressable (no plain GPIO LED on this board)
#define LED_BUILTIN PIN_LED1
#define PIN_LED1 39 // WS2812 RGB LED data 1 (1, 7)
#define LED_STATE_ON 1
// Buttons
#define PIN_BUTTON1 42 // Button A — orange front button (1, 10)
#define PIN_BUTTON2 24 // Boot button (0, 24)
// Serial (USB CDC)
// nRF52840 native USB — no UART pin assignment needed for Serial
// Serial1 is used for GPS
#define PIN_SERIAL1_RX 21 // GPS TX → nRF RX (0, 21)
#define PIN_SERIAL1_TX 19 // nRF TX → GPS RX (0, 19)
// I2C
#define PIN_WIRE_SDA 36 // (1, 4)
#define PIN_WIRE_SCL 34 // (1, 2)
// SPI (LoRa — directly mapped, RadioLib handles pin control)
#define PIN_SPI_MISO 17 // (0, 17)
#define PIN_SPI_MOSI 15 // (0, 15)
#define PIN_SPI_SCK 13 // (0, 13)
// Analog
#define PIN_A0 2 // (0, 2) — Battery ADC / AIN0
// QSPI Flash — ZD25WQ32CEIGR 4MB
// Confirmed from LilyGo t_echo_card_config.h and Meshtastic PR #10267.
// These are on a dedicated SPI bus, separate from LoRa SPI.
#define PIN_QSPI_SCK 4 // (0, 4)
#define PIN_QSPI_CS 12 // (0, 12)
#define PIN_QSPI_IO0 6 // (0, 6) — MOSI / D0
#define PIN_QSPI_IO1 8 // (0, 8) — MISO / D1
#define PIN_QSPI_IO2 41 // (1, 9) — WP / D2
#define PIN_QSPI_IO3 26 // (0, 26) — HOLD / D3
// NFC (dedicated nRF52840 NFC pins — not GPIO-assignable)
// NFC1 = P0.09, NFC2 = P0.10
// These are only usable as NFC when NFC is enabled in UICR.
// If NFC is disabled, they become GPIO9 and GPIO10.
// PDM Microphone
#define PIN_PDM_CLK 35 // (1, 3)
#define PIN_PDM_DIN 37 // (1, 5)
// I2S Speaker (MAX98357)
#define PIN_I2S_SCK 16 // BCLK (0, 16)
#define PIN_I2S_LRCK 22 // LRCK / WS (0, 22)
#define PIN_I2S_SDOUT 20 // DATA (0, 20)
-154
View File
@@ -1,154 +0,0 @@
; =============================================================================
; LilyGo T-Echo Card -- nRF52840 + SX1262 + SSD1306 OLED (72x40) + L76K GPS
; =============================================================================
[lilygo_techo_card]
extends = nrf52_base
board = lilygo_techo_card
platform_packages = framework-arduinoadafruitnrf52
board_build.ldscript = boards/nrf52840_s140_v6.ld
extra_scripts =
create-uf2.py
arch/nrf52/extra_scripts/patch_bluefruit.py
pre:patch_nrf52_bsp.py
; Point FrameworkArduinoVariant at a directory containing ONLY variant.h/cpp.
; Without this, PlatformIO tries to compile TechoCardBoard.cpp and target.cpp
; as part of the framework variant, which fails because MeshCore.h and
; RadioLib.h aren't on the BSP include path.
board_build.variants_dir = variants_bsp
build_flags = ${nrf52_base.build_flags}
-I src/helpers/nrf52
-I lib/nrf52/s140_nrf52_6.1.1_API/include
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
-I variants/lilygo_techo_card
-I lib/PDM_nrf52/src
-I lib/codec2_nrf52/src
-D LILYGO_TECHO_CARD
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
-D USE_U8G2_DISPLAY
-D DISPLAY_CLASS=U8g2Display
-D PIN_BUZZER=77
-D PIN_BOOT_BTN=24
-D ENV_INCLUDE_GPS=1
-D ENV_SKIP_GPS_DETECT
-D ps_calloc=calloc
-D DISABLE_DIAGNOSTIC_OUTPUT
-D AUTO_SHUTDOWN_MILLIVOLTS=2980
build_src_filter = ${nrf52_base.build_src_filter}
+<helpers/*.cpp>
+<helpers/sensors/EnvironmentSensorManager.cpp>
+<helpers/ui/buzzer.cpp>
+<../variants/lilygo_techo_card>
+<../lib/PDM_nrf52/src>
+<../lib/PDM_nrf52/src/utility>
+<../lib/codec2_nrf52/src>
lib_compat_mode = off
lib_ldf_mode = deep+
lib_deps =
${nrf52_base.lib_deps}
stevemarple/MicroNMEA @ ^2.0.6
adafruit/Adafruit NeoPixel @ ^1.12.3
adafruit/Adafruit SSD1306 @ ^2.5.12
adafruit/Adafruit GFX Library @ ^1.11.11
adafruit/Adafruit BusIO @ ^1.16.2
end2endzone/NonBlockingRtttl @ ^1.3.0
olikraus/U8g2 @ ^2.35.19
debug_tool = jlink
upload_protocol = nrfutil
[env:techo_card_companion_radio_ble]
extends = lilygo_techo_card
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${lilygo_techo_card.build_flags}
-I examples/companion_radio
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=250
-D MAX_GROUP_CHANNELS=4
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=16
-D CHANNEL_MSG_HISTORY_SIZE=1
-D AUTO_OFF_MILLIS=60000
-D ADVERT_PATH_TABLE_SIZE=200
; -D BLE_DEBUG_LOGGING=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${lilygo_techo_card.build_src_filter}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${lilygo_techo_card.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:techo_card_companion_radio_usb]
extends = lilygo_techo_card
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${lilygo_techo_card.build_flags}
-I examples/companion_radio
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=350
-D MAX_GROUP_CHANNELS=40
-D AUTO_OFF_MILLIS=0
; -D BLE_PIN_CODE=123456
; -D BLE_DEBUG_LOGGING=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${lilygo_techo_card.build_src_filter}
+<helpers/nrf52/*.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${lilygo_techo_card.lib_deps}
densaugeo/base64 @ ~1.4.0
[env:techo_card_repeater]
extends = lilygo_techo_card
build_flags =
${lilygo_techo_card.build_flags}
-D ADVERT_NAME='"T-Echo Card Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${lilygo_techo_card.build_src_filter}
+<../examples/simple_repeater>
[env:techo_card_room_server]
extends = lilygo_techo_card
build_flags =
${lilygo_techo_card.build_flags}
-D ADVERT_NAME='"T-Echo Card Room"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D ROOM_PASSWORD='"hello"'
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
build_src_filter = ${lilygo_techo_card.build_src_filter}
+<../examples/simple_room_server>
[env:techo_card_sensor]
extends = lilygo_techo_card
build_flags =
${lilygo_techo_card.build_flags}
-D ADVERT_NAME='"T-Echo Card Sensor"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
build_src_filter = ${lilygo_techo_card.build_src_filter}
+<../examples/simple_sensor>
-44
View File
@@ -1,44 +0,0 @@
#pragma once
// =============================================================================
// SD.h -- nRF52 shim for T-Echo Card
//
// Maps the Arduino SD library API to Adafruit InternalFileSystem so that
// screen headers (NotesScreen, MapScreen, TextReaderScreen, EpubZipReader)
// compile without modification. The T-Echo Card has no SD card slot --
// all file I/O goes to the 4MB QSPI flash via LittleFS.
//
// File class is already provided by the Adafruit LittleFS BSP.
// =============================================================================
#include <InternalFileSystem.h>
class SDClass {
public:
bool begin(int cs = -1) {
(void)cs;
return true; // InternalFS is initialised in main.cpp setup()
}
File open(const char* path, uint8_t mode = FILE_O_READ) {
return InternalFS.open(path, mode);
}
bool exists(const char* path) {
return InternalFS.exists(path);
}
bool mkdir(const char* path) {
return InternalFS.mkdir(path);
}
bool remove(const char* path) {
return InternalFS.remove(path);
}
bool rename(const char* from, const char* to) {
return InternalFS.rename(from, to);
}
};
static SDClass SD;
-58
View File
@@ -1,58 +0,0 @@
#include <Arduino.h>
#include "target.h"
#include <helpers/ArduinoHelpers.h>
#include <helpers/sensors/MicroNMEALocationProvider.h>
TechoCardBoard board;
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI);
WRAPPER_CLASS radio_driver(radio, board);
VolatileRTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if ENV_INCLUDE_GPS
GPSStreamCounter gpsStream(Serial1);
MicroNMEALocationProvider gps(gpsStream, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#else
EnvironmentSensorManager sensors;
#endif
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true);
#endif
bool radio_init() {
// board.begin() and display.begin() are called by main.cpp before this.
// radio_init() should ONLY initialise the radio -- matching Meshpocket pattern.
return radio.std_init(&SPI);
}
uint32_t radio_get_rng_seed() {
return radio.random(0x7FFFFFFF);
}
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
radio.setFrequency(freq);
radio.setSpreadingFactor(sf);
radio.setBandwidth(bw);
radio.setCodingRate(cr);
}
void radio_set_tx_power(int8_t dbm) {
radio.setOutputPower(dbm);
}
void radio_reset_agc() {
#ifdef SX126X_RX_BOOSTED_GAIN
radio.setRxBoostedGainMode(true);
#endif
}
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng);
}
-45
View File
@@ -1,45 +0,0 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/sensors/EnvironmentSensorManager.h>
#include <helpers/sensors/LocationProvider.h>
#include "TechoCardBoard.h"
#if ENV_INCLUDE_GPS
#include "GPSStreamCounter.h"
#endif
#ifdef DISPLAY_CLASS
#if defined(USE_U8G2_DISPLAY)
#include <helpers/ui/U8g2Display.h>
#else
#include <helpers/ui/SSD1306Display.h>
#endif
#include <helpers/ui/MomentaryButton.h>
#endif
extern TechoCardBoard board;
extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;
extern MomentaryButton user_btn;
#endif
extern EnvironmentSensorManager sensors;
#if ENV_INCLUDE_GPS
extern GPSStreamCounter gpsStream;
#endif
bool radio_init();
uint32_t radio_get_rng_seed();
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
void radio_set_tx_power(int8_t dbm);
void radio_reset_agc();
mesh::LocalIdentity radio_new_identity();
-11
View File
@@ -1,11 +0,0 @@
#include "variant.h"
#include "nrf.h"
#include "wiring_constants.h"
#include "wiring_digital.h"
const uint32_t g_ADigitalPinMap[] = {
// P0 -- pins 0 and 1 are hardwired for 32.768 kHz crystal (LFXO)
0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
// P1
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47};
-203
View File
@@ -1,203 +0,0 @@
/*
* variant.h -- LilyGo T-Echo Card pin definitions
*
* nRF52840 + SX1262 (HPB16B3) + SSD1315 OLED (72x40) + L76K GPS
* + MAX98357 Speaker + MP34DT05 PDM Mic + ICM20948 IMU + BQ25896 + Solar
*
* Cross-referenced against:
* - LilyGo official: T-Echo-Card/libraries/private_library/t_echo_card_config.h
* - Meshtastic PR #10267 (caveman99)
*/
#pragma once
#include "WVariant.h"
////////////////////////////////////////////////////////////////////////////////
// Low frequency clock source
#define USE_LFXO // 32.768 kHz crystal
#define VARIANT_MCK (64000000ul)
////////////////////////////////////////////////////////////////////////////////
// Power / Battery
#define PIN_VBAT_READ 2 // (0, 2) = AIN0
#define BATTERY_ADC_AIN 0 // nRF SAADC AIN channel number
// Gated voltage divider: drive HIGH before ADC read, LOW after
#define PIN_BAT_CTL 31 // (0, 31)
#define ADC_MULTIPLIER (2.0F)
#define MV_LSB (3000.0F / 4096.0F)
#define ADC_RESOLUTION (14)
#define BATTERY_SENSE_RES (12)
#define AREF_VOLTAGE (3.0)
////////////////////////////////////////////////////////////////////////////////
// Pin counts
#define PINS_COUNT (48)
#define NUM_DIGITAL_PINS (48)
#define NUM_ANALOG_INPUTS (1)
#define NUM_ANALOG_OUTPUTS (0)
////////////////////////////////////////////////////////////////////////////////
// UART -- GPS (L76K)
#define PIN_SERIAL1_RX 19 // (0, 19) -- GPS TX -> nRF RX
#define PIN_SERIAL1_TX 21 // (0, 21) -- nRF TX -> GPS RX
////////////////////////////////////////////////////////////////////////////////
// I2C (shared: OLED, IMU ICM20948)
#define WIRE_INTERFACES_COUNT (1)
#define PIN_WIRE_SDA 36 // (1, 4)
#define PIN_WIRE_SCL 34 // (1, 2)
////////////////////////////////////////////////////////////////////////////////
// LEDs -- WS2812 addressable (no plain GPIO LED)
// The BSP drives LED_BUILTIN via digitalWrite for BLE status -- if pointed at
// the WS2812 data pin (39), it holds the line HIGH and all LEDs glow green.
// Point at an unused GPIO (46 = P1.14) so the BSP toggles harmlessly.
#define LED_BUILTIN 46 // Unused GPIO -- keeps BSP happy
#define PIN_LED LED_BUILTIN
#define LED_RED LED_BUILTIN
#define LED_BLUE (-1) // Prevents Bluefruit flashing during advertising
#define PIN_STATUS_LED LED_BUILTIN
#define LED_STATE_ON 1
// WS2812 RGB LEDs -- 3 LEDs daisy-chained on a single data line (pin 39)
// Hardware verified: all three light when pin 39 is driven HIGH.
// Meshtastic PR #10267 mapped them as separate GPIOs (39, 44, 28) but
// testing confirms they're chained.
#define HAS_RGB_LED 1
#define PIN_RGB_LED_1 39 // (1, 7) -- chain data in
#define PIN_NEOPIXEL PIN_RGB_LED_1
#define NUM_NEOPIXELS 3
////////////////////////////////////////////////////////////////////////////////
// Buttons
#define PIN_BUTTON1 42 // (1, 10) -- orange front button
#define BUTTON_PIN PIN_BUTTON1
#define PIN_USER_BTN BUTTON_PIN
// Boot button: P0.24 (hardware only, used for DFU)
////////////////////////////////////////////////////////////////////////////////
// SPI -- LoRa
#define SPI_INTERFACES_COUNT (1)
#define PIN_SPI_MISO 17 // (0, 17)
#define PIN_SPI_MOSI 15 // (0, 15)
#define PIN_SPI_SCK 13 // (0, 13)
////////////////////////////////////////////////////////////////////////////////
// SX1262 LoRa Radio (HPB16B3 / S62F module)
#define USE_SX1262
#define SX126X_CS 11 // (0, 11)
#define SX126X_DIO1 40 // (1, 8)
#define SX126X_BUSY 14 // (0, 14)
#define SX126X_RESET 7 // (0, 7)
#define SX126X_DIO2_AS_RF_SWITCH true
#define SX126X_DIO3_TCXO_VOLTAGE 1.8
#define P_LORA_NSS SX126X_CS
#define P_LORA_DIO_1 SX126X_DIO1
#define P_LORA_RESET SX126X_RESET
#define P_LORA_BUSY SX126X_BUSY
#define P_LORA_SCLK PIN_SPI_SCK
#define P_LORA_MISO PIN_SPI_MISO
#define P_LORA_MOSI PIN_SPI_MOSI
// RF switch control lines (may be needed in addition to DIO2)
#define LORA_RF_VC1 27 // (0, 27)
#define LORA_RF_VC2 33 // (1, 1)
////////////////////////////////////////////////////////////////////////////////
// OLED Display -- SSD1315 (SSD1306-compatible), 72x40, I2C
//
// Physical panel is 72x40 within 128x64 GDDRAM.
// Visible window: columns 2899, pages 37 (rows 2463).
// SETDISPLAYOFFSET = 24 maps page 0 writes to the visible area.
#define HAS_OLED 1
#define OLED_I2C_ADDR 0x3C
#define OLED_WIDTH 72
#define OLED_HEIGHT 40
#define OLED_DISPLAY_OFFSET 24
// RT9080 enable -- controls 3V3 rail (OLED, GPS, LoRa, sensors)
#define PIN_OLED_EN 30 // (0, 30)
#define PIN_OLED_RESET (-1)
////////////////////////////////////////////////////////////////////////////////
// GPS -- L76K Multi-GNSS
#define HAS_GPS 1
#define GPS_EN_ACTIVE HIGH
#define GPS_BAUDRATE 9600
#define PIN_GPS_TX 21 // nRF TX -> GPS RX (vendor GPS_UART_RX / P0.21)
#define PIN_GPS_RX 19 // nRF RX <- GPS TX (vendor GPS_UART_TX / P0.19)
#define PIN_GPS_EN 47 // (1, 15)
#define PIN_GPS_WAKEUP 25 // (0, 25)
#define PIN_GPS_1PPS 23 // (0, 23)
#define PIN_GPS_RF_EN 29 // (0, 29)
////////////////////////////////////////////////////////////////////////////////
// Speaker -- MAX98357 I2S Class-D Mono Amp
#define HAS_SPEAKER 1
#define PIN_SPK_EN 43 // (1, 11)
#define PIN_SPK_EN2 3 // (0, 3)
#define PIN_SPK_BCLK 16 // (0, 16)
#define PIN_SPK_DATA 20 // (0, 20)
#define PIN_SPK_LRCK 22 // (0, 22)
////////////////////////////////////////////////////////////////////////////////
// Microphone -- MP34DT05 Digital MEMS PDM
#define HAS_MICROPHONE 1
#define PIN_MIC_CLK 35 // (1, 3)
#define PIN_MIC_DATA 37 // (1, 5)
////////////////////////////////////////////////////////////////////////////////
// Buzzer
#ifndef HAS_BUZZER
#define HAS_BUZZER 1
#endif
#ifndef PIN_BUZZER
#define PIN_BUZZER 38 // (1, 6)
#endif
////////////////////////////////////////////////////////////////////////////////
// IMU -- ICM20948
#define HAS_IMU 1
#define IMU_I2C_ADDR 0x68
////////////////////////////////////////////////////////////////////////////////
// NFC -- nRF52840 NFC-A (dedicated P0.09/P0.10)
#define HAS_NFC 1
////////////////////////////////////////////////////////////////////////////////
// External Flash -- ZD25WQ32CEIGR 4MB QSPI
#define HAS_EXT_FLASH 1
#define PIN_QSPI_SCK 4 // (0, 4)
#define PIN_QSPI_CS 12 // (0, 12)
#define PIN_QSPI_IO0 6 // (0, 6)
#define PIN_QSPI_IO1 8 // (0, 8)
#define PIN_QSPI_IO2 41 // (1, 9)
#define PIN_QSPI_IO3 26 // (0, 26)
////////////////////////////////////////////////////////////////////////////////
// No dedicated RTC chip -- time from GPS or BLE companion sync
#define HAS_RTC 0
@@ -1,15 +0,0 @@
#pragma once
// CPUPowerManager.h — nRF52 no-op stub
// nRF52840 runs at fixed 64 MHz; no frequency scaling available.
// All methods are empty so main.cpp compiles without #ifdef guards.
class CPUPowerManager {
public:
void begin() {}
void loop() {}
void setBoost() {}
void setIdle() {}
void setLowPower() {}
void clearLowPower() {}
int getFrequencyMHz() { return 64; }
};
-19
View File
@@ -1,19 +0,0 @@
#pragma once
// FS.h — nRF52 compatibility stub
// ESP32 Arduino core provides this as the base filesystem abstraction.
// On nRF52, File and filesystem types come from Adafruit_LittleFS.
// This stub exists solely to satisfy #include <FS.h> in shared headers.
#include <Arduino.h>
#include <time.h> // struct tm, gmtime — implicit on ESP32, needs explicit on nRF52
// ESP32 FS.h defines these mode strings; some shared code references them
#ifndef FILE_READ
#define FILE_READ "r"
#endif
#ifndef FILE_WRITE
#define FILE_WRITE "w"
#endif
#ifndef FILE_APPEND
#define FILE_APPEND "a"
#endif
-43
View File
@@ -1,43 +0,0 @@
#pragma once
// SD.h — nRF52 compatibility stub for Meck
// Maps Arduino SD API to Adafruit InternalFS (LittleFS on QSPI flash).
// T-Echo Lite has no SD card slot; file operations use internal flash.
#include <Adafruit_LittleFS.h>
#include <InternalFileSystem.h>
#include <time.h> // struct tm, gmtime — implicit on ESP32, explicit on nRF52
// ESP32 SD uses string file modes; define them here for compile compatibility
#ifndef FILE_READ
#define FILE_READ "r"
#endif
#ifndef FILE_WRITE
#define FILE_WRITE "w"
#endif
class SDClass {
public:
// InternalFS is already initialised by main — begin() is a no-op
bool begin(uint8_t cs = 0) { return true; }
// Accept any extra args (cs, SPI, freq) without complaint
template<typename... Args>
bool begin(Args...) { return true; }
bool exists(const char* path) { return InternalFS.exists(path); }
bool remove(const char* path) { return InternalFS.remove(path); }
bool mkdir(const char* path) { return InternalFS.mkdir(path); }
// String mode overload — matches ESP32 SD API (FILE_READ="r", FILE_WRITE="w", "r+")
Adafruit_LittleFS_Namespace::File open(const char* path, const char* mode = "r") {
uint8_t m = FILE_O_READ;
if (mode) {
if (mode[0] == 'w') m = FILE_O_WRITE;
else if (mode[0] == 'r' && mode[1] == '+') m = FILE_O_WRITE;
}
return InternalFS.open(path, m);
}
};
// Static instance per translation unit — no state (just forwards to InternalFS singleton)
static SDClass SD;
@@ -1,54 +0,0 @@
#include <Arduino.h>
#include <Wire.h>
#include "TechoBoard.h"
#ifdef LILYGO_TECHO
void TechoBoard::begin() {
NRF52Board::begin();
// Configure battery measurement control BEFORE Wire.begin()
// to ensure P0.02 is not claimed by another peripheral
pinMode(PIN_VBAT_MEAS_EN, OUTPUT);
digitalWrite(PIN_VBAT_MEAS_EN, LOW);
pinMode(PIN_VBAT_READ, INPUT);
Wire.begin();
pinMode(SX126X_POWER_EN, OUTPUT);
digitalWrite(SX126X_POWER_EN, HIGH);
delay(10);
}
uint16_t TechoBoard::getBattMilliVolts() {
// Use LilyGo's exact ADC configuration
analogReference(AR_INTERNAL_3_0);
analogReadResolution(12);
// Enable battery voltage divider (MOSFET gate on P0.31)
pinMode(PIN_VBAT_MEAS_EN, OUTPUT);
digitalWrite(PIN_VBAT_MEAS_EN, HIGH);
// Reclaim P0.02 for analog input (in case another peripheral touched it)
pinMode(PIN_VBAT_READ, INPUT);
delay(10); // let divider + ADC settle
// Read and average (matching LilyGo's approach)
uint32_t sum = 0;
for (int i = 0; i < 8; i++) {
sum += analogRead(PIN_VBAT_READ);
delayMicroseconds(100);
}
uint16_t adc = sum / 8;
// Disable divider to save power
digitalWrite(PIN_VBAT_MEAS_EN, LOW);
// LilyGo's exact formula: adc * (3000.0 / 4096.0) * 2.0
// = adc * 0.73242188 * 2.0 = adc * 1.46484375
uint16_t millivolts = (uint16_t)((float)adc * (3000.0f / 4096.0f) * 2.0f);
return millivolts;
}
#endif
@@ -1,43 +0,0 @@
#pragma once
#include <MeshCore.h>
#include <Arduino.h>
#include <helpers/NRF52Board.h>
// ============================================================
// T-Echo Lite battery pins — hardcoded from LilyGo t_echo_lite_config.h
// NOT using any defines from variant.h for battery measurement
// ============================================================
#define PIN_VBAT_READ _PINNUM(0, 2) // BATTERY_ADC_DATA
#define PIN_VBAT_MEAS_EN _PINNUM(0, 31) // BATTERY_MEASUREMENT_CONTROL
class TechoBoard : public NRF52BoardDCDC {
public:
TechoBoard() {}
void begin();
uint16_t getBattMilliVolts() override;
const char* getManufacturerName() const override {
return "LilyGo T-Echo Lite";
}
void powerOff() override {
digitalWrite(PIN_VBAT_MEAS_EN, LOW);
#ifdef LED_RED
digitalWrite(LED_RED, LOW);
#endif
#ifdef LED_GREEN
digitalWrite(LED_GREEN, LOW);
#endif
#ifdef LED_BLUE
digitalWrite(LED_BLUE, LOW);
#endif
#ifdef DISP_BACKLIGHT
digitalWrite(DISP_BACKLIGHT, LOW);
#endif
#ifdef PIN_PWR_EN
digitalWrite(PIN_PWR_EN, LOW);
#endif
sd_power_system_off();
}
};
@@ -1,268 +0,0 @@
; =============================================================================
; LilyGo T-Echo Lite — Meck variant configuration
;
; nRF52840 + SX1262 + GxEPD2 1.22" e-ink (176×192, GDEM0122T61/SSD1681)
; + CardKB via QWIIC (0x5F) + optional L76K GPS
;
; Display: GxEPD2_122_T61 — full refresh only (~2s), no fast/partial refresh.
; UI must minimise unnecessary redraws.
; Scale factors: 1.5×/2.0× give ~117×96 virtual coordinate space.
;
; Platform: nRF52 (Adafruit nRF52 Arduino)
; Board JSON: boards/t-echo.json (nRF52840 PCA10056 compatible)
; =============================================================================
; --- Base configuration for all T-Echo Lite Meck builds (with display) ---
[lilygo_techo_lite_meck]
extends = nrf52_base
board = t-echo
board_build.ldscript = boards/nrf52840_s140_v6.ld
extra_scripts = pre:patch_nrf52_bsp.py
build_flags = ${nrf52_base.build_flags}
-I variants/lilygo_techo_lite
-I src/helpers/nrf52
-I lib/nrf52/s140_nrf52_6.1.1_API/include
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
-D LILYGO_TECHO
-D LILYGO_TECHO_LITE
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D SX126X_POWER_EN=30
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
; nRF52 compatibility — no PSRAM, no SD card, fallback GPS baud for CLI code paths
-D ps_calloc=calloc
-D ps_malloc=malloc
-D GPS_BAUDRATE=9600
-D SDCARD_CS=-1
-D ROW_AUTO_LOCK=255
; Display — GxEPD2 1.22" e-ink (176×192, SSD1681)
-D DISPLAY_CLASS=GxEPDDisplay
-D EINK_DISPLAY_MODEL=GxEPD2_122_T61
-D EINK_SCALE_X=1.5f
-D EINK_SCALE_Y=2.0f
-D EINK_X_OFFSET=6
-D EINK_Y_OFFSET=1
-D DISPLAY_ROTATION=4
-D EINK_FULL_REFRESH_ONLY=1
-D EINK_VIRTUAL_W=117
-D EINK_VIRTUAL_H=88
-D AUTO_OFF_MILLIS=0
build_src_filter = ${nrf52_base.build_src_filter}
+<helpers/*.cpp>
+<TechoBoard.cpp>
+<helpers/sensors/EnvironmentSensorManager.cpp>
+<helpers/ui/GxEPDDisplay.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../variants/lilygo_techo_lite>
lib_deps =
${nrf52_base.lib_deps}
stevemarple/MicroNMEA @ ^2.0.6
adafruit/Adafruit BME280 Library @ ^2.3.0
https://github.com/SoulOfNoob/GxEPD2.git
bakercp/CRC32 @ ^2.0.0
debug_tool = jlink
upload_protocol = nrfutil
; =============================================================================
; Build Environments
; =============================================================================
; --- BLE Companion Radio (no GPS) ---
; Pairs with MeshCore companion app over Bluetooth.
; CardKB provides on-device text input for standalone messaging.
; No GPS — time synced via BLE companion or serial CLI.
[env:meck_techo_lite_ble]
extends = lilygo_techo_lite_meck
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${lilygo_techo_lite_meck.build_flags}
-I src/helpers/ui
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=250
-D MAX_GROUP_CHANNELS=8
-D CHANNEL_MSG_HISTORY_SIZE=20
-D BLE_PIN_CODE=123456
; -D BLE_DEBUG_LOGGING=1
-D OFFLINE_QUEUE_SIZE=64
-D MECK_CARDKB
-D UI_SENSORS_PAGE=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
build_src_filter = ${lilygo_techo_lite_meck.build_src_filter}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${lilygo_techo_lite_meck.lib_deps}
densaugeo/base64 @ ~1.4.0
; --- Standalone Radio (no BLE, CardKB + display only) ---
; No companion app — device IS the terminal.
; USB serial for CLI configuration.
; Frees ~20-30KB BLE RAM → room for 500 contacts + larger message history.
[env:meck_techo_lite_standalone]
extends = lilygo_techo_lite_meck
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${lilygo_techo_lite_meck.build_flags}
-I src/helpers/ui
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_GROUP_CHANNELS=8
-D CHANNEL_MSG_HISTORY_SIZE=150
-D OFFLINE_QUEUE_SIZE=1
-D MECK_CARDKB
-D UI_SENSORS_PAGE=1
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
build_src_filter = ${lilygo_techo_lite_meck.build_src_filter}
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${lilygo_techo_lite_meck.lib_deps}
densaugeo/base64 @ ~1.4.0
; --- BLE Companion Radio (with GPS) ---
; Same as above + L76K GPS for location and time sync.
; Requires external GPS module connected to UART1 (TX=P0.29, RX=P1.10).
[env:meck_techo_lite_gps_ble]
extends = lilygo_techo_lite_meck
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${lilygo_techo_lite_meck.build_flags}
-I src/helpers/ui
-I examples/companion_radio/ui-new
-D ENV_INCLUDE_GPS=1
-D GPS_BAUD_RATE=9600
-D PIN_GPS_EN=GPS_EN
-D MAX_CONTACTS=250
-D MAX_GROUP_CHANNELS=8
-D CHANNEL_MSG_HISTORY_SIZE=20
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=64
-D MECK_CARDKB
-D UI_RECENT_LIST_SIZE=9
-D UI_SENSORS_PAGE=1
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
build_src_filter = ${lilygo_techo_lite_meck.build_src_filter}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
+<../examples/companion_radio/ui-new/*.cpp>
lib_deps =
${lilygo_techo_lite_meck.lib_deps}
stevemarple/MicroNMEA @ ^2.0.6
densaugeo/base64 @ ~1.4.0
; --- Repeater ---
; Standalone LoRa repeater node. E-ink shows status.
; CardKB not useful here but included by base for consistency.
[env:meck_techo_lite_repeater]
extends = lilygo_techo_lite_meck
build_src_filter = ${lilygo_techo_lite_meck.build_src_filter}
+<../examples/simple_repeater>
build_flags =
${lilygo_techo_lite_meck.build_flags}
-D ADVERT_NAME='"Meck T-Echo Lite Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
; --- Room Server ---
; BBS-style message board node.
[env:meck_techo_lite_room_server]
extends = lilygo_techo_lite_meck
build_src_filter = ${lilygo_techo_lite_meck.build_src_filter}
+<../examples/simple_room_server>
build_flags =
${lilygo_techo_lite_meck.build_flags}
-D ADVERT_NAME='"Meck T-Echo Lite Room"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
; =============================================================================
; Headless (no display) variants
; =============================================================================
; --- Headless base (no display, no GxEPD2) ---
[lilygo_techo_lite_meck_core]
extends = nrf52_base
board = t-echo
board_build.ldscript = boards/nrf52840_s140_v6.ld
build_flags = ${nrf52_base.build_flags}
-I variants/lilygo_techo_lite
-I src/helpers/nrf52
-I lib/nrf52/s140_nrf52_6.1.1_API/include
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
-D LILYGO_TECHO
-D LILYGO_TECHO_LITE
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
-D LORA_TX_POWER=22
-D SX126X_POWER_EN=30
-D SX126X_CURRENT_LIMIT=140
-D SX126X_RX_BOOSTED_GAIN=1
; nRF52 compatibility
-D ps_calloc=calloc
-D ps_malloc=malloc
-D GPS_BAUDRATE=9600
-D SDCARD_CS=-1
-D ROW_AUTO_LOCK=255
-D DISABLE_DIAGNOSTIC_OUTPUT
-D AUTO_OFF_MILLIS=0
build_src_filter = ${nrf52_base.build_src_filter}
+<helpers/*.cpp>
+<TechoBoard.cpp>
+<helpers/sensors/EnvironmentSensorManager.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../variants/lilygo_techo_lite>
lib_deps =
${nrf52_base.lib_deps}
stevemarple/MicroNMEA @ ^2.0.6
adafruit/Adafruit BME280 Library @ ^2.3.0
bakercp/CRC32 @ ^2.0.0
debug_tool = jlink
upload_protocol = nrfutil
; --- Headless Repeater (no display — lowest power, outdoor deployment) ---
[env:meck_techo_lite_core_repeater]
extends = lilygo_techo_lite_meck_core
build_src_filter = ${lilygo_techo_lite_meck_core.build_src_filter}
+<../examples/simple_repeater>
build_flags =
${lilygo_techo_lite_meck_core.build_flags}
-D ADVERT_NAME='"Meck T-Echo Lite Core Repeater"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
-D ADMIN_PASSWORD='"password"'
-D MAX_NEIGHBOURS=50
; --- Headless BLE Companion (no display — phone-only UI) ---
[env:meck_techo_lite_core_ble]
extends = lilygo_techo_lite_meck_core
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
board_upload.maximum_size = 712704
build_flags =
${lilygo_techo_lite_meck_core.build_flags}
-D MAX_CONTACTS=250
-D MAX_GROUP_CHANNELS=8
-D CHANNEL_MSG_HISTORY_SIZE=20
-D BLE_PIN_CODE=234567
-D OFFLINE_QUEUE_SIZE=64
-D AUTO_SHUTDOWN_MILLIVOLTS=2800
build_src_filter = ${lilygo_techo_lite_meck_core.build_src_filter}
+<helpers/nrf52/SerialBLEInterface.cpp>
+<../examples/companion_radio/*.cpp>
lib_deps =
${lilygo_techo_lite_meck_core.lib_deps}
densaugeo/base64 @ ~1.4.0
-55
View File
@@ -1,55 +0,0 @@
#include <Arduino.h>
#include "target.h"
#include <helpers/ArduinoHelpers.h>
#include <helpers/sensors/MicroNMEALocationProvider.h>
TechoBoard board;
RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI);
WRAPPER_CLASS radio_driver(radio, board);
VolatileRTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#ifdef ENV_INCLUDE_GPS
MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock);
EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea);
#else
EnvironmentSensorManager sensors = EnvironmentSensorManager();
#endif
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true);
#endif
bool radio_init() {
rtc_clock.begin(Wire);
return radio.std_init(&SPI);
}
uint32_t radio_get_rng_seed() {
return radio.random(0x7FFFFFFF);
}
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) {
radio.setFrequency(freq);
radio.setSpreadingFactor(sf);
radio.setBandwidth(bw);
radio.setCodingRate(cr);
}
void radio_set_tx_power(int8_t dbm) {
radio.setOutputPower(dbm);
}
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng); // create new random identity
}
void radio_reset_agc() {
radio.setRxBoostedGainMode(true);
}
-32
View File
@@ -1,32 +0,0 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <TechoBoard.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#include <helpers/sensors/EnvironmentSensorManager.h>
#include <helpers/sensors/LocationProvider.h>
#ifdef DISPLAY_CLASS
#include <helpers/ui/GxEPDDisplay.h>
#include <helpers/ui/MomentaryButton.h>
#endif
extern TechoBoard board;
extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
extern EnvironmentSensorManager sensors;
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;
extern MomentaryButton user_btn;
#endif
bool radio_init();
uint32_t radio_get_rng_seed();
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
void radio_set_tx_power(int8_t dbm);
mesh::LocalIdentity radio_new_identity();
void radio_reset_agc();
@@ -1,39 +0,0 @@
#include "variant.h"
#include "wiring_constants.h"
#include "wiring_digital.h"
const int MISO = PIN_SPI1_MISO;
const int MOSI = PIN_SPI1_MOSI;
const int SCK = PIN_SPI1_SCK;
const uint32_t g_ADigitalPinMap[] = {
0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26,
27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
40, 41, 42, 43, 44, 45, 46, 47
};
void initVariant() {
pinMode(PIN_PWR_EN, OUTPUT);
digitalWrite(PIN_PWR_EN, HIGH);
pinMode(PIN_BUTTON1, INPUT_PULLUP);
pinMode(PIN_BUTTON2, INPUT_PULLUP);
pinMode(LED_RED, OUTPUT);
pinMode(LED_GREEN, OUTPUT);
pinMode(LED_BLUE, OUTPUT);
digitalWrite(LED_BLUE, HIGH);
digitalWrite(LED_GREEN, HIGH);
digitalWrite(LED_RED, HIGH);
// pinMode(PIN_TXCO, OUTPUT);
// digitalWrite(PIN_TXCO, HIGH);
pinMode(DISP_POWER, OUTPUT);
digitalWrite(DISP_POWER, LOW);
// shutdown gps
pinMode(GPS_EN, OUTPUT);
digitalWrite(GPS_EN, LOW);
}
-159
View File
@@ -1,159 +0,0 @@
/*
* variant.h
* Copyright (C) 2023 Seeed K.K.
* MIT License
*/
#pragma once
#define _PINNUM(port, pin) ((port) * 32 + (pin))
#include "WVariant.h"
////////////////////////////////////////////////////////////////////////////////
// Low frequency clock source
#define USE_LFXO // 32.768 kHz crystal oscillator
#define VARIANT_MCK (64000000ul)
#define WIRE_INTERFACES_COUNT (1)
////////////////////////////////////////////////////////////////////////////////
// Power
#define PIN_PWR_EN _PINNUM(0, 30) // RT9080_EN
#define BATTERY_PIN _PINNUM(0, 2)
#define ADC_MULTIPLIER (2.0F)
#define ADC_RESOLUTION (14)
#define BATTERY_SENSE_RES (12)
#define AREF_VOLTAGE (3.0)
////////////////////////////////////////////////////////////////////////////////
// Number of pins
#define PINS_COUNT (48)
#define NUM_DIGITAL_PINS (48)
#define NUM_ANALOG_INPUTS (1)
#define NUM_ANALOG_OUTPUTS (0)
////////////////////////////////////////////////////////////////////////////////
// UART pin definition
#define PIN_SERIAL1_RX PIN_GPS_TX
#define PIN_SERIAL1_TX PIN_GPS_RX
////////////////////////////////////////////////////////////////////////////////
// I2C pin definition
#define PIN_WIRE_SDA _PINNUM(1, 4) // (SDA) - per LilyGo IIC_1_SDA
#define PIN_WIRE_SCL _PINNUM(1, 2) // (SCL) - per LilyGo IIC_1_SCL
////////////////////////////////////////////////////////////////////////////////
// SPI pin definition
#define SPI_INTERFACES_COUNT (2)
#define PIN_SPI_MISO _PINNUM(0, 17) // (MISO)
#define PIN_SPI_MOSI _PINNUM(0, 15) // (MOSI)
#define PIN_SPI_SCK _PINNUM(0, 13) // (SCK)
#define PIN_SPI_NSS (-1)
////////////////////////////////////////////////////////////////////////////////
// QSPI FLASH
#define PIN_QSPI_SCK _PINNUM(0, 4)
#define PIN_QSPI_CS _PINNUM(0, 12)
#define PIN_QSPI_IO0 _PINNUM(0, 6)
#define PIN_QSPI_IO1 _PINNUM(0, 8)
#define PIN_QSPI_IO2 _PINNUM(1, 9)
#define PIN_QSPI_IO3 _PINNUM(0, 26)
#define EXTERNAL_FLASH_DEVICES ZD25WQ32CEIGR
#define EXTERNAL_FLASH_USE_QSPI
////////////////////////////////////////////////////////////////////////////////
// Builtin LEDs
#define LED_RED _PINNUM(1, 14) // LED_3
#define LED_BLUE _PINNUM(1, 5) // LED_2
#define LED_GREEN _PINNUM(1, 7) // LED_1
//#define PIN_STATUS_LED LED_BLUE
#define LED_BUILTIN (-1)
#define LED_PIN LED_BUILTIN
#define LED_STATE_ON LOW
////////////////////////////////////////////////////////////////////////////////
// Builtin buttons
#define PIN_BUTTON1 _PINNUM(0, 24) // BOOT
#define BUTTON_PIN PIN_BUTTON1
#define PIN_USER_BTN BUTTON_PIN
#define PIN_BUTTON2 _PINNUM(0, 18)
#define BUTTON_PIN2 PIN_BUTTON2
#define EXTERNAL_FLASH_DEVICES MX25R1635F
#define EXTERNAL_FLASH_USE_QSPI
////////////////////////////////////////////////////////////////////////////////
// Lora
#define USE_SX1262
#define LORA_CS _PINNUM(0, 11)
#define SX126X_POWER_EN _PINNUM(0, 30)
#define SX126X_DIO1 _PINNUM(1, 8)
#define SX126X_BUSY _PINNUM(0, 14)
#define SX126X_RESET _PINNUM(0, 7)
#define SX126X_RF_VC1 _PINNUM(0, 27)
#define SX126X_RF_VC2 _PINNUM(0, 33)
#define P_LORA_DIO_1 SX126X_DIO1
#define P_LORA_NSS LORA_CS
#define P_LORA_RESET SX126X_RESET
#define P_LORA_BUSY SX126X_BUSY
#define P_LORA_SCLK PIN_SPI_SCK
#define P_LORA_MISO PIN_SPI_MISO
#define P_LORA_MOSI PIN_SPI_MOSI
////////////////////////////////////////////////////////////////////////////////
// SPI1
#define PIN_SPI1_MISO (-1) // Not used for Display
#define PIN_SPI1_MOSI _PINNUM(0, 20)
#define PIN_SPI1_SCK _PINNUM(0, 19)
// GxEPD2 needs that for a panel that is not even used !
extern const int MISO;
extern const int MOSI;
extern const int SCK;
////////////////////////////////////////////////////////////////////////////////
// Display
// #define DISP_MISO (-1) // Not used for Display
#define DISP_MOSI _PINNUM(0, 20)
#define DISP_SCLK _PINNUM(0, 19)
#define DISP_CS _PINNUM(0, 22)
#define DISP_DC _PINNUM(0, 21)
#define DISP_RST _PINNUM(0, 28)
#define DISP_BUSY _PINNUM(0, 3)
#define DISP_POWER _PINNUM(1, 12)
// #define DISP_BACKLIGHT (-1) // Display has no backlight
#define PIN_DISPLAY_CS DISP_CS
#define PIN_DISPLAY_DC DISP_DC
#define PIN_DISPLAY_RST DISP_RST
#define PIN_DISPLAY_BUSY DISP_BUSY
////////////////////////////////////////////////////////////////////////////////
// GPS — per LilyGo t_echo_lite_config.h
// PIN_GPS_TX/RX named from GPS module's perspective
#define PIN_GPS_TX _PINNUM(0, 29) // GPS UART TX → MCU RX
#define PIN_GPS_RX _PINNUM(1, 10) // GPS UART RX ← MCU TX
#define GPS_EN _PINNUM(1, 11) // GPS RT9080 power enable
#define PIN_GPS_STANDBY _PINNUM(1, 13) // GPS wake-up
#define PIN_GPS_PPS _PINNUM(1, 15) // GPS 1PPS