Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae3c4ed2cd | ||
|
|
50ac915fd4 | ||
|
|
12fb0be2d2 | ||
|
|
cd9489969f |
@@ -609,35 +609,20 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing.
|
||||
|
||||
### Native unit tests (C++)
|
||||
|
||||
Unit tests in `test/` directory with 17 test suites:
|
||||
Unit tests in `test/` directory with 12 test suites:
|
||||
|
||||
- `test_admin_radio/` - LoRa region/config validation and AdminModule dispatch
|
||||
- `test_atak/` - ATAK integration
|
||||
- `test_crypto/` - Cryptography
|
||||
- `test_default/` - Default configuration
|
||||
- `test_http_content_handler/` - HTTP handling
|
||||
- `test_mac_from_string/` - MAC address parsing
|
||||
- `test_mqtt/` - MQTT integration
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_mesh_module/` - Module framework
|
||||
- `test_meshpacket_serializer/` - Packet serialization
|
||||
- `test_mqtt/` - MQTT integration
|
||||
- `test_packet_history/` - Packet history tracking
|
||||
- `test_position_precision/` - Position precision helpers
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_serial/` - Serial communication
|
||||
- `test_traffic_management/` - Traffic management
|
||||
- `test_transmit_history/` - Retransmission tracking
|
||||
- `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite)
|
||||
- `test_utf8/` - UTF-8 utilities
|
||||
- `test_atak/` - ATAK integration
|
||||
- `test_default/` - Default configuration
|
||||
- `test_http_content_handler/` - HTTP handling
|
||||
- `test_serial/` - Serial communication
|
||||
|
||||
Run command (preferred — avoids pipe-buffering and the Ubuntu externally-managed-environment error):
|
||||
|
||||
```bash
|
||||
~/.platformio/penv/bin/python -m platformio test -e native -f test_your_suite > /tmp/test_out.txt 2>&1
|
||||
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
|
||||
tail -15 /tmp/test_out.txt
|
||||
```
|
||||
|
||||
Do **not** use `pio test … | tail -N` — it discards build errors and shows stale cached results. Do **not** use `pio test … | grep` — line-buffering makes the terminal appear hung until the process exits. Redirect to a file first, then grep.
|
||||
Run with: `pio test -e native`
|
||||
|
||||
Simulation testing: `bin/test-simulator.sh`
|
||||
|
||||
|
||||
@@ -10,18 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
|
||||
|
||||
## Quick command reference
|
||||
|
||||
| Action | Command |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `~/.platformio/penv/bin/python -m platformio test -e native > /tmp/test_out.txt 2>&1` then `grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt` (redirect first — piping causes line-buffering) |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
| Action | Command |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `pio test -e native` |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
|
||||
## MCP server (device + test automation)
|
||||
|
||||
@@ -108,7 +108,7 @@ Sequence these; don't parallelize on the same port.
|
||||
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
|
||||
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
|
||||
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
|
||||
| `test/` | Firmware unit tests (17 suites; `pio test -e native`) |
|
||||
| `test/` | Firmware unit tests (12 suites; `pio test -e native`) |
|
||||
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
|
||||
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` |
|
||||
| `.claude/commands/` | Claude Code slash command bodies |
|
||||
|
||||
@@ -208,10 +208,16 @@ lib_deps =
|
||||
https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MAX1704X packageName=adafruit/Adafruit_MAX1704X
|
||||
https://github.com/adafruit/Adafruit_MAX1704X/archive/refs/tags/1.0.3.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHTC3 packageName=adafruit/Adafruit_SHTC3
|
||||
https://github.com/adafruit/Adafruit_SHTC3/archive/refs/tags/1.0.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit LPS2X packageName=adafruit/Adafruit_LPS2X
|
||||
https://github.com/adafruit/Adafruit_LPS2X/archive/refs/tags/2.0.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHT31 packageName=adafruit/Adafruit_SHT31
|
||||
https://github.com/adafruit/Adafruit_SHT31/archive/refs/tags/2.2.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit VEML7700 packageName=adafruit/Adafruit_VEML7700
|
||||
https://github.com/adafruit/Adafruit_VEML7700/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHT4x packageName=adafruit/Adafruit_SHT4X
|
||||
https://github.com/adafruit/Adafruit_SHT4X/archive/refs/tags/1.0.5.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun Qwiic Scale NAU7802 packageName=sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library
|
||||
https://github.com/sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library/archive/refs/tags/v1.0.6.zip
|
||||
# renovate: datasource=custom.pio depName=ClosedCube OPT3001 packageName=closedcube/library/ClosedCube OPT3001
|
||||
|
||||
+1
-1
Submodule protobufs updated: 8b68f27367...519a0c7c9c
@@ -1,5 +1,5 @@
|
||||
#include "configuration.h"
|
||||
#if HAS_SCREEN
|
||||
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
||||
#include "FSCommon.h"
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#if HAS_SCREEN
|
||||
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
|
||||
|
||||
// Disable debug logging entirely on release builds of HELTEC_MESH_SOLAR for space constraints
|
||||
#if defined(HELTEC_MESH_SOLAR)
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
FastEPD buffer format: 1bpp, horizontal bytes, MSB = leftmost pixel, 1 = white
|
||||
|
||||
Both formats share the same pixel layout and polarity (1 = white, 0 = black).
|
||||
The InkHUD safe-area buffer (928×508) is copied into the centre of the physical
|
||||
The InkHUD safe-area buffer (944×523) is copied into the centre of the physical
|
||||
960×540 FastEPD buffer so content clears the panel's inactive edge border.
|
||||
See ED047TC1.h for the H_OFFSET_BYTES / V_OFFSET_TOP / V_OFFSET_BOTTOM constants.
|
||||
|
||||
*/
|
||||
|
||||
// Ruler diagnostic — uncomment to draw calibration lines at each physical edge.
|
||||
// #define EINK_EDGE_LINES
|
||||
|
||||
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
|
||||
#ifdef T5_S3_EPAPER_PRO
|
||||
|
||||
@@ -209,6 +212,35 @@ void ED047TC1::update(uint8_t *imageData, UpdateTypes type)
|
||||
memcpy(dstRow, srcRow, srcRowBytes);
|
||||
}
|
||||
|
||||
#ifdef EINK_EDGE_LINES
|
||||
// Draw a 1px black box at the exact boundary of the safe area within the
|
||||
// physical buffer. If the margins are correct, all 4 lines should be
|
||||
// fully visible and right at the edge of the usable display area.
|
||||
|
||||
auto setPixelBlack = [&](uint32_t col, uint32_t row) { cur[row * dstRowBytes + col / 8] &= ~(0x80 >> (col % 8)); };
|
||||
|
||||
const uint32_t safeX = H_OFFSET_BYTES * 8;
|
||||
const uint32_t safeY = V_OFFSET_TOP;
|
||||
const uint32_t safeW = DISPLAY_WIDTH;
|
||||
const uint32_t safeH = DISPLAY_HEIGHT;
|
||||
|
||||
// Top edge: horizontal line at safeY
|
||||
for (uint32_t col = safeX; col < safeX + safeW; col++)
|
||||
setPixelBlack(col, safeY);
|
||||
|
||||
// Bottom edge: horizontal line at safeY + safeH - 1
|
||||
for (uint32_t col = safeX; col < safeX + safeW; col++)
|
||||
setPixelBlack(col, safeY + safeH - 1);
|
||||
|
||||
// Left edge: vertical line at safeX
|
||||
for (uint32_t row = safeY; row < safeY + safeH; row++)
|
||||
setPixelBlack(safeX, row);
|
||||
|
||||
// Right edge: vertical line at safeX + safeW - 1
|
||||
for (uint32_t row = safeY; row < safeY + safeH; row++)
|
||||
setPixelBlack(safeX + safeW - 1, row);
|
||||
#endif
|
||||
|
||||
if (type == FULL) {
|
||||
epaper->fullUpdate(CLEAR_SLOW, false);
|
||||
epaper->backupPlane(); // Sync pPrevious so next partialUpdate has a correct baseline
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
V_OFFSET_TOP and V_OFFSET_BOTTOM (vertical, pixel rows) to position it.
|
||||
|
||||
Changing these constants shifts content inward from each physical edge:
|
||||
H_OFFSET_BYTES = 2 → 16px left margin, 16px right margin (960 – 16 – 16 = 928)
|
||||
V_OFFSET_TOP = 16 → 16px top margin
|
||||
V_OFFSET_BOTTOM = 16 → 16px bottom margin (540 – 16 – 16 = 508)
|
||||
H_OFFSET_BYTES = 1 → 8px left margin, 8px right margin (960 – 8 – 8 = 944)
|
||||
V_OFFSET_TOP = 9 → 9px top margin (asymmetric: top ≠ bottom)
|
||||
V_OFFSET_BOTTOM = 8 → 8px bottom margin (540 – 9 – 8 = 523)
|
||||
|
||||
*/
|
||||
|
||||
@@ -61,13 +61,13 @@ class ED047TC1 : public EInk
|
||||
//
|
||||
// Calibrated by flashing a 1px border box and adjusting until all 4 sides are visible.
|
||||
|
||||
static constexpr uint16_t DISPLAY_WIDTH = 928; // 960 − H_OFFSET_BYTES×8 − right_margin (16+16 = 32px)
|
||||
static constexpr uint16_t DISPLAY_HEIGHT = 508; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (16+16 = 32px)
|
||||
static constexpr uint16_t DISPLAY_WIDTH = 944; // 960 − H_OFFSET_BYTES×8 − right_margin (8+8 = 16px)
|
||||
static constexpr uint16_t DISPLAY_HEIGHT = 523; // 540 − V_OFFSET_TOP − V_OFFSET_BOTTOM (9+8 = 17px)
|
||||
|
||||
static constexpr uint8_t H_OFFSET_BYTES = 2; // visual TOP : 16px physical left margin
|
||||
// visual BOTTOM: 960−16−928=16px physical right margin
|
||||
static constexpr uint8_t V_OFFSET_TOP = 16; // visual RIGHT : 16px physical top margin
|
||||
static constexpr uint8_t V_OFFSET_BOTTOM = 16; // visual LEFT : 16px physical bottom margin
|
||||
static constexpr uint8_t H_OFFSET_BYTES = 1; // visual TOP : 8px physical left margin
|
||||
// visual BOTTOM: 960−8−944=8px physical right margin
|
||||
static constexpr uint8_t V_OFFSET_TOP = 9; // visual RIGHT : CONFIRMED OK
|
||||
static constexpr uint8_t V_OFFSET_BOTTOM = 8; // visual LEFT : 8px physical bottom margin
|
||||
|
||||
static constexpr UpdateTypes supported = static_cast<UpdateTypes>(FULL | FAST);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "./NotificationApplet.h"
|
||||
|
||||
#include "./Notification.h"
|
||||
#include "MessageStore.h"
|
||||
#include "graphics/niche/InkHUD/Persistence.h"
|
||||
|
||||
#include "meshUtils.h"
|
||||
@@ -231,7 +232,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
|
||||
bool msgIsBroadcast = currentNotification.type == Notification::Type::NOTIFICATION_MESSAGE_BROADCAST;
|
||||
|
||||
// Pick source of message
|
||||
const MessageStore::Message *message =
|
||||
const StoredMessage *message =
|
||||
msgIsBroadcast ? &inkhud->persistence->latestMessage.broadcast : &inkhud->persistence->latestMessage.dm;
|
||||
|
||||
// Find info about the sender
|
||||
@@ -261,7 +262,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
|
||||
text += hexifyNodeNum(message->sender);
|
||||
|
||||
text += ": ";
|
||||
text += message->text;
|
||||
text += MessageStore::getText(*message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "./AllMessageApplet.h"
|
||||
|
||||
#include "MessageStore.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
void InkHUD::AllMessageApplet::onActivate()
|
||||
@@ -37,7 +39,7 @@ int InkHUD::AllMessageApplet::onReceiveTextMessage(const meshtastic_MeshPacket *
|
||||
void InkHUD::AllMessageApplet::onRender(bool full)
|
||||
{
|
||||
// Find newest message, regardless of whether DM or broadcast
|
||||
MessageStore::Message *message;
|
||||
StoredMessage *message;
|
||||
if (latestMessage->wasBroadcast)
|
||||
message = &latestMessage->broadcast;
|
||||
else
|
||||
@@ -96,7 +98,7 @@ void InkHUD::AllMessageApplet::onRender(bool full)
|
||||
// ===================
|
||||
|
||||
// Parse any non-ascii chars in the message
|
||||
std::string text = parse(message->text);
|
||||
std::string text = parse(std::string(MessageStore::getText(*message)));
|
||||
|
||||
// Extra gap below the header
|
||||
int16_t textTop = headerDivY + padDivH;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "./DMApplet.h"
|
||||
|
||||
#include "MessageStore.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
void InkHUD::DMApplet::onActivate()
|
||||
@@ -92,7 +94,7 @@ void InkHUD::DMApplet::onRender(bool full)
|
||||
// ===================
|
||||
|
||||
// Parse any non-ascii chars in the message
|
||||
std::string text = parse(latestMessage->dm.text);
|
||||
std::string text = parse(std::string(MessageStore::getText(latestMessage->dm)));
|
||||
|
||||
// Extra gap below the header
|
||||
int16_t textTop = headerDivY + padDivH;
|
||||
|
||||
@@ -7,19 +7,9 @@
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
// Hard limits on how much message data to write to flash
|
||||
// Avoid filling the storage if something goes wrong
|
||||
// Normal usage should be well below this size
|
||||
constexpr uint8_t MAX_MESSAGES_SAVED = 10;
|
||||
constexpr uint32_t MAX_MESSAGE_SIZE = 250;
|
||||
|
||||
InkHUD::ThreadedMessageApplet::ThreadedMessageApplet(uint8_t channelIndex)
|
||||
: SinglePortModule("ThreadedMessageApplet", meshtastic_PortNum_TEXT_MESSAGE_APP), channelIndex(channelIndex)
|
||||
{
|
||||
// Create the message store
|
||||
// Will shortly attempt to load messages from RAM, if applet is active
|
||||
// Label (filename in flash) is set from channel index
|
||||
store = new MessageStore("ch" + to_string(channelIndex));
|
||||
}
|
||||
|
||||
void InkHUD::ThreadedMessageApplet::onRender(bool full)
|
||||
@@ -61,17 +51,24 @@ void InkHUD::ThreadedMessageApplet::onRender(bool full)
|
||||
const uint16_t msgW = (msgR - msgL) + 1;
|
||||
|
||||
int16_t msgB = height() - 1; // Vertical cursor for drawing. Messages are bottom-aligned to this value.
|
||||
uint8_t i = 0; // Index of stored message
|
||||
|
||||
// Loop over messages
|
||||
// - until no messages left, or
|
||||
// - until no part of message fits on screen
|
||||
while (msgB >= (0 - fontSmall.lineHeight()) && i < store->messages.size()) {
|
||||
// Iterate the global store newest-first, showing only broadcast messages on our channel
|
||||
const auto &allMessages = messageStore.getLiveMessages();
|
||||
int msgIdx = (int)allMessages.size() - 1;
|
||||
|
||||
while (msgB >= (0 - fontSmall.lineHeight()) && msgIdx >= 0) {
|
||||
|
||||
const StoredMessage &m = allMessages.at(msgIdx);
|
||||
|
||||
// Skip messages that don't belong to this channel or are DMs
|
||||
if (m.type != MessageType::BROADCAST || m.channelIndex != channelIndex) {
|
||||
msgIdx--;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Grab data for message
|
||||
const MessageStore::Message &m = store->messages.at(i);
|
||||
bool outgoing = (m.sender == 0) || (m.sender == myNodeInfo.my_node_num); // Own NodeNum if canned message
|
||||
std::string bodyText = parse(m.text); // Parse any non-ascii chars in the message
|
||||
bool outgoing = (m.sender == myNodeInfo.my_node_num);
|
||||
std::string bodyText = parse(std::string(MessageStore::getText(m))); // Parse any non-ascii chars
|
||||
|
||||
// Cache bottom Y of message text
|
||||
// - Used when drawing vertical line alongside
|
||||
@@ -152,18 +149,13 @@ void InkHUD::ThreadedMessageApplet::onRender(bool full)
|
||||
// Move cursor up: padding before next message
|
||||
msgB -= fontSmall.lineHeight() * 0.5;
|
||||
|
||||
i++;
|
||||
msgIdx--;
|
||||
} // End of loop: drawing each message
|
||||
|
||||
// Fade effect:
|
||||
// Area immediately below the divider. Overdraw with sparse white lines.
|
||||
// Make text appear to pass behind the header
|
||||
hatchRegion(0, dividerY + 1, width(), fontSmall.lineHeight() / 3, 2, WHITE);
|
||||
|
||||
// If we've run out of screen to draw messages, we can drop any leftover data from the queue
|
||||
// Those messages have been pushed off the screen-top by newer ones
|
||||
while (i < store->messages.size())
|
||||
store->messages.pop_back();
|
||||
}
|
||||
|
||||
// Code which runs when the applet begins running
|
||||
@@ -198,16 +190,8 @@ ProcessMessage InkHUD::ThreadedMessageApplet::handleReceived(const meshtastic_Me
|
||||
if (mp.to != NODENUM_BROADCAST)
|
||||
return ProcessMessage::CONTINUE;
|
||||
|
||||
// Extract info into our slimmed-down "StoredMessage" type
|
||||
MessageStore::Message newMessage;
|
||||
newMessage.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); // Current RTC time
|
||||
newMessage.sender = mp.from;
|
||||
newMessage.channelIndex = mp.channel;
|
||||
newMessage.text = std::string((const char *)mp.decoded.payload.bytes, mp.decoded.payload.size);
|
||||
|
||||
// Store newest message at front
|
||||
// These records are used when rendering, and also stored in flash at shutdown
|
||||
store->messages.push_front(newMessage);
|
||||
// Store in the global messageStore — this handles sender, timestamp, channel, text, and ack status
|
||||
messageStore.addFromPacket(mp);
|
||||
|
||||
// If this was an incoming message, suggest that our applet becomes foreground, if permitted
|
||||
if (getFrom(&mp) != nodeDB->getNodeNum())
|
||||
@@ -232,37 +216,25 @@ bool InkHUD::ThreadedMessageApplet::approveNotification(Notification &n)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save several recent messages to flash
|
||||
// Stores the contents of ThreadedMessageApplet::messages
|
||||
// Just enough messages to fill the display
|
||||
// Messages are packed "back-to-back", to minimize blocks of flash used
|
||||
// Save messages to flash via the global messageStore.
|
||||
// The global store holds messages for all channels; no per-channel file is needed.
|
||||
void InkHUD::ThreadedMessageApplet::saveMessagesToFlash()
|
||||
{
|
||||
// Create a label (will become the filename in flash)
|
||||
std::string label = "ch" + to_string(channelIndex);
|
||||
|
||||
store->saveToFlash();
|
||||
messageStore.saveToFlash();
|
||||
}
|
||||
|
||||
// Load recent messages to flash
|
||||
// Fills ThreadedMessageApplet::messages with previous messages
|
||||
// Just enough messages have been stored to cover the display
|
||||
// Messages are loaded once by InkHUD::begin() before applets start.
|
||||
// Nothing to do here at per-applet activation time.
|
||||
void InkHUD::ThreadedMessageApplet::loadMessagesFromFlash()
|
||||
{
|
||||
// Create a label (will become the filename in flash)
|
||||
std::string label = "ch" + to_string(channelIndex);
|
||||
|
||||
store->loadFromFlash();
|
||||
// No-op: messageStore.loadFromFlash() is called in InkHUD::begin()
|
||||
}
|
||||
|
||||
// Code to run when device is shutting down
|
||||
// This is in addition to any onDeactivate() code, which will also run
|
||||
// Todo: implement before a reboot also
|
||||
void InkHUD::ThreadedMessageApplet::onShutdown()
|
||||
{
|
||||
// Save our current set of messages to flash, provided the applet isn't disabled
|
||||
if (isActive())
|
||||
saveMessagesToFlash();
|
||||
// messageStore.saveToFlash() is called centrally by Events::beforeDeepSleep / beforeReboot
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -20,8 +20,8 @@ Suggest a max of two channel, to minimize fs usage?
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include "MessageStore.h"
|
||||
#include "graphics/niche/InkHUD/Applet.h"
|
||||
#include "graphics/niche/InkHUD/MessageStore.h"
|
||||
|
||||
#include "modules/TextMessageModule.h"
|
||||
|
||||
@@ -49,7 +49,6 @@ class ThreadedMessageApplet : public Applet, public SinglePortModule
|
||||
void saveMessagesToFlash();
|
||||
void loadMessagesFromFlash();
|
||||
|
||||
MessageStore *store; // Messages, held in RAM for use, ready to save to flash on shutdown
|
||||
uint8_t channelIndex = 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "./Events.h"
|
||||
|
||||
#include "MessageStore.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "RTC.h"
|
||||
#include "buzz.h"
|
||||
@@ -514,6 +515,7 @@ int InkHUD::Events::beforeReboot(void *unused)
|
||||
inkhud->persistence->saveLatestMessage();
|
||||
} else {
|
||||
NicheGraphics::clearFlashData();
|
||||
messageStore.clearAllMessages(); // also wipe the shared message store
|
||||
}
|
||||
|
||||
// Note: no forceUpdate call here
|
||||
@@ -532,32 +534,27 @@ int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet)
|
||||
if (getFrom(packet) == nodeDB->getNodeNum())
|
||||
return 0;
|
||||
|
||||
// Determine whether the message is broadcast or a DM
|
||||
// Store this info to prevent confusion after a reboot
|
||||
// Avoids need to compare timestamps, because of situation where "future" messages block newly received, if time not set
|
||||
inkhud->persistence->latestMessage.wasBroadcast = isBroadcast(packet->to);
|
||||
bool isBroadcastMsg = isBroadcast(packet->to);
|
||||
inkhud->persistence->latestMessage.wasBroadcast = isBroadcastMsg;
|
||||
|
||||
// Pick the appropriate variable to store the message in
|
||||
MessageStore::Message *storedMessage = inkhud->persistence->latestMessage.wasBroadcast
|
||||
? &inkhud->persistence->latestMessage.broadcast
|
||||
: &inkhud->persistence->latestMessage.dm;
|
||||
|
||||
// Store nodenum of the sender
|
||||
// Applets can use this to fetch user data from nodedb, if they want
|
||||
storedMessage->sender = packet->from;
|
||||
|
||||
// Store the time (epoch seconds) when message received
|
||||
storedMessage->timestamp = getValidTime(RTCQuality::RTCQualityDevice, true); // Current RTC time
|
||||
|
||||
// Store the channel
|
||||
// - (potentially) used to determine whether notification shows
|
||||
// - (potentially) used to determine which applet to focus
|
||||
storedMessage->channelIndex = packet->channel;
|
||||
|
||||
// Store the text
|
||||
// Need to specify manually how many bytes, because source not null-terminated
|
||||
storedMessage->text =
|
||||
std::string(&packet->decoded.payload.bytes[0], &packet->decoded.payload.bytes[packet->decoded.payload.size]);
|
||||
if (!isBroadcastMsg) {
|
||||
// DMs never pass through ThreadedMessageApplet, so add them to the global store here
|
||||
// so they survive reboots. Derive the latestMessage cache entry from the stored result.
|
||||
inkhud->persistence->latestMessage.dm = messageStore.addFromPacket(*packet);
|
||||
} else {
|
||||
// Broadcasts are added to the global store by ThreadedMessageApplet::handleReceived().
|
||||
// Here we only update the latestMessage cache used by AllMessageApplet / NotificationApplet.
|
||||
StoredMessage &sm = inkhud->persistence->latestMessage.broadcast;
|
||||
sm.sender = packet->from;
|
||||
sm.timestamp = getValidTime(RTCQuality::RTCQualityDevice, true);
|
||||
sm.channelIndex = packet->channel;
|
||||
const char *payload = reinterpret_cast<const char *>(packet->decoded.payload.bytes);
|
||||
size_t storedLen = packet->decoded.payload.size;
|
||||
if (storedLen >= MAX_MESSAGE_SIZE)
|
||||
storedLen = MAX_MESSAGE_SIZE - 1;
|
||||
sm.textOffset = MessageStore::storeText(payload, storedLen);
|
||||
sm.textLength = static_cast<uint16_t>(storedLen);
|
||||
}
|
||||
|
||||
return 0; // Tell caller to continue notifying other observers. (No reason to abort this event)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
#include "./SystemApplet.h"
|
||||
#include "./Tile.h"
|
||||
#include "./WindowManager.h"
|
||||
#include "FSCommon.h"
|
||||
#include "MessageStore.h"
|
||||
#include "SPILock.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
@@ -60,11 +64,102 @@ void InkHUD::InkHUD::notifyApplyingChanges()
|
||||
}
|
||||
}
|
||||
|
||||
// One-time migration from the old per-channel InkHUD message files (/NicheGraphics/ch*.msgs)
|
||||
// to the firmware-wide MessageStore format (/Messages_default.msgs).
|
||||
// Only runs when the new store loaded empty, meaning this is the first boot after the format change.
|
||||
// Old files are deleted once migrated.
|
||||
static void migrateOldInkHUDMessages()
|
||||
{
|
||||
#ifdef FSCom
|
||||
bool migrated = false;
|
||||
constexpr uint8_t MAX_CHANNELS = 8;
|
||||
constexpr uint32_t OLD_MAX_MSG_SIZE = 250;
|
||||
|
||||
for (uint8_t ch = 0; ch < MAX_CHANNELS; ch++) {
|
||||
std::string path = "/NicheGraphics/ch";
|
||||
path += std::to_string(ch);
|
||||
path += ".msgs";
|
||||
|
||||
spiLock->lock();
|
||||
bool exists = FSCom.exists(path.c_str());
|
||||
spiLock->unlock();
|
||||
if (!exists)
|
||||
continue;
|
||||
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
|
||||
auto f = FSCom.open(path.c_str(), FILE_O_READ);
|
||||
if (!f || f.size() == 0) {
|
||||
if (f)
|
||||
f.close();
|
||||
FSCom.remove(path.c_str());
|
||||
continue;
|
||||
}
|
||||
|
||||
uint8_t count = 0;
|
||||
f.readBytes(reinterpret_cast<char *>(&count), 1);
|
||||
|
||||
std::vector<StoredMessage> channelMsgs;
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
StoredMessage sm;
|
||||
f.readBytes(reinterpret_cast<char *>(&sm.timestamp), sizeof(sm.timestamp));
|
||||
f.readBytes(reinterpret_cast<char *>(&sm.sender), sizeof(sm.sender));
|
||||
f.readBytes(reinterpret_cast<char *>(&sm.channelIndex), sizeof(sm.channelIndex));
|
||||
|
||||
char textBuf[OLD_MAX_MSG_SIZE + 1] = {};
|
||||
uint32_t textLen = 0;
|
||||
char c;
|
||||
while (textLen < OLD_MAX_MSG_SIZE) {
|
||||
if (f.readBytes(&c, 1) != 1)
|
||||
break;
|
||||
if (c == '\0')
|
||||
break;
|
||||
textBuf[textLen++] = c;
|
||||
}
|
||||
|
||||
sm.dest = NODENUM_BROADCAST;
|
||||
sm.type = MessageType::BROADCAST;
|
||||
sm.isBootRelative = false;
|
||||
sm.ackStatus = AckStatus::ACKED;
|
||||
size_t storedLen = (textLen >= MAX_MESSAGE_SIZE) ? MAX_MESSAGE_SIZE - 1 : textLen;
|
||||
sm.textOffset = MessageStore::storeText(textBuf, storedLen);
|
||||
sm.textLength = static_cast<uint16_t>(storedLen);
|
||||
|
||||
channelMsgs.push_back(sm);
|
||||
}
|
||||
|
||||
// Old format stored newest-first (push_front); insert oldest-first for correct chronological order
|
||||
for (int i = static_cast<int>(channelMsgs.size()) - 1; i >= 0; i--)
|
||||
messageStore.addLiveMessage(channelMsgs[i]);
|
||||
if (!channelMsgs.empty())
|
||||
migrated = true;
|
||||
|
||||
f.close();
|
||||
FSCom.remove(path.c_str());
|
||||
LOG_INFO("Migrated %u messages from %s", static_cast<uint32_t>(count), path.c_str());
|
||||
}
|
||||
|
||||
// Delete the old latest.msgs; the latestMessage cache will be re-derived from migrated channel messages
|
||||
spiLock->lock();
|
||||
if (FSCom.exists("/NicheGraphics/latest.msgs"))
|
||||
FSCom.remove("/NicheGraphics/latest.msgs");
|
||||
spiLock->unlock();
|
||||
|
||||
if (migrated) {
|
||||
LOG_INFO("InkHUD message migration complete, saving to new format");
|
||||
messageStore.saveToFlash();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Start InkHUD!
|
||||
// Call this only after you have configured InkHUD
|
||||
void InkHUD::InkHUD::begin()
|
||||
{
|
||||
persistence->loadSettings();
|
||||
messageStore.loadFromFlash(); // Load persisted messages before deriving latestMessage cache
|
||||
if (messageStore.getLiveMessages().empty())
|
||||
migrateOldInkHUDMessages(); // First boot after format change: import old per-channel files
|
||||
persistence->loadLatestMessage();
|
||||
|
||||
windowManager->begin();
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
||||
|
||||
#include "./MessageStore.h"
|
||||
|
||||
#include "SafeFile.h"
|
||||
|
||||
using namespace NicheGraphics;
|
||||
|
||||
// Hard limits on how much message data to write to flash
|
||||
// Avoid filling the storage if something goes wrong
|
||||
// Normal usage should be well below this size
|
||||
constexpr uint8_t MAX_MESSAGES_SAVED = 10;
|
||||
constexpr uint32_t MAX_MESSAGE_SIZE = 250;
|
||||
|
||||
InkHUD::MessageStore::MessageStore(const std::string &label)
|
||||
{
|
||||
filename = "";
|
||||
filename += "/NicheGraphics";
|
||||
filename += "/";
|
||||
filename += label;
|
||||
filename += ".msgs";
|
||||
}
|
||||
|
||||
// Write the contents of the MessageStore::messages object to flash
|
||||
// Takes the firmware's SPI lock during FS operations. Implemented for consistency, but only relevant when using SD card.
|
||||
// Need to lock and unlock around specific FS methods, as the SafeFile class takes the lock for itself internally
|
||||
void InkHUD::MessageStore::saveToFlash()
|
||||
{
|
||||
assert(!filename.empty());
|
||||
|
||||
#ifdef FSCom
|
||||
// Make the directory, if doesn't already exist
|
||||
// This is the same directory accessed by NicheGraphics::FlashData
|
||||
spiLock->lock();
|
||||
FSCom.mkdir("/NicheGraphics");
|
||||
spiLock->unlock();
|
||||
|
||||
// Open or create the file
|
||||
// No "full atomic": don't save then rename
|
||||
auto f = SafeFile(filename.c_str(), false);
|
||||
|
||||
LOG_INFO("Saving messages in %s", filename.c_str());
|
||||
|
||||
// Take firmware's SPI Lock while writing
|
||||
spiLock->lock();
|
||||
|
||||
// 1st byte: how many messages will be written to store
|
||||
f.write(messages.size());
|
||||
|
||||
// For each message
|
||||
for (uint8_t i = 0; i < messages.size() && i < MAX_MESSAGES_SAVED; i++) {
|
||||
Message &m = messages.at(i);
|
||||
f.write(reinterpret_cast<const uint8_t *>(&m.timestamp), sizeof(m.timestamp)); // Write timestamp. 4 bytes
|
||||
f.write(reinterpret_cast<const uint8_t *>(&m.sender), sizeof(m.sender)); // Write sender NodeId. 4 Bytes
|
||||
f.write(reinterpret_cast<const uint8_t *>(&m.channelIndex), sizeof(m.channelIndex)); // Write channel index. 1 Byte
|
||||
f.write(reinterpret_cast<const uint8_t *>(m.text.c_str()),
|
||||
min((size_t)MAX_MESSAGE_SIZE, m.text.size())); // Write message text
|
||||
f.write('\0'); // Append null term
|
||||
LOG_DEBUG("Wrote message %u, length %u, text \"%s\"", static_cast<uint32_t>(i),
|
||||
min((size_t)MAX_MESSAGE_SIZE, m.text.size()), m.text.c_str());
|
||||
}
|
||||
|
||||
// Release firmware's SPI lock, because SafeFile::close needs it
|
||||
spiLock->unlock();
|
||||
|
||||
bool writeSucceeded = f.close();
|
||||
|
||||
if (!writeSucceeded) {
|
||||
LOG_ERROR("Can't write data!");
|
||||
}
|
||||
#else
|
||||
LOG_ERROR("ERROR: Filesystem not implemented\n");
|
||||
#endif
|
||||
}
|
||||
|
||||
// Attempt to load the previous contents of the MessageStore:message deque from flash.
|
||||
// Filename is controlled by the "label" parameter
|
||||
// Takes the firmware's SPI lock during FS operations. Implemented for consistency, but only relevant when using SD card.
|
||||
void InkHUD::MessageStore::loadFromFlash()
|
||||
{
|
||||
// Hopefully redundant. Initial intention is to only load / save once per boot.
|
||||
messages.clear();
|
||||
|
||||
#ifdef FSCom
|
||||
|
||||
// Take the firmware's SPI Lock, in case filesystem is on SD card
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
|
||||
// Check that the file *does* actually exist
|
||||
if (!FSCom.exists(filename.c_str())) {
|
||||
LOG_WARN("'%s' not found. Using default values", filename.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that the file *does* actually exist
|
||||
if (!FSCom.exists(filename.c_str())) {
|
||||
LOG_INFO("'%s' not found.", filename.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the file
|
||||
auto f = FSCom.open(filename.c_str(), FILE_O_READ);
|
||||
|
||||
if (f.size() == 0) {
|
||||
LOG_INFO("%s is empty", filename.c_str());
|
||||
f.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// If opened, start reading
|
||||
if (f) {
|
||||
LOG_INFO("Loading threaded messages '%s'", filename.c_str());
|
||||
|
||||
// First byte: how many messages are in the flash store
|
||||
uint8_t flashMessageCount = 0;
|
||||
f.readBytes(reinterpret_cast<char *>(&flashMessageCount), 1);
|
||||
LOG_DEBUG("Messages available: %u", static_cast<uint32_t>(flashMessageCount));
|
||||
|
||||
// For each message
|
||||
for (uint8_t i = 0; i < flashMessageCount && i < MAX_MESSAGES_SAVED; i++) {
|
||||
Message m;
|
||||
|
||||
// Read meta data (fixed width)
|
||||
f.readBytes(reinterpret_cast<char *>(&m.timestamp), sizeof(m.timestamp));
|
||||
f.readBytes(reinterpret_cast<char *>(&m.sender), sizeof(m.sender));
|
||||
f.readBytes(reinterpret_cast<char *>(&m.channelIndex), sizeof(m.channelIndex));
|
||||
|
||||
// Read characters until we find a null term
|
||||
char c;
|
||||
while (m.text.size() < MAX_MESSAGE_SIZE) {
|
||||
f.readBytes(&c, 1);
|
||||
if (c != '\0')
|
||||
m.text += c;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
// Store in RAM
|
||||
messages.push_back(m);
|
||||
|
||||
LOG_DEBUG("#%u, timestamp=%u, sender(num)=%u, text=\"%s\"", static_cast<uint32_t>(i), m.timestamp, m.sender,
|
||||
m.text.c_str());
|
||||
}
|
||||
|
||||
f.close();
|
||||
} else {
|
||||
LOG_ERROR("Could not open / read %s", filename.c_str());
|
||||
}
|
||||
#else
|
||||
LOG_ERROR("Filesystem not implemented");
|
||||
state = LoadFileState::NO_FILESYSTEM;
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,47 +0,0 @@
|
||||
#ifdef MESHTASTIC_INCLUDE_INKHUD
|
||||
|
||||
/*
|
||||
|
||||
We hold a few recent messages, for features like the threaded message applet.
|
||||
This class contains a struct for storing those messages,
|
||||
and methods for serializing them to flash.
|
||||
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#include <deque>
|
||||
|
||||
#include "mesh/MeshTypes.h"
|
||||
|
||||
namespace NicheGraphics::InkHUD
|
||||
{
|
||||
|
||||
class MessageStore
|
||||
{
|
||||
public:
|
||||
// A stored message
|
||||
struct Message {
|
||||
uint32_t timestamp; // Epoch seconds
|
||||
NodeNum sender = 0;
|
||||
uint8_t channelIndex;
|
||||
std::string text;
|
||||
};
|
||||
|
||||
MessageStore() = delete;
|
||||
explicit MessageStore(const std::string &label); // Label determines filename in flash
|
||||
|
||||
void saveToFlash();
|
||||
void loadFromFlash();
|
||||
|
||||
std::deque<Message> messages; // Interact with this object!
|
||||
|
||||
private:
|
||||
std::string filename;
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
#endif
|
||||
@@ -17,23 +17,23 @@ void InkHUD::Persistence::loadSettings()
|
||||
LOG_WARN("Settings version changed. Using defaults");
|
||||
}
|
||||
|
||||
// Load settings and latestMessage data
|
||||
// Rebuild the latestMessage cache from the global messageStore.
|
||||
// Called after messageStore.loadFromFlash() so that the most recent broadcast and DM
|
||||
// are immediately available to applets (DMApplet, AllMessageApplet, NotificationApplet).
|
||||
void InkHUD::Persistence::loadLatestMessage()
|
||||
{
|
||||
// Load previous "latestMessages" data from flash
|
||||
MessageStore store("latest");
|
||||
store.loadFromFlash();
|
||||
|
||||
// Place into latestMessage struct, for convenient access
|
||||
// Number of strings loaded determines whether last message was broadcast or dm
|
||||
if (store.messages.size() == 1) {
|
||||
latestMessage.dm = store.messages.at(0);
|
||||
latestMessage.wasBroadcast = false;
|
||||
} else if (store.messages.size() == 2) {
|
||||
latestMessage.dm = store.messages.at(0);
|
||||
latestMessage.broadcast = store.messages.at(1);
|
||||
latestMessage.wasBroadcast = true;
|
||||
int lastBroadcastPos = -1, lastDMPos = -1, pos = 0;
|
||||
for (const StoredMessage &m : messageStore.getLiveMessages()) {
|
||||
if (m.type == MessageType::BROADCAST) {
|
||||
latestMessage.broadcast = m;
|
||||
lastBroadcastPos = pos;
|
||||
} else if (m.type == MessageType::DM_TO_US) {
|
||||
latestMessage.dm = m;
|
||||
lastDMPos = pos;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
latestMessage.wasBroadcast = (lastBroadcastPos > lastDMPos);
|
||||
}
|
||||
|
||||
// Save the InkHUD settings to flash
|
||||
@@ -42,15 +42,10 @@ void InkHUD::Persistence::saveSettings()
|
||||
FlashData<Settings>::save(&settings, "settings");
|
||||
}
|
||||
|
||||
// Save latestMessage data to flash
|
||||
// Persist all messages via the global messageStore.
|
||||
void InkHUD::Persistence::saveLatestMessage()
|
||||
{
|
||||
// Number of strings saved determines whether last message was broadcast or dm
|
||||
MessageStore store("latest");
|
||||
store.messages.push_back(latestMessage.dm);
|
||||
if (latestMessage.wasBroadcast)
|
||||
store.messages.push_back(latestMessage.broadcast);
|
||||
store.saveToFlash();
|
||||
messageStore.saveToFlash();
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -15,7 +15,7 @@ The save / load mechanism is a shared NicheGraphics feature.
|
||||
#include "configuration.h"
|
||||
|
||||
#include "./InkHUD.h"
|
||||
#include "graphics/niche/InkHUD/MessageStore.h"
|
||||
#include "MessageStore.h"
|
||||
#include "graphics/niche/Utils/FlashData.h"
|
||||
|
||||
namespace NicheGraphics::InkHUD
|
||||
@@ -120,12 +120,12 @@ class Persistence
|
||||
};
|
||||
|
||||
// Most recently received text message
|
||||
// Value is updated by InkHUD::WindowManager, as a courtesy to applets
|
||||
// InkHUD keeps its own latest-message cache for applets.
|
||||
// Value is updated by InkHUD::Events, as a courtesy to applets.
|
||||
// Populated at boot from the global messageStore, then updated live on receive.
|
||||
struct LatestMessage {
|
||||
MessageStore::Message broadcast; // Most recent message received broadcast
|
||||
MessageStore::Message dm; // Most recent received DM
|
||||
bool wasBroadcast; // True if most recent broadcast is newer than most recent dm
|
||||
StoredMessage broadcast; // Most recent broadcast message received
|
||||
StoredMessage dm; // Most recent DM received
|
||||
bool wasBroadcast; // True if most recent broadcast is newer than most recent dm
|
||||
};
|
||||
|
||||
void loadSettings();
|
||||
|
||||
@@ -422,9 +422,11 @@ Stores InkHUD data in flash
|
||||
- settings
|
||||
- most recent text message received (both for broadcast and DM)
|
||||
|
||||
In rare cases, applets may store their own specific data separately (e.g. `ThreadedMessageApplet`)
|
||||
Message history (used by `ThreadedMessageApplet`) is stored by the firmware-wide `MessageStore` (`src/MessageStore.h`), not by `Persistence` directly.
|
||||
|
||||
Data saved only on shutdown / reboot. Not saved if power is removed unexpectedly.
|
||||
Settings are saved only on shutdown / reboot. Not saved if power is removed unexpectedly.
|
||||
|
||||
Message history is saved periodically (every 2 hours by default), as well as on shutdown / reboot.
|
||||
|
||||
---
|
||||
|
||||
@@ -466,18 +468,21 @@ Collected here, so various user applets don't all have to store their own copy o
|
||||
|
||||
We keep this separate latest-message cache for this purpose, because:
|
||||
|
||||
- it is cleared by an outgoing text message
|
||||
- we want to store both a recent broadcast and a recent DM
|
||||
- we want to expose both the most recent broadcast and most recent DM independently
|
||||
- applets like `DMApplet` and `NotificationApplet` need quick access without scanning the full message history
|
||||
|
||||
#### How messages reach the store
|
||||
|
||||
Broadcasts and DMs take different paths into `messageStore`:
|
||||
|
||||
- **Broadcasts** — `ThreadedMessageApplet::handleReceived()` calls `messageStore.addFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`.
|
||||
- **DMs** — `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.addFromPacket()` directly and stores the result in `latestMessage.dm`.
|
||||
|
||||
#### Saving / Loading
|
||||
|
||||
_A bit of a hack.._
|
||||
Stored to flash using `InkHUD::MessageStore`, which is really intended for storing a thread of messages (see `ThreadedMessageApplet`). Used because it stores strings more efficiently than `FlashData.h`.
|
||||
The `LatestMessage` cache is not persisted to its own file. On boot, `InkHUD::begin()` calls `messageStore.loadFromFlash()` first, then `Persistence::loadLatestMessage()` rebuilds the cache by scanning the loaded messages for the most recent broadcast and DM.
|
||||
|
||||
The hack is:
|
||||
|
||||
- If most recent message was a DM, we only store the DM.
|
||||
- If most recent message was a broadcast, we store both a DM and a broadcast. The DM may be 0-length string.
|
||||
Text is stored in the firmware-wide shared text pool. Use `MessageStore::getText(msg)` to retrieve it from a `StoredMessage`.
|
||||
|
||||
---
|
||||
|
||||
@@ -582,13 +587,17 @@ Handles events which impact the InkHUD system generally (e.g. shutdown, button p
|
||||
|
||||
Applets themselves do also listen separately for various events, but for the purpose of gathering information which they would like to display.
|
||||
|
||||
#### Text Messages
|
||||
|
||||
`Events::onReceiveTextMessage()` is the central handler for all incoming text messages. It updates the `LatestMessage` cache and, for DMs, also adds the message to `messageStore` (since `ThreadedMessageApplet` only handles broadcasts). See `Persistence::LatestMessage` for details on how the two message types are stored.
|
||||
|
||||
#### Buttons
|
||||
|
||||
Button input is sometimes handled by a system applet. `InkHUD::Events` determines whether the button should be handled by a specific system applet, or should instead trigger a default behavior
|
||||
|
||||
#### Factory Reset
|
||||
|
||||
The Events class handles the admin messages(s) which trigger factory reset. We set `Events::eraseOnReboot = true`, which causes `Events::onReboot` to erase the contents of InkHUD's data directory. We do this because some applets (e.g. ThreadedMessageApplet) save their own data to flash, so if we erased earlier, that data would get re-written during reboot.
|
||||
The Events class handles the admin message(s) which trigger factory reset. We set `Events::eraseOnReboot = true`, which causes `Events::onReboot` to erase the contents of InkHUD's data directory (`/NicheGraphics/`) and also call `messageStore.clearAllMessages()` to wipe the firmware-wide message store (`/Messages_default.msgs`). Both are cleared during reboot rather than earlier, to avoid data being re-written by applets still running before shutdown.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1243,7 +1243,7 @@ void loop()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
#if HAS_SCREEN && ENABLE_MESSAGE_PERSISTENCE
|
||||
#if (HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)) && ENABLE_MESSAGE_PERSISTENCE
|
||||
messageStoreAutosaveTick();
|
||||
#endif
|
||||
long delayMsec = mainController.runOrDelay();
|
||||
|
||||
@@ -77,7 +77,6 @@ extern const RegionInfo regions[];
|
||||
extern const RegionInfo *myRegion;
|
||||
|
||||
extern void initRegion();
|
||||
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
|
||||
|
||||
// Valid LoRa spread factor range and defaults
|
||||
constexpr uint8_t LORA_SF_MIN = 5;
|
||||
@@ -124,18 +123,8 @@ static inline float clampBandwidthKHz(float bwKHz)
|
||||
|
||||
static inline float bwCodeToKHz(uint16_t bwCode)
|
||||
{
|
||||
if (bwCode == 8)
|
||||
return 7.8f;
|
||||
if (bwCode == 10)
|
||||
return 10.4f;
|
||||
if (bwCode == 16)
|
||||
return 15.6f;
|
||||
if (bwCode == 21)
|
||||
return 20.8f;
|
||||
if (bwCode == 31)
|
||||
return 31.25f;
|
||||
if (bwCode == 42)
|
||||
return 41.7f;
|
||||
if (bwCode == 62)
|
||||
return 62.5f;
|
||||
if (bwCode == 200)
|
||||
@@ -151,18 +140,8 @@ static inline float bwCodeToKHz(uint16_t bwCode)
|
||||
|
||||
static inline uint16_t bwKHzToCode(float bwKHz)
|
||||
{
|
||||
if (bwKHz > 7.7f && bwKHz < 7.9f)
|
||||
return 8;
|
||||
if (bwKHz > 10.3f && bwKHz < 10.5f)
|
||||
return 10;
|
||||
if (bwKHz > 15.5f && bwKHz < 15.7f)
|
||||
return 16;
|
||||
if (bwKHz > 20.7f && bwKHz < 20.9f)
|
||||
return 21;
|
||||
if (bwKHz > 31.24f && bwKHz < 31.26f)
|
||||
return 31;
|
||||
if (bwKHz > 41.6f && bwKHz < 41.8f)
|
||||
return 42;
|
||||
if (bwKHz > 62.49f && bwKHz < 62.51f)
|
||||
return 62;
|
||||
if (bwKHz > 203.12f && bwKHz < 203.13f)
|
||||
|
||||
@@ -834,16 +834,6 @@ bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &lo
|
||||
{
|
||||
const RegionInfo *newRegion = getRegion(loraConfig.region);
|
||||
|
||||
// Reject unrecognized region codes (getRegion returns UNSET sentinel for unknown codes)
|
||||
if (newRegion->code != loraConfig.region) {
|
||||
char err_string[160];
|
||||
snprintf(err_string, sizeof(err_string), "Region code %d is not recognized", loraConfig.region);
|
||||
LOG_ERROR("%s", err_string);
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING);
|
||||
sendErrorNotification(err_string);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If you are not licensed, you can't use ham regions.
|
||||
if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) {
|
||||
char err_string[160];
|
||||
|
||||
@@ -198,28 +198,6 @@ typedef struct _meshtastic_LockdownAuth {
|
||||
way to reset the session clock is a reboot, which costs a boot
|
||||
from the on-flash, HMAC-bound counter. */
|
||||
uint32_t max_session_seconds;
|
||||
/* Disable lockdown mode. Requires a valid passphrase in the same
|
||||
message (the device must prove the operator owns it before
|
||||
reverting at-rest encryption). On success the firmware decrypts
|
||||
every stored config / channel / nodedb file back to plaintext,
|
||||
removes the wrapped DEK, unlock token, monotonic-counter, and
|
||||
backoff files, and reboots out of lockdown.
|
||||
|
||||
This is the inverse of the provision/unlock path: it is how the
|
||||
client app's "lockdown mode" toggle returns a device to normal
|
||||
operation.
|
||||
|
||||
NOT reversed by this operation: APPROTECT. Once the debug port
|
||||
lockout has been burned (on silicon where it is effective) it is
|
||||
permanent — disabling lockdown decrypts your data and removes the
|
||||
access gates, but the SWD/JTAG port stays locked for the life of
|
||||
the device (recoverable only via a full chip erase over a debug
|
||||
probe, which destroys all data). Clients should make this
|
||||
irreversibility clear at the moment lockdown is first enabled.
|
||||
|
||||
When true the passphrase field is still required; boots_remaining,
|
||||
valid_until_epoch, max_session_seconds, and lock_now are ignored. */
|
||||
bool disable;
|
||||
} meshtastic_LockdownAuth;
|
||||
|
||||
/* Parameters for setting up Meshtastic for ameteur radio usage */
|
||||
@@ -543,7 +521,7 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_init_default {0, {0}, {0, {0}}}
|
||||
#define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_default {"", 0, 0, ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
||||
#define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0}
|
||||
@@ -556,7 +534,7 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_init_zero {0, {0}, {0, {0}}}
|
||||
#define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_zero {"", 0, 0, ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
||||
#define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0}
|
||||
@@ -579,7 +557,6 @@ extern "C" {
|
||||
#define meshtastic_LockdownAuth_valid_until_epoch_tag 3
|
||||
#define meshtastic_LockdownAuth_lock_now_tag 4
|
||||
#define meshtastic_LockdownAuth_max_session_seconds_tag 5
|
||||
#define meshtastic_LockdownAuth_disable_tag 6
|
||||
#define meshtastic_HamParameters_call_sign_tag 1
|
||||
#define meshtastic_HamParameters_tx_power_tag 2
|
||||
#define meshtastic_HamParameters_frequency_tag 3
|
||||
@@ -777,8 +754,7 @@ X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \
|
||||
X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \
|
||||
X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \
|
||||
X(a, STATIC, SINGULAR, BOOL, lock_now, 4) \
|
||||
X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) \
|
||||
X(a, STATIC, SINGULAR, BOOL, disable, 6)
|
||||
X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5)
|
||||
#define meshtastic_LockdownAuth_CALLBACK NULL
|
||||
#define meshtastic_LockdownAuth_DEFAULT NULL
|
||||
|
||||
@@ -893,7 +869,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg;
|
||||
#define meshtastic_AdminMessage_size 511
|
||||
#define meshtastic_HamParameters_size 31
|
||||
#define meshtastic_KeyVerificationAdmin_size 25
|
||||
#define meshtastic_LockdownAuth_size 56
|
||||
#define meshtastic_LockdownAuth_size 54
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_size 496
|
||||
#define meshtastic_SCD30_config_size 27
|
||||
#define meshtastic_SCD4X_config_size 29
|
||||
|
||||
@@ -356,21 +356,7 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset {
|
||||
/* Narrow Slow
|
||||
Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth.
|
||||
Comparable link budget and data rate to LONG_FAST. */
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13,
|
||||
/* Tiny Fast
|
||||
Preset optimized for compliance with Amateur Radio restrictions with 20kHz bandwidth.
|
||||
Many regions limit data transmission bandwidth in lower amateur bands (2 Meter).
|
||||
Note: TCXO with tight tolerances (±5 ppm or better) is *absolutely required* at these narrow bandwidths.
|
||||
Only compatible with SX127x and SX126x chipsets.
|
||||
Comparable link budget and data rate to LONG_FAST. */
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_TINY_FAST = 14,
|
||||
/* Tiny Slow
|
||||
Preset optimized for compliance with Amateur Radio restrictions with 20kHz bandwidth.
|
||||
Many regions limit data transmission bandwidth in lower amateur bands (2 Meter).
|
||||
Note: TCXO with tight tolerances (±5 ppm or better) is *absolutely required* at these narrow bandwidths.
|
||||
Only compatible with SX127x and SX126x chipsets.
|
||||
Comparable link budget and data rate to LONG_MODERATE. */
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW = 15
|
||||
meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13
|
||||
} meshtastic_Config_LoRaConfig_ModemPreset;
|
||||
|
||||
typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode {
|
||||
@@ -763,8 +749,8 @@ extern "C" {
|
||||
#define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU3_70CM+1))
|
||||
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW+1))
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW
|
||||
#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW+1))
|
||||
|
||||
#define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED
|
||||
#define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT
|
||||
|
||||
@@ -658,14 +658,7 @@ typedef enum _meshtastic_LockdownStatus_State {
|
||||
token's TTL. */
|
||||
meshtastic_LockdownStatus_State_UNLOCKED = 3,
|
||||
/* Passphrase rejected. backoff_seconds is non-zero when rate-limited. */
|
||||
meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4,
|
||||
/* Lockdown is supported by this firmware but not currently active
|
||||
(no passphrase has been provisioned, or it was disabled via
|
||||
AdminMessage.lockdown_auth.disable). The device is operating in
|
||||
normal, non-encrypted mode. Clients render the lockdown-mode
|
||||
toggle as OFF on receiving this. Distinct from NEEDS_PROVISION,
|
||||
which is only used during an in-progress enable flow. */
|
||||
meshtastic_LockdownStatus_State_DISABLED = 5
|
||||
meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4
|
||||
} meshtastic_LockdownStatus_State;
|
||||
|
||||
/* Struct definitions */
|
||||
@@ -1540,8 +1533,8 @@ extern "C" {
|
||||
#define _meshtastic_LogRecord_Level_ARRAYSIZE ((meshtastic_LogRecord_Level)(meshtastic_LogRecord_Level_CRITICAL+1))
|
||||
|
||||
#define _meshtastic_LockdownStatus_State_MIN meshtastic_LockdownStatus_State_STATE_UNSPECIFIED
|
||||
#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_DISABLED
|
||||
#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_DISABLED+1))
|
||||
#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_UNLOCK_FAILED
|
||||
#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_UNLOCK_FAILED+1))
|
||||
|
||||
#define meshtastic_Position_location_source_ENUMTYPE meshtastic_Position_LocSource
|
||||
#define meshtastic_Position_altitude_source_ENUMTYPE meshtastic_Position_AltSource
|
||||
|
||||
@@ -94,7 +94,7 @@ bool SerialModule::isValidConfig(const meshtastic_ModuleConfig_SerialConfig &con
|
||||
const char *warning =
|
||||
"Invalid Serial config: override console serial port is only supported in NMEA and CalTopo output-only modes.";
|
||||
LOG_ERROR(warning);
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
#if !IS_RUNNING_TESTS
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
cn->time = getValidTime(RTCQualityFromNet);
|
||||
|
||||
+4
-4
@@ -466,7 +466,7 @@ MQTT::MQTT() : concurrency::OSThread("mqtt"), mqttQueue(MAX_MQTT_QUEUE)
|
||||
enabled = true;
|
||||
runASAP = true;
|
||||
reconnectCount = 0;
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
#if !IS_RUNNING_TESTS
|
||||
publishNodeInfo();
|
||||
#endif
|
||||
}
|
||||
@@ -674,7 +674,7 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC
|
||||
const char *warning = "Could not reach the MQTT server. Settings will be saved, but please verify the server "
|
||||
"address and credentials.";
|
||||
LOG_WARN(warning);
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
#if !IS_RUNNING_TESTS
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
if (cn) {
|
||||
cn->level = meshtastic_LogRecord_Level_WARNING;
|
||||
@@ -690,7 +690,7 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC
|
||||
#else
|
||||
const char *warning = "Invalid MQTT config: proxy_to_client_enabled must be enabled on nodes that do not have a network";
|
||||
LOG_ERROR(warning);
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
#if !IS_RUNNING_TESTS
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
cn->time = getValidTime(RTCQualityFromNet);
|
||||
@@ -706,7 +706,7 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC
|
||||
if (defaultServer && !IS_ONE_OF(parsed.serverPort, PubSubConfig::defaultPort, PubSubConfig::defaultPortTls)) {
|
||||
const char *warning = "Invalid MQTT config: default server address must not have a port specified";
|
||||
LOG_ERROR(warning);
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
#if !IS_RUNNING_TESTS
|
||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||
cn->level = meshtastic_LogRecord_Level_ERROR;
|
||||
cn->time = getValidTime(RTCQualityFromNet);
|
||||
|
||||
@@ -562,7 +562,7 @@ void portduinoSetup()
|
||||
}
|
||||
|
||||
getMacAddr(dmac);
|
||||
#ifndef PIO_UNIT_TESTING
|
||||
#ifndef UNIT_TEST
|
||||
if (dmac[0] == 0 && dmac[1] == 0 && dmac[2] == 0 && dmac[3] == 0 && dmac[4] == 0 && dmac[5] == 0) {
|
||||
std::cout << "*** Blank MAC Address not allowed!" << std::endl;
|
||||
std::cout << "Please set a MAC Address in config.yaml using either MACAddress or MACAddressSource." << std::endl;
|
||||
|
||||
+4
-57
@@ -15,30 +15,6 @@ pio test -e native -f test_your_module
|
||||
pio test -e native -f test_your_module -vvv
|
||||
```
|
||||
|
||||
**Never pipe through `| tail -N` to shorten output.** PlatformIO prints build errors at the top of output and test results at the bottom; `tail` will show stale cached results from a prior successful build while hiding the compile error that caused the current run to fail.
|
||||
|
||||
**Preferred pattern — redirect to file, then grep:**
|
||||
|
||||
```bash
|
||||
# Redirect all output to a file; grep for errors and results after it exits
|
||||
pio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
|
||||
echo "exit: $?"
|
||||
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
|
||||
tail -15 /tmp/test_out.txt
|
||||
```
|
||||
|
||||
Why: piping through `| grep` line-buffers the output and suppresses all progress until the process exits, making it look hung. The redirect approach lets the build stream normally while still giving you filtered results afterwards.
|
||||
|
||||
**`externally-managed-environment` error on Ubuntu/Debian:**
|
||||
|
||||
If `pio test` fails immediately with `error: externally-managed-environment`, the system `pio` binary is using the OS Python which newer distros lock down. Use PlatformIO's own venv instead:
|
||||
|
||||
```bash
|
||||
~/.platformio/penv/bin/python -m platformio test -e native -f test_your_module > /tmp/test_out.txt 2>&1
|
||||
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
|
||||
tail -15 /tmp/test_out.txt
|
||||
```
|
||||
|
||||
### Helper Scripts (Useful Shortcuts)
|
||||
|
||||
These wrappers are handy when local host dependencies are missing or when you want repeatable commands.
|
||||
@@ -209,34 +185,20 @@ Subclass the module under test to make protected methods callable and private me
|
||||
class YourModuleTestShim : public YourModule
|
||||
{
|
||||
public:
|
||||
// Pull protected methods into public scope via using.
|
||||
// IMPORTANT: using requires the method to be protected (or public) in the base —
|
||||
// friend alone does NOT satisfy this. See pitfall #6.
|
||||
// Expose protected methods
|
||||
using YourModule::runOnce;
|
||||
using YourModule::someProtectedMethod;
|
||||
|
||||
// Wrap private members with setter methods (friend grants direct access here).
|
||||
// Access private members via friend (see below)
|
||||
void setPrivateField(int x) { privateField = x; }
|
||||
};
|
||||
```
|
||||
|
||||
For methods you want to expose via `using`, use the conditional access-specifier pattern in the header — **not** plain `friend`:
|
||||
In the module header, grant friend access under the `UNIT_TEST` define (set automatically by PlatformIO's test framework):
|
||||
|
||||
```cpp
|
||||
// In YourModule.h, inside the class body:
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
protected:
|
||||
#else
|
||||
private:
|
||||
#endif
|
||||
bool someMethod();
|
||||
```
|
||||
|
||||
For private _member variables_ that a shim setter needs to touch directly, `friend` is sufficient (no `using` involved):
|
||||
|
||||
```cpp
|
||||
// In YourModule.h, inside the class body:
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
#ifdef UNIT_TEST
|
||||
friend class YourModuleTestShim;
|
||||
#endif
|
||||
```
|
||||
@@ -322,21 +284,6 @@ Fixed-size data structures (hash sets, ring buffers) overflow when tests inject
|
||||
|
||||
**Fix:** Simulate multiple realistic time windows rather than one massive burst. Let adaptive mechanisms (if any) self-tune over several rolls.
|
||||
|
||||
### 6. Granting test access to private/protected members
|
||||
|
||||
PlatformIO defines `PIO_UNIT_TESTING` during `pio test` builds. Several production headers (`TransmitHistory.h`, `CryptoEngine.h`, `MQTT.h`, `RTC.h`) use this to gate test-only visibility changes. PlatformIO also defines `UNIT_TEST` in the same builds for backward compatibility, but that spelling is deprecated — always use `PIO_UNIT_TESTING` in new code. The established pattern for exposing a private method to a test shim **without widening production visibility**:
|
||||
|
||||
```cpp
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
protected:
|
||||
#else
|
||||
private:
|
||||
#endif
|
||||
bool myMethod();
|
||||
```
|
||||
|
||||
**Critical C++ rule:** a `using` declaration in a derived class (e.g. `using Base::myMethod`) requires `myMethod` to be `protected` or `public` in the base — `friend` alone does **not** satisfy this. Adding `friend class TestShim` while leaving the method `private` will still fail to compile. Use the conditional access-specifier pattern above, not `friend`.
|
||||
|
||||
## setUp/tearDown Checklist
|
||||
|
||||
- [ ] Create and clear MockNodeDB (if needed)
|
||||
|
||||
@@ -36,6 +36,7 @@ static MockMeshService *mockMeshService;
|
||||
// -----------------------------------------------------------------------
|
||||
// getRegion() tests
|
||||
// -----------------------------------------------------------------------
|
||||
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
|
||||
|
||||
static void test_getRegion_returnsCorrectRegion_US()
|
||||
{
|
||||
@@ -103,29 +104,6 @@ static void test_validateConfigRegion_unsetRegionReturnsTrue()
|
||||
TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg));
|
||||
}
|
||||
|
||||
static void test_validateConfigRegion_unknownCodeReturnsFalse()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)255;
|
||||
|
||||
devicestate.owner.is_licensed = false;
|
||||
|
||||
// Unknown code is not in the regions table; getRegion() returns the UNSET sentinel,
|
||||
// whose .code != 255, so validateConfigRegion should reject it.
|
||||
TEST_ASSERT_FALSE(RadioInterface::validateConfigRegion(cfg));
|
||||
}
|
||||
|
||||
static void test_validateConfigRegion_anotherUnknownCodeReturnsFalse()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
cfg.region = (meshtastic_Config_LoRaConfig_RegionCode)99;
|
||||
|
||||
devicestate.owner.is_licensed = true;
|
||||
|
||||
// Unknown code should be rejected even when owner is licensed.
|
||||
TEST_ASSERT_FALSE(RadioInterface::validateConfigRegion(cfg));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Shadow tables for testing (preset lists → profiles → regions → lookup)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -959,8 +937,6 @@ void setup()
|
||||
// validateConfigRegion()
|
||||
RUN_TEST(test_validateConfigRegion_validRegionReturnsTrue);
|
||||
RUN_TEST(test_validateConfigRegion_unsetRegionReturnsTrue);
|
||||
RUN_TEST(test_validateConfigRegion_unknownCodeReturnsFalse);
|
||||
RUN_TEST(test_validateConfigRegion_anotherUnknownCodeReturnsFalse);
|
||||
|
||||
// Shadow table tests
|
||||
RUN_TEST(test_shadowTable_spacedProfileHasNonZeroSpacing);
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
|
||||
#if defined(UNIT_TEST)
|
||||
#define IS_RUNNING_TESTS 1
|
||||
#else
|
||||
#define IS_RUNNING_TESTS 0
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
// Minimal router needed to receive messages from MQTT.
|
||||
|
||||
@@ -33,12 +33,7 @@ class TestableRadioInterface : public RadioInterface
|
||||
|
||||
static void test_bwCodeToKHz_specialMappings()
|
||||
{
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 7.8f, bwCodeToKHz(8));
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 10.4f, bwCodeToKHz(10));
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 15.6f, bwCodeToKHz(16));
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 20.8f, bwCodeToKHz(21));
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 31.25f, bwCodeToKHz(31));
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 41.7f, bwCodeToKHz(42));
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 62.5f, bwCodeToKHz(62));
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 203.125f, bwCodeToKHz(200));
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 406.25f, bwCodeToKHz(400));
|
||||
@@ -52,18 +47,6 @@ static void test_bwCodeToKHz_passthrough()
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 250.0f, bwCodeToKHz(250));
|
||||
}
|
||||
|
||||
static void test_bwCodeToKHz_roundTrip()
|
||||
{
|
||||
// Round-trip: bwKHzToCode(bwCodeToKHz(code)) should return the original code
|
||||
uint16_t codes[] = {8, 10, 16, 21, 31, 42, 62, 200, 400, 800, 1600};
|
||||
for (size_t i = 0; i < sizeof(codes) / sizeof(codes[0]); i++) {
|
||||
uint16_t code = codes[i];
|
||||
float khz = bwCodeToKHz(code);
|
||||
uint16_t result = bwKHzToCode(khz);
|
||||
TEST_ASSERT_EQUAL_UINT16(code, result);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_validateConfigLora_noopWhenUsePresetFalse()
|
||||
{
|
||||
meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero;
|
||||
@@ -230,7 +213,6 @@ void setup()
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_bwCodeToKHz_specialMappings);
|
||||
RUN_TEST(test_bwCodeToKHz_passthrough);
|
||||
RUN_TEST(test_bwCodeToKHz_roundTrip);
|
||||
RUN_TEST(test_validateConfigLora_noopWhenUsePresetFalse);
|
||||
RUN_TEST(test_validateConfigLora_validPreset_nonWideRegion);
|
||||
RUN_TEST(test_validateConfigLora_validPreset_wideRegion);
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#include "configuration.h"
|
||||
|
||||
#if defined(UNIT_TEST)
|
||||
#define IS_RUNNING_TESTS 1
|
||||
#else
|
||||
#define IS_RUNNING_TESTS 0
|
||||
#endif
|
||||
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_NRF52) || defined(ARCH_RP2040)) && !defined(CONFIG_IDF_TARGET_ESP32S2) && \
|
||||
!defined(CONFIG_IDF_TARGET_ESP32C3)
|
||||
#include "modules/SerialModule.h"
|
||||
|
||||
@@ -11,7 +11,7 @@ build_flags = -fno-strict-aliasing
|
||||
-D USE_EINK
|
||||
-D USE_EINK_PARALLELDISPLAY
|
||||
-D PRIVATE_HW
|
||||
-D TOUCH_THRESHOLD_X=40
|
||||
-D TOUCH_THRESHOLD_X=60
|
||||
-D TOUCH_THRESHOLD_Y=40
|
||||
-D TIME_LONG_PRESS=500
|
||||
; -D EINK_LIMIT_GHOSTING_PX=5000
|
||||
|
||||
Reference in New Issue
Block a user