Spectrum: stat tiles (peak, floor, our channel, busy), plot and waterfall drawn at screen size (the lv_chart and zoomed waterfall were the page's render cost), dark-to-yellow waterfall, peak hold, our-channel marker, Band/Channel and Live/1 min/10 min chips. RF Monitor 1.4 (tiles, filled chart, typed feed, packet detail card; the old type table did not match MeshCore payload types) and Airtime 1.6 (big figure, tiles, duty line, rx/tx split). Lua SDK: ui.panel, bold labels and a size-24 figure font, colors.warn/border, app.on_audio (fires with the screen off), read-only wada.sd.list and sd: audio on the SD_MMC boards (P4, Tanmatsu, Wio L2), caps panel/audio_events/epaper, a "music" icon and an "audio" Store requirement. button:set() no longer writes label fields into the button object (heap corruption, every earlier firmware). The SDK page's input table described events the firmware never sent. Music 1.0 (Store app): plays MP3/WAV from the card's Music folder, shuffle, repeat, keeps going through the list with the screen off. Verified on the T-Display P4 end to end. Store: the update check also fetches the app catalog and badges the Store tile with the number of installed apps that have a newer version. Baked Lua apps are raw-deflated and inflated by the S3 ROM (14.6 KB back on the V4); the V4 language table keeps only texts of apps it can run. Fixes: the SD-card notice no longer fires when the card mounts fine (#667); the Control Center clock keeps time while open (#666); M9 keyboard brightness (#664); the battery widget's number no longer sits on a ring too small for it (Discord); M9 shortcuts doc follows #669's composer order. New texts in all 14 languages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
365 lines
15 KiB
Python
365 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""Which translation keys can the firmware actually look up?
|
|
|
|
The Heltec V4 compiles the translations in (i18n_builtin.h, generated by
|
|
gen-lang-builtin.py) and has no flash to spare, so it bakes only the keys some
|
|
source can pass to TR() or Lua's wada.sys.tr. The rule over-includes on purpose:
|
|
a .lang key is kept when it equals a C string literal anywhere in the sources
|
|
(every #if branch, adjacent literals concatenated, string macros such as
|
|
LV_SYMBOL_* and UI_ICON_* expanded, escapes decoded as GCC does), or that literal
|
|
with TR()'s glyph prefix stripped, or a string literal in any Lua app. A key it
|
|
wrongly drops shows English on the V4 only; TR() falls back to the key, so it
|
|
can never crash. oracle_missing() is the guard: it reads the strings the
|
|
compiler really emitted into the V4's objects and lists any translated key among
|
|
them that the rule dropped.
|
|
"""
|
|
import glob, os, re, struct
|
|
|
|
# ---- C lexer -------------------------------------------------------------------
|
|
SIMPLE = {'n': 10, 't': 9, 'r': 13, 'a': 7, 'b': 8, 'f': 12, 'v': 11, 'e': 27, 'E': 27,
|
|
'\\': 92, "'": 39, '"': 34, '?': 63}
|
|
HEX = set('0123456789abcdefABCDEF')
|
|
|
|
|
|
def c_unescape(body):
|
|
"""body: latin-1 str of the raw bytes between the quotes -> bytes, GCC semantics."""
|
|
out = bytearray(); i = 0; n = len(body)
|
|
while i < n:
|
|
c = body[i]
|
|
if c != '\\':
|
|
out.append(ord(c)); i += 1; continue
|
|
i += 1
|
|
if i >= n: break
|
|
c = body[i]
|
|
if c in SIMPLE: out.append(SIMPLE[c]); i += 1
|
|
elif c == 'x': # greedy: ALL following hex digits
|
|
j = i + 1
|
|
while j < n and body[j] in HEX: j += 1
|
|
v = int(body[i + 1:j] or '0', 16)
|
|
out.append(v & 0xFF); i = j # >0xFF is a GCC error; would not compile
|
|
elif c in '01234567': # up to 3 octal digits
|
|
j = i
|
|
while j < n and j < i + 3 and body[j] in '01234567': j += 1
|
|
out.append(int(body[i:j], 8) & 0xFF); i = j
|
|
elif c in 'uU':
|
|
k = 4 if c == 'u' else 8
|
|
out += chr(int(body[i + 1:i + 1 + k], 16)).encode('utf-8'); i += 1 + k
|
|
else:
|
|
out.append(ord(c)); i += 1 # unknown escape: GCC warns, keeps char
|
|
return bytes(out)
|
|
|
|
|
|
IDST = re.compile(r'[A-Za-z_]')
|
|
IDRE = re.compile(r'[A-Za-z_][A-Za-z0-9_]*')
|
|
STRPFX = ('u8R', 'uR', 'UR', 'LR', 'R', 'u8', 'u', 'U', 'L', '')
|
|
|
|
|
|
def lex(text):
|
|
"""Yield (kind, value, at_line_start) tokens. kind: S (bytes), I (ident), P, D (directive start)."""
|
|
text = text.replace('\\\r\n', '').replace('\\\n', '') # phase-2 splices
|
|
i, n = 0, len(text)
|
|
bol = True
|
|
while i < n:
|
|
c = text[i]
|
|
if c == '\n': bol = True; i += 1; yield ('NL', None); continue
|
|
if c in ' \t\r\f\v': i += 1; continue
|
|
if text.startswith('//', i):
|
|
j = text.find('\n', i); i = n if j < 0 else j; continue
|
|
if text.startswith('/*', i):
|
|
j = text.find('*/', i + 2); i = n if j < 0 else j + 2; continue
|
|
if c == '#' and bol:
|
|
bol = False; yield ('D', None); i += 1; continue
|
|
bol = False
|
|
# string literal (with optional encoding / raw prefix)
|
|
m = IDRE.match(text, i) if IDST.match(c) else None
|
|
if m and m.group(0) in STRPFX and m.end() < n and text[m.end()] == '"':
|
|
pfx = m.group(0); i = m.end()
|
|
if pfx.endswith('R'):
|
|
k = text.find('(', i)
|
|
delim = text[i + 1:k]
|
|
end = text.find(')' + delim + '"', k)
|
|
yield ('S', text[k + 1:end].encode('latin-1'))
|
|
i = end + len(delim) + 2; continue
|
|
j = i + 1
|
|
while j < n and text[j] != '"':
|
|
if text[j] == '\\': j += 1
|
|
elif text[j] == '\n': break
|
|
j += 1
|
|
yield ('S', c_unescape(text[i + 1:j])); i = j + 1; continue
|
|
if m:
|
|
yield ('I', m.group(0)); i = m.end(); continue
|
|
if c == '"':
|
|
j = i + 1
|
|
while j < n and text[j] != '"':
|
|
if text[j] == '\\': j += 1
|
|
elif text[j] == '\n': break
|
|
j += 1
|
|
yield ('S', c_unescape(text[i + 1:j])); i = j + 1; continue
|
|
if c == "'":
|
|
j = i + 1
|
|
while j < n and text[j] != "'":
|
|
if text[j] == '\\': j += 1
|
|
elif text[j] == '\n': break
|
|
j += 1
|
|
i = j + 1; continue
|
|
if c.isdigit() or (c == '.' and i + 1 < n and text[i + 1].isdigit()):
|
|
j = i + 1 # pp-number (keeps 1'000 separators intact)
|
|
while j < n and (text[j].isalnum() or text[j] in "._'" or
|
|
(text[j] in '+-' and text[j - 1] in 'eEpP')):
|
|
j += 1
|
|
i = j; continue
|
|
yield ('P', c); i += 1
|
|
|
|
|
|
def file_tokens(path):
|
|
return list(lex(open(path, 'rb').read().decode('latin-1')))
|
|
|
|
|
|
def scan_file(toks, macros_raw):
|
|
"""Collect string groups and string-valued #defines from one token list."""
|
|
groups = [] # list of [ ('S', bytes) | ('I', name) ]
|
|
i, n = 0, len(toks)
|
|
while i < n:
|
|
k, v = toks[i]
|
|
if k == 'D': # directive: up to the next NL
|
|
j = i + 1
|
|
while j < n and toks[j][0] != 'NL': j += 1
|
|
line = toks[i + 1:j]
|
|
if len(line) >= 2 and line[0] == ('I', 'define'):
|
|
name = line[1][1]
|
|
body = line[2:]
|
|
# function-like macro: '(' glued to the name; lexer lost spacing,
|
|
# so accept only bodies that are pure string/ident sequences.
|
|
if body and all(t[0] in ('S', 'I') for t in body) and any(t[0] == 'S' for t in body):
|
|
macros_raw.setdefault(name, []).append(body)
|
|
elif len(body) == 1 and body[0][0] == 'I':
|
|
macros_raw.setdefault(name, []).append(body)
|
|
if not (line and line[0] == ('I', 'include')):
|
|
for t in line:
|
|
if t[0] == 'S': groups.append([t])
|
|
i = j; continue
|
|
if k in ('S', 'I'):
|
|
j = i; grp = []
|
|
while j < n and toks[j][0] in ('S', 'I', 'NL'):
|
|
if toks[j][0] != 'NL': grp.append(toks[j])
|
|
j += 1
|
|
# split the run at identifiers that are not string macros later; keep raw now
|
|
if any(t[0] == 'S' for t in grp) or len(grp) == 1:
|
|
groups.append(grp)
|
|
i = j if j > i else i + 1; continue
|
|
i += 1
|
|
return groups
|
|
|
|
|
|
def resolve_macros(macros_raw):
|
|
"""name -> set(bytes). Iterates so macros may reference macros."""
|
|
val = {}
|
|
for _ in range(8):
|
|
changed = False
|
|
for name, bodies in macros_raw.items():
|
|
out = set(val.get(name, ()))
|
|
for body in bodies:
|
|
for b in expand(body, val):
|
|
if b not in out: out.add(b); changed = True
|
|
if out: val[name] = out
|
|
if not changed: break
|
|
return val
|
|
|
|
|
|
def expand(grp, mval, cap=64):
|
|
"""Concatenate a run; identifiers that are not string macros break the run."""
|
|
results = []
|
|
cur = [b'']
|
|
def flush():
|
|
nonlocal cur
|
|
if cur != [b'']: results.extend(cur)
|
|
cur = [b'']
|
|
for k, v in grp:
|
|
if k == 'S':
|
|
cur = [c + v for c in cur]
|
|
elif v in mval:
|
|
cur = [c + m for c in cur for m in mval[v]][:cap]
|
|
else:
|
|
flush()
|
|
flush()
|
|
return results
|
|
|
|
|
|
def tr_strip(b):
|
|
"""Mirror i18n.cpp TR(): skip 0xEE/0xEF 3-byte glyphs, then spaces."""
|
|
i = 0
|
|
while i + 2 < len(b) and b[i] in (0xEE, 0xEF) and b[i + 1] and b[i + 2]:
|
|
i += 3
|
|
if i:
|
|
while i < len(b) and b[i] == 0x20: i += 1
|
|
return b[i:] if i < len(b) else b
|
|
|
|
|
|
# ---- Lua lexer -------------------------------------------------------------------
|
|
def lua_strings(src):
|
|
"""src: str. Returns (set of literals, list of non-literal tr( call snippets)."""
|
|
out = set(); i, n = 0, len(src)
|
|
LE = {'n': '\n', 't': '\t', 'r': '\r', 'a': '\a', 'b': '\b', 'f': '\f', 'v': '\v',
|
|
'\\': '\\', '"': '"', "'": "'", '\n': '\n'}
|
|
while i < n:
|
|
if src.startswith('--', i):
|
|
m = re.match(r'--\[(=*)\[', src[i:])
|
|
if m:
|
|
e = src.find(']' + m.group(1) + ']', i); i = n if e < 0 else e + len(m.group(1)) + 2
|
|
else:
|
|
e = src.find('\n', i); i = n if e < 0 else e
|
|
continue
|
|
c = src[i]
|
|
if c == '[':
|
|
m = re.match(r'\[(=*)\[', src[i:])
|
|
if m:
|
|
e = src.find(']' + m.group(1) + ']', i)
|
|
s = src[i + m.end():e]
|
|
if s.startswith('\n'): s = s[1:]
|
|
out.add(s); i = e + len(m.group(1)) + 2; continue
|
|
if c in '"\'':
|
|
j = i + 1; buf = []
|
|
while j < n and src[j] != c:
|
|
if src[j] == '\\':
|
|
d = src[j + 1]
|
|
if d in LE: buf.append(LE[d]); j += 2
|
|
elif d == 'x': buf.append(chr(int(src[j + 2:j + 4], 16))); j += 4
|
|
elif d == 'z':
|
|
j += 2
|
|
while j < n and src[j].isspace(): j += 1
|
|
elif d.isdigit():
|
|
k = j + 1
|
|
while k < n and k < j + 4 and src[k].isdigit(): k += 1
|
|
buf.append(chr(int(src[j + 1:k]))); j = k
|
|
elif d == 'u':
|
|
k = src.find('}', j); buf.append(chr(int(src[j + 3:k], 16))); j = k + 1
|
|
else: buf.append(d); j += 2
|
|
else:
|
|
buf.append(src[j]); j += 1
|
|
s = ''.join(buf)
|
|
# Lua \xNN / \ddd yield BYTES; re-pack as UTF-8 text where possible
|
|
try: s = s.encode('latin-1').decode('utf-8')
|
|
except (UnicodeEncodeError, UnicodeDecodeError): pass
|
|
out.add(s); i = j + 1; continue
|
|
i += 1
|
|
dyn = [m.group(0) for m in re.finditer(r'\btr\(\s*[^"\'\s)][^)\n]{0,50}', src)]
|
|
return out, dyn
|
|
|
|
|
|
|
|
# ---- minimal ELF32 / ar reader (oracle) ---------------------------------------------
|
|
def _elf_sections(d):
|
|
if d[:4] != b'\x7fELF' or d[4] != 1: return
|
|
e = '<' if d[5] == 1 else '>'
|
|
shoff, = struct.unpack_from(e + 'I', d, 0x20)
|
|
shentsize, shnum, shstrndx = struct.unpack_from(e + 'HHH', d, 0x2E)
|
|
hdrs = [struct.unpack_from(e + 'IIIIIIIIII', d, shoff + i * shentsize) for i in range(shnum)]
|
|
so = hdrs[shstrndx][4]
|
|
for h in hdrs:
|
|
name = d[so + h[0]:d.index(b'\0', so + h[0])].decode('latin-1')
|
|
if h[1] == 1 and not name.startswith(('.debug', '.xt', '.comment', '.text', '.literal',
|
|
'.iram', '.xtensa')):
|
|
yield name, d[h[4]:h[4] + h[5]]
|
|
|
|
def obj_data_sections(d):
|
|
"""Data-bearing sections of an ELF object, or of every member of an ar archive.
|
|
Splitting the raw FILE on NUL is wrong: the first string of a .rodata.str
|
|
section is glued to the tail of the preceding section with no NUL between."""
|
|
if d[:8] == b'!<arch>\n':
|
|
i = 8
|
|
while i + 60 <= len(d):
|
|
size = int(d[i + 48:i + 58].strip() or 0)
|
|
yield from (s for _, s in _elf_sections(d[i + 60:i + 60 + size]))
|
|
i += 60 + size + (size & 1)
|
|
else:
|
|
yield from (s for _, s in _elf_sections(d))
|
|
|
|
|
|
# ---- inputs and entry points --------------------------------------------------------
|
|
def _libdeps(root, rel):
|
|
"""First match of rel under any env's .pio/libdeps (the core and LVGL are the same
|
|
versions in every env)."""
|
|
hits = sorted(glob.glob(os.path.join(root, '.pio', 'libdeps', '*', rel)))
|
|
return hits[0] if hits else None
|
|
|
|
|
|
def src_files(root):
|
|
pats = ['src/**/*.c', 'src/**/*.cpp', 'src/**/*.h', 'src/**/*.hpp', 'src/**/*.ino',
|
|
'include/**/*.h', 'lib/**/*.c', 'lib/**/*.cpp', 'lib/**/*.h',
|
|
'variants/heltec_v4/**/*.h', 'variants/heltec_v4/**/*.cpp']
|
|
files = set()
|
|
for p in pats:
|
|
files.update(glob.glob(os.path.join(root, p), recursive=True))
|
|
core = _libdeps(root, 'MeshCore')
|
|
if core:
|
|
for p in ('src/**/*.cpp', 'src/**/*.h', 'examples/companion_radio/**/*.cpp',
|
|
'examples/companion_radio/**/*.h'):
|
|
files.update(glob.glob(os.path.join(core, p), recursive=True))
|
|
# No size cut-off: UITask.cpp alone is ~4 MB (a 3 MB cap once skipped it silently).
|
|
return sorted(f for f in files if os.path.basename(f) != 'i18n_builtin.h')
|
|
|
|
|
|
# The built-in table is baked only for the 2 MB Heltec V4 (CAP_BUILTIN_LANGS), so an
|
|
# app's texts belong in it only if that board can run the app: the version the
|
|
# catalog serves (old versions are gone from the Store), and not an app that needs
|
|
# what the V4 lacks. "sdk_ext" apps need 8 MB of PSRAM; "audio" apps need a speaker.
|
|
# Keeping every version of every app cost the V4 the flash the Music app's 16
|
|
# texts x 14 languages took (2026-10-15). An unknown "requires" is kept, to be safe.
|
|
V4_UNMET = {"sdk_ext", "audio"}
|
|
|
|
|
|
def lua_sources_for_table(root):
|
|
import json
|
|
cat = json.load(open(os.path.join(root, 'deploy/apps/apps.json'), encoding='utf-8'))['apps']
|
|
out = []
|
|
for a in cat:
|
|
if a.get('requires') in V4_UNMET:
|
|
continue
|
|
p = os.path.join(root, 'deploy/apps', a['id'], a['ver'], a['id'] + '.lua')
|
|
if os.path.exists(p):
|
|
out.append(open(p, encoding='utf-8').read())
|
|
return out
|
|
|
|
|
|
def reachable_keys(root, allkeys):
|
|
"""The subset of allkeys (str) some source or Lua app can look up. Raises
|
|
RuntimeError when an input it depends on is missing, rather than trimming blind."""
|
|
lvsym = _libdeps(root, 'lvgl/src/font/lv_symbol_def.h')
|
|
if not lvsym:
|
|
raise RuntimeError('lv_symbol_def.h not found under .pio/libdeps (build any env once)')
|
|
macros_raw = {}
|
|
scan_file(file_tokens(lvsym), macros_raw)
|
|
files = src_files(root)
|
|
if not any(f.endswith(os.path.join('ui-touch', 'UITask.cpp')) for f in files):
|
|
raise RuntimeError('UITask.cpp was not scanned')
|
|
groups = [g for f in files for g in scan_file(file_tokens(f), macros_raw)]
|
|
mval = resolve_macros(macros_raw)
|
|
lits = set()
|
|
for g in groups:
|
|
for b in expand(g, mval):
|
|
lits.update((b, tr_strip(b), b.lstrip(b' ')))
|
|
lua = set()
|
|
for s in lua_sources_for_table(root):
|
|
lua |= lua_strings(s)[0]
|
|
keep = {k for k in allkeys if k.encode('utf-8') in lits or k in lua}
|
|
if len(keep) < 0.8 * len(allkeys):
|
|
raise RuntimeError('only %d of %d keys reachable: the scan looks broken' % (len(keep), len(allkeys)))
|
|
return keep
|
|
|
|
|
|
def oracle_missing(build_dir, allkeys, keep):
|
|
"""Translated keys the compiler emitted into the objects under build_dir that keep
|
|
dropped. Must be empty."""
|
|
objs = {p for p in glob.glob(os.path.join(build_dir, '**', '*.o'), recursive=True)
|
|
+ glob.glob(os.path.join(build_dir, '**', '*.a'), recursive=True)
|
|
if not p.endswith('i18n.cpp.o')}
|
|
runs = set()
|
|
for p in objs:
|
|
for sec in obj_data_sections(open(p, 'rb').read()):
|
|
for r in sec.split(b'\0'):
|
|
if r:
|
|
runs.add(r)
|
|
runs.add(tr_strip(r))
|
|
return sorted(k for k in allkeys if k not in keep and k.encode('utf-8') in runs)
|