diff --git a/docs/nimble_companion_trial.md b/docs/nimble_companion_trial.md index e9be4a6c..30986855 100644 --- a/docs/nimble_companion_trial.md +++ b/docs/nimble_companion_trial.md @@ -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`: diff --git a/docs/v4_pixel5_font_trial.md b/docs/v4_pixel5_font_trial.md new file mode 100644 index 00000000..7c9f89e9 --- /dev/null +++ b/docs/v4_pixel5_font_trial.md @@ -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. diff --git a/platformio.nimble.ini b/platformio.nimble.ini index ad922702..70bb17b7 100644 --- a/platformio.nimble.ini +++ b/platformio.nimble.ini @@ -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 = diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index 7ce941ec..7d2dd2b3 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -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} diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index 069faf00..51ea567a 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -1,5 +1,16 @@ #include "SSD1306Display.h" +#if defined(UI_SSD1306_PICOPIXEL_MESSAGES) && UI_SSD1306_PICOPIXEL_MESSAGES == 1 +#include +#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); diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index d753d9d7..a732e477 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.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; diff --git a/test/fixtures/ssd1306_picopixel/mocks/Adafruit_I2CDevice.h b/test/fixtures/ssd1306_picopixel/mocks/Adafruit_I2CDevice.h new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/test/fixtures/ssd1306_picopixel/mocks/Adafruit_I2CDevice.h @@ -0,0 +1 @@ +#pragma once diff --git a/test/fixtures/ssd1306_picopixel/mocks/Adafruit_SPIDevice.h b/test/fixtures/ssd1306_picopixel/mocks/Adafruit_SPIDevice.h new file mode 100644 index 00000000..6f70f09b --- /dev/null +++ b/test/fixtures/ssd1306_picopixel/mocks/Adafruit_SPIDevice.h @@ -0,0 +1 @@ +#pragma once diff --git a/test/fixtures/ssd1306_picopixel/mocks/Adafruit_SSD1306.h b/test/fixtures/ssd1306_picopixel/mocks/Adafruit_SSD1306.h new file mode 100644 index 00000000..c190098e --- /dev/null +++ b/test/fixtures/ssd1306_picopixel/mocks/Adafruit_SSD1306.h @@ -0,0 +1,32 @@ +#pragma once +#include +#include +#include +#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 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); + } +}; diff --git a/test/fixtures/ssd1306_picopixel/mocks/Arduino.h b/test/fixtures/ssd1306_picopixel/mocks/Arduino.h new file mode 100644 index 00000000..32d0fb3f --- /dev/null +++ b/test/fixtures/ssd1306_picopixel/mocks/Arduino.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#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; } diff --git a/test/fixtures/ssd1306_picopixel/mocks/Print.h b/test/fixtures/ssd1306_picopixel/mocks/Print.h new file mode 100644 index 00000000..7be631dd --- /dev/null +++ b/test/fixtures/ssd1306_picopixel/mocks/Print.h @@ -0,0 +1,9 @@ +#pragma once +#include +#include +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; } +}; diff --git a/test/fixtures/ssd1306_picopixel/mocks/Wire.h b/test/fixtures/ssd1306_picopixel/mocks/Wire.h new file mode 100644 index 00000000..8986985e --- /dev/null +++ b/test/fixtures/ssd1306_picopixel/mocks/Wire.h @@ -0,0 +1,4 @@ +#pragma once +#include +class TwoWire { public: void beginTransmission(int) {} int endTransmission() { return 0; } }; +inline TwoWire Wire; diff --git a/test/fixtures/ssd1306_picopixel/render.cpp b/test/fixtures/ssd1306_picopixel/render.cpp new file mode 100644 index 00000000..2ac6e7f6 --- /dev/null +++ b/test/fixtures/ssd1306_picopixel/render.cpp @@ -0,0 +1,70 @@ +#include +#include +#include +#include +#include +#include +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 +} diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 9547964c..7852e446 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -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" diff --git a/test/test_ssd1306_picopixel.py b/test/test_ssd1306_picopixel.py new file mode 100644 index 00000000..b20f583f --- /dev/null +++ b/test/test_ssd1306_picopixel.py @@ -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()