From 7446785586bc324a2950f5232fb41fab64faecde Mon Sep 17 00:00:00 2001 From: Pat Nakajima Date: Sun, 19 Apr 2026 16:38:19 -0700 Subject: [PATCH] see about wifi --- README.md | 53 +- package.json | 2 +- src/pages/phone.tsx | 1060 +++++++++++++++++++++++++++--------- src/server/game.ts | 44 +- src/server/provisioning.ts | 53 ++ src/server/terminal.ts | 262 +++++++-- src/server/wifi.ts | 243 +++++++++ 7 files changed, 1338 insertions(+), 379 deletions(-) create mode 100644 src/server/provisioning.ts create mode 100644 src/server/wifi.ts diff --git a/README.md b/README.md index eab54d6..2e1cafd 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,44 @@ -# ggwave Audio POC +# Baudy Wi‑Fi Chirp Setup -Proof-of-concept for data-over-sound communication using the [ggwave](https://github.com/ggerganov/ggwave) library. This validates the browser-to-server audio pipeline that will eventually be used for WiFi provisioning on Raspberry Pi (Toes devices). +A proof-of-concept for provisioning Wi‑Fi over sound with [ggwave](https://github.com/ggerganov/ggwave). -## Why +The phone and the server exchange short audio chirps to do a simple setup flow: -Toes devices (Raspberry Pis) need a way to receive WiFi credentials during initial setup. The device has no network connection yet, so we can't use HTTP. Instead, the user's phone encodes the credentials as audio chirps and plays them through the speaker. The Pi's microphone picks up the chirps and decodes them. No Bluetooth pairing, no QR codes, no special hardware — just sound. +1. **Phone says hello** over audio. +2. **Server scans nearby Wi‑Fi networks** with `nmcli`. +3. **Server chirps the SSID list back** to the phone. +4. **Phone selects a network** and, if needed, enters the password. +5. **Phone chirps the password back** to the server. +6. **Server tries to join the network** and chirps the result. -## What this POC does +This is aimed at Raspberry Pi / Linux-style setup flows where networking may not be available yet. -It's a calculator. The phone sends math expressions as audio, the server decodes them and sends back the answer. This is a minimal end-to-end test of the full pipeline: +## Current assumptions -1. **Phone (browser)** — calculator UI. User types `78*5` and hits `=`. The expression is encoded as an audible chirp using ggwave's AUDIBLE_FAST protocol and played through the phone speaker. -2. **Server (Bun)** — listens on the microphone via `sox`, feeds audio frames to ggwave for decoding. When it decodes an expression, it evaluates it and sends the result back via SSE. -3. **Phone receives result** — the answer (`390`) appears on the calculator display. +- Wi‑Fi scanning and joining are implemented for **Linux via `nmcli`**. +- The phone UI is still served as a normal web page for this repo's demo flow. +- Passwords are sent as **plaintext audio payloads** in this POC. +- Audio is **half-duplex**: only one side should chirp at a time. -The server also chirps the result back through the speakers (half-duplex — it stops listening while playing to avoid feedback). +## Run -## How to run - -``` -cd tmp +```sh bun install -bun run server.ts +bun run index.tsx ``` -Open `http://:8888` on your phone. Make sure the server machine's default audio input is a working microphone (check System Settings > Sound > Input on macOS). +Open the shown URL on your phone. -## How it works +For phone microphone access you need a secure origin: -- **ggwave** handles encoding/decoding using multi-frequency FSK modulation with Reed-Solomon error correction. The AUDIBLE_FAST protocol uses audible frequencies (~1-6kHz range). -- **Browser side** uses WebAudio API to play encoded waveforms. ggwave runs as WASM. -- **Server side** uses `sox -d` to capture mic audio as raw 48kHz float32 samples, then feeds frames to ggwave for decoding. -- **Half-duplex** — both sides use the same frequency band, so only one can transmit at a time. The server stops processing mic input while playing back results. -- **SSE** is used as a reliable fallback channel to push results to the phone (vs trying to decode audio on the phone in a noisy environment). +- **macOS:** the app serves local HTTPS directly +- **Linux:** prefer `tailscale serve ` and open the resulting `https://...ts.net` URL -See [docs/ggwave-gotchas.md](docs/ggwave-gotchas.md) for hard-won lessons about iOS audio, macOS mic permissions, WASM heap management, and sample rate matching. +## Notes + +- Nearby network lists are deduped by SSID and trimmed to the strongest entries. +- The browser uses WebAudio + ggwave WASM for chirp encode/decode. +- The server uses `sox` for audio capture/playback. +- The server waits for the phone to switch back into listening mode before replying. + +See [docs/ggwave-gotchas.md](docs/ggwave-gotchas.md) for platform-specific lessons about iOS audio, macOS microphone permissions, Linux ALSA device selection, and ggwave WASM behavior. diff --git a/package.json b/package.json index 1dfa109..a45c88c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "release": "bash publish.sh" }, "toes": { - "icon": "🖥️" + "icon": "📶" }, "devDependencies": { "@types/bun": "latest" diff --git a/src/pages/phone.tsx b/src/pages/phone.tsx index 3e60051..a93ff1a 100644 --- a/src/pages/phone.tsx +++ b/src/pages/phone.tsx @@ -1,255 +1,536 @@ /** @jsxImportSource hono/jsx */ import { define, stylesToCSS } from '@because/forge' -// --------------------------------------------------------------------------- -// Forge components -// --------------------------------------------------------------------------- - const Page = define('Page', { - fontFamily: '-apple-system, system-ui, sans-serif', - background: '#111', - color: '#fff', - height: '100dvh', - width: '100vw', + alignItems: 'stretch', + background: '#09090b', + color: '#fafafa', display: 'flex', flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', + fontFamily: '-apple-system, system-ui, sans-serif', + height: '100dvh', overflow: 'hidden', - userSelect: 'none', - WebkitUserSelect: 'none', position: 'relative', touchAction: 'manipulation', + userSelect: 'none', + WebkitUserSelect: 'none', + width: '100vw', }) const Screen = define('Screen', { + alignItems: 'stretch', display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', flex: 1, + flexDirection: 'column', + gap: 16, + justifyContent: 'center', + margin: '0 auto', + maxWidth: 460, + padding: '24px 20px 72px', width: '100%', - padding: 20, }) const Title = define('Title', { - fontSize: 18, - color: '#888', + fontSize: 32, + fontWeight: 700, + letterSpacing: '-0.03em', + lineHeight: 1.05, + margin: 0, textAlign: 'center', +}) + +const Subtitle = define('Subtitle', { + color: '#a1a1aa', + fontSize: 16, + lineHeight: 1.5, + margin: 0, + textAlign: 'center', +}) + +const WarningCard = define('WarningCard', { + background: 'rgba(245, 158, 11, 0.12)', + border: '1px solid rgba(245, 158, 11, 0.28)', + borderRadius: 16, + color: '#fbbf24', + fontSize: 15, + lineHeight: 1.5, + padding: '14px 16px', + textAlign: 'center', +}) + +const Panel = define('Panel', { + background: '#111217', + border: '1px solid #27272a', + borderRadius: 18, + display: 'flex', + flexDirection: 'column', + gap: 12, + padding: 16, +}) + +const PanelTitle = define('PanelTitle', { + color: '#d4d4d8', + fontSize: 13, + fontWeight: 600, + letterSpacing: '0.02em', + margin: 0, + textTransform: 'uppercase', +}) + +const ButtonRow = define('ButtonRow', { + display: 'flex', + gap: 12, +}) + +const PrimaryBtn = define('PrimaryBtn', { + background: '#2563eb', + base: 'button', + border: 'none', + borderRadius: 16, + color: '#fff', + cursor: 'pointer', + flex: 1, + fontSize: 18, + fontWeight: 700, + minHeight: 56, + padding: '14px 18px', + touchAction: 'manipulation', + WebkitTapHighlightColor: 'transparent', + states: { + ':active': { background: '#1d4ed8' }, + }, +}) + +const SecondaryBtn = define('SecondaryBtn', { + background: '#23252d', + base: 'button', + border: '1px solid #3f3f46', + borderRadius: 16, + color: '#fff', + cursor: 'pointer', + flex: 1, + fontSize: 18, + fontWeight: 700, + minHeight: 56, + padding: '14px 18px', + touchAction: 'manipulation', + WebkitTapHighlightColor: 'transparent', + states: { + ':active': { background: '#2f313b' }, + }, +}) + +const StatusText = define('StatusText', { + color: '#a1a1aa', + fontSize: 15, + lineHeight: 1.5, + margin: 0, + minHeight: 24, + textAlign: 'center', +}) + +const NetworkList = define('NetworkList', { + display: 'flex', + flexDirection: 'column', + gap: 10, +}) + +const NetworkButton = define('NetworkButton', { + alignItems: 'flex-start', + background: '#181a20', + base: 'button', + border: '1px solid #2f3138', + borderRadius: 16, + color: '#fff', + cursor: 'pointer', + display: 'flex', + flexDirection: 'column', + gap: 4, + padding: '14px 16px', + textAlign: 'left', + touchAction: 'manipulation', + WebkitTapHighlightColor: 'transparent', + states: { + ':active': { background: '#20232c', borderColor: '#4b5563' }, + }, +}) + +const NetworkName = define('NetworkName', { + fontSize: 18, + fontWeight: 700, + lineHeight: 1.3, +}) + +const NetworkMeta = define('NetworkMeta', { + color: '#a1a1aa', + fontSize: 14, lineHeight: 1.4, }) -const GuessDisplay = define('GuessDisplay', { - fontSize: 96, - fontWeight: 'bold', - fontFamily: 'monospace', - lineHeight: 1, - margin: '24px 0', -}) - -const Controls = define('Controls', { - display: 'flex', - gap: 12, - marginBottom: 24, -}) - -const StepBtn = define('StepBtn', { - base: 'button', - fontSize: 24, - fontWeight: 'bold', - width: 64, - height: 64, - border: 'none', - borderRadius: 12, - background: '#333', - color: '#fff', - cursor: 'pointer', - WebkitTapHighlightColor: 'transparent', - touchAction: 'manipulation', - states: { ':active': { background: '#555' } }, -}) - -const GuessBtn = define('GuessBtn', { - base: 'button', - fontSize: 24, - fontWeight: 'bold', - padding: '16px 48px', - border: 'none', +const EmptyState = define('EmptyState', { + background: '#15161c', + border: '1px dashed #3f3f46', borderRadius: 16, - background: '#22c55e', - color: '#fff', - cursor: 'pointer', - WebkitTapHighlightColor: 'transparent', - touchAction: 'manipulation', - states: { ':active': { background: '#16a34a' } }, -}) - -const HintText = define('HintText', { - fontSize: 36, - fontWeight: 'bold', - marginTop: 32, - minHeight: 50, - display: 'flex', - alignItems: 'center', - transition: 'opacity 0.15s ease', -}) - -const VictoryEmoji = define('VictoryEmoji', { - fontSize: 64, - marginBottom: 16, -}) - -const VictoryText = define('VictoryText', { - fontSize: 22, + color: '#a1a1aa', + fontSize: 15, lineHeight: 1.5, + padding: '18px 16px', + textAlign: 'center', +}) + +const TextInput = define('TextInput', { + background: '#09090b', + base: 'input', + border: '1px solid #3f3f46', + borderRadius: 14, + color: '#fff', + fontSize: 18, + minHeight: 56, + padding: '0 16px', + selectors: { + '&::placeholder': { color: '#71717a' }, + }, +}) + +const FinePrint = define('FinePrint', { + color: '#71717a', + fontSize: 13, + lineHeight: 1.5, + margin: 0, +}) + +const ResultEmoji = define('ResultEmoji', { + fontSize: 56, + marginBottom: 4, + textAlign: 'center', +}) + +const ResultText = define('ResultText', { + color: '#d4d4d8', + fontSize: 18, + lineHeight: 1.5, + margin: 0, textAlign: 'center', - padding: '0 20px', - maxWidth: 380, }) const StatusBar = define('StatusBar', { - position: 'fixed', bottom: 0, - left: 0, - right: 0, - padding: 8, + color: '#71717a', fontSize: 12, - color: '#555', + left: 0, + padding: '10px 14px calc(env(safe-area-inset-bottom) + 10px)', + position: 'fixed', + right: 0, textAlign: 'center', }) -const VolumeWarning = define('VolumeWarning', { - fontSize: 14, - color: '#f59e0b', - textAlign: 'center', - marginTop: 16, - padding: '8px 16px', - background: 'rgba(245, 158, 11, 0.1)', - borderRadius: 8, - border: '1px solid rgba(245, 158, 11, 0.3)', -}) - -const ConnectBtn = define('ConnectBtn', { - base: 'button', - fontSize: 28, - fontWeight: 'bold', - padding: '20px 48px', - border: 'none', - borderRadius: 16, - background: '#2563eb', - color: '#fff', - cursor: 'pointer', - WebkitTapHighlightColor: 'transparent', - touchAction: 'manipulation', - states: { ':active': { background: '#1d4ed8' } }, -}) - -const ConnectStatus = define('ConnectStatus', { - fontSize: 18, - color: '#888', - marginTop: 24, - textAlign: 'center', -}) - -// --------------------------------------------------------------------------- -// Client-side code — real functions, serialized into - Corey's Screechy Audio Demo - 🔊 Turn your volume up on both devices! -
- Connect -
- + Baudy Wi‑Fi Setup + + Send Wi‑Fi credentials to the device with audio chirps. +
+ The phone listens and talks back through the speaker and mic. +
+ + 🔊 Turn the volume up on both devices. +
+ Keep the phone close to the server while chirping. +
+ + Connect Audio + +
-