Add a V4 Picopixel message font trial with longer previews
This commit is contained in:
1 parent
d93bbe8d69
commit
9f3b48725d
15 files changed
+334
No files matched your search
@@ -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`:
|
||||
|
||||
@@ -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.
|
||||
@@ -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 =
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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; }
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
class TwoWire { public: void beginTransmission(int) {} int endTransmission() { return 0; } };
|
||||
inline TwoWire Wire;
|
||||
+70
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user