Add M5Stack Cardputer-Adv variant with keyboard/display companion UI
New variant variants/m5stack_cardputer_adv for the Cardputer-Adv (ESP32-S3, 8 MB) with the Cap LoRa-1262 (SX1262 + GNSS): - Envs: companion_radio_ble, companion_radio_usb, repeater, room_server. - Board: enables the Cap's antenna path via the PI4IOE5V6408 on the internal I2C bus (Wire1, G8/G9); Wire stays on the Grove port. LoRa and microSD share FSPI because the display owns SPI3. TCXO 1.8 V. - Companion UI adapted from MultiMote/meshcore-cardputer-adv (MIT, LICENSE.MultiMote), running on the standard companion so the USB terminal CLI stays available. Contacts/identity stay in internal SPIFFS; the microSD card is optional (chat history, keyboard layouts). - Sticky Shift, one-shot Fn with Fn+letter shortcuts, top button (G0) sends a flood advert, brightness control. - Text size setting (Normal/Large/Extra large) with DejaVu Sans fonts generated by tools/gen_vlw_font.py; layouts derive from the font size. - Power save maps to the companion's powersaving pref; the UI vetoes light sleep while the screen is on. Low-battery power-off at 3.4 V. - win_long_cmdline.py: Windows-only -iprefix include shortening so the ESP32 compile command stays under the 32,767-character limit. Shared companion changes are guarded by CARDPUTER_ADV_UI: main.cpp selects CardputerDataStore/CardputerUITask/CardputerMesh, and MyMesh.h maps the_mesh to the subclass like the PSRAM storage path does. Tested on hardware (910.525 MHz mesh): radio RX/TX, USB CLI, BLE, UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ec3a07236
commit
5681898ad4
60 files changed
+25938
-3
No files matched your search
@@ -1189,6 +1189,10 @@ private:
|
||||
MyMesh& companionMeshInstance();
|
||||
// Keep the shared UI/CLI lvalue interface; the accessor never allocates.
|
||||
#define the_mesh companionMeshInstance()
|
||||
#elif CARDPUTER_ADV_UI
|
||||
// The Cardputer-Adv UI owns a MyMesh subclass (variants/m5stack_cardputer_adv).
|
||||
MyMesh& cardputerMeshInstance();
|
||||
#define the_mesh cardputerMeshInstance()
|
||||
#else
|
||||
extern MyMesh the_mesh;
|
||||
#endif
|
||||
@@ -268,11 +268,21 @@ static bool isNetworkTerminalActive();
|
||||
DataStore store(LittleFS, rtc_clock);
|
||||
#elif defined(ESP32)
|
||||
#include <SPIFFS.h>
|
||||
DataStore store(SPIFFS, rtc_clock);
|
||||
#if CARDPUTER_ADV_UI
|
||||
#include "CardputerDataStore.h"
|
||||
CardputerDataStore store(SPIFFS, rtc_clock);
|
||||
#else
|
||||
DataStore store(SPIFFS, rtc_clock);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/* GLOBAL OBJECTS */
|
||||
#ifdef DISPLAY_CLASS
|
||||
#if CARDPUTER_ADV_UI
|
||||
// Keyboard/display UI for the M5Stack Cardputer-Adv (variant-provided).
|
||||
#include "CardputerMesh.h"
|
||||
#include <SD.h>
|
||||
CardputerUITask ui_task(&board, &interface_manager);
|
||||
#elif defined(DISPLAY_CLASS)
|
||||
#include "UITask.h"
|
||||
UITask ui_task(&board, &interface_manager);
|
||||
#endif
|
||||
@@ -293,6 +303,9 @@ static bool initializeCompanionMeshStorage() {
|
||||
: "ERROR: insufficient PSRAM for Full Companion; startup stopped");
|
||||
return false;
|
||||
}
|
||||
#elif CARDPUTER_ADV_UI
|
||||
CardputerMesh the_mesh_cp(radio_driver, fast_rng, rtc_clock, tables, store, &ui_task);
|
||||
MyMesh& cardputerMeshInstance() { return the_mesh_cp; }
|
||||
#else
|
||||
MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store
|
||||
#ifdef DISPLAY_CLASS
|
||||
@@ -3830,6 +3843,16 @@ void setup() {
|
||||
companion_display_begin_status = companion_display_available ? 1 : 0;
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
#if CARDPUTER_ADV_UI
|
||||
DisplayDriver* cardputer_disp = NULL;
|
||||
if (display.begin()) {
|
||||
cardputer_disp = &display;
|
||||
display.startFrame();
|
||||
display.setTextSize(2);
|
||||
display.drawTextCentered(display.width() / 2, 28, "Loading...");
|
||||
display.endFrame();
|
||||
}
|
||||
#endif
|
||||
|
||||
int radioinit_attempts = 0;
|
||||
@@ -4267,7 +4290,17 @@ void setup() {
|
||||
the_mesh.applyGpsPrefs();
|
||||
#endif
|
||||
|
||||
#ifdef DISPLAY_CLASS
|
||||
#if CARDPUTER_ADV_UI
|
||||
// microSD is optional: chat history and keyboard layouts.
|
||||
FILESYSTEM* cardputer_sd = NULL;
|
||||
if (SD.begin(PIN_SD_CS, lora_spi, 25000000)) {
|
||||
cardputer_sd = &SD;
|
||||
board.readKeyboardLayout(SD);
|
||||
}
|
||||
store.beginCustom(cardputer_sd);
|
||||
the_mesh_cp.beginCustom();
|
||||
ui_task.begin(cardputer_disp, &sensors, the_mesh.getNodePrefs(), the_mesh_cp.getCustomNodePrefs());
|
||||
#elif defined(DISPLAY_CLASS)
|
||||
#if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED
|
||||
// Retain the wrapper dependency in headless mode so live selection can
|
||||
// enable a screen. The UI skips rendering while no backend is selected.
|
||||
@@ -4409,6 +4442,9 @@ void loop() {
|
||||
#if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) && defined(PIN_USER_BTN)
|
||||
serviceWioE5HeadlessControls();
|
||||
#endif
|
||||
#if CARDPUTER_ADV_UI
|
||||
ui_task.loop();
|
||||
#endif
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Keep button/touch and critical UI power checks live. UITask itself skips
|
||||
// only background redraws while the radio needs precise Morse timing.
|
||||
@@ -4477,6 +4513,9 @@ void loop() {
|
||||
if (pending) return pending;
|
||||
if (mesh::isUsbLoggingWatchdogArmed()) return mask(Reason::UsbRecovery);
|
||||
if (mesh::wireless::control().pending()) return mask(Reason::Wireless);
|
||||
#if CARDPUTER_ADV_UI
|
||||
if (ui_task.isAwake()) return mask(Reason::Button);
|
||||
#endif
|
||||
return 0;
|
||||
}();
|
||||
#if defined(ESP32_PLATFORM) && MESH_USB_LOGGING_AVAILABLE
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <helpers/ESP32Board.h>
|
||||
#include "globals.h"
|
||||
|
||||
#if CARDPUTER_ADV_UI
|
||||
#include <FS.h>
|
||||
#include "hw/CardputerSpeaker.h"
|
||||
#include "hw/CardputerKeyboard.h"
|
||||
#include "hw/CardputerLayout.h"
|
||||
#endif
|
||||
|
||||
// M5Stack Cardputer-Adv with the Cap LoRa-1262 (SX1262 + ATGM336H GNSS).
|
||||
// The internal I2C bus (G8/G9, Wire1) carries the keyboard, the audio codec
|
||||
// and the Cap's PI4IOE5V6408 expander; Wire stays on the Grove port (G2/G1).
|
||||
// The radio cannot reach the antenna until expander P0 is driven high.
|
||||
// Sleep, deep sleep and power-off are ESP32Board's (no LoRa wake on power-off).
|
||||
// Keyboard/display/speaker UI adapted from MultiMote/meshcore-cardputer-adv
|
||||
// (MIT, see LICENSE.MultiMote).
|
||||
#define CARDPUTER_PI4IO_REG_IO_DIR 0x03
|
||||
#define CARDPUTER_PI4IO_REG_OUT_SET 0x05
|
||||
#define CARDPUTER_PI4IO_REG_OUT_HIZ 0x07
|
||||
|
||||
class CardputerAdvBoard : public ESP32Board {
|
||||
bool _lora_cap_ok = false;
|
||||
#if CARDPUTER_ADV_UI
|
||||
CardputerSpeaker speaker;
|
||||
CardputerKeyboard keyboard;
|
||||
CardputerLayout layout;
|
||||
#endif
|
||||
|
||||
static bool pi4ioWrite(uint8_t reg, uint8_t val) {
|
||||
Wire1.beginTransmission(LORA_IOE_I2C_ADDRESS);
|
||||
Wire1.write(reg);
|
||||
Wire1.write(val);
|
||||
return Wire1.endTransmission() == 0;
|
||||
}
|
||||
|
||||
public:
|
||||
void begin() {
|
||||
ESP32Board::begin(); // starts Wire on PIN_BOARD_SDA/SCL (Grove)
|
||||
|
||||
Wire1.begin(PIN_I2C_INTERNAL_SDA, PIN_I2C_INTERNAL_SCL, 100000);
|
||||
_lora_cap_ok = pi4ioWrite(CARDPUTER_PI4IO_REG_IO_DIR, 0x01)
|
||||
&& pi4ioWrite(CARDPUTER_PI4IO_REG_OUT_HIZ, 0x00)
|
||||
&& pi4ioWrite(CARDPUTER_PI4IO_REG_OUT_SET, 0x01);
|
||||
|
||||
#if CARDPUTER_ADV_UI
|
||||
keyboard.begin();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isLoraCapReady() const { return _lora_cap_ok; }
|
||||
|
||||
#if CARDPUTER_ADV_UI
|
||||
CardputerSpeaker *getSpeaker() { return &speaker; }
|
||||
CardputerKeyboard *getKeyboard() { return &keyboard; }
|
||||
CardputerLayout *getLayout() { return &layout; }
|
||||
void readKeyboardLayout(FS &fs) { layout.begin(fs); }
|
||||
#endif
|
||||
|
||||
const char* getManufacturerName() const override {
|
||||
return "M5Stack Cardputer-Adv";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
#include "CardputerDataStore.h"
|
||||
|
||||
#include "globals.h"
|
||||
|
||||
static void buf_to_hex_fast(const unsigned char *src, size_t src_len, char *dst) {
|
||||
static const char hex_digits[] = "0123456789abcdef";
|
||||
|
||||
for (size_t i = 0; i < src_len; i++) {
|
||||
dst[i * 2] = hex_digits[(src[i] >> 4) & 0x0F]; // High nibble
|
||||
dst[i * 2 + 1] = hex_digits[src[i] & 0x0F]; // Low nibble
|
||||
}
|
||||
dst[src_len * 2] = '\0';
|
||||
}
|
||||
|
||||
void CardputerDataStore::beginCustom(FILESYSTEM *history_fs) {
|
||||
_history_fs = history_fs;
|
||||
if (!_history_fs) {
|
||||
return;
|
||||
}
|
||||
_history_fs->mkdir(CUSTOM_DATA_DIR);
|
||||
_history_fs->mkdir(HISTORY_DIR);
|
||||
_history_fs->mkdir(HISTORY_DIR_CHANNEL);
|
||||
_history_fs->mkdir(HISTORY_DIR_DIRECT);
|
||||
}
|
||||
|
||||
void CardputerDataStore::loadCustomPrefs(CustomNodePrefs &prefs) {
|
||||
if (!_fs->exists(CUSTOM_PREFS_FILE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = _fs->open(CUSTOM_PREFS_FILE, FILE_READ);
|
||||
|
||||
// Read file only if it exactly matches struct size, otherwise keep default values
|
||||
if (file.size() == sizeof(prefs)) {
|
||||
file.read((uint8_t *)&prefs, sizeof(prefs));
|
||||
}
|
||||
|
||||
file.close();
|
||||
}
|
||||
|
||||
void CardputerDataStore::saveCustomPrefs(const CustomNodePrefs &prefs) {
|
||||
File file = _fs->open(CUSTOM_PREFS_FILE, FILE_WRITE);
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
file.write((uint8_t *)&prefs, sizeof(prefs));
|
||||
file.close();
|
||||
}
|
||||
|
||||
void CardputerDataStore::storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const char *text, bool is_sent,
|
||||
bool is_channel) {
|
||||
if (!_history_fs) {
|
||||
return;
|
||||
}
|
||||
char path[128];
|
||||
char msg_copy[MAX_MESSAGE_LENGTH + 1] = { 0 };
|
||||
char pkey_hex[PUB_KEY_SIZE * 2 + 1];
|
||||
uint8_t key_len = is_channel ? (PUB_KEY_SIZE / 2) : PUB_KEY_SIZE;
|
||||
|
||||
buf_to_hex_fast(pkey, key_len, pkey_hex);
|
||||
|
||||
int msg_len;
|
||||
for (msg_len = 0; text[msg_len] != '\0' && msg_len < MAX_MESSAGE_LENGTH; msg_len++) {
|
||||
if (text[msg_len] == '\n' || text[msg_len] == '\r') {
|
||||
msg_copy[msg_len] = ' '; // Swap newline with space
|
||||
} else {
|
||||
msg_copy[msg_len] = text[msg_len];
|
||||
}
|
||||
}
|
||||
|
||||
msg_copy[msg_len] = '\0';
|
||||
|
||||
snprintf(path, sizeof(path), "%s/%s", is_channel ? HISTORY_DIR_CHANNEL : HISTORY_DIR_DIRECT, pkey_hex);
|
||||
|
||||
File file = _history_fs->open(path, FILE_APPEND, true);
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
file.print(is_sent ? ">> " : "<< ");
|
||||
file.println(msg_copy);
|
||||
file.close();
|
||||
}
|
||||
|
||||
static void push_history_line(ChatHistory &history, char *buf) {
|
||||
int len = strlen(buf);
|
||||
|
||||
if (len < 4 || (buf[0] != '>' && buf[0] != '<') || buf[0] != buf[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
HistoryMessage *msg = history.push_ref();
|
||||
msg->out = buf[0] == '>';
|
||||
|
||||
strncpy(msg->text, buf + 3, sizeof(msg->text));
|
||||
msg->text[sizeof(msg->text) - 1] = '\0';
|
||||
}
|
||||
|
||||
void CardputerDataStore::loadMessages(const uint8_t pkey[PUB_KEY_SIZE], bool is_channel,
|
||||
ChatHistory &history) {
|
||||
history.clear();
|
||||
|
||||
if (!_history_fs) {
|
||||
return;
|
||||
}
|
||||
char path[128];
|
||||
char pkey_hex[PUB_KEY_SIZE * 2 + 1];
|
||||
char line_buf[256] = { 0 };
|
||||
uint8_t file_read_buf[1024];
|
||||
|
||||
uint8_t key_len = is_channel ? (PUB_KEY_SIZE / 2) : PUB_KEY_SIZE;
|
||||
|
||||
buf_to_hex_fast(pkey, key_len, pkey_hex);
|
||||
|
||||
snprintf(path, sizeof(path), "%s/%s", is_channel ? HISTORY_DIR_CHANNEL : HISTORY_DIR_DIRECT, pkey_hex);
|
||||
|
||||
if (!_history_fs->exists(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = _history_fs->open(path, FILE_READ, false);
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
size_t line_idx = 0;
|
||||
|
||||
while (file.available()) {
|
||||
int bytesRead = file.read(file_read_buf, sizeof(file_read_buf));
|
||||
|
||||
for (int b = 0; b < bytesRead; b++) {
|
||||
char c = (char)file_read_buf[b];
|
||||
|
||||
if (c == '\n' || line_idx >= sizeof(line_buf) - 1) {
|
||||
line_buf[line_idx] = '\0';
|
||||
line_idx = 0;
|
||||
push_history_line(history, line_buf);
|
||||
} else if (c != '\r') {
|
||||
line_buf[line_idx++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any remaining text if the file didn't end with a trailing LF
|
||||
if (line_idx > 0) {
|
||||
line_buf[line_idx] = '\0';
|
||||
push_history_line(history, line_buf);
|
||||
}
|
||||
|
||||
file.close();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <DataStore.h>
|
||||
#include "globals.h"
|
||||
#include "RingBuffer.h"
|
||||
|
||||
//** Custom node preferences, persisted to file. Power saving is the
|
||||
// companion's own powersaving_enabled pref (shared with the CLI). */
|
||||
struct __attribute__((packed)) CustomNodePrefs {
|
||||
float battery_correction;
|
||||
uint8_t brightness;
|
||||
uint8_t text_level; // CardputerDisplay::TextLevel
|
||||
};
|
||||
|
||||
struct HistoryMessage {
|
||||
char text[MAX_MESSAGE_LENGTH];
|
||||
bool out;
|
||||
};
|
||||
|
||||
using ChatHistory = RingBuffer<HistoryMessage, CHAT_HISTORY_RINGBUF_SIZE>;
|
||||
|
||||
// Contacts, channels and node prefs stay on the primary (internal) FS managed
|
||||
// by DataStore. Cardputer UI prefs live there too; per-chat history is kept
|
||||
// only when a microSD card is mounted.
|
||||
class CardputerDataStore : public DataStore {
|
||||
private:
|
||||
FILESYSTEM *_fs;
|
||||
FILESYSTEM *_history_fs = nullptr;
|
||||
|
||||
public:
|
||||
CardputerDataStore(FILESYSTEM &fs, mesh::RTCClock &clock) : DataStore(fs, clock), _fs(&fs) {}
|
||||
|
||||
// Call after the SD card is mounted (or not); nullptr disables history.
|
||||
void beginCustom(FILESYSTEM *history_fs);
|
||||
bool hasHistory() const { return _history_fs != nullptr; }
|
||||
void loadCustomPrefs(CustomNodePrefs &prefs);
|
||||
void saveCustomPrefs(const CustomNodePrefs &prefs);
|
||||
void storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const char *text, bool is_sent, bool is_channel);
|
||||
void loadMessages(const uint8_t pkey[PUB_KEY_SIZE], bool is_channel, ChatHistory &history);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "CardputerSensorManager.h"
|
||||
|
||||
void CardputerSensorManager::start_gps() {
|
||||
if (!gps_active) {
|
||||
gps_standby(false);
|
||||
delay(1000);
|
||||
gps_active = true;
|
||||
}
|
||||
_location->begin();
|
||||
}
|
||||
|
||||
void CardputerSensorManager::stop_gps() {
|
||||
if (gps_active) {
|
||||
gps_standby(true);
|
||||
gps_active = false;
|
||||
}
|
||||
_location->stop();
|
||||
}
|
||||
|
||||
void CardputerSensorManager::gps_standby(bool active) {
|
||||
if (active) {
|
||||
// Cap Lora-1262 does not have GPS switch pin
|
||||
// Instead we send a command to the GPS module to put it into standby for maximum possible value
|
||||
// (65535 seconds)
|
||||
Serial1.print("$PCAS12,65535*1E\r\n");
|
||||
} else {
|
||||
// Hot reboot to exit standby mode
|
||||
Serial1.print("$PCAS10,0*1C\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
bool CardputerSensorManager::begin() {
|
||||
Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX);
|
||||
Serial1.begin(GPS_BAUD_RATE);
|
||||
|
||||
delay(1000);
|
||||
|
||||
// Check data available to determine if GPS active
|
||||
if (Serial1.available() > 0) {
|
||||
start_gps();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CardputerSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) {
|
||||
if (requester_permissions & TELEM_PERM_LOCATION) { // does requester have permission?
|
||||
telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void CardputerSensorManager::loop() {
|
||||
static long next_gps_update = 0;
|
||||
static long next_gps_force_shutdown = 0;
|
||||
|
||||
// periodically send standby command because standby has maximum of 18 hours
|
||||
if (millis() > next_gps_force_shutdown) {
|
||||
if (!gps_active) {
|
||||
gps_standby(true);
|
||||
}
|
||||
next_gps_force_shutdown = millis() + 1000 * 60 * 60; // every hour
|
||||
}
|
||||
|
||||
if (!gps_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
_location->loop();
|
||||
|
||||
if (millis() > next_gps_update) {
|
||||
if (_location->isValid()) {
|
||||
node_lat = ((double)_location->getLatitude()) / 1000000.;
|
||||
node_lon = ((double)_location->getLongitude()) / 1000000.;
|
||||
node_altitude = ((double)_location->getAltitude()) / 1000.0;
|
||||
MESH_DEBUG_PRINTLN("VALID location: lat %f lon %f", node_lat, node_lon);
|
||||
} else {
|
||||
MESH_DEBUG_PRINTLN("INVALID location, waiting for fix");
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("GPS satellites: %d", _location->satellitesCount());
|
||||
next_gps_update = millis() + 1000;
|
||||
}
|
||||
}
|
||||
|
||||
int CardputerSensorManager::getNumSettings() const {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *CardputerSensorManager::getSettingName(int i) const {
|
||||
return i == 0 ? "gps" : NULL;
|
||||
}
|
||||
|
||||
const char *CardputerSensorManager::getSettingValue(int i) const {
|
||||
if (i == 0) {
|
||||
return gps_active ? "1" : "0";
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bool CardputerSensorManager::setSettingValue(const char *name, const char *value) {
|
||||
if (strcmp(name, "gps") == 0) {
|
||||
if (strcmp(value, "0") == 0) {
|
||||
stop_gps();
|
||||
} else {
|
||||
start_gps();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false; // not supported
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/sensors/LocationProvider.h>
|
||||
|
||||
class CardputerSensorManager : public SensorManager {
|
||||
private:
|
||||
bool gps_active = false;
|
||||
LocationProvider *_location;
|
||||
|
||||
public:
|
||||
CardputerSensorManager(LocationProvider &location) : _location(&location) {}
|
||||
bool begin() override;
|
||||
bool querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) override;
|
||||
void loop() override;
|
||||
int getNumSettings() const override;
|
||||
const char *getSettingName(int i) const override;
|
||||
const char *getSettingValue(int i) const override;
|
||||
bool setSettingValue(const char *name, const char *value) override;
|
||||
LocationProvider *getLocationProvider() override { return _location; }
|
||||
void start_gps();
|
||||
void stop_gps();
|
||||
void gps_standby(bool active);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 MultiMote
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,79 @@
|
||||
# M5Stack Cardputer-Adv + Cap LoRa-1262
|
||||
|
||||
Variant for the [M5Stack Cardputer-Adv](https://docs.m5stack.com/en/core/Cardputer-Adv)
|
||||
(ESP32-S3, 8 MB flash) with the
|
||||
[Cap LoRa-1262](https://docs.m5stack.com/en/cap/Cap_LoRa-1262) (SX1262 + ATGM336H GNSS).
|
||||
|
||||
| Environment | Role |
|
||||
|---|---|
|
||||
| `M5Stack_Cardputer_Adv_companion_radio_ble` | Companion with keyboard/display UI, BLE and USB terminal |
|
||||
| `M5Stack_Cardputer_Adv_companion_radio_usb` | Same UI, USB only (no BLE) |
|
||||
| `M5Stack_Cardputer_Adv_repeater` | Headless repeater |
|
||||
| `M5Stack_Cardputer_Adv_room_server` | Headless room server |
|
||||
|
||||
The companion UI is adapted from
|
||||
[MultiMote/meshcore-cardputer-adv](https://github.com/MultiMote/meshcore-cardputer-adv)
|
||||
(MIT, see `LICENSE.MultiMote`) and runs on top of the standard companion, so
|
||||
the USB terminal CLI (`help`, `set ...`, `powersaving ...`) stays available.
|
||||
|
||||
## Hardware notes
|
||||
|
||||
- LoRa: NSS 5, SCK 40, MISO 39, MOSI 14, RST 3, BUSY 6, DIO1 4. DIO2 drives the
|
||||
RF switch; TCXO 1.8 V.
|
||||
- The Cap's antenna path is enabled through P0 of a PI4IOE5V6408 at 0x43 on the
|
||||
internal I2C bus (G8/G9, `Wire1`), shared with the TCA8418 keyboard and ES8311
|
||||
codec. `Wire` is the Grove port (G2/G1).
|
||||
- The microSD slot (CS 12) shares the LoRa SPI bus, which runs on FSPI because
|
||||
the display owns SPI3.
|
||||
- Battery on G10 (x2 divider). GNSS on UART 15/13 at 115200.
|
||||
- Top button (G0) sends a flood advert.
|
||||
|
||||
## Storage
|
||||
|
||||
Contacts, channels, identity and settings live in the internal `spiffs`
|
||||
partition (`default_8MB.csv`). The microSD card is optional and holds chat
|
||||
history and keyboard layouts.
|
||||
|
||||
When the device is managed by bmorcelli's Launcher, install updates by writing
|
||||
the app image to the MeshCore app slot over USB rather than reinstalling from
|
||||
the SD card: Launcher can repartition the flash to fit the image and drop the
|
||||
`spiffs` partition, which wipes the node's identity.
|
||||
|
||||
## Keyboard
|
||||
|
||||
- `del` deletes, `esc` (top-left key) goes back, `Enter` selects or sends.
|
||||
- Arrows are `;` `.` `,` `/`. While typing a chat message, prefix them with `Fn`.
|
||||
- `Aa` (Shift) is sticky: press once for caps, again to release.
|
||||
- `Fn` is one-shot: press `Fn`, then a key; it releases after that key.
|
||||
A `CAPS`/`FN` badge shows while either is active.
|
||||
|
||||
| Fn + | Action |
|
||||
|---|---|
|
||||
| A | Flood advert (same as the top button) |
|
||||
| G | GPS on/off |
|
||||
| B | Bluetooth on/off (same checks as `set bluetooth`) |
|
||||
| M | Sound on/off |
|
||||
| P | Power save on/off (same setting as `powersaving`) |
|
||||
| L | Screen brightness |
|
||||
| D | Discover repeaters |
|
||||
| H / C / N / I | Home / Contacts / Channels / Info |
|
||||
| S / T | Settings / Tools |
|
||||
| K | Switch keyboard layout (layouts loaded from SD) |
|
||||
|
||||
## Display
|
||||
|
||||
Settings -> Text size cycles Normal (12 px), Large (16 px, default) and
|
||||
Extra large (19 px). The larger fonts are generated from DejaVu Sans with
|
||||
`tools/gen_vlw_font.py`.
|
||||
|
||||
## Power
|
||||
|
||||
- Power save uses the companion's own light-sleep loop. It only sleeps while the
|
||||
screen is off and no USB or BLE client is connected.
|
||||
- Below 3.4 V the UI shows "Battery low" and powers off (no LoRa wake).
|
||||
|
||||
## Building on Windows
|
||||
|
||||
`win_long_cmdline.py` shortens the compiler include list with `-iprefix`, since
|
||||
these environments otherwise exceed Windows' 32,767-character command limit.
|
||||
It does nothing on other platforms.
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
|
||||
template <typename T, size_t Size> class RingBuffer {
|
||||
private:
|
||||
T buf[Size];
|
||||
size_t head_idx;
|
||||
size_t tail_idx;
|
||||
bool is_full;
|
||||
|
||||
public:
|
||||
RingBuffer() : head_idx(0), tail_idx(0), is_full(false) {}
|
||||
|
||||
// Nothing cleared, but index is reset
|
||||
inline void clear() {
|
||||
head_idx = 0;
|
||||
tail_idx = 0;
|
||||
is_full = false;
|
||||
}
|
||||
|
||||
// Push element using copy
|
||||
void push(T data) {
|
||||
if (is_full) {
|
||||
tail_idx = (tail_idx + 1) % Size;
|
||||
}
|
||||
|
||||
buf[head_idx] = data;
|
||||
head_idx = (head_idx + 1) % Size;
|
||||
|
||||
if (head_idx == tail_idx) {
|
||||
is_full = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Not exactly push, but returns the address of the element to push and updates the index, we can use it to modify the element
|
||||
T* push_ref() {
|
||||
if (is_full) {
|
||||
tail_idx = (tail_idx + 1) % Size;
|
||||
}
|
||||
|
||||
T* ptr = &buf[head_idx];
|
||||
|
||||
head_idx = (head_idx + 1) % Size;
|
||||
|
||||
if (head_idx == tail_idx) {
|
||||
is_full = true;
|
||||
}
|
||||
|
||||
return ptr;
|
||||
}
|
||||
|
||||
bool get(size_t index, const T* &data) const {
|
||||
if (index >= count()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t real_idx = (tail_idx + index) % Size;
|
||||
data = &buf[real_idx];
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t count() const {
|
||||
if (is_full) {
|
||||
return Size;
|
||||
}
|
||||
|
||||
if (head_idx >= tail_idx) {
|
||||
return head_idx - tail_idx;
|
||||
}
|
||||
|
||||
return Size + head_idx - tail_idx;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
#include "globals.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
struct UnreadCounterItem {
|
||||
bool is_channel;
|
||||
uint8_t pkey[CONTACT_LOOKUP_BYTES]; // todo: maybe replace to index, but what should we do if it gets deleted?
|
||||
int count;
|
||||
};
|
||||
|
||||
class UnreadCounter {
|
||||
|
||||
private:
|
||||
UnreadCounterItem _items[UNREAD_COUNTER_MAX_ITEMS];
|
||||
uint16_t _count = 0;
|
||||
using LookupPkey = uint8_t[CONTACT_LOOKUP_BYTES];
|
||||
|
||||
public:
|
||||
UnreadCounter() {}
|
||||
|
||||
const UnreadCounterItem *get(uint16_t index) const {
|
||||
if (index >= _count) {
|
||||
return nullptr;
|
||||
}
|
||||
return &_items[index];
|
||||
}
|
||||
|
||||
inline const uint16_t countChats() const { return _count; }
|
||||
const uint16_t countMessages() const {
|
||||
uint16_t total = 0;
|
||||
for (uint16_t i = 0; i < _count; i++) {
|
||||
total += _items[i].count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
bool add(bool is_channel, const LookupPkey pkey, int count) {
|
||||
for (uint16_t i = 0; i < _count; i++) {
|
||||
if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, CONTACT_LOOKUP_BYTES) == 0) {
|
||||
_items[i].count += count;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (_count < UNREAD_COUNTER_MAX_ITEMS) {
|
||||
_items[_count].is_channel = is_channel;
|
||||
_items[_count].count = count;
|
||||
memcpy(_items[_count].pkey, pkey, CONTACT_LOOKUP_BYTES);
|
||||
_count++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void reset(bool is_channel, const LookupPkey pkey) {
|
||||
for (uint16_t i = 0; i < _count; i++) {
|
||||
if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, CONTACT_LOOKUP_BYTES) == 0) {
|
||||
// Swap the target item with the last active item
|
||||
if (i < _count - 1) {
|
||||
_items[i] = _items[_count - 1];
|
||||
}
|
||||
|
||||
_count--;
|
||||
_items[_count] = { false, 0, 0 };
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void addChannel(const LookupPkey pkey, int count) { add(true, pkey, count); }
|
||||
|
||||
inline void addContact(const LookupPkey pkey, int count) { add(false, pkey, count); }
|
||||
|
||||
inline void resetChannel(const LookupPkey pkey) { reset(true, pkey); }
|
||||
|
||||
inline void resetContact(const LookupPkey pkey) { reset(false, pkey); }
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
#include "CardputerMesh.h"
|
||||
|
||||
void CardputerMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags,
|
||||
const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) {
|
||||
MyMesh::onTraceRecv(packet, tag, auth_code, flags, path_snrs, path_hashes, path_len);
|
||||
|
||||
if (path_len == getNodePrefs()->path_hash_mode + 1 && tag == last_ping_tag) {
|
||||
int8_t snr_signed = (int8_t)path_snrs[0];
|
||||
_ui->pingRecv(snr_signed / 4.0f, packet->getSNR());
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerMesh::onControlDataRecv(mesh::Packet *packet) {
|
||||
MyMesh::onControlDataRecv(packet);
|
||||
uint8_t type = packet->payload[0] & 0xF0;
|
||||
uint8_t node_type = packet->payload[0] & 0x0F;
|
||||
|
||||
if (!(type == CTL_TYPE_NODE_DISCOVER_RESP && packet->payload_len >= 6)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node_type != ADV_TYPE_REPEATER) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (packet->payload_len < 6 + PUB_KEY_SIZE) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t tag;
|
||||
memcpy(&tag, &packet->payload[2], 4);
|
||||
|
||||
if (tag != last_discover_tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
mesh::Identity id(&packet->payload[6]);
|
||||
|
||||
if (id.matches(self_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_ui->discoverRecv(id, packet->getSNR());
|
||||
}
|
||||
|
||||
mesh::DispatcherAction CardputerMesh::onRecvPacket(mesh::Packet *pkt) {
|
||||
mesh::DispatcherAction action = MyMesh::onRecvPacket(pkt);
|
||||
|
||||
if (pkt->getPayloadType() == PAYLOAD_TYPE_GRP_TXT) {
|
||||
uint8_t hash[MAX_HASH_SIZE];
|
||||
pkt->calculatePacketHash(hash);
|
||||
|
||||
if (memcmp(hash, last_message_hash, MAX_HASH_SIZE) == 0) {
|
||||
last_message_heard_repeats++;
|
||||
_ui->messageRepeatsRecv(last_message_heard_repeats);
|
||||
}
|
||||
}
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
void CardputerMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt,
|
||||
uint32_t timestamp, const char *text) {
|
||||
MyMesh::onChannelMessageRecv(channel, pkt, timestamp, text);
|
||||
_store->storeMessage(channel.secret, text, false, true);
|
||||
_ui->onChannelMessageRecv(channel, text);
|
||||
}
|
||||
|
||||
void CardputerMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
const char *text) {
|
||||
MyMesh::onMessageRecv(from, pkt, sender_timestamp, text);
|
||||
|
||||
_store->storeMessage(from.id.pub_key, text, false, false);
|
||||
_ui->onContactMessageRecv(from, text);
|
||||
}
|
||||
|
||||
bool CardputerMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt,
|
||||
uint32_t delay_millis) {
|
||||
bool sent = MyMesh::sendFloodScoped(channel, pkt, delay_millis);
|
||||
|
||||
if (pkt->getPayloadType() == PAYLOAD_TYPE_GRP_TXT) {
|
||||
pkt->calculatePacketHash(last_message_hash);
|
||||
last_message_heard_repeats = 0;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
bool CardputerMesh::sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, const char *sender_name,
|
||||
const char *text, int text_len) {
|
||||
bool ok = MyMesh::sendGroupMessage(timestamp, channel, sender_name, text, text_len);
|
||||
_store->storeMessage(channel.secret, text, true, true);
|
||||
return ok;
|
||||
}
|
||||
|
||||
int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text) {
|
||||
uint32_t est_timeout;
|
||||
bool flood = (recipient.out_path_len == OUT_PATH_UNKNOWN);
|
||||
last_msg.timestamp = timestamp;
|
||||
last_msg.recipient = recipient;
|
||||
strncpy(last_msg.text, text, sizeof(last_msg.text));
|
||||
last_msg.text[sizeof(last_msg.text) - 1] = 0;
|
||||
last_msg.state = MESSAGE_SENDING;
|
||||
last_msg.need_direct = !flood;
|
||||
last_msg.attempt = 1;
|
||||
last_msg.total_attempts = DIRECT_SEND_FLOOD_ATTEMPTS + (!flood ? DIRECT_SEND_ROUTE_ATTEMPTS : 0);
|
||||
|
||||
_ui->onMessageSendAttempt(last_msg.attempt, last_msg.total_attempts, last_msg.state);
|
||||
|
||||
if (recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
resend_timeout = futureMillis(DIRECT_SEND_FLOOD_RESEND_RELAY);
|
||||
} else {
|
||||
resend_timeout = futureMillis(DIRECT_SEND_ROUTE_RESEND_RELAY);
|
||||
}
|
||||
|
||||
int rc = MyMesh::sendMessage(recipient, timestamp, 0, text, last_msg.expected_ack, est_timeout);
|
||||
_store->storeMessage(recipient.id.pub_key, text, true, false);
|
||||
return rc;
|
||||
}
|
||||
|
||||
void CardputerMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
|
||||
MyMesh::logRxRaw(snr, rssi, raw, len);
|
||||
rx_packet_count++;
|
||||
}
|
||||
|
||||
bool CardputerMesh::processAck(const uint8_t *data, ContactInfo *&peer) {
|
||||
uint32_t hash;
|
||||
memcpy(&hash, data, 4);
|
||||
// _ui->onAckRecv(hash);
|
||||
|
||||
if (last_msg.state == MESSAGE_SENDING && last_msg.expected_ack == hash) {
|
||||
last_msg.state = MESSAGE_DELIVERED;
|
||||
last_msg.expected_ack = 0; // ignore duplicate ACKs
|
||||
_ui->onMessageSendAttempt(last_msg.attempt + 1, last_msg.total_attempts, last_msg.state);
|
||||
}
|
||||
|
||||
return MyMesh::processAck(data, peer);
|
||||
}
|
||||
|
||||
void CardputerMesh::beginCustom() {
|
||||
_store->loadCustomPrefs(_custom_prefs);
|
||||
}
|
||||
|
||||
void CardputerMesh::savePrefs() {
|
||||
MyMesh::savePrefs();
|
||||
_store->saveCustomPrefs(_custom_prefs);
|
||||
}
|
||||
|
||||
void CardputerMesh::setPowerSaving(bool enabled) {
|
||||
getNodePrefs()->powersaving_enabled = enabled ? 1 : 0;
|
||||
savePrefs();
|
||||
sensors.setPowerSavingEnabled(enabled);
|
||||
}
|
||||
|
||||
bool CardputerMesh::sendPing(ContactInfo &contact) {
|
||||
CompanionNodePrefs *prefs = getNodePrefs();
|
||||
uint8_t flags = prefs->path_hash_mode & 0x03;
|
||||
|
||||
getRNG()->random((uint8_t *)&last_ping_tag, sizeof(last_ping_tag));
|
||||
|
||||
mesh::Packet *pkt = the_mesh_cp.createTrace(last_ping_tag, 0, flags);
|
||||
|
||||
if (!pkt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sendDirect(pkt, contact.id.pub_key, prefs->path_hash_mode + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CardputerMesh::sendAdvert(bool flood) {
|
||||
mesh::Packet *pkt;
|
||||
CompanionNodePrefs *prefs = getNodePrefs();
|
||||
|
||||
if (prefs->advert_loc_policy == ADVERT_LOC_NONE) {
|
||||
pkt = createSelfAdvert(prefs->node_name);
|
||||
} else {
|
||||
pkt = createSelfAdvert(prefs->node_name, sensors.node_lat, sensors.node_lon);
|
||||
}
|
||||
|
||||
if (!pkt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (flood) {
|
||||
TransportKey default_scope;
|
||||
memcpy(&default_scope.key, prefs->default_scope_key, sizeof(default_scope.key));
|
||||
MyMesh::sendFloodScoped(default_scope, pkt, 0);
|
||||
} else {
|
||||
sendZeroHop(pkt);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
struct __attribute__((packed)) discover_data_t {
|
||||
uint8_t ctl_type;
|
||||
uint8_t discover_type;
|
||||
uint32_t tag;
|
||||
uint32_t since;
|
||||
};
|
||||
|
||||
bool CardputerMesh::sendRepeatersDiscover() {
|
||||
discover_data_t data;
|
||||
data.ctl_type = CTL_TYPE_NODE_DISCOVER_REQ;
|
||||
data.discover_type = (1 << ADV_TYPE_REPEATER);
|
||||
data.since = 0;
|
||||
getRNG()->random((uint8_t *)&data.tag, sizeof(data.tag));
|
||||
|
||||
auto pkt = createControlData((uint8_t *)&data, sizeof(data));
|
||||
if (!pkt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
last_discover_tag = data.tag;
|
||||
sendZeroHop(pkt);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void CardputerMesh::loadMessageHistory(const uint8_t pkey[PUB_KEY_SIZE], bool is_channel,
|
||||
ChatHistory &history) {
|
||||
_store->loadMessages(pkey, is_channel, history);
|
||||
}
|
||||
|
||||
void CardputerMesh::checkResend() {
|
||||
if (last_msg.state != MESSAGE_SENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (last_msg.need_direct && last_msg.attempt >= DIRECT_SEND_ROUTE_ATTEMPTS) {
|
||||
last_msg.need_direct = false;
|
||||
|
||||
MESH_DEBUG_PRINTLN("Resetting path");
|
||||
ContactInfo *ref = the_mesh_cp.lookupContactByPubKey(last_msg.recipient.id.pub_key, CONTACT_LOOKUP_BYTES);
|
||||
if (ref) {
|
||||
ref->out_path_len = OUT_PATH_UNKNOWN;
|
||||
}
|
||||
last_msg.recipient.out_path_len = OUT_PATH_UNKNOWN;
|
||||
}
|
||||
|
||||
if (last_msg.attempt < last_msg.total_attempts) {
|
||||
uint32_t est_timeout;
|
||||
|
||||
_ui->onMessageSendAttempt(last_msg.attempt + 1, last_msg.total_attempts, last_msg.state);
|
||||
|
||||
if (last_msg.recipient.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
resend_timeout = futureMillis(DIRECT_SEND_FLOOD_RESEND_RELAY);
|
||||
} else {
|
||||
resend_timeout = futureMillis(DIRECT_SEND_ROUTE_RESEND_RELAY);
|
||||
}
|
||||
|
||||
// expected_ack is derived from the attempt number, so it must be refreshed on every resend
|
||||
MyMesh::sendMessage(last_msg.recipient, last_msg.timestamp, last_msg.attempt, last_msg.text,
|
||||
last_msg.expected_ack, est_timeout);
|
||||
|
||||
last_msg.attempt++;
|
||||
} else {
|
||||
last_msg.state = MESSAGE_FAILED;
|
||||
_ui->onMessageSendAttempt(last_msg.attempt + 1, last_msg.total_attempts, last_msg.state);
|
||||
MESH_DEBUG_PRINTLN("Message sending failed");
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerMesh::loop() {
|
||||
MyMesh::loop();
|
||||
|
||||
if (resend_timeout && millisHasNowPassed(resend_timeout)) {
|
||||
resend_timeout = 0;
|
||||
checkResend();
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerMesh::cancelResending() {
|
||||
resend_timeout = 0;
|
||||
if (last_msg.state == MESSAGE_SENDING) {
|
||||
last_msg.state = MESSAGE_FAILED;
|
||||
_ui->onMessageSendAttempt(last_msg.total_attempts, last_msg.total_attempts, last_msg.state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include "CardputerDataStore.h"
|
||||
#include "CardputerUITask.h"
|
||||
#include "types.h"
|
||||
|
||||
#include <MyMesh.h>
|
||||
|
||||
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
|
||||
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
|
||||
|
||||
class CardputerMesh : public MyMesh {
|
||||
CardputerUITask *_ui;
|
||||
CardputerDataStore *_store;
|
||||
uint64_t rx_packet_count = 0;
|
||||
uint32_t last_ping_tag = 0;
|
||||
uint32_t last_discover_tag = 0;
|
||||
uint8_t last_message_hash[MAX_HASH_SIZE] = { 0 };
|
||||
uint16_t last_message_heard_repeats = 0;
|
||||
CustomNodePrefs _custom_prefs{ .battery_correction = 1.0f, .brightness = 128, .text_level = 1 };
|
||||
LastSentMessage last_msg;
|
||||
unsigned long resend_timeout = 0;
|
||||
|
||||
public:
|
||||
CardputerMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables,
|
||||
CardputerDataStore &store, CardputerUITask *ui)
|
||||
: MyMesh(radio, rng, rtc, tables, store, ui), _ui(ui), _store(&store) {}
|
||||
|
||||
void onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags,
|
||||
const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) override;
|
||||
void onControlDataRecv(mesh::Packet *packet) override;
|
||||
mesh::DispatcherAction onRecvPacket(mesh::Packet *pkt) override;
|
||||
void onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t timestamp,
|
||||
const char *text) override;
|
||||
void onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
const char *text) override;
|
||||
bool sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis = 0) override;
|
||||
using MyMesh::sendFloodScoped;
|
||||
|
||||
// not virtual; using function hiding
|
||||
bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel &channel, const char *sender_name,
|
||||
const char *text, int text_len);
|
||||
|
||||
int sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text);
|
||||
|
||||
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
|
||||
|
||||
void checkResend();
|
||||
|
||||
bool processAck(const uint8_t *data, ContactInfo *&peer) override;
|
||||
|
||||
inline uint64_t receivedPacketsCount() const { return rx_packet_count; }
|
||||
|
||||
// Loads Cardputer UI prefs; call after MyMesh::begin().
|
||||
void beginCustom();
|
||||
void savePrefs();
|
||||
// Same effect as the CLI's "powersaving on|off".
|
||||
void setPowerSaving(bool enabled);
|
||||
bool sendPing(ContactInfo &contact);
|
||||
bool sendAdvert(bool flood);
|
||||
bool sendRepeatersDiscover();
|
||||
uint32_t getLastPingTag() const { return last_ping_tag; }
|
||||
inline CustomNodePrefs *getCustomNodePrefs() { return &_custom_prefs; }
|
||||
void loadMessageHistory(const uint8_t pkey[PUB_KEY_SIZE], bool is_channel, ChatHistory &history);
|
||||
void loop();
|
||||
void cancelResending();
|
||||
bool isMessageSending() { return last_msg.state == MESSAGE_SENDING; }
|
||||
};
|
||||
|
||||
extern CardputerMesh the_mesh_cp;
|
||||
@@ -0,0 +1,448 @@
|
||||
#include "CardputerUITask.h"
|
||||
|
||||
#include "screen/MainScreen.h"
|
||||
#include "screen/NewMessageScreen.h"
|
||||
#include "screen/SettingsScreen.h"
|
||||
#include "screen/SplashScreen.h"
|
||||
#include "screen/ToolsScreen.h"
|
||||
#include "target.h"
|
||||
#include "helpers.h"
|
||||
#include "CompanionBluetooth.h"
|
||||
|
||||
void CardputerUITask::begin(DisplayDriver *display, SensorManager *sensors, CompanionNodePrefs *node_prefs,
|
||||
CustomNodePrefs *custom_prefs) {
|
||||
_display = static_cast<CardputerDisplay *>(display);
|
||||
_sensors = sensors;
|
||||
extendAutoOff();
|
||||
|
||||
_node_prefs = node_prefs;
|
||||
_custom_prefs = custom_prefs;
|
||||
|
||||
if (_display != NULL) {
|
||||
_display->setBrightness(_custom_prefs->brightness);
|
||||
_display->setTextLevel(_custom_prefs->text_level);
|
||||
_display->turnOn();
|
||||
}
|
||||
_top_btn.begin();
|
||||
|
||||
ui_started_at = millis();
|
||||
|
||||
splash_screen = new SplashScreen(this);
|
||||
main_screen = new MainScreen(this, &rtc_clock, sensors, node_prefs, custom_prefs);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs, custom_prefs, _board);
|
||||
new_message_screen = new NewMessageScreen(this);
|
||||
tools_screen = new ToolsScreen(this, &rtc_clock);
|
||||
setCurrScreen(splash_screen);
|
||||
}
|
||||
|
||||
void CardputerUITask::showAlert(const char *text, int duration_millis) {
|
||||
snprintf(alert_text, sizeof(alert_text), "%s", text);
|
||||
alert_expiry = millis() + duration_millis;
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
void CardputerUITask::dismissAlert() {
|
||||
alert_expiry = 0;
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
bool CardputerUITask::isAlertActive() {
|
||||
return millis() < alert_expiry;
|
||||
}
|
||||
|
||||
void CardputerUITask::notify(UIEventType t) {
|
||||
if (t == UIEventType::channelMessage || t == UIEventType::contactMessage) {
|
||||
playSound(SoundType::NewMessage);
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::playSound(SoundType t) {
|
||||
if (_node_prefs->buzzer_quiet) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (t) {
|
||||
case SoundType::Keyboard:
|
||||
_board->getSpeaker()->queueTone(4000, 50, 0.2f);
|
||||
break;
|
||||
case SoundType::NewMessage:
|
||||
_board->getSpeaker()->queueTone(3000, 100);
|
||||
break;
|
||||
case SoundType::DiscoveryResult:
|
||||
case SoundType::MessageAck:
|
||||
_board->getSpeaker()->queueTone(800, 70, 0.3f);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::msgRead(int msgcount) {
|
||||
unsynced_msg_count = msgcount;
|
||||
if (msgcount == 0) {
|
||||
gotoMainScreen();
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount,
|
||||
int channel_idx, const char *channel_name, int queue_index) {
|
||||
unsynced_msg_count = msgcount;
|
||||
|
||||
bool onFirstPage =
|
||||
(current_screen == main_screen && ((MainScreen *)main_screen)->getCurrentPage() == MainScreen::FIRST);
|
||||
|
||||
bool display_off = (_display != NULL && !_display->isOn());
|
||||
|
||||
if (display_off || current_screen == new_message_screen || onFirstPage) {
|
||||
((NewMessageScreen *)new_message_screen)->newMessage(from_name, text);
|
||||
setCurrScreen(new_message_screen);
|
||||
}
|
||||
|
||||
if (_display != NULL) {
|
||||
if (!_display->isOn() && !hasConnection()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
extendAutoOff();
|
||||
scheduleRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) {
|
||||
((MainScreen *)main_screen)->onChannelMessageRecv(channel, text);
|
||||
}
|
||||
|
||||
void CardputerUITask::onContactMessageRecv(const ContactInfo &contact, const char *text) {
|
||||
((MainScreen *)main_screen)->onContactMessageRecv(contact, text);
|
||||
}
|
||||
|
||||
void CardputerUITask::onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state) {
|
||||
extendAutoOff();
|
||||
|
||||
if (current_screen == main_screen) {
|
||||
static_cast<MainScreen *>(main_screen)->onMessageSendAttempt(attempt, total, state);
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::pingRecv(float snr_tx, float snr_rx) {
|
||||
char buf[40];
|
||||
sprintf(buf, "SNR there/back: %.2f/%.2f", snr_tx, snr_rx);
|
||||
showAlert(buf, 4000);
|
||||
}
|
||||
|
||||
void CardputerUITask::discoverRecv(const mesh::Identity &id, float snr) {
|
||||
if (current_screen == tools_screen) {
|
||||
static_cast<ToolsScreen *>(tools_screen)->discoverRecv(id, snr);
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::messageRepeatsRecv(uint16_t count) {
|
||||
if (current_screen == main_screen) {
|
||||
static_cast<MainScreen *>(main_screen)->messageRepeatsRecv(count);
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::setCurrScreen(CardputerScreen *c) {
|
||||
current_screen = c;
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
/*
|
||||
hardware-agnostic pre-shutdown activity should be done here
|
||||
*/
|
||||
void CardputerUITask::shutdown(bool restart) {
|
||||
if (restart) {
|
||||
_board->reboot();
|
||||
} else {
|
||||
_display->turnOff();
|
||||
radio_driver.powerOff();
|
||||
_board->powerOff();
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::loop() {
|
||||
_board->getSpeaker()->processQueue();
|
||||
|
||||
CardputerKeyboard *kb = _board->getKeyboard();
|
||||
const Keyboard::Modifiers latched = kb->getModifiers();
|
||||
auto event = kb->poll();
|
||||
|
||||
if (event.changed && event.down) {
|
||||
if (turnDisplayOn()) {
|
||||
kb->setModifiers(latched); // a wake-up press must not flip Shift/Fn
|
||||
} else {
|
||||
playSound(SoundType::Keyboard);
|
||||
if (!handleShortcut(event) && current_screen) {
|
||||
current_screen->handleInput(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_top_btn.check() == BUTTON_EVENT_CLICK) {
|
||||
turnDisplayOn();
|
||||
sendFloodAdvert();
|
||||
}
|
||||
|
||||
if (current_screen) {
|
||||
current_screen->poll();
|
||||
}
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
if (millis() >= next_refresh && current_screen) {
|
||||
_display->startFrame();
|
||||
int delay_millis = current_screen->render(*_display);
|
||||
if (current_screen != main_screen) { // main screen shows them in its status bar
|
||||
drawModifierBadges(*_display, 3, 0);
|
||||
}
|
||||
if (millis() < alert_expiry) { // render alert popup
|
||||
renderAlert();
|
||||
next_refresh = alert_expiry; // will need refresh when alert is dismissed
|
||||
} else {
|
||||
scheduleRefresh(delay_millis);
|
||||
}
|
||||
_display->endFrame();
|
||||
}
|
||||
#if AUTO_OFF_MILLIS > 0
|
||||
if (millis() > auto_off_time) {
|
||||
_display->turnOff();
|
||||
_board->getSpeaker()->sleep();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef AUTO_SHUTDOWN_MILLIVOLTS
|
||||
if (millis() > next_batt_chck) {
|
||||
uint16_t milliVolts = getBattMilliVolts();
|
||||
if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) {
|
||||
if (_display != NULL) {
|
||||
_display->turnOn();
|
||||
_display->startFrame();
|
||||
_display->setTextSize(2);
|
||||
_display->setColor(CardputerDisplay::P_RED);
|
||||
_display->drawTextCentered(_display->width() / 2, _display->height() / 2 - 8, "Battery low");
|
||||
_display->endFrame();
|
||||
delay(2000);
|
||||
}
|
||||
shutdown();
|
||||
}
|
||||
next_batt_chck = millis() + 8000;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool CardputerUITask::turnDisplayOn() {
|
||||
if (_display != NULL) {
|
||||
extendAutoOff();
|
||||
scheduleRefresh();
|
||||
|
||||
if (!_display->isOn()) {
|
||||
_display->turnOn();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool CardputerUITask::getGPSState() {
|
||||
if (_sensors != NULL) {
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
return !strcmp(_sensors->getSettingValue(i), "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void CardputerUITask::toggleGPS() {
|
||||
if (_sensors != NULL) {
|
||||
int num = _sensors->getNumSettings();
|
||||
for (int i = 0; i < num; i++) {
|
||||
if (strcmp(_sensors->getSettingName(i), "gps") == 0) {
|
||||
if (strcmp(_sensors->getSettingValue(i), "1") == 0) {
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
} else {
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
}
|
||||
the_mesh_cp.savePrefs();
|
||||
scheduleRefresh();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::toggleBuzzer() {
|
||||
_node_prefs->buzzer_quiet = !_node_prefs->buzzer_quiet;
|
||||
the_mesh_cp.savePrefs();
|
||||
}
|
||||
|
||||
void CardputerUITask::renderAlert() {
|
||||
CardputerDisplay &lcd = *_display;
|
||||
lcd.setTextSize(1);
|
||||
const int margin = 4, pad = 5;
|
||||
const int box_w = lcd.width() - margin * 2;
|
||||
const int text_w = box_w - pad * 2;
|
||||
const int row = lcd.getRowHeight();
|
||||
|
||||
// Split at the last space that fits when the text needs two lines
|
||||
char first[sizeof(alert_text)];
|
||||
const char *second = nullptr;
|
||||
snprintf(first, sizeof(first), "%s", alert_text);
|
||||
if (lcd.getTextWidth(first) > text_w) {
|
||||
for (int i = strlen(first) - 1; i > 0; i--) {
|
||||
if (first[i] != ' ') continue;
|
||||
first[i] = 0;
|
||||
if (lcd.getTextWidth(first) <= text_w) {
|
||||
second = alert_text + i + 1;
|
||||
break;
|
||||
}
|
||||
first[i] = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
const int lines = second ? 2 : 1;
|
||||
const int box_h = row * lines + pad * 2;
|
||||
const int y = (lcd.height() - box_h) / 2;
|
||||
lcd.setColor(CardputerDisplay::P_BLACK);
|
||||
lcd.fillRect(margin, y, box_w, box_h);
|
||||
lcd.setColor(CardputerDisplay::P_WHITE);
|
||||
lcd.drawRect(margin, y, box_w, box_h);
|
||||
if (second) {
|
||||
lcd.drawTextEllipsized(margin + pad, y + pad, text_w, first);
|
||||
lcd.drawTextEllipsized(margin + pad, y + pad + row, text_w, second);
|
||||
} else {
|
||||
lcd.drawTextCentered(lcd.width() / 2, y + pad, first);
|
||||
}
|
||||
}
|
||||
|
||||
int CardputerUITask::drawModifierBadges(CardputerDisplay &lcd, int x, int y) {
|
||||
lcd.setSmallText();
|
||||
const Keyboard::Modifiers m = _board->getKeyboard()->getModifiers();
|
||||
const char *labels[2] = { m.fn ? "FN" : nullptr, m.shift ? "CAPS" : nullptr };
|
||||
const uint16_t colors[2] = { CardputerDisplay::P_ORANGE, CardputerDisplay::P_YELLOW };
|
||||
for (int i = 0; i < 2; i++) {
|
||||
if (!labels[i]) continue;
|
||||
int w = lcd.getTextWidth(labels[i]) + 4;
|
||||
lcd.setColor(colors[i]);
|
||||
lcd.fillRect(x, y, w, lcd.getRowHeight());
|
||||
lcd.setColor(CardputerDisplay::P_BLACK);
|
||||
lcd.drawTextLeftAlign(x + 2, y, labels[i]);
|
||||
x += w + 3;
|
||||
}
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
lcd.setTextSize(1);
|
||||
return x;
|
||||
}
|
||||
|
||||
void CardputerUITask::toggleBluetooth() {
|
||||
// Same checks and saved preference as the CLI's "set bluetooth on|off"
|
||||
char reply[96];
|
||||
const char *cmd = isBluetoothEnabled() ? "set bluetooth off" : "set bluetooth on";
|
||||
handleCompanionBluetoothCommand(cmd, reply, sizeof(reply), CompanionBluetoothCommandSource::Terminal);
|
||||
showAlert(strncmp(reply, "OK - ", 5) == 0 ? reply + 5 : reply, 2500);
|
||||
}
|
||||
|
||||
void CardputerUITask::cycleTextSize() {
|
||||
uint8_t next = (_display->getTextLevel() + 1) % CardputerDisplay::TEXT_LEVEL_COUNT;
|
||||
_display->setTextLevel(next);
|
||||
_custom_prefs->text_level = next;
|
||||
the_mesh_cp.savePrefs();
|
||||
}
|
||||
|
||||
void CardputerUITask::sendFloodAdvert() {
|
||||
showAlert(the_mesh_cp.sendAdvert(true) ? "Advert sent" : "Advert failed", 2000);
|
||||
}
|
||||
|
||||
void CardputerUITask::cycleBrightness() {
|
||||
static const uint8_t levels[] = { 32, 96, 160, 255 };
|
||||
uint8_t next = levels[0];
|
||||
for (uint8_t level : levels) {
|
||||
if (level > _custom_prefs->brightness) {
|
||||
next = level;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_custom_prefs->brightness = next;
|
||||
_display->setBrightness(next);
|
||||
the_mesh_cp.savePrefs();
|
||||
char buf[24];
|
||||
snprintf(buf, sizeof(buf), "Brightness %d%%", next * 100 / 255);
|
||||
showAlert(buf, 1500);
|
||||
}
|
||||
|
||||
bool CardputerUITask::handleShortcut(const Keyboard::Event &e) {
|
||||
if (!e.modifiers.fn || e.modifiers.ctrl || e.modifiers.alt || e.modifiers.opt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
MainScreen *main = static_cast<MainScreen *>(main_screen);
|
||||
char buf[48];
|
||||
|
||||
switch (e.key) {
|
||||
case Keyboard::KEY_A: // Advert
|
||||
sendFloodAdvert();
|
||||
return true;
|
||||
case Keyboard::KEY_G: // GPS
|
||||
toggleGPS();
|
||||
showAlert(getGPSState() ? "GPS ON" : "GPS OFF", 1500);
|
||||
return true;
|
||||
case Keyboard::KEY_B: // Bluetooth
|
||||
toggleBluetooth();
|
||||
return true;
|
||||
case Keyboard::KEY_M: // Mute
|
||||
toggleBuzzer();
|
||||
showAlert(isBuzzerQuiet() ? "Sound OFF" : "Sound ON", 1500);
|
||||
return true;
|
||||
case Keyboard::KEY_P: // Power save
|
||||
togglePowerSave();
|
||||
showAlert(powerSaveEnabled() ? "Power save ON" : "Power save OFF", 1500);
|
||||
return true;
|
||||
case Keyboard::KEY_L: // Light
|
||||
cycleBrightness();
|
||||
return true;
|
||||
case Keyboard::KEY_D: // Discover repeaters
|
||||
gotoToolsScreen();
|
||||
static_cast<ToolsScreen *>(tools_screen)->startDiscover();
|
||||
return true;
|
||||
case Keyboard::KEY_H: // Home
|
||||
gotoMainScreen();
|
||||
main->setCurrentPage(MainScreen::FIRST);
|
||||
return true;
|
||||
case Keyboard::KEY_C: // Contacts
|
||||
gotoMainScreen();
|
||||
main->setCurrentPage(MainScreen::CONTACTS);
|
||||
return true;
|
||||
case Keyboard::KEY_N: // chaNnels
|
||||
gotoMainScreen();
|
||||
main->setCurrentPage(MainScreen::CHANNELS);
|
||||
return true;
|
||||
case Keyboard::KEY_I: // Info
|
||||
gotoMainScreen();
|
||||
main->setCurrentPage(MainScreen::STATS);
|
||||
return true;
|
||||
case Keyboard::KEY_S: // Settings
|
||||
gotoSettingsScreen();
|
||||
return true;
|
||||
case Keyboard::KEY_T: // Tools
|
||||
gotoToolsScreen();
|
||||
return true;
|
||||
case Keyboard::KEY_K: { // Keyboard layout
|
||||
CardputerLayout *lay = _board->getLayout();
|
||||
if (lay->hasAlternateLayout()) {
|
||||
lay->switchLayout();
|
||||
snprintf(buf, sizeof(buf), "Layout: %s", lay->getCurrentCode());
|
||||
showAlert(buf, 1500);
|
||||
} else {
|
||||
showAlert("No other layout on SD", 2000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerUITask::togglePowerSave() {
|
||||
the_mesh_cp.setPowerSaving(!_node_prefs->powersaving_enabled);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
#pragma once
|
||||
|
||||
#include "CardputerAdvBoard.h"
|
||||
#include "CardputerDataStore.h"
|
||||
#include "screen/CardputerScreen.h"
|
||||
#include "types.h"
|
||||
|
||||
#include <AbstractUITask.h>
|
||||
#include <Arduino.h>
|
||||
#include <MeshCore.h>
|
||||
#include <helpers/MultiSerialInterface.h>
|
||||
#include <helpers/ContactInfo.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/sensors/LPPDataHelpers.h>
|
||||
#include <helpers/ui/MomentaryButton.h>
|
||||
|
||||
#define LONG_PRESS_MILLIS 1200
|
||||
|
||||
class CardputerUITask : public AbstractUITask {
|
||||
CardputerDisplay *_display;
|
||||
SensorManager *_sensors;
|
||||
CardputerAdvBoard *_board;
|
||||
CompanionNodePrefs *_node_prefs;
|
||||
CustomNodePrefs *_custom_prefs;
|
||||
unsigned long next_refresh = 0;
|
||||
unsigned long auto_off_time;
|
||||
bool sleep_enabled = false;
|
||||
char alert_text[80];
|
||||
unsigned long alert_expiry = 0;
|
||||
int unsynced_msg_count = 0;
|
||||
unsigned long ui_started_at = 0;
|
||||
unsigned long next_batt_chck = 0;
|
||||
|
||||
CardputerScreen *splash_screen;
|
||||
CardputerScreen *main_screen;
|
||||
CardputerScreen *new_message_screen;
|
||||
CardputerScreen *settings_screen;
|
||||
CardputerScreen *tools_screen;
|
||||
CardputerScreen *current_screen = nullptr;
|
||||
|
||||
// Top button (G0): click sends a flood advert, like the stock Cardputer build.
|
||||
MomentaryButton _top_btn;
|
||||
|
||||
// Fn+letter shortcuts; returns true when the key was consumed.
|
||||
bool handleShortcut(const Keyboard::Event &e);
|
||||
void renderAlert();
|
||||
void sendFloodAdvert();
|
||||
void cycleBrightness();
|
||||
|
||||
// Returns false if display is already on
|
||||
bool turnDisplayOn();
|
||||
void setCurrScreen(CardputerScreen *c);
|
||||
|
||||
public:
|
||||
CardputerUITask(CardputerAdvBoard *board, MultiSerialInterface *serial)
|
||||
: AbstractUITask(board, serial), _display(NULL), _sensors(NULL), _board(board),
|
||||
_top_btn(PIN_TOP_BUTTON, 1000, true, true) {}
|
||||
|
||||
void begin(DisplayDriver *display, SensorManager *sensors, CompanionNodePrefs *node_prefs,
|
||||
CustomNodePrefs *custom_prefs);
|
||||
|
||||
void gotoMainScreen() { setCurrScreen(main_screen); }
|
||||
void gotoSettingsScreen() { setCurrScreen(settings_screen); }
|
||||
void gotoToolsScreen() { setCurrScreen(tools_screen); }
|
||||
// Draws FN / CAPS badges for latched modifiers; returns the next free x.
|
||||
int drawModifierBadges(CardputerDisplay &lcd, int x, int y);
|
||||
void showAlert(const char *text, int duration_millis);
|
||||
void dismissAlert();
|
||||
bool isAlertActive();
|
||||
inline int getMsgCount() const { return unsynced_msg_count; }
|
||||
inline bool hasDisplay() const { return _display != NULL; }
|
||||
inline bool isBuzzerQuiet() { return _node_prefs->buzzer_quiet; }
|
||||
inline const char* getNodeName() { return _node_prefs->node_name; }
|
||||
inline uint16_t getBattMilliVoltsCorrected() { return (float)getBattMilliVolts() * _custom_prefs->battery_correction; }
|
||||
void toggleBuzzer();
|
||||
void toggleBluetooth();
|
||||
void cycleTextSize();
|
||||
void togglePowerSave();
|
||||
bool getGPSState();
|
||||
void toggleGPS();
|
||||
inline unsigned long getAutoOffTime() const { return auto_off_time; }
|
||||
inline bool powerSaveEnabled() const { return _node_prefs->powersaving_enabled; }
|
||||
// Light sleep stops the backlight PWM and I2S audio; keep the CPU awake
|
||||
// while the screen is on or a tone is queued.
|
||||
bool isAwake() { return (_display && _display->isOn()) || _board->getSpeaker()->isBusy(); }
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount, int channel_idx = -1,
|
||||
const char *channel_name = nullptr, int queue_index = -1) override;
|
||||
|
||||
void onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text);
|
||||
void onContactMessageRecv(const ContactInfo &contact, const char *text);
|
||||
void onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state);
|
||||
|
||||
void pingRecv(float snr_tx, float snr_rx);
|
||||
void discoverRecv(const mesh::Identity &id, float snr);
|
||||
void messageRepeatsRecv(uint16_t count);
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void playSound(SoundType t);
|
||||
void loop() override;
|
||||
void shutdown(bool restart = false);
|
||||
CardputerAdvBoard *getBoard() { return _board; }
|
||||
inline void extendAutoOff() { auto_off_time = millis() + AUTO_OFF_MILLIS; }
|
||||
inline void scheduleRefresh(unsigned long ms = 0) { next_refresh = millis() + ms; }
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// 'meshcore', 128x13px
|
||||
static const uint8_t meshcore_logo [] = {
|
||||
0x3c, 0x01, 0xe3, 0xff, 0xc7, 0xff, 0x8f, 0x03, 0x87, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe, 0x1f, 0xfe,
|
||||
0x3c, 0x03, 0xe3, 0xff, 0xc7, 0xff, 0x8e, 0x03, 0x8f, 0xfe, 0x3f, 0xfe, 0x1f, 0xff, 0x1f, 0xfe,
|
||||
0x3e, 0x03, 0xc3, 0xff, 0x8f, 0xff, 0x0e, 0x07, 0x8f, 0xfe, 0x7f, 0xfe, 0x1f, 0xff, 0x1f, 0xfc,
|
||||
0x3e, 0x07, 0xc7, 0x80, 0x0e, 0x00, 0x0e, 0x07, 0x9e, 0x00, 0x78, 0x0e, 0x3c, 0x0f, 0x1c, 0x00,
|
||||
0x3e, 0x0f, 0xc7, 0x80, 0x1e, 0x00, 0x0e, 0x07, 0x1e, 0x00, 0x70, 0x0e, 0x38, 0x0f, 0x3c, 0x00,
|
||||
0x7f, 0x0f, 0xc7, 0xfe, 0x1f, 0xfc, 0x1f, 0xff, 0x1c, 0x00, 0x70, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
|
||||
0x7f, 0x1f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x0e, 0x38, 0x0e, 0x3f, 0xf8,
|
||||
0x7f, 0x3f, 0xc7, 0xfe, 0x0f, 0xff, 0x1f, 0xff, 0x1c, 0x00, 0xf0, 0x1e, 0x3f, 0xfe, 0x3f, 0xf0,
|
||||
0x77, 0x3b, 0x87, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xfc, 0x38, 0x00,
|
||||
0x77, 0xfb, 0x8f, 0x00, 0x00, 0x07, 0x1c, 0x0f, 0x3c, 0x00, 0xe0, 0x1c, 0x7f, 0xf8, 0x38, 0x00,
|
||||
0x73, 0xf3, 0x8f, 0xff, 0x0f, 0xff, 0x1c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x78, 0x7f, 0xf8,
|
||||
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfe, 0x3c, 0x0e, 0x3f, 0xf8, 0xff, 0xfc, 0x70, 0x3c, 0x7f, 0xf8,
|
||||
0xe3, 0xe3, 0x8f, 0xff, 0x1f, 0xfc, 0x3c, 0x0e, 0x1f, 0xf8, 0xff, 0xf8, 0x70, 0x3c, 0x7f, 0xf8,
|
||||
};
|
||||
|
||||
static const uint8_t muted_icon[] = {
|
||||
0x20, 0x6a, 0xea, 0xe4, 0xe4, 0xea, 0x6a, 0x20
|
||||
};
|
||||
|
||||
static const uint8_t sleep_icon[] = {
|
||||
0x38, 0x1c, 0xe, 0xe, 0xe, 0xe, 0x1c, 0x38
|
||||
};
|
||||
static const uint8_t unread_icon[] = {
|
||||
0x0, 0xfe, 0xc6, 0xaa, 0x92, 0x82, 0xfe, 0x0
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include "hw/CardputerKeyboard.h"
|
||||
#include "hw/CardputerDisplay.h"
|
||||
|
||||
class CardputerScreen {
|
||||
protected:
|
||||
CardputerScreen() {}
|
||||
|
||||
public:
|
||||
virtual int render(CardputerDisplay &lcd) = 0; // return value is number of millis until next render
|
||||
virtual bool handleInput(Keyboard::Event &e) { return false; }
|
||||
virtual void poll() {}
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
#include "MainScreen.h"
|
||||
|
||||
#include "helpers.h"
|
||||
#include "main/ChannelsPage.h"
|
||||
#include "main/ChatPage.h"
|
||||
#include "main/ContactsPage.h"
|
||||
#include "main/FirstPage.h"
|
||||
#include "main/GpsPage.h"
|
||||
#include "main/RecentAdvertsPage.h"
|
||||
#include "main/StatsPage.h"
|
||||
|
||||
MainScreen::MainScreen(CardputerUITask *task, mesh::RTCClock *rtc, SensorManager *sensors,
|
||||
CompanionNodePrefs *node_prefs, CustomNodePrefs *custom_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _custom_prefs(custom_prefs) {
|
||||
contacts_page = new ContactsPage(this);
|
||||
channels_page = new ChannelsPage(this);
|
||||
chat_page = new ChatPage(this);
|
||||
stats_page = new StatsPage(this);
|
||||
gps_page = new GpsPage(this);
|
||||
first_page = new FirstPage(this);
|
||||
recent_adverts_page = new RecentAdvertsPage(this, rtc);
|
||||
}
|
||||
|
||||
void MainScreen::renderStatusIcons(CardputerDisplay &lcd) {
|
||||
char tmp[8];
|
||||
lcd.setSmallText();
|
||||
uint16_t batteryMilliVolts = _task->getBattMilliVolts();
|
||||
// Convert millivolts to percentage
|
||||
const int minMilliVolts = BATT_MIN_MILLIVOLTS;
|
||||
const int maxMilliVolts = BATT_MAX_MILLIVOLTS;
|
||||
int batteryMilliVoltsCorrected = _custom_prefs->battery_correction * batteryMilliVolts;
|
||||
int batteryPercentage =
|
||||
((batteryMilliVoltsCorrected - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
||||
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
|
||||
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
|
||||
|
||||
// battery icon
|
||||
int iconWidth = 26;
|
||||
int iconHeight = 14;
|
||||
int iconX = lcd.width() - iconWidth - 5; // Position the icon near the top-right corner
|
||||
int iconY = 2;
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
|
||||
// battery outline
|
||||
lcd.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
// battery "cap"
|
||||
lcd.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
|
||||
|
||||
// fill the battery based on the percentage
|
||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||
lcd.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||
|
||||
sprintf(tmp, "%d", batteryPercentage);
|
||||
lcd.drawTextCentered(iconX + 2 + iconWidth / 2, 0, tmp);
|
||||
lcd.setColor(CardputerDisplay::P_BLACK);
|
||||
lcd.drawTextCentered(iconX + 2 + iconWidth / 2 - 1, 0, tmp);
|
||||
|
||||
iconX = 3;
|
||||
iconY = 5;
|
||||
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
|
||||
CardputerLayout *lay = _task->getBoard()->getLayout();
|
||||
|
||||
if (lay->hasAlternateLayout()) {
|
||||
const char *layout_label = _task->getBoard()->getLayout()->getCurrentCode();
|
||||
int label_width =
|
||||
std::max(lcd.getTextWidth(lay->getMainLayoutCode()), lcd.getTextWidth(lay->getAlternateLayoutCode()));
|
||||
lcd.drawTextLeftAlign(iconX, 0, layout_label);
|
||||
iconX += label_width + 5;
|
||||
}
|
||||
|
||||
iconX = _task->drawModifierBadges(lcd, iconX, 0);
|
||||
lcd.setSmallText();
|
||||
|
||||
if (_task->isBuzzerQuiet()) {
|
||||
lcd.drawXbm(iconX, iconY, muted_icon, 8, 8);
|
||||
iconX += 11;
|
||||
}
|
||||
|
||||
if (_task->powerSaveEnabled()) {
|
||||
lcd.drawXbm(iconX, iconY, sleep_icon, 8, 8);
|
||||
iconX += 11;
|
||||
}
|
||||
|
||||
if (unread.countChats() > 0) {
|
||||
lcd.drawXbm(iconX, iconY, unread_icon, 8, 8);
|
||||
iconX += 11;
|
||||
}
|
||||
lcd.setTextSize(1);
|
||||
}
|
||||
|
||||
int MainScreen::render(CardputerDisplay &lcd) {
|
||||
lcd.setTextSize(1);
|
||||
|
||||
renderStatusIcons(lcd);
|
||||
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
|
||||
// curr page indicator
|
||||
int y = 16;
|
||||
int x = lcd.width() / 2 - 5 * (MainScreenPage::Count - 1);
|
||||
for (uint8_t i = 0; i < MainScreenPage::Count; i++, x += 10) {
|
||||
if (i == current_page) {
|
||||
lcd.fillRect(x - 1, y - 1, 3, 3);
|
||||
} else {
|
||||
lcd.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
switch (current_page) {
|
||||
case MainScreenPage::FIRST:
|
||||
return first_page->render(lcd);
|
||||
case MainScreenPage::CHANNELS:
|
||||
return channels_page->render(lcd);
|
||||
case MainScreenPage::CONTACTS:
|
||||
return contacts_page->render(lcd);
|
||||
case MainScreenPage::CHAT:
|
||||
return chat_page->render(lcd);
|
||||
case MainScreenPage::RECENT:
|
||||
return recent_adverts_page->render(lcd);
|
||||
case MainScreenPage::STATS:
|
||||
return stats_page->render(lcd);
|
||||
case MainScreenPage::GPS:
|
||||
return gps_page->render(lcd);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return 5000;
|
||||
}
|
||||
|
||||
void MainScreen::messageRepeatsRecv(uint16_t count) {
|
||||
if (current_page == MainScreenPage::CHAT) {
|
||||
char buf[32];
|
||||
sprintf(buf, "Heard repeats: %d", count);
|
||||
_task->showAlert(buf, 2000);
|
||||
_task->playSound(SoundType::MessageAck);
|
||||
}
|
||||
}
|
||||
|
||||
void MainScreen::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) {
|
||||
bool to_selected = static_cast<ChatPage *>(chat_page)->onChannelMessageRecv(channel, text);
|
||||
|
||||
if (!to_selected) {
|
||||
unread.addChannel(channel.secret, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void MainScreen::onContactMessageRecv(const ContactInfo &contact, const char *text) {
|
||||
bool to_selected = static_cast<ChatPage *>(chat_page)->onContactMessageRecv(contact, text);
|
||||
|
||||
if (!to_selected) {
|
||||
unread.addContact(contact.id.pub_key, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void MainScreen::onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state) {
|
||||
char buf[32];
|
||||
|
||||
if (state == MessageSendState::MESSAGE_FAILED) {
|
||||
_task->showAlert("Message ACK not received", 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == MessageSendState::MESSAGE_DELIVERED) {
|
||||
refreshSelectedContact();
|
||||
_task->showAlert("Message delivered", 2000);
|
||||
_task->playSound(SoundType::MessageAck);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt == 4 && total == 6) { // Path reset
|
||||
refreshSelectedContact();
|
||||
}
|
||||
|
||||
int ms = std::max(DIRECT_SEND_ROUTE_RESEND_RELAY, DIRECT_SEND_FLOOD_RESEND_RELAY);
|
||||
|
||||
if (attempt == 1) {
|
||||
_task->showAlert("Waiting for delivery...", ms);
|
||||
} else {
|
||||
sprintf(buf, "Resending... (%u/%u)", attempt, total);
|
||||
_task->showAlert(buf, ms);
|
||||
}
|
||||
}
|
||||
|
||||
void MainScreen::poll() {}
|
||||
|
||||
bool MainScreen::handleInput(Keyboard::Event &e) {
|
||||
if (e.modifiers.ctrl && e.key == Keyboard::KEY_SPACE) {
|
||||
_task->getBoard()->getLayout()->switchLayout();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current_page == MainScreenPage::CONTACTS && contacts_page->handleInput(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current_page == MainScreenPage::CHANNELS && channels_page->handleInput(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current_page == MainScreenPage::CHAT && chat_page->handleInput(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current_page == MainScreenPage::GPS && gps_page->handleInput(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current_page == MainScreenPage::FIRST && first_page->handleInput(e)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::ARROW_LEFT) {
|
||||
auto p = static_cast<MainScreenPage>((current_page + MainScreenPage::Count - 1) % MainScreenPage::Count);
|
||||
setCurrentPage(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::ARROW_RIGHT || e.key == Keyboard::KEY_TAB) {
|
||||
auto p = static_cast<MainScreenPage>((current_page + 1) % MainScreenPage::Count);
|
||||
setCurrentPage(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::KEY_ESC) {
|
||||
current_page = MainScreenPage::FIRST;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void MainScreen::refreshSelectedContact() {
|
||||
static_cast<ChatPage *>(chat_page)->refreshSelectedContact();
|
||||
}
|
||||
|
||||
void MainScreen::selectContact(ContactInfo &contact, int idx) {
|
||||
setCurrentPage(MainScreenPage::CHAT);
|
||||
page_to_return = MainScreenPage::CONTACTS;
|
||||
unread.resetContact(contact.id.pub_key);
|
||||
|
||||
static_cast<ChatPage *>(chat_page)->selectContact(contact, idx);
|
||||
}
|
||||
|
||||
void MainScreen::selectChannel(const ChannelDetails &channel, int idx) {
|
||||
setCurrentPage(MainScreenPage::CHAT);
|
||||
page_to_return = MainScreenPage::CHANNELS;
|
||||
unread.resetChannel(channel.channel.secret);
|
||||
|
||||
static_cast<ChatPage *>(chat_page)->selectChannel(channel, idx);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../CardputerMesh.h"
|
||||
#include "../CardputerUITask.h"
|
||||
#include "../icons.h"
|
||||
#include "RingBuffer.h"
|
||||
#include "UnreadCounter.h"
|
||||
#include "globals.h"
|
||||
|
||||
class MainScreen : public CardputerScreen {
|
||||
public:
|
||||
enum MainScreenPage {
|
||||
FIRST,
|
||||
CHANNELS,
|
||||
CONTACTS,
|
||||
CHAT,
|
||||
RECENT,
|
||||
STATS,
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
GPS,
|
||||
#endif
|
||||
Count
|
||||
};
|
||||
|
||||
private:
|
||||
CardputerUITask *_task;
|
||||
mesh::RTCClock *_rtc;
|
||||
SensorManager *_sensors;
|
||||
CompanionNodePrefs *_node_prefs;
|
||||
CustomNodePrefs *_custom_prefs;
|
||||
|
||||
MainScreenPage current_page = MainScreen::FIRST;
|
||||
MainScreenPage page_to_return = MainScreen::FIRST;
|
||||
|
||||
UnreadCounter unread;
|
||||
|
||||
CardputerScreen *first_page;
|
||||
CardputerScreen *contacts_page;
|
||||
CardputerScreen *channels_page;
|
||||
CardputerScreen *chat_page;
|
||||
CardputerScreen *stats_page;
|
||||
CardputerScreen *gps_page;
|
||||
CardputerScreen *recent_adverts_page;
|
||||
|
||||
void renderStatusIcons(CardputerDisplay &lcd);
|
||||
|
||||
public:
|
||||
MainScreen(CardputerUITask *task, mesh::RTCClock *rtc, SensorManager *sensors, CompanionNodePrefs *node_prefs,
|
||||
CustomNodePrefs *custom_prefs);
|
||||
void messageRepeatsRecv(uint16_t count);
|
||||
void onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text);
|
||||
void onContactMessageRecv(const ContactInfo &contact, const char *text);
|
||||
void onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state);
|
||||
|
||||
void poll() override;
|
||||
int render(CardputerDisplay &display) override;
|
||||
bool handleInput(Keyboard::Event &e) override;
|
||||
void refreshSelectedContact();
|
||||
void selectContact(ContactInfo &contact, int idx);
|
||||
void selectChannel(const ChannelDetails &channel, int idx);
|
||||
|
||||
inline MainScreenPage getCurrentPage() const { return (MainScreenPage)current_page; }
|
||||
inline void setCurrentPage(MainScreenPage page) {
|
||||
page_to_return = MainScreenPage::FIRST;
|
||||
current_page = page;
|
||||
}
|
||||
inline void returnToLastPage() { setCurrentPage(page_to_return); }
|
||||
inline const UnreadCounter *getUnread() { return &unread; }
|
||||
inline CardputerUITask *getUiTask() { return _task; }
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#include "NewMessageScreen.h"
|
||||
|
||||
void NewMessageScreen::newMessage(const char *from, const char *msg) {
|
||||
StrHelper::strncpy(origin, from, sizeof(origin));
|
||||
StrHelper::strncpy(message, msg, sizeof(message));
|
||||
dismiss_after = millis() + NEW_MESSAGE_DISPLAY_MILLIS;
|
||||
}
|
||||
|
||||
int NewMessageScreen::render(CardputerDisplay &lcd) {
|
||||
lcd.setTextSize(1);
|
||||
|
||||
// origin
|
||||
lcd.setColor(CardputerDisplay::P_YELLOW);
|
||||
lcd.drawTextLeftAlign(0, 0, origin);
|
||||
|
||||
// message
|
||||
lcd.setCursor(0, lcd.getRowHeight() + 2);
|
||||
lcd.setColor(CardputerDisplay::P_WHITE);
|
||||
lcd.printWordWrap(message, lcd.width());
|
||||
|
||||
return NEW_MESSAGE_DISPLAY_MILLIS;
|
||||
}
|
||||
|
||||
bool NewMessageScreen::handleInput(Keyboard::Event &e) {
|
||||
// any key
|
||||
dismiss_after = 0;
|
||||
_task->gotoMainScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
void NewMessageScreen::poll() {
|
||||
if (millis() >= dismiss_after) {
|
||||
_task->gotoMainScreen();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#include "../../CardputerMesh.h"
|
||||
#include "../CardputerUITask.h"
|
||||
#include "globals.h"
|
||||
|
||||
class NewMessageScreen : public CardputerScreen {
|
||||
CardputerUITask *_task;
|
||||
char origin[62];
|
||||
char message[MAX_MESSAGE_LENGTH];
|
||||
unsigned long dismiss_after = 0;
|
||||
|
||||
public:
|
||||
NewMessageScreen(CardputerUITask *task) : _task(task) {}
|
||||
void newMessage(const char *from, const char *msg);
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
bool handleInput(Keyboard::Event &e) override;
|
||||
void poll() override;
|
||||
};
|
||||
+515
@@ -0,0 +1,515 @@
|
||||
#include "SettingsScreen.h"
|
||||
|
||||
#include "helpers.h"
|
||||
|
||||
|
||||
void SettingsScreen::renderItem(CardputerDisplay &lcd, SettingsItem item, int x, int y) {
|
||||
char tmp[64] = { 0 };
|
||||
CardputerDisplay::PaletteColor text_color = CardputerDisplay::P_YELLOW;
|
||||
|
||||
switch (item) {
|
||||
case SettingsItem::HdrRadio:
|
||||
text_color = CardputerDisplay::P_GREEN;
|
||||
snprintf(tmp, sizeof(tmp), "---- RADIO ----");
|
||||
break;
|
||||
|
||||
case SettingsItem::HdrDevice:
|
||||
text_color = CardputerDisplay::P_GREEN;
|
||||
snprintf(tmp, sizeof(tmp), "---- DEVICE ----");
|
||||
break;
|
||||
|
||||
case SettingsItem::HdrMesh:
|
||||
text_color = CardputerDisplay::P_GREEN;
|
||||
snprintf(tmp, sizeof(tmp), "---- MESH ----");
|
||||
break;
|
||||
|
||||
case SettingsItem::HdrPublicInfo:
|
||||
text_color = CardputerDisplay::P_GREEN;
|
||||
snprintf(tmp, sizeof(tmp), "---- PUBLIC INFO ----");
|
||||
break;
|
||||
|
||||
case SettingsItem::RadioFreq:
|
||||
snprintf(tmp, sizeof(tmp), "FREQ: %06.3f", _node_prefs->freq);
|
||||
break;
|
||||
|
||||
case SettingsItem::PublicInfoName:
|
||||
snprintf(tmp, sizeof(tmp), "Name: %s", _node_prefs->node_name);
|
||||
break;
|
||||
|
||||
case SettingsItem::RadioBw:
|
||||
snprintf(tmp, sizeof(tmp), "BW: %03.2f", _node_prefs->bw);
|
||||
break;
|
||||
|
||||
case SettingsItem::RadioSf:
|
||||
snprintf(tmp, sizeof(tmp), "SF: %d", _node_prefs->sf);
|
||||
break;
|
||||
|
||||
case SettingsItem::RadioCr:
|
||||
snprintf(tmp, sizeof(tmp), "CR: %d", _node_prefs->cr);
|
||||
break;
|
||||
|
||||
case SettingsItem::RadioPwr:
|
||||
snprintf(tmp, sizeof(tmp), "PWR: %ddBm", _node_prefs->tx_power_dbm);
|
||||
break;
|
||||
|
||||
case SettingsItem::DeviceTextSize:
|
||||
snprintf(tmp, sizeof(tmp), "Text size: %s", CardputerDisplay::textLevelName(lcd.getTextLevel()));
|
||||
break;
|
||||
|
||||
case SettingsItem::DeviceBeep:
|
||||
snprintf(tmp, sizeof(tmp), "Sound: %s", _node_prefs->buzzer_quiet ? "OFF" : "ON");
|
||||
break;
|
||||
|
||||
case SettingsItem::DeviceGps:
|
||||
snprintf(tmp, sizeof(tmp), "GPS: %s", _task->getGPSState() ? "ON" : "OFF");
|
||||
break;
|
||||
|
||||
case SettingsItem::DeviceBluetooth:
|
||||
snprintf(tmp, sizeof(tmp), "Bluetooth: %s", _task->isBluetoothEnabled() ? "ON" : "OFF");
|
||||
break;
|
||||
|
||||
case SettingsItem::DevicePowersave:
|
||||
snprintf(tmp, sizeof(tmp), "Power save: %s", _task->powerSaveEnabled() ? "ON" : "OFF");
|
||||
break;
|
||||
|
||||
case SettingsItem::DeviceBatteryCorrection:
|
||||
snprintf(tmp, sizeof(tmp), "Batt. correction: %.3f", _custom_prefs->battery_correction);
|
||||
break;
|
||||
|
||||
case SettingsItem::MeshPathSize:
|
||||
snprintf(tmp, sizeof(tmp), "Path hash size: %d", _node_prefs->path_hash_mode + 1);
|
||||
break;
|
||||
|
||||
case SettingsItem::MeshDefaultScope:
|
||||
snprintf(tmp, sizeof(tmp), "Default scope: %s",
|
||||
_node_prefs->default_scope_name[0] ? _node_prefs->default_scope_name : "none");
|
||||
break;
|
||||
|
||||
default:
|
||||
text_color = CardputerDisplay::P_RED;
|
||||
snprintf(tmp, sizeof(tmp), "???");
|
||||
break;
|
||||
}
|
||||
|
||||
lcd.setColor(text_color);
|
||||
lcd.drawTextEllipsized(x, y, lcd.width() - x, tmp);
|
||||
}
|
||||
|
||||
bool SettingsScreen::enterItemEdit(SettingsItem item) {
|
||||
switch (item) {
|
||||
case SettingsItem::HdrRadio:
|
||||
case SettingsItem::HdrDevice:
|
||||
case SettingsItem::HdrMesh:
|
||||
return true;
|
||||
|
||||
case SettingsItem::DeviceTextSize:
|
||||
_task->cycleTextSize();
|
||||
return true;
|
||||
|
||||
case SettingsItem::DeviceBeep:
|
||||
_task->toggleBuzzer();
|
||||
return true;
|
||||
|
||||
case SettingsItem::DeviceGps:
|
||||
_task->toggleGPS();
|
||||
return true;
|
||||
|
||||
case SettingsItem::DeviceBluetooth:
|
||||
_task->toggleBluetooth();
|
||||
return true;
|
||||
|
||||
case SettingsItem::DevicePowersave:
|
||||
_task->togglePowerSave();
|
||||
return true;
|
||||
|
||||
case SettingsItem::MeshPathSize:
|
||||
edit_u8 = _node_prefs->path_hash_mode + 1;
|
||||
is_editing = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::RadioSf:
|
||||
edit_u8 = _node_prefs->sf;
|
||||
is_editing = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::RadioCr:
|
||||
edit_u8 = _node_prefs->cr;
|
||||
is_editing = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::RadioPwr:
|
||||
edit_u8 = _node_prefs->tx_power_dbm;
|
||||
is_editing = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::PublicInfoName:
|
||||
edit_buffer = String(_node_prefs->node_name);
|
||||
is_editing = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::MeshDefaultScope:
|
||||
edit_buffer = String(_node_prefs->default_scope_name);
|
||||
is_editing = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::RadioFreq:
|
||||
edit_buffer = String(_node_prefs->freq, 3);
|
||||
is_editing = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::RadioBw:
|
||||
edit_buffer = String(_node_prefs->bw, 2);
|
||||
is_editing = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::DeviceBatteryCorrection:
|
||||
edit_buffer = String(_task->getBoard()->getBattMilliVolts());
|
||||
is_editing = true;
|
||||
return true;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void SettingsScreen::cancelItemEdit(SettingsItem item) {
|
||||
is_editing = false;
|
||||
}
|
||||
|
||||
bool SettingsScreen::commitItemEdit(SettingsItem item) {
|
||||
|
||||
switch (item) {
|
||||
case SettingsItem::PublicInfoName:
|
||||
strncpy(_node_prefs->node_name, edit_buffer.c_str(), sizeof(_node_prefs->node_name) - 1);
|
||||
_node_prefs->node_name[sizeof(_node_prefs->node_name) - 1] = '\0';
|
||||
the_mesh_cp.savePrefs();
|
||||
return true;
|
||||
|
||||
case SettingsItem::MeshDefaultScope:
|
||||
memset(_node_prefs->default_scope_name, 0, sizeof(_node_prefs->default_scope_name));
|
||||
memset(_node_prefs->default_scope_key, 0, sizeof(_node_prefs->default_scope_key));
|
||||
|
||||
if (edit_buffer.length() > 0) {
|
||||
strncpy(_node_prefs->default_scope_name, edit_buffer.c_str(),
|
||||
sizeof(_node_prefs->default_scope_name) - 1);
|
||||
_node_prefs->default_scope_name[sizeof(_node_prefs->default_scope_name) - 1] = '\0';
|
||||
|
||||
const uint8_t prefix[] = "#";
|
||||
|
||||
mesh::Utils::sha256(_node_prefs->default_scope_key, sizeof(_node_prefs->default_scope_key), prefix, 1,
|
||||
reinterpret_cast<const uint8_t *>(_node_prefs->default_scope_name),
|
||||
edit_buffer.length());
|
||||
}
|
||||
|
||||
the_mesh_cp.savePrefs();
|
||||
return true;
|
||||
|
||||
case SettingsItem::RadioSf:
|
||||
_node_prefs->sf = edit_u8;
|
||||
the_mesh_cp.savePrefs();
|
||||
restart_required = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::RadioCr:
|
||||
_node_prefs->cr = edit_u8;
|
||||
the_mesh_cp.savePrefs();
|
||||
restart_required = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::RadioPwr:
|
||||
_node_prefs->tx_power_dbm = edit_u8;
|
||||
the_mesh_cp.savePrefs();
|
||||
restart_required = true;
|
||||
return true;
|
||||
|
||||
case SettingsItem::MeshPathSize:
|
||||
_node_prefs->path_hash_mode = edit_u8 - 1;
|
||||
the_mesh_cp.savePrefs();
|
||||
return true;
|
||||
|
||||
case SettingsItem::RadioFreq:
|
||||
if (inputParsePositiveFloat(_node_prefs->freq)) {
|
||||
the_mesh_cp.savePrefs();
|
||||
restart_required = true;
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
case SettingsItem::RadioBw:
|
||||
if (inputParsePositiveFloat(_node_prefs->bw)) {
|
||||
the_mesh_cp.savePrefs();
|
||||
restart_required = true;
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
case SettingsItem::DeviceBatteryCorrection:
|
||||
|
||||
if (edit_buffer.length() > 0) {
|
||||
uint16_t real = edit_buffer.toInt();
|
||||
uint16_t adc = _task->getBoard()->getBattMilliVolts();
|
||||
if (adc != 0) {
|
||||
_custom_prefs->battery_correction = (float)real / adc;
|
||||
the_mesh_cp.savePrefs();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void SettingsScreen::handleEditInput(SettingsItem item, Keyboard::Event &e) {
|
||||
switch (item) {
|
||||
case SettingsItem::RadioSf:
|
||||
if (e.key == Keyboard::ARROW_LEFT && edit_u8 > 5) {
|
||||
edit_u8--;
|
||||
} else if (e.key == Keyboard::ARROW_RIGHT && edit_u8 < 12) {
|
||||
edit_u8++;
|
||||
}
|
||||
break;
|
||||
case SettingsItem::RadioCr:
|
||||
if (e.key == Keyboard::ARROW_LEFT && edit_u8 > 5) {
|
||||
edit_u8--;
|
||||
} else if (e.key == Keyboard::ARROW_RIGHT && edit_u8 < 8) {
|
||||
edit_u8++;
|
||||
}
|
||||
break;
|
||||
|
||||
case SettingsItem::RadioPwr:
|
||||
if (e.key == Keyboard::ARROW_LEFT && edit_u8 > 1) {
|
||||
edit_u8--;
|
||||
} else if (e.key == Keyboard::ARROW_RIGHT && edit_u8 < LORA_TX_POWER) {
|
||||
edit_u8++;
|
||||
}
|
||||
break;
|
||||
case SettingsItem::MeshPathSize:
|
||||
if (e.key == Keyboard::ARROW_LEFT && edit_u8 > 1) {
|
||||
edit_u8--;
|
||||
} else if (e.key == Keyboard::ARROW_RIGHT && edit_u8 < 3) {
|
||||
edit_u8++;
|
||||
}
|
||||
break;
|
||||
|
||||
case SettingsItem::RadioFreq:
|
||||
case SettingsItem::RadioBw:
|
||||
|
||||
if (e.key == Keyboard::KEY_BACKSPACE) {
|
||||
Helpers::removeLastStringChar(edit_buffer);
|
||||
} else if (e.key == Keyboard::KEY_PERIOD && edit_buffer.indexOf('.') == -1) {
|
||||
edit_buffer.concat('.');
|
||||
} else {
|
||||
const char ch = _task->getBoard()->getLayout()->lookupDefault(e)[0];
|
||||
if (ch && isdigit(ch)) {
|
||||
edit_buffer.concat(ch);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case SettingsItem::DeviceBatteryCorrection:
|
||||
if (e.key == Keyboard::KEY_BACKSPACE) {
|
||||
Helpers::removeLastStringChar(edit_buffer);
|
||||
} else {
|
||||
const char ch = _task->getBoard()->getLayout()->lookupDefault(e)[0];
|
||||
if (ch && isdigit(ch) && edit_buffer.length() < 4) {
|
||||
edit_buffer.concat(ch);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case SettingsItem::PublicInfoName:
|
||||
if (e.key == Keyboard::KEY_BACKSPACE) {
|
||||
Helpers::removeLastStringChar(edit_buffer);
|
||||
} else if (edit_buffer.length() < sizeof(_node_prefs->node_name) - 2) {
|
||||
const char *ch = _task->getBoard()->getLayout()->lookup(e);
|
||||
if (ch[0]) {
|
||||
edit_buffer.concat(ch);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case SettingsItem::MeshDefaultScope:
|
||||
if (e.key == Keyboard::KEY_BACKSPACE) {
|
||||
Helpers::removeLastStringChar(edit_buffer);
|
||||
} else {
|
||||
const char ch = _task->getBoard()->getLayout()->lookupDefault(e)[0];
|
||||
if (ch && (isalnum(ch) || ch == '_' || ch == '-') &&
|
||||
edit_buffer.length() < sizeof(_node_prefs->default_scope_name) - 2) {
|
||||
edit_buffer.concat(ch);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool SettingsScreen::inputParsePositiveFloat(float &val) {
|
||||
float tmp = edit_buffer.toFloat();
|
||||
|
||||
if (tmp == INFINITY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (errno == ERANGE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tmp < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
val = tmp;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int SettingsScreen::render(CardputerDisplay &display) {
|
||||
char buf[64];
|
||||
const int row = display.getRowHeight();
|
||||
const int title_y = 3;
|
||||
const int list_y = title_y + row + 2;
|
||||
const int rows = std::max(1, (display.height() - 1 - list_y) / row);
|
||||
const int item_x = 5 + display.getTextWidth(">") + 6;
|
||||
|
||||
display.setTextSize(1);
|
||||
if (restart_required) {
|
||||
display.setColor(CardputerDisplay::P_RED);
|
||||
display.drawTextCentered(display.width() / 2, title_y, "Settings | Restart");
|
||||
} else {
|
||||
display.setColor(CardputerDisplay::P_GREEN);
|
||||
display.drawTextCentered(display.width() / 2, title_y, "Settings");
|
||||
}
|
||||
|
||||
int real_idx = 0;
|
||||
int list_page = menu_index / rows;
|
||||
int list_idx = menu_index % rows;
|
||||
|
||||
for (int i = 0; i < rows; i++) {
|
||||
real_idx = list_page * rows + i;
|
||||
|
||||
if (real_idx >= SettingsItem::Count) {
|
||||
break;
|
||||
}
|
||||
|
||||
renderItem(display, static_cast<SettingsItem>(real_idx), item_x, list_y + i * row);
|
||||
|
||||
if (i == list_idx) {
|
||||
display.setColor(CardputerDisplay::P_GREEN);
|
||||
display.drawTextLeftAlign(5, list_y + i * row, ">");
|
||||
}
|
||||
}
|
||||
|
||||
if (is_editing) {
|
||||
// Edit popup: optional hint line, then the value in heading size
|
||||
const int x_margin = 5;
|
||||
const int box_y = 22;
|
||||
const int box_h = display.height() - box_y * 2;
|
||||
display.setColor(CardputerDisplay::P_BLACK);
|
||||
display.fillRect(x_margin, box_y, display.width() - x_margin * 2, box_h);
|
||||
display.setColor(CardputerDisplay::P_WHITE);
|
||||
display.drawRect(x_margin, box_y, display.width() - x_margin * 2, box_h);
|
||||
|
||||
display.setTextSize(1);
|
||||
switch (menu_index) {
|
||||
case SettingsItem::PublicInfoName:
|
||||
display.drawTextCentered(display.width() / 2, box_y + 5, _task->getBoard()->getLayout()->getCurrentCode());
|
||||
break;
|
||||
case SettingsItem::DeviceBatteryCorrection:
|
||||
sprintf(buf, "Read %umV, real:", _task->getBoard()->getBattMilliVolts());
|
||||
display.drawTextCentered(display.width() / 2, box_y + 5, buf);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const int value_y = box_y + 5 + row + 6;
|
||||
switch (menu_index) {
|
||||
case SettingsItem::RadioSf:
|
||||
case SettingsItem::RadioCr:
|
||||
case SettingsItem::RadioPwr:
|
||||
case SettingsItem::MeshPathSize:
|
||||
display.setTextSize(2);
|
||||
sprintf(buf, "- %d +", edit_u8);
|
||||
display.drawTextCentered(display.width() / 2, value_y, buf);
|
||||
break;
|
||||
case SettingsItem::PublicInfoName:
|
||||
case SettingsItem::RadioFreq:
|
||||
case SettingsItem::RadioBw:
|
||||
case SettingsItem::DeviceBatteryCorrection:
|
||||
case SettingsItem::MeshDefaultScope:
|
||||
display.setTextSize(menu_index == SettingsItem::PublicInfoName ? 1 : 2);
|
||||
sprintf(buf, "%s_", edit_buffer.c_str());
|
||||
display.drawTextLeftAlignWithScroll(x_margin + 5, value_y, display.width() - x_margin * 2 - 10, buf);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
}
|
||||
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool SettingsScreen::handleInput(Keyboard::Event &e) {
|
||||
if (is_editing) {
|
||||
if (e.key == Keyboard::KEY_RETURN) {
|
||||
if (!commitItemEdit(static_cast<SettingsItem>(menu_index))) {
|
||||
_task->showAlert("Invalid value", 1000);
|
||||
} else {
|
||||
is_editing = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::KEY_ESC) {
|
||||
cancelItemEdit(static_cast<SettingsItem>(menu_index));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.modifiers.ctrl && e.key == Keyboard::KEY_SPACE) {
|
||||
_task->getBoard()->getLayout()->switchLayout();
|
||||
return true;
|
||||
}
|
||||
|
||||
handleEditInput(static_cast<SettingsItem>(menu_index), e);
|
||||
|
||||
} else {
|
||||
if (e.key == Keyboard::KEY_ESC || e.modifiers.opt) {
|
||||
_task->gotoMainScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::ARROW_UP) {
|
||||
if (menu_index == 0) {
|
||||
menu_index = SettingsItem::Count - 1;
|
||||
} else {
|
||||
menu_index--;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
if (e.key == Keyboard::ARROW_DOWN) {
|
||||
if (menu_index < SettingsItem::Count - 1) {
|
||||
menu_index++;
|
||||
} else {
|
||||
menu_index = 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
if (e.key == Keyboard::KEY_RETURN) {
|
||||
if (!enterItemEdit(static_cast<SettingsItem>(menu_index))) {
|
||||
_task->showAlert("Not implemented yet", 2000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "../../CardputerMesh.h"
|
||||
#include "../CardputerUITask.h"
|
||||
#include "../icons.h"
|
||||
|
||||
class SettingsScreen : public CardputerScreen {
|
||||
enum SettingsItem {
|
||||
HdrPublicInfo,
|
||||
PublicInfoName,
|
||||
HdrRadio,
|
||||
RadioFreq,
|
||||
RadioBw,
|
||||
RadioSf,
|
||||
RadioCr,
|
||||
RadioPwr,
|
||||
HdrDevice,
|
||||
DeviceTextSize,
|
||||
DeviceBeep,
|
||||
DeviceBluetooth,
|
||||
DeviceGps,
|
||||
DevicePowersave,
|
||||
DeviceBatteryCorrection,
|
||||
HdrMesh,
|
||||
MeshPathSize,
|
||||
MeshDefaultScope,
|
||||
Count
|
||||
};
|
||||
|
||||
CardputerUITask *_task;
|
||||
mesh::RTCClock *_rtc;
|
||||
CompanionNodePrefs *_node_prefs;
|
||||
CustomNodePrefs *_custom_prefs;
|
||||
|
||||
int menu_index = 0;
|
||||
bool is_editing = false;
|
||||
bool restart_required = false;
|
||||
String edit_buffer;
|
||||
uint8_t edit_u8;
|
||||
|
||||
void renderItem(CardputerDisplay &display, SettingsItem item, int x, int y);
|
||||
bool enterItemEdit(SettingsItem item);
|
||||
void cancelItemEdit(SettingsItem item);
|
||||
bool commitItemEdit(SettingsItem item);
|
||||
void handleEditInput(SettingsItem item, Keyboard::Event &e);
|
||||
bool inputParsePositiveFloat(float &val);
|
||||
|
||||
public:
|
||||
SettingsScreen(CardputerUITask *task, mesh::RTCClock *rtc, CompanionNodePrefs *node_prefs,
|
||||
CustomNodePrefs *custom_prefs, CardputerAdvBoard *board)
|
||||
: _task(task), _rtc(rtc), _node_prefs(node_prefs), _custom_prefs(custom_prefs) {}
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
bool handleInput(Keyboard::Event &e) override;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "SplashScreen.h"
|
||||
|
||||
SplashScreen::SplashScreen(CardputerUITask *task) : _task(task) {
|
||||
// strip off dash and commit hash by changing dash to null terminator
|
||||
// e.g: v1.2.3-abcdef -> v1.2.3
|
||||
const char *ver = FIRMWARE_VERSION;
|
||||
const char *dash = strchr(ver, '-');
|
||||
|
||||
int len = dash ? dash - ver : strlen(ver);
|
||||
if (len >= sizeof(version_info)) len = sizeof(version_info) - 1;
|
||||
memcpy(version_info, ver, len);
|
||||
version_info[len] = 0;
|
||||
|
||||
dismiss_after = millis() + BOOT_SCREEN_MILLIS;
|
||||
}
|
||||
|
||||
int SplashScreen::render(CardputerDisplay &lcd) {
|
||||
int logoWidth = 128;
|
||||
// meshcore logo
|
||||
lcd.setColor(CardputerDisplay::P_BLUE);
|
||||
lcd.drawXbm((lcd.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13);
|
||||
|
||||
// version info
|
||||
lcd.setColor(CardputerDisplay::P_WHITE);
|
||||
lcd.setTextSize(2);
|
||||
lcd.drawTextCentered(lcd.width() / 2, 22, version_info);
|
||||
|
||||
lcd.setTextSize(1);
|
||||
lcd.drawTextCentered(lcd.width() / 2, 52, FIRMWARE_BUILD_DATE);
|
||||
|
||||
lcd.drawTextCentered(lcd.width() / 2, 72, "for Cardputer ADV");
|
||||
|
||||
return 5000;
|
||||
}
|
||||
|
||||
void SplashScreen::poll() {
|
||||
if (millis() >= dismiss_after) {
|
||||
_task->gotoMainScreen();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#include "../../CardputerMesh.h"
|
||||
#include "../CardputerUITask.h"
|
||||
#include "../icons.h"
|
||||
#include "globals.h"
|
||||
|
||||
class SplashScreen : public CardputerScreen {
|
||||
CardputerUITask *_task;
|
||||
unsigned long dismiss_after;
|
||||
char version_info[12];
|
||||
|
||||
public:
|
||||
SplashScreen(CardputerUITask *task);
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
void poll() override;
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
#include "ToolsScreen.h"
|
||||
|
||||
int ToolsScreen::render(CardputerDisplay &lcd) {
|
||||
char buf[64];
|
||||
|
||||
lcd.setTextSize(1);
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
const int row = lcd.getRowHeight();
|
||||
const int title_y = 3;
|
||||
const int list_y = title_y + row + 2;
|
||||
const int rows = std::max(1, (lcd.height() - 1 - list_y) / row);
|
||||
const int item_x = 5 + lcd.getTextWidth(">") + 6;
|
||||
|
||||
if (page == ToolsPage::MenuPage) {
|
||||
lcd.drawTextCentered(lcd.width() / 2, title_y, "Tools");
|
||||
|
||||
int real_idx = 0;
|
||||
int list_page = menu_index / rows;
|
||||
int list_idx = menu_index % rows;
|
||||
|
||||
for (int i = 0; i < rows; i++) {
|
||||
real_idx = list_page * rows + i;
|
||||
|
||||
if (real_idx >= ToolsMenuItem::Count) {
|
||||
break;
|
||||
}
|
||||
|
||||
const char *label = menu_item_labels[real_idx];
|
||||
lcd.setColor(label[0] == '-' ? CardputerDisplay::P_GREEN : CardputerDisplay::P_YELLOW);
|
||||
lcd.drawTextEllipsized(item_x, list_y + i * row, lcd.width() - item_x, label);
|
||||
|
||||
if (i == list_idx) {
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
lcd.drawTextLeftAlign(5, list_y + i * row, ">");
|
||||
}
|
||||
}
|
||||
} else if (page == ToolsPage::DiscoverPage) {
|
||||
lcd.drawTextCentered(lcd.width() / 2, title_y, "Discover");
|
||||
|
||||
for (int i = 0; i < discovered_repeaters_count && i < rows; i++) {
|
||||
DiscoveredRepeater *rep = &discovered_repeaters[i];
|
||||
sprintf(buf, "%.1fdB", rep->snr);
|
||||
const int snr_w = lcd.getTextWidth(buf);
|
||||
|
||||
lcd.setColor(CardputerDisplay::P_YELLOW);
|
||||
lcd.drawTextEllipsized(0, list_y + i * row, lcd.width() - snr_w - 6, rep->name);
|
||||
|
||||
if (rep->snr > 0) {
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
} else {
|
||||
lcd.setColor(CardputerDisplay::P_RED);
|
||||
}
|
||||
|
||||
lcd.drawTextRightAlign(lcd.width() - 2, list_y + i * row, buf);
|
||||
}
|
||||
|
||||
return 1000;
|
||||
}
|
||||
|
||||
return 5000;
|
||||
}
|
||||
|
||||
void ToolsScreen::menuItemEnter(ToolsMenuItem item) {
|
||||
switch (item) {
|
||||
case ToolsMenuItem::AdvertFlood:
|
||||
if (the_mesh_cp.sendAdvert(true)) {
|
||||
_task->showAlert("Flood Advert sent", 3000);
|
||||
}
|
||||
|
||||
break;
|
||||
case ToolsMenuItem::AdvertZeroHop:
|
||||
if (the_mesh_cp.sendAdvert(false)) {
|
||||
_task->showAlert("Advert sent", 3000);
|
||||
}
|
||||
break;
|
||||
|
||||
case ToolsMenuItem::DiscoverRepeaters:
|
||||
discovered_repeaters_count = 0;
|
||||
|
||||
if (the_mesh_cp.sendRepeatersDiscover()) {
|
||||
page = ToolsPage::DiscoverPage;
|
||||
_task->showAlert("Waiting for response...", 10000);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ToolsMenuItem::PowerOff: {
|
||||
bool done = false;
|
||||
// Wait for key release to prevent firing interrupt
|
||||
do {
|
||||
Keyboard::Event e = _task->getBoard()->getKeyboard()->poll();
|
||||
done = (e.changed && e.key == Keyboard::KEY_RETURN && e.down == false);
|
||||
delay(10);
|
||||
} while (!done);
|
||||
_task->shutdown();
|
||||
} break;
|
||||
|
||||
case ToolsMenuItem::Restart:
|
||||
ESP.restart();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool ToolsScreen::handleInput(Keyboard::Event &e) {
|
||||
if (_task->isAlertActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (page == ToolsPage::MenuPage) {
|
||||
if (e.key == Keyboard::KEY_ESC) {
|
||||
_task->gotoMainScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::ARROW_UP) {
|
||||
if (menu_index == 0) {
|
||||
menu_index = ToolsMenuItem::Count - 1;
|
||||
} else {
|
||||
menu_index--;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::ARROW_DOWN) {
|
||||
if (menu_index < ToolsMenuItem::Count - 1) {
|
||||
menu_index++;
|
||||
} else {
|
||||
menu_index = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::KEY_RETURN) {
|
||||
menuItemEnter(static_cast<ToolsMenuItem>(menu_index));
|
||||
return true;
|
||||
}
|
||||
} else if (page == ToolsPage::DiscoverPage) {
|
||||
if (e.key == Keyboard::KEY_ESC) {
|
||||
page = ToolsPage::MenuPage;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ToolsScreen::discoverRecv(const mesh::Identity &id, float snr) {
|
||||
if (page == ToolsPage::DiscoverPage && discovered_repeaters_count < MAX_DISCOVERED_REPEATERS) {
|
||||
DiscoveredRepeater rep;
|
||||
rep.snr = snr;
|
||||
rep.name[0] = '\0';
|
||||
|
||||
ContactInfo *contact = the_mesh_cp.lookupContactByPubKey(id.pub_key, CONTACT_LOOKUP_BYTES);
|
||||
|
||||
if (contact) {
|
||||
snprintf(rep.name, sizeof(rep.name), "%s", contact->name);
|
||||
} else {
|
||||
snprintf(rep.name, sizeof(rep.name), "[%02X %02X %02X %02X]", id.pub_key[0], id.pub_key[1],
|
||||
id.pub_key[2], id.pub_key[3]);
|
||||
}
|
||||
|
||||
discovered_repeaters[discovered_repeaters_count] = rep;
|
||||
discovered_repeaters_count++;
|
||||
|
||||
_task->dismissAlert();
|
||||
_task->playSound(SoundType::DiscoveryResult);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../CardputerMesh.h"
|
||||
#include "../CardputerUITask.h"
|
||||
|
||||
class ToolsScreen : public CardputerScreen {
|
||||
enum ToolsPage { MenuPage, DiscoverPage };
|
||||
enum ToolsMenuItem {
|
||||
MeshcoreSeparator,
|
||||
AdvertZeroHop,
|
||||
AdvertFlood,
|
||||
DiscoverRepeaters,
|
||||
DeviceSeparator,
|
||||
PowerOff,
|
||||
Restart,
|
||||
Count
|
||||
};
|
||||
|
||||
struct DiscoveredRepeater {
|
||||
char name[25];
|
||||
float snr;
|
||||
};
|
||||
|
||||
const char *menu_item_labels[ToolsMenuItem::Count] = {
|
||||
"---- RADIO ----",
|
||||
"Advert (Zero Hop)",
|
||||
"Advert (Flood)",
|
||||
"Discover repeaters",
|
||||
"---- DEVICE ----",
|
||||
"Power off",
|
||||
"Restart",
|
||||
};
|
||||
|
||||
CardputerUITask *_task;
|
||||
mesh::RTCClock *_rtc;
|
||||
|
||||
ToolsPage page = ToolsPage::MenuPage;
|
||||
|
||||
int menu_index = 0;
|
||||
|
||||
DiscoveredRepeater discovered_repeaters[MAX_DISCOVERED_REPEATERS];
|
||||
uint16_t discovered_repeaters_count = 0;
|
||||
|
||||
public:
|
||||
ToolsScreen(CardputerUITask *task, mesh::RTCClock *rtc) : _task(task), _rtc(rtc) {}
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
void menuItemEnter(ToolsMenuItem item);
|
||||
bool handleInput(Keyboard::Event &e) override;
|
||||
void discoverRecv(const mesh::Identity &id, float snr);
|
||||
void startDiscover() { menuItemEnter(ToolsMenuItem::DiscoverRepeaters); }
|
||||
|
||||
};
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
#include "ChannelsPage.h"
|
||||
|
||||
int ChannelsPage::getChannelCount() { // not sure if there is no gaps
|
||||
ChannelDetails channel;
|
||||
int num = 0;
|
||||
|
||||
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
if (!the_mesh_cp.getChannel(i, channel) || strlen(channel.name) == 0) {
|
||||
break;
|
||||
}
|
||||
num++;
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
int ChannelsPage::render(CardputerDisplay &lcd) {
|
||||
const int row = lcd.getRowHeight();
|
||||
const int title_y = 20;
|
||||
const int list_y = title_y + row + 1;
|
||||
const int rows = std::max(1, (lcd.height() - 1 - list_y) / row);
|
||||
const int name_x = 5 + lcd.getTextWidth(">") + 6;
|
||||
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
lcd.drawTextCentered(lcd.width() / 2, title_y, "Channels");
|
||||
|
||||
int real_idx = 0;
|
||||
int list_page = channel_list_idx / rows;
|
||||
int list_idx = channel_list_idx % rows;
|
||||
char buf[6];
|
||||
|
||||
for (int i = 0; i < rows; i++) {
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
const int y = list_y + i * row;
|
||||
|
||||
ChannelDetails channel;
|
||||
real_idx = list_page * rows + i;
|
||||
|
||||
if (!the_mesh_cp.getChannel(real_idx, channel) || strlen(channel.name) == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
int unread_count = 0;
|
||||
|
||||
for (int j = 0; j < _p->getUnread()->countChats(); j++) {
|
||||
const UnreadCounterItem *item = _p->getUnread()->get(j);
|
||||
if (item && item->is_channel && memcmp(item->pkey, channel.channel.secret, CONTACT_LOOKUP_BYTES) == 0) {
|
||||
unread_count = item->count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (i == list_idx) {
|
||||
lcd.drawTextLeftAlign(5, y, ">");
|
||||
}
|
||||
|
||||
int right_pad = 0;
|
||||
|
||||
if (unread_count > 0) {
|
||||
snprintf(buf, sizeof(buf), "%d", unread_count);
|
||||
right_pad = lcd.getTextWidth(buf) + 5;
|
||||
lcd.setColor(CardputerDisplay::P_ORANGE);
|
||||
lcd.drawTextRightAlign(lcd.width() - 1, y, buf);
|
||||
}
|
||||
|
||||
lcd.drawTextEllipsized(name_x, y, lcd.width() - name_x - right_pad, channel.name);
|
||||
}
|
||||
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool ChannelsPage::handleInput(Keyboard::Event &e) {
|
||||
if (e.key == Keyboard::ARROW_UP) {
|
||||
if (channel_list_idx == 0) {
|
||||
channel_list_idx = std::max(getChannelCount() - 1, 0);
|
||||
} else {
|
||||
channel_list_idx--;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (e.key == Keyboard::ARROW_DOWN) {
|
||||
if (channel_list_idx < getChannelCount() - 1) {
|
||||
channel_list_idx++;
|
||||
} else {
|
||||
channel_list_idx = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (e.key == Keyboard::KEY_RETURN) {
|
||||
ChannelDetails c;
|
||||
if (the_mesh_cp.getChannel(channel_list_idx, c) && strlen(c.name) > 0) {
|
||||
_p->selectChannel(c, channel_list_idx);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "../CardputerScreen.h"
|
||||
#include "../MainScreen.h"
|
||||
#include "companion_radio/CardputerMesh.h"
|
||||
|
||||
|
||||
class ChannelsPage : public CardputerScreen {
|
||||
private:
|
||||
MainScreen *_p;
|
||||
int channel_list_idx = 0;
|
||||
int getChannelCount();
|
||||
|
||||
public:
|
||||
ChannelsPage(MainScreen *parent) : _p(parent) {};
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
bool handleInput(Keyboard::Event &e) override;
|
||||
};
|
||||
@@ -0,0 +1,273 @@
|
||||
#include "ChatPage.h"
|
||||
|
||||
#include "helpers.h"
|
||||
|
||||
int ChatPage::render(CardputerDisplay &lcd) {
|
||||
char buf[64];
|
||||
const int row = display.getRowHeight();
|
||||
const int line_h = display.getFontLineHeight(); // advance used when print() wraps
|
||||
const int title_y = 20;
|
||||
const int input_y = display.height() - row - 2;
|
||||
const int history_top = title_y + row + 2;
|
||||
const int max_lines = std::max(1, (input_y - 3 - history_top) / line_h);
|
||||
|
||||
display.setColor(CardputerDisplay::P_GREEN);
|
||||
|
||||
if (current_contact_idx == -1 && current_channel_idx == -1) {
|
||||
display.setColor(CardputerDisplay::P_ORANGE);
|
||||
display.drawTextCentered(display.width() / 2, title_y, "No chat selected");
|
||||
} else if (current_contact_idx != -1) {
|
||||
// out_path_len packs the path hash count in the lower 6 bits, and (hash size - 1) in the upper 2 bits
|
||||
uint8_t path_hops = current_contact.out_path_len & 63;
|
||||
if (current_contact.out_path_len == OUT_PATH_UNKNOWN) {
|
||||
snprintf(buf, sizeof(buf), "%s F", current_contact.name);
|
||||
} else if (path_hops == 0) {
|
||||
snprintf(buf, sizeof(buf), "%s D", current_contact.name);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%s %uH", current_contact.name, path_hops);
|
||||
}
|
||||
display.drawTextCentered(display.width() / 2, title_y, buf);
|
||||
} else if (current_channel_idx != -1) {
|
||||
display.drawTextCentered(display.width() / 2, title_y, current_channel.name);
|
||||
}
|
||||
|
||||
display.setColor(CardputerDisplay::P_GREEN);
|
||||
|
||||
int current_y = history_top + (max_lines - 1) * line_h;
|
||||
int total_lines_drawn = 0;
|
||||
|
||||
int start_index = (int)chat_history.count() - 1 - chat_history_offset;
|
||||
|
||||
for (int i = start_index; i >= 0; i--) {
|
||||
if (total_lines_drawn >= max_lines) {
|
||||
break;
|
||||
}
|
||||
|
||||
const HistoryMessage *msg;
|
||||
|
||||
if (!chat_history.get(i, msg)) {
|
||||
break;
|
||||
}
|
||||
|
||||
int text_w = display.getTextWidth(msg->text);
|
||||
int message_lines = (text_w / display.width()) + 1;
|
||||
|
||||
if (total_lines_drawn + message_lines > max_lines) {
|
||||
message_lines = max_lines - total_lines_drawn;
|
||||
}
|
||||
|
||||
int message_top_y = current_y - ((message_lines - 1) * line_h);
|
||||
|
||||
if (msg->out) {
|
||||
if (text_w <= display.width()) {
|
||||
display.setCursor(display.width() - text_w - 5, message_top_y);
|
||||
} else {
|
||||
display.setCursor(5, message_top_y);
|
||||
}
|
||||
} else {
|
||||
display.setCursor(5, message_top_y);
|
||||
}
|
||||
|
||||
if ((text_w / display.width()) + 1 == message_lines) {
|
||||
display.print(msg->text); // wraps text automatically
|
||||
} else {
|
||||
display.drawTextEllipsized(5, message_top_y, display.width(), msg->text);
|
||||
}
|
||||
|
||||
current_y -= (message_lines * line_h);
|
||||
total_lines_drawn += message_lines;
|
||||
}
|
||||
|
||||
int start_x = 5;
|
||||
display.drawRect(1, input_y - 2, display.width() - 1, 1);
|
||||
|
||||
display.setColor(CardputerDisplay::P_WHITE);
|
||||
|
||||
int available_width = display.width() - start_x;
|
||||
|
||||
if (chat_text_box.isEmpty()) {
|
||||
lcd.setColor(CardputerDisplay::P_GRAY);
|
||||
lcd.drawTextLeftAlign(5, input_y, "Write a message...");
|
||||
} else {
|
||||
display.drawTextLeftAlignWithScroll(start_x, input_y, available_width,
|
||||
chat_text_box.c_str());
|
||||
}
|
||||
|
||||
return 15000;
|
||||
}
|
||||
|
||||
void ChatPage::sendChatMessage() {
|
||||
if (chat_text_box.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t ts = the_mesh_cp.getRTCClock()->getCurrentTime();
|
||||
|
||||
if (current_contact_idx != -1 && current_contact.type == ADV_TYPE_CHAT) { // direct msg
|
||||
int result = the_mesh_cp.sendDirectMessage(current_contact, ts, chat_text_box.c_str());
|
||||
|
||||
if (result != MSG_SEND_FAILED) {
|
||||
HistoryMessage *msg = chat_history.push_ref();
|
||||
msg->out = true;
|
||||
snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", chat_text_box.c_str());
|
||||
|
||||
last_sent_message = chat_text_box;
|
||||
chat_text_box.clear();
|
||||
chat_history_offset = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (current_channel_idx != -1) { // channel msg
|
||||
|
||||
bool ok = the_mesh_cp.sendGroupMessage(ts, current_channel.channel, _p->getUiTask()->getNodeName(),
|
||||
chat_text_box.c_str(), chat_text_box.length());
|
||||
if (ok) {
|
||||
HistoryMessage *msg = chat_history.push_ref();
|
||||
msg->out = true;
|
||||
|
||||
snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", chat_text_box.c_str());
|
||||
|
||||
last_sent_message = chat_text_box;
|
||||
chat_text_box.clear();
|
||||
chat_history_offset = 0;
|
||||
_p->getUiTask()->showAlert("Waiting for repeats...", 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatPage::handleInput(Keyboard::Event &e) {
|
||||
CardputerLayout *lay = _p->getUiTask()->getBoard()->getLayout();
|
||||
|
||||
if (e.modifiers.ctrl && e.key == Keyboard::KEY_T) {
|
||||
the_mesh_cp.cancelResending();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (the_mesh_cp.isMessageSending()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::KEY_BACKSPACE) {
|
||||
Helpers::removeLastStringChar(chat_text_box);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::KEY_ESC) {
|
||||
_p->returnToLastPage();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.modifiers.ctrl && e.key == Keyboard::ARROW_UP && chat_text_box.isEmpty()) {
|
||||
chat_text_box = last_sent_message;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::KEY_RETURN) {
|
||||
sendChatMessage();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine if arrow keys should act as scrolling/navigation or text input
|
||||
bool fnPressed = (chat_text_box.length() == 0) ? !e.modifiers.fn : e.modifiers.fn;
|
||||
|
||||
if (fnPressed) {
|
||||
if (e.key == Keyboard::ARROW_DOWN) {
|
||||
if (chat_history_offset > 0) {
|
||||
chat_history_offset--;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::ARROW_UP) {
|
||||
if (chat_history_offset < chat_history.count() - 1) {
|
||||
chat_history_offset++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool skip_input = (fnPressed && (e.key == Keyboard::ARROW_RIGHT || e.key == Keyboard::ARROW_LEFT)) ||
|
||||
e.key == Keyboard::KEY_ESC;
|
||||
|
||||
if (!skip_input) {
|
||||
const char *repl = lay->lookup(e);
|
||||
|
||||
if (repl[0]) {
|
||||
int maxlen = MAX_MESSAGE_LENGTH - strlen(_p->getUiTask()->getNodeName());
|
||||
|
||||
if (chat_text_box.length() + strlen(repl) <= maxlen) {
|
||||
chat_text_box += repl;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ChatPage::selectContact(const ContactInfo &contact, int idx) {
|
||||
current_contact = contact;
|
||||
current_contact_idx = idx;
|
||||
current_channel_idx = -1;
|
||||
the_mesh_cp.loadMessageHistory(contact.id.pub_key, false, chat_history);
|
||||
chat_history_offset = 0;
|
||||
|
||||
const HistoryMessage *msg;
|
||||
for (size_t i = chat_history.count(); i > 0; i--) {
|
||||
if (chat_history.get(i - 1, msg) && msg->out) {
|
||||
last_sent_message = msg->text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ChatPage::selectChannel(const ChannelDetails &channel, int idx) {
|
||||
current_channel = channel;
|
||||
current_channel_idx = idx;
|
||||
current_contact_idx = -1;
|
||||
the_mesh_cp.loadMessageHistory(channel.channel.secret, true, chat_history);
|
||||
chat_history_offset = 0;
|
||||
|
||||
const HistoryMessage *msg;
|
||||
for (size_t i = chat_history.count(); i > 0; i--) {
|
||||
if (chat_history.get(i - 1, msg) && msg->out) {
|
||||
last_sent_message = msg->text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatPage::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) {
|
||||
|
||||
if (current_channel_idx != -1 &&
|
||||
memcmp(channel.secret, current_channel.channel.secret, CONTACT_LOOKUP_BYTES) == 0) {
|
||||
HistoryMessage *msg = chat_history.push_ref();
|
||||
msg->out = false;
|
||||
snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text);
|
||||
chat_history_offset = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChatPage::onContactMessageRecv(const ContactInfo &contact, const char *text) {
|
||||
if (current_contact_idx != -1 &&
|
||||
memcmp(contact.id.pub_key, current_contact.id.pub_key, CONTACT_LOOKUP_BYTES) == 0) {
|
||||
HistoryMessage *msg = chat_history.push_ref();
|
||||
msg->out = false;
|
||||
snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text);
|
||||
chat_history_offset = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ChatPage::refreshSelectedContact() {
|
||||
if (current_contact_idx != -1) {
|
||||
ContactInfo c;
|
||||
if (the_mesh_cp.getContactByIdx(current_contact_idx, c)) {
|
||||
current_contact = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include "../CardputerScreen.h"
|
||||
#include "../MainScreen.h"
|
||||
#include "companion_radio/CardputerMesh.h"
|
||||
|
||||
class ChatPage : public CardputerScreen {
|
||||
private:
|
||||
MainScreen *_p;
|
||||
String chat_text_box;
|
||||
String last_sent_message;
|
||||
|
||||
ChannelDetails current_channel;
|
||||
int current_channel_idx = -1;
|
||||
|
||||
ContactInfo current_contact;
|
||||
int current_contact_idx = -1;
|
||||
|
||||
ChatHistory chat_history;
|
||||
int chat_history_offset = 0; // from bottom
|
||||
|
||||
void sendChatMessage();
|
||||
|
||||
public:
|
||||
ChatPage(MainScreen *parent) : _p(parent) { chat_text_box.reserve(MAX_MESSAGE_LENGTH); };
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
bool handleInput(Keyboard::Event &e) override;
|
||||
void selectContact(const ContactInfo &contact, int idx);
|
||||
void selectChannel(const ChannelDetails &channel, int idx);
|
||||
|
||||
bool onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text);
|
||||
bool onContactMessageRecv(const ContactInfo &contact, const char *text);
|
||||
|
||||
void refreshSelectedContact();
|
||||
};
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
#include "ContactsPage.h"
|
||||
|
||||
#include "helpers.h"
|
||||
|
||||
int ContactsPage::getFilteredContactCount() {
|
||||
ContactInfo contact;
|
||||
int num = 0;
|
||||
// Start iterating from MAX_ANON_CONTACTS to skip anonymous contacts
|
||||
for (int i = MAX_ANON_CONTACTS; i < the_mesh_cp.getTotalContactSlots(); i++) {
|
||||
if (!the_mesh_cp.getContactByIdx(i, contact)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (contact_search_box.isEmpty() ||
|
||||
Helpers::containsIgnoreCase(contact.name, contact_search_box.c_str())) {
|
||||
num++;
|
||||
}
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
bool ContactsPage::getFilteredContactIndex(int list_idx, int &real_idx) {
|
||||
ContactInfo contact;
|
||||
int match_count = 0;
|
||||
// Start iterating from MAX_ANON_CONTACTS to skip anonymous contacts
|
||||
for (int i = MAX_ANON_CONTACTS; i < the_mesh_cp.getTotalContactSlots(); i++) {
|
||||
if (!the_mesh_cp.getContactByIdx(i, contact)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (contact_search_box.isEmpty() ||
|
||||
Helpers::containsIgnoreCase(contact.name, contact_search_box.c_str())) {
|
||||
if (match_count == list_idx) {
|
||||
real_idx = i;
|
||||
return true;
|
||||
}
|
||||
match_count++;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
int ContactsPage::render(CardputerDisplay &lcd) {
|
||||
const int row = lcd.getRowHeight();
|
||||
const int title_y = 20;
|
||||
const int list_y = title_y + row + 1;
|
||||
const int search_y = lcd.height() - row - 2;
|
||||
const int rows = std::max(1, (search_y - 3 - list_y) / row);
|
||||
const int type_x = 5 + lcd.getTextWidth(">") + 4;
|
||||
const int name_x = type_x + lcd.getTextWidth("C") + 4;
|
||||
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
lcd.drawTextCentered(lcd.width() / 2, title_y, "Contacts");
|
||||
int real_idx = 0;
|
||||
int list_page = contact_list_idx / rows;
|
||||
int list_idx = contact_list_idx % rows;
|
||||
char buf[6];
|
||||
|
||||
for (int i = 0; i < rows; i++) {
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
const int y = list_y + i * row;
|
||||
|
||||
ContactInfo contact;
|
||||
int display_idx = list_page * rows + i;
|
||||
|
||||
if (!getFilteredContactIndex(display_idx, real_idx)) {
|
||||
break;
|
||||
}
|
||||
|
||||
the_mesh_cp.getContactByIdx(real_idx, contact);
|
||||
|
||||
int unread_count = 0;
|
||||
|
||||
for (int j = 0; j < _p->getUnread()->countChats(); j++) {
|
||||
const UnreadCounterItem *item = _p->getUnread()->get(j);
|
||||
if (item && !item->is_channel && memcmp(item->pkey, contact.id.pub_key, CONTACT_LOOKUP_BYTES) == 0) {
|
||||
unread_count = item->count;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (i == list_idx) {
|
||||
lcd.drawTextLeftAlign(5, y, ">");
|
||||
}
|
||||
|
||||
int right_pad = 0;
|
||||
|
||||
if (unread_count > 0) {
|
||||
snprintf(buf, sizeof(buf), "%d", unread_count);
|
||||
right_pad = lcd.getTextWidth(buf) + 5;
|
||||
lcd.setColor(CardputerDisplay::P_ORANGE);
|
||||
lcd.drawTextRightAlign(lcd.width() - 1, y, buf);
|
||||
}
|
||||
|
||||
if (contact.type == ADV_TYPE_CHAT) {
|
||||
lcd.drawTextLeftAlign(type_x, y, "C");
|
||||
}
|
||||
|
||||
lcd.drawTextEllipsized(name_x, y, lcd.width() - name_x - right_pad, contact.name);
|
||||
}
|
||||
|
||||
lcd.drawRect(1, search_y - 2, lcd.width() - 1, 1);
|
||||
if (contact_search_box.isEmpty()) {
|
||||
lcd.setColor(CardputerDisplay::P_GRAY);
|
||||
lcd.drawTextLeftAlign(5, search_y, "Search");
|
||||
} else {
|
||||
lcd.setColor(CardputerDisplay::P_WHITE);
|
||||
lcd.drawTextLeftAlignWithScroll(5, search_y, lcd.width() - 10,
|
||||
contact_search_box.c_str());
|
||||
|
||||
}
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool ContactsPage::handleInput(Keyboard::Event &e) {
|
||||
CardputerLayout *lay = _p->getUiTask()->getBoard()->getLayout();
|
||||
|
||||
if (e.key == Keyboard::KEY_BACKSPACE) {
|
||||
if (contact_search_box.length() > 0) {
|
||||
Helpers::removeLastStringChar(contact_search_box);
|
||||
contact_list_idx = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::ARROW_UP) {
|
||||
if (contact_list_idx == 0) {
|
||||
contact_list_idx = std::max(getFilteredContactCount() - 1, 0);
|
||||
} else {
|
||||
contact_list_idx--;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::ARROW_DOWN) {
|
||||
if (contact_list_idx < getFilteredContactCount() - 1) {
|
||||
contact_list_idx++;
|
||||
} else {
|
||||
contact_list_idx = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::KEY_RETURN) {
|
||||
int real_idx = -1;
|
||||
if (getFilteredContactIndex(contact_list_idx, real_idx)) {
|
||||
ContactInfo c;
|
||||
if (the_mesh_cp.getContactByIdx(real_idx, c)) {
|
||||
if (c.type == ADV_TYPE_CHAT) {
|
||||
_p->selectContact(c, real_idx);
|
||||
} else if ((c.type == ADV_TYPE_REPEATER || c.type == ADV_TYPE_ROOM) &&
|
||||
!_p->getUiTask()->isAlertActive()) {
|
||||
the_mesh_cp.sendPing(c);
|
||||
_p->getUiTask()->showAlert("Waiting for response...", 4000);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.modifiers.ctrl && e.key == Keyboard::KEY_R) { // reset path
|
||||
int real_idx = -1;
|
||||
if (getFilteredContactIndex(contact_list_idx, real_idx)) {
|
||||
ContactInfo c;
|
||||
if (the_mesh_cp.getContactByIdx(real_idx, c)) {
|
||||
if (c.type == ADV_TYPE_CHAT) {
|
||||
// getContactByIdx does not return a reference
|
||||
ContactInfo *ref = the_mesh_cp.lookupContactByPubKey(c.id.pub_key, CONTACT_LOOKUP_BYTES);
|
||||
ref->out_path_len = OUT_PATH_UNKNOWN;
|
||||
_p->getUiTask()->showAlert("Path cleared", 1000);
|
||||
_p->refreshSelectedContact();
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool skip_input = e.key == Keyboard::KEY_ESC || e.key == Keyboard::ARROW_LEFT ||
|
||||
e.key == Keyboard::ARROW_RIGHT || e.key == Keyboard::KEY_TAB;
|
||||
|
||||
if (!skip_input) {
|
||||
const char *repl = lay->lookup(e);
|
||||
if (repl[0]) {
|
||||
if (contact_search_box.length() + strlen(repl) <= UI_CHANNEL_SEARCH_MAX_CHARS) {
|
||||
contact_search_box += repl;
|
||||
}
|
||||
contact_list_idx = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "../CardputerScreen.h"
|
||||
#include "../MainScreen.h"
|
||||
#include "companion_radio/CardputerMesh.h"
|
||||
|
||||
class ContactsPage : public CardputerScreen {
|
||||
private:
|
||||
MainScreen *_p;
|
||||
int contact_list_idx = 0;
|
||||
|
||||
String contact_search_box;
|
||||
|
||||
int getFilteredContactCount();
|
||||
bool getFilteredContactIndex(int list_idx, int &real_idx);
|
||||
|
||||
public:
|
||||
ContactsPage(MainScreen *parent) : _p(parent) {};
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
bool handleInput(Keyboard::Event &e) override;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "FirstPage.h"
|
||||
|
||||
int FirstPage::render(CardputerDisplay &lcd) {
|
||||
char tmp[80];
|
||||
|
||||
if (_p->getUiTask()->hasConnection()) {
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
lcd.setTextSize(1);
|
||||
lcd.drawTextCentered(lcd.width() / 2, 43, "< Connected >");
|
||||
} else if (the_mesh_cp.getBLEPin() != 0) { // BT pin
|
||||
lcd.setColor(CardputerDisplay::P_RED);
|
||||
lcd.setTextSize(2);
|
||||
sprintf(tmp, "Pin: %d", the_mesh_cp.getBLEPin());
|
||||
lcd.drawTextCentered(lcd.width() / 2, 43, tmp);
|
||||
}
|
||||
|
||||
lcd.setTextSize(1);
|
||||
|
||||
if (_p->getUnread()->countChats() > 0) {
|
||||
lcd.setColor(CardputerDisplay::P_YELLOW);
|
||||
sprintf(tmp, "Unread: %u chats, %u msgs", _p->getUnread()->countChats(),
|
||||
_p->getUnread()->countMessages());
|
||||
lcd.drawTextCentered(lcd.width() / 2, lcd.height() - lcd.getRowHeight() * 3 - 2, tmp);
|
||||
}
|
||||
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
lcd.drawTextCentered(lcd.width() / 2, lcd.height() - lcd.getRowHeight() * 2 - 1, "Opt / Fn+S: Settings");
|
||||
|
||||
lcd.setColor(CardputerDisplay::P_ORANGE);
|
||||
lcd.drawTextCentered(lcd.width() / 2, lcd.height() - lcd.getRowHeight(), "T / Fn+T: Tools");
|
||||
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool FirstPage::handleInput(Keyboard::Event &e) {
|
||||
if (e.modifiers.opt) {
|
||||
_p->getUiTask()->gotoSettingsScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key == Keyboard::KEY_T) {
|
||||
_p->getUiTask()->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "../CardputerScreen.h"
|
||||
#include "../MainScreen.h"
|
||||
#include "companion_radio/CardputerMesh.h"
|
||||
|
||||
class FirstPage : public CardputerScreen {
|
||||
private:
|
||||
MainScreen *_p;
|
||||
|
||||
public:
|
||||
FirstPage(MainScreen *parent) : _p(parent) {};
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
bool handleInput(Keyboard::Event &e) override;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "GpsPage.h"
|
||||
|
||||
int GpsPage::render(CardputerDisplay &lcd) {
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
LocationProvider *nmea = sensors.getLocationProvider();
|
||||
char buf[50];
|
||||
int y = 18;
|
||||
bool gps_state = _p->getUiTask()->getGPSState();
|
||||
strcpy(buf, gps_state ? "gps on" : "gps off");
|
||||
lcd.drawTextLeftAlign(0, y, buf);
|
||||
if (nmea == NULL) {
|
||||
y = y + lcd.getRowHeight();
|
||||
lcd.drawTextLeftAlign(0, y, "Can't access GPS");
|
||||
} else {
|
||||
strcpy(buf, nmea->isValid() ? "fix" : "no fix");
|
||||
lcd.drawTextRightAlign(lcd.width() - 1, y, buf);
|
||||
y = y + lcd.getRowHeight();
|
||||
lcd.drawTextLeftAlign(0, y, "sat");
|
||||
sprintf(buf, "%d", nmea->satellitesCount());
|
||||
lcd.drawTextRightAlign(lcd.width() - 1, y, buf);
|
||||
y = y + lcd.getRowHeight();
|
||||
lcd.drawTextLeftAlign(0, y, "pos");
|
||||
sprintf(buf, "%.4f %.4f", nmea->getLatitude() / 1000000., nmea->getLongitude() / 1000000.);
|
||||
lcd.drawTextRightAlign(lcd.width() - 1, y, buf);
|
||||
y = y + lcd.getRowHeight();
|
||||
lcd.drawTextLeftAlign(0, y, "alt");
|
||||
sprintf(buf, "%.2f", nmea->getAltitude() / 1000.);
|
||||
lcd.drawTextRightAlign(lcd.width() - 1, y, buf);
|
||||
y = y + lcd.getRowHeight();
|
||||
}
|
||||
#endif
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool GpsPage::handleInput(Keyboard::Event &e) {
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
if (e.key == Keyboard::KEY_RETURN) {
|
||||
_p->getUiTask()->toggleGPS();
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "../CardputerScreen.h"
|
||||
#include "../MainScreen.h"
|
||||
#include "companion_radio/CardputerMesh.h"
|
||||
|
||||
class GpsPage : public CardputerScreen {
|
||||
private:
|
||||
MainScreen *_p;
|
||||
|
||||
public:
|
||||
GpsPage(MainScreen *parent) : _p(parent) {};
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
bool handleInput(Keyboard::Event &e) override;
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#include "RecentAdvertsPage.h"
|
||||
|
||||
int RecentAdvertsPage::render(CardputerDisplay &lcd) {
|
||||
char tmp[80];
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
|
||||
the_mesh_cp.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
||||
lcd.setColor(CardputerDisplay::P_GREEN);
|
||||
|
||||
const int row = lcd.getRowHeight();
|
||||
int y = 20;
|
||||
lcd.drawTextCentered(lcd.width() / 2, y, "Recent adverts");
|
||||
|
||||
y += row + 1;
|
||||
|
||||
for (int i = 0; i < UI_RECENT_LIST_SIZE && y + row <= lcd.height(); i++, y += row) {
|
||||
auto a = &recent[i];
|
||||
if (a->name[0] == 0) continue; // empty slot
|
||||
int secs = _rtc->getCurrentTime() - a->recv_timestamp;
|
||||
if (secs < 60) {
|
||||
sprintf(tmp, "%ds", secs);
|
||||
} else if (secs < 60 * 60) {
|
||||
sprintf(tmp, "%dm", secs / 60);
|
||||
} else {
|
||||
sprintf(tmp, "%dh", secs / (60 * 60));
|
||||
}
|
||||
|
||||
int timestamp_width = lcd.getTextWidth(tmp);
|
||||
int max_name_width = lcd.width() - timestamp_width - 1;
|
||||
|
||||
lcd.drawTextEllipsized(0, y, max_name_width, a->name);
|
||||
lcd.setCursor(lcd.width() - timestamp_width - 1, y);
|
||||
lcd.print(tmp);
|
||||
}
|
||||
|
||||
return 5000;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "../CardputerScreen.h"
|
||||
#include "../MainScreen.h"
|
||||
#include "companion_radio/CardputerMesh.h"
|
||||
|
||||
class RecentAdvertsPage : public CardputerScreen {
|
||||
private:
|
||||
MainScreen *_p;
|
||||
mesh::RTCClock *_rtc;
|
||||
|
||||
public:
|
||||
RecentAdvertsPage(MainScreen *parent, mesh::RTCClock *rtc) : _p(parent), _rtc(rtc) {};
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
#include "StatsPage.h"
|
||||
|
||||
int StatsPage::render(CardputerDisplay &lcd) {
|
||||
char tmp[80];
|
||||
const int row = display.getRowHeight();
|
||||
int y = 20;
|
||||
display.setColor(CardputerDisplay::P_GREEN);
|
||||
display.drawTextCentered(display.width() / 2, y, "Info");
|
||||
display.setColor(CardputerDisplay::P_YELLOW);
|
||||
|
||||
CardputerUITask *task = _p->getUiTask();
|
||||
CompanionNodePrefs *prefs = the_mesh_cp.getNodePrefs();
|
||||
const uint16_t mv = task->getBattMilliVoltsCorrected();
|
||||
int pct = ((int)mv - BATT_MIN_MILLIVOLTS) * 100 / (BATT_MAX_MILLIVOLTS - BATT_MIN_MILLIVOLTS);
|
||||
pct = pct < 0 ? 0 : (pct > 100 ? 100 : pct);
|
||||
|
||||
const char *lines[6];
|
||||
char l0[48], l1[32], l2[40], l3[32], l4[32], l5[32];
|
||||
snprintf(l0, sizeof(l0), "Name: %s", prefs->node_name);
|
||||
snprintf(l1, sizeof(l1), "Battery: %d%% %umV", pct, mv);
|
||||
snprintf(l2, sizeof(l2), "%.3f SF%d BW%g", prefs->freq, prefs->sf, prefs->bw);
|
||||
snprintf(l3, sizeof(l3), "Noise: %d dBm", radio_driver.getNoiseFloor());
|
||||
snprintf(l4, sizeof(l4), "Packets RX: %llu", (unsigned long long)the_mesh_cp.receivedPacketsCount());
|
||||
snprintf(l5, sizeof(l5), "Unsynced msgs: %d", task->getMsgCount());
|
||||
lines[0] = l0; lines[1] = l1; lines[2] = l2; lines[3] = l3; lines[4] = l4; lines[5] = l5;
|
||||
|
||||
for (int i = 0; i < 6; i++) {
|
||||
y += row;
|
||||
if (y + row > display.height()) break;
|
||||
display.drawTextEllipsized(5, y, display.width() - 10, lines[i]);
|
||||
}
|
||||
|
||||
(void)tmp;
|
||||
return 5000;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include "../CardputerScreen.h"
|
||||
#include "../MainScreen.h"
|
||||
#include "companion_radio/CardputerMesh.h"
|
||||
|
||||
class StatsPage : public CardputerScreen {
|
||||
private:
|
||||
MainScreen *_p;
|
||||
|
||||
public:
|
||||
StatsPage(MainScreen *parent) : _p(parent) {};
|
||||
int render(CardputerDisplay &lcd) override;
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#define CUSTOM_DATA_DIR "/meshcore_custom"
|
||||
#define CUSTOM_PREFS_FILE CUSTOM_DATA_DIR "/prefs"
|
||||
#define HISTORY_DIR CUSTOM_DATA_DIR "/history"
|
||||
#define HISTORY_DIR_CHANNEL HISTORY_DIR "/chan"
|
||||
#define HISTORY_DIR_DIRECT HISTORY_DIR "/direct"
|
||||
#define KB_LAYOUT_MAIN_FILE CUSTOM_DATA_DIR "/keyboard_main.txt"
|
||||
#define KB_LAYOUT_ALT_FILE CUSTOM_DATA_DIR "/keyboard_alt.txt"
|
||||
/** Max utf-8 character length for keyboard key */
|
||||
#define KB_LAYOUT_CHAR_MAX 3
|
||||
|
||||
#define MAX_MESSAGE_LENGTH 150
|
||||
|
||||
#define BATT_MIN_MILLIVOLTS 3350 // From M5Unified
|
||||
#define BATT_MAX_MILLIVOLTS 4150 // From M5Unified
|
||||
|
||||
#define UI_RECENT_LIST_SIZE 8
|
||||
#define UI_CHANNEL_SEARCH_MAX_CHARS 32
|
||||
|
||||
#define AUTO_OFF_MILLIS 15000 // 15 seconds
|
||||
|
||||
#define MAX_UNREAD_MSGS 32
|
||||
#define BOOT_SCREEN_MILLIS 2000
|
||||
#define NEW_MESSAGE_DISPLAY_MILLIS AUTO_OFF_MILLIS
|
||||
|
||||
#define CHAT_HISTORY_RINGBUF_SIZE 15
|
||||
#define UNREAD_COUNTER_MAX_ITEMS 32
|
||||
|
||||
#define MAX_DISCOVERED_REPEATERS 8
|
||||
#define CONTACT_LOOKUP_BYTES 6
|
||||
|
||||
#define DIRECT_SEND_ROUTE_ATTEMPTS 3
|
||||
#define DIRECT_SEND_ROUTE_RESEND_RELAY 8000
|
||||
#define DIRECT_SEND_FLOOD_ATTEMPTS 3
|
||||
#define DIRECT_SEND_FLOOD_RESEND_RELAY 10000
|
||||
|
||||
// ================== PINS ==================
|
||||
|
||||
// Cap LoRa-1262 IO expander, audio codec (ES8311), keyboard (TCA8418)
|
||||
#define PIN_I2C_INTERNAL_SDA 8
|
||||
#define PIN_I2C_INTERNAL_SCL 9
|
||||
|
||||
// Cap LoRa-1262 IO expander
|
||||
#define LORA_IOE_I2C_ADDRESS 0x43
|
||||
|
||||
// LCD (ST7789V2)
|
||||
#define TFT_SPI_HOST SPI3_HOST
|
||||
#define PIN_TFT_SCK 36
|
||||
#define PIN_TFT_MOSI 35
|
||||
#define PIN_TFT_RST 33
|
||||
#define PIN_TFT_DC 34
|
||||
#define PIN_TFT_CS 37
|
||||
#define PIN_TFT_BL 38
|
||||
|
||||
// Audio codec (ES8311)
|
||||
#define AUDIO_I2C_ADDRESS 0x18
|
||||
#define PIN_AUDIO_SCLK 41
|
||||
#define PIN_AUDIO_ASDOUT 46
|
||||
#define PIN_AUDIO_LRCK 43
|
||||
#define PIN_AUDIO_DSDIN 42
|
||||
#define AUDIO_SAMPLE_RATE 16000
|
||||
#define AUDIO_MAX_SAFE_VOLUME 20000.0f
|
||||
#define AUDIO_BUFFER_SIZE 256
|
||||
/** Time to wait from codec init to first sound played */
|
||||
#define AUDIO_WARMUP_MS 1100
|
||||
|
||||
// Top button (BtnG0, active low); sends an advert in the companion UI
|
||||
#define PIN_TOP_BUTTON 0
|
||||
|
||||
// Keyboard (TCA8418)
|
||||
#define KEYBOARD_I2C_ADDRESS 0x34
|
||||
#define PIN_KEYBOARD_INT 11
|
||||
@@ -0,0 +1,43 @@
|
||||
#include <cctype>
|
||||
|
||||
class Helpers {
|
||||
public:
|
||||
static bool containsIgnoreCase(const char *str, const char *search) {
|
||||
if (*search == '\0') {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (; *str != '\0'; ++str) {
|
||||
const char *s1 = str;
|
||||
const char *s2 = search;
|
||||
|
||||
while (*s1 && *s2 &&
|
||||
std::tolower(static_cast<unsigned char>(*s1)) == std::tolower(static_cast<unsigned char>(*s2))) {
|
||||
++s1;
|
||||
++s2;
|
||||
}
|
||||
|
||||
if (*s2 == '\0') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// In case if input has utf-8 multibyte characters
|
||||
static void removeLastStringChar(String &str) {
|
||||
unsigned int len = str.length();
|
||||
if (len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
unsigned int bytesToRemove = 1;
|
||||
|
||||
while (len - bytesToRemove > 0 && ((uint8_t)str.charAt(len - bytesToRemove) & 0xC0) == 0x80) {
|
||||
bytesToRemove++;
|
||||
}
|
||||
|
||||
str.remove(len - bytesToRemove, bytesToRemove);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
#include "CardputerDisplay.h"
|
||||
|
||||
#include "font.h"
|
||||
#include "font_16.h"
|
||||
#include "font_19.h"
|
||||
#include "globals.h"
|
||||
|
||||
#include <MeshCore.h>
|
||||
|
||||
ColorVal UIColor::window_bkg = CardputerDisplay::PaletteColor::P_BLACK;
|
||||
|
||||
int32_t emoji_draw_callback(lgfx::LGFXBase *gfx, int32_t x, int32_t y, uint32_t code, int32_t font_height) {
|
||||
int w = gfx->textWidth("O");
|
||||
int h = gfx->fontHeight();
|
||||
gfx->drawRect(x, y + (h - w - w / 2), w, w, TFT_DARKGRAY);
|
||||
return w;
|
||||
}
|
||||
|
||||
LGFX::LGFX() {
|
||||
{
|
||||
auto cfg = bus_instance_.config();
|
||||
cfg.spi_host = TFT_SPI_HOST;
|
||||
cfg.spi_mode = 0;
|
||||
cfg.freq_write = 40000000; // 40 MHz write clock
|
||||
cfg.spi_3wire = true;
|
||||
cfg.use_lock = true;
|
||||
cfg.dma_channel = SPI_DMA_CH_AUTO;
|
||||
cfg.pin_sclk = PIN_TFT_SCK;
|
||||
cfg.pin_mosi = PIN_TFT_MOSI;
|
||||
cfg.pin_miso = -1;
|
||||
cfg.pin_dc = PIN_TFT_DC;
|
||||
bus_instance_.config(cfg);
|
||||
panel_instance_.setBus(&bus_instance_);
|
||||
}
|
||||
|
||||
{
|
||||
auto cfg = panel_instance_.config();
|
||||
cfg.pin_cs = PIN_TFT_CS;
|
||||
cfg.pin_rst = PIN_TFT_RST;
|
||||
cfg.panel_width = 135;
|
||||
cfg.panel_height = 240;
|
||||
cfg.offset_x = 52; // values from M5GFX
|
||||
cfg.offset_y = 40;
|
||||
cfg.readable = false;
|
||||
cfg.invert = true;
|
||||
// cfg.rgb_order = false;
|
||||
// cfg.dlen_16bit = false;
|
||||
// cfg.bus_shared = true;
|
||||
panel_instance_.config(cfg);
|
||||
}
|
||||
|
||||
{
|
||||
auto cfg = light_instance_.config();
|
||||
cfg.pin_bl = PIN_TFT_BL;
|
||||
cfg.invert = false;
|
||||
cfg.freq = 256;
|
||||
cfg.offset = 16;
|
||||
cfg.pwm_channel = 7;
|
||||
light_instance_.config(cfg);
|
||||
panel_instance_.setLight(&light_instance_);
|
||||
}
|
||||
|
||||
setPanel(&panel_instance_);
|
||||
}
|
||||
|
||||
static const uint8_t *const kFonts[CardputerDisplay::TEXT_LEVEL_COUNT] = { DejaVuSans_11, DejaVuSans_16,
|
||||
DejaVuSans_19 };
|
||||
static const uint8_t kFontPx[CardputerDisplay::TEXT_LEVEL_COUNT] = { 12, 16, 19 };
|
||||
static const uint8_t kNormalPx = 12;
|
||||
|
||||
const char *CardputerDisplay::textLevelName(uint8_t level) {
|
||||
static const char *const names[TEXT_LEVEL_COUNT] = { "Normal", "Large", "Extra large" };
|
||||
return level < TEXT_LEVEL_COUNT ? names[level] : names[TEXT_LARGE];
|
||||
}
|
||||
|
||||
void CardputerDisplay::setTextLevel(uint8_t level) {
|
||||
if (level >= TEXT_LEVEL_COUNT) level = TEXT_LARGE;
|
||||
_textLevel = level;
|
||||
_fontPx = kFontPx[level];
|
||||
LCD.loadFont(kFonts[level]);
|
||||
setTextSize(1);
|
||||
}
|
||||
|
||||
void CardputerDisplay::setSmallText() {
|
||||
_scale = (float)kNormalPx / _fontPx;
|
||||
LCD.setTextSize(_scale);
|
||||
}
|
||||
|
||||
bool CardputerDisplay::begin() {
|
||||
LCD.setBaseColor(TFT_BLACK);
|
||||
|
||||
bool success = LCD.begin();
|
||||
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LCD.setRotation(1);
|
||||
LCD.setColorDepth(16); // lower SPI bandwidth
|
||||
LCD.setBrightness(128);
|
||||
|
||||
LCD.setTextColor(TFT_WHITE, TFT_BLACK);
|
||||
LCD.setEmojiCallback(emoji_draw_callback);
|
||||
setTextLevel(_textLevel);
|
||||
return true;
|
||||
}
|
||||
|
||||
void CardputerDisplay::turnOn() {
|
||||
_isOn = true;
|
||||
LCD.wakeup();
|
||||
}
|
||||
|
||||
void CardputerDisplay::turnOff() {
|
||||
_isOn = false;
|
||||
LCD.sleep();
|
||||
}
|
||||
|
||||
void CardputerDisplay::clear() {
|
||||
LCD.clear();
|
||||
}
|
||||
|
||||
void CardputerDisplay::startFrame(ColorVal bkg) {
|
||||
LCD.startWrite();
|
||||
LCD.clear(P_BLACK);
|
||||
}
|
||||
|
||||
void CardputerDisplay::endFrame() {
|
||||
LCD.endWrite();
|
||||
}
|
||||
|
||||
int32_t CardputerDisplay::getFontHeight() const {
|
||||
return LCD.fontHeight();
|
||||
}
|
||||
|
||||
int16_t CardputerDisplay::getFontLineHeight() const {
|
||||
return LCD.fontHeight();
|
||||
}
|
||||
|
||||
void CardputerDisplay::drawTextLeftAlignWithScroll(int x, int y, int available_width, const char *text) {
|
||||
int text_len = strlen(text);
|
||||
int offset = 0;
|
||||
|
||||
// Calculate how many characters from the start to skip so the text fits
|
||||
while (offset < text_len) {
|
||||
if (getTextWidth(&text[offset]) <= available_width) {
|
||||
break;
|
||||
}
|
||||
// Skip to next UTF-8 character boundary
|
||||
unsigned char b = text[offset];
|
||||
if ((b & 0x80) == 0) {
|
||||
// Single-byte character (ASCII)
|
||||
offset += 1;
|
||||
} else if ((b & 0xE0) == 0xC0) {
|
||||
// 2-byte character
|
||||
offset += 2;
|
||||
} else if ((b & 0xF0) == 0xE0) {
|
||||
// 3-byte character
|
||||
offset += 3;
|
||||
} else if ((b & 0xF8) == 0xF0) {
|
||||
// 4-byte character
|
||||
offset += 4;
|
||||
} else {
|
||||
offset += 1; // Invalid UTF-8, skip single byte
|
||||
}
|
||||
|
||||
if (offset >= text_len) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
drawTextLeftAlign(x, y, &text[offset]);
|
||||
}
|
||||
|
||||
// Size 1 is the chosen text level; larger sizes are headings, scaled so a
|
||||
// heading is about the same height (sz x Normal) at every level.
|
||||
void CardputerDisplay::setTextSize(int sz) {
|
||||
_scale = sz <= 1 ? 1.0f : (float)(kNormalPx * sz) / _fontPx;
|
||||
LCD.setTextSize(_scale);
|
||||
}
|
||||
|
||||
void CardputerDisplay::setColor(ColorVal c) {
|
||||
_lastColor = c;
|
||||
LCD.setColor(_lastColor);
|
||||
LCD.setTextColor(_lastColor);
|
||||
}
|
||||
|
||||
void CardputerDisplay::setCursor(int x, int y) {
|
||||
LCD.setCursor(x, y);
|
||||
}
|
||||
|
||||
void CardputerDisplay::print(const char *str) {
|
||||
LCD.print(str);
|
||||
}
|
||||
|
||||
void CardputerDisplay::fillRect(int x, int y, int w, int h) {
|
||||
LCD.fillRect(x, y, w, h);
|
||||
}
|
||||
|
||||
void CardputerDisplay::drawRect(int x, int y, int w, int h) {
|
||||
LCD.drawRect(x, y, w, h);
|
||||
}
|
||||
|
||||
void CardputerDisplay::drawXbm(int x, int y, const uint8_t *bits, int w, int h) {
|
||||
LCD.drawBitmap(x, y, bits, w, h, _lastColor);
|
||||
}
|
||||
|
||||
uint16_t CardputerDisplay::getTextWidth(const char *str) {
|
||||
return LCD.textWidth(str);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
#include <LovyanGFX.hpp>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
|
||||
|
||||
class LGFX : public lgfx::LGFX_Device {
|
||||
lgfx::Panel_ST7789 panel_instance_;
|
||||
lgfx::Bus_SPI bus_instance_;
|
||||
lgfx::Light_PWM light_instance_;
|
||||
|
||||
public:
|
||||
LGFX();
|
||||
};
|
||||
|
||||
|
||||
class CardputerDisplay : public DisplayDriver {
|
||||
public:
|
||||
// UI text size setting; each level loads its own DejaVu Sans font.
|
||||
enum TextLevel : uint8_t { TEXT_NORMAL = 0, TEXT_LARGE = 1, TEXT_XLARGE = 2, TEXT_LEVEL_COUNT };
|
||||
|
||||
private:
|
||||
bool _isOn = false;
|
||||
uint16_t _lastColor = 0;
|
||||
uint8_t _textLevel = TEXT_LARGE;
|
||||
uint8_t _fontPx = 12; // em size of the loaded font
|
||||
float _scale = 1.0f; // current setTextSize() scale
|
||||
LGFX LCD;
|
||||
|
||||
public:
|
||||
enum PaletteColor : uint16_t {
|
||||
P_BLACK = TFT_BLACK,
|
||||
P_WHITE = TFT_WHITE,
|
||||
P_GREEN = TFT_GREEN,
|
||||
P_BLUE = TFT_BLUE,
|
||||
P_RED = TFT_RED,
|
||||
P_ORANGE = TFT_ORANGE,
|
||||
P_YELLOW = TFT_YELLOW,
|
||||
P_GRAY = TFT_GRAY,
|
||||
};
|
||||
|
||||
CardputerDisplay() : DisplayDriver(240, 135) {}
|
||||
bool begin();
|
||||
void setBrightness(uint8_t level) { LCD.setBrightness(level); }
|
||||
|
||||
bool isOn() override { return _isOn; };
|
||||
void turnOn() override;
|
||||
void turnOff() override;
|
||||
void clear() override;
|
||||
void startFrame(ColorVal bkg = UIColor::window_bkg) override;
|
||||
void setTextSize(int sz) override;
|
||||
void setColor(ColorVal c) override;
|
||||
void setCursor(int x, int y) override;
|
||||
void print(const char *str) override;
|
||||
void fillRect(int x, int y, int w, int h) override;
|
||||
void drawRect(int x, int y, int w, int h) override;
|
||||
void drawXbm(int x, int y, const uint8_t *bits, int w, int h) override;
|
||||
void endFrame() override;
|
||||
uint16_t getTextWidth(const char *str) override;
|
||||
int32_t getFontHeight() const;
|
||||
// Line advance used by LovyanGFX when wrapping text (chat history).
|
||||
int16_t getFontLineHeight() const;
|
||||
// Row pitch for lists and stacked labels at the current text size.
|
||||
int16_t getRowHeight() const { return (int16_t)(_fontPx * _scale + 0.5f); }
|
||||
|
||||
void setTextLevel(uint8_t level);
|
||||
uint8_t getTextLevel() const { return _textLevel; }
|
||||
static const char *textLevelName(uint8_t level);
|
||||
// Status bar text stays at the Normal size whatever the setting.
|
||||
void setSmallText();
|
||||
void drawTextLeftAlignWithScroll(int x, int y, int available_width, const char *text);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "CardputerKeyboard.h"
|
||||
#include "globals.h"
|
||||
#include <Wire.h>
|
||||
|
||||
bool CardputerKeyboard::isModifier(Keyboard::Key key) {
|
||||
|
||||
return key == Keyboard::KEY_SHIFT || key == Keyboard::KEY_OPT ||
|
||||
key == Keyboard::KEY_ALT || key == Keyboard::KEY_CTRL ||
|
||||
key == Keyboard::KEY_FN;
|
||||
}
|
||||
|
||||
void CardputerKeyboard::updateModifier(Keyboard::Key key, bool down) {
|
||||
switch (key) {
|
||||
case Keyboard::KEY_SHIFT:
|
||||
// Sticky: each press toggles (like caps lock), releases are ignored
|
||||
if (down) modifiers.shift = !modifiers.shift;
|
||||
break;
|
||||
case Keyboard::KEY_OPT:
|
||||
modifiers.opt = down;
|
||||
break;
|
||||
case Keyboard::KEY_ALT:
|
||||
modifiers.alt = down;
|
||||
break;
|
||||
case Keyboard::KEY_CTRL:
|
||||
modifiers.ctrl = down;
|
||||
break;
|
||||
case Keyboard::KEY_FN:
|
||||
// One-shot: a press arms Fn for the next key (press again to cancel)
|
||||
if (down) modifiers.fn = !modifiers.fn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerKeyboard::begin() {
|
||||
tio.begin(KEYBOARD_I2C_ADDRESS, &Wire1);
|
||||
tio.matrix(7, 8);
|
||||
tio.flush();
|
||||
tio.enableInterrupts();
|
||||
}
|
||||
|
||||
Keyboard::Event CardputerKeyboard::poll() {
|
||||
Keyboard::Event result;
|
||||
result.changed = tio.available() > 0;
|
||||
|
||||
if (result.changed) {
|
||||
uint8_t e = tio.getEvent();
|
||||
bool down = (e & 0x80);
|
||||
tio.writeRegister(TCA8418_REG_INT_STAT, 1);
|
||||
|
||||
e &= 0x7F;
|
||||
|
||||
// Normalize key code to remove gaps
|
||||
uint8_t key_index = ((e - 1) / 10) * 8 + ((e - 1) % 10) + 1;
|
||||
Keyboard::Key key = static_cast<Keyboard::Key>(key_index);
|
||||
|
||||
result.down = down;
|
||||
|
||||
if (isModifier(key)) {
|
||||
updateModifier(key, down);
|
||||
} else {
|
||||
result.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
result.modifiers = modifiers;
|
||||
if (result.key != Keyboard::KEY_NONE && result.down) {
|
||||
modifiers.fn = false; // Fn applies to this one key only
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
#pragma once
|
||||
#include <Adafruit_TCA8418.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// Non normalized
|
||||
// 1 5 11 15 21 25 31 35 41 45 51 55 61 65
|
||||
// 2 6 12 16 22 26 32 36 42 46 52 56 62 66
|
||||
// 3 7 13 17 23 27 33 37 43 47 53 57 63 67
|
||||
// 4 8 14 18 24 28 34 38 44 48 54 58 64 68
|
||||
|
||||
// Normalized
|
||||
// 1 5 9 13 17 21 25 29 33 37 41 45 49 53
|
||||
// 2 6 10 14 18 22 26 30 34 38 42 46 50 54
|
||||
// 3 7 11 15 19 23 27 31 35 39 43 47 51 55
|
||||
// 4 8 12 16 20 24 28 32 36 40 44 48 52 56
|
||||
|
||||
namespace Keyboard {
|
||||
// Normalized key codes
|
||||
enum Key : uint8_t {
|
||||
KEY_NONE = 0,
|
||||
|
||||
// Column 1
|
||||
KEY_ESC = 1,
|
||||
KEY_TAB = 2,
|
||||
KEY_FN = 3,
|
||||
KEY_CTRL = 4,
|
||||
|
||||
// Column 2
|
||||
KEY_1 = 5,
|
||||
KEY_Q = 6,
|
||||
KEY_SHIFT = 7,
|
||||
KEY_OPT = 8,
|
||||
|
||||
// Column 3
|
||||
KEY_2 = 9,
|
||||
KEY_W = 10,
|
||||
KEY_A = 11,
|
||||
KEY_ALT = 12,
|
||||
|
||||
// Column 4
|
||||
KEY_3 = 13,
|
||||
KEY_E = 14,
|
||||
KEY_S = 15,
|
||||
KEY_Z = 16,
|
||||
|
||||
// Column 5
|
||||
KEY_4 = 17,
|
||||
KEY_R = 18,
|
||||
KEY_D = 19,
|
||||
KEY_X = 20,
|
||||
|
||||
// Column 6
|
||||
KEY_5 = 21,
|
||||
KEY_T = 22,
|
||||
KEY_F = 23,
|
||||
KEY_C = 24,
|
||||
|
||||
// Column 7
|
||||
KEY_6 = 25,
|
||||
KEY_Y = 26,
|
||||
KEY_G = 27,
|
||||
KEY_V = 28,
|
||||
|
||||
// Column 8
|
||||
KEY_7 = 29,
|
||||
KEY_U = 30,
|
||||
KEY_H = 31,
|
||||
KEY_B = 32,
|
||||
|
||||
// Column 9
|
||||
KEY_8 = 33,
|
||||
KEY_I = 34,
|
||||
KEY_J = 35,
|
||||
KEY_N = 36,
|
||||
|
||||
// Column 10
|
||||
KEY_9 = 37,
|
||||
KEY_O = 38,
|
||||
KEY_K = 39,
|
||||
KEY_M = 40,
|
||||
|
||||
// Column 11
|
||||
KEY_0 = 41,
|
||||
KEY_P = 42,
|
||||
KEY_L = 43,
|
||||
KEY_COMMA = 44,
|
||||
|
||||
// Column 12
|
||||
KEY_MINUS = 45,
|
||||
KEY_LBRACKET = 46,
|
||||
KEY_SEMICOLON = 47,
|
||||
KEY_PERIOD = 48,
|
||||
|
||||
// Column 13
|
||||
KEY_PLUS = 49,
|
||||
KEY_RBRACKET = 50,
|
||||
KEY_APOSTROPHE = 51,
|
||||
KEY_SLASH = 52,
|
||||
|
||||
// Column 14
|
||||
KEY_BACKSPACE = 53,
|
||||
KEY_BACKSLASH = 54,
|
||||
KEY_RETURN = 55,
|
||||
KEY_SPACE = 56
|
||||
};
|
||||
|
||||
enum Arrow : uint8_t {
|
||||
ARROW_LEFT = 44,
|
||||
ARROW_UP = 47,
|
||||
ARROW_DOWN = 48,
|
||||
ARROW_RIGHT = 52,
|
||||
};
|
||||
|
||||
struct Modifiers {
|
||||
bool fn = false;
|
||||
bool ctrl = false;
|
||||
bool shift = false;
|
||||
bool opt = false;
|
||||
bool alt = false;
|
||||
|
||||
bool any() const { return fn || ctrl || shift || opt || alt; }
|
||||
};
|
||||
|
||||
struct Event {
|
||||
Key key = KEY_NONE;
|
||||
bool changed = false;
|
||||
bool down = false;
|
||||
Modifiers modifiers;
|
||||
};
|
||||
|
||||
class CardputerKeyboard {
|
||||
private:
|
||||
Adafruit_TCA8418 tio;
|
||||
Keyboard::Modifiers modifiers;
|
||||
bool isModifier(Keyboard::Key key);
|
||||
void updateModifier(Keyboard::Key key, bool down);
|
||||
|
||||
public:
|
||||
CardputerKeyboard() {}
|
||||
void begin();
|
||||
Keyboard::Event poll();
|
||||
// Shift latches on/off per press (caps lock); Fn arms for the next key
|
||||
// only; Ctrl, Opt and Alt are held.
|
||||
Keyboard::Modifiers getModifiers() const { return modifiers; }
|
||||
void setModifiers(const Keyboard::Modifiers &m) { modifiers = m; }
|
||||
};
|
||||
|
||||
}; // namespace Keyboard
|
||||
|
||||
using Keyboard::CardputerKeyboard;
|
||||
@@ -0,0 +1,208 @@
|
||||
#include "CardputerLayout.h"
|
||||
|
||||
using namespace Keyboard::Layout;
|
||||
|
||||
static LayoutLayer default_layout_layers[2] = {
|
||||
{ (Keyboard::Layout::LAYER_MASK_BASE),
|
||||
{ "`", "", "", "", "1", "q", "", "", "2", "w", "a", "", "3", "e", "s", "z", "4", "r", "d",
|
||||
"x", "5", "t", "f", "c", "6", "y", "g", "v", "7", "u", "h", "b", "8", "i", "j", "n", "9", "o",
|
||||
"k", "m", "0", "p", "l", ",", "-", "[", ";", ".", "=", "]", "'", "/", "", "\\", "", "" } },
|
||||
{ (Keyboard::Layout::LAYER_MASK_SHIFT),
|
||||
{ "~", "", "", "", "!", "Q", "", "", "@", "W", "A", "", "#", "E", "S", "Z", "$", "R", "D",
|
||||
"X", "%", "T", "F", "C", "^", "Y", "G", "V", "&", "U", "H", "B", "*", "I", "J", "N", "(", "O",
|
||||
"K", "M", ")", "P", "L", "<", "_", "{", ":", ">", "+", "}", "\"", "?", "", "|", "", "" } }
|
||||
};
|
||||
|
||||
static LayoutData default_layout = { .language_code = "en",
|
||||
.layers_count = 2,
|
||||
.layers = default_layout_layers };
|
||||
|
||||
uint8_t CardputerLayout::getLayerMask(Keyboard::Modifiers modifiers) const {
|
||||
return (modifiers.fn ? LAYER_MASK_FN : 0) | (modifiers.ctrl ? LAYER_MASK_CTRL : 0) |
|
||||
(modifiers.shift ? LAYER_MASK_SHIFT : 0) | (modifiers.opt ? LAYER_MASK_OPT : 0) |
|
||||
(modifiers.alt ? LAYER_MASK_ALT : 0);
|
||||
}
|
||||
|
||||
Keyboard::Layout::CardputerLayout::CardputerLayout()
|
||||
: main_layout(&default_layout), current_layout(&default_layout) {}
|
||||
|
||||
|
||||
const char *CardputerLayout::lookup(Keyboard::Event &event, bool force_default) {
|
||||
if (event.key == Keyboard::KEY_NONE || event.key > TOTAL_KEYS || !current_layout) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const LayoutData *tgt = force_default ? &default_layout : current_layout;
|
||||
uint8_t mask = getLayerMask(event.modifiers);
|
||||
uint8_t key = event.key - 1;
|
||||
|
||||
// Try to find an exact match in the current modifier layer
|
||||
for (uint8_t i = 0; i < tgt->layers_count; i++) {
|
||||
if (mask == tgt->layers[i].modifiers_mask) {
|
||||
const char *data = tgt->layers[i].entries[key].data;
|
||||
if (data && data[0]) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If Shift is pressed and nothing was found, try the dedicated SHIFT layer
|
||||
if (event.modifiers.shift && mask != LAYER_MASK_SHIFT) {
|
||||
for (uint8_t i = 0; i < tgt->layers_count; i++) {
|
||||
if (LAYER_MASK_SHIFT == tgt->layers[i].modifiers_mask) {
|
||||
const char *data = tgt->layers[i].entries[key].data;
|
||||
if (data && data[0]) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to the BASE layer if we haven't returned anything yet
|
||||
if (mask != LAYER_MASK_BASE) {
|
||||
for (uint8_t i = 0; i < tgt->layers_count; i++) {
|
||||
if (LAYER_MASK_BASE == tgt->layers[i].modifiers_mask) {
|
||||
const char *data = tgt->layers[i].entries[key].data;
|
||||
if (data && data[0]) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle space if it not overriden
|
||||
if (event.key == Keyboard::KEY_SPACE && !event.modifiers.ctrl) {
|
||||
return " ";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
void CardputerLayout::begin(FS &fs) {
|
||||
parseLayout(fs, LAYOUT_TYPE_MAIN);
|
||||
parseLayout(fs, LAYOUT_TYPE_ALT);
|
||||
}
|
||||
|
||||
// Uses dynamic allocation
|
||||
void CardputerLayout::parseLayout(FS &fs, LayoutType layoutType) {
|
||||
const char *filename = (layoutType == LAYOUT_TYPE_MAIN) ? KB_LAYOUT_MAIN_FILE : KB_LAYOUT_ALT_FILE;
|
||||
if (!fs.exists(filename)) {
|
||||
return;
|
||||
}
|
||||
|
||||
File layout_file = fs.open(filename, FILE_READ, false);
|
||||
if (!layout_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
LayoutData *layout = new LayoutData();
|
||||
layout->language_code[0] = '\0';
|
||||
layout->layers_count = 0;
|
||||
layout->layers = nullptr;
|
||||
|
||||
int current_layer_index = -1;
|
||||
|
||||
while (layout_file.available()) {
|
||||
String line = layout_file.readStringUntil('\n');
|
||||
if (line.endsWith("\r")) {
|
||||
line.remove(line.length() - 1);
|
||||
}
|
||||
|
||||
if (line.length() < 1 || line.charAt(0) == '#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lang code (format "N xx")
|
||||
if (line.charAt(0) == 'N' && line.length() >= 4) {
|
||||
strncpy(layout->language_code, line.c_str() + 2, 2);
|
||||
layout->language_code[2] = '\0';
|
||||
}
|
||||
|
||||
// Layer mask (format "L BFCSOA", B = base, F = fn, C = ctrl, S = shift, O = opt, A = alt)
|
||||
else if (line.charAt(0) == 'L' && line.length() >= 3) {
|
||||
uint8_t mask = 0;
|
||||
|
||||
for (unsigned int i = 2; i < line.length(); i++) {
|
||||
char mod = line.charAt(i);
|
||||
if (mod == 'B') {
|
||||
mask = LAYER_MASK_BASE;
|
||||
} else if (mod == 'F') {
|
||||
mask |= LAYER_MASK_FN;
|
||||
} else if (mod == 'C') {
|
||||
mask |= LAYER_MASK_CTRL;
|
||||
} else if (mod == 'S') {
|
||||
mask |= LAYER_MASK_SHIFT;
|
||||
} else if (mod == 'O') {
|
||||
mask |= LAYER_MASK_OPT;
|
||||
} else if (mod == 'A') {
|
||||
mask |= LAYER_MASK_ALT;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamically expand the layout array for the new layer
|
||||
uint8_t new_count = layout->layers_count + 1;
|
||||
LayoutLayer *new_layers = new LayoutLayer[new_count];
|
||||
|
||||
memset(new_layers, 0, sizeof(LayoutLayer) * new_count);
|
||||
|
||||
if (layout->layers != nullptr) {
|
||||
// Copy old array contents into the expanded array space
|
||||
memcpy(new_layers, layout->layers, sizeof(LayoutLayer) * layout->layers_count);
|
||||
delete[] layout->layers; // Clear old buffer
|
||||
}
|
||||
|
||||
// Setup new layer
|
||||
new_layers[new_count - 1].modifiers_mask = mask;
|
||||
layout->layers = new_layers;
|
||||
layout->layers_count = new_count;
|
||||
current_layer_index = new_count - 1;
|
||||
}
|
||||
|
||||
// Key replacements (format "K NN=u8char")
|
||||
else if (line.charAt(0) == 'K' && line.length() >= 5 && line.charAt(4) == '=' &&
|
||||
current_layer_index != -1) {
|
||||
String id_str = line.substring(2, 4);
|
||||
int key_id = id_str.toInt();
|
||||
|
||||
if (key_id >= 1 && key_id <= TOTAL_KEYS) {
|
||||
int key_idx = key_id - 1;
|
||||
String val_str = line.substring(5);
|
||||
|
||||
char *dest = layout->layers[current_layer_index].entries[key_idx].data;
|
||||
memset(dest, 0, KB_LAYOUT_CHAR_MAX + 1);
|
||||
strncpy(dest, val_str.c_str(), KB_LAYOUT_CHAR_MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
layout_file.close();
|
||||
|
||||
if (layout->layers_count == 0) {
|
||||
delete layout;
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutType == LAYOUT_TYPE_MAIN) {
|
||||
if (main_layout && main_layout != &default_layout) {
|
||||
delete[] main_layout->layers;
|
||||
delete main_layout;
|
||||
}
|
||||
main_layout = layout;
|
||||
current_layout = main_layout;
|
||||
} else {
|
||||
if (alternate_layout) {
|
||||
delete[] alternate_layout->layers;
|
||||
delete alternate_layout;
|
||||
}
|
||||
alternate_layout = layout;
|
||||
}
|
||||
}
|
||||
|
||||
void Keyboard::Layout::CardputerLayout::switchLayout() {
|
||||
LayoutType new_type = (current_layout_type == LAYOUT_TYPE_MAIN) ? LAYOUT_TYPE_ALT : LAYOUT_TYPE_MAIN;
|
||||
LayoutData *new_layout = (new_type == LAYOUT_TYPE_MAIN) ? main_layout : alternate_layout;
|
||||
|
||||
if (new_layout) {
|
||||
current_layout_type = new_type;
|
||||
current_layout = new_layout;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
#pragma once
|
||||
|
||||
#include "CardputerKeyboard.h"
|
||||
#include "globals.h"
|
||||
|
||||
#include <FS.h>
|
||||
#include <stdint.h>
|
||||
|
||||
|
||||
#define TOTAL_KEYS 56
|
||||
|
||||
namespace Keyboard {
|
||||
namespace Layout {
|
||||
enum LayoutType { LAYOUT_TYPE_MAIN, LAYOUT_TYPE_ALT };
|
||||
|
||||
enum LayerMask {
|
||||
LAYER_MASK_BASE = 0x00,
|
||||
LAYER_MASK_FN = 0x01,
|
||||
LAYER_MASK_CTRL = 0x02,
|
||||
LAYER_MASK_SHIFT = 0x04,
|
||||
LAYER_MASK_OPT = 0x08,
|
||||
LAYER_MASK_ALT = 0x10
|
||||
};
|
||||
|
||||
struct LayoutEntry {
|
||||
char data[KB_LAYOUT_CHAR_MAX + 1];
|
||||
};
|
||||
|
||||
struct LayoutLayer {
|
||||
uint8_t modifiers_mask;
|
||||
LayoutEntry entries[TOTAL_KEYS];
|
||||
};
|
||||
|
||||
struct LayoutData {
|
||||
char language_code[3];
|
||||
uint8_t layers_count;
|
||||
LayoutLayer *layers;
|
||||
};
|
||||
|
||||
class CardputerLayout {
|
||||
private:
|
||||
LayoutData *main_layout = nullptr;
|
||||
LayoutData *alternate_layout = nullptr;
|
||||
LayoutData *current_layout = nullptr;
|
||||
LayoutType current_layout_type = LAYOUT_TYPE_MAIN;
|
||||
void parseLayout(FS &fs, LayoutType layoutType);
|
||||
uint8_t getLayerMask(Keyboard::Modifiers modifiers) const;
|
||||
|
||||
public:
|
||||
CardputerLayout();
|
||||
void begin(FS &fs);
|
||||
|
||||
/// @brief Tries to find a matching layout utf-8 character
|
||||
/// @param event Keyboard event
|
||||
/// @param force_default Ignores custom layout and uses the default one
|
||||
/// @return Utf-8 character or empty string. NULL is never returned.
|
||||
const char *lookup(Keyboard::Event &event, bool force_default = false);
|
||||
|
||||
inline const char *lookupDefault(Keyboard::Event &event) { return lookup(event, true); }
|
||||
|
||||
void switchLayout();
|
||||
inline Keyboard::Layout::LayoutType getCurrentLayoutType() const { return current_layout_type; }
|
||||
inline bool hasAlternateLayout() const { return alternate_layout; }
|
||||
inline const char *getMainLayoutCode() const { return main_layout ? main_layout->language_code : "??"; }
|
||||
inline const char *getAlternateLayoutCode() const {
|
||||
return alternate_layout ? alternate_layout->language_code : "??";
|
||||
}
|
||||
inline const char *getCurrentCode() const {
|
||||
return getCurrentLayoutType() == LAYOUT_TYPE_MAIN ? getMainLayoutCode() : getAlternateLayoutCode();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace Layout
|
||||
|
||||
} // namespace Keyboard
|
||||
|
||||
using Keyboard::Layout::CardputerLayout;
|
||||
@@ -0,0 +1,130 @@
|
||||
#include "CardputerSpeaker.h"
|
||||
|
||||
#include "globals.h"
|
||||
|
||||
#include <Wire.h>
|
||||
#include <driver/i2s.h> // because i2s_std.h not exists for some reason
|
||||
|
||||
void CardputerSpeaker::initI2C() {
|
||||
// From M5Unified
|
||||
writeReg(0x00, 0x80); // 0x00 RESET/ CSM POWER ON
|
||||
writeReg(0x01, 0xB5); // 0x01 CLOCK_MANAGER/ MCLK=BCLK
|
||||
writeReg(0x02, 0x18); // 0x02 CLOCK_MANAGER/ MULT_PRE=3
|
||||
writeReg(0x0D, 0x01); // 0x0D SYSTEM/ Power up analog circuitry
|
||||
writeReg(0x12, 0x00); // 0x12 SYSTEM/ power-up DAC - NOT default
|
||||
writeReg(0x13, 0x10); // 0x13 SYSTEM/ Enable output to HP drive - NOT default
|
||||
writeReg(0x32, 0xBF); // 0x32 DAC/ DAC volume (0xBF == ±0 dB )
|
||||
writeReg(0x37, 0x08); // 0x37 DAC/ Bypass DAC equalizer - NOT default
|
||||
|
||||
// Codec is not ready immediately, it need some time to stabilize
|
||||
busy_time = millis() + AUDIO_WARMUP_MS;
|
||||
i2c_initialized = true;
|
||||
}
|
||||
|
||||
void CardputerSpeaker::initI2S() {
|
||||
i2s_config_t i2s_config = { .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
|
||||
.sample_rate = AUDIO_SAMPLE_RATE,
|
||||
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
|
||||
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
|
||||
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
|
||||
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
|
||||
.dma_buf_count = 4,
|
||||
.dma_buf_len = AUDIO_BUFFER_SIZE,
|
||||
.use_apll = false,
|
||||
.tx_desc_auto_clear = true,
|
||||
.fixed_mclk = 0 };
|
||||
|
||||
i2s_pin_config_t pin_config = {
|
||||
.bck_io_num = PIN_AUDIO_SCLK,
|
||||
.ws_io_num = PIN_AUDIO_LRCK,
|
||||
.data_out_num = PIN_AUDIO_DSDIN,
|
||||
.data_in_num = I2S_PIN_NO_CHANGE // Mic is not needed
|
||||
};
|
||||
|
||||
i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
|
||||
i2s_set_pin(I2S_NUM_0, &pin_config);
|
||||
|
||||
i2s_initialized = true;
|
||||
}
|
||||
|
||||
bool CardputerSpeaker::writeReg(uint8_t reg, uint8_t val) {
|
||||
Wire1.beginTransmission(AUDIO_I2C_ADDRESS);
|
||||
Wire1.write(reg);
|
||||
Wire1.write(val);
|
||||
return Wire1.endTransmission() == 0;
|
||||
}
|
||||
|
||||
void CardputerSpeaker::begin() {
|
||||
if (!i2s_initialized) {
|
||||
initI2S();
|
||||
}
|
||||
|
||||
if (!i2c_initialized) {
|
||||
initI2C();
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerSpeaker::playTone(int32_t frequency_hz, uint32_t duration_ms, float volume) {
|
||||
begin();
|
||||
|
||||
static double current_phase = 0.0;
|
||||
|
||||
const double phase_increment = 2.0 * M_PI * frequency_hz / AUDIO_SAMPLE_RATE;
|
||||
const int16_t target_amplitude = static_cast<int16_t>(volume * AUDIO_MAX_SAFE_VOLUME);
|
||||
|
||||
uint64_t total_samples_to_play = (uint64_t)AUDIO_SAMPLE_RATE * duration_ms / 1000ULL;
|
||||
uint64_t samples_played = 0;
|
||||
|
||||
int16_t audio_buffer[AUDIO_BUFFER_SIZE];
|
||||
size_t bytes_written;
|
||||
|
||||
while (samples_played < total_samples_to_play) {
|
||||
uint64_t remaining_samples = total_samples_to_play - samples_played;
|
||||
int samples_in_chunk =
|
||||
(int)(remaining_samples < AUDIO_BUFFER_SIZE ? remaining_samples : AUDIO_BUFFER_SIZE);
|
||||
|
||||
for (int i = 0; i < samples_in_chunk; ++i) {
|
||||
audio_buffer[i] = static_cast<int16_t>(sin(current_phase) * target_amplitude);
|
||||
|
||||
current_phase += phase_increment;
|
||||
if (current_phase >= 2.0 * M_PI) {
|
||||
current_phase -= 2.0 * M_PI;
|
||||
}
|
||||
}
|
||||
|
||||
i2s_write(I2S_NUM_0, audio_buffer, samples_in_chunk * sizeof(int16_t), &bytes_written, portMAX_DELAY);
|
||||
|
||||
samples_played += samples_in_chunk;
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerSpeaker::sleep() {
|
||||
if (i2c_initialized) {
|
||||
writeReg(0x0E, 0xFF); // SYSTEM
|
||||
writeReg(0x12, 0x02); // SYSTEM
|
||||
writeReg(0x0D, 0xFA); // POWER
|
||||
i2c_initialized = false;
|
||||
}
|
||||
|
||||
if (i2s_initialized) {
|
||||
i2s_driver_uninstall(I2S_NUM_0);
|
||||
i2s_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
void CardputerSpeaker::queueTone(int32_t frequency_hz, uint32_t duration_ms, float volume) {
|
||||
begin();
|
||||
queue_tone.frequency_hz = frequency_hz;
|
||||
queue_tone.duration_ms = duration_ms;
|
||||
queue_tone.volume = volume;
|
||||
queue_tone.done = false;
|
||||
}
|
||||
|
||||
void CardputerSpeaker::processQueue() {
|
||||
if (!queue_tone.done) {
|
||||
if (millis() > busy_time) {
|
||||
queue_tone.done = true;
|
||||
playTone(queue_tone.frequency_hz, queue_tone.duration_ms, queue_tone.volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/** Basic ES8311 blocking driver */
|
||||
class CardputerSpeaker {
|
||||
private:
|
||||
struct queue_tone_t {
|
||||
int32_t frequency_hz = 0;
|
||||
uint32_t duration_ms = 0;
|
||||
float volume = 0;
|
||||
bool done = true;
|
||||
};
|
||||
|
||||
bool i2c_initialized = false;
|
||||
bool i2s_initialized = false;
|
||||
|
||||
uint32_t busy_time = 0;
|
||||
queue_tone_t queue_tone;
|
||||
|
||||
void initI2C();
|
||||
void initI2S();
|
||||
bool writeReg(uint8_t reg, uint8_t val);
|
||||
|
||||
public:
|
||||
CardputerSpeaker() {}
|
||||
|
||||
void begin();
|
||||
void playTone(int32_t frequency_hz, uint32_t duration_ms, float volume = 0.5f);
|
||||
void sleep();
|
||||
|
||||
/** Queue a tone. Will be played when processQueue() is called and the codec read. */
|
||||
void queueTone(int32_t frequency_hz, uint32_t duration_ms, float volume = 0.5f);
|
||||
void processQueue();
|
||||
/** True while a queued tone has not been played yet. */
|
||||
bool isBusy() const { return !queue_tone.done; }
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
; M5Stack Cardputer-Adv (StampS3A, ESP32-S3, 8 MB) + Cap LoRa-1262 (SX1262).
|
||||
; Pin map: docs.m5stack.com/en/cap/Cap_LoRa-1262 (EXT 2.54-14P header).
|
||||
; Internal I2C (Wire1, G8/G9): TCA8418 keyboard, ES8311 codec and the Cap's
|
||||
; PI4IOE5V6408 antenna switch. Wire is the Grove port (G2/G1).
|
||||
; microSD (CS G12) shares the LoRa SPI bus.
|
||||
; Companion builds use the keyboard/display UI adapted from
|
||||
; MultiMote/meshcore-cardputer-adv (MIT); repeater/room builds are headless.
|
||||
; TCXO 1.8 V verified on hardware (radio init + RX/TX on 910.525 MHz).
|
||||
[M5Stack_Cardputer_Adv]
|
||||
extends = esp32_base
|
||||
board = m5stack-stamps3
|
||||
board_check = true
|
||||
board_build.mcu = esp32s3
|
||||
board_build.partitions = default_8MB.csv
|
||||
extra_scripts =
|
||||
${esp32_base.extra_scripts}
|
||||
pre:variants/m5stack_cardputer_adv/win_long_cmdline.py
|
||||
build_flags = ${esp32_base.build_flags}
|
||||
-I variants/m5stack_cardputer_adv
|
||||
-I src/helpers/sensors
|
||||
-D M5STACK_CARDPUTER_ADV
|
||||
; StampS3 board JSON sets USB_MODE=1 but not CDC_ON_BOOT; without this the
|
||||
; console/CLI is on UART0 pins instead of the USB-C port.
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
-D PIN_VBAT_READ=10
|
||||
-D PIN_BOARD_SDA=2
|
||||
-D PIN_BOARD_SCL=1
|
||||
-D PIN_SD_CS=12
|
||||
-D P_LORA_NSS=5
|
||||
-D P_LORA_SCLK=40
|
||||
-D P_LORA_MISO=39
|
||||
-D P_LORA_MOSI=14
|
||||
-D P_LORA_RESET=3
|
||||
-D P_LORA_BUSY=6
|
||||
-D P_LORA_DIO_1=4
|
||||
-D PIN_GPS_RX=13
|
||||
-D PIN_GPS_TX=15
|
||||
-D GPS_BAUD_RATE=115200
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=true
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=1.8
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D USE_SX1262
|
||||
-D MC_SX1262_SPI_HZ=8000000
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=CustomSX1262Wrapper
|
||||
-D LORA_TX_POWER=22
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<helpers/sensors/EnvironmentSensorManager.cpp>
|
||||
+<../variants/m5stack_cardputer_adv/target.cpp>
|
||||
lib_deps =
|
||||
${esp32_base.lib_deps}
|
||||
|
||||
; Keyboard + display UI on top of the Cascade companion (CLI stays on USB).
|
||||
[M5Stack_Cardputer_Adv_ui]
|
||||
extends = M5Stack_Cardputer_Adv
|
||||
build_flags =
|
||||
${M5Stack_Cardputer_Adv.build_flags}
|
||||
-I variants/m5stack_cardputer_adv/companion_radio
|
||||
-I variants/m5stack_cardputer_adv/companion_radio/ui-cardputer
|
||||
-I examples/companion_radio
|
||||
-D CARDPUTER_ADV_UI=1
|
||||
-D ENV_INCLUDE_GPS=1
|
||||
-D AUTO_SHUTDOWN_MILLIVOLTS=3400
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D OFFLINE_QUEUE_SIZE=256
|
||||
-D ENABLE_USB_INTERFACE
|
||||
build_src_filter = ${M5Stack_Cardputer_Adv.build_src_filter}
|
||||
-<helpers/sensors/EnvironmentSensorManager.cpp>
|
||||
+<helpers/esp32/*.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
+<../variants/m5stack_cardputer_adv/*.cpp>
|
||||
+<../variants/m5stack_cardputer_adv/hw>
|
||||
+<../variants/m5stack_cardputer_adv/companion_radio>
|
||||
lib_deps =
|
||||
${M5Stack_Cardputer_Adv.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
lovyan03/LovyanGFX @ ^1.2.24
|
||||
adafruit/Adafruit TCA8418 @ ^1.0.2
|
||||
stevemarple/MicroNMEA @ ^2.0.6
|
||||
|
||||
[env:M5Stack_Cardputer_Adv_repeater]
|
||||
extends = M5Stack_Cardputer_Adv
|
||||
build_src_filter = ${M5Stack_Cardputer_Adv.build_src_filter}
|
||||
+<../examples/simple_repeater/*.cpp>
|
||||
build_flags =
|
||||
${M5Stack_Cardputer_Adv.build_flags}
|
||||
-D ADVERT_NAME='"Cardputer Repeater"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D MAX_NEIGHBOURS=50
|
||||
lib_deps =
|
||||
${M5Stack_Cardputer_Adv.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
|
||||
[env:M5Stack_Cardputer_Adv_room_server]
|
||||
extends = M5Stack_Cardputer_Adv
|
||||
build_src_filter = ${M5Stack_Cardputer_Adv.build_src_filter}
|
||||
+<../examples/simple_room_server>
|
||||
build_flags =
|
||||
${M5Stack_Cardputer_Adv.build_flags}
|
||||
-D ADVERT_NAME='"Cardputer Room"'
|
||||
-D ADVERT_LAT=0.0
|
||||
-D ADVERT_LON=0.0
|
||||
-D ADMIN_PASSWORD='"password"'
|
||||
-D ROOM_PASSWORD='"hello"'
|
||||
lib_deps =
|
||||
${M5Stack_Cardputer_Adv.lib_deps}
|
||||
${esp32_ota.lib_deps}
|
||||
|
||||
[env:M5Stack_Cardputer_Adv_companion_radio_usb]
|
||||
extends = M5Stack_Cardputer_Adv_ui
|
||||
|
||||
[env:M5Stack_Cardputer_Adv_companion_radio_ble]
|
||||
extends = M5Stack_Cardputer_Adv_ui
|
||||
build_flags =
|
||||
${M5Stack_Cardputer_Adv_ui.build_flags}
|
||||
-D BLE_PIN_CODE=123456
|
||||
@@ -0,0 +1,41 @@
|
||||
#include <Arduino.h>
|
||||
#include "target.h"
|
||||
#include <helpers/radiolib/ESP32BufferedRadioHal.h>
|
||||
|
||||
CardputerAdvBoard board;
|
||||
|
||||
// FSPI (SPI2): the display owns SPI3 (HSPI, the SPIClass default on S3).
|
||||
SPIClass lora_spi(FSPI);
|
||||
static ESP32BufferedRadioHal radio_hal(lora_spi);
|
||||
RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY);
|
||||
|
||||
WRAPPER_CLASS radio_driver(radio, board);
|
||||
|
||||
ESP32RTCClock fallback_clock;
|
||||
AutoDiscoverRTCClock rtc_clock(fallback_clock);
|
||||
|
||||
#if CARDPUTER_ADV_UI
|
||||
#include <helpers/sensors/MicroNMEALocationProvider.h>
|
||||
static MicroNMEALocationProvider nmea(Serial1, &rtc_clock);
|
||||
CardputerSensorManager sensors(nmea);
|
||||
CardputerDisplay display;
|
||||
#else
|
||||
EnvironmentSensorManager sensors;
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
fallback_clock.begin();
|
||||
rtc_clock.begin(Wire);
|
||||
#ifdef PIN_SD_CS
|
||||
// Keep the SD card deselected while the radio owns the shared bus.
|
||||
pinMode(PIN_SD_CS, OUTPUT);
|
||||
digitalWrite(PIN_SD_CS, HIGH);
|
||||
#endif
|
||||
lora_spi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI);
|
||||
return radio.std_init(&lora_spi);
|
||||
}
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <SPI.h>
|
||||
#include <helpers/radiolib/RadioLibWrappers.h>
|
||||
#include <helpers/ESP32Board.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include <helpers/AutoDiscoverRTCClock.h>
|
||||
#include "CardputerAdvBoard.h"
|
||||
|
||||
#if CARDPUTER_ADV_UI
|
||||
#include "CardputerSensorManager.h"
|
||||
#include "hw/CardputerDisplay.h"
|
||||
#else
|
||||
#include <helpers/sensors/EnvironmentSensorManager.h>
|
||||
#endif
|
||||
|
||||
extern CardputerAdvBoard board;
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
extern AutoDiscoverRTCClock rtc_clock;
|
||||
// The microSD slot shares SCK/MISO/MOSI with the Cap LoRa-1262.
|
||||
extern SPIClass lora_spi;
|
||||
|
||||
#if CARDPUTER_ADV_UI
|
||||
extern CardputerSensorManager sensors;
|
||||
extern CardputerDisplay display;
|
||||
#else
|
||||
extern EnvironmentSensorManager sensors;
|
||||
#endif
|
||||
|
||||
bool radio_init();
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a LovyanGFX smooth (VLW) font header from a TrueType font.
|
||||
|
||||
Produces the same Unicode coverage as the original DejaVuSans_11 UI font
|
||||
(Basic Latin, Latin-1, Latin Extended-A, Cyrillic, currency symbols,
|
||||
arrows) at a chosen pixel size, as a PROGMEM byte array.
|
||||
|
||||
python gen_vlw_font.py DejaVuSans.ttf 16 ../companion_radio/ui-cardputer/font_16.h
|
||||
|
||||
DejaVuSans.ttf ships with matplotlib (mpl-data/fonts/ttf) and most Linux
|
||||
distributions. Requires Pillow and fontTools.
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
RANGES = [
|
||||
(0x20, 0x7E), # Basic Latin
|
||||
(0xA0, 0xFF), # Latin-1 Supplement
|
||||
(0x100, 0x17F), # Latin Extended-A
|
||||
(0x400, 0x4FF), # Cyrillic
|
||||
(0x20A0, 0x20CF), # Currency Symbols
|
||||
(0x2190, 0x21FF), # Arrows
|
||||
]
|
||||
CONTROL = (0x09, 0x0A, 0x0D) # zero-width placeholders, as in the original
|
||||
|
||||
|
||||
def glyph(font, cp):
|
||||
ch = chr(cp)
|
||||
advance = round(font.getlength(ch))
|
||||
left, top, right, bottom = font.getbbox(ch, anchor="ls")
|
||||
w, h = right - left, bottom - top
|
||||
if w <= 0 or h <= 0:
|
||||
return (cp, 0, 0, advance, 0, 0), b""
|
||||
img = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(img).text((-left, -top), ch, font=font, fill=255, anchor="ls")
|
||||
# (unicode, height, width, xAdvance, dY above baseline, dX)
|
||||
return (cp, h, w, advance, -top, left), img.tobytes()
|
||||
|
||||
|
||||
def build(ttf, px):
|
||||
font = ImageFont.truetype(ttf, px)
|
||||
cmap = TTFont(ttf).getBestCmap()
|
||||
records, bitmaps = [], []
|
||||
for cp in CONTROL:
|
||||
records.append((cp, 1, 1, 0, 2 * px, -px))
|
||||
bitmaps.append(b"\x00")
|
||||
for lo, hi in RANGES:
|
||||
for cp in range(lo, hi + 1):
|
||||
if cp not in cmap:
|
||||
continue
|
||||
rec, bmp = glyph(font, cp)
|
||||
records.append(rec)
|
||||
bitmaps.append(bmp)
|
||||
|
||||
ascent = glyph(font, ord("d"))[0][4]
|
||||
d_p = glyph(font, ord("p"))[0]
|
||||
descent = d_p[1] - d_p[4]
|
||||
|
||||
out = bytearray(struct.pack(">6i", len(records), 11, px, 0, ascent, descent))
|
||||
for rec in records:
|
||||
out += struct.pack(">7i", *rec, 0)
|
||||
for bmp in bitmaps:
|
||||
out += bmp
|
||||
name = (font.getname()[0] or "DejaVu Sans").encode()
|
||||
out += struct.pack(">i", len(name)) + name + b"\x00"
|
||||
return out, len(records)
|
||||
|
||||
|
||||
def main():
|
||||
ttf, px, path = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
||||
data, count = build(ttf, px)
|
||||
lines = [
|
||||
"#pragma once",
|
||||
"",
|
||||
f"// DejaVu Sans {px}px, {count} glyphs, generated by tools/gen_vlw_font.py",
|
||||
"",
|
||||
f"static const uint8_t DejaVuSans_{px}[] PROGMEM = {{",
|
||||
]
|
||||
for i in range(0, len(data), 16):
|
||||
lines.append(" " + ", ".join(f"0x{b:02x}" for b in data[i:i + 16]) + ",")
|
||||
lines.append("};")
|
||||
with open(path, "w", newline="\n") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
print(f"{path}: {count} glyphs, {len(data)} bytes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "globals.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <helpers/ContactInfo.h>
|
||||
|
||||
|
||||
enum class SoundType {
|
||||
NewMessage,
|
||||
Keyboard,
|
||||
DiscoveryResult,
|
||||
MessageAck,
|
||||
};
|
||||
|
||||
enum MessageSendState {
|
||||
MESSAGE_SENDING,
|
||||
MESSAGE_DELIVERED,
|
||||
MESSAGE_FAILED,
|
||||
};
|
||||
|
||||
//** Used to store last sent direct message for resending */
|
||||
struct LastSentMessage {
|
||||
ContactInfo recipient;
|
||||
uint32_t timestamp = 0;
|
||||
uint8_t attempt = 0;
|
||||
uint8_t total_attempts = 0;
|
||||
uint32_t expected_ack;
|
||||
char text[MAX_MESSAGE_LENGTH + 1] = { 0 };
|
||||
MessageSendState state = MESSAGE_DELIVERED;
|
||||
bool need_direct = false;
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
# Windows caps a process command line at 32,767 characters, and the GCC
|
||||
# driver hands cc1plus the fully expanded argument list. ESP32 Arduino
|
||||
# compiles pass ~200 framework include paths; with the Cardputer UI libraries
|
||||
# the command no longer fits ("CreateProcess: No such file or directory").
|
||||
# Shorten it the way Arduino-ESP32's own platform.txt does: name the shared
|
||||
# framework include root once with -iprefix and add each directory under it
|
||||
# with -iwithprefixbefore (same search position as -I, order preserved).
|
||||
Import("env")
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
if sys.platform == "win32":
|
||||
PREFIX_DEPTH = 3 # e.g. <packages>/framework-arduinoespressif32-libs/esp32s3/include
|
||||
|
||||
def _prefix_root(packages_dir, path):
|
||||
rel = os.path.relpath(path, packages_dir)
|
||||
if rel.startswith(".."):
|
||||
return None
|
||||
parts = rel.replace("\\", "/").split("/")
|
||||
if len(parts) <= PREFIX_DEPTH:
|
||||
return None
|
||||
return os.path.join(packages_dir, *parts[:PREFIX_DEPTH])
|
||||
|
||||
def _short_incflags(env):
|
||||
packages_dir = os.path.normcase(os.path.abspath(env.subst("$PROJECT_PACKAGES_DIR")))
|
||||
paths = []
|
||||
for entry in env.Flatten(env.get("CPPPATH", [])):
|
||||
path = env.subst(str(entry)) if not hasattr(entry, "abspath") else entry.abspath
|
||||
if path:
|
||||
paths.append(path)
|
||||
|
||||
roots = Counter()
|
||||
for path in paths:
|
||||
root = _prefix_root(packages_dir, os.path.normcase(os.path.abspath(path)))
|
||||
if root:
|
||||
roots[root] += 1
|
||||
if not roots:
|
||||
return ["-I" + p for p in paths]
|
||||
prefix = roots.most_common(1)[0][0]
|
||||
|
||||
flags = ["-iprefix", prefix.replace("\\", "/") + "/"]
|
||||
for path in paths:
|
||||
full = os.path.normcase(os.path.abspath(path))
|
||||
if full.startswith(prefix + os.sep):
|
||||
flags += ["-iwithprefixbefore", full[len(prefix) + 1:].replace("\\", "/")]
|
||||
else:
|
||||
flags.append("-I" + path)
|
||||
return flags
|
||||
|
||||
env["_cardputer_short_incflags"] = _short_incflags
|
||||
env["_CPPINCFLAGS"] = "$( ${_cardputer_short_incflags(__env__)} $)"
|
||||
Reference in new issue
Block a user