tirtc-device-builder 0.3.0 → 0.5.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
@@ -24,7 +25,40 @@ VERIFICATION_LEVELS = {
24
25
  "hardware_verified": 4,
25
26
  "hil_verified": 5,
26
27
  }
27
- READY_STATUSES = {"READY_TO_PORT", "HIL_VERIFIED"}
28
+ ASSESSMENT_PHASES = {"intake", "build", "hil"}
29
+ PHASE_SUCCESS_STATUSES = {
30
+ "intake": {"READY_TO_PORT", "HIL_VERIFIED"},
31
+ "build": {"BUILD_VERIFIED", "HIL_VERIFIED"},
32
+ "hil": {"HIL_VERIFIED"},
33
+ }
34
+ VIDEO_CONTRACTS = {
35
+ "mjpeg": "jpeg_complete_frames",
36
+ "h264": "h264_annex_b_access_units",
37
+ "h265": "h265_annex_b_access_units",
38
+ }
39
+ WIFI_METHOD_TYPES = {
40
+ "softap",
41
+ "ble",
42
+ "smartconfig",
43
+ "factory_nvs",
44
+ "development_config",
45
+ "custom",
46
+ }
47
+ BINDING_METHOD_TYPES = {
48
+ "verification_code",
49
+ "factory_bound",
50
+ "development_credentials",
51
+ "custom",
52
+ }
53
+ ACCEPTANCE_LEVELS = {"L-1", "L0", "L1", "L2", "L3", "L4", "L5", "L6", "L7"}
54
+ FEATURE_HIL_LEVEL = {
55
+ "h5_live_audio": "L5",
56
+ "h5_live_video": "L5",
57
+ "h5_talkback": "L5",
58
+ "ai_talk": "L6",
59
+ }
60
+ SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
61
+ Requirement = tuple[str, str, int]
28
62
 
29
63
 
30
64
  def load_ir(path: Path) -> dict[str, Any]:
@@ -51,17 +85,32 @@ def nonempty_string(value: Any, path: str, errors: list[str]) -> None:
51
85
  errors.append(f"{path} must be a non-empty string")
52
86
 
53
87
 
88
+ def nullable_string(value: Any, path: str, errors: list[str]) -> None:
89
+ if value is not None and (not isinstance(value, str) or not value.strip()):
90
+ errors.append(f"{path} must be a non-empty string or null")
91
+
92
+
54
93
  def positive_int(value: Any, path: str, errors: list[str], allow_zero: bool = False) -> None:
55
94
  minimum = 0 if allow_zero else 1
56
95
  if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
57
96
  errors.append(f"{path} must be an integer >= {minimum}")
58
97
 
59
98
 
99
+ def nullable_positive_int(value: Any, path: str, errors: list[str]) -> None:
100
+ if value is not None:
101
+ positive_int(value, path, errors)
102
+
103
+
60
104
  def nullable_bool(value: Any, path: str, errors: list[str]) -> None:
61
105
  if value is not None and not isinstance(value, bool):
62
106
  errors.append(f"{path} must be true, false, or null")
63
107
 
64
108
 
109
+ def validate_verification(value: Any, path: str, errors: list[str]) -> None:
110
+ if value not in VERIFICATION_LEVELS:
111
+ errors.append(f"{path} must be one of " + ", ".join(VERIFICATION_LEVELS))
112
+
113
+
65
114
  def validate_source_refs(
66
115
  section: dict[str, Any], path: str, source_ids: set[str], errors: list[str]
67
116
  ) -> None:
@@ -90,18 +139,268 @@ def validate_codec_list(value: Any, path: str, errors: list[str]) -> None:
90
139
  else:
91
140
  for rate_index, rate in enumerate(rates):
92
141
  positive_int(rate, f"{prefix}.sample_rates_hz[{rate_index}]", errors)
93
- verification = codec.get("verification")
94
- if verification not in VERIFICATION_LEVELS:
142
+ validate_verification(codec.get("verification"), f"{prefix}.verification", errors)
143
+
144
+
145
+ def validate_video_profiles(
146
+ camera: dict[str, Any], source_ids: set[str], errors: list[str]
147
+ ) -> None:
148
+ nullable_string(
149
+ camera.get("selected_video_profile"),
150
+ "camera.selected_video_profile",
151
+ errors,
152
+ )
153
+ profiles = camera.get("video_profiles")
154
+ if not isinstance(profiles, list):
155
+ errors.append("camera.video_profiles must be an array")
156
+ return
157
+ ids: set[str] = set()
158
+ for index, item in enumerate(profiles):
159
+ prefix = f"camera.video_profiles[{index}]"
160
+ profile = mapping(item, prefix, errors)
161
+ nonempty_string(profile.get("id"), f"{prefix}.id", errors)
162
+ profile_id = profile.get("id")
163
+ if isinstance(profile_id, str) and profile_id:
164
+ if profile_id in ids:
165
+ errors.append(f"duplicate video profile id {profile_id!r}")
166
+ ids.add(profile_id)
167
+ codec = profile.get("codec")
168
+ if codec not in VIDEO_CONTRACTS:
95
169
  errors.append(
96
- f"{prefix}.verification must be one of "
97
- + ", ".join(VERIFICATION_LEVELS)
170
+ f"{prefix}.codec must be one of " + ", ".join(VIDEO_CONTRACTS)
98
171
  )
