Add MeshPocket message board and worldwide map
This commit is contained in:
1 parent
7d0709bf03
commit
5d31a839c5
41 files changed
+6852
-7
No files matched your search
@@ -552,6 +552,7 @@ jobs:
|
||||
python3 -B test/test_meshtext_transport.py -v
|
||||
python3 -B test/test_meshtext_primary_preamble.py -v
|
||||
python3 -B test/test_meshtext_license.py -v
|
||||
python3 -B test/test_pocket_gui_license.py -v
|
||||
python3 -B test/test_beacon_frames.py -v
|
||||
python3 -B test/test_beacon_manager.py -v
|
||||
python3 -B test/test_beacon_radio.py -v
|
||||
@@ -853,6 +854,7 @@ jobs:
|
||||
run: >-
|
||||
python3 -B -m unittest
|
||||
test_message_navigation test_companion_reader test_reader_touch_coordinates
|
||||
test_pocket_gui test_pocket_world_map
|
||||
test_touch_debug_overlay test_touch_debug_cli test_color_theme
|
||||
test_indicator_messages_profile test_t096_message_footer_profile
|
||||
test_indicator_display_profile test_indicator_render_profile
|
||||
|
||||
@@ -4936,6 +4936,9 @@ collect_build_artifacts() {
|
||||
python3 scripts/package_meshtext_license.py collect "$build_output_dir" \
|
||||
--stem "${OUTPUT_DIR}/${firmware_filename}" || return $?
|
||||
|
||||
python3 scripts/package_pocket_gui_license.py collect "$build_output_dir" \
|
||||
--stem "${OUTPUT_DIR}/${firmware_filename}" || return $?
|
||||
|
||||
python3 scripts/firmware_memory_manifest.py package "$build_output_dir" \
|
||||
--stem "${OUTPUT_DIR}/${firmware_filename}" || return $?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# MeshPocket Full Companion display
|
||||
|
||||
Full Companion on MeshPocket includes a channel message board, a worldwide
|
||||
coastline map and display settings. Other boards keep their existing home UI.
|
||||
USB, Bluetooth, notifications and the shared Reader remain available.
|
||||
|
||||
Use the Pocket's user button:
|
||||
|
||||
| Page | One click | Two clicks | Hold |
|
||||
| --- | --- | --- | --- |
|
||||
| Normal pages | Next page | Previous page | Page action |
|
||||
| Message board, browsing | Older message | Newer message | Finish browsing |
|
||||
| Map, zooming | Zoom in | Zoom out | Save and finish |
|
||||
| Settings, editing | Change value | Next setting | Save and finish |
|
||||
| Network or Radio 2 | Next page | Previous page | Open Reader |
|
||||
|
||||
Hold on the message board, map or settings page to enter its mode. Three or
|
||||
four clicks on the board select the next or previous configured channel.
|
||||
The inbox still opens individual messages, including direct messages.
|
||||
|
||||
The board shares the existing 32-message history, showing the selected channel
|
||||
with sender names and local timestamps. Successfully sent app messages appear
|
||||
there too. History is held in RAM; it is not a permanent message archive.
|
||||
|
||||
Settings cover channel, text size, sender name length, screen orientation,
|
||||
light/dark theme, return-to-board delay, full refresh interval and map zoom.
|
||||
They use a separate checked, atomically replaced record in primary storage.
|
||||
Existing node, channel and contact formats are unchanged. Timezone comes from
|
||||
the existing node preferences.
|
||||
|
||||
The map centres on this node's location, or the first contact with a location.
|
||||
With no locations it shows a world overview. Contacts appear as dots, with
|
||||
nearby names at closer zooms. The view spans 20,000 km down to 5 km vertically;
|
||||
coastline detail stays coarse at close zooms. It contains no streets, terrain,
|
||||
regional downloads or ocean tiles. See the [map data notes](../tools/meshpocket_map/README.md)
|
||||
for resolution, storage and reproducible generation.
|
||||
|
||||
The UI is adapted from [MeshCore-Mesh-Pocket-MessageBoard](https://github.com/jackywashere/MeshCore-Mesh-Pocket-MessageBoard).
|
||||
Include [PocketGui-NOTICE.txt](../tools/meshpocket_map/PocketGui-NOTICE.txt) alongside
|
||||
distributed firmware for the UI and font notices. The application DFU ZIP keeps
|
||||
its required manifest, binary and metadata entries; the notice is a sidecar.
|
||||
|
||||
Build with `Mesh_pocket_companion_radio_full`. Defining `UI_POCKET_GUI=0`
|
||||
retains the standard Pocket display.
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/MultiSerialInterface.h>
|
||||
#include <Arduino.h>
|
||||
#include <helpers/ui/PocketGuiConfig.h>
|
||||
|
||||
#ifdef PIN_BUZZER
|
||||
#include <helpers/ui/buzzer.h>
|
||||
@@ -100,6 +101,12 @@ public:
|
||||
int channel_idx = -1,
|
||||
const char* channel_name = nullptr,
|
||||
int queue_index = -1) = 0;
|
||||
#if UI_POCKET_GUI
|
||||
virtual void channelMessageSent(int channel_idx, const char* channel_name,
|
||||
const char* sender, const char* text, size_t length) {
|
||||
(void)channel_idx; (void)channel_name; (void)sender; (void)text; (void)length;
|
||||
}
|
||||
#endif
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
virtual bool supportsDisplayRotation() const { return false; }
|
||||
virtual bool setDisplayRotationDegrees(uint16_t degrees) {
|
||||
|
||||
@@ -763,6 +763,27 @@ bool DataStore::saveReaderBookmark(mesh::bible::Position pos) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if UI_POCKET_GUI
|
||||
bool DataStore::loadPocketGuiSettings(mesh::ui::pocket::Settings& settings) {
|
||||
if (_primary_storage_unavailable || _fs == nullptr) return false;
|
||||
File file = openRead(_fs, "/pocket.ui");
|
||||
if (!file) return false;
|
||||
uint8_t bytes[mesh::ui::pocket::SETTINGS_RECORD_SIZE];
|
||||
const bool read = file.size() == sizeof(bytes)
|
||||
&& file.read(bytes, sizeof(bytes)) == sizeof(bytes);
|
||||
file.close();
|
||||
return read && mesh::ui::pocket::decodeSettings(settings, bytes, sizeof(bytes), MAX_GROUP_CHANNELS);
|
||||
}
|
||||
|
||||
bool DataStore::savePocketGuiSettings(const mesh::ui::pocket::Settings& settings) {
|
||||
if (_primary_storage_unavailable || _volatile_primary_fs || _fs == nullptr) return false;
|
||||
uint8_t bytes[mesh::ui::pocket::SETTINGS_RECORD_SIZE];
|
||||
if (!mesh::ui::pocket::encodeSettings(settings, bytes, sizeof(bytes), MAX_GROUP_CHANNELS)) return false;
|
||||
mesh::AtomicFileWriter file(_fs, "/pocket.ui");
|
||||
return file && file.write(bytes, sizeof(bytes)) == sizeof(bytes) && file.commit();
|
||||
}
|
||||
#endif
|
||||
|
||||
File DataStore::openRead(FILESYSTEM* fs, const char* filename) {
|
||||
return mesh::openFileRead(fs, filename);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
#include <helpers/ContactSecretCache.h>
|
||||
#endif
|
||||
#include <helpers/CompanionReaderConfig.h>
|
||||
#include <helpers/ui/PocketGuiConfig.h>
|
||||
#if UI_POCKET_GUI
|
||||
#include <helpers/ui/PocketGuiSettings.h>
|
||||
#endif
|
||||
#if defined(ESP32_PLATFORM)
|
||||
namespace mesh { class ContactFileTransaction; }
|
||||
#endif
|
||||
@@ -235,6 +239,10 @@ public:
|
||||
bool loadPrefs(CompanionNodePrefs& prefs, double& node_lat,
|
||||
double& node_lon);
|
||||
bool savePrefs(const CompanionNodePrefs& prefs, double node_lat, double node_lon);
|
||||
#if UI_POCKET_GUI
|
||||
bool loadPocketGuiSettings(mesh::ui::pocket::Settings& settings);
|
||||
bool savePocketGuiSettings(const mesh::ui::pocket::Settings& settings);
|
||||
#endif
|
||||
#if COMPANION_FEATURE_READER
|
||||
bool loadReaderBookmark(mesh::bible::Position& pos);
|
||||
bool saveReaderBookmark(mesh::bible::Position pos);
|
||||
|
||||
@@ -6266,7 +6266,12 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
bool success = getChannel(channel_idx, channel);
|
||||
#if MESH_ENABLE_MESHTEXT_CHAT
|
||||
if (success && isMeshtextChannel(channel)) {
|
||||
if (sendMeshtextText(channel, text, len - i)) writeOKFrame();
|
||||
if (sendMeshtextText(channel, text, len - i)) {
|
||||
#if UI_POCKET_GUI
|
||||
if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i);
|
||||
#endif
|
||||
writeOKFrame();
|
||||
}
|
||||
else writeErrFrame(ERR_CODE_BAD_STATE);
|
||||
} else
|
||||
#endif
|
||||
@@ -6274,6 +6279,9 @@ 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 UI_POCKET_GUI
|
||||
if (_ui) _ui->channelMessageSent(channel_idx, channel.name, _prefs.node_name, text, len - i);
|
||||
#endif
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
||||
|
||||
@@ -584,6 +584,10 @@ public:
|
||||
bool loadReaderBookmark(mesh::bible::Position& pos) { return _store->loadReaderBookmark(pos); }
|
||||
bool saveReaderBookmark(mesh::bible::Position pos) { return _store->saveReaderBookmark(pos); }
|
||||
#endif
|
||||
#if UI_POCKET_GUI
|
||||
bool loadPocketGuiSettings(mesh::ui::pocket::Settings& settings) { return _store->loadPocketGuiSettings(settings); }
|
||||
bool savePocketGuiSettings(const mesh::ui::pocket::Settings& settings) { return _store->savePocketGuiSettings(settings); }
|
||||
#endif
|
||||
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
bool setGpsEnabled(bool enabled);
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/PocketGuiConfig.h>
|
||||
|
||||
#if UI_POCKET_GUI
|
||||
|
||||
// Message-board layout adapted from jackywashere's MeshCore Mesh Pocket
|
||||
// MessageBoard (MIT), https://github.com/jackywashere/MeshCore-Mesh-Pocket-MessageBoard.
|
||||
// The existing companion history, notifications and navigation remain shared.
|
||||
|
||||
#include <helpers/ui/GxEPDDisplay.h>
|
||||
#include <helpers/ui/PocketGuiSettings.h>
|
||||
#include <U8g2_for_Adafruit_GFX.h>
|
||||
#include <stdio.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ui {
|
||||
namespace pocket {
|
||||
|
||||
struct Frame {
|
||||
uint32_t now = 0;
|
||||
uint64_t heard_millis = 0;
|
||||
float tz_offset = 0;
|
||||
int battery_percent = 0;
|
||||
const char* connection = nullptr;
|
||||
int page_index = 0;
|
||||
int page_count = 1;
|
||||
const char* hint = nullptr;
|
||||
int rotation = 3;
|
||||
};
|
||||
|
||||
class PocketGui {
|
||||
static constexpr int HEADER_HEIGHT = 14;
|
||||
static constexpr int FOOTER_HEIGHT = 14;
|
||||
static constexpr uint32_t VALID_TIME = 1735689600UL;
|
||||
struct Line { uint8_t start, length; };
|
||||
|
||||
GxEPDDisplay* _display = nullptr;
|
||||
Adafruit_GFX* _gfx = nullptr;
|
||||
U8G2_FOR_ADAFRUIT_GFX _font;
|
||||
bool _ready = false;
|
||||
Settings _settings;
|
||||
Frame _frame;
|
||||
BoardState _board;
|
||||
int _width = 250, _height = 122;
|
||||
uint16_t _ink = GxEPD_BLACK, _paper = GxEPD_WHITE;
|
||||
char _scratch[192];
|
||||
|
||||
void hash(const void* value, size_t size) { _display->crcAdd(value, size); }
|
||||
void hashGeometry(int a, int b = 0, int c = 0, int d = 0) {
|
||||
const int value[] = {a, b, c, d};
|
||||
hash(value, sizeof(value));
|
||||
}
|
||||
void normal() { _font.setForegroundColor(_ink); _font.setBackgroundColor(_paper); }
|
||||
void inverse() { _font.setForegroundColor(_paper); _font.setBackgroundColor(_ink); }
|
||||
void font(bool bold = false, bool large = false) {
|
||||
const uint8_t key = (bold ? 1 : 0) | (large ? 2 : 0);
|
||||
hash(&key, 1);
|
||||
_font.setFont(large ? (bold ? u8g2_font_helvB10_tf : u8g2_font_helvR10_tf)
|
||||
: (bold ? u8g2_font_helvB08_tf : u8g2_font_helvR08_tf));
|
||||
}
|
||||
int textWidth(const char* value) { return _font.getUTF8Width(value); }
|
||||
void fitText(char* value, int maximum_width) {
|
||||
while (textWidth(value) > maximum_width && value[0] != 0) {
|
||||
const size_t length = strlen(value);
|
||||
size_t previous = 0, cursor = 0;
|
||||
while (cursor < length) { previous = cursor; cursor = nextUtf8Character(value, cursor, length); }
|
||||
value[previous] = 0;
|
||||
}
|
||||
}
|
||||
void textRight(int x, int y, const char* value) { text(x - textWidth(value), y, value); }
|
||||
void textCentre(int x, int y, const char* value) { text(x - textWidth(value) / 2, y, value); }
|
||||
void fill(int x, int y, int width, int height, bool erase = false) {
|
||||
hashGeometry(x, y, width, height);
|
||||
hash(&erase, sizeof(erase));
|
||||
_gfx->fillRect(x, y, width, height, erase ? _paper : _ink);
|
||||
}
|
||||
void dots(int left, int right, int y) {
|
||||
hashGeometry(left, right, y);
|
||||
for (int x = left; x < right; x += 3) _gfx->drawPixel(x, y, _ink);
|
||||
}
|
||||
void textPart(int x, int y, const char* value, size_t length) {
|
||||
if (length >= sizeof(_scratch)) length = sizeof(_scratch) - 1;
|
||||
memcpy(_scratch, value, length);
|
||||
_scratch[length] = 0;
|
||||
text(x, y, _scratch);
|
||||
}
|
||||
int widthPart(const char* value, size_t length) {
|
||||
if (length >= sizeof(_scratch)) length = sizeof(_scratch) - 1;
|
||||
memcpy(_scratch, value, length);
|
||||
_scratch[length] = 0;
|
||||
return textWidth(_scratch);
|
||||
}
|
||||
int wrap(const char* value, int first_width, int width, Line* lines, int capacity) {
|
||||
const size_t length = strlen(value);
|
||||
size_t position = 0;
|
||||
int count = 0;
|
||||
while (position < length && count < capacity) {
|
||||
if (count != 0) while (position < length && value[position] == ' ') ++position;
|
||||
if (position == length) break;
|
||||
const int available = count == 0 ? first_width : width;
|
||||
size_t cursor = position, last_fit = position, last_space = position;
|
||||
while (cursor < length) {
|
||||
const size_t next = nextUtf8Character(value, cursor, length);
|
||||
if (widthPart(value + position, next - position) > available) break;
|
||||
if (value[cursor] == ' ') last_space = cursor;
|
||||
cursor = next;
|
||||
last_fit = next;
|
||||
}
|
||||
if (last_fit == position) {
|
||||
if (count == 0 && first_width < width) {
|
||||
lines[count++] = {(uint8_t)position, 0};
|
||||
continue;
|
||||
}
|
||||
last_fit = nextUtf8Character(value, position, length);
|
||||
} else if (last_fit < length && last_space > position) {
|
||||
last_fit = last_space;
|
||||
}
|
||||
lines[count++] = {(uint8_t)position, (uint8_t)(last_fit - position)};
|
||||
position = last_fit;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
void clock(char* output, size_t size, uint32_t timestamp) const {
|
||||
const int64_t local = (int64_t)timestamp + (int32_t)(_frame.tz_offset * 3600.0f);
|
||||
int32_t seconds = (int32_t)(local % 86400);
|
||||
if (seconds < 0) seconds += 86400;
|
||||
snprintf(output, size, "%02u:%02u", (unsigned)(seconds / 3600),
|
||||
(unsigned)(seconds / 60 % 60));
|
||||
}
|
||||
void messageTime(char* output, size_t size, uint64_t heard_millis) const {
|
||||
const uint32_t age = messageAgeSeconds(_frame.heard_millis, heard_millis);
|
||||
if (age >= 20UL * 3600UL)
|
||||
snprintf(output, size, "%lud", (unsigned long)((age + 43200) / 86400));
|
||||
else if (_frame.now >= VALID_TIME && age <= _frame.now - VALID_TIME)
|
||||
clock(output, size, _frame.now - (uint32_t)age);
|
||||
else if (age >= 3600) snprintf(output, size, "%luh", (unsigned long)(age / 3600));
|
||||
else if (age >= 60) snprintf(output, size, "%lum", (unsigned long)(age / 60));
|
||||
else snprintf(output, size, "now");
|
||||
}
|
||||
|
||||
public:
|
||||
void begin(GxEPDDisplay& display, const Settings& settings, const Frame& frame) {
|
||||
_display = &display;
|
||||
_settings = settings;
|
||||
_frame = frame;
|
||||
display.setNativeRotation((frame.rotation + (settings.flip ? 2 : 0)) & 3);
|
||||
_gfx = &display.gfx();
|
||||
if (!_ready) { _font.begin(*_gfx); _ready = true; }
|
||||
_width = _gfx->width(); _height = _gfx->height();
|
||||
_ink = settings.dark ? GxEPD_WHITE : GxEPD_BLACK;
|
||||
_paper = settings.dark ? GxEPD_BLACK : GxEPD_WHITE;
|
||||
_gfx->fillScreen(_paper);
|
||||
_font.setFontMode(1); _font.setFontDirection(0);
|
||||
normal();
|
||||
const uint8_t appearance[] = {(uint8_t)frame.rotation, settings.flip, settings.dark};
|
||||
hash(appearance, sizeof(appearance));
|
||||
}
|
||||
int width() const { return _width; }
|
||||
int height() const { return _height; }
|
||||
int bodyTop() const { return HEADER_HEIGHT + 2; }
|
||||
int bodyBottom() const { return _height - FOOTER_HEIGHT - 2; }
|
||||
Adafruit_GFX& gfx() { return *_gfx; }
|
||||
uint16_t ink() const { return _ink; }
|
||||
uint16_t paper() const { return _paper; }
|
||||
void text(int x, int y, const char* value) {
|
||||
hashGeometry(x, y);
|
||||
hash(value, strlen(value));
|
||||
_font.drawUTF8(x, y, value);
|
||||
}
|
||||
void line(int x0, int y0, int x1, int y1) {
|
||||
hashGeometry(x0, y0, x1, y1);
|
||||
_gfx->drawLine(x0, y0, x1, y1, _ink);
|
||||
}
|
||||
void label(int x, int y, const char* value, bool bold = false) {
|
||||
font(bold);
|
||||
if (value != nullptr && x >= 0 && x < _width) {
|
||||
sanitizeText(_scratch, sizeof(_scratch), value, strlen(value));
|
||||
fitText(_scratch, _width - x);
|
||||
text(x, y, _scratch);
|
||||
}
|
||||
font();
|
||||
}
|
||||
void marker(int x, int y, bool self = false) {
|
||||
hashGeometry(x, y, self);
|
||||
_gfx->fillCircle(x, y, self ? 3 : 2, _ink);
|
||||
if (self) _gfx->drawCircle(x, y, 5, _ink);
|
||||
}
|
||||
void header(const char* title, const char* centre = nullptr) {
|
||||
fill(0, 0, _width, HEADER_HEIGHT);
|
||||
inverse(); font(true);
|
||||
char title_text[48];
|
||||
sanitizeText(title_text, sizeof(title_text), title, strlen(title));
|
||||
fitText(title_text, _width / 2 - 4);
|
||||
text(3, 10, title_text);
|
||||
const int title_end = 3 + textWidth(title_text);
|
||||
char status[16];
|
||||
const int battery = _frame.battery_percent < 0 ? 0 : _frame.battery_percent > 100 ? 100 : _frame.battery_percent;
|
||||
snprintf(status, sizeof(status), "%d%%", battery);
|
||||
textRight(_width - 3, 10, status);
|
||||
int right = _width - 8 - textWidth(status);
|
||||
if (_frame.connection != nullptr && _frame.connection[0] != 0) {
|
||||
const int connection_width = textWidth(_frame.connection);
|
||||
if (right - connection_width > title_end + 5) {
|
||||
textRight(right, 10, _frame.connection);
|
||||
right -= connection_width + 5;
|
||||
}
|
||||
}
|
||||
if (centre == nullptr && _frame.now >= VALID_TIME) {
|
||||
clock(status, sizeof(status), _frame.now); centre = status;
|
||||
}
|
||||
if (centre != nullptr) {
|
||||
const int centre_width = textWidth(centre);
|
||||
int x = (_width - centre_width) / 2;
|
||||
if (x + centre_width > right) x = right - centre_width;
|
||||
if (x > title_end + 5) text(x, 10, centre);
|
||||
}
|
||||
normal();
|
||||
}
|
||||
void footer(const char* hint = nullptr) {
|
||||
fill(0, _height - FOOTER_HEIGHT, _width, FOOTER_HEIGHT, true);
|
||||
dots(2, _width - 2, _height - FOOTER_HEIGHT);
|
||||
font(); normal();
|
||||
if (hint == nullptr) hint = _frame.hint != nullptr ? _frame.hint : "click next / 2x back";
|
||||
char hint_text[96];
|
||||
sanitizeText(hint_text, sizeof(hint_text), hint, strlen(hint));
|
||||
fitText(hint_text, _width - 6);
|
||||
text(3, _height - 3, hint_text);
|
||||
if (_frame.page_count > 1 && _frame.page_count <= 16) {
|
||||
const int step = 5;
|
||||
const int left = _width - 4 - (_frame.page_count - 1) * step;
|
||||
if (textWidth(hint_text) + 7 < left) {
|
||||
for (int i = 0; i < _frame.page_count; ++i) {
|
||||
const int x = left + i * step;
|
||||
if (i == _frame.page_index) _gfx->fillRect(x - 1, _height - 8, 3, 3, _ink);
|
||||
else _gfx->drawPixel(x, _height - 7, _ink);
|
||||
}
|
||||
hashGeometry(_frame.page_index, _frame.page_count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
template <typename History>
|
||||
unsigned boardCount(const History& history) const {
|
||||
unsigned count = 0;
|
||||
for (size_t age = 0; age < history.count(); ++age) {
|
||||
const auto* entry = history.newest(age);
|
||||
if (entry != nullptr && entry->channel_idx == _settings.channel) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
template <typename History>
|
||||
bool scrollOlder(const History& history) { return _board.scrollOlder(boardCount(history)); }
|
||||
bool scrollOlder(unsigned count) { return _board.scrollOlder(count); }
|
||||
bool scrollNewer() { return _board.scrollNewer(); }
|
||||
bool isScrolled() const { return _board.scroll != 0; }
|
||||
void resetScroll() { _board.scroll = 0; }
|
||||
void messageAdded() { _board.messageAdded(); }
|
||||
|
||||
template <typename History>
|
||||
void renderBoard(History& history, const char* channel_name) {
|
||||
char title[42], centre[18];
|
||||
snprintf(title, sizeof(title), "# %s", channel_name == nullptr ? "Channel" : channel_name);
|
||||
const unsigned count = boardCount(history);
|
||||
_board.clamp(count);
|
||||
if (_board.scroll != 0) snprintf(centre, sizeof(centre), "%u newer", _board.scroll);
|
||||
header(title, _board.scroll != 0 ? centre : nullptr);
|
||||
if (count == 0) {
|
||||
font(true); textCentre(_width / 2, 54, "Listening for messages");
|
||||
font(); textCentre(_width / 2, 72, "hold to browse / settings picks channel");
|
||||
footer(); return;
|
||||
}
|
||||
|
||||
const bool large = _settings.text_large != 0;
|
||||
font(false, large);
|
||||
const int ascent = _font.getFontAscent();
|
||||
const int line_height = ascent - _font.getFontDescent() + (large ? 2 : 1);
|
||||
int y = bodyBottom();
|
||||
unsigned filtered_age = 0;
|
||||
for (size_t age = 0; age < history.count() && y >= bodyTop(); ++age) {
|
||||
const auto* entry = history.newest(age);
|
||||
if (entry == nullptr || entry->channel_idx != _settings.channel) continue;
|
||||
if (filtered_age++ < _board.scroll) continue;
|
||||
BoardMessageView message;
|
||||
parseBoardMessage(entry->origin, entry->message, entry->own_sent, message);
|
||||
char sender[64], body[192], timestamp[16];
|
||||
sanitizeText(sender, sizeof(sender), message.sender, message.sender_length);
|
||||
truncateCharacters(sender, nameCharacters(_settings));
|
||||
sanitizeText(body, sizeof(body), message.text, strlen(message.text));
|
||||
messageTime(timestamp, sizeof(timestamp), entry->heard_millis);
|
||||
font(true, large);
|
||||
int name_width = sender[0] == 0 ? 0 : textWidth(sender) + (entry->own_sent ? 9 : 5);
|
||||
// Long full names must leave room for the timestamp and message text.
|
||||
fitText(sender, _width / 2 - (entry->own_sent ? 9 : 5));
|
||||
name_width = sender[0] == 0 ? 0 : textWidth(sender) + (entry->own_sent ? 9 : 5);
|
||||
font(); const int time_width = textWidth(timestamp);
|
||||
font(false, large);
|
||||
Line lines[24];
|
||||
int line_count = wrap(body, _width - 10 - name_width - time_width,
|
||||
_width - 4, lines, 24);
|
||||
if (line_count == 0) { lines[0] = {0, 0}; line_count = 1; }
|
||||
const int top = y - line_count * line_height + 1;
|
||||
bool shown = false;
|
||||
for (int i = 0; i < line_count; ++i) {
|
||||
const int line_top = top + i * line_height;
|
||||
if (line_top < bodyTop()) continue;
|
||||
const int baseline = line_top + ascent;
|
||||
int x = 2;
|
||||
if (i == 0) {
|
||||
if (sender[0] != 0) {
|
||||
font(true, large);
|
||||
if (entry->own_sent) {
|
||||
fill(x, line_top - 1, name_width - 3, line_height + 1);
|
||||
inverse(); text(x + 3, baseline, sender); normal();
|
||||
} else text(x, baseline, sender);
|
||||
x += name_width;
|
||||
}
|
||||
font(); textRight(_width - 2, baseline, timestamp);
|
||||
shown = true;
|
||||
}
|
||||
font(false, large);
|
||||
if (lines[i].length != 0) {
|
||||
textPart(x, baseline, body + lines[i].start, lines[i].length);
|
||||
shown = true;
|
||||
}
|
||||
}
|
||||
if (shown && _display->isOn()) history.markRead(entry);
|
||||
y = top - 5;
|
||||
if (y > bodyTop()) dots(2, _width - 2, y + 2);
|
||||
}
|
||||
footer();
|
||||
}
|
||||
|
||||
void renderSettings(unsigned selected, bool editing, const char* channel_name = nullptr) {
|
||||
header("Settings", editing ? "editing" : nullptr);
|
||||
static const char* const names[] = {"Channel", "Text", "Names", "Screen", "Theme", "Return", "Clean", "Map zoom"};
|
||||
static const char* const idle[] = {"30 seconds", "1 minute", "5 minutes", "Never"};
|
||||
static const char* const clean[] = {"10 updates", "30 updates", "60 updates", "Manual"};
|
||||
for (unsigned row = 0; row < SETTINGS_COUNT; ++row) {
|
||||
const int y = bodyTop() + row * 11;
|
||||
if (row == selected && editing) { fill(1, y - 1, _width - 2, 11); inverse(); }
|
||||
else normal();
|
||||
font(row == selected && editing);
|
||||
text(4, y + 8, names[row]);
|
||||
char value[40];
|
||||
switch (row) {
|
||||
case 0:
|
||||
if (channel_name != nullptr && channel_name[0] != 0)
|
||||
sanitizeText(value, sizeof(value), channel_name, strlen(channel_name));
|
||||
else snprintf(value, sizeof(value), "Channel %u", _settings.channel);
|
||||
truncateCharacters(value, 19); break;
|
||||
case 1: snprintf(value, sizeof(value), "%s", _settings.text_large ? "Large" : "Small"); break;
|
||||
case 2:
|
||||
if (nameCharacters(_settings) == 0) snprintf(value, sizeof(value), "Full");
|
||||
else snprintf(value, sizeof(value), "%u characters", nameCharacters(_settings));
|
||||
break;
|
||||
case 3: snprintf(value, sizeof(value), "%s", _settings.flip ? "Flipped" : "Normal"); break;
|
||||
case 4: snprintf(value, sizeof(value), "%s", _settings.dark ? "Dark" : "Light"); break;
|
||||
case 5: snprintf(value, sizeof(value), "%s", idle[_settings.idle <= 3 ? _settings.idle : 1]); break;
|
||||
case 6: snprintf(value, sizeof(value), "%s", clean[_settings.refresh <= 3 ? _settings.refresh : 1]); break;
|
||||
default: snprintf(value, sizeof(value), "%u km", (unsigned)mapHeightKm(_settings)); break;
|
||||
}
|
||||
textRight(_width - 4, y + 8, value);
|
||||
}
|
||||
normal(); footer();
|
||||
}
|
||||
|
||||
void renderMapChrome(unsigned actual_height_km = 0) {
|
||||
char range[24];
|
||||
snprintf(range, sizeof(range), "%u km", actual_height_km != 0
|
||||
? actual_height_km : (unsigned)mapHeightKm(_settings));
|
||||
header("Map", range);
|
||||
footer();
|
||||
}
|
||||
void mapEmpty(const char* message) {
|
||||
font(true); textCentre(_width / 2, 54, message);
|
||||
font(); textCentre(_width / 2, 72, "Set location in the companion app");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace pocket
|
||||
} // namespace ui
|
||||
} // namespace mesh
|
||||
|
||||
#endif // UI_POCKET_GUI
|
||||
@@ -24,6 +24,10 @@
|
||||
#include "target.h"
|
||||
#include <time.h>
|
||||
#include <math.h>
|
||||
#if UI_POCKET_GUI
|
||||
#include "PocketGui.h"
|
||||
#include <helpers/ui/PocketWorldMap.h>
|
||||
#endif
|
||||
#if defined(ENABLE_WIFI_INTERFACE) || defined(WIFI_SSID)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
@@ -416,6 +420,11 @@ public:
|
||||
class HomeScreen : public UIScreen {
|
||||
enum HomePage {
|
||||
FIRST,
|
||||
#if UI_POCKET_GUI
|
||||
MAP,
|
||||
STATUS,
|
||||
SETTINGS,
|
||||
#endif
|
||||
#if UI_MESSAGES_HOME_PAGE == 1
|
||||
MESSAGES,
|
||||
#endif
|
||||
@@ -463,6 +472,16 @@ class HomeScreen : public UIScreen {
|
||||
uint32_t _radio_profile_page_started_at = 0;
|
||||
uint8_t _radio_status_page = 0;
|
||||
bool _dual_radio_enabled_seen = false;
|
||||
#if UI_POCKET_GUI
|
||||
mesh::ui::pocket::PocketGui _pocket;
|
||||
bool _pocket_editing = false;
|
||||
bool _pocket_browsing = false;
|
||||
bool _pocket_zooming = false;
|
||||
bool _pocket_dirty = false;
|
||||
uint8_t _pocket_setting = 0;
|
||||
uint32_t _pocket_last_input = 0;
|
||||
uint32_t _pocket_save_retry = 0;
|
||||
#endif
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
#if !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0))
|
||||
DiscoveredNode discovered[UI_RECENT_LIST_SIZE];
|
||||
@@ -620,6 +639,10 @@ class HomeScreen : public UIScreen {
|
||||
}
|
||||
|
||||
void movePage(int direction) {
|
||||
#if UI_POCKET_GUI
|
||||
flushPocketSettings();
|
||||
_pocket_editing = _pocket_browsing = _pocket_zooming = false;
|
||||
#endif
|
||||
do {
|
||||
_page = (_page + HomePage::Count + direction) % HomePage::Count;
|
||||
} while (!isPageVisible(_page));
|
||||
@@ -636,7 +659,268 @@ class HomeScreen : public UIScreen {
|
||||
: UI_RADIO_REFRESH_MILLIS;
|
||||
}
|
||||
|
||||
#if UI_POCKET_GUI
|
||||
void displayForceClean() { _task->cleanPocketDisplay(); }
|
||||
|
||||
void cyclePocketChannel(int direction) {
|
||||
auto& settings = _task->pocketSettings();
|
||||
const int previous = settings.channel;
|
||||
int selected = previous;
|
||||
for (int attempt = 0; attempt < MAX_GROUP_CHANNELS; ++attempt) {
|
||||
selected = (selected + MAX_GROUP_CHANNELS + direction) % MAX_GROUP_CHANNELS;
|
||||
ChannelDetails channel;
|
||||
if (the_mesh.getChannel(selected, channel) && channel.name[0]) {
|
||||
settings.channel = selected;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_pocket_dirty |= previous != settings.channel;
|
||||
_pocket.resetScroll();
|
||||
}
|
||||
|
||||
static bool pocketLocation(double latitude, double longitude) {
|
||||
return isfinite(latitude) && isfinite(longitude)
|
||||
&& latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180
|
||||
&& (latitude != 0 || longitude != 0);
|
||||
}
|
||||
|
||||
__attribute__((noinline)) void renderPocketMap() {
|
||||
using namespace mesh::ui::pocket;
|
||||
const auto& settings = _task->pocketSettings();
|
||||
const double own_lat = sensors.node_lat, own_lon = sensors.node_lon;
|
||||
const bool have_own = pocketLocation(own_lat, own_lon);
|
||||
double latitude = own_lat, longitude = own_lon;
|
||||
bool located = have_own;
|
||||
ContactInfo contact;
|
||||
if (!located) {
|
||||
auto contacts = the_mesh.startContactsIterator();
|
||||
while (contacts.hasNext(&the_mesh, contact)) {
|
||||
latitude = contact.gps_lat * 1e-6;
|
||||
longitude = contact.gps_lon * 1e-6;
|
||||
if (pocketLocation(latitude, longitude)) { located = true; break; }
|
||||
}
|
||||
}
|
||||
MapView view = {located ? latitude : 0, located ? longitude : 0,
|
||||
located ? mapHeightKm(settings) : 20000, _pocket.width(),
|
||||
_pocket.bodyBottom() - _pocket.bodyTop()};
|
||||
if (!renderWorldMap(view, [](void* context, int x0, int y0, int x1, int y1) {
|
||||
auto& gui = *static_cast<PocketGui*>(context);
|
||||
gui.line(x0, y0 + gui.bodyTop(), x1, y1 + gui.bodyTop());
|
||||
}, &_pocket)) {
|
||||
_pocket.label(3, 40, "Map unavailable");
|
||||
}
|
||||
int x, y;
|
||||
unsigned shown = 0, labels = 0;
|
||||
auto contacts = the_mesh.startContactsIterator();
|
||||
while (contacts.hasNext(&the_mesh, contact)) {
|
||||
latitude = contact.gps_lat * 1e-6; longitude = contact.gps_lon * 1e-6;
|
||||
if (!pocketLocation(latitude, longitude)
|
||||
|| !projectWorldPoint(view, latitude, longitude, x, y)) continue;
|
||||
_pocket.marker(x, y + _pocket.bodyTop());
|
||||
++shown;
|
||||
if (view.height_km <= 50 && labels < 4 && x < view.width - 52 && y >= 10) {
|
||||
char name[12];
|
||||
sanitizeText(name, sizeof(name), contact.name, strnlen(contact.name, sizeof(contact.name)));
|
||||
truncateCharacters(name, 8);
|
||||
_pocket.label(x + 4, y + _pocket.bodyTop() - 3, name);
|
||||
++labels;
|
||||
}
|
||||
}
|
||||
if (have_own && projectWorldPoint(view, own_lat, own_lon, x, y))
|
||||
_pocket.marker(x, y + _pocket.bodyTop(), true);
|
||||
char summary[40];
|
||||
if (located) snprintf(summary, sizeof(summary), "%u nodes%s", shown,
|
||||
view.height_km < 100 ? " / coarse coast" : "");
|
||||
else snprintf(summary, sizeof(summary), "World / no node locations");
|
||||
_pocket.label(3, _pocket.bodyTop() + 9, summary);
|
||||
_pocket.renderMapChrome((unsigned)view.height_km);
|
||||
}
|
||||
|
||||
int renderPocket(DisplayDriver& display) {
|
||||
using namespace mesh::ui::pocket;
|
||||
const auto& settings = _task->pocketSettings();
|
||||
Frame frame;
|
||||
const uint32_t uptime_now = millis();
|
||||
_uptime_millis += uint32_t(uptime_now - _uptime_last_millis);
|
||||
_uptime_last_millis = uptime_now;
|
||||
frame.now = _rtc->getCurrentTime(); frame.heard_millis = companionMessageNowMillis();
|
||||
frame.tz_offset = _node_prefs->tz_offset;
|
||||
frame.battery_percent = (int(_task->getBattMilliVolts()) - BATT_MIN_MILLIVOLTS)
|
||||
* 100 / (BATT_MAX_MILLIVOLTS - BATT_MIN_MILLIVOLTS);
|
||||
#if defined(NRF52_POWER_MANAGEMENT)
|
||||
frame.battery_percent = mesh::power::configuredBatteryPercent(_task->getBattMilliVolts());
|
||||
#endif
|
||||
frame.connection = _task->connectedClientLabel();
|
||||
frame.page_count = frame.page_index = 0;
|
||||
for (unsigned page = 0; page < HomePage::Count; ++page) if (isPageVisible(page)) {
|
||||
if (page < _page) ++frame.page_index;
|
||||
++frame.page_count;
|
||||
}
|
||||
frame.rotation = DISPLAY_ROTATION;
|
||||
if (_pocket_browsing) frame.hint = "1x older 2x newer hold exit";
|
||||
else if (_pocket_zooming) frame.hint = "1x zoom in 2x out hold done";
|
||||
else if (_pocket_editing) frame.hint = "1x value 2x field hold save";
|
||||
else if (isRadioPage()) frame.hint = "click next 2x back hold Reader";
|
||||
else if (_page == HomePage::FIRST) frame.hint = "click next 2x back hold browse";
|
||||
else if (_page == HomePage::MAP) frame.hint = "click next 2x back hold zoom";
|
||||
else if (_page == HomePage::SETTINGS) frame.hint = "click next 2x back hold edit";
|
||||
else frame.hint = "click next 2x back hold action";
|
||||
static_cast<GxEPDDisplay&>(display).setMaxPartialRefresh(refreshCount(settings));
|
||||
_pocket.begin(static_cast<GxEPDDisplay&>(display), settings, frame);
|
||||
ChannelDetails channel;
|
||||
char channel_name[32] = {};
|
||||
if (the_mesh.getChannel(settings.channel, channel))
|
||||
StrHelper::strncpy(channel_name, channel.name, sizeof(channel_name));
|
||||
if (_page == HomePage::FIRST) _task->renderPocketBoard(_pocket);
|
||||
else if (_page == HomePage::MAP) { renderPocketMap(); return 10000; }
|
||||
else if (_page == HomePage::SETTINGS) _pocket.renderSettings(_pocket_setting, _pocket_editing, channel_name);
|
||||
else {
|
||||
char text[80];
|
||||
int y = _pocket.bodyTop() + 10;
|
||||
auto row = [&](const char* value) {
|
||||
if (y <= _pocket.bodyBottom() - 2) _pocket.label(4, y, value);
|
||||
y += 14;
|
||||
};
|
||||
if (_page == HomePage::STATUS) {
|
||||
_pocket.header("Status", (_radio_status_page & 1) ? "2 / 2" : "1 / 2");
|
||||
if ((_radio_status_page & 1) == 0) {
|
||||
snprintf(text, sizeof(text), "Uptime %lu min battery %u mV", (unsigned long)(_uptime_millis / 60000), _task->getBattMilliVolts()); row(text);
|
||||
snprintf(text, sizeof(text), "RX %lu flood / %lu direct", (unsigned long)the_mesh.getNumRecvFlood(), (unsigned long)the_mesh.getNumRecvDirect()); row(text);
|
||||
snprintf(text, sizeof(text), "TX %lu flood / %lu direct", (unsigned long)the_mesh.getNumSentFlood(), (unsigned long)the_mesh.getNumSentDirect()); row(text);
|
||||
snprintf(text, sizeof(text), "Inbox %d / %d errors %04X", _task->getMsgCount(), the_mesh.getOfflineQueueCapacity(), the_mesh.getErrFlags()); row(text);
|
||||
mesh::Utils::toHex(text, the_mesh.self_id.pub_key, 6); row(text);
|
||||
} else {
|
||||
const auto status = radioProfileSystemStatus(*_node_prefs);
|
||||
snprintf(text, sizeof(text), "Power saving %s / GPS %s", status.powersaving_enabled ? "on" : "off", status.gps_enabled ? "on" : "off"); row(text);
|
||||
snprintf(text, sizeof(text), "FEM %s / RX boost %s", status.fem_enabled ? "on" : "off", status.rx_boosted_gain ? "on" : "off"); row(text);
|
||||
snprintf(text, sizeof(text), "RX power saving %s / CAD %s", status.rx_powersaving_enabled ? "on" : "off", status.cad_enabled ? "on" : "off"); row(text);
|
||||
mesh::ui::formatRadioProfileNoiseFloor(text, sizeof(text), "Radio 1 noise", status.noise_floor_1, status.noise_floor_1_seconds); row(text);
|
||||
}
|
||||
} else if (_page == HomePage::RECENT) {
|
||||
_pocket.header("Recently heard");
|
||||
const int count = the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE);
|
||||
for (int i = 0; i < count && y <= _pocket.bodyBottom(); ++i) {
|
||||
char name[40]; sanitizeText(name, sizeof(name), recent[i].name, strnlen(recent[i].name, sizeof(recent[i].name)));
|
||||
snprintf(text, sizeof(text), "%.28s %lum", name, (unsigned long)((frame.now - recent[i].recv_timestamp) / 60)); row(text);
|
||||
}
|
||||
if (!count) row("No adverts heard yet");
|
||||
} else if (_page == HomePage::RADIO_STATUS) {
|
||||
_pocket.header("Radio status", (_radio_status_page & 1) ? "2 / 2" : "1 / 2");
|
||||
const auto status = radioProfileSystemStatus(*_node_prefs);
|
||||
if ((_radio_status_page & 1) == 0) {
|
||||
snprintf(text, sizeof(text), "Power saving %s / GPS %s", status.powersaving_enabled ? "on" : "off", status.gps_enabled ? "on" : "off"); row(text);
|
||||
snprintf(text, sizeof(text), "FEM %s / RX boost %s", status.fem_enabled ? "on" : "off", status.rx_boosted_gain ? "on" : "off"); row(text);
|
||||
snprintf(text, sizeof(text), "RX power saving %s / CAD %s", status.rx_powersaving_enabled ? "on" : "off", status.cad_enabled ? "on" : "off"); row(text);
|
||||
} else {
|
||||
mesh::ui::formatRadioProfileNoiseFloor(text, sizeof(text), "Radio 1 noise", status.noise_floor_1, status.noise_floor_1_seconds); row(text);
|
||||
mesh::ui::formatRadioProfileNoiseFloor(text, sizeof(text), "Radio 2 noise", status.noise_floor_2, status.noise_floor_2_seconds); row(text);
|
||||
snprintf(text, sizeof(text), "Radio2 %s%s / cross %s", status.secondary_mode == mesh::RadioProfileMode::RxTx ? "RX/TX" : "RX",
|
||||
status.secondary_temporary ? " temporary" : "", status.cross == mesh::RadioCrossMode::Off ? "off" : status.cross == mesh::RadioCrossMode::On ? "on" : "auto"); row(text);
|
||||
}
|
||||
row("Hold for more status");
|
||||
} else if (_page == HomePage::RADIO || _page == HomePage::RADIO2) {
|
||||
const auto* radio = the_mesh.getProfileRadio();
|
||||
const auto* profiles = radio ? radio->profiles() : nullptr;
|
||||
const bool secondary = _page == HomePage::RADIO2 && profiles && profiles->enabled();
|
||||
_pocket.header(secondary ? "Radio 2" : "Network");
|
||||
mesh::RadioProfileParams params;
|
||||
if (profiles && profiles->enabled()) params = profiles->params(secondary ? 1 : 0);
|
||||
else { params.freq = _node_prefs->freq; params.bw = _node_prefs->bw; params.sf = _node_prefs->sf; params.cr = _node_prefs->cr; }
|
||||
snprintf(text, sizeof(text), "%.3f MHz / %.2f kHz", params.freq, params.bw); row(text);
|
||||
snprintf(text, sizeof(text), "SF%u CR%u / %d dBm", params.sf, params.cr, _node_prefs->tx_power_dbm); row(text);
|
||||
snprintf(text, sizeof(text), "Noise %.1f dBm / RX boost %s", radio_driver.getNoiseFloorDbm(secondary ? 1 : 0), _node_prefs->rx_boosted_gain ? "on" : "off"); row(text);
|
||||
if (profiles && profiles->enabled()) {
|
||||
snprintf(text, sizeof(text), "Radio2 %s%s / cross %s", profiles->secondary.mode == mesh::RadioProfileMode::RxTx ? "RX/TX" : "RX",
|
||||
profiles->secondary_temporary ? " temporary" : "", profiles->cross == mesh::RadioCrossMode::Off ? "off" : profiles->cross == mesh::RadioCrossMode::On ? "on" : "auto"); row(text);
|
||||
snprintf(text, sizeof(text), "Switches %lu / failures %lu", (unsigned long)profiles->switches, (unsigned long)profiles->switch_failures); row(text);
|
||||
} else row("Radio2 off");
|
||||
#if UI_MESSAGES_HOME_PAGE == 1
|
||||
} else if (_page == HomePage::MESSAGES) {
|
||||
_pocket.header(_task->inboxTitle()); _task->renderPocketInbox(_pocket);
|
||||
#endif
|
||||
#if defined(BLE_PIN_CODE)
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
_pocket.header("Bluetooth"); row(_task->isBluetoothEnabled() ? "Bluetooth enabled" : "Bluetooth disabled");
|
||||
row(_task->hasBluetoothConnection() ? "App connected" : "Waiting for an app"); row("Hold to toggle Bluetooth");
|
||||
#endif
|
||||
} else if (_page == HomePage::ADVERT) {
|
||||
_pocket.header("Advert"); row("Hold to send a local advert");
|
||||
snprintf(text, sizeof(text), "Node: %.31s", _node_prefs->node_name); row(text);
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
} else if (_page == HomePage::GPS) {
|
||||
_pocket.header("Location");
|
||||
snprintf(text, sizeof(text), "Latitude %.6f", sensors.node_lat); row(text);
|
||||
snprintf(text, sizeof(text), "Longitude %.6f", sensors.node_lon); row(text);
|
||||
row(_task->getGPSState() ? "GPS enabled" : "GPS disabled"); row("Hold to toggle GPS");
|
||||
#endif
|
||||
#if UI_SENSORS_PAGE == 1
|
||||
} else if (_page == HomePage::SENSORS) {
|
||||
_pocket.header("Sensors"); refresh_sensors();
|
||||
LPPReader readings(sensors_lpp.getBuffer(), sensors_lpp.getSize());
|
||||
uint8_t channel_id, type;
|
||||
for (int i = 0; i < sensors_scroll_offset && readings.readHeader(channel_id, type); ++i)
|
||||
readings.skipData(type);
|
||||
while (y <= _pocket.bodyBottom() - 2 && readings.readHeader(channel_id, type)) {
|
||||
float value;
|
||||
switch (type) {
|
||||
case LPP_GPS: {
|
||||
float lat, lon, alt; readings.readGPS(lat, lon, alt);
|
||||
snprintf(text, sizeof(text), "GPS %.4f / %.4f", lat, lon); break;
|
||||
}
|
||||
case LPP_VOLTAGE: readings.readVoltage(value); snprintf(text, sizeof(text), "Voltage %.2f V", value); break;
|
||||
case LPP_CURRENT: readings.readCurrent(value); snprintf(text, sizeof(text), "Current %.3f A", value); break;
|
||||
case LPP_TEMPERATURE: readings.readTemperature(value); snprintf(text, sizeof(text), "Temperature %.2f C", value); break;
|
||||
case LPP_RELATIVE_HUMIDITY: readings.readRelativeHumidity(value); snprintf(text, sizeof(text), "Humidity %.2f %%", value); break;
|
||||
case LPP_BAROMETRIC_PRESSURE: readings.readPressure(value); snprintf(text, sizeof(text), "Pressure %.2f hPa", value); break;
|
||||
case LPP_ALTITUDE: readings.readAltitude(value); snprintf(text, sizeof(text), "Altitude %.0f m", value); break;
|
||||
case LPP_POWER: readings.readPower(value); snprintf(text, sizeof(text), "Power %.2f W", value); break;
|
||||
default: readings.skipData(type); continue;
|
||||
}
|
||||
row(text);
|
||||
}
|
||||
if (y == _pocket.bodyTop() + 10) row("No sensor readings");
|
||||
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset + 1) % sensors_nb;
|
||||
#endif
|
||||
#if !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0))
|
||||
} else if (_page == HomePage::DISCOVERY) {
|
||||
_pocket.header("Repeaters nearby");
|
||||
const int count = the_mesh.getDiscoveredNodes(discovered, UI_RECENT_LIST_SIZE);
|
||||
for (int i = 0; i < count && y <= _pocket.bodyBottom(); ++i) {
|
||||
char name[33]; StrHelper::strncpy(name, discovered[i].name, sizeof(name));
|
||||
if (!name[0] || !discovery_disp_names) mesh::Utils::toHex(name, discovered[i].pubkey_prefix, 4);
|
||||
snprintf(text, sizeof(text), "%.28s %.1f dB", name, discovered[i].snr_in); row(text);
|
||||
}
|
||||
if (!count) row("Hold to discover nearby repeaters");
|
||||
#endif
|
||||
#ifndef UI_NO_HIBERNATE
|
||||
} else if (_page == HomePage::SHUTDOWN) {
|
||||
_pocket.header("Power"); row("Hold to shut down");
|
||||
#endif
|
||||
} else _pocket.header(_node_prefs->node_name);
|
||||
_pocket.footer();
|
||||
}
|
||||
return 5000;
|
||||
}
|
||||
#endif
|
||||
|
||||
public:
|
||||
#if UI_POCKET_GUI
|
||||
bool isPocketBoard() const { return _page == HomePage::FIRST; }
|
||||
void pocketMessageAdded(int channel) {
|
||||
if (channel == _task->pocketSettings().channel) _pocket.messageAdded();
|
||||
}
|
||||
bool flushPocketSettings() {
|
||||
if (!_pocket_dirty) return true;
|
||||
if (!the_mesh.savePocketGuiSettings(_task->pocketSettings())) {
|
||||
_pocket_save_retry = millis() + 30000;
|
||||
_task->showAlert("Settings save failed", 1500);
|
||||
return false;
|
||||
}
|
||||
_pocket_dirty = false;
|
||||
_pocket_save_retry = 0;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, CompanionNodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
|
||||
_shutdown_init(false),
|
||||
@@ -652,7 +936,15 @@ public:
|
||||
#endif
|
||||
}
|
||||
|
||||
void showFirstPage() { _page = HomePage::FIRST; }
|
||||
void showFirstPage() {
|
||||
#if UI_POCKET_GUI
|
||||
flushPocketSettings();
|
||||
_pocket_editing = _pocket_browsing = _pocket_zooming = false;
|
||||
_pocket.resetScroll();
|
||||
_pocket_last_input = millis();
|
||||
#endif
|
||||
_page = HomePage::FIRST;
|
||||
}
|
||||
#if COMPANION_FEATURE_READER
|
||||
bool isRadioPage() const {
|
||||
return _page == HomePage::RADIO || _page == HomePage::RADIO2;
|
||||
@@ -672,6 +964,20 @@ public:
|
||||
#endif
|
||||
|
||||
void poll() override {
|
||||
#if UI_POCKET_GUI
|
||||
const uint32_t pocket_now = millis();
|
||||
if (_pocket_dirty && _pocket_save_retry && int32_t(pocket_now - _pocket_save_retry) >= 0)
|
||||
flushPocketSettings();
|
||||
const uint32_t idle = mesh::ui::pocket::idleMillis(_task->pocketSettings());
|
||||
if (idle && uint32_t(pocket_now - _pocket_last_input) >= idle
|
||||
&& (_page != HomePage::FIRST || _pocket_browsing)) {
|
||||
flushPocketSettings();
|
||||
_pocket_editing = _pocket_browsing = _pocket_zooming = false;
|
||||
_pocket.resetScroll();
|
||||
_page = HomePage::FIRST;
|
||||
_task->gotoHomeScreen();
|
||||
}
|
||||
#endif
|
||||
if (_dual_radio_enabled_seen != the_mesh.isDualRadioActive()) {
|
||||
resetRadioProfileDisplayPage();
|
||||
}
|
||||
@@ -704,6 +1010,9 @@ public:
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
#if UI_POCKET_GUI
|
||||
return renderPocket(display);
|
||||
#endif
|
||||
display.setTextSize(1);
|
||||
const int normal_line = !display.useSmallMessageFont()
|
||||
&& display.renderWidth() == display.width() ? display.textLineHeight() : 11;
|
||||
@@ -1322,6 +1631,56 @@ public:
|
||||
bool handleInput(char c) override {
|
||||
// Navigation codes exceed 0x7f; keep them valid with signed char too.
|
||||
const uint8_t key = static_cast<uint8_t>(c);
|
||||
#if UI_POCKET_GUI
|
||||
_pocket_last_input = millis();
|
||||
auto& settings = _task->pocketSettings();
|
||||
if (_page == HomePage::SETTINGS && key == KEY_ENTER) {
|
||||
_pocket_editing = !_pocket_editing;
|
||||
if (!_pocket_editing) flushPocketSettings();
|
||||
return true;
|
||||
}
|
||||
if (_page == HomePage::SETTINGS && _pocket_editing) {
|
||||
if (key == KEY_PREV || key == KEY_LEFT) {
|
||||
_pocket_setting = (_pocket_setting + 1) % mesh::ui::pocket::SETTINGS_COUNT;
|
||||
return true;
|
||||
}
|
||||
if (key == KEY_NEXT || key == KEY_RIGHT) {
|
||||
const uint8_t previous_dark = settings.dark, previous_flip = settings.flip;
|
||||
if (_pocket_setting == 0) cyclePocketChannel(1);
|
||||
else mesh::ui::pocket::cycleSetting(settings, _pocket_setting, MAX_GROUP_CHANNELS);
|
||||
_pocket_dirty = true;
|
||||
if (previous_dark != settings.dark || previous_flip != settings.flip) displayForceClean();
|
||||
_pocket.resetScroll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (_page == HomePage::FIRST) {
|
||||
if (key == KEY_ENTER) { _pocket_browsing = !_pocket_browsing; return true; }
|
||||
if (key == KEY_DOWN || key == KEY_UP) {
|
||||
cyclePocketChannel(key == KEY_DOWN ? 1 : -1);
|
||||
flushPocketSettings();
|
||||
return true;
|
||||
}
|
||||
if (_pocket_browsing) {
|
||||
if (key == KEY_NEXT || key == KEY_RIGHT) { _pocket.scrollOlder(_task->pocketBoardCount()); return true; }
|
||||
if (key == KEY_PREV || key == KEY_LEFT) { _pocket.scrollNewer(); return true; }
|
||||
}
|
||||
}
|
||||
if (_page == HomePage::MAP) {
|
||||
if (key == KEY_ENTER) {
|
||||
_pocket_zooming = !_pocket_zooming;
|
||||
if (!_pocket_zooming) flushPocketSettings();
|
||||
return true;
|
||||
}
|
||||
if (_pocket_zooming && (key == KEY_NEXT || key == KEY_RIGHT || key == KEY_PREV || key == KEY_LEFT)) {
|
||||
const bool closer = key == KEY_NEXT || key == KEY_RIGHT;
|
||||
if (closer && settings.map_zoom < 8) ++settings.map_zoom;
|
||||
if (!closer && settings.map_zoom > 0) --settings.map_zoom;
|
||||
_pocket_dirty = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (key == KEY_LEFT || key == KEY_PREV) {
|
||||
movePage(-1);
|
||||
return true;
|
||||
@@ -1344,7 +1703,11 @@ public:
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
if (key == KEY_ENTER && _page == HomePage::RADIO_STATUS) {
|
||||
if (key == KEY_ENTER && (_page == HomePage::RADIO_STATUS
|
||||
#if UI_POCKET_GUI
|
||||
|| _page == HomePage::STATUS
|
||||
#endif
|
||||
)) {
|
||||
_radio_status_page++;
|
||||
return true;
|
||||
}
|
||||
@@ -1615,6 +1978,19 @@ public:
|
||||
#endif
|
||||
explicit MsgPreviewScreen(UITask* task)
|
||||
: _task(task), view_offset(0), channel_filter(CHANNEL_FILTER_ALL) {}
|
||||
#if UI_POCKET_GUI
|
||||
MessageHistory& pocketHistory() { return history; }
|
||||
const MessageHistory& pocketHistory() const { return history; }
|
||||
void addSent(int channel_idx, const char* channel_name, const char* sender,
|
||||
const char* text, size_t length) {
|
||||
char message[UI_MSG_PREVIEW_SIZE];
|
||||
if (length >= sizeof(message)) length = sizeof(message) - 1;
|
||||
memcpy(message, text, length);
|
||||
message[length] = 0;
|
||||
history.add(companionMessageNowMillis(), channel_idx, channel_name, sender,
|
||||
message, -1, true);
|
||||
}
|
||||
#endif
|
||||
|
||||
bool hasMessages() const {
|
||||
for (size_t age = 0; age < history.count(); ++age)
|
||||
@@ -1952,6 +2328,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode
|
||||
#endif
|
||||
|
||||
_node_prefs = node_prefs;
|
||||
#if UI_POCKET_GUI
|
||||
the_mesh.loadPocketGuiSettings(_pocket_settings);
|
||||
if (_display) _display->forceFullRefresh();
|
||||
#endif
|
||||
|
||||
if (_display != NULL) {
|
||||
_display->servicePower(_board->isExternalPowered() || _board->isUsbHostConnected(), hasConnection());
|
||||
@@ -2031,6 +2411,50 @@ void UITask::renderMessageSummary(DisplayDriver& display) const {
|
||||
static_cast<const MsgPreviewScreen*>(msg_preview)->renderSummary(display);
|
||||
}
|
||||
|
||||
#if UI_POCKET_GUI
|
||||
void UITask::renderPocketBoard(mesh::ui::pocket::PocketGui& gui) const {
|
||||
ChannelDetails channel;
|
||||
char name[32] = {};
|
||||
if (the_mesh.getChannel(_pocket_settings.channel, channel))
|
||||
StrHelper::strncpy(name, channel.name, sizeof(name));
|
||||
gui.renderBoard(static_cast<MsgPreviewScreen*>(msg_preview)->pocketHistory(), name);
|
||||
}
|
||||
|
||||
unsigned UITask::pocketBoardCount() const {
|
||||
const auto& history = static_cast<const MsgPreviewScreen*>(msg_preview)->pocketHistory();
|
||||
unsigned count = 0;
|
||||
for (size_t age = 0; age < history.count(); ++age)
|
||||
if (history.newest(age)->channel_idx == _pocket_settings.channel) ++count;
|
||||
return count;
|
||||
}
|
||||
|
||||
void UITask::renderPocketInbox(mesh::ui::pocket::PocketGui& gui) const {
|
||||
const auto& history = static_cast<const MsgPreviewScreen*>(msg_preview)->pocketHistory();
|
||||
int y = gui.bodyTop() + 10;
|
||||
for (size_t age = 0; age < history.count() && y + 13 <= gui.bodyBottom(); ++age) {
|
||||
const auto* entry = history.newest(age);
|
||||
if (mesh::ui::displayPowerPrefs().inbox == mesh::ui::DisplayInboxMode::Pending
|
||||
&& entry->queue_index < 0) continue;
|
||||
if (mesh::ui::displayPowerPrefs().inbox == mesh::ui::DisplayInboxMode::Unread
|
||||
&& !entry->unread) continue;
|
||||
char label[48];
|
||||
mesh::ui::CompanionMessageHistory<32, UI_MSG_PREVIEW_SIZE>::threadLabel(*entry, label, sizeof(label));
|
||||
mesh::ui::pocket::truncateCharacters(label, 32);
|
||||
gui.label(4, y, label, entry->unread);
|
||||
y += 15;
|
||||
}
|
||||
if (y == gui.bodyTop() + 10) gui.label(4, y, "No messages");
|
||||
}
|
||||
|
||||
void UITask::channelMessageSent(int channel_idx, const char* channel_name,
|
||||
const char* sender, const char* text, size_t length) {
|
||||
if (!msg_preview || channel_idx < 0 || channel_idx >= MAX_GROUP_CHANNELS) return;
|
||||
static_cast<MsgPreviewScreen*>(msg_preview)->addSent(channel_idx, channel_name, sender, text, length);
|
||||
static_cast<HomeScreen*>(home)->pocketMessageAdded(channel_idx);
|
||||
if (_display && _display->isOn()) _next_refresh = millis();
|
||||
}
|
||||
#endif
|
||||
|
||||
void UITask::notify(UIEventType t) {
|
||||
#if defined(PIN_BUZZER)
|
||||
if (_notification_outputs & mesh::notify::Sound) switch(t){
|
||||
@@ -2079,6 +2503,9 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
((MsgPreviewScreen *)msg_preview)
|
||||
->addPreview(path_len, from_name, text, channel_idx, channel_name,
|
||||
queue_index, !hasConnection());
|
||||
#if UI_POCKET_GUI
|
||||
static_cast<HomeScreen*>(home)->pocketMessageAdded(channel_idx);
|
||||
#endif
|
||||
if (hasConnection()) {
|
||||
// Keep history, but do not change the page or wake for a connected client.
|
||||
_deferred_msg_preview = false;
|
||||
@@ -2092,6 +2519,12 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
// Queue incoming messages without stealing the reader's page.
|
||||
_deferred_msg_preview = true;
|
||||
}
|
||||
#endif
|
||||
#if UI_POCKET_GUI
|
||||
else if (channel_idx >= 0 && curr == home && static_cast<HomeScreen*>(home)->isPocketBoard()) {
|
||||
// Keep the live channel board visible while retaining normal notifications.
|
||||
_deferred_msg_preview = false;
|
||||
}
|
||||
#endif
|
||||
else {
|
||||
setCurrScreen(msg_preview);
|
||||
@@ -2130,6 +2563,10 @@ void UITask::userLedHandler() {
|
||||
}
|
||||
|
||||
void UITask::setCurrScreen(UIScreen* c) {
|
||||
#if UI_POCKET_GUI
|
||||
if (curr == home && c != home) static_cast<HomeScreen*>(home)->flushPocketSettings();
|
||||
if (curr != c && _pocket_settings.dark && _display) _display->forceFullRefresh();
|
||||
#endif
|
||||
#if COMPANION_FEATURE_READER
|
||||
if (isReaderActive() && c != reader
|
||||
&& !static_cast<ReaderScreen*>(reader)->flush()) {
|
||||
@@ -2666,6 +3103,9 @@ char UITask::handleMultiClick(char c, bool backwards) {
|
||||
if (_display != NULL && (curr == msg_preview
|
||||
#if COMPANION_FEATURE_READER
|
||||
|| isReaderActive()
|
||||
#endif
|
||||
#if UI_POCKET_GUI
|
||||
|| (curr == home && static_cast<HomeScreen*>(home)->isPocketBoard())
|
||||
#endif
|
||||
)) {
|
||||
return checkDisplayOn(backwards ? KEY_UP : KEY_DOWN);
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
#include "../AbstractUITask.h"
|
||||
#include "../NodePrefs.h"
|
||||
#include <helpers/CompanionReaderConfig.h>
|
||||
#if UI_POCKET_GUI
|
||||
#include <helpers/ui/PocketGuiSettings.h>
|
||||
namespace mesh { namespace ui { namespace pocket { class PocketGui; } } }
|
||||
#endif
|
||||
|
||||
class UITask : public AbstractUITask {
|
||||
DisplayDriver* _display;
|
||||
@@ -100,6 +104,9 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* home;
|
||||
UIScreen* msg_preview = nullptr;
|
||||
UIScreen* curr;
|
||||
#if UI_POCKET_GUI
|
||||
mesh::ui::pocket::Settings _pocket_settings;
|
||||
#endif
|
||||
#if COMPANION_FEATURE_READER
|
||||
UIScreen* reader = nullptr;
|
||||
#endif
|
||||
@@ -148,6 +155,15 @@ public:
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
int getPreviewCount() const;
|
||||
void renderMessageSummary(DisplayDriver& display) const;
|
||||
#if UI_POCKET_GUI
|
||||
mesh::ui::pocket::Settings& pocketSettings() { return _pocket_settings; }
|
||||
void renderPocketBoard(mesh::ui::pocket::PocketGui& gui) const;
|
||||
void renderPocketInbox(mesh::ui::pocket::PocketGui& gui) const;
|
||||
unsigned pocketBoardCount() const;
|
||||
void cleanPocketDisplay() { if (_display) _display->forceFullRefresh(); }
|
||||
void channelMessageSent(int channel_idx, const char* channel_name,
|
||||
const char* sender, const char* text, size_t length) override;
|
||||
#endif
|
||||
bool hasDisplay() const { return _display != NULL; }
|
||||
#ifdef HAS_TOUCH
|
||||
bool supportsTouchDebug() const override { return _display != nullptr; }
|
||||
|
||||
@@ -15,6 +15,8 @@ from rp2040_application import read_application as read_rp2040_application
|
||||
from package_meshtext_license import (CAPABILITY as MESHTEXT_CAPABILITY,
|
||||
meshtext_evidence, notice_bytes as meshtext_notice_bytes,
|
||||
notice_record as meshtext_notice_record)
|
||||
from package_pocket_gui_license import (pocket_gui_evidence,
|
||||
notice_bytes as pocket_gui_notice_bytes, notice_record as pocket_gui_notice_record)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "mota"))
|
||||
from motalib import parse_nrf52_layout
|
||||
@@ -217,6 +219,13 @@ def main() -> int:
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"capability check: cannot qualify linked Meshtext code: {error}", file=sys.stderr)
|
||||
malformed = True
|
||||
try:
|
||||
if pocket_gui_evidence(args.image):
|
||||
stem = args.output.name.removesuffix(".capabilities.json")
|
||||
third_party_notices.append(pocket_gui_notice_record(stem, pocket_gui_notice_bytes()))
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"capability check: cannot qualify linked MeshPocket GUI/fonts: {error}", file=sys.stderr)
|
||||
malformed = True
|
||||
if args.require_meshtext and not chat_symbols:
|
||||
checks.append({"capability": MESHTEXT_CAPABILITY,
|
||||
"evidence": "Meshtext encode/decode, Companion send/receive, and Dispatcher raw-frame queue implementations",
|
||||
|
||||
@@ -385,7 +385,9 @@ def requirements(platform, defines, target):
|
||||
external_ota)
|
||||
if display and display != "NullDisplayDriver":
|
||||
parts["display_pixels_and_driver"] = display_heap
|
||||
screen_budget = integer(defines, "MESH_COMPANION_SCREEN_STARTUP_BYTES", 0)
|
||||
pocket_gui = ("HELTEC_MESH_POCKET" in defines and "COMPANION_RADIO_FULL" in defines
|
||||
and integer(defines, "UI_POCKET_GUI", 1) != 0)
|
||||
screen_budget = integer(defines, "MESH_COMPANION_SCREEN_STARTUP_BYTES", 12288 if pocket_gui else 0)
|
||||
if companion and screen_budget:
|
||||
# A target using this override has a source-side static assertion
|
||||
# that covers its concrete startup screens and allocator overhead.
|
||||
|
||||
@@ -24,7 +24,7 @@ RECIPE_ENVIRONMENT = (
|
||||
)
|
||||
PACKAGE_SUFFIXES = (".bin", "-merged.bin", ".uf2", ".zip", ".hex",
|
||||
".capabilities.json", ".memory.json", ".font-license.txt",
|
||||
".meshtext-license.txt")
|
||||
".meshtext-license.txt", ".pocket-gui-license.txt")
|
||||
|
||||
|
||||
def canonical_digest(value):
|
||||
|
||||
@@ -11,6 +11,7 @@ import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from package_nrf52_font_license import validate_artifact_notice
|
||||
from package_meshtext_license import validate_artifact_notice as validate_meshtext_notice
|
||||
from package_pocket_gui_license import validate_artifact_notice as validate_pocket_gui_notice
|
||||
|
||||
|
||||
def digest(path):
|
||||
@@ -45,6 +46,7 @@ def artifact_files(stem):
|
||||
for suffix in (".bin", "-merged.bin", ".uf2", ".zip", ".hex", ".capabilities.json",
|
||||
".font-license.txt")]
|
||||
candidates.append(stem.parent / (stem.name + ".meshtext-license.txt"))
|
||||
candidates.append(stem.parent / (stem.name + ".pocket-gui-license.txt"))
|
||||
return [p for p in candidates if p.is_file()]
|
||||
|
||||
|
||||
@@ -53,6 +55,7 @@ def package_report(directory, stem):
|
||||
report = validate_build(directory)
|
||||
validate_artifact_notice(stem)
|
||||
validate_meshtext_notice(stem)
|
||||
validate_pocket_gui_notice(stem)
|
||||
files = artifact_files(stem)
|
||||
if len(files) < 2 or not any(p.suffix in (".bin", ".uf2", ".zip", ".hex") for p in files):
|
||||
raise ValueError("cannot qualify an empty firmware package")
|
||||
@@ -68,6 +71,7 @@ def validate_package(stem):
|
||||
stem = Path(stem)
|
||||
validate_artifact_notice(stem)
|
||||
validate_meshtext_notice(stem)
|
||||
validate_pocket_gui_notice(stem)
|
||||
report = passing_report(stem.parent / (stem.name + ".memory.json"))
|
||||
actual = {p.name: digest(p) for p in artifact_files(stem)}
|
||||
if len(actual) < 2 or actual != report.get("files"):
|
||||
|
||||
@@ -19,6 +19,7 @@ from firmware_memory_manifest import validate_package
|
||||
from check_nrf52_uf2 import check_uf2
|
||||
from package_nrf52_font_license import validate_artifact_notice
|
||||
from package_meshtext_license import validate_artifact_notice as validate_meshtext_notice
|
||||
from package_pocket_gui_license import validate_artifact_notice as validate_pocket_gui_notice
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -243,6 +244,9 @@ def collect_artifacts(directory, version):
|
||||
meshtext_notice = validate_meshtext_notice(directory / stem)
|
||||
if meshtext_notice is not None:
|
||||
files.append(meshtext_notice)
|
||||
pocket_gui_notice = validate_pocket_gui_notice(directory / stem)
|
||||
if pocket_gui_notice is not None:
|
||||
files.append(pocket_gui_notice)
|
||||
manifest["runtime_ram"] = {key: memory[key] for key in (
|
||||
"passed", "available_internal_bytes", "required_heap_bytes", "elf_sha256")}
|
||||
accounted.update(files)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Carry MeshPocket GUI and font notices beside firmware containing that code."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from firmware_elf import FirmwareElf
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NOTICE_PATH = ROOT / "tools/meshpocket_map/PocketGui-NOTICE.txt"
|
||||
SIDECAR_SUFFIX = ".pocket-gui-license.txt"
|
||||
COMPONENT = "meshpocket-gui-and-fonts"
|
||||
LICENSE = "MIT AND BSD-2-Clause AND LicenseRef-Adobe-X11"
|
||||
FONTS = {"u8g2_font_helvR08_tf", "u8g2_font_helvB08_tf",
|
||||
"u8g2_font_helvR10_tf", "u8g2_font_helvB10_tf"}
|
||||
|
||||
|
||||
def valid_notice(data):
|
||||
return (0 < len(data) <= 262144 and all(text in data for text in (
|
||||
b"jackywashere/MeshCore-Mesh-Pocket-MessageBoard",
|
||||
b"Copyright (c) 2025 Scott Powell", b"Permission is hereby granted, free of charge",
|
||||
b"copies or substantial portions of the Software", b'THE SOFTWARE IS PROVIDED "AS IS"',
|
||||
b"Copyright (c) 2018, olikraus", b"Redistribution and use in source and binary forms",
|
||||
b"Copyright 1984-1989, 1994 Adobe Systems Incorporated.",
|
||||
b"Copyright 1988, 1994 Digital Equipment Corporation.",
|
||||
b"Permission to use, copy, modify, distribute and sell this software",
|
||||
b"without express or implied warranty.")))
|
||||
|
||||
|
||||
def notice_bytes():
|
||||
data = NOTICE_PATH.read_bytes()
|
||||
if not valid_notice(data):
|
||||
raise ValueError("MeshPocket GUI/font source notice is incomplete")
|
||||
return data
|
||||
|
||||
|
||||
def pocket_gui_evidence(path):
|
||||
"""Only allocated definitions qualify, never target names or debug strings."""
|
||||
if Path(path).read_bytes()[:4] != b"\x7fELF":
|
||||
return []
|
||||
elf = FirmwareElf(path)
|
||||
evidence = []
|
||||
for name, (_address, size, index) in elf.symbols.items():
|
||||
if size <= 0 or index >= len(elf.sections):
|
||||
continue
|
||||
section = elf.sections[index]
|
||||
if section[1] == 8 or not section[2] & 2:
|
||||
continue
|
||||
gui = (section[2] & 4 and name.startswith((
|
||||
"_ZN4mesh2ui6pocket9PocketGui", "_ZNK4mesh2ui6pocket9PocketGui")))
|
||||
if gui or name in FONTS:
|
||||
evidence.append(name)
|
||||
return sorted(evidence)
|
||||
|
||||
|
||||
def sidecar(stem):
|
||||
stem = Path(stem)
|
||||
return stem.parent / (stem.name + SIDECAR_SUFFIX)
|
||||
|
||||
|
||||
def notice_record(stem, data):
|
||||
return {"component": COMPONENT, "license": LICENSE,
|
||||
"file": sidecar(stem).name, "sha256": hashlib.sha256(data).hexdigest()}
|
||||
|
||||
|
||||
def manifest_record(stem):
|
||||
stem = Path(stem)
|
||||
manifest = json.loads((stem.parent / (stem.name + ".capabilities.json")).read_text())
|
||||
records = [record for record in manifest.get("third_party_notices", [])
|
||||
if record.get("component") == COMPONENT]
|
||||
if len(records) > 1:
|
||||
raise ValueError(f"{stem}: duplicate MeshPocket GUI/font notice declarations")
|
||||
if not records:
|
||||
return None
|
||||
record = records[0]
|
||||
if (record.get("file") != sidecar(stem).name or record.get("license") != LICENSE
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", str(record.get("sha256", "")))):
|
||||
raise ValueError(f"{stem}: invalid MeshPocket GUI/font notice declaration")
|
||||
return record
|
||||
|
||||
|
||||
def validate_artifact_notice(stem):
|
||||
record = manifest_record(stem)
|
||||
output = sidecar(stem)
|
||||
if record is None:
|
||||
if output.exists():
|
||||
raise ValueError(f"{output}: stale notice for unaffected firmware")
|
||||
return None
|
||||
if not output.is_file():
|
||||
raise ValueError(f"{output}: missing MeshPocket GUI/font notice")
|
||||
data = output.read_bytes()
|
||||
if not valid_notice(data) or hashlib.sha256(data).hexdigest() != record["sha256"]:
|
||||
raise ValueError(f"{output}: incomplete or mismatched MeshPocket GUI/font notice")
|
||||
return output
|
||||
|
||||
|
||||
def collect_build_notice(directory, stem):
|
||||
evidence = pocket_gui_evidence(Path(directory) / "firmware.elf")
|
||||
record = manifest_record(stem)
|
||||
if bool(evidence) != bool(record):
|
||||
raise ValueError("MeshPocket GUI/font notice declaration does not match the linked ELF")
|
||||
if not evidence:
|
||||
return validate_artifact_notice(stem)
|
||||
data = notice_bytes()
|
||||
if record != notice_record(stem, data):
|
||||
raise ValueError("MeshPocket GUI/font notice declaration does not match the source notice")
|
||||
output = sidecar(stem)
|
||||
if not output.exists():
|
||||
temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(data)
|
||||
temporary.replace(output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return validate_artifact_notice(stem)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=("collect", "validate"))
|
||||
parser.add_argument("path", type=Path)
|
||||
parser.add_argument("--stem", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.action == "collect":
|
||||
if args.stem is None:
|
||||
parser.error("collect requires --stem")
|
||||
collect_build_notice(args.path, args.stem)
|
||||
else:
|
||||
validate_artifact_notice(args.path)
|
||||
except (OSError, ValueError, KeyError, TypeError) as error:
|
||||
parser.exit(1, f"MeshPocket GUI/font notice qualification failed: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -86,6 +86,7 @@ public:
|
||||
uint64_t heard_millis;
|
||||
int queue_index;
|
||||
bool unread;
|
||||
bool own_sent;
|
||||
int channel_idx;
|
||||
char channel_name[32];
|
||||
char origin[62];
|
||||
@@ -135,14 +136,15 @@ public:
|
||||
|
||||
void add(uint64_t heard_millis, int channel_idx,
|
||||
const char* channel_name, const char* origin,
|
||||
const char* message, int queue_index = -1) {
|
||||
const char* message, int queue_index = -1, bool own_sent = false) {
|
||||
_head = (_head + 1) % Capacity;
|
||||
if (_count < Capacity) ++_count;
|
||||
|
||||
Entry& entry = _entries[_head];
|
||||
entry.heard_millis = heard_millis;
|
||||
entry.queue_index = queue_index;
|
||||
entry.unread = true;
|
||||
entry.unread = !own_sent;
|
||||
entry.own_sent = own_sent;
|
||||
entry.channel_idx = channel_idx;
|
||||
copyField(entry.channel_name, sizeof(entry.channel_name), channel_name);
|
||||
copyField(entry.origin, sizeof(entry.origin), origin);
|
||||
|
||||
@@ -138,12 +138,17 @@ void GxEPDDisplay::startFrame(ColorVal bkg) {
|
||||
if (_cycles_before_full_refresh != 0) {
|
||||
display.setPartialWindow(0, 0, display.width(), display.height());
|
||||
} else {
|
||||
#if UI_POCKET_GUI
|
||||
display.setFullWindow();
|
||||
_full_frame = true;
|
||||
#else
|
||||
// forces a full wipe of the screen ...
|
||||
display.clearScreen(0xFF); // Clears microcontroller side RAM
|
||||
display.writeScreenBuffer(0xFF); // Forces 0xFF (White) into the display controller's history registers
|
||||
// we'll need a partial refresh after that (whatever crc value is)
|
||||
last_display_crc_value = 0;
|
||||
resetPartialRefreshCounter();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,11 +252,25 @@ uint16_t GxEPDDisplay::getTextWidth(const char* str) {
|
||||
void GxEPDDisplay::endFrame() {
|
||||
if (_isOn == false) return;
|
||||
uint32_t crc = display_crc.finalize();
|
||||
#if UI_POCKET_GUI
|
||||
if (_full_frame) {
|
||||
display.display(false);
|
||||
display.setPartialWindow(0, 0, display.width(), display.height());
|
||||
resetPartialRefreshCounter();
|
||||
_full_frame = false;
|
||||
display.powerOff();
|
||||
} else if (crc != last_display_crc_value) {
|
||||
display.display(true);
|
||||
if (_cycles_before_full_refresh > 0) --_cycles_before_full_refresh;
|
||||
display.powerOff();
|
||||
}
|
||||
#else
|
||||
if (crc != last_display_crc_value) {
|
||||
display.display(true);
|
||||
if (_cycles_before_full_refresh > 0) {
|
||||
_cycles_before_full_refresh--;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
last_display_crc_value = crc;
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <CRC32.h>
|
||||
|
||||
#include "DisplayDriver.h"
|
||||
#include "PocketGuiConfig.h"
|
||||
|
||||
#ifndef EINK_MAX_PARTIAL_REFRESH
|
||||
// 60 to prevent ghosting when refreshing every minute ...
|
||||
@@ -58,6 +59,9 @@ class GxEPDDisplay : public DisplayDriver {
|
||||
int last_display_crc_value = 0;
|
||||
int _cycles_before_full_refresh;
|
||||
int max_partial_refresh = EINK_MAX_PARTIAL_REFRESH; // so this can be changed by an option after
|
||||
#if UI_POCKET_GUI
|
||||
bool _full_frame = false;
|
||||
#endif
|
||||
|
||||
public:
|
||||
#if defined(EINK_DISPLAY_MODEL)
|
||||
@@ -67,6 +71,29 @@ public:
|
||||
#endif
|
||||
|
||||
bool begin();
|
||||
#if UI_POCKET_GUI
|
||||
Adafruit_GFX& gfx() { return display; }
|
||||
void crcAdd(const void* bytes, size_t length) {
|
||||
display_crc.update(static_cast<const uint8_t*>(bytes), length);
|
||||
}
|
||||
void setMaxPartialRefresh(int count) {
|
||||
if (count == max_partial_refresh) return;
|
||||
max_partial_refresh = count;
|
||||
resetPartialRefreshCounter();
|
||||
}
|
||||
void setNativeRotation(int rotation) {
|
||||
rotation &= 3;
|
||||
if (display.getRotation() == rotation) return;
|
||||
display.setRotation(rotation);
|
||||
setDimensions(display.width(), display.height());
|
||||
display.setFullWindow();
|
||||
_full_frame = true;
|
||||
forceFullRefresh();
|
||||
}
|
||||
void setFlipped(bool flipped) override {
|
||||
setNativeRotation((DISPLAY_ROTATION + (flipped ? 2 : 0)) & 3);
|
||||
}
|
||||
#endif
|
||||
|
||||
bool isOn() override { return _isOn; }
|
||||
bool isEink() override { return true; }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#ifndef UI_POCKET_GUI
|
||||
#if defined(HELTEC_MESH_POCKET) && defined(COMPANION_RADIO_FULL)
|
||||
#define UI_POCKET_GUI 1
|
||||
#else
|
||||
#define UI_POCKET_GUI 0
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if UI_POCKET_GUI && !(defined(HELTEC_MESH_POCKET) && defined(COMPANION_RADIO_FULL))
|
||||
#error "The Pocket GUI requires MeshPocket Full Companion"
|
||||
#endif
|
||||
|
||||
#if UI_POCKET_GUI
|
||||
#ifndef UI_MSG_PREVIEW_SIZE
|
||||
#define UI_MSG_PREVIEW_SIZE 170
|
||||
#endif
|
||||
#ifndef MESH_COMPANION_SCREEN_STARTUP_BYTES
|
||||
#define MESH_COMPANION_SCREEN_STARTUP_BYTES 12288
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,213 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ui {
|
||||
namespace pocket {
|
||||
|
||||
// Store an explicit wire record rather than compiler-specific structure padding.
|
||||
struct Settings {
|
||||
uint8_t channel = 0;
|
||||
uint8_t text_large = 0;
|
||||
uint8_t name_len = 1;
|
||||
uint8_t flip = 0;
|
||||
uint8_t dark = 0;
|
||||
uint8_t idle = 1;
|
||||
uint8_t refresh = 1;
|
||||
uint8_t map_zoom = 3;
|
||||
};
|
||||
|
||||
constexpr size_t SETTINGS_RECORD_SIZE = 13;
|
||||
constexpr unsigned SETTINGS_COUNT = 8;
|
||||
|
||||
inline bool settingsValid(const Settings& value, unsigned channel_count = 40) {
|
||||
return channel_count != 0 && channel_count <= 256 && value.channel < channel_count
|
||||
&& value.text_large <= 1 && value.name_len <= 3
|
||||
&& value.flip <= 1 && value.dark <= 1
|
||||
&& value.idle <= 3 && value.refresh <= 3 && value.map_zoom <= 8;
|
||||
}
|
||||
|
||||
inline uint16_t settingsChecksum(const uint8_t* bytes, size_t length) {
|
||||
uint16_t crc = 0xFFFF;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
crc ^= (uint16_t)bytes[i] << 8;
|
||||
for (unsigned bit = 0; bit < 8; ++bit)
|
||||
crc = crc & 0x8000 ? (uint16_t)((crc << 1) ^ 0x1021)
|
||||
: (uint16_t)(crc << 1);
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
inline bool encodeSettings(const Settings& value, uint8_t* bytes, size_t size,
|
||||
unsigned channel_count = 40) {
|
||||
if (bytes == nullptr || size != SETTINGS_RECORD_SIZE || !settingsValid(value, channel_count)) return false;
|
||||
bytes[0] = 'P'; bytes[1] = 'G'; bytes[2] = 1;
|
||||
bytes[3] = value.channel;
|
||||
bytes[4] = value.text_large;
|
||||
bytes[5] = value.name_len;
|
||||
bytes[6] = value.flip | (value.dark << 1);
|
||||
bytes[7] = value.idle;
|
||||
bytes[8] = value.refresh;
|
||||
bytes[9] = value.map_zoom;
|
||||
bytes[10] = 0;
|
||||
const uint16_t crc = settingsChecksum(bytes, 11);
|
||||
bytes[11] = (uint8_t)crc;
|
||||
bytes[12] = (uint8_t)(crc >> 8);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool decodeSettings(Settings& value, const uint8_t* bytes, size_t size,
|
||||
unsigned channel_count = 40) {
|
||||
if (bytes == nullptr || size != SETTINGS_RECORD_SIZE
|
||||
|| bytes[0] != 'P' || bytes[1] != 'G' || bytes[2] != 1
|
||||
|| (bytes[6] & ~3U) != 0 || bytes[10] != 0
|
||||
|| settingsChecksum(bytes, 11) != (uint16_t)(bytes[11] | (uint16_t)bytes[12] << 8)) return false;
|
||||
Settings next;
|
||||
next.channel = bytes[3]; next.text_large = bytes[4]; next.name_len = bytes[5];
|
||||
next.flip = bytes[6] & 1; next.dark = (bytes[6] >> 1) & 1;
|
||||
next.idle = bytes[7]; next.refresh = bytes[8]; next.map_zoom = bytes[9];
|
||||
if (!settingsValid(next, channel_count)) return false;
|
||||
value = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
inline uint32_t idleMillis(const Settings& value) {
|
||||
static const uint32_t values[] = {30000, 60000, 300000, 0};
|
||||
return values[value.idle <= 3 ? value.idle : 1];
|
||||
}
|
||||
|
||||
inline int refreshCount(const Settings& value) {
|
||||
static const int values[] = {10, 30, 60, -1};
|
||||
return values[value.refresh <= 3 ? value.refresh : 1];
|
||||
}
|
||||
|
||||
inline unsigned nameCharacters(const Settings& value) {
|
||||
static const uint8_t values[] = {3, 5, 8, 0};
|
||||
return values[value.name_len <= 3 ? value.name_len : 1];
|
||||
}
|
||||
|
||||
inline double mapHeightKm(const Settings& value) {
|
||||
static const uint16_t values[] = {20000, 5000, 1000, 200, 100, 50, 20, 10, 5};
|
||||
return values[value.map_zoom <= 8 ? value.map_zoom : 3];
|
||||
}
|
||||
|
||||
inline bool cycleSetting(Settings& value, unsigned row, unsigned channel_count = 40) {
|
||||
switch (row) {
|
||||
case 0:
|
||||
if (channel_count == 0 || channel_count > 256) return false;
|
||||
value.channel = (uint8_t)((value.channel + 1U) % channel_count); break;
|
||||
case 1: value.text_large = !value.text_large; break;
|
||||
case 2: value.name_len = (value.name_len + 1) % 4; break;
|
||||
case 3: value.flip = !value.flip; break;
|
||||
case 4: value.dark = !value.dark; break;
|
||||
case 5: value.idle = (value.idle + 1) % 4; break;
|
||||
case 6: value.refresh = (value.refresh + 1) % 4; break;
|
||||
case 7: value.map_zoom = (value.map_zoom + 1) % 9; break;
|
||||
default: return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
struct BoardState {
|
||||
unsigned scroll = 0;
|
||||
void clamp(unsigned count) { if (scroll >= count) scroll = count == 0 ? 0 : count - 1; }
|
||||
bool scrollOlder(unsigned count) {
|
||||
clamp(count);
|
||||
if (count == 0 || scroll + 1 >= count) return false;
|
||||
++scroll;
|
||||
return true;
|
||||
}
|
||||
bool scrollNewer() {
|
||||
if (scroll == 0) return false;
|
||||
--scroll;
|
||||
return true;
|
||||
}
|
||||
void messageAdded() { if (scroll != 0 && scroll < 31) ++scroll; }
|
||||
};
|
||||
|
||||
inline uint32_t messageAgeSeconds(uint64_t now_millis, uint64_t heard_millis) {
|
||||
// MeshPocket uses Arduino's 32-bit millis(), widened in shared history.
|
||||
return (uint32_t)((uint32_t)now_millis - (uint32_t)heard_millis) / 1000;
|
||||
}
|
||||
|
||||
// Never leave a partial UTF-8 character at the end of a displayed name.
|
||||
inline size_t nextUtf8Character(const char* value, size_t offset, size_t length) {
|
||||
if (offset >= length) return length;
|
||||
const uint8_t lead = (uint8_t)value[offset];
|
||||
size_t count = lead < 0x80 ? 1 : lead >= 0xC2 && lead <= 0xDF ? 2
|
||||
: lead >= 0xE0 && lead <= 0xEF ? 3 : lead >= 0xF0 && lead <= 0xF4 ? 4 : 1;
|
||||
if (count > length - offset) return offset + 1;
|
||||
for (size_t i = 1; i < count; ++i)
|
||||
if (((uint8_t)value[offset + i] & 0xC0) != 0x80) return offset + 1;
|
||||
return offset + count;
|
||||
}
|
||||
|
||||
inline void truncateCharacters(char* text, unsigned count) {
|
||||
if (text == nullptr || count == 0) return;
|
||||
const size_t length = strlen(text);
|
||||
size_t position = 0;
|
||||
for (unsigned i = 0; i < count && position < length; ++i)
|
||||
position = nextUtf8Character(text, position, length);
|
||||
text[position] = 0;
|
||||
}
|
||||
|
||||
// The compact fonts include Latin-1. Replace unsupported code points without
|
||||
// splitting UTF-8 or reading beyond the supplied input length.
|
||||
inline void sanitizeText(char* output, size_t capacity, const char* input, size_t length) {
|
||||
if (output == nullptr || capacity == 0) return;
|
||||
size_t source = 0, destination = 0;
|
||||
if (input != nullptr) while (source < length && input[source] != 0) {
|
||||
const uint8_t lead = (uint8_t)input[source];
|
||||
const size_t next = nextUtf8Character(input, source, length);
|
||||
const size_t bytes = next - source;
|
||||
if (lead < 0x80) {
|
||||
if (destination + 1 >= capacity) break;
|
||||
output[destination++] = lead < 0x20 || lead == 0x7F ? ' ' : (char)lead;
|
||||
} else if (bytes == 2 && lead == 0xC2 && (uint8_t)input[source + 1] < 0xA0) {
|
||||
if (destination + 1 >= capacity) break;
|
||||
output[destination++] = ' ';
|
||||
} else if (bytes == 2 && (lead == 0xC2 || lead == 0xC3)) {
|
||||
if (destination + 2 >= capacity) break;
|
||||
output[destination++] = input[source];
|
||||
output[destination++] = input[source + 1];
|
||||
} else {
|
||||
if (destination + 1 >= capacity) break;
|
||||
output[destination++] = '*';
|
||||
}
|
||||
source = next;
|
||||
}
|
||||
output[destination] = 0;
|
||||
}
|
||||
|
||||
struct BoardMessageView {
|
||||
const char* sender;
|
||||
size_t sender_length;
|
||||
const char* text;
|
||||
};
|
||||
|
||||
inline void parseBoardMessage(const char* origin, const char* body, bool own_sent,
|
||||
BoardMessageView& result) {
|
||||
result.sender = origin == nullptr ? "" : origin;
|
||||
result.sender_length = strlen(result.sender);
|
||||
result.text = body == nullptr ? "" : body;
|
||||
if (!own_sent) {
|
||||
const char* separator = strstr(result.text, ": ");
|
||||
if (separator != nullptr && separator != result.text
|
||||
&& (size_t)(separator - result.text) <= 61) {
|
||||
result.sender = result.text;
|
||||
result.sender_length = (size_t)(separator - result.text);
|
||||
result.text = separator + 2;
|
||||
} else {
|
||||
// The old preview's origin includes a route suffix, never show it as a name.
|
||||
const char* route = strstr(result.sender, " [");
|
||||
if (route != nullptr) result.sender_length = (size_t)(route - result.sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace pocket
|
||||
} // namespace ui
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,227 @@
|
||||
#include "PocketWorldMap.h"
|
||||
#include "PocketGuiConfig.h"
|
||||
|
||||
#if UI_POCKET_GUI || defined(POCKET_WORLD_MAP_TEST)
|
||||
|
||||
#include "PocketWorldMapData.generated.h"
|
||||
#include "../ota/tinf/tinf.h"
|
||||
#include <math.h>
|
||||
|
||||
namespace mesh { namespace ui { namespace pocket {
|
||||
namespace {
|
||||
|
||||
constexpr float kPi = 3.14159265358979323846f;
|
||||
constexpr float kRadians = kPi / 180.0f;
|
||||
constexpr float kEarthKm = 6371.0088f;
|
||||
|
||||
struct Projection {
|
||||
float latitude, longitude, sine, cosine, pixels_per_km, radius;
|
||||
int width, height;
|
||||
bool global;
|
||||
};
|
||||
|
||||
bool makeProjection(const MapView& view, Projection& p) {
|
||||
if (!isfinite(view.latitude) || !isfinite(view.longitude) || !isfinite(view.height_km)
|
||||
|| view.latitude < -90.0 || view.latitude > 90.0 || view.longitude < -180.0
|
||||
|| view.longitude > 180.0 || view.height_km < 1.0 || view.height_km > 20000.0
|
||||
|| view.width < 2 || view.height < 2 || view.width > 4096 || view.height > 4096) return false;
|
||||
p.latitude = float(view.latitude) * kRadians;
|
||||
p.longitude = float(view.longitude) * kRadians;
|
||||
p.width = view.width;
|
||||
p.height = view.height;
|
||||
p.global = view.height_km == 20000.0;
|
||||
if (p.global) {
|
||||
// The overview includes every longitude and latitude, independent of the
|
||||
// local centre's latitude. Preserve 2:1 map proportions with blank margins.
|
||||
p.pixels_per_km = fminf((view.width - 1) / 360.0f, (view.height - 1) / 180.0f);
|
||||
return true;
|
||||
}
|
||||
p.sine = sinf(p.latitude);
|
||||
p.cosine = fabsf(float(view.latitude)) == 90.0f ? 0.0f : cosf(p.latitude);
|
||||
p.pixels_per_km = (view.height - 1) / float(view.height_km);
|
||||
p.radius = hypotf(view.width - 1, view.height - 1) * 0.5f / p.pixels_per_km / kEarthKm;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool project(const Projection& p, float latitude, float longitude, float& x, float& y) {
|
||||
if (!isfinite(latitude) || !isfinite(longitude) || latitude < -90.0f || latitude > 90.0f) return false;
|
||||
if (p.global) {
|
||||
float degrees = longitude - p.longitude / kRadians;
|
||||
while (degrees > 180.0f) degrees -= 360.0f;
|
||||
while (degrees < -180.0f) degrees += 360.0f;
|
||||
x = (p.width - 1) * 0.5f + degrees * p.pixels_per_km;
|
||||
y = (p.height - 1) * 0.5f - latitude * p.pixels_per_km;
|
||||
return true;
|
||||
}
|
||||
const float lat = latitude * kRadians;
|
||||
float delta = longitude * kRadians - p.longitude;
|
||||
// Decoded longitudes are bounded to +/-540 degrees, so at most two wraps.
|
||||
while (delta > kPi) delta -= 2.0f * kPi;
|
||||
while (delta < -kPi) delta += 2.0f * kPi;
|
||||
const float sin_lat = sinf(lat);
|
||||
const float cos_lat = fabsf(latitude) == 90.0f ? 0.0f : cosf(lat);
|
||||
const float cos_delta = cosf(delta);
|
||||
float cos_distance = p.sine * sin_lat + p.cosine * cos_lat * cos_delta;
|
||||
if (cos_distance > 1.0f) cos_distance = 1.0f;
|
||||
// At the far hemisphere the azimuthal singularity can join unrelated edges.
|
||||
// The Pocket's local map never needs that hemisphere to cover its viewport.
|
||||
if (cos_distance < 0.0f) return false;
|
||||
// c/sin(c) has a stable expansion at zero. It avoids acosf cancellation
|
||||
// (and two transcendental calls) for nearby nodes and coastline vertices.
|
||||
const float scale = cos_distance > 0.9999f
|
||||
? kEarthKm * (1.0f + (1.0f - cos_distance) / 3.0f)
|
||||
: kEarthKm * acosf(cos_distance) / sqrtf(1.0f - cos_distance * cos_distance);
|
||||
x = (p.width - 1) * 0.5f + scale * cos_lat * sinf(delta) * p.pixels_per_km;
|
||||
y = (p.height - 1) * 0.5f - scale * (p.cosine * sin_lat - p.sine * cos_lat * cos_delta) * p.pixels_per_km;
|
||||
return isfinite(x) && isfinite(y);
|
||||
}
|
||||
|
||||
// Conservative spherical-cap/bounding-box test; it may decode extra chunks,
|
||||
// but never discards visible land at the antimeridian or either pole.
|
||||
bool visibleChunk(const Projection& p, const data::Chunk& chunk) {
|
||||
if (p.global) return true;
|
||||
const float latitude = p.latitude / kRadians;
|
||||
const float degrees = p.radius / kRadians + 0.01f;
|
||||
if (chunk.north * 0.001f < latitude - degrees || chunk.south * 0.001f > latitude + degrees) return false;
|
||||
if (p.radius >= kPi * 0.5f || latitude + degrees >= 90.0f || latitude - degrees <= -90.0f) return true;
|
||||
const float ratio = sinf(p.radius) / p.cosine;
|
||||
if (ratio >= 1.0f || ratio <= -1.0f) return true;
|
||||
const float half_longitude = fabsf(asinf(ratio)) / kRadians + 0.01f;
|
||||
const float center = p.longitude / kRadians;
|
||||
// Source fragments are unwrapped; test adjacent copies of the view.
|
||||
for (int copy = -1; copy <= 1; ++copy) {
|
||||
const float longitude = center + copy * 360.0f;
|
||||
if (chunk.east * 0.001f >= longitude - half_longitude
|
||||
&& chunk.west * 0.001f <= longitude + half_longitude) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool clipAxis(float start, float delta, float minimum, float maximum, float& first, float& last) {
|
||||
if (delta == 0.0f) return start >= minimum && start <= maximum;
|
||||
float a = (minimum - start) / delta, b = (maximum - start) / delta;
|
||||
if (a > b) { const float swap = a; a = b; b = swap; }
|
||||
if (a > first) first = a;
|
||||
if (b < last) last = b;
|
||||
return first <= last;
|
||||
}
|
||||
|
||||
void drawClipped(const Projection& p, float ax, float ay, float bx, float by,
|
||||
MapLine line, void* context) {
|
||||
float first = 0.0f, last = 1.0f;
|
||||
const float margin = p.global ? ((p.width - 1) * 0.5f - 180.0f * p.pixels_per_km) : 0.0f;
|
||||
if (!clipAxis(ax, bx - ax, margin, p.width - 1 - margin, first, last)
|
||||
|| !clipAxis(ay, by - ay, 0.0f, p.height - 1, first, last)) return;
|
||||
const int x0 = static_cast<int>(lroundf(ax + first * (bx - ax)));
|
||||
const int y0 = static_cast<int>(lroundf(ay + first * (by - ay)));
|
||||
const int x1 = static_cast<int>(lroundf(ax + last * (bx - ax)));
|
||||
const int y1 = static_cast<int>(lroundf(ay + last * (by - ay)));
|
||||
line(context, x0, y0, x1, y1);
|
||||
}
|
||||
|
||||
void drawSegment(const Projection& p, float ax, float ay, float bx, float by,
|
||||
MapLine line, void* context) {
|
||||
if (p.global) {
|
||||
const float span = 360.0f * p.pixels_per_km;
|
||||
if (fabsf(bx - ax) > span * 0.5f) {
|
||||
// Split a seam crossing at the two map edges, never across the middle.
|
||||
if (bx > ax) ax += span;
|
||||
else bx += span;
|
||||
drawClipped(p, ax, ay, bx, by, line, context);
|
||||
drawClipped(p, ax - span, ay, bx - span, by, line, context);
|
||||
return;
|
||||
}
|
||||
}
|
||||
drawClipped(p, ax, ay, bx, by, line, context);
|
||||
}
|
||||
|
||||
uint16_t read16(const uint8_t* p) { return uint16_t(p[0]) | (uint16_t(p[1]) << 8); }
|
||||
int32_t read32(const uint8_t* p) {
|
||||
return static_cast<int32_t>(uint32_t(p[0]) | (uint32_t(p[1]) << 8)
|
||||
| (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24));
|
||||
}
|
||||
|
||||
bool readDelta(const uint8_t*& cursor, const uint8_t* end, int32_t& delta) {
|
||||
uint32_t value = 0;
|
||||
// Coordinates fit signed 32-bit, but a map delta needs at most 21 bits.
|
||||
for (unsigned shift = 0; shift < 28; shift += 7) {
|
||||
if (cursor == end) return false;
|
||||
const uint8_t byte = *cursor++;
|
||||
value |= uint32_t(byte & 127) << shift;
|
||||
if (!(byte & 128)) {
|
||||
delta = (value & 1) ? -static_cast<int32_t>((value >> 1) + 1) : static_cast<int32_t>(value >> 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool drawChunk(const Projection& p, const uint8_t* bytes, size_t size, MapLine line, void* context) {
|
||||
const uint8_t* cursor = bytes;
|
||||
const uint8_t* end = bytes + size;
|
||||
while (cursor != end) {
|
||||
if (size_t(end - cursor) < 10) return false;
|
||||
const unsigned count = read16(cursor);
|
||||
int32_t longitude = read32(cursor + 2), latitude = read32(cursor + 6);
|
||||
cursor += 10;
|
||||
if (count < 2 || count > 96 || longitude < -540000 || longitude > 540000
|
||||
|| latitude < -90000 || latitude > 90000) return false;
|
||||
float ax = 0.0f, ay = 0.0f;
|
||||
bool previous = project(p, latitude * 0.001f, longitude * 0.001f, ax, ay);
|
||||
for (unsigned point = 1; point < count; ++point) {
|
||||
int32_t dx, dy;
|
||||
if (!readDelta(cursor, end, dx) || !readDelta(cursor, end, dy)
|
||||
|| dx < -360000 || dx > 360000 || dy < -180000 || dy > 180000) return false;
|
||||
// Bounds checked in 64 bits before updating decoded coordinates.
|
||||
const int64_t next_lon = int64_t(longitude) + dx, next_lat = int64_t(latitude) + dy;
|
||||
if (next_lon < -540000 || next_lon > 540000 || next_lat < -90000 || next_lat > 90000) return false;
|
||||
longitude = int32_t(next_lon);
|
||||
latitude = int32_t(next_lat);
|
||||
float bx = 0.0f, by = 0.0f;
|
||||
const bool current = project(p, latitude * 0.001f, longitude * 0.001f, bx, by);
|
||||
if (previous && current) drawSegment(p, ax, ay, bx, by, line, context);
|
||||
previous = current;
|
||||
ax = bx;
|
||||
ay = by;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool projectWorldPoint(const MapView& view, double latitude, double longitude, int& x, int& y) {
|
||||
Projection p;
|
||||
float px, py;
|
||||
if (longitude < -180.0 || longitude > 180.0 || !makeProjection(view, p) || !project(p, latitude, longitude, px, py)
|
||||
|| px < -0.5f || py < -0.5f || px >= p.width - 0.5f || py >= p.height - 0.5f) return false;
|
||||
x = int(lroundf(px));
|
||||
y = int(lroundf(py));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool renderWorldMap(const MapView& view, MapLine line, void* context) {
|
||||
Projection p;
|
||||
if (!line || !makeProjection(view, p)) return false;
|
||||
uint8_t scratch[data::kScratchBytes];
|
||||
for (const auto& chunk : data::kChunks) {
|
||||
if (!visibleChunk(p, chunk)) continue;
|
||||
if (chunk.raw > sizeof(scratch) || chunk.offset > sizeof(data::kDeflate)
|
||||
|| chunk.packed > sizeof(data::kDeflate) - chunk.offset) return false;
|
||||
unsigned int size = chunk.raw;
|
||||
if (tinf_uncompress_exact(scratch, &size, data::kDeflate + chunk.offset, chunk.packed) != TINF_OK
|
||||
|| size != chunk.raw || !drawChunk(p, scratch, size, line, context)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(POCKET_WORLD_MAP_TEST)
|
||||
bool renderWorldMapChunkForTest(const MapView& view, const uint8_t* bytes, size_t size,
|
||||
MapLine line, void* context) {
|
||||
Projection p;
|
||||
return bytes && line && makeProjection(view, p) && drawChunk(p, bytes, size, line, context);
|
||||
}
|
||||
#endif
|
||||
|
||||
}}}
|
||||
#endif
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh { namespace ui { namespace pocket {
|
||||
|
||||
// Local azimuthal equidistant view. Height is the full vertical extent in km.
|
||||
// At 20,000 km use a complete equirectangular world overview, centred at the
|
||||
// requested longitude and the equator, with blank margins to preserve aspect.
|
||||
// The coarse land outline is useful around 100-200 km; close zooms locate nodes.
|
||||
struct MapView {
|
||||
double latitude, longitude, height_km;
|
||||
int width, height;
|
||||
};
|
||||
|
||||
using MapLine = void (*)(void* context, int x0, int y0, int x1, int y1);
|
||||
|
||||
// Coordinates are relative to this viewport, including its clipped boundary.
|
||||
// Returns false for an invalid view or corrupt built-in map data.
|
||||
bool renderWorldMap(const MapView& view, MapLine line, void* context);
|
||||
// Same projection as the coastline. False means invalid or outside the view.
|
||||
bool projectWorldPoint(const MapView& view, double latitude, double longitude, int& x, int& y);
|
||||
|
||||
#if defined(POCKET_WORLD_MAP_TEST)
|
||||
// Exercise the production bounded parser with malformed/truncated raw chunks.
|
||||
bool renderWorldMapChunkForTest(const MapView& view, const uint8_t* bytes, size_t size,
|
||||
MapLine line, void* context);
|
||||
#endif
|
||||
|
||||
}}}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
#include <helpers/ui/GxEPDDisplay.h>
|
||||
#include <cstring>
|
||||
|
||||
inline constexpr uint8_t u8g2_font_helvR08_tf[] = {0};
|
||||
inline constexpr uint8_t u8g2_font_helvB08_tf[] = {1};
|
||||
inline constexpr uint8_t u8g2_font_helvR10_tf[] = {2};
|
||||
inline constexpr uint8_t u8g2_font_helvB10_tf[] = {3};
|
||||
|
||||
class U8G2_FOR_ADAFRUIT_GFX {
|
||||
Adafruit_GFX* _gfx = nullptr;
|
||||
unsigned _font = 0;
|
||||
uint16_t _foreground = 0, _background = 1;
|
||||
public:
|
||||
void begin(Adafruit_GFX& gfx) { _gfx = &gfx; }
|
||||
void setForegroundColor(uint16_t color) { _foreground = color; }
|
||||
void setBackgroundColor(uint16_t color) { _background = color; }
|
||||
void setFont(const uint8_t* font) { _font = *font; }
|
||||
void setFontMode(int) {}
|
||||
void setFontDirection(int) {}
|
||||
// Helvetica 08/10 ascent/descent from the selected u8g2 font metadata.
|
||||
int getFontAscent() const { return _font >= 2 ? 11 : 8; }
|
||||
int getFontDescent() const { return _font >= 2 ? -3 : -2; }
|
||||
int getUTF8Width(const char* text) const {
|
||||
int width = 0;
|
||||
for (const auto* next = reinterpret_cast<const unsigned char*>(text); *next; ++next) {
|
||||
if ((*next & 0xC0) == 0x80) continue;
|
||||
width += (*next == 'i' || *next == 'l') ? 2 : *next == 'W' ? 8 : _font >= 2 ? 6 : 5;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
void drawUTF8(int x, int baseline, const char* text) {
|
||||
const int width = getUTF8Width(text);
|
||||
assert(x >= 0 && x + width <= _gfx->width());
|
||||
assert(baseline - getFontAscent() >= 0);
|
||||
assert(baseline - getFontDescent() <= _gfx->height());
|
||||
_gfx->labels.push_back({x, baseline, width, getFontAscent(), getFontDescent(), (_font & 1) != 0,
|
||||
_foreground, _background, text});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <cassert>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
constexpr uint16_t GxEPD_BLACK = 0;
|
||||
constexpr uint16_t GxEPD_WHITE = 1;
|
||||
|
||||
class Adafruit_GFX {
|
||||
public:
|
||||
struct Text {
|
||||
int x, baseline, width, ascent, descent;
|
||||
bool bold;
|
||||
uint16_t foreground, background;
|
||||
std::string value;
|
||||
};
|
||||
std::vector<Text> labels;
|
||||
int w = 250, h = 122;
|
||||
int rotation = 3;
|
||||
int width() const { return w; }
|
||||
int height() const { return h; }
|
||||
void fillScreen(uint16_t) { labels.clear(); }
|
||||
void fillRect(int x, int y, int width, int height, uint16_t) {
|
||||
assert(x >= 0 && y >= 0 && width >= 0 && height >= 0);
|
||||
assert(x + width <= w && y + height <= h);
|
||||
}
|
||||
void drawPixel(int x, int y, uint16_t) {
|
||||
assert(x >= 0 && y >= 0 && x < w && y < h);
|
||||
}
|
||||
void drawLine(int x0, int y0, int x1, int y1, uint16_t) {
|
||||
assert(x0 >= 0 && y0 >= 0 && x1 >= 0 && y1 >= 0);
|
||||
assert(x0 < w && x1 < w && y0 < h && y1 < h);
|
||||
}
|
||||
void fillCircle(int x, int y, int, uint16_t color) { drawPixel(x, y, color); }
|
||||
void drawCircle(int x, int y, int, uint16_t color) { drawPixel(x, y, color); }
|
||||
bool contains(const char* value) const {
|
||||
for (const auto& text : labels) if (text.value == value) return true;
|
||||
return false;
|
||||
}
|
||||
unsigned occurrences(const char* value) const {
|
||||
unsigned result = 0;
|
||||
for (const auto& text : labels) if (text.value == value) ++result;
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
class GxEPDDisplay {
|
||||
Adafruit_GFX _gfx;
|
||||
public:
|
||||
bool on = true;
|
||||
uint32_t hash = 2166136261U;
|
||||
bool isOn() const { return on; }
|
||||
Adafruit_GFX& gfx() { return _gfx; }
|
||||
void setNativeRotation(int rotation) {
|
||||
_gfx.rotation = rotation;
|
||||
_gfx.w = (rotation & 1) ? 250 : 122;
|
||||
_gfx.h = (rotation & 1) ? 122 : 250;
|
||||
}
|
||||
void crcAdd(const void* value, size_t size) {
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
hash ^= static_cast<const uint8_t*>(value)[i];
|
||||
hash *= 16777619U;
|
||||
}
|
||||
}
|
||||
void resetHash() { hash = 2166136261U; }
|
||||
};
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
#include <helpers/ui/CompanionMessageHistory.h>
|
||||
#include <helpers/ui/DisplayPowerPolicy.h>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
|
||||
#define UI_MSG_PREVIEW_SIZE 161
|
||||
#define MAX_GROUP_CHANNELS 40
|
||||
#define KEY_UP 0xB5
|
||||
#define KEY_DOWN 0xB6
|
||||
#define MESH_DEBUG_PRINTLN(...)
|
||||
|
||||
static uint32_t now_millis = 12345;
|
||||
uint32_t millis() { return now_millis; }
|
||||
uint64_t companionMessageNowMillis() { return now_millis; }
|
||||
struct Screen { virtual ~Screen() = default; };
|
||||
struct Display {
|
||||
bool on = true;
|
||||
int wakes = 0;
|
||||
bool isOn() const { return on; }
|
||||
void wake(mesh::ui::DisplayWake) { ++wakes; }
|
||||
};
|
||||
|
||||
struct MsgPreviewScreen : Screen {
|
||||
mesh::ui::CompanionMessageHistory<32, UI_MSG_PREVIEW_SIZE> history;
|
||||
bool selected = false;
|
||||
void addPreview(uint8_t, const char* sender, const char* text, int channel,
|
||||
const char* channel_name, int queue_index, bool select) {
|
||||
selected = select;
|
||||
history.add(now_millis, channel, channel_name, sender, text, queue_index);
|
||||
}
|
||||
// @ADD_SENT@
|
||||
};
|
||||
struct HomeScreen : Screen {
|
||||
bool board = true;
|
||||
int message_hooks = 0;
|
||||
bool isPocketBoard() const { return board; }
|
||||
void pocketMessageAdded(int) { ++message_hooks; }
|
||||
};
|
||||
|
||||
class UITask {
|
||||
public:
|
||||
Screen *home, *msg_preview, *reader, *curr;
|
||||
Display* _display;
|
||||
int _msgcount = 0, screen_changes = 0, buzzer_changes = 0;
|
||||
uint32_t _next_refresh = 0;
|
||||
bool connected = false, pairing = false, allow_wake = true;
|
||||
bool _deferred_msg_preview = false;
|
||||
UITask(HomeScreen& h, MsgPreviewScreen& m, Screen& r, Display& d)
|
||||
: home(&h), msg_preview(&m), reader(&r), curr(&h), _display(&d) {}
|
||||
bool hasConnection() const { return connected; }
|
||||
bool isPairingScreenActive() const { return pairing; }
|
||||
bool isReaderActive() const { return curr == reader; }
|
||||
bool shouldWakeDisplayForMessage() const { return allow_wake; }
|
||||
void setCurrScreen(Screen* screen) { curr = screen; ++screen_changes; }
|
||||
char checkDisplayOn(char key) { return key; }
|
||||
void toggleBuzzer() { ++buzzer_changes; }
|
||||
void newMsg(uint8_t path_len, const char* from, const char* text, int count,
|
||||
int channel, const char* channel_name, int queue_index);
|
||||
void channelMessageSent(int channel, const char* channel_name, const char* sender,
|
||||
const char* text, size_t length);
|
||||
char handleMultiClick(char key, bool backwards);
|
||||
};
|
||||
|
||||
// @TASK_METHODS@
|
||||
|
||||
int main() {
|
||||
HomeScreen home;
|
||||
MsgPreviewScreen preview;
|
||||
Screen reader;
|
||||
Display display;
|
||||
UITask task(home, preview, reader, display);
|
||||
auto receive = [&](int channel = 0) {
|
||||
task.newMsg(1, "Channel Public", "Alice: received", 7, channel, "Public", 3);
|
||||
};
|
||||
|
||||
// A live-board channel arrival updates history without stealing the board.
|
||||
receive();
|
||||
assert(task.curr == &home && task.screen_changes == 0);
|
||||
assert(preview.history.count() == 1 && home.message_hooks == 1);
|
||||
assert(!task._deferred_msg_preview && task._msgcount == 7);
|
||||
assert(preview.selected && display.wakes == 1 && task._next_refresh == now_millis);
|
||||
|
||||
// Connected apps, the pairing PIN and the Reader keep their existing policy.
|
||||
task.connected = true;
|
||||
receive();
|
||||
assert(task.curr == &home && !preview.selected && !task._deferred_msg_preview);
|
||||
task.connected = false;
|
||||
task.pairing = true;
|
||||
receive();
|
||||
assert(task.curr == &home && task._deferred_msg_preview);
|
||||
task.pairing = false;
|
||||
task.curr = &reader;
|
||||
receive();
|
||||
assert(task.curr == &reader && task._deferred_msg_preview);
|
||||
task.curr = &home;
|
||||
home.board = false;
|
||||
receive();
|
||||
assert(task.curr == &preview);
|
||||
task.curr = &home;
|
||||
home.board = true;
|
||||
receive(-1);
|
||||
assert(task.curr == &preview); // a direct message retains its normal preview
|
||||
|
||||
const int previous_wakes = display.wakes;
|
||||
task.allow_wake = false;
|
||||
receive();
|
||||
assert(display.wakes == previous_wakes);
|
||||
|
||||
// The outbound app buffer is length-delimited, not necessarily terminated.
|
||||
const char raw[] = {'o', 'w', 'n'};
|
||||
task.curr = &reader;
|
||||
task.channelMessageSent(0, "Public", "Self", raw, sizeof(raw));
|
||||
assert(task.curr == &reader);
|
||||
const auto* sent = preview.history.newest(0);
|
||||
assert(sent->own_sent && !sent->unread && sent->queue_index == -1);
|
||||
assert(std::strcmp(sent->message, "own") == 0);
|
||||
const size_t previous_count = preview.history.count();
|
||||
task.channelMessageSent(-1, "Public", "Self", raw, sizeof(raw));
|
||||
task.channelMessageSent(40, "Public", "Self", raw, sizeof(raw));
|
||||
assert(preview.history.count() == previous_count);
|
||||
char long_text[200];
|
||||
std::memset(long_text, 'x', sizeof(long_text));
|
||||
task.channelMessageSent(0, "Public", "Self", long_text, sizeof(long_text));
|
||||
assert(std::strlen(preview.history.newest(0)->message) == UI_MSG_PREVIEW_SIZE - 1);
|
||||
|
||||
task.curr = &home;
|
||||
assert(static_cast<uint8_t>(task.handleMultiClick(0, false)) == KEY_DOWN);
|
||||
assert(static_cast<uint8_t>(task.handleMultiClick(0, true)) == KEY_UP);
|
||||
assert(task.buzzer_changes == 0);
|
||||
home.board = false;
|
||||
assert(task.handleMultiClick(0, false) == 0 && task.buzzer_changes == 1);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
#include <helpers/ui/PocketGuiSettings.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <cassert>
|
||||
#include <initializer_list>
|
||||
|
||||
#define MAX_GROUP_CHANNELS 40
|
||||
|
||||
static uint32_t clock_millis = 123456;
|
||||
uint32_t millis() { return clock_millis; }
|
||||
struct Task {
|
||||
mesh::ui::pocket::Settings settings;
|
||||
int reader_opens = 0;
|
||||
mesh::ui::pocket::Settings& pocketSettings() { return settings; }
|
||||
unsigned pocketBoardCount() const { return 3; }
|
||||
void showReader() { ++reader_opens; }
|
||||
void showAlert(const char*, int) {}
|
||||
};
|
||||
struct Mesh {
|
||||
bool dual = true;
|
||||
bool isDualRadioActive() const { return dual; }
|
||||
} the_mesh;
|
||||
|
||||
class HomeScreen : public UIScreen {
|
||||
public:
|
||||
enum HomePage { FIRST, MAP, STATUS, SETTINGS, RECENT, RADIO, RADIO2, RADIO_STATUS, Count };
|
||||
struct Board {
|
||||
mesh::ui::pocket::BoardState state;
|
||||
bool scrollOlder(unsigned count) { return state.scrollOlder(count); }
|
||||
bool scrollNewer() { return state.scrollNewer(); }
|
||||
void resetScroll() { state.scroll = 0; }
|
||||
} _pocket;
|
||||
Task* _task;
|
||||
uint8_t _page = FIRST;
|
||||
bool _pocket_editing = false, _pocket_browsing = false, _pocket_zooming = false;
|
||||
bool _pocket_dirty = false, _dual_radio_enabled_seen = false;
|
||||
uint8_t _pocket_setting = 0, _radio_status_page = 0;
|
||||
uint32_t _pocket_last_input = 0, _radio_profile_page_started_at = 0;
|
||||
int save_count = 0, clean_count = 0;
|
||||
explicit HomeScreen(Task& task) : _task(&task) {}
|
||||
int render(DisplayDriver&) override { return 0; }
|
||||
bool flushPocketSettings() { ++save_count; _pocket_dirty = false; return true; }
|
||||
void displayForceClean() { ++clean_count; }
|
||||
void cyclePocketChannel(int direction) {
|
||||
_task->settings.channel = (_task->settings.channel + 40 + direction) % 40;
|
||||
}
|
||||
// @HOME_IMPLEMENTATION@
|
||||
};
|
||||
|
||||
static void key(HomeScreen& home, uint8_t value) {
|
||||
assert(home.handleInput(static_cast<char>(value)));
|
||||
assert(home._pocket_last_input == clock_millis);
|
||||
}
|
||||
|
||||
int main() {
|
||||
Task task;
|
||||
HomeScreen home(task);
|
||||
key(home, KEY_ENTER);
|
||||
assert(home._page == HomeScreen::FIRST && home._pocket_browsing);
|
||||
key(home, KEY_NEXT);
|
||||
assert(home._pocket.state.scroll == 1 && home._page == HomeScreen::FIRST);
|
||||
key(home, KEY_PREV);
|
||||
assert(home._pocket.state.scroll == 0);
|
||||
key(home, KEY_DOWN);
|
||||
assert(task.settings.channel == 1);
|
||||
key(home, KEY_UP);
|
||||
assert(task.settings.channel == 0);
|
||||
key(home, KEY_ENTER);
|
||||
assert(!home._pocket_browsing);
|
||||
key(home, KEY_NEXT);
|
||||
assert(home._page == HomeScreen::MAP);
|
||||
key(home, KEY_ENTER);
|
||||
assert(home._pocket_zooming);
|
||||
key(home, KEY_NEXT);
|
||||
assert(task.settings.map_zoom == 4 && home._pocket_dirty);
|
||||
key(home, KEY_PREV);
|
||||
assert(task.settings.map_zoom == 3);
|
||||
key(home, KEY_ENTER);
|
||||
assert(!home._pocket_zooming && !home._pocket_dirty);
|
||||
key(home, KEY_NEXT);
|
||||
key(home, KEY_NEXT);
|
||||
assert(home._page == HomeScreen::SETTINGS);
|
||||
key(home, KEY_ENTER);
|
||||
assert(home._pocket_editing);
|
||||
key(home, KEY_PREV);
|
||||
assert(home._pocket_setting == 1);
|
||||
key(home, KEY_NEXT);
|
||||
assert(task.settings.text_large == 1 && home._pocket_dirty);
|
||||
home._pocket_setting = 4;
|
||||
key(home, KEY_NEXT);
|
||||
assert(task.settings.dark == 1 && home.clean_count == 1);
|
||||
key(home, KEY_ENTER);
|
||||
assert(!home._pocket_editing && !home._pocket_dirty);
|
||||
key(home, KEY_PREV);
|
||||
assert(home._page == HomeScreen::STATUS);
|
||||
key(home, KEY_ENTER);
|
||||
assert(home._radio_status_page == 1 && task.reader_opens == 0);
|
||||
|
||||
// Pairing can interrupt any editing/browsing mode. Returning to the first
|
||||
// page saves settings and leaves the next click free to navigate normally.
|
||||
for (const auto interrupted : {HomeScreen::SETTINGS, HomeScreen::MAP, HomeScreen::FIRST}) {
|
||||
home._page = interrupted;
|
||||
home._pocket_editing = home._pocket_browsing = home._pocket_zooming = true;
|
||||
home._pocket_dirty = true;
|
||||
home._pocket.state.scroll = 2;
|
||||
home._pocket_last_input = 0;
|
||||
const int saves = home.save_count;
|
||||
clock_millis += 1000;
|
||||
home.showFirstPage();
|
||||
assert(home._page == HomeScreen::FIRST && home.save_count == saves + 1);
|
||||
assert(!home._pocket_editing && !home._pocket_browsing && !home._pocket_zooming);
|
||||
assert(!home._pocket_dirty && home._pocket.state.scroll == 0);
|
||||
assert(home._pocket_last_input == clock_millis);
|
||||
key(home, KEY_NEXT);
|
||||
assert(home._page == HomeScreen::MAP);
|
||||
}
|
||||
|
||||
// The existing Network-page hold remains the Reader action in the new GUI.
|
||||
home._page = HomeScreen::RADIO;
|
||||
key(home, KEY_ENTER);
|
||||
assert(task.reader_opens == 1 && home._page == HomeScreen::RADIO);
|
||||
home._page = HomeScreen::RADIO2;
|
||||
key(home, KEY_ENTER);
|
||||
assert(task.reader_opens == 2 && home._page == HomeScreen::RADIO2);
|
||||
home._page = HomeScreen::RADIO_STATUS;
|
||||
key(home, KEY_ENTER);
|
||||
assert(home._radio_status_page == 2 && task.reader_opens == 2);
|
||||
the_mesh.dual = false;
|
||||
home._page = HomeScreen::RADIO;
|
||||
key(home, KEY_NEXT);
|
||||
assert(home._page == HomeScreen::FIRST);
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
#include <examples/companion_radio/ui-new/PocketGui.h>
|
||||
#include <helpers/ui/CompanionMessageHistory.h>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
|
||||
using namespace mesh::ui::pocket;
|
||||
using History = mesh::ui::CompanionMessageHistory<32, 161>;
|
||||
|
||||
static void channelBoard() {
|
||||
Settings settings;
|
||||
Frame frame;
|
||||
frame.now = 1735689600UL + 30 * 60;
|
||||
frame.heard_millis = 120000;
|
||||
frame.tz_offset = 5.75f;
|
||||
frame.battery_percent = 101;
|
||||
frame.page_count = 11;
|
||||
frame.connection = "BLE+USB";
|
||||
GxEPDDisplay display;
|
||||
PocketGui gui;
|
||||
History history;
|
||||
history.add(60000, 0, "Public", "Ch 0 Public [1h]:", "Alice: hello everyone");
|
||||
history.add(100000, 2, "Private", "Ch 2 Private [1h]:", "secret sender: hidden");
|
||||
history.add(120000, 0, "Public", "Self", "outgoing", -1, true);
|
||||
history.add(120000, -1, "", "Bob [direct]:", "direct hidden");
|
||||
gui.begin(display, settings, frame);
|
||||
assert(gui.boardCount(history) == 2);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(display.gfx().contains("Alice"));
|
||||
assert(display.gfx().contains("Self"));
|
||||
assert(display.gfx().contains("outgoing"));
|
||||
assert(!display.gfx().contains("secret sender"));
|
||||
assert(!display.gfx().contains("direct hidden"));
|
||||
assert(display.gfx().contains("100%"));
|
||||
// UTC 00:30 plus 05:45 Nepal offset. The previous-minute message gets 06:14.
|
||||
assert(display.gfx().contains("06:15"));
|
||||
assert(display.gfx().contains("06:14"));
|
||||
bool sent_inverted = false;
|
||||
for (const auto& text : display.gfx().labels)
|
||||
if (text.value == "Self") sent_inverted = text.foreground == GxEPD_WHITE;
|
||||
assert(sent_inverted);
|
||||
|
||||
display.resetHash();
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
const uint32_t first_hash = display.hash;
|
||||
display.resetHash();
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(display.hash == first_hash); // unchanged content skips physical redraw
|
||||
|
||||
assert(gui.scrollOlder(history) && gui.isScrolled());
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(display.gfx().contains("1 newer") && !display.gfx().contains("Self"));
|
||||
history.add(120000, 0, "Public", "Ch 0 Public [1h]:", "Carol: new message");
|
||||
gui.messageAdded();
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(display.gfx().contains("2 newer") && display.gfx().contains("Alice"));
|
||||
assert(!display.gfx().contains("Carol"));
|
||||
gui.resetScroll();
|
||||
assert(!gui.isScrolled());
|
||||
|
||||
// Negative fractional offsets also cross midnight correctly.
|
||||
frame.tz_offset = -3.5f;
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderMapChrome();
|
||||
gui.header("Map");
|
||||
assert(display.gfx().contains("21:00"));
|
||||
|
||||
// A message received before Arduino millis() wrapped still retains its
|
||||
// actual age, rather than being relabelled as newly received.
|
||||
history.clear();
|
||||
history.add(UINT32_MAX - 59999U, 0, "Public", "Self", "before rollover", -1, true);
|
||||
frame.now = 0;
|
||||
frame.heard_millis = 1000;
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(display.gfx().contains("1m"));
|
||||
}
|
||||
|
||||
static void screenBounds() {
|
||||
History history;
|
||||
char body[161];
|
||||
std::memset(body, 'W', sizeof(body) - 1);
|
||||
body[sizeof(body) - 1] = 0;
|
||||
history.add(0, 0, "Public", "ExtremelyLongSenderNameWWWWWWWWWWWWWWWWWWWW", body, -1, true);
|
||||
history.add(0, 0, "Public", "Ch 0 Public [1h]:", "\xc3\x89lise: UTF-8 caf\xc3\xa9 message with words and a long paragraph that wraps onto several rows");
|
||||
Frame frame;
|
||||
frame.now = 1735689600UL;
|
||||
frame.battery_percent = -1;
|
||||
frame.hint = "click next / 2x back";
|
||||
for (unsigned large : {0U, 1U})
|
||||
for (unsigned flip : {0U, 1U})
|
||||
for (unsigned dark : {0U, 1U})
|
||||
for (unsigned name : {0U, 1U, 2U, 3U}) {
|
||||
Settings settings;
|
||||
settings.text_large = large;
|
||||
settings.flip = flip;
|
||||
settings.dark = dark;
|
||||
settings.name_len = name;
|
||||
PocketGui gui;
|
||||
GxEPDDisplay display;
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW");
|
||||
assert(display.gfx().rotation == (flip ? 1 : 3));
|
||||
for (unsigned row = 0; row < SETTINGS_COUNT; ++row) {
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderSettings(row, true, "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW");
|
||||
}
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderMapChrome();
|
||||
gui.mapEmpty("No valid position");
|
||||
}
|
||||
PocketGui gui;
|
||||
GxEPDDisplay display;
|
||||
Settings settings;
|
||||
settings.channel = 39;
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Empty");
|
||||
assert(gui.boardCount(history) == 0);
|
||||
assert(display.gfx().contains("Listening for messages"));
|
||||
|
||||
// Display hints may be supplied by navigation. They must remain inside the
|
||||
// screen even if a future page supplies more text than fits on one line.
|
||||
frame.hint = "An extremely long footer WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW";
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderMapChrome();
|
||||
|
||||
history.clear();
|
||||
std::memset(body, 'W', sizeof(body) - 1);
|
||||
std::strcpy(body + sizeof(body) - 7, " END42");
|
||||
history.add(0, 0, "Public", "\xc3\x89lise", body, -1, true);
|
||||
settings.channel = 0;
|
||||
settings.text_large = 1;
|
||||
settings.name_len = 3;
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
bool tail_visible = false;
|
||||
for (const auto& text : display.gfx().labels)
|
||||
if (text.value.find("END42") != std::string::npos) tail_visible = true;
|
||||
assert(tail_visible);
|
||||
}
|
||||
|
||||
static void measuredLabels() {
|
||||
Settings settings;
|
||||
Frame frame;
|
||||
GxEPDDisplay display;
|
||||
PocketGui gui;
|
||||
gui.begin(display, settings, frame);
|
||||
gui.label(4, 30, "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW", true);
|
||||
assert(display.gfx().labels.size() == 1);
|
||||
const auto& broad = display.gfx().labels.front();
|
||||
assert(broad.width <= 246 && broad.value.size() < 31 && broad.bold);
|
||||
gui.text(4, 45, "Normal again");
|
||||
assert(!display.gfx().labels.back().bold);
|
||||
gui.label(240, 60, "\xc3\x89WWWWWWWWW", true);
|
||||
assert(display.gfx().labels.back().value == "\xc3\x89");
|
||||
gui.text(4, 75, "Normal again");
|
||||
assert(!display.gfx().labels.back().bold);
|
||||
const size_t previous = display.gfx().labels.size();
|
||||
gui.label(-1, 90, "outside", true);
|
||||
gui.label(250, 90, "outside", true);
|
||||
gui.label(4, 90, nullptr, true);
|
||||
assert(display.gfx().labels.size() == previous);
|
||||
gui.text(4, 105, "Normal after invalid label");
|
||||
assert(!display.gfx().labels.back().bold);
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderMapChrome(20000);
|
||||
assert(display.gfx().contains("20000 km"));
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderMapChrome();
|
||||
assert(display.gfx().contains("200 km"));
|
||||
}
|
||||
|
||||
static void readOnlyVisibleEntries() {
|
||||
Settings settings;
|
||||
Frame frame;
|
||||
GxEPDDisplay display;
|
||||
PocketGui gui;
|
||||
History history;
|
||||
history.add(0, 0, "Public", "Ch 0 Public", "Alice: oldest hidden", 0);
|
||||
history.add(0, 0, "Public", "Ch 0 Public", "Bob: older hidden", 1);
|
||||
history.add(0, 1, "Private", "Ch 1 Private", "Carol: other channel", 2);
|
||||
char long_body[161];
|
||||
std::memset(long_body, 'W', sizeof(long_body) - 1);
|
||||
long_body[sizeof(long_body) - 1] = 0;
|
||||
history.add(0, 0, "Public", "Ch 0 Public", long_body, 3);
|
||||
settings.text_large = 1;
|
||||
gui.begin(display, settings, frame);
|
||||
assert(history.unreadCount() == 4);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(!history.newest(0)->unread); // the long message's visible tail counts
|
||||
assert(history.newest(1)->unread); // another channel
|
||||
assert(history.newest(2)->unread && history.newest(3)->unread); // above the viewport
|
||||
for (unsigned age = 0; age < history.count(); ++age)
|
||||
assert(history.newest(age)->queue_index == static_cast<int>(3 - age));
|
||||
|
||||
assert(gui.scrollOlder(history));
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(!history.newest(2)->unread && !history.newest(3)->unread);
|
||||
assert(history.newest(1)->unread && history.unreadCount() == 1);
|
||||
assert(history.newest(0)->queue_index == 3); // reading never consumes app delivery
|
||||
|
||||
// Merely composing a frame while the panel is off does not mark it read.
|
||||
history.clear();
|
||||
history.add(0, 0, "Public", "Ch 0 Public", "Alice: sleeping panel", 7);
|
||||
gui.resetScroll();
|
||||
display.on = false;
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(history.newest(0)->unread && history.newest(0)->queue_index == 7);
|
||||
display.on = true;
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(!history.newest(0)->unread && history.newest(0)->queue_index == 7);
|
||||
|
||||
// Scrolling to an older entry does not acknowledge the newer skipped one.
|
||||
history.clear();
|
||||
history.add(0, 0, "Public", "Ch 0 Public", "Alice: older", 1);
|
||||
history.add(0, 0, "Public", "Ch 0 Public", "Bob: newer", 2);
|
||||
gui.resetScroll();
|
||||
assert(gui.scrollOlder(history));
|
||||
gui.begin(display, settings, frame);
|
||||
gui.renderBoard(history, "Public");
|
||||
assert(history.newest(0)->unread && !history.newest(1)->unread);
|
||||
assert(history.newest(0)->queue_index == 2 && history.newest(1)->queue_index == 1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
channelBoard();
|
||||
screenBounds();
|
||||
measuredLabels();
|
||||
readOnlyVisibleEntries();
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
#include <helpers/ui/PocketGuiSettings.h>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <limits>
|
||||
|
||||
using namespace mesh::ui::pocket;
|
||||
|
||||
static bool equal(const Settings& left, const Settings& right) {
|
||||
return left.channel == right.channel && left.text_large == right.text_large
|
||||
&& left.name_len == right.name_len && left.flip == right.flip
|
||||
&& left.dark == right.dark && left.idle == right.idle
|
||||
&& left.refresh == right.refresh && left.map_zoom == right.map_zoom;
|
||||
}
|
||||
|
||||
static void repairChecksum(uint8_t* bytes) {
|
||||
const uint16_t crc = settingsChecksum(bytes, 11);
|
||||
bytes[11] = static_cast<uint8_t>(crc);
|
||||
bytes[12] = static_cast<uint8_t>(crc >> 8);
|
||||
}
|
||||
|
||||
static void settingsRoundTrip() {
|
||||
Settings value;
|
||||
assert(settingsValid(value));
|
||||
assert(idleMillis(value) == 60000 && refreshCount(value) == 30);
|
||||
assert(nameCharacters(value) == 5 && mapHeightKm(value) == 200);
|
||||
|
||||
uint8_t bytes[SETTINGS_RECORD_SIZE];
|
||||
for (unsigned channels : {1U, 40U, 256U}) {
|
||||
for (unsigned choices = 0; choices < 8; ++choices)
|
||||
for (unsigned name = 0; name < 4; ++name)
|
||||
for (unsigned idle = 0; idle < 4; ++idle)
|
||||
for (unsigned refresh = 0; refresh < 4; ++refresh)
|
||||
for (unsigned zoom = 0; zoom < 9; ++zoom) {
|
||||
value.channel = static_cast<uint8_t>(channels - 1);
|
||||
value.text_large = choices & 1;
|
||||
value.flip = (choices >> 1) & 1;
|
||||
value.dark = (choices >> 2) & 1;
|
||||
value.name_len = name;
|
||||
value.idle = idle;
|
||||
value.refresh = refresh;
|
||||
value.map_zoom = zoom;
|
||||
assert(encodeSettings(value, bytes, sizeof(bytes), channels));
|
||||
Settings recovered;
|
||||
assert(decodeSettings(recovered, bytes, sizeof(bytes), channels));
|
||||
assert(equal(value, recovered));
|
||||
}
|
||||
}
|
||||
|
||||
value = Settings{};
|
||||
assert(encodeSettings(value, bytes, sizeof(bytes)));
|
||||
const Settings sentinel{39, 1, 3, 1, 1, 3, 3, 8};
|
||||
auto rejects = [&](const uint8_t* input, size_t size, unsigned channels = 40) {
|
||||
Settings destination = sentinel;
|
||||
assert(!decodeSettings(destination, input, size, channels));
|
||||
assert(equal(destination, sentinel));
|
||||
};
|
||||
rejects(nullptr, sizeof(bytes));
|
||||
for (size_t size = 0; size < sizeof(bytes); ++size) rejects(bytes, size);
|
||||
rejects(bytes, sizeof(bytes) + 1);
|
||||
rejects(bytes, sizeof(bytes), 0);
|
||||
rejects(bytes, sizeof(bytes), 257);
|
||||
|
||||
// Detect corruption at every bit, including the stored checksum itself.
|
||||
for (size_t byte = 0; byte < sizeof(bytes); ++byte)
|
||||
for (unsigned bit = 0; bit < 8; ++bit) {
|
||||
uint8_t changed[sizeof(bytes)];
|
||||
std::memcpy(changed, bytes, sizeof(bytes));
|
||||
changed[byte] ^= 1U << bit;
|
||||
rejects(changed, sizeof(changed));
|
||||
}
|
||||
|
||||
// A record with a valid CRC still cannot select invalid enum values, a
|
||||
// missing channel, an unknown format or reserved bits.
|
||||
const unsigned bad_offsets[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
|
||||
const unsigned bad_values[] = {'X', 'X', 2, 40, 2, 4, 4, 4, 4, 9, 1};
|
||||
for (size_t test = 0; test < sizeof(bad_offsets) / sizeof(*bad_offsets); ++test) {
|
||||
uint8_t changed[sizeof(bytes)];
|
||||
std::memcpy(changed, bytes, sizeof(bytes));
|
||||
changed[bad_offsets[test]] = bad_values[test];
|
||||
repairChecksum(changed);
|
||||
rejects(changed, sizeof(changed));
|
||||
}
|
||||
|
||||
value.channel = 39;
|
||||
assert(encodeSettings(value, bytes, sizeof(bytes), 40));
|
||||
rejects(bytes, sizeof(bytes), 39);
|
||||
for (unsigned row = 0; row < SETTINGS_COUNT; ++row) {
|
||||
Settings changed;
|
||||
const Settings initial = changed;
|
||||
const unsigned periods[] = {40, 2, 4, 2, 2, 4, 4, 9};
|
||||
for (unsigned step = 0; step < periods[row]; ++step) {
|
||||
assert(cycleSetting(changed, row));
|
||||
assert(settingsValid(changed));
|
||||
}
|
||||
assert(equal(changed, initial));
|
||||
}
|
||||
const Settings initial;
|
||||
value = initial;
|
||||
assert(!cycleSetting(value, SETTINGS_COUNT) && equal(value, initial));
|
||||
assert(!cycleSetting(value, 0, 0) && equal(value, initial));
|
||||
assert(!cycleSetting(value, 0, 257) && equal(value, initial));
|
||||
value.channel = 255;
|
||||
assert(cycleSetting(value, 0, 256) && value.channel == 0);
|
||||
|
||||
for (unsigned bad = 4; bad <= 255; ++bad) {
|
||||
value.idle = value.refresh = value.name_len = static_cast<uint8_t>(bad);
|
||||
assert(idleMillis(value) == 60000 && refreshCount(value) == 30);
|
||||
assert(nameCharacters(value) == 5);
|
||||
}
|
||||
for (unsigned bad = 9; bad <= 255; ++bad) {
|
||||
value.map_zoom = static_cast<uint8_t>(bad);
|
||||
assert(mapHeightKm(value) == 200);
|
||||
}
|
||||
}
|
||||
|
||||
static void scrollHistory() {
|
||||
assert(messageAgeSeconds(1000, UINT32_MAX - 999U) == 2);
|
||||
assert(messageAgeSeconds(180000, 60000) == 120);
|
||||
for (unsigned count = 0; count <= 32; ++count) {
|
||||
BoardState state;
|
||||
assert(!state.scrollNewer());
|
||||
for (unsigned next = 1; next < count; ++next) {
|
||||
assert(state.scrollOlder(count));
|
||||
assert(state.scroll == next);
|
||||
}
|
||||
assert(!state.scrollOlder(count));
|
||||
assert(state.scroll == (count == 0 ? 0 : count - 1));
|
||||
const unsigned previous = state.scroll;
|
||||
state.messageAdded();
|
||||
// At the live end keep following new messages. While reading old messages
|
||||
// retain the same entry until it falls out of the 32-message ring.
|
||||
assert(state.scroll == (previous == 0 ? 0 : previous < 31 ? previous + 1 : 31));
|
||||
state.clamp(0);
|
||||
assert(state.scroll == 0);
|
||||
}
|
||||
BoardState state;
|
||||
state.scroll = std::numeric_limits<unsigned>::max();
|
||||
state.clamp(1);
|
||||
assert(state.scroll == 0 && !state.scrollOlder(1));
|
||||
state.scroll = 99;
|
||||
state.clamp(32);
|
||||
assert(state.scroll == 31 && !state.scrollOlder(32));
|
||||
for (unsigned left = 31; left > 0; --left) {
|
||||
assert(state.scrollNewer());
|
||||
assert(state.scroll == left - 1);
|
||||
}
|
||||
assert(!state.scrollNewer());
|
||||
}
|
||||
|
||||
static void messageNamesAndText() {
|
||||
BoardMessageView result;
|
||||
parseBoardMessage("Ch 0 Public [1h]:", "Alice: hello: there", false, result);
|
||||
assert(result.sender_length == 5 && std::strncmp(result.sender, "Alice", 5) == 0);
|
||||
assert(std::strcmp(result.text, "hello: there") == 0);
|
||||
parseBoardMessage("My name", "Alice: literal outgoing body", true, result);
|
||||
assert(result.sender_length == 7 && std::strcmp(result.sender, "My name") == 0);
|
||||
assert(std::strcmp(result.text, "Alice: literal outgoing body") == 0);
|
||||
parseBoardMessage("Alice [direct]:", "no sender prefix", false, result);
|
||||
assert(result.sender_length == 5 && std::strcmp(result.text, "no sender prefix") == 0);
|
||||
parseBoardMessage(nullptr, nullptr, false, result);
|
||||
assert(result.sender_length == 0 && *result.text == 0);
|
||||
parseBoardMessage("fallback [direct]:", ": invalid empty sender", false, result);
|
||||
assert(result.sender_length == 8);
|
||||
char long_message[90];
|
||||
std::memset(long_message, 'a', 70);
|
||||
std::strcpy(long_message + 70, ": message");
|
||||
parseBoardMessage("fallback", long_message, false, result);
|
||||
assert(result.sender_length == 8 && result.text == long_message);
|
||||
|
||||
char text[64];
|
||||
sanitizeText(text, sizeof(text), "caf\xc3\xa9\t\xf0\x9f\x93\xbb\n!", 13);
|
||||
assert(std::strcmp(text, "caf\xc3\xa9 * !") == 0);
|
||||
sanitizeText(text, sizeof(text), nullptr, 100);
|
||||
assert(*text == 0);
|
||||
sanitizeText(text, 1, "abc", 3);
|
||||
assert(*text == 0);
|
||||
// Neither an exact-length nonterminated input nor a truncated multibyte
|
||||
// input can over-read, and a small output never ends with half of Latin-1.
|
||||
const char exact[] = {'a', 'b', 'c'};
|
||||
sanitizeText(text, sizeof(text), exact, sizeof(exact));
|
||||
assert(std::strcmp(text, "abc") == 0);
|
||||
const char truncated[] = {'a', '\xc3'};
|
||||
sanitizeText(text, sizeof(text), truncated, sizeof(truncated));
|
||||
assert(std::strcmp(text, "a*") == 0);
|
||||
sanitizeText(text, sizeof(text), "A\xc2\x85" "B", 4);
|
||||
assert(std::strcmp(text, "A B") == 0);
|
||||
sanitizeText(text, 3, "a\xc3\xa9", 3);
|
||||
assert(std::strcmp(text, "a") == 0);
|
||||
std::strcpy(text, "\xc3\x89lise");
|
||||
truncateCharacters(text, 3);
|
||||
assert(std::strcmp(text, "\xc3\x89li") == 0);
|
||||
truncateCharacters(text, 0);
|
||||
assert(std::strcmp(text, "\xc3\x89li") == 0);
|
||||
truncateCharacters(text, 1);
|
||||
assert(std::strcmp(text, "\xc3\x89") == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
settingsRoundTrip();
|
||||
scrollHistory();
|
||||
messageNamesAndText();
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
#include <helpers/ui/CompanionMessageHistory.h>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
|
||||
using History = mesh::ui::CompanionMessageHistory<3, 78>;
|
||||
|
||||
// Own-message styling uses the Entry's existing alignment padding. Every
|
||||
// other board retains the same 32-message history allocation.
|
||||
struct PreviousEntry {
|
||||
uint64_t heard_millis;
|
||||
int queue_index;
|
||||
bool unread;
|
||||
int channel_idx;
|
||||
char channel_name[32];
|
||||
char origin[62];
|
||||
char message[78];
|
||||
};
|
||||
static_assert(sizeof(History::Entry) == sizeof(PreviousEntry),
|
||||
"own-message metadata changed the shared history RAM budget");
|
||||
|
||||
int main() {
|
||||
History history;
|
||||
history.add(100, 0, "Public", "Ch 0 Public [1h]:", "Alice: received", 2);
|
||||
history.add(200, 0, "Public", "Self", "sent", -1, true);
|
||||
assert(history.count() == 2 && history.unreadCount() == 1);
|
||||
assert(history.newest(0)->own_sent && !history.newest(0)->unread);
|
||||
assert(history.newest(0)->queue_index == -1);
|
||||
assert(!history.newest(1)->own_sent && history.newest(1)->unread);
|
||||
|
||||
// App queue reads shift only received-message indices. They do not make a
|
||||
// locally sent message look pending or mark the received one as read.
|
||||
history.queueRemoved(0);
|
||||
assert(history.newest(0)->queue_index == -1);
|
||||
assert(history.newest(1)->queue_index == 1);
|
||||
assert(history.unreadCount() == 1);
|
||||
history.queueRemoved(1);
|
||||
assert(history.newest(1)->queue_index == -1);
|
||||
assert(history.unreadCount() == 1);
|
||||
|
||||
history.add(300, -1, "", "Bob [direct]:", "direct", 0);
|
||||
history.add(400, 0, "Public", "Self", "new sent", -1, true);
|
||||
assert(history.count() == 3 && history.unreadCount() == 1);
|
||||
assert(std::strcmp(history.newest(0)->message, "new sent") == 0);
|
||||
assert(history.newest(2)->own_sent && !history.newest(2)->unread);
|
||||
history.markRead(history.newest(1));
|
||||
assert(history.unreadCount() == 0);
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
#include "helpers/ui/PocketWorldMap.h"
|
||||
#include "helpers/ui/PocketWorldMapData.generated.h"
|
||||
#include "helpers/ota/tinf/tinf.h"
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <stdint.h>
|
||||
#include <initializer_list>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
using namespace mesh::ui::pocket;
|
||||
|
||||
struct Canvas {
|
||||
static constexpr int width = 250, height = 91;
|
||||
bool pixels[height][width] = {};
|
||||
unsigned lines = 0;
|
||||
static void line(void* context, int x0, int y0, int x1, int y1) {
|
||||
Canvas& canvas = *static_cast<Canvas*>(context);
|
||||
assert(x0 >= 0 && x0 < width && x1 >= 0 && x1 < width);
|
||||
assert(y0 >= 0 && y0 < height && y1 >= 0 && y1 < height);
|
||||
++canvas.lines;
|
||||
const int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
|
||||
const int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
|
||||
int error = dx + dy;
|
||||
for (;;) {
|
||||
canvas.pixels[y0][x0] = true;
|
||||
if (x0 == x1 && y0 == y1) break;
|
||||
const int twice = error * 2;
|
||||
if (twice >= dy) { error += dy; x0 += sx; }
|
||||
if (twice <= dx) { error += dx; y0 += sy; }
|
||||
}
|
||||
}
|
||||
bool nearby(int x, int y, int radius) const {
|
||||
for (int yy = y - radius; yy <= y + radius; ++yy)
|
||||
for (int xx = x - radius; xx <= x + radius; ++xx)
|
||||
if (xx >= 0 && xx < width && yy >= 0 && yy < height && pixels[yy][xx]) return true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
static MapView view(double latitude, double longitude, double km = 200) {
|
||||
return {latitude, longitude, km, Canvas::width, Canvas::height};
|
||||
}
|
||||
|
||||
static void testProjection() {
|
||||
int x, y;
|
||||
auto v = view(0, 180);
|
||||
assert(projectWorldPoint(v, 0, 180, x, y) && x == 125 && y == 45);
|
||||
int east, west;
|
||||
assert(projectWorldPoint(v, 0, -179.9, east, y));
|
||||
assert(projectWorldPoint(v, 0, 179.9, west, y));
|
||||
assert(east > 125 && west < 125 && east - west < 12);
|
||||
assert(!projectWorldPoint(v, 0, 0, x, y));
|
||||
assert(!projectWorldPoint(v, 0, 181, x, y));
|
||||
for (double pole : {-90.0, 90.0}) {
|
||||
v = view(pole, 0);
|
||||
assert(projectWorldPoint(v, pole, 170, x, y));
|
||||
assert(x == 125 && y == 45);
|
||||
assert(projectWorldPoint(v, pole > 0 ? 89.5 : -89.5, 90, x, y));
|
||||
}
|
||||
v = view(NAN, 0);
|
||||
assert(!projectWorldPoint(v, 0, 0, x, y));
|
||||
v = view(0, 0); v.width = 0;
|
||||
assert(!projectWorldPoint(v, 0, 0, x, y));
|
||||
}
|
||||
|
||||
static void testFloatProjectionAccuracyAndWorldOverview() {
|
||||
constexpr double radians = 3.14159265358979323846 / 180.0;
|
||||
// Independent double-precision azimuthal reference. The device's float/FPU
|
||||
// implementation must stay within one pixel at close and continental zooms.
|
||||
for (double latitude : {-90.0, -78.0, -45.0, 0.0, 37.8, 78.0, 89.9, 90.0}) {
|
||||
for (double longitude : {-180.0, -122.5, 0.0, 180.0}) {
|
||||
for (double height : {5.0, 50.0, 200.0, 1000.0, 5000.0}) {
|
||||
const auto v = view(latitude, longitude, height);
|
||||
for (int row = -8; row <= 8; ++row) for (int col = -8; col <= 8; ++col) {
|
||||
const double lat = latitude + row * height / 111.195 / 16;
|
||||
if (lat < -90 || lat > 90) continue;
|
||||
double lon = longitude + col * height / 111.195 / 8 / fmax(0.01, cos(latitude * radians));
|
||||
lon = remainder(lon, 360.0);
|
||||
const double a = latitude * radians, b = lat * radians;
|
||||
const double delta = remainder((lon - longitude) * radians, 2 * 3.14159265358979323846);
|
||||
const double cosine = sin(a) * sin(b) + cos(a) * cos(b) * cos(delta);
|
||||
if (cosine < 0.00001) continue;
|
||||
const double distance = acos(fmin(1.0, cosine));
|
||||
const double factor = (distance < 1e-8 ? 6371.0088 : 6371.0088 * distance / sin(distance)) * 90 / height;
|
||||
const double px = 124.5 + factor * cos(b) * sin(delta);
|
||||
const double py = 45 - factor * (cos(a) * sin(b) - sin(a) * cos(b) * cos(delta));
|
||||
if (px < 1 || px > 248 || py < 1 || py > 89) continue;
|
||||
int x, y;
|
||||
assert(projectWorldPoint(v, lat, lon, x, y));
|
||||
assert(abs(x - int(lround(px))) <= 1 && abs(y - int(lround(py))) <= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const auto world = view(0, 0, 20000);
|
||||
// The full-world fallback must show every continent, not just one hemisphere.
|
||||
const double continents[][2] = {{37.8,-122.5}, {-23.5,-46.6}, {51.5,-0.1},
|
||||
{-33.9,18.4}, {35.7,139.7}, {-33.9,151.2}, {-90,0}, {90,0}};
|
||||
for (const auto& point : continents) {
|
||||
int x, y;
|
||||
assert(projectWorldPoint(world, point[0], point[1], x, y));
|
||||
}
|
||||
// A 179.9 -> 180.1 degree edge must end on both map edges and never draw a
|
||||
// horizontal line through the equator's centre.
|
||||
const uint8_t crossing[] = {2,0, 0xbc,0xbe,2,0, 0,0,0,0, 0x90,3,0};
|
||||
Canvas canvas;
|
||||
assert(renderWorldMapChunkForTest(world, crossing, sizeof(crossing), Canvas::line, &canvas));
|
||||
assert(canvas.lines == 2 && !canvas.pixels[45][125]);
|
||||
assert(canvas.nearby(36,45,1) && canvas.nearby(214,45,1));
|
||||
}
|
||||
|
||||
static void testRealCoasts() {
|
||||
Canvas coast;
|
||||
auto v = view(37.8, -122.5);
|
||||
assert(renderWorldMap(v, Canvas::line, &coast));
|
||||
assert(coast.lines > 20);
|
||||
int x, y;
|
||||
assert(projectWorldPoint(v, 37.8, -122.5, x, y));
|
||||
assert(coast.nearby(x, y, 5));
|
||||
Canvas inland;
|
||||
assert(renderWorldMap(view(38.5, -98), Canvas::line, &inland));
|
||||
assert(inland.lines == 0);
|
||||
Canvas islands;
|
||||
assert(renderWorldMap(view(-16.5, 180, 500), Canvas::line, &islands));
|
||||
assert(islands.lines > 10);
|
||||
Canvas arctic;
|
||||
assert(renderWorldMap(view(78.2, 15.6), Canvas::line, &arctic));
|
||||
assert(arctic.lines > 20);
|
||||
for (auto polar : {view(90, 0, 2000), view(-90, 180, 2000), view(90, -180, 20000)}) {
|
||||
Canvas canvas;
|
||||
assert(renderWorldMap(polar, Canvas::line, &canvas));
|
||||
assert(canvas.lines > 0);
|
||||
}
|
||||
Canvas close;
|
||||
assert(renderWorldMap(view(-16.5, -180, 5), Canvas::line, &close));
|
||||
auto invalid = view(0, 0); invalid.height_km = 0;
|
||||
assert(!renderWorldMap(invalid, Canvas::line, &close));
|
||||
assert(!renderWorldMap(view(0, 0), nullptr, nullptr));
|
||||
}
|
||||
|
||||
static void testChunksAndMalformedData() {
|
||||
unsigned decoded = 0;
|
||||
for (const auto& chunk : data::kChunks) {
|
||||
uint8_t scratch[data::kScratchBytes];
|
||||
unsigned size = sizeof(scratch);
|
||||
assert(chunk.offset + chunk.packed <= sizeof(data::kDeflate));
|
||||
assert(tinf_uncompress_exact(scratch, &size, data::kDeflate + chunk.offset, chunk.packed) == TINF_OK);
|
||||
assert(size == chunk.raw && size <= sizeof(scratch));
|
||||
Canvas canvas;
|
||||
assert(renderWorldMapChunkForTest(view(0, 0, 20000), scratch, size, Canvas::line, &canvas));
|
||||
// A truncation at every byte must fail unless it ends on a complete fragment.
|
||||
for (unsigned tail = 1; tail < 10 && tail < size; ++tail) {
|
||||
Canvas truncated;
|
||||
(void)renderWorldMapChunkForTest(view(0, 0), scratch, size - tail, Canvas::line, &truncated);
|
||||
}
|
||||
++decoded;
|
||||
}
|
||||
assert(decoded == 32);
|
||||
assert(data::kRingCount == 1422);
|
||||
// Two points at 0,0 and 0.001,0.001 (zigzag delta 2,2).
|
||||
uint8_t valid[] = {2,0, 0,0,0,0, 0,0,0,0, 2,2};
|
||||
Canvas canvas;
|
||||
assert(renderWorldMapChunkForTest(view(0, 0), valid, sizeof(valid), Canvas::line, &canvas));
|
||||
assert(canvas.lines == 1);
|
||||
for (size_t size = 1; size < sizeof(valid); ++size)
|
||||
assert(!renderWorldMapChunkForTest(view(0, 0), valid, size, Canvas::line, &canvas));
|
||||
valid[0] = 1;
|
||||
assert(!renderWorldMapChunkForTest(view(0, 0), valid, sizeof(valid), Canvas::line, &canvas));
|
||||
valid[0] = 97;
|
||||
assert(!renderWorldMapChunkForTest(view(0, 0), valid, sizeof(valid), Canvas::line, &canvas));
|
||||
valid[0] = 2;
|
||||
valid[10] = valid[11] = 255;
|
||||
assert(!renderWorldMapChunkForTest(view(0, 0), valid, sizeof(valid), Canvas::line, &canvas));
|
||||
const uint8_t overflow[] = {2,0, 0,0,0,0, 0,0,0,0, 255,255,255,255,255,0};
|
||||
assert(!renderWorldMapChunkForTest(view(0, 0), overflow, sizeof(overflow), Canvas::line, &canvas));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testProjection();
|
||||
testFloatProjectionAccuracyAndWorldOverview();
|
||||
testRealCoasts();
|
||||
testChunksAndMalformedData();
|
||||
puts("world map projection, clipped raster, codec and parser checks passed");
|
||||
}
|
||||
@@ -69,6 +69,25 @@ def esp_fixture(path, modern=False, fragmented=False, internal_length=0x10000):
|
||||
|
||||
|
||||
class FirmwareRamTest(unittest.TestCase):
|
||||
def test_pocket_gui_reserves_expanded_history_without_changing_other_boards(self):
|
||||
flags = {"DISPLAY_CLASS": "GxEPDDisplay", "HELTEC_MESH_POCKET": 1,
|
||||
"COMPANION_RADIO_FULL": 1, "UI_MSG_PREVIEW_SIZE": 161}
|
||||
target = "Mesh_pocket_companion_radio_full"
|
||||
enabled = ram.requirements("NRF52_PLATFORM", flags, target)
|
||||
self.assertEqual(enabled["components"]["screen_objects_and_history"], 12288)
|
||||
self.assertNotIn("expanded_message_previews", enabled["components"])
|
||||
self.assertGreaterEqual(enabled["required_contiguous_bytes"], 12288)
|
||||
|
||||
for changed in ({**flags, "UI_POCKET_GUI": 0},
|
||||
{key: value for key, value in flags.items()
|
||||
if key != "HELTEC_MESH_POCKET"}):
|
||||
policy = ram.requirements("NRF52_PLATFORM", changed, target)
|
||||
self.assertEqual(policy["components"]["screen_objects_and_history"], 8192)
|
||||
self.assertEqual(policy["components"]["expanded_message_previews"], 2816)
|
||||
larger = ram.requirements("NRF52_PLATFORM", {
|
||||
**flags, "MESH_COMPANION_SCREEN_STARTUP_BYTES": 16384}, target)
|
||||
self.assertEqual(larger["components"]["screen_objects_and_history"], 16384)
|
||||
|
||||
def test_st7789_budget_matches_production_allocation_peak_and_reuses_buffers(self):
|
||||
ui = ROOT / "src/helpers/ui"
|
||||
spi = (ui / "ST7789Spi.h").read_text()
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise MeshPocket GUI state and settings with the production headers."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURE = ROOT / "test/fixtures/pocket_gui"
|
||||
|
||||
|
||||
class PocketGuiTest(unittest.TestCase):
|
||||
def test_gui_is_only_enabled_for_pocket_full(self):
|
||||
compiler = shutil.which("g++")
|
||||
if compiler is None:
|
||||
self.skipTest("host G++ required")
|
||||
source = "#include <helpers/ui/PocketGuiConfig.h>\nUI_POCKET_GUI\n"
|
||||
profiles = [
|
||||
([], "0"),
|
||||
(["HELTEC_MESH_POCKET=1"], "0"),
|
||||
(["COMPANION_RADIO_FULL=1"], "0"),
|
||||
(["NRF52_PLATFORM=1", "RAK_4631=1", "COMPANION_RADIO_FULL=1"], "0"),
|
||||
(["ESP32_PLATFORM=1", "LILYGO_T_DECK=1", "COMPANION_RADIO_FULL=1"], "0"),
|
||||
(["HELTEC_MESH_POCKET=1", "COMPANION_RADIO_FULL=1"], "1"),
|
||||
(["HELTEC_MESH_POCKET=1", "COMPANION_RADIO_FULL=1", "UI_POCKET_GUI=0"], "0"),
|
||||
]
|
||||
for flags, expected in profiles:
|
||||
with self.subTest(flags=flags):
|
||||
command = [compiler, "-E", "-P", "-x", "c++", "-",
|
||||
"-I" + str(ROOT / "src"), *("-D" + flag for flag in flags)]
|
||||
result = subprocess.run(command, input=source, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), expected)
|
||||
for flags in ([], ["HELTEC_MESH_POCKET=1"], ["COMPANION_RADIO_FULL=1"]):
|
||||
command = [compiler, "-E", "-P", "-x", "c++", "-", "-DUI_POCKET_GUI=1",
|
||||
"-I" + str(ROOT / "src"), *("-D" + flag for flag in flags)]
|
||||
result = subprocess.run(command, input=source, capture_output=True, text=True)
|
||||
self.assertNotEqual(result.returncode, 0, "unsupported board accepted Pocket GUI")
|
||||
self.assertIn("requires MeshPocket Full Companion", result.stderr)
|
||||
|
||||
def compile_and_run(self, source, extra_flags=()):
|
||||
compiler = shutil.which("g++")
|
||||
if compiler is None:
|
||||
self.skipTest("host G++ required")
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-pocket-gui-") as directory:
|
||||
binary = Path(directory) / "gui-test"
|
||||
flags = ["-std=c++17", "-Wall", "-Wextra", "-Werror", "-g"]
|
||||
if os.name != "nt":
|
||||
flags += ["-fsanitize=address,undefined", "-fno-omit-frame-pointer",
|
||||
"-fno-pie", "-no-pie"]
|
||||
command = [compiler, *flags, *extra_flags,
|
||||
"-I" + str(FIXTURE), "-I" + str(ROOT / "src"), "-I" + str(ROOT),
|
||||
str(source), "-o", str(binary)]
|
||||
for args in (command, [str(binary)]):
|
||||
result = subprocess.run(args, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0,
|
||||
" ".join(args) + "\n" + result.stdout + result.stderr)
|
||||
|
||||
def test_sent_messages_preserve_history_without_becoming_unread(self):
|
||||
self.compile_and_run(FIXTURE / "test_sent_history.cpp")
|
||||
|
||||
def test_settings_integrity_and_message_board_state(self):
|
||||
self.compile_and_run(FIXTURE / "test_pocket_state.cpp")
|
||||
|
||||
def test_native_board_layout_and_fractional_timezones(self):
|
||||
self.compile_and_run(FIXTURE / "test_pocket_render.cpp",
|
||||
["-DHELTEC_MESH_POCKET=1", "-DCOMPANION_RADIO_FULL=1"])
|
||||
|
||||
def test_pocket_controls_keep_network_reader_access(self):
|
||||
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
|
||||
home = source[source.index("class HomeScreen :"):]
|
||||
navigation = home[home.index(" bool handleInput(char c) override {"):]
|
||||
navigation = navigation.split("#ifdef COMPANION_EXCLUSIVE_WIFI_BLE", 1)[0]
|
||||
implementation = "\n".join(extract_braced(home, signature) for signature in (
|
||||
"void resetRadioProfileDisplayPage() {", "bool isPageVisible(uint8_t page) const {",
|
||||
"void movePage(int direction) {", "void showFirstPage() {",
|
||||
"bool isRadioPage() const {"))
|
||||
implementation += "\n" + navigation + "return false; }\n"
|
||||
fixture = (FIXTURE / "test_pocket_controls.cpp").read_text()
|
||||
fixture = fixture.replace("// @HOME_IMPLEMENTATION@", implementation)
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-pocket-controls-") as directory:
|
||||
generated = Path(directory) / "controls.cpp"
|
||||
generated.write_text(fixture)
|
||||
for signedness in ("-fsigned-char", "-funsigned-char"):
|
||||
with self.subTest(signedness=signedness):
|
||||
self.compile_and_run(generated, [signedness, "-DUI_POCKET_GUI=1",
|
||||
"-DCOMPANION_FEATURE_READER=1", "-DUI_NO_DISCOVER_SCREEN=1",
|
||||
"-Wno-unused-parameter"])
|
||||
|
||||
def test_received_and_sent_message_bindings_preserve_screen_policy(self):
|
||||
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
|
||||
methods = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"void UITask::newMsg(", "void UITask::channelMessageSent(",
|
||||
"char UITask::handleMultiClick("))
|
||||
fixture = (FIXTURE / "test_pocket_bindings.cpp").read_text()
|
||||
fixture = fixture.replace("// @ADD_SENT@", extract_braced(source, "void addSent("))
|
||||
fixture = fixture.replace("// @TASK_METHODS@", methods)
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-pocket-bindings-") as directory:
|
||||
generated = Path(directory) / "bindings.cpp"
|
||||
generated.write_text(fixture)
|
||||
self.compile_and_run(generated, ["-DUI_POCKET_GUI=1",
|
||||
"-DCOMPANION_FEATURE_READER=1", "-Wno-unused-parameter"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Qualify and carry GUI/font notices without changing firmware or DFU ZIPs."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import package_pocket_gui_license as notice
|
||||
import firmware_memory_manifest as memory
|
||||
import firmware_build_recipe as recipe
|
||||
import package_cascade_release as release
|
||||
|
||||
GUI = "_ZN4mesh2ui6pocket9PocketGui6headerEPKcS5_"
|
||||
FONT = "u8g2_font_helvR08_tf"
|
||||
|
||||
|
||||
def elf32(path, symbols=(GUI, FONT), flags=6, section_type=1, undefined=(), zero=()):
|
||||
names, table = bytearray(b"\0"), bytearray(16)
|
||||
data = b"CODE" * max(1, len(symbols))
|
||||
for i, name in enumerate(symbols):
|
||||
offset = len(names)
|
||||
names += name.encode() + b"\0"
|
||||
table += struct.pack("<IIIBBH", offset, 0x40000000 + i * 4,
|
||||
0 if name in zero else 4, 0x12, 0, 0 if name in undefined else 1)
|
||||
payload = 52 + 4 * 40
|
||||
strings = payload + len(data)
|
||||
symtab = strings + len(names)
|
||||
header = b"\x7fELF\x01\x01\x01" + bytes(9)
|
||||
header += struct.pack("<HH5I6H", 2, 40, 1, 0, 0, 52, 0, 52, 0, 0, 40, 4, 0)
|
||||
sections = bytes(40)
|
||||
sections += struct.pack("<10I", 0, section_type, flags, 0x40000000, payload, len(data), 0, 0, 4, 0)
|
||||
sections += struct.pack("<10I", 0, 3, 0, 0, strings, len(names), 0, 0, 1, 0)
|
||||
sections += struct.pack("<10I", 0, 2, 0, 0, symtab, len(table), 2, 1, 4, 16)
|
||||
path.write_bytes(header + sections + data + names + table)
|
||||
|
||||
|
||||
class PocketGuiLicenseTest(unittest.TestCase):
|
||||
def qualify(self, directory, stem, target="test_companion_radio_usb"):
|
||||
output = stem.parent / (stem.name + ".capabilities.json")
|
||||
result = subprocess.run([sys.executable, str(ROOT / "scripts/check_firmware_capabilities.py"),
|
||||
"--image", str(directory / "firmware.elf"), "--output", str(output),
|
||||
"--target", target, "--platform", "ESP32_PLATFORM", "--build-profile", "standard"],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return json.loads(output.read_text())
|
||||
|
||||
@unittest.skipUnless(sys.platform.startswith("linux"), "requires a Linux ELF compiler")
|
||||
def test_compiler_produced_gui_and_font_symbols_qualify(self):
|
||||
compiler = shutil.which("g++")
|
||||
if compiler is None:
|
||||
self.skipTest("host g++ required")
|
||||
source = """
|
||||
namespace mesh { namespace ui { namespace pocket {
|
||||
class PocketGui { public: void header(const char*, const char*); };
|
||||
void PocketGui::header(const char*, const char*) { asm volatile(\"\"); }
|
||||
}}}
|
||||
extern const unsigned char u8g2_font_helvR08_tf[] = {1, 2, 3, 4};
|
||||
extern \"C\" void _start() {}
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
input_path = root / "linked.cpp"
|
||||
input_path.write_text(source)
|
||||
result = subprocess.run([compiler, "-m32", "-O0", "-fno-pie", "-no-pie",
|
||||
"-fno-exceptions", "-fno-rtti", "-fno-stack-protector", "-nostdlib",
|
||||
"-Wl,-e,_start", "-Wl,--build-id=none", str(input_path),
|
||||
"-o", str(root / "firmware.elf")], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
evidence = notice.pocket_gui_evidence(root / "firmware.elf")
|
||||
self.assertEqual(len(evidence), 2)
|
||||
self.assertIn(FONT, evidence)
|
||||
self.qualify(root, root / "compiled")
|
||||
self.assertEqual(notice.collect_build_notice(root, root / "compiled").read_bytes(),
|
||||
notice.notice_bytes())
|
||||
|
||||
def test_notice_requires_allocated_gui_or_font_definitions(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "firmware.elf"
|
||||
elf32(path)
|
||||
self.assertEqual(notice.pocket_gui_evidence(path), sorted((GUI, FONT)))
|
||||
# A partially linked font still needs its font license.
|
||||
elf32(path, symbols=(FONT,), flags=2)
|
||||
self.assertEqual(notice.pocket_gui_evidence(path), [FONT])
|
||||
for kwargs in ({"flags": 0}, {"flags": 4}, {"section_type": 8},
|
||||
{"symbols": (GUI,), "flags": 2},
|
||||
{"symbols": (FONT, "other"), "undefined": (FONT,)},
|
||||
{"symbols": (GUI, "other"), "zero": (GUI,)}):
|
||||
with self.subTest(kwargs=kwargs):
|
||||
elf32(path, **kwargs)
|
||||
self.assertEqual(notice.pocket_gui_evidence(path), [])
|
||||
path.write_bytes((GUI + " " + FONT).encode())
|
||||
self.assertEqual(notice.pocket_gui_evidence(path), [])
|
||||
|
||||
def test_target_name_alone_never_creates_notice_and_stale_notice_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
stem = root / "plain"
|
||||
elf32(root / "firmware.elf", symbols=("unrelated",))
|
||||
manifest = self.qualify(root, stem, "Mesh_pocket_companion_radio_full")
|
||||
self.assertNotIn("third_party_notices", manifest)
|
||||
self.assertIsNone(notice.collect_build_notice(root, stem))
|
||||
notice.sidecar(stem).write_bytes(notice.notice_bytes())
|
||||
with self.assertRaisesRegex(ValueError, "stale"):
|
||||
notice.collect_build_notice(root, stem)
|
||||
self.assertTrue(recipe.package_occupied(stem))
|
||||
|
||||
def test_notice_is_hashed_with_memory_proof_and_staged_for_release(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
build, output = root / "build", root / "out"
|
||||
build.mkdir(); output.mkdir()
|
||||
elf32(build / "firmware.elf")
|
||||
version = "v1.18.0.1-test-deadbeef"
|
||||
stem = output / ("test_companion_radio_usb-" + version)
|
||||
manifest = self.qualify(build, stem)
|
||||
data = notice.notice_bytes()
|
||||
self.assertEqual(manifest["third_party_notices"], [notice.notice_record(stem, data)])
|
||||
# No artifact bytes are rewritten to carry notices.
|
||||
app = output / (stem.name + ".bin")
|
||||
merged = output / (stem.name + "-merged.bin")
|
||||
app.write_bytes(b"unchanged app"); merged.write_bytes(b"unchanged merged")
|
||||
sidecar = notice.collect_build_notice(build, stem)
|
||||
self.assertEqual(sidecar.read_bytes(), data)
|
||||
self.assertEqual(notice.collect_build_notice(build, stem), sidecar)
|
||||
report = dict(schema_version=1, passed=True, available_internal_bytes=80000,
|
||||
required_heap_bytes=50000, largest_internal_region_bytes=80000,
|
||||
required_contiguous_bytes=5120, target="build_env",
|
||||
elf_sha256=hashlib.sha256((build / "firmware.elf").read_bytes()).hexdigest())
|
||||
(build / "firmware.memory.json").write_text(json.dumps(report))
|
||||
memory.package_report(build, stem)
|
||||
proof = memory.validate_package(stem)
|
||||
self.assertEqual(proof["files"][sidecar.name], hashlib.sha256(data).hexdigest())
|
||||
records = release.collect_artifacts(output, version)
|
||||
self.assertEqual(sum(path == sidecar for path in records[0]["files"]), 1)
|
||||
self.assertEqual(app.read_bytes(), b"unchanged app")
|
||||
self.assertEqual(merged.read_bytes(), b"unchanged merged")
|
||||
sidecar.write_bytes(b"truncated")
|
||||
with self.assertRaisesRegex(ValueError, "mismatched"):
|
||||
memory.validate_package(stem)
|
||||
sidecar.unlink()
|
||||
with self.assertRaisesRegex(ValueError, "missing"):
|
||||
memory.validate_package(stem)
|
||||
with self.assertRaisesRegex(ValueError, "missing"):
|
||||
release.collect_artifacts(output, version)
|
||||
|
||||
def test_metadata_cannot_omit_or_replace_linked_notice(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
stem = root / "gui"
|
||||
elf32(root / "firmware.elf")
|
||||
manifest = self.qualify(root, stem)
|
||||
manifest_path = root / "gui.capabilities.json"
|
||||
manifest["third_party_notices"] = []
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
with self.assertRaisesRegex(ValueError, "linked ELF"):
|
||||
notice.collect_build_notice(root, stem)
|
||||
manifest["third_party_notices"] = [notice.notice_record(stem, b"wrong")]
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
with self.assertRaisesRegex(ValueError, "source notice"):
|
||||
notice.collect_build_notice(root, stem)
|
||||
self.assertFalse(notice.valid_notice(b"MIT License"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Qualify the real bounded world-map decoder, projection and generated pack."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("pack_world_map", ROOT / "tools/meshpocket_map/pack_world_map.py")
|
||||
packer = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(packer)
|
||||
|
||||
|
||||
def run(command):
|
||||
result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
if result.returncode:
|
||||
raise AssertionError("command failed: " + " ".join(map(str, command)) + "\n" + result.stdout)
|
||||
return result.stdout
|
||||
|
||||
|
||||
class PocketWorldMapTests(unittest.TestCase):
|
||||
def test_generated_pack_bounds_and_roundtrip(self):
|
||||
header = packer.OUTPUT.read_text(encoding="utf-8")
|
||||
chunks = [tuple(map(int, row)) for row in re.findall(
|
||||
r"\{(\d+), (\d+), (\d+), (-?\d+), (-?\d+), (-?\d+), (-?\d+)\}", header)]
|
||||
data_section = header.split("static const uint8_t kDeflate[] = {", 1)[1].split("};", 1)[0]
|
||||
packed = bytes(int(byte, 16) for byte in re.findall(r"0x([0-9a-f]{2})", data_section))
|
||||
self.assertEqual(len(chunks), 32)
|
||||
self.assertEqual(len(packed) + 24 * len(chunks), 80658)
|
||||
self.assertIn("kRingCount = 1422;", header)
|
||||
offset = 0
|
||||
for start, size, raw_size, west, south, east, north in chunks:
|
||||
self.assertEqual(start, offset)
|
||||
self.assertLessEqual(raw_size, 3072)
|
||||
self.assertLessEqual(west, east)
|
||||
self.assertLessEqual(south, north)
|
||||
stream = zlib.decompressobj(wbits=-15)
|
||||
raw = stream.decompress(packed[start:start + size])
|
||||
self.assertTrue(stream.eof)
|
||||
self.assertFalse(stream.unused_data)
|
||||
self.assertEqual(len(raw), raw_size)
|
||||
offset += size
|
||||
self.assertEqual(offset, len(packed))
|
||||
|
||||
def test_simplification_retains_tiny_islands_and_dateline(self):
|
||||
island = [(170.0, -20.0), (170.001, -20.0), (170.001, -20.001),
|
||||
(170.0, -20.001), (170.0, -20.0)]
|
||||
simplified = packer.simplify(island)
|
||||
self.assertGreaterEqual(len(set(simplified)), 3)
|
||||
self.assertEqual(simplified[0], simplified[-1])
|
||||
crossing = [(179.8, -20.0), (-179.8, -20.0), (-179.8, -20.4),
|
||||
(179.8, -20.4), (179.8, -20.0)]
|
||||
simplified = packer.simplify(crossing)
|
||||
self.assertLess(max(p[0] for p in simplified) - min(p[0] for p in simplified), 1000)
|
||||
self.assertEqual(simplified[0], simplified[-1])
|
||||
with self.assertRaisesRegex(ValueError, "SHA-256"):
|
||||
list(packer.source_rings(b"incorrect source archive"))
|
||||
|
||||
def test_real_renderer_and_parser_under_sanitizers(self):
|
||||
cc, cxx = shutil.which("gcc"), shutil.which("g++")
|
||||
if not cc or not cxx:
|
||||
self.skipTest("GCC/G++ unavailable")
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-pocket-map-") as temporary:
|
||||
directory = Path(temporary)
|
||||
# Windows MinGW commonly has no sanitizer runtime. Linux CI runs
|
||||
# the same real renderer with both bounds/undefined checks active.
|
||||
sanitizer = [] if __import__("os").name == "nt" else ["-fsanitize=address,undefined"]
|
||||
run([cc, "-std=c11", "-DMESHCORE_TINF_IMPLEMENTATION", "-g", "-O1", *sanitizer,
|
||||
"-c", str(ROOT / "src/helpers/ota/tinf/tinflate.c"), "-o", str(directory / "tinf.o")])
|
||||
executable = directory / "world_map"
|
||||
run([cxx, "-std=c++17", "-DPOCKET_WORLD_MAP_TEST", "-I" + str(ROOT / "src"),
|
||||
"-g", "-O1", *sanitizer, str(ROOT / "test/fixtures/pocket_world_map/test_world_map.cpp"),
|
||||
str(ROOT / "src/helpers/ui/PocketWorldMap.cpp"), str(directory / "tinf.o"), "-o", str(executable)])
|
||||
self.assertIn("checks passed", run([str(executable)]))
|
||||
|
||||
def test_arm_decode_stack_fits_companion_loop(self):
|
||||
tool = shutil.which("arm-none-eabi-g++")
|
||||
if not tool:
|
||||
candidate = Path.home() / ".platformio/packages/toolchain-gccarmnoneeabi/bin/arm-none-eabi-g++"
|
||||
if candidate.is_file():
|
||||
tool = str(candidate)
|
||||
if not tool:
|
||||
self.skipTest("ARM compiler unavailable")
|
||||
cc = str(Path(tool).with_name("arm-none-eabi-gcc"))
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-pocket-map-stack-") as temporary:
|
||||
directory = Path(temporary)
|
||||
flags = ["-mcpu=cortex-m4", "-mthumb", "-Oz", "-fno-inline-small-functions", "-fstack-usage"]
|
||||
run([tool, "-std=gnu++17", *flags, "-fno-exceptions", "-fno-rtti", "-DPOCKET_WORLD_MAP_TEST",
|
||||
"-I" + str(ROOT / "src"), "-c", str(ROOT / "src/helpers/ui/PocketWorldMap.cpp"),
|
||||
"-o", str(directory / "map.o")])
|
||||
run([cc, "-std=gnu11", *flags, "-DMESHCORE_TINF_IMPLEMENTATION", "-c",
|
||||
str(ROOT / "src/helpers/ota/tinf/tinflate.c"), "-o", str(directory / "tinf.o")])
|
||||
|
||||
def frames(name):
|
||||
rows = {}
|
||||
for line in (directory / (name + ".su")).read_text().splitlines():
|
||||
function, size, kind = line.rsplit("\t", 2)
|
||||
self.assertEqual(kind, "static", line)
|
||||
rows[function] = int(size)
|
||||
return rows
|
||||
|
||||
renderer, decoder = frames("map"), frames("tinf")
|
||||
entry = next(size for name, size in renderer.items() if "::renderWorldMap(" in name)
|
||||
# Decoder helper sum overbounds mutually exclusive branches.
|
||||
decode = sum(size for name, size in decoder.items()
|
||||
if not name.endswith(":tinf_init") and not name.endswith(":tinf_uncompress"))
|
||||
draw = sum(size for name, size in renderer.items()
|
||||
if any(signature in name for signature in ("::drawChunk(", "::project(", "::readDelta(")))
|
||||
peak = entry + max(decode, draw)
|
||||
self.assertLessEqual(peak + 1536, 8192)
|
||||
print(f"ARM Pocket map local stack: {peak} bytes + 1536 caller reserve <= 8192")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,100 @@
|
||||
MeshPocket GUI and font notices
|
||||
|
||||
The message-board layout in examples/companion_radio/ui-new/PocketGui.h was
|
||||
adapted from jackywashere's MeshCore Mesh Pocket MessageBoard:
|
||||
https://github.com/jackywashere/MeshCore-Mesh-Pocket-MessageBoard
|
||||
Source revision: 3fc4ce693
|
||||
The project's MIT notice is reproduced below. Our port shares the existing
|
||||
companion history, navigation, settings, notifications and worldwide map.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Scott Powell / rippleradios.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
The GUI uses U8g2 for Adafruit GFX, distributed under BSD-2-Clause, and its
|
||||
Adobe X11 Helvetica fonts (helvR08_tf, helvB08_tf, helvR10_tf, helvB10_tf).
|
||||
Source: https://github.com/olikraus/U8g2_for_Adafruit_GFX
|
||||
Upstream license: https://github.com/olikraus/U8g2_for_Adafruit_GFX/blob/master/LICENSE
|
||||
The upstream code and font notices follow. Include this file with MeshPocket
|
||||
Full Companion firmware distributions containing this GUI.
|
||||
|
||||
|
||||
============ BSD License for U8g2 for Adafruit GFX Code ============
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2018, olikraus
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
============ X11 Fonts COUR, HELV, NCEN, TIM, SYMB ============
|
||||
|
||||
For fonts derived from the following files, the license below applies.
|
||||
COURB08.BDF COURB10.BDF COURB12.BDF COURB14.BDF COURB18.BDF
|
||||
COURB24.BDF COURR08.BDF COURR10.BDF COURR12.BDF COURR14.BDF
|
||||
COURR18.BDF COURR24.BDF HELVB08.BDF HELVB10.BDF HELVB12.BDF HELVB14.BDF
|
||||
HELVB18.BDF HELVB24.BDF HELVR08.BDF HELVR10.BDF HELVR12.BDF HELVR14.BDF
|
||||
HELVR18.BDF HELVR24.BDF NCENB08.BDF NCENB10.BDF NCENB12.BDF
|
||||
NCENB14.BDF NCENB18.BDF NCENB24.BDF NCENR08.BDF NCENR10.BDF
|
||||
NCENR12.BDF NCENR14.BDF NCENR18.BDF NCENR24.BDF SYMB08.BDF SYMB10.BDF
|
||||
SYMB12.BDF SYMB14.BDF SYMB18.BDF SYMB24.BDF TIMB08.BDF TIMB10.BDF
|
||||
TIMB12.BDF TIMB14.BDF TIMB18.BDF TIMB24.BDF TIMR08.BDF TIMR10.BDF
|
||||
TIMR12.BDF TIMR14.BDF TIMR18.BDF TIMR24.BDF
|
||||
|
||||
Copyright 1984-1989, 1994 Adobe Systems Incorporated.
|
||||
Copyright 1988, 1994 Digital Equipment Corporation.
|
||||
|
||||
Adobe is a trademark of Adobe Systems Incorporated which may be
|
||||
registered in certain jurisdictions.
|
||||
Permission to use these trademarks is hereby granted only in
|
||||
association with the images described in this file.
|
||||
|
||||
Permission to use, copy, modify, distribute and sell this software
|
||||
and its documentation for any purpose and without fee is hereby
|
||||
granted, provided that the above copyright notices appear in all
|
||||
copies and that both those copyright notices and this permission
|
||||
notice appear in supporting documentation, and that the names of
|
||||
Adobe Systems and Digital Equipment Corporation not be used in
|
||||
advertising or publicity pertaining to distribution of the software
|
||||
without specific, written prior permission. Adobe Systems and
|
||||
Digital Equipment Corporation make no representations about the
|
||||
suitability of this software for any purpose. It is provided "as
|
||||
is" without express or implied warranty.
|
||||
@@ -0,0 +1,49 @@
|
||||
# MeshPocket worldwide outline
|
||||
|
||||
`PocketWorldMapData.generated.h` contains an outline of the world's land,
|
||||
including all 1,422 rings from Natural Earth's **1:50m land, version 4.1.0**.
|
||||
It has no roads, rivers, borders, labels or ocean raster. Small source islands
|
||||
are retained; this is not a navigational chart. Around 100–200 km of vertical
|
||||
map extent the coarse outline is useful. Closer zooms show node positions
|
||||
against the same coarse background, not more geographic detail.
|
||||
|
||||
Source: <https://www.naturalearthdata.com/downloads/50m-physical-vectors/50m-land/>
|
||||
Archive: <https://naturalearth.s3.amazonaws.com/50m_physical/ne_50m_land.zip>
|
||||
SHA-256: `0b8e670cf80dce9cbebe2a193bc44ba5602758c22e1fa603980553646d7ff162`
|
||||
|
||||
Natural Earth data is public domain:
|
||||
<https://www.naturalearthdata.com/about/terms-of-use/>.
|
||||
No OpenStreetMap map assets or region-specific assets are used.
|
||||
|
||||
Generate with Python and the pinned `requirements-build.txt` dependencies:
|
||||
|
||||
```
|
||||
python tools/meshpocket_map/pack_world_map.py --source-zip ne_50m_land.zip
|
||||
python tools/meshpocket_map/pack_world_map.py --source-zip ne_50m_land.zip --check
|
||||
```
|
||||
|
||||
Without `--source-zip`, the generator downloads that archive and verifies its
|
||||
digest before reading it. The checked-in generated header makes normal
|
||||
firmware builds independent of network access and GIS tools.
|
||||
|
||||
The deterministic generator uses kilometre-scaled Ramer–Douglas–Peucker
|
||||
simplification with a 4 km tolerance, then rounds coordinates to 0.001 degrees.
|
||||
Its local metric is an approximation, especially on large polar polygons;
|
||||
the 4 km setting is not a guaranteed geodesic error bound. Islands that would
|
||||
collapse are kept as at least three distinct vertices. Adjacent fragments
|
||||
share an endpoint, so splitting never removes a connecting edge. Spatially
|
||||
sorted fragments use signed delta varints and independent raw DEFLATE chunks,
|
||||
compressed on the host with Google Zopfli **0.4.3, 1,000 iterations**. There is
|
||||
no compression work on the device. Each indexed chunk has geographic bounds,
|
||||
so local views skip unrelated data.
|
||||
|
||||
The pack uses **79,890 compressed bytes + 768 bytes of index**. Rendering uses
|
||||
one temporary **3,072-byte** decode buffer and the existing bounded `tinf`
|
||||
decoder. No map heap allocation, persistent geometry cache, land fill edge
|
||||
table, or framebuffer is allocated. Lines are clipped to the caller's
|
||||
viewport using a local azimuthal equidistant projection, which wraps the
|
||||
antimeridian and supports views centred on either pole. Coastlines and node
|
||||
markers use the same projection. Its internal geometry uses the nRF52840's
|
||||
single-precision FPU. The 20,000 km setting switches to a full-world
|
||||
equirectangular overview, centred on the selected longitude and the equator.
|
||||
Seam crossings are split at the map edges, not joined across the middle.
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the coarse, worldwide Pocket coastline from pinned Natural Earth land."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from zopfli_compress import MAXIMUM_ITERATIONS, ZOPFLI_VERSION, raw_deflate_compress
|
||||
|
||||
URL = "https://naturalearth.s3.amazonaws.com/50m_physical/ne_50m_land.zip"
|
||||
SOURCE_SHA256 = "0b8e670cf80dce9cbebe2a193bc44ba5602758c22e1fa603980553646d7ff162"
|
||||
OUTPUT = ROOT / "src/helpers/ui/PocketWorldMapData.generated.h"
|
||||
SCALE = 1000
|
||||
TOLERANCE_KM = 4.0
|
||||
BLOCK_BYTES = 3072
|
||||
FRAGMENT_POINTS = 96
|
||||
|
||||
|
||||
def source_rings(archive: bytes):
|
||||
if hashlib.sha256(archive).hexdigest() != SOURCE_SHA256:
|
||||
raise ValueError("Natural Earth source SHA-256 mismatch")
|
||||
with zipfile.ZipFile(io.BytesIO(archive)) as source:
|
||||
if source.read("ne_50m_land.VERSION.txt").strip() != b"4.1.0":
|
||||
raise ValueError("unexpected Natural Earth release")
|
||||
data = source.read("ne_50m_land.shp")
|
||||
if len(data) < 100 or struct.unpack_from(">I", data)[0] != 9994:
|
||||
raise ValueError("invalid shapefile header")
|
||||
offset = 100
|
||||
while offset < len(data):
|
||||
if offset + 8 > len(data):
|
||||
raise ValueError("truncated shapefile record")
|
||||
length = struct.unpack_from(">I", data, offset + 4)[0] * 2
|
||||
offset += 8
|
||||
record = data[offset:offset + length]
|
||||
offset += length
|
||||
if len(record) != length or length < 44:
|
||||
raise ValueError("truncated polygon")
|
||||
kind, = struct.unpack_from("<I", record)
|
||||
if kind != 5:
|
||||
raise ValueError("expected 2D polygon")
|
||||
parts, points = struct.unpack_from("<II", record, 36)
|
||||
if not parts or not points or 44 + parts * 4 + points * 16 != length:
|
||||
raise ValueError("invalid polygon dimensions")
|
||||
starts = struct.unpack_from(f"<{parts}I", record, 44) + (points,)
|
||||
coords = [struct.unpack_from("<dd", record, 44 + parts * 4 + i * 16)
|
||||
for i in range(points)]
|
||||
for start, stop in zip(starts, starts[1:]):
|
||||
ring = coords[start:stop]
|
||||
if len(ring) < 4 or ring[0] != ring[-1]:
|
||||
raise ValueError("invalid closed land ring")
|
||||
yield ring
|
||||
|
||||
|
||||
def rdp(points, tolerance):
|
||||
"""Iterative RDP in kilometres; no dependency on GIS library versions."""
|
||||
kept = {0, len(points) - 1}
|
||||
stack = [(0, len(points) - 1)]
|
||||
while stack:
|
||||
first, last = stack.pop()
|
||||
ax, ay = points[first]
|
||||
bx, by = points[last]
|
||||
dx, dy = bx - ax, by - ay
|
||||
length2 = dx * dx + dy * dy
|
||||
furthest, distance2 = first, tolerance * tolerance
|
||||
for i in range(first + 1, last):
|
||||
px, py = points[i]
|
||||
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / length2)) if length2 else 0
|
||||
candidate = (px - ax - t * dx) ** 2 + (py - ay - t * dy) ** 2
|
||||
if candidate > distance2:
|
||||
furthest, distance2 = i, candidate
|
||||
if furthest != first:
|
||||
kept.add(furthest)
|
||||
stack.extend(((first, furthest), (furthest, last)))
|
||||
return sorted(kept)
|
||||
|
||||
|
||||
def simplify(ring):
|
||||
# Unwrap before simplification so a dateline crossing is a short edge.
|
||||
unwrapped = [ring[0]]
|
||||
for longitude, latitude in ring[1:]:
|
||||
previous = unwrapped[-1][0]
|
||||
longitude += 360 * math.floor((previous - longitude + 180) / 360)
|
||||
unwrapped.append((longitude, latitude))
|
||||
mean_latitude = sum(p[1] for p in unwrapped) / len(unwrapped)
|
||||
cosine = max(0.01, math.cos(math.radians(mean_latitude)))
|
||||
metric = [(x * 111.195 * cosine, y * 111.195) for x, y in unwrapped]
|
||||
selected = rdp(metric, TOLERANCE_KM)
|
||||
# Keep small islands as a triangle rather than dropping the entire ring.
|
||||
if len(selected) < 4:
|
||||
ax, ay = metric[0]
|
||||
opposite = max(range(1, len(metric) - 1),
|
||||
key=lambda i: (metric[i][0] - ax) ** 2 + (metric[i][1] - ay) ** 2)
|
||||
bx, by = metric[opposite]
|
||||
third = max(range(1, len(metric) - 1),
|
||||
key=lambda i: abs((bx - ax) * (metric[i][1] - ay) - (by - ay) * (metric[i][0] - ax)))
|
||||
selected = sorted({0, opposite, third, len(metric) - 1})
|
||||
quantized = []
|
||||
for i in selected:
|
||||
x, y = unwrapped[i]
|
||||
point = (round(x * SCALE), round(y * SCALE))
|
||||
if not quantized or point != quantized[-1]:
|
||||
quantized.append(point)
|
||||
if len(set(quantized)) < 3 or quantized[0] != quantized[-1]:
|
||||
raise ValueError("quantization collapsed a land ring")
|
||||
return quantized
|
||||
|
||||
|
||||
def varint(value):
|
||||
value = (value << 1) if value >= 0 else ((-value << 1) - 1)
|
||||
result = bytearray()
|
||||
while value >= 128:
|
||||
result.append((value & 127) | 128)
|
||||
value >>= 7
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
def fragment_bytes(fragment):
|
||||
result = bytearray(struct.pack("<Hii", len(fragment), *fragment[0]))
|
||||
for (ax, ay), (bx, by) in zip(fragment, fragment[1:]):
|
||||
result.extend(varint(bx - ax))
|
||||
result.extend(varint(by - ay))
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def pack(archive, compressor=raw_deflate_compress):
|
||||
rings = [simplify(ring) for ring in source_rings(archive)]
|
||||
fragments = []
|
||||
for ring in rings:
|
||||
for start in range(0, len(ring) - 1, FRAGMENT_POINTS - 1):
|
||||
points = ring[start:start + FRAGMENT_POINTS]
|
||||
xs, ys = zip(*points)
|
||||
bounds = (min(xs), min(ys), max(xs), max(ys))
|
||||
longitude = ((bounds[0] + bounds[2]) // 2 + 180000) % 360000 - 180000
|
||||
latitude = (bounds[1] + bounds[3]) // 2
|
||||
fragments.append(((longitude // 10000, latitude // 10000), bounds, fragment_bytes(points)))
|
||||
fragments.sort(key=lambda entry: (entry[0], entry[1], entry[2]))
|
||||
raw_blocks, bounds, current = [], [], bytearray()
|
||||
current_bounds = None
|
||||
for _, box, encoded in fragments:
|
||||
if len(current) + len(encoded) > BLOCK_BYTES:
|
||||
raw_blocks.append(bytes(current))
|
||||
bounds.append(current_bounds)
|
||||
current, current_bounds = bytearray(), None
|
||||
current.extend(encoded)
|
||||
current_bounds = box if current_bounds is None else (
|
||||
min(current_bounds[0], box[0]), min(current_bounds[1], box[1]),
|
||||
max(current_bounds[2], box[2]), max(current_bounds[3], box[3]))
|
||||
if current:
|
||||
raw_blocks.append(bytes(current))
|
||||
bounds.append(current_bounds)
|
||||
packed, descriptors = bytearray(), []
|
||||
for raw, box in zip(raw_blocks, bounds):
|
||||
compressed = compressor(raw, numiterations=MAXIMUM_ITERATIONS)
|
||||
if zlib.decompress(compressed, wbits=-15) != raw:
|
||||
raise ValueError("DEFLATE round-trip failed")
|
||||
descriptors.append((len(packed), len(compressed), len(raw), *box))
|
||||
packed.extend(compressed)
|
||||
return bytes(packed), descriptors, {"rings": len(rings), "points": sum(len(ring) for ring in rings),
|
||||
"raw_bytes": sum(map(len, raw_blocks)), "chunks": len(raw_blocks)}
|
||||
|
||||
|
||||
def render(archive):
|
||||
packed, blocks, stats = pack(archive)
|
||||
# uint32_t offset, uint16_t packed/raw lengths, four int32_t bounds.
|
||||
stats.update(packed_bytes=len(packed), index_bytes=len(blocks) * 24,
|
||||
firmware_data_bytes=len(packed) + len(blocks) * 24)
|
||||
lines = ["// Generated by tools/meshpocket_map/pack_world_map.py; do not edit.",
|
||||
"// Natural Earth 1:50m land 4.1.0, public domain; see tools/meshpocket_map/README.md.",
|
||||
f"// Source SHA-256: {SOURCE_SHA256}",
|
||||
f"// Zopfli {ZOPFLI_VERSION}, {MAXIMUM_ITERATIONS} iterations; 4 km RDP, 0.001 degree coordinates.",
|
||||
"#pragma once", "#include <stdint.h>", "namespace mesh { namespace ui { namespace pocket { namespace data {",
|
||||
"struct Chunk { uint32_t offset; uint16_t packed, raw; int32_t west, south, east, north; };",
|
||||
f"static constexpr unsigned kScratchBytes = {BLOCK_BYTES};",
|
||||
f"static constexpr unsigned kRingCount = {stats['rings']};",
|
||||
"static const Chunk kChunks[] = {"]
|
||||
lines.extend(" {%d, %d, %d, %d, %d, %d, %d}," % block for block in blocks)
|
||||
lines.extend(("};", "static const uint8_t kDeflate[] = {"))
|
||||
for offset in range(0, len(packed), 24):
|
||||
lines.append(" " + ",".join(f"0x{byte:02x}" for byte in packed[offset:offset + 24]) + ",")
|
||||
lines.extend(("};", "}}}}", ""))
|
||||
return "\n".join(lines), stats
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source-zip", type=Path, help="already downloaded pinned Natural Earth archive")
|
||||
parser.add_argument("--output", type=Path, default=OUTPUT)
|
||||
parser.add_argument("--check", action="store_true")
|
||||
args = parser.parse_args()
|
||||
archive = args.source_zip.read_bytes() if args.source_zip else urllib.request.urlopen(URL, timeout=60).read()
|
||||
header, stats = render(archive)
|
||||
if args.check:
|
||||
if args.output.read_text(encoding="utf-8") != header:
|
||||
raise SystemExit("generated worldwide map is stale")
|
||||
else:
|
||||
args.output.write_text(header, encoding="utf-8", newline="\n")
|
||||
print(json.dumps(stats, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -20,6 +20,7 @@ build_src_filter = ${nrf52_base.build_src_filter}
|
||||
+<helpers/*.cpp>
|
||||
+<../variants/mesh_pocket>
|
||||
+<helpers/ui/GxEPDDisplay.cpp>
|
||||
+<helpers/ui/PocketWorldMap.cpp>
|
||||
lib_deps =
|
||||
${nrf52_base.lib_deps}
|
||||
adafruit/Adafruit EPD @ 4.6.1
|
||||
@@ -86,6 +87,7 @@ build_src_filter = ${Mesh_pocket.build_src_filter}
|
||||
lib_deps =
|
||||
${Mesh_pocket.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
olikraus/U8g2_for_Adafruit_GFX @ 1.8.0
|
||||
|
||||
[env:Mesh_pocket_companion_radio_usb]
|
||||
extends = Mesh_pocket
|
||||
@@ -109,6 +111,7 @@ build_src_filter = ${Mesh_pocket.build_src_filter}
|
||||
lib_deps =
|
||||
${Mesh_pocket.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
olikraus/U8g2_for_Adafruit_GFX @ 1.8.0
|
||||
|
||||
[env:Mesh_pocket_kiss_modem]
|
||||
extends = Mesh_pocket
|
||||
|
||||
Reference in new issue
Block a user