Fix Cardputer history, notifications and hardware lifecycle

This commit is contained in:
mikecarper committed 2026-10-27 23:45:06 -07:00
1 parent 9a2211e85a
commit 1e22f6326d
36 files changed
+1558 -218

No files matched your search

+2
View File
@@ -403,8 +403,10 @@ jobs:
python3 -B test/test_cardputer_ui_lifecycle.py -v
python3 -B test/test_cardputer_mesh_storage.py -v
python3 -B test/test_cardputer_messaging.py -v
python3 -B test/test_cardputer_channel_identity.py -v
python3 -B test/test_cardputer_delivery.py -v
python3 -B test/test_cardputer_gps.py -v
python3 -B test/test_cardputer_hardware.py -v
python3 -B test/test_cardputer_runtime_memory.py -v
python3 -B test/test_cardputer_license.py -v
python3 -B test/test_protocol_parser_bounds.py -v
+75 -7
View File
@@ -2344,7 +2344,7 @@ bool MyMesh::queueChannelText(uint8_t channel_idx, const uint8_t secret[32], flo
if (!queued) return false;
#if CARDPUTER_ADV_UI
onUiChannelMessage(secret, text);
onUiChannelMessage(channel_idx, text);
#endif
#if COMPANION_FEATURE_NOTIFICATIONS
const bool notification_managed = notify && _notifications.message(nullptr, secret, _serial && _serial->isConnected(), millis());
@@ -6703,6 +6703,9 @@ void MyMesh::handleCmdFrame(size_t len) {
#if MESH_ENABLE_MESHTEXT_CHAT
if (success && isMeshtextChannel(channel)) {
if (sendMeshtextText(channel, text, len - i)) {
#if CARDPUTER_ADV_UI
onUiChannelMessageSent(channel_idx, text, len - i);
#endif
#if UI_POCKET_GUI
if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i);
#endif
@@ -6715,6 +6718,13 @@ void MyMesh::handleCmdFrame(size_t len) {
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) {
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
} else if (success && sendGroupMessage(msg_timestamp, channel.channel, _prefs.node_name, text, len - i)) {
#if CARDPUTER_ADV_UI
// Native group packets include "<node name>: "; BaseChatMesh limits
// the text to the remaining payload. History must match that packet.
const size_t available = MAX_TEXT_LEN - (strlen(_prefs.node_name) + 2);
const size_t sent_length = len - i < available ? len - i : available;
onUiChannelMessageSent(channel_idx, text, sent_length);
#endif
#if UI_POCKET_GUI
if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i);
#endif
@@ -9551,19 +9561,45 @@ void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size,
}
#if CARDPUTER_ADV_UI
int MyMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt,
const char* text, uint32_t& expected_ack, uint32_t& est_timeout,
uint8_t* packet_hash, const uint8_t* replace_retry_key,
const uint8_t* message_retry_key, uint32_t delay_millis) {
const int result = BaseChatMesh::sendMessage(recipient, timestamp, attempt, text,
expected_ack, est_timeout, packet_hash, replace_retry_key, message_retry_key, delay_millis);
if (result != MSG_SEND_FAILED && attempt == 0) onUiContactMessageSent(recipient, text);
return result;
}
void MyMesh::syncUiNotificationSettings() {
if (!_ui) return;
_ui->setHasConnection(_serial && _serial->isConnected());
#if COMPANION_FEATURE_NOTIFICATIONS
const auto& notifications = _notifications.settings();
_ui->setNotificationOutputs(notifications.enabled ? notifications.outputs : 31);
_ui->setNotificationLedOwnership(_notifications.ownsLed());
#endif
}
bool MyMesh::sendUiChannelMessage(int channel_idx, uint32_t timestamp,
const char* text, size_t length) {
ChannelDetails channel;
if (!text || !length || length != strlen(text)
|| !getChannel(channel_idx, channel) || !channel.name[0]) return false;
#if MESH_ENABLE_MESHTEXT_CHAT
if (isMeshtextChannel(channel)) return sendMeshtextText(channel, text, length);
if (isMeshtextChannel(channel)) {
if (!sendMeshtextText(channel, text, length)) return false;
onUiChannelMessageSent(channel_idx, text, length);
return true;
}
#endif
if (isAliasRadio2Defined()
&& parseCompanionChannelAlias(channel.name).kind == CompanionChannelKind::Meshtext) return false;
const size_t prefix_length = strlen(_prefs.node_name) + 2;
if (prefix_length >= MAX_TEXT_LEN || length > MAX_TEXT_LEN - prefix_length) return false;
return sendGroupMessage(timestamp, channel.channel, _prefs.node_name, text, length);
if (!sendGroupMessage(timestamp, channel.channel, _prefs.node_name, text, length)) return false;
onUiChannelMessageSent(channel_idx, text, length);
return true;
}
#endif
@@ -9940,10 +9976,17 @@ void MyMesh::handleTerminalDisplayCommand(const char* arguments) {
}
void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel,
const char* text) {
const char* text
#if CARDPUTER_ADV_UI
, uint8_t channel_idx
#endif
) {
#if MESH_ENABLE_MESHTEXT_CHAT
if (isMeshtextChannel(channel)) {
if (text && sendMeshtextText(channel, text, strlen(text))) {
#if CARDPUTER_ADV_UI
onUiChannelMessageSent(channel_idx, text, strlen(text));
#endif
terminalOutput().printf(" Queued to %s (Meshtext).\r\n", channel.name);
} else {
terminalOutput().printf(" ERROR: %s; use get mt.status\r\n", _mt_status);
@@ -9968,6 +10011,9 @@ void MyMesh::sendTerminalChannelMessage(ChannelDetails& channel,
} else if (sendGroupMessage(getRTCClock()->getCurrentTimeUnique(),
channel.channel, _prefs.node_name, text,
text_len)) {
#if CARDPUTER_ADV_UI
onUiChannelMessageSent(channel_idx, text, text_len);
#endif
terminalOutput().printf(" Sent to %s.\r\n", channel.name);
} else {
terminalOutput().print(" ERROR: unable to send\r\n");
@@ -10432,16 +10478,25 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt,
ChannelDetails channel;
bool found = false;
size_t requested_index = 0;
#if CARDPUTER_ADV_UI
uint8_t selected_channel_idx = 0;
#endif
if (mesh::cli::parseTerminalChannelIndex(
channel_message, MAX_GROUP_CHANNELS, requested_index)) {
found = getChannel((int)requested_index, channel)
&& channel.name[0] != 0;
#if CARDPUTER_ADV_UI
selected_channel_idx = static_cast<uint8_t>(requested_index);
#endif
} else {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
if (getChannel(i, channel) && channel.name[0] != 0
&& mesh::cli::terminalChannelNameMatches(channel_message,
channel.name)) {
found = true;
#if CARDPUTER_ADV_UI
selected_channel_idx = static_cast<uint8_t>(i);
#endif
break;
}
}
@@ -10450,7 +10505,11 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt,
if (!found) {
terminalOutput().print(" ERROR: channel not found (use 'channels')\r\n");
} else {
sendTerminalChannelMessage(channel, channel_message.text);
sendTerminalChannelMessage(channel, channel_message.text
#if CARDPUTER_ADV_UI
, selected_channel_idx
#endif
);
}
} else if (strncmp(command, "send ", 5) == 0) {
ContactInfo* recipient = getTerminalRecipient();
@@ -10488,7 +10547,11 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt,
if (!getChannel(0, channel) || channel.name[0] == 0) {
terminalOutput().print(" ERROR: Public channel is unavailable\r\n");
} else {
sendTerminalChannelMessage(channel, text);
sendTerminalChannelMessage(channel, text
#if CARDPUTER_ADV_UI
, 0
#endif
);
}
} else if (strcmp(command, "list") == 0 || strncmp(command, "list ", 5) == 0) {
int count = command[4] == ' ' ? atoi(command + 5) : 0;
@@ -11752,6 +11815,11 @@ void MyMesh::checkSerialInterface() {
}
void MyMesh::loop() {
#if CARDPUTER_ADV_UI
// Apply saved output masks before the first received packet, not only at
// the end of the loop or after a later settings change.
syncUiNotificationSettings();
#endif
#if COMPANION_FEATURE_DIRECT_OTA_ADMIN
if (_ota_direct_contact.type && !_ota_direct_admin.active(_ms->getMillis())) closeDirectOtaAdmin();
#endif
@@ -11890,7 +11958,7 @@ void MyMesh::loop() {
}
#endif
#ifdef DISPLAY_CLASS
#if defined(DISPLAY_CLASS) || CARDPUTER_ADV_UI
if (_ui) {
_ui->setHasConnection(_serial->isConnected());
#if COMPANION_FEATURE_NOTIFICATIONS
+13 -2
View File
@@ -393,7 +393,10 @@ protected:
// Run after the shared duplicate/retention checks, including room posts and
// Meshtext. Variant history must not bypass the Companion delivery policy.
virtual void onUiContactMessage(const ContactInfo&, const char*) {}
virtual void onUiChannelMessage(const uint8_t[32], const char*) {}
virtual void onUiChannelMessage(uint8_t, const char*) {}
virtual void onUiContactMessageSent(const ContactInfo&, const char*) {}
virtual void onUiChannelMessageSent(uint8_t, const char*, size_t) {}
void syncUiNotificationSettings();
#endif
#if COMPANION_FEATURE_TEMP_RADIO
bool isTempRadioActive() const override {
@@ -618,6 +621,10 @@ public:
#if CARDPUTER_ADV_UI
uint16_t getPrimaryRadioPreamble() const { return _radio_profiles.primaryPreamble(); }
bool queueUiContactWrite(const ContactInfo& contact) { return scheduleContactWrite(contact); }
int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt,
const char* text, uint32_t& expected_ack, uint32_t& est_timeout,
uint8_t* packet_hash = nullptr, const uint8_t* replace_retry_key = nullptr,
const uint8_t* message_retry_key = nullptr, uint32_t delay_millis = 0);
bool sendUiChannelMessage(int channel_idx, uint32_t timestamp,
const char* text, size_t length);
#endif
@@ -769,7 +776,11 @@ private:
void handleTerminalPath(ContactInfo& recipient, const char* path_spec);
void importTerminalCard(char* command);
void listTerminalChannels();
void sendTerminalChannelMessage(ChannelDetails& channel, const char* text);
void sendTerminalChannelMessage(ChannelDetails& channel, const char* text
#if CARDPUTER_ADV_UI
, uint8_t channel_idx
#endif
);
void handleTerminalDisplayCommand(const char* arguments);
void printTerminalSendStatus(const char* operation,
const ContactInfo& recipient, int result,
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Execute channel IDs, accepted sent history, and visible-chat unread policy."""
from pathlib import Path
import unittest
from cpp_source import body
import test_cardputer_mesh_storage as storage_tests
ROOT = Path(__file__).resolve().parents[1]
VARIANT = ROOT / "variants/m5stack_cardputer_adv"
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <openssl/sha.h>
#include "RingBuffer.h"
#include "UnreadCounter.h"
#define PUB_KEY_SIZE 32
namespace mesh {
struct Utils {
static void sha256(uint8_t* out, size_t count, const uint8_t* data, int length) {
assert(count == 32); SHA256(data, length, out);
}
};
}
struct ContactInfo { struct { uint8_t pub_key[32] = {}; } id; };
struct ChannelDetails { struct { uint8_t secret[32] = {}; } channel; char name[32] = {}; };
struct HistoryMessage { char text[MAX_MESSAGE_LENGTH] = {}; bool out = false; };
using ChatHistory = RingBuffer<HistoryMessage, CHAT_HISTORY_RINGBUF_SIZE>;
struct MainScreen;
struct UI {
MainScreen* screen;
bool visible = false;
bool isChatVisible() const { return visible; }
void onChannelMessageRecv(uint8_t, const uint8_t[32], const char*);
void onChannelMessageSent(uint8_t, const uint8_t[32], const char*);
void onContactMessageRecv(const ContactInfo&, const char*);
void onContactMessageSent(const ContactInfo&, const char*);
};
struct ChatPage {
MainScreen* _p;
int current_channel_idx = -1, current_contact_idx = -1;
uint8_t current_channel_history_id[32] = {};
ContactInfo current_contact;
ChatHistory chat_history;
int chat_history_offset = 0;
std::string last_sent_message;
bool onChannelMessageRecv(uint8_t, const uint8_t[32], const char*);
bool onContactMessageRecv(const ContactInfo&, const char*);
void onChannelMessageSent(uint8_t, const uint8_t[32], const char*);
void onContactMessageSent(const ContactInfo&, const char*);
void markRead(UnreadCounter&) const;
};
struct MainScreen {
UI* _task;
ChatPage* chat_page;
UnreadCounter unread;
UI* getUiTask() { return _task; }
void onChannelMessageRecv(uint8_t, const uint8_t[32], const char*);
void onContactMessageRecv(const ContactInfo&, const char*);
void onChannelMessageSent(uint8_t, const uint8_t[32], const char*);
void onContactMessageSent(const ContactInfo&, const char*);
};
void UI::onChannelMessageRecv(uint8_t index, const uint8_t* id, const char* text) { screen->onChannelMessageRecv(index,id,text); }
void UI::onChannelMessageSent(uint8_t index, const uint8_t* id, const char* text) { screen->onChannelMessageSent(index,id,text); }
void UI::onContactMessageRecv(const ContactInfo& contact, const char* text) { screen->onContactMessageRecv(contact,text); }
void UI::onContactMessageSent(const ContactInfo& contact, const char* text) { screen->onContactMessageSent(contact,text); }
struct Stored { std::vector<uint8_t> id; std::string text; bool out, channel; };
struct Store {
std::vector<Stored> messages;
void storeMessage(const uint8_t* id, const char* text, bool out, bool channel) {
messages.push_back({std::vector<uint8_t>(id,id+32), text, out, channel});
}
};
struct CardputerMesh {
Store* _store;
UI* _ui;
ChannelDetails channels[4];
bool getChannel(int index, ChannelDetails& channel) {
if (index < 0 || index >= 4) return false;
channel = channels[index]; return true;
}
static bool channelHistoryId(const ChannelDetails&, uint8_t[32]);
bool getChannelHistoryId(int, uint8_t[32]);
void onUiChannelMessage(uint8_t, const char*);
void onUiContactMessage(const ContactInfo&, const char*);
void onUiChannelMessageSent(uint8_t, const char*, size_t);
void onUiContactMessageSent(const ContactInfo&, const char*);
};
@METHODS@
int main() {
UI ui{}; ChatPage page{}; MainScreen main{&ui,&page,{}};
ui.screen = &main; page._p = &main;
Store store; CardputerMesh node{&store,&ui,{}};
const char* labels[] = {"alpha", "r2-alpha", "mt-alpha", "mt-LongFast"};
uint8_t ids[4][32];
for (unsigned i=0; i<4; ++i) {
strcpy(node.channels[i].name, labels[i]);
memset(node.channels[i].channel.secret, 17, 16);
assert(node.getChannelHistoryId(i, ids[i]));
for (unsigned j=0; j<i; ++j) assert(memcmp(ids[i], ids[j], 32));
}
ChannelDetails tail = node.channels[2]; tail.channel.secret[31] = 9;
uint8_t tail_id[32], padded_id[32];
assert(CardputerMesh::channelHistoryId(tail, tail_id) && memcmp(tail_id, ids[2], 32));
ChannelDetails padded = node.channels[2]; padded.name[31] = 'X';
assert(CardputerMesh::channelHistoryId(padded, padded_id) && !memcmp(padded_id, ids[2], 32));
ChannelDetails invalid{};
assert(!CardputerMesh::channelHistoryId(invalid, padded_id));
memset(invalid.name, 'x', sizeof(invalid.name));
assert(!CardputerMesh::channelHistoryId(invalid, padded_id));
page.current_channel_idx = 3; memcpy(page.current_channel_history_id, ids[3], 32);
node.onUiChannelMessage(2, "other same-key channel");
assert(page.chat_history.count() == 0 && main.unread.countChats() == 1);
assert(store.messages.back().id == std::vector<uint8_t>(ids[2], ids[2]+32));
node.onUiChannelMessage(3, "hidden selected chat");
assert(page.chat_history.count() == 1 && main.unread.countChats() == 2);
ui.visible = true;
page.markRead(main.unread);
assert(main.unread.countChats() == 1);
node.onUiChannelMessage(3, "visible selected chat");
assert(page.chat_history.count() == 2 && main.unread.countMessages() == 1);
page.current_channel_idx = -1; page.current_contact_idx = 1;
page.current_contact.id.pub_key[31] = 1;
ContactInfo other = page.current_contact; other.id.pub_key[31] = 2;
node.onUiContactMessage(other, "same six-byte prefix");
assert(main.unread.countChats() == 2);
ui.visible = false;
node.onUiContactMessage(page.current_contact, "settings overlay");
assert(main.unread.countChats() == 3);
ui.visible = true; page.markRead(main.unread);
assert(main.unread.countChats() == 2);
page.chat_history.clear(); store.messages.clear();
for (const char* origin : {"app", "USB terminal", "keyboard"}) {
node.onUiContactMessageSent(page.current_contact, origin);
}
assert(page.chat_history.count() == 3 && store.messages.size() == 3);
for (const auto& message : store.messages) assert(message.out && !message.channel);
page.current_contact_idx = -1; page.current_channel_idx = 3;
page.chat_history.clear(); store.messages.clear();
for (const char* origin : {"app", "USB terminal", "keyboard"}) {
node.onUiChannelMessageSent(3, origin, strlen(origin));
}
assert(page.chat_history.count() == 3 && store.messages.size() == 3);
for (const auto& message : store.messages) {
assert(message.out && message.channel && message.id == std::vector<uint8_t>(ids[3],ids[3]+32));
}
assert(main.unread.countChats() == 2);
char bounded[MAX_MESSAGE_LENGTH + 20]; memset(bounded, 'x', sizeof(bounded));
node.onUiChannelMessageSent(3, bounded, sizeof(bounded));
assert(store.messages.back().text.size() == MAX_MESSAGE_LENGTH);
}
'''
class CardputerChannelIdentityTests(unittest.TestCase):
def test_identity_delivery_sent_history_and_visible_unread(self):
mesh = (VARIANT / "companion_radio/CardputerMesh.cpp").read_text()
main = (VARIANT / "companion_radio/ui-cardputer/screen/MainScreen.cpp").read_text()
chat = (VARIANT / "companion_radio/ui-cardputer/screen/main/ChatPage.cpp").read_text()
methods = []
for source, signatures in (
(mesh, ("bool CardputerMesh::channelHistoryId(", "bool CardputerMesh::getChannelHistoryId(",
"void CardputerMesh::onUiChannelMessage(", "void CardputerMesh::onUiContactMessage(",
"void CardputerMesh::onUiChannelMessageSent(", "void CardputerMesh::onUiContactMessageSent(")),
(main, ("void MainScreen::onChannelMessageRecv(", "void MainScreen::onContactMessageRecv(",
"void MainScreen::onChannelMessageSent(", "void MainScreen::onContactMessageSent(")),
(chat, ("bool ChatPage::onChannelMessageRecv(", "bool ChatPage::onContactMessageRecv(",
"void ChatPage::onChannelMessageSent(", "void ChatPage::onContactMessageSent(",
"void ChatPage::markRead("))):
methods.extend(body(source, signature) for signature in signatures)
storage_tests.CardputerMeshStorageTests.run_cpp(
self, HARNESS.replace("@METHODS@", "\n".join(methods)),
flags=("-Wl,--no-as-needed", "-lcrypto"))
if __name__ == "__main__":
unittest.main()
+165 -2
View File
@@ -41,6 +41,7 @@ using std::isfinite;
#define RESP_CODE_CHANNEL_MSG_RECV_V3 0x90
#define RESP_CODE_CHANNEL_MSG_RECV 0x83
#define PUSH_CODE_MSG_WAITING 0x80
#define MSG_SEND_FAILED 0
static unsigned long millis() { return 1234; }
enum class UIEventType { contactMessage, channelMessage };
namespace mesh {
@@ -62,6 +63,11 @@ struct Serial {
};
struct UI {
int received = 0, sounds = 0;
uint8_t outputs = 31;
bool connected = false, led_owned = false;
void setHasConnection(bool value) { connected = value; }
void setNotificationOutputs(uint8_t value) { outputs = value; }
void setNotificationLedOwnership(bool value) { led_owned = value; }
void setMessageNotificationOverride(bool, bool = false) {}
void newMsg(uint8_t, const char*, const char*, int, int, const char*, int) { ++received; }
void notify(UIEventType) { ++sounds; }
@@ -103,7 +109,12 @@ struct MyMesh {
bool millisHasNowPassed(unsigned long) const { return false; }
bool addToOfflineQueue(const uint8_t*, int) { if (accept) ++offline_queue_len; return accept; }
void onUiContactMessage(const ContactInfo&, const char*) { ++contact_history; }
void onUiChannelMessage(const uint8_t[32], const char*) { ++channel_history; }
void onUiChannelMessage(uint8_t slot, const char*) { assert(slot == 9); ++channel_history; }
int channel_sent_history = 0;
size_t last_history_length = 0;
void onUiChannelMessageSent(uint8_t slot, const char*, size_t length) {
assert(slot == 9); ++channel_sent_history; last_history_length = length;
}
void makeOneKeyDMId(uint8_t* out, uint32_t, const char*) { memset(out, 7, 8); }
bool wasDeliveredOneKeyDM(const uint8_t*, const uint8_t*) const { return delivered; }
void rememberDeliveredOneKeyDM(const uint8_t*, const uint8_t*) { delivered = true; }
@@ -168,9 +179,11 @@ int main() {
#if MESH_ENABLE_MESHTEXT_CHAT
assert(m.sendUiChannelMessage(9, 101, "text", 4));
assert(m.meshtext_sends == 1 && m.meshcore_sends == 0 && m.sent_key == 71);
assert(m.channel_sent_history == 1);
m.transport_accept = false;
assert(!m.sendUiChannelMessage(9, 101, "text", 4));
assert(m.meshcore_sends == 0); // Rejection never leaks onto the other protocol.
assert(m.channel_sent_history == 1);
m.transport_accept = true;
#else
assert(!m.sendUiChannelMessage(9, 101, "text", 4));
@@ -188,6 +201,7 @@ int main() {
assert(m.sendUiChannelMessage(9, 101, "text", 4));
assert(m.sent_key == 73);
int sends = m.meshcore_sends;
const int sent_history = m.channel_sent_history;
assert(!m.sendUiChannelMessage(1, 101, "text", 4));
assert(!m.sendUiChannelMessage(9, 101, nullptr, 4));
assert(!m.sendUiChannelMessage(9, 101, "", 0));
@@ -195,6 +209,7 @@ int main() {
std::string oversized(160, 'x');
assert(!m.sendUiChannelMessage(9, 101, oversized.c_str(), oversized.size()));
assert(m.meshcore_sends == sends);
assert(m.channel_sent_history == sent_history);
#else
assert(m.channel_history == 0 && m.ui.received == 0);
#endif
@@ -213,13 +228,17 @@ class CardputerDeliveryTests(unittest.TestCase):
if cardputer:
signatures.append("bool MyMesh::sendUiChannelMessage(")
code = HARNESS.replace("@METHODS@", "\n".join(body(source, sig) for sig in signatures))
self.run_program(code, f"-DCARDPUTER_ADV_UI={int(cardputer)}",
f"-DMESH_ENABLE_MESHTEXT_CHAT={int(meshtext)}")
def run_program(self, code, *defines):
with tempfile.TemporaryDirectory(prefix="meshcore-cardputer-delivery-") as temp:
binary = Path(temp) / "delivery"
built = subprocess.run([
os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-Wno-unused-variable", "-Wno-unused-function",
"-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie",
f"-DCARDPUTER_ADV_UI={int(cardputer)}", f"-DMESH_ENABLE_MESHTEXT_CHAT={int(meshtext)}",
*defines,
"-I", str(ROOT / "src"), "-x", "c++", "-", "-o", str(binary),
], input=code, text=True, capture_output=True)
self.assertEqual(built.returncode, 0, built.stderr)
@@ -235,6 +254,150 @@ class CardputerDeliveryTests(unittest.TestCase):
def test_headless_delivery_keeps_its_existing_behavior(self):
self.run_cpp(cardputer=False)
def test_contact_origin_hooks_and_saved_notification_masks(self):
source = SOURCE.read_text()
code = r'''
#include <cassert>
#include <cstdint>
#include <cstring>
#include <string>
constexpr int MSG_SEND_FAILED=0;
struct ContactInfo { uint8_t key[32]{}; };
struct BaseChatMesh {
int result=1, sends=0;
uint8_t last_attempt=0;
int sendMessage(const ContactInfo&, uint32_t stamp, uint8_t attempt, const char* text,
uint32_t& ack, uint32_t& timeout, uint8_t* hash, const uint8_t* replace,
const uint8_t* semantic, uint32_t delay) {
assert(stamp==123 && !strcmp(text,"outgoing") && hash && replace && semantic && delay==42);
++sends; last_attempt=attempt; ack=0x12345678; timeout=5000; return result;
}
};
struct UI {
uint8_t mask=31; bool connected=false, led=false;
void setHasConnection(bool value) { connected=value; }
void setNotificationOutputs(uint8_t value) { mask=value; }
void setNotificationLedOwnership(bool value) { led=value; }
};
struct Serial { bool connected=false; bool isConnected() const { return connected; } };
struct Engine {
struct Settings { bool enabled=true; uint8_t outputs=0; } saved;
const Settings& settings() const { return saved; }
bool ownsLed() const { return true; }
};
struct MyMesh : BaseChatMesh {
UI ui; Serial serial; UI* _ui=&ui; Serial* _serial=&serial; Engine _notifications;
int stored=0; uint8_t stored_key=0;
void onUiContactMessageSent(const ContactInfo& c,const char* text) {
assert(!strcmp(text,"outgoing")); ++stored; stored_key=c.key[31];
}
int sendMessage(const ContactInfo&,uint32_t,uint8_t,const char*,uint32_t&,uint32_t&,
uint8_t*,const uint8_t*,const uint8_t*,uint32_t);
void syncUiNotificationSettings();
};
@METHODS@
int main() {
MyMesh m; ContactInfo recipient; recipient.key[31]=77;
uint32_t ack=0,timeout=0; uint8_t hash[8]{},replace[8]{},semantic[8]{};
assert(m.sendMessage(recipient,123,0,"outgoing",ack,timeout,hash,replace,semantic,42)==1);
assert(m.stored==1 && m.stored_key==77 && ack==0x12345678 && timeout==5000);
for(unsigned attempt=1;attempt<6;++attempt)
assert(m.sendMessage(recipient,123,attempt,"outgoing",ack,timeout,hash,replace,semantic,42)==1);
assert(m.stored==1 && m.sends==6); // Radio retries never duplicate local history.
m.result=MSG_SEND_FAILED;
assert(m.sendMessage(recipient,123,0,"outgoing",ack,timeout,hash,replace,semantic,42)==MSG_SEND_FAILED);
assert(m.stored==1);
for(unsigned mask=0;mask<32;++mask) {
m._notifications.saved.outputs=mask;
m.serial.connected=(mask&1);
m.syncUiNotificationSettings();
assert(m.ui.mask==mask && m.ui.connected==m.serial.connected && m.ui.led);
}
m._notifications.saved.enabled=false; m.syncUiNotificationSettings();
assert(m.ui.mask==31); // Disabled custom controller restores ordinary UI defaults.
m._serial=nullptr; m.syncUiNotificationSettings(); assert(!m.ui.connected);
m._ui=nullptr; m.syncUiNotificationSettings();
}
'''
code = code.replace("@METHODS@", "\n".join(body(source, sig) for sig in (
"int MyMesh::sendMessage(", "void MyMesh::syncUiNotificationSettings(")))
self.run_program(code, "-DCARDPUTER_ADV_UI=1", "-DCOMPANION_FEATURE_NOTIFICATIONS=1")
loop = body(source, "void MyMesh::loop()")
self.assertLess(loop.index("syncUiNotificationSettings();"), loop.index("BaseChatMesh::loop();"))
def test_actual_app_and_terminal_channel_admission_hooks(self):
source = SOURCE.read_text()
prefix = HARNESS.split("@METHODS@")[0]
prefix = prefix.replace("struct MyMesh {", r'''
struct Output { template<typename... Args> void printf(const char*,Args...) {} void print(const char*) {} };
struct Clock { uint32_t getCurrentTimeUnique() { return 123; } };
#define TXT_TYPE_PLAIN 0
#define ERR_CODE_ILLEGAL_ARG 1
#define ERR_CODE_UNSUPPORTED_CMD 2
#define ERR_CODE_BAD_STATE 3
#define ERR_CODE_NOT_FOUND 4
struct MyMesh {''')
prefix = prefix.replace(" bool sendUiChannelMessage(int, uint32_t, const char*, size_t);", r'''
bool sendUiChannelMessage(int, uint32_t, const char*, size_t);
uint8_t cmd_frame[176]{}; int ok=0,errors=0;
const char* _mt_status="test"; Output output; Clock clock;
Output& terminalOutput() { return output; }
Clock* getRTCClock() { return &clock; }
void writeOKFrame() { ++ok; }
void writeErrFrame(int) { ++errors; }
void appChannel(size_t len);
void sendTerminalChannelMessage(ChannelDetails&,const char*,uint8_t);
''')
app = body(source, "} else if (cmd_frame[0] == CMD_SEND_CHANNEL_TXT_MSG)")
app = "void MyMesh::appChannel(size_t len) " + app[app.index("{"):]
methods = app + "\n" + body(source, "void MyMesh::sendTerminalChannelMessage(")
exercise = r'''
int main() {
MyMesh m; strcpy(m.configured.name,"#mt-longfast");
m.cmd_frame[1]=TXT_TYPE_PLAIN; m.cmd_frame[2]=9;
uint32_t stamp=123; memcpy(m.cmd_frame+3,&stamp,4); memcpy(m.cmd_frame+7,"text",4);
m.appChannel(11);
assert(m.ok==1 && m.channel_sent_history==1 && m.meshtext_sends==1 && m.meshcore_sends==0);
m.transport_accept=false; m.appChannel(11);
assert(m.errors==1 && m.channel_sent_history==1);
m.transport_accept=true; strcpy(m.configured.name,"#r2-testing");
m.appChannel(11);
assert(m.ok==2 && m.channel_sent_history==2 && m.meshcore_sends==1);
assert(m.last_history_length==4);
m.transport_accept=false; m.appChannel(11);
assert(m.errors==2 && m.channel_sent_history==2);
m.transport_accept=true;
m.sendTerminalChannelMessage(m.configured,"text",9);
assert(m.channel_sent_history==3);
strcpy(m.configured.name,"#mt-longfast");
m.sendTerminalChannelMessage(m.configured,"text",9);
assert(m.channel_sent_history==4);
m.transport_accept=false;
m.sendTerminalChannelMessage(m.configured,"text",9);
assert(m.channel_sent_history==4);
// Native packets reserve room for the sender name, while Meshtext sends
// have their own framing and must retain the complete admitted text.
MyMesh long_message;
strcpy(long_message.configured.name,"#r2-testing");
memset(long_message._prefs.node_name,'N',31);
long_message._prefs.node_name[31]=0;
long_message.cmd_frame[1]=TXT_TYPE_PLAIN;
long_message.cmd_frame[2]=9;
memcpy(long_message.cmd_frame+3,&stamp,4);
memset(long_message.cmd_frame+7,'x',150);
long_message.appChannel(157);
assert(long_message.ok==1 && long_message.channel_sent_history==1);
assert(long_message.last_history_length==127);
strcpy(long_message.configured.name,"#mt-longfast");
long_message.appChannel(157);
assert(long_message.ok==2 && long_message.channel_sent_history==2);
assert(long_message.last_history_length==150);
}
'''
self.run_program(prefix + methods + exercise,
"-DCARDPUTER_ADV_UI=1", "-DMESH_ENABLE_MESHTEXT_CHAT=1")
def test_keyboard_transport_does_not_depend_on_text_terminal(self):
source = re.sub(r"^\s*#\s*include[^\n]*", "", SOURCE.read_text(), flags=re.MULTILINE)
result = subprocess.run([
+1 -1
View File
@@ -200,7 +200,7 @@ class CardputerGpsTests(unittest.TestCase):
sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
built = subprocess.run([compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", *sanitizer, "-DENV_INCLUDE_GPS=1",
"-Wno-unused-parameter", *sanitizer, "-DENV_INCLUDE_GPS=1", "-DCARDPUTER_ADV_UI=1",
"-DPIN_GPS_TX=15", "-DPIN_GPS_RX=13", "-DGPS_BAUD_RATE=115200",
"-I", str(work), "-I", str(ROOT / "src"), "-I", str(VARIANT),
"-I", str(nmea.parent), str(work / "main.cpp"), str(nmea),
+299
View File
@@ -0,0 +1,299 @@
"""Execute bounded Cardputer audio and headless GNSS/environment ownership."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_cardputer_gps import ARDUINO, MESH, RTC, CAYENNE
ROOT = Path(__file__).resolve().parents[1]
VARIANT = ROOT / "variants/m5stack_cardputer_adv"
I2S = r'''
#pragma once
#include <Arduino.h>
#include <algorithm>
#include <cassert>
#include <deque>
#include <vector>
using esp_err_t=int;
using i2s_mode_t=int;
constexpr int ESP_OK=0,ESP_ERR_TIMEOUT=1,ESP_FAIL=2;
constexpr int I2S_NUM_0=0,I2S_MODE_MASTER=1,I2S_MODE_TX=2,I2S_BITS_PER_SAMPLE_16BIT=16;
constexpr int I2S_CHANNEL_FMT_ONLY_LEFT=0,I2S_COMM_FORMAT_STAND_I2S=0,ESP_INTR_FLAG_LEVEL1=1;
constexpr int I2S_PIN_NO_CHANGE=-1;
struct i2s_config_t {
int mode,sample_rate,bits_per_sample,channel_format,communication_format,intr_alloc_flags;
int dma_buf_count,dma_buf_len;bool use_apll,tx_desc_auto_clear;int fixed_mclk;
};
struct i2s_pin_config_t {int bck_io_num,ws_io_num,data_out_num,data_in_num;};
struct WriteReply {int result;size_t limit;};
inline std::deque<WriteReply> replies;
inline std::vector<uint8_t> audio;
inline unsigned installs=0,pins=0,uninstalls=0,writes=0;
inline int install_result=ESP_OK,pin_result=ESP_OK;
inline esp_err_t i2s_driver_install(int,const i2s_config_t* cfg,int,void*) {
++installs;assert(cfg->dma_buf_count==4&&cfg->dma_buf_len==256);return install_result;
}
inline esp_err_t i2s_set_pin(int,const i2s_pin_config_t* cfg) {
++pins;assert(cfg->bck_io_num==41&&cfg->ws_io_num==43&&cfg->data_out_num==42);return pin_result;
}
inline esp_err_t i2s_driver_uninstall(int){++uninstalls;return ESP_OK;}
inline esp_err_t i2s_write(int,const void* src,size_t size,size_t* written,unsigned wait) {
++writes;assert(wait==0&&size>0&&size<=512);
WriteReply reply{ESP_OK,size};if(!replies.empty()){reply=replies.front();replies.pop_front();}
*written=std::min(size,reply.limit);
const auto* bytes=static_cast<const uint8_t*>(src);audio.insert(audio.end(),bytes,bytes+*written);
return reply.result;
}
'''
WIRE = r'''
#pragma once
#include <Arduino.h>
#include <vector>
struct TwoWire {
unsigned transactions=0,fail_at=0;uint8_t address=0;
void beginTransmission(uint8_t addr){address=addr;++transactions;}
size_t write(uint8_t){return 1;}
uint8_t endTransmission(bool=true){return fail_at==transactions?2:0;}
void begin(int=0,int=0,unsigned=100000){}
void setPins(uint8_t,uint8_t){}
void setClock(unsigned){}
void end(){}
};
inline TwoWire Wire,Wire1;
'''
SPEAKER = r'''
#include <cassert>
#include <cmath>
#include <cstring>
#include <limits>
#include <driver/i2s.h>
#include <Wire.h>
#include "hw/CardputerSpeaker.h"
static void reset(uint32_t now=100) {
now_ms=now;delay_count=0;replies.clear();audio.clear();installs=pins=uninstalls=writes=0;
install_result=pin_result=ESP_OK;Wire1=TwoWire();
}
static void poll(CardputerSpeaker& speaker) {
const auto before=writes;speaker.processQueue();assert(writes-before<=1&&delay_count==0);
}
static void finish(CardputerSpeaker& speaker) {
for(unsigned i=0;i<100&&speaker.isBusy();++i){poll(speaker);++now_ms;}
assert(!speaker.isBusy());
}
static void normalAndPartial() {
reset();CardputerSpeaker speaker;speaker.queueTone(1000,100,0.5f);
assert(speaker.isBusy()&&writes==0&&installs==1&&pins==1);
now_ms+=1099;poll(speaker);assert(writes==0);
++now_ms;replies={{ESP_OK,1},{ESP_ERR_TIMEOUT,3},{ESP_OK,0},{ESP_OK,17},{ESP_OK,511}};
finish(speaker);assert(audio.size()==3200);
for(size_t i=0;i<audio.size()/2;++i) {
int16_t value;std::memcpy(&value,audio.data()+2*i,2);
const auto expected=int16_t(std::sin(double(i)*2*M_PI*1000/16000)*10000);
assert(std::abs(int(value)-int(expected))<=2);
}
speaker.sleep();assert(uninstalls==1&&!speaker.isBusy());
}
static void replacementAndCancellation() {
reset();CardputerSpeaker speaker;speaker.playTone(1000,100);
assert(writes==0);now_ms+=1100;poll(speaker);assert(audio.size()==512);
speaker.queueTone(2000,1);const size_t boundary=audio.size();poll(speaker);
assert(audio.size()==boundary+32);now_ms+=64;poll(speaker);assert(!speaker.isBusy());
int16_t first,second;std::memcpy(&first,audio.data()+boundary,2);std::memcpy(&second,audio.data()+boundary+2,2);
assert(first==0&&second>7000); // new tone has no unsent old suffix
speaker.queueTone(3000,100);speaker.sleep();const auto before=writes;poll(speaker);
assert(!speaker.isBusy()&&writes==before&&uninstalls==1);
speaker.queueTone(3000,1);assert(installs==2);now_ms+=1100;finish(speaker);
for(float volume:{0.0f,-1.0f,std::numeric_limits<float>::quiet_NaN()}) {
speaker.queueTone(4000,20,volume);assert(!speaker.isBusy());
}
speaker.queueTone(0,20);assert(!speaker.isBusy());
speaker.queueTone(9000,20);assert(!speaker.isBusy());
speaker.queueTone(1000,0);assert(!speaker.isBusy());
}
static void failures() {
for(int fault=0;fault<4;++fault) {
reset();CardputerSpeaker speaker;
if(fault==0)install_result=ESP_FAIL;
if(fault==1)pin_result=ESP_FAIL;
if(fault==2)Wire1.fail_at=4;
speaker.queueTone(1000,100);
if(fault<3) {
assert(!speaker.isBusy()&&writes==0&&uninstalls==(fault==0?0U:1U));
} else {
now_ms+=1100;replies={{ESP_FAIL,0}};poll(speaker);
assert(!speaker.isBusy()&&uninstalls==1);
}
install_result=pin_result=ESP_OK;Wire1.fail_at=0;
speaker.queueTone(1000,1);assert(speaker.isBusy());now_ms+=1100;finish(speaker);
}
reset();CardputerSpeaker stalled;stalled.queueTone(1000,100);now_ms+=1100;
replies={{ESP_OK,0}};poll(stalled);assert(stalled.isBusy());
now_ms+=1000;replies={{ESP_ERR_TIMEOUT,0}};poll(stalled);
assert(!stalled.isBusy()&&uninstalls==1);
}
static void rollover() {
reset(UINT32_MAX-500);CardputerSpeaker speaker;speaker.queueTone(1000,1);
now_ms=598;poll(speaker);assert(writes==0);
now_ms=599;poll(speaker);assert(writes==1&&speaker.isBusy());
now_ms=662;poll(speaker);assert(speaker.isBusy());
now_ms=663;poll(speaker);assert(!speaker.isBusy());
}
int main(){normalAndPartial();replacementAndCancellation();failures();rollover();}
'''
INA260 = r'''
#pragma once
#include <Wire.h>
class Adafruit_INA260 {
public:
inline static unsigned begins=0,reads=0;
bool begin(uint8_t addr=0x41,TwoWire* wire=&Wire){++begins;return addr==0x41&&wire==&Wire;}
float readBusVoltage(){++reads;return 6500.0f;}
float readCurrent(){return 250.0f;}
float readPower(){return 1600.0f;}
};
'''
HEADLESS = r'''
#include <cassert>
#include <cstring>
#include <Adafruit_INA260.h>
#include "CardputerSensorManager.h"
#if ENV_INCLUDE_GPS
struct Provider:LocationProvider {
unsigned starts=0,stops=0,polls=0,resets=0;bool valid=false;
void begin()override{++starts;}
void stop()override{++stops;}
void reset()override{++resets;}
void loop()override{++polls;}
bool clearPositionFix()override{valid=false;return true;}
long getLatitude()override{return 47610000;}
long getLongitude()override{return -122330000;}
long getAltitude()override{return 125000;}
bool isValid()override{return valid;}
bool isEnabled()override{return true;}
long satellitesCount()override{return 8;}
long getTimestamp()override{return 0;}
void sendSentence(const char* sentence)override {
Serial1.print(!std::strcmp(sentence,"$PCAS12,65535")?"$PCAS12,65535*1E\r\n":"$PCAS10,0*1C\r\n");
}
};
#endif
void LocationProvider::sendSentence(const char*){}
static void checkExternal(CardputerSensorManager& manager) {
uint8_t channels[2]={};assert(manager.getVoltageSensorChannels(channels,2)==1&&channels[0]==2);
SensorManager::VoltageSensorReading readings[2];assert(manager.queryVoltageSensors(readings,2)==1);
assert(readings[0].valid&&readings[0].voltage==6.5f);
CayenneLPP telemetry(64);const auto before=Adafruit_INA260::reads;
assert(manager.querySensors(TELEM_PERM_ENVIRONMENT,telemetry)&&Adafruit_INA260::reads==before+1);
assert(telemetry.getSize()>0&&telemetry.gps_count==0);
assert(manager.querySensors(TELEM_PERM_BASE,telemetry)&&Adafruit_INA260::reads==before+1);
}
int main() {
now_ms=100;
#if ENV_INCLUDE_GPS
Provider provider;CardputerSensorManager manager(provider);
#else
CardputerSensorManager manager;
#endif
assert(manager.begin()&&Serial1.starts==1&&Adafruit_INA260::begins==1);
assert(Serial1.output=="$PCAS12,65535*1E\r\n"&&!manager.isGpsPowered());
assert(Serial1.rx==15&&Serial1.tx==13&&Serial1.baud==115200);
assert(manager.gpsUsesSerialUart(1)&&manager.gpsSerialTransportMayConflict(1));
assert(!manager.gpsSerialTransportCanYield(1)&&!manager.setGpsSerialTransportBlocked(1,true));
checkExternal(manager);
#if ENV_INCLUDE_GPS
assert(manager.isGPSDetected()&&manager.getNumSettings()==1);
assert(!std::strcmp(manager.getSettingName(0),"gps")&&manager.getSettingName(1)==nullptr);
assert(!std::strcmp(manager.getSettingValue(0),"0"));
assert(provider.starts==0&&provider.resets==0&&provider.polls==0);
manager.setPowerSavingEnabled(true);
for(unsigned i=0;i<4;++i){now_ms+=3600000;provider.syncTime();manager.loop();}
assert(provider.starts==0&&provider.resets==0&&provider.polls==0&&Serial1.starts==1);
assert(manager.setSettingValue("gps","1")&&manager.isGpsPowered());
assert(provider.starts==1&&provider.resets==0);
assert(manager.setSettingValue("gps_interval","2"));provider.valid=true;manager.loop();
CayenneLPP telemetry(64);assert(manager.querySensors(TELEM_PERM_LOCATION,telemetry));
assert(telemetry.gps_count==1); // one cache, one GPS field, generic query delegated exactly once
manager.setSettingValue("gps","0");assert(!manager.isGpsPowered());
const auto polls=provider.polls;now_ms+=3600000;manager.loop();assert(provider.polls==polls);
#else
assert(!manager.isGPSDetected()&&manager.getNumSettings()==0&&manager.getLocationProvider()==nullptr);
assert(manager.getSettingName(0)==nullptr&&manager.getSettingValue(0)==nullptr);
assert(!manager.setSettingValue("gps","1")&&!manager.setSettingValue("gps_interval","2"));
manager.setPowerSavingEnabled(true);now_ms+=3600000;manager.loop();
assert(!manager.isGpsPowered()&&Serial1.output.find("$PCAS10")==std::string::npos);
#endif
assert(Serial1.output.find("$PCAS12",1)!=std::string::npos);
manager.shutdownGpsTransportForDeepSleep();assert(Serial1.ends==1&&!manager.isGpsPowered());
}
'''
class CardputerHardwareTests(unittest.TestCase):
def compile_run(self, code, *, gps=None):
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
arduino = ARDUINO.replace("inline uint32_t delay_count=0;", "inline uint32_t delay_count=0;\n#define MESH_DEBUG_PRINTLN(...)\n#define POWERSAVING_DEBUG_PRINTLN(...)")
arduino = arduino.replace(" void end(){++ends;}", " explicit operator bool()const{return true;}\n void end(){++ends;}")
lpp = CAYENNE.replace(" void addTemperature", " void addCurrent(uint8_t,float){}\n void addPower(uint8_t,float){}\n void addTemperature")
with tempfile.TemporaryDirectory(prefix="cardputer-hardware-") as directory:
work = Path(directory)
(work / "driver").mkdir()
headers = {"Arduino.h": arduino, "Mesh.h": MESH, "RTClib.h": RTC, "Wire.h": WIRE,
"CayenneLPP.h": lpp, "driver/i2s.h": I2S,
"Adafruit_INA260.h": INA260, "main.cpp": code}
for name, content in headers.items():
(work / name).write_text(content, encoding="ascii")
definitions, sources = [], []
if gps is None:
sources = [VARIANT / "hw/CardputerSpeaker.cpp"]
else:
definitions = [f"-DENV_INCLUDE_GPS={int(gps)}", "-DCARDPUTER_ADV_UI=0",
"-DESP32_PLATFORM", "-DENV_INCLUDE_INA260=1", "-DPIN_GPS_TX=15",
"-DPIN_GPS_RX=13", "-DGPS_BAUD_RATE=115200"]
if not gps:
# The unchanged generic no-GPS settings methods retain a
# local index even when every conditional setting is absent.
definitions += ["-Wno-unused-variable"]
sources = [ROOT / "src/helpers/SensorManager.cpp",
ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp",
VARIANT / "CardputerSensorManager.cpp"]
binary = work / ("hardware.exe" if os.name == "nt" else "hardware")
sanitizer = [] if os.name == "nt" else ["-fsanitize=address,undefined",
"-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
built = subprocess.run([compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", *sanitizer, *definitions,
"-I", str(work), "-I", str(ROOT / "src"), "-I", str(VARIANT),
str(work / "main.cpp"), *map(str, sources), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
def test_speaker_nonblocking_partial_replacement_failure_and_wrap(self):
self.compile_run(SPEAKER)
def test_headless_without_gps_provider_preserves_environment_and_standby(self):
self.compile_run(HEADLESS, gps=False)
def test_headless_with_gps_has_one_owner_cache_and_external_sensors(self):
self.compile_run(HEADLESS, gps=True)
def test_all_roles_compile_the_fixed_manager_without_forcing_gps(self):
config = (VARIANT / "platformio.ini").read_text(encoding="ascii")
base = config.split("[M5Stack_Cardputer_Adv]", 1)[1].split("[M5Stack_Cardputer_Adv_ui]", 1)[0]
self.assertIn("+<helpers/sensors/EnvironmentSensorManager.cpp>", base)
self.assertIn("+<../variants/m5stack_cardputer_adv/CardputerSensorManager.cpp>", base)
self.assertNotIn("-D ENV_INCLUDE_GPS=1", base)
target = (VARIANT / "target.cpp").read_text(encoding="ascii")
self.assertIn("#if ENV_INCLUDE_GPS", target)
self.assertIn("CardputerSensorManager sensors(nmea);", target)
self.assertIn("CardputerSensorManager sensors;", target)
self.assertNotIn("EnvironmentSensorManager sensors", target)
if __name__ == "__main__":
unittest.main()
+37 -2
View File
@@ -130,7 +130,7 @@ int main() {
memset(bounded, 'x', MAX_MESSAGE_LENGTH);
store.storeMessage(key, bounded, false, true);
delete[] bounded;
const std::string channel_path = std::string(HISTORY_DIR_CHANNEL) + "/" + std::string(32, '0');
const std::string channel_path = std::string(CHANNEL_HISTORY_ID_DIR) + "/" + std::string(64, '0');
assert(sd.files.at(channel_path)->bytes == "<< " + std::string(MAX_MESSAGE_LENGTH, 'x') + "\n");
store.storeMessage(key, "line1\r\nline2", true, true);
assert(sd.files.at(channel_path)->bytes.find(">> line1 line2\n") != std::string::npos);
@@ -235,7 +235,7 @@ int main() {
assert(node.sendFloodScoped(channel, other, 0));
assert(node.last_message_hash[0] == 77 && node.last_message_heard_repeats == 3);
delete other;
assert(node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 1);
assert(node.sendChannelMessage(7, 0, "text", 4) && node.store.writes == 0);
node.resend_timeout = 10;
node.now = 9;
node.loop();
@@ -422,6 +422,41 @@ int main() {
assert(!memcmp(&restored, &candidate, sizeof(candidate)));
assert(!store.saveCustomPrefs(previous));
}
'''
self.run_cpp((VARIANT / "CardputerDataStore.cpp").read_text() + checks,
headers=self.storage_headers())
def test_channel_identity_storage_preserves_ambiguous_legacy_logs(self):
checks = r'''
#include <cassert>
int main() {
mesh::RTCClock clock;
FILESYSTEM primary, sd; stat_fs = &primary;
CardputerDataStore store(primary, clock); store.beginCustom(&sd);
const std::string legacy_path = std::string(HISTORY_DIR_CHANNEL) + "/" + std::string(32,'0');
auto legacy = sd.open(legacy_path.c_str(), FILE_WRITE);
legacy.println("<< ambiguous old conversation"); legacy.close();
const std::string previous = sd.files.at(legacy_path)->bytes;
uint8_t first[32] = {}, second[32] = {}; first[31] = 1; second[31] = 2;
ChatHistory history;
store.loadMessages(first, true, history);
assert(history.count() == 0);
store.storeMessage(first, "first identity", false, true);
store.storeMessage(second, "second identity", true, true);
store.loadMessages(first, true, history);
const HistoryMessage* message = nullptr;
assert(history.count() == 1 && history.get(0,message));
assert(!strcmp(message->text,"first identity") && !message->out);
store.loadMessages(second, true, history);
assert(history.count() == 1 && history.get(0,message));
assert(!strcmp(message->text,"second identity") && message->out);
assert(sd.files.size() == 3 && sd.files.at(legacy_path)->bytes == previous);
for (const auto& entry : sd.files) {
if (entry.first == legacy_path) continue;
assert(entry.first.find(std::string(HISTORY_DIR) + "/chan2/") == 0);
assert(entry.first.substr(entry.first.find_last_of('/')+1).size() == 64);
}
}
'''
self.run_cpp((VARIANT / "CardputerDataStore.cpp").read_text() + checks,
headers=self.storage_headers())
+32 -9
View File
@@ -42,15 +42,17 @@ struct Store {
};
struct UI {
std::vector<MessageSendState> events;
unsigned contacts = 0, channels = 0;
unsigned contacts = 0, channels = 0, sent_contacts = 0, sent_channels = 0;
uint8_t key[32] = {};
void onMessageSendAttempt(uint8_t, uint8_t, MessageSendState state) { events.push_back(state); }
void onContactMessageRecv(const ContactInfo& contact, const char*) {
++contacts; memcpy(key, contact.id.pub_key, 32);
}
void onChannelMessageRecv(const mesh::GroupChannel& channel, const char*) {
++channels; memcpy(key, channel.secret, 32);
void onChannelMessageRecv(uint8_t, const uint8_t* id, const char*) {
++channels; memcpy(key, id, 32);
}
void onContactMessageSent(const ContactInfo&, const char*) { ++sent_contacts; }
void onChannelMessageSent(uint8_t, const uint8_t*, const char*) { ++sent_channels; }
};
struct MyMesh {
bool admit = true, present = true, persist = true, standard_save = true;
@@ -58,12 +60,15 @@ struct MyMesh {
unsigned sends = 0, standard_saves = 0, path_saves = 0;
ContactInfo current;
uint8_t sent_path = 0, sent_attempt = 0;
int sendMessage(const ContactInfo& contact, uint32_t, uint8_t attempt, const char*,
virtual void onUiContactMessageSent(const ContactInfo&, const char*) {}
int sendMessage(const ContactInfo& contact, uint32_t, uint8_t attempt, const char* text,
uint32_t& ack, uint32_t& estimate) {
++sends;
if (!admit) { ack = 0; estimate = 0; return MSG_SEND_FAILED; }
sent_path = contact.out_path_len; sent_attempt = attempt;
ack = 1000 + attempt; estimate = timeout; return 1;
ack = 1000 + attempt; estimate = timeout;
if (attempt == 0) onUiContactMessageSent(contact, text);
return 1;
}
ContactInfo* lookupContactByPubKey(const uint8_t* key, size_t count) {
assert(count == 32);
@@ -86,7 +91,11 @@ struct CardputerMesh : MyMesh {
void checkResend();
void cancelResending();
void onUiContactMessage(const ContactInfo&, const char*);
void onUiChannelMessage(const uint8_t[32], const char*);
void onUiChannelMessage(uint8_t, const char*);
void onUiContactMessageSent(const ContactInfo&, const char*) override;
bool getChannelHistoryId(int index, uint8_t* id) {
assert(index == 7); memset(id, 0, 32); id[31] = 88; return true;
}
bool savePrefs();
bool saveCustomPrefs();
void beginCustom();
@@ -104,6 +113,7 @@ int main() {
node.current.out_path_len = 2;
assert(node.sendDirectMessage(node.current, 5, "message") != MSG_SEND_FAILED);
assert(node.resend_timeout == node.now + node.timeout && node.store.writes == 1);
assert(node.ui.sent_contacts == 1);
assert(node.last_msg.expected_ack == 1000 && node.last_msg.attempt == 1);
assert(node.sendDirectMessage(node.current, 6, "other") == MSG_SEND_FAILED && node.sends == 1);
node.current.out_path_len = 3;
@@ -111,6 +121,7 @@ int main() {
node.checkResend();
assert(node.sent_path == 3 && node.sent_attempt == 1 && node.last_msg.attempt == 2);
assert(node.resend_timeout == node.now + 32000 && node.store.writes == 1);
assert(node.ui.sent_contacts == 1);
node.admit = false;
node.checkResend();
assert(!node.isMessageSending() && node.resend_timeout == 0 && node.last_msg.expected_ack == 0);
@@ -149,8 +160,7 @@ int main() {
accepted.current.id.pub_key[31] = 77;
accepted.onUiContactMessage(accepted.current, "direct");
assert(accepted.ui.contacts == 1 && accepted.store.writes == 1 && accepted.store.key[31] == 77);
uint8_t channel_key[32] = {}; channel_key[31] = 88;
accepted.onUiChannelMessage(channel_key, "group");
accepted.onUiChannelMessage(7, "group");
assert(accepted.ui.channels == 1 && accepted.store.writes == 2 && accepted.store.key[31] == 88);
assert(accepted.ui.key[31] == 88);
}
@@ -165,6 +175,14 @@ SELECTION = r'''
#define PUB_KEY_SIZE 32
struct ContactInfo { struct { uint8_t pub_key[32] = {}; } id; int marker = 0; };
struct ChannelDetails { struct { uint8_t secret[32] = {}; } channel; char name[32] = "channel"; };
struct CardputerMesh {
static bool channelHistoryId(const ChannelDetails& channel, uint8_t* id) {
memset(id, 0, 32);
for (size_t i=0; i<strlen(channel.name); ++i) id[i%32] ^= channel.name[i];
for (size_t i=0; i<32; ++i) id[i] ^= channel.channel.secret[i];
return channel.name[0] != 0;
}
};
struct Mesh {
std::vector<ContactInfo> contacts;
ChannelDetails channel;
@@ -185,6 +203,7 @@ struct ChatPage {
int current_contact_idx = 2, current_channel_idx = -1;
ContactInfo current_contact;
ChannelDetails current_channel;
uint8_t current_channel_history_id[32] = {};
History chat_history;
int chat_history_offset = 2;
std::string last_sent_message = "previous";
@@ -205,8 +224,11 @@ int main() {
page.refreshSelectedContact();
assert(page.current_contact_idx == -1 && page.chat_history.count == 0 && page.last_sent_message.empty());
page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel;
CardputerMesh::channelHistoryId(page.current_channel, page.current_channel_history_id);
strcpy(the_mesh_cp.channel.name, "renamed");
assert(page.refreshSelectedChannel() && !strcmp(page.current_channel.name, "renamed"));
assert(!page.refreshSelectedChannel() && page.current_channel_idx == -1);
page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel;
CardputerMesh::channelHistoryId(page.current_channel, page.current_channel_history_id);
the_mesh_cp.channel.channel.secret[31] = 1;
assert(!page.refreshSelectedChannel() && page.current_channel_idx == -1);
page.current_channel_idx = 4; page.current_channel = the_mesh_cp.channel;
@@ -225,6 +247,7 @@ class CardputerMessagingTests(unittest.TestCase):
methods = "\n".join(body(source, signature) for signature in (
"int CardputerMesh::sendDirectMessage(", "void CardputerMesh::checkResend()",
"void CardputerMesh::cancelResending()", "void CardputerMesh::onUiContactMessage(",
"void CardputerMesh::onUiContactMessageSent(",
"void CardputerMesh::onUiChannelMessage(", "bool CardputerMesh::saveCustomPrefs()",
"bool CardputerMesh::savePrefs()", "void CardputerMesh::beginCustom()"))
last_message = body((VARIANT / "types.h").read_text(), "struct LastSentMessage {") + ";"
+118 -2
View File
@@ -158,7 +158,7 @@ struct CardputerDisplay {
static const char* textLevelName(int) { return "Large"; }
void setColor(PaletteColor) {}
void setTextSize(int) {}
void drawTextCentered(int,int,const char*) {}
void drawTextCentered(int,int,const char* text) { last=text; }
void drawTextLeftAlign(int,int,const char*) {}
void drawTextEllipsized(int,int,int,const char*) {}
void drawTextLeftAlignWithScroll(int,int,int,const char* s) { last=s; }
@@ -174,6 +174,7 @@ struct SettingsScreen {
Task* _task;
String edit_buffer;
uint8_t edit_u8=0;
int32_t edit_tx_power=0;
int menu_index=RadioFreq;
char edit_error[80]="Invalid value";
bool is_editing=true;
@@ -261,7 +262,7 @@ int main() {
assert(prefs.bw==125 && the_mesh_cp.saves==2);
s.edit_u8=4; assert(!s.commitItemEdit(SettingsScreen::RadioSf));
assert(!s.commitItemEdit(SettingsScreen::RadioCr));
s.edit_u8=23; assert(!s.commitItemEdit(SettingsScreen::RadioPwr));
s.edit_tx_power=23; assert(!s.commitItemEdit(SettingsScreen::RadioPwr));
assert(prefs.sf==8 && prefs.cr==5 && prefs.tx_power_dbm==22 && the_mesh_cp.saves==2);
// The UI must use the live shared command path and leave prefs untouched on failure.
assert(the_mesh_cp.command=="set radio 915.250000,125.000000,8,5,96");
@@ -286,6 +287,19 @@ int main() {
}
s.edit_buffer="4400"; assert(s.commitItemEdit(SettingsScreen::DeviceBatteryCorrection));
assert(custom.battery_correction==1.1f);
// Signed TX values remain visible and editable through zero and both driver limits.
s.menu_index=SettingsScreen::RadioPwr; s.edit_tx_power=-9;
s.render(display); assert(display.last=="- -9 +");
Keyboard::Event left{Keyboard::ARROW_LEFT,""},right{Keyboard::ARROW_RIGHT,""};
s.handleEditInput(SettingsScreen::RadioPwr,left); assert(s.edit_tx_power==-9);
s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==-8);
assert(s.commitItemEdit(SettingsScreen::RadioPwr) && prefs.tx_power_dbm==-8);
s.edit_tx_power=-1; s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==0);
assert(s.commitItemEdit(SettingsScreen::RadioPwr) && prefs.tx_power_dbm==0);
s.edit_tx_power=22; s.handleEditInput(SettingsScreen::RadioPwr,right); assert(s.edit_tx_power==22);
for(int32_t invalid:{-129,-10,23,128}) {
s.edit_tx_power=invalid; assert(!s.commitItemEdit(SettingsScreen::RadioPwr));
}
// A missing/failed display cannot turn a battery shutdown into a null dereference.
CardputerUITask ui{nullptr,&task.board};
ui.shutdown(); assert(task.board.off==1 && radio_driver.off==1);
@@ -330,6 +344,108 @@ int main() {
executed = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
def test_actual_tools_shutdown_release_timeout_and_guard(self):
tools = (UI / "screen/ToolsScreen.cpp").read_text(encoding="ascii")
header = (UI / "screen/ToolsScreen.h").read_text(encoding="ascii")
task = (UI / "CardputerUITask.cpp").read_text(encoding="ascii")
enums = re.search(r"enum ToolsPage.*?enum ToolsMenuItem.*?};", header, re.S).group()
timeout = re.search(r"static constexpr uint32_t POWER_OFF_RELEASE_TIMEOUT_MS = \d+;", header).group()
code = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <string>
uint32_t fake_now=0;
unsigned long millis() { return fake_now; }
namespace Keyboard {
enum Key { KEY_RETURN,KEY_ESC,KEY_A };
struct Event { Key key; bool changed,down; };
}
struct Board {
unsigned boots=0,offs=0;
void reboot() { ++boots; }
void powerOff() { ++offs; }
};
struct Display { void turnOff() {} };
struct Radio { unsigned offs=0; void powerOff() { ++offs; } } radio_driver;
struct CardputerUITask {
Board* _board;
Display* _display=nullptr;
bool allow_shutdown=true;
std::string alert;
bool prepareForShutdown() { return allow_shutdown; }
void showAlert(const char* text,int) { alert=text; }
void dismissAlert() { alert.clear(); }
void shutdown(bool restart=false);
};
struct Mesh {
bool sendAdvert(bool) { return true; }
bool sendRepeatersDiscover() { return true; }
} the_mesh_cp;
struct ToolsScreen {
'''
code += enums + "\n" + timeout + r'''
CardputerUITask* _task;
ToolsPage page=MenuPage;
uint16_t discovered_repeaters_count=0;
bool power_off_pending=false,power_off_released=false;
uint32_t power_off_deadline=0;
void menuItemEnter(ToolsMenuItem);
bool handlePowerOffInput(const Keyboard::Event&);
void poll();
};
'''
code += function(task, "void CardputerUITask::shutdown(") + "\n"
for signature in ("void ToolsScreen::menuItemEnter(", "bool ToolsScreen::handlePowerOffInput(", "void ToolsScreen::poll("):
code += function(tools, signature) + "\n"
code += r'''
int main() {
Board board; CardputerUITask task{&board}; ToolsScreen tools{&task};
Keyboard::Event held{Keyboard::KEY_RETURN,true,true},released{Keyboard::KEY_RETURN,true,false};
Keyboard::Event idle{Keyboard::KEY_A,false,false},escape{Keyboard::KEY_ESC,true,true};
// Menu activation returns immediately while the main loop can continue servicing work.
tools.menuItemEnter(ToolsScreen::PowerOff);
assert(tools.power_off_pending && board.offs==0);
for(unsigned tick=0; tick<tools.POWER_OFF_RELEASE_TIMEOUT_MS; ++tick) {
fake_now=tick; tools.handlePowerOffInput(idle); tools.poll(); assert(board.offs==0);
}
assert(tools.handlePowerOffInput(released)); tools.poll();
assert(!tools.power_off_pending && board.offs==1 && radio_driver.offs==1);
tools.poll(); assert(board.offs==1);
// Missing/held Enter release cancels at the bounded deadline instead of sleeping with a held key.
fake_now=5000; tools.menuItemEnter(ToolsScreen::PowerOff);
assert(tools.handlePowerOffInput(held));
fake_now+=tools.POWER_OFF_RELEASE_TIMEOUT_MS; tools.poll();
assert(!tools.power_off_pending && board.offs==1 && task.alert.find("cancelled")!=std::string::npos);
assert(!tools.handlePowerOffInput(released)); tools.poll(); assert(board.offs==1);
// An explicit cancellation leaves the device running.
tools.menuItemEnter(ToolsScreen::PowerOff); assert(tools.handlePowerOffInput(escape));
tools.poll(); assert(!tools.power_off_pending && board.offs==1);
// Both restart and release-confirmed power-off use the same durability guard.
task.allow_shutdown=false;
tools.menuItemEnter(ToolsScreen::Restart); assert(board.boots==0);
tools.menuItemEnter(ToolsScreen::PowerOff); tools.handlePowerOffInput(released); tools.poll();
assert(board.offs==1 && radio_driver.offs==1 && !tools.power_off_pending);
task.allow_shutdown=true; tools.menuItemEnter(ToolsScreen::Restart); assert(board.boots==1);
// Wrap-safe bounded release wait.
fake_now=0xFFFFFF00U; tools.menuItemEnter(ToolsScreen::PowerOff);
fake_now+=tools.POWER_OFF_RELEASE_TIMEOUT_MS-1; tools.poll(); assert(tools.power_off_pending);
++fake_now; tools.poll(); assert(!tools.power_off_pending && board.offs==1);
std::puts("PASS: actual Tools release wait, timeout, cancellation and restart/shutdown guard");
}
'''
with tempfile.TemporaryDirectory(prefix="cardputer-tools-") as tmp:
source, binary = Path(tmp) / "test.cpp", Path(tmp) / "test"
source.write_text(code, encoding="ascii")
compiled = subprocess.run([
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-missing-field-initializers", "-fsanitize=address,undefined",
"-fno-omit-frame-pointer", "-no-pie", str(source), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
executed = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
def test_bounded_formatting_and_font_attribution(self):
for source in UI.rglob("*.cpp"):
self.assertNotRegex(source.read_text(encoding="ascii"), r"\bsprintf\s*\(", str(source))
+151 -2
View File
@@ -108,6 +108,7 @@ struct CardputerDisplay: DisplayDriver {
struct Speaker {
unsigned tones=0,sleeps=0;
void queueTone(int,int,float=0.5f) { ++tones; }
bool isBusy() const { return false; }
void sleep() { ++sleeps; }
};
struct Board {
@@ -127,7 +128,22 @@ struct Event { Key key; };
}
struct Prefs { uint8_t gps_enabled=1,buzzer_quiet=0,powersaving_enabled=0; };
struct CustomPrefs { uint8_t text_level=1,brightness=96; };
struct NotificationSink: mesh::notify::Sink {
CardputerDisplay* display;
explicit NotificationSink(CardputerDisplay* d):display(d) {}
uint8_t capabilities() const override { return mesh::notify::Screen; }
bool gpioAvailable(uint8_t) const override { return false; }
void pulse(mesh::notify::Output,bool,int8_t) override {}
void melody(const char*) override {}
void screen(int8_t mode) override { display->notificationPower(mode); }
};
struct NotificationStore: mesh::notify::Store {
bool save(const mesh::notify::Settings&) override { return true; }
};
struct Mesh {
mesh::notify::Controller* notifications=nullptr;
unsigned button_calls=0;
bool notificationButton() { ++button_calls; return notifications && notifications->button(); }
Prefs* prefs=nullptr;
CustomPrefs* custom=nullptr;
CustomPrefs committed;
@@ -181,6 +197,9 @@ struct CardputerUITask {
void scheduleRefresh(unsigned ms=0) { next_refresh=millis()+ms; }
void setCurrScreen(CardputerScreen* screen) { current_screen=screen; scheduleRefresh(); }
bool shouldWakeDisplayForMessage() const;
bool isAlertActive() const;
bool isChatVisible() const;
bool handleNotificationInput();
void showAlert(const char*,int);
void serviceDisplayPower();
void servicePairingState();
@@ -204,6 +223,8 @@ struct CardputerUITask {
code += "bool CardputerUITask::" + function(abstract, "bool shouldWakeDisplayForMessage() const").removeprefix("bool ") + "\n"
for signature in (
"void CardputerUITask::showAlert(", "void CardputerUITask::serviceDisplayPower(",
"bool CardputerUITask::isAlertActive(", "bool CardputerUITask::isChatVisible(",
"bool CardputerUITask::handleNotificationInput(",
"void CardputerUITask::servicePairingState(", "bool CardputerUITask::isPairingPromptActive(",
"void CardputerUITask::renderPairingPrompt(", "bool CardputerUITask::handlePairingInput(",
"bool CardputerUITask::turnDisplayOn(", "bool CardputerUITask::getGPSState(",
@@ -327,6 +348,34 @@ int main() {
assert(ui.isPairingPromptActive());
fake_now+=119999U; ui.servicePairingState(); assert(ui.isPairingPromptActive());
++fake_now; ui.servicePairingState(); assert(!ui.isPairingPromptActive());
// Visibility means the matching conversation is actually readable, including overlays.
ui.current_screen=&main; main.page=MainScreen::CHAT; ui.alert_expiry=0;
board.usb=false; power.battery.mode=mesh::ui::DisplayMode::On; ui.serviceDisplayPower();
assert(ui.isChatVisible());
ui.showAlert("overlay",1000); assert(!ui.isChatVisible());
ui.alert_expiry=0; ui.current_screen=&messages; assert(!ui.isChatVisible());
ui.current_screen=&main; display.turnOff(); assert(!ui.isChatVisible());
display.turnOn(); main.page=MainScreen::FIRST; assert(!ui.isChatVisible());
main.page=MainScreen::CHAT;
interfaces.pending=true; ui.servicePairingState(); assert(!ui.isChatVisible());
ui.handlePairingInput(escape); assert(ui.isChatVisible());
// Actual shared controller stops infinite screen overrides on a physical-input action.
NotificationSink sink(&display); NotificationStore store;
mesh::notify::Controller notifications(sink,store); the_mesh_cp.notifications=&notifications;
auto& rules=notifications.settings(); rules.enabled=1;
auto& rule=rules.rules[0]; rule.used=1; rule.fields=mesh::notify::FScreen;
rule.screen=1; rule.repeat=0; rule.stop=mesh::notify::Button;
assert(notifications.message(nullptr,nullptr,false,fake_now));
assert(notifications.active());
unsigned button_calls=the_mesh_cp.button_calls;
assert(ui.handleNotificationInput());
assert(!notifications.active() && the_mesh_cp.button_calls==button_calls+1);
assert(!ui.handleNotificationInput());
// Dismissing a notification during a pairing prompt leaves that modal prompt intact.
assert(notifications.message(nullptr,nullptr,false,fake_now));
interfaces.pending=true; ui.servicePairingState();
assert(ui.handleNotificationInput() && ui.isPairingPromptActive());
ui.handlePairingInput(escape);
// Failed display initialization leaves every display control and notification safe.
ui._display=nullptr;
auto saved=the_mesh_cp.saves;
@@ -338,8 +387,11 @@ int main() {
std::puts("PASS: Cardputer live controls, notifications, shared power and night drawing");
}
'''
self.assertIn("event.changed && event.down && !handlePairingInput(event)", task)
self.assertIn("BUTTON_EVENT_CLICK && !isPairingPromptActive()", task)
self.assertIn("if (handleNotificationInput())", task)
self.assertIn("else if (!power_off_input && !handlePairingInput(event))", task)
self.assertIn("if (!handleNotificationInput() && !isPairingPromptActive()", task)
self.assertIn("tools->handlePowerOffInput(event)", task)
self.assertIn("current_screen != tools_screen) tools->poll()", task)
self.assertIn("renderPairingPrompt();\n _display->endFrame();", task)
with tempfile.TemporaryDirectory(prefix="cardputer-lifecycle-") as tmp:
work = Path(tmp)
@@ -357,6 +409,103 @@ int main() {
executed = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
def test_actual_audio_loop_cleanup_waits_for_drain_and_keeps_visible_codec_warm(self):
task = (VARIANT / "companion_radio/ui-cardputer/CardputerUITask.cpp").read_text(encoding="ascii")
speaker = (VARIANT / "hw/CardputerSpeaker.h").read_text(encoding="ascii")
audio_prefix = function(task, "void CardputerUITask::loop(").split(" CardputerKeyboard *kb", 1)[0]
code = r'''
#include <cassert>
#include <cstdio>
struct Speaker {
bool tone_pending=false,draining=false,finish_drain=false,initialized=true;
unsigned polls=0,sleeps=0,shutdowns=0;
void processQueue() {
++polls;
if(finish_drain) { draining=false; finish_drain=false; }
}
void sleep() {
++sleeps;
if(initialized) { initialized=false; ++shutdowns; }
tone_pending=draining=false;
}
'''
code += function(speaker, "bool isBusy() const") + r'''
};
struct Board {
Speaker speaker;
Speaker* getSpeaker() { return &speaker; }
bool isExternalPowered() const { return false; }
bool isUsbHostConnected() const { return false; }
};
struct Display {
bool on=false,desired=false;
bool isOn() const { return on; }
bool servicePower(bool,bool,bool) { bool changed=on!=desired; on=desired; return changed; }
};
struct CardputerUITask {
Board* _board;
Display* _display;
unsigned refreshes=0;
bool hasConnection() const { return false; }
bool isPairingPromptActive() const { return false; }
void scheduleRefresh() { ++refreshes; }
void serviceDisplayPower();
void servicePairingState() { serviceDisplayPower(); }
void loop();
};
'''
code += function(task, "void CardputerUITask::serviceDisplayPower(") + "\n"
code += audio_prefix + "}\n"
code += r'''
int main() {
Board board; Display display;
CardputerUITask ui{&board,&display};
auto& audio=board.speaker;
// A sound-only notification plays while the screen is already dark.
audio.tone_pending=true;
ui.loop(); assert(audio.polls==1 && audio.sleeps==0 && audio.initialized); // warmup
ui.loop(); assert(audio.polls==2 && audio.sleeps==0 && audio.initialized); // transmitting
audio.tone_pending=false; audio.draining=true;
ui.loop(); assert(audio.polls==3 && audio.sleeps==0 && audio.initialized); // DMA still draining
audio.finish_drain=true;
ui.loop(); assert(audio.polls==4 && audio.sleeps==1 && !audio.initialized && audio.shutdowns==1);
ui.loop(); assert(audio.shutdowns==1); // repeated idle cleanup performs no further hardware shutdown
// A display timeout must not cancel audio before the post-processQueue idle check.
display.on=true; display.desired=false;
audio.initialized=true; audio.tone_pending=true;
unsigned sleeps=audio.sleeps;
ui.loop(); assert(!display.on && audio.tone_pending && audio.sleeps==sleeps && audio.initialized);
audio.tone_pending=false; audio.draining=true;
ui.loop(); assert(audio.draining && audio.sleeps==sleeps);
audio.finish_drain=true;
ui.loop(); assert(!audio.initialized && audio.sleeps==sleeps+1);
// Headless sound uses the same complete-playback-then-cleanup lifecycle.
ui._display=nullptr; audio.initialized=true; audio.tone_pending=true;
sleeps=audio.sleeps; ui.loop(); assert(audio.sleeps==sleeps && audio.tone_pending);
audio.tone_pending=false; audio.draining=true;
ui.loop(); assert(audio.sleeps==sleeps && audio.draining);
audio.finish_drain=true; ui.loop(); assert(audio.sleeps==sleeps+1 && !audio.initialized);
// Visible idle operation retains the warmed codec for keyboard tones.
ui._display=&display; display.on=display.desired=true; audio.initialized=true;
sleeps=audio.sleeps; ui.loop(); assert(audio.sleeps==sleeps && audio.initialized);
std::puts("PASS: actual UI audio loop waits for DMA drain, powers down dark/headless idle, and stays warm while visible");
}
'''
with tempfile.TemporaryDirectory(prefix="cardputer-audio-lifecycle-") as tmp:
source, binary = Path(tmp) / "test.cpp", Path(tmp) / "test"
source.write_text(code, encoding="ascii")
compiled = subprocess.run([
"g++", "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-no-pie",
str(source), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
executed = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr)
if __name__ == "__main__":
unittest.main()
@@ -7,6 +7,8 @@
#include "globals.h"
static const char CHANNEL_HISTORY_ID_DIR[] = HISTORY_DIR "/chan2";
static void buf_to_hex_fast(const unsigned char *src, size_t src_len, char *dst) {
static const char hex_digits[] = "0123456789abcdef";
@@ -24,7 +26,8 @@ void CardputerDataStore::beginCustom(FILESYSTEM *history_fs) {
}
_history_fs->mkdir(CUSTOM_DATA_DIR);
_history_fs->mkdir(HISTORY_DIR);
_history_fs->mkdir(HISTORY_DIR_CHANNEL);
// Leave ambiguous legacy PSK-only /chan histories intact for recovery.
_history_fs->mkdir(CHANNEL_HISTORY_ID_DIR);
_history_fs->mkdir(HISTORY_DIR_DIRECT);
}
@@ -129,9 +132,7 @@ void CardputerDataStore::storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const ch
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);
buf_to_hex_fast(pkey, PUB_KEY_SIZE, pkey_hex);
int msg_len;
for (msg_len = 0; msg_len < MAX_MESSAGE_LENGTH && text[msg_len] != '\0'; msg_len++) {
@@ -144,7 +145,7 @@ void CardputerDataStore::storeMessage(const uint8_t pkey[PUB_KEY_SIZE], const ch
msg_copy[msg_len] = '\0';
snprintf(path, sizeof(path), "%s/%s", is_channel ? HISTORY_DIR_CHANNEL : HISTORY_DIR_DIRECT, pkey_hex);
snprintf(path, sizeof(path), "%s/%s", is_channel ? CHANNEL_HISTORY_ID_DIR : HISTORY_DIR_DIRECT, pkey_hex);
File file = _history_fs->open(path, FILE_APPEND, true);
@@ -183,11 +184,9 @@ void CardputerDataStore::loadMessages(const uint8_t pkey[PUB_KEY_SIZE], bool is_
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, PUB_KEY_SIZE, pkey_hex);
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);
snprintf(path, sizeof(path), "%s/%s", is_channel ? CHANNEL_HISTORY_ID_DIR : HISTORY_DIR_DIRECT, pkey_hex);
if (!_history_fs->exists(path)) {
return;
@@ -22,6 +22,9 @@ 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.
// Channel identities include the complete name/protocol alias and 32-byte key.
// Legacy /history/chan PSK-only logs remain on SD for manual recovery; they
// cannot be assigned safely to one channel and are never silently imported.
class CardputerDataStore : public DataStore {
private:
FILESYSTEM *_fs;
@@ -36,6 +39,7 @@ public:
bool hasHistory() const { return _history_fs != nullptr; }
bool loadCustomPrefs(CustomNodePrefs &prefs);
bool saveCustomPrefs(const CustomNodePrefs &prefs);
// For channels, pkey is the 32-byte channelHistoryId, not the wire PSK.
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);
};
@@ -7,6 +7,7 @@ bool due(uint32_t now, uint32_t deadline) {
}
}
#if ENV_INCLUDE_GPS
void CardputerSensorManager::armGpsPowerSavingCycle() {
if (!powersaving_enabled || !_location->getGPSPowerSaving()) return;
_location->syncTimeForPowerSavingCycle();
@@ -40,11 +41,18 @@ void CardputerSensorManager::stop_gps() {
else _location->setNextGPSOn(0);
_location->stop();
}
#endif
void CardputerSensorManager::gps_standby(bool active) {
// CASIC protocol sections CAS10/CAS12: hot start and standby for at most
// 65535 seconds. The provider's MicroNMEA sender generates the checksum.
#if ENV_INCLUDE_GPS
_location->sendSentence(active ? "$PCAS12,65535" : "$PCAS10,0");
#else
// GPS-free roles still have a powered physical receiver. Keep it in standby
// without linking a parser/provider or advertising a GPS firmware feature.
Serial1.print(active ? "$PCAS12,65535*1E\r\n" : "$PCAS10,0*1C\r\n");
#endif
standby_renew_at = static_cast<uint32_t>(millis()) + STANDBY_RENEW_MS;
}
@@ -55,25 +63,49 @@ bool CardputerSensorManager::begin() {
Serial1.begin(GPS_BAUD_RATE);
gps_serial_open = true;
gps_active = false;
#if ENV_INCLUDE_GPS
_location->setGPSPowerSaving(false);
_location->stopTimeSync();
_location->stop();
#endif
// A fixed cap powers GNSS at boot even when the saved preference is off.
// Silence it explicitly; UART traffic during startup is not a user choice.
gps_standby(true);
#if !CARDPUTER_ADV_UI
#if ENV_INCLUDE_GPS
// The fixed cap uses command-based standby. Keep the generic manager's GPS
// discovery/transport permanently blocked: it must not reset or reopen this
// UART independently. Its environmental discovery and telemetry stay intact.
CardputerSensorBase::gps_serial_transport_blocked = true;
#endif
return CardputerSensorBase::begin();
#else
return true;
#endif
}
bool CardputerSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) {
// Shared cache, permissions, bounded acquisition and manual-off cancellation.
#if !CARDPUTER_ADV_UI
return CardputerSensorBase::querySensors(requester_permissions, telemetry);
#elif ENV_INCLUDE_GPS
queryGpsTelemetry(requester_permissions, telemetry);
#endif
return true;
}
void CardputerSensorManager::loop() {
if (!gps_serial_open) return;
#if !CARDPUTER_ADV_UI && (ENV_INCLUDE_GPS || defined(ENV_INCLUDE_BME680_BSEC))
// Generic GPS discovery is blocked and its gps_detected/active remain false.
// This services external BSEC sensors and the one shared GPS telemetry cache.
CardputerSensorBase::loop();
#endif
const uint32_t now = millis();
#if ENV_INCLUDE_GPS
#if CARDPUTER_ADV_UI
loopGpsTelemetry(now);
#endif
if (powersaving_enabled && isGpsTelemetryUserEnabled()
&& !isTrackerGpsModeEnabled() && _location->getGPSPowerSaving()) {
const uint32_t next_off = _location->getNextGPSOff();
@@ -86,12 +118,14 @@ void CardputerSensorManager::loop() {
start_gps();
}
}
#endif
if (!gps_active) {
// CAS12 eventually wakes on its own. Renew off/standby without changing
// saved intent or creating a GPS telemetry/clock acquisition.
if (due(now, standby_renew_at)) gps_standby(true);
return;
}
#if ENV_INCLUDE_GPS
_location->loop();
if (due(now, next_gps_update)) {
if (_location->isValid()) {
@@ -102,20 +136,36 @@ void CardputerSensorManager::loop() {
}
next_gps_update = now + getGpsUpdateIntervalMillis();
}
#endif
}
int CardputerSensorManager::getNumSettings() const { return 1; }
int CardputerSensorManager::getNumSettings() const {
return CardputerSensorBase::getNumSettings()
#if ENV_INCLUDE_GPS
+ 1
#endif
;
}
const char *CardputerSensorManager::getSettingName(int i) const {
return i == 0 ? "gps" : nullptr;
#if ENV_INCLUDE_GPS
if (i == 0) return "gps";
--i;
#endif
return CardputerSensorBase::getSettingName(i);
}
const char *CardputerSensorManager::getSettingValue(int i) const {
return i == 0 ? (isGpsTelemetryUserEnabled() ? "1" : "0") : nullptr;
#if ENV_INCLUDE_GPS
if (i == 0) return isGpsTelemetryUserEnabled() ? "1" : "0";
--i;
#endif
return CardputerSensorBase::getSettingValue(i);
}
bool CardputerSensorManager::setSettingValue(const char *name, const char *value) {
if (!name || !value) return false;
#if ENV_INCLUDE_GPS
if (strcmp(name, "gps") == 0) {
const bool enabled = strcmp(value, "0") != 0;
const bool was_active = gps_active;
@@ -129,12 +179,16 @@ bool CardputerSensorManager::setSettingValue(const char *name, const char *value
}
return true;
}
const bool accepted = SensorManager::setSettingValue(name, value);
#endif
const bool accepted = CardputerSensorBase::setSettingValue(name, value);
#if ENV_INCLUDE_GPS
if (accepted && strcmp(name, "gps_interval") == 0) next_gps_update = millis();
#endif
return accepted;
}
void CardputerSensorManager::setPowerSavingEnabled(bool enabled) {
#if ENV_INCLUDE_GPS
if (powersaving_enabled == enabled) return;
powersaving_enabled = enabled;
if (isTrackerGpsModeEnabled()) {
@@ -146,11 +200,18 @@ void CardputerSensorManager::setPowerSavingEnabled(bool enabled) {
if (!user_enabled) return;
if (!gps_active) start_gps();
else if (enabled) armGpsPowerSavingCycle();
#else
CardputerSensorBase::setPowerSavingEnabled(enabled);
#endif
}
void CardputerSensorManager::shutdownGpsTransportForDeepSleep() {
if (!gps_serial_open) return;
#if ENV_INCLUDE_GPS
stop_gps();
#else
gps_standby(true);
#endif
Serial1.end();
gps_serial_open = false;
}
@@ -2,13 +2,20 @@
#include <helpers/SensorManager.h>
#include <helpers/sensors/LocationProvider.h>
#if CARDPUTER_ADV_UI
using CardputerSensorBase = SensorManager;
#else
#include <helpers/sensors/EnvironmentSensorManager.h>
using CardputerSensorBase = EnvironmentSensorManager;
#endif
class CardputerSensorManager : public SensorManager {
class CardputerSensorManager : public CardputerSensorBase {
private:
bool gps_active = false;
bool gps_serial_open = false;
uint32_t next_gps_update = 0;
uint32_t standby_renew_at = 0;
#if ENV_INCLUDE_GPS
LocationProvider *_location;
void armGpsPowerSavingCycle();
@@ -17,9 +24,18 @@ protected:
bool telemetryGpsActive() const override { return gps_active; }
void telemetryGpsStart() override { start_gps(); }
void telemetryGpsStop() override { stop_gps(); }
#endif
public:
#if ENV_INCLUDE_GPS
#if CARDPUTER_ADV_UI
CardputerSensorManager(LocationProvider &location) : _location(&location) {}
#else
CardputerSensorManager(LocationProvider &location) : CardputerSensorBase(location), _location(&location) {}
#endif
#else
CardputerSensorManager() = default;
#endif
bool begin() override;
bool querySensors(uint8_t requester_permissions, CayenneLPP &telemetry) override;
void loop() override;
@@ -28,14 +44,33 @@ public:
const char *getSettingValue(int i) const override;
bool setSettingValue(const char *name, const char *value) override;
void setPowerSavingEnabled(bool enabled) override;
bool isGPSDetected() const override { return gps_serial_open; }
bool isGPSDetected() const override {
#if ENV_INCLUDE_GPS
return gps_serial_open;
#else
return false;
#endif
}
// The cap has command-controlled standby, not a GPIO power switch. A
// pinless LocationProvider::isEnabled() cannot report its logical state.
bool isGpsPowered() const override { return gps_active; }
bool gpsUsesSerialUart(uint8_t uart) const override { return uart == 1 && gps_serial_open; }
// The cap cannot prove electrical silence without an enable pin. Do not
// let the environmental base or a serial bridge release this fixed UART.
bool gpsSerialTransportMayConflict(uint8_t uart) const override { return gpsUsesSerialUart(uart); }
bool gpsSerialTransportCanYield(uint8_t) const override { return false; }
bool setGpsSerialTransportBlocked(uint8_t, bool) override { return false; }
void shutdownGpsTransportForDeepSleep() override;
LocationProvider *getLocationProvider() override { return _location; }
LocationProvider *getLocationProvider() override {
#if ENV_INCLUDE_GPS
return _location;
#else
return nullptr;
#endif
}
#if ENV_INCLUDE_GPS
void start_gps();
void stop_gps();
#endif
void gps_standby(bool active);
};
+24 -3
View File
@@ -37,6 +37,17 @@ history and keyboard layouts.
Companion builds retain all 350 contacts and use the existing bounded contact
path cache to leave RAM available for Bluetooth, the display and keyboard layouts.
Accepted incoming messages and successful sends from the keyboard, phone app
or USB terminal share the same local history. Retries do not add another copy.
Only the conversation actually visible on the screen is marked read.
Channel history identifies each channel by its full name (including protocol
prefix) and full key, so channels sharing a key keep separate conversations.
New channel logs use `/meshcore_custom/history/chan2` on the SD card. Older
`/meshcore_custom/history/chan` logs remain on the card for manual retrieval;
they are not imported because their channel identity can be ambiguous. Direct
message logs keep their existing paths.
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
@@ -72,7 +83,8 @@ Extra large (19 px). The larger fonts are generated from DejaVu Sans with
Radio settings use the same live apply and save path as the USB terminal;
a reboot is not required. A busy radio applies the saved setting once idle.
Rejected settings or storage failures show an error
and retain the previous saved value. Display brightness, text size and battery
and retain the previous saved value. TX power supports the radio's signed range,
including negative values. Display brightness, text size and battery
correction are saved separately from the standard node preferences.
Screen wake and timeout follow the shared `display.mode` and `display.timeout`
@@ -91,8 +103,14 @@ Bluetooth pairing requests show the PIN for up to two minutes when the selected
screen mode permits it. Esc dismisses the prompt; the current chat and draft
remain selected. An explicit screen-off setting keeps the screen dark.
The speaker plays fixed UI tones and honors notification sound output and mute
settings. Custom RTTTL notification melodies are unsupported on this board.
The speaker plays fixed UI tones without blocking the mesh loop and honors
the saved notification outputs and mute settings. A key or top-button press
dismisses notifications configured to stop on button input. Custom RTTTL
notification melodies are unsupported on this board.
Tools -> Restart and Power off flush pending contact writes before shutdown.
Power off waits for Enter release without blocking radio processing; holding
the key for two seconds or pressing Esc cancels it.
## Third-party notices
@@ -112,6 +130,9 @@ neither dependency is bundled here.
or at the end of its 10-minute acquisition window, then wakes after 30 minutes.
Location telemetry can temporarily hold it awake under the common GPS policy.
Turning GPS off cancels current acquisitions; saved GPS intervals apply.
- Repeater and room-server builds also put the Cap's fixed GNSS receiver into
standby when unused, while retaining external sensor support. GPS-free builds
send the standby command without adding a GPS parser or GPS settings.
- Below 3.4 V the UI shows "Battery low" and powers off (no LoRa wake).
## Building on Windows
@@ -6,7 +6,7 @@
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?
uint8_t pkey[32]; // Full contact public key or domain-separated channel ID.
int count;
};
@@ -15,7 +15,7 @@ class UnreadCounter {
private:
UnreadCounterItem _items[UNREAD_COUNTER_MAX_ITEMS];
uint16_t _count = 0;
using LookupPkey = uint8_t[CONTACT_LOOKUP_BYTES];
using LookupPkey = uint8_t[32];
public:
UnreadCounter() {}
@@ -27,8 +27,8 @@ public:
return &_items[index];
}
inline const uint16_t countChats() const { return _count; }
const uint16_t countMessages() const {
inline uint16_t countChats() const { return _count; }
uint16_t countMessages() const {
uint16_t total = 0;
for (uint16_t i = 0; i < _count; i++) {
total += _items[i].count;
@@ -38,7 +38,7 @@ public:
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) {
if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, sizeof(_items[i].pkey)) == 0) {
_items[i].count += count;
return true;
}
@@ -47,7 +47,7 @@ public:
if (_count < UNREAD_COUNTER_MAX_ITEMS) {
_items[_count].is_channel = is_channel;
_items[_count].count = count;
memcpy(_items[_count].pkey, pkey, CONTACT_LOOKUP_BYTES);
memcpy(_items[_count].pkey, pkey, sizeof(_items[_count].pkey));
_count++;
return true;
}
@@ -56,14 +56,14 @@ public:
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) {
if (_items[i].is_channel == is_channel && memcmp(_items[i].pkey, pkey, sizeof(_items[i].pkey)) == 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 };
_items[_count] = {};
return;
}
}
@@ -59,11 +59,31 @@ mesh::DispatcherAction CardputerMesh::onRecvPacket(mesh::Packet *pkt) {
return action;
}
void CardputerMesh::onUiChannelMessage(const uint8_t secret[32], const char *text) {
mesh::GroupChannel channel{};
memcpy(channel.secret, secret, sizeof(channel.secret));
_store->storeMessage(secret, text, false, true);
_ui->onChannelMessageRecv(channel, text);
bool CardputerMesh::channelHistoryId(const ChannelDetails &channel, uint8_t id[PUB_KEY_SIZE]) {
static const uint8_t domain[] = "Cardputer channel history v2";
const size_t name_length = strnlen(channel.name, sizeof(channel.name));
if (!name_length || name_length == sizeof(channel.name)) return false;
uint8_t identity[sizeof(domain) + 1 + sizeof(channel.name) + sizeof(channel.channel.secret)];
size_t used = 0;
memcpy(identity, domain, sizeof(domain)); used += sizeof(domain);
identity[used++] = static_cast<uint8_t>(name_length);
memcpy(identity + used, channel.name, name_length); used += name_length;
memcpy(identity + used, channel.channel.secret, sizeof(channel.channel.secret));
used += sizeof(channel.channel.secret);
mesh::Utils::sha256(id, PUB_KEY_SIZE, identity, used);
return true;
}
bool CardputerMesh::getChannelHistoryId(int channel_idx, uint8_t id[PUB_KEY_SIZE]) {
ChannelDetails channel;
return getChannel(channel_idx, channel) && channelHistoryId(channel, id);
}
void CardputerMesh::onUiChannelMessage(uint8_t channel_idx, const char *text) {
uint8_t id[PUB_KEY_SIZE];
if (!getChannelHistoryId(channel_idx, id)) return;
_store->storeMessage(id, text, false, true);
_ui->onChannelMessageRecv(channel_idx, id, text);
}
void CardputerMesh::onUiContactMessage(const ContactInfo &from, const char *text) {
@@ -71,6 +91,22 @@ void CardputerMesh::onUiContactMessage(const ContactInfo &from, const char *text
_ui->onContactMessageRecv(from, text);
}
void CardputerMesh::onUiContactMessageSent(const ContactInfo &recipient, const char *text) {
_store->storeMessage(recipient.id.pub_key, text, true, false);
_ui->onContactMessageSent(recipient, text);
}
void CardputerMesh::onUiChannelMessageSent(uint8_t channel_idx, const char *text, size_t length) {
uint8_t id[PUB_KEY_SIZE];
if (!getChannelHistoryId(channel_idx, id)) return;
char stored_text[MAX_MESSAGE_LENGTH + 1];
const size_t stored_length = length < MAX_MESSAGE_LENGTH ? length : MAX_MESSAGE_LENGTH;
memcpy(stored_text, text, stored_length);
stored_text[stored_length] = 0;
_store->storeMessage(id, stored_text, true, true);
_ui->onChannelMessageSent(channel_idx, id, stored_text);
}
bool CardputerMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt,
uint32_t delay_millis) {
const bool is_group_text = pkt && pkt->getPayloadType() == PAYLOAD_TYPE_GRP_TXT;
@@ -90,15 +126,7 @@ bool CardputerMesh::sendFloodScoped(const mesh::GroupChannel &channel, mesh::Pac
}
bool CardputerMesh::sendChannelMessage(int channel_idx, uint32_t timestamp, const char *text, size_t length) {
ChannelDetails channel;
if (!getChannel(channel_idx, channel) || !channel.name[0]
|| !sendUiChannelMessage(channel_idx, timestamp, text, length)) return false;
char stored_text[MAX_MESSAGE_LENGTH + 1];
const size_t stored_length = length < MAX_MESSAGE_LENGTH ? length : MAX_MESSAGE_LENGTH;
memcpy(stored_text, text, stored_length);
stored_text[stored_length] = 0;
_store->storeMessage(channel.channel.secret, stored_text, true, true);
return true;
return sendUiChannelMessage(channel_idx, timestamp, text, length);
}
int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text) {
@@ -121,7 +149,6 @@ int CardputerMesh::sendDirectMessage(const ContactInfo &recipient, uint32_t time
const uint32_t minimum_timeout = flood ? DIRECT_SEND_FLOOD_RESEND_RELAY : DIRECT_SEND_ROUTE_RESEND_RELAY;
resend_timeout = futureMillis(est_timeout > minimum_timeout ? est_timeout : minimum_timeout);
_ui->onMessageSendAttempt(last_msg.attempt, last_msg.total_attempts, last_msg.state);
_store->storeMessage(recipient.id.pub_key, text, true, false);
return rc;
}
@@ -31,12 +31,16 @@ public:
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 onUiChannelMessage(const uint8_t secret[32], const char *text) override;
void onUiChannelMessage(uint8_t channel_idx, const char *text) override;
void onUiContactMessage(const ContactInfo &from, const char *text) override;
void onUiContactMessageSent(const ContactInfo &recipient, const char *text) override;
void onUiChannelMessageSent(uint8_t channel_idx, const char *text, size_t length) override;
bool sendFloodScoped(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint32_t delay_millis = 0) override;
using MyMesh::sendFloodScoped;
bool sendChannelMessage(int channel_idx, uint32_t timestamp, const char *text, size_t length);
static bool channelHistoryId(const ChannelDetails &channel, uint8_t id[PUB_KEY_SIZE]);
bool getChannelHistoryId(int channel_idx, uint8_t id[PUB_KEY_SIZE]);
int sendDirectMessage(const ContactInfo &recipient, uint32_t timestamp, const char *text);
@@ -47,10 +47,16 @@ void CardputerUITask::dismissAlert() {
scheduleRefresh();
}
bool CardputerUITask::isAlertActive() {
bool CardputerUITask::isAlertActive() const {
return millis() < alert_expiry;
}
bool CardputerUITask::isChatVisible() const {
return _display && _display->isOn() && current_screen == main_screen
&& static_cast<MainScreen *>(main_screen)->getCurrentPage() == MainScreen::CHAT
&& !isPairingPromptActive() && !isAlertActive();
}
void CardputerUITask::notify(UIEventType t) {
if ((_notification_outputs & mesh::notify::Sound) && !_message_notification_override
&& (t == UIEventType::channelMessage || t == UIEventType::contactMessage)) {
@@ -120,7 +126,7 @@ void CardputerUITask::serviceDisplayPower() {
if (_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(),
hasConnection(), isPairingPromptActive())) {
scheduleRefresh();
if (!_display->isOn()) _board->getSpeaker()->sleep();
if (!_display->isOn() && !_board->getSpeaker()->isBusy()) _board->getSpeaker()->sleep();
}
}
@@ -157,6 +163,13 @@ bool CardputerUITask::handlePairingInput(const Keyboard::Event &event) {
return true;
}
bool CardputerUITask::handleNotificationInput() {
if (!the_mesh_cp.notificationButton()) return false;
serviceDisplayPower();
scheduleRefresh();
return true;
}
void CardputerUITask::renderPairingPrompt() {
if (!_display || !_display->isOn() || !isPairingPromptActive()) return;
char pin[16];
@@ -172,8 +185,18 @@ void CardputerUITask::renderPairingPrompt() {
_display->drawTextCentered(_display->width() / 2, 94, "Enter PIN on phone");
}
void CardputerUITask::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) {
((MainScreen *)main_screen)->onChannelMessageRecv(channel, text);
void CardputerUITask::onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text) {
static_cast<MainScreen *>(main_screen)->onChannelMessageRecv(channel_idx, history_id, text);
}
void CardputerUITask::onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text) {
static_cast<MainScreen *>(main_screen)->onChannelMessageSent(channel_idx, history_id, text);
scheduleRefresh();
}
void CardputerUITask::onContactMessageSent(const ContactInfo &contact, const char *text) {
static_cast<MainScreen *>(main_screen)->onContactMessageSent(contact, text);
scheduleRefresh();
}
void CardputerUITask::onContactMessageRecv(const ContactInfo &contact, const char *text) {
@@ -214,7 +237,7 @@ void CardputerUITask::setCurrScreen(CardputerScreen *c) {
*/
void CardputerUITask::shutdown(bool restart) {
if (!prepareForShutdown()) {
showAlert("Shutdown blocked: update in progress", 2000);
showAlert("Shutdown blocked: storage not ready", 2000);
return;
}
if (restart) {
@@ -229,26 +252,40 @@ void CardputerUITask::shutdown(bool restart) {
void CardputerUITask::loop() {
servicePairingState();
_board->getSpeaker()->processQueue();
if ((!_display || !_display->isOn()) && !_board->getSpeaker()->isBusy()) {
_board->getSpeaker()->sleep();
}
CardputerKeyboard *kb = _board->getKeyboard();
const Keyboard::Modifiers latched = kb->getModifiers();
auto event = kb->poll();
if (event.changed && event.down && !handlePairingInput(event)) {
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);
ToolsScreen *tools = static_cast<ToolsScreen *>(tools_screen);
const bool power_off_input = tools && tools->handlePowerOffInput(event);
if (event.changed && event.down) {
if (handleNotificationInput()) {
kb->setModifiers(latched); // dismissal must not also arm a shortcut
} else if (!power_off_input && !handlePairingInput(event)) {
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 && !isPairingPromptActive()) {
turnDisplayOn();
sendFloodAdvert();
if (_top_btn.check() == BUTTON_EVENT_CLICK) {
if (!handleNotificationInput() && !isPairingPromptActive()
&& !(tools && tools->isPowerOffPending())) {
turnDisplayOn();
sendFloodAdvert();
}
}
// A received-message preview must not suspend an armed release/timeout.
if (tools && current_screen != tools_screen) tools->poll();
if (current_screen) {
current_screen->poll();
@@ -48,6 +48,7 @@ class CardputerUITask : public AbstractUITask {
void serviceDisplayPower();
void renderPairingPrompt();
bool handlePairingInput(const Keyboard::Event &event);
bool handleNotificationInput();
// Returns false if display is already on
bool turnDisplayOn();
@@ -68,7 +69,8 @@ public:
int drawModifierBadges(CardputerDisplay &lcd, int x, int y);
void showAlert(const char *text, int duration_millis);
void dismissAlert();
bool isAlertActive();
bool isAlertActive() const;
bool isChatVisible() const;
inline int getMsgCount() const { return unsynced_msg_count; }
inline bool hasDisplay() const { return _display != NULL; }
inline bool isBuzzerQuiet() { return _node_prefs->buzzer_quiet; }
@@ -88,7 +90,9 @@ public:
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 onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text);
void onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text);
void onContactMessageSent(const ContactInfo &contact, const char *text);
void onContactMessageRecv(const ContactInfo &contact, const char *text);
void onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state);
@@ -92,6 +92,7 @@ void MainScreen::renderStatusIcons(CardputerDisplay &lcd) {
}
int MainScreen::render(CardputerDisplay &lcd) {
if (_task->isChatVisible()) static_cast<ChatPage *>(chat_page)->markRead(unread);
lcd.setTextSize(1);
renderStatusIcons(lcd);
@@ -139,14 +140,22 @@ void MainScreen::messageRepeatsRecv(uint16_t count) {
}
}
void MainScreen::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) {
bool to_selected = static_cast<ChatPage *>(chat_page)->onChannelMessageRecv(channel, text);
void MainScreen::onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text) {
bool to_selected = static_cast<ChatPage *>(chat_page)->onChannelMessageRecv(channel_idx, history_id, text);
if (!to_selected) {
unread.addChannel(channel.secret, 1);
unread.addChannel(history_id, 1);
}
}
void MainScreen::onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text) {
static_cast<ChatPage *>(chat_page)->onChannelMessageSent(channel_idx, history_id, text);
}
void MainScreen::onContactMessageSent(const ContactInfo &contact, const char *text) {
static_cast<ChatPage *>(chat_page)->onContactMessageSent(contact, text);
}
void MainScreen::onContactMessageRecv(const ContactInfo &contact, const char *text) {
bool to_selected = static_cast<ChatPage *>(chat_page)->onContactMessageRecv(contact, text);
@@ -244,9 +253,11 @@ void MainScreen::selectContact(ContactInfo &contact, int idx) {
}
void MainScreen::selectChannel(const ChannelDetails &channel, int idx) {
uint8_t history_id[PUB_KEY_SIZE];
if (!CardputerMesh::channelHistoryId(channel, history_id)) return;
setCurrentPage(MainScreenPage::CHAT);
page_to_return = MainScreenPage::CHANNELS;
unread.resetChannel(channel.channel.secret);
unread.resetChannel(history_id);
static_cast<ChatPage *>(chat_page)->selectChannel(channel, idx);
}
@@ -48,8 +48,10 @@ 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 onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text);
void onContactMessageRecv(const ContactInfo &contact, const char *text);
void onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text);
void onContactMessageSent(const ContactInfo &contact, const char *text);
void onMessageSendAttempt(uint8_t attempt, uint8_t total, MessageSendState state);
void poll() override;
@@ -139,7 +139,7 @@ bool SettingsScreen::enterItemEdit(SettingsItem item) {
return true;
case SettingsItem::RadioPwr:
edit_u8 = _node_prefs->tx_power_dbm;
edit_tx_power = _node_prefs->tx_power_dbm;
is_editing = true;
return true;
@@ -236,9 +236,10 @@ bool SettingsScreen::commitItemEdit(SettingsItem item) {
case SettingsItem::RadioCr:
return commitRadio(_node_prefs->freq, _node_prefs->bw, _node_prefs->sf, edit_u8);
case SettingsItem::RadioPwr: {
if (!radio_driver.isTxPowerValid(static_cast<int8_t>(edit_u8))) return false;
if (edit_tx_power < INT8_MIN || edit_tx_power > INT8_MAX
|| !radio_driver.isTxPowerValid(static_cast<int8_t>(edit_tx_power))) return false;
char command[24];
snprintf(command, sizeof(command), "set tx %d", static_cast<int8_t>(edit_u8));
snprintf(command, sizeof(command), "set tx %d", static_cast<int>(edit_tx_power));
return commitCommand(command);
}
case SettingsItem::MeshPathSize: {
@@ -295,10 +296,12 @@ void SettingsScreen::handleEditInput(SettingsItem item, Keyboard::Event &e) {
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++;
if (e.key == Keyboard::ARROW_LEFT && edit_tx_power > INT8_MIN
&& radio_driver.isTxPowerValid(static_cast<int8_t>(edit_tx_power - 1))) {
--edit_tx_power;
} else if (e.key == Keyboard::ARROW_RIGHT && edit_tx_power < INT8_MAX
&& radio_driver.isTxPowerValid(static_cast<int8_t>(edit_tx_power + 1))) {
++edit_tx_power;
}
break;
case SettingsItem::MeshPathSize:
@@ -436,12 +439,16 @@ int SettingsScreen::render(CardputerDisplay &display) {
switch (menu_index) {
case SettingsItem::RadioSf:
case SettingsItem::RadioCr:
case SettingsItem::RadioPwr:
case SettingsItem::MeshPathSize:
display.setTextSize(2);
snprintf(buf, sizeof(buf), "- %d +", edit_u8);
display.drawTextCentered(display.width() / 2, value_y, buf);
break;
case SettingsItem::RadioPwr:
display.setTextSize(2);
snprintf(buf, sizeof(buf), "- %d +", static_cast<int>(edit_tx_power));
display.drawTextCentered(display.width() / 2, value_y, buf);
break;
case SettingsItem::PublicInfoName:
case SettingsItem::RadioFreq:
case SettingsItem::RadioBw:
@@ -35,6 +35,7 @@ class SettingsScreen : public CardputerScreen {
char edit_error[80] = "Invalid value";
String edit_buffer;
uint8_t edit_u8;
int32_t edit_tx_power = 0;
static constexpr size_t MAX_NUMERIC_EDIT_BYTES = 31;
void renderItem(CardputerDisplay &display, SettingsItem item, int x, int y);
@@ -84,19 +84,15 @@ void ToolsScreen::menuItemEnter(ToolsMenuItem item) {
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::PowerOff:
power_off_pending = true;
power_off_released = false;
power_off_deadline = static_cast<uint32_t>(millis()) + POWER_OFF_RELEASE_TIMEOUT_MS;
_task->showAlert("Release Enter to power off", POWER_OFF_RELEASE_TIMEOUT_MS);
break;
case ToolsMenuItem::Restart:
ESP.restart();
_task->shutdown(true);
break;
default:
@@ -104,6 +100,29 @@ void ToolsScreen::menuItemEnter(ToolsMenuItem item) {
}
}
bool ToolsScreen::handlePowerOffInput(const Keyboard::Event &e) {
if (!power_off_pending) return false;
if (e.changed && e.key == Keyboard::KEY_RETURN && !e.down) {
power_off_released = true;
} else if (e.changed && e.key == Keyboard::KEY_ESC && e.down) {
power_off_pending = false;
_task->dismissAlert();
}
return true;
}
void ToolsScreen::poll() {
if (!power_off_pending) return;
if (power_off_released) {
power_off_pending = false;
_task->dismissAlert();
_task->shutdown();
} else if (static_cast<int32_t>(static_cast<uint32_t>(millis()) - power_off_deadline) >= 0) {
power_off_pending = false;
_task->showAlert("Power off cancelled: release Enter", 2000);
}
}
bool ToolsScreen::handleInput(Keyboard::Event &e) {
if (_task->isAlertActive()) {
return false;
@@ -37,6 +37,10 @@ class ToolsScreen : public CardputerScreen {
ToolsPage page = ToolsPage::MenuPage;
int menu_index = 0;
bool power_off_pending = false;
bool power_off_released = false;
uint32_t power_off_deadline = 0;
static constexpr uint32_t POWER_OFF_RELEASE_TIMEOUT_MS = 2000;
DiscoveredRepeater discovered_repeaters[MAX_DISCOVERED_REPEATERS];
uint16_t discovered_repeaters_count = 0;
@@ -46,6 +50,9 @@ public:
int render(CardputerDisplay &lcd) override;
void menuItemEnter(ToolsMenuItem item);
bool handleInput(Keyboard::Event &e) override;
bool handlePowerOffInput(const Keyboard::Event &e);
bool isPowerOffPending() const { return power_off_pending; }
void poll() override;
void discoverRecv(const mesh::Identity &id, float snr);
void startDiscover() { menuItemEnter(ToolsMenuItem::DiscoverRepeaters); }
@@ -51,10 +51,12 @@ int ChannelsPage::render(CardputerDisplay &lcd) {
}
int unread_count = 0;
uint8_t history_id[PUB_KEY_SIZE];
if (!CardputerMesh::channelHistoryId(channel, history_id)) continue;
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) {
if (item && item->is_channel && memcmp(item->pkey, history_id, sizeof(history_id)) == 0) {
unread_count = item->count;
break;
}
@@ -111,10 +111,6 @@ void ChatPage::sendChatMessage() {
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;
@@ -129,11 +125,6 @@ void ChatPage::sendChatMessage() {
bool ok = the_mesh_cp.sendChannelMessage(current_channel_idx, ts,
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;
@@ -232,10 +223,11 @@ void ChatPage::selectContact(const ContactInfo &contact, int idx) {
}
void ChatPage::selectChannel(const ChannelDetails &channel, int idx) {
if (!CardputerMesh::channelHistoryId(channel, current_channel_history_id)) return;
current_channel = channel;
current_channel_idx = idx;
current_contact_idx = -1;
the_mesh_cp.loadMessageHistory(channel.channel.secret, true, chat_history);
the_mesh_cp.loadMessageHistory(current_channel_history_id, true, chat_history);
chat_history_offset = 0;
last_sent_message = "";
@@ -248,15 +240,15 @@ void ChatPage::selectChannel(const ChannelDetails &channel, int idx) {
}
}
bool ChatPage::onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text) {
bool ChatPage::onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text) {
(void)channel_idx; // Identical logical channels can occupy more than one slot.
if (current_channel_idx != -1 &&
memcmp(channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) == 0) {
memcmp(history_id, current_channel_history_id, PUB_KEY_SIZE) == 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 _p->getUiTask()->isChatVisible();
}
return false;
}
@@ -268,11 +260,35 @@ bool ChatPage::onContactMessageRecv(const ContactInfo &contact, const char *text
msg->out = false;
snprintf(msg->text, MAX_MESSAGE_LENGTH, "%s", text);
chat_history_offset = 0;
return true;
return _p->getUiTask()->isChatVisible();
}
return false;
}
void ChatPage::onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text) {
(void)channel_idx;
if (current_channel_idx == -1 || memcmp(history_id, current_channel_history_id, PUB_KEY_SIZE) != 0) return;
HistoryMessage *msg = chat_history.push_ref();
msg->out = true;
snprintf(msg->text, sizeof(msg->text), "%s", text);
last_sent_message = text;
chat_history_offset = 0;
}
void ChatPage::onContactMessageSent(const ContactInfo &contact, const char *text) {
if (current_contact_idx == -1 || memcmp(contact.id.pub_key, current_contact.id.pub_key, PUB_KEY_SIZE) != 0) return;
HistoryMessage *msg = chat_history.push_ref();
msg->out = true;
snprintf(msg->text, sizeof(msg->text), "%s", text);
last_sent_message = text;
chat_history_offset = 0;
}
void ChatPage::markRead(UnreadCounter &unread) const {
if (current_contact_idx != -1) unread.resetContact(current_contact.id.pub_key);
if (current_channel_idx != -1) unread.resetChannel(current_channel_history_id);
}
void ChatPage::refreshSelectedContact() {
if (current_contact_idx != -1) {
ContactInfo *contact = the_mesh_cp.lookupPersistentContactByPubKey(current_contact.id.pub_key, PUB_KEY_SIZE);
@@ -291,8 +307,10 @@ void ChatPage::refreshSelectedContact() {
bool ChatPage::refreshSelectedChannel() {
if (current_channel_idx == -1) return false;
ChannelDetails channel;
uint8_t history_id[PUB_KEY_SIZE];
if (!the_mesh_cp.getChannel(current_channel_idx, channel) || !channel.name[0]
|| memcmp(channel.channel.secret, current_channel.channel.secret, PUB_KEY_SIZE) != 0) {
|| !CardputerMesh::channelHistoryId(channel, history_id)
|| memcmp(history_id, current_channel_history_id, PUB_KEY_SIZE) != 0) {
current_channel_idx = -1;
current_channel = ChannelDetails{};
chat_history.clear();
@@ -11,6 +11,7 @@ private:
String last_sent_message;
ChannelDetails current_channel;
uint8_t current_channel_history_id[PUB_KEY_SIZE] = {};
int current_channel_idx = -1;
ContactInfo current_contact;
@@ -29,8 +30,11 @@ public:
void selectContact(const ContactInfo &contact, int idx);
void selectChannel(const ChannelDetails &channel, int idx);
bool onChannelMessageRecv(const mesh::GroupChannel &channel, const char *text);
bool onChannelMessageRecv(uint8_t channel_idx, const uint8_t history_id[32], const char *text);
bool onContactMessageRecv(const ContactInfo &contact, const char *text);
void onChannelMessageSent(uint8_t channel_idx, const uint8_t history_id[32], const char *text);
void onContactMessageSent(const ContactInfo &contact, const char *text);
void markRead(UnreadCounter &unread) const;
void refreshSelectedContact();
};
@@ -75,7 +75,7 @@ int ContactsPage::render(CardputerDisplay &lcd) {
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) {
if (item && !item->is_channel && memcmp(item->pkey, contact.id.pub_key, PUB_KEY_SIZE) == 0) {
unread_count = item->count;
break;
}
@@ -4,24 +4,36 @@
#include <Wire.h>
#include <driver/i2s.h> // because i2s_std.h not exists for some reason
#include <cmath>
void CardputerSpeaker::initI2C() {
namespace {
bool due(uint32_t now, uint32_t deadline) {
return static_cast<int32_t>(now - deadline) >= 0;
}
const uint32_t AUDIO_STALL_MS = 1000;
const uint32_t AUDIO_DMA_DRAIN_MS =
(4UL * AUDIO_BUFFER_SIZE * 1000UL + AUDIO_SAMPLE_RATE - 1) / AUDIO_SAMPLE_RATE;
}
bool 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
// Mark partial initialization too, so ready() can power it down on failure.
i2c_initialized = true;
if (!writeReg(0x00, 0x80) // RESET / CSM POWER ON
|| !writeReg(0x01, 0xB5) // CLOCK_MANAGER / MCLK=BCLK
|| !writeReg(0x02, 0x18) // CLOCK_MANAGER / MULT_PRE=3
|| !writeReg(0x0D, 0x01) // Power up analog circuitry
|| !writeReg(0x12, 0x00) // Power up DAC
|| !writeReg(0x13, 0x10) // Enable output to HP drive
|| !writeReg(0x32, 0xBF) // DAC volume (+/-0 dB)
|| !writeReg(0x37, 0x08)) return false; // Bypass DAC equalizer
// Codec is not ready immediately, it need some time to stabilize
busy_time = millis() + AUDIO_WARMUP_MS;
i2c_initialized = true;
return true;
}
void CardputerSpeaker::initI2S() {
bool 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,
@@ -41,10 +53,9 @@ void CardputerSpeaker::initI2S() {
.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);
if (i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL) != ESP_OK) return false;
i2s_initialized = true;
return i2s_set_pin(I2S_NUM_0, &pin_config) == ESP_OK;
}
bool CardputerSpeaker::writeReg(uint8_t reg, uint8_t val) {
@@ -54,51 +65,23 @@ bool CardputerSpeaker::writeReg(uint8_t reg, uint8_t val) {
return Wire1.endTransmission() == 0;
}
void CardputerSpeaker::begin() {
if (!i2s_initialized) {
initI2S();
}
if (!i2c_initialized) {
initI2C();
bool CardputerSpeaker::ready() {
if ((!i2s_initialized && !initI2S()) || (!i2c_initialized && !initI2C())) {
sleep();
return false;
}
return true;
}
void CardputerSpeaker::playTone(int32_t frequency_hz, uint32_t duration_ms, float volume) {
begin();
void CardputerSpeaker::begin() { (void)ready(); }
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::playTone(int32_t hz, uint32_t duration_ms, float level) {
queueTone(hz, duration_ms, level);
}
void CardputerSpeaker::sleep() {
tone_pending = draining = false;
total_bytes = bytes_sent = 0;
if (i2c_initialized) {
writeReg(0x0E, 0xFF); // SYSTEM
writeReg(0x12, 0x02); // SYSTEM
@@ -112,19 +95,63 @@ void CardputerSpeaker::sleep() {
}
}
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::queueTone(int32_t hz, uint32_t duration_ms, float level) {
if (hz <= 0 || hz > AUDIO_SAMPLE_RATE / 2 || !std::isfinite(level)
|| level <= 0 || duration_ms == 0) {
sleep();
return;
}
if (!ready()) return;
frequency_hz = hz;
volume = level > 1.0f ? 1.0f : level;
bytes_sent = 0;
total_bytes = (uint64_t(AUDIO_SAMPLE_RATE) * duration_ms / 1000ULL) * sizeof(int16_t);
tone_pending = total_bytes != 0;
draining = false;
// A replacement discards all unsent old samples. Already submitted DMA is
// bounded to four chunks and can finish without stalling the mesh loop.
last_progress = millis();
}
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);
const uint32_t now = millis();
if (draining && due(now, drain_until)) draining = false;
if (!tone_pending || !due(now, busy_time)) return;
// Regenerate the unsubmitted suffix from its exact byte offset. This also
// handles an odd-byte partial write without retaining a heap/instance buffer.
const uint64_t first_sample = bytes_sent / sizeof(int16_t);
const size_t byte_offset = bytes_sent % sizeof(int16_t);
const uint64_t remaining_samples = (total_bytes / sizeof(int16_t)) - first_sample;
const size_t count = remaining_samples < AUDIO_BUFFER_SIZE
? size_t(remaining_samples) : AUDIO_BUFFER_SIZE;
int16_t audio_buffer[AUDIO_BUFFER_SIZE];
const double increment = 2.0 * M_PI * frequency_hz / AUDIO_SAMPLE_RATE;
double phase = std::fmod(double(first_sample) * increment, 2.0 * M_PI);
const int16_t amplitude = static_cast<int16_t>(volume * AUDIO_MAX_SAFE_VOLUME);
for (size_t i = 0; i < count; ++i) {
audio_buffer[i] = static_cast<int16_t>(std::sin(phase) * amplitude);
phase += increment;
if (phase >= 2.0 * M_PI) phase -= 2.0 * M_PI;
}
const size_t requested = count * sizeof(int16_t) - byte_offset;
size_t written = 0;
const esp_err_t result = i2s_write(I2S_NUM_0,
reinterpret_cast<const uint8_t*>(audio_buffer) + byte_offset, requested, &written, 0);
if ((result != ESP_OK && result != ESP_ERR_TIMEOUT) || written > requested) {
sleep();
return;
}
if (written != 0) {
bytes_sent += written;
last_progress = now;
if (bytes_sent == total_bytes) {
tone_pending = false;
draining = true;
drain_until = now + AUDIO_DMA_DRAIN_MS;
}
} else if (static_cast<uint32_t>(now - last_progress) >= AUDIO_STALL_MS + AUDIO_WARMUP_MS) {
// A wedged driver must not hold the node awake indefinitely.
sleep();
}
}
@@ -2,36 +2,36 @@
#include <stdint.h>
/** Basic ES8311 blocking driver */
/** ES8311 tone driver serviced in bounded foreground chunks. */
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;
bool tone_pending = false;
bool draining = false;
uint32_t busy_time = 0;
queue_tone_t queue_tone;
uint32_t last_progress = 0;
uint32_t drain_until = 0;
uint64_t bytes_sent = 0;
uint64_t total_bytes = 0;
int32_t frequency_hz = 0;
float volume = 0;
void initI2C();
void initI2S();
bool initI2C();
bool initI2S();
bool ready();
bool writeReg(uint8_t reg, uint8_t val);
public:
CardputerSpeaker() {}
void begin();
// Both entry points queue playback; neither waits for audio to finish.
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);
// Cancel pending playback, stop DMA and power down the codec.
void sleep();
// At most one AUDIO_BUFFER_SIZE chunk and one zero-timeout write per call.
void processQueue();
/** True while a queued tone has not been played yet. */
bool isBusy() const { return !queue_tone.done; }
bool isBusy() const { return tone_pending || draining; }
};
@@ -47,6 +47,7 @@ build_flags = ${esp32_base.build_flags}
-D LORA_TX_POWER=22
build_src_filter = ${esp32_base.build_src_filter}
+<helpers/sensors/EnvironmentSensorManager.cpp>
+<../variants/m5stack_cardputer_adv/CardputerSensorManager.cpp>
+<../variants/m5stack_cardputer_adv/target.cpp>
lib_deps =
${esp32_base.lib_deps}
+5 -3
View File
@@ -14,13 +14,15 @@ WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
#if CARDPUTER_ADV_UI
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
static MicroNMEALocationProvider nmea(Serial1, &rtc_clock);
CardputerSensorManager sensors(nmea);
CardputerDisplay display;
#else
EnvironmentSensorManager sensors;
CardputerSensorManager sensors;
#endif
#if CARDPUTER_ADV_UI
CardputerDisplay display;
#endif
bool radio_init() {
+2 -6
View File
@@ -8,13 +8,11 @@
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include "CardputerAdvBoard.h"
#include "CardputerSensorManager.h"
#if CARDPUTER_ADV_UI
#include <SD.h>
#include "CardputerSensorManager.h"
#include "hw/CardputerDisplay.h"
#else
#include <helpers/sensors/EnvironmentSensorManager.h>
#endif
extern CardputerAdvBoard board;
@@ -23,11 +21,9 @@ 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;
#if CARDPUTER_ADV_UI
extern CardputerDisplay display;
#else
extern EnvironmentSensorManager sensors;
#endif
bool radio_init();