diff --git a/.gitignore b/.gitignore index a0ad5f6ea9..0b02ba18d7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ compile_commands.json .venv/ venv/ platformio.local.ini +.DS_Store diff --git a/boards/lilygo_techo_card.json b/boards/lilygo_techo_card.json new file mode 100644 index 0000000000..a57f88b455 --- /dev/null +++ b/boards/lilygo_techo_card.json @@ -0,0 +1,50 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + ["0x239A", "0x8029"] + ], + "usb_product": "T-Echo Card", + "mcu": "nrf52840", + "variant": "lilygo_techo_card", + "variants_dir": "variants_bsp", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "6.1.1", + "sd_fwid": "0x00B6" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": ["bluetooth"], + "debug": { + "jlink_device": "nRF52840_xxAA", + "openocd_target": "nrf52840" + }, + "frameworks": ["arduino"], + "name": "LilyGo T-Echo Card (nRF52840, SX1262, 4MB Flash)", + "upload": { + "maximum_ram_size": 248832, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": ["nrfutil", "jlink", "cmsis-dap"], + "native_usb": true, + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://github.com/Xinyuan-LilyGO/T-Echo-Card", + "vendor": "LILYGO" +} \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e8c1914bad..6dca77e1bd 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -642,6 +642,7 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim if (permissions & TELEM_PERM_BASE) { // only respond if base permission bit is set telemetry.reset(); telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + { float t = board.getMCUTemperature(); if (!isnan(t)) telemetry.addTemperature(TELEM_CHANNEL_SELF, t); } // query other sensors -- target specific sensors.querySensors(permissions, telemetry); @@ -1613,6 +1614,7 @@ void MyMesh::handleCmdFrame(size_t len) { } else if (cmd_frame[0] == CMD_SEND_TELEMETRY_REQ && len == 4) { // 'self' telemetry request telemetry.reset(); telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); + { float t = board.getMCUTemperature(); if (!isnan(t)) telemetry.addTemperature(TELEM_CHANNEL_SELF, t); } // query other sensors -- target specific sensors.querySensors(0xFF, telemetry); @@ -2191,3 +2193,12 @@ bool MyMesh::advert() { return false; } } + +// To check if there is pending work +bool MyMesh::hasPendingWork() const { + #if defined(WITH_BRIDGE) + if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep + #endif + // If getOutboundTotal() is not available, use: _mgr->getOutboundCount(0xFFFFFFFF) > 0 + return _mgr->getOutboundTotal() > 0; +} \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index aeff591cf4..9a0390c727 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -174,9 +174,19 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { sprintf(interval_str, "%u", _prefs.gps_interval); sensors.setSettingValue("gps_interval", interval_str); } + #if defined(LILYGO_TECHO_CARD) + // Power the L76K down at boot if GPS is persisted as off. + // Without this, board.begin() drives PIN_GPS_EN HIGH unconditionally + // and the chip stays powered until the user manually toggles GPS + // twice through the menu. + board.enableGPS(_prefs.gps_enabled); + #endif } #endif + // To check if there is pending work + bool hasPendingWork() const; + private: void writeOKFrame(); void writeErrFrame(uint8_t err_code); @@ -249,4 +259,4 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table }; -extern MyMesh the_mesh; +extern MyMesh the_mesh; \ No newline at end of file diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 876dc9c33c..70b0a9c870 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -229,4 +229,10 @@ void loop() { ui_task.loop(); #endif rtc_clock.tick(); -} + + if (!the_mesh.hasPendingWork()) { + #if defined(NRF52_PLATFORM) + board.sleep(0); // nrf ignores seconds param, sleeps whenever possible + #endif + } +} \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 49c75a5b8e..e0a797f73f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -5,6 +5,9 @@ #ifdef WIFI_SSID #include #endif +#if defined(LILYGO_TECHO_CARD) + #include "TechoCardHomeScreen.h" +#endif #ifndef AUTO_OFF_MILLIS #define AUTO_OFF_MILLIS 15000 // 15 seconds @@ -34,7 +37,7 @@ class SplashScreen : public UIScreen { UITask* _task; unsigned long dismiss_after; - char _version_info[12]; + char _version_info[24]; public: SplashScreen(UITask* task) : _task(task) { @@ -52,6 +55,15 @@ class SplashScreen : public UIScreen { } int render(DisplayDriver& display) override { +#if defined(LILYGO_TECHO_CARD) + // Text-only splash for 72×40 OLED -- no room for 128px logo + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + display.drawTextCentered(display.width()/2, 2, "MeshCore"); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width()/2, 14, _version_info); + display.drawTextCentered(display.width()/2, 26, FIRMWARE_BUILD_DATE); +#else // meshcore logo display.setColor(DisplayDriver::BLUE); int logoWidth = 128; @@ -72,6 +84,7 @@ class SplashScreen : public UIScreen { display.setTextSize(1); display.drawTextCentered(display.width()/2, 48, FIRMWARE_BUILD_DATE); +#endif return 1000; } @@ -585,8 +598,14 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no _alert_expiry = 0; splash = new SplashScreen(this); +#if defined(LILYGO_TECHO_CARD) + home = new TechoCardHomeScreen(this, &rtc_clock, node_prefs); +#else home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); +#endif +#if !defined(LILYGO_TECHO_CARD) msg_preview = new MsgPreviewScreen(this, &rtc_clock); +#endif setCurrScreen(splash); } @@ -635,8 +654,10 @@ void UITask::msgRead(int msgcount) { void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { _msgcount = msgcount; +#if !defined(LILYGO_TECHO_CARD) ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text); setCurrScreen(msg_preview); +#endif if (_display != NULL) { if (!_display->isOn() && !hasConnection()) { @@ -742,9 +763,49 @@ void UITask::loop() { } else if (ev == BUTTON_EVENT_LONG_PRESS) { c = handleLongPress(KEY_ENTER); } else if (ev == BUTTON_EVENT_DOUBLE_CLICK) { + #if defined(LILYGO_TECHO_CARD) + // Toggle screen on/off for battery saving + if (_display != NULL) { + if (_display->isOn()) { + _display->turnOff(); + } else { + _display->turnOn(); + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 0; + } + } + c = 0; // consume event + #else c = handleDoubleClick(KEY_PREV); + #endif } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { + #if defined(LILYGO_TECHO_CARD) + toggleBuzzer(); + c = 0; // consume event + #else c = handleTripleClick(KEY_SELECT); + #endif + } +#endif +#if defined(PIN_BOOT_BTN) + // Second navigation button (C / Boot on T-Echo Card) + torch + { + static MomentaryButton boot_btn(PIN_BOOT_BTN, LONG_PRESS_MILLIS, true); + static bool _boot_btn_ready = false; + if (!_boot_btn_ready) { boot_btn.begin(); _boot_btn_ready = true; } + static bool torch_on = false; + int ev2 = boot_btn.check(); + if (ev2 == BUTTON_EVENT_CLICK && c == 0) { + c = checkDisplayOn(KEY_PREV); + } else if (ev2 == BUTTON_EVENT_DOUBLE_CLICK) { + torch_on = !torch_on; + if (torch_on) { + // Single LED only -- driving all three white exceeds RT9080 current budget and reboots + board.setStatusLED(0, 0xFFFFFF); + } else { + board.setStatusLED(0, 0); + } + } } #endif #if defined(PIN_USER_BTN_ANA) @@ -905,6 +966,12 @@ void UITask::toggleGPS() { _node_prefs->gps_enabled = 1; notify(UIEventType::ack); } + #if defined(LILYGO_TECHO_CARD) + // Actually power the L76K down/up at the hardware level. + // Without this, toggling GPS off only stops reading position data + // while the chip itself stays powered (~25mA draw). + board.enableGPS(_node_prefs->gps_enabled); + #endif the_mesh.savePrefs(); showAlert(_node_prefs->gps_enabled ? "GPS: Enabled" : "GPS: Disabled", 800); _next_refresh = 0; @@ -928,4 +995,4 @@ void UITask::toggleBuzzer() { showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); _next_refresh = 0; // trigger refresh #endif -} +} \ No newline at end of file diff --git a/src/helpers/ui/U8g2Display.h b/src/helpers/ui/U8g2Display.h new file mode 100644 index 0000000000..5d736dc3fe --- /dev/null +++ b/src/helpers/ui/U8g2Display.h @@ -0,0 +1,127 @@ +#pragma once + +#include "DisplayDriver.h" +#include +#include + +#ifndef DISPLAY_ADDRESS + #define DISPLAY_ADDRESS 0x3C +#endif + +#ifndef OLED_WIDTH + #define OLED_WIDTH 72 +#endif + +#ifndef OLED_HEIGHT + #define OLED_HEIGHT 40 +#endif + +class U8g2Display : public DisplayDriver { + // U8g2 constructor for SSD1306/SSD1315 72×40 panel — handles all + // GDDRAM column/page offsets, SETMULTIPLEX, SETDISPLAYOFFSET internally + U8G2_SSD1306_72X40_ER_F_HW_I2C _u8g2; + bool _isOn; + uint8_t _drawColor; + + // Font metrics for current font (cached on setTextSize) + uint8_t _fontAscent; + uint8_t _fontHeight; + + void applyFont(int sz) { + if (sz >= 2) { + _u8g2.setFont(u8g2_font_5x7_mr); // 5×7 — "large" for this display + } else { + _u8g2.setFont(u8g2_font_5x7_mr); + } + _fontAscent = _u8g2.getAscent(); + _fontHeight = _u8g2.getAscent() - _u8g2.getDescent(); + } + +public: + U8g2Display() : DisplayDriver(OLED_WIDTH, OLED_HEIGHT), + _u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE), + _isOn(false), _drawColor(1), _fontAscent(5), _fontHeight(6) {} + + bool begin() { + // Wire must already be initialised by board.begin() before this is called + bool ok = _u8g2.begin(); + if (ok) { + _u8g2.setI2CAddress(DISPLAY_ADDRESS * 2); // U8g2 uses 8-bit address + _u8g2.setFontPosTop(); // y coordinate = top of text, not baseline + _u8g2.setFontMode(1); // transparent background + applyFont(1); // default to compact font + _isOn = true; + } + return ok; + } + + bool isOn() override { return _isOn; } + + void turnOn() override { + _u8g2.setPowerSave(0); + _isOn = true; + } + + void turnOff() override { + _u8g2.setPowerSave(1); + _isOn = false; + } + + void clear() override { + _u8g2.clearBuffer(); + _u8g2.sendBuffer(); + } + + void startFrame(Color bkg = DARK) override { + _u8g2.clearBuffer(); + _drawColor = 1; + _u8g2.setDrawColor(1); + applyFont(1); + } + + void setTextSize(int sz) override { + applyFont(sz); + } + + void setColor(Color c) override { + _drawColor = (c != DARK) ? 1 : 0; + _u8g2.setDrawColor(_drawColor); + } + + void setCursor(int x, int y) override { + _cursorX = x; + _cursorY = y; + } + + void print(const char* str) override { + _u8g2.setDrawColor(_drawColor); + _u8g2.drawStr(_cursorX, _cursorY, str); + } + + void fillRect(int x, int y, int w, int h) override { + _u8g2.setDrawColor(_drawColor); + _u8g2.drawBox(x, y, w, h); + } + + void drawRect(int x, int y, int w, int h) override { + _u8g2.setDrawColor(_drawColor); + _u8g2.drawFrame(x, y, w, h); + } + + void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { + _u8g2.setDrawColor(1); + _u8g2.drawXBM(x, y, w, h, bits); + } + + uint16_t getTextWidth(const char* str) override { + return _u8g2.getStrWidth(str); + } + + void endFrame() override { + _u8g2.sendBuffer(); + } + +private: + int _cursorX = 0; + int _cursorY = 0; +}; diff --git a/variants/lilygo_techo_card/GPSStreamCounter.h b/variants/lilygo_techo_card/GPSStreamCounter.h new file mode 100644 index 0000000000..dbe9884658 --- /dev/null +++ b/variants/lilygo_techo_card/GPSStreamCounter.h @@ -0,0 +1,76 @@ +#pragma once + +#include + +// 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; +}; diff --git a/variants/lilygo_techo_card/TechoCardBoard.cpp b/variants/lilygo_techo_card/TechoCardBoard.cpp new file mode 100644 index 0000000000..cf9c601fbe --- /dev/null +++ b/variants/lilygo_techo_card/TechoCardBoard.cpp @@ -0,0 +1,339 @@ +#include "TechoCardBoard.h" +#include "variant.h" +#include +#include +#include +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 ~280µs + _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; +} \ No newline at end of file diff --git a/variants/lilygo_techo_card/TechoCardBoard.h b/variants/lilygo_techo_card/TechoCardBoard.h new file mode 100644 index 0000000000..ae432682b7 --- /dev/null +++ b/variants/lilygo_techo_card/TechoCardBoard.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include +#include "variant.h" + +#if defined(HAS_RGB_LED) + #include +#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() : NRF52Board("TECHO_CARD_OTA") {} + + 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 }; +}; \ No newline at end of file diff --git a/variants/lilygo_techo_card/TechoCardHomeScreen.h b/variants/lilygo_techo_card/TechoCardHomeScreen.h new file mode 100644 index 0000000000..78c789eb72 --- /dev/null +++ b/variants/lilygo_techo_card/TechoCardHomeScreen.h @@ -0,0 +1,512 @@ +// ============================================================================= +// 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 +#include +#include +#include +#include +#include "MyMesh.h" +#include "UITask.h" + +class TechoCardHomeScreen : public UIScreen { + enum Page { + STATUS, + RADIO, +#ifdef BLE_PIN_CODE + BLE, +#endif + ADVERT, +#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; + + // 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) {} + + void cancelEditing() { _shutdown_init = false; } + + 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 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 (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 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(); + } + } + } +}; \ No newline at end of file diff --git a/variants/lilygo_techo_card/platformio.ini b/variants/lilygo_techo_card/platformio.ini new file mode 100644 index 0000000000..34ced774d3 --- /dev/null +++ b/variants/lilygo_techo_card/platformio.ini @@ -0,0 +1,139 @@ +; ============================================================================= +; 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 +; 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 + -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=38 + -D PIN_BOOT_BTN=24 + -D ENV_INCLUDE_GPS=1 + -D ENV_SKIP_GPS_DETECT + -D DISABLE_DIAGNOSTIC_OUTPUT +build_src_filter = ${nrf52_base.build_src_filter} + + + + + + + +<../variants/lilygo_techo_card> +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=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + -D AUTO_OFF_MILLIS=60000 +; -D BLE_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${lilygo_techo_card.build_src_filter} + + + +<../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} + + + +<../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> \ No newline at end of file diff --git a/variants/lilygo_techo_card/target.cpp b/variants/lilygo_techo_card/target.cpp new file mode 100644 index 0000000000..0fb27d2249 --- /dev/null +++ b/variants/lilygo_techo_card/target.cpp @@ -0,0 +1,52 @@ +#include +#include "target.h" +#include +#include + +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); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} \ No newline at end of file diff --git a/variants/lilygo_techo_card/target.h b/variants/lilygo_techo_card/target.h new file mode 100644 index 0000000000..19e41434e5 --- /dev/null +++ b/variants/lilygo_techo_card/target.h @@ -0,0 +1,44 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include "TechoCardBoard.h" + +#if ENV_INCLUDE_GPS +#include "GPSStreamCounter.h" +#endif + +#ifdef DISPLAY_CLASS + #if defined(USE_U8G2_DISPLAY) + #include + #else + #include + #endif + #include +#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); +mesh::LocalIdentity radio_new_identity(); \ No newline at end of file diff --git a/variants/lilygo_techo_card/variant.cpp b/variants/lilygo_techo_card/variant.cpp new file mode 100644 index 0000000000..4cba928f16 --- /dev/null +++ b/variants/lilygo_techo_card/variant.cpp @@ -0,0 +1,11 @@ +#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}; \ No newline at end of file diff --git a/variants/lilygo_techo_card/variant.h b/variants/lilygo_techo_card/variant.h new file mode 100644 index 0000000000..c60c7684bb --- /dev/null +++ b/variants/lilygo_techo_card/variant.h @@ -0,0 +1,202 @@ +/* + * 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 28–99, pages 3–7 (rows 24–63). +// 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_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 \ No newline at end of file