tirtc-device-builder 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import json
8
+ import re
8
9
  import shutil
9
10
  import sys
10
11
  from pathlib import Path
@@ -25,6 +26,34 @@ VERIFICATION_LEVELS = {
25
26
  "hil_verified": 5,
26
27
  }
27
28
  READY_STATUSES = {"READY_TO_PORT", "HIL_VERIFIED"}
29
+ VIDEO_CONTRACTS = {
30
+ "mjpeg": "jpeg_complete_frames",
31
+ "h264": "h264_annex_b_access_units",
32
+ "h265": "h265_annex_b_access_units",
33
+ }
34
+ WIFI_METHOD_TYPES = {
35
+ "softap",
36
+ "ble",
37
+ "smartconfig",
38
+ "factory_nvs",
39
+ "development_config",
40
+ "custom",
41
+ }
42
+ BINDING_METHOD_TYPES = {
43
+ "verification_code",
44
+ "factory_bound",
45
+ "development_credentials",
46
+ "custom",
47
+ }
48
+ ACCEPTANCE_LEVELS = {"L-1", "L0", "L1", "L2", "L3", "L4", "L5", "L6", "L7"}
49
+ FEATURE_HIL_LEVEL = {
50
+ "h5_live_audio": "L5",
51
+ "h5_live_video": "L5",
52
+ "h5_talkback": "L5",
53
+ "ai_talk": "L6",
54
+ }
55
+ SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
56
+ Requirement = tuple[str, str, int]
28
57
 
29
58
 
30
59
  def load_ir(path: Path) -> dict[str, Any]:
@@ -51,17 +80,32 @@ def nonempty_string(value: Any, path: str, errors: list[str]) -> None:
51
80
  errors.append(f"{path} must be a non-empty string")
52
81
 
53
82
 
83
+ def nullable_string(value: Any, path: str, errors: list[str]) -> None:
84
+ if value is not None and (not isinstance(value, str) or not value.strip()):
85
+ errors.append(f"{path} must be a non-empty string or null")
86
+
87
+
54
88
  def positive_int(value: Any, path: str, errors: list[str], allow_zero: bool = False) -> None:
55
89
  minimum = 0 if allow_zero else 1
56
90
  if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
57
91
  errors.append(f"{path} must be an integer >= {minimum}")
58
92
 
59
93
 
94
+ def nullable_positive_int(value: Any, path: str, errors: list[str]) -> None:
95
+ if value is not None:
96
+ positive_int(value, path, errors)
97
+
98
+
60
99
  def nullable_bool(value: Any, path: str, errors: list[str]) -> None:
61
100
  if value is not None and not isinstance(value, bool):
62
101
  errors.append(f"{path} must be true, false, or null")
63
102
 
64
103
 
104
+ def validate_verification(value: Any, path: str, errors: list[str]) -> None:
105
+ if value not in VERIFICATION_LEVELS:
106
+ errors.append(f"{path} must be one of " + ", ".join(VERIFICATION_LEVELS))
107
+
108
+
65
109
  def validate_source_refs(
66
110
  section: dict[str, Any], path: str, source_ids: set[str], errors: list[str]
67
111
  ) -> None:
@@ -90,18 +134,268 @@ def validate_codec_list(value: Any, path: str, errors: list[str]) -> None:
90
134
  else:
91
135
  for rate_index, rate in enumerate(rates):
92
136
  positive_int(rate, f"{prefix}.sample_rates_hz[{rate_index}]", errors)
93
- verification = codec.get("verification")
94
- if verification not in VERIFICATION_LEVELS:
137
+ validate_verification(codec.get("verification"), f"{prefix}.verification", errors)
138
+
139
+
140
+ def validate_video_profiles(
141
+ camera: dict[str, Any], source_ids: set[str], errors: list[str]
142
+ ) -> None:
143
+ nullable_string(
144
+ camera.get("selected_video_profile"),
145
+ "camera.selected_video_profile",
146
+ errors,
147
+ )
148
+ profiles = camera.get("video_profiles")
149
+ if not isinstance(profiles, list):
150
+ errors.append("camera.video_profiles must be an array")
151
+ return
152
+ ids: set[str] = set()
153
+ for index, item in enumerate(profiles):
154
+ prefix = f"camera.video_profiles[{index}]"
155
+ profile = mapping(item, prefix, errors)
156
+ nonempty_string(profile.get("id"), f"{prefix}.id", errors)
157
+ profile_id = profile.get("id")
158
+ if isinstance(profile_id, str) and profile_id:
159
+ if profile_id in ids:
160
+ errors.append(f"duplicate video profile id {profile_id!r}")
161
+ ids.add(profile_id)
162
+ codec = profile.get("codec")
163
+ if codec not in VIDEO_CONTRACTS:
95
164
  errors.append(
96
- f"{prefix}.verification must be one of "
97
- + ", ".join(VERIFICATION_LEVELS)
165
+ f"{prefix}.codec must be one of " + ", ".join(VIDEO_CONTRACTS)
98
166
  )
