Reduce nRF52 infrastructure flash usage without trimming sensors
Keep Companion and Terminal Chat profiles unchanged; retain full-sensor and reduced-sensor OTA builds. Add compact crypto and OTA names, qualified display asset compression, font license packaging, and scope regression tests.
This commit is contained in:
1 parent
010374d7db
commit
8a69e73226
44 files changed
+11150
-14
No files matched your search
@@ -6,6 +6,10 @@ import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from package_nrf52_font_license import validate_artifact_notice
|
||||
|
||||
|
||||
def digest(path):
|
||||
@@ -37,15 +41,17 @@ def validate_build(directory):
|
||||
def artifact_files(stem):
|
||||
stem = Path(stem)
|
||||
candidates = [stem.parent / (stem.name + suffix)
|
||||
for suffix in (".bin", "-merged.bin", ".uf2", ".zip", ".hex", ".capabilities.json")]
|
||||
for suffix in (".bin", "-merged.bin", ".uf2", ".zip", ".hex", ".capabilities.json",
|
||||
".font-license.txt")]
|
||||
return [p for p in candidates if p.is_file()]
|
||||
|
||||
|
||||
def package_report(directory, stem):
|
||||
stem = Path(stem)
|
||||
report = validate_build(directory)
|
||||
validate_artifact_notice(stem)
|
||||
files = artifact_files(stem)
|
||||
if len(files) < 2 or not any(p.suffix != ".json" for p in files):
|
||||
if len(files) < 2 or not any(p.suffix in (".bin", ".uf2", ".zip", ".hex") for p in files):
|
||||
raise ValueError("cannot qualify an empty firmware package")
|
||||
manifest = json.loads((stem.parent / (stem.name + ".capabilities.json")).read_text())
|
||||
report.update(target=manifest["target"], artifact_target=manifest["artifact_target"],
|
||||
@@ -57,6 +63,7 @@ def package_report(directory, stem):
|
||||
|
||||
def validate_package(stem):
|
||||
stem = Path(stem)
|
||||
validate_artifact_notice(stem)
|
||||
report = passing_report(stem.parent / (stem.name + ".memory.json"))
|
||||
actual = {p.name: digest(p) for p in artifact_files(stem)}
|
||||
if len(actual) < 2 or actual != report.get("files"):
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Generate opt-in, lossless Arial font storage experiments.
|
||||
|
||||
The checked-in source font remains authoritative. Every one of its 224 slots,
|
||||
including blank slots and their advance widths, survives these experiments.
|
||||
Mode 2 uses per-glyph byte RLE; mode 3 independently inflates <=512-byte glyph
|
||||
groups. Whole-font compression is measured only, not instantiated on the device.
|
||||
Google Zopfli is used at 1,000 iterations; no compressor runs on the device.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from zopfli_compress import MAXIMUM_ITERATIONS, raw_deflate_compress
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "src/helpers/ui/OLEDDisplayFonts.cpp"
|
||||
OUTPUT = ROOT / "src/helpers/ui/CompactArialFonts.h"
|
||||
CHUNK_LIMIT = 512
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Glyph:
|
||||
width: int
|
||||
bitmap: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Font:
|
||||
name: str
|
||||
original: bytes
|
||||
glyphs: tuple[Glyph, ...]
|
||||
|
||||
@property
|
||||
def header(self) -> bytes:
|
||||
return self.original[:4]
|
||||
|
||||
|
||||
def read_fonts(path: Path = SOURCE) -> tuple[Font, ...]:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
fonts = []
|
||||
for name, body in re.findall(
|
||||
r"const uint8_t (ArialMT_Plain_\d+)\[\] PROGMEM = \{(.*?)\};", source, re.S
|
||||
):
|
||||
body = re.sub(r"//[^\n]*", "", body)
|
||||
data = bytes(int(value, 16) for value in re.findall(r"0x([0-9a-fA-F]+)", body))
|
||||
if len(data) < 4:
|
||||
raise ValueError(f"{name}: incomplete header")
|
||||
base = 4 + 4 * data[3]
|
||||
if base > len(data) or data[2] + data[3] > 256:
|
||||
raise ValueError(f"{name}: invalid character table")
|
||||
glyphs = []
|
||||
consumed = 0
|
||||
for index in range(data[3]):
|
||||
entry = data[4 + 4 * index:8 + 4 * index]
|
||||
offset = int.from_bytes(entry[:2], "big")
|
||||
length, width = entry[2:]
|
||||
if offset == 0xffff:
|
||||
if length:
|
||||
raise ValueError(f"{name}: blank slot contains bitmap bytes")
|
||||
bitmap = b""
|
||||
else:
|
||||
if not length or offset != consumed or base + offset + length > len(data):
|
||||
raise ValueError(f"{name}: invalid or non-contiguous bitmap")
|
||||
bitmap = data[base + offset:base + offset + length]
|
||||
consumed += length
|
||||
glyphs.append(Glyph(width, bitmap))
|
||||
if base + consumed != len(data):
|
||||
raise ValueError(f"{name}: trailing bitmap bytes")
|
||||
fonts.append(Font(name, data, tuple(glyphs)))
|
||||
if [font.name for font in fonts] != [f"ArialMT_Plain_{size}" for size in (10, 16, 24)]:
|
||||
raise ValueError("Expected the original Arial 10, 16 and 24 fonts")
|
||||
return tuple(fonts)
|
||||
|
||||
|
||||
def rle_encode(data: bytes) -> bytes:
|
||||
"""PackBits-style literals (1..128) and repeated bytes (3..130)."""
|
||||
output = bytearray()
|
||||
position = 0
|
||||
while position < len(data):
|
||||
run = 1
|
||||
while position + run < len(data) and data[position + run] == data[position] and run < 130:
|
||||
run += 1
|
||||
if run >= 3:
|
||||
output.extend((0x80 | (run - 3), data[position]))
|
||||
position += run
|
||||
continue
|
||||
first = position
|
||||
position += run
|
||||
while position < len(data) and position - first < 128:
|
||||
run = 1
|
||||
while position + run < len(data) and data[position + run] == data[position] and run < 3:
|
||||
run += 1
|
||||
if run >= 3:
|
||||
break
|
||||
position += min(run, 128 - (position - first))
|
||||
output.append(position - first - 1)
|
||||
output.extend(data[first:position])
|
||||
return bytes(output)
|
||||
|
||||
|
||||
def rle_font(font: Font) -> tuple[bytes, tuple[tuple[int, int, int], ...]]:
|
||||
output = bytearray()
|
||||
records = []
|
||||
for glyph in font.glyphs:
|
||||
if not glyph.bitmap:
|
||||
records.append((0xffff, 0, glyph.width))
|
||||
continue
|
||||
compressed = rle_encode(glyph.bitmap)
|
||||
raw = len(compressed) >= len(glyph.bitmap)
|
||||
location = len(output) | (0x8000 if raw else 0)
|
||||
output.extend(glyph.bitmap if raw else compressed)
|
||||
records.append((location, len(glyph.bitmap), glyph.width))
|
||||
if len(output) >= 0x7fff:
|
||||
raise ValueError("RLE offsets no longer fit the 15-bit experiment format")
|
||||
return bytes(output), tuple(records)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def chunk_font(font: Font) -> tuple[bytes, tuple[tuple[int, int, int], ...], tuple[int, ...], tuple[int, ...]]:
|
||||
chunks = []
|
||||
current = bytearray()
|
||||
records = []
|
||||
for glyph in font.glyphs:
|
||||
if not glyph.bitmap:
|
||||
records.append((0xffff, 0, glyph.width))
|
||||
continue
|
||||
if len(glyph.bitmap) > CHUNK_LIMIT:
|
||||
raise ValueError("A glyph exceeds the bounded font scratch buffer")
|
||||
if len(current) + len(glyph.bitmap) > CHUNK_LIMIT:
|
||||
chunks.append(bytes(current))
|
||||
current.clear()
|
||||
records.append(((len(chunks) << 9) | len(current), len(glyph.bitmap), glyph.width))
|
||||
current.extend(glyph.bitmap)
|
||||
if current:
|
||||
chunks.append(bytes(current))
|
||||
if len(chunks) >= 128:
|
||||
raise ValueError("Chunk identifiers exceed the packed 7-bit format")
|
||||
output = bytearray()
|
||||
offsets = [0]
|
||||
for chunk in chunks:
|
||||
output.extend(raw_deflate_compress(chunk, numiterations=MAXIMUM_ITERATIONS))
|
||||
offsets.append(len(output))
|
||||
if len(output) > 0xffff:
|
||||
raise ValueError("Compressed font offsets exceed 16 bits")
|
||||
return bytes(output), tuple(records), tuple(offsets), tuple(map(len, chunks))
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def whole_font(font: Font) -> bytes:
|
||||
return raw_deflate_compress(font.original, numiterations=MAXIMUM_ITERATIONS)
|
||||
|
||||
|
||||
def array(name: str, data, ctype: str = "uint8_t", columns: int = 16) -> str:
|
||||
values = [f"0x{value:02x}" if ctype == "uint8_t" else str(value) for value in data]
|
||||
lines = [f"static const {ctype} {name}[] = {{"]
|
||||
for first in range(0, len(values), columns):
|
||||
lines.append(" " + ", ".join(values[first:first + columns]) + ",")
|
||||
lines.append("};")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def descriptor(font: Font, rows) -> bytes:
|
||||
output = bytearray(font.header)
|
||||
for location, length, width in rows:
|
||||
output.extend(location.to_bytes(2, "big"))
|
||||
output.extend((length, width))
|
||||
return bytes(output)
|
||||
|
||||
|
||||
def generate_header(fonts: tuple[Font, ...]) -> str:
|
||||
output = [HEADER_START]
|
||||
for mode in (2, 3):
|
||||
output.append(f"#if MESH_ARIAL_EXPERIMENT == {mode}")
|
||||
for index, font in enumerate(fonts[1:], 1):
|
||||
prefix = f"FONT_{index}"
|
||||
# Inline function-local statics have one address across translation
|
||||
# units. Namespace-static arrays would silently break identify().
|
||||
output.append(f"inline const Font& fontStorage{index}() {{")
|
||||
if mode == 2:
|
||||
data, rows = rle_font(font)
|
||||
offsets, lengths = (), ()
|
||||
elif mode == 3:
|
||||
data, rows, offsets, lengths = chunk_font(font)
|
||||
output.append(array(prefix + "_DATA", data))
|
||||
output.append(array(prefix + "_DESCRIPTOR", descriptor(font, rows)))
|
||||
if offsets:
|
||||
output.append(array(prefix + "_OFFSETS", offsets, "uint16_t"))
|
||||
output.append(array(prefix + "_LENGTHS", lengths, "uint16_t"))
|
||||
output.append(
|
||||
f"static const Font {prefix} = {{ " + prefix + "_DESCRIPTOR, " + prefix + "_DATA, "
|
||||
+ (prefix + "_OFFSETS, " + prefix + "_LENGTHS" if offsets else "nullptr, nullptr")
|
||||
+ f", {len(lengths)}, {len(data)} }};"
|
||||
)
|
||||
output.append(f"return {prefix};\n}}")
|
||||
output.append("#endif")
|
||||
output.append(HEADER_END)
|
||||
return "\n\n".join(output) + "\n"
|
||||
|
||||
|
||||
HEADER_START = r'''// Generated by scripts/generate_compact_arial.py; do not edit.
|
||||
// Experiments are LOSSLESS: same Arial pixels, all 224 slots and advance widths.
|
||||
// 2 = glyph RLE (<=91 scratch bytes); 3 = Zopfli 1000 chunks (<=512 scratch).
|
||||
// Only Arial16 and Arial24 are instantiated; the unused initial Arial10 is not.
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#if MESH_ARIAL_EXPERIMENT == 3
|
||||
#include "../ota/tinf/tinf.h"
|
||||
#endif
|
||||
#if MESH_ARIAL_EXPERIMENT != 2 && MESH_ARIAL_EXPERIMENT != 3
|
||||
#error "CompactArialFonts.h is an opt-in experiment for modes 2 and 3 only"
|
||||
#endif
|
||||
namespace mesh { namespace ui { namespace arial {
|
||||
static constexpr size_t kScratchSize = MESH_ARIAL_EXPERIMENT == 2 ? 91 : 512;
|
||||
struct Font {
|
||||
const uint8_t* descriptor;
|
||||
const uint8_t* data;
|
||||
const uint16_t* offsets;
|
||||
const uint16_t* rawLengths;
|
||||
uint16_t chunkCount;
|
||||
uint16_t dataLength;
|
||||
};
|
||||
struct Glyph {
|
||||
const uint8_t* data;
|
||||
uint8_t width;
|
||||
uint8_t length;
|
||||
bool drawable;
|
||||
bool valid;
|
||||
};'''
|
||||
|
||||
|
||||
HEADER_END = r'''inline const uint8_t* font(unsigned id) {
|
||||
return id == 1 ? fontStorage1().descriptor : id == 2 ? fontStorage2().descriptor : nullptr;
|
||||
}
|
||||
inline int identify(const uint8_t* descriptor) {
|
||||
return descriptor == fontStorage1().descriptor ? 1 : descriptor == fontStorage2().descriptor ? 2 : -1;
|
||||
}
|
||||
inline const Font* storage(const uint8_t* descriptor) {
|
||||
const int id = identify(descriptor);
|
||||
return id == 1 ? &fontStorage1() : id == 2 ? &fontStorage2() : nullptr;
|
||||
}
|
||||
// Writes only to caller scratch. No global cache, heap allocation, or mutable state.
|
||||
inline bool decodeRle(const uint8_t* input, size_t inputLength,
|
||||
uint8_t* output, size_t outputLength) {
|
||||
if ((!input && inputLength) || (!output && outputLength)) return false;
|
||||
size_t source = 0, dest = 0;
|
||||
while (source < inputLength) {
|
||||
const uint8_t token = input[source++];
|
||||
const size_t count = token < 128 ? size_t(token) + 1 : size_t(token & 127) + 3;
|
||||
if (count > outputLength - dest) return false;
|
||||
if (token < 128) {
|
||||
if (count > inputLength - source) return false;
|
||||
for (size_t j = 0; j < count; ++j) output[dest++] = input[source++];
|
||||
} else {
|
||||
if (source == inputLength) return false;
|
||||
const uint8_t value = input[source++];
|
||||
for (size_t j = 0; j < count; ++j) output[dest++] = value;
|
||||
}
|
||||
}
|
||||
return dest == outputLength;
|
||||
}
|
||||
inline Glyph glyph(const uint8_t* descriptor, uint8_t code, uint8_t* scratch, size_t capacity) {
|
||||
const Font* f = storage(descriptor);
|
||||
if (!f || code < descriptor[2] || unsigned(code - descriptor[2]) >= descriptor[3])
|
||||
return { nullptr, 0, 0, false, false };
|
||||
const unsigned slot = code - descriptor[2];
|
||||
const uint8_t* record = descriptor + 4 + 4 * slot;
|
||||
const unsigned location = (unsigned(record[0]) << 8) | record[1];
|
||||
const uint8_t length = record[2], width = record[3];
|
||||
if (!length) return { nullptr, width, 0, false, location == 0xffff };
|
||||
#if MESH_ARIAL_EXPERIMENT == 2
|
||||
const unsigned first = location & 0x7fff;
|
||||
unsigned end = f->dataLength;
|
||||
for (unsigned next = slot + 1; next < descriptor[3]; ++next) {
|
||||
const uint8_t* nextRecord = descriptor + 4 + 4 * next;
|
||||
if (nextRecord[2]) { end = ((unsigned(nextRecord[0]) << 8) | nextRecord[1]) & 0x7fff; break; }
|
||||
}
|
||||
if (first > end || end > f->dataLength) return { nullptr, width, 0, false, false };
|
||||
if (location & 0x8000) {
|
||||
if (end - first != length) return { nullptr, width, 0, false, false };
|
||||
return { f->data + first, width, length, true, true };
|
||||
}
|
||||
if (!scratch || capacity < length || !decodeRle(f->data + first, end - first, scratch, length))
|
||||
return { nullptr, width, 0, false, false };
|
||||
return { scratch, width, length, true, true };
|
||||
#elif MESH_ARIAL_EXPERIMENT == 3
|
||||
const unsigned group = location >> 9;
|
||||
const unsigned offset = location & 511;
|
||||
if (group >= f->chunkCount) return { nullptr, width, 0, false, false };
|
||||
const unsigned first = f->offsets[group], end = f->offsets[group + 1];
|
||||
const unsigned expected = f->rawLengths[group];
|
||||
if (!scratch || expected > kScratchSize || capacity < expected || offset + length > expected || first > end || end > f->dataLength)
|
||||
return { nullptr, width, 0, false, false };
|
||||
unsigned int produced = expected;
|
||||
if (tinf_uncompress_exact(scratch, &produced, f->data + first, end - first) != TINF_OK || produced != expected)
|
||||
return { nullptr, width, 0, false, false };
|
||||
return { scratch + offset, width, length, true, true };
|
||||
#endif
|
||||
}
|
||||
}}} // namespace mesh::ui::arial'''
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="Check the generated header without writing")
|
||||
args = parser.parse_args()
|
||||
fonts = read_fonts()
|
||||
header = generate_header(fonts)
|
||||
if args.check:
|
||||
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != header:
|
||||
raise SystemExit("CompactArialFonts.h is stale; regenerate the font experiments")
|
||||
else:
|
||||
OUTPUT.write_text(header, encoding="utf-8", newline="\n")
|
||||
print("font original RLE+table chunks+table wholefont chunks")
|
||||
for font in fonts:
|
||||
rle, _ = rle_font(font)
|
||||
chunk, _, offsets, lengths = chunk_font(font)
|
||||
print(f"{font.name:20} {len(font.original):5} {len(rle) + 4 * len(font.glyphs) + 4:9} "
|
||||
f"{len(chunk) + 4 * len(font.glyphs) + 2 * (len(offsets) + len(lengths)) + 4:12} "
|
||||
f"{len(whole_font(font)):9} {len(lengths):6}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Losslessly compress the selected Noto Condensed production bitmap fonts.
|
||||
|
||||
The checked-in, licensed mode7 assets in FontFamilyTrials.h are authoritative;
|
||||
regeneration needs Google Zopfli but no Pillow, FreeType or installed font file.
|
||||
Every glyph's pixels, advance width, blank slot and font metric are preserved.
|
||||
Normal firmware builds use the checked-in header without running this script.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from generate_compact_arial import (
|
||||
Font, Glyph, HEADER_END as ARIAL_DECODER, ROOT, array, chunk_font, descriptor,
|
||||
)
|
||||
|
||||
|
||||
SOURCE = ROOT / "src/helpers/ui/FontFamilyTrials.h"
|
||||
OUTPUT = ROOT / "src/helpers/ui/CompactNotoFonts.h"
|
||||
|
||||
|
||||
def read_fonts(path: Path = SOURCE) -> tuple[tuple[Font, ...], str]:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
match = re.search(r"#if MESH_ARIAL_EXPERIMENT == 7\s*(.*?)\n#endif", source, re.S)
|
||||
if not match:
|
||||
raise ValueError("Authoritative raw Noto Condensed mode7 assets are missing")
|
||||
body = match[1]
|
||||
fonts = []
|
||||
for id, encoded in re.findall(r"static const uint8_t TRIAL_([12])\[\] = \{(.*?)\};", body, re.S):
|
||||
data = bytes(int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", encoded))
|
||||
index = int(id)
|
||||
expected_header = bytes((16, 19, 32, 224)) if index == 1 else bytes((24, 28, 32, 224))
|
||||
if data[:4] != expected_header:
|
||||
raise ValueError("Production Noto dimensions/character coverage changed")
|
||||
base = 4 + 4 * data[3]
|
||||
if len(data) < base:
|
||||
raise ValueError("Incomplete raw Noto character table")
|
||||
glyphs = []
|
||||
consumed = 0
|
||||
for slot in range(data[3]):
|
||||
record = data[4 + 4 * slot:8 + 4 * slot]
|
||||
offset = int.from_bytes(record[:2], "big")
|
||||
length, width = record[2:]
|
||||
if offset == 0xffff:
|
||||
if length:
|
||||
raise ValueError("A blank Noto slot contains bitmap bytes")
|
||||
bitmap = b""
|
||||
else:
|
||||
if not length or offset != consumed or base + offset + length > len(data):
|
||||
raise ValueError("Invalid or non-contiguous raw Noto glyph")
|
||||
bitmap = data[base + offset:base + offset + length]
|
||||
consumed += length
|
||||
glyphs.append(Glyph(width, bitmap))
|
||||
if base + consumed != len(data) or sum(bool(glyph.bitmap) for glyph in glyphs) != 189:
|
||||
raise ValueError("Raw Noto glyph coverage/trailing bytes changed")
|
||||
fonts.append(Font(f"MeshFontProduction9_{16 if index == 1 else 24}", data, tuple(glyphs)))
|
||||
if len(fonts) != 2 or fonts[0].header[0] != 16 or fonts[1].header[0] != 24:
|
||||
raise ValueError("Expected ordered Noto 16/24 assets")
|
||||
license = re.search(r"/\*(.*?)\*/", body, re.S)
|
||||
if not license or "SIL OPEN FONT LICENSE Version 1.1" not in license[1]:
|
||||
raise ValueError("Required source Noto copyright/OFL notice is missing")
|
||||
return tuple(fonts), license[1].strip()
|
||||
|
||||
|
||||
def chunk_decoder() -> str:
|
||||
"""Reuse the tested Arial codec's exact C++ DEFLATE branch and pointer API.
|
||||
|
||||
Extract at generation time, not with firmware macros: production contains
|
||||
neither the RLE decoder nor an experimental-mode dependency. Tests compile
|
||||
the result in isolation and check every authoritative raw Noto glyph.
|
||||
"""
|
||||
api = ARIAL_DECODER[:ARIAL_DECODER.index("// Writes only to caller scratch.")]
|
||||
start = ARIAL_DECODER.index("inline Glyph glyph(")
|
||||
common = ARIAL_DECODER[start:ARIAL_DECODER.index("#if MESH_ARIAL_EXPERIMENT == 2", start)]
|
||||
deflate = ARIAL_DECODER.split("#elif MESH_ARIAL_EXPERIMENT == 3\n", 1)[1].split("#endif\n", 1)[0]
|
||||
return (api + "// Caller-owned 512-byte scratch; no cache, heap or mutable global state.\n"
|
||||
+ common + deflate + "}\n}}} // namespace mesh::ui::noto")
|
||||
|
||||
|
||||
HEADER_START = '''// Generated by scripts/generate_compact_noto.py; do not edit.
|
||||
// MeshFontProduction9: lossless storage of the licensed MeshFontTrial7 bitmaps.
|
||||
// All 224 slots/189 drawable glyphs preserved at 12/17 px H cap height;
|
||||
// H tops 3/5 px, line boxes 19/28 px. The unused initial Arial10 is not stored.
|
||||
// Google Zopfli 1000; glyph-aligned chunks decode to at most 512 bytes.
|
||||
// Source rasterizer: Pillow 12.3.0 / FreeType 2.14.3, threshold 128.
|
||||
// Nominal source sizes 16/24 px, baselines 15/22 px; existing mode7 placement.
|
||||
// Normal builds need no compressor, rasterizer or source TrueType font.
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "../ota/tinf/tinf.h"
|
||||
namespace mesh { namespace ui { namespace noto {
|
||||
static constexpr size_t kScratchSize = 512;
|
||||
struct Font {
|
||||
const uint8_t* descriptor;
|
||||
const uint8_t* data;
|
||||
const uint16_t* offsets;
|
||||
const uint16_t* rawLengths;
|
||||
uint16_t chunkCount;
|
||||
uint16_t dataLength;
|
||||
};
|
||||
struct Glyph {
|
||||
const uint8_t* data;
|
||||
uint8_t width;
|
||||
uint8_t length;
|
||||
bool drawable;
|
||||
bool valid;
|
||||
};'''
|
||||
|
||||
|
||||
def generate_header(fonts: tuple[Font, ...], license: str) -> str:
|
||||
output = [HEADER_START, "/*\nCompression derivative named MeshFontProduction9.\n"
|
||||
"The following notice identifies the upstream rasterized font source.\n"
|
||||
+ "\n".join(line.rstrip() for line in license.splitlines()) + "\n*/"]
|
||||
for id, font in enumerate(fonts, 1):
|
||||
packed, rows, offsets, lengths = chunk_font(font)
|
||||
prefix = f"FONT_{id}"
|
||||
output.append(f"// Authoritative raw bitmap SHA256: {hashlib.sha256(font.original).hexdigest()}")
|
||||
output.append(f"inline const Font& fontStorage{id}() {{")
|
||||
output.append(array(prefix + "_DATA", packed))
|
||||
output.append(array(prefix + "_DESCRIPTOR", descriptor(font, rows)))
|
||||
output.append(array(prefix + "_OFFSETS", offsets, "uint16_t"))
|
||||
output.append(array(prefix + "_LENGTHS", lengths, "uint16_t"))
|
||||
output.append(f"static const Font {prefix} = {{ {prefix}_DESCRIPTOR, {prefix}_DATA, "
|
||||
f"{prefix}_OFFSETS, {prefix}_LENGTHS, {len(lengths)}, {len(packed)} }};")
|
||||
output.append(f"return {prefix};\n}}")
|
||||
output.append(chunk_decoder())
|
||||
return "\n\n".join(output) + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="Verify the generated header without writing")
|
||||
args = parser.parse_args()
|
||||
fonts, license = read_fonts()
|
||||
header = generate_header(fonts, license)
|
||||
if args.check:
|
||||
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != header:
|
||||
raise SystemExit("CompactNotoFonts.h is stale; regenerate the production font assets")
|
||||
else:
|
||||
OUTPUT.write_text(header, encoding="utf-8", newline="\n")
|
||||
total = 0
|
||||
for font in fonts:
|
||||
packed, records, offsets, lengths = chunk_font(font)
|
||||
size = len(packed) + len(descriptor(font, records)) + 2 * (len(offsets) + len(lengths))
|
||||
total += size
|
||||
print(f"{font.name}: raw={len(font.original)}, compressed+metadata={size}, chunks={len(lengths)}")
|
||||
print(f"Combined storage={total} bytes (runtime accessor code excluded)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Generate optional sans-serif family trials at the existing Arial ink sizes.
|
||||
|
||||
This is not part of normal firmware builds: checked-in bitmap arrays need no
|
||||
Pillow or TrueType fonts on the build host. Regeneration requires Pillow 12.3.0
|
||||
with FreeType 2.14.3 and the SHA-pinned upstream font files below. The derived
|
||||
font names are MeshFontTrial4..8, not their sources' reserved font names.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
from generate_compact_arial import Font, Glyph, ROOT, array, chunk_font, read_fonts, whole_font
|
||||
|
||||
|
||||
OUTPUT = ROOT / "src/helpers/ui/FontFamilyTrials.h"
|
||||
PILLOW_VERSION = "12.3.0"
|
||||
FREETYPE_VERSION = "2.14.3"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Family:
|
||||
mode: int
|
||||
source: str
|
||||
filename: str
|
||||
sha256: str
|
||||
sizes: tuple[int, int]
|
||||
spdx: str
|
||||
upstream: str
|
||||
|
||||
|
||||
FAMILIES = (
|
||||
Family(4, "Liberation Sans", "LiberationSans-Regular.ttf",
|
||||
"597589a852868f782c68e25112bab7785b34bc726f6160dca1519198c6e8c762",
|
||||
(16, 24), "OFL-1.1", "https://github.com/liberationfonts/liberation-fonts"),
|
||||
Family(5, "Noto Sans", "NotoSans-Regular.ttf",
|
||||
"6b04c8dd65af6b73eb4279472ed1580b29102d6496a377340e80a40cdb3b22c9",
|
||||
(16, 24), "OFL-1.1", "https://github.com/notofonts/noto-fonts"),
|
||||
Family(6, "DejaVu Sans", "DejaVuSans.ttf",
|
||||
"7da195a74c55bef988d0d48f9508bd5d849425c1770dba5d7bfc6ce9ed848954",
|
||||
(16, 23), "Bitstream-Vera AND LicenseRef-Arev", "https://github.com/dejavu-fonts/dejavu-fonts"),
|
||||
Family(7, "Noto Sans Condensed", "NotoSans-Condensed.ttf",
|
||||
"4efc2789b9fcaa87b6b55f19c25ac9a2919e4f9ca31d6b5b97bea7977d729d4f",
|
||||
(16, 24), "OFL-1.1", "https://github.com/notofonts/noto-fonts"),
|
||||
Family(8, "DejaVu Sans Condensed", "DejaVuSansCondensed.ttf",
|
||||
"8550cd5ca1acb65a8fc7877c46939cd0b4d909f8e7bc1e24716873d918c5e549",
|
||||
(16, 23), "Bitstream-Vera AND LicenseRef-Arev", "https://github.com/dejavu-fonts/dejavu-fonts"),
|
||||
)
|
||||
|
||||
|
||||
def sfnt_tables(data: bytes) -> dict[bytes, tuple[int, int]]:
|
||||
count = struct.unpack_from(">H", data, 4)[0]
|
||||
result = {}
|
||||
for index in range(count):
|
||||
tag, _checksum, offset, length = struct.unpack_from(">4sIII", data, 12 + 16 * index)
|
||||
if offset + length > len(data):
|
||||
raise ValueError("Truncated TrueType table")
|
||||
result[tag] = offset, length
|
||||
return result
|
||||
|
||||
|
||||
def name_records(data: bytes) -> dict[int, str]:
|
||||
offset, _length = sfnt_tables(data)[b"name"]
|
||||
_format, count, strings = struct.unpack_from(">HHH", data, offset)
|
||||
records = {}
|
||||
for index in range(count):
|
||||
platform, _encoding, _language, name, length, relative = struct.unpack_from(
|
||||
">HHHHHH", data, offset + 6 + 12 * index)
|
||||
raw = data[offset + strings + relative:offset + strings + relative + length]
|
||||
if platform in (0, 3):
|
||||
records[name] = raw.decode("utf-16-be")
|
||||
elif platform == 1 and name not in records:
|
||||
records[name] = raw.decode("mac_roman")
|
||||
return records
|
||||
|
||||
|
||||
def unicode_coverage(data: bytes) -> set[int]:
|
||||
"""Read supported cmap4/cmap12 codepoints; never silently use .notdef."""
|
||||
offset, _length = sfnt_tables(data)[b"cmap"]
|
||||
_version, count = struct.unpack_from(">HH", data, offset)
|
||||
coverage = set()
|
||||
for index in range(count):
|
||||
platform, encoding, relative = struct.unpack_from(">HHI", data, offset + 4 + 8 * index)
|
||||
if platform != 0 and not (platform == 3 and encoding in (1, 10)):
|
||||
continue
|
||||
table = offset + relative
|
||||
format = struct.unpack_from(">H", data, table)[0]
|
||||
if format == 12:
|
||||
groups = struct.unpack_from(">I", data, table + 12)[0]
|
||||
for group in range(groups):
|
||||
start, end, first = struct.unpack_from(">III", data, table + 16 + 12 * group)
|
||||
coverage.update(code for code in range(start, min(end, 255) + 1) if first + code - start)
|
||||
elif format == 4:
|
||||
segments = struct.unpack_from(">H", data, table + 6)[0] // 2
|
||||
end_base = table + 14
|
||||
start_base = end_base + 2 * segments + 2
|
||||
delta_base = start_base + 2 * segments
|
||||
range_base = delta_base + 2 * segments
|
||||
for segment in range(segments):
|
||||
start = struct.unpack_from(">H", data, start_base + 2 * segment)[0]
|
||||
end = struct.unpack_from(">H", data, end_base + 2 * segment)[0]
|
||||
delta = struct.unpack_from(">H", data, delta_base + 2 * segment)[0]
|
||||
relative_glyph = struct.unpack_from(">H", data, range_base + 2 * segment)[0]
|
||||
for code in range(start, min(end, 255) + 1):
|
||||
if relative_glyph:
|
||||
glyph_offset = range_base + 2 * segment + relative_glyph + 2 * (code - start)
|
||||
glyph = struct.unpack_from(">H", data, glyph_offset)[0]
|
||||
glyph = ((glyph + delta) & 0xffff) if glyph else 0
|
||||
else:
|
||||
glyph = (code + delta) & 0xffff
|
||||
if glyph:
|
||||
coverage.add(code)
|
||||
return coverage
|
||||
|
||||
|
||||
def make_font(name: str, header: bytes, glyphs: list[Glyph]) -> Font:
|
||||
table = bytearray(header)
|
||||
bitmap = bytearray()
|
||||
for glyph in glyphs:
|
||||
if len(glyph.bitmap) > 255 or glyph.width > 255:
|
||||
raise ValueError("Rasterized glyph exceeds ThingPulse byte metadata")
|
||||
table.extend((len(bitmap) if glyph.bitmap else 0xffff).to_bytes(2, "big"))
|
||||
table.extend((len(glyph.bitmap), glyph.width))
|
||||
bitmap.extend(glyph.bitmap)
|
||||
return Font(name, bytes(table + bitmap), tuple(glyphs))
|
||||
|
||||
|
||||
def rasterize(family: Family, source: Path, baseline_fonts: tuple[Font, ...]):
|
||||
from PIL import Image, ImageDraw, ImageFont, features
|
||||
if Image.__version__ != PILLOW_VERSION or features.version("freetype2") != FREETYPE_VERSION:
|
||||
raise RuntimeError(f"Require Pillow{PILLOW_VERSION}/FreeType{FREETYPE_VERSION} for deterministic font trials")
|
||||
data = source.read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != family.sha256:
|
||||
raise ValueError(f"{source.name}: source font hash differs from the pinned trial")
|
||||
names = name_records(data)
|
||||
coverage = unicode_coverage(data)
|
||||
fonts = []
|
||||
metrics = []
|
||||
for id, pixel_size in enumerate(family.sizes, 1):
|
||||
base = baseline_fonts[id]
|
||||
baseline = 15 if id == 1 else 22
|
||||
font = ImageFont.truetype(str(source), pixel_size, layout_engine=ImageFont.Layout.BASIC)
|
||||
glyphs = []
|
||||
shifted_x, shifted_y = [], []
|
||||
for slot, original in enumerate(base.glyphs):
|
||||
code = base.header[2] + slot
|
||||
character = chr(code)
|
||||
advance = int(math.floor(font.getlength(character) + 0.5)) if code in coverage else original.width
|
||||
if not original.bitmap:
|
||||
# Control slots stay blank, not .notdef boxes. Spaces use the
|
||||
# candidate font's own spacing; other blanks retain zero width.
|
||||
glyphs.append(Glyph(advance if code in (32, 160) else original.width, b""))
|
||||
continue
|
||||
if code not in coverage:
|
||||
raise ValueError(f"{family.source}: U+{code:04X} missing from Unicode cmap")
|
||||
image = Image.new("L", (128, 128), 0)
|
||||
ImageDraw.Draw(image).text((48, 48), character, font=font, fill=255, anchor="ls")
|
||||
image = image.point(lambda value: 255 if value >= 128 else 0)
|
||||
bbox = image.getbbox()
|
||||
if bbox is None:
|
||||
raise ValueError(f"{family.source}: drawable U+{code:04X} rasterized blank")
|
||||
left, top, right, bottom = (value - 48 for value in bbox)
|
||||
if bottom - top > base.header[1]:
|
||||
raise ValueError(f"{family.source}: U+{code:04X} is too tall for the unchanged line box")
|
||||
x_shift = max(0, -left)
|
||||
y_shift = max(0, -(baseline + top))
|
||||
y_shift -= max(0, baseline + bottom + y_shift - base.header[1])
|
||||
width = max(advance + x_shift, right + x_shift)
|
||||
if baseline + top + y_shift < 0 or baseline + bottom + y_shift > base.header[1]:
|
||||
raise ValueError("Glyph placement would clip pixels")
|
||||
if x_shift:
|
||||
shifted_x.append(code)
|
||||
if y_shift:
|
||||
shifted_y.append((code, y_shift))
|
||||
raster_height = (base.header[1] + 7) // 8
|
||||
bitmap = bytearray(width * raster_height)
|
||||
for y in range(bbox[1], bbox[3]):
|
||||
for x in range(bbox[0], bbox[2]):
|
||||
if image.getpixel((x, y)):
|
||||
dest_x = x - 48 + x_shift
|
||||
dest_y = y - 48 + baseline + y_shift
|
||||
bitmap[dest_x * raster_height + dest_y // 8] |= 1 << (dest_y & 7)
|
||||
while bitmap and not bitmap[-1]:
|
||||
bitmap.pop()
|
||||
glyphs.append(Glyph(width, bytes(bitmap)))
|
||||
made = make_font(f"MeshFontTrial{family.mode}_{16 if id == 1 else 24}", base.header, glyphs)
|
||||
top, bottom = ink_y_bounds(made, ord("H"))
|
||||
if (top, bottom) != ((3, 14) if id == 1 else (5, 21)):
|
||||
raise ValueError("The H baseline/top differs from the original Arial placement")
|
||||
fonts.append(made)
|
||||
metrics.append({"nominal": pixel_size, "cap": bottom - top + 1, "H_top": top,
|
||||
"baseline": baseline, "line_box": base.header[1],
|
||||
"bearing_shifts": shifted_x, "vertical_shifts": shifted_y})
|
||||
return tuple(fonts), metrics, names
|
||||
|
||||
|
||||
def ink_y_bounds(font: Font, code: int) -> tuple[int, int]:
|
||||
glyph = font.glyphs[code - font.header[2]]
|
||||
stride = (font.header[1] + 7) // 8
|
||||
rows = [8 * (index % stride) + bit for index, value in enumerate(glyph.bitmap)
|
||||
for bit in range(8) if value & (1 << bit)]
|
||||
return min(rows), max(rows)
|
||||
|
||||
|
||||
def license_notice(family: Family, names: dict[int, str]) -> str:
|
||||
if family.mode in (4, 5, 7):
|
||||
text = (ROOT / "tools/sensecap_indicator_font/OFL-1.1.txt").read_text(encoding="utf-8")
|
||||
text = text[text.index("SIL OPEN FONT LICENSE Version 1.1"):]
|
||||
notice = names[0] + "\n"
|
||||
if family.mode == 4:
|
||||
notice += "Reserved Font Names: Arimo, Tinos, Cousine, Liberation.\n"
|
||||
return notice + "\n" + text
|
||||
return names[0] + "\n\n" + names[13]
|
||||
|
||||
|
||||
def generate_header(trials) -> str:
|
||||
output = ["// Generated by scripts/generate_font_family_trials.py; do not edit.\n"
|
||||
"// Opt-in derived fonts named MeshFontTrial4..8. Same visible cap sizes\n"
|
||||
"// (12/17 px) and line boxes (19/28 px) as Arial16/24; all 189 drawable\n"
|
||||
"// Latin-1 slots preserved. These are appearance trials, not defaults.\n"
|
||||
"// Rasterizer: Pillow 12.3.0, FreeType 2.14.3; grayscale threshold 128.\n"
|
||||
"#pragma once\n#include <stdint.h>\n"
|
||||
"#if MESH_ARIAL_EXPERIMENT < 4 || MESH_ARIAL_EXPERIMENT > 8\n"
|
||||
'#error "FontFamilyTrials.h is only for font experiment modes 4 through 8"\n#endif\n'
|
||||
"namespace mesh { namespace ui { namespace font_trials {"]
|
||||
for family, fonts, metrics, names in trials:
|
||||
output.append(f"#if MESH_ARIAL_EXPERIMENT == {family.mode}")
|
||||
comment = (f"Derived MeshFontTrial{family.mode}; source {family.source}, {names[5]}\n"
|
||||
f"Source SHA256: {family.sha256}\nUpstream: {family.upstream}\n"
|
||||
f"SPDX-License-Identifier: {family.spdx}\n"
|
||||
"Full license/copyright follows; it applies to the bitmap font data.\n"
|
||||
+ license_notice(family, names))
|
||||
# Font metadata/license text may carry trailing spaces. Normalize only
|
||||
# whitespace, never the required copyright/license wording.
|
||||
comment = "\n".join(line.rstrip() for line in comment.splitlines())
|
||||
output.append("/*\n" + comment.replace("*/", "* /") + "\n*/")
|
||||
for id, (font, metric) in enumerate(zip(fonts, metrics), 1):
|
||||
output.append("// " + "; ".join(f"{key}={value}" for key, value in metric.items()))
|
||||
output.append(f"inline const uint8_t* fontStorage{id}() {{")
|
||||
output.append(array(f"TRIAL_{id}", font.original))
|
||||
output.append(f"return TRIAL_{id};\n}}")
|
||||
output.append("#endif")
|
||||
output.append("inline const uint8_t* font(unsigned id) {\n"
|
||||
" return id == 1 ? fontStorage1() : id == 2 ? fontStorage2() : nullptr;\n"
|
||||
"}\n}}} // namespace mesh::ui::font_trials")
|
||||
return "\n\n".join(output) + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--font-dir", type=Path, default=Path("C:/Windows/Fonts"))
|
||||
parser.add_argument("--check", action="store_true")
|
||||
args = parser.parse_args()
|
||||
baseline_fonts = read_fonts()
|
||||
trials = []
|
||||
for family in FAMILIES:
|
||||
fonts, metrics, names = rasterize(family, args.font_dir / family.filename, baseline_fonts)
|
||||
trials.append((family, fonts, metrics, names))
|
||||
if not args.check:
|
||||
print(f"mode {family.mode} {family.source}: raw={sum(len(font.original) for font in fonts)} "
|
||||
f"whole-font Zopfli={sum(len(whole_font(font)) for font in fonts)}")
|
||||
for font, metric in zip(fonts, metrics):
|
||||
chunk, records, offsets, lengths = chunk_font(font)
|
||||
compressed_size = len(chunk) + 4 + 4 * len(records) + 2 * (len(offsets) + len(lengths))
|
||||
print(f" {font.name}: raw={len(font.original)} chunked Zopfli={compressed_size} {metric}")
|
||||
header = generate_header(trials)
|
||||
if args.check:
|
||||
if OUTPUT.read_text(encoding="utf-8") != header:
|
||||
raise SystemExit("FontFamilyTrials.h is stale")
|
||||
else:
|
||||
OUTPUT.write_text(header, encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Feature-preserving flash trim for nRF52 infrastructure, never Companions.
|
||||
|
||||
Decide from the effective source filter as well as the environment/OTA alias
|
||||
and flags: build.sh can synthesize profiles over another PlatformIO recipe.
|
||||
Unknown roles fail closed. No shared framework or downloaded library is edited.
|
||||
CDC, USB host, sensor/GPS support, BLE P-256, and stored Ed25519 keys remain.
|
||||
The compact Ed25519 implementation trades more signing time/stack for flash;
|
||||
the nRF52 base keeps an 8 KiB loop stack for this and the existing OTA path.
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROLE_SOURCES = (
|
||||
"../examples/simple_repeater/main.cpp",
|
||||
"../examples/simple_room_server/main.cpp",
|
||||
"../examples/simple_sensor/main.cpp",
|
||||
"../examples/kiss_modem/main.cpp",
|
||||
)
|
||||
COMPANION_SOURCES = (
|
||||
"../examples/companion_radio/MyMesh.cpp",
|
||||
"../examples/simple_secure_chat/main.cpp",
|
||||
)
|
||||
TRIM_DEFINES = {
|
||||
"MESH_NRF52_FLASH_TRIM": 1,
|
||||
"ED25519_COMPACT_BASE": 1,
|
||||
"ED25519_COMPACT_SHA512": 1,
|
||||
# No infrastructure role instantiates these USB *device* classes. Do not
|
||||
# touch CDC (including dual CDC logging), DFU, or the MAX3421 USB host.
|
||||
"CFG_TUD_MSC": 0,
|
||||
"CFG_TUD_HID": 0,
|
||||
"CFG_TUD_MIDI": 0,
|
||||
"CFG_TUD_VENDOR": 0,
|
||||
"CFG_TUD_VIDEO": 0,
|
||||
"CFG_TUD_VIDEO_STREAMING": 0,
|
||||
# MeshCore uses UBX navigation, not SparkFun's automatic NMEA cache. This
|
||||
# does not disable navigation, the UBX parser, or MeshCore NMEA logging.
|
||||
"SFE_UBLOX_DISABLE_AUTO_NMEA": 1,
|
||||
"SFE_UBLOX_REDUCED_PROG_MEM": 1,
|
||||
# SSD1306Display clears the vendor splash before its first panel transfer.
|
||||
# Keep MeshCore's own startup logo; only omit the invisible library bitmap.
|
||||
"SSD1306_NO_SPLASH": 1,
|
||||
}
|
||||
CC310_ARCHIVE_SHA256 = "97dc648d44520e47252f62c402a71f976e36d7b3aef99235bbdc4de79a928577"
|
||||
CC310_ARCHIVE = "src/cortex-m4/fpv4-sp-d16-hard/libnrf_cc310_0.9.13-no-interrupts.a"
|
||||
|
||||
|
||||
def flag_defines(flags=(), cppdefines=()):
|
||||
"""Read effective defines without importing PlatformIO/SCons for tests."""
|
||||
values = {}
|
||||
for define in cppdefines:
|
||||
if isinstance(define, (tuple, list)):
|
||||
values[str(define[0])] = str(define[1]) if len(define) > 1 else "1"
|
||||
else:
|
||||
values[str(define)] = "1"
|
||||
text = flags if isinstance(flags, str) else " ".join(map(str, flags))
|
||||
words = shlex.split(text)
|
||||
index = 0
|
||||
while index < len(words):
|
||||
word = words[index]
|
||||
if word in ("-D", "-U"):
|
||||
index += 1
|
||||
word += words[index] if index < len(words) else ""
|
||||
if word.startswith("-D"):
|
||||
name, _, value = word[2:].partition("=")
|
||||
values[name] = value if "=" in word else "1"
|
||||
elif word.startswith("-U"):
|
||||
values[word[2:]] = None
|
||||
index += 1
|
||||
return values
|
||||
|
||||
|
||||
def _enabled(value):
|
||||
return value is not None and str(value).strip("\"'() ") not in ("", "0")
|
||||
|
||||
|
||||
def source_selected(src_filter, source):
|
||||
"""Resolve ordered includes/excludes for the explicit outside-src roles."""
|
||||
text = src_filter if isinstance(src_filter, str) else " ".join(map(str, src_filter))
|
||||
selected = False
|
||||
for sign, pattern in re.findall(r"([+-])<([^>]+)>", text.replace("\\", "/")):
|
||||
# +<*.cpp> and +<*> scan PROJECT_SRC_DIR, not ../examples. They do
|
||||
# not imply that any example role is part of the image.
|
||||
if source.startswith("../examples/") and "examples/" not in pattern:
|
||||
continue
|
||||
pattern = pattern.rstrip("/")
|
||||
if fnmatch.fnmatchcase(source, pattern) or source.startswith(pattern + "/"):
|
||||
selected = sign == "+"
|
||||
return selected
|
||||
|
||||
|
||||
def trim_policy(platform, mcu, env_name, src_filter, flags=(), cppdefines=(),
|
||||
ota_name_mode="front-coded"):
|
||||
"""Return defaults for a qualified non-Companion role, otherwise nothing.
|
||||
|
||||
The name policy is explicit so the profile can use a different verified
|
||||
representation later without changing role selection or hardware trims.
|
||||
"""
|
||||
if platform != "nordicnrf52" or not str(mcu).lower().startswith("nrf52"):
|
||||
return {}
|
||||
values = flag_defines(flags, cppdefines)
|
||||
names = (str(env_name), str(values.get("OTA_VARIANT", "")))
|
||||
if any(any(tag in name.lower() for tag in (
|
||||
"companion", "comp_radio", "terminal_chat", "term_chat", "terminal-chat", "secure_chat")) for name in names):
|
||||
return {}
|
||||
if any(_enabled(value) for name, value in values.items()
|
||||
if name.startswith("COMPANION_")):
|
||||
return {}
|
||||
if any(source_selected(src_filter, source) for source in COMPANION_SOURCES):
|
||||
return {}
|
||||
if not any(source_selected(src_filter, source) for source in ROLE_SOURCES):
|
||||
return {}
|
||||
if "MESH_NRF52_FLASH_TRIM" in values and not _enabled(values["MESH_NRF52_FLASH_TRIM"]):
|
||||
return {}
|
||||
if ota_name_mode != "front-coded":
|
||||
raise ValueError("unsupported nRF52 OTA name trim policy: " + ota_name_mode)
|
||||
definitions = dict(TRIM_DEFINES, OTA_TARGET_NAME_FRONT_CODED=1)
|
||||
# Explicit board/user flags retain their value. In particular never undo
|
||||
# an opt-out or a board-specific USB configuration by re-defining it.
|
||||
return {name: value for name, value in definitions.items() if name not in values}
|
||||
|
||||
|
||||
def supported_cc310(framework_dir, mcu, defines):
|
||||
"""Only wrap the exact inspected archive; future SDKs retain their getter."""
|
||||
if str(mcu).lower() != "nrf52840" or not _enabled(defines.get("USE_CC310_HW_CRYPTO")):
|
||||
return False
|
||||
archive = Path(framework_dir) / "libraries/Adafruit_nRFCrypto" / CC310_ARCHIVE
|
||||
return archive.is_file() and hashlib.sha256(archive.read_bytes()).hexdigest() == CC310_ARCHIVE_SHA256
|
||||
|
||||
|
||||
def trim_tft_library(build_env, node):
|
||||
# PlatformIO invokes this on TFT_eSPI's library builder, which is already
|
||||
# a private clone. Do not add a global forced include to the project: its
|
||||
# User_Setup defines are not appropriate for unrelated sources. The
|
||||
# matching ST7735Display translation unit includes this header directly.
|
||||
if not build_env.get("MESH_NRF52_TFT_FONT_TRIM_APPLIED", False):
|
||||
build_env["MESH_NRF52_TFT_FONT_TRIM_APPLIED"] = True
|
||||
build_env.Append(CCFLAGS=[
|
||||
"-include", build_env.subst("$PROJECT_DIR/src/helpers/ui/Nrf52TftFontTrim.h"),
|
||||
])
|
||||
return node
|
||||
|
||||
|
||||
def install(build_env):
|
||||
platform = build_env.PioPlatform()
|
||||
mcu = build_env.BoardConfig().get("build.mcu", "")
|
||||
flags = build_env.get("BUILD_FLAGS", ())
|
||||
cppdefines = build_env.get("CPPDEFINES", ())
|
||||
definitions = trim_policy(platform.name, mcu, build_env["PIOENV"],
|
||||
build_env.get("SRC_FILTER", ()), flags, cppdefines)
|
||||
if not definitions:
|
||||
return
|
||||
build_env["MESH_NRF52_FLASH_TRIM_ACTIVE"] = True
|
||||
build_env.AppendUnique(CPPDEFINES=list(definitions.items()))
|
||||
print("nRF52 infrastructure flash trim: compact crypto, unused USB device classes/GPS cache/vendor splash, compact OTA target names")
|
||||
framework = platform.get_package_dir("framework-arduinoadafruitnrf52")
|
||||
values = flag_defines(flags, cppdefines)
|
||||
if values.get("ST7789") is not None:
|
||||
# Keep the font license with this qualified display build, including
|
||||
# cached DFU packages. The hook independently checks the effective
|
||||
# font override and excludes every Companion transport/profile.
|
||||
hook_path = build_env.subst("$PROJECT_DIR/scripts/package_nrf52_font_license.py")
|
||||
hook_spec = importlib.util.spec_from_file_location("mesh_nrf52_font_license", hook_path)
|
||||
font_license = importlib.util.module_from_spec(hook_spec)
|
||||
hook_spec.loader.exec_module(font_license)
|
||||
font_license.install(build_env)
|
||||
if (values.get("DISPLAY_CLASS") == "ST7735Display"
|
||||
and source_selected(build_env.get("SRC_FILTER", ()), "helpers/ui/ST7735Display.cpp")):
|
||||
build_env.AddBuildMiddleware(trim_tft_library, "*TFT_eSPI*/*.cpp")
|
||||
if framework and supported_cc310(framework, mcu, values):
|
||||
build_env.AppendUnique(LINKFLAGS=["-Wl,--wrap=CRYS_ECPKI_GetEcDomain"])
|
||||
build_env.AppendUnique(CPPPATH=[str(Path(framework) / "libraries/Adafruit_nRFCrypto/src/nrf_cc310/include")])
|
||||
# BuildSources is called on this selected nRF52 environment only. The
|
||||
# wrapper is not in the common source filter and cannot leak to other
|
||||
# platforms or an excluded Companion role.
|
||||
build_env.BuildSources("$BUILD_DIR/nrf52-flash-trim",
|
||||
"$PROJECT_DIR/src/helpers/nrf52",
|
||||
"+<Cc310DomainTrim.c>")
|
||||
else:
|
||||
print("nRF52 CC310 trim: unsupported/unused SDK; ordinary domain lookup retained")
|
||||
|
||||
|
||||
if "Import" in globals():
|
||||
Import("env")
|
||||
install(env)
|
||||
@@ -16,6 +16,7 @@ from urllib.parse import quote, urljoin
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from firmware_memory_manifest import validate_package
|
||||
from check_nrf52_uf2 import check_uf2
|
||||
from package_nrf52_font_license import validate_artifact_notice
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -69,6 +70,9 @@ def collect_artifacts(directory, version):
|
||||
if any(item.stat().st_size == 0 for item in files):
|
||||
raise ValueError(f"{stem}: empty firmware artifact")
|
||||
memory = validate_package(directory / stem)
|
||||
font_notice = validate_artifact_notice(directory / stem)
|
||||
if font_notice is not None:
|
||||
files.append(font_notice)
|
||||
manifest["runtime_ram"] = {key: memory[key] for key in (
|
||||
"passed", "available_internal_bytes", "required_heap_bytes", "elf_sha256")}
|
||||
accounted.update(files)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Carry the production bitmap-font OFL notice alongside affected firmware.
|
||||
|
||||
Only the qualified nRF52 infrastructure/ST7789 font mode 9 installs this hook.
|
||||
The DFU manifest and all application/init-packet entries stay byte-identical;
|
||||
the extra text entry is not selected by Nordic/Adafruit DFU loaders. UF2 files
|
||||
are unchanged and travel with an identically named .font-license.txt sidecar.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
from zipfile import BadZipFile, ZIP_STORED, ZipFile, ZipInfo
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
NOTICE_PATH = ROOT / "licenses/MeshNotoCondensed-OFL.txt"
|
||||
NOTICE_ENTRY = "FONT-LICENSE.txt"
|
||||
SIDECAR_SUFFIX = ".font-license.txt"
|
||||
|
||||
|
||||
def notice_bytes():
|
||||
# Explicit UTF-8/LF also makes ZIP contents identical on Windows and Linux.
|
||||
return NOTICE_PATH.read_text(encoding="utf-8").replace("\r\n", "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _defines(flags=(), cppdefines=()):
|
||||
"""Read effective -D/-U values without requiring PlatformIO in host tests."""
|
||||
values = {}
|
||||
for define in cppdefines:
|
||||
if isinstance(define, (tuple, list)):
|
||||
values[str(define[0])] = str(define[1]) if len(define) > 1 else "1"
|
||||
else:
|
||||
name, separator, value = str(define).partition("=")
|
||||
values[name] = value if separator else "1"
|
||||
words = shlex.split(flags if isinstance(flags, str) else " ".join(map(str, flags)))
|
||||
index = 0
|
||||
while index < len(words):
|
||||
word = words[index]
|
||||
if word in ("-D", "-U"):
|
||||
index += 1
|
||||
word += words[index] if index < len(words) else ""
|
||||
if word.startswith("-D"):
|
||||
name, separator, value = word[2:].partition("=")
|
||||
values[name] = value if separator else "1"
|
||||
elif word.startswith("-U"):
|
||||
values[word[2:]] = None
|
||||
index += 1
|
||||
return values
|
||||
|
||||
|
||||
def _enabled(value):
|
||||
return value is not None and str(value).strip("\"'() ") not in ("", "0")
|
||||
|
||||
|
||||
def qualifies(build_env):
|
||||
"""Match Nrf52FontConfig.h after the infrastructure trim policy qualifies."""
|
||||
if not build_env.get("MESH_NRF52_FLASH_TRIM_ACTIVE", False):
|
||||
return False
|
||||
values = _defines(build_env.get("BUILD_FLAGS", ()), build_env.get("CPPDEFINES", ()))
|
||||
if (values.get("NRF52_PLATFORM") is None or values.get("ST7789") is None
|
||||
or not _enabled(values.get("MESH_NRF52_FLASH_TRIM"))
|
||||
or values.get("COMPANION_RADIO_FULL") is not None):
|
||||
return False
|
||||
names = (str(build_env.get("PIOENV", "")), str(values.get("OTA_VARIANT", "")))
|
||||
if any(any(tag in name.lower() for tag in (
|
||||
"companion", "comp_radio", "terminal_chat", "term_chat", "terminal-chat", "secure_chat"))
|
||||
for name in names):
|
||||
return False
|
||||
if any(_enabled(value) for name, value in values.items() if name.startswith("COMPANION_")):
|
||||
return False
|
||||
mode = values.get("MESH_ARIAL_EXPERIMENT")
|
||||
if mode is None:
|
||||
mode = values.get("MESH_NRF52_FONT_MODE")
|
||||
if mode is None:
|
||||
mode = "9"
|
||||
try:
|
||||
return int(str(mode).strip("\"'() "), 0) == 9
|
||||
except ValueError:
|
||||
return False # Unknown/expression overrides must not claim this font.
|
||||
|
||||
|
||||
def package_notice(package):
|
||||
"""Return the canonical notice, or None for an unaffected DFU package."""
|
||||
with ZipFile(package) as archive:
|
||||
notices = [info for info in archive.infolist() if info.filename == NOTICE_ENTRY]
|
||||
if not notices:
|
||||
return None
|
||||
if len(notices) != 1 or archive.read(notices[0]) != notice_bytes():
|
||||
raise ValueError(f"{package}: unexpected or duplicate font-license entry")
|
||||
return archive.read(notices[0])
|
||||
|
||||
|
||||
def _check_existing(path, data):
|
||||
if path.exists() and (not path.is_file() or path.read_bytes() != data):
|
||||
raise ValueError(f"{path}: refusing to overwrite a different existing font notice")
|
||||
|
||||
|
||||
def _write_new_or_identical(path, data):
|
||||
_check_existing(path, data)
|
||||
if not path.exists():
|
||||
# Never replace an unrelated file, and preserve mtimes on cached builds.
|
||||
with path.open("xb") as stream:
|
||||
stream.write(data)
|
||||
|
||||
|
||||
def package_font_license(package, sidecar=None):
|
||||
"""Append one fixed, deterministic text entry; never regenerate DFU data."""
|
||||
package = Path(package)
|
||||
sidecar = Path(sidecar) if sidecar is not None else package.with_suffix(SIDECAR_SUFFIX)
|
||||
data = notice_bytes()
|
||||
_check_existing(sidecar, data)
|
||||
existing = package_notice(package)
|
||||
with ZipFile(package) as archive:
|
||||
# Ensure this is an application DFU package before touching anything.
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
application = manifest["manifest"]["application"]
|
||||
for key in ("bin_file", "dat_file"):
|
||||
selected = application[key]
|
||||
if sum(info.filename == selected for info in archive.infolist()) != 1:
|
||||
raise ValueError(f"{package}: invalid DFU application entry {selected}")
|
||||
if archive.testzip() is not None:
|
||||
raise ValueError(f"{package}: corrupt DFU package")
|
||||
if existing is None:
|
||||
entry = ZipInfo(NOTICE_ENTRY, date_time=(1980, 1, 1, 0, 0, 0))
|
||||
entry.compress_type = ZIP_STORED
|
||||
entry.create_system = 3
|
||||
entry.external_attr = 0o100644 << 16
|
||||
with ZipFile(package, "a") as archive:
|
||||
archive.writestr(entry, data)
|
||||
_write_new_or_identical(sidecar, data)
|
||||
return sidecar
|
||||
|
||||
|
||||
def _sidecar(stem):
|
||||
stem = Path(stem)
|
||||
return stem.parent / (stem.name + SIDECAR_SUFFIX)
|
||||
|
||||
|
||||
def validate_artifact_notice(stem):
|
||||
"""Require the notice alongside licensed ZIP/UF2 pairs before hashing."""
|
||||
stem = Path(stem)
|
||||
package = stem.parent / (stem.name + ".zip")
|
||||
sidecar = _sidecar(stem)
|
||||
data = package_notice(package) if package.is_file() else None
|
||||
if data is None:
|
||||
if sidecar.exists():
|
||||
raise ValueError(f"{stem}: font sidecar without a licensed DFU package")
|
||||
return None
|
||||
if not sidecar.is_file() or sidecar.read_bytes() != data:
|
||||
raise ValueError(f"{stem}: missing or mismatched font-license sidecar")
|
||||
return sidecar
|
||||
|
||||
|
||||
def collect_build_notice(directory, stem):
|
||||
"""Copy only a notice proved by this build's ZIP, never a stale sidecar."""
|
||||
directory = Path(directory)
|
||||
package = directory / "firmware.zip"
|
||||
data = package_notice(package) if package.is_file() else None
|
||||
output = _sidecar(stem)
|
||||
if data is None:
|
||||
# A prior mode-9 build may leave its source sidecar when opting out.
|
||||
# Ignore it; do not delete it or accidentally publish it for mode 0.
|
||||
if output.exists():
|
||||
raise ValueError(f"{output}: stale output notice for an unaffected build")
|
||||
return None
|
||||
build_notice = directory / ("firmware" + SIDECAR_SUFFIX)
|
||||
if not build_notice.is_file() or build_notice.read_bytes() != data:
|
||||
raise ValueError(f"{build_notice}: missing or mismatched build font notice")
|
||||
_write_new_or_identical(output, data)
|
||||
return output
|
||||
|
||||
|
||||
def install(build_env):
|
||||
"""Install after nrf52_flash_trim.install has appended effective defines."""
|
||||
if not qualifies(build_env) or build_env.get("MESH_NRF52_FONT_LICENSE_INSTALLED", False):
|
||||
return False
|
||||
build_env["MESH_NRF52_FONT_LICENSE_INSTALLED"] = True
|
||||
package = "$BUILD_DIR/${PROGNAME}.zip"
|
||||
build_env.Depends(package, [str(NOTICE_PATH), str(Path(__file__).resolve())])
|
||||
|
||||
def attach_notice(source, target, env):
|
||||
# buildprog is deliberately idempotent: a cached ZIP still needs its
|
||||
# sidecar validated/emitted. Its alias depends on the ZIP builder.
|
||||
package_font_license(env.subst(package), env.subst("$BUILD_DIR/${PROGNAME}" + SIDECAR_SUFFIX))
|
||||
|
||||
build_env.AddPostAction(package, attach_notice)
|
||||
build_env.AddPostAction("buildprog", attach_notice)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=("collect", "validate"))
|
||||
parser.add_argument("path", type=Path)
|
||||
parser.add_argument("--stem", type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.action == "collect":
|
||||
if args.stem is None:
|
||||
parser.error("collect requires --stem")
|
||||
collect_build_notice(args.path, args.stem)
|
||||
else:
|
||||
validate_artifact_notice(args.path)
|
||||
except (OSError, ValueError, KeyError, BadZipFile) as error:
|
||||
parser.exit(1, f"Firmware font notice qualification failed: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user