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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +632 -427
- package/package.json +1 -1
- package/skills/tirtc-esp32-builder/SKILL.md +12 -4
- package/skills/tirtc-esp32-builder/USAGE.md +19 -12
- package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +53 -0
- package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +122 -0
- package/skills/tirtc-esp32-builder/assets/report-template.md +15 -0
- package/skills/tirtc-esp32-builder/references/capability-rules.md +45 -16
- package/skills/tirtc-esp32-builder/references/hardware-ir.md +57 -24
- package/skills/tirtc-esp32-builder/references/porting-risks.md +49 -0
- package/skills/tirtc-esp32-builder/references/reporting.md +7 -1
- package/skills/tirtc-esp32-builder/references/workflow.md +18 -9
- package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +800 -51
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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}.
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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[
|
|
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
|
|
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[
|
|
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
|
|
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
|
|
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 =
|
|
817
|
+
status = success_status
|
|
258
818
|
return {"status": status, "reasons": reasons}
|
|
259
819
|
|
|
260
820
|
|
|
261
|
-
def project_requirements(data: dict[str, Any]) -> list[
|
|
262
|
-
requirements: list[
|
|
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
|
|
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 = [
|
|
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 = [
|
|
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 = [
|
|
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(
|
|
334
|
-
|
|
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":
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|