Compare commits

...
126 Commits
Author SHA1 Message Date
rUv 6699a2fc44 Match README spatial SVGs to RuView header palette and add RF wave motion 2026-10-12 20:33:57 -07:00
rUv 964377765a Improve README SVG spatial motion and typography (#2257) 2026-10-12 20:28:12 -07:00
rUv 5cfbb8d98d docs: restore original GIF at top of README (#2252) 2026-10-12 14:30:42 -07:00
rUv 0f95193190 Merge pull request #2248 from ruvnet/codex/toolkit-release-20261012
fix(toolkit): integrate capture/device corrections for 0.9.2
2026-10-12 09:54:03 -07:00
rUv 15fd5eebdc Merge pull request #2246 from ruvnet/codex/sensing-release-20261012
fix(calibration): finish failed sessions and reconcile decision diagnostics
2026-10-12 09:27:10 -07:00
ruv b064acf57c test(harness): cover Node 20 WebSocket capability boundary 2026-10-12 12:20:40 -04:00
rUv 4413491370 Merge pull request #2247 from ruvnet/codex/firmware-release-20261012
fix(firmware): correct identity and secure OTA provisioning for 0.8.14
2026-10-12 09:17:31 -07:00
ruv b265454b93 fix(remote): isolate Windows PowerShell module search paths 2026-10-12 12:09:26 -04:00
ruv 99d9851173 test(devices): align end-to-end medians with even-sample correction 2026-10-12 12:01:15 -04:00
ruv 720cfd7c8a fix(remote): replace explicit Windows ACL grants with current-user access 2026-10-12 11:56:13 -04:00
ruv 2c1222789d Merge remote-tracking branch 'origin/main' into codex/toolkit-release-20261012 2026-10-12 11:49:52 -04:00
ruv 3620abff12 fix(toolkit): integrate capture and device corrections for 0.9.2
Integrate reviewed source PRs #2201-#2223, #2225-#2231, #2235-#2240, #2242 and #2243 (firmware README hunks remain in #2247). Regenerate one package manifest after integration, validate native Windows test execution, bound MTK listener startup, support official C6 release bundles, and retain stronger release metadata gates.
2026-10-12 11:49:52 -04:00
ruv a937a5fd20 fix(firmware): correct device identity and provision OTA keys for 0.8.14 2026-10-12 11:48:38 -04:00
ruv 9d863c3d22 fix(cli): report calibration persistence failures as aborted 2026-10-12 11:43:04 -04:00
mallesh-bot 99cea559f9 fix(cli): retain abort status after finalize failure 2026-10-12 11:39:41 -04:00
mallesh-bot 18d98d9452 fix(cli): abort incomplete calibration sessions 2026-10-12 11:39:40 -04:00
ruv 4eb991a101 Merge decision diagnostics with landed holdout explanations 2026-10-12 11:39:24 -04:00
rUv 1c435a0a79 Merge pull request #2190 from ruvnet/docs/cognitum-animated-visuals
docs: add Cognitum-style animated README visuals and linked capability cards
2026-10-12 08:38:54 -07:00
rUv 3c8f7ed96e Merge pull request #2189 from ruvnet/prototype/uncertainty-virtual-view
Add opt-in Dream Machine virtual-view research adapter
2026-10-12 08:38:50 -07:00
rUv 3b0b8d72df Merge pull request #2198 from ruvnet/codex/bootstrap-holdout-diagnostics-20261010
fix(sensing-server): explain bootstrap holdout failures
2026-10-12 08:38:07 -07:00
Dragan Spiridonov ce93244117 feat(sensing): integrate decision and holdout diagnostics for Mac qualification 2026-10-11 14:05:06 +02:00
ruv 8a140c1906 fix(docs): harden README visual XML and path validation 2026-10-10 13:41:26 -04:00
ruv d779961a55 fix(virtual-view): validate bounded plain data before broker calls 2026-10-10 13:29:51 -04:00
ruv f59fe9d7c2 fix(sensing-server): explain bootstrap holdout failures 2026-10-10 13:22:26 -04:00
rUv a71634ae8a Merge pull request #2192 from clonea1/contrib/pin-led-strip-idf54
fix(firmware): pin led_strip to 3.0.x; 3.1.0 breaks ESP-IDF 5.4 builds
2026-10-10 10:15:56 -07:00
JoeandClaude Opus 5.5 f1ff58afe7 fix(firmware): pin led_strip to 3.0.x; 3.1.0 breaks ESP-IDF 5.4 builds
led_strip 3.1.0~1 calls rmt_tx_switch_gpio (IDF >= 5.5 API), which is
undeclared on ESP-IDF 5.4 (src/led_strip_rmt_dev.c:189), so every
clean-checkout firmware build fails. Constrain to ~3.0.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:28:05 -04:00
rUv 4696804b23 docs: add Cognitum-style animated RuView visual system 2026-10-09 05:45:16 -07:00
rUv 88daeb8cf2 Add virtual-view prototype: integrations/virtual-view/README.md 2026-10-09 05:40:33 -07:00
rUv f8750a4a50 Add virtual-view prototype: integrations/virtual-view/adapter.test.mjs 2026-10-09 05:40:32 -07:00
rUv 2c7a0194f4 Add virtual-view prototype: integrations/virtual-view/adapter.mjs 2026-10-09 05:40:30 -07:00
rUv 0ef6b96fe1 docs: describe RuView-focused README animation 2026-10-06 09:25:36 -07:00
rUv 2d5472372f docs: focus animation on RuView sensing and spatial awareness 2026-10-06 09:25:34 -07:00
rUv efdf97ecc1 docs: add ruOS animation below RuView header 2026-10-06 09:12:38 -07:00
rUv 46d80ffc3a docs: upload Ruflo-powered ruOS desktop animation 2026-10-06 09:12:36 -07:00
rUv 0b15c0ebc9 Merge pull request #2163 from ruvnet/release/aepod-integration
release: integrate aepod PRs #2100-#2157 (38 PRs, review fixes, validated)
2026-10-05 13:52:42 -07:00
ruv 682a6f4538 docs(changelog): community batch PRs #2100-#2157
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-05 12:21:07 -04:00
ruv a72174c571 fix: review findings and cross-PR integration fixes for aepod release set
- mediatek: cap per-device maps at 64 with oldest-first eviction; clamp --csi-ring
- training: mark live-buffer fallback runs degraded (data_source/degraded) instead of plain "completed"
- multistatic: skip CIR blend when any frame in the cohort has a placeholder phase
- groundtruth: median count baseline; radar labels carry evidence=CLAIMED
- spatial-evidence: default proof CODE; MEASURED requires a reproducer
- scripts: collect-ground-truth guards non-JSON replies and unreadable token files
- docker: entrypoint accepts 1/true/yes/on for RUVIEW_UDP_INSECURE_LAN
- adaptive model: untrack v2/data/adaptive_model.json (#2135)
- docs: ADR-379 evidence tags, RUNBOOK/README OTA wording, NOTICE for vendored MediaTek patch
- tests: fresh timestamps for privacy tests (#2119 staleness), plan_source Result handling,
  drop obsolete qualcomm-binds-nothing test (#2126), ignore slow live-binary test (#2157)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-05 12:08:21 -04:00
ruv fd323e483e Merge PR #2128 (aepod) into release integration
# Conflicts:
#	.github/workflows/ci.yml
2026-10-05 11:48:40 -04:00
ruv 9f0821cf69 Merge PR #2104 (aepod) into release integration 2026-10-05 11:48:25 -04:00
ruv 69fc9efff7 Merge PR #2145 (aepod) into release integration 2026-10-05 11:48:25 -04:00
ruv 20386cf313 Merge PR #2141 (aepod) into release integration
# Conflicts:
#	docs/adr/README.md
2026-10-05 11:48:24 -04:00
ruv c4fd6d9b36 Merge PR #2101 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/training_api.rs
2026-10-05 11:48:16 -04:00
ruv e223d86ba2 Merge PR #2144 (aepod) into release integration 2026-10-05 11:47:35 -04:00
ruv 52e5ebefbd Merge PR #2143 (aepod) into release integration 2026-10-05 11:47:35 -04:00
ruv ca44cd1a7b Merge PR #2117 (aepod) into release integration
# Conflicts:
#	docs/integrations/home-assistant.md
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:47:34 -04:00
ruv 5fe0a6a48d Merge PR #2105 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:46:33 -04:00
ruv da1ef6a17d Merge PR #2102 (aepod) into release integration
# Conflicts:
#	docs/adr/README.md
2026-10-05 11:46:15 -04:00
ruv b6c2fb5b7d Merge PR #2116 (aepod) into release integration 2026-10-05 11:46:08 -04:00
ruv b8b70b7df7 Merge PR #2115 (aepod) into release integration 2026-10-05 11:46:07 -04:00
ruv bb5d903724 Merge PR #2114 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:46:07 -04:00
ruv 1e2348abef Merge PR #2113 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:45:52 -04:00
ruv 2493185ab8 Merge PR #2112 (aepod) into release integration 2026-10-05 11:45:33 -04:00
ruv 678f63f4f3 Merge PR #2125 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/cli.rs
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:45:26 -04:00
ruv 0aba0fc467 Merge PR #2127 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:44:53 -04:00
ruv 6f550c39ed Merge PR #2157 (aepod) into release integration 2026-10-05 11:44:34 -04:00
ruv e9d0cef610 Merge PR #2133 (aepod) into release integration 2026-10-05 11:44:34 -04:00
ruv bb22f1b185 Merge PR #2109 (aepod) into release integration 2026-10-05 11:44:33 -04:00
ruv 7bc5fe1a53 Merge PR #2103 (aepod) into release integration 2026-10-05 11:44:33 -04:00
ruv 51e1788c7e Merge PR #2129 (aepod) into release integration 2026-10-05 11:44:32 -04:00
ruv 7b62297f3e Merge PR #2126 (aepod) into release integration 2026-10-05 11:44:31 -04:00
ruv 890e521a3a Merge PR #2132 (aepod) into release integration 2026-10-05 11:44:31 -04:00
ruv 444184f9ef Merge PR #2119 (aepod) into release integration 2026-10-05 11:44:30 -04:00
ruv 7955af5bc0 Merge PR #2107 (aepod) into release integration 2026-10-05 11:44:29 -04:00
ruv 273a2a8536 Merge PR #2108 (aepod) into release integration 2026-10-05 11:44:29 -04:00
ruv add0755958 Merge PR #2134 (aepod) into release integration 2026-10-05 11:44:28 -04:00
ruv de5f7edd16 Merge PR #2100 (aepod) into release integration 2026-10-05 11:44:22 -04:00
ruv 69e0729c61 Merge PR #2156 (aepod) into release integration 2026-10-05 11:44:22 -04:00
ruv 9baaf8434f Merge PR #2155 (aepod) into release integration 2026-10-05 11:44:21 -04:00
ruv 27eefb6a7f Merge PR #2140 (aepod) into release integration 2026-10-05 11:44:20 -04:00
ruv c6f7e8ca0d Merge PR #2120 (aepod) into release integration 2026-10-05 11:44:20 -04:00
ruv 474f1a904e Merge PR #2118 (aepod) into release integration 2026-10-05 11:44:19 -04:00
ruv ddaa54eba0 Merge PR #2110 (aepod) into release integration 2026-10-05 11:44:19 -04:00
ruv b3d31510ee Merge PR #2130 (aepod) into release integration 2026-10-05 11:44:18 -04:00
ruv bce512cb8d Merge PR #2131 (aepod) into release integration 2026-10-05 11:44:18 -04:00
ruv 9950ecc340 fix(provision): refuse to write credentials through a symlink with a clear error
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-05 11:44:17 -04:00
ruv e0c4207bd5 Merge PR #2152 (aepod) into release integration
# Conflicts:
#	firmware/esp32-csi-node/provision.py
2026-10-05 11:43:47 -04:00
ruv 044a41d2de Merge PR #2153 (aepod) into release integration 2026-10-05 11:43:14 -04:00
ruv 8d243a67f4 Merge PR #2111 (aepod) into release integration 2026-10-05 11:43:13 -04:00
Mathew Beaneandclaude-flow ee6717f035 fix(sensing-server): lock the CSI grid gate on the majority grid and report a stalled pipeline on /health (#1894)
The per-node grid gate locked onto the densest CSI grid a node had ever
sent and rejected every other grid. Real ESP32-S3 nodes interleave
several grids: one node sent 192 bins on 88% of frames and 306 on 6%, so
the gate locked onto the 306 minority and dropped about 90% of that
node's frames. A node whose radio moved to a sparser grid for good was
rejected forever; when every node did this, `tick` froze while /health
still reported ok.

The gate now votes over each node's last 64 frames and locks onto the
grid the node sends most. A challenger must outnumber the active grid
3:2 to take over, so interleaved grids don't flap the lock. A switch
clears the window as before. On an ESP32-C6 the HE-SU majority still
wins and the HT minority is still rejected.

/health keeps `status: "ok"` (the UI's server probe keys on it) and adds
a `processing` block: a live/stalled/no_input/idle state, the age of the
last published update, and lock-free UDP counters (datagrams, age of the
last datagram, datagrams dropped by the source allowlist). A frozen
pipeline can now be told apart from input that stopped arriving.
Allowlist drops also log a warning on the 1st, 2nd, 4th, 8th... drop
instead of only at debug level.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 17:31:47 -04:00
Mathew Beaneandclaude-flow f5a36666fc fix(hardware): accept ESP32 frames up to the firmware buffer size
Esp32CsiParser capped n_subcarriers at 256. Real ESP32-S3 nodes on
firmware 0.8.12 send 1-antenna frames with 306 subcarriers (612 I/Q
bytes), so the parser dropped about a third of live frames. Pointcloud
started hitting that cap once it switched to this parser.

The bound now comes from the firmware: frames are serialized into
CSI_MAX_FRAME_SIZE = 20 + 4 * 256 * 2 bytes, so a frame carries at most
1024 I/Q pairs across all antennas. The check is n_antennas *
n_subcarriers <= 1024.

HT/legacy bandwidth no longer maps subcarrier counts above 128 to 80 or
160 MHz. ESP32 radios use 20/40 MHz channels, and the larger counts
(192, 306) come from merged LLTF/HT-LTF buffers, so anything above 64
is reported as 40 MHz.

The pointcloud UDP receive buffer grows from 2048 to 4096 bytes so the
largest legal frame (2068 bytes) is not truncated.

Refs #1348

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:52:10 -04:00
Mathew Beaneandclaude-flow d78a221607 feat(provision): read the WiFi password from a file or a prompt
--password puts the WiFi password in argv, where ps and shell history can
see it (#1754). This adds two ways to give it without that:

- --password-file PATH reads it from a file, dropping one trailing newline
  (\n or \r\n). The file must exist and not be empty. If group or others can
  read it, the script refuses and suggests chmod 600.
  --allow-insecure-password-file accepts such a file with a warning, for
  read-only mounts such as 0444 secrets.
- When --ssid is given with no password flag and stdin is a terminal, the
  script asks with getpass. It reuses a saved password only if it was saved
  for the same SSID. Without a terminal (scripts, CI) nothing is asked and
  the existing missing-credentials check applies.

--password still works, but now prints a one-line warning on stderr that
points at --password-file. The two flags are mutually exclusive. The
firmware README examples now use --password-file.

Tests: 10 new tests in tests/test_provision_secrets.py, with placeholder
credentials and getpass and the flasher stubbed. 8 fail on the previous
commit; the other 2 check that nothing is asked without a terminal or when a
saved password matches the SSID. The suite goes from 25 to 35 passing.

Refs #1754

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:50:43 -04:00
Mathew Beaneandclaude-flow db81dce7ba fix(multistatic): gate phase combining on a declared shared reference
The sensing-server bridge builds every node frame with a zero phase
placeholder and the fuser combined those phases across nodes as if they
were measured against one reference. ESP32 nodes each run an independent
crystal with a random PLL phase after reset; mesh time sync aligns frames,
not RF phase, so there is nothing to combine.

- Add PhaseReference (Unavailable / NodeLocal / Shared{id}) to
  MultiBandCsiFrame. The server bridge declares Unavailable.
- MultistaticFuser combines phase only when every frame declares the same
  Shared id. Otherwise fused_phase is empty and FusedSensingFrame carries
  PhaseFusion::AmplitudeOnly(reason). Amplitude fusion is unchanged.
- The ADR-134 CIR blend is skipped when the phase is a placeholder.
- TrustedOutput and /health/ready (trust.phase_fusion) report the mode
  and reason.
- ADR-029 status note updated.

Refs #1752, #1710, #1750.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:42:03 -04:00
Mathew Beaneandclaude-flow 3a8ec8139e fix(pointcloud): parse ESP32 CSI frames with the reference ADR-018 parser
The pointcloud parser read channel from byte 8, RSSI from byte 9, noise
floor from byte 10 and a "timestamp" from bytes 16..19. The firmware
(csi_serialize_frame) writes freq_mhz as u32 at 8..11, sequence at
12..15, RSSI at 16, noise floor at 17 and PPDU type/flags at 18..19. So
RSSI came out as the high byte of the frequency (+9 for 2437 MHz), and
the tomography attenuation was built from it.

parse_adr018 now delegates to wifi_densepose_hardware::Esp32CsiParser,
the same parser the rest of the workspace uses, and only reshapes its
output. CsiFrame.channel/timestamp_us become freq_mhz/sequence, which
are what the header actually carries. ADR-081 feature-state packets
(0xC5110006) are no longer decoded as CSI frames.

build_test_frame (used by csi-test) now emits the real layout. Golden
byte-by-byte tests cover single and two-antenna frames, truncation and
the feature-state packet; a cross-check test compares against the
reference parser.

Refs #1348

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:30:45 -04:00
Mathew Beaneandclaude-flow 98c1d4a1e9 fix(provision): key saved state by board MAC, not serial port
provision.py saved each board's settings under the serial port path. Ports
get reused when boards are swapped (a second board on the same hub slot often
comes up as the same /dev/cu.usbmodem*), so the next board inherited the
previous board's node_id, target_port and channel without any sign (#1755).

- On a real flash the script reads the base MAC with `esptool read_mac` and
  keeps state in mac-<aabbccddeeff>.json. The record also stores the MAC and
  the port it was last written from.
- EUI-64 chips (C5, C6, H2) print an 8-byte "MAC:" line before "BASE MAC:";
  the parser takes the base MAC on those and the single MAC line elsewhere.
- Migration: if the board has no record yet and a port-keyed file from an
  earlier version exists, it is used once, with a notice that points at
  --reset, then removed after the save so no other board can pick it up.
- --state and --dry-run still never open the port. --state shows the board
  last provisioned from that port, and --mac selects a board by hand.
  --dry-run without --mac keeps the port-keyed file, which the QEMU swarm test
  relies on (--port /dev/null --dry-run).
- If the MAC can't be read, state stays keyed by port and the script says so.

Tests: tests/test_provision_mac_state.py adds 18 tests, using placeholder
MACs, with esptool, the NVS generator and the flasher stubbed. 15 of them
fail on the base; the other 3 guard behaviour the base already has (no serial
access on --dry-run, --reset, rejecting a bad --mac). The suite goes from 14
to 32 passing.

Not verified on hardware: the real esptool read_mac call. The output shapes
in the tests come from esptool 4.12 and 5.4.0 cmds.py.

Refs #1755

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:28:47 -04:00
Mathew Beaneandclaude-flow c00deadaf7 fix(provision): keep the WiFi password owner-only and out of --state output
The per-port state file holds the WiFi password and seed token in clear, but
save_state() created the directory and file with the default umask, so they
landed 0755/0644 and any local user could read them (#1754). --state also
printed both values verbatim, and that output gets pasted into issues.

- The state dir is created 0700 and state files 0600. Staging uses mkstemp
  (unique name, O_EXCL, 0600) plus fchmod, so a stale temp file or a planted
  symlink can't receive the secret.
- harden_state_dir() tightens the dir and every .json/.tmp file in it,
  including ones left by older versions. main() runs it before any read or
  early exit, so --state repairs old files too. It skips symlinks and files
  owned by other users, and only warns if chmod fails.
- The --dry-run NVS binary and the generator-failure CSV fallback are written
  0600 even when the file already exists. The generator's CSV and binary now
  live in a private mkdtemp() dir instead of the shared temp dir.
- --state shows password and seed_token as (set)/(empty). --show-secrets
  prints them.

Windows is unchanged: chmod can't set owner-only ACLs there.

Tests: tests/test_provision_secrets.py adds 11 tests; all 11 fail on the
base and the suite goes from 14 to 25 passing.

Refs #1754

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:25:43 -04:00
Mathew Beaneandclaude-flow 985e5d9449 feat(groundtruth): radar + CSI occupancy auto-labeller with session split and baseline
auto_label cuts a RuView .csi.jsonl recording at a radar log's window
boundaries and labels each cut empty/occupied with the radar's person
count and count interval, plus a small CSI summary (frame count, mean
RSSI, mean and variance of per-frame amplitude). The radar-to-recording
clock offset is a required, reported parameter. Windows are dropped with
a counted reason when the radar window is UNKNOWN, when too few CSI
frames fall inside it, or when an "empty" comes within empty_hold_ms of
an occupied window (a motion tracker can lose someone who sits still).

session_disjoint_split splits by session only and refuses a split where
either side would be empty or a named test session has no windows.
majority_baseline fits the no-CSI model on the training side (majority
occupancy class, mean count) and scores it on the test side, so any
occupancy accuracy built on these labels has a baseline next to it.

examples/radar_autolabel.rs is a small CLI over the three. Tests use
synthetic data only; nothing here has been run on a real capture yet.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:55:11 -04:00
Mathew Beaneandclaude-flow b033fd309e feat(groundtruth): radar window adapter for presence, count and range labels
ruview-groundtruth had an MmWave reference modality but no way to read a
radar log. RadarLabels reads one JSON summary per window (the shape a
UART radar reader writes: timestamp_ms, window_start_ms, health, source
flags, presence, target_count, max_target_count, distance_cm, targets
x/y) and turns each window into presence, person-count and nearest-range
labels.

Each label carries its basis and an uncertainty: a count interval from
the counts the window reports, the configured range sigma (a datasheet
figure, so CLAIMED), and the reader's quality. Windows from a simulated
or unverified source, a no_source reader, or with a presence flag that
disagrees with the count are UNKNOWN with a reason rather than guessed.
Labelled windows convert to ReferenceSeries for the existing alignment
and agreement code. Malformed lines are row-numbered InvalidRow errors.

serde_json moves from a dev-dependency to a dependency for the parser.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:55:10 -04:00
Mathew Beaneandclaude-flow 76af08a9b0 docs(adr): ADR-381 node self-survey from pairwise ranging
Proposed. Defines the RangeObservation record, a warn-only check mode
against --node-positions with tolerances from MEASURED ESP32-S3 FTM
bench data, per-boot calibration, and a fill mode gated off until a
source shows MEASURED p90 link error <= 0.3 m (expected from UWB).
Records the FTM bias between responder starts plainly and parks FTM
for check mode, coarse gating and time sync rather than dropping it.
Links ADR-144, ADR-281, rufield ADR-261, #2109 and #2133.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:08:04 -04:00
Mathew Beaneandclaude-flow 951ffcc254 feat(survey): add check-mode node self-survey crate (ADR-381)
Adds ruview-survey, a host-side check that compares pairwise node ranges
with the configured --node-positions and warns, never writes. It reports
per-link residuals, a fitted scale with feet/inch/cm/mm unit detection,
a suggested id swap (exhaustive up to 8 nodes, fewest-changes rule,
explicit ambiguity for symmetric layouts), moved nodes, and unknown or
unranged ids.

RangeObservation is source-agnostic (FTM, UWB, BLE CS, manual). Per-boot
calibration keys offsets by (initiator, responder, responder_boot)
because the measured FTM bias moved with every responder start. Fill
mode is not implemented; fill_gate keeps it off until a source has
MEASURED p90 link error <= 0.3 m over >= 6 links.

FTM tolerances come from a two-board ESP32-S3 bench (20 MHz, ch 1):
spread within a start <= 0.31 m, bias between starts -0.5 to +1.9 m at
1.14 m, clamp to 0 below ~0.8 m. Tests are SYNTHETIC.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:08:04 -04:00
Mathew Beaneandclaude-flow cbd84acf6c fix(unified): stop GaussianMap::decay compounding across calls
decay(now) multiplied confidence by exp(-(now - timestamp_ns)/tau_eff)
on every call, but timestamp_ns is the last-seen time and does not move
when decay runs. Calling decay(t1) then decay(t2) applied
exp(-(t1-ts)/tau) * exp(-(t2-ts)/tau) instead of exp(-(t2-ts)/tau), so
confidence fell faster the more often the caller ticked.

Track how far each Gaussian has already been decayed (decayed_to_ns)
and only apply the interval since max(decayed_to_ns, timestamp_ns).
A fresh observation still restarts the clock, and the lifetime-stretched
tau_eff is unchanged. Fusion and the merge pass keep the later of the
two decayed_to_ns values. The new field is #[serde(default)], so maps
serialized before this change load with 0 and decay from timestamp_ns
as before.

Adds two tests: one-shot vs. per-second decay must agree to 1e-12, and
the wall-vs-transient persistence case must hold when decay runs every
10 s.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 22:58:13 -04:00
Mathew Beaneandclaude-flow a1d7eb0bfd docs(spatial-evidence): ADR-382 for the RF wire contract, pin the v1 yaw convention
The spatial.evidence.v1 format is specified in WeftOS ADR-107 section 7.
ADR-382 records the part RuView emits (envelope, room_enu frame,
rf_gaussian, rf_link_observation, validation ranges, versioning) so the
contract is readable from this repository, and names ADR-107 as its origin.

ADR-107 was amended on 2026-10-03: one yaw convention (degrees
counter-clockwise from room +x), new tof_depth and radar_range types, and
an optional sensor block on radar_track_point. The two RF bodies did not
change. The crate now documents the yaw sense of an rf_gaussian
orientation and tests it (+90 degrees about +z maps local x onto room +y),
and tests that the amended example lines are rejected as unknown types by
this RF-only mirror.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 22:57:08 -04:00
Mathew Beaneandclaude-flow 3741e7ba43 feat(spatial-evidence): emit RF Gaussians and links as spatial.evidence.v1
New leaf crate ruview-spatial-evidence converts ruview-unified output
into the spatial.evidence.v1 JSONL wire format (WeftOS ADR-107 section 7):

- RfGaussian / GaussianMap -> rf_gaussian records. A Gaussian with
  synthetic provenance is always tagged SYNTHETIC; the caller can lower
  the proof tag, never raise it.
- Link measurements (tx/rx, measured vs free-space amplitude) ->
  rf_link_observation with excess_loss_db = 20 log10(free / measured).
  The Friis reference comes from ruview-unified's channel model.

The v1 envelope and the two RF bodies are mirrored with local serde
structs, so there is no WeftOS dependency. Validation follows the v1
rules: schema version, id charset and length, finite values, coordinate,
frequency, scale, occupancy, confidence and excess-loss ranges, and the
16 KiB line limit. Golden tests compare against the ADR example lines.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 18:18:41 -04:00
Mathew Beaneandclaude-flow fabb0c8233 fix(sensing-server): refuse a routable HTTP bind when API auth is off
The UDP receiver already refuses a routable bind without a source
allowlist (ADR-296), and the Docker entrypoint refuses a routable bind
without RUVIEW_API_TOKEN (#864). Running the binary directly with
--bind-addr 0.0.0.0 and no auth only logged that auth was off.

The server now exits at startup when the HTTP/WebSocket bind is not
loopback and API auth (RUVIEW_API_TOKEN or RUVIEW_OAUTH_ISSUER) is off,
unless RUVIEW_ALLOW_UNAUTHENTICATED is set, in which case it starts and
logs a warning. Loopback binds are unaffected. The conditions match the
entrypoint's, so a container that starts today still starts.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:26:26 -04:00
Mathew Beaneandclaude-flow da2304985a fix(sensing): stop absent nodes reporting a person count of 1
Every person count is already gated on presence, but two presence
decisions disagreed with the motion level published next to them:

- smooth_and_classify[_node] set presence = smoothed_motion > 0.03 while
  the debounced level only leaves "absent" above 0.04. A quiet room that
  settles in (0.03, 0.04] read motion_level "absent" with presence true,
  so the count stayed at 1.
- The raw-CSI per-node count was gated on the per-frame classification,
  which an adaptive-model label overrides. /api/v1/nodes could show a
  node as "absent" with person_count 1 while the ADR-297 room vote (which
  reads the node's debounced level) already said the room was empty.

Presence now follows the debounced level, as the room vote already does,
and the per-node count is gated on the node's own published level.
score_to_person_count keeps its floor of 1: it only runs when presence is
true, and an empty room reports 0 through the gate.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:21:48 -04:00
Mathew Beaneandclaude-flow 8c6d29bf83 fix(sensing-server): report measured stream fps and real WebSocket client count
/api/v1/stream/status returned "fps": 10 whenever tick > 1, whatever the
real rate, and both it and /health counted every broadcast subscriber
(recorder, MQTT bridge) as a client. The dashboard Streaming card read
"IDLE, 0 client(s)" for up to 30 s while the page was streaming, because
its first /health poll lands before the page's socket connects; with auth
on, the ticket round-trip widens that gap.

- New stream_stats module: UpdateRateMeter (sliding 5 s window, bounded at
  1024 samples) and WsClientCounter with an RAII guard.
- AppStateInner::advance_tick() replaces the five `s.tick += 1` sites and
  records each sensing update in the meter.
- /api/v1/stream/status now returns the measured update rate as `fps`
  (0 when nothing flows), `active` from that rate, `clients` from the
  WebSocket counter, `nodes` with each fresh node's measured csi_fps (null
  until its EMA has 5 samples) and `csi_fps_total`.
- /health and the /health/health stream component count WebSocket clients
  on /ws/sensing and /api/v1/stream/pose; the stream component also carries
  the measured rate.
- DashboardTab re-reads health 500 ms after the sensing socket connects or
  drops, so the card follows the socket instead of waiting for the next
  30 s poll.

Fixes #2087

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:15:23 -04:00
Mathew Beaneandclaude-flow 5cffe9e7db fix(sensing-server): accept vendor --source values and bind UDP for them
The previous commit limited --source to auto/esp32/wifi/simulated, which
rejected the vendor names the server already keys on: mediatek, qualcomm,
realtek (RTL8720F radar) and realtek_csi (RTL8721Dx CSI). They are the
labels the UDP receiver writes and effective_source() ages.

On 03a89793 those names fell into the catch-all plan, which bound no UDP
receiver, so vendor frames were never ingested. They now plan like esp32
(UDP receiver bound) without the ESP32-shaped simulator. Anything else,
including case variants and runtime-only labels like "mediatek:simulated",
is still rejected.

Refs #2097

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:13:38 -04:00
Mathew Beaneandclaude-flow c51a1cf888 fix(sensing-server): apply --privacy-mode to REST, WebSocket and recordings
--privacy-mode was declared in the MQTT arg group and read only by the
MQTT publisher, so /api/v1/sensing/latest, /api/v1/pose/current, the
/ws/sensing and /api/v1/stream/pose streams and recordings kept serving
pose keypoints (and heart/breathing rate when vitals are published).

- Move --privacy-mode (env RUVIEW_PRIVACY_MODE) out of MqttArgs to a
  top-level server flag. Spelling and env var are unchanged. The MQTT
  config takes its value from the server flag.
- Add privacy_filter: one key filter for vital_signs, HR/BR leaves,
  enhanced_breathing, keypoints, pose_keypoints and joints_m.
- REST: middleware strips those keys from every JSON response on the
  HTTP and WS ports. Unparseable JSON is withheld with a 500.
- WebSocket: /ws/sensing frames and /api/v1/stream/pose messages are
  filtered; the pose physics assessment is withheld.
- /api/v1/pose/current?view=both|refined returns 403 privacy_mode.
- Recordings write filtered frames.
- Default (flag off) behaviour is unchanged; the middleware is a no-op.

Fixes #2094

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:12:45 -04:00
Mathew Beaneandclaude-flow 522374584c fix(ui): stop retrying and ask for the API token when the server requires one
With RUVIEW_API_TOKEN set on the server and no token in the browser, the
sensing socket was refused, closed with 1006, and retried forever while the
header said "Connecting...". A browser cannot see the 401 on a refused
upgrade, so the service could not tell this from an outage.

- ws-ticket.js: resolveWsUrl() reports a stored token the server refuses
  (401/403) as 'rejected'. probeWsAuthRequired() repeats the ticket request
  with no bearer after an unticketed socket fails to open; a 401 means the
  server wants a token this browser does not have ('missing'). The probe
  only runs on the failure path, so auth-off servers see no extra request.
  withWsTicket() keeps its string contract for the other callers.
- sensing.service.js: new 'auth-required' state and dataSource. Entering it
  clears the retry timer; reconnect() leaves it.
- The header widget shows "Token required", the global banner explains it,
  and both open QuickSettings on API Access (once automatically per page
  load, then on click). Tab banners and the dashboard indicator get labels.
- Tests: ws-ticket.test.mjs (+6) and a new sensing.service.test.mjs (5),
  added to the CI UI test step.

Fixes #2099

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:12:23 -04:00
Mathew Beaneandclaude-flow 5755d9f11f fix(sensing-server): status waits for frames; reject unknown --source
/api/v1/status reported source_state "live_unverified" for a configured
live source before any frame had arrived, because source_state() fed a
fixed zero frame age into the ADR-295 resolver. It now uses the real age
of the last accepted frame for the current source, so the states are:

- disconnected (waiting_for_frames: true) until the first frame
- live_unverified while frames arrive within ESP32_OFFLINE_TIMEOUT (5 s)
- stale once they stop for longer than that

The response also carries waiting_for_frames and last_frame_age_ms.
ADR-270 vendor events get their own receive clock so they age the same
way.

An unknown --source value (for example "macos") used to start a server
with no data task. Clap now rejects it with the list of valid values
(auto, esp32, wifi, simulated, simulate), and plan_source returns an
error for anything else.

Docs: user guide lists the valid --source values and status states, the
macOS section uses --source wifi, and the Linux section says there is no
Linux RSSI source in the server yet.

Refs #2097

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:09:50 -04:00
Mathew Beaneandclaude-flow 3bf993275e fix(sensing-server): give the governed fuser the configured node positions
EngineBridge built its StreamingEngine without ever calling
set_node_positions, so the governed fuser placed every node at the
origin even when --node-positions was set. Startup now passes the same
keyed map the sibling multistatic_fuser gets, via
EngineBridge::with_node_positions. The fuser resolves positions by
node_id per frame, so nodes discovered later need no extra call.

/api/v1/status (and /health/ready) now reports the map under
trust.node_positions so the configuration can be checked at runtime.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:08:58 -04:00
Mathew Beaneandclaude-flow 05ea4feaed fix(sensing-server): keep recordings and models under --data-dir
Recordings, the recording list/delete routes, the trainer's dataset
loader and model export, the models routes and the adaptive classifier
all used paths relative to the process cwd (`data/recordings`,
`data/models`, `data/adaptive_model.json`), so `--data-dir` only covered
config and the session secret.

Add `recordings_dir(data_dir)` and `models_dir(data_dir)` and use them
everywhere. `MODELS_DIR` still overrides the models directory, and the
trainer now honours it too. With the default `--data-dir data` the paths
are unchanged.

Fixes #2088

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:07:11 -04:00
Mathew Beaneandclaude-flow 7af5477f04 fix(scripts): parse the ADR-018 header layout in the UDP capture scripts
Both capture scripts decoded a header layout the firmware does not send.
csi_collector.c and esp32_parser.rs use: magic u32 @0, node_id @4,
n_antennas @5, n_subcarriers u16 @6, freq_mhz u32 @8, sequence u32 @12,
rssi i8 @16, noise_floor i8 @17, ppdu_type @18, flags @19, then
n_antennas * n_subcarriers int8 I/Q pairs from @20.

- collect-training-data.py read a 16-byte header and int16 I/Q, so its
  length check rejected every real single-antenna frame. It now reads the
  20-byte header and int8 pairs, records freq/channel/seq/ppdu/flags, and
  lets the recorder stamp receive time (the header has no timestamp).
- record-csi-udp.py read rssi from byte 6 and channel from byte 7 (the
  n_subcarriers bytes) and treated bytes 8-19 as I/Q, so a 64-subcarrier
  frame came out as 70 subcarriers with header bytes in the first six
  amplitudes. It now checks the magic, reads the real fields, and writes
  only the I/Q block to iq_hex.
- Add scripts/tests/test_capture_adr018.py, which builds frames with the
  esp32_parser.rs test layout and checks the decoded fields.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:01:22 -04:00
Mathew Beaneandclaude-flow 84f5e19e9d fix(scripts): run the proof with python3 and report INCOMPLETE when a gate is skipped
prove.sh looked only for `python`, so on macOS (python3 only) it skipped
the deterministic proof and still printed RESULT: PASS with exit 0.

- Pick $PYTHON, then python3, then python.
- Check numpy/scipy import before running verify.py and print the
  lockfile install command when they are missing; warn when installed
  versions differ from archive/v1/requirements-lock.txt.
- Run verify.py single-threaded, as CI does.
- Hard gates that cannot run (no cargo, no Python, missing deps) are
  reported as [NOT RUN] and the run ends INCOMPLETE with exit 2.
  Data/hardware-gated claims are unchanged.
- Tell people to stop any local server on UDP 5005 first (#2086).

Fixes #2095

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 21:57:49 -04:00
Mathew Beaneandclaude-flow 1a50162598 fix(sensing-server): stop serving a frozen /sensing/latest as current
latest_update is only rewritten when a source produces a new update. When
every source went quiet, /api/v1/sensing/latest kept returning the last
classification indefinitely, with no marker that it was old. A one-off
bogus frame could leave "absent 0.97" frozen there.

Past 10 s, the endpoint now returns
{"status":"stale","age_ms":...,"source":effective_source()} instead of
the old payload. Fresh payloads gain an age_ms field. "no data yet" is
unchanged. This is separate from #2060, which excludes stale nodes from
the room vote: that changes what a new update says, while this bounds how
long an old update is served.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 19:22:26 -04:00
Mathew Beaneandclaude-flow 0eb952a9c5 fix(sensing-server): evict stale node_states on a timer, not only on frame arrival
Stale node eviction (60 s) lived inside the ESP32 receive path and ran
every 100 ticks, so it only happened while ESP32 frames kept arriving.
After a burst of bogus node ids, or once every node went quiet, nothing
ever removed the entries, and they stayed listed in /api/v1/nodes for the
life of the process.

Move the existing rule unchanged into evict_stale_node_states (60 s,
nodes that never reported included). Call it from the receive path as
before, and from a new node_states_eviction_task that sweeps every 30 s
alongside the UDP receiver.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 19:22:26 -04:00
Mathew Beaneandclaude-flow 08f0f8859b fix(sensing-server): reject ESP32 CSI frames with zero antennas or subcarriers
parse_esp32_frame accepted any datagram that started with the 0xC511_0001
magic and had a 20-byte header. With n_antennas or n_subcarriers at zero,
the expected length collapses to the header itself, so the length check
passed and the frame minted a node carrying empty amplitude/phase arrays.
A non-ESP32 sender on the shared UDP port produced exactly that: bogus
nodes in /api/v1/nodes.

Return None when either count is zero. Well-formed frames parse as before.
The new tests cover zero antennas, zero subcarriers, and a bare header,
and check that a well-formed frame still parses.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 19:22:26 -04:00
Mathew Beaneandclaude-flow 8c63d7b558 docs(mqtt): document the published entity set and add an ADR-115 P4.5 status note
home-assistant.md now describes current behaviour: the 15 announced
entities (13 with --privacy-mode) with their source and availability
rule, the entities that are not announced and why, publish rates and
retention, the persisted client id, and what privacy mode does and does
not cover. The guide no longer documents --semantic* flags that
sensing-server does not accept, and it drops the HIPAA/GDPR/CCPA
claims.

ADR-115 §11 records what P4.5 wired and what it did not, with a
before/after measurement against a real broker (MEASURED, one live
node).

Refs #2085, #2093

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:46:21 -04:00
Mathew Beaneandclaude-flow e351d0fce1 feat(mqtt): wire semantic states into the publisher, announce only sourced entities
Discovery announced 20 entities per device, but only 6 ever received
state (#2085). presence/state was published on every CSI frame, and the
per-process default client id gave Home Assistant new devices on every
restart (#2093).

- mqtt::planner maps each snapshot to availability, state and events.
  It runs one SemanticBus per node at 1 Hz (ADR-115 P4.5) and gates
  binary states on change with a 60 s retained heartbeat. Sensors keep
  their configured rates. Each entity is `online` only while the server
  has a source for it; otherwise it is `offline` and gets no state.
- Entities with no source on the sensing broadcast are no longer
  announced: zone_occupancy, bathroom_occupied, bed_exit,
  multi_room_transition (no zones), meeting_in_progress (motion is
  three-level) and pose (pose_keypoints is never set). 15 remain, 13
  with --privacy-mode.
- mqtt::bridge, moved from main.rs, reads only sensing_update. That
  removes the phantom aggregate device that edge_vitals frames created.
  It takes fall events from the edge_vitals rising edge and
  motion_energy from motion_band_power (previously always 0.0), and maps
  present_still to 0 % motion.
- The default client id is wifi-densepose-<8 hex>, persisted in
  <data-dir>/mqtt_client_id. --mqtt-client-id still overrides it.

Refs #2085, #2093

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:46:11 -04:00
Mathew Beaneandclaude-flow ea6da967d2 docs(mediatek): OpenWrt WN586X3 CSI patch set, router CSI guide, ADR-266 amendment
- firmware/openwrt-wn586x3/: the patch set that made a Wavlink WL-WN586X3
  Rev A (MT7981B + MT7976C) emit CSI on OpenWrt 24.10.8. It contains:
  MediaTek's mt7915 CSI driver patch rebased onto the mt76 revision 24.10.8
  pins (BSD-3-Clause-Clear, MediaTek author line kept); the Rev A board
  patch (GPL-2.0); a buffer-overrun fix for `mt76-vendor dump csi`; and
  an attribute-enum fix for MtkCSIdump. Each patch keeps the licence of
  the code it modifies. There are no images, firmware blobs, or captures.
- docs/mediatek-router-csi.md: an operator guide covering supported
  silicon, the image build route that was tested, arming CSI, the
  bridge, `--source mediatek`, endpoints and provenance labels, what the
  data is not, evidence, and troubleshooting. Every claim is tagged
  MEASURED, CODE-DERIVED, CLAIMED, or SYNTHETIC.
- scripts/mediatek-csi-phase-stability.py: a read-only reproducer for
  the one MEASURED offline result. On one MT7976C radio, the cross-chain
  phase arg(H0*conj(H1)) moves 0.014-0.039 rad (p50) between adjacent
  frames for well-sampled transmitters, against about 1.56 rad for raw
  per-chain phase, which is what random phase gives. It never prints a
  transmitter address. The captures it was run on are not in the tree.
- ADR-266 Amendment 1: vendor CSI exists on MT7981 through MediaTek's
  public feed patch, demonstrated on one board model, and still
  `mediatek:physical-unvalidated`. The hardware gates remain open.
- user-guide.md and the bridge README link to the guide.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:32:25 -04:00
Mathew Beaneandclaude-flow f44f25d596 feat(mtk-bridge): bridge MT7981 vendor CSI into MTC1
Adds `wifi-densepose-mtk-bridge`. It converts MediaTek mt76 vendor CSI
into ADR-267 MTC1 frames for the sensing server's UDP ingest.

Inputs:
- `--replay`: an `mt76-vendor dump csi` JSON file, or a `.mtkcap`
  recording. Records are grouped into frames by the driver's last-chain
  bit, per transmitter address, so HT 2x2 and legacy 1x2 runs from one
  radio each close with their real dimensions. Concatenated and duplicate
  dumps (the vendor tool appends) are reported, not hidden.
- `--listen`: MtkCSIdump UDP datagrams. They carry no packet identity, so
  each datagram becomes one 1x1 frame rather than a guessed pairing.
  Missing RSSI/noise is the -128 dBm sentinel, and PPDU type is `Unknown`.

Provenance:
- The bridge never sets CALIBRATED.
- A replayed file must be either attested (`--captured-on
  <model>/<firmware>`) or declared `--synthetic`, or the bridge refuses to
  start.
- Synthetic is sticky. It can come from a sidecar, a `.synthetic.` file
  name, or a capture header, and none of these can be overridden.
- `--listen` requires a `--listen-allow` CIDR list. Other sources are
  dropped and counted, never decoded.

Fixtures are fabricated and use locally administered addresses. The
assembler tests that model the two live dump loops use stand-in addresses,
not the real clients'. Captures written under the bridge's earlier format
tag still load.

Tests: 51 unit and 39 integration tests, plus clippy -D warnings.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:30:00 -04:00
Mathew Beaneandclaude-flow 8ecac18978 feat(sensing-server): labelled MediaTek amplitude heuristic and activity index
Real MediaTek frames now produce a room update, but from a heuristic that
is labelled as one on every output. The validated ESP32 pipeline needs
per-subcarrier phase history this path does not feed it, so it is not
used here and is never impersonated.

- `mediatek-amplitude-heuristic-v0` (CLAIMED, no labelled evaluation):
  presence and motion from per-device mean and peak amplitude over a 10 s
  time window (not a sample count, because arrivals are bursty).
  Hysteresis means one dip cannot flip the room to absent. Below a
  confidence of 0.3, a device reports `unknown` and abstains from the
  room vote rather than asserting "absent".
  `SensingUpdate.classifier` names it; `nodes[].device_id` and
  `nodes[].mediatek_diagnostics` say why each device reads as it does.
- `activity-index-v0` (CLAIMED): a fast/slow fluctuation index against
  each device's own baseline, plus an absolute level against a learned
  quiet floor. It is served in `SensingUpdate.activity` and
  `GET /api/v1/csi/mediatek/activity`, with
  `is_occupancy_estimate: false` on every block. `--activity-state <path>`
  persists learned floors across restarts.
- The summary endpoint adds the heuristic's window statistics.
- The MQTT per-node topics key on `device_id` when it is present, so two
  64-bit receiver ids cannot collide on `node_id` 0.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:28:26 -04:00
Mathew Beaneandclaude-flow e5f88b88fe feat(sensing-server): per-device MediaTek CSI ingest and inspection
The server kept one MediaTek snapshot in total, so two MT7981 receivers
overwrote each other, and `--source mediatek` fell through to the
catch-all source plan and bound no UDP socket at all.

- `--source mediatek` binds the UDP receiver and does not start the
  ESP32-shaped simulator, which has no MediaTek branch and would otherwise
  publish a simulated room classification next to real frames.
- Snapshots are kept per `device_id`.
  `GET /api/v1/csi/mediatek/devices` lists them with `age_ms` and drops
  any device silent for over 30 s.
- A bounded per-device ring (`--csi-ring`, default 256 frames, about
  0.5 MiB for a 2x2 device at 64 subcarriers) keeps per-chain,
  per-subcarrier amplitude and phase. It serves
  `/devices/:device_id/frames` and `/devices/:device_id/summary`. Chains
  are keyed by (tx, rx) identity, so a device that interleaves 1x2 legacy
  and 2x2 HT frames is reported correctly.
- New provenance label `mediatek:physical-unvalidated` for real-silicon
  frames that are not calibrated. `SYNTHETIC` still takes precedence over
  every other flag. Plain `mediatek` stays reserved for calibrated frames.

No classifier changes in this commit.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:27:19 -04:00
Mathew Beaneandclaude-flow ebb303cbb8 feat(hardware): carry legacy and unknown PPDU types in MediaTek MTC1
A real MT7981 (WL-WN586X3, MT7981B + MT7976C) CSI report arrived with
rx_mode = MT_PHY_TYPE_OFDM for a 2.4 GHz client. The MTC1 PpduType had no
pre-HT value, so a bridge had to drop the frame. MediaTek's CSI driver
treats legacy OFDM as its own tone-mask group, and association traffic is
legacy-rate, so these frames are routine rather than a corner case.

Add PpduType::Legacy = 6 and PpduType::Unknown = 7. Values 1-5 and the
72-byte header are unchanged. A decoder built before this change rejects
6 and 7 as UnknownPpduType (fails safe, drops the frame), so MTC1
consumers need a rebuild.

ADR-267 gains Amendment 1 recording the change, its compatibility, and the
observed 1x1x64 dimensions of the motivating capture. The capture is not
in the tree: it holds a client transmitter address and raw CSI.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:17:36 -04:00
Mathew Beaneandclaude-flow c80f618119 fix(sensing-server): show which nodes use the default position (#1804)
Nodes not named in --node-positions report [2.0, 0.0, 1.5], and the boot
line counts parsed entries whether or not any matches a live node id. A
config keyed by the wrong ids looked applied and did nothing.

- WARN once per node id when a fresh node has no configured position,
  naming the configured ids (INFO when no positions are configured).
- /api/v1/nodes: add position_configured per node.
- /api/v1/status: add node_positions with configured_node_ids,
  default_position and default_position_node_ids.
- At boot, WARN that entries without a node_id: prefix are keyed by list
  index from 0, with the entry -> node mapping. Semantics are unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:17:25 -04:00
Mathew Beaneandclaude-flow 3b542f8a85 fix(docker): make UDP CSI ingest posture explicit in the container
Since ADR-296 the sensing server binds its UDP receiver to 127.0.0.1 by
default, and docker-entrypoint.sh never set a UDP bind. Inside a
container that address cannot receive from the published 5005/udp port,
so the default image never shows a node (#2089, follow-up to #1394).

The entrypoint now binds UDP on 0.0.0.0 only when the operator has set
an ADR-296 source guard (RUVIEW_UDP_ALLOW, RUVIEW_UDP_INSECURE_LAN, or
the matching CLI flags). Without a guard it keeps the loopback bind and
prints a note explaining why no node will appear and what to set,
including the macOS bridge-gateway allowlist. An explicit RUVIEW_UDP_BIND
or --udp-bind still wins.

I chose this over defaulting the bind to 0.0.0.0 unconditionally. With
no guard that would make the server refuse to start (exit 1) in auto
mode, which binds UDP, and the guard would still have to be supplied. The
change keeps ADR-296's fail-closed rule as is: a routable bind never
happens without an allowlist or the explicit insecure opt-in. Auth
defaults are unchanged.

The entrypoint also normalizes RUVIEW_UDP_INSECURE_LAN=1/yes/on to true,
since clap rejects 1 for that flag.

The compose service passes RUVIEW_API_TOKEN, the RUVIEW_UDP_* variables
and SENSING_ALLOWED_HOSTS through from the shell, with notes on the
exit-64 auth check, the macOS gateway allowlist and the 421 Host check.
The README and user-guide Docker quickstarts now set a token, and the
user guide gains a "Receiving ESP32 frames in Docker" section.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:13:10 -04:00
Mathew Beaneandclaude-flow ef01c9e2be fix(sensing-server): accept 1/0/yes/no/on/off for boolean env flags
clap's default bool parser only takes true/false, so
RUVIEW_UDP_INSECURE_LAN=1 exited 2 with "invalid value '1'". Add a
shared parse_env_bool value parser (1/0, true/false, yes/no, on/off,
case-insensitive; anything else is still an error) and apply it to every
bool+env flag: RUVIEW_UDP_INSECURE_LAN, RUVIEW_NO_MDNS,
RUVIEW_NO_EDGE_REGISTRY, RUVIEW_MQTT, RUVIEW_MQTT_TLS,
RUVIEW_PRIVACY_MODE and RUVIEW_MATTER. The bare --flag form is
unchanged.

The Docker entrypoint now accepts RUVIEW_ALLOW_UNAUTHENTICATED=
true/yes/on (any case) as well as 1.

Fixes #2091

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:07:20 -04:00
Mathew Beaneandclaude-flow abeca0f0ed fix(sensing-server): send multi-node test frames to an ephemeral port
The multi_node_test UDP smoke tests sent synthetic ADR-018 CSI and
vitals frames to 127.0.0.1:5005, the sensing server's default port.
Because the tests are not ignored, a routine `cargo test` (and
scripts/prove.sh) could inject up to 255 fake node IDs into a locally
running server, and the hardcoded port could collide with other
processes.

Each test now binds its own receiver on 127.0.0.1:0 and sends to the
OS-assigned address, keeping the receiver alive for the test's
duration. The tests still only exercise the sender and frame-builder
path; no server is involved.

Ported from whitsentry, a RuView-based project, which carries the same fix.

Closes #2086

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:04:54 -04:00
Mathew Beaneandclaude-flow 5a36be17d2 fix(provision): make --dry-run and failed --reset leave state intact
--dry-run persisted the merged state record, so the next real run merged
on top of values that never reached the device. It now skips save_state.
The NVS binary is still written to nvs_provision.bin because ADR-061 and
scripts/qemu-mesh-test.sh consume it.

--reset deleted the per-port state file before argument validation, so a
run that then failed (e.g. missing WiFi credentials) still lost the record.
It now only ignores the prior state; a successful flash overwrites the file
as before.

Also fix the module docstring's package name: the PyPI package is
esp-idf-nvs-partition-gen, matching the runtime hint.

Fixes #2096

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:03:30 -04:00
Mathew Beaneandclaude-flow 559528155f fix(sensing-server): train the adaptive classifier per node, as it runs
Runtime classification is per node, from that node's own features and
amplitudes. The trainer instead built one sample per recorded line from the
fused frame-level features plus the first node's amplitudes, so a model
trained on a multi-node recording learned a different input distribution
from the one it is later asked to classify.

- load_recording now emits one sample per node per new frame, from that
  node's node_features entry and amplitude vector. A node contributes only
  when its amplitude vector changed since the previous line (every broadcast
  snapshots all nodes), and stale nodes are skipped. Lines without
  node_features (older single-node recordings) fall back to the frame-level
  features.
- Runtime subcarrier statistics use the same 56-subcarrier window that
  sensing_update, and therefore every recording, stores
  (RECORDED_AMPLITUDE_LEN, now shared with the broadcast in main.rs).
- The trainer stamps model version 2 (TRAINED_MODEL_VERSION), so a freshly
  trained model consumes the measured temporal dominant frequency (ADR-356)
  instead of being switched to the legacy subcarrier proxy at runtime.
- The training accuracy log line is labelled in-sample and printed as a
  percentage (it printed the 0..1 fraction with a % sign).

Tests: cargo test -p wifi-densepose-sensing-server --no-default-features:
bin 337 passed (5 new node_sample_tests), lib 539 passed,
integration tests pass. Clippy reports nothing on the changed lines.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:15:05 -04:00
Mathew Beaneandclaude-flow f161abf582 feat(sensing-server): loopback UDP tee for second consumers (ADR-380)
Add --udp-tee / RUVIEW_UDP_TEE: every datagram the source allowlist admits
is copied unchanged to up to four loopback targets, so a fusion host or
Cognitum cog can read full I/Q, sequence numbers and sync packets without
binding the CSI port. Targets must be loopback and may not reuse the
listener port or port 0. Sends are non-blocking and drops are counted. Off
by default.

Tests: cargo test -p wifi-densepose-sensing-server --no-default-features
--lib udp_tee (6 passed) and --bin sensing-server (332 passed). Loopback
smoke test of the debug binary: a 288-byte datagram sent to the UDP port
arrived byte for byte at the tee target, and a non-loopback --udp-tee
target made the server exit 1 with "must be a loopback address".

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:12:19 -04:00
Mathew Beaneandclaude-flow a4f80e63a2 docs(adr): ADR-379 OTA first-boot health check, with hardware evidence
Records the decision behind the two preceding firmware commits: why an
OTA'd image has to confirm itself, the health signals (STA IP plus one CSI
frame accepted by the stream sender), the 30 s minimum uptime and 120 s
deadline, the FORCE_FAIL test hook, ota_state in /ota/status, and the httpd
stack sizing.

MEASURED 2026-09-29:
- ESP32-S3 (16 MB, rollback bootloader): OTA push to ota_1, HTTP 200;
  "httpd stack after POST /ota: 4900 of 8192 bytes never used";
  pending_verify then valid; still valid on ota_1 after a hard reset.
  A FORCE_FAIL build pushed to ota_0 rolled back to ota_1 after ~30 s.
- ESP32-C6 (4 MB layout, rollback bootloader): push to ota_1, HTTP 200;
  pending_verify then valid ~28 s later; still valid after a hard reset.

Not yet verified: the 120 s no-IP timeout path, a true power cycle (an RTS
hard reset was used), and forced rollback on the C6.

Numbered 379: main has up to ADR-378 and open PRs claim nothing higher (2026-10-02).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:12:12 -04:00
Mathew Beaneandclaude-flow 43f5f99108 fix(scripts): send the API token from collect-ground-truth and check the reply
With authentication enabled, collect-ground-truth.py could not start a CSI
recording: it sent no bearer token. It also treated any HTTP 200 as success,
but the recording endpoints answer 200 with {"success": false, "error": ...}
for refusals such as "recording already in progress". Either way it went on
capturing camera keypoints with no paired CSI and only printed a warning.

- Read the token from --token-file, else RUVIEW_API_TOKEN, and send it as
  Authorization: Bearer on start and stop.
- post_json() returns False when the body says success: false.
- Name the CSI recording gt_<timestamp> so it pairs with
  keypoints_<timestamp>.jsonl.
- Exit with an error when the recording cannot start, unless
  --allow-no-csi is given.

Tests: py_compile; scripts/tests (15 passed); a local smoke test against a
stub HTTP server checked the bearer header, the request id, the
success:false refusal, and token precedence (file, then env).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:08 -04:00
Mathew Beaneandclaude-flow f8ab204711 fix(wifiscan): keep the redacted macOS connected link; auto+wifi keeps UDP bound
On macOS 27, without Location Services, CoreWLAN redacts both the SSID and
the BSSID, while rssi, noise and channel stay real. resolve_bssid abstained
when both identities were blank, so every macOS scan was dropped and
`--source wifi` silently produced nothing.

- mac_wifi --scan-once marks its sample `"connected": true`. It is always the
  single connected link, so a synthetic id cannot merge two networks.
- A redacted connected sample gets a stable locally administered synthetic
  BSSID; the SSID stays empty. Unmarked redacted lines still abstain.
- `--source auto` that resolves to host WiFi now also binds UDP, and wifi_task
  yields while the source is a live esp32, so ESP32 nodes that start after
  the boot probe still promote the source (the #1004 rule). Without this, the
  fix would make stock Macs ignore late-starting nodes.
- A missing helper is reported once, with the build command.
- ADR-025: Updated row + Amendment 1 recording the measured behaviour.

Measured on macOS 27: --source wifi gives source wifi:unnamed with sensing
ticks advancing; --source auto on a quiet port starts on wifi with
udp_receiver=true and promotes to esp32 (5 nodes) when real frames arrive.
Tests: wifiscan 158+1, sensing-server bin 332, all passing.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:08 -04:00
Mathew Beaneandclaude-flow 0df5069831 fix(training): train from real sensing recordings, per node
training_api loaded `{id}.csi.jsonl` in a flat {subcarriers, rssi} schema,
but the recorder writes `{id}.jsonl` sensing_update lines (nodes[] amplitude,
node_features[]). Training on a real recording therefore saw zero frames
(#1703).

- Accept sensing_update lines as well as legacy flat frames; try `{id}.jsonl`
  first, then `{id}.csi.jsonl`.
- Emit one frame per node per update, skip repeated snapshots and nodes
  without amplitudes, and keep frames grouped by node (stable sort).
- RecordedFrame gains an optional node_id; temporal features (window, prev)
  only use frames from the same node.
- Malformed lines are counted, not fatal.

Tests: recording_format_tests (4 new);
cargo test -p wifi-densepose-sensing-server --no-default-features
--bin sensing-server: 336 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00
Mathew Beaneandclaude-flow f6a3a3579f fix(firmware): confirm OTA'd images with a bounded health check
Rollback-enabled nodes (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y) boot an
OTA'd image as PENDING_VERIFY, but nothing called
esp_ota_mark_app_valid_cancel_rollback(), so every OTA reverted at the next
reset, a second OTA was refused until then, and a bad image was never
deliberately rolled back.

- main/ota_health.{c,h}: at boot, if the running image is PENDING_VERIFY,
  poll for STA got-IP and >=1 CSI frame accepted by the stream sender.
  Confirm after CONFIG_OTA_HEALTH_MIN_UPTIME_S (30 s) once both are in;
  roll back via esp_ota_mark_app_invalid_rollback_and_reboot() if they are
  not in by CONFIG_OTA_HEALTH_TIMEOUT_S (120 s from boot). VALID, UNDEFINED,
  NEW and no-otadata/factory images are left alone. The decision is a pure
  header function, host-tested in test/test_ota_health.c (make host_tests).
- CONFIG_OTA_HEALTH_FORCE_FAIL (test only) rolls back where it would have
  confirmed, for proving rollback on hardware.
- GET /ota/status reports ota_state so durability is visible remotely.
- RUNBOOK: replace the nonexistent ota_rollback_boot_check() claim and
  document the behaviour and the hardware check (2.1).

Hardware-verified on an ESP32-S3 (push, confirm, hard reset, forced
rollback) and an ESP32-C6 (push, confirm, hard reset); see ADR-379, next in
this series. Host tests: make host_tests passes, including run_ota_health.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00
Mathew Beaneandclaude-flow d25fb8192e fix(firmware): stop POST /ota overflowing the httpd stack
MEASURED 2026-09-29 on an ESP32-S3 (v0.8.12, 16 MB): every POST /ota of a
valid 1,228,672 B image died with "stack overflow in task httpd" and
RTC_SW_CPU_RST, so no OTA had ever completed on this firmware.

- ota_update.c: set config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE (new
  Kconfig, default 8192; HTTPD_DEFAULT_CONFIG gave 4096). Move the 1 KB
  receive buffer off the stack into a static (handlers are serialised on
  the one httpd task). Integer progress percent instead of %f.
  recv_wait_timeout stays 30 s.
- Log the httpd stack high-water mark after every POST /ota and
  POST /wasm/upload so the size rests on device data.
- wasm_upload.c: GET /wasm/list builds its 2 KB JSON on the heap.
- RUNBOOK §3 (over the air) records the failure and the high-water-mark
  log line.

Device check after the fix (same S3): "httpd stack after POST /ota: 4900 of
8192 bytes never used", i.e. about 3.3 KB peak. The ADR with the full
hardware evidence follows in this series (ADR-379).

This overlaps PR #1594, which raises the same stack. If #1594 lands first,
drop this commit; the rest of the series does not depend on it.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00
327 changed files with 31296 additions and 1690 deletions

No files matched your search

+14 -7
View File
@@ -204,7 +204,10 @@ jobs:
node-version: '22'
- name: Run UI unit tests
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/services/sensing.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
- name: Run virtual-view adapter boundary tests
run: node --test integrations/virtual-view/adapter.test.mjs
# Unit and Integration Tests
# Python pytest matrix — runs against the archived v1 Python tree.
@@ -501,7 +504,7 @@ jobs:
notify:
name: Notify
runs-on: ubuntu-latest
needs: [code-quality, test, rust-tests, performance-test, docker-build, docs]
needs: [code-quality, test, rust-tests, ui-tests, performance-test, docker-build, docs]
if: always()
permissions:
contents: write # required by softprops/action-gh-release
@@ -528,18 +531,22 @@ jobs:
text: '❌ CI pipeline failed for ${{ github.ref }}'
- name: Create GitHub Release
if: github.ref == 'refs/heads/main' && needs.docker-build.result == 'success'
if: github.ref == 'refs/heads/main' && needs.rust-tests.result == 'success' && needs.ui-tests.result == 'success' && needs.docker-build.result == 'success'
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
with:
tag_name: v${{ github.run_number }}
name: Release v${{ github.run_number }}
name: CI snapshot v${{ github.run_number }}
# CI snapshots must not replace a curated product release as Latest.
make_latest: false
body: |
Automated release from CI pipeline
Source snapshot validated by the CI pipeline. This entry does not contain firmware bundles.
**Changes:**
${{ github.event.head_commit.message }}
**Docker Image:**
`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}`
**Source commit:** `${{ github.sha }}`
Container images publish separately through the [sensing-server release workflow](https://github.com/ruvnet/RuView/actions/workflows/sensing-server-docker.yml).
Firmware downloads are attached to the dedicated `vX.Y.Z-esp32` releases.
draft: false
prerelease: false
+22 -2
View File
@@ -16,6 +16,17 @@ on:
- '.github/workflows/firmware-ci.yml'
jobs:
provisioning-tests:
name: Provisioning tooling tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.11'
- name: Verify provisioning and secret handling without hardware
run: python -m unittest discover -s firmware/esp32-csi-node/tests -p 'test_provision*.py' -v
version-guard:
name: Verify version.txt matches release tag
runs-on: ubuntu-latest
@@ -119,6 +130,10 @@ jobs:
fi
echo "Binary size OK: $SIZE <= $MAX"
- name: Verify built application version before packaging
working-directory: firmware/esp32-csi-node
run: python test/test_delivery_contract.py --built-app build/esp32-csi-node.bin
- name: Verify flash image integrity
working-directory: firmware/esp32-csi-node
run: |
@@ -208,7 +223,7 @@ jobs:
publish-release-assets:
name: Publish firmware release assets
if: github.ref_type == 'tag'
needs: [version-guard, build, build-c6-idf6]
needs: [version-guard, provisioning-tests, build, build-c6-idf6]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -232,7 +247,12 @@ jobs:
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
with:
name: ESP32 firmware ${{ github.ref_name }}
prerelease: true
make_latest: false
body: |
Exact-run ESP32-S3 8 MB, ESP32-S3 4 MB, and ESP32-C6 4 MB firmware bundles.
Exact-run ESP32-S3 display 8 MB, ESP32-S3 headless 4 MB, and ESP32-C6 4 MB firmware bundles.
Verify each archive against `SHA256SUMS.txt` before flashing.
CI builds and host tests verify software behavior, not target hardware.
This prerelease has no new physical-board qualification or sensing-accuracy claim.
Select the matching board configuration using firmware/esp32-csi-node/README.md.
files: release-assets/*
+44
View File
@@ -0,0 +1,44 @@
name: README visual asset validation
on:
pull_request:
paths:
- 'README.md'
- 'assets/readme/**'
- 'docs/readme-visual-system.md'
- 'scripts/*readme*'
- 'scripts/tests/test_readme_visuals_xml.py'
- '.github/workflows/readme-visuals.yml'
push:
branches: [main]
paths:
- 'README.md'
- 'assets/readme/**'
- 'docs/readme-visual-system.md'
- 'scripts/*readme*'
- 'scripts/tests/test_readme_visuals_xml.py'
- '.github/workflows/readme-visuals.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
verify:
name: Safe and reproducible README visuals
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.11'
- name: Install visual tooling dependency
run: python -m pip install -r scripts/requirements-readme-visuals.txt
- name: Test XML input boundaries
run: python -m unittest discover -s scripts/tests -p test_readme_visuals_xml.py
- name: Verify reproducible and accessible assets
run: |
python scripts/render-readme-visuals.py
python scripts/verify-readme-visuals.py
git diff --exit-code -- assets/readme
test -z "$(git ls-files --others --exclude-standard -- assets/readme)"
+16 -2
View File
@@ -4,6 +4,12 @@ on:
pull_request:
paths:
- 'harness/ruview/**'
- 'scripts/csi-udp-relay.py'
- 'scripts/esp32_jsonl_to_rvcsi.py'
- 'scripts/overnight-empty-capture.py'
- 'scripts/record-csi-udp.py'
- 'scripts/collect-training-data.py'
- 'scripts/align-ground-truth.js'
- '.github/scripts/nightly-sota/**'
- '.github/workflows/nightly-sota-agent.yml'
- '.github/workflows/ruview-harness-flywheel.yml'
@@ -25,8 +31,12 @@ concurrency:
jobs:
verify:
name: Verify contributor harness
runs-on: ubuntu-latest
name: Verify contributor harness (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
defaults:
run:
working-directory: harness/ruview
@@ -39,9 +49,13 @@ jobs:
node-version: 22
cache: npm
cache-dependency-path: harness/ruview/package-lock.json
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.11'
- run: npm ci --ignore-scripts
- run: npm audit --omit=optional
- run: npm test
- run: npm run test:security
- run: npm run brain:verify
- run: npm run flywheel:plan
- run: npm run flywheel:verify
+3
View File
@@ -322,3 +322,6 @@ v2/crates/ruview-offaxis/pkg/
scripts/fal-visual-teacher/training-data/
scripts/fal-visual-teacher/training-data.zip
scripts/fal-visual-teacher/output/
# Trained adaptive classifier (auto-created at runtime; #2135)
/v2/data/adaptive_model.json
+13
View File
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Community batch (PRs #2100-#2157 by aepod, reviewed and integrated)
Integrated as one set: conflicts resolved, review findings fixed, validated on the integrated branch. Hardware figures cited by the PRs are the author's own live runs on ESP32-S3 nodes and were not independently reproduced here.
- **Security and honest status.** Refuse a routable HTTP bind when API auth is off (#2134). `--privacy-mode` now strips biometrics from REST, WebSocket and recordings, not only MQTT (#2125; the stripped-key list is an exact-match allowlist, so a new biometric field must be added to it). `/api/v1/status` waits for a real frame and unknown `--source` values are rejected (#2126; breaking for `--source macos|linux|windows`). A stale `/sensing/latest` reports `{"status":"stale"}` after 10 s (#2119). Measured stream fps and real WebSocket client counts (#2127). Absent nodes no longer count as one person (#2132). ESP32 frames with zero antennas or subcarriers are dropped (#2118). Dead nodes are evicted on a timer (#2120). Recordings and models stay under `--data-dir` (#2129). Boolean env flags accept 1/0/yes/no/on/off (#2108). Docker UDP ingest posture is explicit (#2107).
- **CSI pipeline.** The grid gate locks on the majority frame shape and `/health` reports a stalled pipeline (#2157; a partial fix, mixed 192/306 frame shapes still drop frames, so it does not close #1894). Phase is combined across nodes only when they declare a shared reference (#2156). Point-cloud and hardware parsers use the ADR-018 layout and accept more than 256 subcarriers (#2155; the bandwidth rule for counts above 64 is CLAIMED, inferred from the datasheet). Per-node adaptive training from real recordings (#2101, #2103); a run that fell back to the live buffer now reports `degraded: true`. Gaussian decay no longer compounds across calls (#2140).
- **MediaTek (ADR-266/267).** Legacy and unknown PPDU types in MTC1 (#2112), per-device ingest capped at 64 devices (#2113), a labelled amplitude heuristic and activity index (#2114; CLAIMED, not an occupancy estimate), an MT7981 bridge (#2115), and the OpenWrt WN586X3 patch set and guide (#2116; the vendored CSI patch is BSD-3-Clause-Clear, see `NOTICE`).
- **MQTT / Home Assistant.** Semantic states wired into the publisher, entities announced only when sourced, persisted client id (#2117). Existing Home Assistant installs keep retained discovery configs for the six removed entities until they are cleared manually.
- **Provisioning and firmware.** Wi-Fi password kept owner-only and out of argv and `--state` (#2153); state keyed by board MAC (#2152); `--dry-run` and a failed `--reset` leave state intact (#2111); OTA images confirmed by a bounded health check, which means only "reached the network and sent CSI" (#2100, ADR-379).
- **Ground truth and spatial.** Radar window adapter and auto-labeller with a session-disjoint split and a median count baseline; radar labels carry `evidence: CLAIMED` (#2143, #2144). Node self-survey, check-only (#2145, ADR-381). The `spatial.evidence.v1` export defaults to the CODE proof grade and MEASURED requires a reproducer (#2141, ADR-382). Loopback UDP tee (#2102, ADR-380).
- **Tooling.** `prove.sh` reports INCOMPLETE instead of PASS when a gate is skipped (#2130); ADR-018 capture scripts (#2131); API token on the ground-truth script (#2104); the UI asks for the API token instead of retrying forever (#2128); macOS redacted-link handling (#2105).
- **Not included:** #2106 (MacWifi.app, draft) and #2142 (MR60 probe change, needs a captured MR60BHA2 boot log). `v2/data/adaptive_model.json` (41.5% training accuracy) is no longer tracked (#2135).
### Added
- **`@ruvnet/ruview` 0.9.1: live sensing views inside Claude Code (ADR-377, ADR-378).** The package now ships `ruview-live`, a Claude Code mod. Type `/ruview` and a pane opens next to the conversation showing what your sensors see right now, refreshed automatically. It has three views, switched with keys `1` `2` `3` or with `/ruview overview|waterfall|radar`:
+22
View File
@@ -0,0 +1,22 @@
RuView third-party notices
This repository is licensed MIT OR Apache-2.0 (see LICENSE). The items below
are vendored third-party material that keeps its own licence and is not
covered by that dual licence.
firmware/openwrt-wn586x3/patches/1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch
What: MediaTek's mt76 mt7915 CSI patch, rebased onto openwrt/mt76
eb567bc7 (the revision pinned by OpenWrt 24.10.8).
Author: Bo Jiao <Bo.Jiao@mediatek.com> (original author line kept in the
patch header); copyright MediaTek Inc.
Origin: mediatek/mtk-openwrt-feeds @ a15454c888f4f4144c50e33b5feef4f247c5f78b
Licence: BSD-3-Clause-Clear (SPDX identifier,
https://spdx.org/licenses/BSD-3-Clause-Clear.html). The patch
carries the SPDX tags and MediaTek copyright lines in its added
files; the licence text is not reproduced here.
Scope: A vendored patch applied to an OpenWrt/mt76 build. It is not part
of the Rust crates, is not compiled by the Cargo workspace, and is
not linked into any RuView binary.
See firmware/openwrt-wn586x3/README.md for the other patches in that
directory and the licence each one keeps.
+57 -3
View File
@@ -6,6 +6,27 @@
</a>
</p>
[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero-spatial-v3.svg)](docs/user-guide.md)
<table>
<tr>
<td align="center" width="33%"><a href="docs/user-guide.md"><img src="assets/readme/nav-start-spatial-v3.svg" width="180" alt="Start here" /></a><br/><a href="docs/user-guide.md">Start here</a></td>
<td align="center" width="33%"><a href="firmware/esp32-csi-node/README.md"><img src="assets/readme/nav-hardware-spatial-v3.svg" width="180" alt="Hardware" /></a><br/><a href="firmware/esp32-csi-node/README.md">Hardware</a></td>
<td align="center" width="33%"><a href="https://huggingface.co/ruvnet/wifi-densepose-pretrained"><img src="assets/readme/nav-models-spatial-v3.svg" width="180" alt="Models" /></a><br/><a href="https://huggingface.co/ruvnet/wifi-densepose-pretrained">Models</a></td>
</tr>
<tr>
<td align="center" width="33%"><a href="harness/ruview/README.md"><img src="assets/readme/nav-agents-spatial-v3.svg" width="180" alt="AI toolkit" /></a><br/><a href="harness/ruview/README.md">AI toolkit</a></td>
<td align="center" width="33%"><a href="docs/integrations/home-assistant.md"><img src="assets/readme/nav-home-spatial-v3.svg" width="180" alt="Smart home" /></a><br/><a href="docs/integrations/home-assistant.md">Smart home</a></td>
<td align="center" width="33%"><a href="https://cognitum.one/seed"><img src="assets/readme/nav-seed-spatial-v3.svg" width="180" alt="Cognitum Seed" /></a><br/><a href="https://cognitum.one/seed">Cognitum Seed</a></td>
</tr>
</table>
<p align="center">
<a href="https://ruos.cognitum.one">
<img src="assets/ruos-animated.svg" alt="RuView — WiFi becomes spatial awareness, with Ruflo coordination and a ruOS sensing workspace" width="100%">
</a>
</p>
## **See through walls with WiFi** ##
@@ -21,6 +42,8 @@ Works natively with the four major smart-home ecosystems: **[Home Assistant](doc
Every WiFi router already fills your space with radio waves. When people move, breathe, or even sit still, they disturb those waves in measurable ways. RuView captures these disturbances using Channel State Information (CSI) from low-cost ESP32 sensors and turns them into actionable data: who's there, what they're doing, and whether they're okay.
[![Explore RuView sensing, models and automation](assets/readme/header-features-spatial-v3.svg)](docs/user-guide.md)
**What it senses:**
- **Presence and occupancy** — detect people through walls, count them, track entries and exits
- **Vital signs** — breathing rate and heart rate, contactless, while sleeping or sitting
@@ -37,6 +60,22 @@ Every WiFi router already fills your space with radio waves. When people move, b
- **Governed evidence** — attach privacy policy, uncertainty, provenance, and witness records to sensing events
- **RuView MetaHarness** — use an AI operator to onboard, calibrate, train, verify, and check sensing claims
<!-- ruview-capability-cards -->
<table>
<tr>
<td width="50%"><a href="docs/user-guide.md"><img src="assets/readme/feature-presence-spatial-v3.svg" width="580" alt="Presence and motion: open documentation and current limitations" /></a><br/><a href="docs/user-guide.md">Presence and motion</a></td>
<td width="50%"><a href="v2/crates/wifi-densepose-signal/README.md"><img src="assets/readme/feature-vitals-spatial-v3.svg" width="580" alt="Breathing and vital signals: open documentation and current limitations" /></a><br/><a href="v2/crates/wifi-densepose-signal/README.md">Breathing and vital signals</a></td>
</tr>
<tr>
<td width="50%"><a href="docs/benchmarks/pose-estimation-cog.md"><img src="assets/readme/feature-pose-spatial-v3.svg" width="580" alt="Pose research and limitations: open documentation and current limitations" /></a><br/><a href="docs/benchmarks/pose-estimation-cog.md">Pose research and limitations</a></td>
<td width="50%"><a href="firmware/esp32-csi-node/README.md"><img src="assets/readme/feature-edge-spatial-v3.svg" width="580" alt="Edge sensor mesh: open documentation and current limitations" /></a><br/><a href="firmware/esp32-csi-node/README.md">Edge sensor mesh</a></td>
</tr>
<tr>
<td width="50%"><a href="docs/integrations/home-assistant.md"><img src="assets/readme/feature-automation-spatial-v3.svg" width="580" alt="Local automation: open documentation and current limitations" /></a><br/><a href="docs/integrations/home-assistant.md">Local automation</a></td>
<td width="50%"><a href="harness/ruview/README.md"><img src="assets/readme/feature-agents-spatial-v3.svg" width="580" alt="Guided AI operator: open documentation and current limitations" /></a><br/><a href="harness/ruview/README.md">Guided AI operator</a></td>
</tr>
</table>
<details>
<summary><strong>RuView MetaHarness</strong> — guided operation for humans and AI agents</summary>
@@ -127,8 +166,8 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
> | 🧱 **Through-wall sensing** | Fresnel-zone geometry + multipath modeling | Up to ~5 m, signal-dependent |
> | 🧠 **Edge intelligence** | **105-cog catalog** ([ADR-102](docs/adr/ADR-102-edge-module-registry.md)) live from `app-registry.json` — health, security, building, retail, industrial, research, AI, swarm, signal, network, and developer modules. Optional Cognitum Seed adds persistent vector store + kNN + witness chain | $140 total BOM |
> | 🎯 **Camera-free pre-training** | Self-supervised contrastive encoder, 12.2M training steps on 60K frames, shipped on Hugging Face | 84 s/epoch retrain on M4 Pro |
> | 📷 **Camera-supervised fine-tune** | MediaPipe + ESP32 CSI paired training, end-to-end Candle pipeline on RTX 5080 ([ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md)) | 2.1 s for 400 epochs (~5 ms/epoch) |
> | 📡 **Multi-frequency mesh** | Channel hopping across 6 bands, TDM slot scheduling ([ADR-029](docs/adr/ADR-029-multifrequency-mesh.md)) | 3× sensing bandwidth |
> | 📷 **Camera-supervised fine-tune** | MediaPipe + ESP32 CSI paired training, end-to-end Candle pipeline on RTX 5080 ([ADR-079](docs/adr/ADR-079-camera-ground-truth-training.md)) | 2.1 s for 400 epochs (~5 ms/epoch) |
> | 📡 **Multi-frequency mesh** | Channel hopping across 6 bands, TDM slot scheduling ([ADR-029](docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md)) | 3× sensing bandwidth |
> | 🌐 **3D point cloud fusion** | Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model | 22 ms pipeline · 19K+ points/frame |
>
> Browse the full 105-module catalog (with practical descriptions, sizes, and difficulty) below in [🧩 Edge Module Catalog](#-edge-module-catalog), or visit [seed.cognitum.one/store](https://seed.cognitum.one/store).
@@ -141,8 +180,11 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
```bash
# Option 1: Docker (simulated data, no hardware needed)
docker pull ruvnet/wifi-densepose:latest
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
export RUVIEW_API_TOKEN=$(openssl rand -hex 32) # required; the container exits 64 without it
docker run -p 127.0.0.1:3000:3000 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
# Open http://localhost:3000
# ESP32 nodes in Docker also need a UDP source guard (RUVIEW_UDP_ALLOW);
# see docs/user-guide.md "Receiving ESP32 frames in Docker".
# Option 2a: Live sensing with ESP32-S3 hardware ($9)
# Flash firmware, provision WiFi, and start sensing:
@@ -254,6 +296,8 @@ The separate **17-keypoint pose-estimation model** is now published at [`ruvnet/
### Results & proof
[![RuView visual guide: header-evidence](assets/readme/header-evidence-spatial-v3.svg)](docs/benchmarks/pose-estimation-cog.md)
See the measured benchmarks, witness records, and one-command reproducibility check.
<details>
@@ -308,6 +352,8 @@ number. The path to a first *reproducible* on-device baseline (PCK@20 ≥ 35%) i
## 🧩 Edge Module Catalog
[![RuView visual guide: header-edge](assets/readme/header-edge-spatial-v3.svg)](firmware/esp32-csi-node/README.md)
Add signed modules for health, security, buildings, industry, research, AI, and more.
<details>
@@ -482,6 +528,8 @@ Browse and install modules at [seed.cognitum.one/store](https://seed.cognitum.on
## 🔬 How It Works
[![RuView visual guide: overview](assets/readme/overview-spatial-v3.svg)](docs/user-guide.md)
WiFi routers flood every room with radio waves. When a person moves — or even breathes — those waves scatter differently. WiFi DensePose reads that scattering pattern and reconstructs what happened:
```
@@ -606,6 +654,8 @@ These scenarios exploit WiFi's ability to penetrate solid materials — concrete
## 🧠 Self-Learning WiFi AI
[![RuView visual guide: header-learning](assets/readme/header-learning-spatial-v3.svg)](docs/user-guide.md)
Learn compact room fingerprints from raw CSI and adapt the model to each environment.
<details>
@@ -735,6 +785,8 @@ For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.9.1`. The quick c
## 📖 Documentation
[![RuView visual guide: header-docs](assets/readme/header-docs-spatial-v3.svg)](docs/user-guide.md)
Start with the user, build, and calibration guides; expand for the full reference map.
<details>
@@ -776,6 +828,8 @@ Start with the user, build, and calibration guides; expand for the full referenc
>
> Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues).
[![Cognitum One: Ambient Intelligence at the edge of the physical world](assets/readme/cognitum-spatial-v3.svg)](https://cognitum.one)
## 📄 License
MIT License — see [LICENSE](LICENSE) for details.
+4
View File
@@ -20,6 +20,10 @@ func main() {
exit(1)
}
let sample: [String: Any] = [
// Always the connected link. macOS redacts ssid/bssid without
// Location Services; rssi/noise/channel stay real, so mark the
// sample as the connected link for the Rust adapter.
"connected": true,
"ssid": interface.ssid() ?? "",
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
"channel": channel.channelNumber,
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="304" viewBox="0 0 1200 304" role="img" aria-labelledby="title desc"><title id="title">Explore Cognitum One</title><desc id="desc">Ambient Intelligence at the edge of the physical world. Open cognitum.one.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="303" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="44" y="42" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">COGNITUM ONE</text><text x="44" y="99" font-size="42" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Ambient Intelligence.</text><text x="44" y="147" font-size="25" fill="#78cfff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >At the edge of the physical world.</text><text x="44" y="190" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Explore the platform behind the ecosystem.</text><rect x="44" y="224" width="256" height="46" rx="11" fill="url(#accent)"/><text x="65" y="254" font-size="18" fill="#071124" text-anchor="start" font-weight="500" >Explore Cognitum</text><path d="M263 247H284M277 240L284 247L277 254" fill="none" stroke="#071124" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="947" cy="151" r="173" fill="url(#halo)"/><g transform="translate(945 173) scale(1.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(945 145) scale(1.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-15 1L-5 11L17 -12" fill="none" stroke="#438fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/></g></g><ellipse cx="945" cy="150" rx="140" ry="30" transform="rotate(-25 945 150)" fill="none" stroke="#437cb2" stroke-dasharray="5 18" class="flow"/><ellipse cx="945" cy="150" rx="160" ry="43" transform="rotate(-25 945 150)" fill="none" stroke="#437cb2" stroke-dasharray="5 18" class="flow"/><ellipse cx="945" cy="150" rx="180" ry="56" transform="rotate(-25 945 150)" fill="none" stroke="#437cb2" stroke-dasharray="5 18" class="flow"/></svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="304" viewBox="0 0 1200 304" role="img" aria-labelledby="title desc"><title id="title">Explore Cognitum One</title><desc id="desc">Ambient Intelligence at the edge of the physical world. Open cognitum.one.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="303" rx="18" fill="url(#wash)" stroke="#245360"/><text x="44" y="42" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">COGNITUM ONE</text><text x="44" y="99" font-size="42" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Ambient Intelligence.</text><text x="44" y="147" font-size="25" fill="#72eee0" text-anchor="start" font-weight="650" letter-spacing="-0.6" >At the edge of the physical world.</text><text x="44" y="190" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Explore the platform behind the ecosystem.</text><rect x="44" y="224" width="256" height="46" rx="11" fill="url(#accent)"/><text x="65" y="254" font-size="18" fill="#041117" text-anchor="start" font-weight="500" >Explore Cognitum</text><path d="M263 247H284M277 240L284 247L277 254" fill="none" stroke="#041117" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="947" cy="151" r="173" fill="url(#halo)"/><g transform="translate(945 173) scale(1.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><g transform="translate(945 145) scale(1.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-15 1L-5 11L17 -12" fill="none" stroke="#70b6ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/></g></g><ellipse cx="945" cy="150" rx="140" ry="30" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/><ellipse cx="945" cy="150" rx="160" ry="43" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/><ellipse cx="945" cy="150" rx="180" ry="56" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">A guided AI operator</title><desc id="desc">Source-cited setup and verification. CLI, MCP and agent workflows. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 06</text><text x="30" y="94" font-size="29" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >A guided AI operator</text><text x="30" y="136" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Source-cited setup and verification.</text><text x="30" y="164" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >CLI, MCP and agent workflows.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 0L0 -33" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="0" cy="-33" r="7" stroke="#78cfff" fill="none" class="breathe"/><path d="M0 0L-35 23" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-35" cy="23" r="7" stroke="#78cfff" fill="none" class="breathe"/><path d="M0 0L35 23" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="35" cy="23" r="7" stroke="#78cfff" fill="none" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#78cfff" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">TOOLKIT GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">A guided AI operator</title><desc id="desc">Source-cited setup and verification. CLI, MCP and agent workflows. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">RUVIEW / 06</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >A guided AI operator</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Source-cited setup and verification.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >CLI, MCP and agent workflows.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 0L0 -33" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="0" cy="-33" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L-35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#72eee0" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">TOOLKIT GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Local automation</title><desc id="desc">Connect rooms to your smart home. Home Assistant, MQTT and Matter. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 05</text><text x="30" y="94" font-size="29" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Local automation</text><text x="30" y="136" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Connect rooms to your smart home.</text><text x="30" y="164" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Home Assistant, MQTT and Matter.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-9" r="5" stroke="#438fff" fill="none" class="breathe"/></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">INTEGRATION GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Local automation</title><desc id="desc">Connect rooms to your smart home. Home Assistant, MQTT and Matter. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">RUVIEW / 05</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Local automation</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Connect rooms to your smart home.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Home Assistant, MQTT and Matter.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-9" r="5" stroke="#70b6ff" fill="none" class="breathe"/></g></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">INTEGRATION GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Edge sensor mesh</title><desc id="desc">Provision, capture and calibrate. Explore ESP32 firmware and runbooks. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 04</text><text x="30" y="94" font-size="29" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Edge sensor mesh</text><text x="30" y="136" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Provision, capture and calibrate.</text><text x="30" y="164" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Explore ESP32 firmware and runbooks.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0c2242" stroke="#78cfff"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#438fff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 -16H36" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M0 -36V-25M0 25V36M-36 0H-25M25 0H36" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16 -36V-25M16 25V36M-36 16H-25M25 16H36" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="0" r="4" stroke="#78cfff" fill="#78cfff" class="breathe"/></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">HARDWARE GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Edge sensor mesh</title><desc id="desc">Provision, capture and calibrate. Explore ESP32 firmware and runbooks. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">RUVIEW / 04</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Edge sensor mesh</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Provision, capture and calibrate.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Explore ESP32 firmware and runbooks.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0a2630" stroke="#72eee0"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#70b6ff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 -16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M0 -36V-25M0 25V36M-36 0H-25M25 0H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16 -36V-25M16 25V36M-36 16H-25M25 16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="0" r="4" stroke="#72eee0" fill="#72eee0" class="breathe"/></g></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">HARDWARE GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Pose research</title><desc id="desc">Train and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 03</text><text x="30" y="94" font-size="29" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Pose research</text><text x="30" y="136" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Train and compare body keypoints.</text><text x="30" y="164" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Live accuracy differs from benchmarks.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><circle cx="0" cy="-28" r="8" stroke="#b8f36c" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-14" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="0" cy="12" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="-24" cy="-6" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="24" cy="-6" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="-18" cy="38" r="3" stroke="#ffb45e" fill="#071124" class="breathe"/><circle cx="18" cy="38" r="3" stroke="#ffb45e" fill="#071124" class="breathe"/></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">READ THE EVIDENCE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Pose research</title><desc id="desc">Train and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">RUVIEW / 03</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Pose research</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Train and compare body keypoints.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Live accuracy differs from benchmarks.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><circle cx="0" cy="-28" r="8" stroke="#72eee0" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-14" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="0" cy="12" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="-24" cy="-6" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="24" cy="-6" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="-18" cy="38" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="18" cy="38" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/></g></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">READ THE EVIDENCE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Presence &amp; motion</title><desc id="desc">Explore changes in room occupancy. Inspect motion features from CSI. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 01</text><text x="30" y="94" font-size="29" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Presence &amp; motion</text><text x="30" y="136" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Explore changes in room occupancy.</text><text x="30" y="164" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Inspect motion features from CSI.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><circle cx="0" cy="-28" r="8" stroke="#b8f36c" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-14" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="0" cy="12" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="-24" cy="-6" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="24" cy="-6" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><ellipse cx="0" cy="14" rx="40" ry="13" fill="none" stroke="#438fff" stroke-dasharray="3 8" class="flow"/></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">SENSING GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Presence &amp; motion</title><desc id="desc">Explore changes in room occupancy. Inspect motion features from CSI. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">RUVIEW / 01</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Presence &amp; motion</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Explore changes in room occupancy.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Inspect motion features from CSI.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><circle cx="0" cy="-28" r="8" stroke="#72eee0" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><ellipse cx="0" cy="14" rx="40" ry="13" fill="none" stroke="#70b6ff" stroke-dasharray="3 8" class="flow"/></g></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">SENSING GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Breathing &amp; vital signals</title><desc id="desc">Inspect phase-derived signal trends. Validate estimates on your hardware. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 02</text><text x="30" y="94" font-size="29" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Breathing &amp; vital signals</text><text x="30" y="136" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Inspect phase-derived signal trends.</text><text x="30" y="164" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >Validate estimates on your hardware.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#233f63" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">SIGNAL PIPELINE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Breathing &amp; vital signals</title><desc id="desc">Inspect phase-derived signal trends. Validate estimates on your hardware. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">RUVIEW / 02</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Breathing &amp; vital signals</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Inspect phase-derived signal trends.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" font-weight="500" >Validate estimates on your hardware.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 161) scale(0.53)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><g transform="translate(474 155) scale(0.68)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#214451" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">SIGNAL PIPELINE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Choose your next step</title><desc id="desc">Animated section heading. GUIDES / HARDWARE / INTEGRATIONS</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 0L0 -33" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="0" cy="-33" r="7" stroke="#78cfff" fill="none" class="breathe"/><path d="M0 0L-35 23" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-35" cy="23" r="7" stroke="#78cfff" fill="none" class="breathe"/><path d="M0 0L35 23" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="35" cy="23" r="7" stroke="#78cfff" fill="none" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#78cfff" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="140" y="63" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Choose your next step</text><text x="142" y="99" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">GUIDES / HARDWARE / INTEGRATIONS</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Choose your next step</title><desc id="desc">Animated section heading. GUIDES / HARDWARE / INTEGRATIONS</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 0L0 -33" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="0" cy="-33" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L-35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#72eee0" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Choose your next step</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">GUIDES / HARDWARE / INTEGRATIONS</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Intelligence at the edge</title><desc id="desc">Animated section heading. SENSORS / FIRMWARE / MODULES</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0c2242" stroke="#78cfff"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#438fff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 -16H36" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M0 -36V-25M0 25V36M-36 0H-25M25 0H36" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16 -36V-25M16 25V36M-36 16H-25M25 16H36" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="0" r="4" stroke="#78cfff" fill="#78cfff" class="breathe"/></g></g><text x="140" y="63" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Intelligence at the edge</text><text x="142" y="99" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">SENSORS / FIRMWARE / MODULES</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Intelligence at the edge</title><desc id="desc">Animated section heading. SENSORS / FIRMWARE / MODULES</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0a2630" stroke="#72eee0"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#70b6ff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 -16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M0 -36V-25M0 25V36M-36 0H-25M25 0H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16 -36V-25M16 25V36M-36 16H-25M25 16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="0" r="4" stroke="#72eee0" fill="#72eee0" class="breathe"/></g></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Intelligence at the edge</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">SENSORS / FIRMWARE / MODULES</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Evidence before accuracy claims</title><desc id="desc">Animated section heading. BENCHMARKS / REPRODUCERS / LIMITS</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-15 1L-5 11L17 -12" fill="none" stroke="#438fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/></g></g><text x="140" y="63" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Evidence before accuracy claims</text><text x="142" y="99" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">BENCHMARKS / REPRODUCERS / LIMITS</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Evidence before accuracy claims</title><desc id="desc">Animated section heading. BENCHMARKS / REPRODUCERS / LIMITS</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-15 1L-5 11L17 -12" fill="none" stroke="#70b6ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/></g></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Evidence before accuracy claims</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">BENCHMARKS / REPRODUCERS / LIMITS</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Explore the capabilities</title><desc id="desc">Animated section heading. SENSING / MODELS / AUTOMATION</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><circle cx="0" cy="-28" r="8" stroke="#b8f36c" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-14" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="0" cy="12" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="-24" cy="-6" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="24" cy="-6" r="2.3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><ellipse cx="0" cy="14" rx="40" ry="13" fill="none" stroke="#438fff" stroke-dasharray="3 8" class="flow"/></g></g><text x="140" y="63" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Explore the capabilities</text><text x="142" y="99" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">SENSING / MODELS / AUTOMATION</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Explore the capabilities</title><desc id="desc">Animated section heading. SENSING / MODELS / AUTOMATION</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><circle cx="0" cy="-28" r="8" stroke="#72eee0" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><ellipse cx="0" cy="14" rx="40" ry="13" fill="none" stroke="#70b6ff" stroke-dasharray="3 8" class="flow"/></g></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Explore the capabilities</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">SENSING / MODELS / AUTOMATION</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">From recordings to models</title><desc id="desc">Animated section heading. CAPTURE / TRAIN / EVALUATE</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#233f63" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="140" y="63" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >From recordings to models</text><text x="142" y="99" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">CAPTURE / TRAIN / EVALUATE</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">From recordings to models</title><desc id="desc">Animated section heading. CAPTURE / TRAIN / EVALUATE</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#214451" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >From recordings to models</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">CAPTURE / TRAIN / EVALUATE</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="450" viewBox="0 0 1200 450" role="img" aria-labelledby="title desc"><title id="title">RuView: spaces become signals</title><desc id="desc">Animated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="449" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="48" y="44" font-size="13" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / COGNITUM ECOSYSTEM</text><text x="48" y="121" font-size="76" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >RuView</text><text x="48" y="184" font-size="38" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Spaces become signals.</text><text x="48" y="234" font-size="32" fill="#78cfff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Signals become understanding.</text><text x="50" y="279" font-size="18" fill="#b0c2db" text-anchor="start" font-weight="500" >Explore RF sensing, edge inference and local automation.</text><rect x="48" y="326" width="270" height="52" rx="12" fill="url(#accent)"/><text x="72" y="359" font-size="20" fill="#071124" text-anchor="start" font-weight="500" >Explore the guide</text><path d="M272 352H292M285 345L292 352L285 359" fill="none" stroke="#071124" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><text x="49" y="419" font-size="11" fill="#b0c2db" text-anchor="start" font-weight="500" class="mono">RF SENSING / EDGE MODELS / LOCAL ACTION</text><circle cx="950" cy="235" r="235" fill="url(#halo)"/><path d="M750 282L610 443" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M790 282L690 443" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M830 282L770 443" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M870 282L850 443" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M910 282L930 443" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M950 282L1010 443" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M990 282L1090 443" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M1030 282L1170 443" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M1070 282L1250 443" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 306H1168" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 336H1168" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 372H1168" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 418H1168" fill="none" stroke="#162f50" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M803 157L1008 106L1137 177L930 236ZM803 157V314L930 391L1137 324V177M930 236V391M1008 106V268L803 314M1008 268L1137 324" fill="none" stroke="#5490c5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g><g transform="translate(0 80)"><g class="scan"><path d="M803 157L1008 106L1137 177L930 236Z" fill="#78cfff" fill-opacity=".1" stroke="#78cfff" stroke-opacity=".6"/></g></g><g transform="translate(974 282)"><g class="orbit"><ellipse rx="135" ry="36" fill="none" stroke="#438fff" stroke-dasharray="3 11" opacity=".4"/><circle cx="135" cy="0" r="4" fill="#9ee7ff"/></g></g><g transform="translate(974 312)"><ellipse rx="140" ry="44" fill="none" stroke="#438fff" class="wave" style="animation-delay:-0.0s"/></g><g transform="translate(974 312)"><ellipse rx="140" ry="44" fill="none" stroke="#438fff" class="wave" style="animation-delay:-1.3s"/></g><g transform="translate(974 312)"><ellipse rx="140" ry="44" fill="none" stroke="#438fff" class="wave" style="animation-delay:-2.6s"/></g><circle cx="849" cy="198" r="1.7" stroke="#438fff" fill="#438fff" class="spark" style="animation-delay:-0.0s"/><circle cx="893" cy="198" r="1.7" stroke="#438fff" fill="#438fff" class="spark" style="animation-delay:-0.5s"/><circle cx="937" cy="198" r="1.7" stroke="#438fff" fill="#438fff" class="spark" style="animation-delay:-1.0s"/><circle cx="981" cy="198" r="1.7" stroke="#438fff" fill="#438fff" class="spark" style="animation-delay:-1.5s"/><circle cx="1025" cy="198" r="1.7" stroke="#438fff" fill="#438fff" class="spark" style="animation-delay:-2.0s"/><circle cx="1069" cy="198" r="1.7" stroke="#438fff" fill="#438fff" class="spark" style="animation-delay:-2.5s"/><circle cx="849" cy="233" r="1.7" stroke="#438fff" fill="#438fff" class="spark" style="animation-delay:-3.0s"/><circle cx="893" cy="233" r="1.7" stroke="#438fff" fill="#438fff" class="spark" style="animation-delay:-0.0s"/><circle cx="937" cy="233" r="1.7" stroke="#438fff" fill="#438fff" class="spark" style="animation-delay:-0.5s"/><circle cx="981" cy="233" r="1.7" stroke="#43Line truncated

After

Width:  |  Height:  |  Size: 12 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="450" viewBox="0 0 1200 450" role="img" aria-labelledby="title desc"><title id="title">RuView: spaces become signals</title><desc id="desc">Animated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="449" rx="18" fill="url(#wash)" stroke="#245360"/><text x="48" y="44" font-size="13" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">RUVIEW / COGNITUM ECOSYSTEM</text><text x="48" y="121" font-size="76" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >RuView</text><text x="48" y="184" font-size="38" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Spaces become signals.</text><text x="48" y="234" font-size="32" fill="#72eee0" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Signals become understanding.</text><text x="50" y="279" font-size="18" fill="#9abac5" text-anchor="start" font-weight="500" >Explore RF sensing, edge inference and local automation.</text><rect x="48" y="326" width="270" height="52" rx="12" fill="url(#accent)"/><text x="72" y="359" font-size="20" fill="#041117" text-anchor="start" font-weight="500" >Explore the guide</text><path d="M272 352H292M285 345L292 352L285 359" fill="none" stroke="#041117" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><text x="49" y="419" font-size="11" fill="#9abac5" text-anchor="start" font-weight="500" class="mono">RF SENSING / EDGE MODELS / LOCAL ACTION</text><circle cx="950" cy="235" r="235" fill="url(#halo)"/><path d="M750 282L610 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M790 282L690 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M830 282L770 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M870 282L850 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M910 282L930 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M950 282L1010 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M990 282L1090 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M1030 282L1170 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M1070 282L1250 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 306H1168" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 336H1168" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 372H1168" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 418H1168" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M803 157L1008 106L1137 177L930 236ZM803 157V314L930 391L1137 324V177M930 236V391M1008 106V268L803 314M1008 268L1137 324" fill="none" stroke="#4b9ba4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g><g transform="translate(0 80)"><g class="scan"><path d="M803 157L1008 106L1137 177L930 236Z" fill="#72eee0" fill-opacity=".1" stroke="#72eee0" stroke-opacity=".6"/></g></g><g transform="translate(974 282)"><g class="orbit"><ellipse rx="135" ry="36" fill="none" stroke="#70b6ff" stroke-dasharray="3 11" opacity=".4"/><circle cx="135" cy="0" r="4" fill="#91f6df"/></g></g><circle cx="849" cy="198" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-0.0s"/><circle cx="893" cy="198" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-0.5s"/><circle cx="937" cy="198" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-1.0s"/><circle cx="981" cy="198" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-1.5s"/><circle cx="1025" cy="198" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-2.0s"/><circle cx="1069" cy="198" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-2.5s"/><circle cx="849" cy="233" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-3.0s"/><circle cx="893" cy="233" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-0.0s"/><circle cx="937" cy="233" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-0.5s"/><circle cx="981" cy="233" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-1.0s"/><circle cx="1025" cy="233" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-1.5s"/><circle cx="1069" cy="233" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-2.0s"/><circle cx="849" cy="268" r="1.7" stroke="#70b6ff" fill="#70b6ff" class="spark" style="animation-delay:-2.5s"/><circlLine truncated

After

Width:  |  Height:  |  Size: 11 KiB

+147
View File
@@ -0,0 +1,147 @@
{
"schema_version": 1,
"style": "RuView navy, electric blue, lime and amber",
"motion": "Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies",
"assets": [
{
"file": "assets/readme/hero-spatial-v3.svg",
"title": "RuView: spaces become signals",
"target": "docs/user-guide.md",
"width": 1200,
"height": 450
},
{
"file": "assets/readme/nav-start-spatial-v3.svg",
"title": "Start here",
"target": "docs/user-guide.md",
"width": 180,
"height": 116
},
{
"file": "assets/readme/nav-hardware-spatial-v3.svg",
"title": "Hardware",
"target": "firmware/esp32-csi-node/README.md",
"width": 180,
"height": 116
},
{
"file": "assets/readme/nav-models-spatial-v3.svg",
"title": "Models",
"target": "https://huggingface.co/ruvnet/wifi-densepose-pretrained",
"width": 180,
"height": 116
},
{
"file": "assets/readme/nav-agents-spatial-v3.svg",
"title": "AI toolkit",
"target": "harness/ruview/README.md",
"width": 180,
"height": 116
},
{
"file": "assets/readme/nav-home-spatial-v3.svg",
"title": "Smart home",
"target": "docs/integrations/home-assistant.md",
"width": 180,
"height": 116
},
{
"file": "assets/readme/nav-seed-spatial-v3.svg",
"title": "Cognitum",
"target": "https://cognitum.one/seed",
"width": 180,
"height": 116
},
{
"file": "assets/readme/feature-presence-spatial-v3.svg",
"title": "Presence & motion",
"target": "docs/user-guide.md",
"width": 580,
"height": 276
},
{
"file": "assets/readme/feature-vitals-spatial-v3.svg",
"title": "Breathing & vital signals",
"target": "v2/crates/wifi-densepose-signal/README.md",
"width": 580,
"height": 276
},
{
"file": "assets/readme/feature-pose-spatial-v3.svg",
"title": "Pose research",
"target": "docs/benchmarks/pose-estimation-cog.md",
"width": 580,
"height": 276
},
{
"file": "assets/readme/feature-edge-spatial-v3.svg",
"title": "Edge sensor mesh",
"target": "firmware/esp32-csi-node/README.md",
"width": 580,
"height": 276
},
{
"file": "assets/readme/feature-automation-spatial-v3.svg",
"title": "Local automation",
"target": "docs/integrations/home-assistant.md",
"width": 580,
"height": 276
},
{
"file": "assets/readme/feature-agents-spatial-v3.svg",
"title": "A guided AI operator",
"target": "harness/ruview/README.md",
"width": 580,
"height": 276
},
{
"file": "assets/readme/overview-spatial-v3.svg",
"title": "RuView sensing overview",
"target": "docs/user-guide.md",
"width": 1200,
"height": 396
},
{
"file": "assets/readme/header-features-spatial-v3.svg",
"title": "Explore the capabilities",
"target": "docs/user-guide.md",
"width": 1200,
"height": 148
},
{
"file": "assets/readme/header-edge-spatial-v3.svg",
"title": "Intelligence at the edge",
"target": "firmware/esp32-csi-node/README.md",
"width": 1200,
"height": 148
},
{
"file": "assets/readme/header-learning-spatial-v3.svg",
"title": "From recordings to models",
"target": "docs/user-guide.md",
"width": 1200,
"height": 148
},
{
"file": "assets/readme/header-evidence-spatial-v3.svg",
"title": "Evidence before accuracy claims",
"target": "docs/benchmarks/pose-estimation-cog.md",
"width": 1200,
"height": 148
},
{
"file": "assets/readme/header-docs-spatial-v3.svg",
"title": "Choose your next step",
"target": "docs/user-guide.md",
"width": 1200,
"height": 148
},
{
"file": "assets/readme/cognitum-spatial-v3.svg",
"title": "Explore Cognitum One",
"target": "https://cognitum.one",
"width": 1200,
"height": 304
}
]
}
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">AI toolkit</title><desc id="desc">Open AI toolkit. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 0L0 -33" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="0" cy="-33" r="7" stroke="#78cfff" fill="none" class="breathe"/><path d="M0 0L-35 23" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-35" cy="23" r="7" stroke="#78cfff" fill="none" class="breathe"/><path d="M0 0L35 23" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="35" cy="23" r="7" stroke="#78cfff" fill="none" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#78cfff" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >AI toolkit</text></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">AI toolkit</title><desc id="desc">Open AI toolkit. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 0L0 -33" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="0" cy="-33" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L-35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#72eee0" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" font-weight="500" >AI toolkit</text></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Hardware</title><desc id="desc">Open Hardware. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0c2242" stroke="#78cfff"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#438fff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 -16H36" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M0 -36V-25M0 25V36M-36 0H-25M25 0H36" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16 -36V-25M16 25V36M-36 16H-25M25 16H36" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="0" r="4" stroke="#78cfff" fill="#78cfff" class="breathe"/></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Hardware</text></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Hardware</title><desc id="desc">Open Hardware. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0a2630" stroke="#72eee0"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#70b6ff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 -16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M0 -36V-25M0 25V36M-36 0H-25M25 0H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16 -36V-25M16 25V36M-36 16H-25M25 16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="0" r="4" stroke="#72eee0" fill="#72eee0" class="breathe"/></g></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" font-weight="500" >Hardware</text></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Smart home</title><desc id="desc">Open Smart home. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-9" r="5" stroke="#438fff" fill="none" class="breathe"/></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Smart home</text></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Smart home</title><desc id="desc">Open Smart home. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-9" r="5" stroke="#70b6ff" fill="none" class="breathe"/></g></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" font-weight="500" >Smart home</text></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Models</title><desc id="desc">Open Models. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#233f63" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Models</text></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Models</title><desc id="desc">Open Models. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#214451" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" font-weight="500" >Models</text></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Cognitum</title><desc id="desc">Open Cognitum. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-15 1L-5 11L17 -12" fill="none" stroke="#438fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Cognitum</text></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Cognitum</title><desc id="desc">Open Cognitum. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-15 1L-5 11L17 -12" fill="none" stroke="#70b6ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/></g></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" font-weight="500" >Cognitum</text></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Start here</title><desc id="desc">Open Start here. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-10.4 -5.2Q0 -18.2 10.4 -5.2" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-0.65s"/><path d="M-20.0 -10.0Q0 -35.0 20.0 -10.0" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.25s"/><path d="M-30.400000000000002 -15.200000000000001Q0 -53.199999999999996 30.400000000000002 -15.200000000000001" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.9s"/><circle cx="0" cy="20" r="4" stroke="#78cfff" fill="#78cfff"/><path d="M0 12V-1" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Start here</text></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Start here</title><desc id="desc">Open Start here. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-10.4 -5.2Q0 -18.2 10.4 -5.2" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-0.65s"/><path d="M-20.0 -10.0Q0 -35.0 20.0 -10.0" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.25s"/><path d="M-30.400000000000002 -15.200000000000001Q0 -53.199999999999996 30.400000000000002 -15.200000000000001" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.9s"/><circle cx="0" cy="20" r="4" stroke="#72eee0" fill="#72eee0"/><path d="M0 12V-1" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" font-weight="500" >Start here</text></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="396" viewBox="0 0 1200 396" role="img" aria-labelledby="title desc"><title id="title">RuView sensing overview</title><desc id="desc">Conceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="395" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="36" y="44" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">SIGNAL TO CONTEXT</text><text x="36" y="89" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >A sensing loop you can inspect.</text><rect x="36" y="120" width="336" height="220" rx="14" fill="#091b35" stroke="#315685"/><text x="60" y="153" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" >01</text><text x="60" y="188" font-size="26" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Capture RF</text><text x="60" y="216" font-size="15" fill="#b0c2db" text-anchor="start" font-weight="500" >CSI from supported hardware</text><g transform="translate(305 278) scale(0.35)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(305 270) scale(0.5)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-10.4 -5.2Q0 -18.2 10.4 -5.2" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-0.65s"/><path d="M-20.0 -10.0Q0 -35.0 20.0 -10.0" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.25s"/><path d="M-30.400000000000002 -15.200000000000001Q0 -53.199999999999996 30.400000000000002 -15.200000000000001" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.9s"/><circle cx="0" cy="20" r="4" stroke="#78cfff" fill="#78cfff"/><path d="M0 12V-1" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="60" y="311" font-size="13" fill="#b0c2db" text-anchor="start" font-weight="500" >Provision and calibrate</text><path d="M380 229H420M412 221L420 229L412 237" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flow"/><rect x="432" y="120" width="336" height="220" rx="14" fill="#091b35" stroke="#315685"/><text x="456" y="153" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" >02</text><text x="456" y="188" font-size="26" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Extract context</text><text x="456" y="216" font-size="15" fill="#b0c2db" text-anchor="start" font-weight="500" >Signal processing and models</text><g transform="translate(701 278) scale(0.35)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(701 270) scale(0.5)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#142c50" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#2e527d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g Line truncated

After

Width:  |  Height:  |  Size: 9.7 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="396" viewBox="0 0 1200 396" role="img" aria-labelledby="title desc"><title id="title">RuView sensing overview</title><desc id="desc">Conceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
@keyframes orbit{to{transform:rotate(360deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="395" rx="18" fill="url(#wash)" stroke="#245360"/><text x="36" y="44" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" class="mono">SIGNAL TO CONTEXT</text><text x="36" y="89" font-size="34" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >A sensing loop you can inspect.</text><rect x="36" y="120" width="336" height="220" rx="14" fill="#071b23" stroke="#285360"/><text x="60" y="153" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" >01</text><text x="60" y="188" font-size="26" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Capture RF</text><text x="60" y="216" font-size="15" fill="#9abac5" text-anchor="start" font-weight="500" >CSI from supported hardware</text><g transform="translate(305 278) scale(0.35)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><g transform="translate(305 270) scale(0.5)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-10.4 -5.2Q0 -18.2 10.4 -5.2" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-0.65s"/><path d="M-20.0 -10.0Q0 -35.0 20.0 -10.0" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.25s"/><path d="M-30.400000000000002 -15.200000000000001Q0 -53.199999999999996 30.400000000000002 -15.200000000000001" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.9s"/><circle cx="0" cy="20" r="4" stroke="#72eee0" fill="#72eee0"/><path d="M0 12V-1" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="60" y="311" font-size="13" fill="#9abac5" text-anchor="start" font-weight="500" >Provision and calibrate</text><path d="M380 229H420M412 221L420 229L412 237" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flow"/><rect x="432" y="120" width="336" height="220" rx="14" fill="#071b23" stroke="#285360"/><text x="456" y="153" font-size="12" fill="#72eee0" text-anchor="start" font-weight="500" >02</text><text x="456" y="188" font-size="26" fill="#ecfafb" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Extract context</text><text x="456" y="216" font-size="15" fill="#9abac5" text-anchor="start" font-weight="500" >Signal processing and models</text><g transform="translate(701 278) scale(0.35)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0a2733" stroke="#286171"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#366d80"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#5fe3d8" fill-opacity=".12" stroke="#72eee0" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#70b6ff" fill="#70b6ff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#70b6ff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#91f6df"/></g></g><g transform="translate(701 270) scale(0.5)"><ellipse cx="0" cy="48" rx="43" ry="12" fill="#133a45" opacity=".5"/><path d="M-43 48L0 27L43 48L0 69Z" fill="none" stroke="#285c68" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><g class="float"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="roundLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+87
View File
@@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 960 540" role="img" aria-labelledby="title desc">
<title id="title">RuView · WiFi becomes spatial awareness.</title><desc id="desc">RuView-focused animation showing WiFi sensors, spatial perception, Ruflo coordination and an illustrative ruOS sensing workspace. Room occupancy and actions are synthetic examples, not live measurements.</desc>
<defs><radialGradient id="bg"><stop stop-color="#151518" /><stop offset="1" stop-color="#07080b" /></radialGradient><radialGradient id="aura"><stop stop-color="#edc65a" stop-opacity=".23" /><stop offset=".4" stop-color="#ba8823" stop-opacity=".07" /><stop offset="1" stop-color="#ba8823" stop-opacity="0" /></radialGradient><linearGradient id="gold" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fff4b1" /><stop offset=".5" stop-color="#ebc456" /><stop offset="1" stop-color="#96702f" /></linearGradient><linearGradient id="panel" x2="0" y2="1"><stop stop-color="#191a20" /><stop offset="1" stop-color="#0d0e13" /></linearGradient><pattern id="grid" width="36" height="36" patternUnits="userSpaceOnUse"><circle cx="18" cy="18" r=".65" fill="#d4c78e" opacity=".22" /></pattern><filter id="bloom" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="4" /></filter><clipPath id="frame"><rect width="960" height="540" rx="18" /></clipPath><clipPath id="line1"><rect x="64" y="155" width="850" height="93" /></clipPath><clipPath id="line2"><rect x="64" y="244" width="850" height="100" /></clipPath><g id="crown" fill="url(#gold)"><path d="M0 5l8 7L15 0l7 12 8-7-4 23H4z" /><rect x="4" y="31" width="22" height="3" rx="1" /></g>
<style>
text{font-family:Arial,Helvetica,sans-serif;fill:#f4f2ed}.mono{font-family:monospace}.muted{fill:#888993}.gold{fill:#e8c565}.scene{opacity:0}.end{opacity:1}.wire{fill:none;stroke:#c2a155;stroke-width:1}.all{animation-duration:18s;animation-iteration-count:infinite;animation-timing-function:cubic-bezier(.22,.65,.3,1)}
@keyframes intro{0%,17%{opacity:1;transform:translateY(0)}22%,94%{opacity:0;transform:translateY(-12px)}100%{opacity:1;transform:translateY(0)}}
@keyframes network{0%,20%{opacity:0;transform:translateY(18px) scale(.98)}25%,44%{opacity:1;transform:translateY(0) scale(1)}49%,100%{opacity:0;transform:translateY(-15px) scale(1.02)}}
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}51%,66%{opacity:1;transform:translateY(0)}71%,100%{opacity:0;transform:translateY(-14px)}}
@keyframes finale{0%,69%{opacity:0}74%,92%{opacity:1}97%,100%{opacity:0}}
.intro{animation-name:intro}.network{animation-name:network;transform-origin:480px 270px}.work{animation-name:work}.end{animation-name:finale}
@keyframes reveal1{0%,69%{transform:translateY(100px)}77%,100%{transform:translateY(0)}}@keyframes reveal2{0%,73%{transform:translateY(110px)}81%,100%{transform:translateY(0)}}.headline1{animation-name:reveal1}.headline2{animation-name:reveal2}
@keyframes breathe{50%{opacity:.6;transform:scale(1.12)}}.breathe{animation:breathe 4.5s ease-in-out infinite;transform-origin:480px 239px}
@keyframes spin{to{transform:rotate(360deg)}}.orbit{animation:spin 18s linear infinite;transform-origin:480px 239px}
@keyframes send{0%,9%{transform:translate(0,0)}14%,100%{transform:translate(-14px,-19px)}}.cursor{animation-name:send}
@keyframes type{0%,2%{clip-path:inset(0 100% 0 0)}12%,100%{clip-path:inset(0 0 0 0)}}.typing{animation-name:type;animation-timing-function:steps(26,end)}
@keyframes task{0%,49%{transform:translateY(25px);opacity:0}55%,100%{transform:translateY(0);opacity:1}}.task{animation-name:task}.task2{animation-delay:.3s}.task3{animation-delay:.6s}
@keyframes grow{0%,54%{transform:scaleX(0)}63%,100%{transform:scaleX(1)}}.grow{animation-name:grow;transform-box:fill-box;transform-origin:left}
@keyframes tick{0%,62%{opacity:0}65%,100%{opacity:1}}.tick{animation-name:tick}
@keyframes trace{0%,22%{stroke-dashoffset:1}38%,100%{stroke-dashoffset:0}}.trace{stroke-dasharray:1;animation-name:trace}
@keyframes drift{50%{transform:translateY(-10px);opacity:.8}}.dust{animation:drift 9s ease-in-out infinite}
@media(prefers-reduced-motion:reduce){*{animation:none!important}.scene{opacity:0}.end{opacity:1}.moving{display:none}}
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(215px,155px)}55%{transform:translate(479px,155px)}61%{transform:translate(743px,155px)}72%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
.signal{animation-name:signal}.signal-glow{animation:shimmer 3s ease-in-out infinite}@keyframes shimmer{50%{opacity:.45}}
@keyframes haloExpand{0%,12%{opacity:0;transform:scale(.5)}16%{opacity:.7}23%,100%{opacity:0;transform:scale(2.5)}}.clickring{animation-name:haloExpand;transform-origin:708px 301px}
@keyframes aurora{0%,100%{transform:translate(-30px,10px) rotate(-4deg)}50%{transform:translate(30px,-10px) rotate(4deg)}}.aurora{animation:aurora 18s ease-in-out infinite;transform-origin:480px 270px}
@keyframes dash{to{stroke-dashoffset:-80}}.stream{stroke-dasharray:2 18;animation:dash 3s linear infinite}
@keyframes count{0%,52%{opacity:0;transform:translateY(8px)}58%,100%{opacity:1;transform:translateY(0)}}.detail{animation-name:count}
@keyframes buildLine{0%,51%{stroke-dashoffset:1}64%,100%{stroke-dashoffset:0}}.buildLine{stroke-dasharray:1;animation-name:buildLine}
@keyframes underline{0%,78%{transform:scaleX(0)}85%,100%{transform:scaleX(1)}}.underline{animation-name:underline;transform-origin:66px 350px}
@keyframes dock{0%,49%{opacity:0;transform:translateY(14px)}55%,100%{opacity:1;transform:translateY(0)}}.dock{animation-name:dock}
@media(prefers-reduced-motion:reduce){.signal,.clickring{display:none}}
@keyframes vortexCW{to{transform:rotate(360deg)}}@keyframes vortexCCW{to{transform:rotate(-360deg)}}
.vortex{transform-origin:480px 270px;animation:vortexCW 36s linear infinite}.counter{transform-origin:480px 270px;animation:vortexCCW 54s linear infinite}
@keyframes current{to{stroke-dashoffset:-1200}}.current{animation:current 9s linear infinite}
@keyframes bgEnergy{0%,12%,100%{opacity:.22}22%,40%{opacity:.6}52%,65%{opacity:.4}74%,84%{opacity:.7}94%{opacity:.28}}.bgEnergy{animation:bgEnergy 18s ease-in-out infinite}
@keyframes wave{0%{opacity:0;transform:scale(.3)}20%{opacity:.35}80%{opacity:.07}100%{opacity:0;transform:scale(1.9)}}.wave{transform-origin:480px 270px;animation:wave 9s cubic-bezier(.15,.35,.5,1) infinite}
@keyframes glimmer{0%,100%{opacity:.08}50%{opacity:.72}}.glimmer{animation:glimmer 6s ease-in-out infinite}
@keyframes comet{0%{opacity:0;transform:translate(-160px,70px)}12%{opacity:.65}58%{opacity:.25}75%,100%{opacity:0;transform:translate(1060px,-300px)}}.comet{animation:comet 9s linear infinite}
@keyframes beam{0%,100%{transform:translate(-150px,0) rotate(-12deg);opacity:0}35%{opacity:.18}65%{opacity:.07;transform:translate(1050px,0) rotate(-12deg)}80%{opacity:0}}.beam{animation:beam 18s ease-in-out infinite;transform-origin:center}
@media(prefers-reduced-motion:reduce){.dynamic-bg{display:none}}
.all{animation-duration:24s}
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}50%,72%{opacity:1;transform:translateY(0)}76%,100%{opacity:0;transform:translateY(-14px)}}
@keyframes finale{0%,75%{opacity:0}79%,93%{opacity:1}97%,100%{opacity:0}}
@keyframes reveal1{0%,75%{transform:translateY(100px)}82%,100%{transform:translateY(0)}}
@keyframes reveal2{0%,78%{transform:translateY(110px)}85%,100%{transform:translateY(0)}}
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(803px,182px)}55%{transform:translate(803px,235px)}63%,72%{transform:translate(803px,288px)}80%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
@keyframes browserOpen{0%,48%{opacity:0;transform:translateY(20px) scale(.96)}53%,100%{opacity:1;transform:translateY(0) scale(1)}}.browserOpen{animation-name:browserOpen;transform-origin:380px 230px}
@keyframes terminalOpen{0%,53%{opacity:0;transform:translateY(14px)}58%,100%{opacity:1;transform:translateY(0)}}.terminalOpen{animation-name:terminalOpen}
@keyframes terminalText{0%,57%{opacity:0}60%,100%{opacity:1}}.terminalText{animation-name:terminalText}
@keyframes done{0%,65%{opacity:0}69%,100%{opacity:1}}.done{animation-name:done}
@keyframes pointerMove{0%,50%{transform:translate(350px,275px)}55%{transform:translate(529px,267px)}59%{transform:translate(570px,325px)}66%,100%{transform:translate(738px,346px)}}.desktopPointer{animation-name:pointerMove}
@keyframes chartGrow{0%,53%{transform:scaleY(.05)}63%,100%{transform:scaleY(1)}}.chartGrow{animation-name:chartGrow;transform-box:fill-box;transform-origin:bottom}
</style><radialGradient id="readability"><stop stop-color="#08090e" stop-opacity=".58" /><stop offset=".65" stop-color="#08090e" stop-opacity=".38" /><stop offset="1" stop-color="#08090e" stop-opacity="0" /></radialGradient><linearGradient id="beamGold"><stop stop-color="#e7bf56" stop-opacity="0" /><stop offset=".5" stop-color="#efd57f" stop-opacity=".45" /><stop offset="1" stop-color="#e7bf56" stop-opacity="0" /></linearGradient></defs><g clip-path="url(#frame)"><rect width="960" height="540" fill="url(#bg)" /><rect width="960" height="540" fill="url(#grid)" opacity=".35" /><ellipse cx="490" cy="250" rx="460" ry="320" fill="url(#aura)" />
<g><g class="aurora" fill="none" stroke="#c6a457" opacity=".11"><ellipse cx="480" cy="260" rx="470" ry="156" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="480" ry="166" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="495" ry="180" transform="rotate(-18 480 260)" /></g></g><g><g class="dynamic-bg"><g class="bgEnergy"><rect class="beam" x="0" y="-120" width="160" height="850" fill="url(#beamGold)" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-0s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-3s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-6s" /><g class="vortex" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><g class="counter" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><circle cx="10.1" cy="303.9" r="1.3" fill="#ffe4a0Line truncated
<g class="scene intro all"><circle cx="480" cy="205" r="125" fill="url(#aura)" /><circle cx="480" cy="205" r="28" stroke="#f3d576" stroke-width="1" fill="none" /><text x="480" y="126" text-anchor="middle" font-size="32" letter-spacing="-1">Your room is already sending signals.</text>
<rect x="215" y="269" width="530" height="64" rx="12" fill="url(#panel)" stroke="#554b31" /><text x="239" y="307" font-size="17" class="mono typing all">Sense. Understand. Act.</text><rect x="689" y="282" width="38" height="38" rx="9" fill="url(#gold)" /><path d="M708 307v-13m-5 5 5-5 5 5" fill="none" stroke="#222018" stroke-width="2" stroke-linecap="round" />
<path class="cursor all" d="M731 323l0 27 7-8 8 1z" fill="#faf8e7" stroke="#151515" stroke-width="1.4" />
<text x="480" y="382" text-anchor="middle" font-size="11" letter-spacing="3" class="mono muted">WIFI SENSING. NO CAMERAS.</text><g><circle class="clickring all" cx="708" cy="301" r="25" fill="none" stroke="#fae29b" /></g><g><g fill="none" stroke="#e7c467" opacity=".65"><path d="M437 214Q480 161 523 214" /><path d="M420 205Q480 133 540 205" opacity=".4" /><path d="M404 196Q480 108 556 196" opacity=".2" /></g></g></g>
<g class="scene network all"><text x="480" y="107" text-anchor="middle" font-size="30" letter-spacing="-.7">From radio signals to useful actions.</text>
<g><path d="M480 239C548 323 639 357 735 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C420 328 311 358 225 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C520 143 640 148 714 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C440 148 322 143 247 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><path d="M480 239C440 148 322 143 247 193" class="wire" opacity=".2" /><path d="M480 239C440 148 322 143 247 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle></g><path d="M480 239C520 143 640 148 714 193" class="wire" opacity=".2" /><path d="M480 239C520 143 640 148 714 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle></g><path d="M480 239C420 328 311 358 225 350" class="wire" opacity=".2" /><path d="M480 239C420 328 311 358 225 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle></g><path d="M480 239C548 323 639 357 735 350" class="wire" opacity=".2" /><path d="M480 239C548 323 639 357 735 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle></g><circle cx="480" cy="239" r="98" fill="url(#aura)" class="breathe" /><circle cx="480" cy="239" r="57" fill="#111115" stroke="#6c5830" /><circle cx="480" cy="239" r="66" fill="none" stroke="#eac55e" stroke-opacity=".6" stroke-dasharray="38 170" class="orbit" /><use href="#crown" transform="translate(465 221)" /><rect x="147" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="164" cy="191" r="3" fill="#e8c667" /><text x="177" y="195" font-size="18">WiFi sensors</text><text x="177" y="212" font-size="8" class="mono muted" letter-spacing="1.8">CAPTURE</text><rect x="614" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="631" cy="191" r="3" fill="#e8c667" /><text x="644" y="195" font-size="18">RuView</text><text x="644" y="212" font-size="8" class="mono muted" letter-spacing="1.8">PERCEIVE</text><rect x="125" y="326" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="142" cy="348" r="3" fill="#e8c667" /><text x="155" y="352" font-sizeLine truncated
<g class="scene work all"><g><text x="480" y="92" text-anchor="middle" font-size="28" letter-spacing="-.6">A sensing workspace, powered by RuView.</text>
<rect x="58" y="113" width="844" height="331" rx="12" fill="#090c12" stroke="#6a5931" />
<path d="M70 144H890" stroke="#292b31" /><circle cx="76" cy="129" r="3" fill="#dcbf65" /><circle cx="87" cy="129" r="3" fill="#5b5c64" /><circle cx="98" cy="129" r="3" fill="#5b5c64" /><text x="480" y="133" text-anchor="middle" class="mono muted" font-size="9">ruOS / Desktops / ruview-workspace</text>
<rect x="59" y="145" width="125" height="287" fill="#0c0e13" />
<use href="#crown" transform="translate(72 158) scale(.4)" /><text x="91" y="170" font-size="15" font-weight="700">ruOS</text>
<rect x="67" y="209" width="107" height="25" rx="5" fill="#302819" stroke="#63512c" />
<rect x="76" y="194" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="201" font-size="10" class="muted">Overview</text><rect x="76" y="222" width="7" height="6" rx="1" fill="none" stroke="#e1c577" /><text x="91" y="229" font-size="10" class="gold">Desktops</text><rect x="76" y="250" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="257" font-size="10" class="muted">Terminal</text><rect x="76" y="278" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="285" font-size="10" class="muted">Swarms</text><rect x="76" y="306" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="313" font-size="10" class="muted">Network</text><rect x="76" y="334" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="341" font-size="10" class="muted">Usage</text><rect x="76" y="362" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="369" font-size="10" class="muted">Settings</text><text x="200" y="169" font-size="14" font-weight="700">RuView workspace</text><circle cx="364" cy="165" r="3" fill="#b9cb91" /><text x="374" y="169" font-size="9" class="muted">Running</text>
<text x="882" y="167" text-anchor="end" font-size="8" class="mono gold">SYNTHETIC DEMO</text>
<rect x="197" y="183" width="469" height="244" rx="7" fill="#14131a" stroke="#32313d" /><path d="M197 373Q370 180 666 285M197 330Q440 390 666 210" fill="none" stroke="#b49548" opacity=".18" />
<g class="browserOpen all"><rect x="209" y="195" width="308" height="163" rx="7" fill="#191d26" stroke="#5a5961" />
<rect x="209" y="195" width="308" height="24" rx="7" fill="#262a33" /><text x="221" y="211" font-size="8" class="mono muted">RuView / Room overview</text><circle cx="503" cy="207" r="2" fill="#e3c477" />
<text x="225" y="241" font-size="13" font-weight="700">Room activity</text><text x="225" y="257" font-size="9" class="muted">Synthetic scene · Presence and motion</text>
<rect x="224" y="329" width="83" height="16" rx="4" fill="#4a3d22" /><text x="233" y="340" font-size="8" class="gold">Sensor connected</text><g><path d="M229 270H494V318H229zM390 270v18m0 12v18" fill="#121921" stroke="#727781" stroke-width="1.5" /><path d="M235 278h34v15h-34zM464 280h22v24h-22z" fill="#303843" stroke="#707786" /><circle cx="285" cy="300" r="4" fill="#f0cd70" /><circle cx="285" cy="300" r="16" fill="none" stroke="#d8b353" opacity=".4" /><circle cx="326" cy="287" r="5" fill="#b7cbd0" /><path d="M326 293v12m-7-8h14m-7 8-5 8m5-8 5 8" fill="none" stroke="#b7cbd0" stroke-width="2" /><path d="M292 290Q310 273 326 287M294 306Q312 312 326 302" fill="none" stroke="#e1bd5f" stroke-dasharray="3 5" class="stream" /></g></g>
<g class="terminalOpen all"><rect x="366" y="287" width="284" height="117" rx="6" fill="#090c12" stroke="#777061" /><rect x="367" y="288" width="282" height="21" rx="5" fill="#25272f" /><text x="380" y="302" font-size="9" class="mono">Terminal</text>
<text x="380" y="324" font-size="9" class="mono gold">› Room event received</text><g class="terminalText all"><text x="380" y="343" font-size="9" class="mono muted">✓ Presence detected (demo)</text><text x="380" y="358" font-size="9" class="mono muted">✓ Rule checked</text></g><text x="380" y="384" font-size="10" class="mono gold done all">✓ Action ready for review</text></g>
<rect x="680" y="183" width="207" height="244" rx="7" fill="#12151d" stroke="#33333c" /><text x="695" y="204" font-size="12" font-weight="700">Ruflo agent team</text><path d="M694 216h178" stroke="#34323a" />
<circle cx="699" cy="235" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="239" font-size="11">Sense</text><text x="713" y="254" font-size="8" class="muted">Read room events from RuView</text><circle cx="699" cy="288" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="292" font-size="11">Evaluate</text><text x="713" y="307" font-size="8" class="muted">Check the configured rule</text><circle cx="699" cy="341" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="345" font-size="11">Act</text><text x="713" y="360" font-size="8" class="muted">Request an approved action</text><g class="done all"><rect x="693" y="381" width="181" height="29" rx="5" fill="#332b1b" stroke="#89713b" /><text x="784" y="400" text-anchor="middle" font-size="10" class="gold">✓ Room event processed</text></g>
<g class="desktopPointer all"><path d="M0 0v20l5-6 8 1z" fill="#fff2ce" stroke="#18181a" stroke-width="1" /></g>
<text x="480" y="469" text-anchor="middle" class="gold" font-size="14">Example: room activity → rule check → action review.</text></g></g><g class="scene end all"><ellipse cx="810" cy="290" rx="260" ry="240" fill="url(#aura)" />
<text x="66" y="133" class="mono gold" font-size="11" letter-spacing="3">RUVIEW PERCEPTION. RUFLO COORDINATION.</text>
<g clip-path="url(#line1)"><text class="headline1 all" x="61" y="233" font-size="72" font-weight="700" letter-spacing="-3">WiFi becomes</text></g>
<g clip-path="url(#line2)"><text class="headline2 all" x="61" y="322" font-size="72" font-weight="700" letter-spacing="-3">spatial awareness<tspan class="gold">.</tspan></text></g>
<path d="M66 382H894" stroke="#3b3528" /><text x="66" y="420" class="mono gold" font-size="17">ruos.cognitum.one</text><text x="892" y="420" text-anchor="end" font-size="10" class="mono muted" letter-spacing="2">BY COGNITUM</text><g><path class="underline all" d="M66 350h310" stroke="url(#gold)" stroke-width="2" /></g></g>
<path d="M40 490H920" stroke="#26242a" /><text x="40" y="513" class="mono muted" font-size="9" letter-spacing="1.5">RUVIEW + RUFLO + RUOS</text><circle cx="853" cy="509" r="2.5" fill="#dfbf67" /><text x="866" y="512" class="mono gold" font-size="9">READY</text><g><g class="signal all"><circle r="45" fill="url(#aura)" class="signal-glow" /><circle r="10" fill="#ebc65d" opacity=".5" filter="url(#bloom)" /><circle r="4.5" fill="#ffe5a1" /><circle r="8" fill="none" stroke="#e9c56c" opacity=".3" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 37 KiB

+25
View File
@@ -37,6 +37,31 @@ services:
# volumes: ["/path/to/models:/app/models"]
# MODELS_DIR=/app/models
- MODELS_DIR=${MODELS_DIR:-data/models}
# Auth (issue #864). The entrypoint refuses to start (exit 64) unless
# RUVIEW_API_TOKEN is set. Export it before `docker compose up`:
# export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
# Bare names pass the host value through only when it is set.
- RUVIEW_API_TOKEN
# ESP32 UDP ingest (ADR-296, issue #2089). The UDP receiver defaults to
# 127.0.0.1, which cannot receive from the published 5005/udp port.
# Set ONE source guard and the entrypoint binds 0.0.0.0:
# RUVIEW_UDP_ALLOW=<node-ip-or-cidr> restrict sources (preferred)
# RUVIEW_UDP_INSECURE_LAN=true accept the spoofing risk
# Docker Desktop and OrbStack on macOS rewrite the source address to the
# bridge gateway, so a LAN-subnet allowlist drops every frame. There,
# allow the gateway instead:
# docker network inspect <project>_default \
# --format '{{(index .IPAM.Config 0).Gateway}}'
# RUVIEW_UDP_ALLOW=<gateway>/32
# The gateway admits anything that reaches the published port, so keep
# 5005/udp off untrusted networks.
- RUVIEW_UDP_ALLOW
- RUVIEW_UDP_INSECURE_LAN
- RUVIEW_UDP_BIND
# Browsing the UI from another machine also needs the host name or IP
# in SENSING_ALLOWED_HOSTS (otherwise 421), and the TCP ports above
# published beyond 127.0.0.1.
- SENSING_ALLOWED_HOSTS
security_opt:
- no-new-privileges:true
cap_drop:
+56 -3
View File
@@ -22,6 +22,10 @@
# Default is `auto`. Set CSI_SOURCE=simulated when you want
# fake data tagged as such; never set it implicitly.
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
# RUVIEW_UDP_ALLOW / RUVIEW_UDP_INSECURE_LAN — ADR-296 UDP source guard.
# Setting either makes the entrypoint bind UDP on 0.0.0.0 so
# ESP32 frames on the published 5005/udp port can arrive.
# RUVIEW_UDP_BIND — explicit UDP bind; overrides the choice above.
set -e
# ── Issue #864: fail-closed on default posture ───────────────────────────────
@@ -34,8 +38,8 @@ set -e
#
# Escape hatches (operator must opt in explicitly):
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
# only safe on an isolated trust boundary.
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 (or true/yes/on, any case) →
# preserves the pre-fix behaviour; only safe on an isolated trust boundary.
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
#
@@ -45,7 +49,12 @@ set -e
case "${1:-}" in
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
*)
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
# Same spellings the sensing-server accepts for its boolean env flags.
case "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" in
1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) __allow_unauth=1 ;;
*) __allow_unauth=0 ;;
esac
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "$__allow_unauth" != "1" ]; then
# If the operator hasn't overridden the bind, refuse outright on
# the default 0.0.0.0. If they've nailed it to loopback (or a
# specific private address they trust), let it run.
@@ -97,6 +106,50 @@ esac
# server binary so users can just pass flags:
# docker run <image> --source esp32 --tick-ms 500
if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
# ── Issue #2089: UDP CSI ingest posture inside a container ───────────────
# ADR-296 defaults the UDP receiver to 127.0.0.1. Inside a container that
# bind can never receive from a published port, so no node ever appears.
# We bind 0.0.0.0 only when the operator has configured an ADR-296 guard
# (RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN); without one the server
# would refuse a routable bind, so we keep loopback and say why instead.
# An explicit RUVIEW_UDP_BIND or --udp-bind always wins.
# The binary parses RUVIEW_UDP_INSECURE_LAN itself (1/yes/on/true, any
# case); here we only need to know whether it is truthy to pick the bind.
__insecure_lan=0
case "$(printf '%s' "${RUVIEW_UDP_INSECURE_LAN:-}" | tr 'A-Z' 'a-z')" in
1|true|yes|on) __insecure_lan=1 ;;
esac
__udp_bind_arg=0
__udp_guard_arg=0
for __arg in "$@"; do
case "$__arg" in
--udp-bind|--udp-bind=*) __udp_bind_arg=1 ;;
--udp-allow|--udp-allow=*|--udp-insecure-lan) __udp_guard_arg=1 ;;
esac
done
if [ -z "${RUVIEW_UDP_BIND:-}" ] && [ "$__udp_bind_arg" = 0 ]; then
if [ -n "${RUVIEW_UDP_ALLOW:-}" ] || [ "$__insecure_lan" = 1 ] \
|| [ "$__udp_guard_arg" = 1 ]; then
export RUVIEW_UDP_BIND=0.0.0.0
echo "[entrypoint] UDP CSI receiver: binding 0.0.0.0 inside the container" >&2
echo "[entrypoint] (a source guard is set: RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN)." >&2
else
case "${CSI_SOURCE:-auto}" in
simulated|simulate) ;;
*)
echo "[entrypoint] NOTE: UDP CSI receiver stays on 127.0.0.1 (ADR-296 default)." >&2
echo "[entrypoint] Inside a container that bind cannot receive ESP32 frames from" >&2
echo "[entrypoint] a published 5005/udp port, so no node will appear. To ingest:" >&2
echo "[entrypoint] -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> (restrict sources), or" >&2
echo "[entrypoint] -e RUVIEW_UDP_INSECURE_LAN=true (accept spoofing risk)" >&2
echo "[entrypoint] Docker Desktop/OrbStack on macOS rewrite the source to the bridge" >&2
echo "[entrypoint] gateway, so the allowlist must name <gateway>/32, not the LAN subnet." >&2
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/2089" >&2
;;
esac
fi
fi
set -- /app/sensing-server \
--source "${CSI_SOURCE:-auto}" \
--tick-ms 100 \
+8 -6
View File
@@ -6,7 +6,9 @@ Known issues and fixes from the rebase-to-upstream branch (upstream #301).
## 1. Node not appearing in /api/v1/nodes
**Symptom:** ESP32-S3 node associates with WiFi, LED blinks, but no CSI frames arrive at the server. Node missing from `/api/v1/spatial/nodes`.
**Symptom:** ESP32-S3 node associates with WiFi, LED blinks, but no CSI frames arrive at the server. Node missing from `/api/v1/nodes`.
**Check the server first:** the UDP receiver listens on `127.0.0.1` by default (ADR-296), so a natively run `sensing-server` never hears nodes on your network unless it was started with `--udp-bind 0.0.0.0 --udp-allow <node-ip-or-cidr>` (or `--udp-insecure-lan`). The startup log line for the UDP receiver says `loopback-only` when this is the problem. For Docker, see "Receiving ESP32 frames in Docker" in the [user guide](user-guide.md).
**Root cause:** After USB flash, the node enters a limping state where WiFi associates but the UDP CSI sender silently fails. The SoftAP + mDNS stack initializes but the CSI callback never fires.
@@ -145,18 +147,18 @@ ESP32 nodes still target the host on `--target-ip <host>:5005` — no firmware r
**Symptom:** `sensing-server` starts cleanly, logs `HTTP server listening on http://localhost:3000`, but loading `http://localhost:3000/` (or `/ui/index.html`) returns `404 Not Found`. Reported in #188.
**Root cause:** The default `--ui-path ../../ui` is resolved relative to the binary's *current working directory*, not the binary location. When the binary is launched from anywhere other than `crates/wifi-densepose-sensing-server/`, the relative path doesn't reach the UI assets and Axum's static file handler returns 404.
**Root cause:** The default `--ui-path ../ui` is resolved relative to the *current working directory*, not the binary location. If it doesn't exist the server also tries `./ui` and `../../ui`. Launching from `v2/` works; from anywhere else (including `v2/crates/wifi-densepose-sensing-server/`) those paths miss the repo's UI, or hit some other `ui` directory, and Axum's static file handler returns 404.
**Fix:** Pass an absolute UI path, run the binary from the crate directory, or use the Docker image (which bundles the UI under `/app/ui`).
**Fix:** Pass an absolute UI path, run the binary from `v2/`, or use the Docker image (which bundles the UI under `/app/ui`).
```bash
# Option A — absolute path (recommended for production)
sensing-server --source esp32 --udp-port 5005 --http-port 3000 \
--ws-port 3001 --ui-path /absolute/path/to/ui
# Option B — run from the crate dir (works for local dev / cargo run)
cd v2/crates/wifi-densepose-sensing-server
cargo run -- --source esp32
# Option B — run from v2/ (works for local dev / cargo run)
cd v2
cargo run -p wifi-densepose-sensing-server -- --source esp32
# Option C — Docker (no path config needed)
docker compose -f docker/docker-compose.yml up sensing-server
@@ -4,6 +4,7 @@
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-03-01 |
| **Updated** | 2026-10-01: macOS 27 also redacts SSID without Location Services; the connected link is kept with a synthetic id, and `auto`+wifi keeps UDP bound (see Amendment 1) |
| **Deciders** | ruv |
| **Codename** | **ORCA** — OS-native Radio Channel Acquisition |
| **Relates to** | ADR-013 (Feature-Level Sensing Commodity Gear), ADR-022 (Windows WiFi Enhanced Fidelity), ADR-014 (SOTA Signal Processing), ADR-018 (ESP32 Dev Implementation) |
@@ -313,3 +314,22 @@ All verification on Mac Mini (M2 Pro, macOS 26.3).
- ADR-022: Windows WiFi Enhanced Fidelity (analogous platform adapter)
- ADR-013: Feature-Level Sensing from Commodity Gear
- Issue [#56](https://github.com/ruvnet/wifi-densepose/issues/56): macOS support request
---
## Amendment 1 (2026-10-01): SSID is also redacted; keep the connected link
**Observed on macOS 27 (M-series Mac, CoreWLAN, unsigned CLI helper).** Without Location Services, `mac_wifi --scan-once` returns `"ssid":""` and `"bssid":"00:00:00:00:00:00"`, while `rssi`, `noise`, `channel` and `tx_rate` are real. The §1 assumption that "SSID + RSSI + channel" survive no longer holds. An unsigned CLI helper never calls `CLLocationManager`, so it never appears in Location Services and cannot be granted access.
Before this amendment, `resolve_bssid` abstained when both identities were blank (to avoid merging unrelated networks), so every macOS scan was dropped and `--source wifi` produced nothing, with no error.
**Change.**
1. `--scan-once` marks its single sample `"connected": true`. It is always the one connected link, so it cannot collide with another network.
2. A redacted sample marked `connected` gets a stable, locally administered synthetic BSSID derived from a reserved key plus the channel. The SSID stays empty; none is invented. Unmarked redacted lines still abstain.
3. `--source auto` that resolves to host WiFi now also binds the UDP receiver, and `wifi_task` yields while the source is a live `esp32`. ESP32 nodes that start after the 2 s boot probe still promote the source, matching the #1004 rule for the simulator.
4. A missing helper logs one clear error with the build command instead of a warning on every tick.
**Measured (2026-10-01, this change):** `--source wifi` → source `wifi:unnamed`, sensing ticks advance. `--source auto` on a quiet port → `wifi` with `udp_receiver=true`; relaying real ADR-018 frames promoted the source to `esp32`, with all 5 nodes listed.
**Still open:** real BSSID/SSID would need a signed `.app` helper with `NSLocationWhenInUseUsageDescription` that requests authorization. The helper still lives under `archive/v1` with no build step.
@@ -4,6 +4,7 @@
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-03-02 |
| **Updated** | 2026-10-04 — multistatic fusion combines phase across nodes only when every frame declares the same shared phase reference; independent ESP32 nodes and the amplitude-only server bridge fuse amplitude only and report the reason (`phase_fusion` in `/health/ready`, issue #1752). |
| **Deciders** | ruv |
| **Codename** | **RuvSense** -- RuVector-Enhanced Sensing for Multistatic Fidelity |
| **Relates to** | ADR-012 (ESP32 Mesh), ADR-014 (SOTA Signal Processing), ADR-016 (RuVector Training), ADR-017 (RuVector Signal+MAT), ADR-018 (ESP32 Implementation), ADR-024 (AETHER Embeddings), ADR-026 (Survivor Track Lifecycle), ADR-027 (MERIDIAN Generalization) |
+55 -2
View File
@@ -3,7 +3,7 @@
| Field | Value |
|-------|-------|
| **Status** | **Accepted** (MQTT track P1–P7 + P8a + P9 + P10 shipped 2026-05-23 in PR #778, 410 lib tests, witness bundle VERIFIED) / **Proposed** (Matter SDK wiring P8b deferred to v0.7.1 per §9.10) |
| **Date** | 2026-05-23 |
| **Date** | 2026-05-23 (amended 2026-10-02: P4.5 status note, §11) |
| **Deciders** | ruv |
| **Codename** | **HA-DISCO** (MQTT) + **HA-FABRIC** (Matter) + **HA-MIND** (semantic primitives) |
| **Relates to** | ADR-018 (CSI binary frame format), ADR-021 (ESP32 vitals), ADR-031 (RuView sensing-first), ADR-039 (edge vitals packet 0xC511_0002), ADR-079 (camera ground-truth), ADR-103 (cog-person-count), ADR-110 (ESP32-C6 firmware), ADR-114 (cog-quantum-vitals) |
@@ -440,7 +440,7 @@ This means: **adding a new primitive is one file change**. No MQTT schema rev, n
| **P2** | HA discovery message emitter. New module `mqtt_discovery.rs`. Emits all entity `config` topics on connect + every `--mqtt-refresh-secs`. Schema-validated against HA's published JSON schema. | pending |
| **P3** | State publication. Subscribe to internal `tokio::broadcast` channel (the one `tx.send(json)` writes to on line 3983 of `main.rs`). Translate `edge_vitals` / `sensing_update` / `pose_data` messages into per-entity state payloads. Apply rate-limit + privacy-mode filters. | pending |
| **P4** | Integration tests: dockerised mosquitto in CI (extend `.github/workflows/firmware-qemu.yml` pattern), schema-validate every emitted config against HA's `homeassistant/components/mqtt` JSON schemas (pin to a tested HA version). Add a smoke test that brings up sensing-server in `--source mock --mqtt`, subscribes with `paho-mqtt` test client, asserts on entity creation. | pending |
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | pending |
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | partial — 6 of 10 primitives on the live MQTT publisher; see §11 |
| **P5** | Docs: new `docs/integrations/home-assistant.md` with screenshots of the HA UI after auto-discovery completes, example HA dashboard YAML (Lovelace card configs), 8 starter blueprints from §3.12.2 (distress notify, wake routine, hallway dim, elderly anomaly alert, meeting lights, bathroom fan, fall-risk escalate, auto-arm security), and the raw-channel example automations: "turn on hall light when presence ON", "send notification on fall_detected event", "log HR/BR to InfluxDB". | pending |
| **P6** | Ship `--mqtt` in the next sensing-server release (target: v0.7.0). Demo end-to-end on `cognitum-v0` against a Mosquitto add-on running on a Home Assistant OS install. Update README hardware-options table with "Works with Home Assistant" badge. | pending |
| **P7** | Matter Bridge spike: build a throwaway prototype with `matter-rs` exposing one `OccupancySensor` endpoint + one `GenericSwitch` for fall. Pair against Apple Home, Google Home, and HA's Matter integration. Decision gate: if pairing works on all three, proceed to P8; if blocked, switch to chip-tool FFI and re-spike. | pending |
@@ -667,4 +667,57 @@ Empty as of 2026-05-23. New questions discovered during implementation will be f
---
## 11. Status note: P4.5 semantic states on the live publisher (2026-10-02)
Issues [#2085](https://github.com/ruvnet/RuView/issues/2085) and [#2093](https://github.com/ruvnet/RuView/issues/2093) found that discovery announced 20 entities per device while only 6 ever received state, that `presence/state` was published on every CSI frame, and that HA device ids changed on every restart. The FSMs in `crate::semantic` existed but had no caller outside their tests. This note records what is now wired and what is not.
### 11.1 What changed
- **Semantic primitives run on the live path.** `mqtt::planner` holds one `SemanticBus` per node, sampled at 1 Hz (the rate the FSM constants assume). The bus feeds only the MQTT publisher. Matter does not consume it yet, so the "output broadcast channel shared with Matter" in P4.5 is not built.
- **Announce only what has a source.** Six primitives are announced: `room_active`, `no_movement`, `elderly_inactivity_anomaly`, `someone_sleeping`, `possible_distress` and `fall_risk_elevated`. Four are not: `meeting_in_progress` needs a 1–20 % motion level, but per-node motion is a three-level classification; `bathroom_occupied`, `bed_exit` and `multi_room_transition` need zones, and the broadcast carries none (the §3.12.5 zone file is not implemented). For the same reason `zone_occupancy` (§3.1) and `pose` are not announced: `pose_keypoints` is never populated on the broadcast, and `persons[].keypoints` are synthesized.
- **Per-entity availability (§3.6).** An announced entity is `online` only while its input exists: vitals while the calibration-gated estimate is published, `fall` while the node sends `edge_vitals`, `motion_energy` and `rssi` while present, and semantic primitives after the 60 s warmup (sleeping and distress also need breathing or heart rate, fall risk needs the fall detector). Otherwise it is `offline` and no state is sent. Transitions publish at once; all availability topics are re-sent every 30 s.
- **No default values.** `motion_energy` was always `0.0` (MEASURED: 176 of 176 samples) because the bridge never set it. It now carries the node's `motion_band_power`, or the room value, or nothing. `fall` is driven by the rising edge of `edge_vitals.fall_detected`. Only `sensing_update` produces snapshots, which removes the phantom aggregate device that `edge_vitals` frames created. `present_still` now maps to 0 % motion instead of 100 %.
- **Rates (§3.7).** Binary states publish on change, plus a 60 s retained heartbeat, as §3.5 already specified. Sensors keep their configured rates.
- **Stable device identity (§3.8).** The default client id is now `wifi-densepose-<8 hex>`, persisted in `<data-dir>/mqtt_client_id`. §3.8 specified `wifi-densepose-<hostname>`; a persisted random id avoids a hostname dependency and survives hostname changes. `--mqtt-client-id` still overrides it.
### 11.2 Evidence
Unit tests (CODE-DERIVED, run with `cargo test -p wifi-densepose-sensing-server --features mqtt --lib -- mqtt:: semantic::`): `mqtt::planner::tests` covers the entity → state mapping, unavailable-without-source, warmup, on-change with heartbeat at a 73 frame/s input, sensor rates, reset and privacy mode. `mqtt::bridge::tests` covers message-type filtering, fall edges and motion energy. `mqtt::config::tests` covers client-id persistence across two "restarts" in a temp dir, override and malformed-file recovery.
Live measurement (MEASURED, 2026-10-02): one ESP32 node streaming CSI and `edge_vitals` over UDP to a macOS host; `sensing-server --source esp32 --mqtt` against `eclipse-mosquitto:2` in Docker on a non-default port; every topic captured with a `paho-mqtt` subscriber on `#`. "Before" is `03a89793` (main), "after" is this change, built `--release --features mqtt`, same command and window.
```bash
# Broker: mosquitto.conf = "listener 1883 0.0.0.0 / allow_anonymous true / persistence false"
docker run -d --rm -p 127.0.0.1:18830:1883 \
-v "$PWD/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" eclipse-mosquitto:2
# Run A (180 s, then SIGTERM) and run B (restart, 45 s) share one --data-dir.
# A paho-mqtt subscriber on '#' starts 2 s before each run.
RUVIEW_API_TOKEN=<random> sensing-server --source esp32 --udp-port 5006 \
--udp-bind 0.0.0.0 --udp-allow <node-subnet> --http-port 3000 --ws-port 3001 \
--no-mdns --data-dir <dir> --mqtt --mqtt-host 127.0.0.1 --mqtt-port 18830
```
| Measure (180 s window unless noted) | Before (`03a89793`) | After |
|---|---|---|
| HA devices announced in run A | 2 (one phantom aggregate + `-node1`) | 1 (`-node1`) |
| Entities announced per device | 20 | 15 |
| Entities that received state (node device) | 6 | 10 |
| Announced entities without state that still reported `online` | 14 | 1 (`fall`: an event, and no fall occurred) |
| `presence/state` messages (node device) | 6,814 (37.9 / s) | 4 (one per change, plus 60 s heartbeat) |
| `motion_energy` values | `0.0` in 176 of 176 | node band power, 177 samples |
| Device id, run A → run B (restart, same `--data-dir`) | `wifi-densepose-<pid A>` → `<pid B>` (4 devices on the broker after B) | same persisted id in both runs (1 device) |
| `offline` published on SIGTERM | no | no (unchanged; see 11.3) |
| Same, plus `--privacy-mode` (75 s, fresh `--data-dir`) | — | 13 entities announced; no `heart_rate`, `breathing_rate` or `pose` topic for that device |
After the change, five announced entities had no state: `heart_rate` and `breathing_rate`, because no calibration had been run; `someone_sleeping` and `possible_distress`, which depend on them; and `fall`, which is an event and no fall occurred. The first four were `offline`. `fall` was `online`, because the node sent `edge_vitals`. Before the change all 14 entities without state still reported `online`. Only one node was streaming during this measurement. #2085 used five nodes and measured about 73 `presence` messages per second per node.
### 11.3 Not done
- Semantic precision/recall on real captures. The figures in `semantic-primitives-metrics.md` have no reproducer in the repository and stay CLAIMED.
- Zone and threshold files, `--semantic*` / `--no-semantic` flags on `sensing-server`, the `reason` attribute (§3.12.4), and `semantic_events.jsonl`.
- MQTT last-will and offline-on-shutdown (§3.6). The server registers no LWT and SIGTERM does not publish `offline`, so availability stays as last published after the server stops.
- Matter consumption of the semantic states.
---
*ADR-115 is the integration story that turns RuView from "another sensing platform" into "drop-in upgrade for any HA install **and** any Matter-controller home." MQTT carries the rich, differentiated telemetry; Matter carries the standardised subset across every controller ecosystem. Numbers 111 and 112 remain reserved per the project ADR-numbering policy.*
@@ -66,10 +66,64 @@ hardware claims.
timestamps, sequence behavior, calibration, maximum size, and report rate.
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
## Amendment 1 — vendor CSI on MT7981, still unvalidated
- **Date**: 2026-09-19, recorded here 2026-10-01
- **Status**: proposed. One board model (two units). The hardware gates below are still open.
The 2026-07-18 decision said the public `mt76` tree has no supported CSI export,
and that a firmware change might be required before physical CSI exists. Both
statements need a narrower reading.
Mainline `openwrt/mt76` still has no CSI export. MediaTek's public OpenWrt feed
does: vendor id `0x0ce7`, MCU event `0xc2`, patch
`1001-wifi-mt76-mt7915-csi-implement-csi-support`. On a Wavlink WL-WN586X3
Rev A (MT7981BA + MT7976CN + MT7531AE) that patch, rebased onto the `mt76`
revision pinned by OpenWrt 24.10.8, produced complex samples without a firmware
blob swap. The `0xc2` hook is already in the MT7981 firmware that the OpenWrt
24.10.8 image carries. The
rebased patch and the two userspace fixes are in `firmware/openwrt-wn586x3/`.
What the first capture was: one record, 2.4 GHz, 20 MHz, legacy OFDM, 64
subcarriers, a single chain. CSI arrived only while a client sent frames.
The rate is client traffic, not a sensor sample clock. The capture is not in
this repository.
What it was not:
- Not a calibration, a sequence check, a timestamp check, or a repeatability run.
The label on the wire is `mediatek:physical-unvalidated`. Plain `mediatek` stays
reserved for a frame that has passed those gates.
- Not a 4x4 array. Each radio is two chains. The four antennas are a 2.4 GHz pair
and a 5 GHz pair. Two routers are two receivers. Cross-box phase is not a
shared local oscillator, so it is not a beam.
- Not the OpenWrt One or the BPI-R3. Those remain the upstream-friendly targets
in the original decision. The WN586X3 is the board this path was actually run on.
`wifi-densepose-mtk-bridge` translates `mt76-vendor` JSON, or an MtkCSIdump
datagram, into ADR-267 MTC1 and sends it to the sensing server. The server keeps
one snapshot and a bounded ring of per-subcarrier amplitude and phase per
`device_id`. It runs `mediatek-amplitude-heuristic-v0` on mean and peak amplitude,
and `activity-index-v0` on per-subcarrier fluctuation. Both are `CLAIMED`. Neither
is the ESP32 vital-sign path.
One measured property supports further work, and nothing more. Offline, on six
local dump captures from two units (2.4 GHz, HT20, chains rx0 and rx1 of transmit
stream 0), the cross-chain phase `arg(H0·conj(H1))`, summed over subcarriers,
moved 0.014-0.039 rad (p50) between adjacent frames for well-sampled
transmitters. Raw per-chain phase moved about 1.56 rad (p50), which is what
uniformly random phase gives (MEASURED; reproducer
`scripts/mediatek-csi-phase-stability.py`). That shows a stable observable inside
one radio. It does not show that the observable tracks people.
The operator guide is [`docs/mediatek-router-csi.md`](../mediatek-router-csi.md).
## Links
- [ADR-123: BFLD capture path](ADR-123-bfld-capture-path-nexmon-and-esp32.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
- [ADR-267: MediaTek MIMO CSI wire protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md)
- [MediaTek router CSI guide](../mediatek-router-csi.md)
- [upstream mt76](https://github.com/openwrt/mt76)
- [OpenWrt One](https://openwrt.org/toh/openwrt/one)
- [MediaTek OpenWrt feed](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/)
@@ -34,6 +34,81 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
- Flags distinguish calibrated, saturated, time-synchronized, dropped-predecessor,
and synthetic frames. Synthetic provenance cannot be cleared by downstream code.
## Amendment 1 — PPDU types `Legacy` and `Unknown`
- **Date**: 2026-09-19
- **Status**: proposed. Demonstrated on one WL-WN586X3 capture. Not a maintainer acceptance.
- **Scope**: `PpduType` discriminants only. The 72-byte header layout, CRC,
payload ordering, flags, and caps are unchanged.
### What changed
`PpduType` gains two variants:
| Value | Name | Meaning |
|---|---|---|
| 6 | `Legacy` | Pre-HT: 802.11b CCK or 802.11a/g OFDM |
| 7 | `Unknown` | A PPDU format the producer could not classify |
Values 1-5 (`Ht`, `Vht`, `HeSu`, `HeMu`, `Eht`) are untouched.
### Why
The original set assumed every CSI report would be HT or later. The first real
MT7981 capture disproved that. A Wavlink WL-WN586X3 (MT7981B + MT7976C) running
an OpenWrt 24.10.8 image built with the patches in `firmware/openwrt-wn586x3/`
reported `rx_mode = 1` (`MT_PHY_TYPE_OFDM`) for a client associated on 2.4 GHz
channel 6 at 20 MHz. The bridge had no way to express it and dropped the frame.
That was a real data loss, not a corner case. MediaTek's CSI driver treats legacy
OFDM as a first-class mode: `mt7915_vendor_csi_tone_mask` skips only
`MT_PHY_TYPE_CCK`, and its `mode_map` is keyed by `MT_PHY_TYPE_OFDM`, `_HT`,
`_VHT` and `_HE_SU`, giving OFDM its own tone-mask group
(`1001-wifi-mt76-mt7915-csi-implement-csi-support.patch:1070-1088`). Association
and management exchanges are legacy-rate by design, so a sensing deployment will
see these continuously.
`Unknown` exists so the enum's gaps (5-7 and 12 in `mt76_phy_type`) and future
modes are carried explicitly rather than guessed at or dropped. It also covers
transports that omit the field: the MtkCSIdump UDP datagram carries no `rx_mode`,
and reporting `Unknown` there is honest where defaulting to a concrete format was
not.
### Compatibility
- **Forward, not backward.** A decoder built before this amendment rejects
discriminants 6 and 7 with `UnknownPpduType`, because the original parser
rejects unknown types by design. `TryFrom<u8>` returns `Err` rather than
panicking, so an old decoder fails safely — it just drops legacy frames, and
does so silently. **Rebuild MTC1 consumers after this codec change.**
- **Scope is narrower than "the workspace".** `wifi-densepose-hardware` defines
three independent `PpduType` enums — `csi_frame.rs` (ESP32),
`qualcomm_csi.rs`, and `mediatek_csi.rs` — and this amendment touches only the
MediaTek one. Neither of the others changes, so no other vendor bridge is
affected. The complete set of `mediatek_csi::PpduType` consumers is:
`wifi-densepose-hardware`'s own `mediatek-csi-sim` binary,
`wifi-densepose-mtk-bridge`, and `wifi-densepose-sensing-server`, which renders the
value with `{:?}` and so needed no code change. In particular the
`ppdu_type > 3` bound in `bootstrap_baseline.rs` is fed from the **ESP32**
enum via `CsiGridKey::from_frame(&Esp32Frame)`, not from MediaTek frames, so
`Legacy` and `Unknown` cannot reach it.
- Because the variants were added rather than renumbered, and Rust requires
exhaustive matches, the compiler locates any consumer that must be updated.
Nothing outside the files above needed one.
- Producers must emit 6 only for frames that genuinely are pre-HT, and 7 only
where the format is truly unclassified. Neither is a dumping ground for a
mapping the producer has not done.
- The mapping from `rx_mode` to `PpduType` is now infallible, so no MTC1 producer
may drop a frame on PPDU grounds.
- Unchanged: synthetic provenance still cannot be cleared by downstream code.
### Observed dimensions
The same capture assembled to 1x1x64. That is the client's behaviour, not a
protocol limit: the handset sent single-stream frames to a 2.4 GHz 20 MHz AP.
Expect 1x2 or 2x2 on 5 GHz, and size consumers for the chipset profile's
`max_chains` rather than for what one capture happened to show.
## Consequences
### Positive
@@ -59,3 +134,8 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
- [ADR-266: MediaTek Filogic CSI platform](ADR-266-mediatek-filogic-csi-platform.md)
- [ADR-018: ESP32 binary CSI framing](ADR-018-esp32-csi-frame-protocol.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
- Amendment 1 implementation: `v2/crates/wifi-densepose-hardware/src/mediatek_csi.rs`
(codec) and `v2/crates/wifi-densepose-mtk-bridge` (`ppdu_from_rx_mode`). The
motivating capture is a lab record and is not in this tree: it contains a
client transmitter address and raw CSI. The in-repo check is a fabricated
legacy-OFDM record in `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`.
@@ -0,0 +1,199 @@
# ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back
**Status:** Proposed. Hardware-verified on 2026-09-29 on an ESP32-S3 (node 4) and an ESP32-C6 (node 5).
**Date:** 2026-09-29
**Numbering:** On 2026-10-02 `main` had up to ADR-378 and open PRs claimed
nothing higher. If another branch also takes 379, renumber whichever merges
second.
## Context
Every 16 MB node was flashed with `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`
(`sdkconfig.defaults.16mb`). With that option the bootloader starts a freshly
OTA'd image in `ESP_OTA_IMG_PENDING_VERIFY`. The app must then call
`esp_ota_mark_app_valid_cancel_rollback()`. If it doesn't, the bootloader marks
the image `ABORTED` at the next reset and boots the previous slot.
Nothing in `firmware/esp32-csi-node/main` made that call, and `nm` on the ELF
confirmed the symbol was not linked. RUNBOOK.md said an
`ota_rollback_boot_check()` "sits in the app", but no such function existed
anywhere in the tree. The consequences:
- An image delivered by `POST /ota` ran until the next reset, then reverted
silently. OTA updates were not durable.
- While the image was pending, `esp_ota_begin()` refused a second OTA with
`ESP_ERR_OTA_ROLLBACK_INVALID_STATE`.
- Nothing checked health, so a bad image was never rolled back on purpose. It
only reverted if it happened to crash or be power-cycled.
A second defect meant none of this was ever reached: **OTA never succeeded
on this firmware before this change.** MEASURED on 2026-09-29, on node 4 (an
ESP32-S3 running the v0.8.12 16 MB build, with the OTA key provisioned):
- A `POST /ota` of a valid 1,228,672 B image logged `OTA update started`, then
`stack overflow in task httpd`, then `rst:0xc RTC_SW_CPU_RST`. The node came
back on its old slot. Both attempts failed the same way.
- The cause: the server used `HTTPD_DEFAULT_CONFIG()`, which gives a 4096 B
task stack. The handler held a 1 KB receive buffer on that stack, while
calling `esp_ota_*` and formatting a float progress log.
## Decision
At boot, `ota_health_start()` (in `main/ota_health.c`, called early in
`app_main`) reads the running partition's state:
| state | meaning here | action |
|---|---|---|
| `PENDING_VERIFY` | just OTA'd, rollback armed | run the health check |
| `VALID` | USB-flashed with `ota_data_initial.bin` (the bootloader writes VALID), or already confirmed | nothing |
| `UNDEFINED` | selected by a build without rollback; boots unconditionally | nothing |
| `NEW` | bootloader lacks rollback, so it never promoted the image | nothing |
| error (`NOT_FOUND`/`NOT_SUPPORTED`) | no otadata entry, or factory partition | nothing |
For this table I read the ESP-IDF v5.5 source: `bootloader_utility.c`
(`set_actual_ota_seq`, the NEW to PENDING_VERIFY to ABORTED transitions) and
`esp_ota_ops.c`.
**The health check.** A low-priority task polls once a second and feeds
`ota_health_step()`, which is a pure function in `ota_health.h`:
- **Signals, both latched:** the STA got an IP (`IP_EVENT_STA_GOT_IP`, via
`ota_health_note_got_ip()` in `main.c`'s handler), and the stream sender
accepted at least one CSI frame (`csi_collector_get_send_ok_count() > 0`).
The second signal covers driver callback, serialization and `sendto()`
together.
- **Minimum uptime** `CONFIG_OTA_HEALTH_MIN_UPTIME_S` (default 30 s): the image
is not confirmed before this point even with both signals present. An image
that crashes soon after starting its pipelines still reverts, because a crash
while pending is a rollback.
- **Deadline** `CONFIG_OTA_HEALTH_TIMEOUT_S` (default 120 s, measured from
boot). Both signals are required by then. The minimum uptime is clamped to
the deadline.
- **Pass:** `esp_ota_mark_app_valid_cancel_rollback()`, which logs
`marked valid after <reason> in <ms> ms`.
- **Fail:** logs `health check failed: <reason>, rolling back`, then calls
`esp_ota_mark_app_invalid_rollback_and_reboot()`. If no other bootable slot
exists, that call returns an error. The node then logs and stays up rather
than reboot-looping.
- **Terminal verdict:** once the check has confirmed or rolled back, it never
changes.
**Negative-test hook.** `CONFIG_OTA_HEALTH_FORCE_FAIL` (default n, test only)
makes the check roll back at the point where it would have confirmed. Use it to
prove rollback end to end.
**Remote visibility.** `GET /ota/status` now includes `ota_state` (`valid`,
`pending_verify`, `new`, `undefined`, `other`, or `none`). An operator can
confirm durability without a serial console.
**The httpd stack.** The OTA/WASM HTTP server now sets `config.stack_size =
CONFIG_OTA_HTTPD_STACK_SIZE`, which defaults to 8192. Three smaller changes go
with it:
- The OTA receive buffer is static rather than on the stack. Handlers run one
at a time on the single httpd task, so one buffer is enough.
- The progress log prints an integer percent instead of a float.
- `GET /wasm/list` keeps its 2 KB JSON buffer on the heap, not the stack.
`recv_wait_timeout` stays at 30 s. Each `POST /ota` and `POST /wasm/upload` now
logs `httpd stack after <handler>: N of M bytes never used`, so the size can
be re-tuned from device measurements.
Sizing: this is an estimate from static data, not a device measurement. It
comes from a `-fstack-usage` build of the S3 16 MB configuration with IDF v5.5.
- **Frame sizes along the OTA path** (bytes):
| function | bytes |
|---|---|
| `httpd_thread` | 144 |
| `httpd_sess_process` | 32 |
| `httpd_req_new` | 176 |
| `httpd_uri` | 64 |
| `ota_upload_handler` | 192 (1,216 with the old 1 KB buffer) |
| `esp_ota_end` | 48 |
| `image_validate` | 304 |
| `esp_image_verify` | 32 |
| `image_load` | 80 |
| `process_segments` | 112 |
| SHA-256 | about 110 |
| mmap/flash read | about 190 |
- That chain comes to about 1.5 KB now, and about 2.5 KB with the old buffer.
- Two things are not in the static data:
- libc's `vfprintf` behind every `ESP_LOG`. This build uses full newlib,
not nano, and the old progress line formatted a float.
- The flash-chip driver, which is reached through function pointers.
Together they plausibly add 1 to 1.5 KB. That explains an overflow at 4096
with the old handler.
- The WASM upload path adds `wasm_upload_process` (336) and
`rvf_verify_signature` (352), plus a wasm3 module load that wasn't measured.
- 8192 leaves roughly 4 to 5 KB above the estimated worst case, and costs
4 KB of internal RAM. The high-water-mark log is the check on that estimate.
When the build has no `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` (the 8 MB S3 and
4 MB C6 CI lanes), both entry points compile to no-ops.
## Consequences
- OTA updates become durable on rollback-enabled nodes once the image passes
the check. A second OTA is accepted after confirmation, which can come as
early as `MIN_UPTIME_S`.
- A bad image now reverts instead of limping, but "bad" only means it failed
to reach the network or deliver CSI. An image that passes and then
misbehaves later is not caught.
- If the AP is down when a node boots an OTA'd image, a good image rolls back.
That is the safe direction: push it again later.
- Mock/QEMU builds that skip Wi-Fi can never pass. They must not enable
bootloader rollback, and none do today.
- Bootloader capability is still invisible remotely (RUNBOOK §2). A node
whose bootloader lacks rollback reports `ota_state: "new"` after an OTA. That
is the first remote hint of which bootloader a board has.
## Evidence
- Host test `firmware/esp32-csi-node/test/test_ota_health.c` runs in
`make host_tests`, which CI runs. It covers pass, each timeout reason, the
terminal verdict, force-fail, the clamp, and zero soak. SYNTHETIC: it
exercises the decision function, not a device.
- ESP-IDF v5.5 S3 and C6 builds with rollback enabled link
`esp_ota_mark_app_valid_cancel_rollback` (verified with `nm`). A build is not
hardware evidence.
- The httpd overflow is MEASURED on hardware (node 4, above).
- Hardware verification, MEASURED on 2026-09-29 on node 4 (ESP32-S3,
16 MB, rollback bootloader, OTA key provisioned). The
build from this branch was first written over USB. The pushes were made from
a Pi 5 with the `sensor-ota-push` cog.
1. Positive (MEASURED, S3). An image built from this branch was pushed to `ota_1`: HTTP 200,
1,230,848 B in 22 s.
- The node logged "httpd stack after POST /ota: 4900 of 8192 bytes never
used", so the upload's peak stack use is about 3.3 KB. With the old 1 KB
on-stack buffer that is over 4 KB, which is consistent with the
overflow.
- It rebooted and logged "OTA image pending verify on ota_1".
- `/ota/status` then read `ota_state: valid`.
- After a hard reset it came back on the same build in `ota_1`, still
`valid`, so the update is durable.
2. Negative (MEASURED, S3 forced-fail rollback). A `CONFIG_OTA_HEALTH_FORCE_FAIL=y` build was pushed to `ota_0`:
HTTP 200.
- `/ota/status` showed it running as `pending_verify` for about 30 s.
- The node then rebooted and came back on the previous build in `ota_1`,
`valid`, so rollback works.
3. One push attempt failed mid-upload when a serial capture was opened on the
node's USB-Serial-JTAG port at the same moment. The node reset and was
unharmed. Don't hold the USB console open while pushing OTA to a
USB-attached node.
- C6 positive, MEASURED on 2026-09-29 on node 5 (ESP32-C6, 8 MB
flash, 4 MB layout, rollback bootloader).
- The branch build was first written over USB and the OTA key provisioned.
- A second build was pushed from the Pi 5 cog to `ota_1`: HTTP 200 in 11 s.
- `/ota/status` showed `pending_verify`, then `valid` about 28 s later.
- After a hard reset it stayed on the new build, `valid`.
- Not yet verified, so CLAIMED (no log supports these):
- the 120 s no-IP timeout path (CLAIMED);
- a true power cycle (CLAIMED; a hard reset via RTS was used instead);
- the C6 negative/rollback test (CLAIMED; run on the S3 only).
- A pass of this check means only that the node reached the network and sent
CSI. It does not validate sensing quality, calibration, or anything else the
image does.
+58
View File
@@ -0,0 +1,58 @@
# ADR 380: Loopback UDP tee for second consumers of the CSI stream
## Status
Proposed. Unit tested, and smoke tested on loopback: a datagram sent to the
server arrived byte for byte at the tee target. Not yet exercised against a
live multi node fleet.
Date: 2026-09-28
Numbering: On 2026-10-02 `main` had up to ADR 378 and open PRs claimed nothing
higher; ADR 379 is the OTA first-boot health check. If another branch also
takes 380, renumber whichever merges second.
## Context
A five node deployment (four ESP32 S3, one ESP32 C6) streams ADR 018 frames to
one sensing server. A second consumer cannot see the raw stream.
Each node sends to a single `target_ip` (`firmware/esp32-csi-node/main/stream_sender.c`).
The server's WebSocket and REST outputs carry per node amplitude only: no
phase, no I/Q, no sequence number, and no `0xC511A110` sync packets. A fusion
host that aligns CSI with camera, radar, or lidar needs all of those. Binding a
second listener to the CSI port is not an option, and an external relay in
front of the port would make every source loopback, which the ADR 296
allowlist always admits.
## Decision
`--udp-tee` (env `RUVIEW_UDP_TEE`) copies every datagram the source allowlist
admits, byte for byte, to up to four `ip:port` targets.
- Targets must be loopback, so raw CSI never leaves the host through the tee.
- A target may not use port 0 or the listener's own port, which would loop.
- Sends are non blocking. A copy that cannot be queued is dropped and counted,
so a slow consumer never stalls the receive loop.
- The copy happens after the ADR 296 allowlist check and before any parsing,
so consumers see exactly what the server admitted, in every wire format.
- The tee is off unless configured.
## Consequences
A fusion host or Cognitum cog can subscribe to the full data plane by reading
a loopback port. A consumer that today binds the CSI port itself can keep its
code and move to the tee port instead.
The tee is byte transparent, so it needs no change when the ADR 018 header or
a later wire version changes.
Each target costs one `send_to` per admitted datagram. Four targets is the
limit.
## Validation
```bash
cd v2
cargo test -p wifi-densepose-sensing-server --no-default-features --lib udp_tee
```
+272
View File
@@ -0,0 +1,272 @@
# ADR-381: Node self-survey from pairwise ranging (check mode first)
- **Status**: Proposed
- **Date**: 2026-10-04
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: node-positions, ranging, ftm, uwb, multistatic, least-authority
- **Related**: ADR-018 (ESP32 wire format), ADR-110 (ESP-NOW time sync),
ADR-141 (privacy control plane), ADR-144 (UWB range-constraint fusion),
ADR-281 (BLE Channel Sounding ranging evidence), ADR-308 (placement
optimizer), rufield ADR-261 (BLE evidence and Channel Sounding adapter
boundary, `vendor/rufield/docs/ADR-261-ble-evidence-and-channel-sounding.md`)
- **Issues and PRs**: #1804, #1866, #2109 (show which nodes use the default
position), #2133 (give the governed fuser the configured node positions)
## Context
Node positions reach the sensing server only through `--node-positions`. The
operator types it by hand, keyed by node id (`id:x,y,z;...`) or by list index
when the prefix is omitted. Three kinds of mistake pass silently today:
- **Wrong ids.** A config keyed by ids that do not match the live nodes looks
applied and does nothing (#1804, #1866). #2109 makes the fallback to the
default position visible, and #2133 makes sure both fusers receive the
map. Neither can tell whether a correctly-keyed position is *right*.
- **Wrong units.** Positions measured in feet and typed as metres are 3.28×
too large. Nothing checks the scale.
- **Stale positions.** A node moved after setup keeps its old coordinates.
Each one degrades multistatic fusion without an error. The repository has no
node-to-node ranging and no anchor-free solver. ADR-144 models
anchor-to-*tag* UWB ranges for people tracking
(`wifi-densepose-mat/src/localization/range_constraint.rs`), not ranges
between the fixed nodes themselves. ADR-281 computes BLE Channel Sounding
ranging evidence, but there is no CS-capable radio in the fleet: rufield
ADR-261 notes that the ESP32-S3 does not implement CS.
The ESP32-S3 and C6 nodes do support Wi-Fi Fine Timing Measurement (802.11mc
RTT, station initiator ↔ SoftAP responder) in mainline ESP-IDF. So a ranging
source exists on hardware we already have. The question is how good it is.
### What FTM measured
Bench, 2026-10-04 (UTC): two ESP32-S3 boards (rev v0.2), ESP-IDF v5.5
`examples/wifi/ftm`, SoftAP responder on channel 1 at 20 MHz, 32 FTM frames
per session, line of sight, tape ground truth. All rows MEASURED; the
reproducer is in Appendix A.
| Tape (m) | Result |
|---|---|
| 0.00 | median 0.00 m |
| 0.76 | median 0.00 m. Readings clamp to 0 below about 0.8 m. The first three sessions after a responder start read 1.8 to 3.0 m |
| 1.14 | five responder starts: medians 0.60, 1.50, 2.25 and 3.08 m, plus a 7.7 m run at −68 dBm with a 1.92 m session spread (placement outlier) |
| 3.71 | median 7.80 m (one start) |
- **Precision within one responder start:** ±0.2 to 0.4 m (standard
deviation 0.19 to 0.31 m). A 40-session run over about two minutes showed
no drift beyond that.
- **Bias between responder starts:** −0.5 to +1.9 m at 1.14 m, with the
boards untouched. A plain-versus-auto-start responder A/B test showed the
responder build is not the cause.
- **Quantisation:** every distance is a multiple of 0.15 m (1 ns of RTT).
- **Not tested:** 40 MHz, other channels, `ftm -R -o` offset calibration, a
second pair, non-line-of-sight, CSI coexistence, raw t1..t4 timestamps.
FTM is precise inside one start and inaccurate across starts. It can catch
mistakes worth metres. It cannot place a node to decimetres. UWB (Qorvo
DW3000 family) is the expected accuracy path: about 0.1 m line of sight is
CLAIMED by the vendor, and we have no measurement of our own yet.
## Decision
### 1. A source-agnostic `RangeObservation` record
```text
RangeObservation {
initiator: u8, // node_id, as in --node-positions
responder: u8,
range_m: f64, // session median
sigma_m: f64, // session spread, 0 if unknown
method: Ftm | Uwb | BleCs | Manual,
samples: u16, // valid frames / exchanges
rssi_dbm: Option<i16>,
bandwidth_mhz: Option<u16>,
at_us: u64, // host receive time
responder_boot: Option<u32>, // boot counter or nonce of the responder
calibrated: bool, // set only by the host calibration step
}
```
`responder_boot` is required for calibration. The FTM bias moved with every
responder start, so an offset is only valid within the boot it was measured
in.
On the wire, nodes report ranges either in a new ADR-018-style packet with
its own magic, or as an extension of the existing sync packet. This ADR does
not choose between them. The host-side record and solver come first. The
wire format follows the first firmware that emits ranges.
### 2. Host-side check mode (warn-only)
Given the configured positions and a batch of observations, check mode
reports:
- **Per-link residuals.** `measured − ‖p_a − p_b‖`, with a tolerance of
`floor + k·σ`. σ is the larger of the median session sigma and the spread
across sessions. Each link is `ok`, `outlier`, `too_short` (configured
length below the source's minimum), `weak` (every session below the RSSI
gate), or `noisy` (σ above the source's maximum).
- **Unit mismatch.** A least-squares scale `s` in `measured ≈ s·expected`.
If `s` sits within a band of 0.3048 (feet), 0.0254 (inches), 0.01 (cm) or
0.001 (mm), and rescaling removes outliers, the check reports the unit.
- **Id swap.** For up to 8 ranged nodes, it scores every assignment of the
configured positions to ids. The cost is a truncated quadratic in residual
over tolerance, so one bad link cannot dominate. If another assignment
cuts the cost to at most half and the configured cost is material, the
check suggests that assignment, reported as `(node, position_of)` pairs.
- **Moved node.** If most of a node's links are outliers (at least 2, and a
strict majority), the node was probably moved. Outlier links left over are
reported one at a time as possible bias, a body or wall in the path, or a
bad session, and not as a configuration error.
- **Unknown and unranged nodes.** Observations naming unconfigured ids, and
configured nodes that no scored link reaches.
Distances do not change under rotation, reflection or translation, so check
mode compares distances and never coordinates. Where the layout is
symmetric, it says so instead of guessing:
- A relabelling that is a symmetry of the layout is **invisible**. For
example, ids rotated 180° in a rectangle of corner nodes. Check mode
reports such a layout as consistent.
- When several assignments fit equally well, check mode prefers the one that
changes the fewest ids, since a typo moves few. It reports how many
assignments tied. If the fewest-changes assignments are themselves tied,
as with a swap along one wall of a rectangle (its mirror image is the
opposite wall's swap), it reports `id_swap_ambiguous` and names nothing.
Corner-mounted nodes in a rectangular room are the common case and are
nearly symmetric. Operators should expect ambiguity reports there, and a
fifth node off the axes removes most of them.
**Authority.** Check mode never writes positions, never edits config, and
never changes fusion. Its output is a report for the startup log and a
read-only status field. The report shape is JSON-serialisable; the field
name follows #2109's `node_positions` block once that lands.
**Tolerances come from measured data:**
| Source | `floor_m` | `calibrated_floor_m` | `max_sigma_m` | `min_expected_m` | Basis |
|---|---|---|---|---|---|
| FTM, ESP32-S3, 20 MHz | 2.0 | 0.6 | 1.0 | 1.0 | MEASURED, bench above: bias −0.5 to +1.9 m between starts; spread ≤ 0.31 m within a start (the 1.92 m outlier excepted); clamp below about 0.8 m |
| UWB | 0.3 | 0.3 | 0.3 | 0.0 | ESTIMATE until our own UWB bench. Vendor accuracy is CLAIMED |
With `floor_m = 2.0`, the one 3.71 m start (+4.1 m) would be flagged as a
single-link outlier. That is uncalibrated FTM as it stands. Single-link
outliers stay low-severity on purpose, and the strong findings (unit, swap,
moved node) need several links to agree.
### 3. Per-boot calibration
An offset is recorded per `(initiator, responder, responder_boot)` from
sessions at a known reference distance of at least 1.0 m:
`offset = median(range) − reference`. It applies only to observations with
the same key. After a responder reboot, observations pass through
uncalibrated and get the wider floor.
Zero-separation calibration (ESP-IDF's `cm0` / `ftm -R -o`) is not used,
because short ranges clamp to 0 on this hardware. In practice, the reference
is one tape-measured link per responder per boot, or a UWB link once UWB
exists. That the bias is stable over a whole boot, and not only for two
minutes, is untested (ESTIMATE). Measuring it is the first FTM follow-up.
### 4. Fill mode, gated off
Fill mode (solve positions with classical-MDS initialisation, weighted
robust SMACOF and gauge fixing, then write a file the operator passes to
`--node-positions`) is **not implemented** and stays disabled for a source
until all of these hold:
- the source has **MEASURED** link-error evidence with a reproducer;
- the evidence covers at least 6 ground-truth links;
- the p90 link error is **≤ 0.3 m**.
FTM's measured errors are metres, so it fails this gate. UWB is expected to
pass it, but that is unproven. Even when the gate opens, fill mode writes a
proposal file. The server never rewrites its own positions.
### 5. Pluggable sources
Every source produces `RangeObservation`, and check mode does not care which
one did. Only the tolerance profile changes.
| Source | Status | How it plugs in |
|---|---|---|
| **FTM** (ESP32-S3/C6) | Now, for check mode | APSTA node runs a short scheduled survey window. One node is the SoftAP responder and the others initiate a directed session. `rtt_ns`, RSSI and session spread map to `range_m`, `rssi_dbm`, `sigma_m`. Airtime cost on the CSI channel is unmeasured |
| **UWB** (DW3000 family, e.g. DWM3001CDK over USB) | After its own bench | Two-way ranging between nodes. Shares hardware with ADR-144's anchor-tag model, but node↔node ranges feed this ADR and anchor↔tag ranges keep feeding ADR-144 |
| **BLE CS** | When a CS-capable radio exists | Through the rufield ADR-261 adapter boundary, using ADR-281's cross-validated phase/RTT ranging. A `Divergent` anomaly drops the observation. Single-mechanism evidence gets the wider floor |
| **Manual** | Now | A taped distance entered by the operator. Mainly useful as the per-boot calibration reference |
### 6. Prototype
`v2/crates/ruview-survey` implements sections 1 to 4 on the host, with no
I/O, no clock and no randomness, and bounded inputs: 64 nodes, 10 000
observations, 100 m range, and the swap search capped at 8 nodes.
Integration with the sensing server is a follow-up and depends on #2109 for
where the report surfaces. The crate's tests are SYNTHETIC: generated layouts
with FTM-like bias and noise from the envelope above. They cover consistent
configs, feet and centimetre mistakes, a two-id swap, a three-id rotation,
symmetric invisibility and ambiguity, a moved node, a bench-style single-link
outlier, a noisy link, per-boot calibration, and the fill gate.
`examples/check_ranges.rs` runs check mode on a JSONL file.
## Consequences
- An operator gets a warning when ranges and `--node-positions` disagree
grossly, with no new authority: check mode is read-only.
- FTM on nodes needs APSTA and a SoftAP beacon on the sensing channel. Its
cost to CSI yield must be measured before the firmware side ships.
- The FTM tolerances rest on one evening with one pair. They are parameters,
not constants, and must be revised from more data (40 MHz, more pairs,
non-line-of-sight).
- Ranges between fixed nodes are not person data. Ranges to phones or tags
are, and remain under ADR-141 and ADR-144.
- A symmetric layout can hide a relabelling entirely. Check mode documents
this and does not claim to catch it.
## Evidence rule
No self-survey accuracy claim without a MEASURED reproducer: tape or laser
ground truth, at least 4 nodes, line of sight plus at least one
non-line-of-sight case, every number tagged. The prototype's tests are
SYNTHETIC and show logic, not radio performance.
## Alternatives considered
- **Treat FTM as accurate enough to fill positions.** Rejected: the measured
bias between starts (−0.5 to +1.9 m at 1.14 m) and the +4.1 m at 3.71 m
rule it out.
- **Drop FTM.** Rejected for now. It costs nothing on existing hardware, it
catches metre-scale mistakes, and it may suit coarse gating, calibrated
pairs within a boot, and time sync. It is parked and revisited after UWB.
- **Zero-separation `cm0` calibration.** Rejected: readings clamp to 0 below
about 0.8 m.
- **Server auto-corrects positions.** Rejected under least authority. Fill
mode, when enabled, writes a proposal file only.
- **LoRa SX1280 ranging, router (MT7981) FTM, raw I/Q phase ranging,
CSI-amplitude ranging.** Rejected or deferred: wrong distance regime, no
driver support, unproven, or too inaccurate respectively.
## Open questions
- Does one calibration offset hold for a whole responder boot? For both
roles? For other initiators against the same responder?
- Is the post-start transient (1.8 to 3.0 m in the first three sessions at
0.76 m) real, and how long does it last?
- Does 40 MHz shrink the bias between starts?
- What does an FTM survey window cost in CSI frames on the same channel?
- Can FTM t1..t4 timestamps be tied to the CSI timestamp clock for sync?
- The wire format: new magic, or an extension of the sync packet?
## Appendix A: FTM bench reproducer
1. Flash ESP-IDF v5.5 `examples/wifi/ftm` on two ESP32-S3 boards
(`CONFIG_ESP_WIFI_FTM_ENABLE=y`, the example's default).
2. Responder console: `ap <ssid> <password>` (channel 1, 20 MHz defaults).
Use throwaway credentials and do not commit them.
3. Initiator console, at a taped separation: `ftm -I -s <ssid>`, 32 frames,
10 to 40 sessions per distance. Record `Avg raw RTT`, `Avg RSSI`,
`Estimated Distance` and the valid-frame count for each session.
4. Reset the responder and repeat at the same distance to see the bias move
between starts.
5. Report the median, spread and error per run, tagged MEASURED, with the
raw per-session data alongside.
@@ -0,0 +1,137 @@
# ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations
- **Status**: proposed
- **Date**: 2026-10-03
- **Deciders**: RuView maintainers
- **Tags**: rf, gaussian, spatial, evidence, wire-format, interop
- **Related**: ADR-273 (unified RF world model), ADR-275 (RF-aware Gaussian
memory), ADR-142 (temporal voxel evidence). Origin of the wire format:
WeftOS ADR-107 §7 ("Spatial evidence engine and workspace"), amended
2026-10-03.
- **Crate**: `v2/crates/ruview-spatial-evidence`
Numbering: on 2026-10-03 `main` had up to ADR-378, open PRs claimed 379
and 380, and 381 was taken by another branch.
## Context
`ruview-unified` keeps a map of RF Gaussians (ADR-275) and a Friis channel
model that turns a TX/RX link into an expected amplitude. A spatial evidence
engine fuses many sensor types (tape measurements, radar, ToF, UWB, RF) into
an occupancy octree and promotes stable geometry (walls, furniture) into room
models. That engine reads a versioned JSONL format, `spatial.evidence.v1`,
specified in WeftOS ADR-107 §7.
RuView should be able to feed it without depending on it, and without a
reader of this repository having to go to another project to learn what the
export writes. This ADR records the part of the contract RuView emits.
## Decision
1. A leaf crate, `ruview-spatial-evidence`, converts `ruview-unified` output
to `spatial.evidence.v1` lines. Its only internal dependency is
`ruview-unified`. The wire shapes are local serde structs; the contract is
the JSON, not a shared crate.
2. It emits two record types, `rf_gaussian` and `rf_link_observation`. It
does not mirror the other v1 types and rejects them as unknown when
parsing.
3. Every record is validated against the v1 rules below before it is
serialised, so a bad value fails in RuView rather than in the consumer.
4. If WeftOS ADR-107 §7 changes the envelope or either RF body, this ADR is
amended in the same change that updates the crate.
### Envelope (every record)
| Field | Rule |
|---|---|
| `schema` | exactly `spatial.evidence.v1` |
| `type` | `rf_gaussian` or `rf_link_observation` here |
| `t_ns` | u64, ns since the Unix epoch |
| `frame` | `room_enu` |
| `region` | region id, e.g. `region/urth/meso/<room>` |
| `source_id` | device or operator id, never a person |
| `uncertainty_m` | 1σ position uncertainty, metres, in (0, 100] |
| `provenance` | `{receipt, producer, proof}`; `proof` is `MEASURED`, `CODE` or `SYNTHETIC` |
Ids (`region`, `source_id`, `receipt`, `producer`) match
`[A-Za-z0-9._:/-@]{1,128}`. A line is at most 16 KiB.
**Frame.** `room_enu` is room-local, origin at the room's SW floor corner,
x east, y north, z up, metres. Coordinates are within ±1000 m.
**Angles.** Every yaw in v1 is degrees counter-clockwise from room +x
(east): 0 faces +x, 90 faces +y. The two RF types have no yaw field. The
`rf_gaussian` orientation is a body-to-room quaternion, and a positive
rotation about +z turns the Gaussian's local x axis counter-clockwise from
east, the same sense.
### `rf_gaussian`
| Field | Rule |
|---|---|
| `position` | [x, y, z] m |
| `scale` | per-axis σ, m, each in [1e-6, 1e4] |
| `orientation` | quaternion `[w, x, y, z]`, finite, norm > 1e-6 |
| `occupancy` | peak extinction, nepers/m, in [0, 1e6] |
| `confidence` | [0, 1] |
| `motion` | `static`, `slow` or `fast` |
| `role`? | `absorber` or `reflector`; set only on priors sent back to RuView |
The exporter maps `RfGaussian` field for field. `uncertainty_m` is the
geometric mean of the three σ, clamped to the envelope range, because
`RfGaussian` has no separate position covariance. The receipt is
`gauss:<device>:<t_ns>:<index>`. A Gaussian whose provenance is `synthetic`
is always tagged `SYNTHETIC`; a caller can lower the proof tag, never raise
it.
### `rf_link_observation`
| Field | Rule |
|---|---|
| `tx`, `rx` | antenna positions, m |
| `freq_hz` | [1e8, 3e11] |
| `excess_loss_db` | `20·log10(free / measured)`, in [−60, 200]; negative is constructive multipath |
The free-space reference comes from `ruview-unified`'s channel model with an
empty map, so it is the same Friis amplitude the ADR-275 inverse update uses.
### Examples
These two lines are the WeftOS ADR-107 §7 examples. The crate's tests parse
them and check that its own output has the same shape.
```jsonl
{"schema":"spatial.evidence.v1","type":"rf_link_observation","t_ns":1759500001300000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"node-2","uncertainty_m":1.0,"provenance":{"receipt":"csi:node1-node2:000881","producer":"ruview-adapter@0.1","proof":"MEASURED"},"tx":[6.20,3.50,1.10],"rx":[0.20,0.20,1.10],"freq_hz":2437000000.0,"excess_loss_db":4.2}
{"schema":"spatial.evidence.v1","type":"rf_gaussian","t_ns":1759500002000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ruview-unified","uncertainty_m":0.5,"provenance":{"receipt":"gauss:7f3a","producer":"ruview-unified@0.3","proof":"CODE"},"position":[3.2,0.0,1.2],"scale":[0.05,1.6,1.2],"orientation":[1,0,0,0],"occupancy":0.7,"confidence":0.8,"motion":"static","role":"absorber"}
```
### Versioning
Additive optional fields may appear inside v1 and older readers ignore them.
New record types may also be added inside v1 (the 2026-10-03 amendment added
`tof_depth` and `radar_range`, and an optional `sensor` block on
`radar_track_point`); a reader that does not know a type rejects it as
unknown rather than misreading it. Removing, renaming or changing the
meaning of a field requires `spatial.evidence.v2`, and the parser reports a
v2 line as a version mismatch.
## Consequences
- RuView output can feed a spatial evidence engine with no new runtime
dependency, and the contract is readable from this repository.
- The engine can send promoted walls and objects back as `rf_gaussian`
priors with a `role`. That gives ADR-275's inverse update a geometric
starting point instead of an empty map.
- The struct definitions are duplicated between the two projects. The
golden-line tests catch drift in the RF types; a change on the WeftOS side
still needs someone to amend this ADR and the crate.
## Limitations
- Nothing calls the crate yet. The sensing server does not export records,
and there is no adapter that turns an inbound `rf_gaussian` prior into an
`RfGaussian` in the live map.
- The tests are CODE-level (deterministic conversions and validation). No
MEASURED claim is made about what the exported evidence does to a room
model.
- `uncertainty_m` for a Gaussian is a stand-in derived from its extent.
@@ -0,0 +1,23 @@
# ADR-383: Reject malformed HTTP request targets without stopping the transport
Status: Proposed; maintainer acceptance pending
## Context
ADR-375 introduces the authenticated Streamable HTTP transport. Node accepts an HTTP request target that the WHATWG URL parser rejects. The current request listener lets that parser exception escape, terminating the process before route selection. A bounded loopback fixture using the public `startMcpHttp` API reproduces this with `http://[`; the original code resets the connection and exits.
## Decision
Catch URL construction failures at the request boundary and return HTTP 400 with the generic `invalid_request_target` error. Do not include the request target or parser exception in the response, since the target can carry a secret path. Keep route selection, token checks, origin policy, grants, protocol negotiation and JSON handling unchanged.
## Alternatives
Relying on Node's HTTP parser does not suffice: a syntactically accepted request target can still fail URL construction. A process-level exception handler would obscure unrelated faults and would not provide a request-specific response.
## Verification
MEASURED software fixture: the actual child-process HTTP transport fails the new regression on the original source (nine existing controls pass); the patched transport passes all ten focused tests. The new test also verifies subsequent health, rejected unauthenticated calls and successful authenticated initialization. Broader package checks are recorded separately against the final implementation revision. No external service, device, hardware, throughput or production deployment claim is made.
## Consequences
Malformed URL targets receive a stable client error and do not interrupt later requests. This change does not add SSE or sessions, change authentication, or authorize additional operations.
@@ -0,0 +1,19 @@
# ADR-384: Preserve explicitly supplied SDK search limits
Status: Proposed; maintainer acceptance pending
## Context
The public `memorySearch(query, limit)` helper uses a truthiness check before forwarding its optional limit. Explicit zero and NaN therefore disappear, even though the shared registry rejects them. The helper silently returns success where the equivalent raw SDK call returns `invalid_arguments`; strict SDK users also miss the expected `RuViewError`.
## Decision
Omit the optional limit only when it is undefined. Forward every explicitly supplied value to the existing shared validator. Do not duplicate validation or change the registry range, search algorithm, result count, strict error policy or defaults.
## Alternatives
Validating separately in the helper would duplicate schema rules and risk drift. Retaining truthiness makes invalid caller inputs indistinguishable from an omitted option.
## Verification
MEASURED local software regression exercises the public SDK and actual registry. Original code fails for zero; the corrected helper rejects zero, NaN, negative and excessive limits, preserves strict errors, and matches raw registry results for omitted and valid limits. Package gate receipts are recorded separately against the final revision. No external retrieval, training, hardware or production service claim is made.
@@ -0,0 +1,19 @@
# ADR-385: Stop watch mode on permanent input errors in every output mode
Status: Proposed; maintainer acceptance pending
## Context
The CLI's `esp32 --watch` repeats short capture windows. Permanent bind/port errors stop the terminal view, but the piped/JSON branch continues before reaching that check. Schema-level invalid arguments also never stop either branch. Actual CLI children with a fractional UDP port or rejected bind value repeatedly emit errors despite having no usable capture configuration. These fixtures fail before opening a socket or accessing a device.
## Decision
Classify existing permanent port/bind errors and schema-invalid arguments before selecting output mode. Emit the error once and return exit status 1 for both terminal and JSON output. Keep repeated capture behavior for nonterminal results, including windows with no CSI packets; retain the normal watch output contract.
## Alternatives
Adding a delay would still repeatedly retry an unchanged invalid configuration. Stopping every unsuccessful capture would incorrectly terminate legitimate empty capture windows.
## Verification
MEASURED bounded actual CLI regression checks piped `--watch --json` with invalid inputs, one structured failure and nonzero exit. No network socket, hardware, external provider or live capture is exercised. Whole-package validation receipts accompany the final revision.
@@ -0,0 +1,23 @@
# ADR-386: Recover CSI packet loss by missing sequence identity
Status: Proposed; maintainer acceptance pending
## Context
The RF capture diagnostics report CSI packet loss and account for UDP reordering. The existing tracker decrements its gap count for any late sequence while gaps exist. A duplicated recovery or previously received packet therefore erases a different missing packet. For example, sequences 100, 103, 101, 101 leave 102 missing but currently report zero loss. Accurate loss diagnostics matter when distinguishing transport problems from healthy sensing input.
## Decision
Retain the identities of counted missing sequences within the existing 1024-sequence reorder window. Recover a gap only when deleting its identity succeeds, so a duplicate cannot recover it twice. Prune identities outside the window without erasing their historical gap counts. A confirmed counter reset clears recovery identities but preserves accumulated loss. Keep the existing frame count/rate, loss denominator, stray/reset classification and packet format unchanged.
## Alternatives
A duplicate-only set cannot distinguish packets observed before the capture baseline from genuinely missing ones. Keeping all missing identities indefinitely is unnecessary and could grow without bound.
## Verification
MEASURED software fixtures use byte-accurate RAC1 envelopes, the public capture API and real loopback UDP. Regressions cover duplicated recoveries, already-received packets, pre-capture packets and distinct valid recoveries. A direct public parser/NodeStats control checks bounded bookkeeping and reset semantics. Existing sequence/stray/reset tests remain applicable. This is a software diagnostic correction, not physical RF, device throughput or physiological accuracy validation.
## Consequences
Loss no longer disappears when unrelated late packets arrive. Recovery remains limited to the established reorder window; older gaps stay counted. Stored identities are bounded per node.
@@ -0,0 +1,32 @@
# ADR-389: Measure CSI rates over the observed capture interval
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (receive-only device diagnostics)
## Context
The public `captureEsp32` producer may stop at `max_packets` before its configured
`seconds` deadline. Its node CSI rates still divided received packets by that
requested deadline. Two packets received in approximately 150 milliseconds with a
30-second deadline therefore appeared as 0.07 Hz, misleading operators evaluating
whether a node streams normally.
## Decision
Start a monotonic clock when the UDP socket successfully binds. At completion,
calculate `elapsedSeconds` and use it as the denominator for node CSI rates.
Retain `seconds` as the configured deadline for compatibility and expose the
observed interval separately. Close and analysis processing time are excluded.
A socket error before binding has a zero observed interval and no received data.
## Verification and limits
A deterministic-format ESP32 fixture is transmitted through an owned loopback UDP
socket into the public capture function. The baseline fails because the observed
interval is absent and the rate is 0.07 Hz. The fixed capture checks early
termination, preserves the deadline, and checks the rate against observed elapsed
time. Existing CSI decoding and capture tests remain controls. This is software
transport verification; it makes no radio, hardware, sensing-accuracy, or firmware
throughput claim. Normal deadline captures may report a slightly longer interval
because timer callbacks are scheduled by the host event loop.
@@ -0,0 +1,27 @@
# ADR-390: Reduce LiDAR scan extrema without variadic argument limits
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (device diagnostics)
## Context
`readRplidar` accepts up to 4 MiB of serial scan bytes. Its summary passed every
valid distance as an argument to `Math.min` and `Math.max`. A valid 1 MiB scan
containing 200,000 measurement nodes exceeds the JavaScript argument-stack limit
and throws `RangeError` instead of returning diagnostics.
## Decision
Reduce extrema with a loop and constant auxiliary state. Retain current median,
zero-distance filtering, coverage, units, and empty-scan behavior. This avoids
runtime-specific argument limits across the full supported capture size.
## Verification and limits
An offline serial-pump dependency supplies protocol-valid bytes to the public
`readRplidar` producer. The 3-point control passes on the baseline; the 200,000
point fixture fails with `RangeError`. Both pass after this change with exact
minimum, median, maximum, and coverage checks. Existing device tests provide
empty and mixed-input controls. The fixture uses no serial device or motor and
proves software decoding/aggregation only, not real hardware performance.
@@ -0,0 +1,29 @@
# ADR-391: Retain synthetic CSI provenance in analysis input
- Status: proposed
- Date: 2026-10-11
- Related: ADR-323 (RAC1 synthetic flag), ADR-373 (CSI analysis)
## Context
The RAC1 decoder exposes the firmware's synthetic flag and the waterfall retains
it, but the kernel-analysis frame collector discarded it. Analysis of synthetic
input consequently returned the same live-CSI explanatory note as unmarked input,
preventing callers from distinguishing synthetic-inclusive estimates.
## Decision
Track whether any retained analysis frame is firmware-marked synthetic, expose
that boolean in `analysis.input.synthetic`, and explicitly describe synthetic
input in the estimate note. Frames rejected by the analysis buffer cap do not
change the provenance of the retained computation. Keep arrival-rate measurements
and raw packet diagnostics unchanged. An unmarked packet is not proof of physical
radio origin; this records an explicit upstream flag rather than certifying truth.
## Verification
The public capture producer receives protocol-valid RAC1 datagrams through owned
loopback UDP and invokes an injected deterministic analyzer. A mixed marked and
unmarked window fails the baseline provenance assertion. The fixed path retains
true for the mixed window and false for an unmarked control. This validates
software provenance propagation only, not sensing accuracy or hardware behavior.
@@ -0,0 +1,27 @@
# ADR-392: Return single valid frames for explicitly selected radar models
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (host device diagnostics)
## Context
The serial radar reader requires two valid frames before auto-detecting a model.
That confidence threshold was also applied to an explicitly selected model,
rejecting a valid short capture with one decoded presence or vital report as
`no_valid_frames`. The caller already supplied the model and baud selection.
## Decision
Require two frames for auto detection and one for an explicitly selected model.
Retain the empty-capture failure, checksums, parsing, capture permissions, and
all reported values. A single decoded frame is a successful software read and
is not evidence of clinical accuracy or sustained radar performance.
## Verification
An offline serial-pump fixture supplies one checksum-valid MR60BHA2 presence
report through the public reader. Explicit model selection fails on the baseline
and returns one frame with presentFraction=1 after the change. The same input in
auto mode remains refused. Existing multi-frame and invalid-input device tests
remain controls. No serial port or hardware was opened by this verification.
@@ -0,0 +1,28 @@
# ADR-393: Use the arithmetic median for even LiDAR samples
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (range and depth diagnostics)
## Context
The shared LiDAR median helper selected the upper middle sample for even-sized
sets. The public depth reader reported 4 metres for confident depths of 1 and
4 metres, and the serial scan reader likewise returned 4000 mm. Fields named
median should report the arithmetic median, 2.5 metres or 2500 mm respectively.
## Decision
Average the two central sorted samples for even counts and preserve the central
sample for odd counts. Empty sets remain null. Existing distance/confidence
filtering and metre/millimetre conversion remain in the public producers.
Update existing even-sample expectations to reflect the corrected numerical
contract; this intentionally changes these statistics for even counts.
## Verification
Offline protocol fixtures enter both public readers through their existing
transport injection boundaries. The baseline fails two even-sample cases and
passes an unsorted odd-sample control. The correction verifies both units and
zero-depth exclusion. No real LiDAR, serial device, or radar was used, and this
software calculation does not prove measurement accuracy.
@@ -0,0 +1,26 @@
# ADR-394: Apply strict SDK errors to diagnostic reports
- Status: proposed
- Date: 2026-10-11
- Related: ADR-369 (SDK operator interface)
## Context
A strict SDK instance throws `RuViewError` when an operation returns `ok:false`.
The doctor helper directly invokes the diagnostic producer to retain injectable
operator dependencies, bypassing that shared result check. Failed diagnostics
therefore silently resolved even when the caller selected strict mode.
## Decision
Extract the existing result check and apply it to both registry calls and doctor
reports. Preserve injected dependencies and retain the original structured report
on the thrown error. Non-strict calls and successful diagnostics continue to
resolve reports; the existing dry-run exception remains unchanged.
## Verification
The public SDK doctor runs a deterministic offline invalid-protocol diagnostic.
The baseline fails the promised strict rejection. The correction checks the exact
error class, tool identifier, and failed report; controls verify non-strict failure
reports and successful strict reports. No external service or device is accessed.
@@ -0,0 +1,27 @@
# ADR-395: Preserve missing numeric CLI values for schema rejection
- Status: proposed
- Date: 2026-10-11
- Related: ADR-369 (operator CLI)
## Context
The common device/training CLI flag adapter coerces values with `Number`. A bare
`--node-id` is parsed as boolean true and becomes node 1; `--node-id=` becomes
node 0. Both silently select a real node rather than reporting the missing
numeric argument. The same adapter handles capture deadlines and sample limits.
## Decision
Retain boolean and empty-string values in the common schema argument adapter so
existing numeric schemas reject them. Convert explicit non-empty numeric values
as before, including zero. Keep presentation flags and tool schemas unchanged.
This change is limited to verbs using the common snake-case argument adapter.
## Verification
Execute the actual CLI process with both missing-value forms and explicit zero.
An intentionally invalid UDP port ensures argument validation completes before
opening a socket. Baseline errors omit node_id for both missing forms; the fixed
CLI reports those numeric type errors while preserving the explicit zero control.
This is offline CLI/schema verification, not a radio capture.
@@ -0,0 +1,27 @@
# ADR-396: Convert ESPHome millimetre distances to reported centimetres
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 Amendment 3 (ESPHome radar reader)
## Context
ESPHome sensor discovery advertises each entity's unit. The reader maps entities
named distance into `distanceCmMean`, converting metres to centimetres but leaving
millimetres unchanged. An advertised 500 mm measurement therefore became 500 cm
instead of 50 cm, a factor-of-ten numerical error.
## Decision
Apply unit scales of 100 for metres and 0.1 for millimetres. Preserve centimetre
and existing unspecified/other-unit behavior, positive-distance filtering, and
entity metadata. Unit matching remains case insensitive. This is a correction
to reported units, not a change to sensor discovery or access permissions.
## Verification
The public reader connects to an owned loopback TCP fixture speaking the native
ESPHome protocol. Discovery and state updates carry 500 mm, 0.5 m, and 50 cm.
The millimetre baseline fails with 500 cm; metre and centimetre controls pass.
All three report 50 cm after the correction. This proves software protocol/unit
handling only; no real radar or external network service was accessed.
@@ -0,0 +1,27 @@
# ADR-397: Distinguish ESPHome received updates from retained samples
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 Amendment 3 (bounded ESPHome sessions)
## Context
ESPHome sessions count every incoming state update, but each entity retains only
its first 10,000 samples. The entity report used the retained length as `updates`,
understating actual arrivals once this cap was reached. Mean values also came from
that bounded subset without exposing how many updates were omitted.
## Decision
Maintain a separate arrival counter per entity. Report that count as `updates`,
retain `sampledUpdates` for the aggregation subset, and disclose `droppedSamples`.
Preserve the existing buffer bound and latest-value behavior. Existing hand-built
summary sessions without the new counter fall back to their retained sample count.
## Verification
An owned loopback native-protocol fixture emits 10,001 distance state updates
through the public reader. The baseline reports session=10,001 but entity=10,000.
The correction reports received=10,001, retained=10,000, omitted=1, with unchanged
50 cm mean. An existing small multi-entity fixture verifies zero omitted samples
and correct ordinary counts. This is software aggregation verification only.
@@ -0,0 +1,28 @@
# ADR-398: Treat nonfinite ESPHome sensor states as unavailable
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 Amendment 3 (native sensor-state semantics)
## Context
Native protobuf float states can encode NaN and infinities without setting the
missing-state flag. Positive infinity passed the distance filter and poisoned an
otherwise finite distance mean; last entity states also exposed nonfinite values
that become null only after JSON serialization, producing inconsistent reports.
## Decision
Treat a nonfinite sensor float as missing when ingesting a state. Preserve state
arrival counts, normal finite values, proto3 absent-float zero, explicit missing
flags, and binary/text state behavior. Existing summary rules then exclude the
invalid sample from means and report unavailable last state as null.
## Verification
Owned loopback native-protocol fixtures emit a 50 cm state followed by explicitly
encoded NaN, positive infinity, or negative infinity. Corrected baseline fixtures
fail on the nonfinite mean or last-state result; all fixed cases retain the 50 cm
mean, null latest state, and two arrival counts. The existing fixture controls
finite vitals, explicit missing states, and omitted proto3 zero values. No real
sensor or external service is accessed; this proves software numeric handling.
@@ -0,0 +1,29 @@
# ADR-399: Select the busiest CSI stream using total arrivals
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (bounded live CSI analysis)
## Context
The analysis collector advertises selection of the busiest node's dominant shape.
It ranks buffers by their retained frame length. Once multiple streams reach the
analysis buffer cap, their retained lengths tie and insertion order wins even
when a later stream received substantially more frames. A 70-frame first node
therefore beats a 100-frame second node under a 64-frame cap.
## Decision
Rank candidate streams by retained frames plus their existing dropped-over-cap
counter. Preserve the frame cap, first-window sample rate, explicit node filter,
and deterministic insertion order for actual arrival-count ties. The analyzer
still consumes only the retained bounded samples; ranking does not invent data.
## Verification
The public capture receives two protocol-valid node streams through owned
loopback UDP. The baseline selects the 70-frame node despite the second node's
100 arrivals. The correction selects node 2 and reports 64 retained/36 omitted
frames. An explicit node-1 control still selects node 1 and reports 64/6.
Existing uncapped analysis and decoding tests remain controls. This is software
stream-selection verification, not hardware throughput or sensing accuracy.
@@ -0,0 +1,31 @@
# ADR-400: Decode fused vitals into node diagnostics
- Status: proposed
- Date: 2026-10-11
- Related: ADR-063 (fusion), ADR-373 (node diagnostics)
## Context
The capture parser recognizes firmware magic `0xC5110004` as fused vitals but
returns only a byte count. Node aggregation then omits its RSSI and physiological
values. A valid fused-only stream therefore has null `lastVitals` even though the
firmware supplies the current fused heart and breathing estimates.
## Decision
Decode the exact 48-byte packed `edge_fused_vitals_pkt_t` from
`firmware/esp32-csi-node/main/edge_processing.h`. Share the common fields and fixed
point unit conversions with ordinary vitals, then expose the mmWave presence,
type, confidence, raw heart/breathing values, distance in centimetres, and target
count from their actual offsets. Aggregate fused reports into RSSI and latest
vitals while retaining their distinct packet kind counter. Reject truncated
fused reports before they can count as decoded node data.
## Verification and limits
A firmware-layout fixture enters the public capture over owned loopback UDP.
The baseline counts the packet but returns null RSSI and latest vitals. Fixed
assertions check 72 bpm fused heart, 15 bpm breathing, 120 cm distance, confidence
and target values, and timestamp units. Truncated-prefix tests and existing
ordinary-vitals tests remain controls. These are decoded device-reported values;
no clinical accuracy, real radio, or sensor-fusion quality is established.
@@ -0,0 +1,35 @@
# ADR-401: Validate auxiliary node datagrams before counting decoded reports
- Status: proposed
- Date: 2026-10-11
- Related: ADR-069 (features), ADR-039 (compression), feature-state and WASM firmware formats
## Context
Known auxiliary magics were sufficient to count a datagram as decoded. A
four-byte feature, compressed, feature-state, or WASM prefix created a phantom
node and successful diagnostic counts. Corrupt feature-state CRCs and truncated
variable payloads were also accepted, hiding actual data corruption.
## Decision
Validate firmware-defined payload envelopes before retaining their existing
metadata-only report shape: features require their 48-byte record, compressed
reports require a 10-byte header and declared non-empty payload, feature-state
requires the fixed 60-byte record and its IEEE CRC, and WASM output requires
an 8-byte header plus five bytes for each of at most 16 declared events. Keep
raw auxiliary payloads out of the response and retain existing kind counters.
Source contracts are `edge_processing.h`/`send_compressed_frame` in
`edge_processing.c`, `rv_feature_state.h`, and the variable-length producer in
`wasm_runtime.c`. This validates envelope integrity without claiming to decompress
CSI or interpret feature/event semantics.
## Verification
Owned loopback UDP drives the public capture with four complete envelope controls,
four magic-only prefixes, one corrupt CRC, and three truncated payloads. The
baseline counts all twelve as decoded and hides corruption. The correction counts
four decoded and eight malformed, with one valid node and exact kind counters.
Existing CSI, vitals, and other packet tests remain controls. No firmware or real
sensor was exercised; the claim is software format and diagnostic correctness.
@@ -0,0 +1,29 @@
# ADR-402: Preserve synthetic provenance for the whole CSI shape window
- Status: proposed
- Date: 2026-10-11
- Related: ADR-323 (RAC1 flags), ADR-373 (per-shape node diagnostics)
## Context
A normal capture's shape summary averages all frames but reports the synthetic
flag from only its latest packet. A marked frame followed by an unmarked frame
therefore contributes to the mean while removing the synthetic label shown by
normal CLI diagnostics. The waterfall already preserves a window-wide flag;
kernel-analysis input has a separate collector and provenance boundary.
## Decision
Track whether any frame in each statistical shape window is firmware-marked
synthetic and report that flag alongside the window mean/count. Preserve the
latest packet's other descriptor fields and keep provenance independent for each
shape. An unmarked window still reports false; that is not certification of its
physical origin. No capture or hardware authority changes.
## Verification
The public ordinary capture receives a marked RAC1 frame then an unmarked frame
through owned loopback UDP. The baseline reports two aggregated frames and false
synthetic provenance. The correction reports true; an entirely unmarked control
remains false. Existing shape and source statistics remain controls. This verifies
software window provenance, not sensing accuracy or authentic radio origin.
@@ -0,0 +1,28 @@
# ADR-403: Normalize chronological report timestamps without truthiness
- Status: proposed
- Date: 2026-10-11
- Related: ADR-371 (offline training evidence gate)
## Context
The gate used `Date.parse(value) || numericFallback`. Valid epoch-zero ISO times
parse to zero and were then treated as missing. Documented numeric millisecond
boundaries were first interpreted as calendar strings by Date.parse, so 5 ms and
30 ms could be misordered and falsely refused as temporal leakage.
## Decision
Retain finite numeric boundaries as milliseconds and parse string boundaries
once. Test finiteness explicitly rather than using truthiness. Preserve refusal
of invalid/missing dates and non-increasing train/test chronology. The tool schema
continues accepting its existing ISO string input; the public training export
retains its documented numeric input support.
## Verification
The actual SDK evaluates an epoch-zero ISO boundary with a later test timestamp.
The public training export evaluates numeric 5 ms and 30 ms boundaries. Both
baseline cases are falsely refused and pass after normalization. Equal-time and
invalid-date controls remain refused. Fixtures are explicitly synthetic; no
training, model calls, dataset claims, or publication is performed by these tests.
@@ -0,0 +1,31 @@
# ADR-404: Preserve missing capture windows in CLI watch trends
- Status: proposed
- Date: 2026-10-11
- Related: ADR-375 (per-node watch rate sparklines)
## Context
Watch draws repeated timed capture windows, but appends rate history only for
nodes present in a window. A present–absent–present node is therefore shown as
two adjacent observations, hiding the intervening outage. The sparkline helper
also discards missing positions, so merely adding null history entries would
still compress time. An actual terminal-mode CLI and owned loopback UDP replay
reproduces the false continuous two-bar trend.
## Decision
Append a missing entry for each previously observed node absent from a capture
window. Keep the existing 30-window bound. Render each missing position as `·`,
while scaling finite rates using only finite observations. Missing means no
observation in that window; it is not a measured zero rate. Initial history
still starts when a node is first observed. JSON capture output is unchanged.
## Verification
The public CLI receives one valid packet, an empty one-second capture, and one
returning packet. Its terminal row must show `▄·▄` instead of `▄▄`. A real child
process executes the CLI; only stdout's TTY capability is supplied by the test.
The test owns and cleans up its loopback sockets and child. Finite, zero,
all-missing and empty sparkline controls preserve scale and positions. This is
software replay, not a claim about radio hardware availability.
@@ -0,0 +1,31 @@
# ADR-405: Complete iPhone LiDAR captures when the relay stream closes
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (bounded device readers)
## Context
The iPhone reader completes at its packet limit, timeout, or pre-open failure.
After an opened WebSocket closes normally, however, it keeps the capture timer
running despite having no remaining source of frames. A one-frame stream that
closes immediately waits the entire requested window; an empty normal stream
also delays its no_frames result. This wastes the user's capture time and uses
the idle post-close interval in its reported receive rate.
## Decision
Finish on every close event. Retain accepted depth statistics. Preserve the
existing relay_closed refusal for abnormal empty streams, pre-open connection
failure, and max_frames termination. A normal empty stream still returns
no_frames; a stream with valid depth frames remains a successful capture. The
requested seconds field retains its existing meaning.
## Verification
An owned loopback HTTP server performs a real WebSocket upgrade and sends a
valid one-pixel depth frame followed by a normal close handshake. The public
reader must return promptly instead of waiting its two-second capture timeout.
A normal empty stream and abnormal empty code1008 stream check refusal behavior;
a max_frames=1 control checks existing early termination. These are transport
fixtures, not iPhone hardware or depth-accuracy evidence.
@@ -0,0 +1,31 @@
# ADR-406: Assign numeric radar roles only to ESPHome sensor entities
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 Amendment 3 (ESPHome native API reader)
## Context
The reader infers heart, breathing and distance roles from entity names without
checking their protocol kind. A binary Heart alarm or text Distance status can
therefore be selected before the actual numeric sensor. Boolean true becomes
1 bpm through arithmetic coercion; a numeric-looking text status becomes a
fabricated distance. The actual numeric sensor is then ignored. A loopback
native API producer reproduces this with valid entity metadata and states.
## Decision
Require sensor kind for heartBpm, breathingBpm and distanceCm roles, matching
the existing targets rule. Keep binary presence inference. Preserve binary and
text entities and their raw states in the entities result, without assigning
them numeric radar roles. Numeric scalar filtering and unit handling remain
unchanged. No medical accuracy or sensor-name taxonomy is introduced.
## Verification
A real TCP producer lists binary alarm and text status entities before valid
numeric heart, distance and breathing sensors. The public reader must select
72 bpm,120 cm and15 bpm rather than1,50 and1. A producer with only nonnumeric
entities must return null numeric summaries while retaining its3 raw updates.
Existing ESPHome presence, missing-state and numeric sensor controls run too.
These are deterministic native API fixtures, not radio or clinical evidence.
@@ -0,0 +1,32 @@
# ADR-407: Preserve interrupted serial-capture state in device summaries
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (bounded serial pump and device readers)
## Context
The pump can emit valid HEX chunks before its process fails. readSerial retains
those useful bytes, but discards the failure whenever bytes exist. Radar and
LiDAR callers consequently present an ordinary successful capture without any
indication that the requested window was interrupted. Auto-detected radar also
silently retains only its probe if the remainder capture fails.
## Decision
Keep valid partial bytes and measurements. Mark the capture as incomplete and
include a warning when the pump process fails after producing data. Propagate
that state through both serial device summaries, including an auto-detection
remainder failure. Do not expose subprocess stderr in the warning. Existing
failures without bytes and normal complete capture behavior remain unchanged.
The flag describes transport completeness, not measurement accuracy.
## Verification
The actual Python PUMP_SCRIPT runs through the production execTool process
runner, with a temporary fixture serial module that emits valid radar or scan
bytes then raises. The public serial, radar and LiDAR readers must retain data
and report captureIncomplete. A real normally completing pump is the control.
Auto-detection also receives a normal probe followed by a failing empty pump.
All serial port operations stay inside the fixture module; no hardware opens,
physical motor commands, radio transmissions or model calls occur.
@@ -0,0 +1,31 @@
# ADR-408: Exclude absent LD2410 targets from distance means
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (shared mmWave summary)
## Context
The LD2410 decoder explicitly emits present=false and distanceCm=0 for target
state none. The aggregate treats that no-target sentinel as a measured range.
A 90 cm detection followed by an absent target consequently reports 45 cm; an
all-absent stream reports 0 cm instead of having no distance observation. The
presence fraction already identifies these absent frames correctly.
## Decision
Exclude explicitly absent target frames only from the distance mean. Keep their
raw distance sentinel, frame counts, rates, and presence contribution. Continue
averaging moving and stationary target measurements normally. MR60BHA2 distance
frames are unchanged; they use their own range-validity field in the decoder.
This distinguishes absence from a distance measurement, without new physical
range limits or an accuracy claim.
## Verification
The actual Python serial pump and production process runner read complete
LD2410 frames from an isolated serial module. Public readMmwave must report 90 cm
for present 90/absent, null for all-absent, and 150 cm for moving 90/static 210.
Presence fractions and raw last-frame zero controls remain intact. The baseline
fails both absence regressions and passes the positive-distance control. No
hardware port opens or radar transmissions occur.
@@ -0,0 +1,35 @@
# ADR-409: Align SDK device argument types with supported runtime options
- Status: proposed
- Date: 2026-10-11
- Related: ADR-369 (typed SDK), ADR-373 (device readers), ADR-378 (spectrum)
## Context
The SDK forwards device arguments to the current tool registry, but its shipped
TypeScript declarations still describe earlier options. A real TypeScript
consumer cannot select ESPHome by source/host/API port, request ESP32 spectrum
rows, or set an iPhone depth packet cap. Each workflow already works through the
same public SDK at runtime. Explicit serial source is missing too.
## Decision
Describe mmWave arguments as serial (the existing default, requiring port) or
ESPHome (requiring host). Add the existing optional spectrum parameters and
iPhone max_frames. Keep numeric and enum constraints, and all existing serial
call shapes. This changes declarations only; no reader or tool behavior changes.
Group these omissions as one declaration-drift fix rather than separate fields.
## Verification
A TypeScript consumer compiled with strict NodeNext checks fails on the valid
runtime options before the fix and succeeds after it. Its expected-error controls
retain host/port requirements, numeric parameter types, and known backends. The
same public SDK is exercised with real owned TCP ESPHome, UDP CSI, and WebSocket
depth producers: numeric state, spectrum bins, and packet-cap termination all
work before and after the declaration change. No hardware or external service
is involved. The fixture can be replayed with an installed TypeScript compiler:
```
tsc --strict --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext test/fixtures/device-types.mts
```
@@ -0,0 +1,32 @@
# ADR-410: Require callback/frame markers for CSI monitor flow evidence
- Status: proposed
- Date: 2026-10-11
- Related: ADR-373 (serial monitor), ADR-375 (starvation diagnostics)
## Context
The monitor counted any line containing the ESP32 csi_collector log tag as a
CSI callback. Current firmware uses that tag for early configuration, startup,
self-ping failures and other metadata as well as actual callbacks. A startup
log with no callback consequently returned ok=true and falsely certified flow.
The source's actual callback line uses `CSI cb #`; Realtek uses
`RUVIEW_CSI: frame #`.
## Decision
Count only those explicit callback/frame markers as flow evidence. Keep the
existing startup-upgrade metadata and independent buffer-starvation detection.
The existing count remains a count of observed rate-limited log markers, not a
radio callback-rate measurement. No UART, reset or hardware control behavior
changes. Older log formats with only configuration tags cannot certify flow.
## Verification
The public SDK monitor runs the actual Python monitor script in a real child
process. An isolated serial module supplies current firmware startup/self-ping
messages; the baseline falsely succeeds, and the fix returns no-flow diagnostics.
Mixed startup plus ESP32/Realtek frame markers counts only two observations.
A real frame plus a starvation message still fails as csi_buffer_starvation.
Existing monitor rendering and Python fixture controls run too. No physical
serial port opens or node resets occur.
@@ -0,0 +1,32 @@
# ADR-411: Preserve structured doctor diagnostics for package corruption
- Status: proposed
- Date: 2026-10-11
- Related: ADR-370 (doctor), ADR-394 (SDK strict diagnostic contract)
- Promotion: extend existing doctor PR #2208; no separate publication unit
## Context
The package-integrity diagnostic reports hash drift as a structured warning, but
malformed manifest JSON or a missing outer checksum throws before the doctor
report is assembled. The public SDK therefore cannot return the promised
checks/remedies for those ordinary package-corruption cases. A copied-package
fixture reproduces both exceptions without touching the installed manifest.
## Decision
Convert manifest read/parse and packaged-file read failures into integrity
findings. Report a missing or unreadable checksum explicitly. Preserve existing
hash comparison, file counts, diagnostic report shape and warning policy. SDK
strict mode continues rejecting failed reports through the existing checked
path; integrity warnings retain their current warning behavior. No additional
manifest schema or trust policy is introduced.
## Verification
The actual public SDK is imported from an owned temporary copy containing the
package source and reviewed brain. Strict doctor with groups=harness must return
an integrity warning and reinstall remedy for malformed JSON, a missing or
unreadable checksum, and unreadable packaged data. The valid-copy control retains successful integrity status. Existing
doctor and strict failed-report controls run alongside these tests. No canonical
manifest changes, credentials, network requests or hardware operations occur.
@@ -0,0 +1,30 @@
# ADR-412: Quarantine incomplete recorded I/Q pairs during transcode
- Status: proposed
- Date: 2026-10-11
- Related: ADR-096 and ADR-097 (rvCSI capture bridge)
## Context
The public ESP32 JSONL converter is the documented capture fixture bridge. Its
contract drops rejected frames. It currently floors odd byte lengths, silently
accepting a truncated I/Q pair as a shorter valid frame; malformed hex instead
aborts the entire capture and loses later valid frames. The existing rej_len
counter is never used.
## Decision
Reject invalid hex and odd byte lengths before constructing numeric samples.
Increment the existing rej_len counter once for each rejected record and
continue processing later frames. Preserve complete int8 pair conversion,
quality scoring, timestamps, and output format. Do not infer missing samples or
change adapter calibration or hardware policy.
## Verification
Actual public Python CLI processes owned temporary JSONL captures with valid
frames before and after each damaged record. The baseline accepts the odd pair
and aborts on invalid hex. Fixed runs retain the two complete frames with
contiguous frame IDs and truthful counters. A signed-IQ control verifies
amplitude and real/imaginary orientation. All eight harness gates run alongside
these subprocess tests. This is offline recording evidence only.
@@ -0,0 +1,29 @@
# ADR-413: Preserve complete UDP datagrams in the CSI relay
- Status: proposed
- Date: 2026-10-11
- Related: ADR-018 (ESP32 CSI packet layout)
## Context
The public Python relay promises byte-for-byte forwarding without parsing or
mutation. Its 2048-byte receive buffer truncates larger datagrams; a valid
ADR-018 shape with four antennas and 256 subcarriers occupies 2068 bytes. Its
truthiness check also discards empty UDP datagrams instead of forwarding them.
Both behaviors violate the transparent relay contract.
## Decision
Receive up to 65535 bytes, sufficient for a complete IPv4 UDP payload, and
forward every received datagram, including an empty payload. Retain None as the
socket-timeout sentinel. Preserve bind defaults, destination selection,
reporting and cleanup. Introduce no packet interpretation or hardware policy.
## Verification
Actual public Python CLI relays traffic between owned loopback sockets. The
baseline truncates a complete 2068-byte ADR-018 packet and fails to forward an
empty datagram; a small binary payload is unchanged. Fixed runs compare every
byte and retain both controls. All eight harness gates run with these actual
subprocess tests. This proves local datagram preservation, not physical CSI
capture quality or network performance.
@@ -0,0 +1,31 @@
# ADR-414: Use ADR-018 decoding for overnight capture segments
- Status: proposed
- Date: 2026-10-11
- Related: ADR-018 (wire layout), ADR-135 (empty-room corpus)
## Context
The overnight empty-room recorder claims the same ADR-018 layout as the ordinary
UDP recorder, but still reads an obsolete eight-byte header. It records the
subcarrier-count bytes as RSSI/channel, consumes the remaining header as I/Q,
and accepts unrelated or truncated datagrams. This corrupts baseline corpus
samples before downstream analysis.
## Decision
Reuse record-csi-udp.py's existing twenty-byte ADR-018 parser through a local
module import. Preserve segment naming, duration and receive workflow. The
shared parser retains receive timestamps, decodes metadata and declared
antenna-major payload, ignores trailing bytes, and rejects unsupported magic or
truncated payloads. Avoid a second copy of the packet layout and do not alter
quality, calibration or physical capture policy.
## Verification
Actual public overnight CLI writes owned temporary segment files from loopback
UDP traffic. Baseline reports the wrong RSSI, channel and payload and retains
invalid packets. Fixed tests verify declared multi-antenna amplitudes and raw IQ,
metadata, trailing-byte handling, and valid-frame continuity after invalid or
truncated datagrams. All eight harness gates run alongside these tests.
This is deterministic software packet evidence only; no hardware accuracy claim.
@@ -0,0 +1,31 @@
# ADR-415: Allocate distinct training capture files on session-name collisions
- Status: proposed
- Date: 2026-10-11
- Related: ADR-018 (collector wire input)
## Context
The training collector names sessions to whole seconds and opens recordings in
append mode. Two captures with the same label during that second append samples
to one file and overwrite its metadata with only the later frame count. Dataset
manifests then undercount the actual recording and the original session loses
its identity. Fractional CLI durations and concurrent collection make the
collision reachable.
## Decision
Allocate each recording with exclusive file creation. Retain existing names
when available; on a data or metadata collision, try a numeric suffix until
allocation succeeds. Update session ID, data path and metadata path together.
Preserve prior recordings and metadata instead of implicitly resuming them.
No CLI resume contract exists; ordinary collection creates a new session.
## Verification
Two actual public collector CLI processes receive distinct owned loopback
packets under an isolated fixed datetime fixture that reproduces a same-second
collision deterministically. Baseline leaves one combined data file and one
miscounted metadata entry. Fixed captures retain distinct IDs, one sample per
file, matching metadata counts and a two-session/two-frame manifest. All eight
harness gates run. No hardware capture or physical accuracy is claimed.
@@ -0,0 +1,34 @@
# ADR-416: Validate alignment CLI window parameters before processing captures
- Status: proposed
- Date: 2026-10-11
- Related: ADR-079 (ground-truth pairing)
## Context
The public ground-truth alignment CLI parses window and numeric options without
validation. A zero window frame count makes the windowing loop advance by zero
and grow indefinitely. Negative counts also cannot advance correctly, and
fractional or partially numeric values are silently truncated. Invalid camera
counts, confidence thresholds and time values can bypass matching checks or
produce misleading paired output.
## Decision
Parse complete numeric values and validate before reading or writing captures.
Require positive safe integer frame counts, finite nonnegative window duration,
confidence in [0,1], and finite signed clock offsets. Retain zero expansion and
zero confidence as supported controls. Report the offending option and exit2
for invalid inputs. Preserve valid windowing and pairing calculations.
## Verification
Actual CLI subprocesses process owned tiny feature/camera recordings. The
baseline zero/negative count processes are bounded by a one-second timeout and
32MiB heap cap; fractional/malformed/range cases lack the expected early error.
The fixed CLI rejects each invalid option before output creation and retains
complete valid windows, zero expansion/confidence and a signed finite offset.
An initial fixture passed negative values as separate tokens, which Node's
argument parser already rejects; that attempt is retained but invalidated.
Final regressions use documented --option=value syntax. All eight harness gates
run. No model training, camera, physical capture or hardware operation occurs.
@@ -0,0 +1,29 @@
# ADR-417: Preserve input recordings when converter output aliases the source
- Status: proposed
- Date: 2026-10-11
- Related: ADR-412 (capture converter input quarantine)
- Promotion: extend existing converter PR #2227; no new publication unit
## Context
The public JSONL converter opens output in write mode before reading input. If
both arguments refer to one file, including hard-link or symbolic-link aliases,
it truncates the recording and reports successful conversion with zero frames.
Input validation must not destroy the original evidence it is meant to read.
## Decision
Check existing file identity before opening the output. Reject identical files
with a clear CLI error and exit2, preserving source bytes and links. Keep normal
distinct-path conversion and the existing incomplete-IQ quarantine unchanged.
This does not add in-place conversion or a broader transactional output policy.
## Verification
Actual public Python CLI receives owned input captures with identical, hard-link
and symbolic-link output paths. Baseline overwrites each source; fixed runs
leave the original bytes intact and reject the arguments. Existing signed IQ,
quarantine and later-frame controls pass alongside these fixtures. All eight
harness gates run. Original capture files outside temporary fixtures are never
mutated. This extends the existing converter PR as one reviewed unit.
@@ -0,0 +1,30 @@
# ADR-418: Handle expected resets in owned ESPHome rejection fixtures
- Status: proposed
- Date: 2026-10-11
- Related: ADR-406 (ESPHome fixture-driven role validation)
- Promotion: extend existing ESPHome PR #2221; no new publication unit
## Context
The encrypted/password test fixtures write while the reader deliberately rejects
and closes the connection. The server socket can then emit ECONNRESET after the
test ends. With no socket error listener, that expected fixture event produces
an uncaught exception and intermittent unrelated full/security gate failures.
Converter and alignment failures retained locally show this exact test event.
## Decision
Handle ECONNRESET only in the owned encrypted/password fixture modes. Continue
throwing any unexpected error code and any reset in ordinary fixture mode. Do
not change the production reader, its failure reports or its network policy.
## Verification
Actual public reader connects to owned TCP fixtures and returns the expected
rejection report. A controlled socket reset event reproduces the missing error
lifecycle deterministically: baseline throws, fixed modes accept the reset.
Negative controls retain failures for EIO in rejection mode and ECONNRESET in
ordinary mode. Existing roles and public reader tests, plus all eight gates,
run on this revision. This is an explicitly injected software fixture event,
not physical device or hardware evidence.
+5
View File
@@ -2,6 +2,9 @@
Latest proposed decisions:
- [ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back](ADR-379-ota-first-boot-health-check.md)
- [ADR-380: Loopback UDP tee for second consumers of the CSI stream](ADR-380-loopback-udp-tee.md)
- [ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations](ADR-382-spatial-evidence-rf-export.md)
- [ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells](ADR-378-ruview-live-showcase-views.md)
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
@@ -74,6 +77,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-040](ADR-040-wasm-programmable-sensing.md) | WASM Programmable Sensing (Tier 3) | Accepted |
| [ADR-041](ADR-041-wasm-module-collection.md) | WASM Module Collection (65 edge modules) | Accepted (hardware-validated) |
| [ADR-044](ADR-044-provisioning-tool-enhancements.md) | Provisioning Tool Enhancements | Proposed |
| [ADR-379](ADR-379-ota-first-boot-health-check.md) | OTA'd image confirms itself with a bounded health check (IP + CSI frame) or rolls back; OTA httpd stack sized from device data | Proposed (host-tested; hardware-verified on one S3 and one C6) |
| [ADR-110](ADR-110-esp32-c6-firmware-extension.md) | ESP32-C6 firmware extension — Wi-Fi 6 / 802.15.4 / TWT / LP-core | Accepted, P1-P10 complete, firmware-side substrate closed at **[v0.7.0-esp32](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)**. Companion docs: [`WITNESS-LOG-110`](../WITNESS-LOG-110.md) (13 §A0.x entries · 99.56 % cross-board RX · **104.1 µs smoothed sync stdev** · ≤100 µs target met), [`ADR-110-REVIEW-GUIDE`](../ADR-110-REVIEW-GUIDE.md) (one-page reviewer tour), [`ADR-110-BRANCH-STATE`](../ADR-110-BRANCH-STATE.md) (coordination map vs `feat/adr-115-ha-mqtt-matter`). Host decoders + tests: Python `SyncPacketParser` (10) + Rust `wifi_densepose_hardware::SyncPacket` (15), cross-language hex pin gates drift. |
### Signal processing and sensing
@@ -181,6 +185,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-294](ADR-294-wifi-veil-integration.md) | WiFi Veil integration — emission-shaping countermeasure as advisory BFLD dependency | Accepted (initial implementation) |
| [ADR-295](ADR-295-source-provenance-state-machine.md) | Source provenance state machine — synthetic can never present as live | Accepted (initial implementation) |
| [ADR-296](ADR-296-sensor-data-plane-bind-hardening.md) | Sensor data-plane hardening — UDP bind control and source allowlist (step one) | Accepted (initial implementation) |
| [ADR-380](ADR-380-loopback-udp-tee.md) | Loopback UDP tee for second consumers of the CSI stream (`--udp-tee`) | Proposed |
| [ADR-297](ADR-297-multi-node-semantic-correctness.md) | Multi-node semantic correctness — per-node inference, node-keyed rate limiting, stale state | Accepted (initial implementation) |
| [ADR-298](ADR-298-model-release-sanity-gates.md) | Model release sanity gates — block degenerate and mislabeled model artifacts | Accepted (initial implementation) |
| [ADR-299](ADR-299-csi-data-incident-repo-controls.md) | Repository CSI data-incident controls — ignore rules and pre-commit/CI policy check | Accepted (controls implemented; tree remediation gated) |
+89 -81
View File
@@ -33,59 +33,82 @@ cargo run --release -p wifi-densepose-sensing-server \
--mqtt-username homeassistant
```
Within ~5 seconds of starting, Home Assistant should auto-create:
Within ~5 seconds of the first CSI frame, Home Assistant should auto-create:
- One **device** per RuView node (named after the MAC or the `friendly_name` from your zones config)
- 17+ **entities** per device (presence, person count, heart rate, breathing rate, motion, fall events, signal strength, zones, and the 10 semantic primitives)
- One **device** per RuView node, named `RuView node <client-id>-node<N>` (see [Device identity](#device-identity))
- **15 entities** per device (13 with `--privacy-mode`): the raw signals and six semantic primitives listed in the [Entity reference](#entity-reference)
If nothing appears in HA's Settings → Devices, see [Troubleshooting](#troubleshooting).
### 3. Stop the publisher cleanly
Ctrl-C — the publisher pushes `offline` to every availability topic before disconnect so HA marks all entities unavailable instantly. A `kill -9` triggers MQTT LWT, which has the same effect within ~30 s.
Ctrl-C (SIGINT/SIGTERM) stops the server. It does **not** publish `offline` first, and no MQTT last-will is registered, so every availability topic keeps its last retained value until the server starts again (MEASURED: no availability message in the last 6 s of a SIGTERM'd run, see [ADR-115 §11](../adr/ADR-115-home-assistant-integration.md#11-status-note-p45-semantic-states-on-the-live-publisher-2026-10-02)). While the server runs, a node that stops sending frames goes `offline` at the next 30 s availability heartbeat.
---
## Entity reference
RuView publishes three classes of entity. Names below are the `unique_id` slugs — Home Assistant assigns friendly names automatically.
Names below are the `unique_id` slugs; Home Assistant assigns friendly names automatically. Every entity has its own availability topic (`…/<slug>/availability`, retained). An entity is `online` only while the server has a real source for it. Otherwise it is `offline`, no state is published, and Home Assistant shows **unavailable** rather than a made-up value.
### Raw signals (11 entities)
### Raw signals (9 entities)
| HA entity | Slug | HA component | Unit | Source field |
| HA entity | Slug | HA component | Unit | Source | Available when |
|---|---|---|---|---|---|
| Presence | `presence` | `binary_sensor` | — | `nodes[].node_inference.classification != "absent"` | always |
| Person count | `person_count` | `sensor` | persons | `persons[]` length, else `estimated_persons` (room-level, same on every node) | always |
| Motion level | `motion_level` | `sensor` | % | node classification: `present_moving` = 100, `present_still`/`absent` = 0 | always |
| Presence score | `presence_score` | `sensor` | % | node classification confidence × 100 while present, else 0 | always |
| Motion energy | `motion_energy` | `sensor` | (dimensionless) | `node_features[].features.motion_band_power`, else the room `features.motion_band_power` | a band-power value was seen in the last 30 s |
| Signal strength | `rssi` | `sensor` | dBm | `nodes[].rssi_dbm` | an RSSI was seen in the last 30 s |
| Heart rate | `heart_rate` | `sensor` | bpm | `vital_signs.heart_rate_bpm` (room-level) | a published estimate in the last 30 s |
| Breathing rate | `breathing_rate` | `sensor` | bpm | `vital_signs.breathing_rate_bpm` (room-level) | a published estimate in the last 30 s |
| Fall detected | `fall` | `event` | — | rising edge of the node's `edge_vitals.fall_detected` | the node sent `edge_vitals` in the last 30 s |
The server publishes heart and breathing rate only after explicit calibration, with exactly one occupant and a passing quality gate. Until then both entities stay unavailable. Heart rate and breathing rate are **biometric**: `--privacy-mode` removes them from discovery and state (see [Privacy](#privacy)).
### Semantic automation primitives (6 entities)
Small finite-state machines run inside the server, one set per node, sampled once per second ([ADR-115 §3.12](../adr/ADR-115-home-assistant-integration.md#312-semantic-automation-primitives-ha-mind), P4.5). All of them are unavailable for the first 60 s after start (warmup). After that each publishes its current state, then on every change.
| HA entity | Slug | HA component | What it fires on | Available when |
|---|---|---|---|---|
| Presence | `presence` | `binary_sensor` | — | `edge_vitals.presence` |
| Person count | `person_count` | `sensor` | persons | `edge_vitals.n_persons` |
| Heart rate | `heart_rate` | `sensor` | bpm | `edge_vitals.heartrate_bpm` |
| Breathing rate | `breathing_rate` | `sensor` | bpm | `edge_vitals.breathing_rate_bpm` |
| Motion level | `motion_level` | `sensor` | % | `edge_vitals.motion` × 100 |
| Motion energy | `motion_energy` | `sensor` | (dimensionless) | `edge_vitals.motion_energy` |
| Fall detected | `fall` | `event` | — | `edge_vitals.fall_detected` |
| Presence score | `presence_score` | `sensor` | % | `edge_vitals.presence_score` × 100 |
| Signal strength | `rssi` | `sensor` | dBm | `edge_vitals.rssi` |
| Zone occupancy | `zone_occupancy` | `binary_sensor` | — | `sensing_update.zones` |
| Pose keypoints | `pose` | `sensor` (attrs) | — | `pose_data.keypoints` (opt-in via `--mqtt-publish-pose`) |
| Room active | `room_active` | `binary_sensor` | presence + motion >10% within a 30 s window; off after 10 min idle | after warmup |
| No movement (safety) | `no_movement` | `binary_sensor` | presence + motion <1% for 30 min | after warmup |
| Elderly inactivity anomaly | `elderly_inactivity_anomaly` | `binary_sensor` | stillness > 2× the longest stillness seen since start (floor 30 min) | after warmup |
| Someone sleeping | `someone_sleeping` | `binary_sensor` | presence + motion <5% + BR ∈ [8,20] bpm for 5 min | after warmup, while breathing rate is available |
| Possible distress | `possible_distress` | `binary_sensor` | HR ≥ 1.5× resting baseline + motion >20% + no fall, for 60 s | after warmup, while heart rate is available |
| Fall risk elevated | `fall_risk_elevated` | `sensor` | 0–100 score: 10 per fall in 24 h + 50 × motion variance over 60 s | after warmup, while the fall detector is available |
Heart rate, breathing rate, and pose are **biometric** entities — they are stripped from MQTT (and never published over Matter) when `--privacy-mode` is set. See [Privacy](#privacy) below.
Per-node motion is a three-level classification, so the motion thresholds above effectively mean "moving" or "not moving". The precision and recall figures in [`semantic-primitives-metrics.md`](./semantic-primitives-metrics.md) are CLAIMED: the replay tooling they cite is not in the repository. No `reason` attribute is published yet; the explanation tags stay server-side.
### Semantic automation primitives (10 entities)
### Not announced
These are the inferred high-level states that customer automations actually use. Each one is a small finite-state machine running server-side with explicit warmup, hysteresis, and refractory windows. Per-primitive precision/recall is published in [`semantic-primitives-metrics.md`](./semantic-primitives-metrics.md).
These entities from ADR-115 §3.1 and §3.12 are not announced, because the sensing broadcast has nothing to drive them. Announcing them only produced entities stuck at "unknown" ([#2085](https://github.com/ruvnet/RuView/issues/2085)).
| HA entity | Slug | HA component | What it fires on |
|---|---|---|---|
| Someone sleeping | `someone_sleeping` | `binary_sensor` | presence + motion<5% + BR ∈ [8,20] bpm sustained for 5 min |
| Possible distress | `possible_distress` | `binary_sensor` | HR > 1.5× baseline + motion >20% + no fall, sustained 60 s |
| Room active | `room_active` | `binary_sensor` | motion >10% in a 30-s rolling window |
| Elderly inactivity anomaly | `elderly_inactivity_anomaly` | `binary_sensor` | idle > 2× observed-max-idle baseline |
| Meeting in progress | `meeting_in_progress` | `binary_sensor` | ≥2 persons + low-amplitude motion for 10 min |
| Bathroom occupied | `bathroom_occupied` | `binary_sensor` | presence + active zone tagged `bathroom` |
| Fall risk elevated | `fall_risk_elevated` | `sensor` | 0–100 score; event fires on ≥70 crossing |
| Bed exit (overnight) | `bed_exit` | `event` | sleeping → presence leaves bed zone between 22:00–06:00 |
| No movement (safety) | `no_movement` | `binary_sensor` | presence + motion <1% for 30 min |
| Multi-room transition | `multi_room_transition` | `event` | zone X exit + zone Y enter within 10 s |
| Slug | Why not |
|---|---|
| `zone_occupancy`, `bathroom_occupied`, `bed_exit`, `multi_room_transition` | the broadcast carries no zones, and the server loads no zone-tag map |
| `meeting_in_progress` | needs a 1–20 % motion level; per-node motion is three-level |
| `pose` | `pose_keypoints` is never populated on the broadcast, and the `persons[].keypoints` skeletons are synthesized. `--mqtt-publish-pose` is accepted but logs a warning and announces nothing |
Every state change carries a `reason` attribute (e.g. `["motion<5%", "br=12bpm", "presence=true"]`) so you can template against it in HA automations to understand why an automation triggered.
### Publish rates and retention
| Entity kind | When it publishes | QoS / retain |
|---|---|---|
| `presence` and semantic `binary_sensor`s | on change, plus a heartbeat every 60 s if unchanged | 1 / retained |
| `person_count`, `motion_level`, `motion_energy`, `presence_score`, `fall_risk_elevated` | at most `--mqtt-rate-motion` / `--mqtt-rate-count` (default 1 Hz) | 0 / not retained |
| `heart_rate`, `breathing_rate` | at most `--mqtt-rate-vitals` (default 0.2 Hz) | 0 / not retained |
| `rssi` | at most `--mqtt-rate-rssi` (default 0.1 Hz) | 0 / not retained |
| `fall` | once per detected fall | 1 / not retained |
| availability | on every transition, and all topics every 30 s | 1 / retained |
Before [#2093](https://github.com/ruvnet/RuView/issues/2093), `presence` was published on every CSI frame (MEASURED 37.9 msg/s with one node, 1 msg per ~45 s after the fix; see [ADR-115 §11](../adr/ADR-115-home-assistant-integration.md#11-status-note-p45-semantic-states-on-the-live-publisher-2026-10-02)).
### Device identity
Each node's HA device id is `wifi_densepose_<client-id>-node<N>`, where `<N>` is the node id from the CSI frames. Without `--mqtt-client-id`, the server creates `wifi-densepose-<8 hex digits>` on first start and keeps it in `<data-dir>/mqtt_client_id`, so device ids survive restarts. Keep `--data-dir` on persistent storage (a Docker volume, for example). If the file cannot be written, the server logs a warning and falls back to a per-process id for that run. `--mqtt-client-id` overrides the persisted id. Use it to pin ids across reinstalls or to run two servers against one broker.
Releases before this change used `wifi-densepose-<pid>`, so every restart created a new set of devices. To remove the leftovers, publish an empty retained message to each stale `homeassistant/+/wifi_densepose_<old-id>…/+/config` topic, or delete the devices in Home Assistant.
### Matter device-type mapping
@@ -115,7 +138,7 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so
| `--mqtt-port <PORT>` | 1883 (8883 with TLS) | Broker port |
| `--mqtt-username <U>` | — | Username for broker auth |
| `--mqtt-password-env <VAR>` | `MQTT_PASSWORD` | Env var holding the password |
| `--mqtt-client-id <ID>` | `wifi-densepose-<hostname>` | MQTT client ID |
| `--mqtt-client-id <ID>` | persisted `wifi-densepose-<hex>` in `<data-dir>/mqtt_client_id` | MQTT client ID; prefixes every HA device id |
| `--mqtt-prefix <PREFIX>` | `homeassistant` | Discovery topic prefix |
| `--mqtt-tls` | off | Encrypt connection |
| `--mqtt-ca-file <PATH>` | — | Pinned CA for TLS / mTLS |
@@ -126,43 +149,18 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so
| `--mqtt-rate-motion <HZ>` | 1.0 | Motion publish rate (Hz) |
| `--mqtt-rate-count <HZ>` | 1.0 | Person-count publish rate (Hz) |
| `--mqtt-rate-rssi <HZ>` | 0.1 | RSSI publish rate (Hz) |
| `--mqtt-publish-pose` | off | Enable pose-keypoint publication |
| `--mqtt-rate-pose <HZ>` | 1.0 | Pose publish rate when enabled |
| `--privacy-mode` | off | Strip HR/BR/pose from MQTT and Matter |
| `--matter` | off | Enable the HA-FABRIC Matter Bridge |
| `--matter-setup-file <PATH>` | — | Where to write the QR + manual code |
| `--matter-reset` | off | Wipe fabric credentials and re-commission |
| `--matter-vendor-id <VID>` | `0xFFF1` (dev) | CSA-assigned vendor ID |
| `--matter-product-id <PID>` | `0x8001` | Product ID |
| `--semantic` | on | Enable inference layer |
| `--semantic-thresholds-file <PATH>` | — | Per-primitive threshold overrides |
| `--semantic-zones-file <PATH>` | — | Zone-tag map (`bathroom`, `bedroom`, …) |
| `--no-semantic <PRIMITIVE>` | — | Disable a specific primitive (repeatable) |
| `--mqtt-publish-pose` | off | Accepted; no pose source exists, so it only logs a warning |
| `--mqtt-rate-pose <HZ>` | 1.0 | Unused while no pose entity is announced |
| `--privacy-mode` | off | Strip HR/BR/pose from MQTT, Matter, REST, WebSocket and recordings |
| `--data-dir <DIR>` | `data` | Holds `mqtt_client_id` (and other server state) |
### Zone tag file format
The `--semantic`, `--semantic-thresholds-file`, `--semantic-zones-file` and `--no-semantic` flags from ADR-115 §3.12.5 are **not accepted by `sensing-server` yet**. The primitives always run with the defaults in `PrimitiveConfig`, and no zone map is loaded.
```yaml
# semantic-zones.yaml — passed to --semantic-zones-file
zones:
bathroom: ["zone_3", "zone_7"]
bedroom: ["zone_1"]
kitchen: ["zone_2"]
living: ["zone_5"]
bed_zones: ["zone_1"]
```
The same goes for the Matter Bridge flags (`--matter`, `--matter-setup-file`, `--matter-reset`, `--matter-vendor-id`, `--matter-product-id`). They're defined in the library's `cli::Args` but the binary doesn't parse them, so the Matter Bridge can't be started yet.
### Threshold overrides
### Zone tags and threshold overrides (not implemented)
```yaml
# semantic-thresholds.yaml — passed to --semantic-thresholds-file
sleep_dwell_secs: 300
distress_hr_multiple: 1.5
room_active_motion_threshold: 0.10
elderly_anomaly_multiple: 2.0
meeting_min_persons: 2
no_movement_dwell_secs: 1800
fall_risk_event_threshold: 70.0
```
ADR-115 §3.12.5 specifies a zone-tag file and a threshold-override file. Neither is read by `sensing-server` today, which is why the zone-based primitives are not announced.
---
@@ -170,9 +168,11 @@ fall_risk_event_threshold: 70.0
When deploying in **healthcare**, **AAL (aging-in-place)**, or **commercial** settings, set `--privacy-mode`. This:
- **Strips** heart rate, breathing rate, and pose keypoints from every outbound MQTT publication.
- **Suppresses discovery** for those entities entirely — HA never even sees they exist.
- **Keeps every semantic primitive enabled.** Sleeping / distress / room-active / etc are *inferred* states. The inference happens server-side and only the boolean or score crosses the wire. This is the architectural win that makes the platform deployable in regulated contexts.
- **Strips** heart rate and breathing rate from every outbound MQTT publication and, in the same way, from REST responses, WebSocket frames and recordings (pose is never announced over MQTT; see [Not announced](#not-announced)).
- **Suppresses discovery** for those entities entirely: Home Assistant never sees them, and no availability or state topic is published for them.
- **Keeps the semantic primitives enabled.** `someone_sleeping` and `possible_distress` still use breathing and heart rate *inside the server*; only the ON/OFF state crosses the wire (ADR-115 §3.12.3). If that is too much for your deployment, drop those two entities in Home Assistant.
With `--privacy-mode`, 13 entities are announced per node. Privacy mode reduces what leaves the server. It does not by itself make a deployment compliant with any regulation.
Always pair `--privacy-mode` with `--mqtt-tls` on non-localhost brokers.
@@ -210,7 +210,6 @@ action:
title: "Possible distress detected"
message: >
RuView flagged sustained elevated heart rate + agitated motion.
Reason: {{ state_attr(trigger.entity_id, 'reason') }}.
```
### 2. Dim hallway when someone is sleeping
@@ -256,6 +255,8 @@ action:
### 3. Wake-up routine on bed exit
> `bed_exit` is not published by `sensing-server` today (it needs a bed-zone map; see [Not announced](#not-announced)). This blueprint will not fire until that lands.
```yaml
blueprint:
name: RuView — wake-up routine on bed exit
@@ -335,8 +336,8 @@ cards:
entity: binary_sensor.ruview_kitchen_presence
name: Kitchen
- type: tile
entity: binary_sensor.ruview_bathroom_occupied
name: Bathroom
entity: binary_sensor.ruview_bedroom_room_active
name: Bedroom active
```
---
@@ -373,6 +374,15 @@ All three accept the same HA discovery topics RuView publishes. Performance and
wscat -c ws://localhost:8765/ws/sensing
```
3. Confirm rate limits aren't dropping everything: `--mqtt-rate-vitals 1.0` for diagnosis (default 0.2 Hz = every 5 s).
4. Unchanged binary states (presence, the semantic primitives) publish once per change and then every 60 s. A flat line in HA history is expected while nothing changes.
### Entity shows "unavailable"
The server has no source for it right now: heart and breathing rate need explicit calibration and a single occupant, `fall` needs a node that sends `edge_vitals`, and the semantic primitives wait 60 s after start (`someone_sleeping` and `possible_distress` also need breathing or heart rate). Check the availability topic with `mosquitto_sub -t 'homeassistant/+/+/<slug>/availability' -v`.
### Duplicate devices after a restart
Older releases derived device ids from the process id. See [Device identity](#device-identity) for how to remove the stale devices; the current default id is persisted in `--data-dir`.
### "Plaintext MQTT on non-localhost broker" WARN
@@ -383,6 +393,8 @@ Per [ADR-115 §3.9](../adr/ADR-115-home-assistant-integration.md#39-tls--auth),
### Matter pairing fails
The Matter Bridge can't be started from `sensing-server` yet (see the note under the CLI flags), so this is for when it is.
1. Check the setup code in your `--matter-setup-file` log (defaults to printing on startup).
2. Make sure the host running `sensing-server` is on the same WiFi subnet as the controller.
3. If Apple Home complains about an unknown vendor, that's expected — RuView uses dev VID `0xFFF1` until P10 (see [ADR §9.9](../adr/ADR-115-home-assistant-integration.md#9b-matter-path-p7p10)). Tap "Add anyway".
@@ -391,7 +403,7 @@ Per [ADR-115 §3.9](../adr/ADR-115-home-assistant-integration.md#39-tls--auth),
## Applications — what people actually do with this
The 21 entities per node — 11 raw signals (presence, person count, breathing, heart rate, motion, RSSI, etc.) and 10 inferred semantic states (someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting-in-progress, bathroom-occupied, fall-risk-elevated, bed-exit, no-movement, multi-room-transition) — slot into Home Assistant like any other sensor. The list below groups real-world uses so you can pick the ones that match your space.
The 15 entities per node (9 raw signals and 6 semantic states, see the [Entity reference](#entity-reference)) slot into Home Assistant like any other sensor. Some uses below rely on entities that are not announced yet (zones, bathroom, bed exit, meeting); those wait on a zone source or a finer motion level. The list below groups real-world uses so you can pick the ones that match your space.
### Personal & home
@@ -495,13 +507,9 @@ A few patterns appear over and over; if you understand these you can build most
### What about regulated environments?
Run RuView with `--privacy-mode` and only the 10 inferred semantic states reach Home Assistant — heart rate, breathing rate, and pose values are stripped at the MQTT wire. Per ADR-115 §6, this passes:
With `--privacy-mode`, heart rate and breathing rate never reach the broker; their entities are not even announced. The semantic states still reach Home Assistant, and two of them (`someone_sleeping`, `possible_distress`) are derived from those vitals inside the server, so their ON/OFF values are themselves health-related information.
- **HIPAA-style minimum-necessary** (no biometric numbers leave the device)
- **GDPR purpose-limitation** (the inferred states are the smallest dataset that supports the automation)
- **CCPA "sensitive personal information"** (no health data crosses the wire)
The fall-risk-elevated / possible-distress / someone-sleeping flags still work — they're computed *inside* the sensor pipeline and only the boolean outputs are published. That's the architectural win that makes RuView deployable in care homes, hospitals, schools, and shared-housing scenarios where raw biometrics would be a non-starter.
Privacy mode narrows what crosses the MQTT wire. It is not a compliance certification, and RuView makes no HIPAA, GDPR or CCPA compliance claim. Whether a deployment meets a regulation depends on the whole system: broker access control and TLS, Home Assistant's recorder retention, who can see the dashboards, consent, and local law. Assess that with whoever is responsible for compliance in your setting.
## References
+361
View File
@@ -0,0 +1,361 @@
# MediaTek Router CSI (MT7981, experimental)
RuView can take channel state information (CSI) from a MediaTek Filogic
router, such as an MT7981B with an MT7976C radio running OpenWrt with
MediaTek's vendor CSI patch. The router reports CSI. A host-side bridge
converts it to RuView's MTC1 wire format (ADR-267). The sensing server ingests
it like any other UDP source.
**Status: experimental.** This page documents a working data path, measured
on one board model. It makes no sensing-quality claim. Frames from this path
carry the provenance label `mediatek:physical-unvalidated`. The ADR-266
hardware gates are still open: calibration, sequence, timestamp, and
repeatability.
Developed in whitsentry, a RuView-based project.
Evidence tags used below: **MEASURED** (with a reproducer), **CODE-DERIVED**
(read from source or build scripts, not run here), **CLAIMED** (stated, not
independently verified), **SYNTHETIC** (simulated or fabricated data).
## Contents
- [Supported hardware](#supported-hardware)
- [How the path works](#how-the-path-works)
- [Build the OpenWrt image](#build-the-openwrt-image)
- [Arm CSI on the router](#arm-csi-on-the-router)
- [Run the bridge](#run-the-bridge)
- [Start the sensing server](#start-the-sensing-server)
- [What the data is, and is not](#what-the-data-is-and-is-not)
- [Evidence](#evidence)
- [Troubleshooting](#troubleshooting)
- [Reference](#reference)
## Supported hardware
| Board | SoC + radio | Status |
|---|---|---|
| Wavlink WL-WN586X3 **Rev A** | MT7981BA + MT7976CN (switch MT7531AE) | Run on two units. CSI captured from both is analysed offline below (MEASURED). Live delivery to the sensing server is CLAIMED from local run logs that are not in the tree. |
| OpenWrt One | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table and ADR-266. Not run with this patch set. |
| Xiaomi AX3000T | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table. Not run with this patch set. |
| Other MT7981/MT7986 boards | — | Untested. The CSI patch targets the `mt7915` driver family, so other Filogic radios may work, but nothing here shows that. |
The board patch in `firmware/openwrt-wn586x3/` is specific to the WN586X3
**Rev A**. The CSI driver patch and the two userspace fixes do not depend on
the board.
Each MT7976C radio has **two receive chains**. The WN586X3's four external
antennas are a 2.4 GHz pair and a 5 GHz pair. It is not a 4x4 array, and two
routers are two independent receivers, not one phased array.
## How the path works
```
router (OpenWrt 24.10.8) host
┌────────────────────────────────┐ ┌──────────────────────────────┐
│ mt76 + MediaTek CSI patch │ │ wifi-densepose-mtk-bridge │
│ firmware MCU event 0xc2 │ JSON dump │ groups chains into frames, │
│ mt76-vendor dump csi ─────────┼──────────────►│ applies provenance gate, │
│ CSIdump (UDP, optional) ───────┼──── UDP ─────►│ emits ADR-267 MTC1 │
└────────────────────────────────┘ └──────────────┬───────────────┘
│ UDP :5005
┌──────────────▼───────────────┐
│ sensing-server │
│ --source mediatek │
│ /api/v1/csi/mediatek/* │
└──────────────────────────────┘
```
Two transports leave the router:
- **`mt76-vendor <iface> dump csi <n> <file>`** writes a JSON array of
per-chain records: timestamp, transmitter address, RSSI, SNR, bandwidth,
PPDU mode, tx/rx chain index, chain marker, and I/Q. This is the richer
path, and the one that carried sustained use. You copy the file to the host
(for example, a loop over SSH) and replay it through the bridge.
- **`CSIdump`** (MtkCSIdump) streams one antenna per UDP datagram and drops
RSSI, PPDU mode, and transmitter identity. The bridge emits one 1x1 frame per
datagram. This path was verified end to end once, as a recording. It has not
run as a standing service.
## Build the OpenWrt image
The patch set lives in [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md).
It is not a firmware image. Build and hash your own.
| Patch | Applies to |
|---|---|
| `1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch` | `mt76` package (`package/kernel/mt76/patches/`) |
| `mt76-vendor-csi-dump-bounds.patch` | `mt76-vendor` source (`feed/app/mt76-vendor/src/csi.c` in `mediatek/mtk-openwrt-feeds`) |
| `csidump-nl80211-attr-enum.patch` | MtkCSIdump `src/wifi_drv_api/mt76_api.cpp` (only if you use CSIdump) |
| `0001-wn586x3-reva-single-image-sysupgrade-and-generic-spi-nor.patch` | `target/linux/mediatek` (WN586X3 Rev A only) |
The route that produced the tested image (CODE-DERIVED from its build scripts)
used the stock OpenWrt **24.10.8** `mediatek/filogic` SDK and ImageBuilder in
an x86_64 Linux container:
1. **SDK.** Unpack `openwrt-sdk-24.10.8-mediatek-filogic`, then run
`./scripts/feeds update -a && ./scripts/feeds install -a`.
2. **CSI driver.** Copy the rebased `1001-...` patch into
`feeds/base/package/kernel/mt76/patches/`. Raise `PKG_RELEASE` in that
package's `Makefile` (the tested build used `99`) so the ImageBuilder
prefers your build over the CDN package of the same version.
3. **mt76-vendor.** Add MediaTek's `mt76-vendor` package from
`mtk-openwrt-feeds` as `package/mt76-vendor`, with the bounds patch applied
to `src/csi.c`.
4. **Configure and compile.** Select `CONFIG_TARGET_mediatek_filogic_DEVICE_wavlink_wl-wn586x3`,
`kmod-mt7915e`, `mt76-vendor`, and `libnl-tiny`. Then run
`make package/feeds/base/mt76/compile package/mt76-vendor/compile`.
5. **Image.** Copy the built `.ipk` files into the ImageBuilder's `packages/`
and run:
```bash
make image PROFILE=wavlink_wl-wn586x3 \
PACKAGES="kmod-mt7915e kmod-mt7981-firmware mt7981-wo-firmware mt76-vendor libnl-tiny"
```
6. **Check the manifest.** The image's `.manifest` must list `kmod-mt7915e`
at your raised release (for example `-r99`) and `mt76-vendor`. If it lists
the CDN release, the image has no CSI.
**Rev A board patch.** An ImageBuilder cannot compile a device tree, and its
`make image` reuses the prebuilt DTB and FIT kernel. The tested Rev A build
compiled the patched DTB itself, rebuilt the FIT kernel around it, added the
patched `lib/upgrade/platform.sh` as a rootfs overlay, then checked that the
final sysupgrade image embedded that DTB. A full OpenWrt buildroot applies
`target/` patches directly and avoids those steps. That route was not tested
here.
Flash only a Rev A unit, keep the stock firmware backup, and follow the
OpenWrt wiki and forum thread for this board. Neither this guide nor the patch
set names a LAN, an account, or a password.
**Firmware blobs.** The CSI report is MCU event `0xc2`. The MT7981 firmware in
the tested image (`kmod-mt7981-firmware`, `mt7981-wo-firmware` from OpenWrt
24.10.8) already emits it, so no firmware blob swap was needed on the tested
board. Other firmware versions are untested.
## Arm CSI on the router
CSI follows traffic from **associated clients**. With nothing associated and
transmitting, there is no CSI. Run these on the router, as root, against the AP
interface (`phy0-ap0` was the 2.4 GHz AP on the tested board). The sequence
below is CODE-DERIVED from the capture scripts used on the tested board:
```sh
mt76-vendor phy0-ap0 set csi ctrl=0,0,0,0 # disarm and reset
mt76-vendor phy0-ap0 set csi interval=1000
mt76-vendor phy0-ap0 set csi ctrl=2,3,0,34 # frame-type filter, as used on
mt76-vendor phy0-ap0 set csi ctrl=2,9,1,0 # the tested board (data frames)
mt76-vendor phy0-ap0 set csi ctrl=1,0,0,0 # arm
```
Check that the driver is collecting:
```sh
cat /sys/kernel/debug/ieee80211/phy0/mt76/csi_stats # data_cnt should climb
iw dev phy0-ap0 station dump | grep -E "Station|signal"
```
Pull a batch:
```sh
rm -f /tmp/csi.json # the tool APPENDS; remove first
mt76-vendor phy0-ap0 dump csi 64 /tmp/csi.json
```
`dump csi` needs the filename argument. Without it, the tool returns nothing.
## Run the bridge
```bash
cd v2
cargo build --release -p wifi-densepose-mtk-bridge
B=./target/release/wifi-densepose-mtk-bridge
```
**Dump path (recommended).** Copy `/tmp/csi.json` to the host, then replay it.
A file cannot prove it came off a radio, so `--replay` refuses to start until
you either attest the hardware or declare the file synthetic:
```bash
$B --replay csi.json --captured-on <model>/<firmware> \
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005
```
- `--node` is hashed into the 64-bit `device_id`. Give each router its own name.
- `--center-freq-khz` is the AP channel's centre frequency, for example
`2437000` for 2.4 GHz channel 6.
- `--captured-on` takes `<model>/<firmware>`, for example
`WN586X3-RevA/openwrt-24.10.8-csi`. It is logged at startup.
For a continuous feed, repeat dump, copy, and replay in a loop, one loop per
router. Keep a copy of each dump only if you need it, and never commit one
(see below).
**Streaming path (CSIdump).** Start `CSIdump phy0-ap0 <rate> 8888` on the
router. Then, on the host:
```bash
$B --listen 0.0.0.0:8888 --csidump <router-ip>:8888 --listen-allow <router-ip> \
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005 \
--record captures/<node-name>.mtkcap
```
`--listen-allow` is mandatory. Datagrams from any other source are dropped and
counted. CSIdump disarms CSI when it receives SIGTERM. The dump path and
CSIdump cannot share the driver's ring, so run one or the other.
**No router.** The fabricated fixture exercises the whole host path. It must
be replayed with `--synthetic`, and the server labels it `mediatek:simulated`:
```bash
$B --replay crates/wifi-densepose-mtk-bridge/fixtures/FABRICATED-mt76-vendor-dump-mt7981-bw80.synthetic.json \
--synthetic --node demo --sink 127.0.0.1:5005
```
The ADR-266 simulator is another synthetic source:
```bash
cargo run -p wifi-densepose-hardware --bin mediatek-csi-sim -- \
--profile mt7981 --udp 127.0.0.1:5005 --frames 1000 --realtime
```
Full flag reference: [`v2/crates/wifi-densepose-mtk-bridge/README.md`](../v2/crates/wifi-densepose-mtk-bridge/README.md).
## Start the sensing server
```bash
cd v2
cargo run --release -p wifi-densepose-sensing-server -- --source mediatek \
--activity-state <state-dir>/activity-floor.json
```
- `--source mediatek` binds the UDP receiver (loopback by default, ADR-296) and
does **not** start the ESP32-shaped simulator. Before this source existed,
`--source mediatek` bound nothing.
- If the bridge runs on another host, bind a routable address with
`--udp-bind` and name the bridge host in `--udp-allow`.
- `--activity-state` is optional. It persists each device's learned
quiet-room activity floor so that a restart does not relearn it.
- `--csi-ring <n>` sets frames retained per device for inspection (default
256, about 0.5 MiB per 2x2 device at 64 subcarriers).
Endpoints:
| Route | Returns |
|---|---|
| `GET /api/v1/csi/mediatek/latest` | Last MTC1 snapshot, any device |
| `GET /api/v1/csi/mediatek/devices` | One snapshot per `device_id`, with `age_ms`. Devices silent for over 30 s are omitted. |
| `GET /api/v1/csi/mediatek/devices/:device_id/frames?n=&fields=amp,phase` | Newest `n` frames of per-chain, per-subcarrier amplitude and phase |
| `GET /api/v1/csi/mediatek/devices/:device_id/summary` | Per-chain amplitude mean/std per subcarrier, RSSI per Rx chain, frame rate, heuristic window stats |
| `GET /api/v1/csi/mediatek/activity?seconds=300` | About 1 Hz history of the relative activity index |
| `GET /api/v1/sensing/latest` | Room update. On this source, `classifier` is `mediatek-amplitude-heuristic-v0`. |
Check the label on every snapshot:
| `source` | Meaning |
|---|---|
| `mediatek:simulated` | `SYNTHETIC` flag set. Nothing downstream can clear it. |
| `mediatek:physical-unvalidated` | Real silicon, not calibrated. Everything this path produces today. |
| `mediatek` | Real and calibrated. Reserved. Nothing emits it yet. |
## What the data is, and is not
**It is** complex channel estimates per receive chain from a real MT7976C
radio, carried end to end with provenance intact.
**It is not:**
- **A sensor with its own clock.** The report rate is the clients' traffic
rate. An idle phone gives a few frames per second, and a busy one gives
hundreds. With no associated, transmitting client, there is no data. A
stationary device that keeps transmitting to the AP (an "illuminator", for
example an ESP32 sending a steady packet stream) gives a regular rate
(CLAIMED: the 09-20 and 09-24 captures below include an illuminator's
frames; transmitter identity was not re-checked in this analysis).
- **Calibrated.** `scale` is 1.0 in raw firmware units. Nothing has been
checked against a reference. The ADR-266 gates are open.
- **Validated for presence, motion, or vital signs.** The room output on this
source comes from `mediatek-amplitude-heuristic-v0` (presence and motion from
mean amplitude) and `activity-index-v0` (fluctuation against a learned
floor). Both are **CLAIMED**. Neither has been scored against labelled ground
truth. `activity` blocks carry `is_occupancy_estimate: false`. These outputs
do not feed the validated ESP32 vital-sign pipeline.
- **Coherent across routers.** Each radio has its own oscillator. Cross-chain
phase is meaningful within one radio, not between two boxes.
- **Free of personal data.** A raw dump holds client transmitter addresses
(MACs) and channel samples that encode movement. RuView policy (ADR-299)
forbids committing captures. Keep them out of the repository, and run
`scripts/csi-data-policy-check.sh --staged` before committing near capture
directories.
## Evidence
| Claim | Tag | Basis |
|---|---|---|
| A real WL-WN586X3 frame decodes and reaches the server as `mediatek:physical-unvalidated` | MEASURED (path) | First record, 2026-09-19: legacy OFDM, 20 MHz, 64 subcarriers, one chain. Its shape is pinned by `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`, which uses a fabricated record. The capture itself is not in the tree. |
| Cross-chain phase is steady frame to frame on one MT7976C radio, and raw per-chain phase is not | MEASURED (offline) | See below |
| `mediatek-amplitude-heuristic-v0`, `activity-index-v0` | CLAIMED | No labelled evaluation |
| ADR-266 simulator and bridge fixtures | SYNTHETIC | Labelled on the wire |
**Cross-chain phase stability.** Six local dump captures from two WN586X3 units,
2.4 GHz, 20 MHz HT frames carrying chains (tx0, rx0) and (tx0, rx1), adjacent
frames under 100 ms apart, 56 subcarriers (DC and guard bins excluded). The
two 2026-09-19 captures were analysed whole; the four 09-20 and 09-24 captures
by their first 20 MB each.
| Quantity, per adjacent-frame step | Well-sampled transmitters (6, at 825 to 14,104 frames each) |
|---|---|
| Raw per-chain phase `arg(H0[k])`, per bin, p50 | 1.557 to 1.601 rad, with 49.6 to 50.9 % of steps over π/2 |
| Cross-chain phase `arg(H0[k]·conj(H1[k]))`, per bin, p50 | 0.023 to 0.056 rad |
| Cross-chain phase summed coherently over bins, p50 / p90 / p99 | 0.014–0.039 / 0.205–0.358 / 0.437–0.820 rad |
Uniformly random phase gives a step p50 of π/2 = 1.571 rad, and half of all
steps exceed π/2. Raw per-chain phase is indistinguishable from that, as
expected with per-packet CFO and phase offset. In the cross-chain term those
offsets cancel, and the step drops to hundredths of a radian. Steps are
wrapped, so p99 can never exceed π, and is not evidence by itself.
Transmitters with few, irregular frames (229 to 595 frames) were much worse:
coherent-sum p50 0.177 to 0.456 rad, p99 1.857 to 2.849 rad. So the result
holds only with a steady frame rate.
This shows a **stable phase observable exists** within one radio. It does
**not** show that the observable tracks people, breathing, or anything else.
Reproduce it on your own capture (one `dump csi` array per line):
```bash
python3 scripts/mediatek-csi-phase-stability.py <capture.jsonl> [max_MB]
```
The script prints transmitters as `TX-A`, `TX-B`, and so on, never addresses.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `csi_stats` `data_cnt` stays at 0 | No associated client transmitting, or CSI not armed | Associate a client and generate traffic, then re-run the arm sequence |
| `data_cnt` stopped after a client roamed or the filter changed | Changing the CSI filter stops capture | Re-run the arm sequence ending in `ctrl=1,0,0,0`. Re-arming periodically is a reasonable guard. |
| `mt76-vendor dump csi` prints nothing | No filename argument | Use `dump csi <n> <file>` |
| `mt76-vendor dump csi` segfaults | Unpatched tool: the driver can return n+1 records | Rebuild with `mt76-vendor-csi-dump-bounds.patch` |
| Bridge warns about repeated `(ta, ts, tx_idx, rx_idx)` chains | The dump file was not removed between dumps, and the tool appends | `rm -f` the file before every dump |
| `--replay` refuses to start | Provenance not declared | Add `--captured-on <model>/<firmware>`, or `--synthetic` for fabricated input |
| `--captured-on` is rejected | The input declares itself synthetic (sidecar, `.synthetic.` name, or capture header) | Expected. Synthetic is sticky. Use `--synthetic`. |
| `--listen` refuses to start | No `--listen-allow` | Name the router's address or subnet |
| `rejected_source` counts climb on `--listen` | The router's address is not in `--listen-allow` | Fix the allowlist |
| CSIdump runs but nothing arrives | Unpatched CSIdump uses an off-by-one netlink attribute enum against this driver | Rebuild with `csidump-nl80211-attr-enum.patch` |
| CSI stopped after CSIdump exited | CSIdump disarms CSI on SIGTERM | Re-run the arm sequence |
| `/api/v1/csi/mediatek/devices` is empty | Server not on `--source mediatek` (or `auto` without UDP), wrong `--sink` port, or a non-loopback bridge not in `--udp-allow` | Check `--source`, port 5005, `--udp-bind`, and `--udp-allow` |
| Two routers show as one device | Same `--node` on both bridges | Give each router its own `--node` (or `--device-id`) |
| A frame shows `ppdu_type: legacy` | Normal. Association and management traffic is pre-HT. | None. ADR-267 Amendment 1 carries these frames rather than dropping them. |
| Room status reads `unknown` for a device | The heuristic abstains below its confidence threshold | Expected with sparse frames. It is not an "absent" call. |
| Room reads `absent` with `confidence: 0.0` | Every device abstained, so there is no evidence either way | Treat confidence 0 as "no information", not as an empty room |
| Image flashes, but there is no `mt76-vendor` or CSI | The ImageBuilder used the CDN `kmod-mt7915e` | Check the manifest for your raised `PKG_RELEASE` |
## Reference
- [ADR-266: MediaTek Filogic CSI platform](adr/ADR-266-mediatek-filogic-csi-platform.md) (Amendment 1: vendor CSI on MT7981)
- [ADR-267: MediaTek MIMO CSI wire protocol](adr/ADR-267-mediatek-mimo-csi-wire-protocol.md) (Amendment 1: `Legacy` and `Unknown` PPDU types)
- [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md): patches, licences, origins
- [`v2/crates/wifi-densepose-mtk-bridge/`](../v2/crates/wifi-densepose-mtk-bridge/README.md): bridge transports, provenance rules, frame mapping
- [MediaTek OpenWrt feeds](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/), [MtkCSIdump](https://github.com/MtkWifiRev/MtkCSIdump), [OpenWrt One](https://openwrt.org/toh/openwrt/one)
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff. Show more