mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-13 23:35:44 +00:00
Default to retained history with a separate pending count, and add persistent display.inbox CLI modes for history, pending, and unread. Preserve browsing while clients are connected and show the active USB or Bluetooth connection. Fixes #5
228 lines
9.1 KiB
Python
228 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import re
|
|
import struct
|
|
from pathlib import Path
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
PROFILE = ROOT / "variants" / "sensecap_indicator-espnow" / "platformio.ini"
|
|
DISPLAY = ROOT / "src" / "helpers" / "ui" / "LGFXDisplay.cpp"
|
|
INDICATOR_DISPLAY = (
|
|
ROOT / "variants" / "sensecap_indicator-espnow" / "SCIndicatorDisplay.h"
|
|
)
|
|
FONT = ROOT / "variants" / "sensecap_indicator-espnow" / "sd" / "ui-font.vlw"
|
|
|
|
|
|
def base_profile() -> str:
|
|
text = PROFILE.read_text()
|
|
match = re.search(
|
|
r"^\[SenseCapIndicator-ESPNow\]\n(?P<body>.*?)(?=^\[)",
|
|
text,
|
|
re.MULTILINE | re.DOTALL,
|
|
)
|
|
if match is None:
|
|
raise AssertionError("SenseCap Indicator base profile is missing")
|
|
return match.group("body")
|
|
|
|
|
|
def numeric_flag(name: str) -> float:
|
|
match = re.search(
|
|
rf"^\s*-D\s+{re.escape(name)}=(\S+)\s*$",
|
|
base_profile(),
|
|
re.MULTILINE,
|
|
)
|
|
if match is None:
|
|
raise AssertionError(f"{name} is missing from the Indicator profile")
|
|
return float(match.group(1).removesuffix("f"))
|
|
|
|
|
|
class IndicatorDisplayProfileTest(unittest.TestCase):
|
|
def test_panel_cs_release_cannot_discard_initialized_display(self):
|
|
display = INDICATOR_DISPLAY.read_text()
|
|
self.assertIn("releasePanelChipSelectWithRetry()", display)
|
|
self.assertIn("if (!initialized) {", display)
|
|
self.assertIn("begin_status = 0x82;", display)
|
|
self.assertNotIn("if (!initialized || !released) return false;", display)
|
|
self.assertIn("panel_chip_select_released =", display)
|
|
self.assertIn("void startFrame(ColorVal bkg", display)
|
|
self.assertIn("const bool controllers_prepared = prepareControllers()", display)
|
|
self.assertIn("begin_status = controllers_prepared ? 4 : 0x84", display)
|
|
self.assertIn("if (expander_address == 0 && prepareControllers())", display)
|
|
self.assertIn("uint8_t beginStatus() const", display)
|
|
|
|
def test_preserves_original_indicator_memory_profile(self):
|
|
profile = base_profile()
|
|
self.assertIn("board_build.arduino.memory_type = qio_opi", profile)
|
|
self.assertIn("board_build.psram_type = opi", profile)
|
|
self.assertIn("board_upload.flash_size = 8MB", profile)
|
|
|
|
def test_n16r2_profile_uses_quad_psram_and_full_flash(self):
|
|
profile = PROFILE.read_text()
|
|
start = profile.index(
|
|
"[env:SenseCapIndicator-LoRa-N16R2_comp_radio_usb_wifi]"
|
|
)
|
|
n16r2 = profile[start:]
|
|
self.assertIn("board_build.arduino.memory_type = qio_qspi", n16r2)
|
|
self.assertIn("board_build.psram_type = qspi", n16r2)
|
|
self.assertIn("board_upload.flash_size = 16MB", n16r2)
|
|
self.assertIn("board_upload.maximum_size = 16777216", n16r2)
|
|
self.assertIn("dual_ota_6400k_preserve_spiffs.csv", n16r2)
|
|
|
|
def test_preserves_logical_ui_and_fills_panel(self):
|
|
panel_size = 480
|
|
zoom = numeric_flag("UI_ZOOM")
|
|
coordinate_scale = numeric_flag("UI_COORD_SCALE")
|
|
|
|
logical_size = panel_size / (zoom * coordinate_scale)
|
|
sprite_size = logical_size * coordinate_scale
|
|
|
|
self.assertEqual(logical_size, 160)
|
|
self.assertEqual(sprite_size, 320)
|
|
self.assertEqual(sprite_size * zoom, panel_size)
|
|
|
|
def test_internal_canvas_leaves_interface_headroom(self):
|
|
panel_size = 480
|
|
color_depth = numeric_flag("UI_BUFFER_COLOR_DEPTH")
|
|
zoom = numeric_flag("UI_ZOOM")
|
|
coordinate_scale = numeric_flag("UI_COORD_SCALE")
|
|
logical_size = panel_size / (zoom * coordinate_scale)
|
|
sprite_size = int(logical_size * coordinate_scale)
|
|
|
|
old_canvas_bytes = panel_size * panel_size * int(color_depth) // 8
|
|
canvas_bytes = sprite_size * sprite_size * int(color_depth) // 8
|
|
|
|
self.assertEqual(canvas_bytes, 51_200)
|
|
self.assertGreaterEqual(old_canvas_bytes - canvas_bytes, 64_000)
|
|
self.assertIn("buffer.setPsram(false);", DISPLAY.read_text())
|
|
|
|
def test_uses_dedicated_pairing_block(self):
|
|
self.assertIn("-D UI_DEDICATED_PAIRING_BLOCK=1", base_profile())
|
|
|
|
logical_height = 160
|
|
block_height = 48
|
|
block_center = logical_height * 4 // 5
|
|
block_top = block_center - block_height // 2
|
|
info_top = 40
|
|
info_bottom = block_top - 12
|
|
|
|
self.assertEqual(block_center, 128)
|
|
self.assertEqual(block_top, 104)
|
|
self.assertLess(info_top, info_bottom)
|
|
self.assertLessEqual(info_bottom + 12, block_top)
|
|
|
|
def test_recovered_font_pairing_values_fit_reserved_block(self):
|
|
data = FONT.read_bytes()
|
|
glyph_count = struct.unpack_from(">I", data, 0)[0]
|
|
advances = {}
|
|
heights = {}
|
|
for index in range(glyph_count):
|
|
offset = 24 + index * 28
|
|
codepoint, height, _width, advance = struct.unpack_from(
|
|
">IIII", data, offset
|
|
)
|
|
advances[codepoint] = advance
|
|
heights[codepoint] = height
|
|
|
|
footer = data[-40:]
|
|
self.assertEqual(b"MCEMAP2\0", footer[:8])
|
|
atlas_offset = struct.unpack_from("<I", footer, 20)[0]
|
|
native_scale = data[atlas_offset + 9]
|
|
coordinate_scale = 2
|
|
|
|
def dimensions(text, requested_size, coordinate_scale=2,
|
|
profile_scale=1.0):
|
|
render_scale = (
|
|
requested_size * coordinate_scale / native_scale
|
|
* profile_scale
|
|
)
|
|
# Blank glyphs carry no bitmap record in the packed VLW. The
|
|
# checked-in font is monospaced with the documented 18px cell, so
|
|
# spaces still advance by one complete cell.
|
|
width = sum(
|
|
18 if char == " " else advances[ord(char)] for char in text
|
|
)
|
|
height = max(heights[ord(char)] for char in text if char != " ")
|
|
return (
|
|
int((width * render_scale + coordinate_scale - 1)
|
|
// coordinate_scale),
|
|
int((height * render_scale + coordinate_scale - 1)
|
|
// coordinate_scale),
|
|
)
|
|
|
|
self.assertEqual((108, 24), dimensions("123456", 3))
|
|
self.assertEqual((108, 16), dimensions("CONNECTED", 2))
|
|
for width, height in (
|
|
dimensions("123456", 3), dimensions("CONNECTED", 2)
|
|
):
|
|
self.assertLessEqual(width, 144)
|
|
self.assertLessEqual(height, 24)
|
|
|
|
# This continuous-scale model rounds outward and is deliberately a
|
|
# conservative fit bound; LovyanGFX applies fixed-point rounding while
|
|
# laying out individual glyphs.
|
|
native_pin = dimensions("123456", 3, 3, 1.2)
|
|
native_connected = dimensions("CONNECTED", 2, 3, 1.2)
|
|
for width, height in (native_pin, native_connected):
|
|
self.assertLessEqual(width, 144)
|
|
self.assertLessEqual(height, 30)
|
|
|
|
# Native 480 reflows the spacious home page instead of globally
|
|
# enlarging every dense UI row. The title, count, action, and pairing
|
|
# state each occupy their own large row.
|
|
native_title = dimensions("HISTORY", 3, 3, 1.2)
|
|
native_count = dimensions("9999", 3, 3, 1.2)
|
|
native_count_fallback = dimensions("99999", 2, 3, 1.2)
|
|
self.assertLessEqual(native_title[0], 156)
|
|
self.assertLessEqual(native_title[1], 39)
|
|
self.assertLessEqual(native_count[0], 156)
|
|
self.assertLessEqual(native_count_fallback[0], 156)
|
|
|
|
for text, requested_size, region_width in (
|
|
("TAP", 3, 144),
|
|
("OFF", 3, 144),
|
|
("PIN", 3, 144),
|
|
("LINKED", 3, 144),
|
|
("123456", 3, 144),
|
|
("IP: 192.168.100.200", 1, 144),
|
|
):
|
|
width, height = dimensions(text, requested_size, 3, 1.2)
|
|
self.assertLessEqual(width, region_width)
|
|
self.assertLessEqual(height, 29 if requested_size == 3 else 10)
|
|
|
|
# The two lower size-3 rows must not depend on blank pixels inside the
|
|
# checked-in VLW: the built-in fallback font can fill its whole cell.
|
|
pairing_label_y = 102
|
|
pairing_value_y = 131
|
|
self.assertLessEqual(pairing_label_y + 29, pairing_value_y)
|
|
self.assertLessEqual(pairing_value_y + 29, 160)
|
|
|
|
# Page 8 retains the shared status bar and dots, then uses two equal
|
|
# text rows and a version-2 WiFi QR. Compact mode deliberately bypasses
|
|
# the native 1.2x chrome boost in both render profiles.
|
|
for text in ("WIFI SETUP", "MC-Set-90DF"):
|
|
width, _height = dimensions(text, 2, 3, 1.0)
|
|
self.assertLessEqual(width, 160)
|
|
|
|
label_height = dimensions("WIFI SETUP", 2, 3, 1.0)[1]
|
|
self.assertLessEqual(20 + label_height, 37)
|
|
self.assertLessEqual(37 + label_height, 55)
|
|
|
|
qr_size = 105
|
|
qr_version_2_modules = 25
|
|
quiet_zone_modules = 4
|
|
native_module_size = qr_size * 3 // (
|
|
qr_version_2_modules + quiet_zone_modules * 2
|
|
)
|
|
fallback_module_size = qr_size * 2 // (
|
|
qr_version_2_modules + quiet_zone_modules * 2
|
|
)
|
|
self.assertEqual(native_module_size, 9)
|
|
self.assertEqual(fallback_module_size * 1.5, 9)
|
|
self.assertEqual(55 + qr_size, 160)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|