initial working version

This commit is contained in:
my 2026-09-10 15:06:12 +02:00
commit 257ed842f9
19 changed files with 1623 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -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

9
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"recommendations": [
"Jason2866.esp-decoder",
"pioarduino.pioarduino-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

17
data/config.txt Normal file
View file

@ -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

1
data/notes.txt Normal file
View file

@ -0,0 +1 @@
Your notes will go here.

95
include/config.h Normal file
View file

@ -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 */

12
include/lv_conf.h Normal file
View file

@ -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 */

45
platformio.ini Normal file
View file

@ -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 = +<meshberry/> -<chat-relay/>
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 = -<meshberry/> +<chat-relay/> ; ignores root
lib_deps =
build_flags =
-I include

39
src/meshberry/cardkb.cpp Normal file
View file

@ -0,0 +1,39 @@
#include "cardkb.h"
#include "config.h"
#include <Wire.h>
#include "esp_log.h"
#include <Arduino.h>
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;
}

3
src/meshberry/cardkb.h Normal file
View file

@ -0,0 +1,3 @@
#pragma once
void cardkb_init();
char cardkb_read();

801
src/meshberry/chat.cpp Normal file
View file

@ -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 <Arduino.h>
#include <WiFi.h>
#include <esp_now.h>
#include <esp_sleep.h> // deep sleep
#include <driver/gpio.h> // gpio_hold_en keeps backlight off during sleep
#include <vector>
#include <array>
#include <LittleFS.h>
#include <BleKeyboard.h>
#include <NimBLEDevice.h>
/*
============================================================================================================================
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<NodePresence> nodesConnected;
int nodesCount = 0;
static std::vector<std::array<char,9>> seenMessageIds;
int seenIndex = 0;
char inputBuffer[MAX_MSG_LEN] = "";
uint8_t broadcastAddressEspNow[] = {0xFF,0xFF,0xFF,0xFF,0xFF,0xFF};
// static std::vector<Message> 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 <name> : change pseudo");
//addMessage("/me <action> : action roleplay");
//addMessage("/clear : clear the chat");
//addMessage("/people : list connected");
ui_push_message("", "/nick <name> : change pseudo", false, true);
ui_push_message("", "/me <action> : 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);
}

12
src/meshberry/chat.h Normal file
View file

@ -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();

View file

@ -0,0 +1,2 @@
dependencies:
idf: '>=5.1'

49
src/meshberry/main.cpp Normal file
View file

@ -0,0 +1,49 @@
#include <Arduino.h>
#include <esp_sleep.h>
#include <driver/gpio.h>
#include "config.h"
#include "settings.h"
#include "ui.h"
#include "cardkb.h"
#include "sound.h"
#include "chat.h"
#include <BleKeyboard.h>
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);
}

172
src/meshberry/settings.cpp Normal file
View file

@ -0,0 +1,172 @@
#include "settings.h"
#include <Arduino.h>
#include <LittleFS.h>
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;
}

28
src/meshberry/settings.h Normal file
View file

@ -0,0 +1,28 @@
#pragma once
#include <lvgl.h>
#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);

12
src/meshberry/sound.cpp Normal file
View file

@ -0,0 +1,12 @@
#include "sound.h"
#include "config.h"
#include "settings.h"
#include <Arduino.h>
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); }

5
src/meshberry/sound.h Normal file
View file

@ -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

290
src/meshberry/ui.cpp Normal file
View file

@ -0,0 +1,290 @@
#include "ui.h"
#include "settings.h"
#include "config.h"
#include <Arduino.h>
#include <LittleFS.h>
#include <LovyanGFX.hpp>
#include <lvgl.h>
// 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();
}

23
src/meshberry/ui.h Normal file
View file

@ -0,0 +1,23 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
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();