172
+ nullable_bool(profile.get("available"), f"{prefix}.available", errors)
173
+ nullable_string(profile.get("output_format"), f"{prefix}.output_format", errors)
174
+ nullable_bool(
175
+ profile.get("refresh_frame_control"),
176
+ f"{prefix}.refresh_frame_control",
177
+ errors,
178
+ )
179
+ nullable_positive_int(profile.get("stream_id"), f"{prefix}.stream_id", errors)
180
+ validate_verification(
181
+ profile.get("verification"), f"{prefix}.verification", errors
182
+ )
183
+ validate_source_refs(profile, prefix, source_ids, errors)
184
+ selected = camera.get("selected_video_profile")
185
+ if isinstance(selected, str) and selected and selected not in ids:
186
+ errors.append(
187
+ f"camera.selected_video_profile references unknown profile {selected!r}"
188
+ )
189
+
190
+
191
+ def validate_hardware_resources(
192
+ data: dict[str, Any], source_ids: set[str], errors: list[str]
193
+ ) -> None:
194
+ resources = mapping(data.get("hardware_resources"), "hardware_resources", errors)
195
+ validate_source_refs(resources, "hardware_resources", source_ids, errors)
196
+
197
+ i2c = mapping(resources.get("i2c"), "hardware_resources.i2c", errors)
198
+ nullable_bool(i2c.get("used"), "hardware_resources.i2c.used", errors)
199
+ driver_family = i2c.get("driver_family")
200
+ if driver_family not in {None, "legacy", "ng", "none"}:
201
+ errors.append(
202
+ "hardware_resources.i2c.driver_family must be legacy, ng, none, or null"
203
+ )
204
+ nullable_bool(
205
+ i2c.get("single_driver_family"),
206
+ "hardware_resources.i2c.single_driver_family",
207
+ errors,
208
+ )
209
+ validate_verification(
210
+ i2c.get("verification"), "hardware_resources.i2c.verification", errors
211
+ )
212
+
213
+ i2s = mapping(resources.get("i2s"), "hardware_resources.i2s", errors)
214
+ nullable_bool(i2s.get("used"), "hardware_resources.i2s.used", errors)
215
+ nullable_bool(
216
+ i2s.get("controller_and_gpio_ownership_resolved"),
217
+ "hardware_resources.i2s.controller_and_gpio_ownership_resolved",
218
+ errors,
219
+ )
220
+ validate_verification(
221
+ i2s.get("verification"), "hardware_resources.i2s.verification", errors
222
+ )
223
+
224
+ mapping_section = mapping(
225
+ resources.get("audio_channel_mapping"),
226
+ "hardware_resources.audio_channel_mapping",
227
+ errors,
228
+ )
229
+ nullable_bool(
230
+ mapping_section.get("required"),
231
+ "hardware_resources.audio_channel_mapping.required",
232
+ errors,
233
+ )
234
+ nullable_bool(
235
+ mapping_section.get("resolved"),
236
+ "hardware_resources.audio_channel_mapping.resolved",
237
+ errors,
238
+ )
239
+ validate_verification(
240
+ mapping_section.get("verification"),
241
+ "hardware_resources.audio_channel_mapping.verification",
242
+ errors,
243
+ )
244
+
245
+ realtime = mapping(
246
+ resources.get("camera_realtime"),
247
+ "hardware_resources.camera_realtime",
248
+ errors,
249
+ )
250
+ nullable_bool(
251
+ realtime.get("pipeline_safe"),
252
+ "hardware_resources.camera_realtime.pipeline_safe",
253
+ errors,
254
+ )
255
+ validate_verification(
256
+ realtime.get("verification"),
257
+ "hardware_resources.camera_realtime.verification",
258
+ errors,
259
+ )
260
+
261
+ memory = mapping(resources.get("memory"), "hardware_resources.memory", errors)
262
+ nullable_bool(
263
+ memory.get("startup_and_media_budgeted"),
264
+ "hardware_resources.memory.startup_and_media_budgeted",
265
+ errors,
266
+ )
267
+ validate_verification(
268
+ memory.get("verification"), "hardware_resources.memory.verification", errors
269
+ )
270
+
271
+
272
+ def validate_onboarding(
273
+ data: dict[str, Any], source_ids: set[str], errors: list[str]
274
+ ) -> None:
275
+ onboarding = mapping(data.get("onboarding"), "onboarding", errors)
276
+ validate_source_refs(onboarding, "onboarding", source_ids, errors)
277
+ wifi = mapping(onboarding.get("wifi_credentials"), "onboarding.wifi_credentials", errors)
278
+ nullable_string(
279
+ wifi.get("selected_method"), "onboarding.wifi_credentials.selected_method", errors
280
+ )
281
+ nullable_bool(
282
+ wifi.get("credentials_committed_to_source"),
283
+ "onboarding.wifi_credentials.credentials_committed_to_source",
284
+ errors,
285
+ )
286
+ nullable_bool(
287
+ wifi.get("reprovisioning_defined"),
288
+ "onboarding.wifi_credentials.reprovisioning_defined",
289
+ errors,
290
+ )
291
+ methods = wifi.get("methods")
292
+ method_ids: set[str] = set()
293
+ if not isinstance(methods, list):
294
+ errors.append("onboarding.wifi_credentials.methods must be an array")
295
+ else:
296
+ for index, item in enumerate(methods):
297
+ prefix = f"onboarding.wifi_credentials.methods[{index}]"
298
+ method = mapping(item, prefix, errors)
299
+ nonempty_string(method.get("id"), f"{prefix}.id", errors)
300
+ method_id = method.get("id")
301
+ if isinstance(method_id, str) and method_id:
302
+ if method_id in method_ids:
303
+ errors.append(f"duplicate Wi-Fi method id {method_id!r}")
304
+ method_ids.add(method_id)
305
+ method_type = method.get("type")
306
+ if method_type not in WIFI_METHOD_TYPES:
307
+ errors.append(
308
+ f"{prefix}.type must be one of " + ", ".join(sorted(WIFI_METHOD_TYPES))
309
+ )
310
+ nullable_bool(method.get("available"), f"{prefix}.available", errors)
311
+ validate_verification(
312
+ method.get("verification"), f"{prefix}.verification", errors
313
+ )
314
+ validate_source_refs(method, prefix, source_ids, errors)
315
+ selected = wifi.get("selected_method")
316
+ if isinstance(selected, str) and selected and selected not in method_ids:
317
+ errors.append(
318
+ f"onboarding.wifi_credentials.selected_method references unknown method {selected!r}"
319
+ )
320
+
321
+ binding = mapping(onboarding.get("device_binding"), "onboarding.device_binding", errors)
322
+ nullable_string(
323
+ binding.get("selected_method"),
324
+ "onboarding.device_binding.selected_method",
325
+ errors,
326
+ )
327
+ for field in (
328
+ "credentials_committed_to_source",
329
+ "stored_credential_state_handled",
330
+ "clear_binding_control",
331
+ ):
332
+ nullable_bool(binding.get(field), f"onboarding.device_binding.{field}", errors)
333
+ methods = binding.get("methods")
334
+ method_ids = set()
335
+ if not isinstance(methods, list):
336
+ errors.append("onboarding.device_binding.methods must be an array")
337
+ else:
338
+ for index, item in enumerate(methods):
339
+ prefix = f"onboarding.device_binding.methods[{index}]"
340
+ method = mapping(item, prefix, errors)
341
+ nonempty_string(method.get("id"), f"{prefix}.id", errors)
342
+ method_id = method.get("id")
343
+ if isinstance(method_id, str) and method_id:
344
+ if method_id in method_ids:
345
+ errors.append(f"duplicate binding method id {method_id!r}")
346
+ method_ids.add(method_id)
347
+ method_type = method.get("type")
348
+ if method_type not in BINDING_METHOD_TYPES:
349
+ errors.append(
350
+ f"{prefix}.type must be one of "
351
+ + ", ".join(sorted(BINDING_METHOD_TYPES))
352
+ )
353
+ nullable_bool(method.get("available"), f"{prefix}.available", errors)
354
+ validate_verification(
355
+ method.get("verification"), f"{prefix}.verification", errors
356
+ )
357
+ validate_source_refs(method, prefix, source_ids, errors)
358
+ selected = binding.get("selected_method")
359
+ if isinstance(selected, str) and selected and selected not in method_ids:
360
+ errors.append(
361
+ f"onboarding.device_binding.selected_method references unknown method {selected!r}"
362
+ )
363
+
364
+
365
+ def validate_runtime_evidence(
366
+ data: dict[str, Any], source_ids: set[str], errors: list[str]
367
+ ) -> None:
368
+ evidence = data.get("runtime_evidence")
369
+ if not isinstance(evidence, list):
370
+ errors.append("runtime_evidence must be an array")
371
+ return
372
+ for index, item in enumerate(evidence):
373
+ prefix = f"runtime_evidence[{index}]"
374
+ record = mapping(item, prefix, errors)
375
+ sha = record.get("artifact_sha256")
376
+ if not isinstance(sha, str) or not SHA256_RE.fullmatch(sha):
377
+ errors.append(f"{prefix}.artifact_sha256 must be a 64-character SHA-256")
378
+ levels = record.get("acceptance_levels")
379
+ if not isinstance(levels, list) or not levels:
380
+ errors.append(f"{prefix}.acceptance_levels must be a non-empty array")
381
+ else:
382
+ for level_index, level in enumerate(levels):
383
+ if level not in ACCEPTANCE_LEVELS:
384
+ errors.append(
385
+ f"{prefix}.acceptance_levels[{level_index}] must be a known acceptance level"
386
+ )
387
+ features = record.get("features")
388
+ if not isinstance(features, list) or not features:
389
+ errors.append(f"{prefix}.features must be a non-empty array")
390
+ else:
391
+ for feature_index, feature in enumerate(features):
392
+ if feature not in FEATURES:
393
+ errors.append(
394
+ f"{prefix}.features[{feature_index}] must be a known feature"
395
+ )
396
+ validate_source_refs(record, prefix, source_ids, errors)
99
397
 
