Fix Cardputer history, notifications and hardware lifecycle
This commit is contained in:
1 parent
9a2211e85a
commit
1e22f6326d
36 files changed
+1558
-218
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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([
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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=¬ifications;
|
||||
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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
+15
-8
@@ -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);
|
||||
|
||||
+30
-11
@@ -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); }
|
||||
|
||||
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
|
||||
+34
-16
@@ -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();
|
||||
};
|
||||
+1
-1
@@ -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}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user