mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-13 14:15:43 +00:00
401 lines
17 KiB
Python
401 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
|
|
from pathlib import Path
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BUILD = ROOT / "build.sh"
|
|
MAIN = ROOT / "examples" / "companion_radio" / "main.cpp"
|
|
MESH = ROOT / "examples" / "companion_radio" / "MyMesh.cpp"
|
|
DATA_STORE = ROOT / "examples" / "companion_radio" / "DataStore.cpp"
|
|
PROFILE = ROOT / "variants" / "sensecap_indicator-espnow" / "platformio.ini"
|
|
UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp"
|
|
INDICATOR_DISPLAY = (
|
|
ROOT / "variants" / "sensecap_indicator-espnow" / "SCIndicatorDisplay.h"
|
|
)
|
|
DISPLAY_DRIVER = ROOT / "src" / "helpers" / "ui" / "DisplayDriver.h"
|
|
LGFX_DISPLAY = ROOT / "src" / "helpers" / "ui" / "LGFXDisplay.h"
|
|
LGFX_DISPLAY_CPP = ROOT / "src" / "helpers" / "ui" / "LGFXDisplay.cpp"
|
|
WEBCONFIG_HEADER = ROOT / "src" / "helpers" / "esp32" / "WebConfigServer.h"
|
|
WEBCONFIG_SOURCE = ROOT / "src" / "helpers" / "esp32" / "WebConfigServer.cpp"
|
|
WIFI_QR_PAYLOAD = ROOT / "src" / "helpers" / "ui" / "WiFiSetupQrPayload.h"
|
|
|
|
|
|
def find_bash():
|
|
# The legacy WindowsApps bash.exe bridge drops shell positional arguments
|
|
# and expands variables incorrectly. Prefer Git for Windows' real Bash;
|
|
# other platforms use the normal PATH lookup.
|
|
if os.name == "nt":
|
|
git_bash = (
|
|
Path(os.environ.get("ProgramFiles", r"C:\Program Files"))
|
|
/ "Git"
|
|
/ "bin"
|
|
/ "bash.exe"
|
|
)
|
|
if git_bash.is_file():
|
|
return str(git_bash)
|
|
return shutil.which("bash") or "bash"
|
|
|
|
|
|
BASH = find_bash()
|
|
|
|
|
|
class IndicatorRenderProfileTest(unittest.TestCase):
|
|
def test_transport_matrix(self):
|
|
source = r'''
|
|
#include <helpers/ui/IndicatorRenderProfile.h>
|
|
|
|
int main() {
|
|
using mesh::ui::selectIndicatorRenderProfile;
|
|
const auto lora_wifi = selectIndicatorRenderProfile(false, true);
|
|
const auto lora_ble = selectIndicatorRenderProfile(false, false);
|
|
const auto espnow_wifi = selectIndicatorRenderProfile(true, true);
|
|
const auto espnow_ble = selectIndicatorRenderProfile(true, false);
|
|
if (lora_wifi.canvas_size != 480 || lora_wifi.coordinate_scale != 3
|
|
|| lora_wifi.output_zoom != 1.0f) return 1;
|
|
if (lora_ble.canvas_size != 480 || lora_ble.coordinate_scale != 3
|
|
|| lora_ble.output_zoom != 1.0f) return 2;
|
|
if (espnow_wifi.canvas_size != 480 || espnow_wifi.coordinate_scale != 3
|
|
|| espnow_wifi.output_zoom != 1.0f) return 3;
|
|
if (espnow_ble.canvas_size != 320 || espnow_ble.coordinate_scale != 2
|
|
|| espnow_ble.output_zoom != 1.5f) return 4;
|
|
if (mesh::ui::selectIndicatorTextScale(480, false) != 1.2f) return 5;
|
|
if (mesh::ui::selectIndicatorTextScale(480, true) != 1.0f) return 6;
|
|
if (mesh::ui::selectIndicatorTextScale(320, false) != 1.0f) return 7;
|
|
if (!mesh::ui::usesNativeIndicatorTypography(160, 160, 480, 480)) return 8;
|
|
if (mesh::ui::usesNativeIndicatorTypography(160, 160, 320, 320)) return 9;
|
|
if (mesh::ui::usesNativeIndicatorTypography(128, 128, 480, 480)) return 10;
|
|
return 0;
|
|
}
|
|
'''
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
executable = Path(temp_dir) / "indicator_render_profile"
|
|
compiled = subprocess.run(
|
|
[
|
|
"c++",
|
|
"-std=c++11",
|
|
f"-I{ROOT / 'src'}",
|
|
"-x",
|
|
"c++",
|
|
"-",
|
|
"-o",
|
|
str(executable),
|
|
],
|
|
input=source,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(compiled.returncode, 0, compiled.stderr)
|
|
ran = subprocess.run(
|
|
[str(executable)], capture_output=True, text=True, check=False
|
|
)
|
|
self.assertEqual(ran.returncode, 0, ran.stderr)
|
|
|
|
def test_full_profiles_allocate_native_before_runtime_selection(self):
|
|
build = BUILD.read_text(encoding="utf-8")
|
|
self.assertIn(
|
|
"sensecapindicator-espnow_companion_radio_full|\\\n"
|
|
" sensecapindicator-lora_companion_radio_full)",
|
|
build,
|
|
)
|
|
self.assertIn("-UUI_ZOOM -DUI_ZOOM=1.0f", build)
|
|
self.assertIn("-UUI_COORD_SCALE -DUI_COORD_SCALE=3", build)
|
|
self.assertIn("-DINDICATOR_TRANSPORT_RENDER_PROFILE=1", build)
|
|
self.assertIn("-DUI_WIFI_SETUP_HOME_PAGE=1", build)
|
|
|
|
def test_runtime_selection_precedes_bluetooth_start(self):
|
|
main = MAIN.read_text(encoding="utf-8")
|
|
setup = main[main.index("void setup()") : main.index("\nvoid loop()")]
|
|
select = setup.index("selectIndicatorRenderProfile(")
|
|
resize = setup.index("disp->setRenderScale(", select)
|
|
bluetooth = setup.index("startCompanionBluetooth();", resize)
|
|
self.assertLess(select, resize)
|
|
self.assertLess(resize, bluetooth)
|
|
self.assertIn("true,", setup[select:resize])
|
|
self.assertIn("companionTransportWiFiActiveAtBoot()", setup[select:resize])
|
|
|
|
def test_canvas_stays_internal_and_can_rollback(self):
|
|
display = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn("buffer.setPsram(false);", display)
|
|
self.assertIn("if (!configurePalette()) {\n buffer.deleteSprite();", display)
|
|
self.assertIn("buffer.deleteSprite();", display)
|
|
self.assertIn("_coordinateScale = previous_scale;", display)
|
|
self.assertIn("_outputZoom = previous_zoom;", display)
|
|
self.assertIn("createRenderBuffer();\n return false;", display)
|
|
|
|
def test_failed_native_startup_retries_safe_canvas(self):
|
|
display = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
begin = display[display.index("bool LGFXDisplay::begin()") :]
|
|
begin = begin[: begin.index("\nbool LGFXDisplay::createRenderBuffer()")]
|
|
first_attempt = begin.index("if (createRenderBuffer()) return true;")
|
|
guard = begin.index("INDICATOR_TRANSPORT_RENDER_PROFILE", first_attempt)
|
|
fallback_scale = begin.index("_coordinateScale = 2;", guard)
|
|
fallback_zoom = begin.index("_outputZoom = 1.5f;", fallback_scale)
|
|
retry = begin.index("if (createRenderBuffer()) return true;", fallback_zoom)
|
|
self.assertLess(first_attempt, guard)
|
|
self.assertLess(guard, fallback_scale)
|
|
self.assertLess(fallback_scale, fallback_zoom)
|
|
self.assertLess(fallback_zoom, retry)
|
|
|
|
def test_full_indicator_constructs_the_160_unit_viewport_explicitly(self):
|
|
display = INDICATOR_DISPLAY.read_text(encoding="utf-8")
|
|
self.assertIn("#if defined(INDICATOR_TRANSPORT_RENDER_PROFILE)", display)
|
|
self.assertIn(
|
|
"SCIndicatorDisplay() : LGFXDisplay(160, 160, 3, 1.0f, disp)",
|
|
display,
|
|
)
|
|
|
|
lgfx = LGFX_DISPLAY.read_text(encoding="utf-8")
|
|
self.assertIn(
|
|
"LGFXDisplay(int logical_w, int logical_h, uint8_t coordinate_scale,",
|
|
lgfx,
|
|
)
|
|
self.assertIn("DisplayDriver(logical_w, logical_h)", lgfx)
|
|
self.assertIn("_coordinateScale(coordinate_scale)", lgfx)
|
|
self.assertIn("_outputZoom(output_zoom)", lgfx)
|
|
|
|
def test_native_font_increase_preserves_compact_chrome(self):
|
|
display = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn("selectIndicatorTextScale(", display)
|
|
self.assertIn("static_cast<uint16_t>(renderWidth()), _compactText", display)
|
|
self.assertIn("* profile_scale;", display)
|
|
|
|
def test_indicator_setup_is_a_normal_eighth_home_page(self):
|
|
ui = UI.read_text(encoding="utf-8")
|
|
page_start = ui.index("static void drawCompanionWiFiSetupPage")
|
|
page_end = ui.index("\n}\n#endif\n\n#include \"icons.h\"", page_start) + 2
|
|
page = ui[page_start:page_end]
|
|
self.assertIn("WebConfigServer::getSetupInfo", page)
|
|
self.assertIn('20, "WIFI SETUP"', page)
|
|
self.assertIn("37, setup_ssid", page)
|
|
self.assertIn("static constexpr int qr_size = 105;", page)
|
|
self.assertIn("payload, qr_x, 55, qr_size", page)
|
|
self.assertIn("mesh::ui::buildWiFiSetupQrPayload", page)
|
|
self.assertIn("display.setCompactText(true);", page)
|
|
self.assertGreaterEqual(page.count("display.setCompactText(false);"), 2)
|
|
self.assertNotIn("startFrame", page)
|
|
self.assertNotIn("endFrame", page)
|
|
|
|
enum_start = ui.index(" enum HomePage {")
|
|
enum = ui[enum_start : ui.index("\n };", enum_start)]
|
|
self.assertIn(
|
|
"#if UI_WIFI_SETUP_HOME_PAGE == 1\n WIFI_SETUP,\n#endif\n Count",
|
|
enum,
|
|
)
|
|
self.assertIn("_page == HomePage::WIFI_SETUP", ui)
|
|
self.assertIn("if (_page == HomePage::WIFI_SETUP) return 1000;", ui)
|
|
self.assertIn("wifi_setup_active && !_wifi_setup_was_active", ui)
|
|
self.assertIn("isCompanionWiFiConnected()", page)
|
|
self.assertIn("hasCompanionWiFiCredentials()", page)
|
|
self.assertIn("CompanionWiFiDisplayState::NotConfigured", page)
|
|
self.assertIn('"TAP TO START"', page)
|
|
self.assertIn("wifi_connected != _wifi_was_connected", ui)
|
|
self.assertIn("CompanionWiFiDisplayState::Ready", page)
|
|
self.assertIn("display.clear()", page)
|
|
self.assertIn("noteCompanionWiFiDisplayState(state)", page)
|
|
|
|
main = (ROOT / "examples/companion_radio/main.cpp").read_text()
|
|
helper = main[main.index("bool isCompanionWiFiConnected()"):
|
|
main.index("bool toggleCompanionWiFi()")]
|
|
self.assertNotIn("!companion_wifi_active", helper)
|
|
self.assertIn("esp_wifi_sta_get_ap_info", helper)
|
|
|
|
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
|
self.assertIn('strcmp(command, "get display.wifi")', mesh)
|
|
self.assertIn("_task->gotoHomeScreen();", ui)
|
|
self.assertIn("requestCompanionWiFiSetup();", ui)
|
|
self.assertIn('strcpy(tmp, "WiFi: SETUP");', ui)
|
|
self.assertIn('strcpy(tmp, "SETUP");', ui)
|
|
|
|
main = MAIN.read_text(encoding="utf-8")
|
|
loop = main[main.index("\nvoid loop()") :]
|
|
display_dispatch = loop[loop.index("#ifdef DISPLAY_CLASS") :]
|
|
display_dispatch = display_dispatch[: display_dispatch.index("rtc_clock.tick();")]
|
|
self.assertIn("&& UI_WIFI_SETUP_HOME_PAGE != 1", display_dispatch)
|
|
self.assertIn("renderCompanionSetupDisplay();", display_dispatch)
|
|
self.assertIn("#else\n ui_task.loop();", display_dispatch)
|
|
self.assertIn('"not-configured"', main)
|
|
self.assertIn("companion_wifi_setup_requested", loop)
|
|
|
|
def test_wifi_qr_payload_is_compact_and_escaped(self):
|
|
setup = WIFI_QR_PAYLOAD.read_text(encoding="utf-8")
|
|
self.assertIn("buildWiFiSetupQrPayload", setup)
|
|
self.assertIn('"WIFI:T:WPA;S:"', setup)
|
|
# Omitting the optional open-network type keeps the setup payload in
|
|
# QR version 1 at low error correction on the 64-pixel OLED.
|
|
self.assertIn('"WIFI:S:"', setup)
|
|
self.assertIn('strchr("\\\\;,\\\":", *value)', setup)
|
|
self.assertIn('";P:"', setup)
|
|
self.assertIn('";;"', setup)
|
|
|
|
def test_legacy_setup_overlay_is_not_built_for_page_enabled_profile(self):
|
|
main = MAIN.read_text(encoding="utf-8")
|
|
start = main.index("static DisplayDriver* companion_setup_display")
|
|
declaration = main[start - 120 : start + 80]
|
|
self.assertIn("&& UI_WIFI_SETUP_HOME_PAGE != 1", declaration)
|
|
self.assertNotIn("renderIndicatorSetupDisplay", main)
|
|
self.assertNotIn("renderIndicatorSetupQrDisplay", main)
|
|
|
|
def test_lgfx_qr_renderer_scales_coordinates_and_keeps_quiet_zone(self):
|
|
driver = DISPLAY_DRIVER.read_text(encoding="utf-8")
|
|
self.assertIn(
|
|
"virtual bool drawQrCode(const char* text, int x, int y, int size)",
|
|
driver,
|
|
)
|
|
|
|
header = LGFX_DISPLAY.read_text(encoding="utf-8")
|
|
self.assertIn(
|
|
"bool drawQrCode(const char* text, int x, int y, int size) override;",
|
|
header,
|
|
)
|
|
implementation = LGFX_DISPLAY_CPP.read_text(encoding="utf-8")
|
|
qr = implementation[implementation.index("bool LGFXDisplay::drawQrCode") :]
|
|
qr = qr[: qr.index("\nuint16_t LGFXDisplay::getTextWidth")]
|
|
self.assertIn("lgfx_qrcode_initText", qr)
|
|
self.assertIn("lgfx_qrcode_getModule", qr)
|
|
self.assertIn("ECC_LOW", qr)
|
|
self.assertIn("static constexpr int quiet_zone = 4;", qr)
|
|
self.assertIn("renderColor(UIColor::primary_txt)", qr)
|
|
self.assertIn("renderColor(UIColor::window_bkg)", qr)
|
|
self.assertIn("const int scaled_size = size * _coordinateScale;", qr)
|
|
self.assertIn("x > width() - size", qr)
|
|
self.assertIn("module_size, module_size, dark", qr)
|
|
self.assertNotIn("run_start", qr)
|
|
self.assertNotIn("buffer.qrcode", qr)
|
|
self.assertNotIn("TFT_WHITE", qr)
|
|
self.assertNotIn("TFT_BLACK", qr)
|
|
|
|
def test_indicator_uses_short_captive_setup_ssid(self):
|
|
build = BUILD.read_text(encoding="utf-8")
|
|
short_prefix_flag = "-DWEBCONFIG_AP_PREFIX='\\\"MC-Set\\\"'"
|
|
self.assertEqual(build.count(short_prefix_flag), 1)
|
|
full_case_start = build.index(
|
|
"# Both Indicator Full layouts select exactly one secondary"
|
|
)
|
|
full_case = build[full_case_start : build.index(";;", full_case_start)]
|
|
self.assertIn(short_prefix_flag, full_case)
|
|
|
|
profile = PROFILE.read_text(encoding="utf-8")
|
|
self.assertNotIn("WEBCONFIG_AP_PREFIX", profile)
|
|
|
|
header = WEBCONFIG_HEADER.read_text(encoding="utf-8")
|
|
self.assertIn('#define WEBCONFIG_AP_PREFIX "MeshCore-Setup"', header)
|
|
source = WEBCONFIG_SOURCE.read_text(encoding="utf-8")
|
|
self.assertIn('"%s-%02X%02X"', source)
|
|
self.assertIn("WEBCONFIG_AP_PREFIX, _pub_key[0], _pub_key[1]", source)
|
|
self.assertIn("sizeof(WEBCONFIG_AP_PREFIX) <= 28", source)
|
|
|
|
def test_lora_fresh_install_uses_compiled_cascade_defaults(self):
|
|
profile = PROFILE.read_text(encoding="utf-8")
|
|
lora = profile[profile.index("[SenseCapIndicator-LoRa]") :]
|
|
lora = lora[: lora.index("[env:")]
|
|
self.assertIn("-UMESH_PRIMARY_ESPNOW", lora)
|
|
self.assertIn("-D SENSECAP_INDICATOR_LORA", lora)
|
|
self.assertIn("-D USE_SX1262", lora)
|
|
self.assertIn("-<helpers/esp32/ESPNOWRadio.cpp>", lora)
|
|
|
|
mesh = MESH.read_text(encoding="utf-8")
|
|
self.assertIn("_prefs.freq = LORA_FREQ;", mesh)
|
|
self.assertIn("_prefs.sf = LORA_SF;", mesh)
|
|
self.assertIn("_prefs.bw = LORA_BW;", mesh)
|
|
self.assertIn("_prefs.cr = LORA_CR;", mesh)
|
|
guarded_rx_delay = (
|
|
"#ifdef DEFAULT_RX_DELAY_BASE\n"
|
|
" _prefs.rx_delay_base = DEFAULT_RX_DELAY_BASE;\n"
|
|
"#endif"
|
|
)
|
|
self.assertIn(guarded_rx_delay, mesh)
|
|
|
|
# Saved preferences load after constructor defaults, so an app-only
|
|
# update remains non-destructive while an erased install uses them.
|
|
begin = mesh[mesh.index("void MyMesh::begin(") :]
|
|
self.assertIn("_store->loadPrefs(_prefs", begin)
|
|
store = DATA_STORE.read_text(encoding="utf-8")
|
|
self.assertIn('if (_fs->exists("/new_prefs"))', store)
|
|
self.assertIn('loadPrefsInt("/new_prefs", prefs', store)
|
|
|
|
def test_explicit_usa_cascadia_and_cascade_build_flags(self):
|
|
command = r'''
|
|
set -e
|
|
source ./build.sh
|
|
PLATFORMIO_BUILD_FLAGS=""
|
|
RADIO_FREQ_OVERRIDE=910.525
|
|
RADIO_BW_OVERRIDE=62.5
|
|
RADIO_SF_OVERRIDE=7
|
|
RADIO_CR_OVERRIDE=5
|
|
FIRMWARE_PROFILE_OVERRIDE=cascade
|
|
apply_radio_overrides
|
|
apply_firmware_profile_overrides
|
|
printf '%s\n' "$PLATFORMIO_BUILD_FLAGS"
|
|
'''
|
|
result = subprocess.run(
|
|
[BASH, "-c", command],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
flags = result.stdout.split()
|
|
for expected in (
|
|
"-DLORA_FREQ=910.525",
|
|
"-DLORA_BW=62.5",
|
|
"-DLORA_SF=7",
|
|
"-DLORA_CR=5",
|
|
"-DMESHCORE_USA_RADIO_PRESET=1",
|
|
"-DCASCADE_PROFILE=1",
|
|
"-DDEFAULT_RX_DELAY_BASE=2.0f",
|
|
):
|
|
self.assertIn(expected, flags)
|
|
|
|
build = BUILD.read_text(encoding="utf-8")
|
|
self.assertIn(
|
|
"'SenseCapIndicator-LoRa_comp_radio_usb_wifi|"
|
|
"SenseCapIndicator-LoRa_companion_radio_full'",
|
|
build,
|
|
)
|
|
self.assertIn(
|
|
"'SenseCapIndicator-LoRa-N16R2_comp_radio_usb_wifi|"
|
|
"SenseCapIndicator-LoRa-N16R2_companion_radio_full'",
|
|
build,
|
|
)
|
|
|
|
def test_non_usa_radio_does_not_enable_wizard_skip(self):
|
|
command = r'''
|
|
set -e
|
|
source ./build.sh
|
|
PLATFORMIO_BUILD_FLAGS=""
|
|
RADIO_SETTING_TITLE="Australia"
|
|
RADIO_FREQ_OVERRIDE=915.800
|
|
RADIO_BW_OVERRIDE=250
|
|
RADIO_SF_OVERRIDE=10
|
|
RADIO_CR_OVERRIDE=5
|
|
apply_radio_overrides
|
|
printf '%s\n' "$PLATFORMIO_BUILD_FLAGS"
|
|
'''
|
|
result = subprocess.run(
|
|
[BASH, "-c", command],
|
|
cwd=ROOT,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertNotIn("-DMESHCORE_USA_RADIO_PRESET=1", result.stdout.split())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|