Add a V4 Picopixel message font trial with longer previews

This commit is contained in:
mikecarper committed 2026-09-08 13:22:53 -07:00
1 parent d93bbe8d69
commit 9f3b48725d
15 files changed
+334

No files matched your search

+3
View File
@@ -22,6 +22,9 @@ It is not the generic XIAO profile for separately wired radio modules. Both
boards retain their parent Full Companion features, USB mOTA sender, WiFi
update slots, and PSRAM-backed offline queue. The V4 also retains direct MQTT.
An additional [V4 Picopixel trial](v4_pixel5_font_trial.md) uses 5-pixel capital
letters and a longer on-device message preview.
## Build
Enable the optional configuration in the ignored `platformio.local.ini`:
+54
View File
@@ -0,0 +1,54 @@
# V4 Picopixel message font trial
This optional V4.2/V4.3 OLED Full Companion build uses Adafruit's Picopixel
font for received message text. Capital letters are 5 pixels high; descenders
can occupy a sixth pixel, so lines advance by 7 pixels. Most characters
advance by 4 pixels horizontally, compared with the normal font's 6 pixels.
The normal font has 7-pixel letters and an 8-pixel line height.
The title, channel/sender, menus, Bluetooth PIN and WiFi QR page retain their
normal font. The message preview buffer increases from 78 bytes (77 text
bytes plus a terminator) to 161 bytes, enough for a complete 160-byte MeshCore
message. The screen retains 32 previews; this is separate from the V4's
512-frame offline queue and its mOTA policy.
Five complete Picopixel message lines fit below the title and sender on the
128 x 64 OLED. A typical message can fit in full; wide letters can still
exceed the available space. The last line then ends with `...`. Text wraps
at character boundaries. Unsupported characters appear as `?`.
## Build and install
Enable `platformio.nimble.ini` in the ignored `platformio.local.ini` as shown
in the [NimBLE trial guide](nimble_companion_trial.md#build), then run:
```sh
OUTPUT_DIR=.releases/v4-pixel5 bash build.sh build-firmware \
heltec_v4_2_v4_3_companion_radio_full_femon_nimble_pixel5 \
--firmware-version v1.17.1.5-halo-keymind-cascade-pixel5-trial \
--radio-preset usa-cascadia --profile cascade --standard --require-ota
```
For a V4 already using the matching 16 MB Full Companion partition layout,
flash the application `.bin` at `0x10000`, or use it for WiFi OTA. A clean
USB install uses the merged image at address 0. The trial preserves NimBLE,
350 contacts, 40 channels, USB mOTA sending and WiFi OTA support.
To restore the normal message font, install
`heltec_v4_2_v4_3_companion_radio_full_femon_nimble`. Both builds use the same
partition layout; erasing settings is unnecessary.
## Verification
```sh
python3 -B test/test_ssd1306_picopixel.py
pio test -e native -f test_display_driver -f test_companion_message_history
```
The font test uses the real SSD1306 driver, Adafruit GFX rendering and
Picopixel bitmaps with a host panel double. It checks a full 160-character
sample, all printable ASCII characters, descenders, rotations, bottom-edge
clipping, overflow markers, unsupported-character placeholders and restoring
the normal font. It runs with address/undefined-behavior sanitizers. It needs
the Adafruit GFX dependency from an existing PlatformIO build; without that
dependency it reports a skip. Run only one PlatformIO command at a time.
+7
View File
@@ -19,6 +19,13 @@ lib_deps =
${companion_nimble_trial.lib_deps}
lib_ignore = ${companion_nimble_trial.lib_ignore}
[env:heltec_v4_2_v4_3_companion_radio_full_femon_nimble_pixel5]
extends = env:heltec_v4_2_v4_3_companion_radio_full_femon_nimble
build_flags =
${env:heltec_v4_2_v4_3_companion_radio_full_femon_nimble.build_flags}
-D UI_SSD1306_PICOPIXEL_MESSAGES=1
-D UI_MSG_PREVIEW_SIZE=161
[env:Xiao_S3_WIO_companion_radio_full_nimble]
extends = env:Xiao_S3_WIO_companion_radio_full
build_flags =
+9
View File
@@ -84,6 +84,13 @@ def requirements(platform, defines, target):
if display and display != "NullDisplayDriver":
parts["display_pixels_and_driver"] = display_heap
parts["screen_objects_and_history"] = 8192 if companion else 2048
if companion:
# ui-new retains 32 previews in one heap allocation. The baseline
# covers 78 bytes per message; budget larger buffers explicitly,
# including worst-case 8-byte Entry alignment.
extra = max(0, integer(defines, "UI_MSG_PREVIEW_SIZE", 78) - 78)
if extra:
parts["expanded_message_previews"] = 32 * ((extra + 7) // 8) * 8
parts["allocation_and_transient_margin"] = 16384 if platform == "ESP32_PLATFORM" else 4096
required = sum(parts.values())
if platform == "NRF52_PLATFORM" and full and display == "ST7735Display":
@@ -91,6 +98,8 @@ def requirements(platform, defines, target):
# A new profile may increase this budget; it cannot override it downward.
required = max(required, integer(defines, "MESH_MIN_RUNTIME_HEAP", 0))
largest = max(display_heap, parts["radio_packet_pool"], 8192 if platform == "ESP32_PLATFORM" else 0)
if "expanded_message_previews" in parts:
largest = max(largest, 8192 + parts["expanded_message_previews"])
return {"required_heap_bytes": required, "required_contiguous_bytes": largest,
"components": parts, "display": display, "full_companion": full}
+52
View File
@@ -1,5 +1,16 @@
#include "SSD1306Display.h"
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
#include <Fonts/Picopixel.h>
#include "DisplayTextLayout.h"
// The message UI has already replaced unsupported UTF-8 with CP437 blocks.
// Picopixel is ASCII-only; retain a visible placeholder for those characters.
static uint8_t picopixelCharacter(uint8_t c) {
return c >= 0x20 && c <= 0x7E ? c : '?';
}
#endif
bool SSD1306Display::i2c_probe(TwoWire& wire, uint8_t addr) {
wire.beginTransmission(addr);
uint8_t error = wire.endTransmission();
@@ -112,13 +123,43 @@ void SSD1306Display::setColor(ColorVal c) {
}
void SSD1306Display::setCursor(int x, int y) {
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
// GFX custom fonts take a baseline; the shared UI takes a top coordinate.
if (_picopixel_message) y += 4;
#endif
display.setCursor(x, y);
}
void SSD1306Display::print(const char* str) {
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
if (_picopixel_message) {
while (*str) display.write(picopixelCharacter((uint8_t)*str++));
return;
}
#endif
display.print(str);
}
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
void SSD1306Display::printWordWrap(const char* str, int max_width) {
const int x = display.getCursorX();
const int y = display.getCursorY();
if (max_width > width() - x) max_width = width() - x;
// Capitals are 5px high; descenders can use a sixth pixel. Leave one blank
// row between lines and never start a partially visible final line.
const int max_lines = (height() - y + 1) / 7;
display.setFont(&Picopixel);
display.setTextSize(1);
display.setTextWrap(false);
_picopixel_message = true;
mesh::ui::drawTextWrapped(*this, x, y, max_width, 7, max_lines, str);
_picopixel_message = false;
display.setFont(nullptr);
display.setTextWrap(true);
display.setCursor(x, y);
}
#endif
void SSD1306Display::fillRect(int x, int y, int w, int h) {
display.fillRect(x, y, w, h, _color);
}
@@ -132,6 +173,17 @@ void SSD1306Display::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
}
uint16_t SSD1306Display::getTextWidth(const char* str) {
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
if (_picopixel_message) {
// Include advances for spaces; GFX's ink bounds omit trailing whitespace.
uint16_t width = 0;
while (*str) {
const uint8_t c = picopixelCharacter((uint8_t)*str++);
width += pgm_read_byte(&PicopixelGlyphs[c - 0x20].xAdvance);
}
return width;
}
#endif
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h);
+6
View File
@@ -27,6 +27,9 @@ class SSD1306Display : public DisplayDriver {
#endif
uint8_t _color;
RefCountedDigitalPin* _peripher_power;
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
bool _picopixel_message = false;
#endif
bool i2c_probe(TwoWire& wire, uint8_t addr);
void applyRotation();
@@ -51,6 +54,9 @@ public:
void setColor(ColorVal c) override;
void setCursor(int x, int y) override;
void print(const char* str) override;
#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1
void printWordWrap(const char* str, int max_width) override;
#endif
void fillRect(int x, int y, int w, int h) override;
void drawRect(int x, int y, int w, int h) override;
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
@@ -0,0 +1 @@
#pragma once
@@ -0,0 +1 @@
#pragma once
@@ -0,0 +1,32 @@
#pragma once
#include <Adafruit_GFX.h>
#include <Wire.h>
#include <vector>
#define SSD1306_BLACK 0
#define SSD1306_WHITE 1
#define SSD1306_SWITCHCAPVCC 2
#define SSD1306_DISPLAYON 0xAF
#define SSD1306_DISPLAYOFF 0xAE
class Adafruit_SSD1306 : public GFXcanvas1 {
public:
struct Glyph { char c; int x, y, w, h; };
std::vector<Glyph> glyphs;
inline static Adafruit_SSD1306* last;
int outside = 0;
Adafruit_SSD1306(int w, int h, TwoWire*, int) : GFXcanvas1(w, h) { last=this; }
bool begin(int, int, bool, bool) { return true; }
void clearDisplay() { fillScreen(0); glyphs.clear(); outside=0; }
void display() {}
void ssd1306_command(int) {}
void drawPixel(int16_t x, int16_t y, uint16_t c) override {
if (x<0 || y<0 || x>=width() || y>=height()) ++outside;
GFXcanvas1::drawPixel(x,y,c);
}
size_t write(uint8_t c) override {
if (gfxFont && c>=gfxFont->first && c<=gfxFont->last) {
const auto& g=gfxFont->glyph[c-gfxFont->first];
glyphs.push_back({char(c), cursor_x+g.xOffset, cursor_y+g.yOffset, g.width, g.height});
}
return Adafruit_GFX::write(c);
}
};
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <string>
#define PROGMEM
#define pgm_read_byte(addr) (*(const uint8_t *)(addr))
#define HIGH 1
#define LOW 0
#define OUTPUT 1
using String = std::string;
class __FlashStringHelper;
inline void pinMode(int, int) {}
inline void digitalWrite(int, int) {}
using std::min;
using std::max;
inline float radians(float deg) { return deg * 0.017453292519943295f; }
+9
View File
@@ -0,0 +1,9 @@
#pragma once
#include <cstddef>
#include <cstdint>
class Print {
public:
virtual ~Print() = default;
virtual size_t write(uint8_t) = 0;
size_t print(const char* s) { size_t n=0; while (*s) n += write(*s++); return n; }
};
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#include <Arduino.h>
class TwoWire { public: void beginTransmission(int) {} int endTransmission() { return 0; } };
inline TwoWire Wire;
+70
View File
@@ -0,0 +1,70 @@
#include <helpers/ui/SSD1306Display.h>
#include <cassert>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
void save(const char* file) {
auto& c=*Adafruit_SSD1306::last;
std::ofstream f(file);
f << "P1\n128 64\n";
for (int y=0;y<64;y++) { for (int x=0;x<128;x++) f << c.getPixel(x,y) << ' '; f << '\n'; }
}
int main(int argc, char** argv) {
SSD1306Display d;
assert(d.begin());
auto& c=*Adafruit_SSD1306::last;
const char* message="GIANT KILLER: Out on the Mercerwood mesh today, just the V4 and a small battery Checking the smaller font so the rest of this message is visible. 0123456789 END";
d.startFrame();
d.setCursor(0,0); d.print("Message 3/29");
d.setCursor(86,0); d.print("1m ago");
d.drawRect(0,11,128,1);
d.setCursor(0,14); d.print("Ch 0 Public [4h]:");
d.setCursor(0,25);
#if UI_SSD1306_PICOPIXEL_MESSAGES == 1
d.printWordWrap(message,128);
#else
d.print(std::string(message,77).c_str());
#endif
if (argc > 1) save(argv[1]);
#if UI_SSD1306_PICOPIXEL_MESSAGES == 1
std::string shown;
for (const auto& g:c.glyphs) {
shown += g.c;
if (g.w && g.h) assert(g.x>=0 && g.x+g.w<=128 && g.y>=25 && g.y+g.h<=64);
}
assert(shown==message);
assert(c.outside==0);
assert(d.getTextWidth("ABC")==18); // regular font restored
d.setCursor(0,0); d.print("Regular font");
assert(d.getTextWidth("ABC")==18);
int checks=1;
for (int rotation:{0,90,180,270}) for (int top:{0,25,55,58,59,63,64}) {
d.setRotationDegrees(rotation);
d.startFrame(); d.setCursor(0,top);
d.printWordWrap(std::string(160,'W').c_str(),d.width());
for (const auto& g:c.glyphs) {
if (g.w && g.h) assert(g.x>=0 && g.x+g.w<=d.width() && g.y>=top && g.y+g.h<=d.height());
}
if (!c.glyphs.empty()) {
std::string row;
for (const auto& g:c.glyphs) row += g.c;
while (!row.empty() && row.back()==' ') row.pop_back();
assert(row==std::string(160, 'W') || (row.size()>=3 && row.substr(row.size()-3)=="..."));
}
assert(c.outside==0);
++checks;
}
d.setRotationDegrees(0);
for (int ch=32;ch<=126;ch++) {
d.startFrame(); d.setCursor(0,25);
d.printWordWrap(std::string(160,char(ch)).c_str(),128);
assert(c.outside==0);
++checks;
}
d.startFrame(); d.setCursor(0,25);
d.printWordWrap("A\xDB" "B",128);
assert(c.glyphs.size()==3 && c.glyphs[1].c=='?');
std::cout << "Checks: " << checks+1 << "; sample characters: " << shown.size() << "; sample: " << shown << '\n';
#endif
}
+15
View File
@@ -66,6 +66,21 @@ def esp_fixture(path, modern=False, fragmented=False):
class FirmwareRamTest(unittest.TestCase):
def test_longer_display_previews_reserve_heap_and_contiguous_history(self):
defines = {"DISPLAY_CLASS": "SSD1306Display"}
base = ram.requirements("ESP32_PLATFORM", defines, "v4_companion")
expanded = ram.requirements("ESP32_PLATFORM", {
**defines, "UI_MSG_PREVIEW_SIZE": 161,
}, "v4_companion")
self.assertEqual(expanded["required_heap_bytes"],
base["required_heap_bytes"] + 2816)
self.assertGreaterEqual(expanded["required_contiguous_bytes"], 11008)
# Shrinking the preview cannot reduce the existing safety budget.
smaller = ram.requirements("ESP32_PLATFORM", {
**defines, "UI_MSG_PREVIEW_SIZE": 32,
}, "v4_companion")
self.assertEqual(smaller, base)
def test_published_image_tables_match_elf_and_use_its_own_reservations(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "reference.elf"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Render the real SSD1306 driver with Adafruit GFX and a host panel double.
Build a V4 environment first to install Adafruit GFX, or point
MESHCORE_GFX_LIBRARY to that library's directory. Only the hardware panel,
Arduino strings and Print glue are replaced; font metrics and drawing come
from the library used by the firmware.
"""
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/ssd1306_picopixel"
class SSD1306PicopixelTest(unittest.TestCase):
def test_actual_font_bounds_wrapping_and_default_font_restoration(self):
configured = os.environ.get("MESHCORE_GFX_LIBRARY")
candidates = [Path(configured)] if configured else sorted(
(ROOT / ".pio/libdeps").glob("*/Adafruit GFX Library")
)
library = next((path for path in candidates
if (path / "Adafruit_GFX.cpp").is_file()), None)
if library is None:
self.skipTest("Build a V4 environment or set MESHCORE_GFX_LIBRARY")
with tempfile.TemporaryDirectory(prefix="meshcore-picopixel-") as temp:
for enabled in (0, 1):
with self.subTest(picopixel=enabled):
binary = Path(temp) / f"render-{enabled}"
result = subprocess.run([
"c++", "-std=c++17", "-g",
"-fsanitize=address,undefined", "-DARDUINO=10819",
f"-DUI_SSD1306_PICOPIXEL_MESSAGES={enabled}",
"-I", str(FIXTURE / "mocks"),
"-I", str(ROOT / "src"), "-I", str(library),
str(ROOT / "src/helpers/ui/SSD1306Display.cpp"),
str(library / "Adafruit_GFX.cpp"),
str(FIXTURE / "render.cpp"), "-o", str(binary),
], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run(
[str(binary)], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()