From 8ccb1732f2bd1e1ce15de00a6064bf99c32b3ff8 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Mon, 23 Mar 2026 14:22:25 +0800 Subject: [PATCH] Refine gat562 mono UI and persist UI settings --- .github/workflows/ci.yml | 2 + .../gat562_mesh_evb_pro/protocol_factory.h | 4 +- .../src/app_facade_runtime.cpp | 3 +- .../src/protocol_factory.cpp | 6 +- apps/gat562_mesh_evb_pro/src/ui_runtime.cpp | 75 ++ .../boards/gat562_mesh_evb_pro/gat562_board.h | 1 + .../gat562_mesh_evb_pro/settings_store.h | 20 + boards/gat562_mesh_evb_pro/library.json | 4 +- .../gat562_mesh_evb_pro/src/gat562_board.cpp | 40 +- .../src/platform_ui_bindings.cpp | 82 +- .../src/settings_store.cpp | 394 +++++++- .../include/ui/mono_128x64/runtime.h | 29 +- modules/ui_mono_128x64/src/runtime.cpp | 852 +++++++++++++++++- modules/ui_mono_128x64/src/text_renderer.cpp | 15 +- .../meshtastic/meshtastic_radio_adapter.h | 5 +- .../include/ui/fonts/fusion_pixel_10_font.h | 10 + .../ui/fonts/fusion_pixel_10_font_generated.h | 10 + .../meshtastic/meshtastic_radio_adapter.cpp | 58 +- .../src/platform_ui_settings_store.cpp | 270 +++++- .../src/ui/fonts/fusion_pixel_10_font.cpp | 26 + .../ui/fonts/fusion_pixel_10_font_data.cpp | 173 ++++ .../src/ui/fonts/fusion_pixel_8_font.cpp | 8 + .../src/ui/fonts/fusion_pixel_8_font_data.cpp | 8 + .../tools/gen_scaled_fusion_pixel_font.py | 174 ++++ .../envs/gat562_mesh_evb_pro.ini | 2 + 25 files changed, 2188 insertions(+), 83 deletions(-) create mode 100644 platform/nrf52/arduino_common/include/ui/fonts/fusion_pixel_10_font.h create mode 100644 platform/nrf52/arduino_common/include/ui/fonts/fusion_pixel_10_font_generated.h create mode 100644 platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_10_font.cpp create mode 100644 platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_10_font_data.cpp create mode 100644 platform/nrf52/arduino_common/tools/gen_scaled_fusion_pixel_font.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6d76e28..99911eaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: git ls-files -z '*.c' '*.cpp' '*.h' '*.hpp' ':(exclude)modules/core_chat/generated/**' \ ':(exclude)modules/ui_shared/include/ui/assets/**' \ ':(exclude)modules/ui_shared/src/ui/assets/**' \ + ':(exclude)platform/nrf52/arduino_common/include/ui/fonts/**' \ + ':(exclude)platform/nrf52/arduino_common/src/ui/fonts/**' \ ':(exclude)third_party/codec2/**' \ | xargs -0 clang-format-14 --dry-run --Werror diff --git a/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/protocol_factory.h b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/protocol_factory.h index 5dd13abd..60012ae1 100644 --- a/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/protocol_factory.h +++ b/apps/gat562_mesh_evb_pro/include/apps/gat562_mesh_evb_pro/protocol_factory.h @@ -2,6 +2,7 @@ #include "chat/domain/chat_types.h" #include "chat/runtime/self_identity_provider.h" +#include "chat/usecase/contact_service.h" #include "platform/nrf52/arduino_common/chat/infra/meshtastic/node_store.h" #include @@ -16,6 +17,7 @@ namespace apps::gat562_mesh_evb_pro std::unique_ptr createProtocolAdapter(chat::MeshProtocol protocol, const chat::runtime::SelfIdentityProvider* identity_provider, - platform::nrf52::arduino_common::chat::meshtastic::NodeStore* meshtastic_node_store = nullptr); + platform::nrf52::arduino_common::chat::meshtastic::NodeStore* meshtastic_node_store = nullptr, + chat::contacts::ContactService* contact_service = nullptr); } // namespace apps::gat562_mesh_evb_pro diff --git a/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp b/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp index b6250402..b84970c3 100644 --- a/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp +++ b/apps/gat562_mesh_evb_pro/src/app_facade_runtime.cpp @@ -185,7 +185,8 @@ void AppFacadeRuntime::initializeChatRuntime() (void)installMeshBackend(chat::MeshProtocol::Meshtastic, createProtocolAdapter(chat::MeshProtocol::Meshtastic, identityProvider(), - static_cast(node_store_.get()))); + static_cast(node_store_.get()), + contact_service_.get())); (void)installMeshBackend(chat::MeshProtocol::MeshCore, createProtocolAdapter(chat::MeshProtocol::MeshCore, identityProvider())); diff --git a/apps/gat562_mesh_evb_pro/src/protocol_factory.cpp b/apps/gat562_mesh_evb_pro/src/protocol_factory.cpp index 0ea186db..55c47391 100644 --- a/apps/gat562_mesh_evb_pro/src/protocol_factory.cpp +++ b/apps/gat562_mesh_evb_pro/src/protocol_factory.cpp @@ -8,7 +8,8 @@ namespace apps::gat562_mesh_evb_pro std::unique_ptr createProtocolAdapter(chat::MeshProtocol protocol, const chat::runtime::SelfIdentityProvider* identity_provider, - platform::nrf52::arduino_common::chat::meshtastic::NodeStore* meshtastic_node_store) + platform::nrf52::arduino_common::chat::meshtastic::NodeStore* meshtastic_node_store, + chat::contacts::ContactService* contact_service) { switch (protocol) { @@ -19,7 +20,8 @@ std::unique_ptr createProtocolAdapter(chat::MeshProtocol pro default: return std::unique_ptr( new platform::nrf52::arduino_common::chat::meshtastic::MeshtasticRadioAdapter(identity_provider, - meshtastic_node_store)); + meshtastic_node_store, + contact_service)); } } diff --git a/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp b/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp index 27edb3fe..e2c19b24 100644 --- a/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp +++ b/apps/gat562_mesh_evb_pro/src/ui_runtime.cpp @@ -11,6 +11,7 @@ #include "ui/mono_128x64/runtime.h" #include +#include #include namespace apps::gat562_mesh_evb_pro::ui_runtime @@ -19,11 +20,16 @@ namespace { using boards::gat562_mesh_evb_pro::BoardInputEvent; using boards::gat562_mesh_evb_pro::BoardInputKey; +using Adafruit_LittleFS_Namespace::File; constexpr uint32_t kProbeHoldMs = 900; +constexpr uint32_t kGat562TotalRamBytes = 248832U; +constexpr uint32_t kGat562FsTotalBytes = 7U * 4096U; const char kProbeAscii[] = "ABC123"; const char kProbeCjk[] = "\xE4\xB8\xAD\xE6\x96\x87"; const char kProbeSymbols[] = "\xE2\x94\x80\xE2\x96\x88\xE2\x96\xA0"; +extern "C" char* sbrk(int incr); + uint32_t now_ms() { return millis(); } time_t utc_now() { return static_cast(sys::epoch_seconds_now()); } @@ -37,6 +43,73 @@ bool format_freq(uint32_t freq_hz, char* out, size_t out_len) return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().formatLoraFrequencyMHz(freq_hz, out, out_len); } +ui::mono_128x64::HostCallbacks::ResourceUsage ram_usage() +{ + ui::mono_128x64::HostCallbacks::ResourceUsage usage{}; + usage.available = true; + usage.total_bytes = kGat562TotalRamBytes; + + char stack_marker = 0; + const uintptr_t stack_ptr = reinterpret_cast(&stack_marker); + const uintptr_t heap_ptr = reinterpret_cast(sbrk(0)); + if (stack_ptr > heap_ptr) + { + const uint32_t free_bytes = static_cast(stack_ptr - heap_ptr); + usage.used_bytes = usage.total_bytes > free_bytes ? (usage.total_bytes - free_bytes) : 0U; + } + return usage; +} + +uint32_t accumulateFsBytes(File dir) +{ + uint32_t total = 0; + if (!dir) + { + return total; + } + + dir.rewindDirectory(); + while (true) + { + File entry = dir.openNextFile(); + if (!entry) + { + break; + } + if (entry.isDirectory()) + { + total += accumulateFsBytes(entry); + } + else + { + total += entry.size(); + } + entry.close(); + } + return total; +} + +ui::mono_128x64::HostCallbacks::ResourceUsage flash_usage() +{ + ui::mono_128x64::HostCallbacks::ResourceUsage usage{}; + if (!InternalFS.begin()) + { + return usage; + } + + File root = InternalFS.open("/"); + if (!root) + { + return usage; + } + + usage.available = true; + usage.used_bytes = accumulateFsBytes(root); + usage.total_bytes = kGat562FsTotalBytes; + root.close(); + return usage; +} + ui::mono_128x64::InputAction to_input_action( const BoardInputEvent* event) { @@ -126,6 +199,8 @@ bool initialize() callbacks.gps_data_fn = platform::ui::gps::get_data; callbacks.gps_enabled_fn = platform::ui::gps::is_enabled; callbacks.gps_powered_fn = platform::ui::gps::is_powered; + callbacks.ram_usage_fn = ram_usage; + callbacks.flash_usage_fn = flash_usage; static ui::mono_128x64::Runtime runtime(::boards::gat562_mesh_evb_pro::Gat562Board::instance().monoDisplay(), callbacks); diff --git a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h index e99c94d3..8f17509e 100644 --- a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h +++ b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/gat562_board.h @@ -81,6 +81,7 @@ class Gat562Board final : public BoardBase void wakeUp() override; void handlePowerButton() override; void softwareShutdown() override; + int getPowerTier() const override; void setBrightness(uint8_t level) override; uint8_t getBrightness() override; diff --git a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/settings_store.h b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/settings_store.h index dd114e7c..0db34b54 100644 --- a/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/settings_store.h +++ b/boards/gat562_mesh_evb_pro/include/boards/gat562_mesh_evb_pro/settings_store.h @@ -7,10 +7,30 @@ namespace boards::gat562_mesh_evb_pro::settings_store { +enum class StoreStatus : uint8_t +{ + Ok = 0, + NotFound, + FsInitFailed, + OpenFailed, + ReadFailed, + WriteFailed, + FlushFailed, + HeaderInvalid, + VersionMismatch, + PayloadSizeMismatch, + CrcMismatch, + RenameFailed, + BackupFailed, +}; + void normalizeConfig(app::AppConfig& config); bool loadAppConfig(app::AppConfig& config); bool saveAppConfig(const app::AppConfig& config); uint8_t loadMessageToneVolume(); bool saveMessageToneVolume(uint8_t volume); +StoreStatus lastLoadStatus(); +StoreStatus lastSaveStatus(); +const char* statusLabel(StoreStatus status); } // namespace boards::gat562_mesh_evb_pro::settings_store diff --git a/boards/gat562_mesh_evb_pro/library.json b/boards/gat562_mesh_evb_pro/library.json index cc3c9fd3..af160b03 100644 --- a/boards/gat562_mesh_evb_pro/library.json +++ b/boards/gat562_mesh_evb_pro/library.json @@ -27,7 +27,9 @@ "-I../../.pio/libdeps/gat562_mesh_evb_pro/TinyGPSPlus/src", "-I../../.pio/libdeps/gat562_mesh_evb_pro/Adafruit\\ GFX\\ Library", "-I../../.pio/libdeps/gat562_mesh_evb_pro/Adafruit\\ SSD1306", - "-I../../.pio/libdeps/gat562_mesh_evb_pro/Adafruit\\ BusIO" + "-I../../.pio/libdeps/gat562_mesh_evb_pro/Adafruit\\ BusIO", + "-IC:/Users/VicLi/.platformio/packages/framework-arduinoadafruitnrf52/libraries/Adafruit_LittleFS/src", + "-IC:/Users/VicLi/.platformio/packages/framework-arduinoadafruitnrf52/libraries/InternalFileSytem/src" ] } } diff --git a/boards/gat562_mesh_evb_pro/src/gat562_board.cpp b/boards/gat562_mesh_evb_pro/src/gat562_board.cpp index ffcc3b61..419d34b7 100644 --- a/boards/gat562_mesh_evb_pro/src/gat562_board.cpp +++ b/boards/gat562_mesh_evb_pro/src/gat562_board.cpp @@ -447,10 +447,13 @@ void Gat562Board::enablePeripheralRail() void Gat562Board::wakeUp() { + enablePeripheralRail(); + setStatusLed(false); } void Gat562Board::handlePowerButton() { + pulseNotificationLed(40); } void Gat562Board::softwareShutdown() @@ -458,6 +461,24 @@ void Gat562Board::softwareShutdown() NVIC_SystemReset(); } +int Gat562Board::getPowerTier() const +{ + const int level = const_cast(this)->readBatteryPercent(); + if (level < 0) + { + return 0; + } + if (level <= 10) + { + return 2; + } + if (level <= 20) + { + return 1; + } + return 0; +} + void Gat562Board::setBrightness(uint8_t level) { brightness_ = static_cast( @@ -544,6 +565,9 @@ void Gat562Board::vibrator() void Gat562Board::stopVibrator() { + const auto& leds = kBoardProfile.leds; + const int pin = leds.notification_shares_status ? leds.status : leds.notification; + writeLed(pin, leds.active_high, false); } void Gat562Board::playMessageTone() @@ -709,21 +733,23 @@ bool Gat562Board::pollInputEvent(BoardInputEvent* out_event) namespace { +constexpr int kMonoScreenWidth = 128; +constexpr int kMonoScreenHeight = 64; class Ssd1306MonoDisplay final : public ::ui::mono_128x64::MonoDisplay { public: Ssd1306MonoDisplay() - : display_(SCREEN_WIDTH, - SCREEN_HEIGHT, + : display_(kMonoScreenWidth, + kMonoScreenHeight, &::boards::gat562_mesh_evb_pro::Gat562Board::instance().i2cWire(), -1) { } bool begin() override; - int width() const override { return SCREEN_WIDTH; } - int height() const override { return SCREEN_HEIGHT; } + int width() const override { return kMonoScreenWidth; } + int height() const override { return kMonoScreenHeight; } void clear() override { if (online_) @@ -1001,19 +1027,19 @@ bool Gat562Board::gpsGnssSnapshot(::gps::GnssSatInfo* out, { *status = s_gps.status; } - if (out && max > 0 && s_gps.data.valid) + if (out && max > 0 && s_gps.data.satellites > 0) { out[0].id = 0; out[0].sys = ::gps::GnssSystem::GPS; out[0].snr = -1; - out[0].used = true; + out[0].used = s_gps.data.valid; if (out_count) { *out_count = 1; } return true; } - return s_gps.data.valid; + return s_gps.data.valid || s_gps.data.satellites > 0; } void Gat562Board::setGpsCollectionInterval(uint32_t interval_ms) { s_gps.collection_interval_ms = interval_ms; } diff --git a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp index a38c5f7a..66008381 100644 --- a/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp +++ b/boards/gat562_mesh_evb_pro/src/platform_ui_bindings.cpp @@ -10,6 +10,84 @@ namespace platform::ui::device { +namespace +{ + +struct BatteryUiFilterState +{ + bool initialized = false; + uint32_t last_sample_ms = 0; + int stable_level = -1; +}; + +BatteryUiFilterState s_battery_filter{}; + +int applyBatteryUiFilter(int raw_level) +{ + if (raw_level < 0) + { + return raw_level; + } + + constexpr uint32_t kSampleIntervalMs = 1500; + constexpr int kDisplayHysteresis = 2; + const uint32_t now_ms = millis(); + + if (!s_battery_filter.initialized) + { + s_battery_filter.initialized = true; + s_battery_filter.last_sample_ms = now_ms; + s_battery_filter.stable_level = raw_level; + return raw_level; + } + + if ((now_ms - s_battery_filter.last_sample_ms) < kSampleIntervalMs) + { + return s_battery_filter.stable_level; + } + + s_battery_filter.last_sample_ms = now_ms; + const int delta = raw_level - s_battery_filter.stable_level; + const int abs_delta = delta >= 0 ? delta : -delta; + if (abs_delta >= kDisplayHysteresis) + { + s_battery_filter.stable_level = raw_level; + } + else + { + s_battery_filter.stable_level = (s_battery_filter.stable_level + raw_level) / 2; + } + + return s_battery_filter.stable_level; +} + +int readFilteredBatteryLevel(::boards::gat562_mesh_evb_pro::Gat562Board& board) +{ + constexpr int kBatterySamples = 6; + int valid_sum = 0; + int valid_count = 0; + for (int i = 0; i < kBatterySamples; ++i) + { + const int sample = board.getBatteryLevel(); + if (sample >= 0) + { + valid_sum += sample; + ++valid_count; + } + delay(2); + } + + if (valid_count == 0) + { + return -1; + } + + const int averaged = (valid_sum + (valid_count / 2)) / valid_count; + return applyBatteryUiFilter(averaged); +} + +} // namespace + void delay_ms(uint32_t ms) { delay(ms); @@ -31,7 +109,7 @@ BatteryInfo battery_info() BatteryInfo info{}; info.available = true; info.charging = board.isCharging(); - info.level = board.getBatteryLevel(); + info.level = readFilteredBatteryLevel(board); return info; } @@ -72,7 +150,7 @@ bool gps_ready() int power_tier() { - return 0; + return ::boards::gat562_mesh_evb_pro::Gat562Board::instance().getPowerTier(); } } // namespace platform::ui::device diff --git a/boards/gat562_mesh_evb_pro/src/settings_store.cpp b/boards/gat562_mesh_evb_pro/src/settings_store.cpp index 5d591322..fe7e158c 100644 --- a/boards/gat562_mesh_evb_pro/src/settings_store.cpp +++ b/boards/gat562_mesh_evb_pro/src/settings_store.cpp @@ -4,14 +4,53 @@ #include "chat/infra/meshcore/mc_region_presets.h" #include "chat/infra/meshtastic/mt_region.h" +#include +#include + +#include +#include + namespace boards::gat562_mesh_evb_pro::settings_store { namespace { +using Adafruit_LittleFS_Namespace::FILE_O_READ; +using Adafruit_LittleFS_Namespace::FILE_O_WRITE; -bool s_has_config = false; -app::AppConfig s_config{}; -uint8_t s_tone_volume = 45; +constexpr const char* kSettingsPath = "/gat562_settings.bin"; +constexpr const char* kSettingsTempPath = "/gat562_settings.bin.tmp"; +constexpr const char* kSettingsBackupPath = "/gat562_settings.bin.bak"; +constexpr const char* kSettingsCorruptPath = "/gat562_settings.bin.corrupt"; +constexpr uint32_t kSettingsMagic = 0x53415447UL; // GTAS +constexpr uint16_t kSettingsVersion = 1; +constexpr uint8_t kDefaultToneVolume = 45; + +struct PersistedPayload +{ + app::AppConfig config; + uint8_t tone_volume = kDefaultToneVolume; + uint8_t reserved[3] = {}; +}; + +struct FileHeader +{ + uint32_t magic = 0; + uint16_t version = 0; + uint16_t reserved = 0; + uint32_t payload_size = 0; + uint32_t crc32 = 0; +} __attribute__((packed)); + +struct CachedSettings +{ + app::AppConfig config; + uint8_t tone_volume = kDefaultToneVolume; +}; + +bool s_cache_loaded = false; +CachedSettings s_cache{}; +StoreStatus s_last_load_status = StoreStatus::NotFound; +StoreStatus s_last_save_status = StoreStatus::NotFound; int8_t clampTxPower(int8_t value) { @@ -26,6 +65,307 @@ int8_t clampTxPower(int8_t value) return value; } +uint8_t clampToneVolume(uint8_t volume) +{ + return static_cast(std::min(volume, 100U)); +} + +const char* statusText(StoreStatus status) +{ + switch (status) + { + case StoreStatus::Ok: + return "ok"; + case StoreStatus::NotFound: + return "not_found"; + case StoreStatus::FsInitFailed: + return "fs_init_failed"; + case StoreStatus::OpenFailed: + return "open_failed"; + case StoreStatus::ReadFailed: + return "read_failed"; + case StoreStatus::WriteFailed: + return "write_failed"; + case StoreStatus::FlushFailed: + return "flush_failed"; + case StoreStatus::HeaderInvalid: + return "header_invalid"; + case StoreStatus::VersionMismatch: + return "version_mismatch"; + case StoreStatus::PayloadSizeMismatch: + return "payload_size_mismatch"; + case StoreStatus::CrcMismatch: + return "crc_mismatch"; + case StoreStatus::RenameFailed: + return "rename_failed"; + case StoreStatus::BackupFailed: + return "backup_failed"; + default: + return "unknown"; + } +} + +bool ensureFs() +{ + if (InternalFS.begin()) + { + return true; + } + Serial.printf("[gat562][settings] fs init failed\n"); + return false; +} + +void removeIfExists(const char* path) +{ + if (path && InternalFS.exists(path)) + { + InternalFS.remove(path); + } +} + +uint32_t crc32(const uint8_t* data, size_t len) +{ + uint32_t crc = 0xFFFFFFFFU; + for (size_t i = 0; i < len; ++i) + { + crc ^= data[i]; + for (int bit = 0; bit < 8; ++bit) + { + crc = (crc & 1U) ? ((crc >> 1) ^ 0xEDB88320U) : (crc >> 1); + } + } + return ~crc; +} + +bool quarantineCorruptFile(StoreStatus status) +{ + if (!InternalFS.exists(kSettingsPath)) + { + return true; + } + + removeIfExists(kSettingsCorruptPath); + if (InternalFS.rename(kSettingsPath, kSettingsCorruptPath)) + { + Serial.printf("[gat562][settings] quarantined corrupt store status=%s path=%s\n", + statusText(status), + kSettingsCorruptPath); + return true; + } + + Serial.printf("[gat562][settings] failed to quarantine corrupt store status=%s\n", + statusText(status)); + return false; +} + +void resetCacheToDefaults() +{ + s_cache = CachedSettings{}; + s_cache.tone_volume = kDefaultToneVolume; + normalizeConfig(s_cache.config); +} + +bool loadFromFs() +{ + if (!ensureFs()) + { + s_last_load_status = StoreStatus::FsInitFailed; + return false; + } + + if (!InternalFS.exists(kSettingsPath)) + { + s_last_load_status = StoreStatus::NotFound; + return false; + } + + auto file = InternalFS.open(kSettingsPath, FILE_O_READ); + if (!file) + { + s_last_load_status = StoreStatus::OpenFailed; + Serial.printf("[gat562][settings] open failed path=%s\n", kSettingsPath); + return false; + } + + const uint32_t expected_size = static_cast(sizeof(FileHeader) + sizeof(PersistedPayload)); + const uint32_t actual_size = file.size(); + if (actual_size != expected_size) + { + file.close(); + s_last_load_status = StoreStatus::PayloadSizeMismatch; + (void)quarantineCorruptFile(s_last_load_status); + Serial.printf("[gat562][settings] size mismatch actual=%lu expected=%lu\n", + static_cast(actual_size), + static_cast(expected_size)); + return false; + } + + FileHeader header{}; + if (file.read(&header, sizeof(header)) != sizeof(header)) + { + file.close(); + s_last_load_status = StoreStatus::ReadFailed; + Serial.printf("[gat562][settings] header read failed\n"); + return false; + } + + if (header.magic != kSettingsMagic) + { + file.close(); + s_last_load_status = StoreStatus::HeaderInvalid; + (void)quarantineCorruptFile(s_last_load_status); + Serial.printf("[gat562][settings] magic mismatch got=0x%08lX expected=0x%08lX\n", + static_cast(header.magic), + static_cast(kSettingsMagic)); + return false; + } + + if (header.version != kSettingsVersion) + { + file.close(); + s_last_load_status = StoreStatus::VersionMismatch; + (void)quarantineCorruptFile(s_last_load_status); + Serial.printf("[gat562][settings] version mismatch got=%u expected=%u\n", + static_cast(header.version), + static_cast(kSettingsVersion)); + return false; + } + + if (header.payload_size != sizeof(PersistedPayload)) + { + file.close(); + s_last_load_status = StoreStatus::PayloadSizeMismatch; + (void)quarantineCorruptFile(s_last_load_status); + Serial.printf("[gat562][settings] payload size mismatch got=%lu expected=%lu\n", + static_cast(header.payload_size), + static_cast(sizeof(PersistedPayload))); + return false; + } + + PersistedPayload payload{}; + if (file.read(&payload, sizeof(payload)) != sizeof(payload)) + { + file.close(); + s_last_load_status = StoreStatus::ReadFailed; + Serial.printf("[gat562][settings] payload read failed\n"); + return false; + } + file.close(); + + const uint32_t actual_crc = crc32(reinterpret_cast(&payload), sizeof(payload)); + if (actual_crc != header.crc32) + { + s_last_load_status = StoreStatus::CrcMismatch; + (void)quarantineCorruptFile(s_last_load_status); + Serial.printf("[gat562][settings] crc mismatch got=0x%08lX expected=0x%08lX\n", + static_cast(actual_crc), + static_cast(header.crc32)); + return false; + } + + s_cache.config = payload.config; + normalizeConfig(s_cache.config); + s_cache.tone_volume = clampToneVolume(payload.tone_volume); + s_last_load_status = StoreStatus::Ok; + Serial.printf("[gat562][settings] load ok tone=%u ble=%u proto=%u\n", + static_cast(s_cache.tone_volume), + static_cast(s_cache.config.ble_enabled ? 1 : 0), + static_cast(s_cache.config.mesh_protocol)); + return true; +} + +bool saveToFs() +{ + if (!ensureFs()) + { + s_last_save_status = StoreStatus::FsInitFailed; + return false; + } + + removeIfExists(kSettingsTempPath); + + auto file = InternalFS.open(kSettingsTempPath, FILE_O_WRITE); + if (!file) + { + s_last_save_status = StoreStatus::OpenFailed; + Serial.printf("[gat562][settings] temp open failed path=%s\n", kSettingsTempPath); + return false; + } + + PersistedPayload payload{}; + payload.config = s_cache.config; + payload.tone_volume = clampToneVolume(s_cache.tone_volume); + + FileHeader header{}; + header.magic = kSettingsMagic; + header.version = kSettingsVersion; + header.payload_size = sizeof(payload); + header.crc32 = crc32(reinterpret_cast(&payload), sizeof(payload)); + + const size_t header_written = file.write(reinterpret_cast(&header), sizeof(header)); + const size_t payload_written = header_written == sizeof(header) + ? file.write(reinterpret_cast(&payload), sizeof(payload)) + : 0U; + file.flush(); + const bool write_ok = (header_written == sizeof(header) && payload_written == sizeof(payload)); + file.close(); + + if (!write_ok) + { + removeIfExists(kSettingsTempPath); + s_last_save_status = StoreStatus::WriteFailed; + Serial.printf("[gat562][settings] write failed header=%lu payload=%lu\n", + static_cast(header_written), + static_cast(payload_written)); + return false; + } + + removeIfExists(kSettingsBackupPath); + if (InternalFS.exists(kSettingsPath) && !InternalFS.rename(kSettingsPath, kSettingsBackupPath)) + { + removeIfExists(kSettingsTempPath); + s_last_save_status = StoreStatus::BackupFailed; + Serial.printf("[gat562][settings] backup rename failed main=%s backup=%s\n", + kSettingsPath, + kSettingsBackupPath); + return false; + } + + if (!InternalFS.rename(kSettingsTempPath, kSettingsPath)) + { + if (InternalFS.exists(kSettingsBackupPath)) + { + (void)InternalFS.rename(kSettingsBackupPath, kSettingsPath); + } + removeIfExists(kSettingsTempPath); + s_last_save_status = StoreStatus::RenameFailed; + Serial.printf("[gat562][settings] commit rename failed temp=%s main=%s\n", + kSettingsTempPath, + kSettingsPath); + return false; + } + + removeIfExists(kSettingsBackupPath); + s_last_save_status = StoreStatus::Ok; + Serial.printf("[gat562][settings] save ok tone=%u ble=%u proto=%u\n", + static_cast(payload.tone_volume), + static_cast(payload.config.ble_enabled ? 1 : 0), + static_cast(payload.config.mesh_protocol)); + return true; +} + +void ensureCacheLoaded() +{ + if (s_cache_loaded) + { + return; + } + + resetCacheToDefaults(); + (void)loadFromFs(); + s_cache_loaded = true; +} + } // namespace void normalizeConfig(app::AppConfig& config) @@ -60,38 +400,56 @@ void normalizeConfig(app::AppConfig& config) { config.meshcore_config.meshcore_region_preset = 0; } + + if (config.gps_interval_ms == 0) + { + config.gps_interval_ms = 60000UL; + } } bool loadAppConfig(app::AppConfig& config) { - if (s_has_config) - { - config = s_config; - normalizeConfig(config); - return true; - } - + ensureCacheLoaded(); + config = s_cache.config; normalizeConfig(config); - return false; + s_cache.config = config; + return s_last_load_status == StoreStatus::Ok; } bool saveAppConfig(const app::AppConfig& config) { - s_config = config; - normalizeConfig(s_config); - s_has_config = true; - return true; + ensureCacheLoaded(); + s_cache.config = config; + normalizeConfig(s_cache.config); + return saveToFs(); } uint8_t loadMessageToneVolume() { - return s_tone_volume; + ensureCacheLoaded(); + return s_cache.tone_volume; } bool saveMessageToneVolume(uint8_t volume) { - s_tone_volume = volume; - return true; + ensureCacheLoaded(); + s_cache.tone_volume = clampToneVolume(volume); + return saveToFs(); +} + +StoreStatus lastLoadStatus() +{ + return s_last_load_status; +} + +StoreStatus lastSaveStatus() +{ + return s_last_save_status; +} + +const char* statusLabel(StoreStatus status) +{ + return statusText(status); } } // namespace boards::gat562_mesh_evb_pro::settings_store diff --git a/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h b/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h index 23574bc6..196c5f0f 100644 --- a/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h +++ b/modules/ui_mono_128x64/include/ui/mono_128x64/runtime.h @@ -1,6 +1,7 @@ #pragma once #include "app/app_facades.h" +#include "app/app_config.h" #include "chat/domain/contact_types.h" #include "chat/usecase/chat_service.h" #include "platform/ui/device_runtime.h" @@ -44,10 +45,19 @@ enum class InputAction : uint8_t struct HostCallbacks { + struct ResourceUsage + { + bool available = false; + uint32_t used_bytes = 0; + uint32_t total_bytes = 0; + }; + app::IAppFacade* app = nullptr; - // Platform-owned mono UI font. NRF is expected to provide Fusion Pixel 8px - // here so ASCII and CJK share one renderer and one asset boundary. + // Platform-owned mono UI font. NRF is expected to provide the selected + // Fusion Pixel mono asset here so the renderer stays decoupled from a + // specific platform font size. const MonoFont* ui_font = nullptr; + const MonoFont* accent_font = nullptr; uint32_t (*millis_fn)() = nullptr; time_t (*utc_now_fn)() = nullptr; int (*timezone_offset_min_fn)() = nullptr; @@ -58,6 +68,8 @@ struct HostCallbacks platform::ui::gps::GpsState (*gps_data_fn)() = nullptr; bool (*gps_enabled_fn)() = nullptr; bool (*gps_powered_fn)() = nullptr; + ResourceUsage (*ram_usage_fn)() = nullptr; + ResourceUsage (*flash_usage_fn)() = nullptr; void (*debug_log_fn)(const char* text) = nullptr; }; @@ -145,6 +157,7 @@ class Runtime : public chat::ChatService::IncomingTextObserver void renderInfoPage(); void renderGnssPage(); void renderActionPage(); + void renderSettingPopup(); void enterPage(Page page); void openCompose(EditTarget target, const char* seed_text = nullptr); @@ -160,6 +173,11 @@ class Runtime : public chat::ChatService::IncomingTextObserver void ensureSleepTimeout(InputAction action); void adjustRadioSetting(int delta); void adjustDeviceSetting(int delta); + void beginSettingPopup(Page owner, size_t index); + void cancelSettingPopup(); + void confirmSettingPopup(); + void adjustSettingPopup(int delta); + void formatSettingPopupValue(char* out, size_t out_len) const; void adjustComposeSelection(int delta); void adjustComposeCandidate(int delta); void adjustComposeAction(int delta); @@ -191,6 +209,7 @@ class Runtime : public chat::ChatService::IncomingTextObserver MonoDisplay& display_; TextRenderer text_renderer_; + TextRenderer accent_text_renderer_; HostCallbacks host_{}; bool initialized_ = false; Page page_ = Page::BootLog; @@ -208,6 +227,12 @@ class Runtime : public chat::ChatService::IncomingTextObserver size_t settings_menu_index_ = 0; size_t radio_index_ = 0; size_t device_index_ = 0; + bool setting_popup_active_ = false; + Page setting_popup_owner_ = Page::SettingsMenu; + size_t setting_popup_index_ = 0; + app::AppConfig setting_popup_config_{}; + bool setting_popup_ble_enabled_ = false; + int setting_popup_timezone_min_ = 0; size_t info_scroll_ = 0; size_t action_index_ = 0; size_t chat_list_index_ = 0; diff --git a/modules/ui_mono_128x64/src/runtime.cpp b/modules/ui_mono_128x64/src/runtime.cpp index feb1e369..4372f137 100644 --- a/modules/ui_mono_128x64/src/runtime.cpp +++ b/modules/ui_mono_128x64/src/runtime.cpp @@ -429,6 +429,304 @@ const char* bearingCardinal(double bearing_deg) return kDirs[index]; } +void drawFrame(MonoDisplay& display, int x, int y, int w, int h, bool filled = false) +{ + if (w <= 0 || h <= 0) + { + return; + } + + if (filled) + { + display.fillRect(x, y, w, h, true); + return; + } + + display.fillRect(x, y, w, 1, true); + display.fillRect(x, y + h - 1, w, 1, true); + display.fillRect(x, y, 1, h, true); + display.fillRect(x + w - 1, y, 1, h, true); +} + +void drawBatteryIcon(MonoDisplay& display, int x, int y, int level, bool charging) +{ + drawFrame(display, x, y, 14, 7); + display.fillRect(x + 14, y + 2, 2, 3, true); + const int fill_w = clampValue((level < 0 ? 0 : level), 0, 100) / 25; + for (int i = 0; i < fill_w; ++i) + { + display.fillRect(x + 2 + i * 3, y + 2, 2, 3, true); + } + if (charging) + { + display.fillRect(x + 6, y + 1, 1, 5, true); + display.drawPixel(x + 7, y + 2, true); + display.drawPixel(x + 5, y + 4, true); + } +} + +void drawMessageIcon(MonoDisplay& display, int x, int y, bool active) +{ + drawFrame(display, x, y + 1, 10, 7); + display.drawPixel(x + 1, y + 2, true); + display.drawPixel(x + 2, y + 3, true); + display.drawPixel(x + 3, y + 4, true); + display.drawPixel(x + 4, y + 5, true); + display.drawPixel(x + 8, y + 2, true); + display.drawPixel(x + 7, y + 3, true); + display.drawPixel(x + 6, y + 4, true); + display.drawPixel(x + 5, y + 5, true); + if (active) + { + display.fillRect(x + 11, y, 2, 2, true); + } +} + +void formatToggleLabel(char* out, size_t out_len, const char* name, bool enabled) +{ + if (!out || out_len == 0) + { + return; + } + std::snprintf(out, out_len, "%s %s", name ? name : "--", enabled ? "ON" : "OFF"); +} + +void drawClockSegmentH(MonoDisplay& display, int x, int y, int w) +{ + if (w < 6) + { + display.fillRect(x, y, w, 2, true); + return; + } + display.fillRect(x + 1, y, w - 2, 1, true); + display.fillRect(x, y + 1, w, 1, true); + display.drawPixel(x + 1, y + 2, true); + display.drawPixel(x + w - 2, y + 2, true); +} + +void drawClockSegmentV(MonoDisplay& display, int x, int y, int h) +{ + if (h < 6) + { + display.fillRect(x, y, 2, h, true); + return; + } + display.drawPixel(x + 1, y, true); + display.fillRect(x, y + 1, 2, h - 2, true); + display.drawPixel(x, y + h - 1, true); +} + +void drawClockDigit(MonoDisplay& display, int x, int y, char ch) +{ + if (ch == ':') + { + display.fillRect(x + 1, y + 5, 2, 2, true); + display.fillRect(x + 1, y + 13, 2, 2, true); + return; + } + + if (ch < '0' || ch > '9') + { + return; + } + + static constexpr uint8_t kDigitSegments[] = { + 0b1111110, // 0 + 0b0110000, // 1 + 0b1101101, // 2 + 0b1111001, // 3 + 0b0110011, // 4 + 0b1011011, // 5 + 0b1011111, // 6 + 0b1110000, // 7 + 0b1111111, // 8 + 0b1111011, // 9 + }; + + const uint8_t seg = kDigitSegments[ch - '0']; + constexpr int kDigitW = 8; + constexpr int kDigitH = 15; + constexpr int kMidY = 6; + constexpr int kBottomY = kDigitH - 3; + constexpr int kLeftX = 0; + constexpr int kRightX = kDigitW - 2; + constexpr int kSegW = 6; + constexpr int kSegH = 4; + + if (seg & 0b1000000) + { + drawClockSegmentH(display, x + 1, y, kSegW); + } + if (seg & 0b0100000) + { + drawClockSegmentV(display, x + kRightX, y + 1, kSegH); + } + if (seg & 0b0010000) + { + drawClockSegmentV(display, x + kRightX, y + 8, kSegH); + } + if (seg & 0b0001000) + { + drawClockSegmentH(display, x + 1, y + kBottomY, kSegW); + } + if (seg & 0b0000100) + { + drawClockSegmentV(display, x + kLeftX, y + 8, kSegH); + } + if (seg & 0b0000010) + { + drawClockSegmentV(display, x + kLeftX, y + 1, kSegH); + } + if (seg & 0b0000001) + { + drawClockSegmentH(display, x + 1, y + kMidY, kSegW); + } +} + +void splitClockText(const char* time_text, char* main_out, size_t main_len, char* sec_out, size_t sec_len) +{ + if (main_out && main_len > 0) + { + main_out[0] = '\0'; + } + if (sec_out && sec_len > 0) + { + sec_out[0] = '\0'; + } + if (!time_text || !main_out || main_len == 0) + { + return; + } + + if (std::strlen(time_text) >= 5) + { + std::snprintf(main_out, main_len, "%.5s", time_text); + } + else + { + std::snprintf(main_out, main_len, "%s", time_text); + } + + if (sec_out && sec_len > 0 && std::strlen(time_text) >= 8) + { + std::snprintf(sec_out, sec_len, "%.2s", time_text + 6); + } +} + +int clockGlyphWidth(char ch) +{ + return ch == ':' ? 2 : 8; +} + +int measureClockText(const char* text) +{ + if (!text || *text == '\0') + { + return 0; + } + + int width = 0; + for (const char* p = text; *p != '\0'; ++p) + { + if (p != text) + { + width += 2; + } + width += clockGlyphWidth(*p); + } + return width; +} + +void drawClockText(MonoDisplay& display, int x, int y, const char* text) +{ + if (!text) + { + return; + } + + int cursor_x = x; + for (const char* p = text; *p != '\0'; ++p) + { + drawClockDigit(display, cursor_x, y, *p); + cursor_x += clockGlyphWidth(*p) + 2; + } +} + +const char* signalRatingLabel(float snr, float rssi) +{ + if (snr >= 9.0f || rssi >= -90.0f) + { + return "STR"; + } + if (snr >= 4.0f || rssi >= -102.0f) + { + return "OK"; + } + if (snr > -2.0f || rssi >= -112.0f) + { + return "WEAK"; + } + return "POOR"; +} + +void formatElapsedShort(time_t now_s, uint32_t then_s, char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + if (then_s == 0 || now_s < static_cast(1700000000) || now_s < static_cast(then_s)) + { + std::snprintf(out, out_len, "-"); + return; + } + + uint32_t delta = static_cast(now_s - static_cast(then_s)); + if (delta < 60U) + { + std::snprintf(out, out_len, "%lus", static_cast(delta)); + } + else if (delta < 3600U) + { + std::snprintf(out, out_len, "%lum", static_cast(delta / 60U)); + } + else if (delta < 86400U) + { + std::snprintf(out, out_len, "%luh", static_cast(delta / 3600U)); + } + else + { + std::snprintf(out, out_len, "%lud", static_cast(delta / 86400U)); + } +} + +void formatDistanceShort(double meters, char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + if (!(meters >= 0.0)) + { + std::snprintf(out, out_len, "-"); + return; + } + if (meters < 1000.0) + { + std::snprintf(out, out_len, "%.0fm", meters); + } + else if (meters < 10000.0) + { + std::snprintf(out, out_len, "%.1fk", meters / 1000.0); + } + else + { + std::snprintf(out, out_len, "%.0fk", meters / 1000.0); + } +} + const char* gnssFixLabel(::gps::GnssFix fix) { switch (fix) @@ -647,6 +945,8 @@ const char* blePairingModeLabel(const ble::BlePairingStatus& status) Runtime::Runtime(MonoDisplay& display, const HostCallbacks& host) : display_(display), text_renderer_(host.ui_font ? *host.ui_font : builtin_ui_font()), + accent_text_renderer_(host.accent_font ? *host.accent_font + : (host.ui_font ? *host.ui_font : builtin_ui_font())), host_(host) { } @@ -787,6 +1087,27 @@ void Runtime::handleInput(InputAction action) last_interaction_ms_ = nowMs(); } + if (setting_popup_active_) + { + if (action == InputAction::Back) + { + cancelSettingPopup(); + } + else if (action == InputAction::Select || action == InputAction::Primary) + { + confirmSettingPopup(); + } + else if (action == InputAction::Up || action == InputAction::Right) + { + adjustSettingPopup(1); + } + else if (action == InputAction::Down || action == InputAction::Left) + { + adjustSettingPopup(-1); + } + return; + } + switch (page_) { case Page::MainMenu: @@ -1185,19 +1506,11 @@ void Runtime::handleInput(InputAction action) { ++radio_index_; } - else if (action == InputAction::Left) - { - adjustRadioSetting(-1); - } - else if (action == InputAction::Right) - { - adjustRadioSetting(1); - } - else if (action == InputAction::Back) + else if (action == InputAction::Left || action == InputAction::Back) { enterPage(Page::SettingsMenu); } - else if (action == InputAction::Select || action == InputAction::Primary) + else if (action == InputAction::Right || action == InputAction::Select || action == InputAction::Primary) { if (app()->getConfig().mesh_protocol == chat::MeshProtocol::MeshCore && radio_index_ + 1 == radioItemCount(app()->getConfig().mesh_protocol)) @@ -1206,7 +1519,7 @@ void Runtime::handleInput(InputAction action) } else { - adjustRadioSetting(1); + beginSettingPopup(Page::RadioSettings, radio_index_); } } break; @@ -1220,17 +1533,13 @@ void Runtime::handleInput(InputAction action) { ++device_index_; } - else if (action == InputAction::Left) + else if (action == InputAction::Left || action == InputAction::Back) { - adjustDeviceSetting(-1); + enterPage(Page::SettingsMenu); } else if (action == InputAction::Right || action == InputAction::Select || action == InputAction::Primary) { - adjustDeviceSetting(1); - } - else if (action == InputAction::Back) - { - enterPage(Page::SettingsMenu); + beginSettingPopup(Page::DeviceSettings, device_index_); } break; @@ -1357,6 +1666,11 @@ void Runtime::render() break; } + if (setting_popup_active_) + { + renderSettingPopup(); + } + ble::BlePairingStatus ble_status{}; if (loadBlePairingStatus(app(), &ble_status) && ble_status.requires_passkey && @@ -1392,31 +1706,114 @@ void Runtime::renderScreensaver() char protocol[8] = {}; char freq[20] = {}; char time_buf[16] = {}; + char time_main_buf[8] = {}; + char time_sec_buf[4] = {}; char date_buf[24] = {}; + char status_buf[16] = {}; char node_buf[12] = {}; + char tz_buf[16] = {}; + char unread_buf[8] = {}; + char bat_pct_buf[8] = {}; + char sat_buf[16] = {}; + char top_left_buf[32] = {}; + char top_right_buf[32] = {}; + char left_toggle_buf[12] = {}; + char right_toggle_buf[12] = {}; formatProtocol(protocol, sizeof(protocol)); formatNodeLabel(node_buf, sizeof(node_buf)); + formatTime(time_buf, sizeof(time_buf), date_buf, sizeof(date_buf)); + splitClockText(time_buf, time_main_buf, sizeof(time_main_buf), time_sec_buf, sizeof(time_sec_buf)); + formatTimezoneLabel(host_.timezone_offset_min_fn ? host_.timezone_offset_min_fn() : 0, tz_buf, sizeof(tz_buf)); if (host_.format_frequency_fn) { host_.format_frequency_fn(host_.active_lora_frequency_hz_fn ? host_.active_lora_frequency_hz_fn() : 0U, freq, sizeof(freq)); } - formatTime(time_buf, sizeof(time_buf), date_buf, sizeof(date_buf)); - drawTitleBar(protocol, freq[0] != '\0' ? freq : nullptr); + const auto battery = host_.battery_info_fn ? host_.battery_info_fn() : platform::ui::device::BatteryInfo{}; + const auto gps = host_.gps_data_fn ? host_.gps_data_fn() : platform::ui::gps::GpsState{}; + const int unread = app() ? app()->getChatService().getTotalUnread() : 0; + const bool gps_enabled = host_.gps_enabled_fn && host_.gps_enabled_fn(); + const bool ble_enabled = app() && app()->isBleEnabled(); + std::snprintf(unread_buf, sizeof(unread_buf), unread > 99 ? "99+" : "%d", unread); + if (battery.available && battery.level >= 0) + { + std::snprintf(bat_pct_buf, sizeof(bat_pct_buf), "BAT:%d%%", battery.level); + } + else + { + std::snprintf(bat_pct_buf, sizeof(bat_pct_buf), "BAT:--"); + } + if (gps.satellites > 0) + { + std::snprintf(sat_buf, sizeof(sat_buf), "SAT %u", static_cast(gps.satellites)); + } + else if (gps_enabled) + { + std::snprintf(sat_buf, sizeof(sat_buf), "SAT --"); + } + else + { + std::snprintf(sat_buf, sizeof(sat_buf), "SAT OFF"); + } + const bool clock_unsynced = std::strcmp(date_buf, "TIME UNSYNC") == 0; + if (clock_unsynced) + { + std::snprintf(status_buf, sizeof(status_buf), "CLOCK UNSYNC"); + } + else + { + std::snprintf(status_buf, sizeof(status_buf), "%s", date_buf); + } + std::snprintf(top_left_buf, sizeof(top_left_buf), "%s %s", protocol[0] ? protocol : "--", bat_pct_buf); + std::snprintf(top_right_buf, sizeof(top_right_buf), "%s", freq[0] ? freq : "--"); + formatToggleLabel(left_toggle_buf, sizeof(left_toggle_buf), "GPS", gps_enabled); + formatToggleLabel(right_toggle_buf, sizeof(right_toggle_buf), "BLE", ble_enabled); - const int time_w = text_renderer_.measureTextWidth(time_buf); + constexpr int kTopY = 1; + constexpr int kTopDetailY = 9; + constexpr int kTimeY = 22; + constexpr int kSideToggleY = 26; + constexpr int kSecY = 28; + constexpr int kDateY = 42; + constexpr int kFooterY = 55; + + drawTextClipped(2, kTopY, 60, top_left_buf); + const int top_right_w = text_renderer_.measureTextWidth(top_right_buf); + text_renderer_.drawText(display_, std::max(66, display_.width() - top_right_w - 2), kTopY, top_right_buf); + drawTextClipped(2, kTopDetailY, 42, sat_buf); + char top_detail_right[16] = {}; + std::snprintf(top_detail_right, sizeof(top_detail_right), "MSG %s", unread_buf); + const int top_detail_right_w = text_renderer_.measureTextWidth(top_detail_right); + text_renderer_.drawText(display_, std::max(70, display_.width() - top_detail_right_w - 2), kTopDetailY, top_detail_right); + + const int time_w = measureClockText(time_main_buf); const int time_x = std::max(0, (display_.width() - time_w) / 2); - text_renderer_.drawText(display_, time_x, 18, time_buf); + drawClockText(display_, time_x, kTimeY, time_main_buf); + if (time_sec_buf[0] != '\0') + { + const int sec_x = std::min(display_.width() - 12, time_x + time_w + 4); + text_renderer_.drawText(display_, sec_x, kSecY, time_sec_buf); + } + drawTextClipped(0, kSideToggleY, 28, left_toggle_buf); + const int right_toggle_w = text_renderer_.measureTextWidth(right_toggle_buf); + text_renderer_.drawText(display_, std::max(96, display_.width() - right_toggle_w), kSideToggleY, right_toggle_buf); + const int status_w = text_renderer_.measureTextWidth(status_buf); + text_renderer_.drawText(display_, std::max(0, (display_.width() - status_w) / 2), kDateY, status_buf); - const int date_w = text_renderer_.measureTextWidth(date_buf); - const int date_x = std::max(0, (display_.width() - date_w) / 2); - text_renderer_.drawText(display_, date_x, 34, date_buf); + char footer_left[24] = {}; + char footer_right[24] = {}; + std::snprintf(footer_left, sizeof(footer_left), "ID %s", node_buf[0] ? node_buf : "--"); + std::snprintf(footer_right, sizeof(footer_right), "%s", tz_buf[0] ? tz_buf : "UTC+0"); + drawTextClipped(3, kFooterY, 76, footer_left); + const int right_w = text_renderer_.measureTextWidth(footer_right); + text_renderer_.drawText(display_, std::max(80, display_.width() - right_w - 3), kFooterY, footer_right); - const int node_w = text_renderer_.measureTextWidth(node_buf); - const int node_x = std::max(0, (display_.width() - node_w) / 2); - text_renderer_.drawText(display_, node_x, 50, node_buf); + if (battery.available && battery.level >= 0 && battery.level <= 20) + { + display_.fillRect(display_.width() - 10, kFooterY + text_renderer_.lineHeight() - 2, 7, 2, true); + } } void Runtime::renderSleep() @@ -1462,10 +1859,9 @@ void Runtime::renderNodeList() return; } - const int line_h = text_renderer_.lineHeight(); constexpr size_t kNodeAliasMax = 8; const size_t selected = std::min(node_list_index_, node_count_ - 1U); - const size_t visible = std::min(node_count_, static_cast(6)); + const size_t visible = std::min(node_count_, static_cast(3)); size_t start = 0; if (node_count_ > visible) { @@ -1480,6 +1876,7 @@ void Runtime::renderNodeList() { const size_t node_index = start + i; const auto& node = nodes_[node_index]; + const int card_y = 10 + static_cast(i * 18); char node_id[8] = {}; std::snprintf(node_id, sizeof(node_id), "%04lX", static_cast(node.node_id & 0xFFFFUL)); @@ -1490,7 +1887,7 @@ void Runtime::renderNodeList() alias[kNodeAliasMax] = '\0'; } - char line[40] = {}; + char line[24] = {}; if (alias[0] == '\0' || equalsIgnoreCase(alias, node_id)) { std::snprintf(line, sizeof(line), "%s", node_id); @@ -1499,7 +1896,54 @@ void Runtime::renderNodeList() { std::snprintf(line, sizeof(line), "%s %s", node_id, alias); } - drawTextClipped(0, 10 + static_cast(i * line_h), display_.width(), line, node_index == selected); + + if (node_index == selected) + { + drawFrame(display_, 0, card_y - 1, display_.width(), 17); + } + drawTextClipped(2, card_y, 90, line, false); + + char age_buf[8] = {}; + const time_t now_s = host_.utc_now_fn ? host_.utc_now_fn() : 0; + formatElapsedShort(now_s, node.last_seen, age_buf, sizeof(age_buf)); + + char dist_buf[12] = {}; + const auto gps = host_.gps_data_fn ? host_.gps_data_fn() : platform::ui::gps::GpsState{}; + if (gps.valid && node.position.valid) + { + const double node_lat = static_cast(node.position.latitude_i) / 1e7; + const double node_lon = static_cast(node.position.longitude_i) / 1e7; + formatDistanceShort(haversineMeters(gps.lat, gps.lng, node_lat, node_lon), dist_buf, sizeof(dist_buf)); + } + else + { + std::snprintf(dist_buf, sizeof(dist_buf), "-"); + } + + char subline[32] = {}; + std::snprintf(subline, sizeof(subline), "%s %s %s", + age_buf, + dist_buf, + signalRatingLabel(node.snr, node.rssi)); + drawTextClipped(6, card_y + 8, 78, subline, false); + + const char* sig = signalRatingLabel(node.snr, node.rssi); + const int bars = std::strcmp(sig, "STR") == 0 ? 4 : std::strcmp(sig, "OK") == 0 ? 3 : std::strcmp(sig, "WEAK") == 0 ? 2 + : 1; + for (int bar = 0; bar < 4; ++bar) + { + const int bar_h = 2 + bar * 2; + const int bar_x = 108 + bar * 4; + const int bar_y = card_y + 14 - bar_h; + if (bar < bars) + { + display_.fillRect(bar_x, bar_y, 3, bar_h, true); + } + else + { + drawFrame(display_, bar_x, bar_y, 3, bar_h); + } + } } } @@ -1908,6 +2352,52 @@ void Runtime::renderDeviceSettings() } } +void Runtime::renderSettingPopup() +{ + char title[24] = {}; + char value[32] = {}; + const bool is_radio = setting_popup_owner_ == Page::RadioSettings; + const bool is_device = setting_popup_owner_ == Page::DeviceSettings; + if (!is_radio && !is_device) + { + return; + } + + if (is_radio) + { + const auto protocol = setting_popup_config_.mesh_protocol; + const auto* items = (protocol == chat::MeshProtocol::Meshtastic) ? kMeshtasticRadioItems : kMeshCoreRadioItems; + const size_t count = radioItemCount(protocol); + if (setting_popup_index_ >= count) + { + return; + } + std::snprintf(title, sizeof(title), "%s", items[setting_popup_index_]); + } + else + { + if (setting_popup_index_ >= arrayCount(kDeviceItems)) + { + return; + } + std::snprintf(title, sizeof(title), "%s", kDeviceItems[setting_popup_index_]); + } + + formatSettingPopupValue(value, sizeof(value)); + + constexpr int kBoxX = 8; + constexpr int kBoxY = 18; + constexpr int kBoxW = 112; + constexpr int kBoxH = 28; + display_.fillRect(kBoxX, kBoxY, kBoxW, kBoxH, false); + drawFrame(display_, kBoxX, kBoxY, kBoxW, kBoxH); + const int title_w = text_renderer_.measureTextWidth(title); + text_renderer_.drawText(display_, kBoxX + std::max(2, (kBoxW - title_w) / 2), kBoxY + 3, title); + const int value_w = text_renderer_.measureTextWidth(value); + text_renderer_.drawText(display_, kBoxX + std::max(2, (kBoxW - value_w) / 2), kBoxY + 13, value); + drawTextClipped(kBoxX + 2, kBoxY + 21, kBoxW - 4, "ADJ ARROWS SEL OK BACK ESC"); +} + void Runtime::renderInfoPage() { drawTitleBar("INFO", nullptr); @@ -1937,6 +2427,8 @@ void Runtime::renderInfoPage() const auto& cfg = app()->getConfig(); const auto battery = host_.battery_info_fn ? host_.battery_info_fn() : platform::ui::device::BatteryInfo{}; const auto gps = host_.gps_data_fn ? host_.gps_data_fn() : platform::ui::gps::GpsState{}; + const auto ram = host_.ram_usage_fn ? host_.ram_usage_fn() : HostCallbacks::ResourceUsage{}; + const auto flash = host_.flash_usage_fn ? host_.flash_usage_fn() : HostCallbacks::ResourceUsage{}; ble::BlePairingStatus ble_status{}; const bool has_ble_status = loadBlePairingStatus(app(), &ble_status); @@ -2023,6 +2515,20 @@ void Runtime::renderInfoPage() formatTime(time_buf, sizeof(time_buf), date_buf, sizeof(date_buf)); push_kv("TIME", time_buf[0] ? time_buf : "-"); push_kv("DATE", date_buf[0] ? date_buf : "-"); + if (ram.available && ram.total_bytes > 0) + { + std::snprintf(value, sizeof(value), "%lu/%luK", + static_cast(ram.used_bytes / 1024U), + static_cast(ram.total_bytes / 1024U)); + push_kv("SRAM", value); + } + if (flash.available && flash.total_bytes > 0) + { + std::snprintf(value, sizeof(value), "%lu/%luK", + static_cast(flash.used_bytes / 1024U), + static_cast(flash.total_bytes / 1024U)); + push_kv("FLASH", value); + } push_line("[GPS]"); push_kv("GPS", cfg.gps_mode != 0 ? "ON" : "OFF"); @@ -2434,6 +2940,9 @@ void Runtime::buildNodeInfo() push_kv("RS", value); formatTimestamp(value, sizeof(value), node.last_seen); push_kv("SEEN", value[0] != '\0' ? value : "-"); + formatElapsedShort(host_.utc_now_fn ? host_.utc_now_fn() : 0, node.last_seen, value, sizeof(value)); + push_kv("AGO", value); + push_kv("SIG", signalRatingLabel(node.snr, node.rssi)); push_section("POS"); if (node.position.valid) @@ -2928,6 +3437,285 @@ void Runtime::adjustDeviceSetting(int delta) } } +void Runtime::beginSettingPopup(Page owner, size_t index) +{ + if (!app()) + { + return; + } + setting_popup_active_ = true; + setting_popup_owner_ = owner; + setting_popup_index_ = index; + setting_popup_config_ = app()->getConfig(); + setting_popup_ble_enabled_ = app()->isBleEnabled(); + setting_popup_timezone_min_ = host_.timezone_offset_min_fn ? host_.timezone_offset_min_fn() : 0; +} + +void Runtime::cancelSettingPopup() +{ + setting_popup_active_ = false; +} + +void Runtime::confirmSettingPopup() +{ + if (!setting_popup_active_ || !app()) + { + return; + } + + auto& cfg = app()->getConfig(); + cfg = setting_popup_config_; + cfg.ble_enabled = setting_popup_ble_enabled_; + if (host_.set_timezone_offset_min_fn) + { + host_.set_timezone_offset_min_fn(setting_popup_timezone_min_); + } + app()->setBleEnabled(setting_popup_ble_enabled_); + app()->saveConfig(); + + char value[32] = {}; + formatSettingPopupValue(value, sizeof(value)); + appendStatus(this, "%s", value); + setting_popup_active_ = false; +} + +void Runtime::adjustSettingPopup(int delta) +{ + if (!setting_popup_active_ || !app() || delta == 0) + { + return; + } + + if (setting_popup_owner_ == Page::RadioSettings) + { + auto& cfg = setting_popup_config_; + switch (setting_popup_index_) + { + case 0: + cfg.mesh_protocol = (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) + ? chat::MeshProtocol::MeshCore + : chat::MeshProtocol::Meshtastic; + break; + case 1: + if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) + { + size_t count = 0; + const auto* table = chat::meshtastic::getRegionTable(&count); + if (count > 0) + { + size_t index = 0; + for (size_t i = 0; i < count; ++i) + { + if (table[i].code == + static_cast(cfg.meshtastic_config.region)) + { + index = i; + break; + } + } + index = static_cast( + clampValue(static_cast(index) + delta, 0, static_cast(count) - 1)); + cfg.meshtastic_config.region = static_cast(table[index].code); + } + } + else + { + size_t count = 0; + const auto* table = chat::meshcore::getRegionPresetTable(&count); + if (count > 0) + { + int index = -1; + for (size_t i = 0; i < count; ++i) + { + if (table[i].id == cfg.meshcore_config.meshcore_region_preset) + { + index = static_cast(i); + break; + } + } + index = clampValue(index + delta, 0, static_cast(count) - 1); + cfg.meshcore_config.meshcore_region_preset = table[index].id; + } + } + break; + case 2: + cfg.activeMeshConfig().tx_power = static_cast(clampValue( + static_cast(cfg.activeMeshConfig().tx_power) + delta, + static_cast(app::AppConfig::kTxPowerMinDbm), + static_cast(app::AppConfig::kTxPowerMaxDbm))); + break; + case 3: + if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) + { + constexpr int kPresetMin = static_cast(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + constexpr int kPresetMax = static_cast(meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO); + cfg.meshtastic_config.modem_preset = static_cast(clampValue( + static_cast(cfg.meshtastic_config.modem_preset) + delta, kPresetMin, kPresetMax)); + cfg.meshtastic_config.use_preset = true; + } + else + { + size_t count = 0; + const auto* table = chat::meshcore::getRegionPresetTable(&count); + if (count > 0) + { + int index = -1; + for (size_t i = 0; i < count; ++i) + { + if (table[i].id == cfg.meshcore_config.meshcore_region_preset) + { + index = static_cast(i); + break; + } + } + index = clampValue(index + delta, 0, static_cast(count) - 1); + cfg.meshcore_config.meshcore_region_preset = table[index].id; + cfg.meshcore_config.meshcore_freq_mhz = table[index].freq_mhz; + cfg.meshcore_config.meshcore_bw_khz = table[index].bw_khz; + cfg.meshcore_config.meshcore_sf = table[index].sf; + cfg.meshcore_config.meshcore_cr = table[index].cr; + } + } + break; + case 4: + if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) + { + cfg.meshtastic_config.channel_num = static_cast(clampValue( + static_cast(cfg.meshtastic_config.channel_num) + delta, 0, 255)); + } + else + { + cfg.meshcore_config.meshcore_channel_slot = static_cast(clampValue( + static_cast(cfg.meshcore_config.meshcore_channel_slot) + delta, 0, 15)); + } + break; + case 5: + setEncryptEnabled(cfg, !encryptEnabled(cfg)); + break; + default: + break; + } + return; + } + + if (setting_popup_owner_ == Page::DeviceSettings) + { + switch (setting_popup_index_) + { + case 0: + setting_popup_ble_enabled_ = !setting_popup_ble_enabled_; + break; + case 1: + setting_popup_config_.gps_mode = (setting_popup_config_.gps_mode == 0) ? 1 : 0; + break; + case 2: + setting_popup_config_.gps_sat_mask = nextGpsSatMask(setting_popup_config_.gps_sat_mask, delta); + break; + case 3: + { + static constexpr uint32_t kGpsIntervals[] = {15000UL, 30000UL, 60000UL, 300000UL, 600000UL}; + size_t index = 0; + while (index + 1 < arrayCount(kGpsIntervals) && + kGpsIntervals[index] < setting_popup_config_.gps_interval_ms) + { + ++index; + } + const int next = clampValue(static_cast(index) + delta, 0, static_cast(arrayCount(kGpsIntervals)) - 1); + setting_popup_config_.gps_interval_ms = kGpsIntervals[next]; + break; + } + case 4: + setting_popup_timezone_min_ = clampValue(setting_popup_timezone_min_ + delta * kTimezoneStep, + kTimezoneMin, + kTimezoneMax); + break; + default: + break; + } + } +} + +void Runtime::formatSettingPopupValue(char* out, size_t out_len) const +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + + if (setting_popup_owner_ == Page::RadioSettings) + { + const auto& cfg = setting_popup_config_; + switch (setting_popup_index_) + { + case 0: + std::snprintf(out, out_len, "%s", protocolShortLabel(cfg.mesh_protocol)); + return; + case 1: + std::snprintf(out, out_len, "%s", radioRegionLabel(cfg)); + return; + case 2: + std::snprintf(out, out_len, "%ddBm", static_cast(cfg.activeMeshConfig().tx_power)); + return; + case 3: + if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) + { + std::snprintf(out, out_len, "%s", + chat::meshtastic::presetDisplayName( + static_cast(cfg.meshtastic_config.modem_preset))); + } + else + { + std::snprintf(out, out_len, "%s", radioRegionLabel(cfg)); + } + return; + case 4: + if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) + { + std::snprintf(out, out_len, "CH %u", static_cast(cfg.meshtastic_config.channel_num)); + } + else + { + std::snprintf(out, out_len, "SLOT %u", static_cast(cfg.meshcore_config.meshcore_channel_slot)); + } + return; + case 5: + std::snprintf(out, out_len, "%s", encryptEnabled(cfg) ? "ON" : "OFF"); + return; + default: + break; + } + } + + if (setting_popup_owner_ == Page::DeviceSettings) + { + switch (setting_popup_index_) + { + case 0: + std::snprintf(out, out_len, "%s", setting_popup_ble_enabled_ ? "ON" : "OFF"); + return; + case 1: + std::snprintf(out, out_len, "%s", setting_popup_config_.gps_mode != 0 ? "ON" : "OFF"); + return; + case 2: + std::snprintf(out, out_len, "%s", gpsSatMaskLabel(setting_popup_config_.gps_sat_mask)); + return; + case 3: + std::snprintf(out, out_len, "%lus", static_cast(setting_popup_config_.gps_interval_ms / 1000UL)); + return; + case 4: + { + char tz_label[16] = {}; + formatTimezoneLabel(setting_popup_timezone_min_, tz_label, sizeof(tz_label)); + std::snprintf(out, out_len, "%s", tz_label); + return; + } + default: + break; + } + } +} + void Runtime::adjustComposeSelection(int delta) { if (usesSmartCompose()) diff --git a/modules/ui_mono_128x64/src/text_renderer.cpp b/modules/ui_mono_128x64/src/text_renderer.cpp index f17bd622..b53a7840 100644 --- a/modules/ui_mono_128x64/src/text_renderer.cpp +++ b/modules/ui_mono_128x64/src/text_renderer.cpp @@ -37,6 +37,11 @@ struct GlyphView uint8_t advance; }; +uint8_t glyphRowBytes(uint8_t width) +{ + return static_cast((width + 7U) / 8U); +} + size_t utf8SequenceLength(unsigned char lead) { if ((lead & 0x80U) == 0) @@ -177,7 +182,9 @@ GlyphView resolveGlyph(const MonoFont& font, uint16_t glyph_index) const bool is_compact16 = (font.codepoints16 != nullptr) && (font.glyphs == nullptr); if (is_compact16) { - const uint32_t offset = static_cast(glyph_index) * static_cast(font.glyph_height); + const uint32_t offset = static_cast(glyph_index) * + static_cast(font.glyph_height) * + static_cast(glyphRowBytes(font.glyph_width)); const uint8_t advance = font.advances ? font.advances[glyph_index] : font.fixed_advance; return GlyphView{ font.bitmap + offset, @@ -219,10 +226,12 @@ void drawGlyphBitmap(MonoDisplay& display, const MonoFont& font, int x, int y, c for (int row = 0; row < glyph.height; ++row) { - const uint8_t bits = glyph.bitmap[row]; + const uint8_t row_bytes = glyphRowBytes(glyph.width); + const uint8_t* row_ptr = glyph.bitmap + static_cast(row) * row_bytes; for (int col = 0; col < glyph.width; ++col) { - const bool on = ((bits >> (7 - col)) & 0x01U) != 0; + const uint8_t byte_value = row_ptr[col / 8]; + const bool on = ((byte_value >> (7 - (col % 8))) & 0x01U) != 0; if (!on) { continue; diff --git a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h index 3bba1aca..79980c78 100644 --- a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h +++ b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/meshtastic/meshtastic_radio_adapter.h @@ -4,6 +4,7 @@ #include "chat/ports/i_mesh_adapter.h" #include "chat/runtime/self_identity_policy.h" #include "chat/runtime/self_identity_provider.h" +#include "chat/usecase/contact_service.h" #include "meshtastic/mesh.pb.h" #include "platform/nrf52/arduino_common/chat/infra/meshtastic/node_store.h" @@ -35,7 +36,8 @@ class MeshtasticRadioAdapter final : public ::chat::IMeshAdapter }; explicit MeshtasticRadioAdapter(const ::chat::runtime::SelfIdentityProvider* identity_provider = nullptr, - NodeStore* node_store = nullptr); + NodeStore* node_store = nullptr, + ::chat::contacts::ContactService* contact_service = nullptr); ::chat::MeshCapabilities getCapabilities() const override; bool sendText(::chat::ChannelId channel, const std::string& text, @@ -178,6 +180,7 @@ class MeshtasticRadioAdapter final : public ::chat::IMeshAdapter std::string short_name_; const ::chat::runtime::SelfIdentityProvider* identity_provider_ = nullptr; NodeStore* node_store_ = nullptr; + ::chat::contacts::ContactService* contact_service_ = nullptr; float last_rx_rssi_ = 0.0f; float last_rx_snr_ = 0.0f; std::queue<::chat::MeshIncomingText> text_queue_; diff --git a/platform/nrf52/arduino_common/include/ui/fonts/fusion_pixel_10_font.h b/platform/nrf52/arduino_common/include/ui/fonts/fusion_pixel_10_font.h new file mode 100644 index 00000000..eae2497f --- /dev/null +++ b/platform/nrf52/arduino_common/include/ui/fonts/fusion_pixel_10_font.h @@ -0,0 +1,10 @@ +#pragma once + +#include "ui/mono_128x64/font/mono_font.h" + +namespace platform::nrf52::ui::fonts +{ + +const ::ui::mono_128x64::MonoFont& fusion_pixel_10_font(); + +} // namespace platform::nrf52::ui::fonts diff --git a/platform/nrf52/arduino_common/include/ui/fonts/fusion_pixel_10_font_generated.h b/platform/nrf52/arduino_common/include/ui/fonts/fusion_pixel_10_font_generated.h new file mode 100644 index 00000000..7aa42619 --- /dev/null +++ b/platform/nrf52/arduino_common/include/ui/fonts/fusion_pixel_10_font_generated.h @@ -0,0 +1,10 @@ +#pragma once + +#include "ui/mono_128x64/font/mono_font.h" + +namespace ui::mono_128x64 +{ + +extern const MonoFont kFusionPixel10Font; + +} // namespace ui::mono_128x64 diff --git a/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp b/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp index f47b739c..27c9c8e9 100644 --- a/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp +++ b/platform/nrf52/arduino_common/src/chat/infra/meshtastic/meshtastic_radio_adapter.cpp @@ -10,6 +10,7 @@ #include "meshtastic/mqtt.pb.h" #include "platform/nrf52/arduino_common/chat/infra/radio_packet_io.h" #include "platform/nrf52/arduino_common/device_identity.h" +#include "sys/clock.h" #include @@ -193,13 +194,20 @@ const uint8_t* selectKeyByHash(const ::chat::MeshConfig& config, return nullptr; } +uint32_t nowSeconds() +{ + return sys::epoch_seconds_now(); +} + } // namespace MeshtasticRadioAdapter::MeshtasticRadioAdapter(const ::chat::runtime::SelfIdentityProvider* identity_provider, - NodeStore* node_store) + NodeStore* node_store, + ::chat::contacts::ContactService* contact_service) : node_id_(device_identity::getSelfNodeId()), identity_provider_(identity_provider), - node_store_(node_store) + node_store_(node_store), + contact_service_(contact_service) { randomSeed(static_cast(NRF_FICR->DEVICEADDR[0] ^ NRF_FICR->DEVICEADDR[1] ^ micros())); next_packet_id_ = static_cast<::chat::MessageId>(random(1, 0x7FFFFFFF)); @@ -572,7 +580,7 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) { ::chat::RxMeta implicit_rx{}; implicit_rx.rx_timestamp_ms = millis(); - implicit_rx.rx_timestamp_s = static_cast(std::time(nullptr)); + implicit_rx.rx_timestamp_s = nowSeconds(); implicit_rx.time_source = ::chat::RxTimeSource::DeviceUtc; implicit_rx.origin = ::chat::RxOrigin::Mesh; implicit_rx.channel_hash = header.channel; @@ -601,7 +609,7 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) ::chat::RxMeta rx_meta{}; rx_meta.rx_timestamp_ms = millis(); - rx_meta.rx_timestamp_s = static_cast(std::time(nullptr)); + rx_meta.rx_timestamp_s = nowSeconds(); rx_meta.time_source = ::chat::RxTimeSource::DeviceUtc; rx_meta.origin = ::chat::RxOrigin::Mesh; rx_meta.direct = (::chat::meshtastic::computeHopsAway(header.flags) == 0); @@ -669,7 +677,7 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) const uint8_t hops_away = node.has_hops_away ? node.hops_away : ::chat::meshtastic::computeHopsAway(header.flags); node_store_->upsert(node_id, short_name, long_name, - static_cast(std::time(nullptr)), + nowSeconds(), snr, last_rx_rssi_, static_cast(::chat::contacts::NodeProtocolType::Meshtastic), role, hops_away, @@ -688,7 +696,7 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) role = static_cast(user.role); } node_store_->upsert(header.from, user.short_name, user.long_name, - static_cast(std::time(nullptr)), + nowSeconds(), last_rx_snr_, last_rx_rssi_, static_cast(::chat::contacts::NodeProtocolType::Meshtastic), role, ::chat::meshtastic::computeHopsAway(header.flags), @@ -698,6 +706,32 @@ void MeshtasticRadioAdapter::handleRawPacket(const uint8_t* data, size_t size) } } + if (decoded_ok && + decoded.portnum == meshtastic_PortNum_POSITION_APP && + decoded.payload.size > 0 && + contact_service_) + { + meshtastic_Position position_pb = meshtastic_Position_init_zero; + pb_istream_t pstream = pb_istream_from_buffer(decoded.payload.bytes, decoded.payload.size); + if (pb_decode(&pstream, meshtastic_Position_fields, &position_pb) && + ::chat::meshtastic::hasValidPosition(position_pb)) + { + ::chat::contacts::NodePosition pos{}; + pos.valid = true; + pos.latitude_i = position_pb.latitude_i; + pos.longitude_i = position_pb.longitude_i; + pos.has_altitude = position_pb.has_altitude; + pos.altitude = position_pb.altitude; + pos.timestamp = position_pb.timestamp != 0 ? position_pb.timestamp : position_pb.time; + pos.precision_bits = position_pb.precision_bits; + pos.pdop = position_pb.PDOP; + pos.hdop = position_pb.HDOP; + pos.vdop = position_pb.VDOP; + pos.gps_accuracy_mm = position_pb.gps_accuracy; + contact_service_->updateNodePosition(header.from, pos); + } + } + if (want_ack_flag && to_us && decoded_ok) { (void)sendRoutingAck(header.from, header.id, header.channel, channel, key, key_len, 0); @@ -807,7 +841,7 @@ void MeshtasticRadioAdapter::processSendQueue() } if (node_store_ && pending.dest != 0 && pending.dest != kBroadcastNode) { - node_store_->setNextHop(pending.dest, 0, static_cast(std::time(nullptr))); + node_store_->setNextHop(pending.dest, 0, nowSeconds()); } it = pending_retransmits_.erase(it); continue; @@ -819,7 +853,7 @@ void MeshtasticRadioAdapter::processSendQueue() pending.fallback_sent = true; if (node_store_ && pending.dest != 0 && pending.dest != kBroadcastNode) { - node_store_->setNextHop(pending.dest, 0, static_cast(std::time(nullptr))); + node_store_->setNextHop(pending.dest, 0, nowSeconds()); } } @@ -1141,7 +1175,7 @@ void MeshtasticRadioAdapter::emitRoutingResult(uint32_t request_id, meshtastic_R else { incoming.rx_meta.rx_timestamp_ms = millis(); - incoming.rx_meta.rx_timestamp_s = static_cast(std::time(nullptr)); + incoming.rx_meta.rx_timestamp_s = nowSeconds(); incoming.rx_meta.time_source = ::chat::RxTimeSource::DeviceUtc; incoming.rx_meta.origin = ::chat::RxOrigin::Mesh; incoming.rx_meta.channel_hash = channel_hash; @@ -1175,7 +1209,7 @@ void MeshtasticRadioAdapter::learnNextHop(::chat::NodeId dest, uint8_t next_hop) { return; } - (void)node_store_->setNextHop(dest, next_hop, static_cast(std::time(nullptr))); + (void)node_store_->setNextHop(dest, next_hop, nowSeconds()); } MeshtasticRadioAdapter::PacketHistoryEntry* MeshtasticRadioAdapter::findHistory(::chat::NodeId sender, ::chat::MessageId packet_id) @@ -1384,7 +1418,7 @@ void MeshtasticRadioAdapter::updateNodeLastSeen(::chat::NodeId node_id, uint8_t { return; } - node_store_->upsert(node_id, "", "", static_cast(std::time(nullptr)), + node_store_->upsert(node_id, "", "", nowSeconds(), last_rx_snr_, last_rx_rssi_, static_cast(::chat::contacts::NodeProtocolType::Meshtastic), ::chat::contacts::kNodeRoleUnknown, @@ -1439,7 +1473,7 @@ void MeshtasticRadioAdapter::handleRoutingPacket(const ::chat::meshtastic::Packe { if (node_store_) { - node_store_->setNextHop(header.from, 0, static_cast(std::time(nullptr))); + node_store_->setNextHop(header.from, 0, nowSeconds()); } } diff --git a/platform/nrf52/arduino_common/src/platform_ui_settings_store.cpp b/platform/nrf52/arduino_common/src/platform_ui_settings_store.cpp index 630094b6..44ae7e9a 100644 --- a/platform/nrf52/arduino_common/src/platform_ui_settings_store.cpp +++ b/platform/nrf52/arduino_common/src/platform_ui_settings_store.cpp @@ -1,5 +1,8 @@ #include "platform/ui/settings_store.h" +#include + +#include #include #include #include @@ -8,6 +11,38 @@ namespace { +using Adafruit_LittleFS_Namespace::FILE_O_READ; +using Adafruit_LittleFS_Namespace::FILE_O_WRITE; + +constexpr const char* kSettingsPath = "/ui_settings.bin"; +constexpr const char* kSettingsTempPath = "/ui_settings.bin.tmp"; +constexpr uint32_t kMagic = 0x55535447UL; // USTG +constexpr uint16_t kVersion = 1; + +enum class ValueType : uint8_t +{ + Int = 1, + Bool = 2, + Uint = 3, + Blob = 4, +}; + +struct FileHeader +{ + uint32_t magic = kMagic; + uint16_t version = kVersion; + uint16_t reserved = 0; + uint32_t record_count = 0; +} __attribute__((packed)); + +struct RecordHeader +{ + uint8_t type = 0; + uint8_t reserved = 0; + uint16_t key_len = 0; + uint32_t value_len = 0; +} __attribute__((packed)); + std::string makeScopedKey(const char* ns, const char* key) { const char* scope = ns ? ns : ""; @@ -39,6 +74,224 @@ std::map>& blobStore() return store; } +bool& loadedFlag() +{ + static bool loaded = false; + return loaded; +} + +void clearAllStores() +{ + intStore().clear(); + boolStore().clear(); + uintStore().clear(); + blobStore().clear(); +} + +bool ensureFs() +{ + return InternalFS.begin(); +} + +template +bool writePod(Adafruit_LittleFS_Namespace::File& file, const T& value) +{ + return file.write(reinterpret_cast(&value), sizeof(T)) == sizeof(T); +} + +template +bool readPod(Adafruit_LittleFS_Namespace::File& file, T* value) +{ + return value && file.read(value, sizeof(T)) == sizeof(T); +} + +bool writeRecordHeader(Adafruit_LittleFS_Namespace::File& file, + ValueType type, + const std::string& key, + uint32_t value_len) +{ + RecordHeader header{}; + header.type = static_cast(type); + header.key_len = static_cast(key.size()); + header.value_len = value_len; + return writePod(file, header) && + (key.empty() || file.write(key.data(), key.size()) == key.size()); +} + +bool saveToFs() +{ + if (!ensureFs()) + { + return false; + } + + auto file = InternalFS.open(kSettingsTempPath, FILE_O_WRITE); + if (!file) + { + return false; + } + + const uint32_t record_count = static_cast( + intStore().size() + boolStore().size() + uintStore().size() + blobStore().size()); + FileHeader header{}; + header.record_count = record_count; + if (!writePod(file, header)) + { + file.close(); + return false; + } + + for (const auto& entry : intStore()) + { + const int32_t value = entry.second; + if (!writeRecordHeader(file, ValueType::Int, entry.first, sizeof(value)) || + file.write(reinterpret_cast(&value), sizeof(value)) != sizeof(value)) + { + file.close(); + return false; + } + } + + for (const auto& entry : boolStore()) + { + const uint8_t value = entry.second ? 1U : 0U; + if (!writeRecordHeader(file, ValueType::Bool, entry.first, sizeof(value)) || + file.write(&value, sizeof(value)) != sizeof(value)) + { + file.close(); + return false; + } + } + + for (const auto& entry : uintStore()) + { + const uint32_t value = entry.second; + if (!writeRecordHeader(file, ValueType::Uint, entry.first, sizeof(value)) || + file.write(reinterpret_cast(&value), sizeof(value)) != sizeof(value)) + { + file.close(); + return false; + } + } + + for (const auto& entry : blobStore()) + { + if (!writeRecordHeader(file, ValueType::Blob, entry.first, static_cast(entry.second.size())) || + (!entry.second.empty() && file.write(entry.second.data(), entry.second.size()) != entry.second.size())) + { + file.close(); + return false; + } + } + + file.flush(); + file.close(); + + if (InternalFS.exists(kSettingsPath)) + { + InternalFS.remove(kSettingsPath); + } + return InternalFS.rename(kSettingsTempPath, kSettingsPath); +} + +void ensureLoaded() +{ + if (loadedFlag()) + { + return; + } + loadedFlag() = true; + + clearAllStores(); + if (!ensureFs() || !InternalFS.exists(kSettingsPath)) + { + return; + } + + auto file = InternalFS.open(kSettingsPath, FILE_O_READ); + if (!file) + { + return; + } + + FileHeader header{}; + if (!readPod(file, &header) || header.magic != kMagic || header.version != kVersion) + { + file.close(); + return; + } + + for (uint32_t i = 0; i < header.record_count; ++i) + { + RecordHeader rec{}; + if (!readPod(file, &rec)) + { + break; + } + + std::string key(rec.key_len, '\0'); + if (rec.key_len > 0 && file.read(&key[0], rec.key_len) != rec.key_len) + { + break; + } + + switch (static_cast(rec.type)) + { + case ValueType::Int: + { + int32_t value = 0; + if (rec.value_len != sizeof(value) || !readPod(file, &value)) + { + return; + } + intStore()[key] = value; + break; + } + case ValueType::Bool: + { + uint8_t value = 0; + if (rec.value_len != sizeof(value) || !readPod(file, &value)) + { + return; + } + boolStore()[key] = (value != 0); + break; + } + case ValueType::Uint: + { + uint32_t value = 0; + if (rec.value_len != sizeof(value) || !readPod(file, &value)) + { + return; + } + uintStore()[key] = value; + break; + } + case ValueType::Blob: + { + std::vector value(rec.value_len, 0); + if (rec.value_len > 0 && file.read(value.data(), rec.value_len) != rec.value_len) + { + return; + } + blobStore()[key] = value; + break; + } + default: + { + std::vector skip(rec.value_len, 0); + if (rec.value_len > 0 && file.read(skip.data(), rec.value_len) != rec.value_len) + { + return; + } + break; + } + } + } + + file.close(); +} + } // namespace namespace platform::ui::settings_store @@ -50,7 +303,9 @@ void put_int(const char* ns, const char* key, int value) { return; } + ensureLoaded(); intStore()[makeScopedKey(ns, key)] = value; + (void)saveToFs(); } void put_bool(const char* ns, const char* key, bool value) @@ -59,7 +314,9 @@ void put_bool(const char* ns, const char* key, bool value) { return; } + ensureLoaded(); boolStore()[makeScopedKey(ns, key)] = value; + (void)saveToFs(); } void put_uint(const char* ns, const char* key, uint32_t value) @@ -68,7 +325,9 @@ void put_uint(const char* ns, const char* key, uint32_t value) { return; } + ensureLoaded(); uintStore()[makeScopedKey(ns, key)] = value; + (void)saveToFs(); } bool put_blob(const char* ns, const char* key, const void* data, std::size_t len) @@ -77,9 +336,10 @@ bool put_blob(const char* ns, const char* key, const void* data, std::size_t len { return false; } + ensureLoaded(); auto& blob = blobStore()[makeScopedKey(ns, key)]; blob.assign(static_cast(data), static_cast(data) + len); - return true; + return saveToFs(); } int get_int(const char* ns, const char* key, int default_value) @@ -88,6 +348,7 @@ int get_int(const char* ns, const char* key, int default_value) { return default_value; } + ensureLoaded(); const auto it = intStore().find(makeScopedKey(ns, key)); return it == intStore().end() ? default_value : it->second; } @@ -98,6 +359,7 @@ bool get_bool(const char* ns, const char* key, bool default_value) { return default_value; } + ensureLoaded(); const auto it = boolStore().find(makeScopedKey(ns, key)); return it == boolStore().end() ? default_value : it->second; } @@ -108,6 +370,7 @@ uint32_t get_uint(const char* ns, const char* key, uint32_t default_value) { return default_value; } + ensureLoaded(); const auto it = uintStore().find(makeScopedKey(ns, key)); return it == uintStore().end() ? default_value : it->second; } @@ -119,6 +382,7 @@ bool get_blob(const char* ns, const char* key, std::vector& out) { return false; } + ensureLoaded(); const auto it = blobStore().find(makeScopedKey(ns, key)); if (it == blobStore().end()) { @@ -134,6 +398,7 @@ void remove_keys(const char* ns, const char* const* keys, std::size_t key_count) { return; } + ensureLoaded(); for (std::size_t index = 0; index < key_count; ++index) { if (!keys[index]) @@ -146,10 +411,12 @@ void remove_keys(const char* ns, const char* const* keys, std::size_t key_count) uintStore().erase(scoped); blobStore().erase(scoped); } + (void)saveToFs(); } void clear_namespace(const char* ns) { + ensureLoaded(); const std::string prefix = std::string(ns ? ns : "") + ":"; for (auto it = intStore().begin(); it != intStore().end();) @@ -168,6 +435,7 @@ void clear_namespace(const char* ns) { it = (it->first.rfind(prefix, 0) == 0) ? blobStore().erase(it) : std::next(it); } + (void)saveToFs(); } } // namespace platform::ui::settings_store diff --git a/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_10_font.cpp b/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_10_font.cpp new file mode 100644 index 00000000..bdbc68f8 --- /dev/null +++ b/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_10_font.cpp @@ -0,0 +1,26 @@ +#ifndef TRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED +#define TRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED 0 +#endif + +#if TRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED + +#include "ui/fonts/fusion_pixel_10_font.h" + +namespace ui::mono_128x64 +{ + +extern const MonoFont kFusionPixel10Font; + +} // namespace ui::mono_128x64 + +namespace platform::nrf52::ui::fonts +{ + +const ::ui::mono_128x64::MonoFont& fusion_pixel_10_font() +{ + return ::ui::mono_128x64::kFusionPixel10Font; +} + +} // namespace platform::nrf52::ui::fonts + +#endif diff --git a/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_10_font_data.cpp b/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_10_font_data.cpp new file mode 100644 index 00000000..962c2637 --- /dev/null +++ b/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_10_font_data.cpp @@ -0,0 +1,173 @@ +#ifndef TRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED +#define TRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED 0 +#endif + +#if TRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED + +#include "ui/fonts/fusion_pixel_10_font_generated.h" + +namespace ui::mono_128x64 +{ + +static const uint8_t kFusionPixel10Bitmap[] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xD0, 0x00, 0xF0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xF0, 0x00, 0xD0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xF0, 0x00, 0xE0, 0x00, 0x30, 0x00, 0x30, 0x00, 0x30, 0x00, + 0xE0, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0x10, 0x00, 0x20, 0x00, + 0x20, 0x00, 0xC0, 0x00, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xD0, 0x00, + 0x20, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xC0, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, + 0xF0, 0x00, 0x20, 0x00, 0x20, 0x00, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xF0, 0x00, 0xF0, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x10, 0x00, 0x10, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0xC0, 0x00, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xE0, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x20, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x10, 0x00, + 0x10, 0x00, 0x20, 0x00, 0x20, 0x00, 0xC0, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xE0, 0x00, 0x10, 0x00, 0x20, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x30, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xF0, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0xC0, 0x00, 0xE0, 0x00, 0x10, 0x00, + 0x10, 0x00, 0x10, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xC0, 0x00, + 0xF0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x10, 0x00, 0x10, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xD0, 0x00, 0x20, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xF0, 0x00, + 0xF0, 0x00, 0x10, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x20, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0x20, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, + 0x20, 0x00, 0x10, 0x00, 0x10, 0x00, 0x20, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0xD0, 0x00, 0x10, 0x00, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xD0, 0x00, 0xF0, 0x00, 0xF0, 0x00, 0xF0, 0x00, 0xC0, 0x00, + 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xF0, 0x00, + 0xF0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xD0, 0x00, + 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x30, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0x30, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0xC0, 0x00, 0xF0, 0x00, 0xC0, 0x00, + 0xC0, 0x00, 0xC0, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0xC0, 0x00, + 0xF0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x30, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x30, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xF0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x20, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, + 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0xD0, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, + 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xF0, 0x00, 0xF0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x20, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xC0, 0x00, + 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0x20, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x30, 0x00, 0xC0, 0x00, 0x20, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xE0, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xF0, 0x00, 0xF0, 0x00, 0xF0, 0x00, 0xD0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x10, 0x00, + 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0xC0, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x30, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x30, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x10, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xD0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0xC0, 0x00, + 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0x30, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x30, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xE0, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x20, 0x00, + 0xF0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x30, 0x00, 0x10, 0x00, 0xE0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x20, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xC0, 0x00, 0xC0, 0x00, 0xD0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xF0, 0x00, + 0xF0, 0x00, 0xF0, 0x00, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xE0, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0x30, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xD0, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0xE0, 0x00, 0xE0, 0x00, 0x30, 0x00, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0xF0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xE0, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xD0, 0x00, 0xF0, 0x00, 0xF0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0x20, 0x00, 0x20, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD0, 0x00, 0xD0, 0x00, + 0xD0, 0x00, 0x30, 0x00, 0x10, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xF0, 0x00, 0x20, 0x00, 0x20, 0x00, 0xC0, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x10, 0x00, 0x20, 0x00, 0x20, 0x00, 0xC0, 0x00, 0xC0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x10, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, + 0x20, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x20, 0x00, 0x20, 0x00, 0x10, 0x00, + 0x10, 0x00, 0x20, 0x00, 0x20, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xE0, 0x00, 0x30, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +static const uint16_t kFusionPixel10Codepoints[] = { + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, + 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, + 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, + 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, + 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, + 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, + 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, +}; + +static const uint8_t kFusionPixel10Advances[] = { + 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, + 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, + 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, + 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, + 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, + 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, 0x05, +}; + +const MonoFont kFusionPixel10Font = MonoFont::makeCompact16( + kFusionPixel10Bitmap, + kFusionPixel10Codepoints, + kFusionPixel10Advances, + static_cast(95), + 10, + 9, + 10, + static_cast(31), + 10, + 10, + 10); + +} // namespace ui::mono_128x64 + +#endif diff --git a/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_8_font.cpp b/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_8_font.cpp index 64e0bb74..c947561f 100644 --- a/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_8_font.cpp +++ b/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_8_font.cpp @@ -1,3 +1,9 @@ +#ifndef TRAIL_MATE_NRF_MONO_FUSION_PIXEL_8_ENABLED +#define TRAIL_MATE_NRF_MONO_FUSION_PIXEL_8_ENABLED 1 +#endif + +#if TRAIL_MATE_NRF_MONO_FUSION_PIXEL_8_ENABLED + #include "ui/fonts/fusion_pixel_8_font.h" namespace ui::mono_128x64 @@ -18,3 +24,5 @@ const ::ui::mono_128x64::MonoFont& fusion_pixel_8_font() } } // namespace platform::nrf52::ui::fonts + +#endif diff --git a/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_8_font_data.cpp b/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_8_font_data.cpp index 0d4c130c..93efca00 100644 --- a/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_8_font_data.cpp +++ b/platform/nrf52/arduino_common/src/ui/fonts/fusion_pixel_8_font_data.cpp @@ -1,3 +1,9 @@ +#ifndef TRAIL_MATE_NRF_MONO_FUSION_PIXEL_8_ENABLED +#define TRAIL_MATE_NRF_MONO_FUSION_PIXEL_8_ENABLED 1 +#endif + +#if TRAIL_MATE_NRF_MONO_FUSION_PIXEL_8_ENABLED + #include "ui/fonts/fusion_pixel_8_font_generated.h" namespace ui::mono_128x64 @@ -71356,3 +71362,5 @@ const MonoFont kFusionPixel8Font = MonoFont::makeCompact16( 8); } // namespace ui::mono_128x64 + +#endif diff --git a/platform/nrf52/arduino_common/tools/gen_scaled_fusion_pixel_font.py b/platform/nrf52/arduino_common/tools/gen_scaled_fusion_pixel_font.py new file mode 100644 index 00000000..76375a04 --- /dev/null +++ b/platform/nrf52/arduino_common/tools/gen_scaled_fusion_pixel_font.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import math +import re +from pathlib import Path + + +ARRAY_RE = re.compile( + r"static const uint(8|16)_t (kFusionPixel8(?:Bitmap|Codepoints|Advances))\[\] = \{\s*(.*?)\s*\};", + re.S, +) +COUNT_RE = re.compile(r"static_cast\((\d+)\)") + + +def parse_array(body: str) -> list[int]: + return [int(token, 0) for token in re.findall(r"0x[0-9A-Fa-f]+|\d+", body)] + + +def parse_source(path: Path) -> tuple[list[int], list[int], list[int], int]: + text = path.read_text(encoding="utf-8") + found: dict[str, list[int]] = {} + for _bits, name, body in ARRAY_RE.findall(text): + found[name] = parse_array(body) + + counts = [int(v) for v in COUNT_RE.findall(text)] + if len(counts) < 2: + raise RuntimeError("unable to parse glyph count/fallback index from source") + + glyph_count = counts[0] + bitmap = found["kFusionPixel8Bitmap"] + codepoints = found["kFusionPixel8Codepoints"] + advances = found["kFusionPixel8Advances"] + return bitmap, codepoints, advances, glyph_count + + +def ascii_charset() -> list[int]: + return list(range(0x20, 0x7F)) + + +def filter_charset(bitmap: list[int], + codepoints: list[int], + advances: list[int], + glyph_count: int, + allowed_codepoints: set[int]) -> tuple[list[int], list[int], list[int], int]: + filtered_bitmap: list[int] = [] + filtered_codepoints: list[int] = [] + filtered_advances: list[int] = [] + + for glyph_index in range(glyph_count): + codepoint = codepoints[glyph_index] + if codepoint not in allowed_codepoints: + continue + start = glyph_index * 8 + filtered_bitmap.extend(bitmap[start:start + 8]) + filtered_codepoints.append(codepoint) + filtered_advances.append(advances[glyph_index]) + + return filtered_bitmap, filtered_codepoints, filtered_advances, len(filtered_codepoints) + + +def upscale_glyph(rows8: list[int], dest_size: int = 10) -> list[int]: + rows10: list[int] = [] + for y in range(dest_size): + src_y = min(7, (y * 8) // dest_size) + src_row = rows8[src_y] + dest_row = 0 + for x in range(dest_size): + src_x = min(7, (x * 8) // dest_size) + bit = (src_row >> (7 - src_x)) & 0x01 + if bit: + dest_row |= 1 << (15 - x) + rows10.append(dest_row) + return rows10 + + +def scale_advance(adv: int, dest_size: int = 10) -> int: + return max(1, min(dest_size, int(math.floor((adv * dest_size / 8.0) + 0.5)))) + + +def format_bytes(data: list[int], per_line: int = 16) -> str: + toks = [f"0x{value:02X}" for value in data] + lines = [] + for idx in range(0, len(toks), per_line): + lines.append(" " + ", ".join(toks[idx:idx + per_line]) + ",") + return "\n".join(lines) + + +def format_words(data: list[int], per_line: int = 8) -> str: + toks = [f"0x{value:04X}" for value in data] + lines = [] + for idx in range(0, len(toks), per_line): + lines.append(" " + ", ".join(toks[idx:idx + per_line]) + ",") + return "\n".join(lines) + + +def write_source(path: Path, bitmap8: list[int], codepoints: list[int], advances8: list[int], glyph_count: int) -> None: + bitmap10: list[int] = [] + advances10 = [scale_advance(value) for value in advances8] + + for glyph_index in range(glyph_count): + rows8 = bitmap8[glyph_index * 8:(glyph_index + 1) * 8] + rows10 = upscale_glyph(rows8) + for row in rows10: + bitmap10.append((row >> 8) & 0xFF) + bitmap10.append(row & 0xFF) + + fallback_index = codepoints.index(ord("?")) if ord("?") in codepoints else 0 + + text = f"""#ifndef TRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED +#define TRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED 0 +#endif + +#if TRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED + +#include "ui/fonts/fusion_pixel_10_font_generated.h" + +namespace ui::mono_128x64 +{{ + +static const uint8_t kFusionPixel10Bitmap[] = {{ +{format_bytes(bitmap10)} +}}; + +static const uint16_t kFusionPixel10Codepoints[] = {{ +{format_words(codepoints)} +}}; + +static const uint8_t kFusionPixel10Advances[] = {{ +{format_bytes(advances10)} +}}; + +const MonoFont kFusionPixel10Font = MonoFont::makeCompact16( + kFusionPixel10Bitmap, + kFusionPixel10Codepoints, + kFusionPixel10Advances, + static_cast({glyph_count}), + 10, + 9, + 10, + static_cast({fallback_index}), + 10, + 10, + 10); + +}} // namespace ui::mono_128x64 + +#endif +""" + path.write_text(text, encoding="utf-8") + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--source8", required=True) + ap.add_argument("--out-source", required=True) + ap.add_argument("--ascii-only", action="store_true") + args = ap.parse_args() + + bitmap, codepoints, advances, glyph_count = parse_source(Path(args.source8)) + if args.ascii_only: + bitmap, codepoints, advances, glyph_count = filter_charset(bitmap, + codepoints, + advances, + glyph_count, + set(ascii_charset())) + if ord("?") not in codepoints: + raise RuntimeError("ASCII subset must include '?' fallback glyph") + write_source(Path(args.out_source), bitmap, codepoints, advances, glyph_count) + + +if __name__ == "__main__": + main() diff --git a/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini b/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini index 36a22e63..46876534 100644 --- a/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini +++ b/variants/gat562_mesh_evb_pro/envs/gat562_mesh_evb_pro.ini @@ -15,6 +15,8 @@ build_flags = -DGAT562_NO_SD=1 -DGAT562_NO_CJK=1 -DGAT562_NO_PINYIN_IME=1 + -DTRAIL_MATE_NRF_MONO_FUSION_PIXEL_8_ENABLED=1 + -DTRAIL_MATE_NRF_MONO_FUSION_PIXEL_10_ENABLED=0 -DCONFIG_NFCT_PINS_AS_GPIOS=1 -DUI_SHARED_TOUCH_IME_ENABLED=0 -DSCREEN_WIDTH=128