From 70fe3566b40adbca0ca5a9e8800ffa7f1bea1ea4 Mon Sep 17 00:00:00 2001 From: Razvalyaev Date: Sat, 8 Aug 2026 11:15:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BD=20=D1=80?= =?UTF-8?q?=D1=83=D1=81=D1=81=D0=BA=D0=B8=D0=B9=20=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OpticalChannelTester/App.cpp | 131 ++++++++++++++++----------- OpticalChannelTester/Config_Text.h | 118 ++++++++++++++++++++++++ OpticalChannelTester/Core.cpp | 2 +- OpticalChannelTester/Core.h | 2 +- OpticalChannelTester/Display.cpp | 49 ++++++++-- OpticalChannelTester/Display.h | 4 +- OpticalChannelTester/Font_Cyrillic.h | 57 ++++++++++++ OpticalChannelTester/Measurement.cpp | 2 +- README.md | 4 +- 9 files changed, 300 insertions(+), 69 deletions(-) create mode 100644 OpticalChannelTester/Config_Text.h create mode 100644 OpticalChannelTester/Font_Cyrillic.h diff --git a/OpticalChannelTester/App.cpp b/OpticalChannelTester/App.cpp index eadc06d..558e72a 100644 --- a/OpticalChannelTester/App.cpp +++ b/OpticalChannelTester/App.cpp @@ -1,5 +1,6 @@ #include "App.h" #include "Config.h" +#include "Config_Text.h" #include "Log.h" #include #include @@ -30,12 +31,36 @@ void formatErrorDuty(float duty, char *out, size_t size) { else snprintf(out, size, "%.1f%%", duty); } +size_t utf8CharacterCount(const char *text) { + size_t count = 0; + while (text && *text) { + const uint8_t byte = static_cast(*text++); + // Continuation bytes (10xxxxxx) belong to the preceding UTF-8 character. + if ((byte & 0xC0U) != 0x80U) ++count; + } + return count; +} + +const char *uiRoleName(Role role) { + const uint8_t index = static_cast(role); + return index < sizeof(UiText::ROLE_NAMES) / sizeof(UiText::ROLE_NAMES[0]) + ? UiText::ROLE_NAMES[index] : "?"; +} + +const char *uiFailName(FailReason reason) { + const uint8_t index = static_cast(reason); + return index < sizeof(UiText::FAIL_NAMES) / sizeof(UiText::FAIL_NAMES[0]) + ? UiText::FAIL_NAMES[index] : "UNKNOWN"; +} + void formatMenuLine(const char *label, const char *value, char *out, size_t size) { constexpr size_t OLED_TEXT_COLUMNS = 21; - const size_t valueLength = strlen(value); - const int labelWidth = static_cast( - valueLength < OLED_TEXT_COLUMNS ? OLED_TEXT_COLUMNS - valueLength : 1U); - snprintf(out, size, "%-*s%s", labelWidth, label, value); + const size_t labelLength = utf8CharacterCount(label); + const size_t valueLength = utf8CharacterCount(value); + const size_t usedColumns = labelLength + valueLength; + const int padding = static_cast( + usedColumns < OLED_TEXT_COLUMNS ? OLED_TEXT_COLUMNS - usedColumns : 0U); + snprintf(out, size, "%s%*s%s", label, padding, "", value); } uint32_t overallProgress(uint32_t stageIndex, uint8_t step) { @@ -173,8 +198,9 @@ void App::update() { void App::showIdle() { setActivePerformance(false); lastUserActivityMs_ = millis(); - char one[24]; snprintf(one, sizeof(one), "MODE: %s", roleName(static_cast(settings_.role))); - display_.show(one, "START=RUN"); + char one[64]; snprintf(one, sizeof(one), "%s%s", UiText::MODE_PREFIX, + uiRoleName(static_cast(settings_.role))); + display_.show(one, UiText::START_RUN); } void App::sanitizeRange() { @@ -198,35 +224,35 @@ void App::changeMenu(int d) { } void App::showMenu() { - char one[22], value[12], total[22], all[12]; + char one[64], value[24], total[64], all[12]; const char *label = nullptr; Display::formatDuration(actualNominalTotalUs(), all, sizeof(all)); switch (menuItem_) { case 0: Display::formatTestFrequency(params_.startHz, value, sizeof(value)); - strncat(value, " Hz", sizeof(value) - strlen(value) - 1U); - label = "START FREQ:"; + strncat(value, UiText::FREQUENCY_UNIT, sizeof(value) - strlen(value) - 1U); + label = UiText::MENU_START_FREQUENCY; break; case 1: Display::formatTestFrequency(params_.endHz, value, sizeof(value)); - strncat(value, " Hz", sizeof(value) - strlen(value) - 1U); - label = "END FREQ:"; + strncat(value, UiText::FREQUENCY_UNIT, sizeof(value) - strlen(value) - 1U); + label = UiText::MENU_END_FREQUENCY; break; case 2: snprintf(value, sizeof(value), "+/-%g%%", params_.accuracyPct); - label = "ACCURACY:"; + label = UiText::MENU_ACCURACY; break; case 3: snprintf(value, sizeof(value), "%.1fs", params_.testTimeMs / 1000.0f); - label = "TEST TIME:"; + label = UiText::MENU_TEST_TIME; break; default: snprintf(value, sizeof(value), "%u%%", params_.dutyPct); - label = "PWM DUTY:"; + label = UiText::MENU_PWM_DUTY; break; } formatMenuLine(label, value, one, sizeof(one)); - formatMenuLine("TOTAL TIME:", all, total, sizeof(total)); + formatMenuLine(UiText::MENU_TOTAL_TIME, all, total, sizeof(total)); display_.show(one, total); } @@ -253,7 +279,7 @@ void App::startTest() { state_ = AppState::SOLO_MEASURE; } else if (!radio_.begin()) finish(false, FailReason::LINK_LOST); else if (role == Role::MASTER) startMasterDiscovery(); - else { state_ = AppState::SLAVE_READY; Log::event("TEST", "Slave armed and waiting for Master"); display_.show("SLAVE READY", "WAIT MASTER"); } + else { state_ = AppState::SLAVE_READY; Log::event("TEST", "Slave armed and waiting for Master"); display_.show(UiText::SLAVE_READY, UiText::WAIT_MASTER); } } bool App::armSlave(bool preserveDisplay) { @@ -266,13 +292,13 @@ bool App::armSlave(bool preserveDisplay) { if (!radio_.begin()) { state_ = AppState::FINISHED; pendingReason_ = FailReason::LINK_LOST; slaveRearmAtMs_ = millis() + LINK_HEARTBEAT_TIMEOUT_MS; - display_.show("LINK FAILED", "RADIO ERROR"); + display_.show(UiText::LINK_FAILED, UiText::RADIO_ERROR); return false; } radio_.setWindowedReceive(true); radio_.flush(); state_ = AppState::SLAVE_READY; Log::event("TEST", "Slave automatically armed and waiting for Master"); - if (!preserveDisplay) display_.show("SLAVE READY", "WAIT MASTER"); + if (!preserveDisplay) display_.show(UiText::SLAVE_READY, UiText::WAIT_MASTER); return true; } @@ -336,7 +362,7 @@ void App::startMasterDiscovery() { pendingPacket_ = makePacket(MessageType::DISCOVER); radio_.sendBroadcast(pendingPacket_); lastSendMs_ = millis(); retries_ = 0; state_ = AppState::MASTER_DISCOVER; Log::printf("ESP-NOW", "discovery started session=%08lX", session_); - display_.show("MASTER SEARCH", "HOLD START=STOP"); + display_.show(UiText::MASTER_SEARCH, UiText::HOLD_START_STOP); } ProtocolPacket App::makePacket(MessageType type) const { @@ -400,7 +426,7 @@ void App::handleRadio() { memcpy(peer_, r.mac, 6); havePeer_ = true; session_ = r.packet.session; stageIndex_ = 0; sequence_ = r.packet.sequence; lastPeerSeenMs_ = millis(); ProtocolPacket ack = makePacket(MessageType::DISCOVER_ACK); ack.sequence = r.packet.sequence; sendLinked(ack); - state_ = AppState::SLAVE_WAIT_START; display_.show("MASTER SEEN", "ACK SENT"); continue; + state_ = AppState::SLAVE_WAIT_START; display_.show(UiText::MASTER_SEEN, UiText::ACK_SENT); continue; } if (state_ == AppState::MASTER_DISCOVER && type == MessageType::DISCOVER_ACK && r.packet.session == session_) { memcpy(peer_, r.mac, 6); havePeer_ = true; lastPeerSeenMs_ = lastHeartbeatMs_ = millis(); @@ -497,7 +523,7 @@ void App::handleRadio() { if (pendingPacket_.passed) { if (r.packet.passed) { radio_.end(); pendingReason_ = FailReason::NONE; - if (armSlave(true)) display_.show("SLAVE PASS", "WAIT MASTER"); + if (armSlave(true)) display_.show(UiText::PASS_WORD, UiText::WAIT_MASTER); } else { stageIndex_ = static_cast(r.packet.stage) + 1U; state_ = AppState::SLAVE_WAIT_START; @@ -614,30 +640,30 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) { setActivePerformance(false); lastUserActivityMs_ = millis(); if (slaveLinkLost) { - char target[12], one[24]; + char target[12], one[64]; Display::formatTestFrequency(actual_.actualHz ? actual_.actualHz : requestedHz_, target, sizeof(target)); - snprintf(one, sizeof(one), "FAIL %s %.0f%%", target, + snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct); - display_.show(one, failName(reason), stageIndex_ + 1, stageCount_); + display_.show(one, uiFailName(reason), stageIndex_ + 1, stageCount_); armSlave(true); return; } if (static_cast(settings_.role) == Role::SLAVE) slaveRearmAtMs_ = millis() + 2000; if (preserveDisplay) return; - char one[24]; + char one[64]; if (pass) { const Role role = static_cast(settings_.role); - snprintf(one, sizeof(one), "%s PASS", roleName(role)); - display_.show(one, role == Role::SLAVE ? "WAIT MASTER" : "START=AGAIN"); + snprintf(one, sizeof(one), "%s %s", uiRoleName(role), UiText::PASS_WORD); + display_.show(one, role == Role::SLAVE ? UiText::WAIT_MASTER : UiText::START_AGAIN); } else if (requestedHz_) { char frequency[12]; Display::formatTestFrequency(actual_.actualHz ? actual_.actualHz : requestedHz_, frequency, sizeof(frequency)); - snprintf(one, sizeof(one), "FAIL %s %.0f%%", frequency, + snprintf(one, sizeof(one), UiText::FAIL_FORMAT, frequency, actual_.actualDutyPct > 0.0f ? actual_.actualDutyPct : params_.dutyPct); - display_.show(one, failName(reason), stageIndex_ + 1, stageCount_); + display_.show(one, uiFailName(reason), stageIndex_ + 1, stageCount_); } else { - display_.show("TEST FAILED", failName(reason)); + display_.show(UiText::TEST_FAILED, uiFailName(reason)); } } @@ -739,28 +765,28 @@ void App::printStageStats(const StageStats &s, uint32_t hz) { } void App::showStageResult(const StageStats &s) { - char one[24], two[24]; + char one[64], two[64]; char target[12]; Display::formatTestFrequency(actual_.actualHz, target, sizeof(target)); if (s.reason != FailReason::NONE) { - snprintf(one, sizeof(one), "FAIL %s %.0f%%", target, actual_.actualDutyPct); + snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, actual_.actualDutyPct); if (s.reason == FailReason::PERIOD_OUT && s.badFrequency > 0.0f) { char frequency[12]; Display::formatTestFrequency(static_cast(lroundf(s.badFrequency)), frequency, sizeof(frequency)); - snprintf(two, sizeof(two), "PERIOD OUT %s", frequency); + snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, frequency); } else if (s.reason == FailReason::DUTY_OUT && s.badFrequency > 0.0f) { char duty[10]; formatErrorDuty(s.badDuty, duty, sizeof(duty)); - snprintf(two, sizeof(two), "DUTY OUT %s", duty); + snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, duty); } else { - snprintf(two, sizeof(two), "%s", failName(s.reason)); + snprintf(two, sizeof(two), "%s", uiFailName(s.reason)); } display_.show(one, two, overallProgress(stageIndex_, measurement_.progressStep()), overallProgressTotal(stageCount_)); return; } - snprintf(one, sizeof(one), "Test:%-6s %2.0f%% %2lu/%2lu", - target, actual_.actualDutyPct, stageIndex_ + 1, stageCount_); + char stage[12]; snprintf(stage, sizeof(stage), "%lu/%lu", stageIndex_ + 1, stageCount_); + snprintf(one, sizeof(one), UiText::TEST_FORMAT, target, actual_.actualDutyPct, stage); if (!s.periods || !s.periodSum) { - display_.show(one, "F:--- D:---%", overallProgress(stageIndex_, measurement_.progressStep()), + display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, measurement_.progressStep()), overallProgressTotal(stageCount_)); return; } @@ -775,29 +801,30 @@ void App::showStageResult(const StageStats &s) { void App::showRemoteResult(const ProtocolPacket &packet) { const FailReason reason = packet.reason <= static_cast(FailReason::ABORTED) ? static_cast(packet.reason) : FailReason::UNSUPPORTED; - char target[12], one[24], two[24]; + char target[12], one[64], two[64]; Display::formatTestFrequency(packet.actualHz ? packet.actualHz : packet.requestedHz, target, sizeof(target)); if (reason == FailReason::NONE) { - snprintf(one, sizeof(one), "Test:%-6s %2.0f%% %2lu/%2lu", - target, packet.actualDutyX100 / 100.0f, stageIndex_ + 1, stageCount_); + char stage[12]; snprintf(stage, sizeof(stage), "%lu/%lu", stageIndex_ + 1, stageCount_); + snprintf(one, sizeof(one), UiText::TEST_FORMAT, + target, packet.actualDutyX100 / 100.0f, stage); if (packet.measuredHzX10) { char measured[12]; Display::formatFrequency(packet.measuredHzX10 / 10.0f, measured, sizeof(measured)); snprintf(two, sizeof(two), "F:%-8s D:%4.1f%%", measured, packet.measuredDutyX10 / 10.0f); - } else snprintf(two, sizeof(two), "F:--- D:---%%"); + } else snprintf(two, sizeof(two), "%s", UiText::NO_MEASUREMENT); } else if (reason == FailReason::PERIOD_OUT && packet.measuredHzX10) { - snprintf(one, sizeof(one), "FAIL %s %.0f%%", target, packet.actualDutyX100 / 100.0f); + snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f); char measured[12]; Display::formatTestFrequency((packet.measuredHzX10 + 5U) / 10U, measured, sizeof(measured)); - snprintf(two, sizeof(two), "PERIOD OUT %s", measured); + snprintf(two, sizeof(two), UiText::PERIOD_OUT_FORMAT, measured); } else if (reason == FailReason::DUTY_OUT && packet.measuredDutyX10) { - snprintf(one, sizeof(one), "FAIL %s %.0f%%", target, packet.actualDutyX100 / 100.0f); + snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f); char duty[10]; formatErrorDuty(packet.measuredDutyX10 / 10.0f, duty, sizeof(duty)); - snprintf(two, sizeof(two), "DUTY OUT %s", duty); + snprintf(two, sizeof(two), UiText::DUTY_OUT_FORMAT, duty); } else { - snprintf(one, sizeof(one), "FAIL %s %.0f%%", target, packet.actualDutyX100 / 100.0f); - snprintf(two, sizeof(two), "%s", failName(reason)); + snprintf(one, sizeof(one), UiText::FAIL_FORMAT, target, packet.actualDutyX100 / 100.0f); + snprintf(two, sizeof(two), "%s", uiFailName(reason)); } display_.show(one, two, overallProgress(stageIndex_, packet.progressStep), overallProgressTotal(stageCount_)); @@ -817,10 +844,10 @@ void App::fillMeasuredResult(ProtocolPacket &packet, const StageStats &stats) co } void App::showStageProgress() { - char target[12], one[24]; + char target[12], one[64], stage[12]; Display::formatTestFrequency(actual_.actualHz, target, sizeof(target)); - snprintf(one, sizeof(one), "Test:%-6s %2.0f%% %2lu/%2lu", - target, actual_.actualDutyPct, stageIndex_ + 1, stageCount_); - display_.show(one, "F:--- D:---%", overallProgress(stageIndex_, 0), + snprintf(stage, sizeof(stage), "%lu/%lu", stageIndex_ + 1, stageCount_); + snprintf(one, sizeof(one), UiText::TEST_FORMAT, target, actual_.actualDutyPct, stage); + display_.show(one, UiText::NO_MEASUREMENT, overallProgress(stageIndex_, 0), overallProgressTotal(stageCount_)); } diff --git a/OpticalChannelTester/Config_Text.h b/OpticalChannelTester/Config_Text.h new file mode 100644 index 0000000..3fbe1c7 --- /dev/null +++ b/OpticalChannelTester/Config_Text.h @@ -0,0 +1,118 @@ +#pragma once + +// Select the language used by the OLED interface. +// Build with UI_LANGUAGE_EN for English or UI_LANGUAGE_RU for Russian. +#define UI_LANGUAGE_EN 0 +#define UI_LANGUAGE_RU 1 + +#ifndef UI_LANGUAGE +#define UI_LANGUAGE UI_LANGUAGE_RU +#endif + +namespace UiText { + +#if UI_LANGUAGE == UI_LANGUAGE_RU + +constexpr const char *ROLE_NAMES[] = { + "СОЛО", "МАСТЕР", "СЛЕЙВ" +}; + +constexpr const char *FAIL_NAMES[] = { + "НЕТ ОШИБКИ", + "НЕТ СИГНАЛА", + "ПЕРИОД ВНЕ ДОПУСКА", + "ЗАПОЛН. ВНЕ ДОПУСКА", + "ЛИШНИЙ ФРОНТ", + "ИМПУЛЬСНАЯ ПОМЕХА", + "ПРОПУЩЕН ФРОНТ", + "ОШИБКА ПОТЕРИ ДАННЫХ", + "СВЯЗЬ ПОТЕРЯНА", + "РЕЖИМ НЕ ПОДДЕРЖИВ.", + "НЕ ХВАТАЕТ ТОЧНОСТИ", + "ТЕСТ ОСТАНОВЛЕН" +}; + +constexpr const char *MODE_PREFIX = "РЕЖИМ: "; +constexpr const char *START_RUN = "ГОТОВ К ЗАПУСКУ"; + +constexpr const char *MENU_START_FREQUENCY = "ЧАСТОТА ОТ:"; +constexpr const char *MENU_END_FREQUENCY = "ЧАСТОТА ДО:"; +constexpr const char *MENU_ACCURACY = "ТОЧНОСТЬ:"; +constexpr const char *MENU_TEST_TIME = "ВРЕМЯ ВЫБОРКИ:"; +constexpr const char *MENU_PWM_DUTY = "ЗАПОЛНЕНИЕ:"; +constexpr const char *MENU_TOTAL_TIME = "ОБЩЕЕ ВРЕМЯ:"; +constexpr const char *FREQUENCY_UNIT = " Гц"; + +constexpr const char *SLAVE_READY = "СЛЕЙВ ГОТОВ"; +constexpr const char *WAIT_MASTER = "ОЖИДАНИЕ МАСТЕРА"; +constexpr const char *LINK_FAILED = "СВЯЗЬ НЕ УСТАНОВЛЕНА"; +constexpr const char *RADIO_ERROR = "ОШИБКА СВЯЗИ"; +constexpr const char *MASTER_SEARCH = "ПОИСК СЛЕЙВА"; +constexpr const char *HOLD_START_STOP = "УДЕРЖ. START ДЛЯ СТОП"; +constexpr const char *MASTER_SEEN = "МАСТЕР ОБНАРУЖЕН"; +constexpr const char *ACK_SENT = "ОТВЕТ ОТПРАВЛЕН"; +constexpr const char *START_AGAIN = "ГОТОВ К ЗАПУСКУ"; +constexpr const char *TEST_FAILED = "ТЕСТ НЕ ПРОЙДЕН"; + +constexpr const char *PASS_WORD = "ТЕСТ ПРОЙДЕН"; +constexpr const char *FAIL_FORMAT = "СБОЙ %s %.0f%%"; +constexpr const char *TEST_FORMAT = "Тест:%-6s %2.0f%% %5s"; +constexpr const char *PERIOD_OUT_FORMAT = "ОШИБКА ЧАСТОТЫ %s"; +constexpr const char *DUTY_OUT_FORMAT = "ОШИБКА ЗАПОЛН. %s"; +constexpr const char *NO_MEASUREMENT = "F:--- D:---%"; + +#elif UI_LANGUAGE == UI_LANGUAGE_EN + +constexpr const char *ROLE_NAMES[] = { + "SOLO", "MASTER", "SLAVE" +}; + +constexpr const char *FAIL_NAMES[] = { + "NONE", + "NO SIGNAL", + "PERIOD OUT", + "DUTY OUT", + "EXTRA EDGE", + "GLITCH", + "LOST EDGE", + "DATA LOSS ERROR", + "LINK LOST", + "UNSUPPORTED", + "RESOLUTION", + "ABORTED" +}; + +constexpr const char *MODE_PREFIX = "MODE: "; +constexpr const char *START_RUN = "READY TO START"; + +constexpr const char *MENU_START_FREQUENCY = "START FREQ:"; +constexpr const char *MENU_END_FREQUENCY = "END FREQ:"; +constexpr const char *MENU_ACCURACY = "ACCURACY:"; +constexpr const char *MENU_TEST_TIME = "TEST TIME:"; +constexpr const char *MENU_PWM_DUTY = "PWM DUTY:"; +constexpr const char *MENU_TOTAL_TIME = "TOTAL TIME:"; +constexpr const char *FREQUENCY_UNIT = " Hz"; + +constexpr const char *SLAVE_READY = "SLAVE READY"; +constexpr const char *WAIT_MASTER = "WAIT MASTER"; +constexpr const char *LINK_FAILED = "LINK FAILED"; +constexpr const char *RADIO_ERROR = "RADIO ERROR"; +constexpr const char *MASTER_SEARCH = "MASTER SEARCH"; +constexpr const char *HOLD_START_STOP = "HOLD START TO STOP"; +constexpr const char *MASTER_SEEN = "MASTER SEEN"; +constexpr const char *ACK_SENT = "ACK SENT"; +constexpr const char *START_AGAIN = "READY TO START"; +constexpr const char *TEST_FAILED = "TEST FAILED"; + +constexpr const char *PASS_WORD = "TEST PASS"; +constexpr const char *FAIL_FORMAT = "FAIL %s %.0f%%"; +constexpr const char *TEST_FORMAT = "Test:%-6s %2.0f%% %5s"; +constexpr const char *PERIOD_OUT_FORMAT = "PERIOD OUT %s"; +constexpr const char *DUTY_OUT_FORMAT = "DUTY OUT %s"; +constexpr const char *NO_MEASUREMENT = "F:--- D:---%"; + +#else +#error "UI_LANGUAGE must be UI_LANGUAGE_EN or UI_LANGUAGE_RU" +#endif + +} // namespace UiText diff --git a/OpticalChannelTester/Core.cpp b/OpticalChannelTester/Core.cpp index 9abb4e1..80e5417 100644 --- a/OpticalChannelTester/Core.cpp +++ b/OpticalChannelTester/Core.cpp @@ -11,7 +11,7 @@ const char *roleName(Role r) { const char *failName(FailReason r) { static const char *names[] = {"NONE", "NO SIGNAL", "PERIOD OUT", "DUTY OUT", - "EXTRA EDGE", "GLITCH", "LOST EDGE", "TOO FEW PERIODS", "LINK LOST", + "EXTRA EDGE", "GLITCH", "LOST EDGE", "DATA LOSS ERROR", "LINK LOST", "UNSUPPORTED", "RESOLUTION", "ABORTED"}; const uint8_t i = static_cast(r); return i < (sizeof(names) / sizeof(names[0])) ? names[i] : "UNKNOWN"; diff --git a/OpticalChannelTester/Core.h b/OpticalChannelTester/Core.h index 78c4aaa..38dbea8 100644 --- a/OpticalChannelTester/Core.h +++ b/OpticalChannelTester/Core.h @@ -6,7 +6,7 @@ enum class Role : uint8_t { SOLO, MASTER, SLAVE }; enum class FailReason : uint8_t { NONE, NO_SIGNAL, PERIOD_OUT, DUTY_OUT, EXTRA_EDGE, GLITCH, LOST_EDGE, - TOO_FEW_PERIODS, LINK_LOST, UNSUPPORTED, RESOLUTION, ABORTED + DATA_LOSS, LINK_LOST, UNSUPPORTED, RESOLUTION, ABORTED }; const char *roleName(Role role); diff --git a/OpticalChannelTester/Display.cpp b/OpticalChannelTester/Display.cpp index 89e6264..755917e 100644 --- a/OpticalChannelTester/Display.cpp +++ b/OpticalChannelTester/Display.cpp @@ -1,10 +1,26 @@ #include "Display.h" #include "Config.h" +#include "Font_Cyrillic.h" #include "Log.h" #include #include #include +namespace { +uint32_t nextUtf8Codepoint(const char *&text) { + const uint8_t first = static_cast(*text++); + if (first < 0x80U) return first; + if ((first & 0xE0U) == 0xC0U) { + const uint8_t second = static_cast(*text); + if ((second & 0xC0U) == 0x80U) { + ++text; + return ((first & 0x1FU) << 6) | (second & 0x3FU); + } + } + return '?'; +} +} + Display::Display() : oled_(128, 32, &Wire, -1) {} bool Display::begin() { @@ -37,25 +53,38 @@ void Display::setPower(bool enabled) { Log::printf("OLED", "display power %s", enabled ? "ON" : "OFF"); } -void Display::fit(char *s) { - int16_t x, y; uint16_t w, h; - while (*s) { - oled_.getTextBounds(s, 0, 0, &x, &y, &w, &h); - if (w <= 128) break; - s[strlen(s) - 1] = '\0'; +void Display::drawTextLine(const char *text, int16_t y) { + int16_t x = 0; + while (text && *text && x + CyrillicFont::WIDTH <= oled_.width()) { + const uint32_t codepoint = nextUtf8Codepoint(text); + const uint8_t *glyph = CyrillicFont::glyph(codepoint); + if (glyph) { + for (uint8_t row = 0; row < CyrillicFont::HEIGHT; ++row) { + const uint8_t pixels = pgm_read_byte(glyph + row); + for (uint8_t column = 0; column < CyrillicFont::WIDTH; ++column) + if (pixels & (1U << (CyrillicFont::WIDTH - 1U - column))) + oled_.drawPixel(x + column, y + row, SSD1306_WHITE); + } + } else if (codepoint < 0x100U) { + oled_.drawChar(x, y, static_cast(codepoint), + SSD1306_WHITE, SSD1306_BLACK, 1); + } else { + oled_.drawChar(x, y, '?', SSD1306_WHITE, SSD1306_BLACK, 1); + } + x += CyrillicFont::ADVANCE; } } void Display::show(const char *a, const char *b, uint32_t progress, uint32_t progressTotal) { - char one[32], two[32]; + char one[64], two[64]; snprintf(one, sizeof(one), "%s", a ? a : ""); snprintf(two, sizeof(two), "%s", b ? b : ""); // Serial is the primary UI mirror and remains available when OLED is absent. Log::printf("UI", "%s | %s", one, two); if (!ok_) return; setPower(true); - fit(one); fit(two); - oled_.clearDisplay(); oled_.setCursor(0, 3); oled_.print(one); - oled_.setCursor(0, 19); oled_.print(two); + oled_.clearDisplay(); + drawTextLine(one, 3); + drawTextLine(two, 19); if (progressTotal) { if (progress > progressTotal) progress = progressTotal; const uint16_t width = static_cast( diff --git a/OpticalChannelTester/Display.h b/OpticalChannelTester/Display.h index 9647dcb..a3cdecd 100644 --- a/OpticalChannelTester/Display.h +++ b/OpticalChannelTester/Display.h @@ -14,9 +14,9 @@ class Display { bool powered() const { return powered_; } static void formatFrequency(float hz, char *out, size_t size); static void formatTestFrequency(uint32_t hz, char *out, size_t size); - static void formatDuration(uint64_t us, char *out, size_t size); + static void formatDuration(uint64_t us, char *out, size_t size); private: - void fit(char *text); + void drawTextLine(const char *text, int16_t y); Adafruit_SSD1306 oled_; bool ok_ = false; bool powered_ = false; diff --git a/OpticalChannelTester/Font_Cyrillic.h b/OpticalChannelTester/Font_Cyrillic.h new file mode 100644 index 0000000..a252269 --- /dev/null +++ b/OpticalChannelTester/Font_Cyrillic.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +// Compact 5x7 uppercase Cyrillic font. Lowercase UTF-8 letters are deliberately +// rendered with the same uppercase glyphs: at 5x7 pixels this is considerably +// clearer than trying to preserve lowercase shapes. +namespace CyrillicFont { + +constexpr uint8_t WIDTH = 5; +constexpr uint8_t HEIGHT = 7; +constexpr uint8_t ADVANCE = 6; + +// Rows are stored top-to-bottom; the low five bits are pixels left-to-right. +const uint8_t GLYPHS[][HEIGHT] PROGMEM = { + {0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11}, // А + {0x1F, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x1E}, // Б + {0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E}, // В + {0x1F, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10}, // Г + {0x0E, 0x0A, 0x0A, 0x0A, 0x11, 0x1F, 0x11}, // Д + {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F}, // Е + {0x15, 0x15, 0x0E, 0x04, 0x0E, 0x15, 0x15}, // Ж + {0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E}, // З + {0x11, 0x13, 0x15, 0x15, 0x19, 0x11, 0x11}, // И + {0x0A, 0x11, 0x13, 0x15, 0x19, 0x11, 0x11}, // Й + {0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11}, // К + {0x07, 0x09, 0x09, 0x09, 0x11, 0x11, 0x11}, // Л + {0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11}, // М + {0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11}, // Н + {0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E}, // О + {0x1F, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11}, // П + {0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10}, // Р + {0x0F, 0x10, 0x10, 0x10, 0x10, 0x10, 0x0F}, // С + {0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04}, // Т + {0x11, 0x11, 0x11, 0x0F, 0x01, 0x11, 0x0E}, // У + {0x04, 0x0E, 0x15, 0x15, 0x0E, 0x04, 0x04}, // Ф + {0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11}, // Х + {0x12, 0x12, 0x12, 0x12, 0x12, 0x1E, 0x01}, // Ц + {0x11, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x01}, // Ч + {0x15, 0x15, 0x15, 0x15, 0x15, 0x15, 0x1F}, // Ш + {0x15, 0x15, 0x15, 0x15, 0x15, 0x1E, 0x01}, // Щ + {0x18, 0x08, 0x08, 0x0E, 0x09, 0x09, 0x0E}, // Ъ + {0x11, 0x11, 0x11, 0x1D, 0x15, 0x15, 0x1D}, // Ы + {0x10, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x1E}, // Ь + {0x0E, 0x11, 0x01, 0x07, 0x01, 0x11, 0x0E}, // Э + {0x12, 0x15, 0x15, 0x1D, 0x15, 0x15, 0x12}, // Ю + {0x0F, 0x11, 0x11, 0x0F, 0x05, 0x09, 0x11} // Я +}; + +inline const uint8_t *glyph(uint32_t codepoint) { + if (codepoint >= 0x0430U && codepoint <= 0x044FU) codepoint -= 0x20U; + if (codepoint == 0x0401U || codepoint == 0x0451U) codepoint = 0x0415U; // Ё -> Е + if (codepoint < 0x0410U || codepoint > 0x042FU) return nullptr; + return GLYPHS[codepoint - 0x0410U]; +} + +} // namespace CyrillicFont diff --git a/OpticalChannelTester/Measurement.cpp b/OpticalChannelTester/Measurement.cpp index c372448..102a7c2 100644 --- a/OpticalChannelTester/Measurement.cpp +++ b/OpticalChannelTester/Measurement.cpp @@ -57,7 +57,7 @@ void Measurement::completeMeasurement() { return; } if (!stats_.periods) { - fail(FailReason::TOO_FEW_PERIODS); return; + fail(FailReason::DATA_LOSS); return; } state_ = MeasureState::PASS; } diff --git a/README.md b/README.md index 22108fe..0464991 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ MASTER <~~~~ ESP-NOW Wi-Fi channel 6 ~~~~> SLAVE ### Энергосбережение -В ожидании процессор работает на 80 МГц, во время теста и связанной MASTER/SLAVE-сессии — на 160 МГц. Через две минуты бездействия в `IDLE`, `MENU`, `FINISHED` или `SLAVE READY` OLED выключается, а ESP переходит в короткие циклы light sleep. Экран и текущее состояние сохраняются. +В ожидании процессор работает на 80 МГц, во время теста и связанной MASTER/SLAVE-сессии — на 160 МГц. После тайм-аута бездействия в `IDLE`, `MENU`, `FINISHED` или `SLAVE READY` OLED выключается, а ESP переходит в короткие циклы light sleep. Экран и текущее состояние сохраняются. При использовании встроенного USB Serial/JTAG для следующей прошивки может потребоваться ручной вход в загрузчик кнопкой BOOT. START и MODE будят устройство. Пробуждающее нажатие намеренно не выполняет действие и полностью игнорируется до отпускания кнопки; действие выполняет только следующее нажатие. Принятый `DISCOVER` также немедленно выводит Slave из энергосбережения. Тайм-аут задаётся `IDLE_POWER_SAVE_TIMEOUT_MS` в `Config.h`. @@ -192,7 +192,7 @@ max(TEST_TIME, periods / RX_PROCESSING_PERIODS_PER_SECOND) Незавершённый период в начале и конце окна не учитывается. Период, пересекающий границу повторов, не теряется. Для каждого периода отдельно вычисляются частота и duty; средние используются только для диагностики. Любой один выход за допуск немедленно завершает всю проверку. -Причины: `NO SIGNAL`, `PERIOD OUT`, `DUTY OUT`, `EXTRA EDGE`, `GLITCH`, `LOST EDGE`, `TOO FEW PERIODS`, `LINK LOST`, `UNSUPPORTED`, `RESOLUTION`, `ABORTED`. +Причины: `NO SIGNAL`, `PERIOD OUT`, `DUTY OUT`, `EXTRA EDGE`, `GLITCH`, `LOST EDGE`, `DATA LOSS ERROR`, `LINK LOST`, `UNSUPPORTED`, `RESOLUTION`, `ABORTED`. ### Пределы