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:
mjgodatandClaude Opus 5.5 committed 2026-10-27 16:24:43 -07:00
1 parent 3ec3a07236
commit 5681898ad4
60 files changed
+25938 -3

No files matched your search

+4
View File
@@ -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
+42 -3
View File
@@ -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.
+79
View File
@@ -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; }
};
@@ -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;
};
@@ -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); }
};
@@ -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;
}
@@ -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();
};
@@ -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;
}
@@ -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;
};
@@ -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;
}
@@ -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;
};
+73
View File
@@ -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
+43
View File
@@ -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
+41
View File
@@ -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);
}
+33
View File
@@ -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()
+32
View File
@@ -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__)} $)"