diff --git a/.github/workflows/build-cardputer-adv.yml b/.github/workflows/build-cardputer-adv.yml new file mode 100644 index 0000000000..b49f94c5df --- /dev/null +++ b/.github/workflows/build-cardputer-adv.yml @@ -0,0 +1,42 @@ +name: Build Cardputer ADV + +on: + push: + branches: + - cardputer-ui-new-port + paths: + - 'boards/**' + - 'src/**' + - 'examples/**' + - 'variants/**' + - '.github/workflows/build-cardputer-adv.yml' + pull_request: + branches: + - main + paths: + - 'boards/**' + - 'src/**' + - 'examples/**' + - 'variants/**' + - '.github/workflows/build-cardputer-adv.yml' + workflow_dispatch: + +jobs: + build-cardputer: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + environment: + - M5stack_cardputer_cap_lora1262_companion_radio_ble + - M5stack_cardputer_cap_lora1262_companion_radio_usb + + steps: + - name: Clone Repo + uses: actions/checkout@v4 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build ${{ matrix.environment }} + run: pio run -e ${{ matrix.environment }} diff --git a/boards/m5stack_cardputer.json b/boards/m5stack_cardputer.json new file mode 100644 index 0000000000..1f445ccab0 --- /dev/null +++ b/boards/m5stack_cardputer.json @@ -0,0 +1,41 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_8MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DARDUINO_M5STACK_CARDPUTER", + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "M5Stack Cardputer-Adv (8M Flash 8M PSRAM)", + "upload": { + "flash_size": "8MB", + "maximum_ram_size": 327680, + "maximum_size": 8388608, + "require_upload_port": true, + "speed": 1500000 + }, + "url": "https://shop.m5stack.com/products/m5stack-cardputer-adv", + "vendor": "M5Stack" +} diff --git a/src/helpers/ui/M5CardputerDisplay.cpp b/src/helpers/ui/M5CardputerDisplay.cpp new file mode 100644 index 0000000000..e677ceaa9c --- /dev/null +++ b/src/helpers/ui/M5CardputerDisplay.cpp @@ -0,0 +1,3 @@ +#include "M5CardputerDisplay.h" + +// Most functionality is inline in the header. diff --git a/src/helpers/ui/M5CardputerDisplay.h b/src/helpers/ui/M5CardputerDisplay.h new file mode 100644 index 0000000000..93389791da --- /dev/null +++ b/src/helpers/ui/M5CardputerDisplay.h @@ -0,0 +1,158 @@ +#pragma once + +#include +#include "DisplayDriver.h" + +#ifndef TFT_BLACK +#define TFT_BLACK 0x0000 +#define TFT_WHITE 0xFFFF +#define TFT_RED 0xF800 +#define TFT_GREEN 0x07E0 +#define TFT_BLUE 0x001F +#define TFT_YELLOW 0xFFE0 +#define TFT_ORANGE 0xFD20 +#endif + +#ifdef CARDPUTER_RF_DIAG_OVERLAY +class M5CardputerDisplay; +void cardputerDrawRfOverlay(M5CardputerDisplay& disp); +#endif + +class M5CardputerDisplay : public DisplayDriver { +private: + bool _isOn = false; + uint16_t cursor_x = 0; + uint16_t cursor_y = 0; + uint16_t text_size = 1; + uint16_t _color = TFT_WHITE; + uint16_t _light_color = TFT_WHITE; + uint16_t _dark_color = TFT_BLACK; + uint16_t _bg_color = TFT_BLACK; + static const uint16_t SCREEN_WIDTH = 240; + static const uint16_t SCREEN_HEIGHT = 135; + static const uint8_t CHAR_WIDTH = 6; + static const uint8_t CHAR_HEIGHT = 8; + +public: + M5CardputerDisplay() : DisplayDriver(SCREEN_WIDTH, SCREEN_HEIGHT) {} + + bool begin() { + _isOn = true; + _bg_color = TFT_BLACK; + M5.Display.setRotation(1); + M5.Display.fillScreen(_bg_color); + M5.Display.setTextColor(TFT_WHITE, _bg_color); + M5.Display.setTextSize(1); + return true; + } + + bool isOn() override { return _isOn; } + + void turnOn() override { + if (!_isOn) { + M5.Display.wakeup(); + M5.Display.setTextColor(_color, _bg_color); + _isOn = true; + } + } + + void turnOff() override { + if (_isOn) { + M5.Display.sleep(); + _isOn = false; + } + } + + void clear() override { + if (!_isOn) return; + M5.Display.fillScreen(_bg_color); + cursor_x = 0; + cursor_y = 0; + } + + void startFrame(Color bkg = DARK) override { + if (!_isOn) return; + + switch (bkg) { + case DARK: _bg_color = _dark_color; break; + case LIGHT: _bg_color = _light_color; break; + case RED: _bg_color = TFT_RED; break; + case GREEN: _bg_color = TFT_GREEN; break; + case BLUE: _bg_color = TFT_BLUE; break; + case YELLOW: _bg_color = TFT_YELLOW; break; + case ORANGE: _bg_color = TFT_ORANGE; break; + default: _bg_color = _dark_color; break; + } + + M5.Display.fillScreen(_bg_color); + M5.Display.setTextColor(_color, _bg_color); + cursor_x = 0; + cursor_y = 0; + } + + void setTextSize(int sz) override { + text_size = sz; + M5.Display.setTextSize(sz); + } + + void setColor(Color c) override { + switch (c) { + case DARK: _color = _dark_color; break; + case LIGHT: _color = _light_color; break; + case RED: _color = TFT_RED; break; + case GREEN: _color = TFT_GREEN; break; + case BLUE: _color = TFT_BLUE; break; + case YELLOW: _color = TFT_YELLOW; break; + case ORANGE: _color = TFT_ORANGE; break; + default: _color = _light_color; break; + } + M5.Display.setTextColor(_color, _bg_color); + } + + void setCursor(int x, int y) override { + cursor_x = x; + cursor_y = y; + M5.Display.setCursor(x, y); + } + + void print(const char* str) override { + if (!_isOn) return; + M5.Display.print(str); + } + + void fillRect(int x, int y, int w, int h) override { + if (!_isOn) return; + M5.Display.fillRect(x, y, w, h, _color); + } + + void drawRect(int x, int y, int w, int h) override { + if (!_isOn) return; + M5.Display.drawRect(x, y, w, h, _color); + } + + void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override { + if (!_isOn) return; + for (int yy = 0; yy < h; yy++) { + for (int xx = 0; xx < w; xx++) { + int byte_idx = (yy * ((w + 7) / 8)) + (xx / 8); + int bit_idx = xx % 8; + if (bits[byte_idx] & (1 << bit_idx)) { + M5.Display.drawPixel(x + xx, y + yy, _color); + } + } + } + } + + uint16_t getTextWidth(const char* str) override { + return strlen(str) * CHAR_WIDTH * text_size; + } + + void endFrame() override { + if (!_isOn) return; + } +}; + +inline void drawTextLine(M5CardputerDisplay& disp, int y, const char* str) { + disp.setCursor(0, y); + disp.print(str); +} diff --git a/src/helpers/ui/MomentaryButton.cpp b/src/helpers/ui/MomentaryButton.cpp index 9d01e5b011..45573aff78 100644 --- a/src/helpers/ui/MomentaryButton.cpp +++ b/src/helpers/ui/MomentaryButton.cpp @@ -1,7 +1,64 @@ #include "MomentaryButton.h" +#if defined(M5STACK_CARDPUTER) && defined(CARDPUTER_KEYBOARD_UI_NAV) + #include +#endif + #define MULTI_CLICK_WINDOW_MS 280 +#if defined(M5STACK_CARDPUTER) && defined(CARDPUTER_KEYBOARD_UI_NAV) +static bool cardputerWordHas(const Keyboard_Class::KeysState& keys, char a, char b = 0, char c = 0, char d = 0) { + for (char ch : keys.word) { + if (ch == a || (b != 0 && ch == b) || (c != 0 && ch == c) || (d != 0 && ch == d)) { + return true; + } + } + return false; +} + +static int cardputerKeyboardNavEvent() { + static bool was_pressed = false; + static uint32_t last_event_ms = 0; + + M5Cardputer.update(); + M5Cardputer.Keyboard.updateKeyList(); + M5Cardputer.Keyboard.updateKeysState(); + + auto& keys = M5Cardputer.Keyboard.keysState(); + + // Cardputer uses the punctuation keys with arrow legends for navigation: + // left: ',' / '<' + // right: '/' / '?' ('.' / '>' is also accepted on layouts that mark it as right) + // enter: physical Enter + bool left = cardputerWordHas(keys, ',', '<'); + bool right = cardputerWordHas(keys, '/', '?', '.', '>'); + bool enter = keys.enter; + bool any = left || right || enter; + + if (!any) { + was_pressed = false; + return BUTTON_EVENT_NONE; + } + + if (was_pressed) { + return BUTTON_EVENT_NONE; + } + + uint32_t now = millis(); + if ((uint32_t)(now - last_event_ms) < 120UL) { + return BUTTON_EVENT_NONE; + } + + was_pressed = true; + last_event_ms = now; + + if (enter) return BUTTON_EVENT_LONG_PRESS; // existing UI maps long press to KEY_ENTER + if (left) return BUTTON_EVENT_DOUBLE_CLICK; // existing UI maps double click to KEY_PREV + if (right) return BUTTON_EVENT_CLICK; // existing UI maps click to KEY_NEXT + return BUTTON_EVENT_NONE; +} +#endif + MomentaryButton::MomentaryButton(int8_t pin, int long_press_millis, bool reverse, bool pulldownup, bool multiclick) { _pin = pin; _reverse = reverse; @@ -65,6 +122,13 @@ bool MomentaryButton::isPressed(int level) const { int MomentaryButton::check(bool repeat_click) { if (_pin < 0) return BUTTON_EVENT_NONE; +#if defined(M5STACK_CARDPUTER) && defined(CARDPUTER_KEYBOARD_UI_NAV) && defined(PIN_USER_BTN) + if (_pin == PIN_USER_BTN && _threshold == 0) { + int keyboard_event = cardputerKeyboardNavEvent(); + if (keyboard_event != BUTTON_EVENT_NONE) return keyboard_event; + } +#endif + int event = BUTTON_EVENT_NONE; int btn = _threshold > 0 ? (analogRead(_pin) < _threshold) : digitalRead(_pin); if (btn != prev) { diff --git a/variants/m5stack_cardputer/CardputerRfStabilityWrapper.h b/variants/m5stack_cardputer/CardputerRfStabilityWrapper.h new file mode 100644 index 0000000000..d891819854 --- /dev/null +++ b/variants/m5stack_cardputer/CardputerRfStabilityWrapper.h @@ -0,0 +1,90 @@ +#pragma once + +#include + +#ifndef CARDPUTER_RF_STABILITY_RX_REASSERT_MS +#define CARDPUTER_RF_STABILITY_RX_REASSERT_MS 30000UL +#endif + +#ifndef CARDPUTER_RF_RX_WATCHDOG_SILENCE_MS +#define CARDPUTER_RF_RX_WATCHDOG_SILENCE_MS 180000UL +#endif + +#ifndef CARDPUTER_RF_RX_WATCHDOG_INTERVAL_MS +#define CARDPUTER_RF_RX_WATCHDOG_INTERVAL_MS 60000UL +#endif + +class CardputerRfStabilityWrapper : public CustomSX1262Wrapper { + uint32_t _last_rx_boost_check_ms = 0; + uint32_t _last_rx_packet_ms = 0; + uint32_t _last_rx_watchdog_ms = 0; + uint32_t _rx_watchdog_count = 0; + + void keepRxBoostedGain() { +#if defined(SX126X_RX_BOOSTED_GAIN) && SX126X_RX_BOOSTED_GAIN + if (!getRxBoostedGainMode()) { + setRxBoostedGainMode(true); + } +#endif + } + + void runRxWatchdogIfSilent(uint32_t now) { + if (_last_rx_packet_ms != 0 && (uint32_t)(now - _last_rx_packet_ms) < CARDPUTER_RF_RX_WATCHDOG_SILENCE_MS) { + return; + } + if ((uint32_t)(now - _last_rx_watchdog_ms) < CARDPUTER_RF_RX_WATCHDOG_INTERVAL_MS) { + return; + } + if (isReceivingPacket()) { + return; + } + + _last_rx_watchdog_ms = now; + keepRxBoostedGain(); + resetAGC(); + keepRxBoostedGain(); + _rx_watchdog_count++; + } + +public: + CardputerRfStabilityWrapper(CustomSX1262& radio, mesh::MainBoard& board) + : CustomSX1262Wrapper(radio, board) { } + + bool startSendRaw(const uint8_t* bytes, int len) override { + // Keep the RX gain protection, but do not add custom TX delays here. + // The MeshCore dispatcher already handles channel activity and retry timing. + keepRxBoostedGain(); + return RadioLibWrapper::startSendRaw(bytes, len); + } + + void onSendFinished() override { + RadioLibWrapper::onSendFinished(); + keepRxBoostedGain(); + } + + int recvRaw(uint8_t* bytes, int sz) override { + int len = RadioLibWrapper::recvRaw(bytes, sz); + if (len > 0) { + _last_rx_packet_ms = millis(); + keepRxBoostedGain(); + } + return len; + } + + void loop() override { + RadioLibWrapper::loop(); + uint32_t now = millis(); + if ((uint32_t)(now - _last_rx_boost_check_ms) >= CARDPUTER_RF_STABILITY_RX_REASSERT_MS) { + _last_rx_boost_check_ms = now; + keepRxBoostedGain(); + } + runRxWatchdogIfSilent(now); + } + + uint16_t getLastTxWaitMillis() const { return 0; } + uint32_t getTxWindowCount() const { return 0; } + uint16_t getLastRxGuardWaitMillis() const { return 0; } + uint32_t getRxGuardCount() const { return 0; } + uint32_t getRxWatchdogCount() const { return _rx_watchdog_count; } + uint32_t getLastRxAgeMillis() const { return _last_rx_packet_ms == 0 ? 0xFFFFFFFFUL : millis() - _last_rx_packet_ms; } +}; diff --git a/variants/m5stack_cardputer/M5CardputerBoard.h b/variants/m5stack_cardputer/M5CardputerBoard.h new file mode 100644 index 0000000000..4c018cc577 --- /dev/null +++ b/variants/m5stack_cardputer/M5CardputerBoard.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include "helpers/ESP32Board.h" + +#ifndef PIN_VBAT_READ +#define PIN_VBAT_READ 10 +#endif + +#define BATTERY_SAMPLES 8 + +class M5CardputerBoard : public ESP32Board { +public: + void begin() { + ESP32Board::begin(); + + auto cfg = M5.config(); + cfg.clear_display = true; + cfg.internal_imu = false; + cfg.internal_rtc = true; + cfg.internal_spk = false; + cfg.internal_mic = false; + M5Cardputer.begin(cfg, true); + delay(100); + M5Cardputer.Keyboard.begin(); + + // Cardputer ADV LoRa Cap RF switch supply is controlled by the PI4IOE expander. + // Only expander pin 0 should be driven high here; SX1262 DIO2 handles TX/RX RF switching. + M5.In_I2C.writeRegister8(0x43, 0x03, 0xFE, 100000); + delay(10); + M5.In_I2C.writeRegister8(0x43, 0x01, 0x01, 100000); + delay(200); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + uint64_t wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1ULL << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; + } + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } + } + + uint16_t getBattMilliVolts() override { + #ifdef PIN_VBAT_READ + analogReadResolution(12); + uint32_t raw = 0; + for (int i = 0; i < BATTERY_SAMPLES; i++) { + raw += analogReadMilliVolts(PIN_VBAT_READ); + delay(1); + } + raw = raw / BATTERY_SAMPLES; + return (2 * raw); + #else + return 0; + #endif + } + + const char* getManufacturerName() const override { + return "M5Stack Cardputer-Adv"; + } + + void powerOff() override { + M5.Display.sleep(); + #ifdef PIN_USER_BTN + enterDeepSleep(0, PIN_USER_BTN); + #else + enterDeepSleep(0, -1); + #endif + } + + void enterDeepSleep(uint32_t secs, int pin_wake_btn) { + uint64_t wake_mask = (1ULL << P_LORA_DIO_1); + if (pin_wake_btn >= 0) { + wake_mask |= (1ULL << pin_wake_btn); + } + esp_sleep_enable_ext1_wakeup(wake_mask, ESP_EXT1_WAKEUP_ANY_HIGH); + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000ULL); + } + + esp_deep_sleep_start(); + } +}; diff --git a/variants/m5stack_cardputer/RfLinkDiagnostics.cpp b/variants/m5stack_cardputer/RfLinkDiagnostics.cpp new file mode 100644 index 0000000000..ace9a01a31 --- /dev/null +++ b/variants/m5stack_cardputer/RfLinkDiagnostics.cpp @@ -0,0 +1,176 @@ +#include +#include "target.h" + +#ifdef CARDPUTER_RF_DIAG_OVERLAY + +namespace { +struct RfDiagState { + uint32_t lastRecvCount = 0; + uint32_t lastSentCount = 0; + uint32_t lastRxMillis = 0; + uint32_t bestScoreMillis = 0; + uint32_t windowStartMillis = 0; + uint32_t windowRecvStart = 0; + uint8_t bestScore = 0; + float lastRssi = 0.0f; + float lastSnr = 0.0f; + float avgRssi = 0.0f; + float avgSnr = 0.0f; + uint16_t avgSamples = 0; +}; + +RfDiagState rf_diag; + +static bool hasRxSample() { + return rf_diag.lastRxMillis != 0; +} + +static uint8_t clampScore(int value) { + if (value < 0) return 0; + if (value > 100) return 100; + return (uint8_t)value; +} + +static int scoreFromSnr(float snr) { + if (snr <= -20.0f) return 0; + if (snr >= 5.0f) return 35; + return (int)((snr + 20.0f) * 35.0f / 25.0f); +} + +static int scoreFromRssi(float rssi) { + if (rssi <= -130.0f) return 0; + if (rssi >= -95.0f) return 25; + return (int)((rssi + 130.0f) * 25.0f / 35.0f); +} + +static int scoreFromFreshness(uint32_t age_ms) { + if (!hasRxSample()) return 0; + if (age_ms <= 5000UL) return 25; + if (age_ms >= 120000UL) return 0; + return (int)(25 - ((age_ms - 5000UL) * 25UL / 115000UL)); +} + +static int scoreFromBattery(uint16_t mv) { + if (mv == 0) return 0; + if (mv >= 3900) return 15; + if (mv <= 3400) return 0; + return (int)((mv - 3400) * 15 / 500); +} + +static const char* hintFor(uint8_t score, uint16_t batt_mv, uint32_t age_ms) { + if (batt_mv > 0 && batt_mv < 3550) return "BAT"; + if (!hasRxSample() || age_ms > 120000UL) return "MOVE"; + if (score >= 70 && age_ms <= 15000UL) return "SEND"; + if (score >= 45) return "HOLD"; + return "MOVE"; +} + +static void formatAge(char* dest, size_t len, uint32_t age_ms) { + if (!hasRxSample()) { + snprintf(dest, len, "--"); + return; + } + + uint32_t secs = age_ms / 1000UL; + if (secs < 100UL) { + snprintf(dest, len, "%02lus", (unsigned long)secs); + } else if (secs < 3600UL) { + snprintf(dest, len, "%02lum", (unsigned long)(secs / 60UL)); + } else { + snprintf(dest, len, "%02luh", (unsigned long)(secs / 3600UL)); + } +} + +static uint16_t rxPerMinute(uint32_t now, uint32_t recvCount) { + if (rf_diag.windowStartMillis == 0 || (uint32_t)(now - rf_diag.windowStartMillis) > 60000UL) { + rf_diag.windowStartMillis = now; + rf_diag.windowRecvStart = recvCount; + return 0; + } + + uint32_t elapsed = now - rf_diag.windowStartMillis; + if (elapsed < 1000UL) return 0; + uint32_t delta = recvCount - rf_diag.windowRecvStart; + return (uint16_t)min((delta * 60000UL) / elapsed, 999UL); +} +} + +void cardputerDrawRfOverlay(M5CardputerDisplay& disp) { + uint32_t now = millis(); + uint32_t recvCount = radio_driver.getPacketsRecv(); + uint32_t sentCount = radio_driver.getPacketsSent(); + + if (recvCount != rf_diag.lastRecvCount) { + rf_diag.lastRecvCount = recvCount; + rf_diag.lastRxMillis = now; + rf_diag.lastRssi = radio_driver.getLastRSSI(); + rf_diag.lastSnr = radio_driver.getLastSNR(); + + if (rf_diag.avgSamples == 0) { + rf_diag.avgRssi = rf_diag.lastRssi; + rf_diag.avgSnr = rf_diag.lastSnr; + rf_diag.avgSamples = 1; + } else { + // Exponential moving average; cheap and stable on small MCU. + rf_diag.avgRssi = (rf_diag.avgRssi * 0.80f) + (rf_diag.lastRssi * 0.20f); + rf_diag.avgSnr = (rf_diag.avgSnr * 0.80f) + (rf_diag.lastSnr * 0.20f); + if (rf_diag.avgSamples < 1000) rf_diag.avgSamples++; + } + } + rf_diag.lastSentCount = sentCount; + + uint16_t batt_mv = board.getBattMilliVolts(); + uint32_t age_ms = hasRxSample() ? now - rf_diag.lastRxMillis : 0xFFFFFFFFUL; + + int score = 0; + if (hasRxSample()) { + score += scoreFromSnr(rf_diag.lastSnr); + score += scoreFromRssi(rf_diag.lastRssi); + score += scoreFromFreshness(age_ms); + } + score += scoreFromBattery(batt_mv); + uint8_t linkScore = clampScore(score); + + if (linkScore > rf_diag.bestScore || (now - rf_diag.bestScoreMillis) > 60000UL) { + rf_diag.bestScore = linkScore; + rf_diag.bestScoreMillis = now; + } + + char age[8]; + formatAge(age, sizeof(age), age_ms); + uint16_t rpm = rxPerMinute(now, recvCount); + + char line1[42]; + char line2[42]; + const char* hint = hintFor(linkScore, batt_mv, age_ms); + + snprintf(line1, sizeof(line1), "RF %03u %-4s RX%03u/m BAT %u.%02uV", + (unsigned)linkScore, + hint, + (unsigned)rpm, + (unsigned)(batt_mv / 1000), + (unsigned)((batt_mv % 1000) / 10)); + + if (hasRxSample()) { + snprintf(line2, sizeof(line2), "S%+05.1f R%4d A%+04.1f/%4d %s", + rf_diag.lastSnr, + (int)rf_diag.lastRssi, + rf_diag.avgSnr, + (int)rf_diag.avgRssi, + age); + } else { + snprintf(line2, sizeof(line2), "S --.- R ---- A --.-/---- RX --"); + } + + const int overlayY = disp.height() - 22; + disp.setTextSize(1); + disp.setColor(DisplayDriver::DARK); + disp.fillRect(0, overlayY - 1, disp.width(), 23); + disp.setColor(linkScore >= 70 ? DisplayDriver::GREEN : (linkScore >= 45 ? DisplayDriver::YELLOW : DisplayDriver::RED)); + disp.setCursor(0, overlayY); + disp.print(line1); + disp.setCursor(0, overlayY + 11); + disp.print(line2); +} + +#endif diff --git a/variants/m5stack_cardputer/platformio.ini b/variants/m5stack_cardputer/platformio.ini new file mode 100644 index 0000000000..9a210099e5 --- /dev/null +++ b/variants/m5stack_cardputer/platformio.ini @@ -0,0 +1,105 @@ +[m5stack_cardputer_base] +extends = esp32_base +board = m5stack_cardputer +board_build.partitions = default_8MB.csv +build_flags = + ${esp32_base.build_flags} + -I variants/m5stack_cardputer + -D M5STACK_CARDPUTER + -D BOARD_HAS_PSRAM=1 + -D CORE_DEBUG_LEVEL=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D ARDUINO_USB_MODE=1 + -D PIN_USER_BTN=0 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CardputerRfStabilityWrapper + -D HAS_GPS=1 + -D LORA_TX_POWER=22 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_CURRENT_LIMIT=240 + -D SX126X_RX_BOOSTED_GAIN=1 + -D SX126X_DIO3_TCXO_VOLTAGE=3.3f + -D CARDPUTER_RF_DIAG_OVERLAY=1 + -D CARDPUTER_KEYBOARD_UI_NAV=1 + -D CARDPUTER_RF_STABILITY_MODE=1 + -D P_LORA_NSS=5 + -D P_LORA_DIO_1=4 + -D P_LORA_RESET=3 + -D P_LORA_BUSY=6 + -D P_LORA_SCLK=40 + -D P_LORA_MISO=39 + -D P_LORA_MOSI=14 + -D DISPLAY_CLASS=M5CardputerDisplay + -D PIN_BOARD_SDA=2 + -D PIN_BOARD_SCL=1 + -D PIN_VBAT_READ=10 + -D ENV_INCLUDE_GPS=1 + -D ENV_INCLUDE_AHTX0=0 + -D ENV_INCLUDE_BME280=0 + -D ENV_INCLUDE_BMP280=0 + -D ENV_INCLUDE_SHTC3=0 + -D ENV_INCLUDE_SHT4X=0 + -D ENV_INCLUDE_LPS22HB=0 + -D ENV_INCLUDE_INA3221=0 + -D ENV_INCLUDE_INA219=0 + -D ENV_INCLUDE_INA226=0 + -D ENV_INCLUDE_INA260=0 + -D ENV_INCLUDE_MLX90614=0 + -D ENV_INCLUDE_VL53L0X=0 + -D ENV_INCLUDE_BME680=0 + -D ENV_INCLUDE_BMP085=0 + -D PIN_GPS_RX=15 + -D PIN_GPS_TX=13 + -D GPS_RX_PIN=15 + -D GPS_TX_PIN=13 + -D GPS_BAUD_RATE=115200 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/m5stack_cardputer> +lib_deps = + ${esp32_base.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 + M5Cardputer=https://github.com/m5stack/M5Cardputer + +[env:M5stack_cardputer_cap_lora1262_companion_radio_ble] +extends = m5stack_cardputer_base +build_flags = + ${m5stack_cardputer_base.build_flags} + -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=15000 +build_src_filter = ${m5stack_cardputer_base.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + -<../examples/companion_radio/ui-cardputer-basic/*.cpp> + + +lib_deps = + ${m5stack_cardputer_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + yoprogramo/QRcodeDisplay @ ^2.1.0 + +[env:M5stack_cardputer_cap_lora1262_companion_radio_usb] +extends = m5stack_cardputer_base +build_flags = + ${m5stack_cardputer_base.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D AUTO_OFF_MILLIS=0 +build_src_filter = ${m5stack_cardputer_base.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + -<../examples/companion_radio/ui-cardputer-basic/*.cpp> + + +lib_deps = + ${m5stack_cardputer_base.lib_deps} + densaugeo/base64 @ ~1.4.0 + yoprogramo/QRcodeDisplay @ ^2.1.0 diff --git a/variants/m5stack_cardputer/target.cpp b/variants/m5stack_cardputer/target.cpp new file mode 100644 index 0000000000..e8d0b28b75 --- /dev/null +++ b/variants/m5stack_cardputer/target.cpp @@ -0,0 +1,195 @@ +#include +#include +#include "target.h" +#include "../../examples/companion_radio/NodePrefs.h" + +M5CardputerBoard board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi, SPISettings()); +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#ifdef HAS_GPS +static MicroNMEALocationProvider location_provider(Serial1, &rtc_clock); +CardputerSensorManager sensors(location_provider); + +static uint32_t startGpsUartWithFallback() { + const uint32_t candidates[] = { GPS_BAUD_RATE, 9600UL, 38400UL }; + const size_t candidate_count = sizeof(candidates) / sizeof(candidates[0]); + + for (size_t i = 0; i < candidate_count; ++i) { + uint32_t baud = candidates[i]; + bool seen = false; + for (size_t j = 0; j < i; ++j) { + if (candidates[j] == baud) { + seen = true; + break; + } + } + if (seen) continue; + + Serial.printf("[GPS] Trying UART baud %lu on RX=%d TX=%d\n", (unsigned long)baud, PIN_GPS_RX, PIN_GPS_TX); + Serial1.end(); + delay(50); + Serial1.begin(baud, SERIAL_8N1, PIN_GPS_RX, PIN_GPS_TX); + delay(1200); + + int available = Serial1.available(); + Serial.printf("[GPS] Bytes available after %lu baud start: %d\n", (unsigned long)baud, available); + if (available > 0) { + return baud; + } + } + + return 0; +} + +bool CardputerSensorManager::begin() { + gps_active = false; + return true; +} + +bool CardputerSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { + if ((requester_permissions & TELEM_PERM_LOCATION) && gps_active) { + telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude); + } + return true; +} + +void CardputerSensorManager::loop() { + static unsigned long next_gps_update = 0; + + if (!gps_active) { + return; + } + + _location->loop(); + + if (millis() > next_gps_update) { + bool valid = _location->isValid(); + long sats = _location->satellitesCount(); + Serial.printf("[GPS] valid=%d sats=%ld\n", valid ? 1 : 0, sats); + + if (valid) { + node_lat = ((double)_location->getLatitude()) / 1000000.; + node_lon = ((double)_location->getLongitude()) / 1000000.; + node_altitude = ((double)_location->getAltitude()) / 1000.0; + Serial.printf("[GPS] fix lat=%.6f lon=%.6f alt=%.1f\n", node_lat, node_lon, node_altitude); + } + next_gps_update = millis() + (gps_interval_secs * 1000UL); + } +} + +int CardputerSensorManager::getNumSettings() const { + return 2; +} + +const char* CardputerSensorManager::getSettingName(int i) const { + switch (i) { + case 0: return "gps"; + case 1: return "gps_interval"; + default: return NULL; + } +} + +const char* CardputerSensorManager::getSettingValue(int i) const { + switch (i) { + case 0: + return gps_active ? "1" : "0"; + case 1: + snprintf(_gps_interval_buf, sizeof(_gps_interval_buf), "%lu", (unsigned long)gps_interval_secs); + return _gps_interval_buf; + default: + return NULL; + } +} + +bool CardputerSensorManager::setSettingValue(const char* name, const char* value) { + if (strcmp(name, "gps") == 0) { + bool should_enable = (strcmp(value, "0") != 0); + Serial.printf("[GPS] Request to turn %s\n", should_enable ? "ON" : "OFF"); + + if (should_enable && !gps_active) { + uint32_t actual_baud = startGpsUartWithFallback(); + if (actual_baud == 0) { + Serial.println("[GPS] WARNING: no UART data seen on any tested baud"); + Serial1.begin(GPS_BAUD_RATE, SERIAL_8N1, PIN_GPS_RX, PIN_GPS_TX); + } else { + Serial.printf("[GPS] Using detected baud %lu\n", (unsigned long)actual_baud); + } + + _location->begin(); + _location->reset(); + _location->syncTime(); + gps_active = true; + } else if (!should_enable && gps_active) { + _location->stop(); + Serial1.end(); + gps_active = false; + Serial.println("[GPS] Turned OFF"); + } + return true; + } + + if (strcmp(name, "gps_interval") == 0) { + unsigned long parsed = strtoul(value, NULL, 10); + gps_interval_secs = parsed > 0 ? parsed : 180; + Serial.printf("[GPS] Interval set to %lu seconds\n", (unsigned long)gps_interval_secs); + return true; + } + + return false; +} +#else +SensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS +DISPLAY_CLASS display; +MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +#ifndef LORA_CR + #define LORA_CR 5 +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + + pinMode(P_LORA_RESET, OUTPUT); + digitalWrite(P_LORA_RESET, LOW); + delay(10); + digitalWrite(P_LORA_RESET, HIGH); + delay(100); + + bool init_result = radio.std_init(&spi); + if (init_result) { + int16_t pa_result = radio.setPaConfig(0x04, 0x07, 0x00, 0x01); + Serial.printf("[LoRa] PA config result: %d\n", pa_result); + } + return init_result; +} + +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(uint8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} diff --git a/variants/m5stack_cardputer/target.h b/variants/m5stack_cardputer/target.h new file mode 100644 index 0000000000..c798bde301 --- /dev/null +++ b/variants/m5stack_cardputer/target.h @@ -0,0 +1,56 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#ifdef HAS_GPS + #include +#endif +#ifdef DISPLAY_CLASS + #include + #include +#endif + +#ifdef HAS_GPS +class CardputerSensorManager : public SensorManager { + bool gps_active = false; + uint32_t gps_interval_secs = 180; + mutable char _gps_interval_buf[12] = {0}; + LocationProvider* _location; +public: + CardputerSensorManager(LocationProvider &location): _location(&location) { } + bool begin() override; + bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override; + void loop() override; + int getNumSettings() const override; + const char* getSettingName(int i) const override; + const char* getSettingValue(int i) const override; + bool setSettingValue(const char* name, const char* value) override; + LocationProvider* getLocationProvider() override { return _location; } +}; +#endif + +extern M5CardputerBoard board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +#ifdef HAS_GPS +extern CardputerSensorManager sensors; +#else +extern SensorManager sensors; +#endif + +#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(uint8_t dbm); +mesh::LocalIdentity radio_new_identity();