3232import hashlib
3333import json
3434import shutil
35+ import struct
3536from dataclasses import dataclass
3637from pathlib import Path
3738from typing import Dict , List
4142 "package must look like " "<name>:<version>:<elf|shared-lib>:<source>"
4243)
4344
45+ # Fixed square icon size nxstore renders app-list icons at (see
46+ # nxstore_load_icon() in system/nxstore/nxstore_main.c) - small enough to
47+ # stay a quick download over the board's WiFi, large enough to read as
48+ # actual artwork rather than a favicon.
49+ ICON_SIZE = 48
50+
51+ # Matches lv_image_header_t's own bit layout (LVGL v9, see
52+ # graphics/lvgl/lvgl/src/draw/lv_image_dsc.h): magic/cf/flags packed into
53+ # the first 4 bytes, then w/h, then stride/reserved - all little-endian,
54+ # 12 bytes total, immediately followed by raw pixel data. This is
55+ # deliberately the simplest format LVGL can render directly with no
56+ # decoder, since the board has no PNG/JPEG decode capability wired up.
57+ LV_IMAGE_HEADER_MAGIC = 0x19
58+ LV_COLOR_FORMAT_RGB565 = 0x12
59+
4460
4561@dataclass
4662class PackageSpec :
@@ -88,6 +104,24 @@ def parse_package_spec(value: str) -> PackageSpec:
88104 )
89105
90106
107+ def parse_name_value (value : str ) -> tuple :
108+ parts = value .split ("=" , 1 )
109+ if len (parts ) != 2 or not parts [0 ] or not parts [1 ]:
110+ raise argparse .ArgumentTypeError ("value must look like <name>=<text>" )
111+
112+ return parts [0 ], parts [1 ]
113+
114+
115+ def parse_icon_spec (value : str ) -> tuple :
116+ name , source = parse_name_value (value )
117+ source_path = Path (source ).expanduser ().resolve ()
118+ if not source_path .is_file ():
119+ msg = f"icon source does not exist: { source_path } "
120+ raise argparse .ArgumentTypeError (msg )
121+
122+ return name , source_path
123+
124+
91125def artifact_relpath (arch : str , chip : str , compat : str ,
92126 spec : PackageSpec ) -> Path : # fmt: skip
93127 filename = spec .source .name
@@ -96,6 +130,47 @@ def artifact_relpath(arch: str, chip: str, compat: str,
96130 return path
97131
98132
133+ def icon_relpath (arch : str , chip : str , compat : str , name : str ) -> Path :
134+ # Keep one repository object per package. nxstore keys its local cache
135+ # by package name and version, so a new catalog version fetches this
136+ # object again without requiring versioned repository duplication.
137+ return Path ("icons" ) / arch / chip / compat / f"{ name } .bin"
138+
139+
140+ def encode_icon_rgb565 (source : Path , size : int = ICON_SIZE ) -> bytes :
141+ """Convert an arbitrary source image into nxstore's raw icon format:
142+ a 12-byte lv_image_header_t-compatible header (see the module
143+ docstring comment above) followed by size*size RGB565 pixel data.
144+ """
145+
146+ from PIL import Image
147+
148+ with Image .open (source ) as img :
149+ img = img .convert ("RGB" ).resize ((size , size ), Image .Resampling .LANCZOS )
150+ pixels = list (img .getdata ())
151+
152+ stride = size * 2
153+ header = struct .pack (
154+ "<BBHHHHH" ,
155+ LV_IMAGE_HEADER_MAGIC ,
156+ LV_COLOR_FORMAT_RGB565 ,
157+ 0 , # flags
158+ size , # w
159+ size , # h
160+ stride ,
161+ 0 , # reserved_2
162+ )
163+
164+ body = bytearray (stride * size )
165+ offset = 0
166+ for r , g , b in pixels :
167+ rgb565 = ((r & 0xF8 ) << 8 ) | ((g & 0xFC ) << 3 ) | (b >> 3 )
168+ struct .pack_into ("<H" , body , offset , rgb565 )
169+ offset += 2
170+
171+ return header + bytes (body )
172+
173+
99174def package_identity (package : Dict [str , str ]) -> tuple :
100175 return (
101176 package ["name" ],
@@ -171,6 +246,30 @@ def main() -> int:
171246 type = parse_package_spec ,
172247 help = PACKAGE_SPEC_HELP ,
173248 )
249+ parser .add_argument (
250+ "--package-description" ,
251+ action = "append" ,
252+ default = [],
253+ type = parse_name_value ,
254+ metavar = "NAME=TEXT" ,
255+ help = "Optional package description, keyed by package name" ,
256+ )
257+ parser .add_argument (
258+ "--package-category" ,
259+ action = "append" ,
260+ default = [],
261+ type = parse_name_value ,
262+ metavar = "NAME=TEXT" ,
263+ help = "Optional package category, keyed by package name" ,
264+ )
265+ parser .add_argument (
266+ "--package-icon" ,
267+ action = "append" ,
268+ default = [],
269+ type = parse_icon_spec ,
270+ metavar = "NAME=PATH" ,
271+ help = "Optional source image for a package icon" ,
272+ )
174273 args = parser .parse_args ()
175274
176275 repo_dir = args .repo_dir .expanduser ().resolve ()
@@ -181,6 +280,9 @@ def main() -> int:
181280 for package in packages :
182281 packages_by_id [package_identity (package )] = package
183282 prefix = args .artifact_prefix .rstrip ("/" )
283+ descriptions = dict (args .package_description )
284+ categories = dict (args .package_category )
285+ icons = dict (args .package_icon )
184286
185287 for spec in args .package :
186288 relpath = artifact_relpath (args .arch , args .chip , args .compat , spec )
@@ -201,6 +303,26 @@ def main() -> int:
201303 "sha256" : sha256_file (destination ),
202304 "type" : spec .payload_type ,
203305 }
306+
307+ if spec .name in descriptions :
308+ package ["description" ] = descriptions [spec .name ]
309+
310+ if spec .name in categories :
311+ package ["category" ] = categories [spec .name ]
312+
313+ if spec .name in icons :
314+ icon_bytes = encode_icon_rgb565 (icons [spec .name ])
315+ icon_dest = repo_dir / icon_relpath (
316+ args .arch , args .chip , args .compat , spec .name
317+ )
318+ icon_dest .parent .mkdir (parents = True , exist_ok = True )
319+ icon_dest .write_bytes (icon_bytes )
320+
321+ icon_rel = icon_relpath (
322+ args .arch , args .chip , args .compat , spec .name
323+ ).as_posix ()
324+ package ["icon" ] = f"{ prefix } /{ icon_rel } " if prefix else icon_rel
325+
204326 packages_by_id [package_identity (package )] = package
205327
206328 packages = sorted (
0 commit comments