167
+ nullable_bool(profile.get("available"), f"{prefix}.available", errors)
168
+ nullable_string(profile.get("output_format"), f"{prefix}.output_format", errors)
169
+ nullable_bool(
170
+ profile.get("refresh_frame_control"),
171
+ f"{prefix}.refresh_frame_control",
172
+ errors,
173
+ )
174
+ nullable_positive_int(profile.get("stream_id"), f"{prefix}.stream_id", errors)
175
+ validate_verification(
176
+ profile.get("verification"), f"{prefix}.verification", errors
177
+ )
178
+ validate_source_refs(profile, prefix, source_ids, errors)
179
+ selected = camera.get("selected_video_profile")
180
+ if isinstance(selected, str) and selected and selected not in ids:
181
+ errors.append(
182
+ f"camera.selected_video_profile references unknown profile {selected!r}"
183
+ )
184
+
185
+
186
+ def validate_hardware_resources(
187
+ data: dict[str, Any], source_ids: set[str], errors: list[str]
188
+ ) -> None:
189
+ resources = mapping(data.get("hardware_resources"), "hardware_resources", errors)
190
+ validate_source_refs(resources, "hardware_resources", source_ids, errors)
191
+
192
+ i2c = mapping(resources.get("i2c"), "hardware_resources.i2c", errors)
193
+ nullable_bool(i2c.get("used"), "hardware_resources.i2c.used", errors)
194
+ driver_family = i2c.get("driver_family")
195
+ if driver_family not in {None, "legacy", "ng", "none"}:
196
+ errors.append(
197
+ "hardware_resources.i2c.driver_family must be legacy, ng, none, or null"
198
+ )
199
+ nullable_bool(
200
+ i2c.get("single_driver_family"),
201
+ "hardware_resources.i2c.single_driver_family",
202
+ errors,
203
+ )
204
+ validate_verification(
205
+ i2c.get("verification"), "hardware_resources.i2c.verification", errors
206
+ )
207
+
208
+ i2s = mapping(resources.get("i2s"), "hardware_resources.i2s", errors)
209
+ nullable_bool(i2s.get("used"), "hardware_resources.i2s.used", errors)
210
+ nullable_bool(
211
+ i2s.get("controller_and_gpio_ownership_resolved"),
212
+ "hardware_resources.i2s.controller_and_gpio_ownership_resolved",
213
+ errors,
214
+ )
215
+ validate_verification(
216
+ i2s.get("verification"), "hardware_resources.i2s.verification", errors
217
+ )
218
+
219
+ mapping_section = mapping(
220
+ resources.get("audio_channel_mapping"),
221
+ "hardware_resources.audio_channel_mapping",
222
+ errors,
223
+ )
224
+ nullable_bool(
225
+ mapping_section.get("required"),
226
+ "hardware_resources.audio_channel_mapping.required",
227
+ errors,
228
+ )
229
+ nullable_bool(
230
+ mapping_section.get("resolved"),
231
+ "hardware_resources.audio_channel_mapping.resolved",
232
+ errors,
233
+ )
234
+ validate_verification(
235
+ mapping_section.get("verification"),
236
+ "hardware_resources.audio_channel_mapping.verification",
237
+ errors,
238
+ )
239
+
240
+ realtime = mapping(
241
+ resources.get("camera_realtime"),
242
+ "hardware_resources.camera_realtime",
243
+ errors,
244
+ )
245
+ nullable_bool(
246
+ realtime.get("pipeline_safe"),
247
+ "hardware_resources.camera_realtime.pipeline_safe",
248
+ errors,
249
+ )
250
+ validate_verification(
251
+ realtime.get("verification"),
252
+ "hardware_resources.camera_realtime.verification",
253
+ errors,
254
+ )
255
+
256
+ memory = mapping(resources.get("memory"), "hardware_resources.memory", errors)
257
+ nullable_bool(
258
+ memory.get("startup_and_media_budgeted"),
259
+ "hardware_resources.memory.startup_and_media_budgeted",
260
+ errors,
261
+ )
262
+ validate_verification(
263
+ memory.get("verification"), "hardware_resources.memory.verification", errors
264
+ )
265
+
266
+
267
+ def validate_onboarding(
268
+ data: dict[str, Any], source_ids: set[str], errors: list[str]
269
+ ) -> None:
270
+ onboarding = mapping(data.get("onboarding"), "onboarding", errors)
271
+ validate_source_refs(onboarding, "onboarding", source_ids, errors)
272
+ wifi = mapping(onboarding.get("wifi_credentials"), "onboarding.wifi_credentials", errors)
273
+ nullable_string(
274
+ wifi.get("selected_method"), "onboarding.wifi_credentials.selected_method", errors
275
+ )
276
+ nullable_bool(
277
+ wifi.get("credentials_committed_to_source"),
278
+ "onboarding.wifi_credentials.credentials_committed_to_source",
279
+ errors,
280
+ )
281
+ nullable_bool(
282
+ wifi.get("reprovisioning_defined"),
283
+ "onboarding.wifi_credentials.reprovisioning_defined",
284
+ errors,
285
+ )
286
+ methods = wifi.get("methods")
287
+ method_ids: set[str] = set()
288
+ if not isinstance(methods, list):
289
+ errors.append("onboarding.wifi_credentials.methods must be an array")
290
+ else:
291
+ for index, item in enumerate(methods):
292
+ prefix = f"onboarding.wifi_credentials.methods[{index}]"
293
+ method = mapping(item, prefix, errors)
294
+ nonempty_string(method.get("id"), f"{prefix}.id", errors)
295
+ method_id = method.get("id")
296
+ if isinstance(method_id, str) and method_id:
297
+ if method_id in method_ids:
298
+ errors.append(f"duplicate Wi-Fi method id {method_id!r}")
299
+ method_ids.add(method_id)
300
+ method_type = method.get("type")
301
+ if method_type not in WIFI_METHOD_TYPES:
302
+ errors.append(
303
+ f"{prefix}.type must be one of " + ", ".join(sorted(WIFI_METHOD_TYPES))
304
+ )
305
+ nullable_bool(method.get("available"), f"{prefix}.available", errors)
306
+ validate_verification(
307
+ method.get("verification"), f"{prefix}.verification", errors
308
+ )
309
+ validate_source_refs(method, prefix, source_ids, errors)
310
+ selected = wifi.get("selected_method")
311
+ if isinstance(selected, str) and selected and selected not in method_ids:
312
+ errors.append(
313
+ f"onboarding.wifi_credentials.selected_method references unknown method {selected!r}"
314
+ )
315
+
316
+ binding = mapping(onboarding.get("device_binding"), "onboarding.device_binding", errors)
317
+ nullable_string(
318
+ binding.get("selected_method"),
319
+ "onboarding.device_binding.selected_method",
320
+ errors,
321
+ )
322
+ for field in (
323
+ "credentials_committed_to_source",
324
+ "stored_credential_state_handled",
325
+ "clear_binding_control",
326
+ ):
327
+ nullable_bool(binding.get(field), f"onboarding.device_binding.{field}", errors)
328
+ methods = binding.get("methods")
329
+ method_ids = set()
330
+ if not isinstance(methods, list):
331
+ errors.append("onboarding.device_binding.methods must be an array")
332
+ else:
333
+ for index, item in enumerate(methods):
334
+ prefix = f"onboarding.device_binding.methods[{index}]"
335
+ method = mapping(item, prefix, errors)
336
+ nonempty_string(method.get("id"), f"{prefix}.id", errors)
337
+ method_id = method.get("id")
338
+ if isinstance(method_id, str) and method_id:
339
+ if method_id in method_ids:
340
+ errors.append(f"duplicate binding method id {method_id!r}")
341
+ method_ids.add(method_id)
342
+ method_type = method.get("type")
343
+ if method_type not in BINDING_METHOD_TYPES:
344
+ errors.append(
345
+ f"{prefix}.type must be one of "
346
+ + ", ".join(sorted(BINDING_METHOD_TYPES))
347
+ )
348
+ nullable_bool(method.get("available"), f"{prefix}.available", errors)
349
+ validate_verification(
350
+ method.get("verification"), f"{prefix}.verification", errors
351
+ )
352
+ validate_source_refs(method, prefix, source_ids, errors)
353
+ selected = binding.get("selected_method")
354
+ if isinstance(selected, str) and selected and selected not in method_ids:
355
+ errors.append(
356
+ f"onboarding.device_binding.selected_method references unknown method {selected!r}"
357
+ )
358
+
359
+
360
+ def validate_runtime_evidence(
361
+ data: dict[str, Any], source_ids: set[str], errors: list[str]
362
+ ) -> None:
363
+ evidence = data.get("runtime_evidence")
364
+ if not isinstance(evidence, list):
365
+ errors.append("runtime_evidence must be an array")
366
+ return
367
+ for index, item in enumerate(evidence):
368
+ prefix = f"runtime_evidence[{index}]"
369
+ record = mapping(item, prefix, errors)
370
+ sha = record.get("artifact_sha256")
371
+ if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
372
+ errors.append(f"{prefix}.artifact_sha256 must be a 64-character SHA-256")
373
+ levels = record.get("acceptance_levels")
374
+ if not isinstance(levels, list) or not levels:
375
+ errors.append(f"{prefix}.acceptance_levels must be a non-empty array")
376
+ else:
377
+ for level_index, level in enumerate(levels):
378
+ if level not in ACCEPTANCE_LEVELS:
379
+ errors.append(
380
+ f"{prefix}.acceptance_levels[{level_index}] must be a known acceptance level"
381
+ )
382
+ features = record.get("features")
383
+ if not isinstance(features, list) or not features:
384
+ errors.append(f"{prefix}.features must be a non-empty array")
385
+ else:
386
+ for feature_index, feature in enumerate(features):
387
+ if feature not in FEATURES:
388
+ errors.append(
389
+ f"{prefix}.features[{feature_index}] must be a known feature"
390
+ )
391
+ validate_source_refs(record, prefix, source_ids, errors)
99
392
 