100
398
 
101
399
  def validate_ir(data: dict[str, Any]) -> list[str]:
102
400
  errors: list[str] = []
103
- if data.get("schema_version") != 1:
104
- errors.append("schema_version must be 1")
401
+ schema_version = data.get("schema_version")
402
+ if schema_version not in {1, 2}:
403
+ errors.append("schema_version must be 1 or 2")
105
404
 
106
405
  board = mapping(data.get("board"), "board", errors)
107
406
  for key in ("id", "vendor", "model", "hardware_revision"):
@@ -135,12 +434,9 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
135
434
  nonempty_string(
136
435
  toolchain.get("framework_version"), "toolchain.framework_version", errors
137
436
  )
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
- )
437
+ validate_verification(
438
+ toolchain.get("verification"), "toolchain.verification", errors
439
+ )
144
440
  tirtc = mapping(toolchain.get("tirtc"), "toolchain.tirtc", errors)
145
441
  for key in ("platform", "version", "sdk_path", "build_contract"):
146
442
  nonempty_string(tirtc.get(key), f"toolchain.tirtc.{key}", errors)
@@ -148,17 +444,17 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
148
444
 
149
445
  camera = mapping(data.get("camera"), "camera", errors)
150
446
  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)
447
+ if schema_version == 1:
448
+ h264 = mapping(camera.get("h264"), "camera.h264", errors)
449
+ nullable_bool(h264.get("available"), "camera.h264.available", errors)
450
+ nullable_bool(
451
+ h264.get("key_frame_control"), "camera.h264.key_frame_control", errors
452
+ )
453
+ validate_verification(
454
+ h264.get("verification"), "camera.h264.verification", errors
161
455
  )
