30static const char*
TAG =
"I18n";
35constexpr const char* NVS_KEY_LANG_CODE =
"langc";
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"},
58 {
"core.back",
"Back"},
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"},
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"},
81 {
"core.saved",
"Saved"},
82 {
"core.deleted",
"Deleted"},
83 {
"core.failed",
"Failed"},
84 {
"core.timeout",
"Timeout"},
85 {
"core.empty",
"empty"},
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"},
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"},
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"},
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?"},
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."},
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"},
251 {
"core.actions",
"Actions"},
252 {
"core.light",
"Light"},
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"},
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"},
289 {
"core.qr_error",
"QR Error"},
290 {
"core.no_data",
"No data"},
292 {
"core.status",
"Status"},
293 {
"core.connected",
"Connected"},
294 {
"core.details",
"Details"},
295 {
"core.signal",
"Signal"},
296 {
"core.inactive",
"Inactive"},
299constexpr std::size_t kCoreCount =
300 sizeof(kCoreStrings) /
sizeof(kCoreStrings[0]);
304I18n::I18n() =
default;
312void I18n::registerCoreEnglishTable()
319 registerCoreEnglishTable();
320 loadLanguageFromNvs();
321 LOG_I(
TAG,
"I18n initialized, lang=%s, core=%u entries",
322 currentLang_.c_str(),
static_cast<unsigned>(kCoreCount));
328 if (!entries || count == 0)
return;
329 en_.reserve(en_.size() + count);
330 en_.insert(en_.end(), entries, entries + count);
334bool I18n::sortIfNeeded()
const
336 if (enSorted_)
return true;
337 std::sort(en_.begin(), en_.end(),
339 return std::strcmp(a.key, b.key) < 0;
345const char* I18n::enLookup(
const char* key)
const
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;
353 if (it != en_.end() && std::strcmp(it->key, key) == 0)
return it->en;
357const char* I18n::overlayLookup(
const char* key)
const
359 if (overlayCount_ == 0 || !overlayRefs_)
return nullptr;
360 const OverlayRef* base = overlayRefs_.get();
361 std::size_t lo = 0, hi = overlayCount_;
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;
373 if (!key || currentLang_ ==
"en")
return nullptr;
374 return overlayLookup(key);
380 if (currentLang_ !=
"en") {
381 if (
const char* s = overlayLookup(key))
return s;
383 if (
const char* s = enLookup(key))
return s;
384 static thread_local char missing[64];
385 std::snprintf(missing,
sizeof(missing),
"?%s", key);
391 std::string newLang = (code && *code) ? code :
"en";
392 if (newLang == currentLang_)
return true;
393 currentLang_ = std::move(newLang);
395 overlayBlob_.reset();
396 overlayRefs_.reset();
398 if (currentLang_ !=
"en") loadActiveOverlayFile();
399 if (onChanged_) onChanged_();
402 LOG_I(
TAG,
"Language changed to %s", currentLang_.c_str());
406void I18n::scanAvailableLanguages()
408 overlayLangs_.clear();
412 constexpr const char* kPrefix =
"lang_";
413 constexpr size_t kPrefixLen = 5;
414 constexpr size_t kSuffixLen = 5;
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;
424 std::string code(n + kPrefixLen, len - kPrefixLen - kSuffixLen);
425 if (code.empty() || code ==
"en")
continue;
430 const std::string path = std::string(
OVERLAY_DIR) +
"/" + n;
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) {
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) {
450 overlayLangs_.push_back(std::move(lang));
454 std::sort(overlayLangs_.begin(), overlayLangs_.end(),
455 [](
const OverlayLanguage& a,
const OverlayLanguage& b) {
456 return a.code < b.code;
460bool I18n::loadActiveOverlayFile()
462 overlayBlob_.reset();
463 overlayRefs_.reset();
466 const std::string path =
467 std::string(
OVERLAY_DIR) +
"/lang_" + currentLang_ +
".json";
471 LOG_W(
TAG,
"Overlay file not found: %s", path.c_str());
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);
485 LOG_E(
TAG,
"Overlay PSRAM allocation failed (%ld bytes)", size);
488 if (std::fread(buf.get(), 1, size, fp.get()) !=
static_cast<size_t>(size)) {
489 LOG_E(
TAG,
"Overlay file read failed");
492 buf.get()[size] =
'\0';
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);
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;
511 bytes += std::strlen(e->string) + 1;
514 if (count == 0) { cJSON_Delete(root);
return true; }
518 if (!blob || !refs) {
519 LOG_E(
TAG,
"Overlay storage PSRAM allocation failed");
525 char* w = blob.get();
527 for (cJSON* e = root->child; e; e = e->next) {
528 if (!cJSON_IsString(e) || !e->string || !e->valuestring)
continue;
530 const std::size_t kl = std::strlen(e->string);
531 refs.get()[idx].key = w;
532 std::memcpy(w, e->string, kl + 1);
534 refs.get()[idx].value = w;
535 std::memcpy(w, val.c_str(), val.size() + 1);
541 std::sort(refs.get(), refs.get() + count,
542 [](
const OverlayRef& a,
const OverlayRef& b) {
543 return std::strcmp(a.key, b.key) < 0;
546 overlayBlob_ = std::move(blob);
547 overlayRefs_ = std::move(refs);
548 overlayCount_ = count;
550 LOG_I(
TAG,
"Overlay '%s' loaded: %u entries (PSRAM)",
551 currentLang_.c_str(),
static_cast<unsigned>(count));
557 scanAvailableLanguages();
560 if (currentLang_ !=
"en") {
561 ok = loadActiveOverlayFile();
563 overlayBlob_.reset();
564 overlayRefs_.reset();
568 LOG_I(
TAG,
"i18n overlay: %u languages available, active='%s'",
569 static_cast<unsigned>(overlayLangs_.size()),
570 currentLang_.c_str());
572 if (onChanged_) onChanged_();
578 if (!code || !*code || std::strcmp(code,
"en") == 0) {
579 const char* en = enLookup(
"core.lang_name");
580 return en ? en :
"English";
582 for (
const auto& l : overlayLangs_) {
583 if (l.code == code)
return l.name.c_str();
588void I18n::loadLanguageFromNvs()
593 if (nvs_get_str(nvs, NVS_KEY_LANG_CODE,
nullptr, &len) != ESP_OK || len == 0)
return;
594 if (len > 8) len = 8;
596 if (nvs_get_str(nvs, NVS_KEY_LANG_CODE, buf, &len) == ESP_OK) {
601void I18n::saveLanguageToNvs()
605 nvs_set_str(nvs, NVS_KEY_LANG_CODE, currentLang_.c_str());
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,...)
#define LOG_I(tag, fmt,...)
#define LOG_E(tag, fmt,...)
RAII wrapper for an NVS handle.
static I18n & instance()
Singleton accessor.
const char * overlayTr(const char *key) const
Overlay-only lookup with no English fallback.
bool setLanguageCode(const char *code)
Set the active language by code.
bool loadOverlay()
Rescan available languages and (re)load the active overlay.
void registerEnglishTable(const I18nEntry *entries, std::size_t count)
Append English entries to the lookup table.
const char * languageName(const char *code) const
Display name (endonym) for a language code, for the picker.
static constexpr const char * OVERLAY_DIR
bool init()
Initialize and load persisted language code from NVS.
const char * tr(const char *key) const
Look up a translation by key.
std::string fromUtf8(const char *s)
Convert a UTF-8 string to CP437 bytes (unmapped chars dropped).
FilePtr openFile(const char *path, const char *mode) noexcept
Open a FILE* and wrap it in a FilePtr.
PsramUniquePtr< T > psramAlloc(std::size_t count) noexcept
Allocate count elements of T in PSRAM (8-bit capable region).
Centralized key-code constants for cdc_views.
Single English translation entry.
Internationalization singleton.
std::string name
Endonym for the picker (CP437-encoded), e.g. "Deutsch".