CDC Badge OS
Firmware for the CDC Badge v1.0 hardware security key
Loading...
Searching...
No Matches
I18n.cpp
Go to the documentation of this file.
1
6
7#include "cdc_ui/I18n.h"
8
9#include "cdc_core/Raii.h"
10#include "cdc_core/Cp437.h"
11#include "cdc_ui/PsramCjson.h"
12#include "cdc_log.h"
13
14#include "cJSON.h"
15#include "esp_err.h"
16#include "nvs.h"
17#include "nvs_flash.h"
18
19#include <dirent.h>
20
21#include <algorithm>
22#include <cstdint>
23#include <cstdio>
24#include <cstdlib>
25#include <cstring>
26#include <string>
27
28namespace cdc::ui {
29
30static const char* TAG = "I18n";
31
32namespace {
33
34constexpr const char* NVS_NAMESPACE = "i18n";
35constexpr const char* NVS_KEY_LANG_CODE = "langc";
36
39constexpr I18nEntry kCoreStrings[] = {
40 {"core.main_menu", "Main Menu"},
41 {"core.settings", "Settings"},
42 {"core.hardware", "Hardware"},
43 {"core.tools", "Tools"},
44 {"core.hardware_info", "Hardware Info"},
45 {"core.firmware_check", "Firmware Check"},
46 {"core.fw_api_level", "API Level"},
47 {"core.fw_checking", "Checking for updates..."},
48 {"core.fw_up_to_date", "You are up to date."},
49 {"core.fw_update_available","Update available:"},
50 {"core.fw_check_failed", "Update check failed."},
51 {"core.fw_needs_wifi", "Update check needs WiFi (not connected)."},
52 {"core.fw_recheck_hint", "[Y] Check [N] Back"},
53 {"core.name", "Name"},
54 {"core.info", "Info"},
55 {"core.info2", "Info 2"},
56 {"core.default_name", "CDC Badge"},
57 {"core.default_info", "v" APP_VERSION},
58 {"core.back", "Back"},
59 {"core.ok", "OK"},
60 {"core.cancel", "Cancel"},
61 {"core.save", "Save"},
62 {"core.delete", "Delete"},
63 {"core.edit", "Edit"},
64 {"core.view", "View"},
65 {"core.select", "Select"},
66 {"core.open", "Open"},
67 {"core.add", "Add"},
68 {"core.new", "New"},
69 {"core.new_folder", "New folder"},
70 {"core.new_file", "New file"},
71 {"core.exists", "Already exists"},
72 {"core.not_empty", "Not empty"},
73 {"core.sure", "Are you sure?"},
74 {"core.vfat", "vFAT"},
75 {"core.files", "Files"},
76 {"core.system_files", "System Files"},
77 {"core.yes", "Yes"},
78 {"core.no", "No"},
79 {"core.on", "On"},
80 {"core.off", "Off"},
81 {"core.saved", "Saved"},
82 {"core.deleted", "Deleted"},
83 {"core.failed", "Failed"},
84 {"core.timeout", "Timeout"},
85 {"core.empty", "empty"},
86
87 {"core.lock", "Lock"},
88 {"core.unlock", "Unlock"},
89 {"core.enter_pin", "Enter PIN"},
90 {"core.press_any_key", "Any key: unlock [3]: menu"},
91 {"core.deep_sleep", "Deep Sleep"},
92 {"core.ship_mode_hint", "Shipping mode - press PW ON 2s to re-enable"},
93 {"core.wrong_pin", "Wrong PIN"},
94 {"core.locked_out", "Locked out"},
95 {"core.too_many_attempts", "Too many attempts"},
96
97 {"core.change_pin", "Change PIN"},
98 {"core.set_duress_pin", "Set Duress PIN"},
99 {"core.current_pin", "Current PIN"},
100 {"core.new_pin", "New PIN"},
101 {"core.confirm_pin", "Confirm PIN"},
102 {"core.pin_changed", "PIN changed"},
103 {"core.pins_dont_match", "PINs don't match"},
104 {"core.pin_too_short", "PIN too short"},
105 {"core.pin_mismatch", "PINs don't match"},
106 {"core.retries", "Retries"},
107 {"core.error_generic", "Error"},
108
109 {"core.brightness", "Brightness"},
110 {"core.language", "Language"},
111 {"core.lang_name", "English"},
112 {"core.timezone", "Timezone"},
113 {"core.summer_time", "Daylight Saving"},
114 {"core.badge_text", "Badge Text"},
115 {"core.auto_sleep", "Sleep Interval"},
116 {"core.set_date", "Set Date"},
117 {"core.set_time", "Set Time"},
118 {"core.date", "Date"},
119 {"core.time", "Time"},
120 {"core.date_saved", "Date saved"},
121 {"core.time_saved", "Time saved"},
122 {"core.modules", "Modules"},
123 {"core.never", "Never"},
124 {"core.minutes", "min"},
125
126 {"core.wifi_menu", "WiFi"},
127 {"core.wifi_setup", "WiFi Setup"},
128 {"core.wifi_connect", "Connect"},
129 {"core.wifi_disconnect", "Disconnect"},
130 {"core.wifi_on", "WiFi ON"},
131 {"core.wifi_off", "WiFi OFF"},
132 {"core.wifi_scanning", "Scanning..."},
133 {"core.wifi_no_networks", "No networks"},
134 {"core.wifi_connected", "Connected!"},
135 {"core.wifi_disconnected", "Disconnected"},
136 {"core.wifi_failed", "Connection failed"},
137 {"core.wifi_no_config", "No WiFi configured"},
138 {"core.wifi_password", "Password"},
139 {"core.wifi_add_manual", "Add Manual"},
140 {"core.wifi_ssid", "SSID"},
141 {"core.wifi_encryption", "Encryption"},
142 {"core.wifi_ip_mode", "IP Mode"},
143 {"core.wifi_dhcp", "DHCP (Auto)"},
144 {"core.wifi_static", "Static IP"},
145 {"core.wifi_gateway", "Gateway"},
146 {"core.wifi_netmask", "Netmask"},
147 {"core.wifi_dns", "DNS"},
148 {"core.wifi_saved_config", "Saved Config"},
149 {"core.ntp_sync", "Sync Time"},
150 {"core.ntp_syncing", "Syncing time..."},
151 {"core.ntp_success", "Time synced!"},
152 {"core.ntp_failed", "Sync failed"},
153 {"core.ntp_timeout", "Sync timeout"},
154 {"core.bluetooth", "Bluetooth"},
155 {"core.bluetooth_on", "Bluetooth ON"},
156 {"core.bluetooth_off", "Bluetooth OFF"},
157 {"core.ble_status", "BLE Status"},
158 {"core.ble_scan", "Scan Devices"},
159 {"core.ble_scanning", "Scanning..."},
160 {"core.ble_no_devices", "No devices found"},
161 {"core.ble_connected_to", "Connected to"},
162 {"core.ble_not_connected", "Not connected"},
163 {"core.ble_mac_address", "MAC"},
164 {"core.ble_paired_devices", "Paired devices"},
165 {"core.ble_no_paired", "No paired devices"},
166 {"core.ble_forget_one", "Forget this device?"},
167 {"core.ble_forget_all", "Forget all bonds"},
168 {"core.ble_forget_all_confirm", "Forget all paired devices?"},
169 {"core.ble_bonds_cleared", "Bonds cleared"},
170 {"core.ble_pairing_menu", "Pair device"},
171 {"core.ble_pairing_title", "Pairing Mode"},
172 {"core.ble_pairing_instr", "On your device, select:"},
173 {"core.ble_pairing_waiting", "Waiting for device..."},
174 {"core.ble_pairing_exit", "[N] Exit"},
175 {"core.msg_beacon", "Beacon"},
176 {"core.msg_beacon_on", "Beacon: on"},
177 {"core.msg_beacon_off", "Beacon: off"},
178 {"core.msg_beacon_name", "Beacon name"},
179 {"core.msg_beacon_scan", "Beacon scan"},
180 {"core.msg_pick_peer", "Select badge"},
181 {"core.msg_searching", "Searching for badges..."},
182 {"core.msg_offer_title", "Incoming transfer"},
183 {"core.msg_offer_from", "%s wants to send you:"},
184 {"core.msg_text", "Text message"},
185 {"core.msg_data", "Data"},
186 {"core.msg_sending", "Sending..."},
187 {"core.msg_receiving", "Receiving..."},
188 {"core.msg_transfer_ok", "Transfer complete"},
189 {"core.msg_transfer_fail", "Transfer failed"},
190 {"core.msg_declined", "Declined"},
191 {"core.msg_unsupported", "Type not supported"},
192 {"core.feature_missing", "No plugin for this feature"},
193 {"core.system_test", "System Test"},
194 {"core.tr01_cache_rebuild", "TR01 Cache Rebuild"},
195 {"core.tr01_cache_cleanup", "TR01 Cache Cleanup"},
196 {"core.expert", "Expert"},
197 {"core.expert_warning", "Caution"},
198 {"core.bootloader", "Bootloader"},
199 {"core.shipping_mode", "Shipping Mode"},
200 {"core.shipping_mode_q", "Enter shipping mode? Disconnects the battery."},
201 {"core.plugins", "Plugins"},
202 {"core.task_working", "Please wait"},
203 {"core.sleep", "Sleep"},
204 {"core.usb_replug_required","USB replug may be needed"},
205 {"core.usb_no_free_slot", "No free USB slot - disable a USB module (e.g. GPG) first"},
206 {"core.module_error_generic","Module error"},
207 {"core.module_retry_prompt","Reload module?"},
208
209 {"core.backup", "Backup"},
210 {"core.backup_export", "Export"},
211 {"core.backup_import", "Import"},
212 {"core.backup_passphrase", "Passphrase"},
213 {"core.backup_confirm_passphrase", "Confirm passphrase"},
214 {"core.backup_pass_empty", "Passphrase required"},
215 {"core.backup_export_ok", "Backup saved"},
216 {"core.backup_none", "No backup present"},
217 {"core.backup_import_fail", "Wrong passphrase or corrupt file"},
218 {"core.backup_delete_q", "Delete backup?"},
219 {"core.backup_summary", "Restore Summary"},
220 {"core.backup_imported", "Imported"},
221 {"core.backup_failed", "Failed"},
222 {"core.backup_modules", "Modules"},
223 {"core.backup_skipped", "Skipped"},
224 {"core.backup_system", "System Settings"},
225 {"core.backup_scope_info", "No keys are backed up (FIDO2/GPG); only data."},
226
227 {"core.hw_section_memory", "Memory"},
228 {"core.hw_section_runtime", "Runtime"},
229 {"core.hw_i2c_bus", "I2C Bus"},
230 {"core.hw_bq25895", "BQ25895"},
231 {"core.hw_tca9535", "TCA9535"},
232 {"core.hw_display", "Display"},
233 {"core.hw_tropic01", "TROPIC01"},
234 {"core.hw_tr01_session", "TR01 Session"},
235 {"core.hw_tr01_riscv_fw", "TR01 RISC-V FW"},
236 {"core.hw_tr01_spect_fw", "TR01 SPECT FW"},
237 {"core.hw_tr01_rmem_slot", "TR01 R-Mem slot"},
238 {"core.hw_wifi", "WiFi"},
239 {"core.hw_ble", "BLE"},
240 {"core.hw_heap", "Heap"},
241 {"core.hw_psram", "PSRAM"},
242 {"core.hw_nvs", "NVS"},
243 {"core.hw_entries", "entries"},
244 {"core.hw_battery", "Battery"},
245 {"core.hw_temp", "Temp"},
246 {"core.hw_uptime", "Uptime"},
247 {"core.hw_cpu_load", "CPU Load"},
248 {"core.hw_charging_suffix", " (chg)"},
249 {"core.hw_not_available", "n/a"},
250
251 {"core.actions", "Actions"},
252 {"core.light", "Light"},
253
254 {"core.hint_back", "[N] Back"},
255 {"core.hint_select", "[Y] Select"},
256 {"core.hint_ok_back", "[Y] OK [N] Back"},
257 {"core.hint_approve_deny", "[Y] Approve [N] Deny"},
258 {"core.hint_brightness", "<4 6> Adjust [Y] Save"},
259 {"core.hint_pin_input", "[0-9] Input [Y] OK"},
260 {"core.hint_t9_input", "[0-9] T9 [Y] OK"},
261 {"core.t9_full", "Full"},
262 {"core.hint_list_menu", "[3] Menu"},
263 {"core.hint_scroll_back", "[2/8] Scroll [N] Back"},
264 {"core.md_truncated", "-- truncated --"},
265 {"core.hint_image", "[5] Zoom [2/4/6/8] Pan [N] Back"},
266 {"core.img_too_large", "Image too large"},
267 {"core.img_unsupported", "Unsupported image"},
268 {"core.img_decode_failed", "Cannot decode image"},
269 {"core.hint_field_nav", "[4] < [6] >"},
270 {"core.hint_date_input", "[0-9] [Y] OK [N] Clear"},
271 {"core.hint_time_input", "[0-9] [Y] OK [N] Clear"},
272
273 {"core.wifi_connecting", "Connecting to"},
274 {"core.plugin_loading", "Loading"},
275 {"core.stop", "Stop"},
276 {"core.start", "Start"},
277 {"core.enable", "Enable"},
278 {"core.disable", "Disable"},
279 {"core.uninstall", "Uninstall"},
280 {"core.plugin_enabled", "Plugin enabled"},
281 {"core.plugin_disabled", "Plugin disabled"},
282 {"core.plugin_bg_running", "Runs in background"},
283 {"core.plugin_stop_bg", "Stop background"},
284 {"core.plugin_not_running", "Not running"},
285 {"core.hint_plugin_list", "[Y] Start [3] Menu [N] Back"},
286 {"core.hint_password_hidden", "[hold Y] Show [Y] Save"},
287 {"core.hint_password_revealed", "[hold Y] Hide [Y] Save"},
288
289 {"core.qr_error", "QR Error"},
290 {"core.no_data", "No data"},
291
292 {"core.status", "Status"},
293 {"core.connected", "Connected"},
294 {"core.details", "Details"},
295 {"core.signal", "Signal"},
296 {"core.inactive", "Inactive"},
297};
298
299constexpr std::size_t kCoreCount =
300 sizeof(kCoreStrings) / sizeof(kCoreStrings[0]);
301
302} // namespace
303
304I18n::I18n() = default;
305
307{
308 static I18n s;
309 return s;
310}
311
312void I18n::registerCoreEnglishTable()
313{
314 registerEnglishTable(kCoreStrings, kCoreCount);
315}
316
318{
319 registerCoreEnglishTable();
320 loadLanguageFromNvs();
321 LOG_I(TAG, "I18n initialized, lang=%s, core=%u entries",
322 currentLang_.c_str(), static_cast<unsigned>(kCoreCount));
323 return true;
324}
325
326void I18n::registerEnglishTable(const I18nEntry* entries, std::size_t count)
327{
328 if (!entries || count == 0) return;
329 en_.reserve(en_.size() + count);
330 en_.insert(en_.end(), entries, entries + count);
331 enSorted_ = false;
332}
333
334bool I18n::sortIfNeeded() const
335{
336 if (enSorted_) return true;
337 std::sort(en_.begin(), en_.end(),
338 [](const I18nEntry& a, const I18nEntry& b) {
339 return std::strcmp(a.key, b.key) < 0;
340 });
341 enSorted_ = true;
342 return true;
343}
344
345const char* I18n::enLookup(const char* key) const
346{
347 sortIfNeeded();
348 I18nEntry probe{key, nullptr};
349 auto it = std::lower_bound(en_.begin(), en_.end(), probe,
350 [](const I18nEntry& a, const I18nEntry& b) {
351 return std::strcmp(a.key, b.key) < 0;
352 });
353 if (it != en_.end() && std::strcmp(it->key, key) == 0) return it->en;
354 return nullptr;
355}
356
357const char* I18n::overlayLookup(const char* key) const
358{
359 if (overlayCount_ == 0 || !overlayRefs_) return nullptr;
360 const OverlayRef* base = overlayRefs_.get();
361 std::size_t lo = 0, hi = overlayCount_;
362 while (lo < hi) {
363 std::size_t mid = lo + (hi - lo) / 2;
364 int c = std::strcmp(base[mid].key, key);
365 if (c == 0) return base[mid].value;
366 if (c < 0) lo = mid + 1; else hi = mid;
367 }
368 return nullptr;
369}
370
371const char* I18n::overlayTr(const char* key) const
372{
373 if (!key || currentLang_ == "en") return nullptr;
374 return overlayLookup(key);
375}
376
377const char* I18n::tr(const char* key) const
378{
379 if (!key) return "";
380 if (currentLang_ != "en") {
381 if (const char* s = overlayLookup(key)) return s;
382 }
383 if (const char* s = enLookup(key)) return s;
384 static thread_local char missing[64];
385 std::snprintf(missing, sizeof(missing), "?%s", key);
386 return missing;
387}
388
389bool I18n::setLanguageCode(const char* code)
390{
391 std::string newLang = (code && *code) ? code : "en";
392 if (newLang == currentLang_) return true;
393 currentLang_ = std::move(newLang);
394
395 overlayBlob_.reset();
396 overlayRefs_.reset();
397 overlayCount_ = 0;
398 if (currentLang_ != "en") loadActiveOverlayFile();
399 if (onChanged_) onChanged_();
400
401 saveLanguageToNvs();
402 LOG_I(TAG, "Language changed to %s", currentLang_.c_str());
403 return true;
404}
405
406void I18n::scanAvailableLanguages()
407{
408 overlayLangs_.clear();
409 DIR* dir = opendir(OVERLAY_DIR);
410 if (!dir) return;
411
412 constexpr const char* kPrefix = "lang_";
413 constexpr size_t kPrefixLen = 5; // strlen("lang_")
414 constexpr size_t kSuffixLen = 5; // strlen(".json")
415
416 struct dirent* ent = nullptr;
417 while ((ent = readdir(dir)) != nullptr) {
418 const char* n = ent->d_name;
419 const size_t len = std::strlen(n);
420 if (len <= kPrefixLen + kSuffixLen) continue;
421 if (std::strncmp(n, kPrefix, kPrefixLen) != 0) continue;
422 if (std::strcmp(n + len - kSuffixLen, ".json") != 0) continue;
423
424 std::string code(n + kPrefixLen, len - kPrefixLen - kSuffixLen);
425 if (code.empty() || code == "en") continue; // English is in-code
426
427 // Default the display name to the code, then try to read the file's
428 // own `core.lang_name` endonym.
429 OverlayLanguage lang{code, code};
430 const std::string path = std::string(OVERLAY_DIR) + "/" + n;
431 if (auto fp = cdc::core::openFile(path.c_str(), "rb")) {
432 std::fseek(fp.get(), 0, SEEK_END);
433 const long size = std::ftell(fp.get());
434 std::fseek(fp.get(), 0, SEEK_SET);
435 if (size > 0 && size <= 1024 * 1024) {
436 auto buf = cdc::core::psramAlloc<char>(static_cast<std::size_t>(size) + 1);
437 if (buf && std::fread(buf.get(), 1, size, fp.get()) == static_cast<size_t>(size)) {
438 buf.get()[size] = '\0';
439 PsramCjsonScope cjson_psram;
440 if (cJSON* root = cJSON_Parse(buf.get())) {
441 cJSON* nm = cJSON_GetObjectItemCaseSensitive(root, "core.lang_name");
442 if (cJSON_IsString(nm) && nm->valuestring && *nm->valuestring) {
443 lang.name = cdc::core::cp437::fromUtf8(nm->valuestring);
444 }
445 cJSON_Delete(root);
446 }
447 }
448 }
449 }
450 overlayLangs_.push_back(std::move(lang));
451 }
452 closedir(dir);
453
454 std::sort(overlayLangs_.begin(), overlayLangs_.end(),
455 [](const OverlayLanguage& a, const OverlayLanguage& b) {
456 return a.code < b.code;
457 });
458}
459
460bool I18n::loadActiveOverlayFile()
461{
462 overlayBlob_.reset();
463 overlayRefs_.reset();
464 overlayCount_ = 0;
465
466 const std::string path =
467 std::string(OVERLAY_DIR) + "/lang_" + currentLang_ + ".json";
468
469 auto fp = cdc::core::openFile(path.c_str(), "rb");
470 if (!fp) {
471 LOG_W(TAG, "Overlay file not found: %s", path.c_str());
472 return false;
473 }
474
475 std::fseek(fp.get(), 0, SEEK_END);
476 long size = std::ftell(fp.get());
477 std::fseek(fp.get(), 0, SEEK_SET);
478 if (size <= 0 || size > 1024 * 1024) {
479 LOG_W(TAG, "Overlay file size invalid: %ld", size);
480 return false;
481 }
482
483 auto buf = cdc::core::psramAlloc<char>(static_cast<std::size_t>(size) + 1);
484 if (!buf) {
485 LOG_E(TAG, "Overlay PSRAM allocation failed (%ld bytes)", size);
486 return false;
487 }
488 if (std::fread(buf.get(), 1, size, fp.get()) != static_cast<size_t>(size)) {
489 LOG_E(TAG, "Overlay file read failed");
490 return false;
491 }
492 buf.get()[size] = '\0';
493
494 // Parse tree + final storage both live in PSRAM.
495 PsramCjsonScope cjson_psram;
496 cJSON* root = cJSON_Parse(buf.get());
497 if (!root || !cJSON_IsObject(root)) {
498 LOG_E(TAG, "Overlay JSON parse failed near: %s",
499 cJSON_GetErrorPtr() ? cJSON_GetErrorPtr() : "<unknown>");
500 if (root) cJSON_Delete(root);
501 return false;
502 }
503
504 // Pass 1: count string entries and the bytes needed for the packed blob
505 // (key + CP437-converted value, each null-terminated).
506 std::size_t count = 0;
507 std::size_t bytes = 0;
508 for (cJSON* e = root->child; e; e = e->next) {
509 if (!cJSON_IsString(e) || !e->string || !e->valuestring) continue;
510 ++count;
511 bytes += std::strlen(e->string) + 1;
512 bytes += cdc::core::cp437::fromUtf8(e->valuestring).size() + 1;
513 }
514 if (count == 0) { cJSON_Delete(root); return true; }
515
516 auto blob = cdc::core::psramAlloc<char>(bytes);
517 auto refs = cdc::core::psramAlloc<OverlayRef>(count);
518 if (!blob || !refs) {
519 LOG_E(TAG, "Overlay storage PSRAM allocation failed");
520 cJSON_Delete(root);
521 return false;
522 }
523
524 // Pass 2: pack into the PSRAM blob and record key/value pointers.
525 char* w = blob.get();
526 std::size_t idx = 0;
527 for (cJSON* e = root->child; e; e = e->next) {
528 if (!cJSON_IsString(e) || !e->string || !e->valuestring) continue;
529 const std::string val = cdc::core::cp437::fromUtf8(e->valuestring);
530 const std::size_t kl = std::strlen(e->string);
531 refs.get()[idx].key = w;
532 std::memcpy(w, e->string, kl + 1);
533 w += kl + 1;
534 refs.get()[idx].value = w;
535 std::memcpy(w, val.c_str(), val.size() + 1);
536 w += val.size() + 1;
537 ++idx;
538 }
539 cJSON_Delete(root);
540
541 std::sort(refs.get(), refs.get() + count,
542 [](const OverlayRef& a, const OverlayRef& b) {
543 return std::strcmp(a.key, b.key) < 0;
544 });
545
546 overlayBlob_ = std::move(blob);
547 overlayRefs_ = std::move(refs);
548 overlayCount_ = count;
549
550 LOG_I(TAG, "Overlay '%s' loaded: %u entries (PSRAM)",
551 currentLang_.c_str(), static_cast<unsigned>(count));
552 return true;
553}
554
556{
557 scanAvailableLanguages();
558
559 bool ok = true;
560 if (currentLang_ != "en") {
561 ok = loadActiveOverlayFile();
562 } else {
563 overlayBlob_.reset();
564 overlayRefs_.reset();
565 overlayCount_ = 0;
566 }
567
568 LOG_I(TAG, "i18n overlay: %u languages available, active='%s'",
569 static_cast<unsigned>(overlayLangs_.size()),
570 currentLang_.c_str());
571
572 if (onChanged_) onChanged_();
573 return ok;
574}
575
576const char* I18n::languageName(const char* code) const
577{
578 if (!code || !*code || std::strcmp(code, "en") == 0) {
579 const char* en = enLookup("core.lang_name");
580 return en ? en : "English";
581 }
582 for (const auto& l : overlayLangs_) {
583 if (l.code == code) return l.name.c_str();
584 }
585 return code;
586}
587
588void I18n::loadLanguageFromNvs()
589{
590 cdc::core::NvsScope nvs(NVS_NAMESPACE, NVS_READONLY);
591 if (!nvs) return;
592 size_t len = 0;
593 if (nvs_get_str(nvs, NVS_KEY_LANG_CODE, nullptr, &len) != ESP_OK || len == 0) return;
594 if (len > 8) len = 8;
595 char buf[9] = {};
596 if (nvs_get_str(nvs, NVS_KEY_LANG_CODE, buf, &len) == ESP_OK) {
597 currentLang_ = buf;
598 }
599}
600
601void I18n::saveLanguageToNvs()
602{
603 cdc::core::NvsScope nvs(NVS_NAMESPACE, NVS_READWRITE);
604 if (!nvs) return;
605 nvs_set_str(nvs, NVS_KEY_LANG_CODE, currentLang_.c_str());
606 nvs.commit();
607}
608
609} // namespace cdc::ui
Canonical CP437 <-> Unicode/UTF-8 codec.
Internationalization with English fallbacks in code and overlay translations loaded at runtime from a...
RAII scope that routes cJSON allocations to PSRAM.
Shared RAII wrappers for firmware resources.
CDC Log: logging over TinyUSB CDC and UART.
#define LOG_W(tag, fmt,...)
Definition cdc_log.h:146
#define LOG_I(tag, fmt,...)
Definition cdc_log.h:147
#define LOG_E(tag, fmt,...)
Definition cdc_log.h:145
RAII wrapper for an NVS handle.
Definition Raii.h:106
static I18n & instance()
Singleton accessor.
Definition I18n.cpp:306
const char * overlayTr(const char *key) const
Overlay-only lookup with no English fallback.
Definition I18n.cpp:371
bool setLanguageCode(const char *code)
Set the active language by code.
Definition I18n.cpp:389
bool loadOverlay()
Rescan available languages and (re)load the active overlay.
Definition I18n.cpp:555
void registerEnglishTable(const I18nEntry *entries, std::size_t count)
Append English entries to the lookup table.
Definition I18n.cpp:326
const char * languageName(const char *code) const
Display name (endonym) for a language code, for the picker.
Definition I18n.cpp:576
static constexpr const char * OVERLAY_DIR
Definition I18n.h:70
bool init()
Initialize and load persisted language code from NVS.
Definition I18n.cpp:317
const char * tr(const char *key) const
Look up a translation by key.
Definition I18n.cpp:377
#define NVS_NAMESPACE
std::string fromUtf8(const char *s)
Convert a UTF-8 string to CP437 bytes (unmapped chars dropped).
Definition Cp437.cpp:88
FilePtr openFile(const char *path, const char *mode) noexcept
Open a FILE* and wrap it in a FilePtr.
Definition Raii.h:87
PsramUniquePtr< T > psramAlloc(std::size_t count) noexcept
Allocate count elements of T in PSRAM (8-bit capable region).
Definition Raii.h:51
Centralized key-code constants for cdc_views.
Definition IModule.h:8
static const char * TAG
#define APP_VERSION
Single English translation entry.
Definition I18n.h:44
Internationalization singleton.
Definition I18n.h:61
std::string name
Endonym for the picker (CP437-encoded), e.g. "Deutsch".
Definition I18n.h:63