From acb0408c32716d94e8718bd1056b99ad388bbf10 Mon Sep 17 00:00:00 2001 From: Hacuchino-hash <246103064+Hacuchino-hash@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:36:01 -0500 Subject: [PATCH 1/2] Add Seeed Wio Tracker L2 Pro variant ESP32-S3 with SX1262, a 3.2" 320x240 touch LCD, an L76K GNSS module and an SD slot. Everything except the radio hangs off a TCA9535 expander, so the board brings up power and reset lines in Seeed's documented order before the display and GNSS are usable. Companion (BLE and USB), repeater and room server environments run on the existing shared code. Pre-release hardware: the pin map may change on production units. --- boards/seeed_wio_tracker_l2.json | 43 ++++ variants/wio-tracker-l2/GpsTap.h | 139 ++++++++++++ variants/wio-tracker-l2/README.md | 118 ++++++++++ variants/wio-tracker-l2/Sound.cpp | 157 +++++++++++++ variants/wio-tracker-l2/Sound.h | 19 ++ variants/wio-tracker-l2/WioTrackerL2Board.cpp | 204 +++++++++++++++++ variants/wio-tracker-l2/WioTrackerL2Board.h | 106 +++++++++ variants/wio-tracker-l2/WioTrackerL2Display.h | 210 ++++++++++++++++++ variants/wio-tracker-l2/nv3031b_odr_fix.cpp | 15 ++ variants/wio-tracker-l2/platformio.ini | 187 ++++++++++++++++ variants/wio-tracker-l2/target.cpp | 35 +++ variants/wio-tracker-l2/target.h | 30 +++ .../wio-tracker-l2/tools/tile_darkener.py | 82 +++++++ .../wio-tracker-l2/tools/tile_downloader.py | 163 ++++++++++++++ variants/wio-tracker-l2/tools/tile_packer.py | 73 ++++++ 15 files changed, 1581 insertions(+) create mode 100644 boards/seeed_wio_tracker_l2.json create mode 100644 variants/wio-tracker-l2/GpsTap.h create mode 100644 variants/wio-tracker-l2/README.md create mode 100644 variants/wio-tracker-l2/Sound.cpp create mode 100644 variants/wio-tracker-l2/Sound.h create mode 100644 variants/wio-tracker-l2/WioTrackerL2Board.cpp create mode 100644 variants/wio-tracker-l2/WioTrackerL2Board.h create mode 100644 variants/wio-tracker-l2/WioTrackerL2Display.h create mode 100644 variants/wio-tracker-l2/nv3031b_odr_fix.cpp create mode 100644 variants/wio-tracker-l2/platformio.ini create mode 100644 variants/wio-tracker-l2/target.cpp create mode 100644 variants/wio-tracker-l2/target.h create mode 100644 variants/wio-tracker-l2/tools/tile_darkener.py create mode 100644 variants/wio-tracker-l2/tools/tile_downloader.py create mode 100644 variants/wio-tracker-l2/tools/tile_packer.py diff --git a/boards/seeed_wio_tracker_l2.json b/boards/seeed_wio_tracker_l2.json new file mode 100644 index 0000000000..706ed4c170 --- /dev/null +++ b/boards/seeed_wio_tracker_l2.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi", + "partitions": "default_16MB.csv" + }, + "core": "esp32", + "extra_flags": [ + "-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", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino"], + "name": "Seeed Wio Tracker L2 (16 MB flash, 8 MB OPI PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.seeedstudio.com/", + "vendor": "Seeed Studio" +} diff --git a/variants/wio-tracker-l2/GpsTap.h b/variants/wio-tracker-l2/GpsTap.h new file mode 100644 index 0000000000..020f5e9b18 --- /dev/null +++ b/variants/wio-tracker-l2/GpsTap.h @@ -0,0 +1,139 @@ +#pragma once + +#include + +// Transparent wrapper around the GNSS UART. MicroNMEA ignores GSV sentences, +// so this watches the byte stream for them and keeps a running count of +// satellites in view, strong satellites and best SNR, which separates RF +// faults from a receiver that only lacks sky view. It also records the +// fix flags straight from GGA/RMC so a parser problem upstream can't hide a +// real fix, and can hand every complete line to a logger (SD card). +class GpsTapStream : public Stream { + Stream& _src; + char _line[120]; + uint8_t _len = 0; + struct Talker { char id[3]; uint8_t in_view; uint8_t strong; uint8_t strong_acc; unsigned long at; }; + Talker _talkers[6] = {}; + int _best_snr = 0; + unsigned long _best_at = 0; + unsigned long _last_gsv = 0; + int _gga_fix = -1; // GGA fix quality (0 = no fix) + int _gga_used = 0; // GGA satellites used + char _rmc_status = '?'; // RMC status: A = valid, V = void + unsigned long _last_fix_sentence = 0; + + static const unsigned long FRESH_MS = 6000; + static const int STRONG_DB = 30; + + // pointer to comma-separated field n (0 = sentence id), NULL if absent + static const char* field(const char* s, int n) { + const char* p = s; + for (int i = 0; i < n; i++) { + p = strchr(p, ','); + if (p == NULL) return NULL; + p++; + } + return p; + } + + void parseGsv(const char* s) { + // $GPGSV,total,msg,in_view,prn,elev,az,snr,...*cs + char talker[3] = {s[1], s[2], 0}; + const char* f; + int total = (f = field(s, 1)) ? atoi(f) : 0; + int msg = (f = field(s, 2)) ? atoi(f) : 0; + int in_view = (f = field(s, 3)) ? atoi(f) : -1; + if (in_view < 0) return; + + int strong_here = 0; + for (int k = 0; k < 4; k++) { + const char* snr = field(s, 7 + 4 * k); + if (snr == NULL || *snr == ',' || *snr == '*' || *snr == 0) continue; + int v = atoi(snr); + if (v >= STRONG_DB) strong_here++; + if (v > _best_snr || millis() - _best_at > FRESH_MS) { _best_snr = v; _best_at = millis(); } + } + + int slot = -1; + for (int i = 0; i < 6; i++) { + if (strcmp(_talkers[i].id, talker) == 0) { slot = i; break; } + if (slot < 0 && _talkers[i].id[0] == 0) slot = i; + } + if (slot < 0) return; + Talker& t = _talkers[slot]; + memcpy(t.id, talker, 3); + t.in_view = (uint8_t) in_view; + if (msg <= 1) t.strong_acc = 0; + t.strong_acc += strong_here; + if (msg >= total) t.strong = t.strong_acc; + t.at = millis(); + _last_gsv = millis(); + } + + void parseFixFlags(const char* s) { + const char* type = s + 3; + if (strncmp(type, "GGA", 3) == 0) { + const char* f; + _gga_fix = (f = field(s, 6)) && *f != ',' ? atoi(f) : 0; + _gga_used = (f = field(s, 7)) && *f != ',' ? atoi(f) : 0; + _last_fix_sentence = millis(); + } else if (strncmp(type, "RMC", 3) == 0) { + const char* f = field(s, 2); + _rmc_status = (f != NULL && *f != ',' && *f != 0) ? *f : '?'; + _last_fix_sentence = millis(); + } + } + + void feed(int c) { + if (echo) Serial.write((uint8_t) c); + if (c == '\n' || c == '\r') { + if (_len > 6 && _line[0] == '$') { + _line[_len] = 0; + if (on_line != NULL) on_line(_line); + if (strncmp(_line + 3, "GSV", 3) == 0) parseGsv(_line); + else parseFixFlags(_line); + } + _len = 0; + } else if (_len < sizeof(_line) - 1) { + _line[_len++] = (char) c; + } + } + +public: + bool echo = false; // mirror raw NMEA to USB Serial + void (*on_line)(const char* line) = NULL; // complete-sentence hook (e.g. SD log) + + explicit GpsTapStream(Stream& src) : _src(src) {} + + int satsInView() const { + int n = 0; + for (int i = 0; i < 6; i++) { + if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].in_view; + } + return n; + } + int strongSats() const { + int n = 0; + for (int i = 0; i < 6; i++) { + if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].strong; + } + return n; + } + int bestSnr() const { return millis() - _best_at < FRESH_MS ? _best_snr : 0; } + bool streaming() const { return millis() - _last_gsv < FRESH_MS; } + int ggaFix() const { return _gga_fix; } + int ggaUsed() const { return _gga_used; } + char rmcStatus() const { return _rmc_status; } + + // Stream interface: pass-through with a tap on read() + int available() override { return _src.available(); } + int peek() override { return _src.peek(); } + int read() override { + int c = _src.read(); + if (c >= 0) feed(c); + return c; + } + void flush() override { _src.flush(); } + size_t write(uint8_t b) override { return _src.write(b); } + size_t write(const uint8_t* buf, size_t n) override { return _src.write(buf, n); } +}; diff --git a/variants/wio-tracker-l2/README.md b/variants/wio-tracker-l2/README.md new file mode 100644 index 0000000000..0e5dfa73d0 --- /dev/null +++ b/variants/wio-tracker-l2/README.md @@ -0,0 +1,118 @@ +# MeshCore for Seeed Wio Tracker L2 Pro + +MeshCore port for the **Seeed Studio Wio Tracker L2 Pro** (pre-release hardware; +pin map may change on production units). + +## Hardware + +| Component | Detail | +|-----------|--------| +| MCU module | Wio-S3: ESP32-S3, 16 MB flash (QIO), 8 MB PSRAM (OPI) | +| LoRa | SX1262, 862-930 MHz (SCK 4, MISO 5, MOSI 6, CS 21, RST 7, BUSY 8, DIO1 9, DIO2 = RF switch, TCXO 1.8 V) | +| GNSS | Quectel L76K (GPS/BeiDou/GLONASS/QZSS), UART: module TX to GPIO 18, module RX from GPIO 17, 9600 baud | +| Display | 3.2" 320x240 NV3031B on quad-SPI (SCLK 42, IO0-3 = 41/40/39/38, CS 46), 75 MHz | +| Touch | GT911 capacitive, I2C 0x5D | +| Backlight | LP5814 LED driver, I2C 0x2C | +| I/O expander | TCA9535, I2C 0x21 - gates power/reset for GNSS, LCD, touch, SD, speaker amp, battery ADC, Grove | +| Battery ADC | ADS1115, I2C 0x48, AIN0 through x2 divider | +| USB-C detect | AW35615 CC controller, I2C 0x22 (charge detection) | +| Audio | ES8311 codec (speaker, alert tones) + ES7243E mic ADC (unused) on I2S | +| I2C bus | SDA 47, SCL 48 | +| Buttons | User/Boot = GPIO 0; Wake/lock button on expander P00 | +| SD card | SDIO 1-bit: CLK 2, CMD 3, D0 1 - offline map tiles, logs | + +## Firmware flavors + +| Env | What it does | +|-----|--------------| +| `Wio_Tracker_L2_companion_radio_ble` | **Phone companion.** Standard display UI, MeshCore app over BLE (pairing PIN `123456`), on-screen Bluetooth toggle page. | +| `Wio_Tracker_L2_standalone_lvgl` | **Standalone.** Full touch UI, Bluetooth compiled out (USB CLI only). | +| `Wio_Tracker_L2_companion_radio_usb` | Standard display companion over USB-C serial. | +| `Wio_Tracker_L2_repeater` | Standalone mesh repeater. | +| `Wio_Tracker_L2_room_server` | Standalone room/BBS server. | + +## Touch UI (`ui-lvgl`) + +LVGL 9 interface for the standalone build - no phone involved: + +- **Chats**: direct messages and `#channels` with persisted history, delivery + states (sent / delivered / failed with tap-to-resend), repeat-echo counter, + quick replies, and a live flood/direct/hops route indicator per contact. +- **Contacts**: type filters, name/recency sort, detail view (last heard, + path, distance/bearing), manual path picker (up to eight hops), zero-hop + contact share, and path trace for repeaters. A trace is sent out along the + chosen hops and back through the same ones, so the reply is only received + when the first hop is within direct range. +- **Repeater admin**: saved passwords, status dashboard with auto-refresh, + one-tap advert / clock sync / version / neighbors / reboot, full CLI. +- **Map**: offline tiles from SD with pan/zoom, own position, tappable node + markers, day and night tile sets (sun/moon toggle). +- **Settings**: radio presets + full parameter editor with repeat mode, + TX power, node name, timezone, brightness with auto-dim, screen timeout, + alert tone styles, 12/24 h clock, km/mi units, node backup/restore to SD, + factory reset. +- First-boot wizard (region / name / timezone), notification banner, + unread badges, About screen with the node's public key. + +## Offline maps + +The map reads 256 px OSM raster tiles from a FAT32 card in the usual layout, +so a tile folder prepared for any other mesh device works unchanged: + +``` +/maps/{z}/{x}/{y}.png +``` + +Nothing else is required. Copy that folder to the card root and the map +renders it. + +Two optional extras this variant understands: + +- **Packed tiles.** `/maps/{z}/{x}.pak` holds one tile column per file: + `'TPK1' | u32 y_min | u32 y_max | u32 offsets[n+1] | PNG blobs` + (little-endian; equal offsets mark a missing tile). Loose tiles are read + when no pack covers a column, so the two can be mixed. Packing matters only + for very large sets: a few thousand pack files copy to a card in minutes, + where the equivalent millions of loose PNGs take many hours and waste most + of the card on cluster overhead. +- **A night tile set** in `/maps_dark/`, same layout either way. The map's + sun/moon button swaps sets when one is present, and dims the day tiles when + it is not. + +Helper scripts live in `variants/wio-tracker-l2/tools/`: `tile_downloader.py` +(fetch or render tiles), `tile_packer.py` (pack columns) and +`tile_darkener.py` (derive a night set from the day set). None of them are +needed if you already have a tile folder. + +## Build & flash + +```bash +# from the MeshCore repo root +pio run -e Wio_Tracker_L2_standalone_lvgl + +# bootloader mode if needed: hold User/Boot, tap RST, release - then: +pio run -e Wio_Tracker_L2_standalone_lvgl -t upload +``` + +The board enumerates as a native USB-CDC port (auto-reset via 1200-bps touch +is enabled). For the BLE companion build, pair from the MeshCore app with the +PIN shown on the device screen. + +## Port notes + +- **Everything hangs off the TCA9535 expander.** `WioTrackerL2Board::begin()` + replays Seeed's power-up sequence; order and delays matter (especially the + 500 ms LCD reset settle). If the expander probe fails, the firmware still + runs but GPS/display/battery reads are dead. +- **Battery** is read via the ADS1115 at +/-4.096 V FSR and mapped through a + LiPo discharge curve. +- **GNSS**: GPS+BeiDou+GLONASS are enabled at start (`$PCAS04,7`). A watchdog + resets the module if it tracks 4+ strong satellites for minutes without + producing a fix. Raw NMEA can be logged to SD for diagnosis. +- **Power**: CPU drops to 80 MHz while the screen sleeps; the speaker amp and + I2S clocks run only while a tone plays; the GNSS and Grove rails are gated; + idle Bluetooth switches to slow advertising. +- **Display orientation**: `offset_rotation=1` (landscape). If a unit shows + the UI upside-down, use 3 in `WioTrackerL2Display.h`. +- Pin map source: `meshtastic/firmware` PR #10909 and `meshtastic/device-ui` + (`wio-l2` branches). diff --git a/variants/wio-tracker-l2/Sound.cpp b/variants/wio-tracker-l2/Sound.cpp new file mode 100644 index 0000000000..b84f4a7719 --- /dev/null +++ b/variants/wio-tracker-l2/Sound.cpp @@ -0,0 +1,157 @@ +#include "Sound.h" + +#include + +#include +#include +#include +#include +#include "AudioBoard.h" // pschatzmann/arduino-audio-driver +#include +#include + +// Wio-S3 audio wiring: ES8311 codec on I2C (0x18), I2S MCLK 10 / BCK 11 / +// WS 12 / DOUT 16; speaker amp power (PA EN) is raised by the board init. +#define SND_I2S_MCLK 10 +#define SND_I2S_BCK 11 +#define SND_I2S_WS 12 +#define SND_I2S_DOUT 16 +#define SND_SAMPLE_RATE 44100 + +static DriverPins snd_pins; +static AudioBoard snd_codec(AudioDriverES8311, snd_pins); +static bool snd_ready = false; +static void (*snd_amp_ctl)(bool) = NULL; // board hook: speaker amp power + +void soundSetAmpControl(void (*fn)(bool on)) { snd_amp_ctl = fn; } + +void soundInit() { + // i2s_driver_install crashes inside IDF's cleanup if its DMA allocation + // fails (LoadProhibited boot loop) - refuse to try without clear headroom + if (heap_caps_get_free_size(MALLOC_CAP_DMA) < 60000) { + MESH_DEBUG_PRINTLN("sound: low DMA heap - sound disabled this boot"); + return; + } + + snd_pins.addI2C(PinFunction::CODEC, Wire); + snd_pins.addI2S(PinFunction::CODEC, SND_I2S_MCLK, SND_I2S_BCK, SND_I2S_WS, SND_I2S_DOUT, -1); + + CodecConfig cfg; + cfg.input_device = ADC_INPUT_NONE; + cfg.output_device = DAC_OUTPUT_ALL; + cfg.i2s.bits = BIT_LENGTH_16BITS; + cfg.i2s.rate = RATE_44K; + if (!snd_codec.begin(cfg)) { + MESH_DEBUG_PRINTLN("sound: ES8311 init failed - sound disabled"); + return; + } + snd_codec.setVolume(70); + + i2s_config_t i2s_cfg = {}; + i2s_cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); + i2s_cfg.sample_rate = SND_SAMPLE_RATE; + i2s_cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + i2s_cfg.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT; + i2s_cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; + i2s_cfg.intr_alloc_flags = 0; + i2s_cfg.dma_buf_count = 2; // keep the DMA footprint minimal + i2s_cfg.dma_buf_len = 256; + i2s_cfg.tx_desc_auto_clear = true; + if (i2s_driver_install(I2S_NUM_0, &i2s_cfg, 0, NULL) != ESP_OK) { + MESH_DEBUG_PRINTLN("sound: i2s driver install failed"); + return; + } + i2s_pin_config_t pin_cfg = {}; + pin_cfg.mck_io_num = SND_I2S_MCLK; + pin_cfg.bck_io_num = SND_I2S_BCK; + pin_cfg.ws_io_num = SND_I2S_WS; + pin_cfg.data_out_num = SND_I2S_DOUT; + pin_cfg.data_in_num = I2S_PIN_NO_CHANGE; + if (i2s_set_pin(I2S_NUM_0, &pin_cfg) != ESP_OK) { + MESH_DEBUG_PRINTLN("sound: i2s pin config failed"); + return; + } + i2s_stop(I2S_NUM_0); // clocks off until a tone plays (GNSS-adjacent EMI, battery) + snd_ready = true; + MESH_DEBUG_PRINTLN("sound: ES8311 + I2S ready"); +} + +bool soundReady() { return snd_ready; } + +void soundBeep(int freq_hz, int duration_ms) { + if (!snd_ready) return; + if (snd_amp_ctl) snd_amp_ctl(true); + i2s_start(I2S_NUM_0); + delay(3); // amp settle before the first samples + const int total = SND_SAMPLE_RATE * duration_ms / 1000; + const float amp = 6000.0f; // gentle level; codec volume does the rest + static int16_t buf[256 * 2]; // 256 stereo frames per chunk + + int written_frames = 0; + while (written_frames < total) { + int chunk = total - written_frames; + if (chunk > 256) chunk = 256; + for (int i = 0; i < chunk; i++) { + float t = (float)(written_frames + i) / SND_SAMPLE_RATE; + // short attack/decay envelope so the beep doesn't click + float env = 1.0f; + int pos = written_frames + i; + if (pos < 220) env = pos / 220.0f; + else if (pos > total - 220) env = (total - pos) / 220.0f; + int16_t s = (int16_t)(amp * env * sinf(2.0f * (float)M_PI * freq_hz * t)); + buf[i * 2] = s; + buf[i * 2 + 1] = s; + } + size_t written_bytes = 0; + i2s_write(I2S_NUM_0, buf, chunk * 2 * sizeof(int16_t), &written_bytes, portMAX_DELAY); + written_frames += chunk; + } + i2s_zero_dma_buffer(I2S_NUM_0); + i2s_stop(I2S_NUM_0); + if (snd_amp_ctl) snd_amp_ctl(false); +} + +// selectable alert melodies; {0,0} terminates a style early +struct ToneNote { uint16_t freq; uint16_t ms; }; +static const ToneNote TONE_STYLES[SOUND_TONE_STYLES][5] = { + {{880, 90}, {1175, 120}, {0, 0}}, // Classic + {{1400, 40}, {1800, 40}, {2200, 60}, {0, 0}}, // Chirp + {{1319, 140}, {988, 180}, {0, 0}}, // Ding dong + {{1568, 45}, {1245, 45}, {1568, 45}, {1245, 90}, {0, 0}}, // Trill + {{659, 70}, {880, 70}, {1175, 70}, {1568, 110}, {0, 0}}, // Rise + {{1047, 120}, {523, 120}, {1047, 120}, {0, 0}}, // Alarm +}; +static int tone_style = 0; + +void soundLoadTonePref() { + File f = SPIFFS.open("/tone", "r"); + if (f) { + int v = f.parseInt(); + f.close(); + if (v >= 0 && v < SOUND_TONE_STYLES) tone_style = v; + } +} + +void soundSetToneStyle(int style) { + if (style < 0 || style >= SOUND_TONE_STYLES) return; + tone_style = style; + File f = SPIFFS.open("/tone", "w"); + if (f) { + f.print(style); + f.close(); + } +} + +int soundGetToneStyle() { return tone_style; } + +void soundMessageTone() { + const ToneNote* n = TONE_STYLES[tone_style]; + for (int i = 0; i < 5 && n[i].freq != 0; i++) soundBeep(n[i].freq, n[i].ms); +} + +void soundChannelTone() { + // softer cousin of the chosen alert: first note, dropped a fourth + soundBeep(TONE_STYLES[tone_style][0].freq * 3 / 4, 100); +} + +void soundAckTone() { soundBeep(1568, 50); } diff --git a/variants/wio-tracker-l2/Sound.h b/variants/wio-tracker-l2/Sound.h new file mode 100644 index 0000000000..9e5f633c0b --- /dev/null +++ b/variants/wio-tracker-l2/Sound.h @@ -0,0 +1,19 @@ +#pragma once + +// Board audio backend for the Wio Tracker L2 Pro (ES8311 codec + speaker). +// UI code includes "Sound.h" and gets whatever the variant provides; all +// functions are safe no-ops if the codec failed to initialize. + +#define SOUND_TONE_STYLES 6 // Classic, Chirp, Ding dong, Trill, Rise, Alarm + +void soundInit(); // call once after Wire is up (board.begin done) +void soundSetAmpControl(void (*fn)(bool on)); // board hook to gate the speaker amp power +bool soundReady(); +void soundBeep(int freq_hz, int duration_ms); // short blocking tone +void soundMessageTone(); // incoming DM (plays the selected alert style) +void soundChannelTone(); // incoming channel message (softer variant) +void soundAckTone(); // delivery ack / confirm + +void soundLoadTonePref(); // read /tone from SPIFFS (safe before soundInit) +void soundSetToneStyle(int style); // select + persist an alert style +int soundGetToneStyle(); diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.cpp b/variants/wio-tracker-l2/WioTrackerL2Board.cpp new file mode 100644 index 0000000000..edeaacce52 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Board.cpp @@ -0,0 +1,204 @@ +#include "WioTrackerL2Board.h" + +// TCA9535 register map +static const uint8_t TCA_REG_OUTPUT0 = 0x02; +static const uint8_t TCA_REG_OUTPUT1 = 0x03; +static const uint8_t TCA_REG_CONFIG0 = 0x06; // 1 = input, 0 = output +static const uint8_t TCA_REG_CONFIG1 = 0x07; + +// ADS1115 registers / config bits +static const uint8_t ADS_REG_CONVERSION = 0x00; +static const uint8_t ADS_REG_CONFIG = 0x01; +// OS=1 (start single shot) | MUX=100 (AIN0 vs GND) | PGA=001 (+/-4.096V) +// | MODE=1 (single shot) | DR=100 (128 SPS) | COMP_QUE=11 (disabled) +static const uint16_t ADS_CFG_BATT = 0x8000 | 0x4000 | 0x0200 | 0x0100 | 0x0080 | 0x0003; +// +/-4.096V FSR -> 125uV/LSB; battery is behind a x2 divider -> 0.25 mV/LSB +static const float ADS_MV_PER_LSB = 0.125f * 2.0f; + +bool WioTrackerL2Board::expWriteReg(uint8_t reg, uint8_t val) { + Wire.beginTransmission(TCA9535_ADDR); + Wire.write(reg); + Wire.write(val); + return Wire.endTransmission() == 0; +} + +void WioTrackerL2Board::expSetOutput(uint8_t pin, bool initial_level) { + uint8_t port = pin >> 3, bit = pin & 7; + // set desired level first so the pin doesn't glitch when direction flips + if (initial_level) out_shadow[port] |= (1 << bit); + else out_shadow[port] &= ~(1 << bit); + expWriteReg(port ? TCA_REG_OUTPUT1 : TCA_REG_OUTPUT0, out_shadow[port]); + + cfg_shadow[port] &= ~(1 << bit); // 0 = output + expWriteReg(port ? TCA_REG_CONFIG1 : TCA_REG_CONFIG0, cfg_shadow[port]); +} + +void WioTrackerL2Board::expSetInput(uint8_t pin) { + uint8_t port = pin >> 3, bit = pin & 7; + cfg_shadow[port] |= (1 << bit); + expWriteReg(port ? TCA_REG_CONFIG1 : TCA_REG_CONFIG0, cfg_shadow[port]); +} + +void WioTrackerL2Board::expWritePin(uint8_t pin, bool level) { + uint8_t port = pin >> 3, bit = pin & 7; + if (level) out_shadow[port] |= (1 << bit); + else out_shadow[port] &= ~(1 << bit); + expWriteReg(port ? TCA_REG_OUTPUT1 : TCA_REG_OUTPUT0, out_shadow[port]); +} + +// Power-up sequence mirrors Seeed's reference firmware for this board +// (order and delays matter, especially the LCD reset settle time). +bool WioTrackerL2Board::initExpander() { + Wire.beginTransmission(TCA9535_ADDR); + if (Wire.endTransmission() != 0) { + return false; // expander not responding + } + + expSetInput(EXP_PIN_WAKE_BTN); + expSetInput(EXP_PIN_I2C_IRQ); + expSetInput(EXP_PIN_SD_DETECT); + + expSetOutput(EXP_PIN_OTG_EN, LOW); + delay(10); + expSetOutput(EXP_PIN_PA_EN, LOW); // amp only powered while a tone plays + delay(10); + expSetOutput(EXP_PIN_TF_EN, HIGH); + delay(10); + expSetOutput(EXP_PIN_BAT_ADC_EN, HIGH); + delay(10); + expSetOutput(EXP_PIN_GNSS_EN, HIGH); + delay(10); + // GNSS reset is active HIGH; hold 10ms then release LOW so the L76K runs + expSetOutput(EXP_PIN_GNSS_RST, HIGH); + delay(10); + expWritePin(EXP_PIN_GNSS_RST, LOW); + // user LED is driven active-low through the expander; idle level HIGH = off + expSetOutput(EXP_PIN_USER_LED, HIGH); + delay(10); + expSetOutput(EXP_PIN_GROVE_EN, HIGH); + delay(10); + + expSetOutput(EXP_PIN_LCD_EN, HIGH); + delay(50); + expSetOutput(EXP_PIN_LCD_RST, HIGH); + delay(5); + expWritePin(EXP_PIN_LCD_RST, LOW); + delay(10); + expWritePin(EXP_PIN_LCD_RST, HIGH); + delay(500); // NV3031B needs a long settle after reset before init commands + expSetOutput(EXP_PIN_LCD_CS, HIGH); + delay(10); + + // GT911 touch reset sequence: INT low during reset selects I2C addr 0x5D + expSetOutput(EXP_PIN_TP_RST, LOW); + expSetOutput(EXP_PIN_TP_INT, LOW); + delay(10); + expWritePin(EXP_PIN_TP_RST, HIGH); + delay(60); + + // capture WAKE button idle level (polarity unknown on alpha hardware) + int inputs = expReadInputs(); + wake_btn_baseline = inputs >= 0 ? (uint8_t)(inputs & 1) : 0; + + return true; +} + +void WioTrackerL2Board::begin() { + // GNSS UART: NMEA arrives at ~500 B/s and the main loop can stall for + // hundreds of ms on SD tile decodes; the 256-byte default overflowed + Serial1.setRxBufferSize(1024); + + uint32_t t0 = millis(); + while (!Serial && millis() - t0 < 3000) { delay(50); } + MESH_DEBUG_PRINTLN("WioTrackerL2Board: ESP32Board init"); + + ESP32Board::begin(); // starts Wire on PIN_BOARD_SDA/PIN_BOARD_SCL (47/48) + + pinMode(PIN_USER_BTN, INPUT_PULLUP); + pinMode(P_LORA_MISO, INPUT_PULLUP); + + MESH_DEBUG_PRINTLN("WioTrackerL2Board: TCA9535 expander init"); + expander_ok = initExpander(); + if (!expander_ok) { + Serial.println("ERROR: TCA9535 IO expander not found - peripherals unpowered!"); + } + + Wire.beginTransmission(0x22); + aw_ok = Wire.endTransmission() == 0; + MESH_DEBUG_PRINTLN("WioTrackerL2Board: init done"); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; // LoRa packet woke us from deep sleep + } + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +void WioTrackerL2Board::setLed(bool on) { + // P10 doubles as GNSS wakeup and idles HIGH; blink = brief LOW pulses so + // the GPS never sees a sustained low level + if (expander_ok) { + expWritePin(EXP_PIN_USER_LED, !on); + } +} + +int WioTrackerL2Board::expReadInputs() { + Wire.beginTransmission(TCA9535_ADDR); + Wire.write((uint8_t)0x00); // input port 0 register + if (Wire.endTransmission() != 0) return -1; + if (Wire.requestFrom((int)TCA9535_ADDR, 2) != 2) return -1; + int lo = Wire.read(); + int hi = Wire.read(); + return lo | (hi << 8); +} + +bool WioTrackerL2Board::isExternalPowered() { + if (!aw_ok) return false; + Wire.beginTransmission(0x22); + Wire.write((uint8_t)0x40); // STATUS0 + if (Wire.endTransmission() != 0) return false; + if (Wire.requestFrom(0x22, 1) != 1) return false; + return (Wire.read() & 0x80) != 0; // VBUSOK bit +} + +bool WioTrackerL2Board::readWakeButton() { + if (!expander_ok) return false; + int v = expReadInputs(); + if (v < 0) return false; + return ((uint8_t)(v & 1)) != wake_btn_baseline; +} + +int16_t WioTrackerL2Board::adsReadRaw() { + Wire.beginTransmission(ADS1115_ADDR); + Wire.write(ADS_REG_CONFIG); + Wire.write((uint8_t)(ADS_CFG_BATT >> 8)); + Wire.write((uint8_t)(ADS_CFG_BATT & 0xFF)); + if (Wire.endTransmission() != 0) return -1; + + delay(10); // 128 SPS -> ~8ms conversion time + + Wire.beginTransmission(ADS1115_ADDR); + Wire.write(ADS_REG_CONVERSION); + if (Wire.endTransmission() != 0) return -1; + if (Wire.requestFrom((int)ADS1115_ADDR, 2) != 2) return -1; + + // sequence the two reads explicitly: operand evaluation order of <<| + // is unspecified and each read dequeues a byte + uint8_t hi = Wire.read(); + uint8_t lo = Wire.read(); + int16_t raw = ((int16_t)hi << 8) | lo; + return raw < 0 ? 0 : raw; +} + +uint16_t WioTrackerL2Board::getBattMilliVolts() { + if (!expander_ok) return 0; // BAT_ADC_EN rail never came up + + int16_t raw = adsReadRaw(); + if (raw < 0) return 0; + + return (uint16_t)(raw * ADS_MV_PER_LSB); +} diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.h b/variants/wio-tracker-l2/WioTrackerL2Board.h new file mode 100644 index 0000000000..c5644e2f20 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Board.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include "helpers/ESP32Board.h" + +// --------------------------------------------------------------------------- +// Seeed Wio Tracker L2 Pro (Wio-S3 module: ESP32-S3 + SX1262) +// +// Nearly all peripheral power rails and reset lines are behind a TCA9535 +// 16-bit I2C IO expander at address 0x21. Nothing (GNSS, LCD, touch, SD, +// battery ADC, speaker amp) works until the expander is configured, so +// begin() must run before any display / GPS / sensor init. +// +// Battery voltage is read through an ADS1115 16-bit I2C ADC at 0x48, +// channel AIN0, behind a x2 resistive divider (rail gated by expander). +// --------------------------------------------------------------------------- + +#define TCA9535_ADDR 0x21 +#define ADS1115_ADDR 0x48 + +// TCA9535 pin numbering: 0..7 = port 0 (P00..P07), 8..15 = port 1 (P10..P17) +#define EXP_PIN_WAKE_BTN 0 // input - side WAKE button +#define EXP_PIN_I2C_IRQ 1 // input - shared I2C IRQ +#define EXP_PIN_SD_DETECT 2 // input - microSD card detect +#define EXP_PIN_TP_INT 3 // output - touch panel interrupt (driven for reset seq) +#define EXP_PIN_LCD_CS 4 // output - LCD chip select (idle high) +#define EXP_PIN_LCD_EN 5 // output - LCD power enable +#define EXP_PIN_LCD_RST 6 // output - LCD reset +#define EXP_PIN_GROVE_EN 7 // output - Grove port power +#define EXP_PIN_TP_RST 8 // output - touch panel reset +#define EXP_PIN_GNSS_RST 9 // output - GNSS reset (active HIGH) +#define EXP_PIN_USER_LED 10 // output - mesh/user LED (also GNSS wakeup) +#define EXP_PIN_OTG_EN 11 // output - USB OTG power +#define EXP_PIN_PA_EN 12 // output - speaker amp power +#define EXP_PIN_GNSS_EN 13 // output - GNSS power +#define EXP_PIN_TF_EN 14 // output - microSD power +#define EXP_PIN_BAT_ADC_EN 15 // output - battery ADC divider enable + +class WioTrackerL2Board : public ESP32Board { +public: + void begin(); + + uint16_t getBattMilliVolts() override; + + const char* getManufacturerName() const override { + return "Seeed Wio Tracker L2 Pro"; + } + + void onBeforeTransmit() override { setLed(true); } + void onAfterTransmit() override { setLed(false); } + + // Mesh/user LED lives on the IO expander, not a GPIO + void setLed(bool on); + + // speaker amplifier power (expander P12); off when idle to keep the GNSS + // antenna away from class-D switching noise and to save battery + void setSpeakerAmp(bool on) { expWritePin(EXP_PIN_PA_EN, on); } + + // hardware reset pulse to the L76K GNSS (active HIGH on this board); the + // module keeps almanac/ephemeris across it, so this is a warm restart + // GNSS rail: off saves the whole receiver when GPS is disabled; on re-runs + // the power-up reset so the module comes back cleanly + void setGnssPower(bool on) { + if (on) { + expWritePin(EXP_PIN_GNSS_EN, HIGH); + delay(10); + gnssReset(); + } else { + expWritePin(EXP_PIN_GNSS_EN, LOW); + } + } + // Grove expansion port rail (nothing on-board depends on it) + void setGrovePower(bool on) { expWritePin(EXP_PIN_GROVE_EN, on); } + + void gnssReset() { + expWritePin(EXP_PIN_GNSS_RST, HIGH); + delay(10); + expWritePin(EXP_PIN_GNSS_RST, LOW); + } + + // WAKE button on expander P00: pressed = level differs from boot baseline + bool readWakeButton(); + + // VBUS presence via the AW35615 USB-C controller (I2C 0x22) + bool isExternalPowered() override; + + bool expanderOK() const { return expander_ok; } + +private: + uint8_t out_shadow[2] = { 0xFF, 0xFF }; // TCA9535 output regs default high + uint8_t cfg_shadow[2] = { 0xFF, 0xFF }; // 1 = input (power-on default) + bool expander_ok = false; + bool aw_ok = false; // AW35615 USB-C controller responded at probe + uint8_t wake_btn_baseline = 0; // idle level of P00, captured at init + + int expReadInputs(); // 16-bit input register pair, -1 on error + + bool expWriteReg(uint8_t reg, uint8_t val); + void expSetOutput(uint8_t pin, bool initial_level); + void expSetInput(uint8_t pin); + void expWritePin(uint8_t pin, bool level); + bool initExpander(); + + int16_t adsReadRaw(); +}; diff --git a/variants/wio-tracker-l2/WioTrackerL2Display.h b/variants/wio-tracker-l2/WioTrackerL2Display.h new file mode 100644 index 0000000000..d9089a64d4 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Display.h @@ -0,0 +1,210 @@ +#pragma once + +#include +#include + +#define LGFX_USE_V1 +#include + +// --------------------------------------------------------------------------- +// Wio Tracker L2 Pro display stack: +// - NV3031B 320x240 panel on quad-SPI (SCLK 42, IO0-3 = 41/40/39/38, CS 46) +// - GT911 capacitive touch on I2C 0x5D (SDA 47 / SCL 48) +// - LP5814 4-channel LED driver on I2C 0x2C used as backlight +// Panel power/reset lines are on the TCA9535 expander and are already +// sequenced by WioTrackerL2Board::begin() before this driver initializes. +// --------------------------------------------------------------------------- + +#ifndef L2_SPI_FREQUENCY + #define L2_SPI_FREQUENCY 75000000 +#endif + +#define LP5814_I2C_ADDR 0x2C + +// LP5814 used as backlight controller (all 4 channels in parallel) +class WioTrackerL2Backlight : public lgfx::v1::ILight { + static constexpr uint8_t REG_DEVICE_CONFIG0 = 0x00; + static constexpr uint8_t REG_MAX_CURRENT = 0x01; + static constexpr uint8_t REG_ENABLE_CONTROL = 0x02; + static constexpr uint8_t REG_DIM_MODE = 0x04; + static constexpr uint8_t REG_ENGINE_MODE = 0x05; + static constexpr uint8_t REG_UPDATE = 0x0F; + static constexpr uint8_t REG_LED0_DC = 0x14; + static constexpr uint8_t REG_LED0_PWM = 0x18; + + uint8_t _brightness = 153; // 60% + + void writeReg(uint8_t reg, uint8_t value) { + Wire.beginTransmission(LP5814_I2C_ADDR); + Wire.write(reg); + Wire.write(value); + Wire.endTransmission(); + } + +public: + bool init(uint8_t brightness) override { + Wire.beginTransmission(LP5814_I2C_ADDR); + if (Wire.endTransmission() != 0) { + return false; // LP5814 not found + } + + writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable + writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current + writeReg(REG_ENABLE_CONTROL, 0x00); // outputs off while configuring + writeReg(REG_DIM_MODE, 0x4E); + writeReg(REG_ENGINE_MODE, 0xF0); + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_DC + i, 200); + } + writeReg(REG_ENABLE_CONTROL, 0x0F); // enable all 4 channels + writeReg(REG_UPDATE, 0x55); // latch (LP5814 requires 0x55) + delay(5); + + setBrightness(brightness); + return true; + } + + void setBrightness(uint8_t brightness) override { + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_PWM + i, brightness); + } + _brightness = brightness; + } + + uint8_t getBrightness() const { return _brightness; } + + virtual ~WioTrackerL2Backlight() = default; +}; + +class LGFX_WioTrackerL2 : public lgfx::LGFX_Device { + lgfx::Panel_NV3031B _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Touch_GT911 _touch_instance; + WioTrackerL2Backlight _light_instance; + +public: + bool init_impl(bool use_reset, bool use_clear) override { + // bring up backlight controller while the I2C bus is still clean + _light_instance.init(_light_instance.getBrightness()); + + bool result = LGFX_Device::init_impl(use_reset, use_clear); + + // GT911 probe can leave the ESP32 I2C peripheral with a stuck BUSY flag; + // cycling Wire resets it so later LP5814/sensor traffic doesn't time out + Wire.end(); + Wire.begin(47, 48); + + return result; + } + + LGFX_WioTrackerL2(void) { + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI3_HOST; + cfg.spi_mode = 3; + cfg.freq_write = L2_SPI_FREQUENCY; + cfg.freq_read = 16000000; + cfg.pin_sclk = 42; + // quad SPI data pins + cfg.pin_io0 = 41; + cfg.pin_io1 = 40; + cfg.pin_io2 = 39; + cfg.pin_io3 = 38; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = 46; + cfg.pin_rst = -1; // reset is on the IO expander, done in board init + cfg.pin_busy = -1; + cfg.panel_width = 240; // native portrait orientation + cfg.panel_height = 320; + cfg.memory_width = 240; + cfg.memory_height = 320; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 1; // panel mounted landscape (320x240) + cfg.invert = true; + cfg.rgb_order = true; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + + { + auto cfg = _touch_instance.config(); + cfg.pin_cs = -1; + cfg.x_min = 0; + cfg.x_max = 239; + cfg.y_min = 0; + cfg.y_max = 319; + cfg.pin_int = -1; // INT is on the IO expander + cfg.offset_rotation = 2; + cfg.i2c_port = 0; + cfg.i2c_addr = 0x5D; + cfg.pin_sda = 47; + cfg.pin_scl = 48; + cfg.bus_shared = false; + cfg.freq = 400000; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + + _panel_instance.setLight(&_light_instance); + setPanel(&_panel_instance); + } +}; + +class WioTrackerL2Display : public LGFXDisplay { + LGFX_WioTrackerL2 disp; +public: + WioTrackerL2Display() : LGFXDisplay(320, 240, disp) {} + + // direct access to the LGFX device (LVGL flush/touch glue) + lgfx::LGFX_Device* lgfxDevice() { return &disp; } + + // reliable touch read in logical (UI_ZOOM-scaled) coords; returns false when + // not touched (LGFXDisplay::getTouch reads an uninitialized point on release) + bool readTouch(int& x, int& y) { + lgfx::touch_point_t tp; + if (disp.getTouch(&tp, 1) == 0) return false; + x = tp.x / UI_ZOOM; + y = tp.y / UI_ZOOM; + return true; + } + + // shadows LGFXDisplay::begin() (non-virtual, called on the concrete type in + // main.cpp) - identical except rotation: our panel config already carries + // offset_rotation=1 for the landscape mounting, so no extra rotation here + bool begin() { + // dark theme: deep navy ground, MeshCore blue accents (RGB565) + UIColor::window_bkg = 0x0885; // dark navy + UIColor::title_bkg = 0x1299; // meshcore blue + UIColor::title_txt = 0xFFFF; + UIColor::primary_txt = 0xE73C; // near-white + UIColor::secondary_txt = 0x8D59; // muted blue-gray + UIColor::warning_txt = 0xFD20; // orange + UIColor::popup_bkg = 0x1299; + UIColor::popup_txt = 0xFFFF; + UIColor::corp_blue = 0x5DBF; // sky blue (own messages, icons) + + turnOn(); + display->init(); + display->setRotation(0); + display->setColorDepth(8); + // The zoomed sprite blit stops a pixel short of the panel edge, so those + // pixels keep whatever the panel powered up with (they showed as coloured + // lines down the right side and along the bottom). Clear the panel once. + display->fillScreen(UIColor::window_bkg); + display->setBrightness(153); + display->setTextColor(TFT_WHITE); + + buffer.setColorDepth(8); + buffer.setPsram(true); + buffer.createSprite(width(), height()); + + return true; + } +}; diff --git a/variants/wio-tracker-l2/nv3031b_odr_fix.cpp b/variants/wio-tracker-l2/nv3031b_odr_fix.cpp new file mode 100644 index 0000000000..ca4033ea76 --- /dev/null +++ b/variants/wio-tracker-l2/nv3031b_odr_fix.cpp @@ -0,0 +1,15 @@ +// LovyanGFX's Panel_NV3031B declares `init_cmds` as an in-class +// `static constexpr` array. Under C++17 that is implicitly inline, but the +// Arduino ESP32 core builds with gnu++14 where an ODR-used static constexpr +// member still needs an out-of-class definition - without this the link +// fails with "undefined reference to lgfx::v1::Panel_NV3031B::init_cmds". +#include + +#if __cplusplus < 201703L +namespace lgfx { +inline namespace v1 { +constexpr uint8_t Panel_NV3031B::init_cmds[]; +constexpr uint8_t Panel_NV3031B::CMD_INIT_DELAY; +} +} +#endif diff --git a/variants/wio-tracker-l2/platformio.ini b/variants/wio-tracker-l2/platformio.ini new file mode 100644 index 0000000000..0182574e90 --- /dev/null +++ b/variants/wio-tracker-l2/platformio.ini @@ -0,0 +1,187 @@ +; Seeed Wio Tracker L2 Pro (alpha) - Wio-S3 module: ESP32-S3R8 + SX1262 +; Pin map derived from Seeed's Meshtastic support branch (meshtastic/firmware +; PR #10909 + device-ui `wio-l2` branch). +[Wio_Tracker_L2] +extends = esp32_base +board = seeed_wio_tracker_l2 +build_flags = ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/wio-tracker-l2 + -D SEEED_WIO_TRACKER_L2 + -D PIN_BOARD_SDA=47 + -D PIN_BOARD_SCL=48 + -D PIN_USER_BTN=0 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D P_LORA_NSS=21 + -D P_LORA_RESET=7 + -D P_LORA_BUSY=8 + -D P_LORA_DIO_1=9 + -D P_LORA_SCLK=4 + -D P_LORA_MISO=5 + -D P_LORA_MOSI=6 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D ENV_INCLUDE_GPS=1 + -D PIN_GPS_TX=18 ; L76K TX -> ESP32 RX + -D PIN_GPS_RX=17 ; L76K RX <- ESP32 TX + -D GPS_BAUD_RATE=9600 + -D ENV_SKIP_GPS_DETECT=1 ; GPS is always present, powered via IO expander + -D DISPLAY_CLASS=WioTrackerL2Display + -D UI_ZOOM=2 + ; disable ALL env sensor I2C probes: this bus carries six onboard chips + ; (TCA9535 0x21, AW35615 0x22, LP5814 0x2C, ADS1115 0x48, GT911 0x5D, + ; ES8311 0x18) and stray probe matches can hang sensors.begin() + -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_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 ENV_INCLUDE_LPS22HB=0 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/wio-tracker-l2> + + + + + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + lovyan03/LovyanGFX @ ^1.2.25 + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.2.1.zip + +; -------- Companion: BLE phone app + standalone touch UI -------------------- +[env:Wio_Tracker_L2_companion_radio_ble] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.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 HAS_TOUCH=1 + -D AUTO_OFF_MILLIS=60000 ; LCD, no burn-in worry; 1 min screen timeout +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; -------- Companion: USB serial link + standalone touch UI ------------------ +[env:Wio_Tracker_L2_companion_radio_usb] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE + -D HAS_TOUCH=1 + -D AUTO_OFF_MILLIS=60000 +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; -------- Companion: BLE + full LVGL touch UI (next-gen interface) ---------- +[Wio_Tracker_L2_lvgl] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-lvgl + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D AUTO_OFF_MILLIS=60000 + -D LV_CONF_SKIP=1 + -D LV_COLOR_DEPTH=16 + -D LV_FONT_MONTSERRAT_12=1 + -D LV_FONT_MONTSERRAT_14=1 + -D LV_FONT_MONTSERRAT_16=1 + -D LV_FONT_MONTSERRAT_28=1 ; splash title + ; emoji font support (default font is set via the theme in UITask.cpp) + -D LV_LVGL_H_INCLUDE_SIMPLE + -D UI_LVGL=1 ; suppress main.cpp 'Loading...' banner - LVGL splash instead + -D CHAT_STORE_SIZE=120 ; message history depth (persisted to SPIFFS) + ; map tiles: PNG decode + POSIX FS (ESP32 VFS -> /sdcard) + decoded-image cache + -D LV_USE_LODEPNG=1 + -D LV_USE_FS_POSIX=1 + -D LV_FS_POSIX_LETTER=65 + -D LV_FS_POSIX_PATH='"/sdcard"' + -D LV_CACHE_DEF_SIZE=2097152 + ; all LVGL memory in one PSRAM pool: internal RAM stays free for BT/SD bursts + -D LV_USE_STDLIB_MALLOC=LV_STDLIB_BUILTIN + -D LV_MEM_SIZE=3670016 + -D LV_MEM_ADR=0 + -D LV_MEM_POOL_INCLUDE='"lv_psram_pool.h"' + -D LV_MEM_POOL_ALLOC=lv_psram_pool_alloc +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-lvgl/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + lvgl/lvgl @ 9.2.2 ; pinned: UI is validated against this exact release + +; Standalone: full touch UI, no Bluetooth (USB CLI only). The BLE companion +; builds use the standard display UI instead - with a phone attached, the +; phone is the interface. STANDALONE_NO_BT compiles out the BT settings rows. +[env:Wio_Tracker_L2_standalone_lvgl] +extends = Wio_Tracker_L2_lvgl +build_flags = + ${Wio_Tracker_L2_lvgl.build_flags} + -D ENABLE_USB_INTERFACE + -D STANDALONE_NO_BT=1 +build_src_filter = ${Wio_Tracker_L2_lvgl.build_src_filter} + - + +; -------- Standalone repeater: extends mesh coverage, no phone needed ------- +[env:Wio_Tracker_L2_repeater] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -D ADVERT_NAME='"WioL2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + +<../examples/simple_repeater/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + ${esp32_ota.lib_deps} + +; -------- Standalone room server: shared message board / BBS ---------------- +[env:Wio_Tracker_L2_room_server] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -D ADVERT_NAME='"WioL2 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/wio-tracker-l2/target.cpp b/variants/wio-tracker-l2/target.cpp new file mode 100644 index 0000000000..e865464b31 --- /dev/null +++ b/variants/wio-tracker-l2/target.cpp @@ -0,0 +1,35 @@ +#include +#include "target.h" + +WioTrackerL2Board board; + +static SPIClass spi; +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); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +GpsTapStream gps_tap(Serial1); // GSV sats-in-view / SNR watcher +MicroNMEALocationProvider gps(gps_tap, &rtc_clock); +EnvironmentSensorManager sensors(gps); + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); +#endif + +bool radio_init() { + MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init"); + fallback_clock.begin(); + rtc_clock.begin(Wire); // Wire already running on 47/48 from board.begin() + + bool ok = radio.std_init(&spi); + MESH_DEBUG_PRINTLN(ok ? "radio_init: SX1262 OK" : "radio_init: SX1262 FAILED"); + return ok; +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/wio-tracker-l2/target.h b/variants/wio-tracker-l2/target.h new file mode 100644 index 0000000000..11a66e4ec1 --- /dev/null +++ b/variants/wio-tracker-l2/target.h @@ -0,0 +1,30 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include "WioTrackerL2Board.h" +#include +#include +#include +#include +#include "GpsTap.h" +#ifdef DISPLAY_CLASS + #include "WioTrackerL2Display.h" + #include +#endif + +extern WioTrackerL2Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; +extern GpsTapStream gps_tap; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/wio-tracker-l2/tools/tile_darkener.py b/variants/wio-tracker-l2/tools/tile_darkener.py new file mode 100644 index 0000000000..059dcef088 --- /dev/null +++ b/variants/wio-tracker-l2/tools/tile_darkener.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Builds a dark tile set from the packed day tiles. + +Luminance inversion with preserved hue: invert Y in YCbCr, gently compress +toward dark so labels come out light on a near-black ground and water/parks +keep their (darkened) colors. Reads maps/{z}/{x}.pak, writes the same format +to packs_dark/, palette-quantized so the dark set stays close to the day +set's size. Resumable: existing outputs are skipped. +""" +import io +import os +import struct +import sys +from multiprocessing import Pool + +from PIL import Image + +SRC = "tiles_stage/packs" +DST = "tiles_stage/packs_dark" + +def darken_png(data: bytes) -> bytes: + img = Image.open(io.BytesIO(data)).convert("RGB").convert("YCbCr") + y, cb, cr = img.split() + y = y.point(lambda v: 16 + (255 - v) * 220 // 255) # invert, lift blacks + out = Image.merge("YCbCr", (y, cb, cr)).convert("RGB").quantize(colors=192) + buf = io.BytesIO() + out.save(buf, format="PNG", optimize=False) + return buf.getvalue() + +def process_pak(job) -> int: + z, name = job + src = os.path.join(SRC, z, name) + dst = os.path.join(DST, z, name) + if os.path.exists(dst): + return 0 + with open(src, "rb") as f: + raw = f.read() + if raw[:4] != b"TPK1": + return 0 + y0, y1 = struct.unpack_from(" None: + jobs = [] + for z in sorted(os.listdir(SRC), key=lambda s: int(s) if s.isdigit() else 99): + zdir = os.path.join(SRC, z) + if not os.path.isdir(zdir): + continue + for name in os.listdir(zdir): + if name.endswith(".pak"): + jobs.append((z, name)) + print(f"PLAN: {len(jobs)} paks to darken", flush=True) + done = tiles = 0 + with Pool(processes=8) as pool: + for cnt in pool.imap_unordered(process_pak, jobs, chunksize=4): + done += 1 + tiles += cnt + if done % 250 == 0: + print(f"MILESTONE: {done}/{len(jobs)} paks, {tiles} tiles darkened", flush=True) + print(f"DONE: {done} paks, {tiles} tiles darkened", flush=True) + +if __name__ == "__main__": + main() diff --git a/variants/wio-tracker-l2/tools/tile_downloader.py b/variants/wio-tracker-l2/tools/tile_downloader.py new file mode 100644 index 0000000000..1799aed2da --- /dev/null +++ b/variants/wio-tracker-l2/tools/tile_downloader.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Offline map tile downloader for the Wio Tracker L2 Pro SD card. + +Downloads OSM raster tiles into /maps/{z}/{x}/{y}.png - the layout +Meshtastic MUI reads and the planned MeshCore map screen will share. + +Polite by design: single-threaded, ~2 req/s, resumable (skips existing +files), retries with backoff. Re-run any time to fill gaps or add areas. +""" + +import math +import os +import sys +import time +import threading +import urllib.request +from concurrent.futures import ThreadPoolExecutor + +DEST = sys.argv[1] if len(sys.argv) > 1 else "/Volumes/L2MAPS" +# defaults are polite for public OSM; for a local render server use e.g. +# TILE_URL="http://localhost:8080/tile/{z}/{x}/{y}.png" TILE_WORKERS=8 TILE_DELAY=0 +TILE_URL = os.environ.get("TILE_URL", "https://tile.openstreetmap.org/{z}/{x}/{y}.png") +USER_AGENT = "L2Pro-offline-map-prep/1.0 (personal one-time use, throttled)" +WORKERS = int(os.environ.get("TILE_WORKERS", "2")) +DELAY_SECS = float(os.environ.get("TILE_DELAY", "0.2")) +MILESTONE_EVERY = 2500 + +# (name, lon_min, lat_min, lon_max, lat_max, z_min, z_max) +AREAS = [ + ("fargo_moorhead", -97.00, 46.70, -96.60, 47.00, 10, 17), + ("bismarck_mandan", -100.95, 46.70, -100.60, 46.90, 10, 17), + ("i94_corridor", -100.95, 46.55, -96.60, 47.05, 8, 12), + ("nd_region", -104.10, 45.80, -96.00, 49.10, 5, 9), + # --- expansion pack --- + ("grand_forks", -97.15, 47.85, -96.95, 48.00, 10, 17), + ("i29_corridor", -97.30, 45.93, -96.60, 49.00, 8, 12), + ("minot", -101.40, 48.18, -101.20, 48.30, 10, 15), + ("jamestown", -98.78, 46.85, -98.62, 46.95, 10, 15), + ("valley_city", -98.05, 46.88, -97.95, 46.96, 10, 15), + ("devils_lake", -98.92, 48.08, -98.80, 48.16, 10, 15), + ("wahpeton", -96.65, 46.23, -96.55, 46.31, 10, 15), + ("dickinson", -102.85, 46.83, -102.72, 46.92, 10, 15), + ("williston", -103.70, 48.11, -103.55, 48.21, 10, 15), + ("mn_lakes", -96.00, 46.20, -94.00, 47.10, 9, 13), + ("msp_metro", -93.55, 44.70, -92.90, 45.25, 10, 16), + ("nd_blanket", -104.10, 45.80, -95.20, 49.10, 10, 13), + # widened Fargo-Moorhead metro at EVERY zoom the map UI offers + ("fargo_metro_wide", -97.10, 46.65, -96.50, 47.05, 5, 17), + # statewide street level: both states finish z5-15 before any z16 starts, + # so a full card degrades gracefully (z16 is ~75% of the tile count) + ("nd_full", -104.10, 45.80, -96.50, 49.10, 5, 15), + ("mn_full", -97.30, 43.45, -89.45, 49.40, 5, 15), + ("nd_full_z16", -104.10, 45.80, -96.50, 49.10, 16, 16), + ("mn_full_z16", -97.30, 43.45, -89.45, 49.40, 16, 16), +] + +MIN_FREE_BYTES = 2 * 1024 ** 3 # stop writing when the card has < 2GB left + + +def tile_range(lon_min, lat_min, lon_max, lat_max, z): + def to_tile(lon, lat): + n = 2 ** z + x = int((lon + 180.0) / 360.0 * n) + lat_r = math.radians(lat) + y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n) + return max(0, min(n - 1, x)), max(0, min(n - 1, y)) + + x0, y1 = to_tile(lon_min, lat_min) # note: y grows southward + x1, y0 = to_tile(lon_max, lat_max) + return range(x0, x1 + 1), range(y0, y1 + 1) + + +def iter_tiles(): + seen = set() + for name, lon_min, lat_min, lon_max, lat_max, z_min, z_max in AREAS: + for z in range(z_min, z_max + 1): + xs, ys = tile_range(lon_min, lat_min, lon_max, lat_max, z) + for x in xs: + for y in ys: + key = (z, x, y) + if key not in seen: + seen.add(key) + yield key + + +def fetch(url): + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read() + + +counts_lock = threading.Lock() +done = skipped = failed = processed = 0 +total = 0 + + +def bump(kind): + global done, skipped, failed, processed + with counts_lock: + if kind == "done": done += 1 + elif kind == "skip": skipped += 1 + else: failed += 1 + processed += 1 + if processed % MILESTONE_EVERY == 0: + pct = processed * 100 // total + print(f"MILESTONE: {processed}/{total} ({pct}%) done={done} skipped={skipped} failed={failed}", flush=True) + + +card_full = False + + +def card_has_room(): + global card_full + if card_full: + return False + st = os.statvfs(DEST) + if st.f_bavail * st.f_frsize < MIN_FREE_BYTES: + card_full = True + print("CARD FULL: below free-space floor, skipping remaining downloads", flush=True) + return False + return True + + +def handle(tile): + z, x, y = tile + path = os.path.join(DEST, "maps", str(z), str(x), f"{y}.png") + if os.path.exists(path) and os.path.getsize(path) > 0: + bump("skip") + return + if not card_has_room(): + bump("fail") + return + os.makedirs(os.path.dirname(path), exist_ok=True) + for attempt in range(4): + try: + data = fetch(TILE_URL.format(z=z, x=x, y=y)) + with open(path, "wb") as f: + f.write(data) + bump("done") + time.sleep(DELAY_SECS) + return + except Exception as e: + wait = 2 ** attempt * 5 + print(f"RETRY z{z}/{x}/{y} attempt {attempt + 1}: {e} (wait {wait}s)", flush=True) + time.sleep(wait) + print(f"ERROR: gave up on z{z}/{x}/{y}", flush=True) + bump("fail") + + +def main(): + global total + tiles = list(iter_tiles()) + total = len(tiles) + print(f"PLAN: {total} unique tiles -> {DEST}/maps ({WORKERS} workers)", flush=True) + + with ThreadPoolExecutor(max_workers=WORKERS) as pool: + list(pool.map(handle, tiles)) + + print(f"DONE: total={total} downloaded={done} skipped={skipped} failed={failed}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/variants/wio-tracker-l2/tools/tile_packer.py b/variants/wio-tracker-l2/tools/tile_packer.py new file mode 100644 index 0000000000..913287c4a4 --- /dev/null +++ b/variants/wio-tracker-l2/tools/tile_packer.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Packs loose z/x/y.png tiles into per-column pak files: maps/{z}/{x}.pak + +Format 'TPK1' (all uint32 little-endian): + magic 'TPK1' | y_min | y_max | offsets[n+1] (n = y_max - y_min + 1) +Tile i's PNG bytes live at [offsets[i], offsets[i+1]); equal offsets = no tile. +Offsets are absolute file positions. 4.5M tiny files become ~30k paks, so a +FAT SD card copies at raw bandwidth instead of file-creation rate. +""" +import os +import struct +import sys +from concurrent.futures import ThreadPoolExecutor + +SRC = sys.argv[1] if len(sys.argv) > 1 else "tiles_stage/maps" +DST = sys.argv[2] if len(sys.argv) > 2 else "tiles_stage/packs" + +def pack_column(z: str, x: str) -> int: + xdir = os.path.join(SRC, z, x) + ys = sorted(int(n[:-4]) for n in os.listdir(xdir) if n.endswith(".png")) + if not ys: + return 0 + y0, y1 = ys[0], ys[-1] + n = y1 - y0 + 1 + out = os.path.join(DST, z, f"{x}.pak") + tmp = out + ".tmp" + os.makedirs(os.path.dirname(out), exist_ok=True) + header_size = 12 + 4 * (n + 1) + offsets = [header_size] + blobs = [] + have = set(ys) + for y in range(y0, y1 + 1): + if y in have: + with open(os.path.join(xdir, f"{y}.png"), "rb") as f: + b = f.read() + blobs.append(b) + offsets.append(offsets[-1] + len(b)) + else: + blobs.append(b"") + offsets.append(offsets[-1]) + with open(tmp, "wb") as f: + f.write(b"TPK1" + struct.pack(" None: + jobs = [] + for z in sorted(os.listdir(SRC), key=lambda s: int(s) if s.isdigit() else 99): + zdir = os.path.join(SRC, z) + if not os.path.isdir(zdir): + continue + for x in os.listdir(zdir): + if os.path.isdir(os.path.join(zdir, x)): + # skip columns already packed with a plausible size + out = os.path.join(DST, z, f"{x}.pak") + if not os.path.exists(out): + jobs.append((z, x)) + print(f"PLAN: {len(jobs)} columns to pack", flush=True) + done = tiles = 0 + with ThreadPoolExecutor(max_workers=8) as pool: + for cnt in pool.map(lambda j: pack_column(*j), jobs): + done += 1 + tiles += cnt + if done % 2000 == 0: + print(f"MILESTONE: {done}/{len(jobs)} columns, {tiles} tiles packed", flush=True) + print(f"DONE: {done} columns, {tiles} tiles packed", flush=True) + +if __name__ == "__main__": + main() From e7999bafbf73f64295c175f5ef4137e887356c44 Mon Sep 17 00:00:00 2001 From: Hacuchino-hash <246103064+Hacuchino-hash@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:36:01 -0500 Subject: [PATCH 2/2] Add ui-lvgl touch interface for the Wio Tracker L2 Pro An LVGL 9 interface for boards with a touch panel, used by this variant's standalone environment: messaging with delivery state and history, contacts with path control and repeater trace, a repeater admin screen, an offline map from SD, and on-device radio and node settings. The shared companion sources gain optional hooks for it: new no-op virtuals on AbstractUITask (delivery ack, login result, status and CLI replies, trace results), MyMesh entry points that mirror the existing phone command paths (uiLogin, uiRequestStatus, uiTracePath, advertFlood), and a touch handler on UIScreen. saveChannels, saveContacts and isValidClientRepeatFreq move to the public section so the on-device settings screen can reach them. The boot splash draws the wordmark bitmap the other display UIs already share, expanded to an alpha mask and tinted from the widget style. --- examples/companion_radio/AbstractUITask.h | 6 + examples/companion_radio/MyMesh.cpp | 92 + examples/companion_radio/MyMesh.h | 22 +- examples/companion_radio/main.cpp | 2 + .../companion_radio/ui-lvgl/ChatStore.cpp | 183 + examples/companion_radio/ui-lvgl/ChatStore.h | 48 + examples/companion_radio/ui-lvgl/MapView.cpp | 437 +++ examples/companion_radio/ui-lvgl/MapView.h | 13 + .../companion_radio/ui-lvgl/SettingsUI.cpp | 678 ++++ examples/companion_radio/ui-lvgl/SettingsUI.h | 18 + examples/companion_radio/ui-lvgl/UITask.cpp | 3448 +++++++++++++++++ examples/companion_radio/ui-lvgl/UITask.h | 118 + .../companion_radio/ui-lvgl/fa_icons_14.cpp | 198 + .../companion_radio/ui-lvgl/lv_psram_pool.h | 10 + .../companion_radio/ui-lvgl/meshcore_logo.cpp | 37 + src/helpers/ui/UIScreen.h | 1 + 16 files changed, 5308 insertions(+), 3 deletions(-) create mode 100644 examples/companion_radio/ui-lvgl/ChatStore.cpp create mode 100644 examples/companion_radio/ui-lvgl/ChatStore.h create mode 100644 examples/companion_radio/ui-lvgl/MapView.cpp create mode 100644 examples/companion_radio/ui-lvgl/MapView.h create mode 100644 examples/companion_radio/ui-lvgl/SettingsUI.cpp create mode 100644 examples/companion_radio/ui-lvgl/SettingsUI.h create mode 100644 examples/companion_radio/ui-lvgl/UITask.cpp create mode 100644 examples/companion_radio/ui-lvgl/UITask.h create mode 100644 examples/companion_radio/ui-lvgl/fa_icons_14.cpp create mode 100644 examples/companion_radio/ui-lvgl/lv_psram_pool.h create mode 100644 examples/companion_radio/ui-lvgl/meshcore_logo.cpp diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index b25b1442fc..5e391c4973 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -41,6 +41,12 @@ class AbstractUITask { void disableBluetooth() { _interfaceManager->disableBluetooth(); } virtual void msgRead(int msgcount) = 0; virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0; + virtual void msgAck(uint32_t ack_crc) { } // delivery ACK received (any origin) + virtual void msgEchoHeard() { } // our last sent message heard being retransmitted + virtual void loginResult(const uint8_t* pub_key, bool success) { } // repeater/room login outcome + virtual void statusResponse(const uint8_t* pub_key, const uint8_t* data, int len) { } // REQ_TYPE_GET_STATUS reply + virtual void cliResponse(const char* from_name, const char* text) { } // repeater CLI reply text + virtual void traceResponse(uint32_t tag, const uint8_t* path_hashes, const uint8_t* path_snrs, uint8_t hop_count, int8_t final_snr) { } // TRACE round trip returned virtual void notify(UIEventType t = UIEventType::none) = 0; virtual void loop() = 0; }; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 46c8e2f60c..4b95ac27d0 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -289,7 +289,33 @@ uint8_t MyMesh::getExtraAckTransmitCount() const { return _prefs.multi_acks; } +static uint32_t fnv1aHash(const uint8_t* data, int len) { + uint32_t h = 2166136261u; + for (int i = 0; i < len; i++) { + h ^= data[i]; + h *= 16777619u; + } + return h; +} + +void MyMesh::logTx(mesh::Packet* packet, int len) { + // remember the payload signature of outgoing text so the UI can count + // repeaters echoing it (payload bytes survive retransmission unchanged) + uint8_t t = packet->getPayloadType(); + if (_ui != NULL && (t == PAYLOAD_TYPE_TXT_MSG || t == PAYLOAD_TYPE_GRP_TXT) && packet->payload_len > 0) { + echo_hash = fnv1aHash(packet->payload, packet->payload_len); + echo_len = packet->payload_len; + echo_time = millis(); + } +} + void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { + // the payload sits at the tail of the frame; a match on the last echo_len + // bytes means a neighbor just retransmitted our message + if (_ui != NULL && echo_len > 0 && len > (int) echo_len && millis() - echo_time < 60000) { + if (fnv1aHash(&raw[len - echo_len], echo_len) == echo_hash) _ui->msgEchoHeard(); + } + if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) { int i = 0; out_frame[i++] = PUSH_CODE_LOG_RX_DATA; @@ -465,6 +491,13 @@ void MyMesh::onContactPathUpdated(const ContactInfo &contact) { } ContactInfo* MyMesh::processAck(const uint8_t *data) { +#ifdef DISPLAY_CLASS + if (_ui) { // let on-device UI match acks for messages it originated + uint32_t ack_crc; + memcpy(&ack_crc, data, 4); + _ui->msgAck(ack_crc); + } +#endif // see if matches any in a table for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { if (memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient @@ -525,6 +558,8 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe if (!_serial->isConnected()) { _ui->notify(UIEventType::contactMessage); } + } else if (txt_type == TXT_TYPE_CLI_DATA && _ui) { + _ui->cliResponse(from.name, text); // for UIs with a repeater terminal } #endif } @@ -737,6 +772,35 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim return 0; // unknown } +int MyMesh::uiLogin(const ContactInfo &recipient, const char *password) { + uint32_t est_timeout; + int result = sendLogin(recipient, password, est_timeout); + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + memcpy(&pending_login, recipient.id.pub_key, 4); // match this to onContactResponse() + } + return result; +} + +int MyMesh::uiRequestStatus(const ContactInfo &recipient) { + uint32_t tag, est_timeout; + int result = sendRequest(recipient, REQ_TYPE_GET_STATUS, tag, est_timeout); + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + memcpy(&pending_status, recipient.id.pub_key, 4); // match this to onContactResponse() + } + return result; +} + +int MyMesh::uiTracePath(const uint8_t *path, uint8_t path_len, uint32_t tag) { + auto pkt = createTrace(tag, 0, 0); // flags = 0: one-byte path hashes + if (pkt == NULL) return 0; + sendDirect(pkt, path, path_len); + + uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2); + return (int) calcDirectTimeoutMillisFor(t, path_len); +} + void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) { uint32_t tag; memcpy(&tag, data, 4); @@ -745,6 +809,13 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, // yes, is response to pending sendLogin() pending_login = 0; +#ifdef DISPLAY_CLASS + if (_ui) { + bool login_ok = (memcmp(&data[4], "OK", 2) == 0) || data[4] == RESP_SERVER_LOGIN_OK; + _ui->loginResult(contact.id.pub_key, login_ok); + } +#endif + int i = 0; if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS; @@ -778,6 +849,10 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, ) { pending_status = 0; +#ifdef DISPLAY_CLASS + if (_ui) _ui->statusResponse(contact.id.pub_key, &data[4], len - 4); +#endif + int i = 0; out_frame[i++] = PUSH_CODE_STATUS_RESPONSE; out_frame[i++] = 0; // reserved @@ -888,6 +963,9 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) { void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) { uint8_t path_sz = flags & 0x03; // NEW v1.11+ + if (_ui != NULL) { // on-device trace UI (matches by tag) + _ui->traceResponse(tag, path_hashes, path_snrs, (uint8_t)(path_len >> path_sz), (int8_t)(packet->getSNR() * 4)); + } if (12 + path_len + (path_len >> path_sz) + 1 > sizeof(out_frame)) { MESH_DEBUG_PRINTLN("onTraceRecv(), path_len is too long: %d", (uint32_t)path_len); return; @@ -2403,6 +2481,20 @@ void MyMesh::loop() { #endif } +bool MyMesh::advertFlood() { + mesh::Packet* pkt; + if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { + pkt = createSelfAdvert(_prefs.node_name); + } else { + pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); + } + if (pkt == NULL) return false; + TransportKey default_scope; + memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); + sendFloodScoped(default_scope, pkt, 0); + return true; +} + bool MyMesh::advert() { mesh::Packet* pkt; if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 3b98a4f674..1ca5e2f270 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -108,6 +108,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void loop(); void handleCmdFrame(size_t len); bool advert(); + bool advertFlood(); // same advert, flood-routed (phone app "flood advert") + const mesh::LocalIdentity& selfId() const { return self_id; } void enterCLIRescue(); int getRecentlyHeard(AdvertPath dest[], int max_num); @@ -117,6 +119,17 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { int getDiscoveredNodes(DiscoveredNode nodes[], int max_num); #endif + // on-device UI login to repeater/room server (registers for the response + // like the phone CMD_SEND_LOGIN path does) + int uiLogin(const ContactInfo& recipient, const char* password); + int uiRequestStatus(const ContactInfo& recipient); + int uiTracePath(const uint8_t* path, uint8_t path_len, uint32_t tag); + + // used by the on-device UI as well as the phone command handlers + void saveChannels() { _store->saveChannels(this); } + void saveContacts(); + bool isValidClientRepeatFreq(uint32_t f) const; + protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; @@ -136,6 +149,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; + void logTx(mesh::Packet* packet, int len) override; bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; bool shouldOverwriteWhenFull() const override; @@ -224,11 +238,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void checkCLIRescueCmd(); bool handleCommand(const char* text, uint32_t sender_timestamp, char* reply); void checkSerialInterface(); - bool isValidClientRepeatFreq(uint32_t f) const; // helpers, short-cuts - void saveChannels() { _store->saveChannels(this); } - void saveContacts(); DataStore* _store; NodePrefs _prefs; @@ -255,6 +266,11 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { TransportKey send_scope; + // signature of the last transmitted text message, for UI echo detection + uint32_t echo_hash = 0; + uint16_t echo_len = 0; + unsigned long echo_time = 0; + uint8_t cmd_frame[MAX_FRAME_SIZE + 1]; uint8_t out_frame[MAX_FRAME_SIZE + 1]; CayenneLPP telemetry; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 89f0e6cb9f..f55421b8aa 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -125,12 +125,14 @@ void setup() { DisplayDriver* disp = NULL; if (display.begin()) { disp = &display; +#ifndef UI_LVGL // LVGL UI shows its own splash; skip the text banner disp->startFrame(); #ifdef ST7789 disp->setTextSize(2); #endif disp->drawTextCentered(disp->width() / 2, 28, "Loading..."); disp->endFrame(); +#endif } #endif diff --git a/examples/companion_radio/ui-lvgl/ChatStore.cpp b/examples/companion_radio/ui-lvgl/ChatStore.cpp new file mode 100644 index 0000000000..62a05b4443 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/ChatStore.cpp @@ -0,0 +1,183 @@ +#include "ChatStore.h" +#include + +#ifdef ESP32 + #include +#endif + +#define CHAT_HIST_FILE "/chat_hist.bin" +#define CHAT_HIST_MAGIC 0x43483032 // "CH02" + +static ChatMsg chat_store[CHAT_STORE_SIZE]; +static int chat_head = 0; +static bool chat_dirty = false; +static unsigned long chat_flush_at = 0; + +void makeChannelKey(int idx, uint8_t key[6]) { + key[0] = 0xFE; key[1] = 0xC4; key[2] = (uint8_t)idx; + key[3] = 0x00; key[4] = 0xBE; key[5] = 0xEF; +} + +bool isChannelKey(const uint8_t key[6], int* idx_out) { + if (key[0] == 0xFE && key[1] == 0xC4 && key[3] == 0x00 && key[4] == 0xBE && key[5] == 0xEF) { + if (idx_out) *idx_out = key[2]; + return true; + } + return false; +} + +void chatStoreMarkDirty() { + chat_dirty = true; + chat_flush_at = millis() + 4000; +} + +ChatMsg* chatStorePush(const uint8_t* pub_key, bool outgoing, uint32_t timestamp, const char* text) { + auto m = &chat_store[chat_head]; + chat_head = (chat_head + 1) % CHAT_STORE_SIZE; + memcpy(m->prefix, pub_key, 6); + m->outgoing = outgoing ? 1 : 0; + m->valid = 1; + m->status = MSG_STATUS_NONE; + m->expected_ack = 0; + m->timeout_at = 0; + m->timestamp = timestamp; + StrHelper::strncpy(m->text, text, sizeof(m->text)); + chatStoreMarkDirty(); + return m; +} + +ChatMsg* chatStoreGet(const uint8_t* pub_key, int k) { + int found = 0; + for (int i = 0; i < CHAT_STORE_SIZE; i++) { + int idx = (chat_head - 1 - i + CHAT_STORE_SIZE * 2) % CHAT_STORE_SIZE; + auto m = &chat_store[idx]; + if (!m->valid || memcmp(m->prefix, pub_key, 6) != 0) continue; + if (found == k) return m; + found++; + } + return NULL; +} + +bool chatStoreAck(uint32_t ack_crc) { + bool changed = false; + for (int i = 0; i < CHAT_STORE_SIZE; i++) { + auto m = &chat_store[i]; + if (m->valid && m->status == MSG_STATUS_PENDING && m->expected_ack == ack_crc) { + m->status = MSG_STATUS_DELIVERED; + m->expected_ack = 0; + changed = true; + } + } + if (changed) chatStoreMarkDirty(); + return changed; +} + +int chatStoreThreads(uint8_t keys[][6], int max) { + int count = 0; + for (int i = 0; i < CHAT_STORE_SIZE && count < max; i++) { + int idx = (chat_head - 1 - i + CHAT_STORE_SIZE * 2) % CHAT_STORE_SIZE; + auto m = &chat_store[idx]; + if (!m->valid) continue; + bool seen = false; + for (int j = 0; j < count; j++) { + if (memcmp(keys[j], m->prefix, 6) == 0) { seen = true; break; } + } + if (!seen) memcpy(keys[count++], m->prefix, 6); + } + return count; +} + +void chatStoreLoad() { +#ifdef ESP32 + if (!SPIFFS.begin(true)) return; + File f = SPIFFS.open(CHAT_HIST_FILE, "r"); + if (!f) return; + uint32_t magic = 0; + int32_t head = 0; + bool ok = f.read((uint8_t *)&magic, 4) == 4 && magic == CHAT_HIST_MAGIC + && f.read((uint8_t *)&head, 4) == 4 + && f.read((uint8_t *)chat_store, sizeof(chat_store)) == sizeof(chat_store) + && head >= 0 && head < CHAT_STORE_SIZE; + f.close(); + if (ok) { + chat_head = head; + for (int i = 0; i < CHAT_STORE_SIZE; i++) { + chat_store[i].text[sizeof(chat_store[i].text) - 1] = 0; // guard vs flash corruption + if (chat_store[i].valid && chat_store[i].status == MSG_STATUS_PENDING) { + chat_store[i].status = MSG_STATUS_NO_ACK; // acks can't match across reboot + } + } + } else { + memset(chat_store, 0, sizeof(chat_store)); + chat_head = 0; + } +#endif +} + +static void chatStoreSave() { + chat_dirty = false; +#ifdef ESP32 + File f = SPIFFS.open(CHAT_HIST_FILE, "w"); + if (!f) return; + uint32_t magic = CHAT_HIST_MAGIC; + int32_t head = chat_head; + f.write((const uint8_t *)&magic, 4); + f.write((const uint8_t *)&head, 4); + f.write((const uint8_t *)chat_store, sizeof(chat_store)); + f.close(); +#endif +} + +void chatStoreFlushLoop() { + if (chat_dirty && millis() > chat_flush_at) { + chatStoreSave(); + } +} + +void chatStoreClearThread(const uint8_t key[6]) { + for (int i = 0; i < CHAT_STORE_SIZE; i++) { + if (chat_store[i].valid && memcmp(chat_store[i].prefix, key, 6) == 0) { + chat_store[i].valid = 0; + } + } + chatStoreMarkDirty(); +} + +// --- unread counters --- +#define UNREAD_SLOTS 24 +struct UnreadSlot { uint8_t key[6]; int count; }; +static UnreadSlot unread[UNREAD_SLOTS]; + +void chatStoreUnreadBump(const uint8_t key[6]) { + int free_slot = -1; + for (int i = 0; i < UNREAD_SLOTS; i++) { + if (unread[i].count > 0 && memcmp(unread[i].key, key, 6) == 0) { + unread[i].count++; + return; + } + if (unread[i].count == 0 && free_slot < 0) free_slot = i; + } + if (free_slot >= 0) { + memcpy(unread[free_slot].key, key, 6); + unread[free_slot].count = 1; + } +} + +void chatStoreUnreadClear(const uint8_t key[6]) { + for (int i = 0; i < UNREAD_SLOTS; i++) { + if (unread[i].count > 0 && memcmp(unread[i].key, key, 6) == 0) unread[i].count = 0; + } +} + +int chatStoreUnreadGet(const uint8_t key[6]) { + for (int i = 0; i < UNREAD_SLOTS; i++) { + if (unread[i].count > 0 && memcmp(unread[i].key, key, 6) == 0) return unread[i].count; + } + return 0; +} + +int chatStoreUnreadTotal() { + int total = 0; + for (int i = 0; i < UNREAD_SLOTS; i++) total += unread[i].count; + return total; +} diff --git a/examples/companion_radio/ui-lvgl/ChatStore.h b/examples/companion_radio/ui-lvgl/ChatStore.h new file mode 100644 index 0000000000..2ae301099d --- /dev/null +++ b/examples/companion_radio/ui-lvgl/ChatStore.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +// On-device chat history, persisted to /chat_hist.bin so threads survive a +// reboot. Fixed-size ring: CHAT_STORE_SIZE messages across all threads. + +#define MSG_STATUS_NONE 0 +#define MSG_STATUS_PENDING 1 +#define MSG_STATUS_DELIVERED 2 +#define MSG_STATUS_NO_ACK 3 + +struct ChatMsg { + uint8_t prefix[6]; // contact pub_key prefix; identifies the thread + uint8_t outgoing; // 1 = sent from this device + uint8_t valid; + uint8_t status; // MSG_STATUS_* + uint32_t timestamp; + uint32_t expected_ack; // ack CRC we're waiting for (pending only) + uint32_t timeout_at; // millis deadline for pending -> no_ack + char text[120]; +}; + +#ifndef CHAT_STORE_SIZE + #define CHAT_STORE_SIZE 40 +#endif + +// synthetic 6-byte thread key for channel slot idx (vs contact pub_key prefix) +void makeChannelKey(int idx, uint8_t key[6]); +bool isChannelKey(const uint8_t key[6], int* idx_out); + +ChatMsg* chatStorePush(const uint8_t* pub_key, bool outgoing, uint32_t timestamp, const char* text); +ChatMsg* chatStoreGet(const uint8_t* pub_key, int k); // k-th newest in thread; NULL when exhausted +void chatStoreLoad(); +void chatStoreFlushLoop(); // call from loop(); debounced save when dirty +void chatStoreMarkDirty(); +bool chatStoreAck(uint32_t ack_crc); // mark matching pending msg delivered + +// distinct thread keys, newest activity first; returns count (<= max) +int chatStoreThreads(uint8_t keys[][6], int max); + +void chatStoreClearThread(const uint8_t key[6]); // delete a thread's messages + +// per-thread unread counters (RAM only) +void chatStoreUnreadBump(const uint8_t key[6]); +void chatStoreUnreadClear(const uint8_t key[6]); +int chatStoreUnreadGet(const uint8_t key[6]); +int chatStoreUnreadTotal(); diff --git a/examples/companion_radio/ui-lvgl/MapView.cpp b/examples/companion_radio/ui-lvgl/MapView.cpp new file mode 100644 index 0000000000..1bcd7d168f --- /dev/null +++ b/examples/companion_radio/ui-lvgl/MapView.cpp @@ -0,0 +1,437 @@ +#include "MapView.h" + +#include +#include +#include +#include +#include +#include +#include "../MyMesh.h" +#include "target.h" +#include "UITask.h" + +// Wio Tracker L2 Pro SDIO 1-bit wiring; TF power rail is raised by board init +#define SD_PIN_CLK 2 +#define SD_PIN_CMD 3 +#define SD_PIN_D0 1 + +#define TILE_PX 256 +#define GRID_COLS 3 +#define GRID_ROWS 2 +#define MAP_MIN_Z 5 +#define MAP_MAX_Z 17 + +static SensorManager* map_sensors = NULL; +static UITask* map_task = NULL; +static lv_obj_t* map_canvas = NULL; +static lv_obj_t* tiles[GRID_COLS * GRID_ROWS]; +static lv_obj_t* marker = NULL; +static lv_obj_t* status_lbl = NULL; +static lv_obj_t* zoom_lbl = NULL; +static bool sd_ok = false; + +#define MAX_MAP_NODES 16 +static lv_obj_t* node_marks[MAX_MAP_NODES]; +static lv_obj_t* node_labels[MAX_MAP_NODES]; +static int node_contact_idx[MAX_MAP_NODES]; // contact index behind each marker + +static void node_mark_cb(lv_event_t* e) { + int slot = (int)(intptr_t) lv_event_get_user_data(e); + if (slot >= 0 && slot < MAX_MAP_NODES && map_task != NULL) { + map_task->openContactDetail(node_contact_idx[slot]); + } +} + +// view center in tile-float coordinates at the current zoom +static int map_z = 13; +static double center_fx, center_fy; +static bool center_initialized = false; + +static void lonlatToTileF(double lon, double lat, int z, double* fx, double* fy) { + double n = (double)(1 << z); + *fx = (lon + 180.0) / 360.0 * n; + double lat_r = lat * M_PI / 180.0; + *fy = (1.0 - asinh(tan(lat_r)) / M_PI) / 2.0 * n; +} + +static void savedPosLoad(double* lat, double* lon) { + File f = SPIFFS.open("/lastpos", "r"); + if (!f) return; + double a = f.parseFloat(); + double b = f.parseFloat(); + f.close(); + if (a != 0 || b != 0) { *lat = a; *lon = b; } +} +static void savedPosStore(double lat, double lon) { + static unsigned long last_save = 0; + if (last_save != 0 && millis() - last_save < 600000) return; // at most every 10 min + last_save = millis(); + File f = SPIFFS.open("/lastpos", "w"); + if (f) { f.printf("%.6f %.6f\n", lat, lon); f.close(); } +} + +static void mapApplyDim(bool dim); + +static void mapInitCenter() { + if (center_initialized) return; + double lat = 0, lon = 0; + NodePrefs* np = map_task != NULL ? map_task->nodePrefs() : NULL; + if (np != NULL && (np->node_lat != 0 || np->node_lon != 0)) { lat = np->node_lat; lon = np->node_lon; } + else savedPosLoad(&lat, &lon); + if (lat == 0 && lon == 0) { + map_z = 5; // nothing known yet: wide view until GPS or the phone sets a position + lonlatToTileF(0.0, 20.0, map_z, ¢er_fx, ¢er_fy); + } else { + lonlatToTileF(lon, lat, map_z, ¢er_fx, ¢er_fy); + } + center_initialized = true; +} + +// not exported through lvgl.h in this configuration +extern "C" void lv_image_cache_drop(const void* src); + +#define SYMBOL_SUN "\xEF\x86\x85" /* U+F185 */ +#define SYMBOL_MOON "\xEF\x86\x86" /* U+F186 */ + +static bool map_night = false; // defined here: the pak loader needs it +static bool dark_set_ok = false; // a dark set was seen on the card +static bool day_set_ok = false; // a day set was seen on the card +static int dark_hits = 0; // tiles served from maps_dark this refresh + +// Packed tiles: maps/{z}/{x}.pak = 'TPK1' | y0 | y1 | offsets[n+1] | PNGs. +// One file per tile column, since a FAT card copies millions of small files +// far slower than a few thousand large ones. Loose z/x/y.png still works. +static uint8_t* pak_buf[GRID_COLS * GRID_ROWS]; +static size_t pak_cap[GRID_COLS * GRID_ROWS]; +static lv_image_dsc_t pak_dsc[GRID_COLS * GRID_ROWS]; +static int pak_key_z[GRID_COLS * GRID_ROWS]; +static int pak_key_x[GRID_COLS * GRID_ROWS]; +static int pak_key_y[GRID_COLS * GRID_ROWS]; + +static bool loadPakTile(lv_obj_t* img, int z, int tx, int ty, int slot) { + int zkey = z | (map_night ? 0x100 : 0); + if (pak_key_z[slot] == zkey && pak_key_x[slot] == tx && pak_key_y[slot] == ty) { + return true; // this slot already shows exactly this tile (and this set) + } + const char* first = map_night ? "maps_dark" : "maps"; + const char* second = map_night ? "maps" : "maps_dark"; + bool from_dark = map_night; + char path[48]; + snprintf(path, sizeof(path), "/sdcard/%s/%d/%d.pak", first, z, tx); + FILE* f = fopen(path, "rb"); + if (f == NULL) { + snprintf(path, sizeof(path), "/sdcard/%s/%d/%d.pak", second, z, tx); + f = fopen(path, "rb"); + from_dark = !map_night; + } + if (f == NULL) return false; + uint8_t hdr[12]; + uint32_t y0, y1; + if (fread(hdr, 1, 12, f) != 12 || memcmp(hdr, "TPK1", 4) != 0) { fclose(f); return false; } + memcpy(&y0, hdr + 4, 4); + memcpy(&y1, hdr + 8, 4); + if (ty < (int) y0 || ty > (int) y1) { fclose(f); return false; } + uint32_t off[2]; + if (fseek(f, 12 + 4 * (ty - (int) y0), SEEK_SET) != 0 || fread(off, 4, 2, f) != 2) { fclose(f); return false; } + uint32_t len = off[1] - off[0]; + if (len == 0 || len > 262144) { fclose(f); return false; } + if (pak_cap[slot] < len) { + uint8_t* nb = (uint8_t*) heap_caps_realloc(pak_buf[slot], len, MALLOC_CAP_SPIRAM); + if (nb == NULL) { fclose(f); return false; } + pak_buf[slot] = nb; + pak_cap[slot] = len; + } + bool ok = fseek(f, off[0], SEEK_SET) == 0 && fread(pak_buf[slot], 1, len, f) == len; + fclose(f); + if (!ok) return false; + + lv_image_cache_drop(&pak_dsc[slot]); // same dsc pointer, new bytes + memset(&pak_dsc[slot], 0, sizeof(pak_dsc[slot])); + pak_dsc[slot].header.magic = LV_IMAGE_HEADER_MAGIC; + pak_dsc[slot].header.cf = LV_COLOR_FORMAT_RAW; + pak_dsc[slot].header.w = TILE_PX; + pak_dsc[slot].header.h = TILE_PX; + pak_dsc[slot].data_size = len; + pak_dsc[slot].data = pak_buf[slot]; + lv_image_set_src(img, &pak_dsc[slot]); + lv_obj_invalidate(img); + pak_key_z[slot] = zkey; pak_key_x[slot] = tx; pak_key_y[slot] = ty; + if (from_dark) { dark_hits++; dark_set_ok = true; } + return true; +} + +static bool sdMount() { + if (sd_ok) return true; + SD_MMC.setPins(SD_PIN_CLK, SD_PIN_CMD, SD_PIN_D0); + sd_ok = SD_MMC.begin("/sdcard", true /* 1-bit mode */); + MESH_DEBUG_PRINTLN("map: SD %s", sd_ok ? "mounted" : "mount FAILED"); + if (sd_ok) { + File d = SD_MMC.open("/maps"); + day_set_ok = d && d.isDirectory(); + MESH_DEBUG_PRINTLN("map: tile store %s", day_set_ok ? "present" : "missing"); + if (d) d.close(); + File dd = SD_MMC.open("/maps_dark"); + dark_set_ok = dd && dd.isDirectory(); + if (dd) dd.close(); + MESH_DEBUG_PRINTLN("map: dark set %s", dark_set_ok ? "present" : "not on card"); + if (dark_set_ok && !day_set_ok) map_night = true; // dark-only card: start dark + } + return sd_ok; +} + +void mapViewRefresh() { + if (map_canvas == NULL) return; + mapInitCenter(); + dark_hits = 0; + + int cw = lv_obj_get_width(map_canvas); + int chh = lv_obj_get_height(map_canvas); + if (cw <= 0) { cw = 320; chh = 150; } // pre-layout fallback + + int n = 1 << map_z; + int base_tx = (int)floor(center_fx) - 1; + int base_ty = (int)floor(center_fy) - 1; + + char src[48]; + for (int j = 0; j < GRID_ROWS; j++) { + for (int i = 0; i < GRID_COLS; i++) { + lv_obj_t* img = tiles[j * GRID_COLS + i]; + int tx = base_tx + i; + int ty = base_ty + j; + int px = (int)((tx - center_fx) * TILE_PX) + cw / 2; + int py = (int)((ty - center_fy) * TILE_PX) + chh / 2; + lv_obj_set_pos(img, px, py); + int slot = j * GRID_COLS + i; + if (sd_ok && tx >= 0 && ty >= 0 && tx < n && ty < n) { + if (!loadPakTile(img, map_z, tx, ty, slot)) { + // loose-tile fallback; the leading slash matters after "/sdcard" + pak_key_z[slot] = -1; + snprintf(src, sizeof(src), "A:/%s/%d/%d/%d.png", + map_night ? "maps_dark" : "maps", map_z, tx, ty); + lv_image_set_src(img, src); + } + lv_obj_remove_flag(img, LV_OBJ_FLAG_HIDDEN); + } else { + pak_key_z[slot] = -1; + lv_obj_add_flag(img, LV_OBJ_FLAG_HIDDEN); + } + } + } + + mapApplyDim(map_night && dark_hits == 0); + lv_label_set_text_fmt(zoom_lbl, map_night ? "z%d dark" : "z%d", map_z); + if (!sd_ok) { + lv_label_set_text(status_lbl, "No SD card / tiles"); + lv_obj_remove_flag(status_lbl, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(status_lbl, LV_OBJ_FLAG_HIDDEN); + } + + int used = 0; + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots() && used < MAX_MAP_NODES; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] == 0 || (c.gps_lat == 0 && c.gps_lon == 0)) continue; + double nfx, nfy; + lonlatToTileF(c.gps_lon / 1000000.0, c.gps_lat / 1000000.0, map_z, &nfx, &nfy); + int px = (int)((nfx - center_fx) * TILE_PX) + cw / 2; + int py = (int)((nfy - center_fy) * TILE_PX) + chh / 2; + if (px < -20 || py < -20 || px > cw + 20 || py > chh + 20) continue; + node_contact_idx[used] = idx; + lv_obj_set_pos(node_marks[used], px - 5, py - 5); + lv_obj_remove_flag(node_marks[used], LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(node_labels[used], c.name); + lv_obj_set_pos(node_labels[used], px + 7, py - 6); + lv_obj_remove_flag(node_labels[used], LV_OBJ_FLAG_HIDDEN); + used++; + } + for (int i = used; i < MAX_MAP_NODES; i++) { + lv_obj_add_flag(node_marks[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(node_labels[i], LV_OBJ_FLAG_HIDDEN); + } + + bool marker_shown = false; + if (map_sensors != NULL) { + LocationProvider* nmea = map_sensors->getLocationProvider(); + if (nmea != NULL && nmea->isValid()) { + savedPosStore(nmea->getLatitude() / 1000000.0, nmea->getLongitude() / 1000000.0); + double gfx, gfy; + lonlatToTileF(nmea->getLongitude() / 1000000.0, nmea->getLatitude() / 1000000.0, + map_z, &gfx, &gfy); + int mx = (int)((gfx - center_fx) * TILE_PX) + cw / 2; + int my = (int)((gfy - center_fy) * TILE_PX) + chh / 2; + if (mx >= 0 && my >= 0 && mx < cw && my < chh) { + lv_obj_set_pos(marker, mx - 6, my - 6); + lv_obj_remove_flag(marker, LV_OBJ_FLAG_HIDDEN); + marker_shown = true; + } + } + } + if (!marker_shown) lv_obj_add_flag(marker, LV_OBJ_FLAG_HIDDEN); +} + +static void map_press_cb(lv_event_t* e) { + lv_indev_t* indev = lv_indev_active(); + if (indev == NULL) return; + lv_point_t vect; + lv_indev_get_vect(indev, &vect); + if (vect.x == 0 && vect.y == 0) return; + center_fx -= (double)vect.x / TILE_PX; + center_fy -= (double)vect.y / TILE_PX; + double n = (double)(1 << map_z); + if (center_fx < 0) center_fx = 0; + if (center_fy < 0) center_fy = 0; + if (center_fx > n) center_fx = n; + if (center_fy > n) center_fy = n; + mapViewRefresh(); +} + +static void zoom_cb(lv_event_t* e) { + int dir = (int)(intptr_t) lv_event_get_user_data(e); + int new_z = map_z + dir; + if (new_z < MAP_MIN_Z || new_z > MAP_MAX_Z) return; + double scale = dir > 0 ? 2.0 : 0.5; + center_fx *= scale; + center_fy *= scale; + map_z = new_z; + mapViewRefresh(); +} + +static void locate_cb(lv_event_t* e) { + if (map_sensors == NULL) return; + LocationProvider* nmea = map_sensors->getLocationProvider(); + if (nmea == NULL || !nmea->isValid()) return; + lonlatToTileF(nmea->getLongitude() / 1000000.0, nmea->getLatitude() / 1000000.0, + map_z, ¢er_fx, ¢er_fy); + mapViewRefresh(); +} + +// with a dark set on the card the toggle swaps sets, otherwise it recolors +static lv_obj_t* night_btn_lbl = NULL; + +// Dimming is only a fallback for cards without a dark set, so it is applied +// after a refresh, once it is known whether any tile came from maps_dark. +static void mapApplyDim(bool dim) { + for (int k = 0; k < GRID_COLS * GRID_ROWS; k++) { + lv_obj_set_style_image_recolor(tiles[k], lv_color_hex(0x0A1428), 0); + lv_obj_set_style_image_recolor_opa(tiles[k], dim ? LV_OPA_60 : LV_OPA_TRANSP, 0); + } +} + +static void mapApplyNight() { + for (int k = 0; k < GRID_COLS * GRID_ROWS; k++) pak_key_z[k] = -1; // reload + if (night_btn_lbl != NULL) lv_label_set_text(night_btn_lbl, map_night ? SYMBOL_SUN : SYMBOL_MOON); + mapViewRefresh(); +} + +static void mapNightLoad() { + File f = SPIFFS.open("/mapnight", "r"); + if (f) { map_night = f.parseInt() != 0; f.close(); } +} + +static void night_cb(lv_event_t* e) { + map_night = !map_night; + File f = SPIFFS.open("/mapnight", "w"); + if (f) { f.print(map_night ? 1 : 0); f.close(); } + mapApplyNight(); + if (map_task != NULL) { + if (!map_night) map_task->showToast("Day tiles"); + else if (dark_hits > 0) map_task->showToast("Dark tiles"); + else map_task->showToast("No dark tiles here - dimming instead"); + } +} + +static lv_obj_t* makeMapBtn(lv_obj_t* parent, const char* txt, lv_event_cb_t cb, void* ud, + lv_align_t align, int x_ofs, int y_ofs) { + lv_obj_t* btn = lv_button_create(parent); + lv_obj_set_size(btn, 36, 30); + lv_obj_align(btn, align, x_ofs, y_ofs); + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, ud); + lv_obj_t* lbl = lv_label_create(btn); + lv_label_set_text(lbl, txt); + lv_obj_center(lbl); + return btn; +} + +void mapViewBuild(lv_obj_t* parent, SensorManager* sensors, UITask* task) { + map_sensors = sensors; + map_task = task; + sdMount(); + + lv_obj_set_style_pad_all(parent, 0, 0); + map_canvas = lv_obj_create(parent); + lv_obj_set_size(map_canvas, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(map_canvas, lv_color_hex(0x101820), 0); + lv_obj_set_style_border_width(map_canvas, 0, 0); + lv_obj_set_style_radius(map_canvas, 0, 0); + lv_obj_set_style_pad_all(map_canvas, 0, 0); + lv_obj_remove_flag(map_canvas, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(map_canvas, LV_OBJ_FLAG_SCROLL_CHAIN); // drags pan the map, never the tabview + lv_obj_add_flag(map_canvas, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(map_canvas, map_press_cb, LV_EVENT_PRESSING, NULL); + lv_obj_set_style_clip_corner(map_canvas, true, 0); + + for (int k = 0; k < GRID_COLS * GRID_ROWS; k++) { + pak_key_z[k] = pak_key_x[k] = pak_key_y[k] = -1; + tiles[k] = lv_image_create(map_canvas); + lv_obj_set_size(tiles[k], TILE_PX, TILE_PX); + lv_obj_remove_flag(tiles[k], LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(tiles[k], LV_OBJ_FLAG_HIDDEN); + } + + for (int i = 0; i < MAX_MAP_NODES; i++) { + node_marks[i] = lv_obj_create(map_canvas); + lv_obj_set_size(node_marks[i], 10, 10); + lv_obj_set_style_radius(node_marks[i], LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(node_marks[i], lv_color_hex(0xFFA030), 0); // orange = other nodes + lv_obj_set_style_border_color(node_marks[i], lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_border_width(node_marks[i], 1, 0); + lv_obj_add_flag(node_marks[i], LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(node_marks[i], 8); // fingertip-sized hit box + lv_obj_add_event_cb(node_marks[i], node_mark_cb, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)i); + lv_obj_add_flag(node_marks[i], LV_OBJ_FLAG_HIDDEN); + + node_labels[i] = lv_label_create(map_canvas); + lv_label_set_text(node_labels[i], ""); + lv_label_set_long_mode(node_labels[i], LV_LABEL_LONG_DOT); + lv_obj_set_width(node_labels[i], 90); + lv_obj_set_style_text_color(node_labels[i], lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_color(node_labels[i], lv_color_hex(0x101820), 0); + lv_obj_set_style_bg_opa(node_labels[i], LV_OPA_70, 0); + lv_obj_set_style_radius(node_labels[i], 4, 0); + lv_obj_set_style_pad_hor(node_labels[i], 3, 0); + lv_obj_set_style_pad_ver(node_labels[i], 1, 0); + lv_obj_add_flag(node_labels[i], LV_OBJ_FLAG_HIDDEN); + } + + marker = lv_obj_create(map_canvas); + lv_obj_set_size(marker, 12, 12); + lv_obj_set_style_radius(marker, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(marker, lv_color_hex(0x58B4FF), 0); + lv_obj_set_style_border_color(marker, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_border_width(marker, 2, 0); + lv_obj_remove_flag(marker, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(marker, LV_OBJ_FLAG_HIDDEN); + + status_lbl = lv_label_create(map_canvas); + lv_label_set_text(status_lbl, ""); + lv_obj_set_style_text_color(status_lbl, lv_color_hex(0x8FA3BF), 0); + lv_obj_align(status_lbl, LV_ALIGN_CENTER, 0, 0); + + zoom_lbl = lv_label_create(map_canvas); + lv_label_set_text(zoom_lbl, ""); + lv_obj_set_style_text_color(zoom_lbl, lv_color_hex(0xE8ECF2), 0); + lv_obj_align(zoom_lbl, LV_ALIGN_TOP_LEFT, 4, 4); + + makeMapBtn(map_canvas, LV_SYMBOL_PLUS, zoom_cb, (void*)(intptr_t)+1, LV_ALIGN_BOTTOM_RIGHT, -4, -40); + makeMapBtn(map_canvas, LV_SYMBOL_MINUS, zoom_cb, (void*)(intptr_t)-1, LV_ALIGN_BOTTOM_RIGHT, -4, -4); + makeMapBtn(map_canvas, LV_SYMBOL_GPS, locate_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 4, -4); + lv_obj_t* nb = makeMapBtn(map_canvas, LV_SYMBOL_EYE_OPEN, night_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 4, -40); + night_btn_lbl = lv_obj_get_child(nb, 0); + mapNightLoad(); + mapApplyNight(); + + mapViewRefresh(); +} + +bool mapViewSdOk() { return sd_ok; } diff --git a/examples/companion_radio/ui-lvgl/MapView.h b/examples/companion_radio/ui-lvgl/MapView.h new file mode 100644 index 0000000000..e9bfb6bdc5 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/MapView.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +// Slippy-map viewer: renders OSM raster tiles from the microSD card +// (/sdcard/maps/{z}/{x}/{y}.png via LVGL's POSIX FS + lodepng decoder), +// with drag panning, zoom buttons, and an own-position marker from GPS. + +class UITask; +void mapViewBuild(lv_obj_t* parent, SensorManager* sensors, UITask* task); +void mapViewRefresh(); // reposition/reload tiles + marker +bool mapViewSdOk(); diff --git a/examples/companion_radio/ui-lvgl/SettingsUI.cpp b/examples/companion_radio/ui-lvgl/SettingsUI.cpp new file mode 100644 index 0000000000..9e39174819 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/SettingsUI.cpp @@ -0,0 +1,678 @@ +#include "SettingsUI.h" + +#include +#include +#include "UITask.h" +#include "../MyMesh.h" +#include "target.h" +#include +#include + +#define S_COL_CARD 0x16233B +#define S_COL_ACCENT 0x58B4FF +#define S_COL_MUTED 0x8FA3BF +#define S_KB_H 160 + +#define TX_MIN 2 +#define TX_MAX 22 + +static UITask* s_task = NULL; + +static lv_obj_t* name_val; +static lv_obj_t* radio_val; +static lv_obj_t* tx_val; +static lv_obj_t* pin_val; +static lv_obj_t* tz_val; +static int tz_offset = 0; // UTC offset hours (persisted; wizard suggests from GPS) + +// single-field editor overlay (name / BLE pin) +enum EditMode { EDIT_NAME, EDIT_PIN }; +static EditMode edit_mode; +static lv_obj_t* edit_scr; +static lv_obj_t* edit_title; +static lv_obj_t* edit_ta; +static lv_obj_t* edit_kb; + +// radio params overlay +static lv_obj_t* radio_scr; +static lv_obj_t* radio_ta[4]; // freq MHz, BW kHz, SF, CR +static lv_obj_t* radio_kb; +static lv_obj_t* radio_apply_btn; +static lv_obj_t* radio_apply_lbl; +static lv_obj_t* radio_preset_dd; +static lv_obj_t* radio_repeat_sw; +static lv_obj_t* tx_minus_btn; +static lv_obj_t* tx_plus_btn; +static bool radio_confirm_armed = false; + +// Regional presets. Client repeat mode only runs on the repeat frequencies +// MyMesh validates, so each preset carries the one for its band. +struct RadioPreset { + const char* label; // dropdown text + const char* short_label; // Settings row text + float freq; float bw; uint8_t sf; uint8_t cr; + float repeat_freq; +}; +static const RadioPreset RADIO_PRESETS[] = { + { "USA/CA narrow (rec.)", "US narrow", 910.525f, 62.5f, 7, 5, 918.000f }, + { "USA/CA classic", "US classic", 910.525f, 250.0f, 10, 5, 918.000f }, + { "EU/UK 869.525", "EU/UK", 869.525f, 250.0f, 11, 5, 869.495f }, + { "AUS/NZ 915.8", "AUS/NZ", 915.800f, 250.0f, 11, 5, 918.000f }, +}; +#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0])) + +static float repeatFreqForBand(float freq) { + if (freq < 500.0f) return 433.000f; + if (freq < 900.0f) return 869.495f; + return 918.000f; +} + +static void radioSetFreqField(float mhz) { + char buf[20]; + snprintf(buf, sizeof(buf), "%.3f", mhz); + lv_textarea_set_text(radio_ta[0], buf); +} + +static NodePrefs* prefs() { return s_task->nodePrefs(); } + +// --------------------------------------------------------------------------- +// row refresh +// --------------------------------------------------------------------------- +void settingsRefreshRows() { + if (s_task == NULL) return; + char buf[48]; + lv_label_set_text(name_val, prefs()->node_name); + const char* preset = "Custom"; + for (unsigned i = 0; i < NUM_RADIO_PRESETS; i++) { + const RadioPreset* r = &RADIO_PRESETS[i]; + if (fabsf(prefs()->freq - r->freq) < 0.001f && fabsf((float)prefs()->bw - r->bw) < 0.1f && + prefs()->sf == r->sf && prefs()->cr == r->cr) { preset = r->short_label; break; } + } + if (prefs()->isRepeatEn()) { + char rbuf[64]; + snprintf(rbuf, sizeof(rbuf), "%s + repeat", preset); // e.g. "US narrow + repeat" + lv_label_set_text(radio_val, rbuf); + } else { + lv_label_set_text(radio_val, preset); // full parameters shown in the dialog + } + snprintf(buf, sizeof(buf), "%d dBm", prefs()->tx_power_dbm); + lv_label_set_text(tx_val, buf); + if (tx_minus_btn != NULL && tx_plus_btn != NULL) { + bool at_min = prefs()->tx_power_dbm <= TX_MIN; + bool at_max = prefs()->tx_power_dbm >= TX_MAX; + if (at_min) lv_obj_add_state(tx_minus_btn, LV_STATE_DISABLED); + else lv_obj_remove_state(tx_minus_btn, LV_STATE_DISABLED); + if (at_max) lv_obj_add_state(tx_plus_btn, LV_STATE_DISABLED); + else lv_obj_remove_state(tx_plus_btn, LV_STATE_DISABLED); + } + snprintf(buf, sizeof(buf), "%u", (unsigned)prefs()->ble_pin); + lv_label_set_text(pin_val, prefs()->ble_pin ? buf : "(random)"); + snprintf(buf, sizeof(buf), "UTC%+d", tz_offset); + lv_label_set_text(tz_val, buf); +} + +// --------------------------------------------------------------------------- +// single-field editor +// --------------------------------------------------------------------------- +static void editOpen(EditMode mode) { + edit_mode = mode; + if (mode == EDIT_NAME) { + lv_label_set_text(edit_title, "Node name"); + lv_textarea_set_text(edit_ta, prefs()->node_name); + lv_keyboard_set_mode(edit_kb, LV_KEYBOARD_MODE_TEXT_LOWER); + } else { + lv_label_set_text(edit_title, "Bluetooth PIN (6 digits, next boot)"); + lv_textarea_set_text(edit_ta, ""); + lv_keyboard_set_mode(edit_kb, LV_KEYBOARD_MODE_NUMBER); + } + lv_obj_remove_flag(edit_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(edit_kb, LV_OBJ_FLAG_HIDDEN); +} + +static void editSave() { + const char* txt = lv_textarea_get_text(edit_ta); + if (edit_mode == EDIT_NAME) { + if (txt == NULL || txt[0] == 0) { + s_task->showToast("Name can't be empty"); + return; + } + StrHelper::strncpy(prefs()->node_name, txt, sizeof(prefs()->node_name)); + the_mesh.savePrefs(); + s_task->nodeNameChanged(); + s_task->showToast("Name saved"); + } else { + uint32_t pin = (uint32_t) strtoul(txt, NULL, 10); + if (pin < 100000 || pin > 999999) { + s_task->showToast("Pin must be 6 digits"); + return; + } + prefs()->ble_pin = pin; + the_mesh.savePrefs(); + s_task->showToast("Pin saved - takes effect on reboot"); + } + lv_obj_add_flag(edit_scr, LV_OBJ_FLAG_HIDDEN); + settingsRefreshRows(); +} + +static void edit_save_cb(lv_event_t* e) { editSave(); } +static void edit_cancel_cb(lv_event_t* e) { lv_obj_add_flag(edit_scr, LV_OBJ_FLAG_HIDDEN); } +static void edit_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_obj_remove_flag(edit_kb, LV_OBJ_FLAG_HIDDEN); + } else if (code == LV_EVENT_READY) { + editSave(); + } +} + +static void name_row_cb(lv_event_t* e) { editOpen(EDIT_NAME); } +static void pin_row_cb(lv_event_t* e) { editOpen(EDIT_PIN); } + +// --------------------------------------------------------------------------- +// TX power stepper (applies immediately - safe, reversible) +// --------------------------------------------------------------------------- +static void tx_step_cb(lv_event_t* e) { + int dir = (int)(intptr_t) lv_event_get_user_data(e); + int p = prefs()->tx_power_dbm + dir; + if (p < TX_MIN || p > TX_MAX) return; + prefs()->tx_power_dbm = p; + the_mesh.savePrefs(); + radio_driver.setTxPower(p); + settingsRefreshRows(); +} + +// --------------------------------------------------------------------------- +// radio params editor +// --------------------------------------------------------------------------- +static void radioDisarm() { + radio_confirm_armed = false; + lv_label_set_text(radio_apply_lbl, "Apply"); + lv_obj_set_style_bg_color(radio_apply_btn, lv_color_hex(S_COL_ACCENT), 0); +} + +static void radioOpen(lv_event_t* e) { + char buf[20]; + snprintf(buf, sizeof(buf), "%.3f", prefs()->freq); + lv_textarea_set_text(radio_ta[0], buf); + snprintf(buf, sizeof(buf), "%g", (double)prefs()->bw); + lv_textarea_set_text(radio_ta[1], buf); + snprintf(buf, sizeof(buf), "%d", prefs()->sf); + lv_textarea_set_text(radio_ta[2], buf); + snprintf(buf, sizeof(buf), "%d", prefs()->cr); + lv_textarea_set_text(radio_ta[3], buf); + lv_dropdown_set_selected(radio_preset_dd, 0); // "(presets)" placeholder + if (prefs()->isRepeatEn()) lv_obj_add_state(radio_repeat_sw, LV_STATE_CHECKED); + else lv_obj_remove_state(radio_repeat_sw, LV_STATE_CHECKED); + radioDisarm(); + lv_obj_add_flag(radio_kb, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(radio_scr, LV_OBJ_FLAG_HIDDEN); +} + +// repeat mode has its own frequency: move the field with the switch +static void radio_repeat_cb(lv_event_t* e) { + float freq = atof(lv_textarea_get_text(radio_ta[0])); + bool on = lv_obj_has_state(radio_repeat_sw, LV_STATE_CHECKED); + if (on) { + if (!the_mesh.isValidClientRepeatFreq((uint32_t)(freq * 1000.0f))) { + float rf = repeatFreqForBand(freq); + radioSetFreqField(rf); + char msg[56]; + snprintf(msg, sizeof(msg), "Repeat mode uses %.3f MHz", rf); + s_task->showToast(msg); + } + } else if (the_mesh.isValidClientRepeatFreq((uint32_t)(freq * 1000.0f))) { + for (unsigned i = 0; i < NUM_RADIO_PRESETS; i++) { + if (fabsf(RADIO_PRESETS[i].repeat_freq - freq) < 0.001f) { + radioSetFreqField(RADIO_PRESETS[i].freq); + break; + } + } + } + radioDisarm(); +} + +static void radio_preset_cb(lv_event_t* e) { + int sel = (int) lv_dropdown_get_selected(radio_preset_dd); + if (sel < 1 || sel > (int)NUM_RADIO_PRESETS) return; // index 0 = placeholder + const RadioPreset* p = &RADIO_PRESETS[sel - 1]; + char buf[20]; + radioSetFreqField(lv_obj_has_state(radio_repeat_sw, LV_STATE_CHECKED) ? p->repeat_freq : p->freq); + snprintf(buf, sizeof(buf), "%g", (double)p->bw); + lv_textarea_set_text(radio_ta[1], buf); + snprintf(buf, sizeof(buf), "%d", p->sf); + lv_textarea_set_text(radio_ta[2], buf); + snprintf(buf, sizeof(buf), "%d", p->cr); + lv_textarea_set_text(radio_ta[3], buf); + radioDisarm(); +} + +static void radio_apply_cb(lv_event_t* e) { + float freq = atof(lv_textarea_get_text(radio_ta[0])); + float bw = atof(lv_textarea_get_text(radio_ta[1])); + int sf = atoi(lv_textarea_get_text(radio_ta[2])); + int cr = atoi(lv_textarea_get_text(radio_ta[3])); + + // same bounds the phone command path enforces (freq narrowed to SX1262 range) + if (freq < 400.0f || freq > 960.0f || bw < 7.0f || bw > 500.0f || + sf < 5 || sf > 12 || cr < 5 || cr > 8) { + s_task->showToast("Out of range - check values"); + radioDisarm(); + return; + } + + bool repeat_en = lv_obj_has_state(radio_repeat_sw, LV_STATE_CHECKED); + if (repeat_en && !the_mesh.isValidClientRepeatFreq((uint32_t)(freq * 1000.0f))) { + s_task->showToast("Repeat mode needs 433.000, 869.495 or 918.000 MHz"); + radioDisarm(); + return; + } + + if (!radio_confirm_armed) { // two-tap confirm: mismatched nodes fall off-mesh + radio_confirm_armed = true; + lv_label_set_text(radio_apply_lbl, "CONFIRM?"); + lv_obj_set_style_bg_color(radio_apply_btn, lv_color_hex(0xFFA030), 0); + return; + } + + prefs()->freq = freq; + prefs()->bw = bw; + prefs()->sf = sf; + prefs()->cr = cr; + prefs()->setRepeatEn(repeat_en); + the_mesh.savePrefs(); + radio_driver.setParams(prefs()->freq, prefs()->bw, prefs()->sf, prefs()->cr); + + lv_obj_add_flag(radio_scr, LV_OBJ_FLAG_HIDDEN); + settingsRefreshRows(); + s_task->refreshNodeTab(); + s_task->showToast("Radio settings applied"); +} + +static void radio_cancel_cb(lv_event_t* e) { lv_obj_add_flag(radio_scr, LV_OBJ_FLAG_HIDDEN); } + +static void radio_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + lv_obj_t* ta = (lv_obj_t*) lv_event_get_target(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_keyboard_set_textarea(radio_kb, ta); + lv_obj_remove_flag(radio_kb, LV_OBJ_FLAG_HIDDEN); + radioDisarm(); + } else if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) { + lv_obj_add_flag(radio_kb, LV_OBJ_FLAG_HIDDEN); + } +} + +// --------------------------------------------------------------------------- +// timezone offset (persisted in its own SPIFFS file) +// --------------------------------------------------------------------------- +int settingsTzOffset() { return tz_offset; } + +static void tzLoad() { + File f = SPIFFS.open("/tzoff", "r"); + if (f) { + int8_t v = 0; + if (f.read((uint8_t*)&v, 1) == 1 && v >= -12 && v <= 14) tz_offset = v; + f.close(); + } +} + +static void tzSave() { + File f = SPIFFS.open("/tzoff", "w"); + if (f) { + int8_t v = (int8_t)tz_offset; + f.write((uint8_t*)&v, 1); + f.close(); + } +} + +void settingsSetTzOffset(int hours) { + if (hours < -12 || hours > 14) return; + tz_offset = hours; + tzSave(); + settingsRefreshRows(); +} + +int settingsPresetCount() { return (int) NUM_RADIO_PRESETS; } +const char* settingsPresetLabel(int idx) { + return (idx >= 0 && idx < (int) NUM_RADIO_PRESETS) ? RADIO_PRESETS[idx].label : ""; +} +void settingsApplyPreset(int idx) { + if (idx < 0 || idx >= (int) NUM_RADIO_PRESETS) return; + const RadioPreset* r = &RADIO_PRESETS[idx]; + prefs()->freq = r->freq; + prefs()->bw = r->bw; + prefs()->sf = r->sf; + prefs()->cr = r->cr; + the_mesh.savePrefs(); + radio_driver.setParams(prefs()->freq, prefs()->bw, prefs()->sf, prefs()->cr); + settingsRefreshRows(); +} + +static void tz_step_cb(lv_event_t* e) { + int dir = (int)(intptr_t) lv_event_get_user_data(e); + int v = tz_offset + dir; + if (v < -12 || v > 14) return; + tz_offset = v; + tzSave(); + settingsRefreshRows(); +} + +// --------------------------------------------------------------------------- +// node backup / restore / factory reset +// --------------------------------------------------------------------------- +#define BACKUP_DIR "/l2_backup" +static bool restore_armed = false; +static bool factory_armed = false; +static lv_obj_t* restore_lbl; +static lv_obj_t* factory_lbl; + +static bool sdReady() { return SD_MMC.cardType() != CARD_NONE && SD_MMC.cardType() != CARD_UNKNOWN; } + +static int copyAll(fs::FS& src_fs, const char* src_dir, fs::FS& dst_fs, const char* dst_dir) { + File root = src_fs.open(src_dir); + if (!root || !root.isDirectory()) return -1; + int copied = 0; + File f = root.openNextFile(); + while (f) { + if (!f.isDirectory()) { + const char* name = strrchr(f.name(), '/'); + name = name ? name + 1 : f.name(); + char dst_path[96]; + snprintf(dst_path, sizeof(dst_path), "%s/%s", dst_dir, name); + File out = dst_fs.open(dst_path, "w"); + if (out) { + uint8_t buf[512]; + int n; + while ((n = f.read(buf, sizeof(buf))) > 0) out.write(buf, n); + out.close(); + copied++; + } + } + f = root.openNextFile(); + } + return copied; +} + +static void backup_cb(lv_event_t* e) { + if (!sdReady()) { s_task->showToast("No SD card"); return; } + SD_MMC.mkdir(BACKUP_DIR); + int n = copyAll(SPIFFS, "/", SD_MMC, BACKUP_DIR); + char buf[48]; + snprintf(buf, sizeof(buf), n >= 0 ? "Backed up %d files to SD" : "Backup failed", n); + s_task->showToast(buf); +} + +static void restore_cb(lv_event_t* e) { + if (!sdReady()) { s_task->showToast("No SD card"); return; } + if (!restore_armed) { + restore_armed = true; + lv_label_set_text(restore_lbl, "SURE? (overwrites node)"); + return; + } + int n = copyAll(SD_MMC, BACKUP_DIR, SPIFFS, ""); + if (n > 0) { + s_task->showToast("Restored - rebooting"); + lv_refr_now(NULL); + delay(1500); + s_task->shutdown(true); + } else { + s_task->showToast("No backup found on SD"); + restore_armed = false; + lv_label_set_text(restore_lbl, LV_SYMBOL_DOWNLOAD " Restore from SD"); + } +} + +static void factory_cb(lv_event_t* e) { + if (!factory_armed) { + factory_armed = true; + lv_label_set_text(factory_lbl, "SURE? (wipes identity)"); + return; + } + SPIFFS.format(); + s_task->showToast("Factory reset - rebooting"); + lv_refr_now(NULL); + delay(1500); + s_task->shutdown(true); +} + +// --------------------------------------------------------------------------- +// builders +// --------------------------------------------------------------------------- +static lv_obj_t* makeRow(lv_obj_t* parent, const char* title, lv_event_cb_t cb, lv_obj_t** val_out) { + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(S_COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + if (cb != NULL) { + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, NULL); + } + lv_obj_t* lbl = lv_label_create(row); + lv_label_set_text(lbl, title); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* val = lv_label_create(row); + lv_label_set_text(val, ""); + lv_obj_set_style_text_color(val, lv_color_hex(S_COL_MUTED), 0); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + // bounded so long values elide instead of running into the title + lv_obj_set_width(val, 168); + lv_label_set_long_mode(val, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(val, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(val, LV_ALIGN_RIGHT_MID, -4, 0); + *val_out = val; + return row; +} + +static lv_obj_t* makeDialog(const char* none) { + lv_obj_t* scr = lv_obj_create(lv_layer_top()); + lv_obj_set_size(scr, 320, 240); + lv_obj_set_style_bg_color(scr, lv_color_hex(0x0A1020), 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(scr, 0, 0); + lv_obj_set_style_radius(scr, 0, 0); + lv_obj_set_style_pad_all(scr, 8, 0); + lv_obj_add_flag(scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + return scr; +} + +static void styleTa(lv_obj_t* ta) { + lv_obj_set_height(ta, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(ta, 5, 0); + lv_obj_set_scroll_dir(ta, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_opa(ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); +} + +static lv_obj_t* makeBtn(lv_obj_t* parent, const char* txt, lv_event_cb_t cb, void* ud, + lv_align_t align, int xo, int yo, int w) { + lv_obj_t* btn = lv_button_create(parent); + lv_obj_set_size(btn, w, 32); + lv_obj_align(btn, align, xo, yo); + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, ud); + lv_obj_t* lbl = lv_label_create(btn); + lv_label_set_text(lbl, txt); + lv_obj_center(lbl); + return btn; +} + +void settingsBuildExtras(lv_obj_t* parent, UITask* task) { + s_task = task; + + makeRow(parent, LV_SYMBOL_EDIT " Node name", name_row_cb, &name_val); + makeRow(parent, LV_SYMBOL_WIFI " Radio settings", radioOpen, &radio_val); + + lv_obj_t* tx_row = lv_obj_create(parent); + lv_obj_set_size(tx_row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(tx_row, lv_color_hex(S_COL_CARD), 0); + lv_obj_set_style_border_width(tx_row, 0, 0); + lv_obj_remove_flag(tx_row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* tx_lbl = lv_label_create(tx_row); + lv_label_set_text(tx_lbl, LV_SYMBOL_CHARGE " TX power"); + lv_obj_align(tx_lbl, LV_ALIGN_LEFT_MID, 4, 0); + tx_minus_btn = lv_button_create(tx_row); + lv_obj_set_size(tx_minus_btn, 30, 26); + lv_obj_align(tx_minus_btn, LV_ALIGN_RIGHT_MID, -108, 0); + lv_obj_add_event_cb(tx_minus_btn, tx_step_cb, LV_EVENT_CLICKED, (void*)(intptr_t)-1); + lv_obj_t* ml = lv_label_create(tx_minus_btn); lv_label_set_text(ml, LV_SYMBOL_MINUS); lv_obj_center(ml); + tx_val = lv_label_create(tx_row); + lv_label_set_text(tx_val, ""); + lv_obj_set_width(tx_val, 62); + lv_obj_set_style_text_align(tx_val, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(tx_val, LV_ALIGN_RIGHT_MID, -40, 0); + tx_plus_btn = lv_button_create(tx_row); + lv_obj_set_size(tx_plus_btn, 30, 26); + lv_obj_align(tx_plus_btn, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_add_event_cb(tx_plus_btn, tx_step_cb, LV_EVENT_CLICKED, (void*)(intptr_t)+1); + lv_obj_t* pl = lv_label_create(tx_plus_btn); lv_label_set_text(pl, LV_SYMBOL_PLUS); lv_obj_center(pl); + +#ifndef STANDALONE_NO_BT + makeRow(parent, LV_SYMBOL_BLUETOOTH " Bluetooth PIN", pin_row_cb, &pin_val); +#else + pin_val = lv_label_create(parent); // keep the refresh path valid; never shown + lv_obj_add_flag(pin_val, LV_OBJ_FLAG_HIDDEN); +#endif + + tzLoad(); + lv_obj_t* tz_row = lv_obj_create(parent); + lv_obj_set_size(tz_row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(tz_row, lv_color_hex(S_COL_CARD), 0); + lv_obj_set_style_border_width(tz_row, 0, 0); + lv_obj_remove_flag(tz_row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* tz_lbl = lv_label_create(tz_row); + lv_label_set_text(tz_lbl, LV_SYMBOL_REFRESH " Timezone"); + lv_obj_align(tz_lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* tzm = lv_button_create(tz_row); + lv_obj_set_size(tzm, 30, 26); + lv_obj_align(tzm, LV_ALIGN_RIGHT_MID, -100, 0); + lv_obj_add_event_cb(tzm, tz_step_cb, LV_EVENT_CLICKED, (void*)(intptr_t)-1); + lv_obj_t* tzml = lv_label_create(tzm); lv_label_set_text(tzml, LV_SYMBOL_MINUS); lv_obj_center(tzml); + tz_val = lv_label_create(tz_row); + lv_label_set_text(tz_val, ""); + lv_obj_align(tz_val, LV_ALIGN_RIGHT_MID, -44, 0); + lv_obj_t* tzp = lv_button_create(tz_row); + lv_obj_set_size(tzp, 30, 26); + lv_obj_align(tzp, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_add_event_cb(tzp, tz_step_cb, LV_EVENT_CLICKED, (void*)(intptr_t)+1); + lv_obj_t* tzpl = lv_label_create(tzp); lv_label_set_text(tzpl, LV_SYMBOL_PLUS); lv_obj_center(tzpl); + + lv_obj_t* bkp_btn = lv_button_create(parent); + lv_obj_set_size(bkp_btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(bkp_btn, lv_color_hex(S_COL_CARD), 0); + lv_obj_add_event_cb(bkp_btn, backup_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* bkp_lbl = lv_label_create(bkp_btn); + lv_label_set_text(bkp_lbl, LV_SYMBOL_SD_CARD " Backup node to SD"); + lv_obj_center(bkp_lbl); + + lv_obj_t* rst_btn = lv_button_create(parent); + lv_obj_set_size(rst_btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(rst_btn, lv_color_hex(S_COL_CARD), 0); + lv_obj_add_event_cb(rst_btn, restore_cb, LV_EVENT_CLICKED, NULL); + restore_lbl = lv_label_create(rst_btn); + lv_label_set_text(restore_lbl, LV_SYMBOL_DOWNLOAD " Restore from SD"); + lv_obj_center(restore_lbl); + + lv_obj_t* fct_btn = lv_button_create(parent); + lv_obj_set_size(fct_btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(fct_btn, lv_color_hex(0x7A2020), 0); + lv_obj_add_event_cb(fct_btn, factory_cb, LV_EVENT_CLICKED, NULL); + factory_lbl = lv_label_create(fct_btn); + lv_label_set_text(factory_lbl, LV_SYMBOL_WARNING " Factory reset"); + lv_obj_center(factory_lbl); + + edit_scr = makeDialog(NULL); + edit_title = lv_label_create(edit_scr); + lv_label_set_text(edit_title, ""); + lv_obj_set_style_text_color(edit_title, lv_color_hex(S_COL_ACCENT), 0); + lv_obj_align(edit_title, LV_ALIGN_TOP_LEFT, 0, 0); + edit_ta = lv_textarea_create(edit_scr); + lv_textarea_set_one_line(edit_ta, true); + lv_textarea_set_max_length(edit_ta, 30); + lv_obj_set_width(edit_ta, 300); + styleTa(edit_ta); + lv_obj_align(edit_ta, LV_ALIGN_TOP_MID, 0, 40); + lv_obj_add_event_cb(edit_ta, edit_ta_cb, LV_EVENT_ALL, NULL); + // in the title row, clear of the keyboard + makeBtn(edit_scr, LV_SYMBOL_OK, edit_save_cb, NULL, LV_ALIGN_TOP_RIGHT, -78, 0, 72); + makeBtn(edit_scr, LV_SYMBOL_CLOSE, edit_cancel_cb, NULL, LV_ALIGN_TOP_RIGHT, -2, 0, 72); + edit_kb = lv_keyboard_create(edit_scr); + lv_keyboard_set_textarea(edit_kb, edit_ta); + kbAttachShiftBehavior(edit_kb); + lv_obj_set_size(edit_kb, 320, S_KB_H); + lv_obj_align(edit_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(edit_kb, LV_OBJ_FLAG_HIDDEN); + + radio_scr = makeDialog(NULL); + lv_obj_t* rt = lv_label_create(radio_scr); + lv_label_set_text(rt, "Radio settings"); + lv_obj_set_style_text_color(rt, lv_color_hex(S_COL_ACCENT), 0); + lv_obj_set_style_text_font(rt, &lv_font_montserrat_16, 0); + lv_obj_align(rt, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_t* rh = lv_label_create(radio_scr); + lv_label_set_text(rh, "Every node on your mesh must use the same values."); + lv_obj_set_style_text_font(rh, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rh, lv_color_hex(S_COL_MUTED), 0); + lv_obj_align(rh, LV_ALIGN_TOP_LEFT, 0, 20); + + static const char* labels[4] = {"Frequency MHz", "Bandwidth kHz", "Spread factor", "Coding rate"}; + for (int i = 0; i < 4; i++) { + int x = i * 76; + lv_obj_t* l = lv_label_create(radio_scr); + lv_label_set_text(l, labels[i]); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, lv_color_hex(S_COL_MUTED), 0); + lv_obj_align(l, LV_ALIGN_TOP_LEFT, x + 1, 40); + radio_ta[i] = lv_textarea_create(radio_scr); + lv_textarea_set_one_line(radio_ta[i], true); + lv_textarea_set_max_length(radio_ta[i], 8); + lv_obj_set_width(radio_ta[i], 72); + styleTa(radio_ta[i]); + lv_obj_align(radio_ta[i], LV_ALIGN_TOP_LEFT, x, 56); + lv_obj_add_event_cb(radio_ta[i], radio_ta_cb, LV_EVENT_ALL, NULL); + } + + radio_preset_dd = lv_dropdown_create(radio_scr); + char dd_opts[160] = "(presets)"; + for (unsigned i = 0; i < NUM_RADIO_PRESETS; i++) { + strlcat(dd_opts, "\n", sizeof(dd_opts)); + strlcat(dd_opts, RADIO_PRESETS[i].label, sizeof(dd_opts)); + } + lv_dropdown_set_options(radio_preset_dd, dd_opts); + lv_obj_set_size(radio_preset_dd, 304, 32); + lv_obj_align(radio_preset_dd, LV_ALIGN_TOP_MID, 0, 96); + lv_obj_add_event_cb(radio_preset_dd, radio_preset_cb, LV_EVENT_VALUE_CHANGED, NULL); + + // repeat mode: this node forwards other nodes' packets + lv_obj_t* rep_lbl = lv_label_create(radio_scr); + lv_label_set_text(rep_lbl, "Repeat mode"); + lv_obj_set_style_text_color(rep_lbl, lv_color_hex(S_COL_MUTED), 0); + lv_label_set_text(rep_lbl, "Repeat mode - this node relays for others"); + lv_obj_set_style_text_font(rep_lbl, &lv_font_montserrat_12, 0); + lv_obj_align(rep_lbl, LV_ALIGN_TOP_LEFT, 2, 142); + radio_repeat_sw = lv_switch_create(radio_scr); + lv_obj_align(radio_repeat_sw, LV_ALIGN_TOP_RIGHT, -2, 134); + lv_obj_add_event_cb(radio_repeat_sw, radio_repeat_cb, LV_EVENT_VALUE_CHANGED, NULL); + + lv_obj_t* rep_hint = lv_label_create(radio_scr); + lv_label_set_text(rep_hint, "Repeat mode runs on its own frequency\n(433.000 / 869.495 / 918.000 MHz) and is\nset here automatically."); + lv_obj_set_style_text_font(rep_hint, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rep_hint, lv_color_hex(S_COL_MUTED), 0); + lv_obj_set_width(rep_hint, 304); + lv_label_set_long_mode(rep_hint, LV_LABEL_LONG_WRAP); + lv_obj_align(rep_hint, LV_ALIGN_TOP_LEFT, 2, 162); + + radio_apply_btn = makeBtn(radio_scr, LV_SYMBOL_OK " Apply", radio_apply_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 0, -2, 148); + radio_apply_lbl = lv_obj_get_child(radio_apply_btn, 0); + makeBtn(radio_scr, LV_SYMBOL_CLOSE " Cancel", radio_cancel_cb, NULL, LV_ALIGN_BOTTOM_RIGHT, 0, -2, 148); + + radio_kb = lv_keyboard_create(radio_scr); + lv_keyboard_set_mode(radio_kb, LV_KEYBOARD_MODE_NUMBER); + lv_obj_set_style_text_font(radio_kb, &lv_font_montserrat_16, LV_PART_ITEMS); + lv_obj_set_size(radio_kb, 320, 128); // numeric pad: keep the fields visible while typing + lv_obj_align(radio_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(radio_kb, LV_OBJ_FLAG_HIDDEN); + + settingsRefreshRows(); +} diff --git a/examples/companion_radio/ui-lvgl/SettingsUI.h b/examples/companion_radio/ui-lvgl/SettingsUI.h new file mode 100644 index 0000000000..2cece8fe58 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/SettingsUI.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +class UITask; + +// On-device node & radio configuration rows for the Settings tab: +// node name, radio params (freq/BW/SF/CR with preset prefills), TX power, +// BLE pin. Applies through the same internals the phone commands use. +void settingsBuildExtras(lv_obj_t* parent, UITask* task); +void settingsRefreshRows(); +int settingsTzOffset(); // UTC offset in hours (persisted) + +// exports for the first-boot wizard and about screen +int settingsPresetCount(); +const char* settingsPresetLabel(int idx); +void settingsApplyPreset(int idx); // sets + saves radio params, no confirm +void settingsSetTzOffset(int hours); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp new file mode 100644 index 0000000000..3c19d699e8 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -0,0 +1,3448 @@ +#include "UITask.h" +#include +#include "../MyMesh.h" +#include "target.h" +#include +#include +#include +#include +#ifndef STANDALONE_NO_BT + #include +#endif +#include "Sound.h" +#include "MapView.h" +#include "SettingsUI.h" + +// from base64.hpp, which defines (not just declares) its functions and is +// already compiled into BaseChatMesh.cpp - re-including would double-define +unsigned int encode_base64(const unsigned char input[], unsigned int len, unsigned char output[]); +unsigned int decode_base64(const unsigned char input[], unsigned int len, unsigned char output[]); + +#ifndef AUTO_OFF_MILLIS + #define AUTO_OFF_MILLIS 60000 +#endif + +// --------------------------------------------------------------------------- +// theme +// --------------------------------------------------------------------------- +#define COL_BG 0x0A1020 // deep navy +#define COL_CARD 0x16233B // panel/card +#define COL_ACCENT 0x58B4FF // meshcore sky blue +#define COL_ACCENT_D 0x1F4E7A // outgoing bubble +#define COL_TXT 0xE8ECF2 +#define COL_MUTED 0x8FA3BF +#define COL_WARN 0xFFA030 + +// --------------------------------------------------------------------------- +// LVGL <-> LovyanGFX glue +// --------------------------------------------------------------------------- +static UITask* ui = NULL; // singleton for LVGL callbacks + +static void lv_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { + auto gfx = (lgfx::LGFX_Device*) lv_display_get_user_data(disp); + int w = area->x2 - area->x1 + 1; + int h = area->y2 - area->y1 + 1; + gfx->startWrite(); + gfx->setAddrWindow(area->x1, area->y1, w, h); + gfx->pushPixels((uint16_t*) px_map, (uint32_t)w * h, true /* swap bytes */); + gfx->endWrite(); + lv_display_flush_ready(disp); +} + +static void lv_touch_cb(lv_indev_t* indev, lv_indev_data_t* data) { + auto gfx = (lgfx::LGFX_Device*) lv_indev_get_user_data(indev); + lgfx::touch_point_t tp; + if (gfx->getTouch(&tp, 1) > 0) { + data->state = LV_INDEV_STATE_PRESSED; + data->point.x = tp.x; + data->point.y = tp.y; + } else { + data->state = LV_INDEV_STATE_RELEASED; + } +} + +// --------------------------------------------------------------------------- +// widget handles +// --------------------------------------------------------------------------- +static lv_obj_t* status_bar; +static lv_obj_t* lbl_node_name; +static lv_obj_t* lbl_status_right; +static lv_obj_t* tabview; +static lv_obj_t* tab_chats; +static lv_obj_t* tab_contacts; +static lv_obj_t* tab_map; +static lv_obj_t* tab_node; +static lv_obj_t* tab_settings; +// add-channel dialog +static lv_obj_t* addch_scr; +static lv_obj_t* addch_name_ta; +static lv_obj_t* addch_psk_ta; +static lv_obj_t* addch_kb; +static lv_obj_t* chats_list; +static lv_obj_t* contacts_list; +static lv_obj_t* node_info_lbl; +// thread overlay +static lv_obj_t* thread_scr; +static lv_obj_t* thread_title; +static lv_obj_t* thread_sub_lbl; // route (flood/hops) + repeat-echo count +static lv_obj_t* thread_msgs; +static lv_obj_t* thread_input_row; +static lv_obj_t* thread_ta; +static lv_obj_t* thread_kb; + +#define KB_HEIGHT 160 + +// UI font: the icon glyphs the screens use, falling back to montserrat for text. +LV_FONT_DECLARE(fa_icons_14); + +#define SYMBOL_TOWER "\xEF\x94\x99" /* U+F519 broadcast tower */ +#define SYMBOL_ADDR_BOOK "\xEF\x8A\xB9" /* U+F2B9 address book */ + +// boot splash +const lv_image_dsc_t* meshcoreLogoImage(); +static lv_obj_t* splash_scr; +static lv_obj_t* wiz_scr = NULL; // first-boot setup wizard +static bool wizard_pending = false; +static void wizSuggestTz(); + +static void splash_dismiss_cb(lv_timer_t* t) { + lv_obj_delete(splash_scr); + splash_scr = NULL; + lv_timer_delete(t); + if (wizard_pending && wiz_scr != NULL) { wizSuggestTz(); lv_obj_remove_flag(wiz_scr, LV_OBJ_FLAG_HIDDEN); } +} + +static void buildSplash() { + splash_scr = lv_obj_create(lv_layer_top()); + lv_obj_set_size(splash_scr, 320, 240); + lv_obj_set_style_bg_color(splash_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(splash_scr, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(splash_scr, 0, 0); + lv_obj_set_style_radius(splash_scr, 0, 0); + lv_obj_remove_flag(splash_scr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(splash_scr, LV_OBJ_FLAG_CLICKABLE); // eat taps during boot + + lv_obj_t* title = lv_image_create(splash_scr); + lv_image_set_src(title, meshcoreLogoImage()); + lv_obj_set_style_image_recolor(title, lv_color_hex(COL_ACCENT), 0); + lv_obj_set_style_image_recolor_opa(title, LV_OPA_COVER, 0); + lv_obj_align(title, LV_ALIGN_CENTER, 0, -26); + + lv_obj_t* sub = lv_label_create(splash_scr); + lv_label_set_text(sub, "Wio Tracker L2 Pro"); + lv_obj_set_style_text_color(sub, lv_color_hex(COL_TXT), 0); + lv_obj_align(sub, LV_ALIGN_CENTER, 0, 2); + + lv_obj_t* ver = lv_label_create(splash_scr); + char vbuf[40]; + snprintf(vbuf, sizeof(vbuf), "%s %s", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE); + lv_label_set_text(ver, vbuf); + lv_obj_set_style_text_color(ver, lv_color_hex(COL_MUTED), 0); + lv_obj_align(ver, LV_ALIGN_CENTER, 0, 26); + + lv_timer_create(splash_dismiss_cb, 2500, NULL); +} + +// invisible overlay that eats the wake-up tap while the display is dimmed +static lv_obj_t* sleep_shield; + +// repeater manager overlay +static lv_obj_t* rep_scr; +static lv_obj_t* rep_title; +static lv_obj_t* rep_body; +static lv_obj_t* rep_pw_ta; +static lv_obj_t* rep_kb; + +// mirrors RepeaterStats in examples/simple_repeater/MyMesh.h (wire format) +struct RepStats { + uint16_t batt_milli_volts; + uint16_t curr_tx_queue_len; + int16_t noise_floor; + int16_t last_rssi; + uint32_t n_packets_recv; + uint32_t n_packets_sent; + uint32_t total_air_time_secs; + uint32_t total_up_time_secs; + uint32_t n_sent_flood, n_sent_direct; + uint32_t n_recv_flood, n_recv_direct; + uint16_t err_events; + int16_t last_snr; // x 4 + uint16_t n_direct_dups, n_flood_dups; + uint32_t total_rx_air_time_secs; + uint32_t n_recv_errors; +}; +static RepStats rep_stats; +static bool rep_stats_valid = false; +static bool rep_stats_waiting = false; +static lv_obj_t* rep_stats_lbl; // card body: status or command reply +static lv_obj_t* rep_card_title; +static bool rep_show_reply = false; // card is showing a command reply +static char rep_last_cmd[24] = ""; +static bool rep_auto_refresh = false; // opt-in: poll status while open +static char rep_last_reply[110] = ""; +static lv_obj_t* rep_remember_cb; +static lv_obj_t* rep_hint_lbl; +static lv_obj_t* rep_flood_cb; +static bool rep_flood_on = false; // survives screen rebuilds while logging in +static lv_obj_t* rep_login_btn; + +// saved repeater passwords (device flash; the node itself is the boundary) +struct SavedPw { uint8_t key[6]; char pw[26]; }; +#define MAX_SAVED_PW 8 +static SavedPw saved_pw[MAX_SAVED_PW]; +static char rep_pending_pw[26] = ""; +static bool rep_pending_remember = false; + +static void savedPwLoad() { + File f = SPIFFS.open("/rep_pw.bin", "r"); + if (f) { + f.read((uint8_t*)saved_pw, sizeof(saved_pw)); + f.close(); + } +} + +static void savedPwStore() { + File f = SPIFFS.open("/rep_pw.bin", "w"); + if (f) { + f.write((const uint8_t*)saved_pw, sizeof(saved_pw)); + f.close(); + } +} + +static const char* savedPwFor(const uint8_t key[6]) { + for (int i = 0; i < MAX_SAVED_PW; i++) { + if (saved_pw[i].pw[0] != 0 && memcmp(saved_pw[i].key, key, 6) == 0) return saved_pw[i].pw; + } + return NULL; +} + +static void savedPwSet(const uint8_t key[6], const char* pw) { + int slot = -1; + for (int i = 0; i < MAX_SAVED_PW; i++) { + if (memcmp(saved_pw[i].key, key, 6) == 0) { slot = i; break; } + if (saved_pw[i].pw[0] == 0 && slot < 0) slot = i; + } + if (slot < 0) slot = 0; // full: overwrite oldest slot + memcpy(saved_pw[slot].key, key, 6); + StrHelper::strncpy(saved_pw[slot].pw, pw, sizeof(saved_pw[slot].pw)); + savedPwStore(); +} + +static void savedPwForget(const uint8_t key[6]) { + for (int i = 0; i < MAX_SAVED_PW; i++) { + if (memcmp(saved_pw[i].key, key, 6) == 0) saved_pw[i].pw[0] = 0; + } + savedPwStore(); +} + +// ---- small persisted comfort prefs ---- +static uint32_t auto_off_ms = AUTO_OFF_MILLIS; // 0 = never sleep +static bool clock_12h = false; +static bool ble_off_pref = false; +static bool ble_pref_applied = false; +static bool units_miles = false; +static int ble_autooff_min = 0; // 0 = never; else minutes idle before slow advertising +static bool ble_slow = false; +static bool grove_on = true; +static lv_obj_t* ble_sw = NULL; + +static int prefReadInt(const char* path, int dflt) { + File f = SPIFFS.open(path, "r"); + if (!f) return dflt; + int v = f.parseInt(); + f.close(); + return v; +} +static void prefWriteInt(const char* path, int v) { + File f = SPIFFS.open(path, "w"); + if (f) { f.print(v); f.close(); } +} +static void polishPrefsLoad() { + // only the dropdown values: a torn write could yield a near-zero timeout + int off = prefReadInt("/autooff", -1); + static const uint32_t valid_off[] = {30000, 60000, 120000, 300000, 0}; + for (uint32_t v : valid_off) { + if (off >= 0 && (uint32_t) off == v) { auto_off_ms = v; break; } + } + clock_12h = prefReadInt("/clock12", 0) != 0; + ble_off_pref = prefReadInt("/ble_off", 0) != 0; + int ba = prefReadInt("/ble_auto", 0); + ble_autooff_min = (ba == 10 || ba == 30 || ba == 60) ? ba : 0; + grove_on = prefReadInt("/grove", 1) != 0; + rep_auto_refresh = prefReadInt("/rep_auto", 0) != 0; + units_miles = prefReadInt("/units", 0) != 0; +} + +// Slow advertising instead of switching the radio off, so phones can still +// reconnect. Intervals are 0.625 ms units; the defaults are 0x20/0x40. +static void bleSlowAdvertising(bool slow) { +#ifndef STANDALONE_NO_BT + BLEAdvertising* adv = BLEDevice::getAdvertising(); + if (adv == NULL) return; + adv->setMinInterval(slow ? 1600 : 0x20); + adv->setMaxInterval(slow ? 2080 : 0x40); + adv->stop(); + adv->start(); +#endif + ble_slow = slow; +} + +// nothing to render while the screen sleeps, so drop to 80 MHz +static void screenPower(bool asleep) { + setCpuFrequencyMhz(asleep ? 80 : 240); +} + +static uint32_t autoOffMs() { return auto_off_ms == 0 ? 0x7FFFFFFFu : auto_off_ms; } + +// LiPo discharge curve (single cell, light load) instead of a straight line +static int battPercent(uint16_t mv) { + static const uint16_t v[] = {4200, 4100, 4000, 3900, 3800, 3700, 3600, 3500, 3300}; + static const uint8_t pc[] = { 100, 90, 78, 62, 45, 25, 10, 4, 0}; + if (mv >= v[0]) return 100; + for (int i = 1; i < 9; i++) { + if (mv >= v[i]) return pc[i] + (int)(mv - v[i]) * (pc[i-1] - pc[i]) / (v[i-1] - v[i]); + } + return 0; +} + +// ---- contact list filter chips ---- +static int contact_filter = 0; // 0 all, 1 chat, 2 repeater, 3 room +static bool contacts_by_name = false; +static lv_obj_t* filter_btns[5]; + +static void updateFilterChips() { + for (int i = 0; i < 5; i++) { + bool on = i == 4 ? contacts_by_name : (i == contact_filter); + lv_obj_set_style_bg_color(filter_btns[i], lv_color_hex(on ? COL_ACCENT : COL_CARD), 0); + } +} +static void contact_filter_cb(lv_event_t* e) { + int i = (int)(intptr_t) lv_event_get_user_data(e); + if (i == 4) contacts_by_name = !contacts_by_name; + else contact_filter = i; + updateFilterChips(); + ui->refreshContactsTab(); +} + +// ---- notification banner: sender + preview, tap to open the thread ---- +static lv_obj_t* notif_banner = NULL; +static lv_obj_t* notif_lbl = NULL; +static lv_timer_t* notif_timer = NULL; +static uint8_t notif_key[6]; +static char notif_name[36]; + +static void notif_hide_cb(lv_timer_t* tm) { + lv_obj_add_flag(notif_banner, LV_OBJ_FLAG_HIDDEN); + notif_timer = NULL; // repeat count 1: LVGL deletes the timer itself +} +static void notif_tap_cb(lv_event_t* e) { + if (notif_timer != NULL) { lv_timer_delete(notif_timer); notif_timer = NULL; } + lv_obj_add_flag(notif_banner, LV_OBJ_FLAG_HIDDEN); + ui->openThread(notif_key, notif_name); +} +static void showNotifBanner(const uint8_t key[6], const char* from_name, const char* text) { + if (notif_banner == NULL) return; + memcpy(notif_key, key, 6); + int ch_idx; + if (isChannelKey(key, &ch_idx) && from_name[0] != '#') snprintf(notif_name, sizeof(notif_name), "#%s", from_name); + else StrHelper::strncpy(notif_name, from_name, sizeof(notif_name)); + lv_label_set_text_fmt(notif_lbl, "%s\n%.60s", notif_name, text); + lv_obj_remove_flag(notif_banner, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(notif_banner); + if (notif_timer != NULL) lv_timer_delete(notif_timer); + notif_timer = lv_timer_create(notif_hide_cb, 6000, NULL); + lv_timer_set_repeat_count(notif_timer, 1); +} + +// ---- channel options (long-press a channel row): rename / remove ---- +static lv_obj_t* chopt_scr; +static lv_obj_t* chopt_title; +static lv_obj_t* chopt_ta; +static lv_obj_t* chopt_kb; +static lv_obj_t* chopt_remove_lbl; +static int chopt_idx = -1; +static uint8_t chopt_key[6]; +static bool chopt_remove_armed = false; + +static void chopt_close() { + lv_obj_add_flag(chopt_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(chopt_kb, LV_OBJ_FLAG_HIDDEN); +} +static void chopt_cancel_cb(lv_event_t* e) { chopt_close(); } +static void chopt_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) lv_obj_remove_flag(chopt_kb, LV_OBJ_FLAG_HIDDEN); + else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL || code == LV_EVENT_READY) lv_obj_add_flag(chopt_kb, LV_OBJ_FLAG_HIDDEN); +} +static void chopt_save_cb(lv_event_t* e) { + const char* txt = lv_textarea_get_text(chopt_ta); + if (txt != NULL && txt[0] == '#') txt++; + if (txt == NULL || txt[0] == 0) { ui->showToast("Channel needs a name"); return; } + ChannelDetails ch; + if (chopt_idx < 0 || !the_mesh.getChannel(chopt_idx, ch)) { chopt_close(); return; } + StrHelper::strncpy(ch.name, txt, sizeof(ch.name)); + the_mesh.setChannel(chopt_idx, ch); + the_mesh.saveChannels(); + ui->refreshChatsTab(); + ui->showToast("Channel renamed"); + chopt_close(); +} +static void chopt_remove_cb(lv_event_t* e) { + if (!chopt_remove_armed) { + chopt_remove_armed = true; + lv_label_set_text(chopt_remove_lbl, "SURE? (removes channel + history)"); + return; + } + ChannelDetails ch; + if (chopt_idx >= 0 && the_mesh.getChannel(chopt_idx, ch)) { + memset(&ch, 0, sizeof(ch)); // empty name = free slot + the_mesh.setChannel(chopt_idx, ch); + the_mesh.saveChannels(); + chatStoreClearThread(chopt_key); + ui->refreshChatsTab(); + ui->showToast("Channel removed"); + } + chopt_close(); +} + +// ---- about & help ---- +static lv_obj_t* about_scr; +static lv_obj_t* about_lbl; +static void about_close_cb(lv_event_t* e) { lv_obj_add_flag(about_scr, LV_OBJ_FLAG_HIDDEN); } +static void about_advert_cb(lv_event_t* e) { + ui->showToast(the_mesh.advertFlood() ? "Flood advert sent" : "Advert failed"); +} +static void about_open_cb(lv_event_t* e) { + char hex[65]; + const uint8_t* pk = the_mesh.selfId().pub_key; + for (int i = 0; i < 32; i++) snprintf(&hex[i * 2], 3, "%02X", pk[i]); + char buf[640]; + snprintf(buf, sizeof(buf), +#ifdef STANDALONE_NO_BT + "MeshCore %s (%s)\nWio Tracker L2 Pro - standalone (no Bluetooth)\n\n" +#else + "MeshCore %s (%s)\nWio Tracker L2 Pro - standalone + Bluetooth\n\n" +#endif + "Node: %s\nPublic key:\n%.32s\n%.32s\n\n" + "Legend\n" + LV_SYMBOL_OK " delivered " LV_SYMBOL_REFRESH " sending " LV_SYMBOL_WARNING " not delivered (tap to retry)\n" + LV_SYMBOL_LOOP "N repeaters heard repeating your message\n" + "flood = no path yet, sent everywhere\n" + "direct = 0 hops, N hops = via repeaters\n" + "#name = channel (group chat)\n" + "Long-press a contact or channel for options.\n" + "Advert = announce this node to the mesh.", + FIRMWARE_VERSION, FIRMWARE_BUILD_DATE, the_mesh.getNodeName(), hex, hex + 32); + lv_label_set_text(about_lbl, buf); + lv_obj_remove_flag(about_scr, LV_OBJ_FLAG_HIDDEN); +} + +// ---- first-boot setup wizard: region, name, timezone ---- +static lv_obj_t* wiz_title; +static lv_obj_t* wiz_steps[3]; +static lv_obj_t* wiz_dd; +static lv_obj_t* wiz_name_ta; +static lv_obj_t* wiz_kb; +static lv_obj_t* wiz_tz_lbl; +static lv_obj_t* wiz_next_lbl; +static int wiz_step = 0; +static int wiz_tz = -5; + +static void wizShowStep(int s) { + wiz_step = s; + static const char* titles[3] = {"Welcome to MeshCore", "Name your node", "Set your timezone"}; + lv_label_set_text(wiz_title, titles[s]); + for (int i = 0; i < 3; i++) { + if (i == s) lv_obj_remove_flag(wiz_steps[i], LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(wiz_steps[i], LV_OBJ_FLAG_HIDDEN); + } + lv_label_set_text(wiz_next_lbl, s == 2 ? LV_SYMBOL_OK " Finish" : "Next " LV_SYMBOL_RIGHT); + if (s != 1) lv_obj_add_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); +} +static void wiz_next_cb(lv_event_t* e) { + if (wiz_step < 2) { wizShowStep(wiz_step + 1); return; } + ui->finishSetupWizard((int) lv_dropdown_get_selected(wiz_dd), lv_textarea_get_text(wiz_name_ta), wiz_tz, true); +} +static void wiz_skip_cb(lv_event_t* e) { ui->finishSetupWizard(0, "", wiz_tz, false); } +static void wiz_tz_cb(lv_event_t* e) { + int v = wiz_tz + (int)(intptr_t) lv_event_get_user_data(e); + if (v < -12 || v > 14) return; + wiz_tz = v; + lv_label_set_text_fmt(wiz_tz_lbl, "UTC%+d", wiz_tz); +} +static void wiz_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) lv_obj_remove_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); + else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL || code == LV_EVENT_READY) lv_obj_add_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); +} + +static lv_obj_t* makeOverlay() { + lv_obj_t* scr = lv_obj_create(lv_layer_top()); + lv_obj_set_size(scr, 320, 240); + lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(scr, 0, 0); + lv_obj_set_style_radius(scr, 0, 0); + lv_obj_set_style_pad_all(scr, 8, 0); + lv_obj_add_flag(scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + return scr; +} +static lv_obj_t* overlayBtn(lv_obj_t* parent, const char* txt, lv_event_cb_t cb, void* ud, + lv_align_t align, int xo, int yo, int w, bool accent) { + lv_obj_t* b = lv_button_create(parent); + lv_obj_set_size(b, w, 32); + lv_obj_align(b, align, xo, yo); + if (!accent) lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, ud); + lv_obj_t* l = lv_label_create(b); + lv_label_set_text(l, txt); + lv_obj_center(l); + return b; +} + +static void buildPolishOverlays() { + // notification banner (top layer, above everything) + notif_banner = lv_obj_create(lv_layer_top()); + lv_obj_set_size(notif_banner, 304, 46); + lv_obj_align(notif_banner, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_set_style_bg_color(notif_banner, lv_color_hex(COL_ACCENT_D), 0); + lv_obj_set_style_radius(notif_banner, 8, 0); + lv_obj_set_style_pad_all(notif_banner, 6, 0); + lv_obj_set_style_border_width(notif_banner, 0, 0); + lv_obj_add_flag(notif_banner, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(notif_banner, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(notif_banner, notif_tap_cb, LV_EVENT_CLICKED, NULL); + notif_lbl = lv_label_create(notif_banner); + lv_obj_set_width(notif_lbl, 290); + lv_label_set_long_mode(notif_lbl, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_font(notif_lbl, &lv_font_montserrat_12, 0); + lv_obj_add_flag(notif_banner, LV_OBJ_FLAG_HIDDEN); + + // channel options + chopt_scr = makeOverlay(); + chopt_title = lv_label_create(chopt_scr); + lv_obj_set_style_text_color(chopt_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(chopt_title, LV_ALIGN_TOP_LEFT, 0, 6); + overlayBtn(chopt_scr, LV_SYMBOL_OK, chopt_save_cb, NULL, LV_ALIGN_TOP_RIGHT, -78, 0, 72, true); + overlayBtn(chopt_scr, LV_SYMBOL_CLOSE, chopt_cancel_cb, NULL, LV_ALIGN_TOP_RIGHT, -2, 0, 72, false); + chopt_ta = lv_textarea_create(chopt_scr); + lv_textarea_set_one_line(chopt_ta, true); + lv_textarea_set_max_length(chopt_ta, 30); + lv_textarea_set_placeholder_text(chopt_ta, "Channel name"); + lv_obj_set_width(chopt_ta, 304); + lv_obj_set_height(chopt_ta, LV_SIZE_CONTENT); + lv_obj_align(chopt_ta, LV_ALIGN_TOP_MID, 0, 40); + lv_obj_set_style_opa(chopt_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(chopt_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_event_cb(chopt_ta, chopt_ta_cb, LV_EVENT_ALL, NULL); + lv_obj_t* rb = overlayBtn(chopt_scr, LV_SYMBOL_TRASH " Remove channel", chopt_remove_cb, NULL, LV_ALIGN_TOP_MID, 0, 84, 304, false); + lv_obj_set_style_bg_color(rb, lv_color_hex(0x7A2020), 0); + chopt_remove_lbl = lv_obj_get_child(rb, 0); + chopt_kb = lv_keyboard_create(chopt_scr); + lv_keyboard_set_textarea(chopt_kb, chopt_ta); + kbAttachShiftBehavior(chopt_kb); + lv_obj_set_size(chopt_kb, 320, KB_HEIGHT); + lv_obj_align(chopt_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(chopt_kb, LV_OBJ_FLAG_HIDDEN); + + about_scr = makeOverlay(); + lv_obj_t* body = lv_obj_create(about_scr); + lv_obj_set_size(body, 304, 186); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, 0, 0); + about_lbl = lv_label_create(body); + lv_obj_set_width(about_lbl, 290); + lv_label_set_long_mode(about_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(about_lbl, &lv_font_montserrat_12, 0); + lv_label_set_text(about_lbl, ""); + overlayBtn(about_scr, LV_SYMBOL_UPLOAD " Send advert", about_advert_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 0, -2, 148, false); + overlayBtn(about_scr, LV_SYMBOL_CLOSE " Close", about_close_cb, NULL, LV_ALIGN_BOTTOM_RIGHT, 0, -2, 148, true); + + // first-boot wizard + wiz_scr = makeOverlay(); + wiz_title = lv_label_create(wiz_scr); + lv_obj_set_style_text_color(wiz_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_set_style_text_font(wiz_title, &lv_font_montserrat_16, 0); + lv_obj_align(wiz_title, LV_ALIGN_TOP_LEFT, 0, 0); + for (int i = 0; i < 3; i++) { + wiz_steps[i] = lv_obj_create(wiz_scr); + lv_obj_set_size(wiz_steps[i], 304, 150); + lv_obj_align(wiz_steps[i], LV_ALIGN_TOP_MID, 0, 26); + lv_obj_set_style_bg_opa(wiz_steps[i], LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(wiz_steps[i], 0, 0); + lv_obj_set_style_pad_all(wiz_steps[i], 0, 0); + lv_obj_remove_flag(wiz_steps[i], LV_OBJ_FLAG_SCROLLABLE); + } + // step 0: region preset + lv_obj_t* l0 = lv_label_create(wiz_steps[0]); + lv_obj_set_width(l0, 300); + lv_label_set_long_mode(l0, LV_LABEL_LONG_WRAP); + lv_label_set_text(l0, "Pick your region so the radio settings match the other MeshCore nodes around you. You can change this later in Settings."); + lv_obj_align(l0, LV_ALIGN_TOP_LEFT, 0, 0); + char opts[200] = ""; + for (int i = 0; i < settingsPresetCount(); i++) { + if (i > 0) strlcat(opts, "\n", sizeof(opts)); + strlcat(opts, settingsPresetLabel(i), sizeof(opts)); + } + wiz_dd = lv_dropdown_create(wiz_steps[0]); + lv_obj_set_size(wiz_dd, 300, 34); + lv_obj_align(wiz_dd, LV_ALIGN_TOP_MID, 0, 80); + lv_dropdown_set_options(wiz_dd, opts); + // step 1: node name + lv_obj_t* l1 = lv_label_create(wiz_steps[1]); + lv_obj_set_width(l1, 300); + lv_label_set_long_mode(l1, LV_LABEL_LONG_WRAP); + lv_label_set_text(l1, "Give this node a name. Other people see it in their contact list."); + lv_obj_align(l1, LV_ALIGN_TOP_LEFT, 0, 0); + wiz_name_ta = lv_textarea_create(wiz_steps[1]); + lv_textarea_set_one_line(wiz_name_ta, true); + lv_textarea_set_max_length(wiz_name_ta, 30); + lv_obj_set_width(wiz_name_ta, 300); + lv_obj_set_height(wiz_name_ta, LV_SIZE_CONTENT); + lv_obj_align(wiz_name_ta, LV_ALIGN_TOP_MID, 0, 44); + lv_obj_set_style_opa(wiz_name_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(wiz_name_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_event_cb(wiz_name_ta, wiz_ta_cb, LV_EVENT_ALL, NULL); + // step 2: timezone + lv_obj_t* l2 = lv_label_create(wiz_steps[2]); + lv_obj_set_width(l2, 300); + lv_label_set_long_mode(l2, LV_LABEL_LONG_WRAP); + lv_label_set_text(l2, "Hours offset from UTC so message times show local time. Examples: New York -5, London 0, Berlin +1, Sydney +10."); + lv_obj_align(l2, LV_ALIGN_TOP_LEFT, 0, 0); + overlayBtn(wiz_steps[2], LV_SYMBOL_MINUS, wiz_tz_cb, (void*)(intptr_t)-1, LV_ALIGN_TOP_MID, -70, 80, 50, false); + wiz_tz_lbl = lv_label_create(wiz_steps[2]); + lv_obj_set_style_text_font(wiz_tz_lbl, &lv_font_montserrat_16, 0); + lv_obj_align(wiz_tz_lbl, LV_ALIGN_TOP_MID, 0, 88); + overlayBtn(wiz_steps[2], LV_SYMBOL_PLUS, wiz_tz_cb, (void*)(intptr_t)+1, LV_ALIGN_TOP_MID, 70, 80, 50, false); + // nav + overlayBtn(wiz_scr, "Skip", wiz_skip_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 0, -2, 100, false); + lv_obj_t* nb = overlayBtn(wiz_scr, "Next", wiz_next_cb, NULL, LV_ALIGN_BOTTOM_RIGHT, 0, -2, 148, true); + wiz_next_lbl = lv_obj_get_child(nb, 0); + wiz_kb = lv_keyboard_create(wiz_scr); + lv_keyboard_set_textarea(wiz_kb, wiz_name_ta); + kbAttachShiftBehavior(wiz_kb); + lv_obj_set_size(wiz_kb, 320, KB_HEIGHT); + lv_obj_align(wiz_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); + wiz_tz = settingsTzOffset(); + lv_label_set_text_fmt(wiz_tz_lbl, "UTC%+d", wiz_tz); + if (ui->nodePrefs() != NULL) lv_textarea_set_text(wiz_name_ta, ui->nodePrefs()->node_name); + wizShowStep(0); +} + +// when the wizard opens with a GPS fix available, suggest the zone from longitude +static void wizSuggestTz() { + SensorManager* s = ui->sensors(); + LocationProvider* nmea = s != NULL ? s->getLocationProvider() : NULL; + if (nmea == NULL || !nmea->isValid()) return; + int guess = (int) lroundf((nmea->getLongitude() / 1000000.0f) / 15.0f); + if (guess < -12 || guess > 14) return; + wiz_tz = guess; + lv_label_set_text_fmt(wiz_tz_lbl, "UTC%+d (from GPS)", wiz_tz); +} + +// re-run the wizard on demand from Settings +static void wizardOpen() { + if (ui->nodePrefs() != NULL) lv_textarea_set_text(wiz_name_ta, ui->nodePrefs()->node_name); + wiz_tz = settingsTzOffset(); + lv_label_set_text_fmt(wiz_tz_lbl, "UTC%+d", wiz_tz); + wizSuggestTz(); + wizShowStep(0); + lv_obj_remove_flag(wiz_scr, LV_OBJ_FLAG_HIDDEN); +} +static void wizard_row_cb(lv_event_t* e) { wizardOpen(); } + +// ---- quick replies: canned messages sendable without the keyboard ---- +#define QR_MAX 6 +#define QR_TEXT_LEN 64 +static char qr_texts[QR_MAX][QR_TEXT_LEN]; +static int qr_count = 0; +static lv_obj_t* qr_panel = NULL; +static lv_obj_t* thread_qr_btn; +static lv_obj_t* qredit_scr; +static lv_obj_t* qredit_ta; +static lv_obj_t* qredit_kb; + +static void qrDefaults() { + static const char* defs[] = {"On my way", "Yes", "No", "Copy that", "At camp, all good"}; + qr_count = 0; + for (auto d : defs) StrHelper::strncpy(qr_texts[qr_count++], d, QR_TEXT_LEN); +} + +static void qrLoad() { + File f = SPIFFS.open("/qreplies.txt", "r"); + if (!f) { qrDefaults(); return; } + qr_count = 0; + while (f.available() && qr_count < QR_MAX) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() > 0) StrHelper::strncpy(qr_texts[qr_count++], line.c_str(), QR_TEXT_LEN); + } + f.close(); + if (qr_count == 0) qrDefaults(); +} + +static void qrSave() { + File f = SPIFFS.open("/qreplies.txt", "w"); + if (!f) return; + for (int i = 0; i < qr_count; i++) { + f.print(qr_texts[i]); + f.print('\n'); + } + f.close(); +} + +// ---- backlight brightness (percent, persisted) ---- +static uint8_t bright_pct = 60; +static bool bright_dimmed = false; +#define DIM_LEAD_MILLIS 15000 + +static uint8_t brightRaw() { return (uint8_t)((uint16_t) bright_pct * 255 / 100); } + +static void brightLoad() { + File f = SPIFFS.open("/bright", "r"); + if (f) { + int v = f.parseInt(); + f.close(); + if (v >= 10 && v <= 100) bright_pct = (uint8_t) v; + } +} + +static void brightSave() { + File f = SPIFFS.open("/bright", "w"); + if (f) { + f.print((int) bright_pct); + f.close(); + } +} + +// ---- phone-style keyboard shift: one-shot upper, double-tap = caps lock ---- +struct KbShiftState { bool prev_upper; bool shift_pending; bool caps; }; +static KbShiftState kb_shift_states[10]; +static int kb_shift_count = 0; + +static void kb_shift_cb(lv_event_t* e) { + KbShiftState* st = (KbShiftState*) lv_event_get_user_data(e); + lv_obj_t* kb = (lv_obj_t*) lv_event_get_target(e); + lv_keyboard_mode_t mode = lv_keyboard_get_mode(kb); + if (mode != LV_KEYBOARD_MODE_TEXT_LOWER && mode != LV_KEYBOARD_MODE_TEXT_UPPER) { + st->prev_upper = false; // number/special map: shift state doesn't apply + st->shift_pending = st->caps = false; + return; + } + bool upper = mode == LV_KEYBOARD_MODE_TEXT_UPPER; + + if (upper && !st->prev_upper) { // shift tapped + st->shift_pending = true; + st->caps = false; + } else if (!upper && st->prev_upper) { // shift tapped again while shifted + if (st->shift_pending) { // double-tap: engage caps lock + st->caps = true; + st->shift_pending = false; + lv_keyboard_set_mode(kb, LV_KEYBOARD_MODE_TEXT_UPPER); + upper = true; + } else { + st->caps = false; + } + } else if (upper && st->shift_pending && !st->caps) { + // character typed on a one-shot shift: drop back to lower case + // (control glyphs are 3-byte UTF-8 symbols; keep shift through those) + uint32_t id = lv_keyboard_get_selected_button(kb); + const char* txt = lv_keyboard_get_button_text(kb, id); + if (txt != NULL && (uint8_t) txt[0] < 0x80 && strcmp(txt, "1#") != 0) { + st->shift_pending = false; + lv_keyboard_set_mode(kb, LV_KEYBOARD_MODE_TEXT_LOWER); + upper = false; + } + } + st->prev_upper = upper; +} + +void kbAttachShiftBehavior(lv_obj_t* kb) { + lv_obj_set_style_text_font(kb, &lv_font_montserrat_16, LV_PART_ITEMS); // bigger key labels + if (kb_shift_count >= 10) return; + KbShiftState* st = &kb_shift_states[kb_shift_count++]; + st->prev_upper = st->shift_pending = st->caps = false; + lv_obj_add_event_cb(kb, kb_shift_cb, LV_EVENT_VALUE_CHANGED, st); +} + +// per-row keys for the chats list (parallel to buttons, bounded) +#define MAX_THREAD_ROWS 24 +static uint8_t chats_row_keys[MAX_THREAD_ROWS][6]; + +// --------------------------------------------------------------------------- +// event callbacks +// --------------------------------------------------------------------------- +static void contact_row_cb(lv_event_t* e) { + int idx = (int)(intptr_t) lv_event_get_user_data(e); + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) return; + if (c.type == ADV_TYPE_CHAT) { + ui->openContactThread(c); + } else if (c.type == ADV_TYPE_REPEATER || c.type == ADV_TYPE_ROOM) { + ui->openRepeaterManager(c); + } else { + ui->showToast("Not a chat node"); + } +} + +static void contact_row_long_cb(lv_event_t* e) { + ui->openContactDetail((int)(intptr_t) lv_event_get_user_data(e)); +} + +// ---- contact detail overlay ---- +static lv_obj_t* detail_scr; +static lv_obj_t* detail_title; +static lv_obj_t* detail_info; +static lv_obj_t* detail_remove_lbl; +static lv_obj_t* detail_trace_btn; +static int detail_idx = -1; +static bool detail_remove_armed = false; + +static void detail_close_cb(lv_event_t* e) { lv_obj_add_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); } + +static void detail_msg_cb(lv_event_t* e) { + ContactInfo c; + if (!the_mesh.getContactByIdx(detail_idx, c)) return; + lv_obj_add_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); + if (c.type == ADV_TYPE_CHAT) ui->openContactThread(c); + else ui->openRepeaterManager(c); +} + +// ---- trace route overlay: per-hop SNR along the contact's path ---- +static lv_obj_t* trace_scr; +static lv_obj_t* trace_title; +static lv_obj_t* trace_lbl; + +static bool path_from_trace = false; // the picker returns where it came from +static void path_open_cb(lv_event_t* e); + +static void detail_trace_cb(lv_event_t* e) { ui->openTraceForContact(detail_idx); } +static void trace_rerun_cb(lv_event_t* e) { ui->openTraceForContact(detail_idx); } +static void trace_close_cb(lv_event_t* e) { lv_obj_add_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); } +static void trace_setpath_cb(lv_event_t* e) { + path_from_trace = true; + lv_obj_add_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); + path_open_cb(NULL); +} + +static void traceHashName(uint8_t hash, char* out, size_t sz) { + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots(); idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] != 0 && c.id.pub_key[0] == hash) { + StrHelper::strncpy(out, c.name, sz); + return; + } + } + snprintf(out, sz, "hop %02X", hash); +} + +// ---- manual route picker: up to 3 repeater hops or flood/direct ---- +#define PATH_MAX_HOPS 8 // the hop list scrolls, so this is not a screen limit +static lv_obj_t* path_scr; +static lv_obj_t* path_dd[PATH_MAX_HOPS]; +static lv_obj_t* path_hop_lbl[PATH_MAX_HOPS]; +static lv_obj_t* path_add_btn; +static int path_hops_shown = 1; +static int path_rep_idx[40]; // dropdown option order -> contact index +static int path_rep_count = 0; + +// only as many hop rows as are in use, plus the button that reveals the next +static void pathShowHops(int n) { + if (n < 1) n = 1; + if (n > PATH_MAX_HOPS) n = PATH_MAX_HOPS; + path_hops_shown = n; + for (int i = 0; i < PATH_MAX_HOPS; i++) { + if (i < n) { + lv_obj_remove_flag(path_dd[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(path_hop_lbl[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(path_dd[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(path_hop_lbl[i], LV_OBJ_FLAG_HIDDEN); + } + } + lv_obj_align(path_add_btn, LV_ALIGN_TOP_LEFT, 20, 4 + n * 38); + if (n < PATH_MAX_HOPS) lv_obj_remove_flag(path_add_btn, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(path_add_btn, LV_OBJ_FLAG_HIDDEN); +} + +static void path_add_cb(lv_event_t* e) { pathShowHops(path_hops_shown + 1); } + +static void path_open_cb(lv_event_t* e) { // NOLINT: forward declared above + char opts[600] = "(none)"; + path_rep_count = 0; + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots() && path_rep_count < 40; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] == 0 || c.type != ADV_TYPE_REPEATER) continue; + strlcat(opts, "\n", sizeof(opts)); + strlcat(opts, c.name, sizeof(opts)); + path_rep_idx[path_rep_count++] = idx; + } + if (path_rep_count == 0) { + ui->showToast("No repeaters known yet"); + return; + } + for (int i = 0; i < PATH_MAX_HOPS; i++) { + lv_dropdown_set_options(path_dd[i], opts); + lv_dropdown_set_selected(path_dd[i], 0); + } + + // show the path already in use, so it can be edited rather than retyped + int hops = 0; + ContactInfo cur; + if (the_mesh.getContactByIdx(detail_idx, cur) && cur.out_path_len != OUT_PATH_UNKNOWN) { + for (int h = 0; h < cur.out_path_len && h < PATH_MAX_HOPS; h++) { + for (int o = 0; o < path_rep_count; o++) { + ContactInfo rep_c; + if (!the_mesh.getContactByIdx(path_rep_idx[o], rep_c)) continue; + if (rep_c.id.pub_key[0] == cur.out_path[h]) { lv_dropdown_set_selected(path_dd[h], o + 1); break; } + } + hops++; + } + } + pathShowHops(hops); + lv_obj_remove_flag(path_scr, LV_OBJ_FLAG_HIDDEN); +} + +static void path_flood_cb(lv_event_t* e) { + ContactInfo c; + if (the_mesh.getContactByIdx(detail_idx, c)) { + ContactInfo* live = the_mesh.lookupContactByPubKey(c.id.pub_key, 6); + if (live != NULL) { + the_mesh.resetPathTo(*live); + the_mesh.saveContacts(); + ui->showToast("Path reset to flood"); + } + } + lv_obj_add_flag(path_scr, LV_OBJ_FLAG_HIDDEN); + if (path_from_trace) { path_from_trace = false; ui->openTraceForContact(detail_idx); } + else ui->openContactDetail(detail_idx); +} + +static void path_apply_cb(lv_event_t* e) { + ContactInfo c; + if (!the_mesh.getContactByIdx(detail_idx, c)) return; + ContactInfo* live = the_mesh.lookupContactByPubKey(c.id.pub_key, 6); + if (live == NULL) return; + + uint8_t pos = 0; + for (int i = 0; i < PATH_MAX_HOPS; i++) { + int sel = (int) lv_dropdown_get_selected(path_dd[i]); + if (sel < 1 || sel > path_rep_count) continue; // "(none)" + ContactInfo rep; + if (!the_mesh.getContactByIdx(path_rep_idx[sel - 1], rep)) continue; + pos += rep.id.copyHashTo(&live->out_path[pos]); + } + live->out_path_len = pos; // 0 hops = zero-hop direct + the_mesh.saveContacts(); + ui->showToast(pos == 0 ? "Path set: direct (0 hops)" : "Path set"); + lv_obj_add_flag(path_scr, LV_OBJ_FLAG_HIDDEN); + if (path_from_trace) { path_from_trace = false; ui->openTraceForContact(detail_idx); } + else ui->openContactDetail(detail_idx); +} + +static void path_cancel_cb(lv_event_t* e) { + lv_obj_add_flag(path_scr, LV_OBJ_FLAG_HIDDEN); + if (path_from_trace) { path_from_trace = false; lv_obj_remove_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); } +} + +static void detail_share_cb(lv_event_t* e) { + ContactInfo c; + if (!the_mesh.getContactByIdx(detail_idx, c)) return; + ui->showToast(the_mesh.shareContactZeroHop(c) ? "Contact shared (zero hop)" : "Share failed"); +} + +static void detail_remove_cb(lv_event_t* e) { + if (!detail_remove_armed) { + detail_remove_armed = true; + lv_label_set_text(detail_remove_lbl, "SURE?"); + return; + } + ContactInfo c; + if (the_mesh.getContactByIdx(detail_idx, c)) { + ContactInfo* live = the_mesh.lookupContactByPubKey(c.id.pub_key, 6); + if (live != NULL && the_mesh.removeContact(*live)) { + the_mesh.saveContacts(); + ui->showToast("Contact removed"); + } else { + ui->showToast("Remove failed"); + } + } + lv_obj_add_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); + ui->refreshContactsTab(); + ui->refreshChatsTab(); +} + +static void rep_back_cb(lv_event_t* e) { ui->closeRepeaterManager(); } + +static const char* REP_ACTIONS[6] = {"advert", "clock sync", "ver", "neighbors", "clock", "reboot"}; +static void rep_cmd_cb(lv_event_t* e) { + int i = (int)(intptr_t) lv_event_get_user_data(e); + if (i < 0 || i >= 6) return; + rep_show_reply = true; // results land in the card at the top + // never push an unset clock to a repeater: a bad admin sync is exactly how + // repeaters end up years in the past for everyone + if (strcmp(REP_ACTIONS[i], "clock sync") == 0 && rtc_clock.getCurrentTime() < 1600000000UL) { + ui->showToast("This node has no valid time yet (GPS or app first)"); + return; + } + ui->sendRepeaterCommand(REP_ACTIONS[i]); +} + +static void rep_status_cb(lv_event_t* e) { + rep_show_reply = false; // back to the status view + ui->requestRepeaterStatus(); +} + +// used by both the Login button and the keyboard's accept key +static void repeaterLoginFromField() { + const char* pw = lv_textarea_get_text(rep_pw_ta); + if (pw == NULL || pw[0] == 0) { + pw = ui->savedRepeaterPw(); // empty field: fall back to saved password + if (pw == NULL) { + ui->showToast("Enter the repeater admin password"); + return; + } + } + rep_pending_remember = rep_remember_cb != NULL + && lv_obj_has_state(rep_remember_cb, LV_STATE_CHECKED); + bool flood = rep_flood_on; + StrHelper::strncpy(rep_pending_pw, pw, sizeof(rep_pending_pw)); + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + ui->repeaterLogin(pw, flood); + lv_textarea_set_text(rep_pw_ta, ""); +} + +static void rep_login_btn_cb(lv_event_t* e) { repeaterLoginFromField(); } + +static void rep_flood_toggle_cb(lv_event_t* e) { + rep_flood_on = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); +} + +static void rep_terminal_cb(lv_event_t* e) { ui->openConsoleThread(); } + +static void rep_auto_cb(lv_event_t* e) { + rep_auto_refresh = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + prefWriteInt("/rep_auto", rep_auto_refresh ? 1 : 0); +} + +// the keyboard covers the lower screen: keep the field and checkbox above it +static void repLoginLayout(bool kb_visible) { + if (rep_pw_ta == NULL) return; + if (kb_visible) { + lv_obj_add_flag(rep_hint_lbl, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(rep_login_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(rep_flood_cb, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(rep_pw_ta, LV_ALIGN_TOP_MID, 0, 2); + lv_obj_align(rep_remember_cb, LV_ALIGN_TOP_MID, 0, 30); + } else { + lv_obj_remove_flag(rep_hint_lbl, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(rep_login_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(rep_flood_cb, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(rep_pw_ta, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_align(rep_remember_cb, LV_ALIGN_TOP_MID, 0, 58); + } +} + +static void rep_pw_event_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_obj_remove_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + repLoginLayout(true); + } else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL) { + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + repLoginLayout(false); + } else if (code == LV_EVENT_READY) { + repeaterLoginFromField(); // same path as the Login button + } +} + +// channels may already carry a leading '#' in their stored name +static void channelLabel(char* dest, size_t sz, const char* name) { + snprintf(dest, sz, "%s%s", name[0] == '#' ? "" : "#", name); +} + +static void chats_row_long_cb(lv_event_t* e) { + int row = (int)(intptr_t) lv_event_get_user_data(e); + if (row < 0 || row >= MAX_THREAD_ROWS) return; + int ch_idx; + if (isChannelKey(chats_row_keys[row], &ch_idx)) ui->openChannelOptions(chats_row_keys[row]); +} + +static void chats_row_cb(lv_event_t* e) { + int row = (int)(intptr_t) lv_event_get_user_data(e); + if (row < 0 || row >= MAX_THREAD_ROWS) return; + const uint8_t* key = chats_row_keys[row]; + + int ch_idx; + if (isChannelKey(key, &ch_idx)) { + ChannelDetails ch; + if (the_mesh.getChannel(ch_idx, ch) && ch.name[0]) { + char name[36]; + channelLabel(name, sizeof(name), ch.name); + ui->openThread(key, name); + } + } else { + ContactInfo* c = the_mesh.lookupContactByPubKey(key, 6); + if (c != NULL && c->type != ADV_TYPE_CHAT) { + ui->openRepeaterManager(*c); // console threads belong to the manager + return; + } + ui->openThread(key, c ? c->name : "(unknown)"); + } +} + +static void thread_back_cb(lv_event_t* e) { ui->closeThread(); } +static void thread_clear_cb(lv_event_t* e) { ui->clearCurrentThread(); } +static void thread_route_cb(lv_event_t* e) { ui->openRouteForThread(); } +static void bubble_retry_cb(lv_event_t* e) { ui->resendMessage((int)(intptr_t) lv_event_get_user_data(e)); } + +static void fmtAge(char* buf, size_t sz, uint32_t ts) { + long secs = (long) rtc_clock.getCurrentTime() - (long) ts; + if (secs < 0) secs = 0; + if (secs < 60) snprintf(buf, sz, "now"); + else if (secs < 3600) snprintf(buf, sz, "%ldm", secs / 60); + else if (secs < 86400) snprintf(buf, sz, "%ldh", secs / 3600); + else snprintf(buf, sz, "%ldd", secs / 86400); +} + +static void kb_show(bool show) { + if (show) { + lv_obj_remove_flag(thread_kb, LV_OBJ_FLAG_HIDDEN); + // lift the input row above the keyboard so typed text stays visible + lv_obj_align(thread_input_row, LV_ALIGN_BOTTOM_MID, 0, -KB_HEIGHT); + } else { + lv_obj_add_flag(thread_kb, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(thread_input_row, LV_ALIGN_BOTTOM_MID, 0, 0); + } +} + +static void ta_event_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + // CLICKED too: a still-focused textarea fires no FOCUSED on re-tap, + // which left the keyboard unreachable after dismissing it + kb_show(true); + } else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL) { + kb_show(false); + } else if (code == LV_EVENT_READY) { // keyboard checkmark + const char* txt = lv_textarea_get_text(thread_ta); + if (txt != NULL && txt[0] != 0) { + ui->sendFromThread(txt); + lv_textarea_set_text(thread_ta, ""); + } + kb_show(false); + } +} + +// ---- quick reply panel (pops up over the thread, one tap to send) ---- +static void qrPanelClose() { + if (qr_panel != NULL) { + lv_obj_delete(qr_panel); + qr_panel = NULL; + } +} + +static void qr_pick_cb(lv_event_t* e) { + int i = (int)(intptr_t) lv_event_get_user_data(e); + qrPanelClose(); + if (i >= 0 && i < qr_count && qr_texts[i][0] != 0) ui->sendFromThread(qr_texts[i]); +} + +static void qr_loc_cb(lv_event_t* e) { + qrPanelClose(); + SensorManager* s = ui->sensors(); + LocationProvider* nmea = s != NULL ? s->getLocationProvider() : NULL; + if (nmea == NULL || !nmea->isValid()) { + ui->showToast("No GPS fix yet"); + return; + } + char msg[48]; + snprintf(msg, sizeof(msg), "I'm at %.5f, %.5f", + nmea->getLatitude() / 1000000.0, nmea->getLongitude() / 1000000.0); + ui->sendFromThread(msg); +} + +static void qr_open_cb(lv_event_t* e) { + if (qr_panel != NULL) { qrPanelClose(); return; } + kb_show(false); + + qr_panel = lv_obj_create(thread_scr); + lv_obj_set_size(qr_panel, 250, LV_SIZE_CONTENT); + lv_obj_set_style_max_height(qr_panel, 168, 0); + lv_obj_align(qr_panel, LV_ALIGN_BOTTOM_LEFT, 2, -38); + lv_obj_set_style_bg_color(qr_panel, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_color(qr_panel, lv_color_hex(COL_ACCENT_D), 0); + lv_obj_set_style_pad_all(qr_panel, 4, 0); + lv_obj_set_flex_flow(qr_panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(qr_panel, 3, 0); + + for (int i = 0; i < qr_count; i++) { + lv_obj_t* b = lv_button_create(qr_panel); + lv_obj_set_size(b, LV_PCT(100), 26); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(b, qr_pick_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); + lv_obj_t* l = lv_label_create(b); + lv_label_set_text(l, qr_texts[i]); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_align(l, LV_ALIGN_LEFT_MID, 0, 0); + } + + SensorManager* s = ui->sensors(); + LocationProvider* nmea = s != NULL ? s->getLocationProvider() : NULL; + lv_obj_t* b = lv_button_create(qr_panel); + lv_obj_set_size(b, LV_PCT(100), 26); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(b, qr_loc_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* l = lv_label_create(b); + lv_label_set_text(l, LV_SYMBOL_GPS " Send my location"); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + if (nmea == NULL || !nmea->isValid()) lv_obj_set_style_text_color(l, lv_color_hex(COL_MUTED), 0); + lv_obj_align(l, LV_ALIGN_LEFT_MID, 0, 0); +} + +// ---- add-channel dialog ---- +static void addch_open_cb(lv_event_t* e) { + lv_textarea_set_text(addch_name_ta, ""); + lv_textarea_set_text(addch_psk_ta, ""); + lv_obj_add_flag(addch_kb, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(addch_scr, LV_OBJ_FLAG_HIDDEN); +} + +static void addch_cancel_cb(lv_event_t* e) { + lv_keyboard_set_textarea(addch_kb, NULL); + lv_obj_add_flag(addch_scr, LV_OBJ_FLAG_HIDDEN); +} + +static void addch_ta_focus_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + lv_obj_t* ta = (lv_obj_t*) lv_event_get_target(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_keyboard_set_textarea(addch_kb, ta); + lv_obj_remove_flag(addch_kb, LV_OBJ_FLAG_HIDDEN); + } else if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) { + lv_obj_add_flag(addch_kb, LV_OBJ_FLAG_HIDDEN); + } +} + +static void addch_create_cb(lv_event_t* e) { + const char* raw_name = lv_textarea_get_text(addch_name_ta); + while (*raw_name == '#') raw_name++; // stored names carry no leading '#' + if (raw_name[0] == 0) { + ui->showToast("Channel needs a name"); + return; + } + const char* psk = lv_textarea_get_text(addch_psk_ta); + char psk_b64[32]; + if (psk == NULL || psk[0] == 0) { + uint8_t key[16]; + esp_fill_random(key, sizeof(key)); + unsigned int b64len = encode_base64(key, sizeof(key), (unsigned char*)psk_b64); + psk_b64[b64len] = 0; + psk = psk_b64; + } + + // NOTE: BaseChatMesh::addChannel writes at its own counter which ignores + // flash-loaded channels (it would overwrite slot 0) - place the channel + // into the first truly empty slot ourselves + int slot = -1; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails t; + if (!the_mesh.getChannel(i, t)) break; + const char* existing = t.name[0] == '#' ? t.name + 1 : t.name; + if (t.name[0] != 0 && strcmp(existing, raw_name) == 0) { + ui->showToast("Channel already exists"); + return; + } + if (t.name[0] == 0 && slot < 0) slot = i; + } + if (slot < 0) { + ui->showToast("Channel table full"); + return; + } + + ChannelDetails ch; + memset(&ch, 0, sizeof(ch)); + int keylen = decode_base64((const unsigned char*)psk, strlen(psk), ch.channel.secret); + if (keylen != 16 && keylen != 32) { + ui->showToast("PSK must be 16/32 bytes b64"); + return; + } + mesh::Utils::sha256(ch.channel.hash, sizeof(ch.channel.hash), ch.channel.secret, keylen); + StrHelper::strncpy(ch.name, raw_name, sizeof(ch.name)); + if (!the_mesh.setChannel(slot, ch)) { + ui->showToast("Channel add failed"); + return; + } + the_mesh.saveChannels(); + lv_keyboard_set_textarea(addch_kb, NULL); + lv_obj_add_flag(addch_scr, LV_OBJ_FLAG_HIDDEN); + ui->refreshChatsTab(); + ui->showToast("Channel added"); +} + +static void sleep_shield_cb(lv_event_t* e) { + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); +} + +static void tabview_changed_cb(lv_event_t* e) { + ui->refreshChatsTab(); + ui->refreshContactsTab(); + ui->refreshNodeTab(); + mapViewRefresh(); + settingsRefreshRows(); +} + +static void advert_btn_cb(lv_event_t* e) { + bool flood = (intptr_t) lv_event_get_user_data(e) == 1; + bool ok = flood ? the_mesh.advertFlood() : the_mesh.advert(); + ui->showToast(ok ? (flood ? "Flood advert sent" : "Zero-hop advert sent") : "Advert failed"); +} + +static void reboot_btn_cb(lv_event_t* e) { ui->shutdown(true); } + +static void sound_switch_cb(lv_event_t* e) { + auto sw = (lv_obj_t*) lv_event_get_target(e); + bool on = lv_obj_has_state(sw, LV_STATE_CHECKED); + ui->nodePrefs()->buzzer_quiet = on ? 0 : 1; + the_mesh.savePrefs(); + if (on) soundAckTone(); // audible confirmation + ui->showToast(on ? "Sounds on" : "Sounds off"); +} + +// L76K (CASIC) GNSS: enable GPS + BeiDou + GLONASS so acquisition has more +// satellites to work with (board default is GPS + BeiDou only). Volatile +// setting; resent on every GPS start. + +static File gps_log; +static uint32_t gps_log_lines = 0; +static void gpsLogLine(const char* line) { + if (!gps_log) { + if (SD_MMC.cardType() == CARD_NONE || SD_MMC.cardType() == CARD_UNKNOWN) return; + gps_log = SD_MMC.open("/gps_nmea.log", FILE_APPEND); + if (!gps_log) return; + if (gps_log.size() > 2000000) { + gps_log.close(); + gps_log = SD_MMC.open("/gps_nmea.log", FILE_WRITE); + if (!gps_log) return; + } + gps_log.printf("--- boot +%lums ---\n", millis()); + } + gps_log.println(line); + if (++gps_log_lines % 25 == 0) gps_log.flush(); +} + +static void gpsEnableAllConstellations() { + Serial1.print("$PCAS04,7*1E\r\n"); +} + +// The L76K can track strong satellites for many minutes without producing a +// fix, and recovers from a reset. Pulse GNSS reset after 3 minutes of >=4 +// strong satellites with no fix, then re-send the constellation config. +static int gps_kicks = 0; +static unsigned long gps_stuck_since = 0; +static unsigned long gps_next_kick = 0; +static bool gps_reconfig_pending = false; + +static void gpsWatchdogTick() { + if (gps_reconfig_pending) { + gpsEnableAllConstellations(); + gps_reconfig_pending = false; + } + bool tracking = gps_tap.streaming() && gps_tap.strongSats() >= 4; + bool fixed = gps_tap.ggaFix() > 0 || gps_tap.rmcStatus() == 'A'; + if (fixed) { gps_stuck_since = 0; gps_kicks = 0; return; } + if (!tracking) { gps_stuck_since = 0; return; } + if (gps_stuck_since == 0) { gps_stuck_since = millis(); return; } + if (millis() - gps_stuck_since > 180000 && millis() > gps_next_kick) { + MESH_DEBUG_PRINTLN("gps watchdog: %d strong sats, no fix - GNSS reset #%d", + gps_tap.strongSats(), gps_kicks + 1); + board.gnssReset(); + gps_reconfig_pending = true; + gps_kicks++; + gps_next_kick = millis() + 180000; + gps_stuck_since = 0; + } +} + +static void gps_switch_cb(lv_event_t* e) { + auto sw = (lv_obj_t*) lv_event_get_target(e); + bool on = lv_obj_has_state(sw, LV_STATE_CHECKED); + SensorManager* sensors = ui->sensors(); + if (sensors) { + sensors->setSettingValue("gps", on ? "1" : "0"); + ui->nodePrefs()->gps_enabled = on ? 1 : 0; + board.setGnssPower(on); // really off, not just ignored + if (on) gps_reconfig_pending = true; // constellations re-sent once it has booted + the_mesh.savePrefs(); + ui->showToast(on ? "GPS enabled" : "GPS disabled"); + } +} + +// --------------------------------------------------------------------------- +// UITask +// --------------------------------------------------------------------------- +void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs* node_prefs) { + ui = this; + _sensors = sensors; + _node_prefs = node_prefs; + + chatStoreLoad(); + savedPwLoad(); + qrLoad(); + brightLoad(); + polishPrefsLoad(); + soundLoadTonePref(); + gps_tap.echo = false; // set true to mirror raw NMEA to the console + gps_tap.on_line = gpsLogLine; // raw NMEA log on the SD card + soundSetAmpControl([](bool on) { board.setSpeakerAmp(on); }); + soundInit(); // claim I2S DMA memory BEFORE LVGL takes its share + + lv_init(); + lv_tick_set_cb([]() -> uint32_t { return (uint32_t) millis(); }); + + auto gfx = display.lgfxDevice(); // concrete display from target.h + + lv_display_t* disp = lv_display_create(320, 240); + lv_display_set_user_data(disp, gfx); + lv_display_set_flush_cb(disp, lv_flush_cb); + + const uint32_t buf_sz = 320 * 60 * 2; // 60-line partial buffers + uint8_t* buf1 = (uint8_t*) heap_caps_malloc(buf_sz, MALLOC_CAP_SPIRAM); + uint8_t* buf2 = (uint8_t*) heap_caps_malloc(buf_sz, MALLOC_CAP_SPIRAM); + if (buf1 == NULL) { // PSRAM exhausted: fall back to internal RAM, single buffer + buf1 = (uint8_t*) heap_caps_malloc(buf_sz, MALLOC_CAP_8BIT); + buf2 = NULL; + } + if (buf1 == NULL) { + Serial.println("FATAL: no memory for LVGL framebuffers"); + while (true) { delay(1000); } // halt visibly rather than crash on flush + } + lv_display_set_buffers(disp, buf1, buf2, buf_sz, LV_DISPLAY_RENDER_MODE_PARTIAL); + + lv_indev_t* indev = lv_indev_create(); + lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); + lv_indev_set_user_data(indev, gfx); + lv_indev_set_read_cb(indev, lv_touch_cb); + + lv_theme_default_init(disp, lv_color_hex(COL_ACCENT), lv_color_hex(COL_ACCENT_D), + true /* dark */, &fa_icons_14); + + buildShell(); + buildRepeaterScr(); + buildPolishOverlays(); // wizard / about / banner / channel options + wizard_pending = !SPIFFS.exists("/setup_done"); + buildSplash(); + display.lgfxDevice()->setBrightness(brightRaw()); // apply stored brightness + refreshChatsTab(); + refreshContactsTab(); + refreshNodeTab(); + + // the saved GPS preference is never auto-applied at boot (gps_active + // starts false regardless), so re-apply it here + if (_node_prefs != NULL && _node_prefs->gps_enabled && _sensors != NULL) { + _sensors->setSettingValue("gps", "1"); + gpsEnableAllConstellations(); + } else { + board.setGnssPower(false); // GPS disabled: cut the receiver's rail + } + board.setGrovePower(grove_on); +} + +void UITask::buildRepeaterScr() { + lv_obj_t* scr = lv_screen_active(); + rep_scr = lv_obj_create(scr); + lv_obj_set_size(rep_scr, 320, 240); + lv_obj_align(rep_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(rep_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(rep_scr, 0, 0); + lv_obj_set_style_radius(rep_scr, 0, 0); + lv_obj_set_style_pad_all(rep_scr, 0, 0); + lv_obj_add_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(rep_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* hdr = lv_obj_create(rep_scr); + lv_obj_set_size(hdr, 320, 26); + lv_obj_align(hdr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(hdr, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(hdr, 0, 0); + lv_obj_set_style_radius(hdr, 0, 0); + lv_obj_set_style_pad_all(hdr, 2, 0); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* back = lv_button_create(hdr); + lv_obj_set_size(back, 40, 20); + lv_obj_align(back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_add_event_cb(back, rep_back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* back_lbl = lv_label_create(back); + lv_label_set_text(back_lbl, LV_SYMBOL_LEFT); + lv_obj_center(back_lbl); + + rep_title = lv_label_create(hdr); + lv_label_set_text(rep_title, ""); + lv_label_set_long_mode(rep_title, LV_LABEL_LONG_DOT); + lv_obj_set_width(rep_title, 250); + lv_obj_align(rep_title, LV_ALIGN_LEFT_MID, 50, 0); + + rep_body = lv_obj_create(rep_scr); + lv_obj_set_size(rep_body, 320, 240 - 26); + lv_obj_align(rep_body, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_set_style_bg_color(rep_body, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(rep_body, 0, 0); + lv_obj_set_style_pad_all(rep_body, 8, 0); + + rep_kb = lv_keyboard_create(rep_scr); + kbAttachShiftBehavior(rep_kb); + lv_obj_set_size(rep_kb, 320, KB_HEIGHT); + lv_obj_align(rep_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + + addch_scr = lv_obj_create(lv_screen_active()); + lv_obj_set_size(addch_scr, 320, 240); + lv_obj_align(addch_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(addch_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(addch_scr, 0, 0); + lv_obj_set_style_radius(addch_scr, 0, 0); + lv_obj_set_style_pad_all(addch_scr, 8, 0); + lv_obj_add_flag(addch_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(addch_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* addch_title = lv_label_create(addch_scr); + lv_label_set_text(addch_title, "New channel"); + lv_obj_set_style_text_color(addch_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(addch_title, LV_ALIGN_TOP_LEFT, 0, 0); + + addch_name_ta = lv_textarea_create(addch_scr); + lv_textarea_set_one_line(addch_name_ta, true); + lv_textarea_set_placeholder_text(addch_name_ta, "Channel name"); + lv_textarea_set_max_length(addch_name_ta, 30); + lv_obj_set_width(addch_name_ta, 300); + lv_obj_set_height(addch_name_ta, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(addch_name_ta, 5, 0); + lv_obj_set_scroll_dir(addch_name_ta, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(addch_name_ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_align(addch_name_ta, LV_ALIGN_TOP_MID, 0, 16); + lv_obj_add_event_cb(addch_name_ta, addch_ta_focus_cb, LV_EVENT_ALL, NULL); + + addch_psk_ta = lv_textarea_create(addch_scr); + lv_textarea_set_one_line(addch_psk_ta, true); + lv_textarea_set_placeholder_text(addch_psk_ta, "PSK base64 (blank = random)"); + lv_textarea_set_max_length(addch_psk_ta, 44); + lv_obj_set_width(addch_psk_ta, 300); + lv_obj_set_height(addch_psk_ta, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(addch_psk_ta, 5, 0); + lv_obj_set_scroll_dir(addch_psk_ta, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(addch_psk_ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_align(addch_psk_ta, LV_ALIGN_TOP_MID, 0, 46); + lv_obj_add_event_cb(addch_psk_ta, addch_ta_focus_cb, LV_EVENT_ALL, NULL); + + lv_obj_t* create_btn = lv_button_create(addch_scr); + lv_obj_set_size(create_btn, 140, 32); + lv_obj_align(create_btn, LV_ALIGN_TOP_LEFT, 4, 90); + lv_obj_add_event_cb(create_btn, addch_create_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* cl = lv_label_create(create_btn); + lv_label_set_text(cl, LV_SYMBOL_OK " Create"); + lv_obj_center(cl); + + lv_obj_t* cancel_btn = lv_button_create(addch_scr); + lv_obj_set_size(cancel_btn, 140, 32); + lv_obj_align(cancel_btn, LV_ALIGN_TOP_RIGHT, -4, 90); + lv_obj_set_style_bg_color(cancel_btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(cancel_btn, addch_cancel_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* xl = lv_label_create(cancel_btn); + lv_label_set_text(xl, LV_SYMBOL_CLOSE " Cancel"); + lv_obj_center(xl); + + addch_kb = lv_keyboard_create(addch_scr); + kbAttachShiftBehavior(addch_kb); + lv_obj_set_size(addch_kb, 320, KB_HEIGHT); + lv_obj_align(addch_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(addch_kb, LV_OBJ_FLAG_HIDDEN); + + detail_scr = lv_obj_create(lv_screen_active()); + lv_obj_set_size(detail_scr, 320, 240); + lv_obj_align(detail_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(detail_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(detail_scr, 0, 0); + lv_obj_set_style_radius(detail_scr, 0, 0); + lv_obj_set_style_pad_all(detail_scr, 8, 0); + lv_obj_add_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(detail_scr, LV_OBJ_FLAG_SCROLLABLE); + + detail_title = lv_label_create(detail_scr); + lv_label_set_text(detail_title, ""); + lv_obj_set_style_text_color(detail_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_set_style_text_font(detail_title, &lv_font_montserrat_16, 0); + lv_obj_align(detail_title, LV_ALIGN_TOP_LEFT, 0, 0); + + detail_info = lv_label_create(detail_scr); + lv_label_set_text(detail_info, ""); + lv_obj_set_width(detail_info, 300); + lv_obj_align(detail_info, LV_ALIGN_TOP_LEFT, 0, 26); + + lv_obj_t* b; + lv_obj_t* bl; + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_LEFT, 0, -76); + lv_obj_add_event_cb(b, detail_msg_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_ENVELOPE " Message"); lv_obj_center(bl); + + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -76); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, path_open_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_SHUFFLE " Set path"); lv_obj_center(bl); + + path_scr = lv_obj_create(lv_screen_active()); + lv_obj_set_size(path_scr, 320, 240); + lv_obj_align(path_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(path_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(path_scr, 0, 0); + lv_obj_set_style_radius(path_scr, 0, 0); + lv_obj_set_style_pad_all(path_scr, 8, 0); + lv_obj_add_flag(path_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(path_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* pt = lv_label_create(path_scr); + lv_label_set_text(pt, "Path via repeaters (in order)"); + lv_obj_set_style_text_color(pt, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(pt, LV_ALIGN_TOP_LEFT, 0, 0); + + lv_obj_t* ph = lv_label_create(path_scr); + lv_label_set_text(ph, "hop 1 must be within direct range"); + lv_obj_set_style_text_font(ph, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(ph, lv_color_hex(COL_MUTED), 0); + lv_obj_align(ph, LV_ALIGN_TOP_RIGHT, 0, 2); + + lv_obj_t* hop_list = lv_obj_create(path_scr); + lv_obj_set_size(hop_list, 304, 158); + lv_obj_align(hop_list, LV_ALIGN_TOP_MID, 0, 18); + lv_obj_set_style_bg_opa(hop_list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(hop_list, 0, 0); + lv_obj_set_style_pad_all(hop_list, 0, 0); + lv_obj_set_scroll_dir(hop_list, LV_DIR_VER); + + for (int i = 0; i < PATH_MAX_HOPS; i++) { + path_hop_lbl[i] = lv_label_create(hop_list); + lv_label_set_text_fmt(path_hop_lbl[i], "%d", i + 1); + lv_obj_set_style_text_color(path_hop_lbl[i], lv_color_hex(COL_MUTED), 0); + lv_obj_align(path_hop_lbl[i], LV_ALIGN_TOP_LEFT, 2, 10 + i * 38); + path_dd[i] = lv_dropdown_create(hop_list); + lv_obj_set_size(path_dd[i], 274, 32); + lv_obj_align(path_dd[i], LV_ALIGN_TOP_LEFT, 20, 2 + i * 38); + } + + path_add_btn = lv_button_create(hop_list); + lv_obj_set_size(path_add_btn, 110, 30); + lv_obj_set_style_bg_color(path_add_btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(path_add_btn, path_add_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(path_add_btn); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_12, 0); + lv_label_set_text(bl, LV_SYMBOL_PLUS " Add hop"); + lv_obj_center(bl); + pathShowHops(1); + + b = lv_button_create(path_scr); lv_obj_set_size(b, 96, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_LEFT, 0, -4); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, path_flood_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, "Reset (flood)"); lv_obj_center(bl); + + b = lv_button_create(path_scr); lv_obj_set_size(b, 96, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_MID, 0, -4); + lv_obj_add_event_cb(b, path_apply_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_OK " Apply"); lv_obj_center(bl); + + b = lv_button_create(path_scr); lv_obj_set_size(b, 96, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -4); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, path_cancel_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_CLOSE " Cancel"); lv_obj_center(bl); + + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_LEFT, 0, -40); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, detail_share_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_UPLOAD " Share"); lv_obj_center(bl); + + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -40); + lv_obj_set_style_bg_color(b, lv_color_hex(0x7A2020), 0); + lv_obj_add_event_cb(b, detail_remove_cb, LV_EVENT_CLICKED, NULL); + detail_remove_lbl = lv_label_create(b); + lv_label_set_text(detail_remove_lbl, LV_SYMBOL_TRASH " Remove"); + lv_obj_center(detail_remove_lbl); + + detail_trace_btn = lv_button_create(detail_scr); lv_obj_set_size(detail_trace_btn, 148, 32); + lv_obj_align(detail_trace_btn, LV_ALIGN_BOTTOM_LEFT, 0, -2); + lv_obj_add_event_cb(detail_trace_btn, detail_trace_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(detail_trace_btn); lv_label_set_text(bl, LV_SYMBOL_GPS " Trace path"); lv_obj_center(bl); + + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -2); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, detail_close_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_CLOSE " Close"); lv_obj_center(bl); + + trace_scr = lv_obj_create(lv_screen_active()); + lv_obj_set_size(trace_scr, 320, 240); + lv_obj_align(trace_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(trace_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(trace_scr, 0, 0); + lv_obj_set_style_radius(trace_scr, 0, 0); + lv_obj_set_style_pad_all(trace_scr, 8, 0); + lv_obj_add_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(trace_scr, LV_OBJ_FLAG_SCROLLABLE); + + trace_title = lv_label_create(trace_scr); + lv_label_set_text(trace_title, ""); + lv_obj_set_style_text_color(trace_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_set_style_text_font(trace_title, &lv_font_montserrat_16, 0); + lv_obj_set_width(trace_title, 304); + lv_label_set_long_mode(trace_title, LV_LABEL_LONG_DOT); + lv_obj_align(trace_title, LV_ALIGN_TOP_LEFT, 0, 0); + + // long hop lists scroll rather than running past the buttons + lv_obj_t* trace_body = lv_obj_create(trace_scr); + lv_obj_set_size(trace_body, 304, 158); + lv_obj_align(trace_body, LV_ALIGN_TOP_LEFT, 0, 26); + lv_obj_set_style_bg_opa(trace_body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(trace_body, 0, 0); + lv_obj_set_style_pad_all(trace_body, 0, 0); + lv_obj_set_scroll_dir(trace_body, LV_DIR_VER); + lv_obj_set_scrollbar_mode(trace_body, LV_SCROLLBAR_MODE_AUTO); + + trace_lbl = lv_label_create(trace_body); + lv_label_set_text(trace_lbl, ""); + lv_obj_set_width(trace_lbl, 296); + lv_label_set_long_mode(trace_lbl, LV_LABEL_LONG_WRAP); + lv_obj_align(trace_lbl, LV_ALIGN_TOP_LEFT, 0, 0); + + b = lv_button_create(trace_scr); lv_obj_set_size(b, 98, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_LEFT, 0, -2); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, trace_setpath_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_12, 0); + lv_label_set_text(bl, LV_SYMBOL_SHUFFLE " Set path"); lv_obj_center(bl); + + b = lv_button_create(trace_scr); lv_obj_set_size(b, 98, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_MID, 0, -2); + lv_obj_add_event_cb(b, trace_rerun_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_12, 0); + lv_label_set_text(bl, LV_SYMBOL_REFRESH " Trace"); lv_obj_center(bl); + + b = lv_button_create(trace_scr); lv_obj_set_size(b, 98, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -2); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, trace_close_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_12, 0); + lv_label_set_text(bl, LV_SYMBOL_CLOSE " Close"); lv_obj_center(bl); + + // sleep shield: topmost, eats the first tap when the screen is dark + sleep_shield = lv_obj_create(lv_layer_top()); + lv_obj_set_size(sleep_shield, 320, 240); + lv_obj_set_style_bg_opa(sleep_shield, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(sleep_shield, 0, 0); + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_event_cb(sleep_shield, sleep_shield_cb, LV_EVENT_CLICKED, NULL); +} + +void UITask::refreshRepeaterScr() { + // detach the keyboard BEFORE cleaning: lv_keyboard_set_textarea dereferences + // its previous textarea, which lv_obj_clean is about to free (use-after-free) + lv_keyboard_set_textarea(rep_kb, NULL); + lv_obj_clean(rep_body); + lv_label_set_text(rep_title, _rep_name); + + if (!_rep_logged_in) { + bool have_saved = savedPwFor(_rep_key) != NULL; + rep_hint_lbl = lv_label_create(rep_body); + lv_label_set_text(rep_hint_lbl, _rep_logging_in ? "Logging in..." + : have_saved ? "Saved password - just tap Login" + : "Admin login required"); + lv_obj_set_style_text_color(rep_hint_lbl, lv_color_hex(have_saved ? COL_ACCENT : COL_MUTED), 0); + lv_obj_align(rep_hint_lbl, LV_ALIGN_TOP_MID, 0, 4); + + rep_pw_ta = lv_textarea_create(rep_body); + lv_textarea_set_one_line(rep_pw_ta, true); + lv_textarea_set_password_mode(rep_pw_ta, true); + lv_textarea_set_placeholder_text(rep_pw_ta, have_saved ? "********" : "Admin password"); + lv_textarea_set_max_length(rep_pw_ta, 40); + lv_obj_set_width(rep_pw_ta, 280); + lv_obj_set_height(rep_pw_ta, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(rep_pw_ta, 5, 0); + lv_obj_set_scroll_dir(rep_pw_ta, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(rep_pw_ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_opa(rep_pw_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(rep_pw_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_align(rep_pw_ta, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_add_event_cb(rep_pw_ta, rep_pw_event_cb, LV_EVENT_ALL, NULL); + lv_keyboard_set_textarea(rep_kb, rep_pw_ta); + + rep_remember_cb = lv_checkbox_create(rep_body); + lv_checkbox_set_text(rep_remember_cb, "Remember password"); + lv_obj_set_style_text_font(rep_remember_cb, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rep_remember_cb, lv_color_hex(COL_MUTED), 0); + lv_obj_align(rep_remember_cb, LV_ALIGN_TOP_MID, 0, 58); + lv_obj_add_state(rep_remember_cb, LV_STATE_CHECKED); // on by default + + // flood the login when the stored route has gone stale + rep_flood_cb = lv_checkbox_create(rep_body); + lv_checkbox_set_text(rep_flood_cb, "Flood connect (ignore known path)"); + lv_obj_set_style_text_font(rep_flood_cb, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rep_flood_cb, lv_color_hex(COL_MUTED), 0); + lv_obj_align(rep_flood_cb, LV_ALIGN_TOP_MID, 0, 80); + if (rep_flood_on) lv_obj_add_state(rep_flood_cb, LV_STATE_CHECKED); // keep the choice + lv_obj_add_event_cb(rep_flood_cb, rep_flood_toggle_cb, LV_EVENT_VALUE_CHANGED, NULL); + + rep_login_btn = lv_button_create(rep_body); + lv_obj_set_size(rep_login_btn, 160, 36); + lv_obj_align(rep_login_btn, LV_ALIGN_TOP_MID, 0, 108); + lv_obj_add_event_cb(rep_login_btn, rep_login_btn_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* blbl = lv_label_create(rep_login_btn); + lv_label_set_text(blbl, LV_SYMBOL_OK " Login"); + lv_obj_center(blbl); + } else { + lv_obj_t* card = lv_obj_create(rep_body); + lv_obj_set_size(card, 300, 92); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(card, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(card, 0, 0); + lv_obj_set_style_pad_all(card, 6, 0); + lv_obj_set_scroll_dir(card, LV_DIR_VER); // long replies (neighbour lists) scroll + lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); + + rep_card_title = lv_label_create(card); + lv_obj_set_style_text_font(rep_card_title, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rep_card_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(rep_card_title, LV_ALIGN_TOP_LEFT, 0, 0); + lv_label_set_text(rep_card_title, rep_show_reply ? rep_last_cmd : "Status"); + + rep_stats_lbl = lv_label_create(card); + lv_obj_set_width(rep_stats_lbl, 284); + lv_obj_align(rep_stats_lbl, LV_ALIGN_TOP_LEFT, 0, 15); + lv_label_set_long_mode(rep_stats_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(rep_stats_lbl, &lv_font_montserrat_12, 0); + if (rep_show_reply) { + lv_label_set_text(rep_stats_lbl, rep_last_reply); + } else if (rep_stats_valid) { + uint32_t up = rep_stats.total_up_time_secs; + char up_s[20]; + if (up >= 86400) snprintf(up_s, sizeof(up_s), "%lud %luh", (unsigned long)(up / 86400), (unsigned long)((up % 86400) / 3600)); + else snprintf(up_s, sizeof(up_s), "%luh %lum", (unsigned long)(up / 3600), (unsigned long)((up % 3600) / 60)); + lv_label_set_text_fmt(rep_stats_lbl, + "Batt %u.%02uV Up %s\n" + "Noise %d RSSI %d SNR %d\n" + "Sent %lu (f%lu d%lu)\n" + "Recv %lu (f%lu d%lu)\n" + "Air TX %lus RX %lus Q%u E%u", + rep_stats.batt_milli_volts / 1000, (rep_stats.batt_milli_volts % 1000) / 10, up_s, + (int)rep_stats.noise_floor, (int)rep_stats.last_rssi, (int)(rep_stats.last_snr / 4), + (unsigned long)rep_stats.n_packets_sent, (unsigned long)rep_stats.n_sent_flood, (unsigned long)rep_stats.n_sent_direct, + (unsigned long)rep_stats.n_packets_recv, (unsigned long)rep_stats.n_recv_flood, (unsigned long)rep_stats.n_recv_direct, + (unsigned long)rep_stats.total_air_time_secs, (unsigned long)rep_stats.total_rx_air_time_secs, + (unsigned)rep_stats.curr_tx_queue_len, (unsigned)rep_stats.err_events); + } else { + lv_label_set_text(rep_stats_lbl, rep_stats_waiting ? "Fetching status..." : "No status yet - tap refresh"); + lv_obj_set_style_text_color(rep_stats_lbl, lv_color_hex(COL_MUTED), 0); + } + + lv_obj_t* refresh = lv_button_create(rep_body); + lv_obj_set_size(refresh, 44, 26); + lv_obj_align(refresh, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_add_event_cb(refresh, rep_status_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* rl = lv_label_create(refresh); + lv_label_set_text(rl, LV_SYMBOL_REFRESH); + lv_obj_center(rl); + + static const char* action_labels[6] = { + LV_SYMBOL_UPLOAD " Advert", LV_SYMBOL_LOOP " Clock sync", LV_SYMBOL_FILE " Version", + LV_SYMBOL_WIFI " Neighbors", LV_SYMBOL_REFRESH " Clock", LV_SYMBOL_WARNING " Reboot" + }; + for (int i = 0; i < 6; i++) { + lv_obj_t* btn = lv_button_create(rep_body); + lv_obj_set_size(btn, 96, 30); + lv_obj_align(btn, LV_ALIGN_TOP_LEFT, (i % 3) * 102, 98 + (i / 3) * 34); + if (i == 5) lv_obj_set_style_bg_color(btn, lv_color_hex(0x7A2020), 0); + lv_obj_add_event_cb(btn, rep_cmd_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); + lv_obj_t* blbl = lv_label_create(btn); + lv_obj_set_style_text_font(blbl, &lv_font_montserrat_12, 0); + lv_label_set_text(blbl, action_labels[i]); + lv_obj_center(blbl); + } + + lv_obj_t* auto_cb = lv_checkbox_create(rep_body); + lv_checkbox_set_text(auto_cb, "auto-refresh"); + lv_obj_set_style_text_font(auto_cb, &lv_font_montserrat_12, 0); + lv_obj_align(auto_cb, LV_ALIGN_TOP_LEFT, 2, 172); + if (rep_auto_refresh) lv_obj_add_state(auto_cb, LV_STATE_CHECKED); + lv_obj_add_event_cb(auto_cb, rep_auto_cb, LV_EVENT_VALUE_CHANGED, NULL); + + lv_obj_t* btn = lv_button_create(rep_body); + lv_obj_set_size(btn, 118, 28); + lv_obj_align(btn, LV_ALIGN_TOP_RIGHT, 0, 166); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, rep_terminal_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* blbl = lv_label_create(btn); + lv_obj_set_style_text_font(blbl, &lv_font_montserrat_12, 0); + lv_label_set_text(blbl, LV_SYMBOL_KEYBOARD " CLI"); + lv_obj_center(blbl); + } +} + +void UITask::openRepeaterManager(const ContactInfo& contact) { + // every visit starts at the login screen + rep_show_reply = false; + _rep_logged_in = false; + _rep_logging_in = false; + if (memcmp(_rep_key, contact.id.pub_key, 6) != 0) { // different repeater + rep_stats_valid = false; + rep_stats_waiting = false; + rep_last_reply[0] = 0; + } + memcpy(_rep_key, contact.id.pub_key, 6); + StrHelper::strncpy(_rep_name, contact.name, sizeof(_rep_name)); + refreshRepeaterScr(); + lv_obj_remove_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::closeRepeaterManager() { + lv_obj_add_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + _rep_logged_in = false; // re-authenticate on the next visit + _rep_logging_in = false; +} + +void UITask::repeaterLogin(const char* password, bool flood) { + ContactInfo* c = the_mesh.lookupContactByPubKey(_rep_key, 6); + if (c == NULL) { showToast("Contact gone"); return; } + if (flood) { + the_mesh.resetPathTo(*c); + the_mesh.saveContacts(); + } + if (the_mesh.uiLogin(*c, password) == MSG_SEND_FAILED) { + showToast("Login send failed"); + } else { + _rep_logging_in = true; + memcpy(_pending_login_key, _rep_key, 6); + _pending_login_deadline = millis() + 30000; + refreshRepeaterScr(); + } +} + +void UITask::sendRepeaterCommand(const char* cmd) { + ContactInfo* c = the_mesh.lookupContactByPubKey(_rep_key, 6); + if (c == NULL) { showToast("Contact gone"); return; } + uint32_t est_timeout; + uint32_t timestamp = rtc_clock.getCurrentTimeUnique(); + if (the_mesh.sendCommandData(*c, timestamp, 0, TXT_TYPE_CLI_DATA, cmd, est_timeout) == MSG_SEND_FAILED) { + showToast("Send failed"); + } else { + chatStorePush(_rep_key, true, timestamp, cmd); // terminal history keeps everything + StrHelper::strncpy(rep_last_cmd, cmd, sizeof(rep_last_cmd)); + rep_show_reply = true; + snprintf(rep_last_reply, sizeof(rep_last_reply), "waiting for reply..."); + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); + } +} + +void UITask::openConsoleThread() { + lv_obj_add_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); // manager under terminal, not over it + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + _thread_clear_armed = false; + memcpy(_thread_key, _rep_key, 6); + _thread_is_channel = false; + _thread_is_console = true; + char title[40]; + snprintf(title, sizeof(title), "@%s", _rep_name); + StrHelper::strncpy(_thread_name, title, sizeof(_thread_name)); + lv_label_set_text(thread_title, title); + lv_textarea_set_placeholder_text(thread_ta, "Command..."); + lv_obj_add_flag(thread_qr_btn, LV_OBJ_FLAG_HIDDEN); // canned chat replies make no sense as CLI + refreshThread(); + lv_obj_remove_flag(thread_scr, LV_OBJ_FLAG_HIDDEN); + updateThreadSubtitle(); +} + +void UITask::buildShell() { + lv_obj_t* scr = lv_screen_active(); + lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_text_color(scr, lv_color_hex(COL_TXT), 0); + + // --- status bar --- + status_bar = lv_obj_create(scr); + lv_obj_set_size(status_bar, 320, 22); + lv_obj_align(status_bar, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(status_bar, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(status_bar, 0, 0); + lv_obj_set_style_radius(status_bar, 0, 0); + lv_obj_set_style_pad_all(status_bar, 2, 0); + lv_obj_remove_flag(status_bar, LV_OBJ_FLAG_SCROLLABLE); + + lbl_node_name = lv_label_create(status_bar); + lv_label_set_text(lbl_node_name, _node_prefs ? _node_prefs->node_name : "MeshCore"); + lv_obj_set_style_text_color(lbl_node_name, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(lbl_node_name, LV_ALIGN_LEFT_MID, 4, 0); + + lbl_status_right = lv_label_create(status_bar); + lv_label_set_text(lbl_status_right, ""); + lv_obj_set_style_text_color(lbl_status_right, lv_color_hex(COL_MUTED), 0); + lv_obj_align(lbl_status_right, LV_ALIGN_RIGHT_MID, -4, 0); + + // --- tabview --- + tabview = lv_tabview_create(scr); + lv_obj_set_size(tabview, 320, 240 - 22); + lv_obj_align(tabview, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_tabview_set_tab_bar_position(tabview, LV_DIR_BOTTOM); + lv_tabview_set_tab_bar_size(tabview, 36); // icons only + lv_obj_set_style_bg_color(tabview, lv_color_hex(COL_BG), 0); + + tab_chats = lv_tabview_add_tab(tabview, LV_SYMBOL_ENVELOPE); + tab_contacts = lv_tabview_add_tab(tabview, SYMBOL_ADDR_BOOK); + tab_map = lv_tabview_add_tab(tabview, LV_SYMBOL_GPS); + tab_settings = lv_tabview_add_tab(tabview, LV_SYMBOL_SETTINGS); + lv_obj_add_event_cb(tabview, tabview_changed_cb, LV_EVENT_VALUE_CHANGED, NULL); + // tab switching via the tab bar only; swipe gestures belong to the content + // (map panning especially) and were causing accidental page swaps + lv_obj_remove_flag(lv_tabview_get_content(tabview), LV_OBJ_FLAG_SCROLLABLE); + + buildChatsTab(tab_chats); + buildContactsTab(tab_contacts); + mapViewBuild(tab_map, _sensors, this); + buildSettingsTab(tab_settings); + + thread_scr = lv_obj_create(scr); + lv_obj_set_size(thread_scr, 320, 240); + lv_obj_align(thread_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(thread_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(thread_scr, 0, 0); + lv_obj_set_style_radius(thread_scr, 0, 0); + lv_obj_set_style_pad_all(thread_scr, 0, 0); + lv_obj_add_flag(thread_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(thread_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* hdr = lv_obj_create(thread_scr); + lv_obj_set_size(hdr, 320, 26); + lv_obj_align(hdr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(hdr, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(hdr, 0, 0); + lv_obj_set_style_radius(hdr, 0, 0); + lv_obj_set_style_pad_all(hdr, 2, 0); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* back = lv_button_create(hdr); + lv_obj_set_size(back, 40, 20); + lv_obj_align(back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_add_event_cb(back, thread_back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* back_lbl = lv_label_create(back); + lv_label_set_text(back_lbl, LV_SYMBOL_LEFT); + lv_obj_center(back_lbl); + + thread_title = lv_label_create(hdr); + lv_label_set_text(thread_title, ""); + lv_label_set_long_mode(thread_title, LV_LABEL_LONG_DOT); + lv_obj_set_width(thread_title, 106); + lv_obj_align(thread_title, LV_ALIGN_LEFT_MID, 50, 0); + + thread_sub_lbl = lv_label_create(hdr); + lv_label_set_text(thread_sub_lbl, ""); + lv_obj_set_style_text_font(thread_sub_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(thread_sub_lbl, lv_color_hex(COL_MUTED), 0); + lv_label_set_long_mode(thread_sub_lbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(thread_sub_lbl, 82); + lv_obj_align(thread_sub_lbl, LV_ALIGN_LEFT_MID, 160, 0); + + lv_obj_t* route_btn = lv_button_create(hdr); + lv_obj_set_size(route_btn, 34, 20); + lv_obj_align(route_btn, LV_ALIGN_RIGHT_MID, -40, 0); + lv_obj_set_style_bg_color(route_btn, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(route_btn, thread_route_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* route_lbl = lv_label_create(route_btn); + lv_label_set_text(route_lbl, LV_SYMBOL_SHUFFLE); + lv_obj_center(route_lbl); + + lv_obj_t* clear_btn = lv_button_create(hdr); + lv_obj_set_size(clear_btn, 34, 20); + lv_obj_align(clear_btn, LV_ALIGN_RIGHT_MID, -2, 0); + lv_obj_set_style_bg_color(clear_btn, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(clear_btn, thread_clear_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* clear_lbl = lv_label_create(clear_btn); + lv_label_set_text(clear_lbl, LV_SYMBOL_TRASH); + lv_obj_center(clear_lbl); + + thread_msgs = lv_obj_create(thread_scr); + lv_obj_set_size(thread_msgs, 320, 240 - 26 - 34); + lv_obj_align(thread_msgs, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_set_style_bg_color(thread_msgs, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(thread_msgs, 0, 0); + lv_obj_set_style_pad_all(thread_msgs, 4, 0); + lv_obj_set_flex_flow(thread_msgs, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(thread_msgs, 3, 0); + + thread_input_row = lv_obj_create(thread_scr); + lv_obj_set_size(thread_input_row, 320, 34); + lv_obj_align(thread_input_row, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(thread_input_row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(thread_input_row, 0, 0); + lv_obj_set_style_radius(thread_input_row, 0, 0); + lv_obj_set_style_pad_all(thread_input_row, 3, 0); + lv_obj_remove_flag(thread_input_row, LV_OBJ_FLAG_SCROLLABLE); + + thread_qr_btn = lv_button_create(thread_input_row); + lv_obj_set_size(thread_qr_btn, 30, 28); + lv_obj_align(thread_qr_btn, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_bg_color(thread_qr_btn, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(thread_qr_btn, qr_open_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* qrl = lv_label_create(thread_qr_btn); + lv_label_set_text(qrl, LV_SYMBOL_LIST); + lv_obj_center(qrl); + + thread_ta = lv_textarea_create(thread_input_row); + lv_textarea_set_one_line(thread_ta, true); + lv_textarea_set_placeholder_text(thread_ta, "Message..."); + lv_textarea_set_max_length(thread_ta, 110); + // height = exactly one text line + padding: zero vertical scroll freedom + lv_obj_set_width(thread_ta, 278); + lv_obj_set_height(thread_ta, LV_SIZE_CONTENT); + lv_obj_align(thread_ta, LV_ALIGN_LEFT_MID, 34, 0); + lv_obj_set_style_pad_ver(thread_ta, 5, 0); + lv_obj_set_style_pad_left(thread_ta, 6, 0); + lv_obj_set_scroll_dir(thread_ta, LV_DIR_HOR); // horizontal scroll only + lv_obj_set_scrollbar_mode(thread_ta, LV_SCROLLBAR_MODE_OFF); // no bouncing edge lines + // blinking cursor only while actually typing (focused), not at idle + lv_obj_set_style_opa(thread_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(thread_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_event_cb(thread_ta, ta_event_cb, LV_EVENT_ALL, NULL); + + thread_kb = lv_keyboard_create(thread_scr); + lv_keyboard_set_textarea(thread_kb, thread_ta); + lv_obj_set_size(thread_kb, 320, KB_HEIGHT); + lv_obj_align(thread_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(thread_kb, LV_OBJ_FLAG_HIDDEN); + kbAttachShiftBehavior(thread_kb); +} + +void UITask::buildChatsTab(lv_obj_t* parent) { + lv_obj_set_style_pad_all(parent, 4, 0); + chats_list = lv_list_create(parent); + lv_obj_set_size(chats_list, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(chats_list, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(chats_list, 0, 0); +} + +void UITask::buildContactsTab(lv_obj_t* parent) { + lv_obj_set_style_pad_all(parent, 4, 0); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 4, 0); + + lv_obj_t* chips = lv_obj_create(parent); + lv_obj_set_size(chips, LV_PCT(100), 28); + lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(chips, 0, 0); + lv_obj_set_style_pad_all(chips, 0, 0); + lv_obj_set_style_pad_column(chips, 4, 0); + lv_obj_remove_flag(chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW); + static const char* names[5] = {"All", "Chat", "Repeater", "Room", "A-Z"}; + for (int i = 0; i < 5; i++) { + lv_obj_t* b = lv_button_create(chips); + lv_obj_set_height(b, 26); + lv_obj_set_flex_grow(b, 1); + lv_obj_set_style_pad_hor(b, 2, 0); + lv_obj_add_event_cb(b, contact_filter_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); + lv_obj_t* l = lv_label_create(b); + lv_label_set_text(l, names[i]); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_center(l); + filter_btns[i] = b; + } + updateFilterChips(); + + contacts_list = lv_list_create(parent); + lv_obj_set_width(contacts_list, LV_PCT(100)); + lv_obj_set_flex_grow(contacts_list, 1); + lv_obj_set_style_bg_color(contacts_list, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(contacts_list, 0, 0); +} + +void UITask::buildNodeTab(lv_obj_t* parent) { + lv_obj_t* card = lv_obj_create(parent); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_color(card, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(card, 0, 0); + lv_obj_set_style_pad_all(card, 8, 0); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* hdr = lv_label_create(card); + lv_label_set_text(hdr, LV_SYMBOL_CHARGE " Telemetry"); + lv_obj_set_style_text_color(hdr, lv_color_hex(COL_ACCENT), 0); + node_info_lbl = lv_label_create(card); + lv_label_set_text(node_info_lbl, ""); + lv_obj_set_width(node_info_lbl, LV_PCT(100)); + lv_obj_align_to(node_info_lbl, hdr, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 4); +} + +static void tone_dd_cb(lv_event_t* e) { + lv_obj_t* dd = (lv_obj_t*) lv_event_get_target(e); + soundSetToneStyle((int) lv_dropdown_get_selected(dd)); + soundMessageTone(); // instant preview of the chosen alert +} + +static void ble_switch_cb(lv_event_t* e) { + bool on = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + ble_off_pref = !on; + prefWriteInt("/ble_off", on ? 0 : 1); + if (on) ui->enableBluetooth(); else ui->disableBluetooth(); + ui->showToast(on ? "Bluetooth on" : "Bluetooth off"); +} + +static void ble_auto_dd_cb(lv_event_t* e) { + static const int opts[4] = {0, 10, 30, 60}; + int sel = (int) lv_dropdown_get_selected((lv_obj_t*) lv_event_get_target(e)); + if (sel < 0 || sel > 3) return; + ble_autooff_min = opts[sel]; + prefWriteInt("/ble_auto", ble_autooff_min); +} + +static void grove_switch_cb(lv_event_t* e) { + grove_on = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + prefWriteInt("/grove", grove_on ? 1 : 0); + board.setGrovePower(grove_on); +} + +static void autooff_dd_cb(lv_event_t* e) { + static const uint32_t opts[5] = {30000, 60000, 120000, 300000, 0}; + int sel = (int) lv_dropdown_get_selected((lv_obj_t*) lv_event_get_target(e)); + if (sel < 0 || sel > 4) return; + auto_off_ms = opts[sel]; + prefWriteInt("/autooff", (int) auto_off_ms); +} + +static void units_switch_cb(lv_event_t* e) { + units_miles = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + prefWriteInt("/units", units_miles ? 1 : 0); +} + +static void clock12_switch_cb(lv_event_t* e) { + clock_12h = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + prefWriteInt("/clock12", clock_12h ? 1 : 0); +} + +static void bright_slider_cb(lv_event_t* e) { + lv_obj_t* s = (lv_obj_t*) lv_event_get_target(e); + if (lv_event_get_code(e) == LV_EVENT_VALUE_CHANGED) { + bright_pct = (uint8_t) lv_slider_get_value(s); + display.lgfxDevice()->setBrightness(brightRaw()); + } else if (lv_event_get_code(e) == LV_EVENT_RELEASED) { + brightSave(); + } +} + +// ---- quick replies editor (one reply per line) ---- +static void qredit_open_cb(lv_event_t* e) { + char buf[QR_MAX * QR_TEXT_LEN]; + int off = 0; + buf[0] = 0; + for (int i = 0; i < qr_count; i++) { + off += snprintf(&buf[off], sizeof(buf) - off, "%s\n", qr_texts[i]); + } + lv_textarea_set_text(qredit_ta, buf); + lv_obj_remove_flag(qredit_scr, LV_OBJ_FLAG_HIDDEN); +} + +static void qredit_save_cb(lv_event_t* e) { + const char* txt = lv_textarea_get_text(qredit_ta); + qr_count = 0; + const char* p = txt; + while (*p != 0 && qr_count < QR_MAX) { + const char* nl = strchr(p, '\n'); + size_t n = nl != NULL ? (size_t)(nl - p) : strlen(p); + if (n > 0) { + if (n >= QR_TEXT_LEN) n = QR_TEXT_LEN - 1; + memcpy(qr_texts[qr_count], p, n); + qr_texts[qr_count][n] = 0; + qr_count++; + } + if (nl == NULL) break; + p = nl + 1; + } + if (qr_count == 0) qrDefaults(); + qrSave(); + lv_obj_add_flag(qredit_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); + ui->showToast("Quick replies saved"); +} + +static void qredit_cancel_cb(lv_event_t* e) { + lv_obj_add_flag(qredit_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); +} + +static void qredit_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_obj_remove_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); + } else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL || code == LV_EVENT_READY) { + lv_obj_add_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); + } +} + +void UITask::buildSettingsTab(lv_obj_t* parent) { + lv_obj_set_style_pad_all(parent, 8, 0); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 8, 0); + + buildNodeTab(parent); // telemetry card first + + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_VOLUME_MAX " Sounds"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (_node_prefs && !_node_prefs->buzzer_quiet) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, sound_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 40); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_BELL " Alert tone"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* tone_dd = lv_dropdown_create(row); + lv_obj_set_size(tone_dd, 140, 32); + lv_obj_align(tone_dd, LV_ALIGN_RIGHT_MID, -4, 0); + lv_dropdown_set_options(tone_dd, "Classic\nChirp\nDing dong\nTrill\nRise\nAlarm"); + lv_dropdown_set_selected(tone_dd, soundGetToneStyle()); + lv_obj_add_event_cb(tone_dd, tone_dd_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_GPS " GPS"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (_node_prefs && _node_prefs->gps_enabled) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, gps_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + +#ifndef STANDALONE_NO_BT + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_BLUETOOTH " Bluetooth"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (!ble_off_pref) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, ble_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + ble_sw = sw; + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 40); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_BLUETOOTH " BT power save when idle"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* ba_dd = lv_dropdown_create(row); + lv_obj_set_size(ba_dd, 110, 32); + lv_obj_align(ba_dd, LV_ALIGN_RIGHT_MID, -4, 0); + lv_dropdown_set_options(ba_dd, "Never\n10 min\n30 min\n1 hour"); + lv_dropdown_set_selected(ba_dd, ble_autooff_min == 10 ? 1 : ble_autooff_min == 30 ? 2 : ble_autooff_min == 60 ? 3 : 0); + lv_obj_add_event_cb(ba_dd, ble_auto_dd_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_USB " Grove port power"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (grove_on) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, grove_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); +#else + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_USB " Grove port power"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (grove_on) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, grove_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); +#endif + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 40); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_POWER " Screen off"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* off_dd = lv_dropdown_create(row); + lv_obj_set_size(off_dd, 120, 32); + lv_obj_align(off_dd, LV_ALIGN_RIGHT_MID, -4, 0); + lv_dropdown_set_options(off_dd, "30 sec\n1 min\n2 min\n5 min\nNever"); + lv_dropdown_set_selected(off_dd, auto_off_ms == 30000 ? 0 : auto_off_ms == 60000 ? 1 : auto_off_ms == 120000 ? 2 : auto_off_ms == 300000 ? 3 : auto_off_ms == 0 ? 4 : 1); + lv_obj_add_event_cb(off_dd, autooff_dd_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_REFRESH " 12-hour clock"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (clock_12h) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, clock12_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_GPS " Distances in miles"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (units_miles) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, units_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_EYE_OPEN " Brightness"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* slider = lv_slider_create(row); + lv_obj_set_size(slider, 160, 14); + lv_obj_align(slider, LV_ALIGN_RIGHT_MID, -10, 0); + lv_slider_set_range(slider, 10, 100); + lv_slider_set_value(slider, bright_pct, LV_ANIM_OFF); + lv_obj_add_event_cb(slider, bright_slider_cb, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(slider, bright_slider_cb, LV_EVENT_RELEASED, NULL); + + lv_obj_t* qr_row = lv_button_create(parent); + lv_obj_set_size(qr_row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(qr_row, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(qr_row, qredit_open_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(qr_row); + lv_label_set_text(lbl, LV_SYMBOL_LIST " Quick replies"); + lv_obj_center(lbl); + + settingsBuildExtras(parent, this); + + lv_obj_t* btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_add_event_cb(btn, advert_btn_cb, LV_EVENT_CLICKED, (void*)(intptr_t)1); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_UPLOAD " Send advert (flood)"); + lv_obj_center(lbl); + + btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, advert_btn_cb, LV_EVENT_CLICKED, (void*)(intptr_t)0); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_UPLOAD " Send advert (zero hop)"); + lv_obj_center(lbl); + + btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, wizard_row_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_SETTINGS " Run setup wizard"); + lv_obj_center(lbl); + + btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, about_open_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_FILE " About & help"); + lv_obj_center(lbl); + + btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_add_event_cb(btn, reboot_btn_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_REFRESH " Reboot"); + lv_obj_center(lbl); + + qredit_scr = lv_obj_create(lv_layer_top()); + lv_obj_set_size(qredit_scr, 320, 240); + lv_obj_set_style_bg_color(qredit_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(qredit_scr, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(qredit_scr, 0, 0); + lv_obj_set_style_radius(qredit_scr, 0, 0); + lv_obj_set_style_pad_all(qredit_scr, 8, 0); + lv_obj_add_flag(qredit_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(qredit_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* qt = lv_label_create(qredit_scr); + lv_label_set_text(qt, "Quick replies"); + lv_obj_set_style_text_color(qt, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(qt, LV_ALIGN_TOP_LEFT, 0, 6); + + lv_obj_t* qb = lv_button_create(qredit_scr); + lv_obj_set_size(qb, 70, 28); + lv_obj_align(qb, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_set_style_bg_color(qb, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(qb, qredit_cancel_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(qb); lv_label_set_text(lbl, LV_SYMBOL_CLOSE); lv_obj_center(lbl); + + qb = lv_button_create(qredit_scr); + lv_obj_set_size(qb, 70, 28); + lv_obj_align(qb, LV_ALIGN_TOP_RIGHT, -76, 0); + lv_obj_add_event_cb(qb, qredit_save_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(qb); lv_label_set_text(lbl, LV_SYMBOL_OK); lv_obj_center(lbl); + + lv_obj_t* qh = lv_label_create(qredit_scr); + lv_label_set_text_fmt(qh, "One reply per line, up to %d", QR_MAX); + lv_obj_set_style_text_font(qh, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(qh, lv_color_hex(COL_MUTED), 0); + lv_obj_align(qh, LV_ALIGN_TOP_LEFT, 0, 32); + + qredit_ta = lv_textarea_create(qredit_scr); + lv_textarea_set_max_length(qredit_ta, QR_MAX * QR_TEXT_LEN - 1); + lv_obj_set_size(qredit_ta, 304, 172); + lv_obj_align(qredit_ta, LV_ALIGN_TOP_MID, 0, 52); + lv_obj_set_style_opa(qredit_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(qredit_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_event_cb(qredit_ta, qredit_ta_cb, LV_EVENT_ALL, NULL); + + qredit_kb = lv_keyboard_create(qredit_scr); + lv_keyboard_set_textarea(qredit_kb, qredit_ta); + kbAttachShiftBehavior(qredit_kb); + lv_obj_set_size(qredit_kb, 320, KB_HEIGHT); + lv_obj_align(qredit_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); +} + +// --------------------------------------------------------------------------- +// refreshers +// --------------------------------------------------------------------------- +void UITask::refreshStatusBar() { + char buf[64]; + + // clock, once the RTC has real time (GPS fix or phone connection syncs it) + uint32_t now = rtc_clock.getCurrentTime(); + if (now > 1600000000UL) { + time_t local = (time_t) now + (time_t) settingsTzOffset() * 3600; + struct tm tmv; + gmtime_r(&local, &tmv); + char namebuf[48]; + if (clock_12h) { + int h12 = tmv.tm_hour % 12; if (h12 == 0) h12 = 12; + snprintf(namebuf, sizeof(namebuf), "%s %d:%02d %s", + _node_prefs ? _node_prefs->node_name : "", h12, tmv.tm_min, tmv.tm_hour < 12 ? "am" : "pm"); + } else { + snprintf(namebuf, sizeof(namebuf), "%s %02d:%02d", + _node_prefs ? _node_prefs->node_name : "", tmv.tm_hour, tmv.tm_min); + } + lv_label_set_text(lbl_node_name, namebuf); + } + + uint16_t mv = getBattMilliVolts(); + int pct = battPercent(mv); + const char* bsym = pct > 80 ? LV_SYMBOL_BATTERY_FULL + : pct > 55 ? LV_SYMBOL_BATTERY_3 + : pct > 30 ? LV_SYMBOL_BATTERY_2 + : pct > 10 ? LV_SYMBOL_BATTERY_1 : LV_SYMBOL_BATTERY_EMPTY; + snprintf(buf, sizeof(buf), "%s%s %d %s %d%%", +#ifdef STANDALONE_NO_BT + // no phone link in this build, so report charging instead + _board->isExternalPowered() ? LV_SYMBOL_CHARGE " " : "", +#else + hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "", +#endif + LV_SYMBOL_ENVELOPE, chatStoreUnreadTotal(), bsym, pct); + lv_label_set_text(lbl_status_right, buf); + + lv_obj_t* bar = lv_tabview_get_tab_bar(tabview); + lv_obj_t* tab0 = bar != NULL ? lv_obj_get_child(bar, 0) : NULL; + lv_obj_t* tl = tab0 != NULL ? lv_obj_get_child(tab0, 0) : NULL; + if (tl != NULL) { + int unread = chatStoreUnreadTotal(); + if (unread > 0) lv_label_set_text_fmt(tl, LV_SYMBOL_ENVELOPE " %d", unread); + else lv_label_set_text(tl, LV_SYMBOL_ENVELOPE); + } +} + +void UITask::refreshChatsTab() { + lv_obj_clean(chats_list); + int n = chatStoreThreads(chats_row_keys, MAX_THREAD_ROWS); + for (int i = 0; i < n; i++) { + char label[64]; + const char* icon = LV_SYMBOL_ENVELOPE; + int ch_idx; + if (isChannelKey(chats_row_keys[i], &ch_idx)) { + ChannelDetails ch; + if (!the_mesh.getChannel(ch_idx, ch) || ch.name[0] == 0) continue; + channelLabel(label, sizeof(label), ch.name); + icon = LV_SYMBOL_ENVELOPE; + } else { + ContactInfo* c = the_mesh.lookupContactByPubKey(chats_row_keys[i], 6); + if (c != NULL && c->type != ADV_TYPE_CHAT) continue; // repeater consoles: manager only + snprintf(label, sizeof(label), "%s", c ? c->name : "(unknown)"); + } + int unread = chatStoreUnreadGet(chats_row_keys[i]); + if (unread > 0) { + size_t l = strlen(label); + snprintf(label + l, sizeof(label) - l, " (%d)", unread); + } + lv_obj_t* btn = lv_list_add_button(chats_list, icon, label); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, chats_row_cb, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)i); + lv_obj_add_event_cb(btn, chats_row_long_cb, LV_EVENT_LONG_PRESSED, (void*)(intptr_t)i); + } + for (int idx = 0; idx < MAX_GROUP_CHANNELS; idx++) { + ChannelDetails ch; + if (!the_mesh.getChannel(idx, ch)) break; + if (ch.name[0] == 0) continue; + uint8_t key[6]; + makeChannelKey(idx, key); + bool listed = false; + for (int i = 0; i < n; i++) { + if (memcmp(chats_row_keys[i], key, 6) == 0) { listed = true; break; } + } + if (listed || n >= MAX_THREAD_ROWS) continue; + memcpy(chats_row_keys[n], key, 6); + char label[40]; + channelLabel(label, sizeof(label), ch.name); + lv_obj_t* btn = lv_list_add_button(chats_list, LV_SYMBOL_ENVELOPE, label); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, chats_row_cb, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)n); + lv_obj_add_event_cb(btn, chats_row_long_cb, LV_EVENT_LONG_PRESSED, (void*)(intptr_t)n); + n++; + } + if (n == 0) { + lv_list_add_text(chats_list, "No chats yet - pick a contact"); + } + lv_obj_t* add_btn = lv_list_add_button(chats_list, LV_SYMBOL_PLUS, "Add channel"); + lv_obj_set_style_text_color(add_btn, lv_color_hex(COL_MUTED), 0); + lv_obj_add_event_cb(add_btn, addch_open_cb, LV_EVENT_CLICKED, NULL); +} + +void UITask::refreshContactsTab() { + lv_obj_clean(contacts_list); + + // sort by most recently heard (or by name), honoring the type filter + struct Entry { int idx; uint32_t ts; char name[32]; }; + static Entry order[64]; + int n = 0; + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots() && n < 64; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] == 0) continue; + if (contact_filter == 1 && c.type != ADV_TYPE_CHAT) continue; + if (contact_filter == 2 && c.type != ADV_TYPE_REPEATER) continue; + if (contact_filter == 3 && c.type != ADV_TYPE_ROOM) continue; + order[n].idx = idx; + order[n].ts = c.last_advert_timestamp; + StrHelper::strncpy(order[n].name, c.name, sizeof(order[n].name)); + n++; + } + for (int i = 1; i < n; i++) { // insertion sort + Entry key = order[i]; + int j = i - 1; + while (j >= 0 && (contacts_by_name ? strcasecmp(order[j].name, key.name) > 0 : order[j].ts < key.ts)) { + order[j + 1] = order[j]; j--; + } + order[j + 1] = key; + } + + int shown = 0; + for (int oi = 0; oi < n; oi++) { + int idx = order[oi].idx; + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) continue; + const char* icon = LV_SYMBOL_ENVELOPE; + if (c.type == ADV_TYPE_REPEATER) icon = SYMBOL_TOWER; + else if (c.type == ADV_TYPE_ROOM) icon = LV_SYMBOL_HOME; + else if (c.type == ADV_TYPE_SENSOR) icon = LV_SYMBOL_TINT; + lv_obj_t* btn = lv_list_add_button(contacts_list, icon, c.name); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, contact_row_cb, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)idx); + lv_obj_add_event_cb(btn, contact_row_long_cb, LV_EVENT_LONG_PRESSED, (void*)(intptr_t)idx); + shown++; + } + if (shown == 0) { + lv_list_add_text(contacts_list, contact_filter == 0 ? "No contacts yet - waiting for adverts" : "No contacts of this type"); + } +} + +void UITask::refreshNodeTab() { + if (_node_prefs == NULL) return; + char buf[400]; + char gps_line[120] = "GPS: off"; + if (_sensors != NULL) { + LocationProvider* nmea = _sensors->getLocationProvider(); + if (nmea != NULL && nmea->isValid()) { + snprintf(gps_line, sizeof(gps_line), "GPS: fix, %d sats\n%.5f %.5f", + nmea->satellitesCount(), + nmea->getLatitude() / 1000000., nmea->getLongitude() / 1000000.); + } else if (_node_prefs->gps_enabled) { + // sats in view + best SNR come from GSV: shows RF health long before a fix + snprintf(gps_line, sizeof(gps_line), + "GPS: searching - %d in view (%d strong), best %d dB\nGGA fix=%d used=%d RMC=%c\n%s", + gps_tap.satsInView(), gps_tap.strongSats(), gps_tap.bestSnr(), + gps_tap.ggaFix(), gps_tap.ggaUsed(), gps_tap.rmcStatus(), + gps_kicks > 0 ? "auto-restarted, re-acquiring" : "needs open sky for first fix"); + } + } + snprintf(buf, sizeof(buf), + "Node: %s\n\n" + "Freq: %.3f MHz SF%d\n" + "BW: %.2f CR: %d\n" + "TX: %d dBm\n\n" + "%s\n\n" + "Battery: %d mV\n" + "BLE pin: %u", + _node_prefs->node_name, + _node_prefs->freq, _node_prefs->sf, + _node_prefs->bw, _node_prefs->cr, + _node_prefs->tx_power_dbm, + gps_line, + getBattMilliVolts(), + (unsigned) the_mesh.getBLEPin()); + lv_label_set_text(node_info_lbl, buf); +} + +// --------------------------------------------------------------------------- +// thread view +// --------------------------------------------------------------------------- +void UITask::openRouteForThread() { + if (_thread_is_channel) { + showToast("Channels always flood"); + return; + } + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots(); idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] != 0 && memcmp(c.id.pub_key, _thread_key, 6) == 0) { + detail_idx = idx; + path_open_cb(NULL); + return; + } + } + showToast("Contact not found"); +} + +void UITask::clearCurrentThread() { + if (!_thread_clear_armed) { + _thread_clear_armed = true; + showToast("Tap trash again to clear thread"); + return; + } + _thread_clear_armed = false; + chatStoreClearThread(_thread_key); + refreshThread(); + refreshChatsTab(); + showToast("Thread cleared"); +} + +void UITask::openThread(const uint8_t key[6], const char* name) { + memcpy(_thread_key, key, 6); + _thread_clear_armed = false; + chatStoreUnreadClear(key); + refreshStatusBar(); + _thread_is_channel = isChannelKey(key, &_thread_channel_idx); + _thread_is_console = false; + StrHelper::strncpy(_thread_name, name, sizeof(_thread_name)); + lv_label_set_text(thread_title, name); + lv_textarea_set_placeholder_text(thread_ta, "Message..."); + lv_obj_remove_flag(thread_qr_btn, LV_OBJ_FLAG_HIDDEN); + refreshThread(); + lv_obj_remove_flag(thread_scr, LV_OBJ_FLAG_HIDDEN); + updateThreadSubtitle(); +} + +void UITask::openContactThread(const ContactInfo& contact) { + openThread(contact.id.pub_key, contact.name); +} + +void UITask::closeThread() { + qrPanelClose(); + lv_obj_add_flag(thread_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(thread_kb, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(thread_input_row, LV_ALIGN_BOTTOM_MID, 0, 0); + if (_thread_is_console) { + _thread_is_console = false; + lv_obj_remove_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); // back to the manager + } else { + refreshChatsTab(); + } +} + +static lv_obj_t* echo_meta_lbl = NULL; +static char echo_meta_base[16]; + +void UITask::refreshThread() { + lv_obj_clean(thread_msgs); + echo_meta_lbl = NULL; + + int count = 0; + while (chatStoreGet(_thread_key, count) != NULL && count < CHAT_STORE_SIZE) count++; + if (count > 50) count = 50; // render the newest 50; full history stays stored + + for (int k = count - 1; k >= 0; k--) { // oldest first, newest at bottom + ChatMsg* m = chatStoreGet(_thread_key, k); + if (m == NULL) continue; + + lv_obj_t* row = lv_obj_create(thread_msgs); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, m->outgoing ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + char age[16]; + fmtAge(age, sizeof(age), m->timestamp); + if (m->outgoing) { // time sits left of an outgoing (right-aligned) bubble + lv_obj_t* tl = lv_label_create(row); + lv_label_set_text(tl, age); + lv_obj_set_style_text_font(tl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tl, lv_color_hex(COL_MUTED), 0); + if (m->timestamp == _echo_msg_ts && millis() <= _echo_window_end) { + echo_meta_lbl = tl; // repeat-echo counter rides on this label + StrHelper::strncpy(echo_meta_base, age, sizeof(echo_meta_base)); + if (_echo_count > 0) lv_label_set_text_fmt(tl, "%s " LV_SYMBOL_LOOP "%d", age, _echo_count); + } + } + + lv_obj_t* bubble = lv_label_create(row); + if (m->outgoing && m->status != MSG_STATUS_NONE) { + if (m->status == MSG_STATUS_PENDING && millis() > m->timeout_at) m->status = MSG_STATUS_NO_ACK; + const char* mark = m->status == MSG_STATUS_DELIVERED ? LV_SYMBOL_OK + : m->status == MSG_STATUS_PENDING ? LV_SYMBOL_REFRESH : LV_SYMBOL_WARNING; + lv_label_set_text_fmt(bubble, "%s %s", m->text, mark); + if (m->status == MSG_STATUS_NO_ACK) { // tap to resend + lv_obj_add_flag(bubble, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(bubble, bubble_retry_cb, LV_EVENT_CLICKED, (void*)(intptr_t)k); + } + } else { + lv_label_set_text(bubble, m->text); + } + lv_label_set_long_mode(bubble, LV_LABEL_LONG_WRAP); + lv_obj_set_style_max_width(bubble, 240, 0); + lv_obj_set_style_bg_opa(bubble, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(bubble, lv_color_hex(m->outgoing ? COL_ACCENT_D : COL_CARD), 0); + lv_obj_set_style_radius(bubble, 8, 0); + lv_obj_set_style_pad_all(bubble, 6, 0); + + if (!m->outgoing) { // time sits right of an incoming bubble + lv_obj_t* tl = lv_label_create(row); + lv_label_set_text(tl, age); + lv_obj_set_style_text_font(tl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tl, lv_color_hex(COL_MUTED), 0); + } + } + lv_obj_scroll_to_y(thread_msgs, LV_COORD_MAX, LV_ANIM_OFF); +} + +void UITask::sendFromThread(const char* text) { + uint32_t timestamp = rtc_clock.getCurrentTime(); + + _echo_count = 0; + _echo_window_end = millis() + 60000; + _echo_msg_ts = timestamp; + + if (_thread_is_console) { // repeater terminal: everything is a CLI command + ContactInfo* c = the_mesh.lookupContactByPubKey(_thread_key, 6); + if (c == NULL) { showToast("Contact gone"); return; } + uint32_t est_timeout; + timestamp = rtc_clock.getCurrentTimeUnique(); // a repeat within the same second would look like a replay + _echo_msg_ts = timestamp; + if (the_mesh.sendCommandData(*c, timestamp, 0, TXT_TYPE_CLI_DATA, text, est_timeout) == MSG_SEND_FAILED) { + showToast("Send failed"); + } else { + chatStorePush(_thread_key, true, timestamp, text); + } + refreshThread(); + return; + } + + if (_thread_is_channel) { + ChannelDetails ch; + if (!the_mesh.getChannel(_thread_channel_idx, ch) || ch.name[0] == 0) return; + if (the_mesh.sendGroupMessage(timestamp, ch.channel, _node_prefs->node_name, text, strlen(text))) { + chatStorePush(_thread_key, true, timestamp, text); + } else { + showToast("Send failed"); + } + } else { + ContactInfo* c = the_mesh.lookupContactByPubKey(_thread_key, 6); + if (c == NULL) { showToast("Contact gone"); return; } + uint32_t expected_ack, est_timeout; + int result = the_mesh.sendMessage(*c, timestamp, 0, text, expected_ack, est_timeout); + if (result == MSG_SEND_FAILED) { + showToast("Send failed"); + } else { + ChatMsg* m = chatStorePush(_thread_key, true, timestamp, text); + if (m != NULL && expected_ack != 0) { + m->status = MSG_STATUS_PENDING; + m->expected_ack = expected_ack; + // flood acks route back through repeaters and can take a long while; + // est_timeout models the direct case only - be patient like the app is + uint32_t wait = est_timeout * 4; + if (wait < 60000) wait = 60000; + m->timeout_at = millis() + wait; + } + } + } + refreshThread(); +} + +// --------------------------------------------------------------------------- +// toast +// --------------------------------------------------------------------------- +static void toast_del_cb(lv_timer_t* t) { + lv_obj_t* toast = (lv_obj_t*) lv_timer_get_user_data(t); + lv_obj_delete(toast); + lv_timer_delete(t); +} + +void UITask::openContactDetail(int contact_idx) { + ContactInfo c; + if (!the_mesh.getContactByIdx(contact_idx, c) || c.name[0] == 0) return; + detail_idx = contact_idx; + detail_remove_armed = false; + lv_label_set_text(detail_remove_lbl, LV_SYMBOL_TRASH " Remove"); + + lv_label_set_text(detail_title, c.name); + + const char* type_s = c.type == ADV_TYPE_CHAT ? "chat node" + : c.type == ADV_TYPE_REPEATER ? "repeater" + : c.type == ADV_TYPE_ROOM ? "room server" : "sensor"; + + char heard[32] = "never"; + if (c.last_advert_timestamp > 0) { + long secs = (long)rtc_clock.getCurrentTime() - (long)c.last_advert_timestamp; + if (secs < 0) secs = 0; + if (secs < 3600) snprintf(heard, sizeof(heard), "%ldm ago", secs / 60); + else if (secs < 86400) snprintf(heard, sizeof(heard), "%ldh ago", secs / 3600); + else snprintf(heard, sizeof(heard), "%ldd ago", secs / 86400); + } + + char path[32]; + if (c.out_path_len == OUT_PATH_UNKNOWN) strcpy(path, "flood (no path learned yet)"); + else snprintf(path, sizeof(path), "direct, %d hop%s", c.out_path_len, c.out_path_len == 1 ? "" : "s"); + + char pos[80] = "position: unknown"; + if (c.gps_lat != 0 || c.gps_lon != 0) { + double clat = c.gps_lat / 1000000.0, clon = c.gps_lon / 1000000.0; + LocationProvider* nmea = _sensors != NULL ? _sensors->getLocationProvider() : NULL; + if (nmea != NULL && nmea->isValid()) { + double mlat = nmea->getLatitude() / 1000000.0, mlon = nmea->getLongitude() / 1000000.0; + // haversine distance + initial bearing + double dlat = (clat - mlat) * M_PI / 180.0, dlon = (clon - mlon) * M_PI / 180.0; + double a = sin(dlat / 2) * sin(dlat / 2) + + cos(mlat * M_PI / 180.0) * cos(clat * M_PI / 180.0) * sin(dlon / 2) * sin(dlon / 2); + double dist_km = 6371.0 * 2.0 * atan2(sqrt(a), sqrt(1 - a)); + double y = sin(dlon) * cos(clat * M_PI / 180.0); + double x = cos(mlat * M_PI / 180.0) * sin(clat * M_PI / 180.0) - + sin(mlat * M_PI / 180.0) * cos(clat * M_PI / 180.0) * cos(dlon); + int brg = ((int)(atan2(y, x) * 180.0 / M_PI) + 360) % 360; + static const char* dirs[] = {"N","NE","E","SE","S","SW","W","NW"}; + double dist = units_miles ? dist_km * 0.621371 : dist_km; + snprintf(pos, sizeof(pos), "%.2f %s %s (%d deg)\n%.5f %.5f", + dist, units_miles ? "mi" : "km", dirs[((brg + 22) / 45) % 8], brg, clat, clon); + } else { + snprintf(pos, sizeof(pos), "%.5f %.5f", clat, clon); + } + } + + char info[220]; + snprintf(info, sizeof(info), "%s\nlast heard: %s\npath: %s\n%s", type_s, heard, path, pos); + lv_label_set_text(detail_info, info); + // trace only applies to nodes that relay it + if (c.type == ADV_TYPE_REPEATER || c.type == ADV_TYPE_ROOM) { + lv_obj_remove_flag(detail_trace_btn, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(detail_trace_btn, LV_OBJ_FLAG_HIDDEN); + } + lv_obj_remove_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::openTraceForContact(int contact_idx) { + ContactInfo c; + if (!the_mesh.getContactByIdx(contact_idx, c)) return; + detail_idx = contact_idx; + // only repeaters and room servers relay a trace; clients do not + if (c.type != ADV_TYPE_REPEATER && c.type != ADV_TYPE_ROOM) { + showToast("Trace measures repeaters only"); + return; + } + lv_label_set_text_fmt(trace_title, LV_SYMBOL_GPS " Trace path: %s", c.name); + if (c.out_path_len == OUT_PATH_UNKNOWN) { + lv_label_set_text(trace_lbl, "No path to this repeater yet.\n\nUse Set path to choose which\nrepeaters to trace through."); + lv_obj_remove_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); + return; + } + + // out through the known hops, the target repeater, then back the same way + uint8_t path[MAX_PATH_SIZE * 2 + 1]; + uint8_t n = 0; + for (int i = 0; i < c.out_path_len; i++) path[n++] = c.out_path[i]; + n += c.id.copyHashTo(&path[n]); + for (int i = c.out_path_len - 1; i >= 0; i--) path[n++] = c.out_path[i]; + + uint32_t tag = esp_random(); + if (tag == 0) tag = 1; + int est_timeout = the_mesh.uiTracePath(path, n, tag); + if (est_timeout <= 0) { + showToast("Trace send failed"); + return; + } + + _trace_tag = tag; + _trace_started = millis(); + _trace_deadline = millis() + est_timeout + 3000; + + lv_label_set_text(trace_lbl, "Tracing out and back...\n\nEach repeater on the path reports the\nlevel it heard. The reply lands here\nonly if this node can hear the first\nhop directly."); + lv_obj_remove_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::traceResponse(uint32_t tag, const uint8_t* path_hashes, const uint8_t* path_snrs, + uint8_t hop_count, int8_t final_snr) { + if (_trace_tag == 0 || tag != _trace_tag) return; + _trace_tag = 0; + + unsigned long rtt = millis() - _trace_started; + int out_hops = (hop_count + 1) / 2; // round trip turns around at the far end + + char buf[768]; // up to eight hops out and back, plus the round-trip line + int off = snprintf(buf, sizeof(buf), "Round trip %lu.%lus\n\n", rtt / 1000, (rtt % 1000) / 100); + for (int i = 0; i < hop_count && off < (int) sizeof(buf) - 48; i++) { + char nm[20]; + traceHashName(path_hashes[i], nm, sizeof(nm)); + off += snprintf(&buf[off], sizeof(buf) - off, "%s %.14s %+.1f dB\n", + i < out_hops ? LV_SYMBOL_RIGHT : LV_SYMBOL_LEFT, nm, (int8_t) path_snrs[i] / 4.0); + } + snprintf(&buf[off], sizeof(buf) - off, "%s You %+.1f dB", LV_SYMBOL_LEFT, final_snr / 4.0); + lv_label_set_text(trace_lbl, buf); +} + +void UITask::nodeNameChanged() { + if (_node_prefs != NULL) lv_label_set_text(lbl_node_name, _node_prefs->node_name); +} + +void UITask::updateThreadSubtitle() { + if (lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN)) return; + char buf[32] = ""; + if (!_thread_is_channel && !_thread_is_console) { + ContactInfo* c = the_mesh.lookupContactByPubKey(_thread_key, 6); + if (c != NULL) { + if (c->out_path_len == OUT_PATH_UNKNOWN) strcpy(buf, "flood"); + else if (c->out_path_len == 0) strcpy(buf, "direct"); + else snprintf(buf, sizeof(buf), "%d hop%s", c->out_path_len, c->out_path_len == 1 ? "" : "s"); + } + } + lv_label_set_text(thread_sub_lbl, buf); +} + +void UITask::resendMessage(int k) { + ChatMsg* m = chatStoreGet(_thread_key, k); + if (m == NULL || !m->outgoing || m->status != MSG_STATUS_NO_ACK) return; + char txt[200]; + StrHelper::strncpy(txt, m->text, sizeof(txt)); + sendFromThread(txt); + showToast("Resent"); +} + +void UITask::openChannelOptions(const uint8_t key[6]) { + int idx; + ChannelDetails ch; + if (!isChannelKey(key, &idx) || !the_mesh.getChannel(idx, ch) || ch.name[0] == 0) return; + chopt_idx = idx; + memcpy(chopt_key, key, 6); + chopt_remove_armed = false; + lv_label_set_text(chopt_remove_lbl, LV_SYMBOL_TRASH " Remove channel"); + lv_label_set_text_fmt(chopt_title, "#%s", ch.name); + lv_textarea_set_text(chopt_ta, ch.name); + lv_obj_remove_flag(chopt_scr, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::finishSetupWizard(int preset_idx, const char* name, int tz_hours, bool apply) { + if (apply) { + settingsApplyPreset(preset_idx); + if (name != NULL && name[0] != 0 && _node_prefs != NULL) { + StrHelper::strncpy(_node_prefs->node_name, name, sizeof(_node_prefs->node_name)); + the_mesh.savePrefs(); + nodeNameChanged(); + } + settingsSetTzOffset(tz_hours); + refreshStatusBar(); + refreshNodeTab(); + showToast("Setup complete"); + } + File f = SPIFFS.open("/setup_done", "w"); + if (f) { f.print(1); f.close(); } + wizard_pending = false; + lv_obj_add_flag(wiz_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::msgEchoHeard() { + if (millis() > _echo_window_end) return; // stale echo, window closed + _echo_count++; + if (echo_meta_lbl != NULL) { + lv_label_set_text_fmt(echo_meta_lbl, "%s " LV_SYMBOL_LOOP "%d", echo_meta_base, _echo_count); + } +} + +void UITask::showToast(const char* text) { + lv_obj_t* toast = lv_obj_create(lv_layer_top()); + lv_obj_set_size(toast, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_color(toast, lv_color_hex(COL_ACCENT_D), 0); + lv_obj_set_style_radius(toast, 8, 0); + lv_obj_set_style_pad_all(toast, 8, 0); + lv_obj_align(toast, LV_ALIGN_TOP_MID, 0, 30); + lv_obj_t* lbl = lv_label_create(toast); + lv_label_set_text(lbl, text); + lv_timer_create(toast_del_cb, 2000, toast); +} + +// --------------------------------------------------------------------------- +// mesh events +// --------------------------------------------------------------------------- +void UITask::msgRead(int msgcount) { + _msgcount = msgcount; + refreshStatusBar(); +} + +void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { + _msgcount = msgcount; + + bool in_open_thread = false; + uint8_t msg_key[6] = {0}; + bool have_key = false; + ContactInfo* from = the_mesh.searchContactsByPrefix(from_name); + if (from != NULL) { + memcpy(msg_key, from->id.pub_key, 6); + have_key = true; + } else { + for (int idx = 0; idx < MAX_GROUP_CHANNELS; idx++) { + ChannelDetails ch; + if (!the_mesh.getChannel(idx, ch)) break; + if (ch.name[0] == 0 || strcmp(ch.name, from_name) != 0) continue; + makeChannelKey(idx, msg_key); + have_key = true; + break; + } + } + if (have_key) { + chatStorePush(msg_key, false, rtc_clock.getCurrentTime(), text); + in_open_thread = !lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN) + && memcmp(_thread_key, msg_key, 6) == 0; + if (!in_open_thread) chatStoreUnreadBump(msg_key); + } + + if (in_open_thread) { + refreshThread(); + } else { + if (have_key) showNotifBanner(msg_key, from_name, text); + else { + char buf[48]; + snprintf(buf, sizeof(buf), LV_SYMBOL_ENVELOPE " %s", from_name); + showToast(buf); + } + refreshChatsTab(); + } + refreshStatusBar(); +} + +void UITask::msgAck(uint32_t ack_crc) { + if (chatStoreAck(ack_crc)) { + if (!lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN)) refreshThread(); + } +} + +void UITask::loginResult(const uint8_t* pub_key, bool success) { + // only react to the login we're actually waiting on (phone app logins and + // stale responses for other repeaters shouldn't clear state or toast) + if (_pending_login_deadline != 0 && memcmp(_pending_login_key, pub_key, 6) == 0) { + _pending_login_deadline = 0; + showToast(success ? "Login OK" : "Login failed"); + } + if (memcmp(_rep_key, pub_key, 6) == 0) { + _rep_logging_in = false; + _rep_logged_in = success; + if (success && rep_pending_pw[0] != 0 && rep_pending_remember) { + savedPwSet(_rep_key, rep_pending_pw); + } else if (!success && rep_pending_pw[0] != 0) { + savedPwForget(_rep_key); // stale saved password: drop it + } + rep_pending_pw[0] = 0; + if (success) { + ContactInfo* c = the_mesh.lookupContactByPubKey(_rep_key, 6); + if (c != NULL && c->out_path_len != OUT_PATH_UNKNOWN) rep_flood_on = false; + } + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); + if (success && rep_auto_refresh) requestRepeaterStatus(); // opt-in + } +} + +const char* UITask::savedRepeaterPw() { + return savedPwFor(_rep_key); +} + +void UITask::requestRepeaterStatus() { + ContactInfo* c = the_mesh.lookupContactByPubKey(_rep_key, 6); + if (c == NULL) return; + if (the_mesh.uiRequestStatus(*c) == MSG_SEND_FAILED) { + showToast("Status request failed"); + } else { + rep_stats_waiting = true; + rep_stats_valid = false; + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); + } +} + +void UITask::cliResponse(const char* from_name, const char* text) { + ContactInfo* from = the_mesh.searchContactsByPrefix(from_name); + if (from == NULL) return; + chatStorePush(from->id.pub_key, false, rtc_clock.getCurrentTime(), text); + + if (memcmp(_rep_key, from->id.pub_key, 6) == 0 + && !lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) { + StrHelper::strncpy(rep_last_reply, text, sizeof(rep_last_reply)); + refreshRepeaterScr(); + return; + } + if (!lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN) + && memcmp(_thread_key, from->id.pub_key, 6) == 0) { + refreshThread(); // terminal is open: reply appears in place + } else { + char buf[48]; + snprintf(buf, sizeof(buf), LV_SYMBOL_KEYBOARD " %s replied", from_name); + showToast(buf); + } +} + +void UITask::statusResponse(const uint8_t* pub_key, const uint8_t* data, int len) { + if (memcmp(_rep_key, pub_key, 6) != 0 || len <= 0) return; + memset(&rep_stats, 0, sizeof(rep_stats)); + memcpy(&rep_stats, data, len < (int)sizeof(rep_stats) ? len : (int)sizeof(rep_stats)); + rep_stats_valid = true; + rep_stats_waiting = false; + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); +} + +void UITask::notify(UIEventType t) { + if (_node_prefs != NULL && _node_prefs->buzzer_quiet) return; // sounds off + switch (t) { + case UIEventType::contactMessage: + case UIEventType::newContactMessage: + soundMessageTone(); + break; + case UIEventType::channelMessage: + case UIEventType::roomMessage: + soundChannelTone(); + break; + case UIEventType::ack: + soundAckTone(); + break; + default: + break; + } +} + +void UITask::shutdown(bool restart) { + if (restart) _board->reboot(); + else _board->powerOff(); +} + +void UITask::loop() { + lv_timer_handler(); + chatStoreFlushLoop(); + + if (millis() > _next_status_refresh) { + refreshStatusBar(); + if (!lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN)) { + updateThreadSubtitle(); // route can change as paths are learned + for (int k = 0; ; k++) { + ChatMsg* m = chatStoreGet(_thread_key, k); + if (m == NULL) break; + if (m->outgoing && m->status == MSG_STATUS_PENDING && millis() > m->timeout_at) { + refreshThread(); + break; + } + } + } else if (lv_tabview_get_tab_active(tabview) == 3) { // Settings: telemetry card + refreshNodeTab(); + } else if (lv_tabview_get_tab_active(tabview) == 2) { // Map tab: track GPS + mapViewRefresh(); + } + if (rep_auto_refresh && !lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN) && _rep_logged_in) { + static unsigned long next_rep_auto = 0; + if (millis() > next_rep_auto) { + next_rep_auto = millis() + 30000; + requestRepeaterStatus(); // keep the admin dashboard live + } + } + if (_trace_tag != 0 && millis() > _trace_deadline) { + _trace_tag = 0; + if (!lv_obj_has_flag(trace_scr, LV_OBJ_FLAG_HIDDEN)) { + lv_label_set_text(trace_lbl, + "No reply - trace timed out.\n\nA trace returns through the same\nhops, so hop 1 must be a repeater\nthis node can hear directly. Check\nthe path with Set path, or a hop is\noffline."); + } + } + _next_status_refresh = millis() + 2000; + } + +#ifdef SEEED_WIO_TRACKER_L2 + // WAKE button (top edge, on the IO expander): toggles screen lock + if (millis() > _next_wake_poll) { + _next_wake_poll = millis() + 150; + bool pressed = board.readWakeButton(); + if (pressed && !_wake_prev) { + if (_display_asleep) { + screenPower(false); + display.lgfxDevice()->setBrightness(brightRaw()); + bright_dimmed = false; + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + lv_display_trigger_activity(NULL); + _display_asleep = false; + } else { + display.lgfxDevice()->setBrightness(0); + bright_dimmed = false; + lv_obj_remove_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + _display_asleep = true; + _lock_grace = millis() + 1200; + screenPower(true); + } + } + _wake_prev = pressed; + } +#endif + + // low battery: warn and power off (never while externally powered) + static unsigned long next_batt_check = 30000; + if (millis() > next_batt_check) { + next_batt_check = millis() + 30000; + uint16_t mv = getBattMilliVolts(); + // re-applied each pass: the stack restarts fast advertising on disconnect + static unsigned long last_ble_conn = 0; + if (hasConnection()) { last_ble_conn = millis(); ble_slow = false; } + bool want_slow = ble_autooff_min > 0 && !ble_off_pref && isBluetoothEnabled() && !hasConnection() && + millis() - last_ble_conn > (unsigned long) ble_autooff_min * 60000UL; + if (want_slow && !ble_slow) bleSlowAdvertising(true); + static bool low_warned = false; + if (_board->isExternalPowered()) low_warned = false; + if (mv > 500 && mv < 3450 && !low_warned && !_board->isExternalPowered()) { + low_warned = true; + showToast(LV_SYMBOL_BATTERY_1 " Battery low - charge soon"); + soundChannelTone(); + } + if (mv > 500 && mv < 3250 && !_board->isExternalPowered()) { + display.lgfxDevice()->setBrightness(brightRaw()); + showToast("LOW BATTERY - shutting down"); + lv_refr_now(NULL); + delay(3000); + shutdown(false); + } + } + + // backlight auto-dim on inactivity; the sleep shield eats the wake-up tap + // (stay awake while externally powered - LCD has no burn-in concern) + uint32_t idle = lv_display_get_inactive_time(NULL); + if (!_display_asleep && idle > autoOffMs() && _board->isExternalPowered()) { + lv_display_trigger_activity(NULL); + idle = 0; + } + if (!_display_asleep) { + bool want_dim = idle > autoOffMs() - DIM_LEAD_MILLIS && idle <= autoOffMs(); + if (want_dim && !bright_dimmed) { + uint8_t dim = brightRaw() / 4; + display.lgfxDevice()->setBrightness(dim < 12 ? 12 : dim); + bright_dimmed = true; + } else if (!want_dim && bright_dimmed && idle < autoOffMs()) { + display.lgfxDevice()->setBrightness(brightRaw()); + bright_dimmed = false; + } + } + if (!_display_asleep && idle > autoOffMs()) { + display.lgfxDevice()->setBrightness(0); + bright_dimmed = false; + lv_obj_remove_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + _display_asleep = true; + screenPower(true); + } else if (_display_asleep && idle < 1000 && millis() > _lock_grace) { + screenPower(false); + display.lgfxDevice()->setBrightness(brightRaw()); + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + _display_asleep = false; + } + +#ifndef STANDALONE_NO_BT + // Bluetooth off preference: apply once the interface is up + if (!ble_pref_applied && millis() > 4000) { + ble_pref_applied = true; + if (ble_off_pref && isBluetoothEnabled()) disableBluetooth(); + } +#endif + + static unsigned long next_gps_diag = 0; + if (millis() > next_gps_diag) { + next_gps_diag = millis() + 10000; + if (_node_prefs != NULL && _node_prefs->gps_enabled && _sensors != NULL) { + gpsWatchdogTick(); + LocationProvider* n = _sensors->getLocationProvider(); + MESH_DEBUG_PRINTLN("gps: used=%d valid=%d inview=%d strong=%d bestsnr=%d gga=%d/%d rmc=%c nmea=%d | heap int=%u largest=%u", + n != NULL ? (int)n->satellitesCount() : -1, + n != NULL ? (int)n->isValid() : 0, + gps_tap.satsInView(), gps_tap.strongSats(), gps_tap.bestSnr(), + gps_tap.ggaFix(), gps_tap.ggaUsed(), gps_tap.rmcStatus(), (int) gps_tap.streaming(), + (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)); + } + } + + if (_pending_login_deadline != 0 && millis() > _pending_login_deadline) { + _pending_login_deadline = 0; + _rep_logging_in = false; + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); + showToast("Login: no response - check password/range"); + } +} diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h new file mode 100644 index 0000000000..535387d117 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../AbstractUITask.h" +#include "../NodePrefs.h" +#include "ChatStore.h" + +struct ContactInfo; + +// LVGL-based standalone UI: status bar + bottom tabs (Chats/Contacts/Node/ +// Settings), message threads with bubbles, on-screen keyboard. +class UITask : public AbstractUITask { + SensorManager* _sensors; + NodePrefs* _node_prefs; + int _msgcount; + bool _display_asleep; + unsigned long _next_status_refresh; + uint8_t _pending_login_key[6] = {0}; + unsigned long _pending_login_deadline = 0; + bool _wake_prev = false; + unsigned long _next_wake_poll = 0; + unsigned long _lock_grace = 0; // suppress auto-wake right after button lock + bool _thread_clear_armed = false; + + // active thread (chat screen) + uint8_t _thread_key[6] = {0}; + bool _thread_is_channel = false; + bool _thread_is_console = false; // repeater terminal: sends are CLI commands + int _thread_channel_idx = -1; + char _thread_name[36] = {0}; + + // repeater manager state + uint8_t _rep_key[6] = {0}; + char _rep_name[36] = {0}; + bool _rep_logged_in = false; + bool _rep_logging_in = false; + + // trace route state (contact detail Trace button) + uint32_t _trace_tag = 0; + unsigned long _trace_started = 0; + unsigned long _trace_deadline = 0; + + // repeat-echo counter for the last message sent from the open thread + int _echo_count = 0; + unsigned long _echo_window_end = 0; + uint32_t _echo_msg_ts = 0; // which bubble the counter belongs to + + void updateThreadSubtitle(); // route (flood/hops) + echo count in header + + void buildShell(); + void buildRepeaterScr(); + void refreshRepeaterScr(); + void buildChatsTab(lv_obj_t* parent); + void buildContactsTab(lv_obj_t* parent); + void buildNodeTab(lv_obj_t* parent); + void buildSettingsTab(lv_obj_t* parent); + void refreshStatusBar(); + +public: + void refreshChatsTab(); + void refreshContactsTab(); + void refreshNodeTab(); + UITask(mesh::MainBoard* board, MultiSerialInterface* serial) + : AbstractUITask(board, serial), _sensors(NULL), _node_prefs(NULL), + _msgcount(0), _display_asleep(false), _next_status_refresh(0) { } + + void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); + + // navigation / actions (called from LVGL event callbacks) + void openThread(const uint8_t key[6], const char* name); + void openContactThread(const ContactInfo& contact); + void openConsoleThread(); // repeater terminal on _rep_key + void closeThread(); + void refreshThread(); + void sendFromThread(const char* text); + void openRepeaterManager(const ContactInfo& contact); + void closeRepeaterManager(); + void repeaterLogin(const char* password, bool flood = false); + void sendRepeaterCommand(const char* cmd); + bool threadIsConsole() const { return _thread_is_console; } + void showToast(const char* text); + void nodeNameChanged(); // refresh status bar after settings edit + void openContactDetail(int contact_idx); // long-press on contact / map marker + void clearCurrentThread(); // trash button in thread header (armed) + void openRouteForThread(); // route button in DM thread header + void openTraceForContact(int contact_idx); // trace button in contact detail + void requestRepeaterStatus(); // GUI manager status refresh + void resendMessage(int k); // tap a not-delivered bubble + void openChannelOptions(const uint8_t key[6]); // long-press a channel row + void finishSetupWizard(int preset_idx, const char* name, int tz_hours, bool apply); + const char* savedRepeaterPw(); // saved password for current repeater, or NULL + int getMsgCount() const { return _msgcount; } + NodePrefs* nodePrefs() { return _node_prefs; } + SensorManager* sensors() { return _sensors; } + + // from AbstractUITask + void msgRead(int msgcount) override; + void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override; + void msgAck(uint32_t ack_crc) override; + void msgEchoHeard() override; + void loginResult(const uint8_t* pub_key, bool success) override; + void statusResponse(const uint8_t* pub_key, const uint8_t* data, int len) override; + void cliResponse(const char* from_name, const char* text) override; + void traceResponse(uint32_t tag, const uint8_t* path_hashes, const uint8_t* path_snrs, uint8_t hop_count, int8_t final_snr) override; + void notify(UIEventType t = UIEventType::none) override; + void loop() override; + + void shutdown(bool restart = false); +}; + +// phone-style shift for LVGL keyboards: one-shot upper case, double-tap for caps lock +void kbAttachShiftBehavior(lv_obj_t* kb); diff --git a/examples/companion_radio/ui-lvgl/fa_icons_14.cpp b/examples/companion_radio/ui-lvgl/fa_icons_14.cpp new file mode 100644 index 0000000000..f904a8e297 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/fa_icons_14.cpp @@ -0,0 +1,198 @@ +/******************************************************************************* + * Size: 14 px + * Bpp: 4 + * Opts: --font fa-solid-900.ttf -r 0xF519 -r 0xF2B9 -r 0xF185 -r 0xF186 --size 14 --bpp 4 --format lvgl --lv-font-name fa_icons_14 --no-compress -o fa_icons_14.c + ******************************************************************************/ + +#ifdef LV_LVGL_H_INCLUDE_SIMPLE +#include "lvgl.h" +#else +#include "lvgl/lvgl.h" +#endif + +LV_FONT_DECLARE(lv_font_montserrat_14) + +#ifdef __cplusplus +extern "C" { +#endif + +extern const lv_font_t fa_icons_14; + +#ifndef FA_ICONS_14 +#define FA_ICONS_14 1 +#endif + +#if FA_ICONS_14 + +/*----------------- + * BITMAPS + *----------------*/ + +/*Store the image of the glyphs*/ +static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { + /* U+F185 */ + 0x0, 0x0, 0x0, 0x12, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0xb, 0xc0, 0x0, 0x0, 0x0, 0x0, + 0x61, 0x3, 0xff, 0x40, 0x15, 0x0, 0x0, 0xe, + 0xfc, 0xcf, 0xfd, 0xcf, 0xf0, 0x0, 0x0, 0x9f, + 0xf8, 0x33, 0x7f, 0xfa, 0x0, 0x0, 0x4, 0xf5, + 0x6d, 0xd6, 0x4f, 0x50, 0x0, 0x5, 0xdc, 0x3f, + 0xff, 0xf4, 0xbd, 0x50, 0xc, 0xff, 0x88, 0xff, + 0xff, 0x97, 0xff, 0xc0, 0x4d, 0xf9, 0x7f, 0xff, + 0xf8, 0x9f, 0xd5, 0x0, 0x6, 0xe1, 0xdf, 0xfe, + 0x1e, 0x70, 0x0, 0x0, 0x7f, 0xb1, 0x55, 0x1b, + 0xf8, 0x0, 0x0, 0xc, 0xff, 0xfb, 0xbe, 0xff, + 0xd0, 0x0, 0x0, 0xd9, 0x47, 0xff, 0x84, 0x9d, + 0x0, 0x0, 0x0, 0x0, 0xe, 0xe0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x66, 0x0, 0x0, 0x0, + 0x0, + + /* U+F186 */ + 0x0, 0x0, 0x0, 0x1, 0x10, 0x0, 0x0, 0x0, + 0x0, 0x5c, 0xff, 0xf2, 0x0, 0x0, 0x0, 0xa, + 0xff, 0xfe, 0x20, 0x0, 0x0, 0x0, 0xaf, 0xff, + 0xf3, 0x0, 0x0, 0x0, 0x5, 0xff, 0xff, 0xb0, + 0x0, 0x0, 0x0, 0xd, 0xff, 0xff, 0x70, 0x0, + 0x0, 0x0, 0x1f, 0xff, 0xff, 0x60, 0x0, 0x0, + 0x0, 0x3f, 0xff, 0xff, 0x80, 0x0, 0x0, 0x0, + 0x2f, 0xff, 0xff, 0xd0, 0x0, 0x0, 0x0, 0xf, + 0xff, 0xff, 0xf6, 0x0, 0x0, 0x0, 0xb, 0xff, + 0xff, 0xff, 0x50, 0x0, 0x0, 0x3, 0xff, 0xff, + 0xff, 0xfb, 0x52, 0x30, 0x0, 0x7f, 0xff, 0xff, + 0xff, 0xff, 0xd1, 0x0, 0x6, 0xff, 0xff, 0xff, + 0xfc, 0x10, 0x0, 0x0, 0x17, 0xbd, 0xc9, 0x40, + 0x0, + + /* U+F2B9 */ + 0x3, 0x44, 0x44, 0x44, 0x43, 0x10, 0xc, 0xff, + 0xff, 0xff, 0xff, 0xfe, 0x0, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xf2, 0xf, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xa2, 0xff, 0xff, 0xb4, 0xaf, 0xff, 0xff, + 0x3f, 0xff, 0xf1, 0x0, 0xef, 0xff, 0x40, 0xff, + 0xff, 0x0, 0xe, 0xff, 0xf3, 0xf, 0xff, 0xfb, + 0x49, 0xff, 0xff, 0xf3, 0xff, 0xfe, 0xac, 0xad, + 0xff, 0xfb, 0x2f, 0xfd, 0x0, 0x0, 0xb, 0xff, + 0x20, 0xff, 0xa0, 0x0, 0x0, 0x7f, 0xf8, 0x1f, + 0xfe, 0x88, 0x88, 0x8d, 0xff, 0xf3, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xf6, 0xf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x20, 0x9f, 0xff, 0xff, 0xff, 0xff, + 0xb0, 0x0, + + /* U+F519 */ + 0x2, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, + 0x10, 0x6f, 0x60, 0x0, 0x0, 0x0, 0x0, 0x0, + 0xd, 0xf0, 0xbf, 0x11, 0x61, 0x0, 0x52, 0x0, + 0x54, 0x8, 0xf4, 0xed, 0x6, 0xf6, 0xb, 0xff, + 0x30, 0xed, 0x4, 0xf7, 0xfc, 0x7, 0xf4, 0xf, + 0xff, 0x70, 0xcf, 0x3, 0xf8, 0xee, 0x5, 0xf6, + 0xd, 0xff, 0x50, 0xed, 0x5, 0xf7, 0xaf, 0x20, + 0x10, 0x3f, 0xdf, 0xb0, 0x10, 0x9, 0xf3, 0x3d, + 0x50, 0x0, 0xaf, 0x4c, 0xf2, 0x0, 0xb, 0xb0, + 0x0, 0x0, 0x1, 0xfc, 0x4, 0xf9, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x8, 0xfd, 0xbb, 0xff, 0x0, + 0x0, 0x0, 0x0, 0x0, 0xe, 0xff, 0xff, 0xff, + 0x60, 0x0, 0x0, 0x0, 0x0, 0x5f, 0x80, 0x0, + 0x1f, 0xd0, 0x0, 0x0, 0x0, 0x0, 0xcf, 0x10, + 0x0, 0x9, 0xf4, 0x0, 0x0, 0x0, 0x2, 0xfa, + 0x0, 0x0, 0x2, 0xfa, 0x0, 0x0, 0x0, 0x0, + 0x62, 0x0, 0x0, 0x0, 0x62, 0x0, 0x0 +}; + + +/*--------------------- + * GLYPH DESCRIPTION + *--------------------*/ + +static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = { + {.bitmap_index = 0, .adv_w = 0, .box_w = 0, .box_h = 0, .ofs_x = 0, .ofs_y = 0} /* id = 0 reserved */, + {.bitmap_index = 0, .adv_w = 224, .box_w = 15, .box_h = 15, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 113, .adv_w = 224, .box_w = 14, .box_h = 15, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 218, .adv_w = 196, .box_w = 13, .box_h = 15, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 316, .adv_w = 280, .box_w = 18, .box_h = 15, .ofs_x = 0, .ofs_y = -2} +}; + +/*--------------------- + * CHARACTER MAPPING + *--------------------*/ + +static const uint16_t unicode_list_0[] = { + 0x0, 0x1, 0x134, 0x394 +}; + +/*Collect the unicode lists and glyph_id offsets*/ +static const lv_font_fmt_txt_cmap_t cmaps[] = +{ + { + .range_start = 61829, .range_length = 917, .glyph_id_start = 1, + .unicode_list = unicode_list_0, .glyph_id_ofs_list = NULL, .list_length = 4, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY + } +}; + + + +/*-------------------- + * ALL CUSTOM DATA + *--------------------*/ + +#if LVGL_VERSION_MAJOR == 8 +/*Store all the custom data of the font*/ +static lv_font_fmt_txt_glyph_cache_t cache; +#endif + +#if LVGL_VERSION_MAJOR >= 8 +static const lv_font_fmt_txt_dsc_t font_dsc = { +#else +static lv_font_fmt_txt_dsc_t font_dsc = { +#endif + .glyph_bitmap = glyph_bitmap, + .glyph_dsc = glyph_dsc, + .cmaps = cmaps, + .kern_dsc = NULL, + .kern_scale = 0, + .cmap_num = 1, + .bpp = 4, + .kern_classes = 0, + .bitmap_format = 0, +#if LVGL_VERSION_MAJOR == 8 + .cache = &cache +#endif +}; + + + +/*----------------- + * PUBLIC FONT + *----------------*/ + +/*Initialize a public general font descriptor*/ +#if LVGL_VERSION_MAJOR >= 8 +const lv_font_t fa_icons_14 = { +#else +lv_font_t fa_icons_14 = { +#endif + .get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt, /*Function pointer to get glyph's data*/ + .get_glyph_bitmap = lv_font_get_bitmap_fmt_txt, /*Function pointer to get glyph's bitmap*/ + .line_height = 15, /*The maximum line height required by the font*/ + .base_line = 2, /*Baseline measured from the bottom of the line*/ +#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0) + .subpx = LV_FONT_SUBPX_NONE, +#endif +#if LV_VERSION_CHECK(7, 4, 0) || LVGL_VERSION_MAJOR >= 8 + .underline_position = -1, + .underline_thickness = 1, +#endif + .dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */ +#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9 + .fallback = &lv_font_montserrat_14, +#endif + .user_data = NULL, +}; + + + +#endif /*#if FA_ICONS_14*/ + +#ifdef __cplusplus +} /* extern "C" */ +#endif + diff --git a/examples/companion_radio/ui-lvgl/lv_psram_pool.h b/examples/companion_radio/ui-lvgl/lv_psram_pool.h new file mode 100644 index 0000000000..ede3b2b017 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/lv_psram_pool.h @@ -0,0 +1,10 @@ +#pragma once + +// LVGL's builtin allocator pool, placed in PSRAM: keeps every widget, image +// decode, and cache allocation out of internal RAM so the Bluetooth stack +// and SD driver always have room for their connection-time bursts. +#include + +static inline void* lv_psram_pool_alloc(size_t sz) { + return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); +} diff --git a/examples/companion_radio/ui-lvgl/meshcore_logo.cpp b/examples/companion_radio/ui-lvgl/meshcore_logo.cpp new file mode 100644 index 0000000000..4abd15d87b --- /dev/null +++ b/examples/companion_radio/ui-lvgl/meshcore_logo.cpp @@ -0,0 +1,37 @@ +// MeshCore wordmark for the boot splash. The bitmap is the same one the other +// display UIs draw; it is expanded to a 2x alpha mask so LVGL can scale it into +// place and tint it from the widget style. +#include +#include "../ui-new/icons.h" +#include "lv_psram_pool.h" + +#define LOGO_W 128 +#define LOGO_H 13 +#define LOGO_ZOOM 2 + +static lv_image_dsc_t logo_dsc; + +const lv_image_dsc_t* meshcoreLogoImage() { + if (logo_dsc.data != NULL) return &logo_dsc; + + const uint32_t w = LOGO_W * LOGO_ZOOM, h = LOGO_H * LOGO_ZOOM; + uint8_t* mask = (uint8_t *) lv_psram_pool_alloc(w * h); + if (mask == NULL) return NULL; + + for (uint32_t y = 0; y < h; y++) { + const uint8_t* row = &meshcore_logo[(y / LOGO_ZOOM) * (LOGO_W / 8)]; + for (uint32_t x = 0; x < w; x++) { + uint32_t sx = x / LOGO_ZOOM; + mask[y * w + x] = (row[sx >> 3] & (0x80 >> (sx & 7))) ? 0xff : 0x00; + } + } + + logo_dsc.header.magic = LV_IMAGE_HEADER_MAGIC; + logo_dsc.header.cf = LV_COLOR_FORMAT_A8; + logo_dsc.header.w = w; + logo_dsc.header.h = h; + logo_dsc.header.stride = w; + logo_dsc.data_size = w * h; + logo_dsc.data = mask; + return &logo_dsc; +} diff --git a/src/helpers/ui/UIScreen.h b/src/helpers/ui/UIScreen.h index 6aa1d69c10..8f694d7d5a 100644 --- a/src/helpers/ui/UIScreen.h +++ b/src/helpers/ui/UIScreen.h @@ -20,6 +20,7 @@ class UIScreen { public: virtual int render(DisplayDriver& display) =0; // return value is number of millis until next render virtual bool handleInput(char c) { return false; } + virtual bool handleTouch(int x, int y) { return false; } // logical display coords, tap-on-release virtual void poll() { } };