Compare commits

...

1 Commits
main ... fi

Author SHA1 Message Date
7446785586 see about wifi 2026-04-19 16:38:19 -07:00
7 changed files with 1338 additions and 379 deletions

View File

@ -1,37 +1,44 @@
# ggwave Audio POC
# Baudy WiFi 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 WiFi 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 WiFi 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.
- WiFi 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://<hostname>: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 <port>` 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.

View File

@ -13,7 +13,7 @@
"release": "bash publish.sh"
},
"toes": {
"icon": "🖥️"
"icon": "📶"
},
"devDependencies": {
"@types/bun": "latest"

File diff suppressed because it is too large Load Diff

View File

@ -1,41 +1,3 @@
import { playAudio } from './audio'
let secret = randomNumber()
let guessCount = 0
function randomNumber() {
return Math.floor(Math.random() * 100) + 1
}
export function getSecret() {
return secret
}
export type GuessResult = {
type: 'higher' | 'lower' | 'correct'
guess: number
guessCount: number
}
export async function handleGuess(guessStr: string): Promise<GuessResult | undefined> {
const guess = parseInt(guessStr, 10)
if (isNaN(guess) || guess < 1 || guess > 100) return
guessCount++
if (guess < secret) {
await playAudio('Higher!')
return { type: 'higher', guess, guessCount }
}
if (guess > secret) {
await playAudio('Lower!')
return { type: 'lower', guess, guessCount }
}
await playAudio('Correct!')
const result: GuessResult = { type: 'correct', guess, guessCount }
secret = randomNumber()
guessCount = 0
return result
}
// Number-guessing demo removed.
// WiFi audio provisioning now lives in ./provisioning.ts and ./wifi.ts.
export {}

View File

@ -0,0 +1,53 @@
import { connectToWifiNetwork, type WifiNetwork, scanWifiNetworks, type WifiScanResult } from './wifi'
let scannedNetworks: WifiNetwork[] = []
let selectedNetwork: WifiNetwork | undefined
export interface SelectScannedNetworkResult {
code: SelectScannedNetworkCode
network?: WifiNetwork
}
export type SelectScannedNetworkCode = 'INVALID_SELECTION' | 'NETWORK_NOT_FOUND' | 'OK'
export async function connectSelectedNetwork(password: string) {
if (!selectedNetwork) return
return connectToWifiNetwork(selectedNetwork, password)
}
export function getScannedNetworks() {
return scannedNetworks
}
export function getSelectedNetwork() {
return selectedNetwork
}
export async function scanAndStoreNetworks() {
const result = await scanWifiNetworks()
if (result.code === 'OK') {
scannedNetworks = result.networks
selectedNetwork = undefined
} else {
scannedNetworks = []
selectedNetwork = undefined
}
return result satisfies WifiScanResult
}
export function selectScannedNetwork(idText: string) {
const id = Number.parseInt(idText, 10)
if (!Number.isInteger(id) || id < 1) {
return { code: 'INVALID_SELECTION' } satisfies SelectScannedNetworkResult
}
const network = scannedNetworks.find(candidate => candidate.id === id)
if (!network) {
return { code: 'NETWORK_NOT_FOUND' } satisfies SelectScannedNetworkResult
}
selectedNetwork = network
return { code: 'OK', network } satisfies SelectScannedNetworkResult
}

View File

@ -1,9 +1,9 @@
import { getAudioSetup, isLowMixerLevel, refreshAudioSetup, setMixerLevel } from './audio-setup'
import { loopbackTest, playAudio, startMicListener } from './audio'
import { getSecret, handleGuess } from './game'
import type { GuessResult } from './game'
import { connectSelectedNetwork, getSelectedNetwork, scanAndStoreNetworks, selectScannedNetwork } from './provisioning'
import { getLocalServerUrls, isLocalTlsEnabled } from './local-tls'
import { getRgbLedStatus, setRgbLedState } from './rgbled'
import type { WifiConnectCode, WifiConnectResult, WifiNetwork, WifiScanCode } from './wifi'
import pkg from '../../package.json'
const RESET = '\x1b[0m'
@ -15,6 +15,8 @@ const BLUE = '\x1b[34m'
const CYAN = '\x1b[36m'
const RED = '\x1b[31m'
let messageChain = Promise.resolve()
interface TailscaleInfo {
dnsName: string
url: string
@ -37,10 +39,27 @@ function commandExists(command: string) {
return proc.exitCode === 0
}
function decodeToken(token: string) {
try {
return Buffer.from(token, 'base64url').toString('utf8')
} catch {
return ''
}
}
function encodeToken(text: string) {
return Buffer.from(text, 'utf8').toString('base64url')
}
function formatPeak(value: number) {
return value.toFixed(4)
}
function formatSecurity(network: WifiNetwork) {
if (network.isOpen) return 'open'
return network.security || 'secured'
}
function getAlternativeUrls(urls: string[], primaryUrl: string) {
const externalUrls = urls.filter(url => !url.includes('localhost'))
const visibleUrls = externalUrls.length > 0 ? externalUrls : urls
@ -59,6 +78,16 @@ function getCommandOutput(args: string[]) {
}
}
function getConnectErrorText(code: WifiConnectCode) {
if (code === 'BAD_PASSWORD') return 'Bad password.'
if (code === 'NMCLI_MISSING') return 'nmcli is not installed.'
if (code === 'NOT_FOUND') return 'That network is no longer visible.'
if (code === 'NO_WIFI_DEVICE') return 'No WiFi device was found.'
if (code === 'PASSWORD_REQUIRED') return 'Password required.'
if (code === 'NOT_SUPPORTED') return 'WiFi provisioning is implemented for Linux only.'
return 'WiFi connection failed.'
}
function getLocalUrls(port: number) {
const { primaryUrl, urls } = getLocalServerUrls(port)
const alternativeUrls = getAlternativeUrls(urls, primaryUrl)
@ -66,6 +95,21 @@ function getLocalUrls(port: number) {
return { alternativeUrls, primaryUrl }
}
function getLoopbackPauseQuestion() {
return ' Press Enter to continue anyway... '
}
function getResultMessage(result: WifiConnectResult) {
return `RESULT|${result.code}|${encodeToken(result.network.ssid)}`
}
function getScanErrorMessage(code: WifiScanCode) {
if (code === 'NMCLI_MISSING') return 'NetworkManager (nmcli) is not installed.'
if (code === 'NO_WIFI_DEVICE') return 'No WiFi device was found.'
if (code === 'NOT_SUPPORTED') return 'WiFi scanning is implemented for Linux only.'
return 'Scanning for WiFi networks failed.'
}
function getSoxInstallCommand() {
if (process.platform === 'darwin') return 'brew install sox'
if (process.platform === 'linux') return 'sudo apt install sox'
@ -88,34 +132,130 @@ function getTailscaleInfo(): TailscaleInfo | undefined {
}
}
async function logQrCode(url: string) {
try {
const QRCode = await import('qrcode')
const qr = await QRCode.toString(url, { type: 'terminal', small: true })
console.log(qr)
} catch {
console.log(`${BOLD}${CYAN}${url}${RESET}`)
console.log()
}
}
async function handleIncomingMessage(text: string) {
const trimmed = text.trim()
if (!trimmed) return
function logGuess(result: GuessResult) {
const { type, guess, guessCount } = result
await Bun.sleep(500)
if (type === 'higher') {
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${YELLOW}📢 Higher!${RESET}`)
if (trimmed === 'HELLO') {
console.log(`${GREEN}${BOLD}Phone connected via audio.${RESET}`)
await playAudio('READY')
return
}
if (type === 'lower') {
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${BLUE}📢 Lower!${RESET}`)
if (trimmed === 'SCAN') {
await handleScanRequest()
return
}
console.log(`${DIM}Guess #${guessCount}:${RESET} ${BOLD}${guess}${RESET}${GREEN}🎉 CORRECT!${RESET}`)
if (trimmed.startsWith('SELECT|')) {
await handleSelectRequest(trimmed)
return
}
if (trimmed.startsWith('PASS|')) {
await handlePasswordRequest(trimmed)
return
}
console.log(`${DIM}Ignored unknown audio message:${RESET} ${trimmed}`)
}
async function handlePasswordRequest(message: string) {
const network = getSelectedNetwork()
if (!network) {
console.log(`${YELLOW}Received a password before any network was selected.${RESET}`)
await playAudio('PASS_ERR|NO_SELECTION')
return
}
const token = message.slice('PASS|'.length)
const password = decodeToken(token)
if (!password) {
console.log(`${YELLOW}Received an empty or undecodable password token for ${BOLD}${network.ssid}${RESET}${YELLOW}.${RESET}`)
} else {
console.log(`${CYAN}Received WiFi password for ${BOLD}${network.ssid}${RESET}${CYAN}. Attempting connection...${RESET}`)
}
await playAudio('CONNECTING')
const result = await connectSelectedNetwork(password)
if (!result) {
await playAudio('PASS_ERR|NO_SELECTION')
return
}
logConnectionResult(result)
if (result.code === 'OK') setRgbLedState('correct')
await playAudio(getResultMessage(result))
}
async function handleScanRequest() {
console.log(`${CYAN}Scanning for WiFi networks...${RESET}`)
const result = await scanAndStoreNetworks()
if (result.code !== 'OK') {
console.log(`${RED}${getScanErrorMessage(result.code)}${RESET}`)
if (result.details) console.log(`${DIM}${result.details.split('\n')[0]}${RESET}`)
console.log()
console.log(`${CYAN}New round! Secret number: ${BOLD}${getSecret()}${RESET}`)
console.log(`${'─'.repeat(40)}`)
await playAudio(`SCAN_ERR|${result.code}`)
return
}
logNetworks(result.networks)
await playAudio(`NETS_BEGIN|${result.networks.length}`)
for (const network of result.networks) {
const security = network.isOpen ? 'OPEN' : 'LOCKED'
await playAudio(`NET|${network.id}|${security}|${network.signal}|${encodeToken(network.ssid)}`)
}
await playAudio('NETS_END')
}
async function handleSelectRequest(message: string) {
const idText = message.slice('SELECT|'.length)
const result = selectScannedNetwork(idText)
if (result.code !== 'OK' || !result.network) {
console.log(`${YELLOW}Received invalid WiFi selection:${RESET} ${idText}`)
await playAudio(`SELECT_ERR|${result.code}`)
return
}
const network = result.network
console.log(`${BLUE}Selected:${RESET} ${BOLD}${network.ssid}${RESET} ${DIM}(${network.signal}% · ${formatSecurity(network)})${RESET}`)
if (!network.isOpen) {
await playAudio('PASSWORD')
return
}
console.log(`${CYAN}Connecting to open network ${BOLD}${network.ssid}${RESET}${CYAN}...${RESET}`)
await playAudio('CONNECTING')
const connectResult = await connectSelectedNetwork('')
if (!connectResult) {
await playAudio('SELECT_ERR|NETWORK_NOT_FOUND')
return
}
logConnectionResult(connectResult)
if (connectResult.code === 'OK') setRgbLedState('correct')
await playAudio(getResultMessage(connectResult))
}
function logConnectionResult(result: WifiConnectResult) {
if (result.code === 'OK') {
console.log(`${GREEN}${BOLD}Connected:${RESET} ${BOLD}${result.network.ssid}${RESET}`)
console.log()
return
}
console.log(`${RED}${BOLD}Connection failed:${RESET} ${BOLD}${result.network.ssid}${RESET} ${DIM}(${getConnectErrorText(result.code)})${RESET}`)
if (result.details) console.log(`${DIM}${result.details.split('\n')[0]}${RESET}`)
console.log()
}
@ -156,6 +296,46 @@ function logLoopbackFailureDetails(result: Awaited<ReturnType<typeof loopbackTes
console.log()
}
function logNetworks(networks: WifiNetwork[]) {
console.log(`${GREEN}${BOLD}Found ${networks.length} WiFi network${networks.length === 1 ? '' : 's'}.${RESET}`)
if (networks.length === 0) {
console.log(`${DIM}No visible SSIDs were returned by nmcli.${RESET}`)
console.log()
return
}
for (const network of networks) {
console.log(`${DIM}${network.id}.${RESET} ${BOLD}${network.ssid}${RESET} ${DIM}(${network.signal}% · ${formatSecurity(network)})${RESET}`)
}
console.log()
}
function logWifiProvisioningHint() {
if (process.platform !== 'linux') {
console.log(`${YELLOW}WiFi scan/connect commands are only implemented on Linux via nmcli.${RESET}`)
console.log()
return
}
if (!commandExists('nmcli')) {
console.log(`${YELLOW}nmcli was not found. Install NetworkManager if you want WiFi scan/connect to work.${RESET}`)
console.log()
}
}
async function logQrCode(url: string) {
try {
const QRCode = await import('qrcode')
const qr = await QRCode.toString(url, { type: 'terminal', small: true })
console.log(qr)
} catch {
console.log(`${BOLD}${CYAN}${url}${RESET}`)
console.log()
}
}
async function maybePause(question: string) {
if (!process.stdin.isTTY) return
await prompt(question)
@ -175,20 +355,12 @@ async function maybeRaiseSpeakerGain() {
return refreshAudioSetup()
}
async function onMessage(text: string) {
await Bun.sleep(500)
if (text === 'HELLO') {
console.log(`${GREEN}${BOLD}Player connected via audio!${RESET}`)
await playAudio('HEY BUDDY')
return
}
const result = await handleGuess(text)
if (!result) return
if (result.type === 'correct') setRgbLedState('correct')
logGuess(result)
function queueIncomingMessage(text: string) {
messageChain = messageChain
.then(() => handleIncomingMessage(text))
.catch(error => {
console.error('Message handling failed:', error)
})
}
export async function startup(port: number) {
@ -203,7 +375,7 @@ export async function startup(port: number) {
clearScreen()
console.log()
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`)
console.log(`${BOLD}Baudy WiFi Chirp Setup${RESET} ${DIM}v${pkg.version}${RESET}`)
console.log()
console.log(`${BOLD}Speaker:${RESET} ${GREEN}${audioSetup.playbackLabel}${RESET}`)
console.log(`${BOLD}Microphone:${RESET} ${GREEN}${audioSetup.captureLabel}${RESET}`)
@ -213,6 +385,8 @@ export async function startup(port: number) {
}
console.log()
logWifiProvisioningHint()
if (process.platform === 'linux' && isLowMixerLevel(audioSetup.mixerLevel)) {
const level = audioSetup.mixerLevel
const db = level.db === undefined ? '' : ` (${level.db.toFixed(1)} dB)`
@ -234,12 +408,12 @@ export async function startup(port: number) {
} else {
setRgbLedState('error')
logLoopbackFailureDetails(loopback)
await maybePause(' Press Enter to continue anyway... ')
await maybePause(getLoopbackPauseQuestion())
}
clearScreen()
console.log()
console.log(`${BOLD}Corey's Screechy Audio Demo${RESET} ${DIM}v${pkg.version}${RESET}`)
console.log(`${BOLD}Baudy WiFi Chirp Setup${RESET} ${DIM}v${pkg.version}${RESET}`)
console.log(`${DIM}Speaker: ${audioSetup.playbackLabel} · Mic: ${audioSetup.captureLabel}${RESET}`)
console.log()
@ -284,11 +458,15 @@ export async function startup(port: number) {
console.log(`${DIM}Set BAUDY_HOST=<host-or-ip> to override local URL detection if needed.${RESET}`)
console.log()
console.log(`${BOLD}Secret number: ${GREEN}${getSecret()}${RESET}`)
console.log(`${'─'.repeat(40)}`)
console.log(`${DIM}🎤 Listening for guesses...${RESET}`)
console.log(`${BOLD}Phone flow:${RESET}`)
console.log(`${DIM}1.${RESET} Connect audio`)
console.log(`${DIM}2.${RESET} Scan nearby WiFi networks`)
console.log(`${DIM}3.${RESET} Select a network on the phone`)
console.log(`${DIM}4.${RESET} Enter the password and chirp it back`)
console.log()
console.log(`${DIM}🎤 Listening for WiFi setup chirps...${RESET}`)
console.log()
setRgbLedState('listening')
startMicListener(onMessage)
startMicListener(queueIncomingMessage)
}

243
src/server/wifi.ts Normal file
View File

@ -0,0 +1,243 @@
const MAX_NETWORKS = 8
export interface WifiConnectResult {
code: WifiConnectCode
details?: string
network: WifiNetwork
}
export interface WifiNetwork {
id: number
isOpen: boolean
security: string
signal: number
ssid: string
}
export interface WifiScanResult {
code: WifiScanCode
details?: string
networks: WifiNetwork[]
}
interface CommandResult {
exitCode: number
stderr: string
stdout: string
}
interface ParsedWifiRecord {
security: string
signal: number
ssid: string
}
export type WifiConnectCode =
| 'BAD_PASSWORD'
| 'FAILED'
| 'NMCLI_MISSING'
| 'NOT_FOUND'
| 'NO_WIFI_DEVICE'
| 'OK'
| 'PASSWORD_REQUIRED'
| 'NOT_SUPPORTED'
export type WifiScanCode = 'NMCLI_MISSING' | 'NO_WIFI_DEVICE' | 'OK' | 'SCAN_FAILED' | 'NOT_SUPPORTED'
export async function connectToWifiNetwork(network: WifiNetwork, password: string) {
if (process.platform !== 'linux') {
return { code: 'NOT_SUPPORTED', network } satisfies WifiConnectResult
}
if (!commandExists('nmcli')) {
return { code: 'NMCLI_MISSING', network } satisfies WifiConnectResult
}
if (!network.isOpen && !password.trim()) {
return { code: 'PASSWORD_REQUIRED', network } satisfies WifiConnectResult
}
const device = await findWifiDevice()
if (!device) {
return { code: 'NO_WIFI_DEVICE', network } satisfies WifiConnectResult
}
const args = ['nmcli', '--colors', 'no', '--wait', '30', 'device', 'wifi', 'connect', network.ssid, 'ifname', device]
if (!network.isOpen) args.push('password', password)
const command = await runCommand(args)
if (command.exitCode === 0) {
return { code: 'OK', details: command.stdout, network } satisfies WifiConnectResult
}
return {
code: classifyConnectError(command),
details: command.stderr || command.stdout,
network,
} satisfies WifiConnectResult
}
export async function scanWifiNetworks() {
if (process.platform !== 'linux') {
return { code: 'NOT_SUPPORTED', networks: [] } satisfies WifiScanResult
}
if (!commandExists('nmcli')) {
return { code: 'NMCLI_MISSING', networks: [] } satisfies WifiScanResult
}
const device = await findWifiDevice()
if (!device) {
return { code: 'NO_WIFI_DEVICE', networks: [] } satisfies WifiScanResult
}
const command = await runCommand([
'nmcli', '--colors', 'no', '--escape', 'no', '--mode', 'multiline',
'--fields', 'SSID,SIGNAL,SECURITY', 'device', 'wifi', 'list', 'ifname', device, '--rescan', 'yes',
])
if (command.exitCode !== 0) {
return {
code: 'SCAN_FAILED',
details: command.stderr || command.stdout,
networks: [],
} satisfies WifiScanResult
}
return {
code: 'OK',
networks: parseWifiList(command.stdout),
} satisfies WifiScanResult
}
function classifyConnectError(command: CommandResult): WifiConnectCode {
const text = `${command.stdout}\n${command.stderr}`.toLowerCase()
if (text.includes('secrets were required') || text.includes('wrong password') || text.includes('authentication')) {
return 'BAD_PASSWORD'
}
if (text.includes('no network with ssid') || text.includes('could not find') || text.includes('not found')) {
return 'NOT_FOUND'
}
return 'FAILED'
}
function commandExists(command: string) {
const proc = Bun.spawnSync(['which', command], {
stdout: 'ignore',
stderr: 'ignore',
})
return proc.exitCode === 0
}
async function findWifiDevice() {
const command = await runCommand(['nmcli', '--colors', 'no', '--terse', '--fields', 'DEVICE,TYPE', 'device', 'status'])
if (command.exitCode !== 0) return
for (const rawLine of command.stdout.split('\n')) {
const line = rawLine.trim()
if (!line) continue
const [device, type] = line.split(':')
if (type === 'wifi' && device) return device
}
}
function getOpenState(security: string) {
const normalized = security.trim()
return normalized === '' || normalized === '--'
}
function normalizeNmcliLine(line: string) {
return line.trim()
}
function parseSignal(value: string) {
const signal = Number.parseInt(value, 10)
if (Number.isNaN(signal)) return 0
return Math.max(0, Math.min(100, signal))
}
function parseWifiList(output: string) {
const bySsid = new Map<string, ParsedWifiRecord>()
const records = output.split(/\n\s*\n/g)
for (const record of records) {
const parsed = parseWifiRecord(record)
if (!parsed || !parsed.ssid) continue
const existing = bySsid.get(parsed.ssid)
if (!existing || parsed.signal > existing.signal) bySsid.set(parsed.ssid, parsed)
}
return [...bySsid.values()]
.sort((left, right) => right.signal - left.signal || left.ssid.localeCompare(right.ssid))
.slice(0, MAX_NETWORKS)
.map((record, index) => ({
id: index + 1,
isOpen: getOpenState(record.security),
security: record.security,
signal: record.signal,
ssid: record.ssid,
})) satisfies WifiNetwork[]
}
function parseWifiRecord(record: string) {
let security = ''
let signal = 0
let ssid = ''
for (const rawLine of record.split('\n')) {
const line = rawLine.trim()
if (!line) continue
if (line.startsWith('SSID:')) {
ssid = normalizeNmcliLine(line.slice(5))
continue
}
if (line.startsWith('SIGNAL:')) {
signal = parseSignal(line.slice(7).trim())
continue
}
if (line.startsWith('SECURITY:')) {
security = normalizeNmcliLine(line.slice(9))
}
}
if (!ssid) return
return {
security,
signal,
ssid,
} satisfies ParsedWifiRecord
}
async function runCommand(args: string[]) {
const proc = Bun.spawn(args, {
stdout: 'pipe',
stderr: 'pipe',
})
const [stdout, stderr, exitCode] = await Promise.all([
getText(proc.stdout),
getText(proc.stderr),
proc.exited,
])
return {
exitCode,
stderr: stderr.trim(),
stdout: stdout.trim(),
} satisfies CommandResult
}
function getText(stream: ReadableStream<Uint8Array> | null | undefined) {
if (!stream) return Promise.resolve('')
return new Response(stream).text().catch(() => '')
}