Add MeshPocket message board and worldwide map

This commit is contained in:
mikecarper committed 2026-10-21 23:57:14 -07:00
1 parent 7d0709bf03
commit 5d31a839c5
41 files changed
+6852 -7

No files matched your search

+2
View File
@@ -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
+3
View File
@@ -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 $?
}
+44
View File
@@ -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) {
+21
View File
@@ -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);
}
+8
View File
@@ -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);
+9 -1
View File
@@ -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
+4
View File
@@ -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);
+385
View File
@@ -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
+442 -2
View File
@@ -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);
+16
View File
@@ -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; }
+9
View File
@@ -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",
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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):
+4
View File
@@ -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"):
+4
View File
@@ -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)
+141
View File
@@ -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()
+4 -2
View File
@@ -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);
+19
View File
@@ -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;
}
+27
View File
@@ -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; }
+22
View File
@@ -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
+213
View File
@@ -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
+227
View File
@@ -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
+31
View File
@@ -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
+40
View File
@@ -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});
}
};
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
+19
View File
@@ -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()
+112
View File
@@ -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()
+171
View File
@@ -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()
+119
View File
@@ -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()
+100
View File
@@ -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.
+49
View File
@@ -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.
+213
View File
@@ -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()
+3
View File
@@ -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