feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract the fixed call-status glyphs into the GFX compatibility font ABI.
|
||||
|
||||
No font converter, network, LVGL headers, or rasterizer is used. The source is
|
||||
the checked-in uncompressed 1bpp font; Pillow is needed only with --preview.
|
||||
Run --check to verify the committed generated files without modifying them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_font.c"
|
||||
SOURCE_SHA256 = "abfffdf0cd809cf2e3cd9425988b8945786fb5166bf4c5e91d2b6e1fb3839f74"
|
||||
FONT_C = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_call_status_font.c"
|
||||
FONT_H = ROOT / "components/application/platform/src/espressif/lvgl_source/echoear_call_status_font.h"
|
||||
HOST_TEST = ROOT / "tools/test_call_status_font.c"
|
||||
CHARACTERS = "?待机正在连接通话中结束失败断"
|
||||
STATUSES = ["待机", "正在连接", "通话中", "正在结束", "通话结束", "连接失败", "连接中断"]
|
||||
SCALE = 2
|
||||
|
||||
|
||||
def array_body(source: str, name: str) -> str:
|
||||
match = re.search(r"\b" + re.escape(name) + r"\[\]\s*=\s*\{(.*?)\};", source, re.S)
|
||||
if not match:
|
||||
raise ValueError(f"missing source array {name}")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def numbers(body: str) -> list[int]:
|
||||
body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
|
||||
return [int(value, 0) for value in re.findall(r"0[xX][0-9a-fA-F]+|\b\d+\b", body)]
|
||||
|
||||
|
||||
def fields(body: str) -> dict[str, str]:
|
||||
return dict(re.findall(r"\.(\w+)\s*=\s*([^,}\s]+)", body))
|
||||
|
||||
|
||||
def bitmap_pixels(data: bytes, width: int, height: int) -> list[int]:
|
||||
return [(data[index // 8] >> (7 - index % 8)) & 1 for index in range(width * height)]
|
||||
|
||||
|
||||
def pack_pixels(pixels: list[int]) -> bytes:
|
||||
packed = bytearray((len(pixels) + 7) // 8)
|
||||
for index, bit in enumerate(pixels):
|
||||
packed[index // 8] |= bit << (7 - index % 8)
|
||||
return bytes(packed)
|
||||
|
||||
|
||||
def pixel_hash(pixels: list[int]) -> int:
|
||||
value = 2166136261
|
||||
for bit in pixels:
|
||||
value = ((value ^ (255 if bit else 0)) * 16777619) & 0xFFFFFFFF
|
||||
return value
|
||||
|
||||
|
||||
def extract() -> tuple[str, list[dict], int, int]:
|
||||
raw = SOURCE.read_bytes()
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
if digest != SOURCE_SHA256:
|
||||
raise ValueError(f"source font changed: {digest}; review before updating the pinned hash")
|
||||
source = raw.decode("utf-8").replace("\r\n", "\n")
|
||||
original_header = re.match(r"/\*.*?\*/", source, re.S).group(0)
|
||||
if not re.search(r"\.bpp\s*=\s*1\s*,", source) or not re.search(r"\.bitmap_format\s*=\s*0\s*,", source):
|
||||
raise ValueError("expected uncompressed 1bpp source font")
|
||||
bitmap_body = array_body(source, "glyph_bitmap")
|
||||
bitmap = bytes(numbers(bitmap_body))
|
||||
descriptors = [{key: int(value, 0) for key, value in fields(body).items()}
|
||||
for body in re.findall(r"\{([^{}]*)\}", array_body(source, "glyph_dsc"))]
|
||||
cmaps = [fields(body) for body in re.findall(r"\{([^{}]*)\}", array_body(source, "cmaps"))]
|
||||
tables: dict[str, list[int]] = {}
|
||||
|
||||
def table(name: str) -> list[int]:
|
||||
if name not in tables:
|
||||
tables[name] = numbers(array_body(source, name))
|
||||
return tables[name]
|
||||
|
||||
def glyph_id(codepoint: int) -> int:
|
||||
for cmap in cmaps:
|
||||
offset = codepoint - int(cmap["range_start"])
|
||||
if not 0 <= offset < int(cmap["range_length"]):
|
||||
continue
|
||||
start = int(cmap["glyph_id_start"])
|
||||
kind = cmap["type"].removeprefix("LV_FONT_FMT_TXT_CMAP_")
|
||||
if kind == "FORMAT0_TINY":
|
||||
return start + offset
|
||||
if kind == "FORMAT0_FULL":
|
||||
return start + table(cmap["glyph_id_ofs_list"])[offset]
|
||||
offsets = table(cmap["unicode_list"])
|
||||
if offset not in offsets:
|
||||
continue
|
||||
index = offsets.index(offset)
|
||||
if kind == "SPARSE_TINY":
|
||||
return start + index
|
||||
if kind == "SPARSE_FULL":
|
||||
return start + table(cmap["glyph_id_ofs_list"])[index]
|
||||
raise ValueError(f"unsupported source cmap {kind}")
|
||||
raise ValueError(f"missing source glyph U+{codepoint:04X}")
|
||||
|
||||
# Independently cross-check the cmap result against converter bitmap comments.
|
||||
labelled_offsets = {}
|
||||
cursor = 0
|
||||
for match in re.finditer(r"/\* U\+([0-9A-Fa-f]+).*?\*/(.*?)(?=/\* U\+|\Z)", bitmap_body, re.S):
|
||||
labelled_offsets[int(match.group(1), 16)] = cursor
|
||||
cursor += len(numbers(match.group(2)))
|
||||
assert cursor == len(bitmap)
|
||||
glyphs = []
|
||||
for char in sorted(set(CHARACTERS)):
|
||||
codepoint = ord(char)
|
||||
gid = glyph_id(codepoint)
|
||||
original = descriptors[gid]
|
||||
assert original["bitmap_index"] == labelled_offsets[codepoint], f"cmap/comment mismatch for {char}"
|
||||
width, height = original["box_w"], original["box_h"]
|
||||
size = (width * height + 7) // 8
|
||||
assert 0 < size and original["bitmap_index"] + size <= len(bitmap)
|
||||
original_pixels = bitmap_pixels(bitmap[original["bitmap_index"]:][:size], width, height)
|
||||
pixels = [original_pixels[(y // SCALE) * width + x // SCALE]
|
||||
for y in range(height * SCALE) for x in range(width * SCALE)]
|
||||
packed = pack_pixels(pixels)
|
||||
assert bitmap_pixels(packed, width * SCALE, height * SCALE) == pixels
|
||||
assert sum(pixels) == sum(original_pixels) * SCALE * SCALE > 0
|
||||
glyphs.append({"char": char, "codepoint": codepoint, "source_glyph_id": gid,
|
||||
"width": width * SCALE, "height": height * SCALE,
|
||||
"ofs_x": original["ofs_x"] * SCALE, "ofs_y": original["ofs_y"] * SCALE,
|
||||
# GFX reads adv_w as 1/256 px, unlike source LVGL's 1/16 px.
|
||||
"advance": original["adv_w"] * SCALE * 16,
|
||||
"bitmap": packed, "pixels": pixels, "pixel_hash": pixel_hash(pixels)})
|
||||
line_height = int(re.search(r"\.line_height\s*=\s*(\d+)", source).group(1)) * SCALE
|
||||
baseline = int(re.search(r"\.base_line\s*=\s*(\d+)", source).group(1)) * SCALE
|
||||
assert line_height <= 42
|
||||
for glyph in glyphs:
|
||||
top = line_height - baseline - glyph["height"] - glyph["ofs_y"]
|
||||
assert 0 <= top and top + glyph["height"] <= line_height
|
||||
by_char = {glyph["char"]: glyph for glyph in glyphs}
|
||||
for status in STATUSES:
|
||||
assert len(status) <= 4
|
||||
assert sum(max(by_char[c]["advance"] >> 8, by_char[c]["width"] + by_char[c]["ofs_x"]) for c in status) <= 240
|
||||
return original_header, glyphs, line_height, baseline
|
||||
|
||||
|
||||
def generated_files(original_header: str, glyphs: list[dict], line_height: int, baseline: int) -> dict[Path, str]:
|
||||
comment = ("/* Generated by tools/generate_call_status_font.py; do not hand edit.\n"
|
||||
f" * Source SHA256: {SOURCE_SHA256}\n"
|
||||
" * 2x nearest-neighbor glyphs; packed 1bpp, GFX 1/256-pixel advance.\n"
|
||||
" * The original source font's leading notice is preserved below.\n */\n")
|
||||
c = [comment, original_header, '\n#ifdef LVGL_VERSION_MAJOR\n#error "Build this font in its own GFX-only translation unit"\n#endif\n#include "widget/gfx_font_lvgl.h"\n',
|
||||
'#if LVGL_VERSION_MAJOR != 8 || LV_FONT_FMT_TXT_LARGE != 1\n#error "Unexpected GFX font compatibility ABI"\n#endif\n_Static_assert(sizeof(lv_font_fmt_txt_glyph_dsc_t) == 16, "GFX LARGE glyph ABI required");\n',
|
||||
'static const uint8_t status_bitmap[] = {']
|
||||
cursor = 0
|
||||
for glyph in glyphs:
|
||||
glyph["bitmap_index"] = cursor
|
||||
c.append(f' /* U+{glyph["codepoint"]:04X} {glyph["char"]} */')
|
||||
values = list(glyph["bitmap"])
|
||||
for start in range(0, len(values), 24):
|
||||
c.append(' ' + ','.join(str(byte) for byte in values[start:start + 24]) + ',')
|
||||
cursor += len(values)
|
||||
c += ['};\n', 'static const lv_font_fmt_txt_glyph_dsc_t status_glyphs[] = {', ' {0}, /* glyph zero is reserved */']
|
||||
for g in glyphs:
|
||||
c.append(f' {{{g["bitmap_index"]},{g["advance"]},{g["width"]},{g["height"]},{g["ofs_x"]},{g["ofs_y"]}}}, /* {g["char"]} */')
|
||||
c += ['};\n', '#define STATUS_CMAP(cp, id) { .range_start=(cp), .range_length=1, .glyph_id_start=(id), .unicode_list=NULL, .glyph_id_ofs_list=NULL, .list_length=0, .type=LV_FONT_FMT_TXT_CMAP_FORMAT0_TINY }',
|
||||
'static const lv_font_fmt_txt_cmap_t status_cmaps[] = {']
|
||||
c += [f' STATUS_CMAP(0x{g["codepoint"]:04X}, {index}),' for index, g in enumerate(glyphs, 1)]
|
||||
c += ['};', '#undef STATUS_CMAP\n',
|
||||
'static const lv_font_fmt_txt_dsc_t status_font_dsc = {',
|
||||
' .glyph_bitmap=status_bitmap, .glyph_dsc=status_glyphs, .cmaps=status_cmaps,',
|
||||
f' .kern_dsc=NULL, .kern_scale=0, .cmap_num={len(glyphs)}, .bpp=1,',
|
||||
' .kern_classes=0, .bitmap_format=0, .cache=NULL', '};\n',
|
||||
'static const lv_font_t status_font = {',
|
||||
' .get_glyph_dsc=lv_font_get_glyph_dsc_fmt_txt, .get_glyph_bitmap=lv_font_get_bitmap_fmt_txt,',
|
||||
f' .line_height={line_height}, .base_line={baseline}, .subpx=LV_FONT_SUBPX_NONE, .static_bitmap=1,',
|
||||
' .underline_position=-6, .underline_thickness=2, .dsc=&status_font_dsc, .fallback=NULL, .user_data=NULL',
|
||||
'};\n', 'const void *echoear_call_status_font(void) { return &status_font; }\n']
|
||||
header = comment + '#pragma once\n#ifdef __cplusplus\nextern "C" {\n#endif\n/* GFX compatibility font; deliberately exposes no LVGL types. */\nconst void *echoear_call_status_font(void);\n#ifdef __cplusplus\n}\n#endif\n'
|
||||
host = [comment, '#include <assert.h>\n#include <stdint.h>\n#include <stdio.h>\n#include "widget/gfx_font_internal.h"\n#include "echoear_call_status_font.h"\n',
|
||||
'static const struct { uint32_t cp; int w, h, x, y, advance; uint32_t hash; } expected[] = {']
|
||||
for g in glyphs:
|
||||
host.append(f' {{0x{g["codepoint"]:04X},{g["width"]},{g["height"]},{g["ofs_x"]},{g["ofs_y"]},{g["advance"]},0x{g["pixel_hash"]:08X}}},')
|
||||
host += ['};\n', 'int main(void) {', ' const void *font=echoear_call_status_font(); assert(gfx_is_lvgl_font(font));',
|
||||
' gfx_font_ctx_t ctx={0}; gfx_font_lv_init_context(&ctx,font);',
|
||||
f' assert(ctx.get_line_height(&ctx)=={line_height} && ctx.get_base_line(&ctx)=={baseline});',
|
||||
' for (unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) {',
|
||||
' gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,expected[i].cp,0));',
|
||||
' assert(g.box_w==expected[i].w && g.box_h==expected[i].h);',
|
||||
' assert(g.ofs_x==expected[i].x && g.ofs_y==expected[i].y && g.adv_w==(uint32_t)expected[i].advance);',
|
||||
f' assert(g.bitmap_index+(g.box_w*g.box_h+7)/8 <= {cursor});',
|
||||
' int top=ctx.adjust_baseline_offset(&ctx,&g); assert(top>=0 && top+g.box_h<=ctx.get_line_height(&ctx));',
|
||||
' assert(ctx.get_advance_width(&ctx,&g)==32 || expected[i].cp==0x3F);',
|
||||
' const uint8_t *bits=ctx.get_glyph_bitmap(&ctx,expected[i].cp,&g); assert(bits); uint32_t hash=2166136261u;',
|
||||
' for(int y=0;y<g.box_h;y++) for(int x=0;x<g.box_w;x++) hash=(hash^ctx.get_pixel_value(&ctx,bits,x,y,g.box_w))*16777619u;',
|
||||
' assert(hash==expected[i].hash);', ' }',
|
||||
' /* Exhaustive Unicode lookup also exercises every cmap range-end boundary. */',
|
||||
' for(uint32_t cp=0;cp<=0x10FFFF;cp++) {',
|
||||
' int present=0; for(unsigned i=0;i<sizeof(expected)/sizeof(expected[0]);i++) present |= expected[i].cp==cp;',
|
||||
' gfx_glyph_dsc_t g={0}; assert(ctx.get_glyph_dsc(&ctx,&g,cp,0)==(present!=0));', ' }',
|
||||
' puts("GFX call-status font: ABI, coverage, exact pixels, metrics and Unicode boundaries PASS");', ' return 0;', '}\n']
|
||||
return {FONT_C: '\n'.join(c), FONT_H: header, HOST_TEST: '\n'.join(host)}
|
||||
|
||||
|
||||
def preview(path: Path, glyphs: list[dict], line_height: int, baseline: int) -> None:
|
||||
from PIL import Image, ImageDraw
|
||||
by_char = {g["char"]: g for g in glyphs}
|
||||
panel = 360 # CONFIG_EXAMPLE_LCD_INTERFACE_QSPI: HW_LCD_H_RES / V_RES.
|
||||
image = Image.new('RGB', (panel * 3, panel * 3), '#24272b')
|
||||
draw = ImageDraw.Draw(image)
|
||||
labels = STATUSES + ['?', '正在连接']
|
||||
for index, label in enumerate(labels):
|
||||
left, top = index % 3 * panel, index // 3 * panel
|
||||
draw.ellipse((left, top, left + panel - 1, top + panel - 1), fill='#080b10', outline='#64707b')
|
||||
draw.rectangle((left + 60, top + 58, left + 299, top + 99), outline='#28313b')
|
||||
width = sum(max(by_char[c]['advance'] >> 8, by_char[c]['width'] + by_char[c]['ofs_x']) for c in label)
|
||||
x, line_top = left + (panel - width) // 2, top + 58 + (42 - line_height) // 2
|
||||
for char in label:
|
||||
glyph = by_char[char]
|
||||
y = line_top + line_height - baseline - glyph['height'] - glyph['ofs_y']
|
||||
for py in range(glyph['height']):
|
||||
for px in range(glyph['width']):
|
||||
if glyph['pixels'][py * glyph['width'] + px]:
|
||||
image.putpixel((x + glyph['ofs_x'] + px, y + py), (255, 255, 255))
|
||||
x += max(glyph['advance'] >> 8, glyph['width'] + glyph['ofs_x'])
|
||||
draw.text((left + 70, top + 286), f'status {index + 1} / 360x360 / 1:1 px', fill='#9faab6')
|
||||
draw.text((left + 61, top + 306), 'GLYPH PREVIEW - NOT A SCREEN PHOTO', fill='#9faab6')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--check', action='store_true')
|
||||
parser.add_argument('--preview', type=Path)
|
||||
parser.add_argument('--report', type=Path)
|
||||
args = parser.parse_args()
|
||||
notice, glyphs, line_height, baseline = extract()
|
||||
outputs = generated_files(notice, glyphs, line_height, baseline)
|
||||
for path, text in outputs.items():
|
||||
if args.check:
|
||||
assert path.read_text(encoding='utf-8') == text, f'generated file differs: {path}'
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding='utf-8', newline='\n')
|
||||
if args.preview:
|
||||
preview(args.preview, glyphs, line_height, baseline)
|
||||
report = {'source': str(SOURCE.relative_to(ROOT)), 'source_sha256': SOURCE_SHA256,
|
||||
'glyph_count': len(glyphs), 'characters': ''.join(g['char'] for g in glyphs),
|
||||
'statuses': STATUSES, 'bitmap_bytes': sum(len(g['bitmap']) for g in glyphs),
|
||||
'line_height': line_height, 'base_line': baseline, 'scale': SCALE,
|
||||
'files': {str(path.relative_to(ROOT)): {'bytes': path.stat().st_size, 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} for path in outputs},
|
||||
'glyphs': [{key: value for key, value in g.items() if key not in ('pixels', 'bitmap')} for g in glyphs]}
|
||||
if args.report:
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user