Fix bidirectional RF-001A coordination
This commit is contained in:
737
configs/rf/rf001a_truth_metadata.json
Normal file
737
configs/rf/rf001a_truth_metadata.json
Normal file
@@ -0,0 +1,737 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"test_id": "RF001_QPSK_500K_R23_256B_100P_GAP5MS",
|
||||
"timestamp_utc": "2026-09-18T12:23:31.127702+00:00",
|
||||
"modulation": "QPSK",
|
||||
"symbol_rate": 500000,
|
||||
"sample_rate": 8000000,
|
||||
"fec_rate": "2/3",
|
||||
"digital_carrier_offset_hz": 125000.0,
|
||||
"application_payload_bytes": 256,
|
||||
"packet_count": 100,
|
||||
"inter_packet_gap_ms": 5,
|
||||
"inter_packet_gap_samples": 40000,
|
||||
"inter_array_zero_samples": 39872,
|
||||
"seed": 20260917,
|
||||
"iq_format": "c64le",
|
||||
"dtype": "<c8",
|
||||
"waveform_sample_count": 8833828,
|
||||
"waveform_duration_s": 1.1042285,
|
||||
"packet_waveform_start_samples": [
|
||||
0,
|
||||
88737,
|
||||
177474,
|
||||
266211,
|
||||
354948,
|
||||
443685,
|
||||
532422,
|
||||
621159,
|
||||
709896,
|
||||
798633,
|
||||
887370,
|
||||
976107,
|
||||
1064844,
|
||||
1153581,
|
||||
1242318,
|
||||
1331055,
|
||||
1419792,
|
||||
1508529,
|
||||
1597266,
|
||||
1686003,
|
||||
1774740,
|
||||
1863477,
|
||||
1952214,
|
||||
2040951,
|
||||
2129688,
|
||||
2218425,
|
||||
2307162,
|
||||
2395899,
|
||||
2484636,
|
||||
2573373,
|
||||
2662110,
|
||||
2750847,
|
||||
2839584,
|
||||
2928321,
|
||||
3017058,
|
||||
3105795,
|
||||
3194532,
|
||||
3283269,
|
||||
3372006,
|
||||
3460743,
|
||||
3549480,
|
||||
3638217,
|
||||
3726954,
|
||||
3815691,
|
||||
3904428,
|
||||
3993165,
|
||||
4081902,
|
||||
4170639,
|
||||
4259376,
|
||||
4348113,
|
||||
4436850,
|
||||
4525587,
|
||||
4614324,
|
||||
4703061,
|
||||
4791798,
|
||||
4880535,
|
||||
4969272,
|
||||
5058009,
|
||||
5146746,
|
||||
5235483,
|
||||
5324220,
|
||||
5412957,
|
||||
5501694,
|
||||
5590431,
|
||||
5679168,
|
||||
5767905,
|
||||
5856642,
|
||||
5945379,
|
||||
6034116,
|
||||
6122853,
|
||||
6211590,
|
||||
6300327,
|
||||
6389064,
|
||||
6477801,
|
||||
6566538,
|
||||
6655275,
|
||||
6744012,
|
||||
6832749,
|
||||
6921486,
|
||||
7010223,
|
||||
7098960,
|
||||
7187697,
|
||||
7276434,
|
||||
7365171,
|
||||
7453908,
|
||||
7542645,
|
||||
7631382,
|
||||
7720119,
|
||||
7808856,
|
||||
7897593,
|
||||
7986330,
|
||||
8075067,
|
||||
8163804,
|
||||
8252541,
|
||||
8341278,
|
||||
8430015,
|
||||
8518752,
|
||||
8607489,
|
||||
8696226,
|
||||
8784963
|
||||
],
|
||||
"expected_packet_hashes": [
|
||||
{
|
||||
"sequence_number": 0,
|
||||
"payload_sha256": "4e95e78189b1156739263a383aa26e568b369a24737b78f169a02d7182b66f0b",
|
||||
"packet_sha256": "e50f93a6389e4911db17eb822d583f06fae05df6708c64019de08c6ec6834960"
|
||||
},
|
||||
{
|
||||
"sequence_number": 1,
|
||||
"payload_sha256": "8d2104b86fe512059111192d7ff0bc089f1931a57756c8b930fa03c238352e75",
|
||||
"packet_sha256": "35c7fbd31f2452da87e40be898553a29d05ae1303ab7e9928d514ca7f94f19e9"
|
||||
},
|
||||
{
|
||||
"sequence_number": 2,
|
||||
"payload_sha256": "a1c20c33df93076fcb95cbfc1e5c6eeeb7c0b35733a4e28a8c7330c994850aca",
|
||||
"packet_sha256": "d672a74d80a3e16265bb4fe531579c907dcff464ae036e4cf129c3e22de8ec80"
|
||||
},
|
||||
{
|
||||
"sequence_number": 3,
|
||||
"payload_sha256": "d3ffdd0789e7bb3f7dba60b96a61175414912253001475d16ec6f837eb8b17a5",
|
||||
"packet_sha256": "7083d4c56b3cda44f9d34be4588809519dd65a4072dbfcc6d563470a2a527547"
|
||||
},
|
||||
{
|
||||
"sequence_number": 4,
|
||||
"payload_sha256": "03f62ee09971f903b3610e9b73a8484021e76d09a5758a4aafc0e951ca1a4399",
|
||||
"packet_sha256": "8e74c8ac395bfca64ad1a2b72f755ff909626735d375bdc75d12750dae79334f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 5,
|
||||
"payload_sha256": "a7915faacc162ae02f5b962eafb6ee221d4320e60a9a29f9aaafaa23358e76b7",
|
||||
"packet_sha256": "46d3e3d8b40beadb6a881c821c460e44a4cdc6e43ae2c94649677f7b26087617"
|
||||
},
|
||||
{
|
||||
"sequence_number": 6,
|
||||
"payload_sha256": "9484962525e16284e0a5efb2d1e3672c5ee688f7edce17c25d0eb4d2884eee5d",
|
||||
"packet_sha256": "3c72b11fc893d6b28ec966f0677897ffdb33da26c820fe29a5528be0535c8638"
|
||||
},
|
||||
{
|
||||
"sequence_number": 7,
|
||||
"payload_sha256": "b9476ffd787f7d88612de8763244274f5b89b0b3744dbac7d5286992de7be12c",
|
||||
"packet_sha256": "ed6a563a919e2ada004520042696bb2b18af80eb81afcb9c35ba0b4ce96b712e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 8,
|
||||
"payload_sha256": "1e8ce60401efc25916c78b94fd5ee1d5ad9f61fd4ca0f79377815bf7e2e8cdc0",
|
||||
"packet_sha256": "cc377708eae5a9da1fd7238f124b1a8658769ec8b19f405b9f3d902eece165f2"
|
||||
},
|
||||
{
|
||||
"sequence_number": 9,
|
||||
"payload_sha256": "1dd003103ed9065ad42ad177c6cdcb6abe95e1b6d85703a4b82a550d9fe1a5f8",
|
||||
"packet_sha256": "a11f702f42659771e907d969c53f6ae953ee8e7576ee0e5d29a1e9a95caeeccc"
|
||||
},
|
||||
{
|
||||
"sequence_number": 10,
|
||||
"payload_sha256": "09106da81d922fef36a31ba4632ec1bce31061f3b6da3322e6be0e1c4806f0c4",
|
||||
"packet_sha256": "cebbd0b3f5ba39659a8f784542f97b68d47ce271a7349e616ee453933bdf5fd0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 11,
|
||||
"payload_sha256": "ff2de9370b5817127586092275a38c8878693962ba91b7ff5836334e835020b8",
|
||||
"packet_sha256": "93d38f1a1f5d1b103e970d606424847838b2e8c3bcedbf538e963e369f6d05f5"
|
||||
},
|
||||
{
|
||||
"sequence_number": 12,
|
||||
"payload_sha256": "194d4eea8b148f8097e18882d97d1089b7e46896fe5bb98befa37ebb3c50aa29",
|
||||
"packet_sha256": "c1043bf2927f0c94afe3e5166e264eb38660dea6f3e62f715bd9eee7dc0f716c"
|
||||
},
|
||||
{
|
||||
"sequence_number": 13,
|
||||
"payload_sha256": "074c679bb903935c6bf5cc52c3ced97408376b1c7a0cc15eb72b514063bf4810",
|
||||
"packet_sha256": "55feddabdea9a8e154e787b587a1f1ce09b0a85d4b0cddd79bb09e721b7d66e1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 14,
|
||||
"payload_sha256": "257814be75560af99a9c4a8f63c01ab7c6ed12a13487cfd1d0f8c1c872ff9bb6",
|
||||
"packet_sha256": "6963cf0fa929c33bbf44fd44044d7cd70ca20ec3ceb5ac17f35b06baf6b290b3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 15,
|
||||
"payload_sha256": "9d2c9736b139bea03b80a21f2b6d7c8055e167ecdbea0e69f812b6cb3dd37fe4",
|
||||
"packet_sha256": "84537c730059fb93ce34ded7ca5b71f74db0055931132568233325fa6710cce8"
|
||||
},
|
||||
{
|
||||
"sequence_number": 16,
|
||||
"payload_sha256": "a6ee2a88458190abfbe8ebdfcc93d9346442ed8041c47d032d75eeae81d1a5e0",
|
||||
"packet_sha256": "2c6781c0fa023af965f994cf246d6138dd878235c96e0f1ed641bb9b631102bf"
|
||||
},
|
||||
{
|
||||
"sequence_number": 17,
|
||||
"payload_sha256": "421f076061c433b5515bc5eb42e06ed42355a46e5099c6caf9f364433f41b140",
|
||||
"packet_sha256": "fac6f86486df9d66931360844789e8d0f350b9ec0140e925b52eaec011252e27"
|
||||
},
|
||||
{
|
||||
"sequence_number": 18,
|
||||
"payload_sha256": "e93c614ff590771cba9d220c81f44c8ebdba3f7ed6406768648872b889050fb6",
|
||||
"packet_sha256": "8c20ec5bd30a1a8091e6b0b173d01ee0018b52017d32c62742434fa032c5fb98"
|
||||
},
|
||||
{
|
||||
"sequence_number": 19,
|
||||
"payload_sha256": "d14f3bfce872f3ae12d5b10c0861ff438c1dedefd652cb7762c94cdef48b052f",
|
||||
"packet_sha256": "f34f48c6a7327425f7fc42d244e65b57fc13dd5caf0079b37a94e76719a5e16f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 20,
|
||||
"payload_sha256": "54fa05ab31402bd66ccb9fd7bf130c167d2d11546bcb34530923fa14103f07f1",
|
||||
"packet_sha256": "4c44637f18540a4bc7eb765e0b331abcfd683ab21136d24522f91298e5329a38"
|
||||
},
|
||||
{
|
||||
"sequence_number": 21,
|
||||
"payload_sha256": "dcf67daa5a735ca45f4609f9981a00bab143f2fd5f7d1d5ea56240e1650ffade",
|
||||
"packet_sha256": "da583f81b26a0035fb829ec1707a33dddcca2662ddae9ca22d22bacaa4580cb6"
|
||||
},
|
||||
{
|
||||
"sequence_number": 22,
|
||||
"payload_sha256": "f0578ad0a65f0f9df231fd508d9f6f27fe0043e2720d1ba20a9927c730f0601f",
|
||||
"packet_sha256": "677b77e784fa91edc4c2896f9ca574bd9f3e71fc18e355082674200a7166d9c5"
|
||||
},
|
||||
{
|
||||
"sequence_number": 23,
|
||||
"payload_sha256": "12246760d0aa098ffe08470487f15d832ef6731dbce3ed9c4b1ba11db94f15c2",
|
||||
"packet_sha256": "c91339dfc311df65afbbb88b0f5f7ca06c1cbffb0e2b330bb3a4b4202586b308"
|
||||
},
|
||||
{
|
||||
"sequence_number": 24,
|
||||
"payload_sha256": "2d7d070265ae85f0014490a9b53046c143df8947f99161b6770619c34f6a69dc",
|
||||
"packet_sha256": "8bc0d37edd1b0e0fceefe84150fa240133221e75b4c7dd632a8211aaab27a2f2"
|
||||
},
|
||||
{
|
||||
"sequence_number": 25,
|
||||
"payload_sha256": "74a152e7fa829dfc448669927bf714c5b610947f7225bbc8bf5d491ed2a300ad",
|
||||
"packet_sha256": "9d2b1fe0c9d1722093944105bfe64d99109a595a6e4ce5504030f23f10f10d09"
|
||||
},
|
||||
{
|
||||
"sequence_number": 26,
|
||||
"payload_sha256": "7fb9a08bbd3fb7c8004bcb54ac76e016c37f5686f23c4f14bd3b6f6db7bfaa85",
|
||||
"packet_sha256": "6461326859169e27329f26c064a87445d33ceb7846368ae3d7b683d9aaec912b"
|
||||
},
|
||||
{
|
||||
"sequence_number": 27,
|
||||
"payload_sha256": "18d0572fc2bb366b04198f0c0f451ea79c06f93e8b729a6194db3ce950e35d0f",
|
||||
"packet_sha256": "627b69cd08eb2c539cee8b470ba7c007d78cfa9eb5464b91274ef6749a3bf24e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 28,
|
||||
"payload_sha256": "6ae0006e493efe63ba7f482832b32e59c1556eae2e845402e36685ea9d64f2eb",
|
||||
"packet_sha256": "64511c3982b1c77ec76d75e9036fbfb91fc378ea3275e420c66de5acba93a0c0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 29,
|
||||
"payload_sha256": "c8810ded095de421272cb2ac06d03a4dae07603c92aa54ed6f95c9e92b94aa4d",
|
||||
"packet_sha256": "5c363e0729272ef745715362825755ac4d5bf64e9ce6245a116226ab7a3860fb"
|
||||
},
|
||||
{
|
||||
"sequence_number": 30,
|
||||
"payload_sha256": "b738ff110f5bc52700094b698f2f814b59f62edd1553073634818cf41634a7b9",
|
||||
"packet_sha256": "69f0ce4e421548ffe79b5262eec9cf8c1f0e01e338e4a5c47c344c095203cc7e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 31,
|
||||
"payload_sha256": "f70565bb9395029c857ea85ebb9fbeca8c55f37cff2de52a43aa5dcaee49fce4",
|
||||
"packet_sha256": "826f2108ecd64fe1c4f7b750fcd6f0c0d586d9092e7a608b050ec9d298039291"
|
||||
},
|
||||
{
|
||||
"sequence_number": 32,
|
||||
"payload_sha256": "a6110fb9975df074ec37b653a288fbcc3356eb60460e9b7c77fab99cf519d54e",
|
||||
"packet_sha256": "35a4f770aeb60a0b269fd70b48c0856230a2661fc6f85ace9e3fe7ffeaaedd1b"
|
||||
},
|
||||
{
|
||||
"sequence_number": 33,
|
||||
"payload_sha256": "e6ee539b6824c69e0cfac0f166d8ca08bdfe1b72a4dc47bbdaf3c1bfdfd20af4",
|
||||
"packet_sha256": "7530f6f6480163162bb5c799f114b068ee3fa6e840d837b5d45f307dc78842b2"
|
||||
},
|
||||
{
|
||||
"sequence_number": 34,
|
||||
"payload_sha256": "3320f4036487dba8c882d2b2a3b0fc03375f803e73ee8ca4a170699bb2b49c73",
|
||||
"packet_sha256": "6a990db6b516d4c264b965d54851dbde6782a56b5b810996ffd04a6f8b58a200"
|
||||
},
|
||||
{
|
||||
"sequence_number": 35,
|
||||
"payload_sha256": "049538d4d23c057af2aa77989ff2260b72fd67920e864f6e7c7761500a777ae5",
|
||||
"packet_sha256": "4f247fe687e7a91ca3a7362e47098ffee42e351e0e346bed46c9c684ceb8935d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 36,
|
||||
"payload_sha256": "72102a33749d838277ea1e8243ebdf4ae30b6dbda739c334fdf11cf20d408724",
|
||||
"packet_sha256": "60d3b3d234ac875609f83497e1137c05579663a2d9d1ea0911309f5cfdfca12a"
|
||||
},
|
||||
{
|
||||
"sequence_number": 37,
|
||||
"payload_sha256": "fb8457ec7bcda3c70b61181c95c2d8a844e4b341f8d6d4f9533f594344952bd7",
|
||||
"packet_sha256": "2e5e79705c683ed6cec2b0705124a3a51d7c918cd5477334173378eed5ad72c9"
|
||||
},
|
||||
{
|
||||
"sequence_number": 38,
|
||||
"payload_sha256": "e21bd69eaa967ab61212dc57e211d76733099b2534d76591710eef5c4bbb68c0",
|
||||
"packet_sha256": "deb2bdd76ae1bdaab5cf45693cc913bf860720b8a6369ea6dab984398635c142"
|
||||
},
|
||||
{
|
||||
"sequence_number": 39,
|
||||
"payload_sha256": "e440830eb1dcb5e952ffd38a6c4a8aa7a455c73f483fd96487ca3b1b5814f74a",
|
||||
"packet_sha256": "9377bc52bb374a1f148e30b8a583b2504f5cd9754d79a488219ae6594add0d49"
|
||||
},
|
||||
{
|
||||
"sequence_number": 40,
|
||||
"payload_sha256": "ab22ef291546afad3c096faf4c11a917ba530b71063a74899e748e291ddc1888",
|
||||
"packet_sha256": "50a5d4f1af820acc6611ad05cca7e2a58743ffb75e2e21c5e537bcb4f01d1ca1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 41,
|
||||
"payload_sha256": "3a77bddd7867d943b8c70673f63e0b7dc45af645d82f27779ca3b716d4688772",
|
||||
"packet_sha256": "21192e0633a549a6863c1412050379a37f784c2850fe3b0fd9a6189e6fa04716"
|
||||
},
|
||||
{
|
||||
"sequence_number": 42,
|
||||
"payload_sha256": "349255af17303f3ad7de96079f17001db1120bdc1d015414ab1189450ca23867",
|
||||
"packet_sha256": "799d9e8af80186dd693bea85869ae8e05bbdbacb188fdbdcd355ee568563152e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 43,
|
||||
"payload_sha256": "70eb1217481f5a17dad4ba973f99ae149bf7d591b2739a2fe7a44b33946fc2de",
|
||||
"packet_sha256": "ece74d595de7a24e04d7c17afbdeef079337f316d6da6f9f0a7936a3a0ed2a51"
|
||||
},
|
||||
{
|
||||
"sequence_number": 44,
|
||||
"payload_sha256": "64127d2496375c8c508b39c0a80bff78eb26ed00c57d462de27b75dfb852f23f",
|
||||
"packet_sha256": "8ffcae35363d9f1ca2f6bfc02c0833cff9fad1e75312d587db44db6d290a316e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 45,
|
||||
"payload_sha256": "94e697b1bb63efb563fe8f23a7ac717bcbe55ec12b612b5946fa95a34804d2ad",
|
||||
"packet_sha256": "9a893918e5441504c77c49107975c34671e6ab572dedfa64b52d554b3b220057"
|
||||
},
|
||||
{
|
||||
"sequence_number": 46,
|
||||
"payload_sha256": "f0b9ffe40da709a93f71477c7e7ef7e5c0c7960a81249e73032e45b389b23a37",
|
||||
"packet_sha256": "ba04e7b50f0a2c3dce78694aa977d6e4f6a17842761c94b90395aa8dc136b57b"
|
||||
},
|
||||
{
|
||||
"sequence_number": 47,
|
||||
"payload_sha256": "2177b36f9aac5bfbef359e662f9c947c98f800526ffe9f99665912c303f22abb",
|
||||
"packet_sha256": "3da0bafcd46fc6a917505c5615c830dd27e4d3c58e97603d089c79282fc9f9c0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 48,
|
||||
"payload_sha256": "3aa5a3818488ea9cbf9fdcf655aaaa22dcf01d7b00ffcc5e127a9a2fd9332cd8",
|
||||
"packet_sha256": "2d2424d3024c65353da8065ba8a32e8f8a6a18633267a8b01e32cbe734e2d9dc"
|
||||
},
|
||||
{
|
||||
"sequence_number": 49,
|
||||
"payload_sha256": "365bef881aa8d57ddce1849485d33938f6ea85b9f75383c99eaf4fd95b4233b9",
|
||||
"packet_sha256": "471f68e3c998b8a65917b5e5ea978aa9e1fb1a8ba9586c3e10ba69ef67f5f373"
|
||||
},
|
||||
{
|
||||
"sequence_number": 50,
|
||||
"payload_sha256": "47043687b94a7c4abbe6fd405bcf92cc843cd0185bd1246b50e3678ded1331c3",
|
||||
"packet_sha256": "58610a2248c28fb815df0acba42d2de1d474f5bba54fb634572771e22bb841a5"
|
||||
},
|
||||
{
|
||||
"sequence_number": 51,
|
||||
"payload_sha256": "1cc173ed5a928de1a49017d88f233ed64c4212edeffb618dd99b03c7a06f9e69",
|
||||
"packet_sha256": "e91154fb19ca789296e09a1884243dda26a20ff6e964a99350b63a56f9900d6d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 52,
|
||||
"payload_sha256": "d073a047015bc48b7249996ac967cbe573e70d0a383685ecb5fd65ae0fa7f5d0",
|
||||
"packet_sha256": "4fabd2ece3ba7b35ce77ab185e5689d9a69ec25e97d25431ba06c4fe41ce3ca1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 53,
|
||||
"payload_sha256": "9bd5a56c09781c5358e2e0aad364a470667f519550f18eb38c7de982c1080280",
|
||||
"packet_sha256": "5a9fff30607546c10719fc8b2373751936d85c9cbb825351d40ff192c35e67c3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 54,
|
||||
"payload_sha256": "80673128b291980917d288d4909f2024f331bada7884dedcb38a3b25e115a4e2",
|
||||
"packet_sha256": "291d6753b048005cc93ff7916df1c702ceffe6ad34dd20824f9fc93c802839cc"
|
||||
},
|
||||
{
|
||||
"sequence_number": 55,
|
||||
"payload_sha256": "86a03ba0f472f0627123e22e11014f42b2f318cee7055b439e5ebca516061370",
|
||||
"packet_sha256": "038b17c9da0844d003d4780cd61217e904a51e113bf0705862c30285bf4c216d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 56,
|
||||
"payload_sha256": "1497fa9c6aae68d2a41256f9257c13264d50bd32dcc79b76c3be0fbff53d14f8",
|
||||
"packet_sha256": "203e9373cc6fa3d52ffcc4a9e3ce9675bf86f2e154bcba9217a302355819c6b1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 57,
|
||||
"payload_sha256": "acd390d05a2a51bdb14c74c3a26588e544af4167d3907b49a86897e8c5d82f22",
|
||||
"packet_sha256": "1af6cc447cc333df9af40721d7c126efb1fcb065bef62ba42f00ba2e812b320d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 58,
|
||||
"payload_sha256": "5366d7d4670582403cbd0e2f6fa12974384278b03ad2899b2c5e4b92ab32a564",
|
||||
"packet_sha256": "8c500c11f4f412bd2eaa86862398c4ccf9fc499c32a8717e5931df5b8c9e7836"
|
||||
},
|
||||
{
|
||||
"sequence_number": 59,
|
||||
"payload_sha256": "99a4565ae1db6cb094cbddc0adaa442bd9b0fa04e02115ac5f82b1ba0cb021f3",
|
||||
"packet_sha256": "1ae3de9c1f2a4ea0a97dce9df11929f82caf63e17e404498cb08b678acfaafaa"
|
||||
},
|
||||
{
|
||||
"sequence_number": 60,
|
||||
"payload_sha256": "1785424f2d9427a9c13bbdcaca2975f4ea2893daf6f3642deac0b7929acd7d4d",
|
||||
"packet_sha256": "662b48a8e61ada3671dfff3af459c5ee979849856be2602b4b5f85828b87e3f8"
|
||||
},
|
||||
{
|
||||
"sequence_number": 61,
|
||||
"payload_sha256": "1fb826298448fd4183522f7ccd3f45e9889ab78314ef78749cf9d679501d2efb",
|
||||
"packet_sha256": "73e2f50f4cf77ef91e0b374b68aa07bb7a7cc9601cfa5d0e19475bedda4a9847"
|
||||
},
|
||||
{
|
||||
"sequence_number": 62,
|
||||
"payload_sha256": "86b46d9e32a85309e34183b5fadb8e64030dc804c6ba54d685e32b2e367ec362",
|
||||
"packet_sha256": "32de517b56537ead8cb46eabdfcfa41bb3924528c5bc31dd1f04b9c8865246ee"
|
||||
},
|
||||
{
|
||||
"sequence_number": 63,
|
||||
"payload_sha256": "19cb46c3f3fbf8d804aa64f66031f4cb9c551dd198083394568ba12e7f4140e1",
|
||||
"packet_sha256": "964a64272e72c8377f10baf43845d1023e2f66363d10ea14861005e3496201d0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 64,
|
||||
"payload_sha256": "cce7d5957bf9573b07cd537ad1882e4f9ca8e40a06e7ee651b45eb1083ac062e",
|
||||
"packet_sha256": "2a9f1cd3eb0a6fd78a79ea5b7ee20eb90266967ad60519ffd52d379f27968cc5"
|
||||
},
|
||||
{
|
||||
"sequence_number": 65,
|
||||
"payload_sha256": "a3022b2ccdddfb3ae31666802fc50862d328b90fa3f1fd919a7ec282a803d01b",
|
||||
"packet_sha256": "9fb0676a2a05c48791b14dcac0fe694462123d07aa1f0539a25ae2c85edcb467"
|
||||
},
|
||||
{
|
||||
"sequence_number": 66,
|
||||
"payload_sha256": "d700f2d05d13f0397b8314a413b3bdeefdb29b19fe266ed6fadd7847c8d370a8",
|
||||
"packet_sha256": "4db24f6fcccde301f1a7f3c7f93a22f809b6304ecada8ff08964bf47f97c7d44"
|
||||
},
|
||||
{
|
||||
"sequence_number": 67,
|
||||
"payload_sha256": "a7c6780dfa088e82142e99839a3794d068e7dbed9c1c984ab89e1c18341aa165",
|
||||
"packet_sha256": "47eb548881c5c2bb7f4568ec803d62744041363a8df836ac24f212dd043b194d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 68,
|
||||
"payload_sha256": "4e94ad2174f8379eb311155969ef7619b862d6db93c0ce2b6b0b8222641869e0",
|
||||
"packet_sha256": "16d123eac8487f32dfbef2836b9ca28bf0bbbc9ec9cbde9473be3ad49dad8561"
|
||||
},
|
||||
{
|
||||
"sequence_number": 69,
|
||||
"payload_sha256": "19375b8ab0a37f5eff619977273c547c0698ed9264d91b725568bb4dd7ccb6c4",
|
||||
"packet_sha256": "e806ca6e9be15f5e5d3e9ab21ddfe5a81cba66c5dc40283e3543bbbb1de8ad23"
|
||||
},
|
||||
{
|
||||
"sequence_number": 70,
|
||||
"payload_sha256": "d60d45967a1dcf5188222ed928adb9afa9599b1c036db00c3b7c1ff2290edf30",
|
||||
"packet_sha256": "c542d6426d41d9245715b262c3353c05c3e2459f6e57b92b197d0e325b6f1a39"
|
||||
},
|
||||
{
|
||||
"sequence_number": 71,
|
||||
"payload_sha256": "408ab0b95a563333d7705a01201fba72079d78a840ac2c0aebc85a807fafc760",
|
||||
"packet_sha256": "1a40abde7d1ca18c86ccb8cad98005adb259a7262d04f655fb6b59f713339a90"
|
||||
},
|
||||
{
|
||||
"sequence_number": 72,
|
||||
"payload_sha256": "9960aee518000cf96d71300a043bb7826e611314dbfb41b544c64aa62eae8de1",
|
||||
"packet_sha256": "b96ec2a35c3f04a1eb1d11416b6bba6c464eb9223823bc90286e93496995aef2"
|
||||
},
|
||||
{
|
||||
"sequence_number": 73,
|
||||
"payload_sha256": "a0b867b3f9d3e09113a663acc1310c9245d49c985d624d6407f8553cc0f0a8eb",
|
||||
"packet_sha256": "4cd1b3313cd7f2270e083c16fe77f4ce5b843cc57834382e30d8fb3350eb6ac8"
|
||||
},
|
||||
{
|
||||
"sequence_number": 74,
|
||||
"payload_sha256": "6d8f21d468de22c1939ae0f72374cee7e505528e49c052fb5c2295574195c595",
|
||||
"packet_sha256": "1d3512901cb78072a773198320330ac528009c6aff7fb4f42fe10eeb2134a498"
|
||||
},
|
||||
{
|
||||
"sequence_number": 75,
|
||||
"payload_sha256": "918f46919ed53f70bd031a739886dc8b93b212e054388076076bb81624d9a4cb",
|
||||
"packet_sha256": "d423db049f9e9ef07dd9c358c3315239e745ab0f8bf6dee1c93ee027b63550f4"
|
||||
},
|
||||
{
|
||||
"sequence_number": 76,
|
||||
"payload_sha256": "c898050384f25ea8e8a06121ca9c870b21ffdf5d7d66941ea80f1f43cc7d6c48",
|
||||
"packet_sha256": "2ed7dbda8de3ad2b55fb5631aa0d6adb52e0e7a2e87b3ccbf4c4d14b93737224"
|
||||
},
|
||||
{
|
||||
"sequence_number": 77,
|
||||
"payload_sha256": "420551db636aa35fa85dae39b8166056a028338da82fdaeab3d2e2f90fb692f9",
|
||||
"packet_sha256": "ea584a2d123b4969df4bc52dd721f9983fa5a34c9e2a98a0b857a99ee7e7bae3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 78,
|
||||
"payload_sha256": "ff37d42b153f57cd46808c831894c180ec8710827cbe5ddd84891a349e78584e",
|
||||
"packet_sha256": "44f213f517d5ecf2b419426c14eaae5c03176485db73b78b29a145ad5c952a66"
|
||||
},
|
||||
{
|
||||
"sequence_number": 79,
|
||||
"payload_sha256": "795aa97056eb0c8b5e4134ba9204581c06a3f73eb72a26cfd52cdfba5c2d3ebd",
|
||||
"packet_sha256": "a7a4fff4bf520ebe57db314995036ce76eeb09a778ccedd5eec4fa4179782fc0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 80,
|
||||
"payload_sha256": "772225bc6d1c9d7da39b90cf545409d21b8a267ec3881abb8b4cf737dc8b5586",
|
||||
"packet_sha256": "c04ae816948e9f645dde14c688bf7a881fd761c12666169bf3cf944397d2ae1f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 81,
|
||||
"payload_sha256": "5d7b128857e4eea0290eb5748b26da0ff30136e2d2b0cbf6527b08cb84b576ee",
|
||||
"packet_sha256": "19d35293afa0ff3705dd9fe7dff134aec57655a2cfacfc10b3b56792488cc379"
|
||||
},
|
||||
{
|
||||
"sequence_number": 82,
|
||||
"payload_sha256": "6f13fedc3925d328f7aa448715f4f88bf7f8397430f015474d1ec0b8c2524f19",
|
||||
"packet_sha256": "7f5da368cdb8d93f01a743046842a94b779408bf03be46ea749b7d3d875efb68"
|
||||
},
|
||||
{
|
||||
"sequence_number": 83,
|
||||
"payload_sha256": "db4fae9f025383ee9fcb272bf591391be9a7511480a6155bd16368b2a7526da8",
|
||||
"packet_sha256": "bc5c2b1a99bb0f03281a6a8977fb91497c1fcb08f824fe37f3a5dc58272e135f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 84,
|
||||
"payload_sha256": "c7263f589c0b9a8bfed09664ba80c2c98bef86e4e190ecadf5a4764be50af600",
|
||||
"packet_sha256": "19e7ac7b9331adb5b9e58a91612203767498150463300b360e0ba603b20ca104"
|
||||
},
|
||||
{
|
||||
"sequence_number": 85,
|
||||
"payload_sha256": "8813e4ed36a33b415487386e94ee7e07bc33825b7516e662cf51f5b43f2f25a6",
|
||||
"packet_sha256": "9714f48963007f0802169f73e765a3d19d958d130ce82f022919aa1f29cf97e3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 86,
|
||||
"payload_sha256": "ae35ed2b855402a457dc0ebf13e9c6915a1964a87a9a6f58b678ada2549e36a0",
|
||||
"packet_sha256": "a76f078847dc4afb6bb6f7c55ae2c7d04770e2ea8d5ecf11cb9a1a72e2374efc"
|
||||
},
|
||||
{
|
||||
"sequence_number": 87,
|
||||
"payload_sha256": "1e508aacf860bfa4fa124c366386be546cebd62a375f14e771be08999506fe85",
|
||||
"packet_sha256": "5531ebb6a8138ee6b9f549be25ee2cf47c0c321bfe05eebb7232e611b6124fff"
|
||||
},
|
||||
{
|
||||
"sequence_number": 88,
|
||||
"payload_sha256": "96942711e476e7bdb2aa57c3845c4594825f4dd8b8fd8f3203c87acccdc6d527",
|
||||
"packet_sha256": "a23c2b138284b533fbbc7ff2350056c82878d92572e7b96ccdc60e28977858de"
|
||||
},
|
||||
{
|
||||
"sequence_number": 89,
|
||||
"payload_sha256": "e356c8ef03d32d28983c088d6c0bad625ca577351dd6e067763b212e817d75c4",
|
||||
"packet_sha256": "6f003c9766914ee11f6d4bd5cb8f80f05451e06f4ba21e589e7ee288ee0ec87f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 90,
|
||||
"payload_sha256": "bd6da495c09b3b533bd4e8c6f0ac5751c0b26174172fc5085e22b48f3b53a577",
|
||||
"packet_sha256": "5b2a1a43ebf039b998af09037edf1054d913dfa4d364fa502ac5e89279055fa7"
|
||||
},
|
||||
{
|
||||
"sequence_number": 91,
|
||||
"payload_sha256": "648b2da1112dcfeb229519fde8ef8034449937e7b42602588c223855382925be",
|
||||
"packet_sha256": "90a075a8c1804a32a42c5d0ebce27bc7dc3e23feb7e9d8cdffe3f3486ec5cf0f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 92,
|
||||
"payload_sha256": "669afb8bb74b9dbdf96708d2a48ee1fce635ae22294e06ed4500586e57213762",
|
||||
"packet_sha256": "2567a20ec8033be6286594ce3a542566866544bc083ad278f9340ca2b48aae24"
|
||||
},
|
||||
{
|
||||
"sequence_number": 93,
|
||||
"payload_sha256": "a2f6f9d77355b3a04c32d069af37031a06f2c484d58b51849bb55b3cdec572e5",
|
||||
"packet_sha256": "9192777c9fecb9f5ed4f065b7d5bfafcc41f6f76ac5ce742ccedc5b2a6f61de3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 94,
|
||||
"payload_sha256": "055d60647878f18901ec2a1e009363e3d5517fccbbe92fa2cd6b6feeadf58391",
|
||||
"packet_sha256": "b54786e36fd680a81066bddf3289f17aa95b57d46bf006cf08c1e07a7aaa5a27"
|
||||
},
|
||||
{
|
||||
"sequence_number": 95,
|
||||
"payload_sha256": "d10f469fbad708685b16c1ddcceca68a84f77a802b3ad04b0d394e45f754d0a2",
|
||||
"packet_sha256": "f37eb95b8e0344bedc4ad85436cbe4db62593dde31fdb3daf0284e49a2ac32c1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 96,
|
||||
"payload_sha256": "12a224b34c176ef7fed560197a39ad3bc448eee1467733f84250b29f3a06ce03",
|
||||
"packet_sha256": "fe3ce30a3d7649061f208a86c8ede5aac568803d079d8da1a0a5a11a962dd358"
|
||||
},
|
||||
{
|
||||
"sequence_number": 97,
|
||||
"payload_sha256": "db488bd0b7c4c38e5fce4a309d323955bd870a5b9947c753fd9065a0ce42b210",
|
||||
"packet_sha256": "a60f4e0dfe9b40ae18e03deb331022e53292368803bed492bc3069dbc564bb38"
|
||||
},
|
||||
{
|
||||
"sequence_number": 98,
|
||||
"payload_sha256": "3f12c8bc119739279484078bc5e17c39a7c36e4078ae55fc6a25aba0b7457348",
|
||||
"packet_sha256": "88e79568d2845aa646c91d1e211ee97e10c3a7cb43696607c6d56c7da0c5bb8b"
|
||||
},
|
||||
{
|
||||
"sequence_number": 99,
|
||||
"payload_sha256": "c91c2b855495e75e3751b1e4c3abc5525db4d7500b1845d0e9907d8f60ccb2dc",
|
||||
"packet_sha256": "ea7ce09526a21d432e7ae7a9007a9e69d69197f1a6b2c2668cd66c95c1e42688"
|
||||
}
|
||||
],
|
||||
"rf_tx": 0,
|
||||
"sdr_hardware": "NOT_ACCESSED",
|
||||
"status": "PREPARED_NOT_RUN",
|
||||
"iq_sha256": "7cd81395efaa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2",
|
||||
"shared_spec_test_id": "RF001A_COMMON_CLOCK_001",
|
||||
"payload_size": 256,
|
||||
"expected_sequence_numbers": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
34,
|
||||
35,
|
||||
36,
|
||||
37,
|
||||
38,
|
||||
39,
|
||||
40,
|
||||
41,
|
||||
42,
|
||||
43,
|
||||
44,
|
||||
45,
|
||||
46,
|
||||
47,
|
||||
48,
|
||||
49,
|
||||
50,
|
||||
51,
|
||||
52,
|
||||
53,
|
||||
54,
|
||||
55,
|
||||
56,
|
||||
57,
|
||||
58,
|
||||
59,
|
||||
60,
|
||||
61,
|
||||
62,
|
||||
63,
|
||||
64,
|
||||
65,
|
||||
66,
|
||||
67,
|
||||
68,
|
||||
69,
|
||||
70,
|
||||
71,
|
||||
72,
|
||||
73,
|
||||
74,
|
||||
75,
|
||||
76,
|
||||
77,
|
||||
78,
|
||||
79,
|
||||
80,
|
||||
81,
|
||||
82,
|
||||
83,
|
||||
84,
|
||||
85,
|
||||
86,
|
||||
87,
|
||||
88,
|
||||
89,
|
||||
90,
|
||||
91,
|
||||
92,
|
||||
93,
|
||||
94,
|
||||
95,
|
||||
96,
|
||||
97,
|
||||
98,
|
||||
99
|
||||
],
|
||||
"tx_waveform_sha256": "75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd",
|
||||
"tx_waveform_format": "cs8",
|
||||
"fec": "2/3",
|
||||
"gap_ms": 5,
|
||||
"source_provenance": "Generated from deterministic RF-001 seed and verified against configs/rf/rf001a_common_clock.json"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
TASK_ID = RF001A_OTA_CAPTURE_002
|
||||
TASK_ID = RF001A_OTA_CAPTURE_003
|
||||
TASK_TITLE = Start coordinated RF-001A common-clock OTA capture
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = 34f5ace54540f947311ada7e81a463c08abfd2d3
|
||||
EXPECTED_HEAD = bc55d0516dc1ee36cefa1ff096e7d86f60645303
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = YES
|
||||
RF_RX = YES
|
||||
@@ -11,7 +11,7 @@ ALLOW_PUSH = RX_RESULT_ONLY
|
||||
RISK_LEVEL = YELLOW
|
||||
|
||||
This file is the authoritative current task for the RX laptop.
|
||||
The RX assistant must read RX_NODE_STATE.md first, then execute this file.
|
||||
The RX assistant must read RX_NODE_STATE.md and RF_HANDOFF_CHANNEL_TEST.md first, then execute this file.
|
||||
If chat history conflicts with these files, the shared RF-001A spec or current Git HEAD, stop and report the conflict.
|
||||
|
||||
# Current RX task
|
||||
@@ -19,13 +19,25 @@ If chat history conflicts with these files, the shared RF-001A spec or current G
|
||||
TASK =
|
||||
START COORDINATED RF-001A COMMON-CLOCK OTA CAPTURE
|
||||
|
||||
This is a new YELLOW task. Do not reuse approval or completion state from RF001A_OTA_CAPTURE_001 or any earlier task.
|
||||
This is a new YELLOW task. Do not reuse approval or completion state from RF001A_OTA_CAPTURE_001, RF001A_OTA_CAPTURE_002 or any earlier task.
|
||||
|
||||
One approval for this task covers its read-only checks, bounded RF RX capture, and publication of the two small allowlisted result files to the existing rx-node branch. It never authorizes RF TX.
|
||||
One approval for this task covers the model-free channel test, read-only checks, one bounded RF RX capture and publication of the two small allowlisted result files to the existing rx-node branch. It never authorizes RF TX.
|
||||
|
||||
## 1. Preconditions
|
||||
The preceding RF001A_OTA_CAPTURE_002 produced a real 120.1413067 s, 1920000000-byte capture with CLKIN detected before and after, zero reported overruns and SHA256 99a3e7cb51d3ad4e3b914b63da0695ed6658aa169110c32659aba995b542fa0c. Its READY publication failed, its exact child exit code was not recorded and no synchronized truth metadata was available. Treat that as historical evidence only.
|
||||
|
||||
After the user chooses [A] Approve for this exact TASK_ID:
|
||||
## 1. Model-free handoff channel gate
|
||||
|
||||
After the user chooses [A] Approve for this exact TASK_ID, run before any hardware access:
|
||||
|
||||
python -B tools/rf_handoff_smoke.py --hold-seconds 7
|
||||
|
||||
This subtask uses TASK_ID RF_HANDOFF_CHANNEL_TEST_001. It accesses no SDR and keeps RF TX = 0. It publishes fake READY and COMPLETE results through the real origin of the main RX checkout. Require the MAIN watcher to observe both states, require confirmed remote commit hashes, and require COMPLETE to remain as the final origin/rx-node state. Require the MAIN watcher state to contain both channel_test_ready_digest and channel_test_complete_digest for this run.
|
||||
|
||||
If any channel check or publication fails, do not access HackRF, do not start capture and STOP with RF TX = 0.
|
||||
|
||||
## 2. Preconditions
|
||||
|
||||
After the same [A] approval for this exact TASK_ID and after the channel gate passes:
|
||||
|
||||
1. Run git pull --ff-only origin video-development.
|
||||
2. Require branch video-development.
|
||||
@@ -33,7 +45,7 @@ After the user chooses [A] Approve for this exact TASK_ID:
|
||||
4. Require local HEAD to equal origin/video-development and EXPECTED_HEAD to be its ancestor.
|
||||
5. Run hackrf_info and preserve complete stdout.
|
||||
6. Require RX serial 000000000000000097c063dc2e5327d3.
|
||||
7. Confirm that configs/rf/rf001a_common_clock.json exists and matches the fixed RF-001A frequency, sample rate, modulation and common-clock plan.
|
||||
7. Confirm that configs/rf/rf001a_common_clock.json and configs/rf/rf001a_truth_metadata.json exist. Require the tracked truth metadata test ID, seed, 100 packet hashes, sequence numbers, waveform SHA256 and PHY settings to agree with the shared RF-001A spec.
|
||||
8. With MASTER CLKOUT physically connected to SLAVE CLKIN, run:
|
||||
|
||||
hackrf_clock.exe -d 000000000000000097c063dc2e5327d3 -i
|
||||
@@ -42,13 +54,13 @@ After the user chooses [A] Approve for this exact TASK_ID:
|
||||
|
||||
CLKIN status: clock signal detected
|
||||
|
||||
10. Require the remote branch origin/rx-node to exist. Do not create it in this task.
|
||||
10. Require the remote branch origin/rx-node to exist through both git fetch origin rx-node and git ls-remote --heads origin rx-node. Do not create it in this task.
|
||||
11. Create a unique capture directory outside Git. Refuse an existing directory and never overwrite an earlier capture.
|
||||
12. Require at least 2500000000 free bytes on the target volume. Record the measured free-byte count.
|
||||
|
||||
If any condition fails, do not start capture. Report the failed condition, keep RF TX = 0, and STOP.
|
||||
|
||||
## 2. Authorized bounded RX capture
|
||||
## 3. Authorized bounded RX capture
|
||||
|
||||
Only this finite receive operation is authorized:
|
||||
|
||||
@@ -80,16 +92,16 @@ The hackrf_transfer.exe arguments are fixed:
|
||||
|
||||
Do not add repeat mode. Do not change gain, clock, frequency or sample rate.
|
||||
|
||||
## 3. Background runner and durable evidence
|
||||
## 4. Background runner and durable evidence
|
||||
|
||||
Use a local runner outside Git. The runner must:
|
||||
Use the tracked tools/capture_process_runner.ps1. Write the exact hackrf_transfer argument array to a unique local JSON file outside Git. Start the runner itself as a controlled background PowerShell process and pass unique paths for the capture, complete stdout, complete stderr, capture_started.json and capture_result.json. The runner must:
|
||||
|
||||
- start exactly one hackrf_transfer child;
|
||||
- record its PID and start time immediately;
|
||||
- record the expected end time as start time plus 120 seconds;
|
||||
- redirect complete stdout and stderr to unique local files;
|
||||
- wait for that exact child;
|
||||
- record its exact exit code, finish time, duration and output size;
|
||||
- wait for that exact child, refresh its process object and read ExitCode only after WaitForExit;
|
||||
- record its exact integer exit code, finish time, duration and output size;
|
||||
- calculate SHA256 after the file is closed;
|
||||
- write started and final JSON atomically;
|
||||
- never start a second capture automatically.
|
||||
@@ -103,13 +115,13 @@ Start the runner as a controlled background process. Within five seconds, requir
|
||||
|
||||
If any fact is false, publish no READY state. Preserve local evidence, report RX CAPTURE STARTED = NO, keep RF TX = 0, and STOP.
|
||||
|
||||
## 4. Publish READY immediately
|
||||
## 5. Publish READY immediately
|
||||
|
||||
As soon as the child is alive and the file is growing, build a local JSON file outside Git with every field below:
|
||||
|
||||
{
|
||||
"schema_version": 1,
|
||||
"task_id": "RF001A_OTA_CAPTURE_002",
|
||||
"task_id": "RF001A_OTA_CAPTURE_003",
|
||||
"state": "RX_READY_FOR_TX",
|
||||
"rx_ready_for_tx": true,
|
||||
"process_alive": true,
|
||||
@@ -142,14 +154,16 @@ The publisher may change only:
|
||||
docs/handoff/results/RX_RESULT.json
|
||||
docs/handoff/results/RX_RESULT.md
|
||||
|
||||
It uses a temporary checkout of the existing rx-node branch. It must not switch the source checkout, push video-development, or add raw IQ, video, waveform or large logs.
|
||||
The publisher checks origin/rx-node through the real origin of the main checkout. It creates a temporary Git worktree from that repository, stages exactly the two allowlisted result paths, pushes HEAD:rx-node, confirms the new remote HEAD with git ls-remote and removes the worktree. It must not switch the source checkout, push video-development, or add raw IQ, video, waveform or large logs.
|
||||
|
||||
If publication fails, show the complete error. Do not claim RX READY FOR TX and do not trigger TX automatically. The finite capture may finish normally, but RF TX remains 0.
|
||||
Use this order without exception: verify CLKIN, start capture, verify the child is alive, verify the file is growing, publish READY, verify RX RESULT REMOTE HEAD equals the published commit, and only then report RX READY FOR TX = YES.
|
||||
|
||||
If publication or remote confirmation fails, show the complete error and report RX READY FOR TX = NO. The finite capture may finish normally, but TX coordination does not start and RF TX remains 0.
|
||||
|
||||
After successful publication, immediately return control to the user without waiting for capture completion:
|
||||
|
||||
TASK_ID =
|
||||
RF001A_OTA_CAPTURE_002
|
||||
RF001A_OTA_CAPTURE_003
|
||||
|
||||
RX CAPTURE STARTED =
|
||||
YES
|
||||
@@ -168,14 +182,16 @@ After successful publication, immediately return control to the user without wai
|
||||
RX RESULT PUBLISH =
|
||||
PASS
|
||||
|
||||
RX RESULT REMOTE HEAD =
|
||||
|
||||
RF TX =
|
||||
0
|
||||
|
||||
The READY state describes a live receiver only. It does not authorize RF TX on either computer.
|
||||
|
||||
## 5. Capture completion
|
||||
## 6. Capture completion
|
||||
|
||||
After the child exits, read the durable final status. Preserve all local logs and inspect every line for RX overruns, USB errors and file write errors. Do not infer zero when a tool did not report a counter; use JSON null and report NOT_REPORTED.
|
||||
After the child exits, read capture_result.json and require an integer exit_code recorded by the runner. Preserve all local logs and inspect every line for RX overruns, USB errors and file write errors. Do not infer zero when a tool did not report a counter; use JSON null and report NOT_REPORTED.
|
||||
|
||||
Run the read-only clock check again:
|
||||
|
||||
@@ -193,7 +209,7 @@ Require CLKIN to remain detected. The expected complete capture has:
|
||||
|
||||
A shorter file is incomplete. Any reported nonzero error remains evidence. Do not retry automatically.
|
||||
|
||||
## 6. Publish final result
|
||||
## 7. Publish final result
|
||||
|
||||
Build a second local JSON document with the same identity and timing fields. Set:
|
||||
|
||||
@@ -219,17 +235,17 @@ Publish with:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --publish-result <final-json-path> --result-branch rx-node
|
||||
|
||||
If the push fails, preserve the local JSON and logs, report the error, and do not claim publication success.
|
||||
Require RX RESULT REMOTE HEAD to equal the final result commit. If the push or confirmation fails, preserve the local JSON and logs, report the error, and do not claim publication success.
|
||||
|
||||
## 7. Offline decode
|
||||
## 8. Offline decode
|
||||
|
||||
Do not start automatic decode unless synchronized truth metadata for this exact transmitted waveform is available and consistent. If it is absent, preserve the IQ file and report:
|
||||
Use the tracked synchronized truth artifact without copying TX files manually:
|
||||
|
||||
OFFLINE DECODE = BLOCKED_BY_TRUTH_METADATA
|
||||
python -B -X utf8 tools/qpsk_rf_rx.py --iq-file <capture-path> --iq-format cs8 --truth-metadata configs/rf/rf001a_truth_metadata.json --report <unique-path-outside-git>\rf001a_ota_decode.json
|
||||
|
||||
Do not modify the receiver or invent results.
|
||||
Report exact packets, missing packets, duplicates, BER and post-FEC errors from the generated report. Preserve a decoder failure as evidence; do not modify the receiver or invent results.
|
||||
|
||||
## 8. Prohibited actions
|
||||
## 9. Prohibited actions
|
||||
|
||||
On the RX laptop, do not perform:
|
||||
|
||||
@@ -244,10 +260,12 @@ On the RX laptop, do not perform:
|
||||
- creation of rx-node;
|
||||
- publication of raw IQ, video, waveform or large logs.
|
||||
|
||||
## 9. Final RX report
|
||||
## 10. Final RX report
|
||||
|
||||
TASK_ID =
|
||||
RF001A_OTA_CAPTURE_002
|
||||
RF001A_OTA_CAPTURE_003
|
||||
|
||||
CHANNEL TEST =
|
||||
|
||||
RX CAPTURE COMPLETE =
|
||||
|
||||
@@ -271,8 +289,20 @@ On the RX laptop, do not perform:
|
||||
|
||||
FINAL RX RESULT PUBLISH =
|
||||
|
||||
FINAL RX RESULT REMOTE HEAD =
|
||||
|
||||
OFFLINE DECODE =
|
||||
|
||||
EXACT PACKETS =
|
||||
|
||||
MISSING =
|
||||
|
||||
DUPLICATES =
|
||||
|
||||
BER =
|
||||
|
||||
POST-FEC ERRORS =
|
||||
|
||||
RF TX =
|
||||
0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
TASK_ID = RF001A_OTA_TX_002
|
||||
TASK_ID = RF001A_OTA_TX_003
|
||||
TASK_TITLE = Transmit one coordinated RF-001A QPSK burst
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = 34f5ace54540f947311ada7e81a463c08abfd2d3
|
||||
EXPECTED_HEAD = bc55d0516dc1ee36cefa1ff096e7d86f60645303
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = YES
|
||||
RF_RX = NO
|
||||
@@ -18,7 +18,7 @@ Read TX_NODE_STATE.md first. If chat history, this file, the shared RF-001A spec
|
||||
TASK =
|
||||
TRANSMIT ONE COORDINATED RF-001A QPSK BURST
|
||||
|
||||
This is a new RED task. Publication of this file is not permission to transmit. It does not inherit any approval from RF001A_OTA_TX_001, the RX task, a previous watcher prompt or a previous RF run.
|
||||
This is a new RED task. Publication of this file is not permission to transmit. It does not inherit any approval from RF001A_OTA_TX_001, RF001A_OTA_TX_002, the RX task, a previous watcher prompt or a previous RF run.
|
||||
|
||||
## 1. Coordination gate
|
||||
|
||||
@@ -28,7 +28,7 @@ Start the local model-free watcher before the RX task begins:
|
||||
|
||||
The watcher polls origin/rx-node every 5 seconds and accepts only a strict result for:
|
||||
|
||||
RX TASK_ID = RF001A_OTA_CAPTURE_002
|
||||
RX TASK_ID = RF001A_OTA_CAPTURE_003
|
||||
STATE = RX_READY_FOR_TX
|
||||
RX_READY_FOR_TX = YES
|
||||
PROCESS_ALIVE = YES
|
||||
@@ -36,7 +36,7 @@ The watcher polls origin/rx-node every 5 seconds and accepts only a strict resul
|
||||
COMMON_CLOCK_VERIFIED = YES
|
||||
RF_TX = 0
|
||||
|
||||
The READY publication must be no more than 30 seconds old. Its planned capture window must be 120 seconds. At least 10 seconds must remain.
|
||||
The watcher records RF_HANDOFF_CHANNEL_TEST_001 READY and COMPLETE states without prompting for RED approval and never treats them as production readiness. A production READY publication must be no more than 30 seconds old. Its planned capture window must be 120 seconds. At least 10 seconds must remain. COMMON_CLOCK_VERIFIED must be YES, source_head must equal the expected video-development HEAD, and the result commit must be the current valid origin/rx-node HEAD.
|
||||
|
||||
When a valid new READY result appears, the watcher prints:
|
||||
|
||||
@@ -47,7 +47,7 @@ When a valid new READY result appears, the watcher prints:
|
||||
CAPTURE END EXPECTED =
|
||||
|
||||
TX TASK =
|
||||
RF001A_OTA_TX_002
|
||||
RF001A_OTA_TX_003
|
||||
|
||||
RF TX REQUIRES RED APPROVAL
|
||||
|
||||
@@ -56,7 +56,7 @@ When a valid new READY result appears, the watcher prints:
|
||||
|
||||
Without the exact phrase AUTHORIZE RF TX, do not transmit.
|
||||
|
||||
After the phrase, the watcher fetches rx-node again and repeats all READY, freshness and capture-window checks. If the result changed or less than 10 seconds remain, it refuses authorization and requires a new RX capture.
|
||||
After the phrase, the watcher fetches rx-node again and repeats the result commit, digest, task ID, source HEAD, READY, freshness, common-clock and capture-window checks. If the result changed or less than 10 seconds remain, it refuses authorization and requires a new RX capture.
|
||||
|
||||
The watcher never opens HackRF hardware. On success it writes one local authorization record under:
|
||||
|
||||
@@ -103,7 +103,7 @@ Before the watcher can request RED approval:
|
||||
|
||||
10. Verify the actual cs8 bytes through the existing offline receiver:
|
||||
|
||||
python -B -X utf8 tools/qpsk_rf_rx.py --iq-file data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8 --iq-format cs8 --truth-metadata data/raw/rf001/rf001a_common_clock_001/tx_metadata.json --report <new-path-outside-git>\rf001a_tx_offline_verify.json
|
||||
python -B -X utf8 tools/qpsk_rf_rx.py --iq-file data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8 --iq-format cs8 --truth-metadata configs/rf/rf001a_truth_metadata.json --report <new-path-outside-git>\rf001a_tx_offline_verify.json
|
||||
|
||||
Require:
|
||||
|
||||
@@ -112,7 +112,7 @@ Require:
|
||||
offline hardware-format roundtrip = 100/100 exact
|
||||
TX waveform path = data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8
|
||||
TX waveform size = 17667656 bytes
|
||||
TX waveform SHA256 = 75f3fa39bfa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2
|
||||
TX waveform SHA256 = 75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd
|
||||
sample count = 8833828 complex samples
|
||||
expected duration = 1.1042285 s
|
||||
|
||||
@@ -122,9 +122,9 @@ If any value differs, do not transmit.
|
||||
|
||||
The controlled TX executor must read the authorization record immediately before TX and require:
|
||||
|
||||
- task_id = RF001A_OTA_TX_002;
|
||||
- task_id = RF001A_OTA_TX_003;
|
||||
- authorization = AUTHORIZE RF TX;
|
||||
- rx_task_id = RF001A_OTA_CAPTURE_002;
|
||||
- rx_task_id = RF001A_OTA_CAPTURE_003;
|
||||
- dry_run = false;
|
||||
- action = MANUAL_EXECUTOR_GATE_OPEN;
|
||||
- rf_tx_started = false;
|
||||
@@ -165,7 +165,7 @@ Set RF TX STOPPED = YES only after the finite process exits. Never retry automat
|
||||
## 6. Final TX report
|
||||
|
||||
TASK_ID =
|
||||
RF001A_OTA_TX_002
|
||||
RF001A_OTA_TX_003
|
||||
|
||||
RED AUTHORIZATION =
|
||||
AUTHORIZE RF TX / MISSING
|
||||
|
||||
41
docs/handoff/RF_HANDOFF_CHANNEL_TEST.md
Normal file
41
docs/handoff/RF_HANDOFF_CHANNEL_TEST.md
Normal file
@@ -0,0 +1,41 @@
|
||||
TASK_ID = RF_HANDOFF_CHANNEL_TEST_001
|
||||
TASK_TITLE = Verify the bidirectional Git handoff channel without SDR
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = bc55d0516dc1ee36cefa1ff096e7d86f60645303
|
||||
REQUIRES_APPROVAL = NO
|
||||
HARDWARE_ACCESS = NO
|
||||
RF_RX = NO
|
||||
RF_TX = NO
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = RX_RESULT_ONLY
|
||||
RISK_LEVEL = GREEN
|
||||
|
||||
# Model-free handoff channel test
|
||||
|
||||
Run this gate before RF001A_OTA_CAPTURE_003 and while the MAIN watcher is active:
|
||||
|
||||
python -B tools/rf_handoff_smoke.py --hold-seconds 7
|
||||
|
||||
The script uses the real origin configured in the main RX checkout. It publishes a synthetic READY result to the existing origin/rx-node branch, waits seven seconds, then publishes a synthetic COMPLETE result. Both publications may change only:
|
||||
|
||||
docs/handoff/results/RX_RESULT.json
|
||||
docs/handoff/results/RX_RESULT.md
|
||||
|
||||
The MAIN watcher must observe both states and must not request RED approval. Its state file must record both channel_test_ready_digest and channel_test_complete_digest. The final remote state must be CAPTURE_COMPLETE, with rx_ready_for_tx false. A synthetic READY state must not remain active.
|
||||
|
||||
The script and watcher do not access SDR hardware, do not run hackrf_transfer and do not authorize any RF transmission.
|
||||
|
||||
Required report:
|
||||
|
||||
TASK_ID = RF_HANDOFF_CHANNEL_TEST_001
|
||||
READY PUBLISH = PASS / FAIL
|
||||
READY REMOTE HEAD =
|
||||
MAIN WATCHER RECEIVES READY = PASS / FAIL
|
||||
COMPLETE PUBLISH = PASS / FAIL
|
||||
COMPLETE REMOTE HEAD =
|
||||
MAIN WATCHER RECEIVES COMPLETE = PASS / FAIL
|
||||
FINAL REMOTE STATE = CAPTURE_COMPLETE / OTHER
|
||||
REAL RX = NOT_RUN
|
||||
RF TX = 0
|
||||
|
||||
If any field fails, do not start the hardware RX task. STOP.
|
||||
@@ -10,7 +10,7 @@ The reverse result channel uses the separate origin/rx-node branch. The RX lapto
|
||||
|
||||
docs/handoff/NEXT_RX_TASK.md starts with these plain key-value fields:
|
||||
|
||||
TASK_ID = RF001A_OTA_CAPTURE_002
|
||||
TASK_ID = RF001A_OTA_CAPTURE_003
|
||||
TASK_TITLE = Start coordinated RF-001A common-clock OTA capture
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = <full 40-character base commit hash>
|
||||
@@ -69,7 +69,7 @@ After the task actually finishes, mark the local ID complete:
|
||||
|
||||
The coordinated RF-001A result is a strict JSON object. Unknown or missing keys are rejected. READY requires:
|
||||
|
||||
- task_id = RF001A_OTA_CAPTURE_002;
|
||||
- task_id = RF001A_OTA_CAPTURE_003;
|
||||
- state = RX_READY_FOR_TX;
|
||||
- rx_ready_for_tx, process_alive, file_growing and common_clock_verified all true;
|
||||
- a positive capture_pid;
|
||||
@@ -105,12 +105,12 @@ The publisher checks:
|
||||
6. origin/rx-node already exists;
|
||||
7. the JSON schema is valid.
|
||||
|
||||
It clones only the existing result branch into a temporary directory, writes exactly:
|
||||
It checks the branch with git fetch and git ls-remote through the real origin configured in the main checkout. It creates a temporary worktree from that repository and writes exactly:
|
||||
|
||||
docs/handoff/results/RX_RESULT.json
|
||||
docs/handoff/results/RX_RESULT.md
|
||||
|
||||
It checks the staged path set, commits, and pushes HEAD to origin/rx-node. It never switches the source checkout. It rejects any additional staged path and enforces a 64 KiB limit per result file.
|
||||
It checks the staged path set, commits, pushes HEAD:rx-node, and confirms that git ls-remote returns the new commit. It then removes the temporary worktree. It never switches the source checkout. It rejects any additional staged path and enforces a 64 KiB limit per result file.
|
||||
|
||||
Raw IQ, video, waveform files, large logs, secrets and machine-local state remain outside Git. A missing result branch or failed push is reported and never authorizes TX.
|
||||
|
||||
@@ -118,9 +118,9 @@ Raw IQ, video, waveform files, large logs, secrets and machine-local state remai
|
||||
|
||||
tools/tx_agent_watcher.py polls origin/rx-node every 5 seconds by default. It does not call an LLM or open SDR hardware.
|
||||
|
||||
For a new valid READY result it checks task ID, state, live-process flags, common clock, publication age, planned duration and remaining capture time. It then requests the exact phrase AUTHORIZE RF TX.
|
||||
For RF_HANDOFF_CHANNEL_TEST_001 the watcher records synthetic READY and COMPLETE states without requesting RED approval. For a new valid production READY result it checks task ID, source HEAD, current result branch commit, state, live-process flags, common clock, publication age, planned duration and remaining capture time. It then requests the exact phrase AUTHORIZE RF TX.
|
||||
|
||||
After the phrase, it fetches rx-node again and repeats the checks. A changed result, stale publication or fewer than 10 seconds remaining blocks authorization.
|
||||
After the phrase, it fetches rx-node again and repeats every check. A changed result or commit, stale publication or fewer than 10 seconds remaining blocks authorization.
|
||||
|
||||
A successful gate writes one local authorization record under .local/tx_authorizations/. The record has rf_tx_started = false and rf_tx = 0. The separately controlled TX executor must re-fetch and compare the result commit and digest immediately before opening hardware.
|
||||
|
||||
@@ -135,6 +135,8 @@ Duplicate result digests are not shown repeatedly.
|
||||
- Neither watcher starts RF TX.
|
||||
- The TX executor remains a separate RED-controlled action.
|
||||
|
||||
## Dry-run contract
|
||||
## Model-free channel and dry-run contracts
|
||||
|
||||
The fake coordination tests use a temporary local bare Git repository and branch named rx-node. They publish a fake READY result, detect it, require the exact RED phrase and invoke a fake TX action that writes RF TX = 0. They do not fetch the real remote, access SDR hardware, run hackrf_transfer, create the production rx-node branch or transmit RF.
|
||||
The preflight channel command publishes synthetic READY and COMPLETE results with TASK_ID RF_HANDOFF_CHANNEL_TEST_001 through the real origin. The MAIN watcher must observe both without a RED prompt, and COMPLETE must be the final remote state. It does not access SDR hardware or run hackrf_transfer.
|
||||
|
||||
Automated coordination tests use a temporary local bare Git repository and branch named rx-node. They cover worktree publication, READY, COMPLETE, push failure and watcher reception without accessing the real remote or SDR hardware and without transmitting RF.
|
||||
|
||||
@@ -41,11 +41,11 @@ When the task is actually complete, run:
|
||||
|
||||
## Publish READY
|
||||
|
||||
For RF001A_OTA_CAPTURE_002, create the strict READY JSON outside Git only after the capture child is alive and the file is growing. Then run:
|
||||
For RF001A_OTA_CAPTURE_003, first complete RF_HANDOFF_CHANNEL_TEST_001. Then create the strict READY JSON outside Git only after CLKIN is detected, the capture child is alive and the file is growing. Run:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --publish-result <ready-json-path> --result-branch rx-node
|
||||
|
||||
On success, the watcher prints RX RESULT PUBLISH = PUBLISHED or UNCHANGED, the result commit, and RF TX = 0.
|
||||
On success, the watcher prints RX RESULT PUBLISH = PUBLISHED or UNCHANGED, the result commit, the independently confirmed RX RESULT REMOTE HEAD, and RF TX = 0. Report RX READY FOR TX = YES only after the remote HEAD equals the result commit.
|
||||
|
||||
The command fails if:
|
||||
|
||||
@@ -58,6 +58,14 @@ The command fails if:
|
||||
|
||||
A failed publication is not READY evidence and cannot authorize TX.
|
||||
|
||||
## Model-free channel test
|
||||
|
||||
Before hardware access, while the MAIN watcher is running, execute:
|
||||
|
||||
python -B tools/rf_handoff_smoke.py --hold-seconds 7
|
||||
|
||||
It publishes synthetic READY and COMPLETE results through the real origin. Require the MAIN watcher to observe both and require COMPLETE as the final remote state. Failure blocks the hardware capture. REAL RX remains NOT_RUN and RF TX remains 0.
|
||||
|
||||
## Publish COMPLETE
|
||||
|
||||
After capture completion, build the strict final JSON with state CAPTURE_COMPLETE and publish it with the same command:
|
||||
|
||||
@@ -6,7 +6,7 @@ The publisher creates both docs/handoff/results/RX_RESULT.json and docs/handoff/
|
||||
|
||||
{
|
||||
"schema_version": 1,
|
||||
"task_id": "RF001A_OTA_CAPTURE_002",
|
||||
"task_id": "RF001A_OTA_CAPTURE_003",
|
||||
"state": "RX_READY_FOR_TX",
|
||||
"rx_ready_for_tx": true,
|
||||
"process_alive": true,
|
||||
|
||||
@@ -14,17 +14,19 @@ The visible console polls every 5 seconds. A missing rx-node branch or invalid r
|
||||
|
||||
## READY and RED approval
|
||||
|
||||
For a fresh valid RF001A_OTA_CAPTURE_002 READY result, the watcher prints the task ID, capture start and expected end. It then asks for:
|
||||
RF_HANDOFF_CHANNEL_TEST_001 READY and COMPLETE states are recorded without a RED prompt and never open the production gate.
|
||||
|
||||
For a fresh valid RF001A_OTA_CAPTURE_003 READY result, the watcher checks the expected source HEAD, current origin/rx-node commit, common clock and capture window. It then prints the task ID, capture start and expected end and asks for:
|
||||
|
||||
AUTHORIZE RF TX
|
||||
|
||||
Any other input blocks authorization.
|
||||
|
||||
After the exact phrase, the watcher fetches rx-node again. It requires the same result digest, a fresh publication, an active 120-second capture window and at least 10 seconds remaining.
|
||||
After the exact phrase, the watcher fetches rx-node again. It requires the same result digest and commit, the expected source HEAD, a fresh publication, an active 120-second capture window, verified common clock and at least 10 seconds remaining.
|
||||
|
||||
On success it writes:
|
||||
|
||||
.local\tx_authorizations\RF001A_OTA_TX_002_authorized.json
|
||||
.local\tx_authorizations\RF001A_OTA_TX_003_authorized.json
|
||||
|
||||
This record opens the gate for the separately controlled TX executor. The watcher itself does not run hackrf_transfer. The record explicitly says rf_tx_started = false and rf_tx = 0.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"tools/rf001a_prepare.py",
|
||||
"tools/rx_agent_watcher.py",
|
||||
"tools/rf_handoff_channel.py",
|
||||
"tools/rf_handoff_smoke.py",
|
||||
"tools/tx_agent_watcher.py",
|
||||
"tools/clock001.py",
|
||||
"tests/test_video005_iq.py",
|
||||
@@ -33,6 +34,8 @@
|
||||
"tests/test_rf001a_prepare.py",
|
||||
"tests/test_rx_agent_watcher.py",
|
||||
"tests/test_rf_handoff_coordination.py",
|
||||
"tests/test_capture_process_runner.py",
|
||||
"tests/test_rf001a_truth_metadata.py",
|
||||
"tests/test_clock001.py"
|
||||
],
|
||||
"typeCheckingMode": "basic",
|
||||
|
||||
87
tests/test_capture_process_runner.py
Normal file
87
tests/test_capture_process_runner.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Software-only checks for the durable capture process runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNER = ROOT / "tools" / "capture_process_runner.ps1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expected_exit_code", [0, 7])
|
||||
def test_runner_persists_exact_child_exit_code(
|
||||
tmp_path: Path,
|
||||
expected_exit_code: int,
|
||||
) -> None:
|
||||
child = tmp_path / f"child_{expected_exit_code}.cmd"
|
||||
child.write_text(
|
||||
f"@echo off\r\nexit /b {expected_exit_code}\r\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
capture = tmp_path / "fake_capture.cs8"
|
||||
capture_bytes = b"software-only-capture-evidence\x00\x01"
|
||||
capture.write_bytes(capture_bytes)
|
||||
arguments_path = tmp_path / "arguments.json"
|
||||
arguments_path.write_text(
|
||||
json.dumps(["/d", "/c", str(child)]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
stdout_path = tmp_path / "child.stdout.txt"
|
||||
stderr_path = tmp_path / "child.stderr.txt"
|
||||
started_path = tmp_path / "capture_started.json"
|
||||
result_path = tmp_path / "capture_result.json"
|
||||
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(RUNNER),
|
||||
"-Executable",
|
||||
str(Path(os.environ["COMSPEC"])),
|
||||
"-ArgumentsJsonPath",
|
||||
str(arguments_path),
|
||||
"-CapturePath",
|
||||
str(capture),
|
||||
"-StdoutPath",
|
||||
str(stdout_path),
|
||||
"-StderrPath",
|
||||
str(stderr_path),
|
||||
"-StartedStatusPath",
|
||||
str(started_path),
|
||||
"-ResultStatusPath",
|
||||
str(result_path),
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == expected_exit_code
|
||||
started = json.loads(started_path.read_text(encoding="utf-8-sig"))
|
||||
result = json.loads(result_path.read_text(encoding="utf-8-sig"))
|
||||
assert started["state"] == "RUNNING"
|
||||
assert isinstance(started["pid"], int) and started["pid"] > 0
|
||||
assert result["pid"] == started["pid"]
|
||||
assert result["started_at"] == started["started_at"]
|
||||
assert result["finished_at"]
|
||||
assert result["duration"] >= 0
|
||||
assert result["exit_code"] == expected_exit_code
|
||||
assert result["capture_path"] == str(capture)
|
||||
assert result["capture_size"] == len(capture_bytes)
|
||||
assert result["sha256"] == hashlib.sha256(capture_bytes).hexdigest()
|
||||
assert result["runner_error"] is None
|
||||
49
tests/test_rf001a_truth_metadata.py
Normal file
49
tests/test_rf001a_truth_metadata.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Checks for the tracked RF-001A decoder truth artifact."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC_PATH = ROOT / "configs" / "rf" / "rf001a_common_clock.json"
|
||||
TRUTH_PATH = ROOT / "configs" / "rf" / "rf001a_truth_metadata.json"
|
||||
|
||||
|
||||
def test_truth_metadata_is_small_complete_and_spec_aligned() -> None:
|
||||
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
|
||||
truth = json.loads(TRUTH_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
assert TRUTH_PATH.stat().st_size < 64 * 1024
|
||||
assert truth["shared_spec_test_id"] == spec["test_id"]
|
||||
assert truth["test_id"] == spec["legacy_waveform_test_id"]
|
||||
assert truth["seed"] == spec["seed"]
|
||||
assert truth["packet_count"] == spec["packet_count"] == 100
|
||||
assert truth["payload_size"] == spec["application_payload_bytes"] == 256
|
||||
assert truth["application_payload_bytes"] == truth["payload_size"]
|
||||
assert truth["expected_sequence_numbers"] == list(range(100))
|
||||
assert len(truth["expected_packet_hashes"]) == 100
|
||||
assert [
|
||||
row["sequence_number"] for row in truth["expected_packet_hashes"]
|
||||
] == list(range(100))
|
||||
assert all(
|
||||
len(row["payload_sha256"]) == 64 and len(row["packet_sha256"]) == 64
|
||||
for row in truth["expected_packet_hashes"]
|
||||
)
|
||||
assert truth["tx_waveform_sha256"] == spec["expected_hardware_sha256"]
|
||||
assert truth["iq_sha256"] == spec["expected_canonical_sha256"]
|
||||
assert truth["modulation"] == spec["modulation"]
|
||||
assert truth["symbol_rate"] == spec["symbol_rate_sps"]
|
||||
assert truth["sample_rate"] == spec["sample_rate_hz"]
|
||||
assert truth["fec"] == spec["inner_fec"]
|
||||
assert truth["fec_rate"] == truth["fec"]
|
||||
assert truth["gap_ms"] == spec["inter_packet_gap_ms"]
|
||||
assert truth["inter_packet_gap_ms"] == truth["gap_ms"]
|
||||
assert (
|
||||
truth["digital_carrier_offset_hz"]
|
||||
== spec["digital_carrier_offset_hz"]
|
||||
)
|
||||
assert truth["tx_waveform_format"] == spec["hardware_format"]
|
||||
assert "waveform" not in truth
|
||||
assert "raw_iq" not in truth
|
||||
@@ -11,6 +11,7 @@ import subprocess
|
||||
import pytest
|
||||
|
||||
from tools.rf_handoff_channel import (
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
COMPLETE_STATE,
|
||||
EXPECTED_RX_TASK_ID,
|
||||
GitResultChannel,
|
||||
@@ -18,18 +19,26 @@ from tools.rf_handoff_channel import (
|
||||
RESULT_JSON_PATH,
|
||||
RESULT_MARKDOWN_PATH,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
RxHandoffResult,
|
||||
canonical_result_json,
|
||||
evaluate_ready,
|
||||
result_digest,
|
||||
result_from_mapping,
|
||||
)
|
||||
from tools.rf_handoff_smoke import run_smoke
|
||||
from tools.rx_agent_watcher import (
|
||||
WatcherState as RxWatcherState,
|
||||
publish_result,
|
||||
save_state as save_rx_state,
|
||||
)
|
||||
from tools.tx_agent_watcher import TxWatcherState, handle_result
|
||||
from tools.tx_agent_watcher import (
|
||||
TxWatcherState,
|
||||
handle_result,
|
||||
load_state as load_tx_state,
|
||||
request_stop,
|
||||
save_state as save_tx_state,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||
@@ -65,6 +74,40 @@ def ready_result(**changes: object) -> RxHandoffResult:
|
||||
return replace(result, **changes)
|
||||
|
||||
|
||||
def channel_ready_result(**changes: object) -> RxHandoffResult:
|
||||
return ready_result(
|
||||
task_id=CHANNEL_TEST_TASK_ID,
|
||||
capture_path="MODEL_FREE_FAKE_CAPTURE",
|
||||
common_clock_verified=False,
|
||||
**changes,
|
||||
)
|
||||
|
||||
|
||||
def complete_result(
|
||||
result: RxHandoffResult | None = None,
|
||||
**changes: object,
|
||||
) -> RxHandoffResult:
|
||||
source = result or ready_result()
|
||||
complete = replace(
|
||||
source,
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=(NOW + timedelta(seconds=121)).isoformat(),
|
||||
exit_code=0,
|
||||
file_size=1_920_000_000,
|
||||
duration_seconds=120.01,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256="b" * 64,
|
||||
clkin_after="DETECTED" if source.common_clock_verified else "NOT_RUN",
|
||||
rf_tx_observed="UNKNOWN",
|
||||
)
|
||||
return replace(complete, **changes)
|
||||
|
||||
|
||||
def _git(arguments: list[str], cwd: Path) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", *arguments],
|
||||
@@ -106,6 +149,7 @@ class FakeSource:
|
||||
|
||||
def fetch(self) -> None:
|
||||
self.fetch_count += 1
|
||||
return None
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||
return self.result, self.digest, self.head
|
||||
@@ -136,7 +180,7 @@ class FakeExecutor:
|
||||
return self.path
|
||||
|
||||
|
||||
def test_result_parsing_rejects_extra_and_inconsistent_fields() -> None:
|
||||
def test_result_parsing_rejects_extra_types_and_inconsistent_fields() -> None:
|
||||
raw = asdict(ready_result())
|
||||
assert result_from_mapping(raw) == ready_result()
|
||||
|
||||
@@ -157,35 +201,20 @@ def test_result_parsing_rejects_extra_and_inconsistent_fields() -> None:
|
||||
|
||||
incomplete = dict(raw)
|
||||
incomplete["file_growing"] = False
|
||||
with pytest.raises(ValueError, match="live capture"):
|
||||
with pytest.raises(ValueError, match="live process"):
|
||||
result_from_mapping(incomplete)
|
||||
|
||||
|
||||
def test_complete_result_requires_final_evidence() -> None:
|
||||
final = ready_result(
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=(NOW + timedelta(seconds=121)).isoformat(),
|
||||
exit_code=0,
|
||||
file_size=1_920_000_000,
|
||||
duration_seconds=120.01,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256="b" * 64,
|
||||
clkin_after="DETECTED",
|
||||
rf_tx_observed="EXPECTED_EXTERNAL_TX",
|
||||
)
|
||||
def test_complete_result_accepts_explicit_unreported_counters() -> None:
|
||||
final = complete_result()
|
||||
assert result_from_mapping(asdict(final)) == final
|
||||
unreported_counters = replace(
|
||||
unreported = replace(
|
||||
final,
|
||||
overruns=None,
|
||||
usb_errors=None,
|
||||
file_errors=None,
|
||||
)
|
||||
assert result_from_mapping(asdict(unreported_counters)) == unreported_counters
|
||||
assert result_from_mapping(asdict(unreported)) == unreported
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -213,41 +242,37 @@ def test_complete_result_requires_final_evidence() -> None:
|
||||
NOW,
|
||||
"CAPTURE_WINDOW_EXPIRED",
|
||||
),
|
||||
(ready_result(task_id="WRONG_TASK_001"), NOW, "WRONG_TASK_ID"),
|
||||
(
|
||||
ready_result(task_id="WRONG_TASK_001"),
|
||||
NOW,
|
||||
"WRONG_TASK_ID",
|
||||
),
|
||||
(
|
||||
ready_result(
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
exit_code=0,
|
||||
file_size=1_920_000_000,
|
||||
duration_seconds=120.0,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256="c" * 64,
|
||||
clkin_after="DETECTED",
|
||||
),
|
||||
complete_result(),
|
||||
NOW,
|
||||
"MISSING_READY",
|
||||
),
|
||||
(
|
||||
ready_result(source_head="c" * 40),
|
||||
NOW,
|
||||
"WRONG_SOURCE_HEAD",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_freshness_and_window_rejections(
|
||||
def test_freshness_window_task_and_source_rejections(
|
||||
result: RxHandoffResult,
|
||||
now: datetime,
|
||||
reason: str,
|
||||
) -> None:
|
||||
assert evaluate_ready(result, now=now).reason == reason
|
||||
assert evaluate_ready(
|
||||
result,
|
||||
now=now,
|
||||
expected_source_head=HEAD,
|
||||
).reason == reason
|
||||
|
||||
|
||||
def test_ready_evaluation_passes_with_more_than_ten_seconds() -> None:
|
||||
evaluation = evaluate_ready(ready_result(), now=NOW + timedelta(seconds=2))
|
||||
evaluation = evaluate_ready(
|
||||
ready_result(),
|
||||
now=NOW + timedelta(seconds=2),
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert evaluation.ready
|
||||
assert evaluation.reason == "READY"
|
||||
assert evaluation.seconds_remaining == pytest.approx(118.0)
|
||||
@@ -271,6 +296,7 @@ def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
|
||||
input_function=lambda _prompt: "DENY",
|
||||
output_function=output.append,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "RED_APPROVAL_BLOCKED"
|
||||
assert executor.calls == 0
|
||||
@@ -287,12 +313,13 @@ def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
output_function=output.append,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "DUPLICATE_SUPPRESSED"
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_window_is_rechecked_after_exact_approval(tmp_path: Path) -> None:
|
||||
def test_window_and_commit_are_rechecked_after_exact_approval(tmp_path: Path) -> None:
|
||||
result = ready_result()
|
||||
digest = result_digest(result)
|
||||
source = FakeSource(result, digest)
|
||||
@@ -310,57 +337,134 @@ def test_window_is_rechecked_after_exact_approval(tmp_path: Path) -> None:
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
output_function=lambda _text: None,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
max_age_seconds=200.0,
|
||||
)
|
||||
assert status == "CAPTURE_WINDOW_EXPIRED"
|
||||
assert source.fetch_count == 1
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_fake_end_to_end_publish_detect_authorize_without_rf(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
result = ready_result()
|
||||
publisher = GitResultChannel(work)
|
||||
outcome = publisher.publish(result)
|
||||
assert outcome.status == "PUBLISHED"
|
||||
publisher.fetch()
|
||||
|
||||
branch_files = set(
|
||||
_git(["ls-tree", "-r", "--name-only", "origin/rx-node"], work).splitlines()
|
||||
)
|
||||
assert RESULT_JSON_PATH in branch_files
|
||||
assert RESULT_MARKDOWN_PATH in branch_files
|
||||
assert not any(path.endswith((".cs8", ".iq", ".mp4")) for path in branch_files)
|
||||
|
||||
source = GitResultChannel(work)
|
||||
source.fetch()
|
||||
observed, digest, head = source.read()
|
||||
assert observed == result
|
||||
assert head == outcome.commit
|
||||
|
||||
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||
output: list[str] = []
|
||||
status, state = handle_result(
|
||||
observed,
|
||||
source = FakeSource(result, digest, head="d" * 40)
|
||||
status, _ = handle_result(
|
||||
result,
|
||||
digest,
|
||||
head,
|
||||
HEAD,
|
||||
TxWatcherState(),
|
||||
source,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=2),
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
output_function=output.append,
|
||||
output_function=lambda _text: None,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "TX_AUTHORIZATION_PREPARED"
|
||||
assert state.last_authorized_digest == digest
|
||||
assert executor.calls == 1
|
||||
assert (tmp_path / "fake_tx.txt").read_text(encoding="utf-8") == (
|
||||
"FAKE TX ONLY\nRF TX = 0\n"
|
||||
assert status == "RESULT_CHANGED"
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_stop_request_clears_stale_watcher_pid(tmp_path: Path) -> None:
|
||||
state_path = tmp_path / "watcher_state.json"
|
||||
save_tx_state(state_path, TxWatcherState(running=True, pid=2_000_000_000))
|
||||
|
||||
assert request_stop(state_path, timeout_s=1.0)
|
||||
state = load_tx_state(state_path)
|
||||
assert not state.running
|
||||
assert state.pid is None
|
||||
assert not state_path.with_suffix(".json.stop").exists()
|
||||
|
||||
|
||||
def test_real_origin_detection_and_worktree_publication(tmp_path: Path) -> None:
|
||||
remote, work = git_fixture(tmp_path)
|
||||
branch_before = _git(["branch", "--show-current"], work)
|
||||
channel = GitResultChannel(work)
|
||||
assert channel.branch_exists()
|
||||
remote_before = channel.fetch()
|
||||
|
||||
outcome = channel.publish(ready_result())
|
||||
assert outcome.status == "PUBLISHED"
|
||||
assert outcome.commit == outcome.remote_head
|
||||
assert outcome.commit != remote_before
|
||||
assert _git(["branch", "--show-current"], work) == branch_before
|
||||
assert not (work / RESULT_JSON_PATH).exists()
|
||||
assert not (work / RESULT_MARKDOWN_PATH).exists()
|
||||
|
||||
remote_head = _git(
|
||||
["--git-dir", str(remote), "rev-parse", "refs/heads/rx-node"],
|
||||
tmp_path,
|
||||
)
|
||||
assert any(line == "RF TX = 0" for line in output)
|
||||
assert remote_head == outcome.commit
|
||||
worktrees = _git(["worktree", "list", "--porcelain"], work)
|
||||
assert worktrees.count("worktree ") == 1
|
||||
|
||||
|
||||
def test_ready_then_complete_reaches_main_watcher_without_red_prompt(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
ready = channel_ready_result()
|
||||
ready_outcome = channel.publish(ready)
|
||||
observed_ready, ready_digest, ready_head = channel.read()
|
||||
prompts: list[str] = []
|
||||
|
||||
status, state = handle_result(
|
||||
observed_ready,
|
||||
ready_digest,
|
||||
ready_head,
|
||||
TxWatcherState(),
|
||||
channel,
|
||||
FakeExecutor(tmp_path / "forbidden.txt"),
|
||||
now_function=lambda: NOW,
|
||||
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "CHANNEL_TEST_READY"
|
||||
assert state.channel_test_ready_digest == ready_digest
|
||||
assert prompts == []
|
||||
|
||||
complete = complete_result(
|
||||
ready,
|
||||
published_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
file_size=4,
|
||||
duration_seconds=1.0,
|
||||
sha256="d" * 64,
|
||||
)
|
||||
complete_outcome = channel.publish(complete)
|
||||
observed_complete, complete_digest, complete_head = channel.read()
|
||||
status, state = handle_result(
|
||||
observed_complete,
|
||||
complete_digest,
|
||||
complete_head,
|
||||
state,
|
||||
channel,
|
||||
FakeExecutor(tmp_path / "forbidden.txt"),
|
||||
now_function=lambda: NOW + timedelta(seconds=1),
|
||||
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "CHANNEL_TEST_COMPLETE"
|
||||
assert state.channel_test_complete_digest == complete_digest
|
||||
assert ready_outcome.remote_head != complete_outcome.remote_head
|
||||
assert prompts == []
|
||||
|
||||
|
||||
def test_smoke_utility_leaves_complete_not_ready(tmp_path: Path) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
ready, complete, expected = run_smoke(
|
||||
work,
|
||||
source_head=HEAD,
|
||||
hold_seconds=0,
|
||||
sleep_function=lambda _seconds: None,
|
||||
)
|
||||
assert ready.status == "PUBLISHED"
|
||||
assert complete.status == "PUBLISHED"
|
||||
assert ready.remote_head != complete.remote_head
|
||||
observed, _digest, head = GitResultChannel(work).read()
|
||||
assert observed == expected
|
||||
assert observed.state == COMPLETE_STATE
|
||||
assert not observed.rx_ready_for_tx
|
||||
assert head == complete.remote_head
|
||||
|
||||
|
||||
def test_missing_result_branch_is_blocked(tmp_path: Path) -> None:
|
||||
@@ -371,11 +475,14 @@ def test_missing_result_branch_is_blocked(tmp_path: Path) -> None:
|
||||
_git(["init"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
channel = GitResultChannel(work)
|
||||
assert not channel.branch_exists()
|
||||
with pytest.raises(ResultBranchMissing):
|
||||
channel.publish(ready_result())
|
||||
|
||||
|
||||
def test_push_failure_is_reported(tmp_path: Path) -> None:
|
||||
def test_push_failure_is_reported_and_remote_head_is_unchanged(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
|
||||
class FailingPushChannel(GitResultChannel):
|
||||
@@ -387,17 +494,23 @@ def test_push_failure_is_reported(tmp_path: Path) -> None:
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if arguments and arguments[0] == "push":
|
||||
raise subprocess.CalledProcessError(
|
||||
1,
|
||||
return subprocess.CompletedProcess(
|
||||
["git", *arguments],
|
||||
stderr="simulated push failure",
|
||||
1,
|
||||
"",
|
||||
"simulated push failure",
|
||||
)
|
||||
return super()._run(arguments, cwd=cwd, check=check)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError, match="git"):
|
||||
FailingPushChannel(work).publish(ready_result())
|
||||
channel = FailingPushChannel(work)
|
||||
before = channel.remote_head()
|
||||
with pytest.raises(ResultChannelError, match="simulated push failure"):
|
||||
channel.publish(ready_result())
|
||||
assert channel.remote_head() == before
|
||||
assert _git(["worktree", "list", "--porcelain"], work).count("worktree ") == 1
|
||||
|
||||
def test_rx_watcher_publishes_ready_only_after_local_task_approval(
|
||||
|
||||
def test_rx_watcher_publishes_ready_after_local_task_approval(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
@@ -453,8 +566,8 @@ Fake task used only by the local coordination test.
|
||||
assert publish_result(arguments) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "RX RESULT PUBLISH = PUBLISHED" in output
|
||||
assert "RX RESULT REMOTE HEAD" in output
|
||||
assert "RF TX = 0" in output
|
||||
|
||||
channel = GitResultChannel(work)
|
||||
published, _digest, _head = channel.read()
|
||||
published, _digest, _head = GitResultChannel(work).read()
|
||||
assert published == ready_result(source_head=source_head)
|
||||
|
||||
104
tools/capture_process_runner.ps1
Normal file
104
tools/capture_process_runner.ps1
Normal file
@@ -0,0 +1,104 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Executable,
|
||||
[Parameter(Mandatory = $true)][string]$ArgumentsJsonPath,
|
||||
[Parameter(Mandatory = $true)][string]$CapturePath,
|
||||
[Parameter(Mandatory = $true)][string]$StdoutPath,
|
||||
[Parameter(Mandatory = $true)][string]$StderrPath,
|
||||
[Parameter(Mandatory = $true)][string]$StartedStatusPath,
|
||||
[Parameter(Mandatory = $true)][string]$ResultStatusPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-JsonAtomically {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][object]$Value,
|
||||
[Parameter(Mandatory = $true)][string]$Path
|
||||
)
|
||||
|
||||
$temporaryPath = "$Path.tmp"
|
||||
$json = $Value | ConvertTo-Json -Depth 8
|
||||
$encoding = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($temporaryPath, $json + [Environment]::NewLine, $encoding)
|
||||
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
|
||||
}
|
||||
|
||||
$decodedArguments = Get-Content -Raw -LiteralPath $ArgumentsJsonPath |
|
||||
ConvertFrom-Json
|
||||
if ($decodedArguments -isnot [System.Array]) {
|
||||
throw 'ArgumentsJsonPath must contain a JSON array.'
|
||||
}
|
||||
$childArguments = @(
|
||||
foreach ($argument in $decodedArguments) {
|
||||
[string]$argument
|
||||
}
|
||||
)
|
||||
$startedAt = [DateTimeOffset]::UtcNow
|
||||
$finishedAt = $null
|
||||
$childProcess = $null
|
||||
$exitCode = $null
|
||||
$runnerError = $null
|
||||
$captureSize = 0
|
||||
$sha256 = $null
|
||||
|
||||
try {
|
||||
$startParameters = @{
|
||||
FilePath = $Executable
|
||||
ArgumentList = $childArguments
|
||||
RedirectStandardOutput = $StdoutPath
|
||||
RedirectStandardError = $StderrPath
|
||||
PassThru = $true
|
||||
WindowStyle = 'Hidden'
|
||||
}
|
||||
$childProcess = Start-Process @startParameters
|
||||
# PowerShell 5 can lose ExitCode for redirected processes unless the
|
||||
# underlying process handle is opened before WaitForExit().
|
||||
$null = $childProcess.Handle
|
||||
|
||||
Write-JsonAtomically -Path $StartedStatusPath -Value ([ordered]@{
|
||||
state = 'RUNNING'
|
||||
pid = $childProcess.Id
|
||||
started_at = $startedAt.ToString('o')
|
||||
capture_path = $CapturePath
|
||||
})
|
||||
|
||||
$childProcess.WaitForExit()
|
||||
$childProcess.Refresh()
|
||||
$exitCode = [int]$childProcess.ExitCode
|
||||
} catch {
|
||||
$runnerError = $_.Exception.ToString()
|
||||
} finally {
|
||||
$finishedAt = [DateTimeOffset]::UtcNow
|
||||
if (Test-Path -LiteralPath $CapturePath) {
|
||||
try {
|
||||
$captureSize = [int64](Get-Item -LiteralPath $CapturePath).Length
|
||||
$sha256 = (
|
||||
Get-FileHash -LiteralPath $CapturePath -Algorithm SHA256
|
||||
).Hash.ToLowerInvariant()
|
||||
} catch {
|
||||
$additionalError = $_.Exception.ToString()
|
||||
if ([string]::IsNullOrWhiteSpace($runnerError)) {
|
||||
$runnerError = $additionalError
|
||||
} else {
|
||||
$runnerError = $runnerError + [Environment]::NewLine + $additionalError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-JsonAtomically -Path $ResultStatusPath -Value ([ordered]@{
|
||||
pid = if ($null -ne $childProcess) { $childProcess.Id } else { $null }
|
||||
started_at = $startedAt.ToString('o')
|
||||
finished_at = $finishedAt.ToString('o')
|
||||
duration = ($finishedAt - $startedAt).TotalSeconds
|
||||
exit_code = $exitCode
|
||||
capture_path = $CapturePath
|
||||
capture_size = $captureSize
|
||||
sha256 = $sha256
|
||||
runner_error = $runnerError
|
||||
})
|
||||
}
|
||||
|
||||
if ($null -eq $exitCode) {
|
||||
exit 255
|
||||
}
|
||||
exit $exitCode
|
||||
@@ -14,8 +14,9 @@ from typing import Any, Mapping
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_002"
|
||||
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_002"
|
||||
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_003"
|
||||
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_003"
|
||||
CHANNEL_TEST_TASK_ID = "RF_HANDOFF_CHANNEL_TEST_001"
|
||||
READY_STATE = "RX_READY_FOR_TX"
|
||||
COMPLETE_STATE = "CAPTURE_COMPLETE"
|
||||
RESULT_JSON_PATH = "docs/handoff/results/RX_RESULT.json"
|
||||
@@ -72,6 +73,7 @@ class ReadyEvaluation:
|
||||
class PublishOutcome:
|
||||
status: str
|
||||
commit: str
|
||||
remote_head: str
|
||||
json_path: str = RESULT_JSON_PATH
|
||||
markdown_path: str = RESULT_MARKDOWN_PATH
|
||||
|
||||
@@ -172,9 +174,13 @@ def _validate_ready(result: RxHandoffResult) -> None:
|
||||
result.rx_ready_for_tx
|
||||
and result.process_alive
|
||||
and result.file_growing
|
||||
and result.common_clock_verified
|
||||
):
|
||||
raise ValueError("READY requires a live capture and verified common clock")
|
||||
raise ValueError("READY requires a live process and growing capture")
|
||||
if result.task_id == CHANNEL_TEST_TASK_ID:
|
||||
if result.common_clock_verified:
|
||||
raise ValueError("channel smoke READY must not claim hardware clock proof")
|
||||
elif not result.common_clock_verified:
|
||||
raise ValueError("production READY requires verified common clock")
|
||||
if result.capture_pid is None or result.capture_pid <= 0:
|
||||
raise ValueError("READY requires a positive capture_pid")
|
||||
final_fields = (
|
||||
@@ -289,15 +295,22 @@ def evaluate_ready(
|
||||
*,
|
||||
now: datetime,
|
||||
expected_task_id: str = EXPECTED_RX_TASK_ID,
|
||||
expected_source_head: str | None = None,
|
||||
max_age_seconds: float = 30.0,
|
||||
min_remaining_seconds: float = 10.0,
|
||||
) -> ReadyEvaluation:
|
||||
validate_result(result)
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must contain a timezone")
|
||||
now_utc = now.astimezone(timezone.utc)
|
||||
if result.task_id != expected_task_id:
|
||||
return ReadyEvaluation(False, "WRONG_TASK_ID")
|
||||
if expected_source_head is not None and result.source_head != expected_source_head:
|
||||
return ReadyEvaluation(False, "WRONG_SOURCE_HEAD")
|
||||
if result.state != READY_STATE or not result.rx_ready_for_tx:
|
||||
return ReadyEvaluation(False, "MISSING_READY")
|
||||
if not result.common_clock_verified:
|
||||
return ReadyEvaluation(False, "COMMON_CLOCK_NOT_VERIFIED")
|
||||
started = _parse_utc(result.capture_started_at, "capture_started_at")
|
||||
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
|
||||
published = _parse_utc(result.published_at, "published_at")
|
||||
@@ -316,7 +329,7 @@ def evaluate_ready(
|
||||
|
||||
|
||||
class GitResultChannel:
|
||||
"""Read and publish the two allowlisted result files on an existing branch."""
|
||||
"""Publish through a temporary worktree of the configured main repository."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -346,99 +359,163 @@ class GitResultChannel:
|
||||
check=check,
|
||||
)
|
||||
|
||||
def branch_exists(self) -> bool:
|
||||
def remote_head(self) -> str | None:
|
||||
result = self._run(
|
||||
["ls-remote", "--exit-code", "--heads", self.remote, self.branch],
|
||||
["ls-remote", "--heads", self.remote, self.branch],
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0 and bool(result.stdout.strip())
|
||||
|
||||
def _require_branch(self) -> None:
|
||||
if not self.branch_exists():
|
||||
raise ResultBranchMissing(
|
||||
f"remote branch {self.remote}/{self.branch} does not exist; "
|
||||
"separate authorization is required before creating it"
|
||||
if result.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"git ls-remote failed for {self.remote}/{self.branch}: "
|
||||
f"{result.stderr.strip()}"
|
||||
)
|
||||
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return None
|
||||
if len(lines) != 1:
|
||||
raise ResultChannelError("remote result branch lookup is ambiguous")
|
||||
head, reference = lines[0].split(maxsplit=1)
|
||||
if reference != f"refs/heads/{self.branch}" or not _HEAD_RE.fullmatch(head):
|
||||
raise ResultChannelError("remote result branch returned an invalid HEAD")
|
||||
return head
|
||||
|
||||
def fetch(self) -> None:
|
||||
self._require_branch()
|
||||
self._run(
|
||||
[
|
||||
"fetch",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"refs/heads/{self.branch}:refs/remotes/{self.remote}/{self.branch}",
|
||||
]
|
||||
def branch_exists(self) -> bool:
|
||||
return self.remote_head() is not None
|
||||
|
||||
def fetch(self) -> str:
|
||||
initial_fetch = self._run(
|
||||
["fetch", "--quiet", self.remote, self.branch],
|
||||
check=False,
|
||||
)
|
||||
remote_head = self.remote_head()
|
||||
if remote_head is None:
|
||||
raise ResultBranchMissing(
|
||||
f"remote branch {self.remote}/{self.branch} does not exist"
|
||||
)
|
||||
if initial_fetch.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"git fetch failed for {self.remote}/{self.branch}: "
|
||||
f"{initial_fetch.stderr.strip()}"
|
||||
)
|
||||
tracking_reference = (
|
||||
f"refs/heads/{self.branch}:"
|
||||
f"refs/remotes/{self.remote}/{self.branch}"
|
||||
)
|
||||
tracking_fetch = self._run(
|
||||
["fetch", "--quiet", self.remote, tracking_reference],
|
||||
check=False,
|
||||
)
|
||||
if tracking_fetch.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"tracking fetch failed for {self.remote}/{self.branch}: "
|
||||
f"{tracking_fetch.stderr.strip()}"
|
||||
)
|
||||
tracking_head = self._run(
|
||||
["rev-parse", f"refs/remotes/{self.remote}/{self.branch}"]
|
||||
).stdout.strip()
|
||||
if tracking_head != remote_head:
|
||||
raise ResultChannelError("remote-tracking result branch is stale")
|
||||
return remote_head
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||
self.fetch()
|
||||
remote_head = self.fetch()
|
||||
reference = f"refs/remotes/{self.remote}/{self.branch}"
|
||||
json_result = self._run(["show", f"{reference}:{RESULT_JSON_PATH}"])
|
||||
raw = json.loads(json_result.stdout)
|
||||
if not isinstance(raw, dict):
|
||||
raise ResultChannelError("remote result JSON root is not an object")
|
||||
result = result_from_mapping(raw)
|
||||
head = self._run(["rev-parse", reference]).stdout.strip()
|
||||
return result, result_digest(result), head
|
||||
observed_head = self._run(["rev-parse", reference]).stdout.strip()
|
||||
if observed_head != remote_head:
|
||||
raise ResultChannelError("result commit differs from remote HEAD")
|
||||
return result, result_digest(result), observed_head
|
||||
|
||||
def publish(self, result: RxHandoffResult) -> PublishOutcome:
|
||||
validate_result(result)
|
||||
self._require_branch()
|
||||
remote_url = self._run(["remote", "get-url", self.remote]).stdout.strip()
|
||||
with tempfile.TemporaryDirectory(prefix="rx-result-publish-") as directory:
|
||||
checkout = Path(directory) / "result-branch"
|
||||
self._run(
|
||||
[
|
||||
"clone",
|
||||
"--quiet",
|
||||
"--single-branch",
|
||||
"--branch",
|
||||
self.branch,
|
||||
remote_url,
|
||||
str(checkout),
|
||||
]
|
||||
)
|
||||
json_path = checkout / RESULT_JSON_PATH
|
||||
markdown_path = checkout / RESULT_MARKDOWN_PATH
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_text = canonical_result_json(result)
|
||||
markdown_text = render_result_markdown(result)
|
||||
if len(json_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result JSON exceeds the size limit")
|
||||
if len(markdown_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result Markdown exceeds the size limit")
|
||||
json_path.write_text(json_text, encoding="utf-8", newline="\n")
|
||||
markdown_path.write_text(markdown_text, encoding="utf-8", newline="\n")
|
||||
self._run(["add", "--", *sorted(RESULT_PATHS)], cwd=checkout)
|
||||
staged_text = self._run(
|
||||
["diff", "--cached", "--name-only", "--"],
|
||||
cwd=checkout,
|
||||
).stdout
|
||||
staged = {
|
||||
line.strip().replace("\\", "/")
|
||||
for line in staged_text.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
if not staged:
|
||||
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||
return PublishOutcome("UNCHANGED", commit)
|
||||
if staged != RESULT_PATHS:
|
||||
raise ResultChannelError(
|
||||
f"refusing unexpected staged paths: {sorted(staged)}"
|
||||
remote_before = self.fetch()
|
||||
tracking_reference = f"refs/remotes/{self.remote}/{self.branch}"
|
||||
with tempfile.TemporaryDirectory(prefix="rx-result-worktree-") as directory:
|
||||
checkout = Path(directory) / "rx-node"
|
||||
worktree_added = False
|
||||
try:
|
||||
self._run(
|
||||
["worktree", "add", "--detach", str(checkout), tracking_reference]
|
||||
)
|
||||
self._run(["config", "user.name", "RX Node"], cwd=checkout)
|
||||
self._run(
|
||||
["config", "user.email", "rx-node@local.invalid"],
|
||||
cwd=checkout,
|
||||
)
|
||||
self._run(
|
||||
["commit", "--quiet", "-m", f"Publish {result.task_id} {result.state}"],
|
||||
cwd=checkout,
|
||||
)
|
||||
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||
self._run(
|
||||
["push", "--quiet", "origin", f"HEAD:refs/heads/{self.branch}"],
|
||||
cwd=checkout,
|
||||
)
|
||||
return PublishOutcome("PUBLISHED", commit)
|
||||
worktree_added = True
|
||||
json_path = checkout / RESULT_JSON_PATH
|
||||
markdown_path = checkout / RESULT_MARKDOWN_PATH
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_text = canonical_result_json(result)
|
||||
markdown_text = render_result_markdown(result)
|
||||
if len(json_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result JSON exceeds the size limit")
|
||||
if len(markdown_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result Markdown exceeds the size limit")
|
||||
json_path.write_text(json_text, encoding="utf-8", newline="\n")
|
||||
markdown_path.write_text(
|
||||
markdown_text,
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
self._run(["add", "--", *sorted(RESULT_PATHS)], cwd=checkout)
|
||||
staged_text = self._run(
|
||||
["diff", "--cached", "--name-only", "--"],
|
||||
cwd=checkout,
|
||||
).stdout
|
||||
staged = {
|
||||
line.strip().replace("\\", "/")
|
||||
for line in staged_text.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
if not staged:
|
||||
return PublishOutcome("UNCHANGED", remote_before, remote_before)
|
||||
if staged != RESULT_PATHS:
|
||||
raise ResultChannelError(
|
||||
f"refusing unexpected staged paths: {sorted(staged)}"
|
||||
)
|
||||
self._run(
|
||||
[
|
||||
"-c",
|
||||
"user.name=RX Node",
|
||||
"-c",
|
||||
"user.email=rx-node@local.invalid",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"-m",
|
||||
f"Publish {result.task_id} {result.state}",
|
||||
],
|
||||
cwd=checkout,
|
||||
)
|
||||
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||
push = self._run(
|
||||
[
|
||||
"push",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"HEAD:refs/heads/{self.branch}",
|
||||
],
|
||||
cwd=checkout,
|
||||
check=False,
|
||||
)
|
||||
if push.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"result push failed: {push.stderr.strip()}"
|
||||
)
|
||||
remote_after = self.remote_head()
|
||||
if remote_after is None or remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote result HEAD does not confirm the published commit"
|
||||
)
|
||||
return PublishOutcome("PUBLISHED", commit, remote_after)
|
||||
finally:
|
||||
if worktree_added:
|
||||
removal = self._run(
|
||||
["worktree", "remove", "--force", str(checkout)],
|
||||
check=False,
|
||||
)
|
||||
if removal.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"temporary worktree cleanup failed: "
|
||||
f"{removal.stderr.strip()}"
|
||||
)
|
||||
self._run(["worktree", "prune"], check=False)
|
||||
|
||||
154
tools/rf_handoff_smoke.py
Normal file
154
tools/rf_handoff_smoke.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Publish a model-free READY/COMPLETE smoke sequence to the RX result branch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.rf_handoff_channel import ( # noqa: E402
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
COMPLETE_STATE,
|
||||
READY_STATE,
|
||||
GitResultChannel,
|
||||
PublishOutcome,
|
||||
RxHandoffResult,
|
||||
)
|
||||
|
||||
|
||||
def _git_head(repository: Path) -> str:
|
||||
return subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=repository,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def run_smoke(
|
||||
repository: Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
branch: str = "rx-node",
|
||||
source_head: str | None = None,
|
||||
hold_seconds: float = 7.0,
|
||||
sleep_function: Callable[[float], None] = time.sleep,
|
||||
) -> tuple[PublishOutcome, PublishOutcome, RxHandoffResult]:
|
||||
"""Publish fake READY then COMPLETE; never access SDR or open a TX gate."""
|
||||
|
||||
repository = repository.resolve()
|
||||
observed_source_head = source_head or _git_head(repository)
|
||||
channel = GitResultChannel(repository, remote=remote, branch=branch)
|
||||
channel.fetch()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="rf-handoff-smoke-") as directory:
|
||||
fake_capture = Path(directory) / "model_free_fake_capture.txt"
|
||||
started = datetime.now(timezone.utc)
|
||||
fake_capture.write_text(
|
||||
"RF_HANDOFF_CHANNEL_TEST_001\nREADY\nRF TX = 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
published = datetime.now(timezone.utc)
|
||||
ready = RxHandoffResult(
|
||||
schema_version=1,
|
||||
task_id=CHANNEL_TEST_TASK_ID,
|
||||
state=READY_STATE,
|
||||
rx_ready_for_tx=True,
|
||||
process_alive=True,
|
||||
file_growing=True,
|
||||
capture_pid=max(1, os.getpid()),
|
||||
published_at=published.isoformat(),
|
||||
capture_started_at=started.isoformat(),
|
||||
expected_capture_end=(started + timedelta(seconds=120)).isoformat(),
|
||||
capture_path=str(fake_capture.resolve()),
|
||||
common_clock_verified=False,
|
||||
source_head=observed_source_head,
|
||||
rf_tx=0,
|
||||
exit_code=None,
|
||||
file_size=None,
|
||||
duration_seconds=None,
|
||||
overruns=None,
|
||||
usb_errors=None,
|
||||
file_errors=None,
|
||||
sha256=None,
|
||||
clkin_after="NOT_RUN",
|
||||
rf_tx_observed="UNKNOWN",
|
||||
)
|
||||
ready_outcome = channel.publish(ready)
|
||||
sleep_function(hold_seconds)
|
||||
|
||||
fake_capture.write_text(
|
||||
"RF_HANDOFF_CHANNEL_TEST_001\nCOMPLETE\nRF TX = 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
finished = datetime.now(timezone.utc)
|
||||
content = fake_capture.read_bytes()
|
||||
complete = replace(
|
||||
ready,
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=finished.isoformat(),
|
||||
exit_code=0,
|
||||
file_size=len(content),
|
||||
duration_seconds=(finished - started).total_seconds(),
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256=hashlib.sha256(content).hexdigest(),
|
||||
)
|
||||
complete_outcome = channel.publish(complete)
|
||||
return ready_outcome, complete_outcome, complete
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path, default=ROOT)
|
||||
parser.add_argument("--remote", default="origin")
|
||||
parser.add_argument("--result-branch", default="rx-node")
|
||||
parser.add_argument("--source-head")
|
||||
parser.add_argument("--hold-seconds", type=float, default=7.0)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arguments = build_parser().parse_args()
|
||||
if arguments.hold_seconds < 0:
|
||||
raise SystemExit("--hold-seconds cannot be negative")
|
||||
ready, complete, result = run_smoke(
|
||||
arguments.repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.result_branch,
|
||||
source_head=arguments.source_head,
|
||||
hold_seconds=arguments.hold_seconds,
|
||||
)
|
||||
print("TASK_ID =")
|
||||
print(CHANNEL_TEST_TASK_ID)
|
||||
print(f"READY PUBLISH = {ready.status}")
|
||||
print(f"READY REMOTE HEAD = {ready.remote_head}")
|
||||
print(f"COMPLETE PUBLISH = {complete.status}")
|
||||
print(f"COMPLETE REMOTE HEAD = {complete.remote_head}")
|
||||
print(f"FINAL STATE = {result.state}")
|
||||
print("REAL RX = NOT_RUN")
|
||||
print("RF TX = 0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -528,6 +528,7 @@ def publish_result(arguments: argparse.Namespace) -> int:
|
||||
outcome = channel.publish(result)
|
||||
print(f"RX RESULT PUBLISH = {outcome.status}")
|
||||
print(f"RX RESULT HEAD = {outcome.commit}")
|
||||
print(f"RX RESULT REMOTE HEAD = {outcome.remote_head}")
|
||||
print("RF TX = 0")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -19,8 +20,11 @@ if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.rf_handoff_channel import ( # noqa: E402
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
COMPLETE_STATE,
|
||||
EXPECTED_TX_TASK_ID,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
RxHandoffResult,
|
||||
@@ -32,10 +36,11 @@ from tools.rf_handoff_channel import ( # noqa: E402
|
||||
|
||||
DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json"
|
||||
DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations"
|
||||
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ResultSource(Protocol):
|
||||
def fetch(self) -> None: ...
|
||||
def fetch(self) -> str | None: ...
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]: ...
|
||||
|
||||
@@ -58,6 +63,8 @@ class TxWatcherState:
|
||||
last_seen_digest: str | None = None
|
||||
last_prompted_digest: str | None = None
|
||||
last_authorized_digest: str | None = None
|
||||
channel_test_ready_digest: str | None = None
|
||||
channel_test_complete_digest: str | None = None
|
||||
|
||||
|
||||
class StaticResultSource:
|
||||
@@ -98,6 +105,7 @@ class ManualTxExecutor:
|
||||
"rx_task_id": result.task_id,
|
||||
"rx_result_digest": digest,
|
||||
"rx_result_head": result_head,
|
||||
"source_head": result.source_head,
|
||||
"capture_started_at": result.capture_started_at,
|
||||
"expected_capture_end": result.expected_capture_end,
|
||||
"prepared_at": datetime.now(timezone.utc).isoformat(),
|
||||
@@ -117,16 +125,13 @@ class ManualTxExecutor:
|
||||
def load_state(path: Path) -> TxWatcherState:
|
||||
if not path.exists():
|
||||
return TxWatcherState()
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("TX watcher state must be a JSON object")
|
||||
return TxWatcherState(
|
||||
running=bool(raw.get("running", False)),
|
||||
pid=raw.get("pid"),
|
||||
last_seen_digest=raw.get("last_seen_digest"),
|
||||
last_prompted_digest=raw.get("last_prompted_digest"),
|
||||
last_authorized_digest=raw.get("last_authorized_digest"),
|
||||
)
|
||||
allowed = set(TxWatcherState.__dataclass_fields__)
|
||||
if any(key not in allowed for key in raw):
|
||||
raise ValueError("TX watcher state contains an unknown field")
|
||||
return TxWatcherState(**raw)
|
||||
|
||||
|
||||
def save_state(path: Path, state: TxWatcherState) -> None:
|
||||
@@ -148,7 +153,7 @@ def process_exists(pid: int | None) -> bool:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
except (OSError, SystemError):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -160,7 +165,14 @@ def request_stop(state_path: Path, timeout_s: float) -> bool:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
state = load_state(state_path)
|
||||
if not state.running or not process_exists(state.pid):
|
||||
if not state.running:
|
||||
stop_path.unlink(missing_ok=True)
|
||||
return True
|
||||
if not process_exists(state.pid):
|
||||
state.running = False
|
||||
state.pid = None
|
||||
save_state(state_path, state)
|
||||
stop_path.unlink(missing_ok=True)
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
@@ -206,6 +218,46 @@ def ready_message(result: RxHandoffResult) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _valid_result_head(result_head: str) -> bool:
|
||||
return _HEAD_RE.fullmatch(result_head) is not None
|
||||
|
||||
|
||||
def handle_channel_test(
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
result_head: str,
|
||||
state: TxWatcherState,
|
||||
*,
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> tuple[str, TxWatcherState]:
|
||||
"""Record model-free channel smoke states without opening the RED gate."""
|
||||
|
||||
if digest == state.last_seen_digest:
|
||||
return "DUPLICATE_SUPPRESSED", state
|
||||
state.last_seen_digest = digest
|
||||
if result.task_id != CHANNEL_TEST_TASK_ID:
|
||||
return "WRONG_TASK_ID", state
|
||||
if not _valid_result_head(result_head):
|
||||
return "INVALID_RESULT_COMMIT", state
|
||||
if result.common_clock_verified:
|
||||
return "CHANNEL_TEST_CLAIMS_HARDWARE", state
|
||||
if result.state == READY_STATE:
|
||||
state.channel_test_ready_digest = digest
|
||||
output_function("CHANNEL TEST READY RECEIVED = YES")
|
||||
output_function(f"RX RESULT HEAD = {result_head}")
|
||||
output_function("RF TX = 0")
|
||||
return "CHANNEL_TEST_READY", state
|
||||
if result.state == COMPLETE_STATE:
|
||||
state.channel_test_complete_digest = digest
|
||||
output_function("CHANNEL TEST COMPLETE RECEIVED = YES")
|
||||
output_function(f"RX RESULT HEAD = {result_head}")
|
||||
output_function("RF TX = 0")
|
||||
if state.channel_test_ready_digest is None:
|
||||
return "CHANNEL_TEST_COMPLETE_WITHOUT_READY", state
|
||||
return "CHANNEL_TEST_COMPLETE", state
|
||||
return "CHANNEL_TEST_INVALID_STATE", state
|
||||
|
||||
|
||||
def handle_result(
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
@@ -218,17 +270,30 @@ def handle_result(
|
||||
input_function: Callable[[str], str],
|
||||
output_function: Callable[[str], None] = print,
|
||||
dry_run: bool = False,
|
||||
expected_source_head: str | None = None,
|
||||
max_age_seconds: float = 30.0,
|
||||
min_remaining_seconds: float = 10.0,
|
||||
) -> tuple[str, TxWatcherState]:
|
||||
"""Validate READY, request exact approval, then revalidate remote state."""
|
||||
|
||||
if result.task_id == CHANNEL_TEST_TASK_ID:
|
||||
return handle_channel_test(
|
||||
result,
|
||||
digest,
|
||||
result_head,
|
||||
state,
|
||||
output_function=output_function,
|
||||
)
|
||||
if digest == state.last_seen_digest:
|
||||
return "DUPLICATE_SUPPRESSED", state
|
||||
if not _valid_result_head(result_head):
|
||||
state.last_seen_digest = digest
|
||||
return "INVALID_RESULT_COMMIT", state
|
||||
|
||||
evaluation = evaluate_ready(
|
||||
result,
|
||||
now=now_function(),
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=max_age_seconds,
|
||||
min_remaining_seconds=min_remaining_seconds,
|
||||
)
|
||||
@@ -246,7 +311,7 @@ def handle_result(
|
||||
|
||||
source.fetch()
|
||||
refreshed, refreshed_digest, refreshed_head = source.read()
|
||||
if refreshed_digest != digest:
|
||||
if refreshed_digest != digest or refreshed_head != result_head:
|
||||
output_function("RX RESULT CHANGED; NEW APPROVAL REQUIRED")
|
||||
output_function("RF TX = 0")
|
||||
return "RESULT_CHANGED", state
|
||||
@@ -254,6 +319,7 @@ def handle_result(
|
||||
refreshed_evaluation = evaluate_ready(
|
||||
refreshed,
|
||||
now=now_function(),
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=max_age_seconds,
|
||||
min_remaining_seconds=min_remaining_seconds,
|
||||
)
|
||||
@@ -274,6 +340,18 @@ def handle_result(
|
||||
return "TX_AUTHORIZATION_PREPARED", state
|
||||
|
||||
|
||||
def _git_output(repository: Path, arguments: list[str]) -> str:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=repository,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
state_path = arguments.state_file.resolve()
|
||||
if arguments.request_stop:
|
||||
@@ -287,6 +365,7 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
if arguments.approval is not None and not arguments.dry_run:
|
||||
raise ValueError("--approval is allowed only with --dry-run")
|
||||
|
||||
repository = arguments.repository.resolve()
|
||||
state = load_state(state_path)
|
||||
if state.running and process_exists(state.pid):
|
||||
raise RuntimeError(f"TX watcher already running with PID {state.pid}")
|
||||
@@ -298,12 +377,24 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
|
||||
if arguments.result_file is not None:
|
||||
source: ResultSource = StaticResultSource(load_result(arguments.result_file))
|
||||
expected_source_head = (
|
||||
arguments.expected_source_head or source.result.source_head
|
||||
)
|
||||
else:
|
||||
branch = _git_output(repository, ["branch", "--show-current"])
|
||||
if branch != arguments.source_branch:
|
||||
raise ValueError("TX watcher must run on the configured source branch")
|
||||
expected_source_head = (
|
||||
arguments.expected_source_head
|
||||
or _git_output(repository, ["rev-parse", "HEAD"])
|
||||
)
|
||||
source = GitResultChannel(
|
||||
arguments.repository.resolve(),
|
||||
repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.result_branch,
|
||||
)
|
||||
if not _HEAD_RE.fullmatch(expected_source_head):
|
||||
raise ValueError("expected source HEAD must be a full lowercase Git hash")
|
||||
executor = ManualTxExecutor(arguments.outbox.resolve())
|
||||
|
||||
def input_function(prompt: str) -> str:
|
||||
@@ -316,7 +407,6 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
try:
|
||||
while not stop_path.exists():
|
||||
try:
|
||||
source.fetch()
|
||||
result, digest, result_head = source.read()
|
||||
status, state = handle_result(
|
||||
result,
|
||||
@@ -328,6 +418,7 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
now_function=lambda: datetime.now(timezone.utc),
|
||||
input_function=input_function,
|
||||
dry_run=arguments.dry_run,
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=arguments.max_age,
|
||||
min_remaining_seconds=arguments.min_remaining,
|
||||
)
|
||||
@@ -366,7 +457,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path, default=ROOT)
|
||||
parser.add_argument("--remote", default="origin")
|
||||
parser.add_argument("--source-branch", default="video-development")
|
||||
parser.add_argument("--result-branch", default="rx-node")
|
||||
parser.add_argument("--expected-source-head")
|
||||
parser.add_argument("--poll-interval", type=float, default=5.0)
|
||||
parser.add_argument("--max-age", type=float, default=30.0)
|
||||
parser.add_argument("--min-remaining", type=float, default=10.0)
|
||||
|
||||
Reference in New Issue
Block a user