Harden RF handoff and stream OTA decode
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
TASK_ID = RF001A_OTA_CAPTURE_003
|
||||
TASK_TITLE = Start coordinated RF-001A common-clock OTA capture
|
||||
TASK_ID = RF001A_OTA_CAPTURE_004
|
||||
TASK_TITLE = Start hardened RF-001A common-clock OTA capture
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = bc55d0516dc1ee36cefa1ff096e7d86f60645303
|
||||
EXPECTED_HEAD = 168e03d9e778ab2ec63502552d385ec7be3ef677
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = YES
|
||||
RF_RX = YES
|
||||
@@ -10,70 +10,79 @@ ALLOW_COMMIT = NO
|
||||
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 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
|
||||
# RF-001A RX capture 004
|
||||
|
||||
TASK =
|
||||
START COORDINATED RF-001A COMMON-CLOCK OTA CAPTURE
|
||||
This file is the authoritative current task for the RX laptop. Read
|
||||
`RX_NODE_STATE.md` first. If chat history conflicts with these files or the
|
||||
current Git HEAD, stop and report the conflict.
|
||||
|
||||
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.
|
||||
This task is prepared only. A previous approval does not apply to this new
|
||||
TASK_ID. Do not access HackRF until the user approves this exact YELLOW task.
|
||||
|
||||
One approval for this task covers the read-only bidirectional channel check, other 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. Software and Git gates
|
||||
|
||||
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.
|
||||
1. Run `git pull --ff-only origin video-development`.
|
||||
2. Require branch `video-development` and a clean tracked worktree.
|
||||
3. Require local HEAD to equal `origin/video-development`; `EXPECTED_HEAD`
|
||||
must be its ancestor.
|
||||
4. Confirm the historical channel-test READY and COMPLETE acknowledgements
|
||||
from `origin/tx-node` through the RX watcher.
|
||||
5. Before opening HackRF, run the authenticated no-change publication check:
|
||||
|
||||
## 1. Bidirectional handoff channel gate
|
||||
python -B tools/rx_agent_watcher.py --check-result-write --result-branch rx-node
|
||||
|
||||
Before offering this task for YELLOW approval, the RX watcher must fetch origin/rx-node and origin/tx-node and scan the acknowledgement history from oldest to newest. It must confirm both exact records:
|
||||
Require `RX-NODE WRITE PREFLIGHT = PASS`. This command fetches the branch,
|
||||
performs `git push --dry-run` of its existing HEAD to the same ref, and
|
||||
confirms that the remote HEAD did not change. It creates no commit and no
|
||||
production READY event.
|
||||
6. Run the targeted tests, Ruff, Pyright and `git diff --check` according to
|
||||
the tracked project instructions.
|
||||
7. Validate `configs/rf/rf001a_truth_metadata.json`: waveform SHA256, seed,
|
||||
PHY settings, 100 sequence numbers and 100 packet hashes must agree with
|
||||
`configs/rf/rf001a_common_clock.json`.
|
||||
|
||||
STATE = CHANNEL_READY_OBSERVED
|
||||
SOURCE_RX_COMMIT = 4cb6c8cad1ab5194755c2740c2a5a83933d099d8
|
||||
SOURCE_RX_DIGEST = 23fc54f1f788f068fd4a842daf0a445bd4ec192866b4d89406c74f1797a9952b
|
||||
If any gate fails, do not open HackRF and stop. In particular, a failed Git
|
||||
write preflight blocks the hardware task.
|
||||
|
||||
and:
|
||||
## 2. Optional offline check of capture 003
|
||||
|
||||
STATE = CHANNEL_COMPLETE_OBSERVED
|
||||
SOURCE_RX_COMMIT = 83d91ff5309777ab639a24f774e9d0b8825a11e9
|
||||
SOURCE_RX_DIGEST = 7d98ed2e70e98a087b968c0d65fb1db4277b81f251e075d20583de2e5002b2d6
|
||||
If the existing `RF001A_OTA_CAPTURE_003` file is present locally, analyze it
|
||||
read-only with the streaming cs8 decoder before starting new hardware work:
|
||||
|
||||
Both acknowledgements must use TASK_ID RF_HANDOFF_CHANNEL_TEST_001, the original shared source HEAD and RF_TX = 0. Required watcher output:
|
||||
python -B -X utf8 tools/qpsk_rf_rx.py --iq-file <capture-003-path>\rf001a_ota_capture.cs8 --iq-format cs8 --truth-metadata configs/rf/rf001a_truth_metadata.json --report <new-path-outside-git>\capture003_streaming_report.json
|
||||
|
||||
MAIN READY RECEIVED = CONFIRMED
|
||||
MAIN COMPLETE RECEIVED = CONFIRMED
|
||||
No RF-001A transmission occurred during capture 003. The expected exact
|
||||
packet count is 0. CRC-valid background candidates must not be reported as
|
||||
exact unless their complete packet SHA256 matches the tracked truth metadata.
|
||||
The analysis must use memmap/chunks and must not materialize the full 1.92 GB
|
||||
capture as complex64.
|
||||
|
||||
Do not rerun the channel test. If origin/tx-node is missing or either exact acknowledgement is absent or invalid, do not access HackRF, do not start capture and STOP with RF TX = 0.
|
||||
If capture 003 is unavailable, report `CAPTURE_003 ANALYSIS = NOT_RUN` and
|
||||
continue only after all mandatory gates pass.
|
||||
|
||||
## 2. Preconditions
|
||||
## 3. Hardware preflight after YELLOW approval
|
||||
|
||||
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.
|
||||
3. Require git status --short --untracked-files=no to produce no output.
|
||||
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 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:
|
||||
1. Run `hackrf_info` and require RX serial
|
||||
`000000000000000097c063dc2e5327d3`.
|
||||
2. With `MASTER CLKOUT -> SLAVE CLKIN` connected, run:
|
||||
|
||||
hackrf_clock.exe -d 000000000000000097c063dc2e5327d3 -i
|
||||
|
||||
9. Preserve complete stdout and require the exact line:
|
||||
3. Require `CLKIN status: clock signal detected`.
|
||||
4. Repeat the Git write preflight immediately before the finite RX command.
|
||||
5. Require antennas connected, approximately 2 m distance, clear LOS, and
|
||||
video disabled.
|
||||
6. Require at least 1500000000 free bytes on the capture volume and record the
|
||||
measured value.
|
||||
|
||||
CLKIN status: clock signal detected
|
||||
Do not reconfigure clocks. If CLKIN or the second Git preflight fails, stop
|
||||
before capture.
|
||||
|
||||
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.
|
||||
## 4. Finite RX capture
|
||||
|
||||
If any condition fails, do not start capture. Report the failed condition, keep RF TX = 0, and STOP.
|
||||
|
||||
## 3. Authorized bounded RX capture
|
||||
|
||||
Only this finite receive operation is authorized:
|
||||
Use a unique directory outside Git. Never overwrite an earlier capture.
|
||||
|
||||
RX serial = 000000000000000097c063dc2e5327d3
|
||||
center frequency = 1400000000 Hz
|
||||
@@ -82,239 +91,114 @@ Only this finite receive operation is authorized:
|
||||
RX VGA = 24 dB
|
||||
RF amp = OFF
|
||||
antenna bias = OFF
|
||||
duration = 120 s
|
||||
sample count = 960000000 complex samples
|
||||
duration = 60 s
|
||||
sample count = 480000000 complex samples
|
||||
hardware format = cs8
|
||||
expected file size = 1920000000 bytes
|
||||
common clock = DETECTED
|
||||
expected file size = 960000000 bytes
|
||||
|
||||
The hackrf_transfer.exe arguments are fixed:
|
||||
Use these fixed `hackrf_transfer.exe` arguments:
|
||||
|
||||
-d 000000000000000097c063dc2e5327d3
|
||||
-r <unique_capture_path>\rf001a_ota_capture.cs8
|
||||
-f 1400000000
|
||||
-s 8000000
|
||||
-n 960000000
|
||||
-n 480000000
|
||||
-l 24
|
||||
-g 24
|
||||
-a 0
|
||||
-p 0
|
||||
-B
|
||||
|
||||
Do not add repeat mode. Do not change gain, clock, frequency or sample rate.
|
||||
Use `tools/capture_process_runner.ps1` as a controlled background process.
|
||||
Preserve its complete stdout, stderr, start JSON and result JSON outside Git.
|
||||
The runner must start exactly one finite child, record the actual child PID and
|
||||
UTC start time, set the expected end to start plus 60 seconds, wait for that
|
||||
exact process, record its integer exit code, size and duration, and calculate
|
||||
SHA256 after the output file closes. Never retry the capture automatically.
|
||||
|
||||
## 4. Background runner and durable evidence
|
||||
Within five seconds require the runner and child to be alive and the file to
|
||||
exist and grow. Otherwise publish no READY and stop.
|
||||
|
||||
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:
|
||||
## 5. READY publication
|
||||
|
||||
- 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, 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.
|
||||
Create the strict schema-version-1 READY JSON outside Git for TASK_ID
|
||||
`RF001A_OTA_CAPTURE_004`. Use the actual child PID and UTC timestamps,
|
||||
`expected_capture_end = capture_started_at + 60 s`, current source HEAD,
|
||||
`common_clock_verified = true`, `rf_tx = 0`, and null final result fields.
|
||||
|
||||
Start the runner as a controlled background process. Within five seconds, require all four facts:
|
||||
Publish it once with:
|
||||
|
||||
runner process alive = YES
|
||||
hackrf_transfer child alive = YES
|
||||
capture file created = YES
|
||||
capture file growing = YES
|
||||
python -B tools/rx_agent_watcher.py --publish-result <ready-json> --result-branch rx-node
|
||||
|
||||
If any fact is false, publish no READY state. Preserve local evidence, report RX CAPTURE STARTED = NO, keep RF TX = 0, and STOP.
|
||||
The publisher creates one immutable READY commit. On a recognized transient
|
||||
network or authentication failure it checks the remote branch and may retry
|
||||
the exact same commit once. It never creates a second READY commit or changes
|
||||
the payload between attempts. If the remote already contains that commit, the
|
||||
publication is successful. A second failure means:
|
||||
|
||||
## 5. Publish READY immediately
|
||||
READY publication = FAIL
|
||||
RX READY FOR TX = NO
|
||||
RF TX = 0
|
||||
|
||||
As soon as the child is alive and the file is growing, build a local JSON file outside Git with every field below:
|
||||
Do not publish READY after the capture window has ended. After the process
|
||||
exits, publish only a `CAPTURE_COMPLETE` result with the actual exit code,
|
||||
size, duration and error counters, including nonzero failure values.
|
||||
|
||||
{
|
||||
"schema_version": 1,
|
||||
"task_id": "RF001A_OTA_CAPTURE_003",
|
||||
"state": "RX_READY_FOR_TX",
|
||||
"rx_ready_for_tx": true,
|
||||
"process_alive": true,
|
||||
"file_growing": true,
|
||||
"capture_pid": <actual positive child PID>,
|
||||
"published_at": "<current UTC ISO-8601 time>",
|
||||
"capture_started_at": "<actual UTC ISO-8601 start time>",
|
||||
"expected_capture_end": "<start time plus 120 seconds>",
|
||||
"capture_path": "<absolute local capture path>",
|
||||
"common_clock_verified": true,
|
||||
"source_head": "<current local 40-character Git HEAD>",
|
||||
"rf_tx": 0,
|
||||
"exit_code": null,
|
||||
"file_size": null,
|
||||
"duration_seconds": null,
|
||||
"overruns": null,
|
||||
"usb_errors": null,
|
||||
"file_errors": null,
|
||||
"sha256": null,
|
||||
"clkin_after": "NOT_RUN",
|
||||
"rf_tx_observed": "UNKNOWN"
|
||||
}
|
||||
|
||||
Publish it with the approved RX watcher state:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --publish-result <ready-json-path> --result-branch rx-node
|
||||
|
||||
The publisher may change only:
|
||||
|
||||
docs/handoff/results/RX_RESULT.json
|
||||
docs/handoff/results/RX_RESULT.md
|
||||
|
||||
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.
|
||||
|
||||
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_003
|
||||
|
||||
RX CAPTURE STARTED =
|
||||
YES
|
||||
|
||||
RX READY FOR TX =
|
||||
YES
|
||||
After the remote HEAD confirms the READY commit, immediately print:
|
||||
|
||||
TASK_ID = RF001A_OTA_CAPTURE_004
|
||||
RX CAPTURE STARTED = YES
|
||||
RX READY FOR TX = YES
|
||||
CAPTURE PID =
|
||||
|
||||
CAPTURE PATH =
|
||||
|
||||
CAPTURE START TIME =
|
||||
|
||||
EXPECTED CAPTURE END =
|
||||
RX RESULT HEAD =
|
||||
RF TX = 0
|
||||
|
||||
RX RESULT PUBLISH =
|
||||
PASS
|
||||
Return control without waiting for capture completion.
|
||||
|
||||
RX RESULT REMOTE HEAD =
|
||||
## 6. Completion and offline decode
|
||||
|
||||
RF TX =
|
||||
0
|
||||
After the child exits, record exact exit code, actual size, duration, overruns,
|
||||
USB errors, file-write errors and SHA256. A complete capture is exactly
|
||||
960000000 bytes; a smaller file is incomplete. Run the CLKIN check again and
|
||||
require detected status.
|
||||
|
||||
The READY state describes a live receiver only. It does not authorize RF TX on either computer.
|
||||
Publish an honest schema-version-1 COMPLETE result with final values. Keep IQ,
|
||||
stdout, stderr and large logs outside Git.
|
||||
|
||||
## 6. Capture completion
|
||||
After `origin/tx-node` contains `TX_STARTED` and `TX_COMPLETE` for
|
||||
`RF001A_OTA_TX_004`, run the streaming decoder with RX/TX timestamps:
|
||||
|
||||
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.
|
||||
python -B -X utf8 tools/qpsk_rf_rx.py --iq-file <capture-path>\rf001a_ota_capture.cs8 --iq-format cs8 --truth-metadata configs/rf/rf001a_truth_metadata.json --capture-started-at <CAPTURE_STARTED_AT> --tx-started-at <TX_STARTED_AT> --tx-finished-at <TX_FINISHED_AT> --report <new-path-outside-git>\rf001a_streaming_report.json
|
||||
|
||||
Run the read-only clock check again:
|
||||
The decoder uses a two-second margin before and after the timestamped burst.
|
||||
Without complete timestamps it uses the streaming power locator. Chunk overlap
|
||||
is derived from the real 256-byte RF-001 waveform and duplicate detections from
|
||||
overlap are suppressed.
|
||||
|
||||
hackrf_clock.exe -d 000000000000000097c063dc2e5327d3 -i
|
||||
## 7. Prohibited actions
|
||||
|
||||
Require CLKIN to remain detected. The expected complete capture has:
|
||||
RF TX, firmware update, CPLD update, Zadig, USB driver changes, clock
|
||||
reconfiguration, commit or push to `video-development`, repeated capture, raw
|
||||
IQ/video/waveform publication and large-log publication are forbidden.
|
||||
|
||||
exit code = 0
|
||||
file size = 1920000000 bytes
|
||||
duration = consistent with 120 s
|
||||
RX overruns = 0 or NOT_REPORTED
|
||||
USB errors = 0 or NOT_REPORTED
|
||||
file write errors = 0 or NOT_REPORTED
|
||||
CLKIN after capture = DETECTED
|
||||
|
||||
A shorter file is incomplete. Any reported nonzero error remains evidence. Do not retry automatically.
|
||||
|
||||
## 7. Publish final result
|
||||
|
||||
Build a second local JSON document with the same identity and timing fields. Set:
|
||||
|
||||
state = CAPTURE_COMPLETE
|
||||
rx_ready_for_tx = false
|
||||
process_alive = false
|
||||
file_growing = false
|
||||
exit_code = <exact integer>
|
||||
file_size = <actual integer bytes>
|
||||
duration_seconds = <actual number>
|
||||
overruns = <integer or null when not reported>
|
||||
usb_errors = <integer or null when not reported>
|
||||
file_errors = <integer or null when not reported>
|
||||
sha256 = <lowercase 64-character SHA256>
|
||||
clkin_after = DETECTED or NOT_DETECTED
|
||||
rf_tx_observed = EXPECTED_EXTERNAL_TX or UNKNOWN
|
||||
published_at = <current UTC ISO-8601 time>
|
||||
rf_tx = 0
|
||||
|
||||
Use EXPECTED_EXTERNAL_TX only when the coordinated MAIN TX event is known to have occurred inside this capture window. Otherwise use UNKNOWN.
|
||||
|
||||
Publish with:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --publish-result <final-json-path> --result-branch rx-node
|
||||
|
||||
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.
|
||||
|
||||
## 8. Offline decode
|
||||
|
||||
Use the tracked synchronized truth artifact without copying TX files manually:
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## 9. Prohibited actions
|
||||
|
||||
On the RX laptop, do not perform:
|
||||
|
||||
- RF TX;
|
||||
- continuous RX;
|
||||
- automatic capture retry;
|
||||
- firmware or CPLD update;
|
||||
- hackrf_spiflash;
|
||||
- Zadig or USB driver changes;
|
||||
- clock reconfiguration;
|
||||
- commit or push to video-development;
|
||||
- creation of rx-node;
|
||||
- publication of raw IQ, video, waveform or large logs.
|
||||
|
||||
## 10. Final RX report
|
||||
|
||||
TASK_ID =
|
||||
RF001A_OTA_CAPTURE_003
|
||||
|
||||
CHANNEL TEST =
|
||||
|
||||
RX CAPTURE COMPLETE =
|
||||
|
||||
EXIT CODE =
|
||||
Final report:
|
||||
|
||||
TASK_ID = RF001A_OTA_CAPTURE_004
|
||||
RX-NODE WRITE PREFLIGHT = PASS / FAIL
|
||||
COMMON CLOCK = VERIFIED / NOT_VERIFIED
|
||||
RX CAPTURE COMPLETE = YES / NO
|
||||
CAPTURE SIZE =
|
||||
|
||||
CAPTURE DURATION =
|
||||
|
||||
RX OVERRUNS =
|
||||
|
||||
USB ERRORS =
|
||||
|
||||
FILE WRITE ERRORS =
|
||||
|
||||
RAW IQ SHA256 =
|
||||
|
||||
CLKIN AFTER =
|
||||
|
||||
RF TX OBSERVED =
|
||||
|
||||
FINAL RX RESULT PUBLISH =
|
||||
|
||||
FINAL RX RESULT REMOTE HEAD =
|
||||
|
||||
OFFLINE DECODE =
|
||||
|
||||
EXACT PACKETS =
|
||||
|
||||
MISSING =
|
||||
|
||||
DUPLICATES =
|
||||
|
||||
BER =
|
||||
|
||||
POST-FEC ERRORS =
|
||||
|
||||
RF TX =
|
||||
0
|
||||
STREAMING DECODE = PASS / FAIL / NOT_RUN
|
||||
EXACT RF001A PACKETS =
|
||||
PEAK ANALYSIS MEMORY =
|
||||
RF TX = 0
|
||||
|
||||
STOP.
|
||||
|
||||
@@ -1,142 +1,96 @@
|
||||
TASK_ID = RF001A_OTA_TX_003
|
||||
TASK_TITLE = Transmit one coordinated RF-001A QPSK burst
|
||||
TASK_ID = RF001A_OTA_TX_004
|
||||
TASK_TITLE = Send one finite RF-001A burst into RX capture 004
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = bc55d0516dc1ee36cefa1ff096e7d86f60645303
|
||||
EXPECTED_HEAD = 168e03d9e778ab2ec63502552d385ec7be3ef677
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = YES
|
||||
RF_RX = NO
|
||||
RF_TX = YES
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = NO
|
||||
ALLOW_PUSH = TX_RESULT_ONLY
|
||||
RISK_LEVEL = RED
|
||||
|
||||
This file is the authoritative current task for the MAIN PC TX node.
|
||||
Read TX_NODE_STATE.md first. If chat history, this file, the shared RF-001A spec or current Git HEAD conflicts, stop and report the conflict.
|
||||
---
|
||||
|
||||
# Current TX task
|
||||
# RF-001A TX 004
|
||||
|
||||
TASK =
|
||||
TRANSMIT ONE COORDINATED RF-001A QPSK BURST
|
||||
This task is prepared only. Do not execute it now. Old approval for any earlier
|
||||
TASK_ID is invalid. The MAIN watcher waits for a fresh
|
||||
`RF001A_OTA_CAPTURE_004` READY event from `origin/rx-node`.
|
||||
|
||||
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. Watcher gate
|
||||
|
||||
## 1. Coordination gate
|
||||
The watcher must require all of the following:
|
||||
|
||||
Start the local model-free watcher before the RX task begins:
|
||||
- RX TASK_ID = `RF001A_OTA_CAPTURE_004`;
|
||||
- state = `RX_READY_FOR_TX`;
|
||||
- source HEAD is the current `video-development` HEAD;
|
||||
- COMMON_CLOCK_VERIFIED = YES;
|
||||
- live child and growing capture are both true;
|
||||
- planned RX duration is 59–61 seconds;
|
||||
- READY is no more than 30 seconds old;
|
||||
- at least 10 seconds remain in the capture window;
|
||||
- the observed commit is the current valid `origin/rx-node` HEAD.
|
||||
|
||||
tools\start_tx_watcher.cmd
|
||||
The watcher then requests exactly:
|
||||
|
||||
The watcher polls origin/rx-node every 5 seconds. It processes every new result-branch commit from `last_processed_rx_node_commit` to the current `origin/rx-node` tip in chronological order. It accepts only a strict result for:
|
||||
|
||||
RX TASK_ID = RF001A_OTA_CAPTURE_003
|
||||
STATE = RX_READY_FOR_TX
|
||||
RX_READY_FOR_TX = YES
|
||||
PROCESS_ALIVE = YES
|
||||
FILE_GROWING = YES
|
||||
COMMON_CLOCK_VERIFIED = YES
|
||||
RF_TX = 0
|
||||
|
||||
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:
|
||||
|
||||
RX IS READY
|
||||
|
||||
TASK ID =
|
||||
CAPTURE STARTED =
|
||||
CAPTURE END EXPECTED =
|
||||
|
||||
TX TASK =
|
||||
RF001A_OTA_TX_003
|
||||
|
||||
RF TX REQUIRES RED APPROVAL
|
||||
|
||||
Enter:
|
||||
AUTHORIZE RF TX
|
||||
|
||||
Without the exact phrase AUTHORIZE RF TX, do not transmit.
|
||||
Any other response blocks TX. After the exact phrase, fetch `origin/rx-node`
|
||||
again and repeat every commit, digest, task, source HEAD, freshness, common
|
||||
clock and remaining-time check. A changed or stale result requires a new RX
|
||||
capture and a new exact RED approval.
|
||||
|
||||
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.
|
||||
On success publish a schema-version-2 `TX_AUTHORIZED` event to `origin/tx-node`
|
||||
with the exact source RX commit and digest, TX TASK_ID `RF001A_OTA_TX_004`,
|
||||
`RF_TX_AUTHORIZED = YES`, `RF_TX_STARTED = NO` and `RF_TX = 0`. Also write the
|
||||
local authorization record. This event is permission evidence, not proof of a
|
||||
transmission.
|
||||
|
||||
The watcher never opens HackRF hardware. On success it writes one local authorization record under:
|
||||
## 2. Read-only TX preflight
|
||||
|
||||
.local\tx_authorizations\
|
||||
Before opening TX hardware:
|
||||
|
||||
The record has rf_tx_started = false and rf_tx = 0. It is the handoff to the separately controlled TX executor. It is not evidence that transmission occurred.
|
||||
|
||||
## 2. Fixed RF-001A parameters
|
||||
|
||||
center frequency = 1400000000 Hz
|
||||
sample rate = 8000000 samples/s
|
||||
modulation = QPSK
|
||||
symbol rate = 500000 symbols/s
|
||||
inner FEC = 2/3
|
||||
payload = 256 bytes
|
||||
packet count = 100
|
||||
inter-packet gap = 5 ms
|
||||
digital carrier offset = +125000 Hz
|
||||
TX VGA = 20 dB
|
||||
RF amp = OFF
|
||||
antenna bias = OFF
|
||||
common 10 MHz reference = VERIFIED
|
||||
distance = approximately 2 m
|
||||
LOS = YES
|
||||
video = OFF
|
||||
|
||||
The waveform is one finite burst. Never add repeat mode.
|
||||
|
||||
## 3. Read-only preflight
|
||||
|
||||
Before the watcher can request RED approval:
|
||||
|
||||
1. Run git pull --ff-only origin video-development.
|
||||
2. Require branch video-development.
|
||||
3. Require git status --short --untracked-files=no to produce no output.
|
||||
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 TX serial 000000000000000097c063dc323b97d3.
|
||||
7. Require the recorded MASTER CLKOUT command result = PASS.
|
||||
8. Require confirmation that both antennas are connected, the nodes are approximately 2 m apart, and LOS is clear.
|
||||
9. Verify the prepared waveform without radio access:
|
||||
1. Run `git pull --ff-only origin video-development`.
|
||||
2. Require branch `video-development`, a clean tracked worktree and local HEAD
|
||||
equal to `origin/video-development`; `EXPECTED_HEAD` must be its ancestor.
|
||||
3. Run `hackrf_info`; require TX serial
|
||||
`000000000000000097c063dc323b97d3`.
|
||||
4. Require the recorded MASTER CLKOUT command = PASS, both antennas connected,
|
||||
approximately 2 m distance, clear LOS, and video disabled.
|
||||
5. Verify the waveform without radio access:
|
||||
|
||||
python -B -X utf8 tools/rf001a_prepare.py --verify-dir data/raw/rf001/rf001a_common_clock_001
|
||||
|
||||
10. Verify the actual cs8 bytes through the existing offline receiver:
|
||||
6. Require:
|
||||
|
||||
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:
|
||||
|
||||
canonical waveform = PASS
|
||||
hardware-format cs8 = PASS
|
||||
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 = 75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd
|
||||
waveform path = data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8
|
||||
waveform size = 17667656 bytes
|
||||
waveform SHA256 = 75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd
|
||||
sample count = 8833828 complex samples
|
||||
expected duration = 1.1042285 s
|
||||
duration = 1.1042285 s
|
||||
offline exact packets = 100/100
|
||||
|
||||
If any value differs, do not transmit.
|
||||
|
||||
## 4. Final check before opening TX hardware
|
||||
## 3. Final executor gate
|
||||
|
||||
The controlled TX executor must read the authorization record immediately before TX and require:
|
||||
Immediately before hardware access, the controlled executor must read the
|
||||
local authorization and re-fetch `origin/rx-node`. Require:
|
||||
|
||||
- task_id = RF001A_OTA_TX_003;
|
||||
- authorization = AUTHORIZE RF TX;
|
||||
- rx_task_id = RF001A_OTA_CAPTURE_003;
|
||||
- dry_run = false;
|
||||
- action = MANUAL_EXECUTOR_GATE_OPEN;
|
||||
- rf_tx_started = false;
|
||||
- rf_tx = 0;
|
||||
- the authorization references the latest fetched rx-node result commit and digest;
|
||||
- current time is still inside the READY capture window with at least 10 seconds remaining.
|
||||
- TX TASK_ID = `RF001A_OTA_TX_004`;
|
||||
- RX TASK_ID = `RF001A_OTA_CAPTURE_004`;
|
||||
- authorization phrase = `AUTHORIZE RF TX`;
|
||||
- authorization binds the current READY commit and digest;
|
||||
- `rf_tx_started = false` and `rf_tx = 0` in the authorization record;
|
||||
- at least 10 seconds remain in the same READY window.
|
||||
|
||||
Fetch origin/rx-node once more and compare its current result digest and commit with the authorization record. Re-run the READY and time checks. If anything differs, if the result is stale, or if fewer than 10 seconds remain, do not transmit and require a new RX capture and a new exact RED approval.
|
||||
If any check fails, do not open HackRF.
|
||||
|
||||
## 5. One authorized TX execution
|
||||
## 4. One finite TX burst
|
||||
|
||||
Only after every preceding condition passes may the executor create a new unique evidence directory outside Git and run exactly one finite command:
|
||||
Only after all gates pass may the executor create a unique evidence directory
|
||||
outside Git and run exactly one finite command:
|
||||
|
||||
hackrf_transfer.exe
|
||||
-d 000000000000000097c063dc323b97d3
|
||||
@@ -149,71 +103,48 @@ Only after every preceding condition passes may the executor create a new unique
|
||||
-p 0
|
||||
-B
|
||||
|
||||
Run it once, synchronously, and preserve:
|
||||
Never add repeat mode and never retry automatically.
|
||||
|
||||
- exact process PID;
|
||||
- start and end UTC timestamps;
|
||||
- exact exit code;
|
||||
- complete stdout and stderr;
|
||||
- every TX underrun line;
|
||||
- every USB error;
|
||||
- waveform path, size and SHA256;
|
||||
- command parameters.
|
||||
At the exact process start, publish `TX_STARTED` to `origin/tx-node`, preserving
|
||||
the authorization binding and recording `TX_STARTED_AT` as accurately as the
|
||||
host permits. Set `RF_TX_STARTED = YES` and `RF_TX = 1` because this event is
|
||||
evidence of an actual started process.
|
||||
|
||||
Set RF TX STOPPED = YES only after the finite process exits. Never retry automatically. Never start a second burst.
|
||||
Write the strict schema-version-2 event JSON outside Git and publish it with:
|
||||
|
||||
## 6. Final TX report
|
||||
python -B tools/tx_agent_watcher.py --publish-tx-event <started-json> --acknowledgement-branch tx-node
|
||||
|
||||
TASK_ID =
|
||||
RF001A_OTA_TX_003
|
||||
After that exact process exits, publish `TX_COMPLETE` with `TX_FINISHED_AT`,
|
||||
the integer process exit code, underrun count and error count. Preserve the
|
||||
complete stdout and stderr outside Git. The event reports what happened; it
|
||||
does not by itself prove that packets were received. Captured IQ remains the
|
||||
source for exact packet verification.
|
||||
|
||||
RED AUTHORIZATION =
|
||||
AUTHORIZE RF TX / MISSING
|
||||
Publish the completion JSON with the same `--publish-tx-event` command. The
|
||||
publisher requires the matching `TX_AUTHORIZED` and `TX_STARTED` history.
|
||||
|
||||
RX READY FOR TX =
|
||||
YES / NO
|
||||
The only files permitted on `tx-node` are:
|
||||
|
||||
docs/handoff/results/TX_RESULT.json
|
||||
docs/handoff/results/TX_RESULT.md
|
||||
|
||||
Do not publish waveform, IQ, video, source code, local state or large logs.
|
||||
|
||||
## 5. Final report
|
||||
|
||||
TASK_ID = RF001A_OTA_TX_004
|
||||
RX READY FOR TX = YES / NO
|
||||
RX RESULT HEAD =
|
||||
|
||||
RX RESULT DIGEST =
|
||||
|
||||
TX SERIAL =
|
||||
|
||||
TX WAVEFORM SHA256 =
|
||||
|
||||
TX START TIME =
|
||||
|
||||
TX END TIME =
|
||||
|
||||
TX EXIT CODE =
|
||||
|
||||
TX AUTHORIZED EVENT = PASS / FAIL
|
||||
TX STARTED AT =
|
||||
TX FINISHED AT =
|
||||
TX PROCESS EXIT CODE =
|
||||
TX UNDERRUNS =
|
||||
TX ERRORS =
|
||||
TX COMPLETE EVENT = PASS / FAIL
|
||||
RF TX STOPPED = YES / NO
|
||||
|
||||
USB ERRORS =
|
||||
|
||||
RF TX STOPPED =
|
||||
YES / NO
|
||||
|
||||
BURSTS TRANSMITTED =
|
||||
0 / 1
|
||||
|
||||
RF TX =
|
||||
0
|
||||
|
||||
At report time, RF TX = 0 means no transmission process remains active.
|
||||
|
||||
## 7. Prohibited actions
|
||||
|
||||
Do not:
|
||||
|
||||
- transmit without the fresh READY state and exact RED phrase;
|
||||
- reuse an earlier authorization record;
|
||||
- use repeat mode;
|
||||
- transmit a second burst;
|
||||
- retry automatically;
|
||||
- change frequency, gains, clock configuration or waveform;
|
||||
- update firmware or CPLD;
|
||||
- use Zadig or change USB drivers;
|
||||
- commit or push from this task.
|
||||
|
||||
STOP after the single finite burst or any failed gate.
|
||||
STOP after the one finite burst and event publication. Firmware, CPLD, Zadig,
|
||||
USB driver changes, clock reconfiguration, a second burst, commit or push to
|
||||
`video-development` are forbidden.
|
||||
|
||||
@@ -12,7 +12,7 @@ The MAIN-to-RX acknowledgement channel uses the independent origin/tx-node branc
|
||||
|
||||
docs/handoff/NEXT_RX_TASK.md starts with these plain key-value fields:
|
||||
|
||||
TASK_ID = RF001A_OTA_CAPTURE_003
|
||||
TASK_ID = RF001A_OTA_CAPTURE_004
|
||||
TASK_TITLE = Start coordinated RF-001A common-clock OTA capture
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = <full 40-character base commit hash>
|
||||
@@ -73,11 +73,11 @@ 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_003;
|
||||
- task_id = RF001A_OTA_CAPTURE_004;
|
||||
- state = RX_READY_FOR_TX;
|
||||
- rx_ready_for_tx, process_alive, file_growing and common_clock_verified all true;
|
||||
- a positive capture_pid;
|
||||
- 120-second planned duration;
|
||||
- 60-second planned duration;
|
||||
- rf_tx = 0;
|
||||
- final capture fields set to null.
|
||||
|
||||
@@ -95,6 +95,12 @@ Timestamps use timezone-aware ISO-8601. source_head is the actual local 40-chara
|
||||
|
||||
## Publishing to rx-node
|
||||
|
||||
Before any production hardware access, run
|
||||
`python -B tools/rx_agent_watcher.py --check-result-write`. It performs an
|
||||
authenticated fetch, a no-change `git push --dry-run` of the existing rx-node
|
||||
HEAD to the same ref, and a second remote HEAD check. Failure blocks capture.
|
||||
The check creates no commit and no READY event.
|
||||
|
||||
Publish a prepared local JSON file with:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --publish-result <result-json> --result-branch rx-node
|
||||
@@ -114,7 +120,14 @@ It checks the branch with git fetch and git ls-remote through the real origin co
|
||||
docs/handoff/results/RX_RESULT.json
|
||||
docs/handoff/results/RX_RESULT.md
|
||||
|
||||
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.
|
||||
It checks the staged path set, creates one immutable commit, pushes that exact
|
||||
commit hash to rx-node, and confirms that `git ls-remote` returns it. After a
|
||||
recognized transient network or authentication error, it first checks whether
|
||||
the commit already reached the remote. If not, it fetches and may retry that
|
||||
same commit once. It never creates a second READY commit or changes the payload
|
||||
between attempts. 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.
|
||||
|
||||
@@ -132,7 +145,12 @@ For each successfully handled channel-test event, the MAIN watcher publishes a s
|
||||
|
||||
The tx-node publisher stages exactly `docs/handoff/results/TX_RESULT.json` and `docs/handoff/results/TX_RESULT.md`, checks the staged path allowlist and enforces 64 KiB per file. A push failure prevents the rx-node cursor from advancing. Duplicate acknowledgements are suppressed by their source binding. No local state, code, IQ, video, waveform or large log is published.
|
||||
|
||||
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.
|
||||
A successful gate publishes a schema-version-2 `TX_AUTHORIZED` event and 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. It then publishes `TX_STARTED` at process start and
|
||||
`TX_COMPLETE` after exit with UTC timestamps, exit code, underruns and errors.
|
||||
|
||||
Duplicate result digests are not shown repeatedly.
|
||||
|
||||
@@ -147,6 +165,6 @@ Duplicate result digests are not shown repeatedly.
|
||||
|
||||
## Bidirectional channel and dry-run contracts
|
||||
|
||||
The completed preflight channel command published synthetic READY and COMPLETE results with TASK_ID RF_HANDOFF_CHANNEL_TEST_001 through origin/rx-node. The MAIN watcher observed both without a RED prompt. The RX watcher now requires exact matching acknowledgements in origin/tx-node before RF001A_OTA_CAPTURE_003 can pass its software gate.
|
||||
The completed preflight channel command published synthetic READY and COMPLETE results with TASK_ID RF_HANDOFF_CHANNEL_TEST_001 through origin/rx-node. The MAIN watcher observed both without a RED prompt. The RX watcher now requires exact matching acknowledgements in origin/tx-node before RF001A_OTA_CAPTURE_004 can pass its software gate.
|
||||
|
||||
Automated coordination tests use a temporary local bare Git repository and independent rx-node and tx-node branches. They cover exact commit and digest binding, duplicate suppression, two commits between polls, stale acknowledgement rejection, missing tx-node, push failure, non-fast-forward protection and a full fake two-way flow without accessing the real remote or SDR hardware and without transmitting RF.
|
||||
|
||||
@@ -10,7 +10,7 @@ The watcher stays in a visible console and polls origin/video-development every
|
||||
|
||||
## Idle
|
||||
|
||||
While no new TASK_ID exists, the watcher performs only git fetch, reads the remote task file and compares IDs with .local/rx_agent_state.json. There is no model execution or token use. For RF001A_OTA_CAPTURE_003 it also verifies the required historical acknowledgements on origin/tx-node before offering the task for approval.
|
||||
While no new TASK_ID exists, the watcher performs only git fetch, reads the remote task file and compares IDs with .local/rx_agent_state.json. There is no model execution or token use. For RF001A_OTA_CAPTURE_004 it also verifies the required historical acknowledgements on origin/tx-node and performs the authenticated no-change rx-node write preflight before offering the task for approval.
|
||||
|
||||
## New task
|
||||
|
||||
@@ -41,7 +41,9 @@ When the task is actually complete, run:
|
||||
|
||||
## Publish READY
|
||||
|
||||
For RF001A_OTA_CAPTURE_003, first require the watcher to print:
|
||||
For RF001A_OTA_CAPTURE_004, first require the watcher to print:
|
||||
|
||||
RX-NODE WRITE PREFLIGHT = PASS
|
||||
|
||||
MAIN READY RECEIVED = CONFIRMED
|
||||
MAIN COMPLETE RECEIVED = CONFIRMED
|
||||
@@ -65,6 +67,14 @@ The command fails if:
|
||||
|
||||
A failed publication is not READY evidence and cannot authorize TX.
|
||||
|
||||
Immediately before hardware access, repeat the same non-mutating check:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --check-result-write --result-branch rx-node
|
||||
|
||||
The READY publisher retries only one recognized transient failure. It checks
|
||||
the remote before retrying and pushes the same immutable commit; it does not
|
||||
create a replacement READY commit.
|
||||
|
||||
## Verified model-free channel evidence
|
||||
|
||||
The completed RF_HANDOFF_CHANNEL_TEST_001 evidence is bound to READY commit `4cb6c8cad1ab5194755c2740c2a5a83933d099d8` and COMPLETE commit `83d91ff5309777ab639a24f774e9d0b8825a11e9`. The RX watcher accepts the gate only after origin/tx-node contains the matching MAIN acknowledgements. This read-only check performs no capture and keeps RF TX at 0.
|
||||
|
||||
@@ -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_003",
|
||||
"task_id": "RF001A_OTA_CAPTURE_004",
|
||||
"state": "RX_READY_FOR_TX",
|
||||
"rx_ready_for_tx": true,
|
||||
"process_alive": true,
|
||||
@@ -14,7 +14,7 @@ The publisher creates both docs/handoff/results/RX_RESULT.json and docs/handoff/
|
||||
"capture_pid": 1234,
|
||||
"published_at": "2026-09-21T12:00:02+00:00",
|
||||
"capture_started_at": "2026-09-21T12:00:00+00:00",
|
||||
"expected_capture_end": "2026-09-21T12:02:00+00:00",
|
||||
"expected_capture_end": "2026-09-21T12:01:00+00:00",
|
||||
"capture_path": "C:\\Users\\Professional\\Documents\\SDR_Rover_Captures\\<run>\\rf001a_ota_capture.cs8",
|
||||
"common_clock_verified": true,
|
||||
"source_head": "<40-character lowercase Git HEAD>",
|
||||
|
||||
@@ -44,19 +44,37 @@ The acknowledgement operation is ordinary Python and Git. It does not invoke Cha
|
||||
|
||||
RF_HANDOFF_CHANNEL_TEST_001 READY and COMPLETE states are recorded and acknowledged 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:
|
||||
For a fresh valid RF001A_OTA_CAPTURE_004 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 and commit, the expected source HEAD, a fresh publication, an active 120-second capture window, verified common clock and at least 10 seconds remaining. An expired historical READY is recorded as observed but cannot create an authorization.
|
||||
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 60-second capture window, verified common clock and at least 10 seconds remaining. An expired historical READY is recorded as observed but cannot create an authorization.
|
||||
|
||||
On success it writes:
|
||||
|
||||
.local\tx_authorizations\RF001A_OTA_TX_003_authorized.json
|
||||
.local\tx_authorizations\RF001A_OTA_TX_004_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.
|
||||
The watcher also publishes `TX_AUTHORIZED` to origin/tx-node. 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`.
|
||||
|
||||
At actual process start the executor publishes `TX_STARTED` with the most
|
||||
accurate available UTC timestamp. After process exit it publishes
|
||||
`TX_COMPLETE` with finish time, integer exit code, underruns and errors. These
|
||||
events preserve the exact RX READY commit and digest binding. They do not
|
||||
replace verification of packets from captured IQ.
|
||||
|
||||
The executor writes each strict schema-version-2 event JSON outside Git, then
|
||||
uses the model-free publisher:
|
||||
|
||||
python -B tools/tx_agent_watcher.py --publish-tx-event <event-json> --acknowledgement-branch tx-node
|
||||
|
||||
The publisher requires a clean, current `video-development` checkout. A
|
||||
`TX_STARTED` event requires a matching `TX_AUTHORIZED` in tx-node. A
|
||||
`TX_COMPLETE` event also requires the matching `TX_STARTED` timestamp.
|
||||
|
||||
The TX executor must re-fetch rx-node and compare the result commit and digest immediately before hardware access.
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@
|
||||
"tools/qpsk_rf_common.py",
|
||||
"tools/qpsk_rf_tx.py",
|
||||
"tools/qpsk_rf_rx.py",
|
||||
"tools/rf001a_streaming.py",
|
||||
"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/tx_handoff_channel.py",
|
||||
"tools/clock001.py",
|
||||
"tests/test_video005_iq.py",
|
||||
"tests/test_video005_video.py",
|
||||
@@ -34,6 +36,9 @@
|
||||
"tests/test_rf001a_prepare.py",
|
||||
"tests/test_rx_agent_watcher.py",
|
||||
"tests/test_rf_handoff_coordination.py",
|
||||
"tests/test_rf_handoff_reliability.py",
|
||||
"tests/test_rf001a_streaming.py",
|
||||
"tests/test_tx_handoff_acknowledgement.py",
|
||||
"tests/test_capture_process_runner.py",
|
||||
"tests/test_rf001a_truth_metadata.py",
|
||||
"tests/test_clock001.py"
|
||||
|
||||
109
tests/test_rf001a_streaming.py
Normal file
109
tests/test_rf001a_streaming.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Проверки оконного cs8-декодера RF-001A без SDR и RF TX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol.video003_modem import build_frame
|
||||
from protocol.video005_iq_waveform import shape_packet
|
||||
from tools.qpsk_rf_common import SEED, payload_for_sequence
|
||||
from tools.qpsk_rf_rx import decode_file
|
||||
from tools.rf001a_streaming import (
|
||||
MemoryPeakSampler,
|
||||
SampleWindow,
|
||||
count_cs8_clipping,
|
||||
cs8_sample_count,
|
||||
decode_cs8_streaming,
|
||||
read_cs8_window,
|
||||
receive_cs8_windows,
|
||||
required_overlap_samples,
|
||||
rf001_packet_waveform_samples,
|
||||
timestamp_sample_window,
|
||||
)
|
||||
|
||||
|
||||
def _write_cs8(path: Path, iq: np.ndarray) -> None:
|
||||
peak = float(np.max(np.abs(iq))) if iq.size else 1.0
|
||||
normalized = iq / max(peak, 1e-12) * 0.75
|
||||
interleaved = np.empty(iq.size * 2, dtype=np.int8)
|
||||
interleaved[0::2] = np.rint(normalized.real * 127).astype(np.int8)
|
||||
interleaved[1::2] = np.rint(normalized.imag * 127).astype(np.int8)
|
||||
interleaved.tofile(path)
|
||||
|
||||
|
||||
def test_timestamp_to_sample_window_uses_safe_margins() -> None:
|
||||
capture = datetime(2026, 9, 22, 12, 0, tzinfo=timezone.utc)
|
||||
window = timestamp_sample_window(
|
||||
capture_started_at=capture.isoformat(),
|
||||
tx_started_at=(capture + timedelta(seconds=10)).isoformat(),
|
||||
tx_finished_at=(capture + timedelta(seconds=11.1042285)).isoformat(),
|
||||
total_samples=160_000_000,
|
||||
margin_seconds=2.0,
|
||||
)
|
||||
assert window.start == 64_000_000
|
||||
assert window.stop == 104_833_824
|
||||
|
||||
|
||||
def test_chunked_cs8_reading_from_large_sparse_capture(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "large_sparse.cs8"
|
||||
with capture.open("wb") as stream:
|
||||
stream.seek(1_920_000_000 - 1)
|
||||
stream.write(b"\0")
|
||||
raw = np.memmap(capture, dtype=np.int8, mode="r")
|
||||
window = read_cs8_window(raw, 900_000_000, 900_001_000)
|
||||
assert capture.stat().st_size == 1_920_000_000
|
||||
assert cs8_sample_count(capture) == 960_000_000
|
||||
assert window.dtype == np.complex64
|
||||
assert window.size == 1_000
|
||||
assert window.nbytes == 8_000
|
||||
with MemoryPeakSampler() as memory:
|
||||
assert count_cs8_clipping(capture) == 0
|
||||
assert memory.additional_peak_bytes < 512_000_000
|
||||
|
||||
|
||||
def test_packet_across_chunk_boundary_is_decoded_once(tmp_path: Path) -> None:
|
||||
payload = payload_for_sequence(SEED, 7)
|
||||
frame = build_frame(payload, "QPSK", "2/3", sequence_number=7)
|
||||
waveform = shape_packet(frame)
|
||||
chunk_samples = 120_000
|
||||
packet_start = chunk_samples - waveform.size // 2
|
||||
iq = np.zeros(chunk_samples * 2, dtype=np.complex64)
|
||||
iq[packet_start:packet_start + waveform.size] = waveform
|
||||
capture = tmp_path / "boundary.cs8"
|
||||
_write_cs8(capture, iq)
|
||||
|
||||
result = receive_cs8_windows(
|
||||
capture,
|
||||
(SampleWindow(0, iq.size),),
|
||||
decoder_backend="legacy",
|
||||
chunk_samples=chunk_samples,
|
||||
)
|
||||
|
||||
assert required_overlap_samples() == rf001_packet_waveform_samples() == 48_865
|
||||
assert [packet.packet for packet in result.reception.packets] == [frame.packet]
|
||||
assert result.maximum_loaded_samples < iq.size
|
||||
|
||||
|
||||
def test_capture_without_rf001a_signal_has_zero_packets(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "noise_only.cs8"
|
||||
np.zeros(400_000, dtype=np.int8).tofile(capture)
|
||||
result = decode_cs8_streaming(capture, decoder_backend="legacy")
|
||||
assert result.window_source == "STREAMING_POWER_LOCATOR"
|
||||
assert result.windows == ()
|
||||
assert result.reception.packets == ()
|
||||
assert result.maximum_loaded_samples == 0
|
||||
|
||||
truth = Path(__file__).resolve().parents[1] / "configs/rf/rf001a_truth_metadata.json"
|
||||
report = decode_file(
|
||||
capture,
|
||||
iq_format="cs8",
|
||||
truth_metadata_path=truth,
|
||||
decoder_backend="legacy",
|
||||
)
|
||||
assert report["exact_packets"] == 0
|
||||
assert report["crc_valid_packets"] == 0
|
||||
assert report["streaming_decoder"] is True
|
||||
assert report["peak_additional_ram_bytes"] < 1_000_000_000
|
||||
@@ -31,6 +31,7 @@ from tools.rf_handoff_smoke import run_smoke
|
||||
from tools.rx_agent_watcher import (
|
||||
WatcherState as RxWatcherState,
|
||||
publish_result,
|
||||
require_live_ready_window,
|
||||
save_state as save_rx_state,
|
||||
)
|
||||
from tools.tx_agent_watcher import (
|
||||
@@ -59,7 +60,7 @@ def ready_result(**changes: object) -> RxHandoffResult:
|
||||
capture_pid=1234,
|
||||
published_at=NOW.isoformat(),
|
||||
capture_started_at=NOW.isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=120)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=60)).isoformat(),
|
||||
capture_path=r"C:\captures\rf001a_ota_capture.cs8",
|
||||
common_clock_verified=True,
|
||||
source_head=HEAD,
|
||||
@@ -97,10 +98,10 @@ def complete_result(
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=(NOW + timedelta(seconds=121)).isoformat(),
|
||||
published_at=(NOW + timedelta(seconds=61)).isoformat(),
|
||||
exit_code=0,
|
||||
file_size=1_920_000_000,
|
||||
duration_seconds=120.01,
|
||||
file_size=960_000_000,
|
||||
duration_seconds=60.01,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
@@ -278,7 +279,7 @@ def test_complete_result_accepts_explicit_unreported_counters() -> None:
|
||||
ready_result(
|
||||
published_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||
capture_started_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=89)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=29)).isoformat(),
|
||||
),
|
||||
NOW,
|
||||
"STALE_READY",
|
||||
@@ -290,7 +291,7 @@ def test_complete_result_accepts_explicit_unreported_counters() -> None:
|
||||
),
|
||||
(
|
||||
ready_result(
|
||||
capture_started_at=(NOW - timedelta(seconds=111)).isoformat(),
|
||||
capture_started_at=(NOW - timedelta(seconds=51)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=9)).isoformat(),
|
||||
),
|
||||
NOW,
|
||||
@@ -329,7 +330,7 @@ def test_ready_evaluation_passes_with_more_than_ten_seconds() -> None:
|
||||
)
|
||||
assert evaluation.ready
|
||||
assert evaluation.reason == "READY"
|
||||
assert evaluation.seconds_remaining == pytest.approx(118.0)
|
||||
assert evaluation.seconds_remaining == pytest.approx(58.0)
|
||||
|
||||
|
||||
def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
|
||||
@@ -378,7 +379,7 @@ def test_window_and_commit_are_rechecked_after_exact_approval(tmp_path: Path) ->
|
||||
digest = result_digest(result)
|
||||
source = FakeSource(result, digest)
|
||||
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||
times = iter((NOW + timedelta(seconds=2), NOW + timedelta(seconds=111)))
|
||||
times = iter((NOW + timedelta(seconds=2), NOW + timedelta(seconds=51)))
|
||||
|
||||
status, _ = handle_result(
|
||||
result,
|
||||
@@ -600,7 +601,7 @@ def test_expired_historical_ready_is_observed_without_authorization(
|
||||
started = NOW - timedelta(seconds=200)
|
||||
expired = ready_result(
|
||||
capture_started_at=started.isoformat(),
|
||||
expected_capture_end=(started + timedelta(seconds=120)).isoformat(),
|
||||
expected_capture_end=(started + timedelta(seconds=60)).isoformat(),
|
||||
published_at=(NOW - timedelta(seconds=100)).isoformat(),
|
||||
)
|
||||
expired_outcome = channel.publish(expired)
|
||||
@@ -796,9 +797,16 @@ Fake task used only by the local coordination test.
|
||||
)
|
||||
source_head = _git(["rev-parse", "HEAD"], work)
|
||||
|
||||
fresh_start = datetime.now(timezone.utc)
|
||||
fresh_result = ready_result(
|
||||
source_head=source_head,
|
||||
published_at=fresh_start.isoformat(),
|
||||
capture_started_at=fresh_start.isoformat(),
|
||||
expected_capture_end=(fresh_start + timedelta(seconds=60)).isoformat(),
|
||||
)
|
||||
result_path = tmp_path / "ready.json"
|
||||
result_path.write_text(
|
||||
canonical_result_json(ready_result(source_head=source_head)),
|
||||
canonical_result_json(fresh_result),
|
||||
encoding="utf-8",
|
||||
)
|
||||
state_path = tmp_path / "rx_state.json"
|
||||
@@ -824,4 +832,12 @@ Fake task used only by the local coordination test.
|
||||
assert "RF TX = 0" in output
|
||||
|
||||
published, _digest, _head = GitResultChannel(work).read()
|
||||
assert published == ready_result(source_head=source_head)
|
||||
assert published == fresh_result
|
||||
|
||||
|
||||
def test_rx_publisher_refuses_ready_after_capture_window() -> None:
|
||||
with pytest.raises(ValueError, match="window has already ended"):
|
||||
require_live_ready_window(
|
||||
ready_result(),
|
||||
now=NOW + timedelta(seconds=60),
|
||||
)
|
||||
|
||||
145
tests/test_rf_handoff_reliability.py
Normal file
145
tests/test_rf_handoff_reliability.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Проверки повторной публикации READY и Git preflight без оборудования."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf_handoff_channel import GitResultChannel, ResultChannelError
|
||||
|
||||
|
||||
OLD_HEAD = "1" * 40
|
||||
READY_HEAD = "2" * 40
|
||||
|
||||
|
||||
class SimulatedPushChannel(GitResultChannel):
|
||||
def __init__(
|
||||
self,
|
||||
push_results: list[subprocess.CompletedProcess[str]],
|
||||
observed_heads: list[str],
|
||||
) -> None:
|
||||
self.remote = "origin"
|
||||
self.branch = "rx-node"
|
||||
self.push_results = iter(push_results)
|
||||
self.observed_heads = iter(observed_heads)
|
||||
self.push_arguments: list[tuple[str, ...]] = []
|
||||
self.fetch_calls = 0
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
del cwd, check
|
||||
assert arguments[0] == "push"
|
||||
self.push_arguments.append(tuple(arguments))
|
||||
return next(self.push_results)
|
||||
|
||||
def remote_head(self) -> str:
|
||||
return next(self.observed_heads)
|
||||
|
||||
def fetch(self) -> str:
|
||||
self.fetch_calls += 1
|
||||
return OLD_HEAD
|
||||
|
||||
|
||||
def _push_result(returncode: int, stderr: str = "") -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.CompletedProcess(["git", "push"], returncode, "", stderr)
|
||||
|
||||
|
||||
def _git(arguments: list[str], cwd: Path) -> str:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def test_transient_failure_retries_the_same_ready_commit_once() -> None:
|
||||
channel = SimulatedPushChannel(
|
||||
[_push_result(1, "Authentication failed"), _push_result(0)],
|
||||
[OLD_HEAD, READY_HEAD],
|
||||
)
|
||||
assert channel._push_exact_commit(Path("checkout"), READY_HEAD, OLD_HEAD) == READY_HEAD
|
||||
assert len(channel.push_arguments) == 2
|
||||
assert channel.push_arguments[0] == channel.push_arguments[1]
|
||||
assert channel.push_arguments[0][-1] == f"{READY_HEAD}:refs/heads/rx-node"
|
||||
assert channel.fetch_calls == 1
|
||||
|
||||
|
||||
def test_ambiguous_push_is_success_when_remote_already_has_ready() -> None:
|
||||
channel = SimulatedPushChannel(
|
||||
[_push_result(1, "TLS connection was non-properly terminated")],
|
||||
[READY_HEAD],
|
||||
)
|
||||
assert channel._push_exact_commit(Path("checkout"), READY_HEAD, OLD_HEAD) == READY_HEAD
|
||||
assert len(channel.push_arguments) == 1
|
||||
assert channel.fetch_calls == 0
|
||||
|
||||
|
||||
def test_second_transient_failure_does_not_create_another_ready() -> None:
|
||||
channel = SimulatedPushChannel(
|
||||
[
|
||||
_push_result(1, "Authentication failed"),
|
||||
_push_result(1, "Authentication failed"),
|
||||
],
|
||||
[OLD_HEAD, OLD_HEAD],
|
||||
)
|
||||
with pytest.raises(ResultChannelError, match="after one retry"):
|
||||
channel._push_exact_commit(Path("checkout"), READY_HEAD, OLD_HEAD)
|
||||
assert len(channel.push_arguments) == 2
|
||||
assert channel.push_arguments[0] == channel.push_arguments[1]
|
||||
|
||||
|
||||
def test_failed_write_preflight_stops_before_any_publication() -> None:
|
||||
class FailingPreflight(GitResultChannel):
|
||||
def __init__(self) -> None:
|
||||
self.remote = "origin"
|
||||
self.branch = "rx-node"
|
||||
|
||||
def fetch(self) -> str:
|
||||
return OLD_HEAD
|
||||
|
||||
def remote_head(self) -> str:
|
||||
raise AssertionError("remote confirmation must not follow failed dry-run")
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
del cwd, check
|
||||
assert arguments[:3] == ["push", "--dry-run", "--porcelain"]
|
||||
return _push_result(1, "Authentication failed")
|
||||
|
||||
with pytest.raises(ResultChannelError, match="push preflight failed"):
|
||||
FailingPreflight().verify_write_access()
|
||||
|
||||
|
||||
def test_write_preflight_checks_access_without_changing_remote(tmp_path: Path) -> None:
|
||||
remote = tmp_path / "remote.git"
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "--bare", str(remote)], tmp_path)
|
||||
work.mkdir()
|
||||
_git(["init"], work)
|
||||
_git(["config", "user.name", "Test"], work)
|
||||
_git(["config", "user.email", "test@example.invalid"], work)
|
||||
(work / "seed.txt").write_text("seed\n", encoding="utf-8")
|
||||
_git(["add", "seed.txt"], work)
|
||||
_git(["commit", "-m", "Seed result branch"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
_git(["push", "origin", "HEAD:refs/heads/rx-node"], work)
|
||||
channel = GitResultChannel(work)
|
||||
before = channel.remote_head()
|
||||
assert channel.verify_write_access() == before
|
||||
assert channel.remote_head() == before
|
||||
@@ -26,10 +26,15 @@ from tools.tx_agent_watcher import TxWatcherState, process_history_cycle
|
||||
from tools.tx_handoff_channel import (
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
CHANNEL_READY_OBSERVED,
|
||||
TX_ACK_SCHEMA_VERSION,
|
||||
TX_AUTHORIZED,
|
||||
TX_COMPLETE,
|
||||
TX_STARTED,
|
||||
TX_RESULT_PATHS,
|
||||
GitAcknowledgementChannel,
|
||||
TxHandoffAcknowledgement,
|
||||
acknowledgement_from_mapping,
|
||||
build_tx_execution_event,
|
||||
channel_test_acknowledgements,
|
||||
)
|
||||
|
||||
@@ -91,7 +96,7 @@ def channel_ready(**changes: object) -> RxHandoffResult:
|
||||
capture_pid=1234,
|
||||
published_at=NOW.isoformat(),
|
||||
capture_started_at=NOW.isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=120)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=60)).isoformat(),
|
||||
capture_path="MODEL_FREE_FAKE_CAPTURE",
|
||||
common_clock_verified=False,
|
||||
source_head=SOURCE_HEAD,
|
||||
@@ -175,6 +180,95 @@ def test_acknowledgement_schema_binds_digest_and_source_commit() -> None:
|
||||
acknowledgement_from_mapping({**raw, "rf_tx_authorized": True})
|
||||
|
||||
|
||||
def test_tx_events_preserve_authorization_binding_and_timestamps() -> None:
|
||||
authorization = TxHandoffAcknowledgement(
|
||||
schema_version=TX_ACK_SCHEMA_VERSION,
|
||||
task_id="RF001A_OTA_CAPTURE_004",
|
||||
state=TX_AUTHORIZED,
|
||||
source_rx_commit="1" * 40,
|
||||
source_rx_digest="2" * 64,
|
||||
source_head=SOURCE_HEAD,
|
||||
observed_at=NOW.isoformat(),
|
||||
main_watcher_pid=4321,
|
||||
rf_tx_authorized=True,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
tx_task_id="RF001A_OTA_TX_004",
|
||||
)
|
||||
started_at = (NOW + timedelta(seconds=1)).isoformat()
|
||||
started = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_STARTED,
|
||||
observed_at=started_at,
|
||||
tx_started_at=started_at,
|
||||
)
|
||||
finished_at = (NOW + timedelta(seconds=2.2)).isoformat()
|
||||
complete = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_COMPLETE,
|
||||
observed_at=finished_at,
|
||||
tx_started_at=started_at,
|
||||
tx_finished_at=finished_at,
|
||||
process_exit_code=0,
|
||||
underruns=0,
|
||||
errors=0,
|
||||
)
|
||||
assert started.source_rx_commit == authorization.source_rx_commit
|
||||
assert started.source_rx_digest == authorization.source_rx_digest
|
||||
assert started.rf_tx_started and started.rf_tx == 1
|
||||
assert complete.tx_finished_at == finished_at
|
||||
assert complete.tx_process_exit_code == 0
|
||||
|
||||
|
||||
def test_execution_event_publication_requires_authorization_and_start(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
authorization = TxHandoffAcknowledgement(
|
||||
schema_version=TX_ACK_SCHEMA_VERSION,
|
||||
task_id="RF001A_OTA_CAPTURE_004",
|
||||
state=TX_AUTHORIZED,
|
||||
source_rx_commit="1" * 40,
|
||||
source_rx_digest="2" * 64,
|
||||
source_head=SOURCE_HEAD,
|
||||
observed_at=NOW.isoformat(),
|
||||
main_watcher_pid=4321,
|
||||
rf_tx_authorized=True,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
tx_task_id="RF001A_OTA_TX_004",
|
||||
)
|
||||
started_at = (NOW + timedelta(seconds=1)).isoformat()
|
||||
finished_at = (NOW + timedelta(seconds=2.2)).isoformat()
|
||||
started = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_STARTED,
|
||||
observed_at=started_at,
|
||||
tx_started_at=started_at,
|
||||
)
|
||||
complete = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_COMPLETE,
|
||||
observed_at=finished_at,
|
||||
tx_started_at=started_at,
|
||||
tx_finished_at=finished_at,
|
||||
process_exit_code=0,
|
||||
underruns=0,
|
||||
errors=0,
|
||||
)
|
||||
|
||||
with pytest.raises(ResultChannelError, match="TX_AUTHORIZED"):
|
||||
channel.publish_execution_event(started)
|
||||
channel.publish(authorization)
|
||||
with pytest.raises(ResultChannelError, match="TX_STARTED"):
|
||||
channel.publish_execution_event(complete)
|
||||
started_outcome = channel.publish_execution_event(started)
|
||||
complete_outcome = channel.publish_execution_event(complete)
|
||||
assert started_outcome.status == "PUBLISHED"
|
||||
assert complete_outcome.status == "PUBLISHED"
|
||||
|
||||
|
||||
def test_main_publish_uses_exact_allowlist_and_suppresses_duplicate(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -31,6 +31,12 @@ from tools.qpsk_rf_common import (
|
||||
GAP_MS, PACKET_COUNT, PAYLOAD_BYTES, SEED, TEST_ID,
|
||||
payload_for_sequence, read_iq_file, sha256_file,
|
||||
)
|
||||
from tools.rf001a_streaming import (
|
||||
DEFAULT_CHUNK_SAMPLES,
|
||||
MemoryPeakSampler,
|
||||
cs8_sample_count,
|
||||
decode_cs8_streaming,
|
||||
)
|
||||
|
||||
|
||||
def _bit_errors(observed: bytes, expected: bytes) -> tuple[int, int]:
|
||||
@@ -124,7 +130,7 @@ def summarize_result(
|
||||
unique_sequences = set(sequences)
|
||||
cfo = [row["cfo_hz"] for row in rows]
|
||||
report: dict[str, Any] = {
|
||||
"rf001_status": "PREPARED_NOT_RUN",
|
||||
"rf001_status": "OFFLINE_ANALYZED",
|
||||
"test_id": truth.get("test_id") if truth is not None else None,
|
||||
"modulation": "QPSK",
|
||||
"symbol_rate": 500_000,
|
||||
@@ -242,10 +248,32 @@ def decode_file(
|
||||
iq_format: str = "c64le",
|
||||
truth_metadata_path: Path | None = None,
|
||||
decoder_backend: str = "numba",
|
||||
capture_started_at: str | None = None,
|
||||
tx_started_at: str | None = None,
|
||||
tx_finished_at: str | None = None,
|
||||
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
|
||||
) -> dict[str, Any]:
|
||||
"""Открывает только файл; truth читается после вызова рабочего приёмника."""
|
||||
"""Декодирует c64le целиком, а большие cs8 — ограниченными окнами."""
|
||||
|
||||
streaming = None
|
||||
with MemoryPeakSampler() as memory:
|
||||
if iq_format == "cs8":
|
||||
streaming = decode_cs8_streaming(
|
||||
iq_path,
|
||||
capture_started_at=capture_started_at,
|
||||
tx_started_at=tx_started_at,
|
||||
tx_finished_at=tx_finished_at,
|
||||
decoder_backend=decoder_backend,
|
||||
chunk_samples=chunk_samples,
|
||||
)
|
||||
reception = streaming.reception
|
||||
iq_sample_count = cs8_sample_count(iq_path)
|
||||
clipping_codes = streaming.clipping_codes
|
||||
iq = None
|
||||
else:
|
||||
iq, clipping_codes = read_iq_file(iq_path, iq_format)
|
||||
reception = receive_iq_stream(iq, decoder_backend=decoder_backend)
|
||||
iq_sample_count = int(iq.size)
|
||||
truth = (json.loads(truth_metadata_path.read_text(encoding="utf-8"))
|
||||
if truth_metadata_path is not None else None)
|
||||
if truth is not None and (
|
||||
@@ -262,17 +290,38 @@ def decode_file(
|
||||
or truth.get("seed") != SEED
|
||||
):
|
||||
raise ValueError("TX-ведомость не соответствует режиму RF-001")
|
||||
raw_bits = (_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
|
||||
if truth is not None else None)
|
||||
return summarize_result(
|
||||
raw_bits = (
|
||||
_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
|
||||
if truth is not None and iq is not None
|
||||
else None
|
||||
)
|
||||
report = summarize_result(
|
||||
reception,
|
||||
iq_sample_count=int(iq.size),
|
||||
iq_sample_count=iq_sample_count,
|
||||
iq_sha256=sha256_file(iq_path),
|
||||
iq_format=iq_format,
|
||||
clipping_codes=clipping_codes,
|
||||
truth=truth,
|
||||
raw_bits=raw_bits,
|
||||
)
|
||||
report["peak_analysis_memory_bytes"] = memory.peak_bytes
|
||||
report["peak_additional_ram_bytes"] = memory.additional_peak_bytes
|
||||
if streaming is not None:
|
||||
report.update({
|
||||
"streaming_decoder": True,
|
||||
"window_source": streaming.window_source,
|
||||
"analysis_windows": [
|
||||
{"start_sample": window.start, "stop_sample": window.stop}
|
||||
for window in streaming.windows
|
||||
],
|
||||
"overlap_samples": streaming.overlap_samples,
|
||||
"maximum_loaded_complex_samples": streaming.maximum_loaded_samples,
|
||||
"maximum_loaded_iq_bytes": streaming.maximum_loaded_samples * 8,
|
||||
"raw_ber_scope": "NOT_COMPUTED_FOR_STREAMING_WINDOWS",
|
||||
})
|
||||
else:
|
||||
report["streaming_decoder"] = False
|
||||
return report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -282,6 +331,10 @@ def main() -> None:
|
||||
parser.add_argument("--truth-metadata", type=Path)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--numba-packages", type=Path)
|
||||
parser.add_argument("--capture-started-at")
|
||||
parser.add_argument("--tx-started-at")
|
||||
parser.add_argument("--tx-finished-at")
|
||||
parser.add_argument("--chunk-samples", type=int, default=DEFAULT_CHUNK_SAMPLES)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.report.exists():
|
||||
parser.error("отчёт уже существует; перезапись запрещена")
|
||||
@@ -290,7 +343,11 @@ def main() -> None:
|
||||
parser.error("каталог пакетов Numba отсутствует")
|
||||
sys.path.insert(0, str(arguments.numba_packages.resolve()))
|
||||
result = decode_file(arguments.iq_file, iq_format=arguments.iq_format,
|
||||
truth_metadata_path=arguments.truth_metadata)
|
||||
truth_metadata_path=arguments.truth_metadata,
|
||||
capture_started_at=arguments.capture_started_at,
|
||||
tx_started_at=arguments.tx_started_at,
|
||||
tx_finished_at=arguments.tx_finished_at,
|
||||
chunk_samples=arguments.chunk_samples)
|
||||
arguments.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
arguments.report.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
|
||||
363
tools/rf001a_streaming.py
Normal file
363
tools/rf001a_streaming.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Потоковое чтение больших cs8-захватов и оконный приём RF-001A."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
import ctypes
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol.video003_modem import SAMPLE_RATE_HZ, build_frame, samples_per_symbol
|
||||
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE, shape_packet
|
||||
from protocol.video005_receiver import IqStreamResult, ReceivedIqPacket, receive_iq_stream
|
||||
from tools.qpsk_rf_common import PAYLOAD_BYTES, SEED, payload_for_sequence
|
||||
|
||||
|
||||
DEFAULT_CHUNK_SAMPLES = 4_000_000
|
||||
LOCATOR_BLOCK_SAMPLES = 400_000
|
||||
TIMESTAMP_MARGIN_SECONDS = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SampleWindow:
|
||||
start: int
|
||||
stop: int
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return self.stop - self.start
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamingReception:
|
||||
reception: IqStreamResult
|
||||
windows: tuple[SampleWindow, ...]
|
||||
window_source: str
|
||||
overlap_samples: int
|
||||
maximum_loaded_samples: int
|
||||
clipping_codes: int
|
||||
|
||||
|
||||
class MemoryPeakSampler:
|
||||
"""Измеряет пиковый working set процесса во время анализа."""
|
||||
|
||||
def __init__(self, poll_seconds: float = 0.02) -> None:
|
||||
self.poll_seconds = poll_seconds
|
||||
self.baseline_bytes = current_process_rss_bytes()
|
||||
self.peak_bytes = self.baseline_bytes
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._sample, daemon=True)
|
||||
|
||||
def _sample(self) -> None:
|
||||
while not self._stop.wait(self.poll_seconds):
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
|
||||
def __enter__(self) -> MemoryPeakSampler:
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
@property
|
||||
def additional_peak_bytes(self) -> int:
|
||||
return max(0, self.peak_bytes - self.baseline_bytes)
|
||||
|
||||
|
||||
def current_process_rss_bytes() -> int:
|
||||
"""Возвращает текущий working set процесса без сторонних библиотек."""
|
||||
|
||||
if not hasattr(ctypes, "windll"):
|
||||
return 0
|
||||
|
||||
class ProcessMemoryCounters(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cb", ctypes.c_ulong),
|
||||
("page_fault_count", ctypes.c_ulong),
|
||||
("peak_working_set_size", ctypes.c_size_t),
|
||||
("working_set_size", ctypes.c_size_t),
|
||||
("quota_peak_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_peak_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("pagefile_usage", ctypes.c_size_t),
|
||||
("peak_pagefile_usage", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
counters = ProcessMemoryCounters()
|
||||
counters.cb = ctypes.sizeof(counters)
|
||||
get_current_process = ctypes.windll.kernel32.GetCurrentProcess # type: ignore[attr-defined]
|
||||
get_current_process.restype = ctypes.c_void_p
|
||||
get_process_memory_info = ctypes.windll.psapi.GetProcessMemoryInfo # type: ignore[attr-defined]
|
||||
get_process_memory_info.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(ProcessMemoryCounters),
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
get_process_memory_info.restype = ctypes.c_int
|
||||
succeeded = get_process_memory_info(
|
||||
get_current_process(),
|
||||
ctypes.byref(counters),
|
||||
counters.cb,
|
||||
)
|
||||
return int(counters.working_set_size) if succeeded else 0
|
||||
|
||||
|
||||
def cs8_sample_count(path: Path) -> int:
|
||||
size = path.stat().st_size
|
||||
if size == 0 or size % 2:
|
||||
raise ValueError("cs8 должен содержать целое ненулевое число пар I/Q")
|
||||
return size // 2
|
||||
|
||||
|
||||
def read_cs8_window(raw: np.memmap, start: int, stop: int) -> np.ndarray:
|
||||
"""Преобразует в complex64 только заданное окно cs8."""
|
||||
|
||||
total_samples = raw.size // 2
|
||||
if start < 0 or stop <= start or stop > total_samples:
|
||||
raise ValueError("границы окна cs8 выходят за пределы файла")
|
||||
values = np.asarray(raw[start * 2:stop * 2], dtype=np.int8)
|
||||
real = values[0::2].astype(np.float32)
|
||||
imaginary = values[1::2].astype(np.float32)
|
||||
return ((real + 1j * imaginary) / 128).astype(np.complex64)
|
||||
|
||||
|
||||
def rf001_packet_waveform_samples() -> int:
|
||||
"""Вычисляет длину волны из настоящего 256-байтного пакета RF-001."""
|
||||
|
||||
if PAYLOAD_BYTES != 256:
|
||||
raise AssertionError("канонический RF-001 payload неожиданно изменился")
|
||||
payload = payload_for_sequence(SEED, 0)
|
||||
frame = build_frame(payload, "QPSK", "2/3", sequence_number=0)
|
||||
return int(shape_packet(frame).size)
|
||||
|
||||
|
||||
def required_overlap_samples() -> int:
|
||||
"""Перекрывает целую максимальную волну одного пакета RF-001."""
|
||||
|
||||
return rf001_packet_waveform_samples()
|
||||
|
||||
|
||||
def _parse_utc(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp должен содержать часовой пояс")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def timestamp_sample_window(
|
||||
*,
|
||||
capture_started_at: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
total_samples: int,
|
||||
sample_rate: int = SAMPLE_RATE_HZ,
|
||||
margin_seconds: float = TIMESTAMP_MARGIN_SECONDS,
|
||||
) -> SampleWindow:
|
||||
"""Переводит UTC-границы TX в безопасное окно отсчётов RX."""
|
||||
|
||||
if total_samples <= 0 or sample_rate <= 0 or margin_seconds < 0:
|
||||
raise ValueError("параметры timestamp-окна должны быть положительными")
|
||||
capture = _parse_utc(capture_started_at)
|
||||
started = _parse_utc(tx_started_at)
|
||||
finished = _parse_utc(tx_finished_at)
|
||||
if finished < started:
|
||||
raise ValueError("TX_FINISHED_AT не может предшествовать TX_STARTED_AT")
|
||||
margin_samples = round(margin_seconds * sample_rate)
|
||||
start_offset = round((started - capture).total_seconds() * sample_rate)
|
||||
stop_offset = round((finished - capture).total_seconds() * sample_rate)
|
||||
start = max(0, start_offset - margin_samples)
|
||||
stop = min(total_samples, stop_offset + margin_samples)
|
||||
if stop <= start:
|
||||
raise ValueError("TX timestamps не пересекаются с RX capture")
|
||||
return SampleWindow(start, stop)
|
||||
|
||||
|
||||
def _merge_windows(windows: Iterable[SampleWindow]) -> tuple[SampleWindow, ...]:
|
||||
ordered = sorted(windows, key=lambda item: item.start)
|
||||
merged: list[SampleWindow] = []
|
||||
for window in ordered:
|
||||
if not merged or window.start > merged[-1].stop:
|
||||
merged.append(window)
|
||||
else:
|
||||
merged[-1] = SampleWindow(
|
||||
merged[-1].start,
|
||||
max(merged[-1].stop, window.stop),
|
||||
)
|
||||
return tuple(merged)
|
||||
|
||||
|
||||
def locate_power_windows(
|
||||
path: Path,
|
||||
*,
|
||||
block_samples: int = LOCATOR_BLOCK_SAMPLES,
|
||||
padding_seconds: float = 1.5,
|
||||
) -> tuple[SampleWindow, ...]:
|
||||
"""Ищет повышенную мощность по всему cs8-файлу ограниченными блоками."""
|
||||
|
||||
total_samples = cs8_sample_count(path)
|
||||
if block_samples <= 0 or padding_seconds < 0:
|
||||
raise ValueError("параметры locator должны быть неотрицательными")
|
||||
powers: list[float] = []
|
||||
with path.open("rb") as stream:
|
||||
while raw_bytes := stream.read(block_samples * 2):
|
||||
values = np.frombuffer(raw_bytes, dtype=np.int8).astype(np.float32)
|
||||
powers.append(float(np.mean(values * values)))
|
||||
if not powers:
|
||||
return ()
|
||||
power_array = np.asarray(powers, dtype=np.float64)
|
||||
median = float(np.median(power_array))
|
||||
mad = float(np.median(np.abs(power_array - median)))
|
||||
threshold = median + max(6.0 * mad, median * 0.15, 1.0)
|
||||
active = np.flatnonzero(power_array > threshold)
|
||||
if active.size == 0:
|
||||
return ()
|
||||
padding = round(padding_seconds * SAMPLE_RATE_HZ)
|
||||
candidates = (
|
||||
SampleWindow(
|
||||
max(0, int(index) * block_samples - padding),
|
||||
min(total_samples, (int(index) + 1) * block_samples + padding),
|
||||
)
|
||||
for index in active
|
||||
)
|
||||
return _merge_windows(candidates)
|
||||
|
||||
|
||||
def count_cs8_clipping(path: Path, *, block_samples: int = DEFAULT_CHUNK_SAMPLES) -> int:
|
||||
"""Считает rail-коды во всём файле, сохраняя ограниченный объём памяти."""
|
||||
|
||||
cs8_sample_count(path)
|
||||
clipping = 0
|
||||
with path.open("rb") as stream:
|
||||
while raw_bytes := stream.read(block_samples * 2):
|
||||
values = np.frombuffer(raw_bytes, dtype=np.int8)
|
||||
clipping += int(np.count_nonzero((values == -128) | (values == 127)))
|
||||
return clipping
|
||||
|
||||
|
||||
def _is_overlap_duplicate(
|
||||
packets: list[ReceivedIqPacket],
|
||||
candidate: ReceivedIqPacket,
|
||||
) -> bool:
|
||||
tolerance = samples_per_symbol(SYMBOL_RATE)
|
||||
return any(
|
||||
packet.packet == candidate.packet
|
||||
and abs(packet.start_sample - candidate.start_sample) <= tolerance
|
||||
for packet in packets
|
||||
)
|
||||
|
||||
|
||||
def receive_cs8_windows(
|
||||
path: Path,
|
||||
windows: Iterable[SampleWindow],
|
||||
*,
|
||||
decoder_backend: str = "numba",
|
||||
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
|
||||
overlap_samples: int | None = None,
|
||||
) -> StreamingReception:
|
||||
"""Принимает заданные окна частями с перекрытием и удаляет повторы."""
|
||||
|
||||
selected = _merge_windows(windows)
|
||||
overlap = required_overlap_samples() if overlap_samples is None else overlap_samples
|
||||
if chunk_samples <= overlap or overlap < required_overlap_samples():
|
||||
raise ValueError("chunk должен быть длиннее полного overlap RF-001")
|
||||
raw = np.memmap(path, dtype=np.int8, mode="r")
|
||||
packets: list[ReceivedIqPacket] = []
|
||||
candidate_count = 0
|
||||
rejected_candidates = 0
|
||||
stage_time: dict[str, float] = {}
|
||||
maximum_loaded = 0
|
||||
for selected_window in selected:
|
||||
for primary_start in range(selected_window.start, selected_window.stop, chunk_samples):
|
||||
primary_stop = min(selected_window.stop, primary_start + chunk_samples)
|
||||
read_start = max(selected_window.start, primary_start - overlap)
|
||||
read_stop = min(selected_window.stop, primary_stop + overlap)
|
||||
iq = read_cs8_window(raw, read_start, read_stop)
|
||||
maximum_loaded = max(maximum_loaded, int(iq.size))
|
||||
received = receive_iq_stream(
|
||||
iq,
|
||||
carrier_hz=NOMINAL_CARRIER_HZ,
|
||||
decoder_backend=decoder_backend,
|
||||
)
|
||||
candidate_count += received.candidate_count
|
||||
rejected_candidates += received.rejected_candidates
|
||||
for name, duration in received.stage_time_s.items():
|
||||
stage_time[name] = stage_time.get(name, 0.0) + duration
|
||||
for packet in received.packets:
|
||||
global_packet = replace(
|
||||
packet,
|
||||
start_sample=packet.start_sample + read_start,
|
||||
)
|
||||
if not primary_start <= global_packet.start_sample < primary_stop:
|
||||
continue
|
||||
if not _is_overlap_duplicate(packets, global_packet):
|
||||
packets.append(global_packet)
|
||||
del iq
|
||||
packets.sort(key=lambda packet: packet.start_sample)
|
||||
return StreamingReception(
|
||||
IqStreamResult(
|
||||
packets=tuple(packets),
|
||||
candidate_count=candidate_count,
|
||||
rejected_candidates=rejected_candidates,
|
||||
stage_time_s=stage_time,
|
||||
),
|
||||
selected,
|
||||
"EXPLICIT",
|
||||
overlap,
|
||||
maximum_loaded,
|
||||
count_cs8_clipping(path),
|
||||
)
|
||||
|
||||
|
||||
def decode_cs8_streaming(
|
||||
path: Path,
|
||||
*,
|
||||
capture_started_at: str | None = None,
|
||||
tx_started_at: str | None = None,
|
||||
tx_finished_at: str | None = None,
|
||||
decoder_backend: str = "numba",
|
||||
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
|
||||
margin_seconds: float = TIMESTAMP_MARGIN_SECONDS,
|
||||
) -> StreamingReception:
|
||||
"""Выбирает timestamp-окно либо потоковый power locator и запускает приём."""
|
||||
|
||||
total_samples = cs8_sample_count(path)
|
||||
timestamp_values = (capture_started_at, tx_started_at, tx_finished_at)
|
||||
if all(value is not None for value in timestamp_values):
|
||||
windows = (
|
||||
timestamp_sample_window(
|
||||
capture_started_at=str(capture_started_at),
|
||||
tx_started_at=str(tx_started_at),
|
||||
tx_finished_at=str(tx_finished_at),
|
||||
total_samples=total_samples,
|
||||
margin_seconds=margin_seconds,
|
||||
),
|
||||
)
|
||||
source = "TX_TIMESTAMPS"
|
||||
elif any(value is not None for value in timestamp_values):
|
||||
raise ValueError("для timestamp-окна нужны все три времени RX/TX")
|
||||
else:
|
||||
windows = locate_power_windows(path)
|
||||
source = "STREAMING_POWER_LOCATOR"
|
||||
if not windows:
|
||||
return StreamingReception(
|
||||
IqStreamResult((), 0, 0, {}),
|
||||
(),
|
||||
source,
|
||||
required_overlap_samples(),
|
||||
0,
|
||||
count_cs8_clipping(path),
|
||||
)
|
||||
reception = receive_cs8_windows(
|
||||
path,
|
||||
windows,
|
||||
decoder_backend=decoder_backend,
|
||||
chunk_samples=chunk_samples,
|
||||
)
|
||||
return replace(reception, window_source=source)
|
||||
@@ -14,8 +14,9 @@ from typing import Any, Mapping
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_003"
|
||||
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_003"
|
||||
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_004"
|
||||
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_004"
|
||||
EXPECTED_CAPTURE_DURATION_SECONDS = 60.0
|
||||
CHANNEL_TEST_TASK_ID = "RF_HANDOFF_CHANNEL_TEST_001"
|
||||
READY_STATE = "RX_READY_FOR_TX"
|
||||
COMPLETE_STATE = "CAPTURE_COMPLETE"
|
||||
@@ -25,6 +26,21 @@ RESULT_PATHS = frozenset((RESULT_JSON_PATH, RESULT_MARKDOWN_PATH))
|
||||
MAX_RESULT_BYTES = 64 * 1024
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_TRANSIENT_PUSH_ERRORS = (
|
||||
"authentication failed",
|
||||
"failed to authenticate",
|
||||
"could not resolve host",
|
||||
"connection reset",
|
||||
"connection timed out",
|
||||
"failed to connect",
|
||||
"remote end hung up",
|
||||
"rpc failed",
|
||||
"schannel",
|
||||
"ssl",
|
||||
"tls",
|
||||
"the requested url returned error: 5",
|
||||
"unable to access",
|
||||
)
|
||||
|
||||
|
||||
class ResultChannelError(RuntimeError):
|
||||
@@ -319,7 +335,11 @@ def evaluate_ready(
|
||||
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
|
||||
published = _parse_utc(result.published_at, "published_at")
|
||||
planned_duration = (expected_end - started).total_seconds()
|
||||
if not 119.0 <= planned_duration <= 121.0:
|
||||
if not (
|
||||
EXPECTED_CAPTURE_DURATION_SECONDS - 1.0
|
||||
<= planned_duration
|
||||
<= EXPECTED_CAPTURE_DURATION_SECONDS + 1.0
|
||||
):
|
||||
return ReadyEvaluation(False, "WRONG_CAPTURE_WINDOW")
|
||||
age = (now_utc - published).total_seconds()
|
||||
if age < -5.0:
|
||||
@@ -386,6 +406,32 @@ class GitResultChannel:
|
||||
def branch_exists(self) -> bool:
|
||||
return self.remote_head() is not None
|
||||
|
||||
def verify_write_access(self) -> str:
|
||||
"""Проверить право записи dry-run push без коммита и изменения ветки."""
|
||||
|
||||
remote_head = self.fetch()
|
||||
dry_run = self._run(
|
||||
[
|
||||
"push",
|
||||
"--dry-run",
|
||||
"--porcelain",
|
||||
self.remote,
|
||||
f"{remote_head}:refs/heads/{self.branch}",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if dry_run.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
"authenticated result-branch push preflight failed: "
|
||||
f"{dry_run.stderr.strip()}"
|
||||
)
|
||||
confirmed_head = self.remote_head()
|
||||
if confirmed_head != remote_head:
|
||||
raise ResultChannelError(
|
||||
"result branch changed during write preflight"
|
||||
)
|
||||
return remote_head
|
||||
|
||||
def _tracking_reference(self) -> str:
|
||||
return f"refs/remotes/{self.remote}/{self.branch}"
|
||||
|
||||
@@ -553,6 +599,66 @@ class GitResultChannel:
|
||||
return commit
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_transient_push_error(stderr: str) -> bool:
|
||||
normalized = stderr.casefold()
|
||||
return any(pattern in normalized for pattern in _TRANSIENT_PUSH_ERRORS)
|
||||
|
||||
def _push_exact_commit(
|
||||
self,
|
||||
checkout: Path,
|
||||
commit: str,
|
||||
remote_before: str,
|
||||
) -> str:
|
||||
"""Отправить неизменный коммит с одной безопасной повторной попыткой."""
|
||||
|
||||
push_arguments = [
|
||||
"push",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"{commit}:refs/heads/{self.branch}",
|
||||
]
|
||||
first = self._run(push_arguments, cwd=checkout, check=False)
|
||||
if first.returncode == 0:
|
||||
remote_after = self.remote_head()
|
||||
if remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote result HEAD does not confirm the published commit"
|
||||
)
|
||||
return commit
|
||||
|
||||
observed_after_failure = self.remote_head()
|
||||
if observed_after_failure == commit:
|
||||
return commit
|
||||
if observed_after_failure != remote_before:
|
||||
raise ResultChannelError(
|
||||
"result push failed and remote branch changed to a conflicting "
|
||||
f"commit: {remote_before} -> {observed_after_failure}"
|
||||
)
|
||||
if not self._is_transient_push_error(first.stderr):
|
||||
raise ResultChannelError(f"result push failed: {first.stderr.strip()}")
|
||||
|
||||
fetched_head = self.fetch()
|
||||
if fetched_head != remote_before:
|
||||
raise ResultChannelError(
|
||||
"remote result branch changed before the single retry"
|
||||
)
|
||||
second = self._run(push_arguments, cwd=checkout, check=False)
|
||||
if second.returncode != 0:
|
||||
observed_after_retry = self.remote_head()
|
||||
if observed_after_retry == commit:
|
||||
return commit
|
||||
raise ResultChannelError(
|
||||
"result push failed after one retry of the same commit: "
|
||||
f"{second.stderr.strip()}"
|
||||
)
|
||||
remote_after = self.remote_head()
|
||||
if remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote result HEAD does not confirm the retried commit"
|
||||
)
|
||||
return commit
|
||||
|
||||
def publish(self, result: RxHandoffResult) -> PublishOutcome:
|
||||
validate_result(result)
|
||||
remote_before = self.fetch()
|
||||
@@ -610,24 +716,10 @@ class GitResultChannel:
|
||||
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"
|
||||
remote_after = self._push_exact_commit(
|
||||
checkout,
|
||||
commit,
|
||||
remote_before,
|
||||
)
|
||||
return PublishOutcome("PUBLISHED", commit, remote_after)
|
||||
finally:
|
||||
|
||||
@@ -23,6 +23,8 @@ if str(ROOT) not in sys.path:
|
||||
from tools.rf_handoff_channel import ( # noqa: E402
|
||||
EXPECTED_RX_TASK_ID,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
RxHandoffResult,
|
||||
load_result,
|
||||
)
|
||||
from tools.tx_handoff_channel import ( # noqa: E402
|
||||
@@ -571,6 +573,7 @@ def publish_result(arguments: argparse.Namespace) -> int:
|
||||
task = parse_task((repository / DEFAULT_TASK_PATH).read_text(encoding="utf-8-sig"))
|
||||
state = load_state(arguments.state_file.resolve())
|
||||
result = load_result(arguments.publish_result.resolve())
|
||||
require_live_ready_window(result)
|
||||
if task.task_id != result.task_id:
|
||||
raise ValueError("result TASK_ID disagrees with current RX task")
|
||||
if task.allow_push != "RX_RESULT_ONLY" or task.rf_tx:
|
||||
@@ -613,10 +616,55 @@ def publish_result(arguments: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def require_live_ready_window(
|
||||
result: RxHandoffResult,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""Запретить READY после окончания конечного окна захвата."""
|
||||
|
||||
if result.state != READY_STATE:
|
||||
return
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
expected_end = datetime.fromisoformat(
|
||||
result.expected_capture_end.replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
if current >= expected_end:
|
||||
raise ValueError("READY capture window has already ended")
|
||||
|
||||
|
||||
def verify_result_channel_write_access(
|
||||
repository: Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
result_branch: str = "rx-node",
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> str:
|
||||
"""Проверить право публикации результата RX без изменения ветки."""
|
||||
|
||||
channel = GitResultChannel(
|
||||
repository,
|
||||
remote=remote,
|
||||
branch=result_branch,
|
||||
)
|
||||
remote_head = channel.verify_write_access()
|
||||
output_function("RX-NODE WRITE PREFLIGHT = PASS")
|
||||
output_function(f"RX-NODE REMOTE HEAD = {remote_head}")
|
||||
output_function("RF TX = 0")
|
||||
return remote_head
|
||||
|
||||
|
||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
state_path = arguments.state_file.resolve()
|
||||
if arguments.publish_result is not None:
|
||||
return publish_result(arguments)
|
||||
if arguments.check_result_write:
|
||||
verify_result_channel_write_access(
|
||||
arguments.repository.resolve(),
|
||||
remote=arguments.remote,
|
||||
result_branch=arguments.result_branch,
|
||||
)
|
||||
return 0
|
||||
if arguments.request_stop:
|
||||
stopped = request_stop(state_path, arguments.stop_timeout)
|
||||
print("WATCHER STOPPED" if stopped else "WATCHER STOP TIMEOUT")
|
||||
@@ -670,6 +718,12 @@ def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
task_text, source_head = source.read()
|
||||
task = parse_task(task_text)
|
||||
if task.task_id == EXPECTED_RX_TASK_ID and not arguments.dry_run:
|
||||
if is_new_task(task, state):
|
||||
verify_result_channel_write_access(
|
||||
arguments.repository.resolve(),
|
||||
remote=arguments.remote,
|
||||
result_branch=arguments.result_branch,
|
||||
)
|
||||
confirm_main_channel_test(
|
||||
arguments.repository.resolve(),
|
||||
state,
|
||||
@@ -726,6 +780,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--stop-timeout", type=float, default=35.0)
|
||||
parser.add_argument("--mark-completed")
|
||||
parser.add_argument("--publish-result", type=Path)
|
||||
parser.add_argument("--check-result-write", action="store_true")
|
||||
parser.add_argument("--result-branch", default="rx-node")
|
||||
parser.add_argument("--acknowledgement-branch", default="tx-node")
|
||||
return parser
|
||||
|
||||
@@ -37,9 +37,13 @@ from tools.tx_handoff_channel import ( # noqa: E402
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
CHANNEL_READY_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
TX_AUTHORIZATION_CREATED,
|
||||
TX_ACK_SCHEMA_VERSION,
|
||||
TX_AUTHORIZED,
|
||||
TX_COMPLETE,
|
||||
TX_STARTED,
|
||||
GitAcknowledgementChannel,
|
||||
TxHandoffAcknowledgement,
|
||||
load_acknowledgement,
|
||||
)
|
||||
|
||||
|
||||
@@ -445,12 +449,12 @@ def acknowledgement_for_result(
|
||||
elif status == "RED_APPROVAL_BLOCKED":
|
||||
acknowledgement_state = RX_READY_OBSERVED
|
||||
elif status == "TX_AUTHORIZATION_PREPARED":
|
||||
acknowledgement_state = TX_AUTHORIZATION_CREATED
|
||||
acknowledgement_state = TX_AUTHORIZED
|
||||
rf_tx_authorized = True
|
||||
if acknowledgement_state is None:
|
||||
return None
|
||||
return TxHandoffAcknowledgement(
|
||||
schema_version=1,
|
||||
schema_version=TX_ACK_SCHEMA_VERSION,
|
||||
task_id=result.task_id,
|
||||
state=acknowledgement_state,
|
||||
source_rx_commit=result_head,
|
||||
@@ -461,6 +465,7 @@ def acknowledgement_for_result(
|
||||
rf_tx_authorized=rf_tx_authorized,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
tx_task_id=(EXPECTED_TX_TASK_ID if rf_tx_authorized else None),
|
||||
)
|
||||
|
||||
|
||||
@@ -536,6 +541,42 @@ def process_history_cycle(
|
||||
|
||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
state_path = arguments.state_file.resolve()
|
||||
if arguments.publish_tx_event is not None:
|
||||
if arguments.dry_run:
|
||||
raise ValueError("--publish-tx-event cannot be combined with --dry-run")
|
||||
repository = arguments.repository.resolve()
|
||||
event = load_acknowledgement(arguments.publish_tx_event.resolve())
|
||||
if event.state not in {TX_STARTED, TX_COMPLETE}:
|
||||
raise ValueError("published execution event has the wrong state")
|
||||
branch = _git_output(repository, ["branch", "--show-current"])
|
||||
if branch != arguments.source_branch:
|
||||
raise ValueError("TX event publisher is on the wrong source branch")
|
||||
if _git_output(repository, ["status", "--porcelain", "--untracked-files=no"]):
|
||||
raise ValueError("tracked worktree must be clean before TX event publication")
|
||||
_git_output(
|
||||
repository,
|
||||
["fetch", "--quiet", arguments.remote, arguments.source_branch],
|
||||
)
|
||||
head = _git_output(repository, ["rev-parse", "HEAD"])
|
||||
remote_head = _git_output(
|
||||
repository,
|
||||
["rev-parse", f"{arguments.remote}/{arguments.source_branch}"],
|
||||
)
|
||||
if head != remote_head:
|
||||
raise ValueError("current HEAD disagrees with remote source branch")
|
||||
if event.source_head != head:
|
||||
raise ValueError("TX event SOURCE_HEAD disagrees with current HEAD")
|
||||
publisher = GitAcknowledgementChannel(
|
||||
repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.acknowledgement_branch,
|
||||
)
|
||||
outcome = publisher.publish_execution_event(event)
|
||||
print(f"TX EVENT PUBLISH = {outcome.status}")
|
||||
print(f"TX EVENT STATE = {event.state}")
|
||||
print(f"TX EVENT HEAD = {outcome.commit}")
|
||||
print(f"TX EVENT REMOTE HEAD = {outcome.remote_head}")
|
||||
return 0
|
||||
if arguments.request_stop:
|
||||
stopped = request_stop(state_path, arguments.stop_timeout)
|
||||
print("TX WATCHER STOPPED" if stopped else "TX WATCHER STOP TIMEOUT")
|
||||
@@ -664,6 +705,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--result-file", type=Path)
|
||||
parser.add_argument("--publish-tx-event", type=Path)
|
||||
parser.add_argument("--approval", choices=("authorize", "deny"))
|
||||
parser.add_argument("--request-stop", action="store_true")
|
||||
parser.add_argument("--stop-timeout", type=float, default=15.0)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from dataclasses import asdict, dataclass, fields, replace
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
@@ -22,7 +22,8 @@ from tools.rf_handoff_channel import (
|
||||
)
|
||||
|
||||
|
||||
TX_ACK_SCHEMA_VERSION = 1
|
||||
TX_ACK_SCHEMA_VERSION = 2
|
||||
LEGACY_TX_ACK_SCHEMA_VERSION = 1
|
||||
TX_RESULT_JSON_PATH = "docs/handoff/results/TX_RESULT.json"
|
||||
TX_RESULT_MARKDOWN_PATH = "docs/handoff/results/TX_RESULT.md"
|
||||
TX_RESULT_PATHS = frozenset((TX_RESULT_JSON_PATH, TX_RESULT_MARKDOWN_PATH))
|
||||
@@ -32,6 +33,7 @@ CHANNEL_READY_OBSERVED = "CHANNEL_READY_OBSERVED"
|
||||
CHANNEL_COMPLETE_OBSERVED = "CHANNEL_COMPLETE_OBSERVED"
|
||||
RX_READY_OBSERVED = "RX_READY_OBSERVED"
|
||||
TX_AUTHORIZATION_CREATED = "TX_AUTHORIZATION_CREATED"
|
||||
TX_AUTHORIZED = "TX_AUTHORIZED"
|
||||
TX_STARTED = "TX_STARTED"
|
||||
TX_COMPLETE = "TX_COMPLETE"
|
||||
TX_ACK_STATES = frozenset(
|
||||
@@ -40,6 +42,7 @@ TX_ACK_STATES = frozenset(
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
TX_AUTHORIZATION_CREATED,
|
||||
TX_AUTHORIZED,
|
||||
TX_STARTED,
|
||||
TX_COMPLETE,
|
||||
)
|
||||
@@ -74,6 +77,12 @@ class TxHandoffAcknowledgement:
|
||||
rf_tx_authorized: bool
|
||||
rf_tx_started: bool
|
||||
rf_tx: int
|
||||
tx_task_id: str | None = None
|
||||
tx_started_at: str | None = None
|
||||
tx_finished_at: str | None = None
|
||||
tx_process_exit_code: int | None = None
|
||||
tx_underruns: int | None = None
|
||||
tx_errors: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -99,8 +108,13 @@ def validate_acknowledgement(
|
||||
) -> TxHandoffAcknowledgement:
|
||||
if type(acknowledgement.schema_version) is not int:
|
||||
raise ValueError("schema_version must contain an integer")
|
||||
if acknowledgement.schema_version != TX_ACK_SCHEMA_VERSION:
|
||||
raise ValueError(f"schema_version must be {TX_ACK_SCHEMA_VERSION}")
|
||||
if acknowledgement.schema_version not in {
|
||||
LEGACY_TX_ACK_SCHEMA_VERSION,
|
||||
TX_ACK_SCHEMA_VERSION,
|
||||
}:
|
||||
raise ValueError(
|
||||
"schema_version must be a supported TX acknowledgement version"
|
||||
)
|
||||
string_values = (
|
||||
acknowledgement.task_id,
|
||||
acknowledgement.state,
|
||||
@@ -121,7 +135,7 @@ def validate_acknowledgement(
|
||||
raise ValueError("source_rx_digest must be a lowercase SHA256")
|
||||
if not _HEAD_RE.fullmatch(acknowledgement.source_head):
|
||||
raise ValueError("source_head must be a lowercase 40-character hash")
|
||||
_parse_utc(acknowledgement.observed_at)
|
||||
observed_at = _parse_utc(acknowledgement.observed_at)
|
||||
if (
|
||||
type(acknowledgement.main_watcher_pid) is not int
|
||||
or acknowledgement.main_watcher_pid <= 0
|
||||
@@ -133,6 +147,58 @@ def validate_acknowledgement(
|
||||
raise ValueError("rf_tx_started must contain a boolean")
|
||||
if type(acknowledgement.rf_tx) is not int or acknowledgement.rf_tx not in {0, 1}:
|
||||
raise ValueError("rf_tx must be 0 or 1")
|
||||
optional_integers = (
|
||||
acknowledgement.tx_process_exit_code,
|
||||
acknowledgement.tx_underruns,
|
||||
acknowledgement.tx_errors,
|
||||
)
|
||||
if any(value is not None and type(value) is not int for value in optional_integers):
|
||||
raise ValueError("TX counters and exit code must contain integers or null")
|
||||
if any(
|
||||
value is not None and value < 0
|
||||
for value in (acknowledgement.tx_underruns, acknowledgement.tx_errors)
|
||||
):
|
||||
raise ValueError("TX error counters cannot be negative")
|
||||
optional_strings = (
|
||||
acknowledgement.tx_task_id,
|
||||
acknowledgement.tx_started_at,
|
||||
acknowledgement.tx_finished_at,
|
||||
)
|
||||
if any(value is not None and type(value) is not str for value in optional_strings):
|
||||
raise ValueError("optional TX fields must contain strings or null")
|
||||
if (
|
||||
acknowledgement.tx_task_id is not None
|
||||
and not _TASK_ID_RE.fullmatch(acknowledgement.tx_task_id)
|
||||
):
|
||||
raise ValueError("tx_task_id is invalid")
|
||||
tx_started_at = (
|
||||
_parse_utc(acknowledgement.tx_started_at)
|
||||
if acknowledgement.tx_started_at is not None
|
||||
else None
|
||||
)
|
||||
tx_finished_at = (
|
||||
_parse_utc(acknowledgement.tx_finished_at)
|
||||
if acknowledgement.tx_finished_at is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
tx_started_at is not None
|
||||
and tx_finished_at is not None
|
||||
and tx_finished_at < tx_started_at
|
||||
):
|
||||
raise ValueError("tx_finished_at cannot precede tx_started_at")
|
||||
if acknowledgement.schema_version == LEGACY_TX_ACK_SCHEMA_VERSION and any(
|
||||
value is not None
|
||||
for value in (
|
||||
acknowledgement.tx_task_id,
|
||||
acknowledgement.tx_started_at,
|
||||
acknowledgement.tx_finished_at,
|
||||
acknowledgement.tx_process_exit_code,
|
||||
acknowledgement.tx_underruns,
|
||||
acknowledgement.tx_errors,
|
||||
)
|
||||
):
|
||||
raise ValueError("legacy acknowledgement cannot contain TX event metadata")
|
||||
|
||||
if (
|
||||
acknowledgement.state
|
||||
@@ -153,41 +219,146 @@ def validate_acknowledgement(
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
} and acknowledgement.task_id != CHANNEL_TEST_TASK_ID:
|
||||
raise ValueError("channel acknowledgement has the wrong task_id")
|
||||
if (
|
||||
acknowledgement.state == TX_AUTHORIZATION_CREATED
|
||||
and (
|
||||
not acknowledgement.rf_tx_authorized
|
||||
if acknowledgement.state == TX_AUTHORIZATION_CREATED and (
|
||||
acknowledgement.schema_version != LEGACY_TX_ACK_SCHEMA_VERSION
|
||||
or not acknowledgement.rf_tx_authorized
|
||||
or acknowledgement.rf_tx_started
|
||||
or acknowledgement.rf_tx != 0
|
||||
)
|
||||
):
|
||||
raise ValueError("legacy TX authorization state is inconsistent")
|
||||
if acknowledgement.state == TX_AUTHORIZED and (
|
||||
acknowledgement.schema_version != TX_ACK_SCHEMA_VERSION
|
||||
or not acknowledgement.rf_tx_authorized
|
||||
or acknowledgement.rf_tx_started
|
||||
or acknowledgement.rf_tx != 0
|
||||
or acknowledgement.tx_task_id is None
|
||||
or acknowledgement.tx_started_at is not None
|
||||
or acknowledgement.tx_finished_at is not None
|
||||
or acknowledgement.tx_process_exit_code is not None
|
||||
):
|
||||
raise ValueError("TX authorization state is inconsistent")
|
||||
if acknowledgement.state in {TX_STARTED, TX_COMPLETE} and not (
|
||||
if acknowledgement.state == TX_STARTED and not (
|
||||
acknowledgement.rf_tx_authorized
|
||||
and acknowledgement.rf_tx_started
|
||||
and acknowledgement.rf_tx == 1
|
||||
and acknowledgement.tx_task_id is not None
|
||||
and acknowledgement.tx_started_at is not None
|
||||
and acknowledgement.tx_finished_at is None
|
||||
and acknowledgement.tx_process_exit_code is None
|
||||
):
|
||||
raise ValueError("TX execution state is inconsistent")
|
||||
raise ValueError("TX start state is inconsistent")
|
||||
if acknowledgement.state == TX_STARTED and (
|
||||
tx_started_at is None or observed_at < tx_started_at
|
||||
):
|
||||
raise ValueError("TX start observation cannot precede process start")
|
||||
if acknowledgement.state == TX_COMPLETE and not (
|
||||
acknowledgement.rf_tx_authorized
|
||||
and acknowledgement.rf_tx_started
|
||||
and acknowledgement.rf_tx == 1
|
||||
and acknowledgement.tx_task_id is not None
|
||||
and acknowledgement.tx_started_at is not None
|
||||
and acknowledgement.tx_finished_at is not None
|
||||
and acknowledgement.tx_process_exit_code is not None
|
||||
and acknowledgement.tx_underruns is not None
|
||||
and acknowledgement.tx_errors is not None
|
||||
):
|
||||
raise ValueError("TX completion state is inconsistent")
|
||||
if acknowledgement.state == TX_COMPLETE and (
|
||||
tx_finished_at is None or observed_at < tx_finished_at
|
||||
):
|
||||
raise ValueError("TX completion observation cannot precede process finish")
|
||||
if acknowledgement.state in {
|
||||
CHANNEL_READY_OBSERVED,
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
} and any(
|
||||
value is not None
|
||||
for value in (
|
||||
acknowledgement.tx_task_id,
|
||||
acknowledgement.tx_started_at,
|
||||
acknowledgement.tx_finished_at,
|
||||
acknowledgement.tx_process_exit_code,
|
||||
acknowledgement.tx_underruns,
|
||||
acknowledgement.tx_errors,
|
||||
)
|
||||
):
|
||||
raise ValueError("observation state cannot contain TX event metadata")
|
||||
return acknowledgement
|
||||
|
||||
|
||||
def build_tx_execution_event(
|
||||
authorization: TxHandoffAcknowledgement,
|
||||
*,
|
||||
state: str,
|
||||
observed_at: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str | None = None,
|
||||
process_exit_code: int | None = None,
|
||||
underruns: int | None = None,
|
||||
errors: int | None = None,
|
||||
) -> TxHandoffAcknowledgement:
|
||||
"""Построить строгое событие TX_STARTED или TX_COMPLETE из разрешения."""
|
||||
|
||||
validate_acknowledgement(authorization)
|
||||
if authorization.state != TX_AUTHORIZED:
|
||||
raise ValueError("TX execution event requires a TX_AUTHORIZED source")
|
||||
if state not in {TX_STARTED, TX_COMPLETE}:
|
||||
raise ValueError("TX execution event state is not supported")
|
||||
event = replace(
|
||||
authorization,
|
||||
state=state,
|
||||
observed_at=observed_at,
|
||||
rf_tx_started=True,
|
||||
rf_tx=1,
|
||||
tx_started_at=tx_started_at,
|
||||
tx_finished_at=tx_finished_at,
|
||||
tx_process_exit_code=process_exit_code,
|
||||
tx_underruns=underruns,
|
||||
tx_errors=errors,
|
||||
)
|
||||
return validate_acknowledgement(event)
|
||||
|
||||
|
||||
def acknowledgement_from_mapping(
|
||||
raw: Mapping[str, Any],
|
||||
) -> TxHandoffAcknowledgement:
|
||||
expected_fields = {field.name for field in fields(TxHandoffAcknowledgement)}
|
||||
optional_event_fields = {
|
||||
"tx_task_id",
|
||||
"tx_started_at",
|
||||
"tx_finished_at",
|
||||
"tx_process_exit_code",
|
||||
"tx_underruns",
|
||||
"tx_errors",
|
||||
}
|
||||
actual_fields = set(raw)
|
||||
if actual_fields != expected_fields:
|
||||
schema_version = raw.get("schema_version")
|
||||
legacy_fields = expected_fields - optional_event_fields
|
||||
if schema_version == LEGACY_TX_ACK_SCHEMA_VERSION and actual_fields == legacy_fields:
|
||||
normalized = {**raw, **dict.fromkeys(optional_event_fields)}
|
||||
elif actual_fields == expected_fields:
|
||||
normalized = dict(raw)
|
||||
else:
|
||||
missing = sorted(expected_fields - actual_fields)
|
||||
extra = sorted(actual_fields - expected_fields)
|
||||
raise ValueError(
|
||||
f"acknowledgement fields disagree: missing={missing}, extra={extra}"
|
||||
)
|
||||
acknowledgement = TxHandoffAcknowledgement(
|
||||
**{name: raw[name] for name in expected_fields}
|
||||
**{name: normalized[name] for name in expected_fields}
|
||||
)
|
||||
return validate_acknowledgement(acknowledgement)
|
||||
|
||||
|
||||
def load_acknowledgement(path: Path) -> TxHandoffAcknowledgement:
|
||||
"""Прочитать строгое малое TX-событие из локального JSON."""
|
||||
|
||||
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("TX acknowledgement root must be a JSON object")
|
||||
return acknowledgement_from_mapping(raw)
|
||||
|
||||
|
||||
def canonical_acknowledgement_json(
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
) -> str:
|
||||
@@ -226,6 +397,12 @@ def render_acknowledgement_markdown(
|
||||
f"{'YES' if acknowledgement.rf_tx_authorized else 'NO'}",
|
||||
f"RF_TX_STARTED = {'YES' if acknowledgement.rf_tx_started else 'NO'}",
|
||||
f"RF_TX = {acknowledgement.rf_tx}",
|
||||
f"TX_TASK_ID = {acknowledgement.tx_task_id}",
|
||||
f"TX_STARTED_AT = {acknowledgement.tx_started_at}",
|
||||
f"TX_FINISHED_AT = {acknowledgement.tx_finished_at}",
|
||||
f"TX_PROCESS_EXIT_CODE = {acknowledgement.tx_process_exit_code}",
|
||||
f"TX_UNDERRUNS = {acknowledgement.tx_underruns}",
|
||||
f"TX_ERRORS = {acknowledgement.tx_errors}",
|
||||
"",
|
||||
"Raw IQ, video, waveforms, source code, local state and large logs "
|
||||
"are not stored in this branch.",
|
||||
@@ -368,10 +545,59 @@ class GitAcknowledgementChannel(GitResultChannel):
|
||||
== acknowledgement.rf_tx_authorized
|
||||
and observed.rf_tx_started == acknowledgement.rf_tx_started
|
||||
and observed.rf_tx == acknowledgement.rf_tx
|
||||
and observed.tx_task_id == acknowledgement.tx_task_id
|
||||
and observed.tx_started_at == acknowledgement.tx_started_at
|
||||
and observed.tx_finished_at == acknowledgement.tx_finished_at
|
||||
and observed.tx_process_exit_code
|
||||
== acknowledgement.tx_process_exit_code
|
||||
and observed.tx_underruns == acknowledgement.tx_underruns
|
||||
and observed.tx_errors == acknowledgement.tx_errors
|
||||
):
|
||||
return commit
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _same_execution_binding(
|
||||
left: TxHandoffAcknowledgement,
|
||||
right: TxHandoffAcknowledgement,
|
||||
) -> bool:
|
||||
return (
|
||||
left.task_id == right.task_id
|
||||
and left.source_rx_commit == right.source_rx_commit
|
||||
and left.source_rx_digest == right.source_rx_digest
|
||||
and left.source_head == right.source_head
|
||||
and left.tx_task_id == right.tx_task_id
|
||||
)
|
||||
|
||||
def publish_execution_event(
|
||||
self,
|
||||
event: TxHandoffAcknowledgement,
|
||||
) -> PublishOutcome:
|
||||
"""Опубликовать TX-событие только после связанного TX_AUTHORIZED."""
|
||||
|
||||
validate_acknowledgement(event)
|
||||
if event.state not in {TX_STARTED, TX_COMPLETE}:
|
||||
raise ResultChannelError("execution event must be TX_STARTED or TX_COMPLETE")
|
||||
commits, _remote_head = self.commits_since(None)
|
||||
authorization_found = False
|
||||
start_found = event.state == TX_STARTED
|
||||
for commit in commits:
|
||||
observed, _digest, _head = self.read_commit(commit)
|
||||
if not self._same_execution_binding(observed, event):
|
||||
continue
|
||||
if observed.state == TX_AUTHORIZED:
|
||||
authorization_found = True
|
||||
if (
|
||||
observed.state == TX_STARTED
|
||||
and observed.tx_started_at == event.tx_started_at
|
||||
):
|
||||
start_found = True
|
||||
if not authorization_found:
|
||||
raise ResultChannelError("matching TX_AUTHORIZED event is absent")
|
||||
if not start_found:
|
||||
raise ResultChannelError("matching TX_STARTED event is absent")
|
||||
return self.publish(event)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
@@ -471,24 +697,10 @@ class GitAcknowledgementChannel(GitResultChannel):
|
||||
raise ResultChannelError(
|
||||
"committed tx-node tree violates the path allowlist"
|
||||
)
|
||||
push = self._run(
|
||||
[
|
||||
"push",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"HEAD:refs/heads/{self.branch}",
|
||||
],
|
||||
cwd=checkout,
|
||||
check=False,
|
||||
)
|
||||
if push.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"acknowledgement push failed: {push.stderr.strip()}"
|
||||
)
|
||||
remote_after = self.remote_head()
|
||||
if remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote tx-node HEAD does not confirm the published commit"
|
||||
self._push_exact_commit(
|
||||
checkout,
|
||||
commit,
|
||||
remote_before,
|
||||
)
|
||||
return PublishOutcome(
|
||||
"PUBLISHED",
|
||||
|
||||
Reference in New Issue
Block a user