100
393
 
101
394
  def validate_ir(data: dict[str, Any]) -> list[str]:
102
395
  errors: list[str] = []
103
- if data.get("schema_version") != 1:
104
- errors.append("schema_version must be 1")
396
+ schema_version = data.get("schema_version")
397
+ if schema_version not in {1, 2}:
398
+ errors.append("schema_version must be 1 or 2")
105
399
 
106
400
  board = mapping(data.get("board"), "board", errors)
107
401
  for key in ("id", "vendor", "model", "hardware_revision"):
@@ -135,12 +429,9 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
135
429
  nonempty_string(
136
430
  toolchain.get("framework_version"), "toolchain.framework_version", errors
137
431
  )
138
- toolchain_verification = toolchain.get("verification")
139
- if toolchain_verification not in VERIFICATION_LEVELS:
140
- errors.append(
141
- "toolchain.verification must be one of "
142
- + ", ".join(VERIFICATION_LEVELS)
143
- )
432
+ validate_verification(
433
+ toolchain.get("verification"), "toolchain.verification", errors
434
+ )
144
435
  tirtc = mapping(toolchain.get("tirtc"), "toolchain.tirtc", errors)
145
436
  for key in ("platform", "version", "sdk_path", "build_contract"):
146
437
  nonempty_string(tirtc.get(key), f"toolchain.tirtc.{key}", errors)