456
+ elif schema_version == 2:
457
+ validate_video_profiles(camera, source_ids, errors)
162
458
  validate_source_refs(camera, "camera", source_ids, errors)
163
459
 
164
460
  for name in ("audio_input", "audio_output"):
@@ -183,10 +479,23 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
183
479
  errors.append(f"features.requested contains duplicate {feature!r}")
184
480
  else:
185
481
  seen.add(feature)
482
+
483
+ if schema_version == 2:
484
+ validate_hardware_resources(data, source_ids, errors)
485
+ validate_onboarding(data, source_ids, errors)
486
+ validate_runtime_evidence(data, source_ids, errors)
186
487
  return errors
187
488
 
188
489
 
189
- def codec_requirement(media: dict[str, Any], section: str) -> tuple[str, str, int]:
490
+ def minimum_verification_for_phase(phase: str) -> str:
491
+ if phase == "intake":
492
+ return "corroborated"
493
+ return "build_verified"
494
+
495
+
496
+ def codec_requirement(
497
+ media: dict[str, Any], section: str, minimum_verification: str = "corroborated"
498
+ ) -> Requirement:
190
499
  present = media.get("present")
191
500
  if present is None:
192
501
  return "NEEDS_CONFIRMATION", f"{section} presence is unknown", 0
