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:
mikecarper committed 2026-10-02 15:52:23 -07:00
1 parent 010374d7db
commit 8a69e73226
44 files changed
+11150 -14

No files matched your search

+9 -2
View File
@@ -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"):
+329
View File
@@ -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()
+154
View File
@@ -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()
+279
View File
@@ -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()
+190
View File
@@ -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)
+4
View File
@@ -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)
+211
View File
@@ -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()