@@ -148,17 +439,17 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
148
439
 
149
440
  camera = mapping(data.get("camera"), "camera", errors)
150
441
  nullable_bool(camera.get("present"), "camera.present", errors)
151
- h264 = mapping(camera.get("h264"), "camera.h264", errors)
152
- nullable_bool(h264.get("available"), "camera.h264.available", errors)
153
- nullable_bool(
154
- h264.get("key_frame_control"), "camera.h264.key_frame_control", errors
155
- )
156
- verification = h264.get("verification")
157
- if verification not in VERIFICATION_LEVELS:
158
- errors.append(
159
- "camera.h264.verification must be one of "
160
- + ", ".join(VERIFICATION_LEVELS)
442
+ if schema_version == 1:
443
+ h264 = mapping(camera.get("h264"), "camera.h264", errors)
444
+ nullable_bool(h264.get("available"), "camera.h264.available", errors)
445
+ nullable_bool(
446
+ h264.get("key_frame_control"), "camera.h264.key_frame_control", errors
161
447
  )
448
+ validate_verification(
449
+ h264.get("verification"), "camera.h264.verification", errors
450
+ )
451
+ elif schema_version == 2:
452
+ validate_video_profiles(camera, source_ids, errors)
162
453
  validate_source_refs(camera, "camera", source_ids, errors)
163
454
 
164
455
  for name in ("audio_input", "audio_output"):
@@ -183,10 +474,15 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
183
474
  errors.append(f"features.requested contains duplicate {feature!r}")
184
475
  else:
185
476
  seen.add(feature)
477
+
478
+ if schema_version == 2:
479
+ validate_hardware_resources(data, source_ids, errors)
480
+ validate_onboarding(data, source_ids, errors)
481
+ validate_runtime_evidence(data, source_ids, errors)
186
482
  return errors
187
483
 
188
484
 
189
- def codec_requirement(media: dict[str, Any], section: str) -> tuple[str, str, int]:
485
+ def codec_requirement(media: dict[str, Any], section: str) -> Requirement:
190
486
  present = media.get("present")
191
487
  if present is None:
192
488
  return "NEEDS_CONFIRMATION", f"{section} presence is unknown", 0
@@ -210,7 +506,7 @@ def codec_requirement(media: dict[str, Any], section: str) -> tuple[str, str, in
210
506
  return "BLOCKED", f"{section} has no A-law 8 kHz path", 0
211
507
 
212
508
 
213
- def video_requirement(camera: dict[str, Any]) -> tuple[str, str, int]:
509
+ def legacy_video_requirement(camera: dict[str, Any]) -> Requirement:
214
510
  present = camera.get("present")
215
511
  if present is None:
216
512
  return "NEEDS_CONFIRMATION", "camera presence is unknown", 0
@@ -243,7 +539,219 @@ def video_requirement(camera: dict[str, Any]) -> tuple[str, str, int]:
243
539
  return "SATISFIED", "camera provides H.264 Annex-B and IDR control", level
244
540
 
245
541
 
246
- def combine_requirements(requirements: list[tuple[str, str, int]]) -> dict[str, Any]:
542
+ def selected_item(items: Any, selected_id: Any) -> dict[str, Any] | None:
543
+ if not isinstance(items, list) or not isinstance(selected_id, str):
544
+ return None
545
+ for item in items:
546
+ if isinstance(item, dict) and item.get("id") == selected_id:
547
+ return item
548
+ return None
549
+
550
+
551
+ def video_requirement_v2(camera: dict[str, Any]) -> Requirement:
552
+ present = camera.get("present")
553
+ if present is None:
554
+ return "NEEDS_CONFIRMATION", "camera presence is unknown", 0
555
+ if present is False:
556
+ return "BLOCKED", "camera is not present", 0
557
+ selected_id = camera.get("selected_video_profile")
558
+ if not isinstance(selected_id, str) or not selected_id:
559
+ return "NEEDS_CONFIRMATION", "selected video profile is unknown", 0
560
+ profile = selected_item(camera.get("video_profiles"), selected_id)
561
+ if profile is None:
562
+ return "BLOCKED", f"selected video profile {selected_id!r} does not exist", 0
563
+ codec = profile.get("codec")
564
+ if codec not in VIDEO_CONTRACTS:
565
+ return "BLOCKED", f"selected video codec {codec!r} is unsupported", 0
566
+ available = profile.get("available")
567
+ if available is None:
568
+ return "NEEDS_CONFIRMATION", f"{codec} path availability is unknown", 0
569
+ if available is False:
570
+ return "BLOCKED", f"selected {codec} path is unavailable", 0
571
+ output_format = profile.get("output_format")
572
+ if output_format is None:
573
+ return "NEEDS_CONFIRMATION", f"{codec} output format is unknown", 0
574
+ expected = VIDEO_CONTRACTS[codec]
575
+ if str(output_format).lower() != expected:
576
+ return (
577
+ "BLOCKED",
578
+ f"selected {codec} profile requires output_format={expected}",
579
+ 0,
580
+ )
581
+ refresh = profile.get("refresh_frame_control")
582
+ if refresh is None:
583
+ return "NEEDS_CONFIRMATION", "refresh/key-frame control is unknown", 0
584
+ if refresh is False:
585
+ return "BLOCKED", "H5 refresh requests cannot reach the media pipeline", 0
586
+ verification = profile.get("verification")
587
+ level = VERIFICATION_LEVELS.get(verification, 0)
588
+ if level < VERIFICATION_LEVELS["corroborated"]:
589
+ return (
590
+ "NEEDS_CONFIRMATION",
591
+ f"selected {codec} path is only {verification}",
592
+ level,
593
+ )
594
+ return (
595
+ "SATISFIED",
596
+ f"selected {codec} profile provides {expected} on stream {profile.get('stream_id')}",
597
+ level,
598
+ )
599
+
600
+
601
+ def verified_bool_requirement(
602
+ section: dict[str, Any], field: str, label: str
603
+ ) -> Requirement:
604
+ value = section.get(field)
605
+ if value is None:
606
+ return "NEEDS_CONFIRMATION", f"{label} is unknown", 0
607
+ if value is False:
608
+ return "BLOCKED", f"{label} is unresolved", 0
609
+ verification = section.get("verification")
610
+ level = VERIFICATION_LEVELS.get(verification, 0)
611
+ if level < VERIFICATION_LEVELS["corroborated"]:
612
+ return "NEEDS_CONFIRMATION", f"{label} is only {verification}", level
613
+ return "SATISFIED", f"{label} is resolved", level
614
+
615
+
616
+ def i2c_requirement(resources: dict[str, Any]) -> Requirement:
617
+ i2c = resources.get("i2c", {})
618
+ used = i2c.get("used")
619
+ if used is None:
620
+ return "NEEDS_CONFIRMATION", "I2C usage is unknown", 0
621
+ if used is False:
622
+ return "SATISFIED", "board media path does not use I2C", 2
623
+ family = i2c.get("driver_family")
624
+ if family is None:
625
+ return "NEEDS_CONFIRMATION", "I2C driver family is unknown", 0
626
+ if family == "none":
627
+ return "BLOCKED", "I2C is used but no driver family is selected", 0
628
+ return verified_bool_requirement(
629
+ i2c, "single_driver_family", f"single {family} I2C driver family"
630
+ )
631
+
632
+
633
+ def i2s_requirement(resources: dict[str, Any]) -> Requirement:
634
+ i2s = resources.get("i2s", {})
635
+ used = i2s.get("used")
636
+ if used is None:
637
+ return "NEEDS_CONFIRMATION", "I2S usage is unknown", 0
638
+ if used is False:
639
+ return "SATISFIED", "audio path does not use I2S", 2
640
+ return verified_bool_requirement(
641
+ i2s,
642
+ "controller_and_gpio_ownership_resolved",
643
+ "I2S controller and GPIO ownership",
644
+ )
645
+
646
+
647
+ def audio_mapping_requirement(resources: dict[str, Any]) -> Requirement:
648
+ channel = resources.get("audio_channel_mapping", {})
649
+ required = channel.get("required")
650
+ if required is None:
651
+ return "NEEDS_CONFIRMATION", "audio channel/TDM mapping requirement is unknown", 0
652
+ if required is False:
653
+ return "SATISFIED", "audio channel/TDM mapping is not required", 2
654
+ return verified_bool_requirement(
655
+ channel, "resolved", "audio channel/TDM mapping"
656
+ )
657
+
658
+
659
+ def camera_realtime_requirement(resources: dict[str, Any]) -> Requirement:
660
+ return verified_bool_requirement(
661
+ resources.get("camera_realtime", {}),
662
+ "pipeline_safe",
663
+ "camera DMA/task realtime policy",
664
+ )
665
+
666
+
667
+ def memory_requirement(resources: dict[str, Any]) -> Requirement:
668
+ return verified_bool_requirement(
669
+ resources.get("memory", {}),
670
+ "startup_and_media_budgeted",
671
+ "startup and media memory budget",
672
+ )
673
+
674
+
675
+ def wifi_requirement(onboarding: dict[str, Any]) -> Requirement:
676
+ wifi = onboarding.get("wifi_credentials", {})
677
+ committed = wifi.get("credentials_committed_to_source")
678
+ if committed is None:
679
+ return "NEEDS_CONFIRMATION", "credential source-control policy is unknown", 0
680
+ if committed is True:
681
+ return "BLOCKED", "Wi-Fi credentials are committed to source", 0
682
+ reprovisioning = wifi.get("reprovisioning_defined")
683
+ if reprovisioning is None:
684
+ return "NEEDS_CONFIRMATION", "Wi-Fi reprovisioning path is unknown", 0
685
+ if reprovisioning is False:
686
+ return "BLOCKED", "Wi-Fi reprovisioning path is undefined", 0
687
+ selected_id = wifi.get("selected_method")
688
+ if not isinstance(selected_id, str) or not selected_id:
689
+ return "NEEDS_CONFIRMATION", "Wi-Fi credential method is not selected", 0
690
+ method = selected_item(wifi.get("methods"), selected_id)
691
+ if method is None:
692
+ return "BLOCKED", f"selected Wi-Fi method {selected_id!r} does not exist", 0
693
+ available = method.get("available")
694
+ if available is None:
695
+ return "NEEDS_CONFIRMATION", "selected Wi-Fi method availability is unknown", 0
696
+ if available is False:
697
+ return "BLOCKED", "selected Wi-Fi method is unavailable", 0
698
+ verification = method.get("verification")
699
+ level = VERIFICATION_LEVELS.get(verification, 0)
700
+ if level < VERIFICATION_LEVELS["corroborated"]:
701
+ return (
702
+ "NEEDS_CONFIRMATION",
703
+ f"selected Wi-Fi method is only {verification}",
704
+ level,
705
+ )
706
+ return (
707
+ "SATISFIED",
708
+ f"Wi-Fi credentials use {method.get('type')} outside source control",
709
+ level,
710
+ )
711
+
712
+
713
+ def binding_requirement(onboarding: dict[str, Any]) -> Requirement:
714
+ binding = onboarding.get("device_binding", {})
715
+ committed = binding.get("credentials_committed_to_source")
716
+ if committed is None:
717
+ return "NEEDS_CONFIRMATION", "device credential source-control policy is unknown", 0
718
+ if committed is True:
719
+ return "BLOCKED", "device credentials are committed to source", 0
720
+ for field in ("stored_credential_state_handled", "clear_binding_control"):
721
+ value = binding.get(field)
722
+ if value is None:
723
+ return "NEEDS_CONFIRMATION", f"device binding {field} is unknown", 0
724
+ if value is False:
725
+ return "BLOCKED", f"device binding {field} is unsupported", 0
726
+ selected_id = binding.get("selected_method")
727
+ if not isinstance(selected_id, str) or not selected_id:
728
+ return "NEEDS_CONFIRMATION", "device binding method is not selected", 0
729
+ method = selected_item(binding.get("methods"), selected_id)
730
+ if method is None:
731
+ return "BLOCKED", f"selected binding method {selected_id!r} does not exist", 0
732
+ available = method.get("available")
733
+ if available is None:
734
+ return "NEEDS_CONFIRMATION", "selected binding method availability is unknown", 0
735
+ if available is False:
736
+ return "BLOCKED", "selected binding method is unavailable", 0
737
+ verification = method.get("verification")
738
+ level = VERIFICATION_LEVELS.get(verification, 0)
739
+ if level < VERIFICATION_LEVELS["corroborated"]:
740
+ return (
741
+ "NEEDS_CONFIRMATION",
742
+ f"selected binding method is only {verification}",
743
+ level,
744
+ )
745
+ return (
746
+ "SATISFIED",
747
+ f"device binding uses {method.get('type')} outside source control",
748
+ level,
749
+ )
750
+
751
+
752
+ def combine_requirements(
753
+ requirements: list[Requirement], legacy_hil_from_levels: bool = False
754
+ ) -> dict[str, Any]:
247
755
  reasons = [reason for _, reason, _ in requirements]
248
756
  states = {state for state, _, _ in requirements}
249
757
  levels = [level for state, _, level in requirements if state == "SATISFIED"]
@@ -251,15 +759,19 @@ def combine_requirements(requirements: list[tuple[str, str, int]]) -> dict[str,
251
759
  status = "BLOCKED"
252
760
  elif "NEEDS_CONFIRMATION" in states:
253
761
  status = "NEEDS_CONFIRMATION"
254
- elif levels and min(levels) >= VERIFICATION_LEVELS["hil_verified"]:
762
+ elif (
763
+ legacy_hil_from_levels
764
+ and levels
765
+ and min(levels) >= VERIFICATION_LEVELS["hil_verified"]
766
+ ):
255
767
  status = "HIL_VERIFIED"
256
768
  else:
257
769
  status = "READY_TO_PORT"
258
770
  return {"status": status, "reasons": reasons}
259
771
 
260
772
 
261
- def project_requirements(data: dict[str, Any]) -> list[tuple[str, str, int]]:
262
- requirements: list[tuple[str, str, int]] = []
773
+ def project_requirements(data: dict[str, Any]) -> list[Requirement]:
774
+ requirements: list[Requirement] = []
263
775
  revision = data["board"]["hardware_revision"].strip().lower()
264
776
  if revision in {"unknown", "unspecified", "n/a"}:
265
777
  requirements.append(
@@ -312,31 +824,143 @@ def project_requirements(data: dict[str, Any]) -> list[tuple[str, str, int]]:
312
824
  return requirements
313
825
 
314
826
 
315
- def assess_ir(data: dict[str, Any]) -> dict[str, Any]:
827
+ def matching_runtime_evidence(
828
+ data: dict[str, Any], artifact_sha256: str | None
829
+ ) -> dict[str, Any] | None:
830
+ if artifact_sha256 is None:
831
+ return None
832
+ normalized = artifact_sha256.lower()
833
+ for record in data.get("runtime_evidence", []):
834
+ if (
835
+ isinstance(record, dict)
836
+ and str(record.get("artifact_sha256", "")).lower() == normalized
837
+ ):
838
+ return record
839
+ return None
840
+
841
+
842
+ def assess_ir(
843
+ data: dict[str, Any], artifact_sha256: str | None = None
844
+ ) -> dict[str, Any]:
845
+ schema_version = data.get("schema_version")
316
846
  requested = data["features"]["requested"]
317
847
  audio_input = data["audio_input"]
318
848
  audio_output = data["audio_output"]
319
849
  camera = data["camera"]
320
850
  result: dict[str, Any] = {}
851
+ project = project_requirements(data)
852
+ selected_video: dict[str, Any] | None = None
853
+ selected_wifi: dict[str, Any] | None = None
854
+ selected_binding: dict[str, Any] | None = None
855
+
856
+ if schema_version == 2:
857
+ resources = data["hardware_resources"]
858
+ onboarding = data["onboarding"]
859
+ project.extend(
860
+ [
861
+ i2c_requirement(resources),
862
+ wifi_requirement(onboarding),
863
+ binding_requirement(onboarding),
864
+ ]
865
+ )
866
+ selected_video = selected_item(
867
+ camera.get("video_profiles"), camera.get("selected_video_profile")
868
+ )
869
+ wifi = onboarding.get("wifi_credentials", {})
870
+ selected_wifi = selected_item(wifi.get("methods"), wifi.get("selected_method"))
871
+ binding = onboarding.get("device_binding", {})
872
+ selected_binding = selected_item(
873
+ binding.get("methods"), binding.get("selected_method")
874
+ )
875
+ else:
876
+ resources = {}
877
+
321
878
  for feature in requested:
879
+ if schema_version == 1:
880
+ if feature == "h5_live_audio":
881
+ requirements = [codec_requirement(audio_input, "audio_input")]
882
+ elif feature == "h5_live_video":
883
+ requirements = [legacy_video_requirement(camera)]
884
+ elif feature == "h5_talkback":
885
+ requirements = [codec_requirement(audio_output, "audio_output")]
886
+ else:
887
+ requirements = [
888
+ codec_requirement(audio_input, "audio_input"),
889
+ codec_requirement(audio_output, "audio_output"),
890
+ ]
891
+ result[feature] = combine_requirements(
892
+ requirements, legacy_hil_from_levels=True
893
+ )
894
+ continue
895
+
322
896
  if feature == "h5_live_audio":
323
- requirements = [codec_requirement(audio_input, "audio_input")]
897
+ requirements = [
898
+ codec_requirement(audio_input, "audio_input"),
899
+ i2s_requirement(resources),
900
+ audio_mapping_requirement(resources),
901
+ memory_requirement(resources),
902
+ ]
324
903
  elif feature == "h5_live_video":
325
- requirements = [video_requirement(camera)]
904
+ requirements = [
905
+ video_requirement_v2(camera),
906
+ camera_realtime_requirement(resources),
907
+ memory_requirement(resources),
908
+ ]
326
909
  elif feature == "h5_talkback":
327
- requirements = [codec_requirement(audio_output, "audio_output")]
910
+ requirements = [
911
+ codec_requirement(audio_output, "audio_output"),
912
+ i2s_requirement(resources),
913
+ memory_requirement(resources),
914
+ ]
328
915
  else:
329
916
  requirements = [
330
917
  codec_requirement(audio_input, "audio_input"),
331
918
  codec_requirement(audio_output, "audio_output"),
919
+ i2s_requirement(resources),
920
+ audio_mapping_requirement(resources),
921
+ memory_requirement(resources),
332
922
  ]
333
923
  result[feature] = combine_requirements(requirements)
334
- return {
924
+
925
+ project_gate = combine_requirements(
926
+ project, legacy_hil_from_levels=(schema_version == 1)
927
+ )
928
+ evidence = matching_runtime_evidence(data, artifact_sha256)
929
+ if schema_version == 2 and evidence is not None:
930
+ evidence_features = set(evidence.get("features", []))
931
+ evidence_levels = set(evidence.get("acceptance_levels", []))
932
+ for feature, assessment in result.items():
933
+ if (
934
+ assessment["status"] == "READY_TO_PORT"
935
+ and feature in evidence_features
936
+ and FEATURE_HIL_LEVEL[feature] in evidence_levels
937
+ ):
938
+ assessment["status"] = "HIL_VERIFIED"
939
+ assessment["reasons"].append(
940
+ f"artifact {artifact_sha256} passed {FEATURE_HIL_LEVEL[feature]}"
941
+ )
942
+ if result and all(
943
+ item["status"] == "HIL_VERIFIED" for item in result.values()
944
+ ):
945
+ project_gate["status"] = "HIL_VERIFIED"
946
+ project_gate["reasons"].append(
947
+ f"all requested features have matching artifact evidence for {artifact_sha256}"
948
+ )
949
+
950
+ assessment: dict[str, Any] = {
951
+ "schema_version": schema_version,
335
952
  "board_id": data["board"]["id"],
336
953
  "hardware_revision": data["board"]["hardware_revision"],
337
- "project_gate": combine_requirements(project_requirements(data)),
954
+ "project_gate": project_gate,
338
955
  "features": result,
339
956
  }
957
+ if schema_version == 2:
958
+ assessment["selected_video_profile"] = selected_video
959
+ assessment["selected_wifi_method"] = selected_wifi
960
+ assessment["selected_binding_method"] = selected_binding
961
+ assessment["artifact_sha256"] = artifact_sha256
962
+ assessment["artifact_evidence_matched"] = evidence is not None
963
+ return assessment
340
964
 
341
965
 
342
966
  def command_init(args: argparse.Namespace) -> int:
@@ -344,7 +968,11 @@ def command_init(args: argparse.Namespace) -> int:
344
968
  if output.exists():
345
969
  print(f"refusing to overwrite existing file: {output}", file=sys.stderr)
346
970
  return 2
347
- example = Path(__file__).resolve().parent.parent / "assets" / "hardware-ir.example.json"
971
+ example = (
972
+ Path(__file__).resolve().parent.parent
973
+ / "assets"
974
+ / "hardware-ir-v2.example.json"
975
+ )
348
976
  output.parent.mkdir(parents=True, exist_ok=True)
349
977
  shutil.copyfile(example, output)
350
978
  print(f"created Hardware IR: {output}")
@@ -377,12 +1005,10 @@ def command_assess(args: argparse.Namespace) -> int:
377
1005
  for error in errors:
378
1006
  print(f"error: {error}", file=sys.stderr)
379
1007
  return 2
380
- assessment = assess_ir(data)
1008
+ assessment = assess_ir(data, artifact_sha256=args.artifact_sha256)
381
1009
  print(json.dumps(assessment, ensure_ascii=False, indent=2))
382
1010
  if args.strict:
383
- statuses = {
384
- item["status"] for item in assessment["features"].values()
385
- }
1011
+ statuses = {item["status"] for item in assessment["features"].values()}
386
1012
  statuses.add(assessment["project_gate"]["status"])
387
1013
  if not statuses.issubset(READY_STATUSES):
388
1014
  return 3
@@ -395,7 +1021,7 @@ def parse_args() -> argparse.Namespace:
395
1021
  )
396
1022
  subparsers = parser.add_subparsers(dest="command", required=True)
397
1023
 
398
- init_parser = subparsers.add_parser("init", help="create a new Hardware IR")
1024
+ init_parser = subparsers.add_parser("init", help="create a new Hardware IR v2")
399
1025
  init_parser.add_argument("output", type=Path)
400
1026
  init_parser.set_defaults(handler=command_init)
401
1027
 
@@ -412,6 +1038,10 @@ def parse_args() -> argparse.Namespace:
412
1038
  action="store_true",
413
1039
  help="return non-zero unless every requested feature is ready or HIL verified",
414
1040
  )
1041
+ assess_parser.add_argument(
1042
+ "--artifact-sha256",
1043
+ help="bind HIL status to runtime evidence for this exact firmware artifact",
1044
+ )
415
1045
  assess_parser.set_defaults(handler=command_assess)
416
1046
  return parser.parse_args()
417
1047