@@ -200,17 +509,20 @@ def codec_requirement(media: dict[str, Any], section: str) -> tuple[str, str, in
200
509
  continue
201
510
  verification = codec.get("verification")
202
511
  level = VERIFICATION_LEVELS.get(verification, 0)
203
- if level < VERIFICATION_LEVELS["corroborated"]:
512
+ if level < VERIFICATION_LEVELS[minimum_verification]:
204
513
  return (
205
514
  "NEEDS_CONFIRMATION",
206
- f"{section} A-law 8 kHz path is only {verification}",
515
+ f"{section} A-law 8 kHz path is only {verification}; "
516
+ f"{minimum_verification} is required",
207
517
  level,
208
518
  )
209
519
  return "SATISFIED", f"{section} provides A-law 8 kHz", level
210
520
  return "BLOCKED", f"{section} has no A-law 8 kHz path", 0
211
521
 
212
522
 
213
- def video_requirement(camera: dict[str, Any]) -> tuple[str, str, int]:
523
+ def legacy_video_requirement(
524
+ camera: dict[str, Any], minimum_verification: str = "corroborated"
525
+ ) -> Requirement:
214
526
  present = camera.get("present")
215
527
  if present is None:
216
528
  return "NEEDS_CONFIRMATION", "camera presence is unknown", 0
@@ -234,16 +546,260 @@ def video_requirement(camera: dict[str, Any]) -> tuple[str, str, int]:
234
546
  return "BLOCKED", "key-frame requests cannot reach the encoder", 0
235
547
  verification = h264.get("verification")
236
548
  level = VERIFICATION_LEVELS.get(verification, 0)
237
- if level < VERIFICATION_LEVELS["corroborated"]:
549
+ if level < VERIFICATION_LEVELS[minimum_verification]:
238
550
  return (
239
551
  "NEEDS_CONFIRMATION",
240
- f"H.264 Annex-B path is only {verification}",
552
+ f"H.264 Annex-B path is only {verification}; "
553
+ f"{minimum_verification} is required",
241
554
  level,
242
555
  )
243
556
  return "SATISFIED", "camera provides H.264 Annex-B and IDR control", level
244
557
 
245
558
 
246
- def combine_requirements(requirements: list[tuple[str, str, int]]) -> dict[str, Any]:
559
+ def selected_item(items: Any, selected_id: Any) -> dict[str, Any] | None:
560
+ if not isinstance(items, list) or not isinstance(selected_id, str):
561
+ return None
562
+ for item in items:
563
+ if isinstance(item, dict) and item.get("id") == selected_id:
564
+ return item
565
+ return None
566
+
567
+
568
+ def video_requirement_v2(
569
+ camera: dict[str, Any], minimum_verification: str = "corroborated"
570
+ ) -> Requirement:
571
+ present = camera.get("present")
572
+ if present is None:
573
+ return "NEEDS_CONFIRMATION", "camera presence is unknown", 0
574
+ if present is False:
575
+ return "BLOCKED", "camera is not present", 0
576
+ selected_id = camera.get("selected_video_profile")
577
+ if not isinstance(selected_id, str) or not selected_id:
578
+ return "NEEDS_CONFIRMATION", "selected video profile is unknown", 0
579
+ profile = selected_item(camera.get("video_profiles"), selected_id)
580
+ if profile is None:
581
+ return "BLOCKED", f"selected video profile {selected_id!r} does not exist", 0
582
+ codec = profile.get("codec")
583
+ if codec not in VIDEO_CONTRACTS:
584
+ return "BLOCKED", f"selected video codec {codec!r} is unsupported", 0
585
+ available = profile.get("available")
586
+ if available is None:
587
+ return "NEEDS_CONFIRMATION", f"{codec} path availability is unknown", 0
588
+ if available is False:
589
+ return "BLOCKED", f"selected {codec} path is unavailable", 0
590
+ output_format = profile.get("output_format")
591
+ if output_format is None:
592
+ return "NEEDS_CONFIRMATION", f"{codec} output format is unknown", 0
593
+ expected = VIDEO_CONTRACTS[codec]
594
+ if str(output_format).lower() != expected:
595
+ return (
596
+ "BLOCKED",
597
+ f"selected {codec} profile requires output_format={expected}",
598
+ 0,
599
+ )
600
+ refresh = profile.get("refresh_frame_control")
601
+ if refresh is None:
602
+ return "NEEDS_CONFIRMATION", "refresh/key-frame control is unknown", 0
603
+ if refresh is False:
604
+ return "BLOCKED", "H5 refresh requests cannot reach the media pipeline", 0
605
+ verification = profile.get("verification")
606
+ level = VERIFICATION_LEVELS.get(verification, 0)
607
+ if level < VERIFICATION_LEVELS[minimum_verification]:
608
+ return (
609
+ "NEEDS_CONFIRMATION",
610
+ f"selected {codec} path is only {verification}; "
611
+ f"{minimum_verification} is required",
612
+ level,
613
+ )
614
+ return (
615
+ "SATISFIED",
616
+ f"selected {codec} profile provides {expected} on stream {profile.get('stream_id')}",
617
+ level,
618
+ )
619
+
620
+
621
+ def verified_bool_requirement(
622
+ section: dict[str, Any],
623
+ field: str,
624
+ label: str,
625
+ minimum_verification: str = "corroborated",
626
+ ) -> Requirement:
627
+ value = section.get(field)
628
+ if value is None:
629
+ return "NEEDS_CONFIRMATION", f"{label} is unknown", 0
630
+ if value is False:
631
+ return "BLOCKED", f"{label} is unresolved", 0
632
+ verification = section.get("verification")
633
+ level = VERIFICATION_LEVELS.get(verification, 0)
634
+ if level < VERIFICATION_LEVELS[minimum_verification]:
635
+ return (
636
+ "NEEDS_CONFIRMATION",
637
+ f"{label} is only {verification}; {minimum_verification} is required",
638
+ level,
639
+ )
640
+ return "SATISFIED", f"{label} is resolved", level
641
+
642
+
643
+ def i2c_requirement(
644
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
645
+ ) -> Requirement:
646
+ i2c = resources.get("i2c", {})
647
+ used = i2c.get("used")
648
+ if used is None:
649
+ return "NEEDS_CONFIRMATION", "I2C usage is unknown", 0
650
+ if used is False:
651
+ return "SATISFIED", "board media path does not use I2C", 2
652
+ family = i2c.get("driver_family")
653
+ if family is None:
654
+ return "NEEDS_CONFIRMATION", "I2C driver family is unknown", 0
655
+ if family == "none":
656
+ return "BLOCKED", "I2C is used but no driver family is selected", 0
657
+ return verified_bool_requirement(
658
+ i2c,
659
+ "single_driver_family",
660
+ f"single {family} I2C driver family plan",
661
+ minimum_verification,
662
+ )
663
+
664
+
665
+ def i2s_requirement(
666
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
667
+ ) -> Requirement:
668
+ i2s = resources.get("i2s", {})
669
+ used = i2s.get("used")
670
+ if used is None:
671
+ return "NEEDS_CONFIRMATION", "I2S usage is unknown", 0
672
+ if used is False:
673
+ return "SATISFIED", "audio path does not use I2S", 2
674
+ return verified_bool_requirement(
675
+ i2s,
676
+ "controller_and_gpio_ownership_resolved",
677
+ "I2S controller and GPIO ownership plan",
678
+ minimum_verification,
679
+ )
680
+
681
+
682
+ def audio_mapping_requirement(
683
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
684
+ ) -> Requirement:
685
+ channel = resources.get("audio_channel_mapping", {})
686
+ required = channel.get("required")
687
+ if required is None:
688
+ return "NEEDS_CONFIRMATION", "audio channel/TDM mapping requirement is unknown", 0
689
+ if required is False:
690
+ return "SATISFIED", "audio channel/TDM mapping is not required", 2
691
+ return verified_bool_requirement(
692
+ channel,
693
+ "resolved",
694
+ "audio channel/TDM mapping",
695
+ minimum_verification,
696
+ )
697
+
698
+
699
+ def camera_realtime_requirement(
700
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
701
+ ) -> Requirement:
702
+ return verified_bool_requirement(
703
+ resources.get("camera_realtime", {}),
704
+ "pipeline_safe",
705
+ "camera DMA/task realtime policy design",
706
+ minimum_verification,
707
+ )
708
+
709
+
710
+ def memory_requirement(
711
+ resources: dict[str, Any], minimum_verification: str = "corroborated"
712
+ ) -> Requirement:
713
+ return verified_bool_requirement(
714
+ resources.get("memory", {}),
715
+ "startup_and_media_budgeted",
716
+ "static startup and media memory budget",
717
+ minimum_verification,
718
+ )
719
+
720
+
721
+ def wifi_requirement(onboarding: dict[str, Any]) -> Requirement:
722
+ wifi = onboarding.get("wifi_credentials", {})
723
+ committed = wifi.get("credentials_committed_to_source")
724
+ if committed is None:
725
+ return "NEEDS_CONFIRMATION", "credential source-control policy is unknown", 0
726
+ if committed is True:
727
+ return "BLOCKED", "Wi-Fi credentials are committed to source", 0
728
+ reprovisioning = wifi.get("reprovisioning_defined")
729
+ if reprovisioning is None:
730
+ return "NEEDS_CONFIRMATION", "Wi-Fi reprovisioning path is unknown", 0
731
+ if reprovisioning is False:
732
+ return "BLOCKED", "Wi-Fi reprovisioning path is undefined", 0
733
+ selected_id = wifi.get("selected_method")
734
+ if not isinstance(selected_id, str) or not selected_id:
735
+ return "NEEDS_CONFIRMATION", "Wi-Fi credential method is not selected", 0
736
+ method = selected_item(wifi.get("methods"), selected_id)
737
+ if method is None:
738
+ return "BLOCKED", f"selected Wi-Fi method {selected_id!r} does not exist", 0
739
+ available = method.get("available")
740
+ if available is None:
741
+ return "NEEDS_CONFIRMATION", "selected Wi-Fi method availability is unknown", 0
742
+ if available is False:
743
+ return "BLOCKED", "selected Wi-Fi method is unavailable", 0
744
+ verification = method.get("verification")
745
+ level = VERIFICATION_LEVELS.get(verification, 0)
746
+ if level < VERIFICATION_LEVELS["corroborated"]:
747
+ return (
748
+ "NEEDS_CONFIRMATION",
749
+ f"selected Wi-Fi method is only {verification}",
750
+ level,
751
+ )
752
+ return (
753
+ "SATISFIED",
754
+ f"Wi-Fi credentials use {method.get('type')} outside source control",
755
+ level,
756
+ )
757
+
758
+
759
+ def binding_requirement(onboarding: dict[str, Any]) -> Requirement:
760
+ binding = onboarding.get("device_binding", {})
761
+ committed = binding.get("credentials_committed_to_source")
762
+ if committed is None:
763
+ return "NEEDS_CONFIRMATION", "device credential source-control policy is unknown", 0
764
+ if committed is True:
765
+ return "BLOCKED", "device credentials are committed to source", 0
766
+ for field in ("stored_credential_state_handled", "clear_binding_control"):
767
+ value = binding.get(field)
768
+ if value is None:
769
+ return "NEEDS_CONFIRMATION", f"device binding {field} is unknown", 0
770
+ if value is False:
771
+ return "BLOCKED", f"device binding {field} is unsupported", 0
772
+ selected_id = binding.get("selected_method")
773
+ if not isinstance(selected_id, str) or not selected_id:
774
+ return "NEEDS_CONFIRMATION", "device binding method is not selected", 0
775
+ method = selected_item(binding.get("methods"), selected_id)
776
+ if method is None:
777
+ return "BLOCKED", f"selected binding method {selected_id!r} does not exist", 0
778
+ available = method.get("available")
779
+ if available is None:
780
+ return "NEEDS_CONFIRMATION", "selected binding method availability is unknown", 0
781
+ if available is False:
782
+ return "BLOCKED", "selected binding method is unavailable", 0
783
+ verification = method.get("verification")
784
+ level = VERIFICATION_LEVELS.get(verification, 0)
785
+ if level < VERIFICATION_LEVELS["corroborated"]:
786
+ return (
787
+ "NEEDS_CONFIRMATION",
788
+ f"selected binding method is only {verification}",
789
+ level,
790
+ )
791
+ return (
792
+ "SATISFIED",
793
+ f"device binding uses {method.get('type')} outside source control",
794
+ level,
795
+ )
796
+
797
+
798
+ def combine_requirements(
799
+ requirements: list[Requirement],
800
+ success_status: str = "READY_TO_PORT",
801
+ legacy_hil_from_levels: bool = False,
802
+ ) -> dict[str, Any]:
247
803
  reasons = [reason for _, reason, _ in requirements]
248
804
  states = {state for state, _, _ in requirements}
249
805
  levels = [level for state, _, level in requirements if state == "SATISFIED"]
@@ -251,15 +807,19 @@ def combine_requirements(requirements: list[tuple[str, str, int]]) -> dict[str,
251
807
  status = "BLOCKED"
252
808
  elif "NEEDS_CONFIRMATION" in states:
253
809
  status = "NEEDS_CONFIRMATION"
254
- elif levels and min(levels) >= VERIFICATION_LEVELS["hil_verified"]:
810
+ elif (
811
+ legacy_hil_from_levels
812
+ and levels
813
+ and min(levels) >= VERIFICATION_LEVELS["hil_verified"]
814
+ ):
255
815
  status = "HIL_VERIFIED"
256
816
  else:
257
- status = "READY_TO_PORT"
817
+ status = success_status
258
818
  return {"status": status, "reasons": reasons}
259
819
 
260
820
 
261
- def project_requirements(data: dict[str, Any]) -> list[tuple[str, str, int]]:
262
- requirements: list[tuple[str, str, int]] = []
821
+ def project_requirements(data: dict[str, Any]) -> list[Requirement]:
822
+ requirements: list[Requirement] = []
263
823
  revision = data["board"]["hardware_revision"].strip().lower()
264
824
  if revision in {"unknown", "unspecified", "n/a"}:
265
825
  requirements.append(
@@ -312,31 +872,195 @@ def project_requirements(data: dict[str, Any]) -> list[tuple[str, str, int]]:
312
872
  return requirements
313
873
 
314
874
 
315
- def assess_ir(data: dict[str, Any]) -> dict[str, Any]:
875
+ def matching_runtime_evidence(
876
+ data: dict[str, Any], artifact_sha256: str | None
877
+ ) -> dict[str, Any] | None:
878
+ if artifact_sha256 is None:
879
+ return None
880
+ normalized = artifact_sha256.lower()
881
+ for record in data.get("runtime_evidence", []):
882
+ if (
883
+ isinstance(record, dict)
884
+ and str(record.get("artifact_sha256", "")).lower() == normalized
885
+ ):
886
+ return record
887
+ return None
888
+
889
+
890
+ def artifact_requirement(artifact_sha256: str | None) -> Requirement:
891
+ if artifact_sha256 is None:
892
+ return "NEEDS_CONFIRMATION", "exact build artifact SHA-256 is missing", 0
893
+ if not SHA256_RE.fullmatch(artifact_sha256):
894
+ return "BLOCKED", "build artifact SHA-256 is invalid", 0
895
+ return (
896
+ "SATISFIED",
897
+ f"build evidence is bound to artifact {artifact_sha256}",
898
+ VERIFICATION_LEVELS["build_verified"],
899
+ )
900
+
901
+
902
+ def assess_ir(
903
+ data: dict[str, Any],
904
+ artifact_sha256: str | None = None,
905
+ phase: str | None = None,
906
+ ) -> dict[str, Any]:
907
+ selected_phase = phase or ("hil" if artifact_sha256 else "intake")
908
+ if selected_phase not in ASSESSMENT_PHASES:
909
+ raise ValueError(
910
+ f"assessment phase must be one of {', '.join(sorted(ASSESSMENT_PHASES))}"
911
+ )
912
+ minimum_verification = minimum_verification_for_phase(selected_phase)
913
+ success_status = (
914
+ "READY_TO_PORT" if selected_phase == "intake" else "BUILD_VERIFIED"
915
+ )
916
+ schema_version = data.get("schema_version")
316
917
  requested = data["features"]["requested"]
317
918
  audio_input = data["audio_input"]
318
919
  audio_output = data["audio_output"]
319
920
  camera = data["camera"]
320
921
  result: dict[str, Any] = {}
922
+ project = project_requirements(data)
923
+ selected_video: dict[str, Any] | None = None
924
+ selected_wifi: dict[str, Any] | None = None
925
+ selected_binding: dict[str, Any] | None = None
926
+
927
+ if schema_version == 2:
928
+ resources = data["hardware_resources"]
929
+ onboarding = data["onboarding"]
930
+ project.extend(
931
+ [
932
+ i2c_requirement(resources, minimum_verification),
933
+ wifi_requirement(onboarding),
934
+ binding_requirement(onboarding),
935
+ ]
936
+ )
937
+ selected_video = selected_item(
938
+ camera.get("video_profiles"), camera.get("selected_video_profile")
939
+ )
940
+ wifi = onboarding.get("wifi_credentials", {})
941
+ selected_wifi = selected_item(wifi.get("methods"), wifi.get("selected_method"))
942
+ binding = onboarding.get("device_binding", {})
943
+ selected_binding = selected_item(
944
+ binding.get("methods"), binding.get("selected_method")
945
+ )
946
+ else:
947
+ resources = {}
948
+
949
+ if selected_phase in {"build", "hil"}:
950
+ project.append(artifact_requirement(artifact_sha256))
951
+
321
952
  for feature in requested:
953
+ if schema_version == 1:
954
+ if feature == "h5_live_audio":
955
+ requirements = [
956
+ codec_requirement(
957
+ audio_input, "audio_input", minimum_verification
958
+ )
959
+ ]
960
+ elif feature == "h5_live_video":
961
+ requirements = [
962
+ legacy_video_requirement(camera, minimum_verification)
963
+ ]
964
+ elif feature == "h5_talkback":
965
+ requirements = [
966
+ codec_requirement(
967
+ audio_output, "audio_output", minimum_verification
968
+ )
969
+ ]
970
+ else:
971
+ requirements = [
972
+ codec_requirement(
973
+ audio_input, "audio_input", minimum_verification
974
+ ),
975
+ codec_requirement(
976
+ audio_output, "audio_output", minimum_verification
977
+ ),
978
+ ]
979
+ result[feature] = combine_requirements(
980
+ requirements,
981
+ success_status=success_status,
982
+ legacy_hil_from_levels=(selected_phase == "intake"),
983
+ )
984
+ continue
985
+
322
986
  if feature == "h5_live_audio":
323
- requirements = [codec_requirement(audio_input, "audio_input")]
987
+ requirements = [
988
+ codec_requirement(audio_input, "audio_input", minimum_verification),
989
+ i2s_requirement(resources, minimum_verification),
990
+ audio_mapping_requirement(resources, minimum_verification),
991
+ memory_requirement(resources, minimum_verification),
992
+ ]
324
993
  elif feature == "h5_live_video":
325
- requirements = [video_requirement(camera)]
994
+ requirements = [
995
+ video_requirement_v2(camera, minimum_verification),
996
+ camera_realtime_requirement(resources, minimum_verification),
997
+ memory_requirement(resources, minimum_verification),
998
+ ]
326
999
  elif feature == "h5_talkback":
327
- requirements = [codec_requirement(audio_output, "audio_output")]
1000
+ requirements = [
1001
+ codec_requirement(audio_output, "audio_output", minimum_verification),
1002
+ i2s_requirement(resources, minimum_verification),
1003
+ memory_requirement(resources, minimum_verification),
1004
+ ]
328
1005
  else:
329
1006
  requirements = [
330
- codec_requirement(audio_input, "audio_input"),
331
- codec_requirement(audio_output, "audio_output"),
1007
+ codec_requirement(audio_input, "audio_input", minimum_verification),
1008
+ codec_requirement(audio_output, "audio_output", minimum_verification),
1009
+ i2s_requirement(resources, minimum_verification),
1010
+ audio_mapping_requirement(resources, minimum_verification),
1011
+ memory_requirement(resources, minimum_verification),
332
1012
  ]
333
- result[feature] = combine_requirements(requirements)
334
- return {
1013
+ result[feature] = combine_requirements(
1014
+ requirements, success_status=success_status
1015
+ )
1016
+
1017
+ project_gate = combine_requirements(
1018
+ project,
1019
+ success_status=success_status,
1020
+ legacy_hil_from_levels=(schema_version == 1 and selected_phase == "intake"),
1021
+ )
1022
+ evidence = matching_runtime_evidence(data, artifact_sha256)
1023
+ if schema_version == 2 and selected_phase == "hil":
1024
+ evidence_features = set(evidence.get("features", [])) if evidence else set()
1025
+ evidence_levels = (
1026
+ set(evidence.get("acceptance_levels", [])) if evidence else set()
1027
+ )
1028
+ for feature, feature_assessment in result.items():
1029
+ if feature_assessment["status"] != "BUILD_VERIFIED":
1030
+ continue
1031
+ required_level = FEATURE_HIL_LEVEL[feature]
1032
+ if feature in evidence_features and required_level in evidence_levels:
1033
+ feature_assessment["status"] = "HIL_VERIFIED"
1034
+ feature_assessment["reasons"].append(
1035
+ f"artifact {artifact_sha256} passed {required_level}"
1036
+ )
1037
+ else:
1038
+ feature_assessment["reasons"].append(
1039
+ f"artifact {artifact_sha256} has no matching {required_level} runtime evidence"
1040
+ )
1041
+ if result and all(
1042
+ item["status"] == "HIL_VERIFIED" for item in result.values()
1043
+ ) and project_gate["status"] == "BUILD_VERIFIED":
1044
+ project_gate["status"] = "HIL_VERIFIED"
1045
+ project_gate["reasons"].append(
1046
+ f"all requested features have matching artifact evidence for {artifact_sha256}"
1047
+ )
1048
+
1049
+ assessment: dict[str, Any] = {
1050
+ "schema_version": schema_version,
1051
+ "phase": selected_phase,
335
1052
  "board_id": data["board"]["id"],
336
1053
  "hardware_revision": data["board"]["hardware_revision"],
337
- "project_gate": combine_requirements(project_requirements(data)),
1054
+ "project_gate": project_gate,
338
1055
  "features": result,
339
1056
  }
1057
+ if schema_version == 2:
1058
+ assessment["selected_video_profile"] = selected_video
1059
+ assessment["selected_wifi_method"] = selected_wifi
1060
+ assessment["selected_binding_method"] = selected_binding
1061
+ assessment["artifact_sha256"] = artifact_sha256
1062
+ assessment["artifact_evidence_matched"] = evidence is not None
1063
+ return assessment
340
1064
 
341
1065
 
342
1066
  def command_init(args: argparse.Namespace) -> int:
@@ -344,7 +1068,11 @@ def command_init(args: argparse.Namespace) -> int:
344
1068
  if output.exists():
345
1069
  print(f"refusing to overwrite existing file: {output}", file=sys.stderr)
346
1070
  return 2
347
- example = Path(__file__).resolve().parent.parent / "assets" / "hardware-ir.example.json"
1071
+ example = (
1072
+ Path(__file__).resolve().parent.parent
1073
+ / "assets"
1074
+ / "hardware-ir-v2.example.json"
1075
+ )
348
1076
  output.parent.mkdir(parents=True, exist_ok=True)
349
1077
  shutil.copyfile(example, output)
350
1078
  print(f"created Hardware IR: {output}")
@@ -377,14 +1105,26 @@ def command_assess(args: argparse.Namespace) -> int:
377
1105
  for error in errors:
378
1106
  print(f"error: {error}", file=sys.stderr)
379
1107
  return 2
380
- assessment = assess_ir(data)
1108
+ if args.artifact_sha256 is not None and not SHA256_RE.fullmatch(
1109
+ args.artifact_sha256
1110
+ ):
1111
+ print("error: --artifact-sha256 must be a 64-character SHA-256", file=sys.stderr)
1112
+ return 2
1113
+ try:
1114
+ assessment = assess_ir(
1115
+ data,
1116
+ artifact_sha256=args.artifact_sha256,
1117
+ phase=args.phase,
1118
+ )
1119
+ except ValueError as exc:
1120
+ print(f"error: {exc}", file=sys.stderr)
1121
+ return 2
381
1122
  print(json.dumps(assessment, ensure_ascii=False, indent=2))
382
1123
  if args.strict:
383
- statuses = {
384
- item["status"] for item in assessment["features"].values()
385
- }
1124
+ statuses = {item["status"] for item in assessment["features"].values()}
386
1125
  statuses.add(assessment["project_gate"]["status"])
387
- if not statuses.issubset(READY_STATUSES):
1126
+ allowed_statuses = PHASE_SUCCESS_STATUSES[assessment["phase"]]
1127
+ if not statuses.issubset(allowed_statuses):
388
1128
  return 3
389
1129
  return 0
390
1130
 
@@ -395,7 +1135,7 @@ def parse_args() -> argparse.Namespace:
395
1135
  )
396
1136
  subparsers = parser.add_subparsers(dest="command", required=True)
397
1137
 
398
- init_parser = subparsers.add_parser("init", help="create a new Hardware IR")
1138
+ init_parser = subparsers.add_parser("init", help="create a new Hardware IR v2")
399
1139
  init_parser.add_argument("output", type=Path)
400
1140
  init_parser.set_defaults(handler=command_init)
401
1141
 
@@ -410,7 +1150,16 @@ def parse_args() -> argparse.Namespace:
410
1150
  assess_parser.add_argument(
411
1151
  "--strict",
412
1152
  action="store_true",
413
- help="return non-zero unless every requested feature is ready or HIL verified",
1153
+ help="return non-zero unless every requested feature passes the selected phase",
1154
+ )
1155
+ assess_parser.add_argument(
1156
+ "--phase",
1157
+ choices=sorted(ASSESSMENT_PHASES),
1158
+ help="assessment phase; defaults to intake, or hil when an artifact SHA is supplied",
1159
+ )
1160
+ assess_parser.add_argument(
1161
+ "--artifact-sha256",
1162
+ help="bind build or HIL assessment to this exact firmware artifact",
414
1163
  )
415
1164
  assess_parser.set_defaults(handler=command_assess)
416
1165
  return parser.parse_args()