Files
Kaj SchittecatandClaude Opus 5.5 d3a9cf1453 touch: radio tools redesign, Music app, Store update badge, triage fixes
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>
2026-10-15 17:07:11 +02:00

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)