Merge branch 'pr-2267' into integrate/release-20261017

This commit is contained in:
ruv committed 2026-10-17 08:58:35 -04:00
commit fde7514128
20 files changed
+1034

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
name: W1700K RF diagnostics
on:
pull_request:
paths: ['tools/w1700k/**', 'docs/rf-diagnostics/**', '.github/workflows/rf-diagnostics.yml']
push:
branches: [main]
paths: ['tools/w1700k/**', 'docs/rf-diagnostics/**', '.github/workflows/rf-diagnostics.yml']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: rf-diagnostics-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Bridge ABI and failure tests
run: make -C tools/w1700k check
- name: Viewer validation tests
run: |
node --test docs/rf-diagnostics/model.test.cjs
node --check docs/rf-diagnostics/app.js
deploy:
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
needs: verify
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Stage static viewer only
run: |
mkdir -p _site
cp docs/rf-diagnostics/index.html docs/rf-diagnostics/model.js docs/rf-diagnostics/app.js docs/rf-diagnostics/style.css _site/
- name: Preserve existing Pages content
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./_site
destination_dir: rf-diagnostics
keep_files: true
commit_message: 'deploy(rf-diagnostics): ${{ github.sha }}'
+3
View File
@@ -1,5 +1,8 @@
# π RuView
> **W1700K RF diagnostics:** [receiver bridge](tools/w1700k/README.md) · [browser viewer](https://ruvnet.github.io/RuView/rf-diagnostics/) · [integration design](docs/adr/ADR-419-w1700k-rf-diagnostics.md).
> Reads IQ locally and exports bounded quality summaries to RuField. Synthetic contract tests are separate from hardware validation. RF diagnostics only; no CSI extraction or presence detection.
<p align="center">
<a href="https://cognitum.one/seed">
<img src="assets/ruview-hero-h3-v3.gif" alt="RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi" width="100%">
+40
View File
@@ -0,0 +1,40 @@
# ADR-419: W1700K diagnostics and RuField evidence boundary
Status: implementation candidate; hardware validation pending.
Date: 2026-10-14
## Decision
Use an original C consumer of the upstream MIT shared memory ABI, compact JSONL diagnostics, a local browser viewer, and a bounded RuField adapter. Spectrum quality is a separate modality and cannot contribute occupancy evidence.
Upstream: h0m3us3r/w1700k-sdr commit 0b0413e4309f0c083702fa4eb2d1c2bca4532163, host/stream/include/w1700k_stream.h. Do not copy the GPL recorder.
## Flow and invariants
1. The upstream daemon publishes native-endian IQ in ABI v1 shared memory.
2. The consumer validates geometry, reads bounded slots, checks tags around copies, and calculates RMS and int16 rail fractions.
3. JSONL retains source mode, sample rate, completion index, loss and quality reasons. Raw IQ stays on the host.
4. The browser imports records locally. Importing live-labelled data is still file replay.
5. RuField preserves source labels, produces untrusted diagnostics and excludes them from occupancy fusion.
Acceptance: synthetic ABI fixture through the real bridge, JSON validator, viewer and Rust adapter. Malformed geometry, truncation and invalid numbers fail closed. Unknown fields cannot smuggle IQ into downstream evidence.
## Configuration and timing
The header does not establish LO, gain, band or an atomic configuration generation. Caller frequency is advisory. All v1 records have config_verified=false and valid=false. Metrics remain useful for inspection; they cannot promote sensing claims.
Monotonic nanoseconds are host-local, not UTC or cross-device synchronization. Event slots consume no sample time. Unknown sample position remains null.
IQ scale is 8192. Flattening may legitimately exceed that amplitude; rail fraction counts int16 rails, not calibrated ADC clipping.
## Alternatives and limits
A second UDP receiver duplicates transport handling. Direct IQ into a CSI model violates its input contract. Upstream ci16 sidecars truncate gap indices and omit event positions, preventing exact arbitrary replay. Use shared memory and exact synthetic fixtures.
No CSI extraction, passive radar, pose, hardware accuracy lift or sustained 320 Msps benchmark is claimed. No router flashing or transmission.
## Security and release
Bound file sizes, offsets, record lengths and numeric ranges. Reject unknown diagnostic fields. Local file import performs no upload. Trust enrollment is separate from parsing; synthetic evidence stays labelled.
Publish tested source after independent review. Deploy only the viewer directory, preserving other Pages content. Roll back by reverting this change and its deployment.
+45
View File
@@ -0,0 +1,45 @@
# RuView RF diagnostics viewer
Open [index.html](index.html) in a modern browser, or use the deployed documentation page. This is a standalone local JSONL viewer, not a hardware connection. It has no runtime dependencies, telemetry, external assets, uploads or network API.
1. Run the producer described in [tools/w1700k](../../tools/w1700k/) and save its diagnostic JSONL output.
2. Select **Import local JSONL**. Files must be at most 10 MiB, with 1 to 10,000 records.
3. Inspect relative RMS, numeric rail fraction, reported missing samples, reason counts and the last 500 observations.
4. Select **Clear** to remove the loaded observations, or **Load synthetic demo** for a deterministic synthetic example.
Raw IQ and unknown fields are rejected. The viewer accepts the frozen `ruview.rf_diagnostics.v1` schema and requires `valid:false`, `config_verified:false` and the `configuration_unverified` reason. Integers must fit JavaScript's safe integer range; larger producer counters are rejected rather than silently rounded.
## Evidence boundary
| Producer source_kind | Display |
| --- | --- |
| synthetic | SYNTHETIC |
| captured_replay | CAPTURED REPLAY |
| live | LIVE UNVERIFIED |
Every imported file is replay. Source labels are claims from the input and do not prove that a device was used. Mixed input displays all applicable labels. Nothing here establishes human presence, motion, pose, CSI or calibrated RF power.
RMS is relative dBFS; numeric rails are an advisory clipping indicator. Missing sample totals sum the producer's per record values. Record order is preserved rather than interpreted as synchronized time. Host monotonic timestamps cannot establish wall clock freshness. The producer TTL is displayed but is never converted into a valid or fresh verdict for file replay.
All text is rendered with textContent. A restrictive content security policy blocks network connections. Invalid imports clear the previous dataset to avoid showing stale values as the new result.
## Verification
From the repository root:
```sh
node --test docs/rf-diagnostics/model.test.cjs
node --check docs/rf-diagnostics/app.js
```
Optional browser smoke with an existing Playwright installation and Chromium:
```sh
PLAYWRIGHT_MODULE=/absolute/path/to/playwright node docs/rf-diagnostics/browser-smoke.cjs
```
The browser test checks mobile layout, synthetic rendering, actual local file import, unverified live labels, malformed input rejection, clear and zero external requests. It does not establish physical hardware performance. No package is installed by these commands.
### Numeric contract
Sample rates are unknown (0), 40, 80, 160 or 320 Msps. Centre frequency is unknown or 2 to 8 GHz. Each record describes zero or 2048 samples; missing sample counts are multiples of 2048. Absolute sample start remains null. RMS is bounded to −200 through 16 dBFS to accommodate numeric filter headroom, and an available RMS requires an available rail fraction. Reason names are from the producer whitelist and cannot repeat.
+48
View File
@@ -0,0 +1,48 @@
'use strict';
const M=RFDiagnostics,$=id=>document.getElementById(id),ns='http://www.w3.org/2000/svg';
let generation=0;
function render(rows){
const last=rows.at(-1),kinds=[...new Set(rows.map(r=>M.modes[r.source_kind]))];
$('mode').textContent=kinds.join(' + ')||'NO DATA';
$('count').textContent=rows.length.toLocaleString();
$('rms').textContent=last?.rms_dbfs==null?'—':last.rms_dbfs.toFixed(2);
$('rails').textContent=last?.rail_fraction==null?'—':(last.rail_fraction*100).toFixed(3)+'%';
$('gaps').textContent=rows.length?rows.reduce((n,r)=>n+BigInt(r.missing_samples),0n).toLocaleString():'—';
const reasons=new Map();
for(const r of rows)for(const reason of new Set(r.invalid_reasons))reasons.set(reason,(reasons.get(reason)||0)+1);
$('reasons').replaceChildren();
for(const [reason,count] of reasons){const li=document.createElement('li');li.textContent=reason+' · '+count.toLocaleString();$('reasons').append(li);}
$('meta').textContent=last?[
'Source: '+last.source_id,'Origin: '+last.source_kind+' (file/demo replay)',
'Rate: '+(last.sample_rate_hz||'unknown')+' samples/s',
'Centre: '+(last.center_frequency_hz??'unknown')+' Hz (unverified)',
'Run / slot: '+last.run+' / '+last.slot_index,
'Host monotonic ns: '+last.observed_monotonic_ns,
'Absolute sample start: '+(last.sample_start??'unknown'),
'Configuration verified: false','Valid for inference: false',
'TTL: '+last.ttl_ms+' ms; replay freshness unknown'
].join('\n'):'No observations';
const tail=rows.slice(-500),values=tail.map(r=>r.rms_dbfs).filter(v=>v!==null);
const low=Math.min(...values),high=Math.max(...values),span=high-low||1;
$('chart').replaceChildren();
let path='';
tail.forEach((r,i)=>{
if(r.rms_dbfs===null){path+=' ';return;}
const x=20+i*960/Math.max(1,tail.length-1),y=220-200*(r.rms_dbfs-low)/span;
path+=(i===0||tail[i-1].rms_dbfs===null?'M':'L')+x+','+y+' ';
if(r.missing_samples){const dot=document.createElementNS(ns,'circle');dot.setAttribute('cx',x);dot.setAttribute('cy',y);dot.setAttribute('r',4);dot.setAttribute('fill','#ffb66c');$('chart').append(dot);}
});
const line=document.createElementNS(ns,'path');line.setAttribute('d',path);line.setAttribute('fill','none');line.setAttribute('stroke','#71e7db');line.setAttribute('stroke-width',2);$('chart').prepend(line);
$('range').textContent=values.length?'Range '+low.toFixed(2)+' to '+high.toFixed(2)+' dBFS · '+tail.length+' latest records':'No RMS observations';
$('chart').setAttribute('aria-label',$('range').textContent);
}
$('demo').onclick=()=>{generation++;render(M.demo());$('status').textContent='Synthetic demonstration loaded. No hardware evidence.';};
$('clear').onclick=()=>{generation++;render([]);$('file').value='';$('status').textContent='Cleared. No observations retained.';};
$('file').onchange=async()=>{
const ticket=++generation,file=$('file').files[0];if(!file)return;
try{
if(file.size>M.MAX_BYTES)throw Error('File exceeds 10 MiB');
const rows=M.parse(await file.text());if(ticket!==generation)return;
render(rows);$('status').textContent='Imported '+rows.length+' records. Source labels are unverified claims; this is file replay.';
}catch(e){if(ticket!==generation)return;render([]);$('status').textContent='Import rejected: '+e.message;}
};
+29
View File
@@ -0,0 +1,29 @@
'use strict';
const assert=require('node:assert/strict'),path=require('node:path');
const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage({viewport:{width:390,height:844}});
const errors=[],external=[];
page.on('pageerror',e=>errors.push(e.message));
page.on('request',r=>{if(/^https?:/.test(r.url()))external.push(r.url());});
await page.goto('file://'+path.join(__dirname,'index.html'));
await page.click('#demo');
assert.equal(await page.textContent('#mode'),'SYNTHETIC');
assert.equal(await page.textContent('#count'),'96');
assert(await page.locator('#chart path').getAttribute('d'));
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
const r=require('./model.js').demo()[0];r.source_kind='live';
await page.setInputFiles('#file',{name:'live.jsonl',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(r))});
await page.waitForFunction(()=>document.querySelector('#mode').textContent==='LIVE UNVERIFIED');
assert.match(await page.textContent('#status'),/file replay/);
await page.setInputFiles('#file',{name:'bad.jsonl',mimeType:'application/json',buffer:Buffer.from('{"iq":[1,2]}')});
await page.waitForFunction(()=>document.querySelector('#status').textContent.startsWith('Import rejected'));
assert.equal(await page.textContent('#count'),'0');
await page.click('#demo');await page.click('#clear');
assert.equal(await page.textContent('#count'),'0');
assert.deepEqual(errors,[]);assert.deepEqual(external,[]);
console.log('PASS mobile render, demo, JSONL import, unverified live label, malformed rejection, clear, zero external requests');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>RuView RF Diagnostics</title><link rel="stylesheet" href="style.css"><script defer src="model.js"></script><script defer src="app.js"></script></head>
<body><main><header><p class="eyebrow">COGNITUM / RUVIEW / W1700K</p><span id="mode" class="badge">NO DATA</span><h1>RF diagnostics.</h1><p class="intro">Understand the measurement before trusting the inference.</p></header>
<section class="controls" aria-label="Import controls"><label class="button">Import local JSONL<input id="file" type="file" accept=".jsonl,.ndjson,.json,application/json"></label><button id="demo">Load synthetic demo</button><button id="clear">Clear</button><p>Local processing only. Maximum 10 MiB and 10,000 observations. No uploads.</p></section>
<p id="status" role="status">Import bridge diagnostics or explore a clearly labelled synthetic example.</p>
<aside>ADVISORY ONLY · Configuration is unverified. These records cannot establish presence, pose or CSI accuracy. Imported files are replay, never an active hardware connection.</aside>
<section class="metrics" aria-label="Latest observation"><article><p>Latest RMS</p><strong id="rms">—</strong><small>relative dBFS, not calibrated RF power</small></article><article><p>Latest rails</p><strong id="rails">—</strong><small>samples at numeric limits</small></article><article><p>Reported missing</p><strong id="gaps">—</strong><small>sum of per record missing samples</small></article><article><p>Observations</p><strong id="count">0</strong><small>all v1 observations invalid</small></article></section>
<section class="panel"><h2>Relative RMS history</h2><p>Record order, last 500 observations. Missing metrics break the line. Orange dots flag missing samples.</p><svg id="chart" viewBox="0 0 1000 240" role="img" aria-label="Relative RMS history; import a diagnostics file"></svg><p id="range">No observations</p></section>
<div class="split"><section class="panel"><h2>Invalidation reasons</h2><ul id="reasons"><li>No observations</li></ul></section><section class="panel"><h2>Source metadata</h2><pre id="meta">No observations</pre></section></div>
<footer>RuField evidence boundary · 1 second TTL is producer metadata. Host monotonic time is not wall time; file replay cannot establish freshness.</footer></main></body></html>
+46
View File
@@ -0,0 +1,46 @@
(function(root){
'use strict';
const MAX_BYTES=10*1024*1024, MAX_RECORDS=10000;
const fields='schema source_kind source_id observed_monotonic_ns run slot_index sample_start sample_rate_hz center_frequency_hz config_verified valid invalid_reasons sample_count rms_dbfs rail_fraction missing_samples event_count overrun_slices ttl_ms'.split(' ');
const modes={synthetic:'SYNTHETIC',live:'LIVE UNVERIFIED',captured_replay:'CAPTURED REPLAY'};
function validate(r){
const fail=(s)=>{throw Error(s)};
if(!r||Array.isArray(r)||typeof r!=='object')fail('Expected an object');
for(const k of Object.keys(r))if(!fields.includes(k))fail('Unsupported field: '+k);
for(const k of fields)if(!Object.hasOwn(r,k))fail('Missing field: '+k);
if(r.schema!=='ruview.rf_diagnostics.v1')fail('Unsupported schema');
if(!Object.hasOwn(modes,r.source_kind)||r.source_id!=='w1700k')fail('Unsupported source');
for(const k of ['observed_monotonic_ns','run','slot_index','sample_rate_hz','sample_count','missing_samples','event_count','overrun_slices','ttl_ms'])
if(!Number.isSafeInteger(r[k])||r[k]<0)fail('Invalid '+k);
if(r.ttl_ms!==1000)fail('Invalid rate or TTL');
for(const k of ['sample_start','center_frequency_hz'])
if(r[k]!==null&&(!Number.isSafeInteger(r[k])||r[k]<0))fail('Invalid '+k);
if(r.rms_dbfs!==null&&(typeof r.rms_dbfs!=='number'||!Number.isFinite(r.rms_dbfs)))fail('Invalid RMS');
if(r.rail_fraction!==null&&(typeof r.rail_fraction!=='number'||!Number.isFinite(r.rail_fraction)||r.rail_fraction<0||r.rail_fraction>1))fail('Invalid rail fraction');
if(r.config_verified!==false||r.valid!==false)fail('v1 must remain unverified and invalid');
if(!Array.isArray(r.invalid_reasons)||r.invalid_reasons.length>32||!r.invalid_reasons.includes('configuration_unverified')||r.invalid_reasons.some(x=>typeof x!=='string'||!/^[a-z_]{1,64}$/.test(x)))fail('Invalid reasons');
if(![0,40000000,80000000,160000000,320000000].includes(r.sample_rate_hz))fail('Invalid sample rate');
if(r.sample_start!==null)fail('Absolute sample start is unavailable in v1');
if(r.center_frequency_hz!==null&&(r.center_frequency_hz<2000000000||r.center_frequency_hz>8000000000))fail('Invalid centre frequency');
if(![0,2048].includes(r.sample_count)||r.missing_samples%2048!==0)fail('Invalid sample count');
if(r.rms_dbfs!==null&&(r.rms_dbfs< -200||r.rms_dbfs>16||r.rail_fraction===null))fail('Invalid RMS or missing rail fraction');
const reasons=['configuration_unverified','lost','event','overrun','stale','tag_mismatch','run_change'];
if(new Set(r.invalid_reasons).size!==r.invalid_reasons.length||r.invalid_reasons.length>7||r.invalid_reasons.some(x=>!reasons.includes(x)))fail('Unsupported invalid reason');
return r;
}
function parse(text){
if(typeof text!=='string'||new TextEncoder().encode(text).length>MAX_BYTES)throw Error('File exceeds 10 MiB');
const lines=text.split(/\r?\n/).filter(x=>x.trim());
if(!lines.length||lines.length>MAX_RECORDS)throw Error('Expected 1 to 10,000 records');
return lines.map((s,i)=>{try{return validate(JSON.parse(s))}catch(e){throw Error('Record '+(i+1)+': '+e.message)}});
}
function demo(){return Array.from({length:96},(_,i)=>({
schema:'ruview.rf_diagnostics.v1',source_kind:'synthetic',source_id:'w1700k',
observed_monotonic_ns:i*100000000,run:1,slot_index:i,sample_start:null,
sample_rate_hz:40000000,center_frequency_hz:2437000000,config_verified:false,valid:false,
invalid_reasons:i%23===0?['configuration_unverified','lost']:['configuration_unverified'],
sample_count:2048,rms_dbfs:-34+Math.sin(i/8)*6,rail_fraction:i%23===0?0.03:0,
missing_samples:i%23===0?2048:0,event_count:0,overrun_slices:0,ttl_ms:1000}));}
const api={MAX_BYTES,MAX_RECORDS,modes,validate,parse,demo};
if(typeof module==='object')module.exports=api;else root.RFDiagnostics=api;
})(globalThis);
+23
View File
@@ -0,0 +1,23 @@
'use strict';
const {test}=require('node:test'),assert=require('node:assert/strict'),M=require('./model.js');
const good=()=>M.demo()[0];
test('synthetic fixture and JSONL roundtrip',()=>assert.equal(M.parse(M.demo().map(JSON.stringify).join('\n')).length,96));
test('captured and live labels remain unverified',()=>{for(const source_kind of ['live','captured_replay'])assert.equal(M.validate({...good(),source_kind}).valid,false);});
test('reject malformed, empty, oversized and too many records',()=>{
for(const s of ['', '[]','{',' '.repeat(M.MAX_BYTES+1),Array(10002).fill('{}').join('\n')])assert.throws(()=>M.parse(s));
});
test('reject inference promotion, raw IQ, nonfinite metrics and unsafe indices',()=>{
for(const patch of [{valid:true},{config_verified:true},{iq:[1,2]},{rms_dbfs:Infinity},{rail_fraction:2},{rail_fraction:NaN},{observed_monotonic_ns:2**53},{source_kind:'__proto__'},{ttl_ms:0},{invalid_reasons:['<script>']},{invalid_reasons:[]}])assert.throws(()=>M.validate({...good(),...patch}));
});
test('reject missing fields with record index',()=>{
const r=good();delete r.sample_start;assert.throws(()=>M.parse(JSON.stringify(r)),/Record 1: Missing field/);
});
test('allow unavailable measurements but retain invalid evidence',()=>{
assert.equal(M.validate({...good(),rms_dbfs:null,rail_fraction:null,center_frequency_hz:null,sample_rate_hz:0}).valid,false);
});
test('match producer and RuField numeric contract',()=>{
for(const patch of [{sample_start:0},{sample_count:4096},{missing_samples:128},{sample_rate_hz:123},{center_frequency_hz:1999999999},{center_frequency_hz:8000000001},{rms_dbfs:17},{rms_dbfs:-201},{rms_dbfs:-6,rail_fraction:null},{invalid_reasons:['configuration_unverified','configuration_unverified']},{invalid_reasons:['configuration_unverified','other']}])
assert.throws(()=>M.validate({...good(),...patch}));
for(const patch of [{center_frequency_hz:7576000000},{rms_dbfs:15.05},{sample_count:0,rms_dbfs:null,rail_fraction:null},{sample_rate_hz:0},{missing_samples:2048}])M.validate({...good(),...patch});
});
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#080f17;color:#d9e8ee}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 85% 0,#113642,transparent 50%)}main{max-width:1200px;margin:auto;padding:48px 28px}header{position:relative}.eyebrow{letter-spacing:.2em;color:#62d9da;font-size:12px;font-weight:700}h1{font-size:clamp(38px,7vw,76px);letter-spacing:-.055em;margin:18px 0}.intro{font-size:20px;color:#a4bcc6}.badge{display:inline-block;padding:8px 14px;border:1px solid #b89851;border-radius:32px;color:#f5d18c;font-size:12px;font-weight:bold}.controls{margin-top:32px;display:flex;gap:12px;flex-wrap:wrap;align-items:center}.controls p{flex-basis:100%;font-size:13px;color:#a4bcc6}button,.button{font:inherit;font-size:14px;border:1px solid #38616f;background:#152b37;color:#e7faff;border-radius:10px;padding:13px 18px;cursor:pointer}.button{background:#77e9dd;color:#082b2c;font-weight:700;position:relative;overflow:hidden}.button input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}button:hover,.button:hover{filter:brightness(1.2)}button:focus-visible,.button:focus-within{outline:3px solid #fff;outline-offset:3px}aside{border-left:3px solid #e5b85e;padding:16px 20px;background:#30291c;font-size:14px;line-height:1.6;color:#f5ddab}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:24px 0}.metrics article,.panel{border:1px solid #25414d;background:#0d1c26;border-radius:14px;padding:22px}.metrics p{font-size:13px;color:#9cb6c1}.metrics strong{font-size:30px;color:#79eddf;display:block;margin:16px 0}.metrics small{color:#9cb6c1;font-size:11px}.panel{margin-bottom:18px}h2{font-size:18px;margin-top:0}.panel p,footer{color:#9cb6c1;font-size:13px;line-height:1.6}svg{width:100%;height:auto;display:block;background:linear-gradient(#132c37,#0d1c26);border-radius:8px}.split{display:grid;grid-template-columns:1fr 1fr;gap:18px}pre{white-space:pre-wrap;word-break:break-word;color:#b7d1da;line-height:1.7;font-size:12px}li{margin-bottom:10px;overflow-wrap:anywhere}#status{font-size:14px;min-height:22px}footer{margin:24px 0} @media(max-width:700px){main{padding:28px 16px}.metrics{grid-template-columns:1fr 1fr}.split{grid-template-columns:1fr;gap:0}.metrics article{padding:14px}.metrics strong{font-size:24px}}
+3
View File
@@ -0,0 +1,3 @@
build/
__pycache__/
*.shm
+15
View File
@@ -0,0 +1,15 @@
CC ?= cc
CFLAGS ?= -O2 -std=c11 -Wall -Wextra -Werror
BUILD ?= build
.PHONY: all check test clean
all: $(BUILD)/w1700k-diagnostics $(BUILD)/w1700k-fixture
$(BUILD):
mkdir -p $@
$(BUILD)/w1700k-diagnostics: diagnostics.c vendor/w1700k_stream.h | $(BUILD)
$(CC) $(CFLAGS) $< -lm -o $@
$(BUILD)/w1700k-fixture: fixture.c vendor/w1700k_stream.h | $(BUILD)
$(CC) $(CFLAGS) $< -o $@
check test:
python3 test_bridge.py
clean:
rm -f $(BUILD)/w1700k-diagnostics $(BUILD)/w1700k-fixture
+117
View File
@@ -0,0 +1,117 @@
# W1700K RF diagnostics bridge
Status: **SYNTHETIC tested**, hardware validation pending. This read-only Linux
consumer produces advisory RF quality JSONL for RuView and RuField. It does not
extract CSI, classify presence, estimate pose, or output calibrated RF power.
## Build and use
Requires a C11 compiler, make, Python 3 for tests, and upstream w1700k-stream
for a live sensor. No network access or runtime dependencies.
```sh
make -C tools/w1700k
make -C tools/w1700k check
tools/w1700k/build/w1700k-diagnostics --once
tools/w1700k/build/w1700k-diagnostics --follow --timeout-seconds 60 --max-records 4096
```
The default input is /dev/shm/w1700k-stream. The input is never written and radio
controls are never changed. --source-kind defaults to live; synthetic and
captured_replay are explicit caller assertions, not cryptographic provenance.
Optional --center-frequency-hz accepts 2 through 8 GHz, but remains unverified.
Generate an ABI-complete **synthetic** fixture and compact diagnostics:
```sh
tools/w1700k/build/w1700k-fixture /tmp/w1700k-synthetic.shm normal
tools/w1700k/build/w1700k-diagnostics --input /tmp/w1700k-synthetic.shm --source-kind synthetic --once > /tmp/w1700k-synthetic.jsonl
```
Other fixture modes: gap, event, flat, tag, magic, ring, offset, overflow,
seqlock, truncated, stale, idle, run_overflow. Fixtures are test-only generators;
do not overwrite a running service's shared memory. Arbitrary ci16 recorder
outputs are not accepted: their missing event positions cannot recover timing.
## Evidence contract
One JSON object per slot, schema ruview.rf_diagnostics.v1. Fields:
| Field | Meaning |
|---|---|
| source_kind, source_id | Caller assertion live, synthetic or captured_replay; w1700k |
| observed_monotonic_ns | Local host CLOCK_MONOTONIC read time, never UTC or cross-host comparable |
| run, slot_index | Upstream run and DMA completion identity, not a sample timestamp |
| sample_start | Always null in v1; prior event history is not available |
| sample_rate_hz | Control-word claim, 0 unknown or 40/80/160/320 MHz |
| center_frequency_hz | Null or caller-supplied claim; not observed from hardware |
| config_verified, valid | Always false: ABI lacks actual LO, gain, band and configuration generation |
| invalid_reasons | configuration_unverified plus lost/event/overrun/stale/tag_mismatch/run_change |
| sample_count | 2048 for a stable sample slot, otherwise 0 |
| rms_dbfs | 10 log10(mean(I²+Q²)/8192²), null at zero power or no samples |
| rail_fraction | Fraction of int16 components on either storage rail, null without samples |
| missing_samples | 2048 for one LOST slot; 0 for EVENT and unknown overrun composition |
| event_count, overrun_slices | Per-record events or skipped overwritten completions |
| ttl_ms | 1000; receiving systems must start their own local expiry timer |
14-bit full scale is 8192. Filter headroom can legitimately exceed that value;
positive dBFS is not clipping. Rail fraction is an indicator, not proof of ADC
clipping. Complex RMS is normalized per complex sample, not per component.
Metrics are advisory even when numeric. Nothing here may promote human presence
or override RuField's missing calibration/configuration gates.
## Safety and operating limits
Header magic/version, fixed geometry, power-of-two bounded ring, aligned
nonoverlapping offsets, arithmetic overflow, exact file size and tag identity
ranges are checked. Position reads have a 64-attempt bound. Input is opened
read-only with no symlink following and must be a regular file (including SHM).
The bridge uses Linux pread snapshots and acquire fences, then tag/copy/tag
validation, rather than exposing mmap memory to untrusted concurrent truncation.
Short reads fail with exit 2 instead of SIGBUS. This is intended for the trusted
local upstream producer on a native ABI host, not an adversarial writer capable
of forging consistent tags. It does not claim that fences convert kernel copies
into cross-process C atomic loads; hardware qualification must validate this
snapshot strategy on target architectures.
Live mode rejects stale or future heartbeats with an invalid stale record and
exit 3. Captured replay and synthetic inputs deliberately do not compare saved
heartbeats with the current host. A producer restart may leave the old inode
open; heartbeat expiry requires restart/reopen of this consumer. No automatic
reconnect or permission expansion.
--once takes the newest available bounded tail (64 records by default).
--follow starts at that same tail and follows until timeout, record limit,
interrupt or failure. Polling is 10 ms. This is a bounded diagnostic sampler,
not a promise of lossless full-bandwidth capture. Detected overruns are invalid
records; their unknown mix of samples/events is never converted to invented
missing sample time. Existing backlog intentionally outside the initial tail is
not treated as a newly observed overrun. Hardware throughput is unmeasured.
No raw IQ is exported. JSONL contains no decoded payloads, identifiers, MAC
addresses or presence claims. Do not commit live recordings. Shipping evidence
must distinguish fixtures from real device captures.
## Cross-repository acceptance
Build the RuField adapter from the companion ruvnet/rufield checkout:
```sh
cd ../rufield
cargo build -p rufield-adapters --bin rf-diagnostics-replay
cd ../RuView
python3 tools/w1700k/verify_integration.py --rufield-bin ../rufield/target/debug/rf-diagnostics-replay
```
This checks six synthetic ABI scenarios through the C bridge, browser validator and RuField adapter. It verifies gap/event semantics and that diagnostic evidence remains unsigned, synthetic and zero-confidence. It does not establish real receiver performance.
For interactive inspection, open [the local viewer](../../docs/rf-diagnostics/index.html) or [the hosted viewer](https://ruvnet.github.io/RuView/rf-diagnostics/) and import the bridge JSONL. Files remain in your browser. An imported live-labelled file is replay, not a live connection.
Convert diagnostic JSONL to advisory FieldEvents:
```sh
../rufield/target/debug/rf-diagnostics-replay < diagnostics.jsonl > advisory-events.jsonl
```
These events cannot contribute spatial/occupancy fusion. Automatic suppression of other sensors needs a future association and timing policy. CSI extraction, spectrum bins and hardware accuracy benchmarks remain future work.
+10
View File
@@ -0,0 +1,10 @@
# Third-party notice
vendor/w1700k_stream.h is copied unchanged from:
https://github.com/h0m3us3r/w1700k-sdr
commit 0b0413e4309f0c083702fa4eb2d1c2bca4532163
path host/stream/include/w1700k_stream.h
It is explicitly SPDX-License-Identifier: MIT. Its MIT license text is retained
in vendor/LICENSE.MIT. No GPL recorder implementation was copied. Bridge and
fixture code are original integration code licensed MIT.
+176
View File
@@ -0,0 +1,176 @@
/* SPDX-License-Identifier: MIT
* RuView advisory W1700K diagnostics; no CSI or presence inference.
* Local trusted producer only. pread avoids SIGBUS on concurrent truncation.
*/
#define _POSIX_C_SOURCE 200809L
#include "vendor/w1700k_stream.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <limits.h>
#include <inttypes.h>
#include <stdatomic.h>
#include <signal.h>
#define MAX_RING 1048576ULL
#define MAX_RECORDS 4096
static volatile sig_atomic_t stop;
static void stopped(int sig) { (void)sig; stop=1; }
static void die(const char *s) { fprintf(stderr,"w1700k-diagnostics: %s\n",s); exit(2); }
static void rd(int fd, void *p, size_t n, uint64_t off) {
size_t got=0;
while(got<n) {
ssize_t k=pread(fd,(char*)p+got,n-got,(off_t)(off+got));
if(k<0 && errno==EINTR) continue;
if(k<=0) die("short read or input changed");
got+=(size_t)k;
}
atomic_thread_fence(memory_order_acquire);
}
static uint64_t u64(int fd, uint64_t off) { uint64_t x; rd(fd,&x,8,off); return x; }
static uint64_t number(const char *s, uint64_t max) {
if(!s[0]) die("empty numeric argument");
for(const char *p=s;*p;p++) if(*p<'0'||*p>'9') die("invalid numeric argument");
errno=0; char *end; unsigned long long n=strtoull(s,&end,10);
if(errno || *end || n>max) die("numeric argument out of range");
return n;
}
static void header(int fd, struct w1700k_stream_header *h) {
struct stat st;
if(fstat(fd,&st) || !S_ISREG(st.st_mode)) die("input must be regular file or POSIX SHM");
rd(fd,h,sizeof(*h),0);
if(h->magic!=W1700K_STREAM_MAGIC || h->version!=1 ||
h->header_bytes!=4096 || h->slice_samples!=2048) die("unsupported ABI header");
uint64_t r=h->ring_slices;
if(!r || r>MAX_RING || (r&(r-1))) die("invalid ring size");
if(h->tags_offset<4096 || h->tags_offset%8 ||
h->tags_offset>UINT64_MAX-r*8 ||
h->samples_offset<h->tags_offset+r*8 || h->samples_offset%8 ||
h->samples_offset>UINT64_MAX-r*8192 ||
h->total_bytes!=h->samples_offset+r*8192 ||
h->total_bytes>(uint64_t)INT64_MAX || st.st_size<0 ||
h->total_bytes!=(uint64_t)st.st_size) die("invalid layout or truncated file");
}
static void position(int fd,uint64_t *run,uint64_t *newest) {
for(int i=0;i<64;i++) {
uint64_t s=u64(fd,offsetof(struct w1700k_stream_header,position_seq));
*run=u64(fd,offsetof(struct w1700k_stream_header,run));
*newest=u64(fd,offsetof(struct w1700k_stream_header,newest));
uint64_t t=u64(fd,offsetof(struct w1700k_stream_header,position_seq));
if(!(s&1) && s==t) {
if((!*run && *newest) || *run>=(1ULL<<21) || *newest>=(1ULL<<40)) die("tag identity overflow");
return;
}
}
die("position seqlock did not stabilize");
}
static int stale(int fd) {
uint64_t hb=u64(fd,offsetof(struct w1700k_stream_header,heartbeat_ns));
uint64_t now=w1700k_stream_now_ns();
return hb>now || now-hb>=1000000000ULL;
}
static void emit(const char *source,uint64_t run,uint64_t index,uint64_t rate,
uint64_t center,const char *reason,uint64_t count,double power,
double rail,uint64_t missing,uint64_t events,uint64_t overrun) {
printf("{\"schema\":\"ruview.rf_diagnostics.v1\",\"source_kind\":\"%s\","
"\"source_id\":\"w1700k\",\"observed_monotonic_ns\":%" PRIu64 ","
"\"run\":%" PRIu64 ",\"slot_index\":%" PRIu64 ",\"sample_start\":null,"
"\"sample_rate_hz\":%" PRIu64 ",\"center_frequency_hz\":",
source,w1700k_stream_now_ns(),run,index,rate);
if(center) printf("%" PRIu64,center); else printf("null");
printf(",\"config_verified\":false,\"valid\":false,"
"\"invalid_reasons\":[\"configuration_unverified\"");
if(reason) printf(",\"%s\"",reason);
printf("],\"sample_count\":%" PRIu64 ",\"rms_dbfs\":",count);
if(count && power>0) printf("%.6f",10*log10(power)); else printf("null");
printf(",\"rail_fraction\":");
if(count) printf("%.8f",rail); else printf("null");
printf(",\"missing_samples\":%" PRIu64 ",\"event_count\":%" PRIu64 ","
"\"overrun_slices\":%" PRIu64 ",\"ttl_ms\":1000}\n",missing,events,overrun);
if(fflush(stdout) || ferror(stdout)) die("output failed");
}
int main(int argc,char **argv) {
const char *path="/dev/shm/w1700k-stream",*source="live";
uint64_t center=0,limit=64,timeout=10; int follow=0;
for(int i=1;i<argc;i++) {
if(!strcmp(argv[i],"--once")) { follow=0; continue; }
if(!strcmp(argv[i],"--follow")) { follow=1; continue; }
if(!strcmp(argv[i],"--help")) {
puts("w1700k-diagnostics [--input PATH] [--source-kind live|synthetic|captured_replay] [--once|--follow] [--max-records 1..4096] [--timeout-seconds 1..3600] [--center-frequency-hz HZ]\nRead-only advisory diagnostics. Configuration is always unverified. Source kind is caller asserted. No CSI, presence or spectrum bins.");
return 0;
}
if(i+1>=argc) die("missing option value");
const char *key=argv[i],*value=argv[++i];
if(!strcmp(key,"--input")) path=value;
else if(!strcmp(key,"--source-kind")) source=value;
else if(!strcmp(key,"--center-frequency-hz")) center=number(value,100000000000ULL);
else if(!strcmp(key,"--max-records")) limit=number(value,MAX_RECORDS);
else if(!strcmp(key,"--timeout-seconds")) timeout=number(value,3600);
else die("unknown option");
}
if(strcmp(source,"live") && strcmp(source,"synthetic") && strcmp(source,"captured_replay"))
die("invalid source kind");
if(!limit || !timeout) die("zero limit");
if(center && (center<2000000000ULL || center>8000000000ULL)) die("center frequency outside RF bands");
int fd=open(path,O_RDONLY|O_CLOEXEC|O_NOFOLLOW|O_NONBLOCK);
if(fd<0) die("cannot open input");
struct w1700k_stream_header h; header(fd,&h);
uint64_t run=0,next=0,newest=0,emitted=0;
position(fd,&run,&newest);
uint64_t backlog=newest<h.ring_slices?newest:h.ring_slices;
if(backlog>limit) backlog=limit;
next=newest-backlog;
uint64_t deadline=w1700k_stream_now_ns()+timeout*1000000000ULL;
signal(SIGINT,stopped); signal(SIGTERM,stopped);
while(!stop && emitted<limit && w1700k_stream_now_ns()<deadline) {
struct w1700k_stream_header check; header(fd,&check);
if(memcmp(&h,&check,offsetof(struct w1700k_stream_header,service_pid)))
die("layout changed");
uint64_t current; position(fd,&current,&newest);
uint64_t rate=u64(fd,offsetof(struct w1700k_stream_header,radio_sample_rate));
if(rate && rate!=40000000 && rate!=80000000 && rate!=160000000 && rate!=320000000) die("unsupported sample rate");
if(!strcmp(source,"live") && stale(fd)) {
emit(source,current,next,rate,center,"stale",0,0,0,0,0,0); close(fd); return 3;
}
if(current!=run) {
emit(source,current,0,rate,center,"run_change",0,0,0,0,0,0);
emitted++; run=current; next=newest; continue;
}
if(newest<next) die("frontier moved backward");
if(newest-next>h.ring_slices) {
uint64_t lost=newest-next-h.ring_slices;
emit(source,run,next,rate,center,"overrun",0,0,0,0,0,lost);
emitted++; next=newest-h.ring_slices; continue;
}
if(next==newest) {
if(!follow) break;
struct timespec pause={0,10000000}; nanosleep(&pause,NULL); continue;
}
uint64_t slot=next&(h.ring_slices-1),tag=u64(fd,h.tags_offset+slot*8);
uint64_t expected=w1700k_stream_tag(run,next),count=0,missing=0,events=0;
double power=0,rail=0; const char *reason=NULL;
if((tag&~W1700K_STREAM_TAG_KIND)!=expected) reason="tag_mismatch";
else if((tag&W1700K_STREAM_TAG_KIND) && ((tag&W1700K_STREAM_TAG_KIND)&((tag&W1700K_STREAM_TAG_KIND)-1))) reason="tag_mismatch";
else if(tag&W1700K_STREAM_TAG_EVENT) { reason="event"; events=1; }
else if(tag&W1700K_STREAM_TAG_LOST) { reason="lost"; missing=2048; }
else {
int16_t iq[4096]; rd(fd,iq,sizeof(iq),h.samples_offset+slot*8192);
uint64_t after=u64(fd,h.tags_offset+slot*8);
if(tag!=after) reason="tag_mismatch";
else {
count=2048;
for(int j=0;j<4096;j++) {
double x=(double)iq[j]/8192.0; power+=x*x;
if(iq[j]==INT16_MIN || iq[j]==INT16_MAX) rail++;
}
power/=2048; rail/=4096;
}
}
if(tag!=u64(fd,h.tags_offset+slot*8)) {
reason="tag_mismatch"; count=0; missing=0; events=0;
}
emit(source,run,next,rate,center,reason,count,power,rail,missing,events,0);
next++; emitted++;
}
close(fd); return 0;
}
+43
View File
@@ -0,0 +1,43 @@
/* SPDX-License-Identifier: MIT */
/* SYNTHETIC ABI fixture generator, not hardware evidence. */
#define _POSIX_C_SOURCE 200809L
#include "vendor/w1700k_stream.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc,char **argv) {
if(argc!=3) return 2;
FILE *f=fopen(argv[1],"wb"); if(!f) return 2;
struct w1700k_stream_header h={0};
h.magic=W1700K_STREAM_MAGIC; h.version=1; h.header_bytes=4096; h.slice_samples=2048;
h.ring_slices=8; h.tags_offset=4096; h.samples_offset=4160; h.total_bytes=69700-4;
h.run=1; h.newest=4; h.radio_sample_rate=40000000; h.heartbeat_ns=w1700k_stream_now_ns();
const char *kind=argv[2];
if(!strcmp(kind,"magic")) h.magic=0;
if(!strcmp(kind,"ring")) h.ring_slices=3;
if(!strcmp(kind,"offset")) h.samples_offset=8;
if(!strcmp(kind,"overflow")) h.tags_offset=UINT64_MAX-7;
if(!strcmp(kind,"seqlock")) h.position_seq=1;
if(!strcmp(kind,"stale")) h.heartbeat_ns=1;
if(!strcmp(kind,"idle")) h.newest=0;
if(!strcmp(kind,"run_overflow")) h.run=1ULL<<21;
char *data=calloc(1,69696); if(!data) return 2;
memcpy(data,&h,sizeof(h));
uint64_t *tags=(uint64_t*)(data+4096);
for(int i=0;i<4;i++) {
tags[i]=w1700k_stream_tag(1,i);
int16_t *iq=(int16_t*)(data+4160+i*8192);
for(int j=0;j<2048;j++) { iq[j*2]=4096; iq[j*2+1]=0; }
}
if(!strcmp(kind,"gap")) tags[1]|=W1700K_STREAM_TAG_LOST;
if(!strcmp(kind,"event")) tags[1]|=W1700K_STREAM_TAG_EVENT;
if(!strcmp(kind,"badkind")) tags[1]|=W1700K_STREAM_TAG_EVENT|W1700K_STREAM_TAG_LOST;
if(!strcmp(kind,"tag")) tags[1]=0;
if(!strcmp(kind,"flat")) {
tags[1]|=W1700K_STREAM_TAG_FLAT;
int16_t *iq=(int16_t*)(data+4160+8192);
for(int j=0;j<4096;j++) iq[j]=16384;
}
size_t size=!strcmp(kind,"truncated")?4096:69696;
int ok=fwrite(data,1,size,f)==size; free(data); fclose(f); return ok?0:2;
}
+122
View File
@@ -0,0 +1,122 @@
"""SYNTHETIC ABI and failure-path tests; no physical sensor claims."""
import json
import math
import os
from pathlib import Path
import struct
import subprocess
import tempfile
import time
import unittest
HERE = Path(__file__).resolve().parent
class BridgeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.bin = Path(cls.tmp.name) / "diagnostics"
cls.fixture_bin = Path(cls.tmp.name) / "fixture"
for source, target in [("diagnostics.c", cls.bin), ("fixture.c", cls.fixture_bin)]:
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-O2", str(HERE/source), "-lm", "-o", str(target)], check=True)
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
def fixture(self, kind="normal"):
path = Path(self.tmp.name) / (kind + ".shm")
subprocess.run([str(self.fixture_bin), str(path), kind], check=True)
return path
def invoke(self, kind="normal", *args):
return subprocess.run([str(self.bin), "--input", str(self.fixture(kind)),
"--source-kind", "synthetic", *args],
capture_output=True, text=True, timeout=4)
def test_normal(self):
p = self.invoke()
self.assertEqual(p.returncode, 0, p.stderr)
rows = [json.loads(x) for x in p.stdout.splitlines()]
self.assertEqual(len(rows), 4)
for x in rows:
self.assertEqual(x["sample_count"], 2048)
self.assertAlmostEqual(x["rms_dbfs"], 20*math.log10(.5), places=5)
self.assertEqual(x["rail_fraction"], 0)
self.assertFalse(x["valid"])
self.assertFalse(x["config_verified"])
self.assertIsNone(x["sample_start"])
self.assertEqual(x["source_kind"], "synthetic")
def test_invalid_headers(self):
for kind in ["magic", "ring", "offset", "overflow", "seqlock", "truncated", "run_overflow"]:
with self.subTest(kind=kind):
p = self.invoke(kind)
self.assertEqual(p.returncode, 2)
self.assertEqual(p.stdout, "")
def test_gap_event_and_tag(self):
for kind, reason in [("gap", "lost"), ("event", "event"), ("tag", "tag_mismatch"), ("badkind", "tag_mismatch")]:
p = self.invoke(kind)
rows = list(map(json.loads, p.stdout.splitlines()))
self.assertIn(reason, rows[1]["invalid_reasons"])
self.assertEqual(rows[1]["sample_count"], 0)
self.assertIsNone(rows[1]["rms_dbfs"])
self.assertEqual(rows[1]["missing_samples"], 2048 if kind == "gap" else 0)
def test_flat_headroom_not_clipping(self):
rows = list(map(json.loads, self.invoke("flat").stdout.splitlines()))
self.assertEqual(rows[1]["rail_fraction"], 0)
self.assertGreater(rows[1]["rms_dbfs"], 0)
def test_live_stale(self):
p = self.invoke("stale", "--source-kind", "live")
self.assertEqual(p.returncode, 3)
self.assertIn("stale", json.loads(p.stdout)["invalid_reasons"])
def test_bounded_tail(self):
rows = list(map(json.loads, self.invoke("normal", "--max-records", "2").stdout.splitlines()))
self.assertEqual([r["slot_index"] for r in rows], [2, 3])
def test_idle_timeout(self):
start = time.monotonic()
p = self.invoke("idle", "--follow", "--timeout-seconds", "1")
self.assertEqual(p.returncode, 0)
self.assertEqual(p.stdout, "")
self.assertLess(time.monotonic()-start, 2)
def test_concurrent_truncate(self):
path = self.fixture("idle")
p = subprocess.Popen([str(self.bin), "--input", str(path), "--source-kind", "synthetic",
"--follow", "--timeout-seconds", "2"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
time.sleep(.05)
with path.open("wb"):
pass
p.communicate(timeout=3)
self.assertEqual(p.returncode, 2) # no SIGBUS
def test_overrun(self):
path = self.fixture("idle")
p = subprocess.Popen([str(self.bin), "--input", str(path), "--source-kind", "synthetic",
"--follow", "--max-records", "1", "--timeout-seconds", "2"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
time.sleep(.05)
with path.open("r+b", buffering=0) as f:
f.seek(128); f.write(struct.pack("=Q", 1))
f.seek(144); f.write(struct.pack("=Q", 20))
f.seek(128); f.write(struct.pack("=Q", 2))
out, err = p.communicate(timeout=3)
self.assertEqual(p.returncode, 0, err)
row = json.loads(out)
self.assertIn("overrun", row["invalid_reasons"])
self.assertEqual(row["overrun_slices"], 12)
self.assertEqual(row["missing_samples"], 0) # event slots have no sample time
def test_symlink_rejected(self):
link = Path(self.tmp.name)/"symlink"
if link.exists(): link.unlink()
link.symlink_to(self.fixture())
p = subprocess.run([str(self.bin), "--input", str(link)], capture_output=True)
self.assertEqual(p.returncode, 2)
def test_wide_center(self):
p = self.invoke("normal", "--center-frequency-hz", "7576000000")
self.assertEqual(p.returncode, 0, p.stderr)
row = json.loads(p.stdout.splitlines()[0])
self.assertEqual(row["center_frequency_hz"], 7576000000)
self.assertFalse(row["config_verified"])
def test_bad_args(self):
for args in [["--source-kind","madeup"], ["--max-records","0"], ["--max-records","999999"],
["--timeout-seconds","-1"], ["--center-frequency-hz","nan"]]:
p = self.invoke("normal", *args)
self.assertEqual(p.returncode, 2)
if __name__ == "__main__":
unittest.main(verbosity=2)
+30
View File
@@ -0,0 +1,30 @@
Valid-License-Identifier: MIT
SPDX-URL: https://spdx.org/licenses/MIT.html
Usage-Guide:
To use the MIT License put the following SPDX tag/value pair into a
comment according to the placement guidelines in the licensing rules
documentation:
SPDX-License-Identifier: MIT
License-Text:
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+176
View File
@@ -0,0 +1,176 @@
/* SPDX-License-Identifier: MIT */
/*
* The W1700K's IQ stream as every consumer on the host reads it: the shared memory of
* w1700k-stream (host/stream). One POSIX shared-memory object, by default /dev/shm/w1700k-stream,
* mapped by the service and by each consumer. Native byte order and alignment; every word shared
* between processes is read and written with the __atomic builtins, from C and C++ alike.
*
* Layout: the header (4 KiB), then one 64-bit tag per slot, then the samples: per slot one slice
* of W1700K_STREAM_SLICE complex samples, int16 I then int16 Q, on the scale of the radio's 14-bit
* samples (full scale W1700K_STREAM_FULL_SCALE). The int16 range above it is headroom for the
* correction filter's gain; a sample beyond it saturates and is counted.
*
* Slot `index & (ring_slices - 1)` holds slice `index` of run `run` (a run is one stream, numbered
* from 1; `index` counts the unit's DMA completions from the run's first, 0..). Its tag is
* w1700k_stream_tag(run, index), with W1700K_STREAM_TAG_EVENT for a firmware event (no samples and
* no sample time: the slices either side are contiguous), W1700K_STREAM_TAG_LOST for a completion
* that never arrived (no samples; the sample time is lost), and W1700K_STREAM_TAG_FLAT on a slice
* the correction filter produced. A reader reads the tag (acquire), the samples, then the tag
* again: a changed tag means the service overwrote the slot meanwhile (a seqlock).
*
* The service publishes slots in index order. `newest` is the frontier: every slot of the run
* below it is final. Read run and newest together with w1700k_stream_position().
*/
#ifndef W1700K_STREAM_H
#define W1700K_STREAM_H
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#define W1700K_STREAM_NAME "/w1700k-stream"
#define W1700K_STREAM_MAGIC 0x4d525453u /* "STRM" */
#define W1700K_STREAM_VERSION 1u
#define W1700K_STREAM_SLICE 2048u /* complex samples per slot */
#define W1700K_STREAM_FULL_SCALE 8192 /* the radio's 14-bit full scale */
#define W1700K_STREAM_HEADER_BYTES 4096u
#define W1700K_STREAM_TAG_EVENT (1ull << 63)
#define W1700K_STREAM_TAG_LOST (1ull << 62)
#define W1700K_STREAM_TAG_FLAT (1ull << 61)
#define W1700K_STREAM_TAG_KIND (W1700K_STREAM_TAG_EVENT | W1700K_STREAM_TAG_LOST | W1700K_STREAM_TAG_FLAT)
struct w1700k_stream_header {
/* layout, fixed at creation */
uint32_t magic, version;
uint32_t header_bytes, slice_samples;
uint64_t ring_slices; /* a power of two */
uint64_t tags_offset, samples_offset, total_bytes;
uint64_t service_pid;
uint64_t heartbeat_ns; /* CLOCK_MONOTONIC, refreshed about every 100 ms */
uint64_t reserved0[8];
/* the published position */
uint64_t position_seq; /* odd while the service writes run and newest */
uint64_t run; /* 1..; 0: no stream yet */
uint64_t newest; /* slots of the run below this are final */
uint64_t run_started_ns, last_any_ns; /* CLOCK_MONOTONIC */
uint64_t received; /* the newest completion received + 1 (ahead of newest) */
uint64_t reserved1[10];
/* counters since the service started */
uint64_t datagrams, bytes;
uint64_t slices, packed, events, lost; /* completions filed: samples (packed among them), events, lost */
uint64_t pieces, bad, late, before_start, duplicates, socket_drops;
/* the export's integrity: raw slices with spare bits set (words), and slices or 32-byte lines
* equal to the same slice or line one lap (128 completions) earlier: a capture or packer
* that repeated old data */
uint64_t spare_words, stale_slices, stale_lines;
uint64_t flattened, saturated; /* slices the correction produced; samples clipped to int16 */
uint64_t overruns; /* of the lost: arrived, but overwritten in the service's receive
* ring before it was published (the service fell behind) */
uint64_t reserved2[12];
/* control: written by consumers, read by the service */
uint64_t restart_requests; /* +1: the next completion starts a new run */
uint64_t flatten; /* 1: correct the decimation's roll-off where it applies */
uint64_t radio_sample_rate; /* samples/s; 0: unknown */
uint64_t radio_capture_rate; /* samples/s before the stream (640/2: 640e6); 0: unknown */
uint64_t radio_filter_hz; /* the analog filter's width; 0: unknown */
uint64_t reserved3[11];
/* the service's view of the control */
uint64_t flatten_active; /* the correction applies to the slices being published */
uint64_t flatten_rate; /* the sample rate whose correction applies; 0: none */
uint64_t reserved4[14];
};
#define w1700k_stream_load(p) __atomic_load_n((p), __ATOMIC_ACQUIRE)
#define w1700k_stream_store(p, v) __atomic_store_n((p), (v), __ATOMIC_RELEASE)
static inline uint64_t w1700k_stream_tag(uint64_t run, uint64_t index)
{
return (run << 40) | (index + 1);
}
static inline uint64_t *w1700k_stream_tags(const struct w1700k_stream_header *h)
{
return (uint64_t *)((char *)h + h->tags_offset);
}
static inline int16_t *w1700k_stream_slice(const struct w1700k_stream_header *h, uint64_t index)
{
return (int16_t *)((char *)h + h->samples_offset) +
(size_t)(index & (h->ring_slices - 1)) * W1700K_STREAM_SLICE * 2;
}
/* run and newest, read consistently. */
static inline void w1700k_stream_position(const struct w1700k_stream_header *h, uint64_t *run, uint64_t *newest)
{
uint64_t s0, s1;
do {
s0 = w1700k_stream_load(&h->position_seq);
*run = w1700k_stream_load(&h->run);
*newest = w1700k_stream_load(&h->newest);
s1 = w1700k_stream_load(&h->position_seq);
} while ((s0 & 1) || s0 != s1);
}
static inline uint64_t w1700k_stream_now_ns(void)
{
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return (uint64_t)t.tv_sec * 1000000000ull + (uint64_t)t.tv_nsec;
}
/* The service is up: it refreshed its heartbeat within the last second. */
static inline int w1700k_stream_alive(const struct w1700k_stream_header *h)
{
return w1700k_stream_now_ns() - w1700k_stream_load(&h->heartbeat_ns) < 1000000000ull;
}
/*
* Maps the service's shared memory (name NULL: W1700K_STREAM_NAME); `writable` for a consumer that
* writes the control words. Returns NULL with errno set: ENOENT while no service has created it,
* EPROTO for another layout. *bytes receives the mapping's length (for munmap), *inode the
* object's inode (a restarted service creates a new one).
*/
static inline struct w1700k_stream_header *w1700k_stream_open(const char *name, int writable, size_t *bytes,
uint64_t *inode)
{
struct w1700k_stream_header *h;
struct stat st;
int fd = shm_open(name ? name : W1700K_STREAM_NAME, writable ? O_RDWR : O_RDONLY, 0);
if (fd < 0)
return NULL;
if (fstat(fd, &st) || (size_t)st.st_size < W1700K_STREAM_HEADER_BYTES) {
close(fd);
errno = EPROTO;
return NULL;
}
h = (struct w1700k_stream_header *)mmap(NULL, (size_t)st.st_size,
writable ? PROT_READ | PROT_WRITE : PROT_READ, MAP_SHARED, fd, 0);
close(fd);
if (h == MAP_FAILED)
return NULL;
if (h->magic != W1700K_STREAM_MAGIC || h->version != W1700K_STREAM_VERSION ||
h->slice_samples != W1700K_STREAM_SLICE || h->total_bytes != (uint64_t)st.st_size) {
munmap(h, (size_t)st.st_size);
errno = EPROTO;
return NULL;
}
*bytes = (size_t)st.st_size;
if (inode)
*inode = (uint64_t)st.st_ino;
return h;
}
#endif /* W1700K_STREAM_H */
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""SYNTHETIC cross-repository acceptance, never hardware evidence."""
import argparse
import json
from pathlib import Path
import subprocess
import tempfile
def run(args, **kw):
return subprocess.run([str(a) for a in args], check=True, capture_output=True, timeout=30, **kw)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--rufield-bin", type=Path, required=True)
args = parser.parse_args()
base = Path(__file__).resolve().parent
model = base.parent.parent / "docs/rf-diagnostics/model.js"
run(["make", "-C", base, "all"])
scenarios = ("normal", "gap", "event", "flat", "tag", "badkind")
with tempfile.TemporaryDirectory() as temp:
for scenario in scenarios:
fixture = Path(temp) / "stream.shm"
run([base / "build/w1700k-fixture", fixture, scenario])
output = run([base / "build/w1700k-diagnostics", "--input", fixture,
"--source-kind", "synthetic", "--once"]).stdout
records = [json.loads(line) for line in output.splitlines()]
assert len(records) == 4
run(["node", "-e", "const m=require(process.argv[1]);m.parse(require('fs').readFileSync(0,'utf8'));", model], input=output)
events = [json.loads(line) for line in run([args.rufield_bin.resolve()], input=output).stdout.splitlines()]
assert len(events) == len(records)
for record, event in zip(records, events):
assert record["valid"] is False and record["config_verified"] is False
assert event["sensor"]["modality"] == "rf_spectrum_quality"
assert event["provenance"]["synthetic"] is True
assert event["observation"]["confidence"] == 0
assert event["observation"]["labels"] == []
assert event["observation"]["attributes"]["trust"] == "unverified_advisory"
if scenario == "gap":
assert sum(r["missing_samples"] for r in records) == 2048
if scenario == "event":
assert sum(r["event_count"] for r in records) == 1
assert sum(r["sample_count"] for r in records) == 6144
if scenario in ("tag", "badkind"):
assert records[1]["sample_count"] == 0
if scenario == "flat":
assert records[1]["rms_dbfs"] > 0 and records[1]["rail_fraction"] == 0
print(f"PASS {scenario}: C ABI -> JSONL -> browser validator -> RuField")
print("SYNTHETIC INTEGRATION PASS; hardware validation pending")
if __name__ == "__main__":
main()