From 257ed842f9379f20aafdc7f4bff268c20b884cd7 Mon Sep 17 00:00:00 2001 From: my Date: Thu, 10 Sep 2026 15:06:12 +0200 Subject: [PATCH] initial working version --- .gitignore | 8 + .vscode/extensions.json | 9 + data/config.txt | 17 + data/notes.txt | 1 + include/config.h | 95 ++++ include/lv_conf.h | 12 + platformio.ini | 45 ++ src/meshberry/cardkb.cpp | 39 ++ src/meshberry/cardkb.h | 3 + src/meshberry/chat.cpp | 801 ++++++++++++++++++++++++++++++++ src/meshberry/chat.h | 12 + src/meshberry/idf_component.yml | 2 + src/meshberry/main.cpp | 49 ++ src/meshberry/settings.cpp | 172 +++++++ src/meshberry/settings.h | 28 ++ src/meshberry/sound.cpp | 12 + src/meshberry/sound.h | 5 + src/meshberry/ui.cpp | 290 ++++++++++++ src/meshberry/ui.h | 23 + 19 files changed, 1623 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 data/config.txt create mode 100644 data/notes.txt create mode 100644 include/config.h create mode 100644 include/lv_conf.h create mode 100644 platformio.ini create mode 100644 src/meshberry/cardkb.cpp create mode 100644 src/meshberry/cardkb.h create mode 100644 src/meshberry/chat.cpp create mode 100644 src/meshberry/chat.h create mode 100644 src/meshberry/idf_component.yml create mode 100644 src/meshberry/main.cpp create mode 100644 src/meshberry/settings.cpp create mode 100644 src/meshberry/settings.h create mode 100644 src/meshberry/sound.cpp create mode 100644 src/meshberry/sound.h create mode 100644 src/meshberry/ui.cpp create mode 100644 src/meshberry/ui.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d5ef49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.pio +.cache +.dummy +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch +compile_commands.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..b397401 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "Jason2866.esp-decoder", + "pioarduino.pioarduino-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/data/config.txt b/data/config.txt new file mode 100644 index 0000000..f29256d --- /dev/null +++ b/data/config.txt @@ -0,0 +1,17 @@ +#nick=your nick, picked randomly on first run +brightness=200 + +msg_freq=1800 +msg_ms=20 +sys_freq=440 +sys_ms=10 + +touch_threshold=300 + +color_bg=ffffff +color_header_bg=999999 +color_msg=dddddd +color_nick_other=44ff44 +color_nick_own=ffdd00 +color_system=666666 +color_input_prompt=99ff00 \ No newline at end of file diff --git a/data/notes.txt b/data/notes.txt new file mode 100644 index 0000000..6e5827a --- /dev/null +++ b/data/notes.txt @@ -0,0 +1 @@ +Your notes will go here. \ No newline at end of file diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..497ab21 --- /dev/null +++ b/include/config.h @@ -0,0 +1,95 @@ +#pragma once + +// Display (ST7789, SPI) +// Source: https://www.lcdwiki.com/2.8inch_ESP32-32E-7789 +// Driver: LovyanGFX with Panel_ST7789 + +#define PIN_TFT_MOSI 13 +#define PIN_TFT_MISO 12 +#define PIN_TFT_SCLK 14 +#define PIN_TFT_CS 15 +#define PIN_TFT_DC 2 +#define PIN_TFT_RST -1 +#define PIN_TFT_BL 21 // Backlight + +#define PANEL_NATIVE_W 240 +#define PANEL_NATIVE_H 320 + +// Dimensions after rotation, for sanity. +#define TFT_W 320 +#define TFT_H 240 + + +// Touchscreen (XPT2046, software SPI) +// unused for now, because shared pins with KB i2c. +#define PIN_BOOT_BTN 0 +#define PIN_TOUCH_IRQ 36 +#define PIN_TOUCH_MOSI 32 +#define PIN_TOUCH_MISO 39 +#define PIN_TOUCH_CLK 25 +#define PIN_TOUCH_CS 33 +// TODO: should calibrate if used. +#define TOUCH_X_MIN 200 +#define TOUCH_X_MAX 3800 +#define TOUCH_Y_MIN 200 +#define TOUCH_Y_MAX 3800 + + +// CardKB (M5Stack v1.1, I2C) +// SDA/GPIO21 is taken by the display backlight. +#define PIN_KB_SDA 23 +#define PIN_KB_SCL 19 +#define CARDKB_ADDR 0x5F +#define CARDKB_I2C_HZ 100000UL +// CardKB key byte values +#define KB_KEY_ENTER 0x0D +#define KB_KEY_BACKSPACE 0x08 +#define KB_KEY_TAB 0x09 +#define KB_KEY_ESC 0x1B + +// Notes mode / BT dump +#define BT_DUMP_COUNTDOWN_S 3 +#define NOTES_FILE "/notes.txt" + + +// ESP-NOW Chat +#define CHAT_PING_INTERVAL_MS 500 +#define CHAT_PEER_TIMEOUT_MS 2000 +#define CHAT_DEDUP_HISTORY 32 +#define CHAT_MAX_MESSAGES 20 +#define CHAT_MAX_MSG_LEN 100 +#define CHAT_MAX_NICK_LEN 16 +#define CHAT_MAX_PEERS 30 +#define INPUT_MAX_LEN (CHAT_MAX_MSG_LEN - 1) +#define NICK_DEFAULT_PREFIX "user" + +// UI layout constants +#define UI_HEADER_H 20 // top status bar height +#define UI_INPUT_H 20 // bottom input line height +#define UI_MSG_AREA_Y (UI_HEADER_H + 1) +#define UI_MSG_AREA_H (TFT_H - UI_HEADER_H - UI_INPUT_H - 2) +#define UI_INPUT_Y (TFT_H - UI_INPUT_H) +#define UI_LINE_H 18 // Approximate +#define UI_VISIBLE_LINES (UI_MSG_AREA_H / UI_LINE_H) + +#define PIN_LED_RED 22 +#define PIN_LED_GREEN 16 +#define PIN_LED_BLUE 17 + +// SD card reader (unused) +// note: some same pins as cardKB! +#define PIN_SD_CS 5 +#define PIN_SD_MOSI 23 +#define PIN_SD_SCLK 18 +#define PIN_SD_MISO 19 + + +// Speaker +#define PIN_SPEAKER 26 +#define PIN_SPEAKER_EN 4 + +// Misc +#define SERIAL_BAUD 115200 + +#define PIN_TOUCH7 27 +#define TOUCH_THRESHOLD 200 /* Greater the value, more the sensitivity */ diff --git a/include/lv_conf.h b/include/lv_conf.h new file mode 100644 index 0000000..6dbf62b --- /dev/null +++ b/include/lv_conf.h @@ -0,0 +1,12 @@ +#ifndef LV_CONF_H +#define LV_CONF_H + +#define LV_USE_LOG 1 +#define LV_COLOR_DEPTH 16 +#define LV_MEM_SIZE (24 * 1024U) +#define LV_COLOR_16_SWAP 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_USE_FLEX 1 +#define LV_USE_LABEL 1 + +#endif /* LV_CONF_H */ diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..440f7cc --- /dev/null +++ b/platformio.ini @@ -0,0 +1,45 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:cyberdeck-chat] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip +board = esp32dev +framework = arduino +board_build.flash_size = 4MB +board_build.partitions = huge_app.csv +board_build.filesystem = littlefs +monitor_speed = 115200 +upload_speed = 921600 +;monitor_filters = send_on_enter +build_src_filter = + - +lib_deps = + lovyan03/LovyanGFX @ ^1.2 + lvgl/lvgl @ ^9.0 + ; t-vk/ESP32 BLE Keyboard@^0.3.2 + https://github.com/sakul-the-one/sakuls-ESP32-BLE-Keyboard + h2zero/NimBLE-Arduino +build_flags = + -DLV_CONF_INCLUDE_SIMPLE + -I include + ; We only need HID peripheral from NimBLE + ; saving some memory here. + -DCONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED + -DCONFIG_BT_NIMBLE_ROLE_OBSERVER_DISABLED + +[env:evilchat-relay] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip +board = esp32dev +framework = arduino +monitor_speed = 115200 +upload_speed = 921600 +build_src_filter = - + ; ignores root +lib_deps = +build_flags = + -I include \ No newline at end of file diff --git a/src/meshberry/cardkb.cpp b/src/meshberry/cardkb.cpp new file mode 100644 index 0000000..9aca780 --- /dev/null +++ b/src/meshberry/cardkb.cpp @@ -0,0 +1,39 @@ +#include "cardkb.h" +#include "config.h" +#include +#include "esp_log.h" +#include + +static TwoWire kbWire(1); // Use other I2C bus + +void cardkb_init() { + esp_log_level_set("esp32-hal-i2c-ng", ESP_LOG_NONE); + esp_log_level_set("Wire", ESP_LOG_NONE); + pinMode(PIN_KB_SDA, INPUT_PULLUP); + pinMode(PIN_KB_SCL, INPUT_PULLUP); + kbWire.begin(PIN_KB_SDA, PIN_KB_SCL); + kbWire.setClock(CARDKB_I2C_HZ); +} + +char cardkb_read() { + static bool present = false; + static unsigned long lastRetry = 0; + + // test if the keyboard is connected, + // prevent endless logging of errors + if (!present) { + unsigned long now = millis(); + if (now - lastRetry < 1000) return 0; + lastRetry = now; + } + + uint8_t got = kbWire.requestFrom((uint8_t)CARDKB_ADDR, (uint8_t)1); + if (!got) { + present = false; + return 0; + } + present = true; + + char key = kbWire.available() ? (char)kbWire.read() : 0; + return key; +} diff --git a/src/meshberry/cardkb.h b/src/meshberry/cardkb.h new file mode 100644 index 0000000..b1f61f6 --- /dev/null +++ b/src/meshberry/cardkb.h @@ -0,0 +1,3 @@ +#pragma once +void cardkb_init(); +char cardkb_read(); diff --git a/src/meshberry/chat.cpp b/src/meshberry/chat.cpp new file mode 100644 index 0000000..d8d4a7c --- /dev/null +++ b/src/meshberry/chat.cpp @@ -0,0 +1,801 @@ +// chat.cpp, EvilChatMesh ported to ESP32 CYD + M5Stack CardKB +// +// Original source : Evil-Cardputer-v1-5-2.ino, lines 20480-20871 +// Original author : 7h30th3r0n3, https://github.com/7h30th3r0n3/Evil-M5Project +// +// I've tried to keep the original code as much as possible as it was +// but some ui/logic snuck in when adding the BT functonality. + +#include "chat.h" +#include "config.h" +#include "settings.h" +#include "ui.h" +#include "sound.h" +#include +#include +#include +#include // deep sleep +#include // gpio_hold_en keeps backlight off during sleep +#include +#include +#include +#include +#include + +/* +============================================================================================================================ +EvilChatMesh +============================================================================================================================ +*/ + + +#define MAX_MSG_LEN 100 +#define MAX_HISTORY 32 +#define MAX_NODES 30 + + +struct NodePresence { + char nick[16]; + unsigned long lastSeen; +}; + +struct MeshMessage { + char id[9]; + char from[16]; + char body[MAX_MSG_LEN]; +}; + +// Replaced by the LVGL UI +//struct Message { +// char content[MAX_MSG_LEN]; +//}; + +/* ---------- VARIABLES GLOBALES ---------- */ +static std::vector nodesConnected; +int nodesCount = 0; + +static std::vector> seenMessageIds; +int seenIndex = 0; + +char inputBuffer[MAX_MSG_LEN] = ""; + +uint8_t broadcastAddressEspNow[] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF}; + +// static std::vector messages; +int messageCount = 0; +int messageHead = 0; + +// init from g_settings.nick +static char currentNick[16]; + +/* ---------- Notes mode / BT dump ---------- */ +enum class AppMode { CHAT, NOTES, BT }; +static AppMode appMode = AppMode::CHAT; +static AppMode modeBeforeBt = AppMode::CHAT; +static BleKeyboard* g_ble = nullptr; + +// for redrawing +#define MODE_HISTORY_LINES 12 + +// buffer for chat messages +struct ChatHistEntry { + char nick[CHAT_MAX_NICK_LEN]; + char text[MAX_MSG_LEN]; + bool is_own; + bool is_system; +}; +static ChatHistEntry chatHistory[MODE_HISTORY_LINES]; +static int chatHistCount = 0; +static int chatHistNext = 0; // next write slot + +static void chat_history_record(const char* nick, const char* text, bool is_own, bool is_system) { + ChatHistEntry& e = chatHistory[chatHistNext]; + strncpy(e.nick, nick, sizeof(e.nick) - 1); + e.nick[sizeof(e.nick) - 1] = '\0'; + strncpy(e.text, text, sizeof(e.text) - 1); + e.text[sizeof(e.text) - 1] = '\0'; + e.is_own = is_own; + e.is_system = is_system; + chatHistNext = (chatHistNext + 1) % MODE_HISTORY_LINES; + if (chatHistCount < MODE_HISTORY_LINES) chatHistCount++; +} + +static void chat_show_recent() { + int start = (chatHistCount < MODE_HISTORY_LINES) ? 0 : chatHistNext; + for (int i = 0; i < chatHistCount; i++) { + ChatHistEntry& e = chatHistory[(start + i) % MODE_HISTORY_LINES]; + ui_push_message(e.is_system ? "" : e.nick, e.text, e.is_own, e.is_system); + } +} + +// get the last few lines of the notes file. +static void notes_show_recent() { + File f = LittleFS.open(NOTES_FILE, "r"); + if (!f) return; + + static char buf[MODE_HISTORY_LINES][MAX_MSG_LEN]; + int count = 0, head = 0; + char line[MAX_MSG_LEN]; + while (f.available()) { + int n = f.readBytesUntil('\n', line, sizeof(line) - 1); + if (n > 0 && line[n - 1] == '\r') + n--; // remove trailing /r from .println + line[n] = '\0'; + if (n == 0) continue; + strncpy(buf[head], line, MAX_MSG_LEN - 1); + buf[head][MAX_MSG_LEN - 1] = '\0'; + head = (head + 1) % MODE_HISTORY_LINES; + if (count < MODE_HISTORY_LINES) count++; + } + + f.close(); + int start = (count < MODE_HISTORY_LINES) ? 0 : head; + for (int i = 0; i < count; i++) + ui_push_message("", buf[(start + i) % MODE_HISTORY_LINES], true, false); +} + +/* +Switch between chat mode (default) adn notes mode. +also redraw the UI somewhat +*/ +static void switch_mode(AppMode m) { + appMode = m; + ui_clear_messages(); + if (m == AppMode::NOTES) { + ui_set_header_mode("NOTE MODE"); + notes_show_recent(); + } else { + ui_set_header(nodesCount, currentNick); + chat_show_recent(); + } +} + +/* Timings */ +#define TIMEOUT_PRESENCE 2000 +#define PING_INTERVAL 500 +unsigned long lastPing = 0; + +static int lastNodeCount = -1; + +/* ---------- BUFFER SÉCURISÉ POUR LE CALLBACK ---------- */ +volatile bool newPacketPending = false; +volatile uint8_t packetBuffer[sizeof(MeshMessage)+sizeof(uint16_t)]; +volatile int packetLen = 0; + +/* ---------- PROTOTYPES (déclarés ici pour la loop) ---------- */ +void handleIncomingPacket(const uint8_t* data, int len); +//void drawChatWindow(); // now ui_push_message / ui_set_header +void broadcastPing(); +void updatePresence(const char* nick); +void broadcastChatMessage(const char* from, const char* body); +static void go_to_sleep(); + +/* ---------- CALLBACK ESP‑NOW ULTRA‑COURT ---------- */ + +void IRAM_ATTR OnDataRecvChat( + const esp_now_recv_info_t* recv_info, + const uint8_t* incomingData, int len +//#else // IDF4 signature, [removed] +// const uint8_t* mac, +// const uint8_t* incomingData, int len +//#endif +) +{ + const uint8_t* mac = recv_info ? recv_info->src_addr : nullptr; + (void)mac; + if (len > (int)sizeof(packetBuffer)) return; // paquet trop gros + memcpy((void*)packetBuffer, incomingData, len); + packetLen = len; + newPacketPending = true; // flag pour la loop() +} + +// ----- Hash pour dé‑duplication ----- +void simpleHash(const char* s, char* outHash) { + uint32_t h = 5381; + while (*s) h = ((h << 5) + h) + (uint8_t)(*s++); + snprintf(outHash, 9, "%08X", h); // 8 hex, zéro‑terminé +} + +// ----- CRC16 (CRC‑CCITT) ----- +uint16_t crc16(const uint8_t* data, size_t len) { + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < len; ++i) { + crc ^= (uint16_t)data[i] << 8; + for (int j = 0; j < 8; ++j) + crc = (crc & 0x8000) ? (crc << 1) ^ 0x1021 : (crc << 1); + } + return crc; +} + +// ----- Historique d'ID reçus ----- +bool hasSeenId(const char* id) { + if ((int)seenMessageIds.size() < MAX_HISTORY) return false; + for (int i = 0; i < MAX_HISTORY; ++i) + if (strncmp(seenMessageIds[i].data(), id, 8) == 0) return true; + return false; +} +void rememberId(const char* id) { + if ((int)seenMessageIds.size() < MAX_HISTORY) seenMessageIds.resize(MAX_HISTORY); + strncpy(seenMessageIds[seenIndex].data(), id, 8); + seenMessageIds[seenIndex].data()[8] = '\0'; + seenIndex = (seenIndex + 1) % MAX_HISTORY; +} + +// ----- Buffer circulaire de messages pour l'écran ----- +//void addMessage(const char* m) { +// if (messages.size() < 20) messages.assign(20, Message()); +// strncpy(messages[messageHead].content, m, MAX_MSG_LEN - 1); +// messages[messageHead].content[MAX_MSG_LEN - 1] = '\0'; +// messageHead = (messageHead + 1) % 20; +// if (messageCount < 20) messageCount++; +//} + +// rafraîchit l'écran +//void drawChatWindow() { +// M5.Display.fillScreen(TFT_BLACK); +// M5.Display.setTextColor(TFT_GREEN); +// M5.Display.setCursor(5, 5); +// M5.Display.printf("Connected: %d", nodesCount); +// +// M5.Display.setTextColor(TFT_WHITE); +// M5.Display.setCursor(5, 10); +// +// const int lines = 13; +// int start = (messageCount < lines) ? 0 : (messageHead + 20 - lines) % 20; +// for (int i = 0; i < min(lines, messageCount); ++i) { +// int idx = (start + i) % 20; +// M5.Display.println(messages[idx].content); +// } +// M5.Display.display(); +//} + +/* ---------- Traitement d'un paquet en attente ---------- */ +void handleIncomingPacket(const uint8_t* data, int len) { + if (len != (int)(sizeof(MeshMessage) + sizeof(uint16_t))) return; + + uint16_t rxCrc; + memcpy(&rxCrc, data + sizeof(MeshMessage), sizeof(uint16_t)); + if (rxCrc != crc16(data, sizeof(MeshMessage))) return; + + MeshMessage msg; + memcpy(&msg, data, sizeof(MeshMessage)); + + if (hasSeenId(msg.id)) return; + rememberId(msg.id); + + // --- Gestion du PING (mise à jour de présence) --- + if (strcmp(msg.body, "PING") == 0) { + updatePresence(msg.from); + esp_now_send(broadcastAddressEspNow, data, len); + return; // pas d'affichage + } + // --- Filtre messages "System" visant ce client --- + if (!strcmp(msg.from, "System") && strstr(msg.body, currentNick)) + return; + + // addMessage(line) + drawChatWindow() replaced: + //char line[MAX_MSG_LEN + 16]; + //snprintf(line, sizeof(line), "%s: %s", msg.from, msg.body); + //addMessage(line); + //drawChatWindow(); + bool is_system = !strcmp(msg.from, "System"); + chat_history_record(is_system ? "" : msg.from, msg.body, false, is_system); + if (appMode == AppMode::CHAT) + ui_push_message(is_system ? "" : msg.from, msg.body, false, is_system); + is_system ? sound_system() : sound_msg_in(); + + esp_now_send(broadcastAddressEspNow, data, len); +} + +/* ----- Notes mode / BT dump ----- */ + +void chat_set_ble(BleKeyboard* kb) { g_ble = kb; } + +bool chat_is_bt_mode() { return appMode == AppMode::BT; } + +// Append one line to the notes file (created on first write). +static void notes_append(const char* line) { + File f = LittleFS.open(NOTES_FILE, "a"); + if (!f) return; + f.println(line); + f.close(); +} + +static bool btConnectedLast = false; + +static void set_bt_status_message() { + if (!g_ble) { ui_push_message("", "BT unavailable.", false, true); return; } + if (g_ble->isConnected()) { + ui_push_message("", "BT connected. Press FN + ESC to send notes. TAB to exit.", false, true); + } else { + char buf[64]; + snprintf(buf, sizeof(buf), "Pair Bluetooth keyboard: \"%s %s\"", currentNick, "Keyboard"); + ui_push_message("", buf, false, true); + } +} + +// Switch into/out of BT dump mode. /bt command +static void toggle_bt_mode() { + inputBuffer[0] = '\0'; + ui_set_input(inputBuffer); + if (appMode != AppMode::BT) { + modeBeforeBt = appMode; + appMode = AppMode::BT; + ui_set_header_mode("BT MODE"); + btConnectedLast = g_ble && g_ble->isConnected(); + String bleName = String(g_settings.nick) + " Keyboard"; + g_ble->setName(bleName.c_str()); // make sure to use latest nick + if (!btConnectedLast) NimBLEDevice::startAdvertising(); // TODO: not sure if this is still necessary? + set_bt_status_message(); + } else { + switch_mode(modeBeforeBt); + } +} + +/* +BT dump mode: countdown, then replay the notes file as keystrokes. +Blocking while it dump. +*/ +static void bt_dump_start() { + if (!g_ble || !g_ble->isConnected()) { // dump not started. + ui_push_message("", "Not connected yet.", false, true); + return; + } + for (int s = BT_DUMP_COUNTDOWN_S; s > 0; s--) { + char buf[24]; + snprintf(buf, sizeof(buf), "Sending in %d...", s); + ui_push_message("", buf, false, true); + ui_tick(); + delay(1000); + } + ui_push_message("", "Sending...", false, true); + ui_tick(); + + File f = LittleFS.open(NOTES_FILE, "r"); + if (f) { + char line[MAX_MSG_LEN]; + while (f.available()) { + int n = f.readBytesUntil('\n', line, sizeof(line) - 1); + if (n > 0 && line[n - 1] == '\r') n--; // remove \r + line[n] = '\0'; + g_ble->setDelay(30); // look what i found! + g_ble->print(line); // is too fast, drops keypresses on my machine + // for (int i = 0; line[i] != '\0'; i++) + // { + // g_ble->write(line[i]); + // //delay(30); // TODO: maybe make configurable? + // } + // g_ble->print(line); // is too fast, drops keypresses on my machine + g_ble->write(KEY_RETURN); + delay(30); + } + f.close(); + } + ui_push_message("", "Notes sent.", false, true); + toggle_bt_mode(); // done, leave BT mode, restore the previous screen +} + +/* ----- Commandes ----- */ +void handleCommand(const char* in) { + if (!strncmp(in, "/nick ", 6)) { + strncpy(currentNick, in + 6, 15); currentNick[15] = '\0'; + settings_set_nick(currentNick); // persist to LittleFS + ui_set_header(nodesCount, currentNick); // refresh header bar + char l[MAX_MSG_LEN]; snprintf(l, sizeof(l), + "Nickname set to: %s", currentNick); + ui_push_message("", l, false, true); + + } else if (!strcmp(in, "/clear")) { + ui_clear_messages(); + + } else if (!strcmp(in, "/clearnotes")) { + LittleFS.remove(NOTES_FILE); + ui_push_message("", "Notes cleared.", false, true); + + } else if (!strcmp(in, "/bt")) { + toggle_bt_mode(); + + } else if (!strncmp(in, "/set ", 5)) { + char args[MAX_MSG_LEN]; + strncpy(args, in + 5, sizeof(args) - 1); args[sizeof(args) - 1] = '\0'; + char* sp = strchr(args, ' '); + if (!sp) { + ui_push_message("", "Usage: /set color_bg ff0000", false, true); + } else { + *sp = '\0'; + if (settings_set_color(args, sp + 1)) + ui_push_message("", "Saved. Restart to apply.", false, true); + else + ui_push_message("", "Unknown color or bad hex value.", false, true); + } + + } else if (!strncmp(in, "/me ", 4)) { + char act[MAX_MSG_LEN]; + snprintf(act, sizeof(act), "* %s %s", currentNick, in + 4); + broadcastChatMessage(currentNick, act); + + } else if (!strcmp(in, "/people")) { + + ui_push_message("", "-> Users online:", false, true); + char b[MAX_MSG_LEN]; + for (int i = 0; i < nodesCount; ++i) { + snprintf(b, sizeof(b), " %s", nodesConnected[i].nick); + ui_push_message("", b, false, true); + } + + } else if (!strcmp(in, "/help")) { + //addMessage("/nick : change pseudo"); + //addMessage("/me : action roleplay"); + //addMessage("/clear : clear the chat"); + //addMessage("/people : list connected"); + ui_push_message("", "/nick : change pseudo", false, true); + ui_push_message("", "/me : action roleplay", false, true); + ui_push_message("", "/clear : clear the chat", false, true); + ui_push_message("", "/clearnotes : erase saved notes", false, true); + ui_push_message("", "/bt : toggle BT send mode", false, true); + ui_push_message("", "/set color_bg ff0000 : set a color (restart to apply)", false, true); + ui_push_message("", "/people : list connected", false, true); + ui_push_message("", "/sleep : sleep device", false, true); + ui_push_message("", "TAB : notes mode", false, true); + //drawChatWindow(); + + } else if (!strcmp(in, "/sleep") || !strcmp(in, "/s")) { + go_to_sleep(); // sleep device. maybe add a button? + + } else { + ui_push_message("", "Unknown command. /help", false, true); + } +} + +/* ----- Envoi d'un message Chat (hors callback) ----- */ +void broadcastChatMessage(const char* from, const char* body) { + MeshMessage msg; + char hash[9], tmp[MAX_MSG_LEN+32]; + snprintf(tmp, sizeof(tmp), "%s%s%lu", from, body, millis()); + simpleHash(tmp, hash); + + strncpy(msg.id, hash, 8); msg.id[8] = '\0'; + strncpy(msg.from, from, 15); msg.from[15] = '\0'; + strncpy(msg.body, body, MAX_MSG_LEN-1); msg.body[MAX_MSG_LEN-1]='\0'; + + uint8_t pkt[sizeof(MeshMessage)+2]; + memcpy(pkt, &msg, sizeof(MeshMessage)); + uint16_t crc = crc16((uint8_t*)&msg, sizeof(MeshMessage)); + memcpy(pkt + sizeof(MeshMessage), &crc, 2); + + esp_now_send(broadcastAddressEspNow, pkt, sizeof(pkt)); + rememberId(msg.id); + + bool is_system = !strcmp(from, "System"); + bool is_own = !is_system && !strcmp(from, currentNick); + chat_history_record(is_system ? "" : from, body, is_own, is_system); + if (appMode == AppMode::CHAT) + ui_push_message(is_system ? "" : from, body, is_own, is_system); +} + +/* ----- Ping périodique ----- */ +void broadcastPing() { + MeshMessage msg; + char hash[9], tmp[32]; + + // hash = from + "PING" + horodatage + snprintf(tmp, sizeof(tmp), "%sPING%lu", currentNick, millis()); + simpleHash(tmp, hash); + + strncpy(msg.id, hash, 8); msg.id[8] = '\0'; + strncpy(msg.from, currentNick, 15); msg.from[15] = '\0'; + strncpy(msg.body, "PING", MAX_MSG_LEN - 1); + msg.body[MAX_MSG_LEN - 1] = '\0'; + + uint8_t pkt[sizeof(MeshMessage) + 2]; + memcpy(pkt, &msg, sizeof(MeshMessage)); + uint16_t crc = crc16((uint8_t*)&msg, sizeof(MeshMessage)); + memcpy(pkt + sizeof(MeshMessage), &crc, 2); + + esp_now_send(broadcastAddressEspNow, pkt, sizeof(pkt)); + rememberId(msg.id); // pour ignorer notre propre écho +} + + +/* ----- Gestion de présence ----- */ +void updatePresence(const char* nick) { + for (int i = 0; i < nodesCount; ++i) + if (!strcmp(nodesConnected[i].nick, nick)) { + nodesConnected[i].lastSeen = millis(); return; + } + + if (nodesCount < MAX_NODES) { + if ((int)nodesConnected.size() < MAX_NODES) nodesConnected.resize(MAX_NODES); + strncpy(nodesConnected[nodesCount].nick, nick, 15); + nodesConnected[nodesCount].nick[15] = '\0'; + nodesConnected[nodesCount].lastSeen = millis(); + ++nodesCount; + } +} +void checkPresenceTimeouts() { + unsigned long now = millis(); + for (int i = 0; i < nodesCount; ++i) + if ((now - nodesConnected[i].lastSeen) > TIMEOUT_PRESENCE) { + char m[MAX_MSG_LEN]; + snprintf(m, sizeof(m), "%s left the chat!", nodesConnected[i].nick); + broadcastChatMessage("System", m); + + for (int j = i; j < nodesCount-1; ++j) + nodesConnected[j] = nodesConnected[j+1]; + --nodesCount; --i; + } +} + +// ----- Gestion clavier & saisie utilisateur ----- +// handleKeyboard() removed, M5Cardputer-specific; logic moved into chat_tick(char key) +//void handleKeyboard() { +// if (!kpAny()) return; +// +// Keyboard_Class::KeysState st = M5Cardputer.Keyboard.keysState(); +// +// /* Ajout des caractères tapés */ +// for (char c : st.word) { +// int n = strlen(inputBuffer); +// if (n < MAX_MSG_LEN - 2) { // -2 : place pour '\0' +// inputBuffer[n] = c; +// inputBuffer[n+1] = '\0'; +// } +// } +// /* Backspace */ +// if (st.del && *inputBuffer) +// inputBuffer[strlen(inputBuffer) - 1] = '\0'; +// +// /* Entrée : envoi */ +// if (st.enter && *inputBuffer) { +// if (inputBuffer[0] == '/') +// handleCommand(inputBuffer); +// else +// broadcastChatMessage(currentNick, inputBuffer); +// +// inputBuffer[0] = '\0'; +// } +// +// /* Ligne d'édition */ +// M5.Display.fillRect(0, 120, 240, 16, TFT_NAVY); +// M5.Display.setCursor(5, 120); +// M5.Display.setTextColor(TFT_YELLOW); +// M5.Display.print("> "); +// M5.Display.print(inputBuffer); +//} + +// ----- Fonction principale ----- +// EvilChatMesh() split into chat_init() + chat_tick(), no blocking while(true) +//void EvilChatMesh() { +// auto cfg = M5.config(); +// M5.begin(cfg); +// M5.Display.setTextSize(1); +// M5.Display.setTextColor(TFT_WHITE); +// M5.Display.fillScreen(TFT_BLACK); +// +// WiFi.disconnect(true); delay(100); +// WiFi.mode(WIFI_STA); +// +// if (esp_now_init() != ESP_OK) { +// M5.Display.println("ESP‑NOW init failed"); return; +// } +// esp_now_register_recv_cb(OnDataRecvChat); +// +// esp_now_peer_info_t peer = {}; +// memcpy(peer.peer_addr, broadcastAddressEspNow, 6); +// peer.channel = 0; // changed to 1, EvilChatMesh-Relay.ino expects channel 1 +// peer.encrypt = false; +// if (!esp_now_is_peer_exist(peer.peer_addr)) +// esp_now_add_peer(&peer); +// +// nodesConnected.clear(); nodesConnected.resize(MAX_NODES); +// messages.assign(20, Message()); +// drawChatWindow(); +// +// char msgArr[MAX_MSG_LEN]; +// snprintf(msgArr, sizeof(msgArr), "%s enter the chat!", currentNick); +// broadcastChatMessage("System", msgArr); +// +// /* ---------- Boucle ---------- */ +// while (true) { +// M5.update(); cardUpdate(); +// +// /* 1. paquet reçu ? */ +// if (newPacketPending) { +// noInterrupts(); +// uint8_t localBuf[sizeof(MeshMessage)+2]; +// int len = packetLen; +// memcpy(localBuf, (const void*)packetBuffer, len); +// newPacketPending = false; +// interrupts(); +// +// handleIncomingPacket(localBuf, len); +// } +// +// /* 2. ping & timeouts */ +// unsigned long now = millis(); +// if (now - lastPing > PING_INTERVAL) { +// lastPing = now; broadcastPing(); +// } +// checkPresenceTimeouts(); +// +// /* 3. clavier */ +// if (M5Cardputer.Keyboard.isChange()) +// handleKeyboard(); +// +// /* 4. touche ` pour quitter */ +// if (kp('`')) { +// char m[MAX_MSG_LEN]; +// snprintf(m, sizeof(m), "%s left the chat!", currentNick); +// broadcastChatMessage("System", m); +// +// esp_now_unregister_recv_cb(); +// esp_now_deinit(); +// waitAndReturnToMenu("Left the chat..."); +// return; +// } +// delay(10); +// } +//} + + +/* +Triggered by /sleep command. +Broadcasts a leave message, turns off the backlight. +two wake sources: touch IRQ (GPIO36) and BOOT button (GPIO0). +Touch is quirky and not recommended. +*/ +static void go_to_sleep() { + char m[MAX_MSG_LEN]; + snprintf(m, sizeof(m), "%s left the chat!", currentNick); + broadcastChatMessage("System", m); + delay(200); // let the packet fly + + // Hold the backlight OFF pin through deep sleep so the screen stays dark. + digitalWrite(PIN_TFT_BL, LOW); + gpio_hold_en((gpio_num_t)PIN_TFT_BL); + + // Wake on BOOT button (GPIO0), active LOW. + esp_sleep_enable_ext1_wakeup(1ULL << PIN_BOOT_BTN, ESP_EXT1_WAKEUP_ALL_LOW); + // wake from touchpad? + touchAttachInterrupt(PIN_TOUCH7, [](){}, g_settings.touch_threshold); // configure GPIO27 as touch7 + esp_sleep_enable_touchpad_wakeup(); + esp_deep_sleep_start(); +} + +void chat_init() { + strncpy(currentNick, g_settings.nick, sizeof(currentNick) - 1); + currentNick[sizeof(currentNick) - 1] = '\0'; + + nodesConnected.clear(); nodesConnected.resize(MAX_NODES); + seenMessageIds.resize(MAX_HISTORY); + for (auto& e : seenMessageIds) e.fill(0); + + WiFi.persistent(false); + WiFi.mode(WIFI_STA); + WiFi.setSleep(false); + + if (esp_now_init() != ESP_OK) { + Serial.println("[chat] ESP-NOW init failed"); + ui_push_message("", "ESP-NOW init failed!", false, true); + return; + } + esp_now_register_recv_cb(OnDataRecvChat); + + esp_now_peer_info_t peer = {}; + memcpy(peer.peer_addr, broadcastAddressEspNow, 6); + peer.channel = 1; // relay uses channel 1; original was 0 + peer.encrypt = false; + if (!esp_now_is_peer_exist(peer.peer_addr)) + esp_now_add_peer(&peer); + + char msgArr[MAX_MSG_LEN]; + snprintf(msgArr, sizeof(msgArr), "%s entered the chat!", currentNick); + broadcastChatMessage("System", msgArr); + + Serial.printf("[chat] ready as %s\n", currentNick); +} + +void chat_tick(char key) { + /* 1. paquet reçu ? */ + if (newPacketPending) { + noInterrupts(); + uint8_t localBuf[sizeof(MeshMessage)+2]; + int len = packetLen; + memcpy(localBuf, (const void*)packetBuffer, len); + newPacketPending = false; + interrupts(); + + handleIncomingPacket(localBuf, len); + } + + /* 2. ping & timeouts */ + unsigned long now = millis(); + if (now - lastPing > PING_INTERVAL) { + lastPing = now; broadcastPing(); + checkPresenceTimeouts(); + if (nodesCount != lastNodeCount) { // refresh header only on peer count change + lastNodeCount = nodesCount; + if (appMode == AppMode::CHAT) ui_set_header(nodesCount, currentNick); // only for chat + } + } + + // update bt connection status (here instead of waiting for keypress) + if (appMode == AppMode::BT) { + bool nowConnected = g_ble && g_ble->isConnected(); + if (nowConnected != btConnectedLast) { + btConnectedLast = nowConnected; + if (!nowConnected) NimBLEDevice::startAdvertising(); + set_bt_status_message(); + } + } + + /* 3. clavier */ + if (key == 0) return; + + /* Tab: toggle notes/chat mode */ + if (key == KB_KEY_TAB) { + if (appMode == AppMode::CHAT) switch_mode(AppMode::NOTES); + else if (appMode == AppMode::NOTES) switch_mode(AppMode::CHAT); + else if (appMode == AppMode::BT) switch_mode(AppMode::CHAT); + return; + } + + if (appMode == AppMode::BT) { + if((uint8_t)key == 0x80) { // FN + ESC + bt_dump_start(); + } else { + if(KB_KEY_ENTER == key) { + g_ble->write(KEY_RETURN); + } else { + g_ble->write(key); + } + } + } else { + // Serial.print("Received key: "); + // Serial.print((uint8_t)key); + // Serial.print(" -> "); + // Serial.println(key); + + /* Backspace */ + if (key == KB_KEY_BACKSPACE) { // was: if (st.del && *inputBuffer) + int n = strlen(inputBuffer); + if (n > 0) inputBuffer[n - 1] = '\0'; + + /* Entrée : envoi */ + } else if (key == KB_KEY_ENTER || key == '\n') { // '\n' for Serial keypresses + // if (appMode == AppMode::BT && inputBuffer[0] != '/') { // let /command pass... + // bt_dump_start(); + // } else + if (*inputBuffer) { // if (st.enter && *inputBuffer) { + if (inputBuffer[0] == '/') + handleCommand(inputBuffer); + else if (appMode == AppMode::NOTES) { + notes_append(inputBuffer); + ui_push_message("", inputBuffer, true, false); + } else if (appMode == AppMode::CHAT) { + broadcastChatMessage(currentNick, inputBuffer); + } + } + inputBuffer[0] = '\0'; + + /* Ajout des caractères tapés */ + + + } else if ((uint8_t)key >= 0x20 && (uint8_t)key < 0x7F) { + + int n = strlen(inputBuffer); + if (n < MAX_MSG_LEN - 2) { // -2 : place pour '\0' + inputBuffer[n] = key; + inputBuffer[n+1] = '\0'; + } + + } + // else if (appMode == AppMode::BT && (uint8_t)key == 0x80) { // FN + ESC + // bt_dump_start(); + // } + } + + /* Ligne d'édition */ + ui_set_input(inputBuffer); + +} diff --git a/src/meshberry/chat.h b/src/meshberry/chat.h new file mode 100644 index 0000000..331789f --- /dev/null +++ b/src/meshberry/chat.h @@ -0,0 +1,12 @@ +#pragma once + +class BleKeyboard; + +// Lifted from EvilChatMesh in Evil-Cardputer-v1-5-2.ino and adapted for +// the CYD hardware. + +void chat_init(); +void chat_tick(char key); +void chat_set_ble(BleKeyboard* kb); +bool chat_is_bt_mode(); +static void toggle_bt_mode(); \ No newline at end of file diff --git a/src/meshberry/idf_component.yml b/src/meshberry/idf_component.yml new file mode 100644 index 0000000..d752765 --- /dev/null +++ b/src/meshberry/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + idf: '>=5.1' diff --git a/src/meshberry/main.cpp b/src/meshberry/main.cpp new file mode 100644 index 0000000..97efc30 --- /dev/null +++ b/src/meshberry/main.cpp @@ -0,0 +1,49 @@ +#include +#include +#include +#include "config.h" +#include "settings.h" +#include "ui.h" +#include "cardkb.h" +#include "sound.h" +#include "chat.h" +#include + +BleKeyboard bleKeyboard; + +void setup() { + Serial.begin(SERIAL_BAUD); + esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); + if (cause == ESP_SLEEP_WAKEUP_EXT1 || cause == ESP_SLEEP_WAKEUP_TOUCHPAD) { + Serial.println("[wake] resumed from deep sleep"); + gpio_hold_dis((gpio_num_t)PIN_TFT_BL); // release backlight hold before ui_init + } + Serial.println("Cyberdeck Chat, starting up"); + + settings_load(); // load config.txt from LittleFS + ui_init(); + ui_chat_init(); + ui_set_header(0, g_settings.nick); + + cardkb_init(); + sound_init(); + chat_init(); + + // BLE keyboard device name = chat nick + "Keyboard" + String bleName = String(g_settings.nick) + " Keyboard"; + bleKeyboard.setName(bleName.c_str()); + bleKeyboard.begin(); + chat_set_ble(&bleKeyboard); // chat gets ble instance + + Serial.println("Ready"); +} + +void loop() { + if (chat_is_bt_mode()) { + while (Serial.available()) Serial.read(); + } + char key = Serial.available() ? (char)Serial.read() : cardkb_read(); + chat_tick(key); // TODO: debug why a serial key does not show in the UI? + ui_tick(); + delay(5); +} diff --git a/src/meshberry/settings.cpp b/src/meshberry/settings.cpp new file mode 100644 index 0000000..e2d41f3 --- /dev/null +++ b/src/meshberry/settings.cpp @@ -0,0 +1,172 @@ +#include "settings.h" +#include +#include + +settings_t g_settings; + +static const settings_t DEFAULTS = { + .nick = NICK_DEFAULT_PREFIX, + .brightness = 200, + .msg_freq = 1800, + .msg_ms = 20, + .sys_freq = 440, + .sys_ms = 10, + .touch_threshold = TOUCH_THRESHOLD, + .color_bg = {.blue = 0x11, .green = 0x11, .red = 0x11}, + .color_header_bg = {.blue = 0x1e, .green = 0x1e, .red = 0x1e}, + .color_msg = {.blue = 0xdd, .green = 0xdd, .red = 0xdd}, + .color_nick_other = {.blue = 0x44, .green = 0xff, .red = 0x44}, + .color_nick_own = {.blue = 0x00, .green = 0xdd, .red = 0xff}, + .color_system = {.blue = 0x66, .green = 0x66, .red = 0x66}, + .color_input_prompt = {.blue = 0x33, .green = 0x77, .red = 0x33}, +}; + +static lv_color_t parse_color(const char* hex) { + uint32_t v = strtoul(hex, nullptr, 16); + return lv_color_make((v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF); +} + +static char* trim(char* s) { + while (*s == ' ' || *s == '\t') ++s; + char* end = s + strlen(s) - 1; + while (end > s && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) + *end-- = '\0'; + return s; +} + +static bool is_hex6(const char* s) { + if (strlen(s) != 6) return false; + for (int i = 0; i < 6; i++) if (!isxdigit((unsigned char)s[i])) return false; + return true; +} + +static bool apply_kv(const char* key, const char* val) { + if (!strcmp(key, "nick")) { strncpy(g_settings.nick, val, CHAT_MAX_NICK_LEN - 1); } + else if (!strcmp(key, "brightness")) { g_settings.brightness = (uint8_t)constrain(atoi(val), 0, 255); } + else if (!strcmp(key, "msg_freq")) { g_settings.msg_freq = (uint16_t)atoi(val); } + else if (!strcmp(key, "msg_ms")) { g_settings.msg_ms = (uint16_t)atoi(val); } + else if (!strcmp(key, "sys_freq")) { g_settings.sys_freq = (uint16_t)atoi(val); } + else if (!strcmp(key, "sys_ms")) { g_settings.sys_ms = (uint16_t)atoi(val); } + else if (!strcmp(key, "touch_threshold")) { g_settings.touch_threshold = (uint16_t)atoi(val); } + else if (!strcmp(key, "color_bg")) { if (!is_hex6(val)) return false; g_settings.color_bg = parse_color(val); } + else if (!strcmp(key, "color_header_bg")) { if (!is_hex6(val)) return false; g_settings.color_header_bg = parse_color(val); } + else if (!strcmp(key, "color_msg")) { if (!is_hex6(val)) return false; g_settings.color_msg = parse_color(val); } + else if (!strcmp(key, "color_nick_other")) { if (!is_hex6(val)) return false; g_settings.color_nick_other = parse_color(val); } + else if (!strcmp(key, "color_nick_own")) { if (!is_hex6(val)) return false; g_settings.color_nick_own = parse_color(val); } + else if (!strcmp(key, "color_system")) { if (!is_hex6(val)) return false; g_settings.color_system = parse_color(val); } + else if (!strcmp(key, "color_input_prompt")) { if (!is_hex6(val)) return false; g_settings.color_input_prompt = parse_color(val); } + else return false; + return true; +} + +static void dump_settings() { + Serial.println("[settings] ---- effective config ----"); + Serial.printf ("[settings] nick=%s brightness=%d\n", + g_settings.nick, g_settings.brightness); + Serial.printf ("[settings] msg_freq=%u msg_ms=%u sys_freq=%u sys_ms=%u touch_threshold=%u\n", + g_settings.msg_freq, g_settings.msg_ms, g_settings.sys_freq, + g_settings.sys_ms, g_settings.touch_threshold); + Serial.printf ("[settings] color_bg=%02x%02x%02x color_header_bg=%02x%02x%02x\n", + g_settings.color_bg.red, g_settings.color_bg.green, g_settings.color_bg.blue, + g_settings.color_header_bg.red, g_settings.color_header_bg.green, g_settings.color_header_bg.blue); + Serial.printf ("[settings] color_msg=%02x%02x%02x color_nick_other=%02x%02x%02x color_nick_own=%02x%02x%02x\n", + g_settings.color_msg.red, g_settings.color_msg.green, g_settings.color_msg.blue, + g_settings.color_nick_other.red, g_settings.color_nick_other.green, g_settings.color_nick_other.blue, + g_settings.color_nick_own.red, g_settings.color_nick_own.green, g_settings.color_nick_own.blue); + Serial.printf ("[settings] color_system=%02x%02x%02x color_input_prompt=%02x%02x%02x\n", + g_settings.color_system.red, g_settings.color_system.green, g_settings.color_system.blue, + g_settings.color_input_prompt.red, g_settings.color_input_prompt.green, g_settings.color_input_prompt.blue); + Serial.println("[settings] ----------------------------"); +} + +// Public + +void settings_load() { + g_settings = DEFAULTS; + + snprintf(g_settings.nick, sizeof(g_settings.nick), + "%s%04u", NICK_DEFAULT_PREFIX, (unsigned)(esp_random() % 10000)); + + if (!LittleFS.begin(true)) { + Serial.println("[settings] LittleFS mount failed, using defaults"); + dump_settings(); + return; + } + + File f = LittleFS.open("/config.txt", "r"); + if (!f) { + Serial.println("[settings] /config.txt not found, using defaults"); + dump_settings(); + return; + } + + char line[128]; + while (f.available()) { + int n = f.readBytesUntil('\n', line, sizeof(line) - 1); + line[n] = '\0'; + char* l = trim(line); + if (*l == '\0' || *l == '#') continue; // blank line or comment + + char* eq = strchr(l, '='); + if (!eq) continue; // no '=' skip + *eq = '\0'; + char* key = trim(l); + char* val = trim(eq + 1); + apply_kv(key, val); + } + f.close(); + + dump_settings(); +} + +static void rewrite_config_line(const char* key, const char* val) { + bool found = false; + size_t keylen = strlen(key); + + File in = LittleFS.open("/config.txt", "r"); + File out = LittleFS.open("/config.tmp", "w"); + if (!out) { + Serial.println("[settings] failed to open /config.tmp for writing"); + if (in) in.close(); + return; + } + + if (in) { + char line[128]; + while (in.available()) { + int n = in.readBytesUntil('\n', line, sizeof(line) - 1); + line[n] = '\0'; + // Preserve \r\n on Windows-edited files + bool had_cr = (n > 0 && line[n-1] == '\r'); + if (had_cr) line[--n] = '\0'; + + if (!found && strncmp(line, key, keylen) == 0 && line[keylen] == '=') { + out.printf("%s=%s%s\n", key, val, had_cr ? "\r" : ""); + found = true; + } else { + out.printf("%s%s\n", line, had_cr ? "\r" : ""); + } + } + in.close(); + } + + if (!found) out.printf("%s=%s\n", key, val); + out.close(); + + LittleFS.remove("/config.txt"); + LittleFS.rename("/config.tmp", "/config.txt"); + Serial.printf("[settings] %s saved: %s\n", key, val); +} + +void settings_set_nick(const char* nick) { + strncpy(g_settings.nick, nick, CHAT_MAX_NICK_LEN - 1); + g_settings.nick[CHAT_MAX_NICK_LEN - 1] = '\0'; + rewrite_config_line("nick", g_settings.nick); +} + +bool settings_set_color(const char* key, const char* hex) { + if (strncmp(key, "color_", 6) != 0) return false; // scope to color_* only + if (!apply_kv(key, hex)) return false; // unknown key or bad hex + rewrite_config_line(key, hex); + return true; +} diff --git a/src/meshberry/settings.h b/src/meshberry/settings.h new file mode 100644 index 0000000..6651cb9 --- /dev/null +++ b/src/meshberry/settings.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include "config.h" + +// All values read from /config.txt on LittleFS. +struct settings_t { + char nick[CHAT_MAX_NICK_LEN]; + uint8_t brightness; + uint16_t msg_freq; // Hz, notification beep for incoming messages + uint16_t msg_ms; // duration in ms + uint16_t sys_freq; // Hz, beep for join / leave events + uint16_t sys_ms; // duration in ms + uint16_t touch_threshold; // touch7 (GPIO27) wake-from-sleep sensitivity + lv_color_t color_bg; + lv_color_t color_header_bg; + lv_color_t color_msg; + lv_color_t color_nick_other; + lv_color_t color_nick_own; + lv_color_t color_system; + lv_color_t color_input_prompt; +}; + +// Global settings, read-only +extern settings_t g_settings; + +void settings_load(); +void settings_set_nick(const char* nick); +bool settings_set_color(const char* key, const char* hex); diff --git a/src/meshberry/sound.cpp b/src/meshberry/sound.cpp new file mode 100644 index 0000000..16f4fd0 --- /dev/null +++ b/src/meshberry/sound.cpp @@ -0,0 +1,12 @@ +#include "sound.h" +#include "config.h" +#include "settings.h" +#include + +void sound_init() { + pinMode(PIN_SPEAKER_EN, OUTPUT); + digitalWrite(PIN_SPEAKER_EN, LOW); // LOW = amp enabled +} + +void sound_msg_in() { tone(PIN_SPEAKER, g_settings.msg_freq, g_settings.msg_ms); } +void sound_system() { tone(PIN_SPEAKER, g_settings.sys_freq, g_settings.sys_ms); } diff --git a/src/meshberry/sound.h b/src/meshberry/sound.h new file mode 100644 index 0000000..79348eb --- /dev/null +++ b/src/meshberry/sound.h @@ -0,0 +1,5 @@ +#pragma once + +void sound_init(); +void sound_msg_in(); // short high beep, new chat message +void sound_system(); // short low beep , join / leave diff --git a/src/meshberry/ui.cpp b/src/meshberry/ui.cpp new file mode 100644 index 0000000..919f1f9 --- /dev/null +++ b/src/meshberry/ui.cpp @@ -0,0 +1,290 @@ +#include "ui.h" +#include "settings.h" +#include "config.h" +#include +#include +#include +#include + +// All pins are set in config.h +class LGFX : public lgfx::LGFX_Device { + lgfx::Panel_ST7789 _panel; + lgfx::Bus_SPI _bus; + lgfx::Light_PWM _light; +public: + LGFX() { + { + auto cfg = _bus.config(); + cfg.spi_host = VSPI_HOST; + cfg.spi_mode = 0; + cfg.freq_write = 40000000; + cfg.pin_sclk = PIN_TFT_SCLK; + cfg.pin_mosi = PIN_TFT_MOSI; + cfg.pin_miso = PIN_TFT_MISO; + cfg.pin_dc = PIN_TFT_DC; + _bus.config(cfg); + _panel.setBus(&_bus); + } + { + auto cfg = _panel.config(); + cfg.pin_cs = PIN_TFT_CS; + cfg.pin_rst = PIN_TFT_RST; + cfg.panel_width = PANEL_NATIVE_W; // portrait native width + cfg.panel_height = PANEL_NATIVE_H; // portrait native height + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.invert = false; + _panel.config(cfg); + } + { + auto cfg = _light.config(); + cfg.pin_bl = PIN_TFT_BL; + _light.config(cfg); + _panel.setLight(&_light); + } + setPanel(&_panel); + } +}; + +static LGFX tft; + +static uint8_t* lvgl_buf = nullptr; +static constexpr size_t LVGL_BUF_BYTES = TFT_W * 20 * sizeof(lv_color_t); + +uint16_t bg_sw = TFT_W, bg_sh = TFT_H; + +// LVGL flush +static void lvgl_flush(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { + uint32_t w = area->x2 - area->x1 + 1; + uint32_t h = area->y2 - area->y1 + 1; + tft.startWrite(); + tft.setAddrWindow(area->x1, area->y1, w, h); + + tft.writePixels((uint16_t*)px_map, w * h, false); + tft.endWrite(); + lv_display_flush_ready(disp); +} + +// Message display state + +// Flags stored per visible row +static constexpr uint8_t MSG_OWN = 0x01; +static constexpr uint8_t MSG_SYSTEM = 0x02; + +static char msg_nicks[UI_VISIBLE_LINES][CHAT_MAX_NICK_LEN + 3]; +static char msg_texts[UI_VISIBLE_LINES][CHAT_MAX_MSG_LEN]; +static uint8_t msg_flags[UI_VISIBLE_LINES]; +static int msg_count = 0; // how many rows are filled (0..UI_VISIBLE_LINES) + +static lv_obj_t* header_peers = nullptr; +static lv_obj_t* header_nick = nullptr; +static lv_obj_t* input_label = nullptr; + +struct MsgRow { lv_obj_t *row, *nick, *text; }; +static MsgRow msg_rows[UI_VISIBLE_LINES]; + +static void refresh_msgs() { + int empty = UI_VISIBLE_LINES - msg_count; + for (int i = 0; i < UI_VISIBLE_LINES; i++) { + if (i < empty) { + lv_obj_add_flag(msg_rows[i].row, LV_OBJ_FLAG_HIDDEN); + continue; + } + lv_obj_clear_flag(msg_rows[i].row, LV_OBJ_FLAG_HIDDEN); + lv_color_t nick_color, text_color; + if (msg_flags[i] & MSG_SYSTEM) { + nick_color = text_color = g_settings.color_system; + } else if (msg_flags[i] & MSG_OWN) { + nick_color = g_settings.color_nick_own; + text_color = g_settings.color_msg; + } else { + nick_color = g_settings.color_nick_other; + text_color = g_settings.color_msg; + } + lv_obj_set_style_text_color(msg_rows[i].nick, nick_color, 0); + lv_obj_set_style_text_color(msg_rows[i].text, text_color, 0); + lv_label_set_text(msg_rows[i].nick, msg_nicks[i]); + lv_label_set_text(msg_rows[i].text, msg_texts[i]); + } +} + +static void strip_style(lv_obj_t* obj) { + lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_pad_all(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); +} + +// +// Public +// + +void ui_init() { + tft.init(); + tft.setRotation(3); // usb on the right + tft.setBrightness(g_settings.brightness); + + lvgl_buf = (uint8_t*)malloc(LVGL_BUF_BYTES); + if (!lvgl_buf) { + Serial.println("[ui] FATAL: LVGL draw buffer allocation failed"); + return; + } + + lv_init(); + lv_tick_set_cb(millis); + + lv_display_t* disp = lv_display_create(TFT_W, TFT_H); + lv_display_set_buffers(disp, lvgl_buf, nullptr, LVGL_BUF_BYTES, LV_DISPLAY_RENDER_MODE_PARTIAL); + lv_display_set_flush_cb(disp, lvgl_flush); +} + +void ui_chat_init() { + lv_obj_t* scr = lv_screen_active(); + + // Background + lv_obj_set_style_bg_color(scr, g_settings.color_bg, 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + + // Header + lv_obj_t* hdr = lv_obj_create(scr); + lv_obj_set_size(hdr, TFT_W, UI_HEADER_H); + lv_obj_set_pos(hdr, 0, 0); + lv_obj_set_style_bg_color(hdr, g_settings.color_header_bg, 0); + lv_obj_set_style_bg_opa(hdr, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(hdr, 1, 0); + lv_obj_set_style_border_color(hdr, lv_color_black(), 0); + lv_obj_set_style_border_side(hdr, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_pad_all(hdr, 0, 0); + lv_obj_set_style_radius(hdr, 0, 0); + lv_obj_clear_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + + header_peers = lv_label_create(hdr); + lv_obj_set_style_text_color(header_peers, g_settings.color_msg, 0); + lv_obj_set_style_bg_opa(header_peers, LV_OPA_TRANSP, 0); + lv_label_set_long_mode(header_peers, LV_LABEL_LONG_CLIP); + lv_obj_set_width(header_peers, TFT_W / 2); + lv_obj_align(header_peers, LV_ALIGN_LEFT_MID, 4, 0); + lv_label_set_text(header_peers, "● 0 online"); + + header_nick = lv_label_create(hdr); + lv_obj_set_style_text_color(header_nick, g_settings.color_nick_own, 0); + lv_obj_set_style_bg_opa(header_nick, LV_OPA_TRANSP, 0); + lv_label_set_long_mode(header_nick, LV_LABEL_LONG_CLIP); + lv_obj_set_width(header_nick, TFT_W / 2 - 4); + lv_obj_align(header_nick, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_set_style_text_align(header_nick, LV_TEXT_ALIGN_RIGHT, 0); + lv_label_set_text_fmt(header_nick, "you: %s", g_settings.nick); + + // Messages + lv_obj_t* msg_area = lv_obj_create(scr); + lv_obj_set_size(msg_area, TFT_W, UI_MSG_AREA_H); + lv_obj_set_pos(msg_area, 0, UI_MSG_AREA_Y); + strip_style(msg_area); + lv_obj_set_flex_flow(msg_area, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(msg_area, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + + for (int i = 0; i < UI_VISIBLE_LINES; i++) { + lv_obj_t* row = lv_obj_create(msg_area); + lv_obj_set_width(row, TFT_W); + lv_obj_set_height(row, LV_SIZE_CONTENT); + strip_style(row); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_column(row, 0, 0); + lv_obj_add_flag(row, LV_OBJ_FLAG_HIDDEN); + + lv_obj_t* nick = lv_label_create(row); + lv_obj_set_style_bg_opa(nick, LV_OPA_TRANSP, 0); + lv_label_set_long_mode(nick, LV_LABEL_LONG_CLIP); + lv_obj_set_height(nick, UI_LINE_H); + lv_obj_set_width(nick, LV_SIZE_CONTENT); + lv_label_set_text(nick, ""); + + lv_obj_t* text = lv_label_create(row); + lv_obj_set_style_bg_opa(text, LV_OPA_TRANSP, 0); + lv_label_set_long_mode(text, LV_LABEL_LONG_WRAP); + lv_obj_set_height(text, LV_SIZE_CONTENT); + lv_obj_set_flex_grow(text, 1); + lv_label_set_text(text, ""); + + msg_rows[i] = {row, nick, text}; + } + + // Footer / input + lv_obj_t* inp = lv_obj_create(scr); + lv_obj_set_size(inp, TFT_W, UI_INPUT_H); + lv_obj_set_pos(inp, 0, UI_INPUT_Y); + lv_obj_set_style_bg_color(inp, g_settings.color_header_bg, 0); + lv_obj_set_style_bg_opa(inp, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(inp, 1, 0); + lv_obj_set_style_border_color(inp, lv_color_black(), 0); + lv_obj_set_style_border_side(inp, LV_BORDER_SIDE_TOP, 0); + lv_obj_set_style_pad_all(inp, 0, 0); + lv_obj_set_style_radius(inp, 0, 0); + lv_obj_clear_flag(inp, LV_OBJ_FLAG_SCROLLABLE); + + input_label = lv_label_create(inp); + lv_obj_set_style_text_color(input_label, g_settings.color_input_prompt, 0); + lv_obj_set_style_bg_opa(input_label, LV_OPA_TRANSP, 0); + lv_label_set_long_mode(input_label, LV_LABEL_LONG_CLIP); + lv_obj_set_width(input_label, TFT_W - 8); + lv_obj_align(input_label, LV_ALIGN_LEFT_MID, 4, 0); + lv_label_set_text(input_label, "> "); + +} + +void ui_push_message(const char* nick, const char* text, bool is_own, bool is_system) { + // Shift buffer up by one + for (int i = 0; i < UI_VISIBLE_LINES - 1; i++) { + memcpy(msg_nicks[i], msg_nicks[i + 1], sizeof(msg_nicks[0])); + memcpy(msg_texts[i], msg_texts[i + 1], sizeof(msg_texts[0])); + msg_flags[i] = msg_flags[i + 1]; + } + + int last = UI_VISIBLE_LINES - 1; + if (is_system || nick[0] == '\0') { + msg_nicks[last][0] = '\0'; + } else { + snprintf(msg_nicks[last], sizeof(msg_nicks[0]), "%s: ", nick); + } + strncpy(msg_texts[last], text, sizeof(msg_texts[0]) - 1); + msg_texts[last][sizeof(msg_texts[0]) - 1] = '\0'; + msg_flags[last] = (is_own ? MSG_OWN : 0) | (is_system ? MSG_SYSTEM : 0); + + if (msg_count < UI_VISIBLE_LINES) msg_count++; + + refresh_msgs(); +} + +void ui_set_input(const char* text) { + if (!input_label) return; + lv_label_set_text_fmt(input_label, "> %s", text); +} + +void ui_set_header(int peer_count, const char* my_nick) { + if (!header_peers || !header_nick) return; + lv_label_set_text_fmt(header_peers, "* %d online", peer_count); + lv_label_set_text_fmt(header_nick, "you: %s", my_nick); +} + +void ui_set_header_mode(const char* text) { + if (!header_peers || !header_nick) return; + lv_label_set_text(header_peers, ""); + lv_label_set_text(header_nick, text); +} + +void ui_clear_messages() { + memset(msg_nicks, 0, sizeof(msg_nicks)); + memset(msg_texts, 0, sizeof(msg_texts)); + memset(msg_flags, 0, sizeof(msg_flags)); + msg_count = 0; + refresh_msgs(); +} + + +void ui_tick() { + lv_timer_handler(); +} diff --git a/src/meshberry/ui.h b/src/meshberry/ui.h new file mode 100644 index 0000000..9f86dc7 --- /dev/null +++ b/src/meshberry/ui.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include + +void ui_init(); + +void ui_chat_init(); + +/* +* Add one message to the ui. +* nick senders nickname ("" for system messages) +* text message body (system messages include their own prefix, e.g. "* alice joined") +* is_own true: nick rendered in color_nick_own +* is_system true: entire line rendered in color_system, nick ignored +*/ +void ui_push_message(const char* nick, const char* text, bool is_own, bool is_system); +void ui_set_input(const char* text); +void ui_set_header(int peer_count, const char* my_nick); +void ui_set_header_mode(const char* text); +void ui_clear_messages(); + +// Must be called every iteration of loop(). +void ui_tick();