From 285aea8f2de16f95564a08cb9982d3609162a967 Mon Sep 17 00:00:00 2001 From: rUv Date: Wed, 14 Oct 2026 04:13:50 -0700 Subject: [PATCH] feat(sensing): add W1700K advisory RF diagnostics Source tree verified against ruOS tested commit fa6b1bdf92e7959b8500d42e2a5c620e207e81b0. Synthetic pipeline validation only; hardware validation pending. --- .github/workflows/rf-diagnostics.yml | 44 ++++++ README.md | 3 + docs/adr/ADR-419-w1700k-rf-diagnostics.md | 40 +++++ docs/rf-diagnostics/README.md | 45 ++++++ docs/rf-diagnostics/app.js | 48 ++++++ docs/rf-diagnostics/browser-smoke.cjs | 29 ++++ docs/rf-diagnostics/index.html | 12 ++ docs/rf-diagnostics/model.js | 46 ++++++ docs/rf-diagnostics/model.test.cjs | 23 +++ docs/rf-diagnostics/style.css | 1 + tools/w1700k/.gitignore | 3 + tools/w1700k/Makefile | 15 ++ tools/w1700k/README.md | 117 ++++++++++++++ tools/w1700k/THIRD_PARTY.md | 10 ++ tools/w1700k/diagnostics.c | 176 ++++++++++++++++++++++ tools/w1700k/fixture.c | 43 ++++++ tools/w1700k/test_bridge.py | 122 +++++++++++++++ tools/w1700k/vendor/LICENSE.MIT | 30 ++++ tools/w1700k/vendor/w1700k_stream.h | 176 ++++++++++++++++++++++ tools/w1700k/verify_integration.py | 51 +++++++ 20 files changed, 1034 insertions(+) create mode 100644 .github/workflows/rf-diagnostics.yml create mode 100644 docs/adr/ADR-419-w1700k-rf-diagnostics.md create mode 100644 docs/rf-diagnostics/README.md create mode 100644 docs/rf-diagnostics/app.js create mode 100644 docs/rf-diagnostics/browser-smoke.cjs create mode 100644 docs/rf-diagnostics/index.html create mode 100644 docs/rf-diagnostics/model.js create mode 100644 docs/rf-diagnostics/model.test.cjs create mode 100644 docs/rf-diagnostics/style.css create mode 100644 tools/w1700k/.gitignore create mode 100644 tools/w1700k/Makefile create mode 100644 tools/w1700k/README.md create mode 100644 tools/w1700k/THIRD_PARTY.md create mode 100644 tools/w1700k/diagnostics.c create mode 100644 tools/w1700k/fixture.c create mode 100644 tools/w1700k/test_bridge.py create mode 100644 tools/w1700k/vendor/LICENSE.MIT create mode 100644 tools/w1700k/vendor/w1700k_stream.h create mode 100644 tools/w1700k/verify_integration.py diff --git a/.github/workflows/rf-diagnostics.yml b/.github/workflows/rf-diagnostics.yml new file mode 100644 index 000000000..59244a735 --- /dev/null +++ b/.github/workflows/rf-diagnostics.yml @@ -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 }}' diff --git a/README.md b/README.md index 98c12d745..2b10abbba 100644 --- a/README.md +++ b/README.md @@ -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. +

RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi diff --git a/docs/adr/ADR-419-w1700k-rf-diagnostics.md b/docs/adr/ADR-419-w1700k-rf-diagnostics.md new file mode 100644 index 000000000..2c6d45269 --- /dev/null +++ b/docs/adr/ADR-419-w1700k-rf-diagnostics.md @@ -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. diff --git a/docs/rf-diagnostics/README.md b/docs/rf-diagnostics/README.md new file mode 100644 index 000000000..d3db42204 --- /dev/null +++ b/docs/rf-diagnostics/README.md @@ -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. diff --git a/docs/rf-diagnostics/app.js b/docs/rf-diagnostics/app.js new file mode 100644 index 000000000..c546118ee --- /dev/null +++ b/docs/rf-diagnostics/app.js @@ -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;} +}; diff --git a/docs/rf-diagnostics/browser-smoke.cjs b/docs/rf-diagnostics/browser-smoke.cjs new file mode 100644 index 000000000..84e074804 --- /dev/null +++ b/docs/rf-diagnostics/browser-smoke.cjs @@ -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}); diff --git a/docs/rf-diagnostics/index.html b/docs/rf-diagnostics/index.html new file mode 100644 index 000000000..479103c69 --- /dev/null +++ b/docs/rf-diagnostics/index.html @@ -0,0 +1,12 @@ + + + +RuView RF Diagnostics +

COGNITUM / RUVIEW / W1700K

NO DATA

RF diagnostics.

Understand the measurement before trusting the inference.

+

Local processing only. Maximum 10 MiB and 10,000 observations. No uploads.

+

Import bridge diagnostics or explore a clearly labelled synthetic example.

+ +

Latest RMS

—relative dBFS, not calibrated RF power

Latest rails

—samples at numeric limits

Reported missing

—sum of per record missing samples

Observations

0all v1 observations invalid
+

Relative RMS history

Record order, last 500 observations. Missing metrics break the line. Orange dots flag missing samples.

No observations

+

Invalidation reasons

  • No observations

Source metadata

No observations
+
RuField evidence boundary · 1 second TTL is producer metadata. Host monotonic time is not wall time; file replay cannot establish freshness.
diff --git a/docs/rf-diagnostics/model.js b/docs/rf-diagnostics/model.js new file mode 100644 index 000000000..f27e4fa29 --- /dev/null +++ b/docs/rf-diagnostics/model.js @@ -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); diff --git a/docs/rf-diagnostics/model.test.cjs b/docs/rf-diagnostics/model.test.cjs new file mode 100644 index 000000000..5e7cb82aa --- /dev/null +++ b/docs/rf-diagnostics/model.test.cjs @@ -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:['