Compare commits

..

No commits in common. "1c717a5b4772147c3b7ce20f512452f13c8cc510" and "18629565318688d5600ed00abef94801d2113546" have entirely different histories.

21 changed files with 87 additions and 1472 deletions

View File

@ -1,6 +1,6 @@
# Deployment Script
This directory contains a deployment script for the Phone project to a Raspberry Pi.
This directory contains a deployment script for the Yellow Phone project to a Raspberry Pi.
## File: deploy.ts
@ -8,8 +8,8 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and
### Configuration
- **Target Host**: `phone.local`
- **Target Directory**: `/home/corey/phone`
- **Target Host**: `yellow-phone.local`
- **Target Directory**: `/home/corey/yellow-phone`
### What It Does
@ -25,13 +25,11 @@ A Bun-based deployment script that automates copying files to a Raspberry Pi and
### Usage
**Standard deployment** (just copy files and restart services):
```bash
bun deploy.ts
```
**First-time deployment** (copy files + run bootstrap):
```bash
bun deploy.ts --bootstrap
```
@ -39,19 +37,17 @@ bun deploy.ts --bootstrap
### Services
The script manages two systemd services:
- `phone-ap.service` - Access point service
- `phone-web.service` - Web interface service
### Access
After deployment, the Pi is accessible at:
- **Web URL**: http://phone.local
- **WiFi Network**: phone-setup
- **Web URL**: http://yellow-phone.local
- **WiFi Network**: yellow-phone-setup
### Requirements
- Bun runtime
- SSH access to `phone.local`
- SSH access to `yellow-phone.local`
- Local `pi/` directory with files to deploy

View File

@ -3,14 +3,21 @@
set -e
echo "==========================================
Bun Installation for Raspberry Pi
Bun Installation for Yellow Phone
==========================================
"
# Check if already installed
if command -v bun >/dev/null 2>&1; then
echo "✓ Bun is already installed at: $(which bun) $(bun --version)"
exit 0
echo "✓ Bun is already installed at: $(which bun)"
bun --version
echo ""
read -p "Reinstall anyway? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Skipping installation."
exit 0
fi
fi
echo "Step 1: Installing Bun..."

View File

@ -3,7 +3,7 @@ import { writeFileSync } from "fs"
console.log(`
==========================================
Phone Setup Bootstrap
Yellow Phone Setup Bootstrap
==========================================
`)
@ -15,7 +15,7 @@ if (process.getuid && process.getuid() !== 0) {
}
// Get install directory from argument or use default
const INSTALL_DIR = process.argv[2] || "/home/corey/phone"
const INSTALL_DIR = process.argv[2] || "/home/corey/yellow-phone"
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
@ -44,7 +44,7 @@ console.log(`Using bun at: ${bunPath}`)
// Create AP monitor service
const apServiceContent = `[Unit]
Description=Phone WiFi AP Monitor
Description=Yellow Phone WiFi AP Monitor
After=network.target
Before=phone-web.service
@ -64,7 +64,7 @@ console.log("✓ Created phone-ap.service")
// Create web server service
const webServiceContent = `[Unit]
Description=Phone Web Server
Description=Yellow Phone Web Server
After=network.target phone-ap.service
[Service]
@ -102,9 +102,9 @@ Both services are now running and will start automatically on boot:
- phone-web.service: Web server for configuration
How it works:
- If connected to WiFi: Access at http://phone.local
- If NOT connected: WiFi AP "phone-setup" will start automatically
Connect to the AP at the same address http://phone.local
- If connected to WiFi: Access at http://yellow-phone.local
- If NOT connected: WiFi AP "yellow-phone-setup" will start automatically
Connect to the AP at the same address http://yellow-phone.local
To check status use ./cli
`)

View File

@ -33,7 +33,7 @@ const command = process.argv[2];
if (!command || command === "help") {
console.log(`
Phone CLI - Service Management Tool
Yellow Phone CLI - Service Management Tool
Usage: bun cli <command>
@ -76,7 +76,7 @@ if (!Object.keys(commands).includes(command)) {
process.exit(1);
}
console.log(`\n🔧 Phone CLI - ${command}\n`);
console.log(`\n🔧 Yellow Phone CLI - ${command}\n`);
// Parse service-specific commands
const match = command.match(/^(ap|web)-(.+)$/);

View File

@ -2,8 +2,8 @@
import { $ } from "bun"
const PI_HOST = process.env.PI_HOST || "phone.local"
const PI_DIR = "/home/corey/phone"
const PI_HOST = "yellow-phone.local"
const PI_DIR = "/home/corey/yellow-phone"
// Parse command line arguments
const shouldBootstrap = process.argv.includes("--bootstrap")
@ -30,11 +30,8 @@ console.log("\n✓ Files deployed!\n")
// Run bootstrap if requested
if (shouldBootstrap) {
console.log("🍞 Running bootstrap-bun on Pi...\n")
await $`ssh ${PI_HOST} "bash ${PI_DIR}/scripts/bootstrap-bun.sh"`
console.log("Running bootstrap on Pi...\n")
await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun ${PI_DIR}/scripts/bootstrap.ts ${PI_DIR}"`
await $`ssh ${PI_HOST} "cd ${PI_DIR} && sudo bun bootstrap.ts ${PI_DIR}"`
}
// Always check if services exist and restart them (whether we bootstrapped or not)
@ -57,6 +54,6 @@ if (apServiceExists.exitCode === 0 && webServiceExists.exitCode === 0) {
console.log(`
Deploy complete!
Access via WiFi at http://phone.local
The Pi is discoverable as "phone-setup"
Access via WiFi at http://yellow-phone.local
The Pi is discoverable as "yellow-phone-setup"
`)

View File

@ -1,47 +0,0 @@
import { GPIO } from "./pins"
console.log(`kill -9 ${process.pid}`)
const gpio = new GPIO({ resetOnClose: true })
// // Blink an LED
using led = gpio.output(21)
// Read a button
using inputs = gpio.inputGroup({
button: { pin: 20, pull: "up", debounce: 10 },
switch: { pin: 16, pull: "up", debounce: 10 }
})
led.value = inputs.pins.button.value
const iteratorEvents = new Promise(async (resolve) => {
for await (const event of inputs.events()) {
if (event.pin === "button") {
console.log(`🌭`, event.value)
led.value = event.value
}
}
})
const switchEvent = new Promise<void>(async (resolve) => {
await inputs.pins.switch.waitForValue(0)
console.log("Switch pressed!")
resolve()
})
process.on("SIGINT", () => {
inputs.close()
led.close()
process.exit(0)
})
process.on("SIGTERM", () => {
inputs.close()
process.exit(0)
})
await Promise.race([iteratorEvents, switchEvent])
console.log(`👋 Goodbye!`)

View File

@ -25,7 +25,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
const waitingIndicator = new WaitingSounds(player, streamPlayback)
// Set up agent event listeners
agent.events.connect(async (event) => {
agent.events.connect((event) => {
switch (event.type) {
case "connected":
console.log("✅ Connected to AI agent\n")
@ -40,7 +40,7 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
break
case "audio":
await waitingIndicator.stop()
waitingIndicator.stop()
const audioBuffer = Buffer.from(event.audioBase64, "base64")
streamPlayback.write(audioBuffer)
break

View File

@ -1,173 +0,0 @@
# Bun FFI Learnings
After researching GitHub examples and Bun's FFI documentation, here's what I found surprising and helpful.
## Surprising Discoveries
### 1. **String Handling is Simpler Than Expected**
I initially thought you'd need `CString` everywhere, but:
- For **args**: `FFIType.cstring` just needs `ptr(Buffer.from(str + "\0"))`
- For **returns**: `FFIType.cstring` automatically converts pointers to JS strings
- `CString` is mainly for **reading** C strings from pointers, not passing them
**Example from real code:**
```javascript
const str = Buffer.from("hello\0", "utf8");
myFunction(ptr(str)); // Clean and simple!
```
### 2. **No Type Wrappers Needed**
Unlike Node-FFI, Bun doesn't require defining structs or complex type wrappers. Just:
```javascript
add: {
args: [FFIType.i32, FFIType.i32],
returns: FFIType.i32,
}
```
### 3. **TinyCC JIT Compilation**
Bun embeds TinyCC and JIT-compiles C bindings on the fly. This means:
- 2-6x faster than Node-API
- Zero build step for type conversions
- Direct memory access without serialization
## Helpful Patterns
### Pattern 1: String Helper
```typescript
import { ptr } from "bun:ffi"
const cstr = (s: string) => ptr(Buffer.from(s + "\0"))
// Usage:
gpiod.open(cstr("/dev/gpiochip0"))
```
### Pattern 2: Resource Cleanup
Always use cleanup handlers:
```javascript
const cleanup = () => {
lib.symbols.release(resource)
lib.symbols.close(chip)
}
process.on("SIGINT", cleanup)
process.on("SIGTERM", cleanup)
```
### Pattern 3: Destructuring Symbols
```javascript
const {
symbols: { functionName }
} = dlopen(path, { /* defs */ })
// Call directly:
functionName(arg1, arg2)
```
## Common Mistakes to Avoid
1. **Don't forget null terminators** - `Buffer.from(str + "\0")` not `Buffer.from(str)`
2. **Pointer lifetime** - Keep TypedArrays alive while C code uses them
3. **Type mismatches** - `FFIType.i32` vs `FFIType.u32` matters!
4. **Missing cleanup** - C libraries don't have garbage collection
## Best Practices from Real Examples
1. **Use `suffix` for cross-platform library loading:**
```javascript
import { suffix } from "bun:ffi"
dlopen(`libname.${suffix}`, { /* ... */ })
```
2. **Check for null on resource creation:**
```javascript
const chip = lib.gpiod_chip_open(cstr(path))
if (!chip) {
console.error("Failed to open")
process.exit(1)
}
```
3. **Free configs after use:**
```javascript
const config = lib.create_config()
// ... use config ...
lib.free_config(config) // Don't leak!
```
## What Makes Bun FFI Special
- **Performance**: JIT compilation beats traditional FFI
- **Simplicity**: No build tools, no gyp, no node-gyp nightmares
- **TypeScript native**: Works seamlessly with TS type system
- **Built-in**: Ships with Bun, zero dependencies
## Hard-Won Lessons from GPIO Implementation
### 1. **Enum values MUST match the C header exactly**
We spent hours debugging because our constants were off by one:
```typescript
// WRONG - missing GPIOD_LINE_BIAS_AS_IS
export const GPIOD_LINE_BIAS_UNKNOWN = 1 // Actually should be 2!
export const GPIOD_LINE_BIAS_DISABLED = 2 // Actually should be 3!
export const GPIOD_LINE_BIAS_PULL_UP = 3 // Actually should be 4!
// CORRECT - includes AS_IS at position 1
export const GPIOD_LINE_BIAS_AS_IS = 1
export const GPIOD_LINE_BIAS_UNKNOWN = 2
export const GPIOD_LINE_BIAS_DISABLED = 3
export const GPIOD_LINE_BIAS_PULL_UP = 4
export const GPIOD_LINE_BIAS_PULL_DOWN = 5
```
**Lesson:** Always grep the header file for the complete enum, don't assume!
### 2. **Hardware debouncing requires correct constants**
With wrong constants, we were accidentally passing `BIAS_DISABLED` instead of `BIAS_PULL_UP`, which meant:
- No pull resistor (pin floated)
- Debouncing didn't work at all
- Got 6+ events per button press
After fixing: **Clean single events with 1ms debounce via kernel!**
### 3. **Edge detection is event-driven, not polling**
Don't poll `get_value()` in a loop! Use:
- `gpiod_line_request_wait_edge_events()` - blocks until interrupt
- `gpiod_line_request_read_edge_events()` - reads queued events
- Much more efficient, CPU sleeps until hardware event
### 4. **TypedArray to pointer needs `ptr()`**
When passing arrays to C functions:
```typescript
const offsets = new Uint32Array([21])
gpiod.gpiod_line_config_add_line_settings(
lineConfig,
ptr(offsets), // Need ptr() wrapper!
1,
lineSettings
)
```
### 5. **Signal handling for clean shutdown**
Generators don't run `finally` blocks if abandoned. Need:
```typescript
let shouldExit = false
process.on("SIGINT", () => { shouldExit = true })
while (!shouldExit) {
const ret = wait_edge_events(request, 100_000_000) // Use timeout!
// ...
}
```
### 6. **Button wiring determines logic**
- **GND button + pull-UP**: Press = FALLING edge (HIGH→LOW)
- **VCC button + pull-DOWN**: Press = RISING edge (LOW→HIGH)
Always check initial pin state to verify wiring!
## Resources Used
- Official Bun FFI docs: https://bun.com/docs/runtime/ffi
- libgpiod v2 C API: https://libgpiod.readthedocs.io/en/latest/core_api.html
- Python bindings examples: https://github.com/brgl/libgpiod/tree/master/bindings/python/examples
- Real examples: GitHub searches for bun FFI projects
- Community discussions: Bun issue tracker and HN threads

View File

@ -1,341 +0,0 @@
# Pins
High-level GPIO library for Bun using libgpiod v2 with automatic resource management.
## Features
- Type-safe TypeScript API with autocomplete for pin names
- Automatic resource cleanup with `using` keyword
- Hardware debouncing via kernel
- Event-driven input handling
- Efficient multi-pin monitoring with input groups
- Zero external dependencies (uses Bun FFI)
## Requirements
- Bun 1.0+
- libgpiod v2 (libgpiod.so.3)
- Linux system with GPIO support (Raspberry Pi, etc.)
- TypeScript 5.2+ (for `using` keyword support)
## Quick Start
```typescript
import { GPIO } from "pins"
const gpio = new GPIO()
// Blink an LED
using led = gpio.output(17)
for (let i = 0; i < 10; i++) {
led.toggle()
await Bun.sleep(500)
}
// Read a button
using button = gpio.input(20, { pull: "up", debounce: 10 })
console.log(button.value)
// Listen for button events
for await (const event of button.events()) {
console.log(event.value === 0 ? "Pressed!" : "Released")
}
```
## API
### GPIO Class
#### `new GPIO(options?)`
Create a GPIO instance.
```typescript
const gpio = new GPIO({ chip: "/dev/gpiochip0" }) // defaults to /dev/gpiochip0
```
#### `gpio.output(pin, options?)`
Configure a pin as output.
```typescript
using led = gpio.output(17, { initialValue: 0 })
led.value = 1
led.toggle()
```
Options:
- `initialValue?: 0 | 1` - Initial pin state (default: 0)
#### `gpio.input(pin, options?)`
Configure a pin as input.
```typescript
using button = gpio.input(20, {
pull: "up", // 'up' | 'down' | 'none'
debounce: 10, // milliseconds
edge: "both", // 'rising' | 'falling' | 'both'
})
```
Options:
- `pull?: 'up' | 'down' | 'none'` - Pull resistor (default: 'up')
- `debounce?: number` - Debounce period in milliseconds (default: 0)
- `edge?: 'rising' | 'falling' | 'both'` - Edge detection (default: 'both')
#### `gpio.inputGroup(config)`
Monitor multiple inputs efficiently with a single file descriptor. Pin names are fully type-safe!
```typescript
using inputs = gpio.inputGroup({
hook: { pin: 20, pull: "up" },
rotary: { pin: 21, pull: "up", debounce: 1 },
button: { pin: 22, pull: "down" },
})
// Access individual pins (fully typed!)
console.log(inputs.pins.hook.value) // TypeScript knows about .hook
console.log(inputs.pins.button.value) // TypeScript knows about .button
// Monitor all pins
for await (const event of inputs.events()) {
console.log(`${event.pin}: ${event.value}`) // event.pin is "hook" | "rotary" | "button"
}
```
#### `gpio.listChips()`
List available GPIO chips.
```typescript
const chips = await gpio.listChips()
console.log(chips)
// [{ path: '/dev/gpiochip0', name: 'pinctrl-bcm2835', label: '...', numLines: 58 }]
```
### InputPin
```typescript
using button = gpio.input(20)
// Read current state
const value: 0 | 1 = button.value
// Wait for specific value
await button.waitForValue(0) // wait for LOW
await button.waitForValue(1, 5000) // wait for HIGH with 5s timeout
// Event stream
for await (const event of button.events()) {
console.log(event.value, event.timestamp)
}
```
### OutputPin
```typescript
using led = gpio.output(17)
// Read/write state
led.value = 1
const value = led.value
led.toggle()
```
### InputGroup
```typescript
using inputs = gpio.inputGroup({
switch: { pin: 16, pull: "up" },
button: { pin: 20, pull: "up", debounce: 10 }
})
// Access pins with full type safety
inputs.pins.switch.value // ✓ TypeScript autocomplete
inputs.pins.button.value // ✓ TypeScript autocomplete
// Wait for specific pin values
await inputs.pins.button.waitForValue(0) // wait for button to go LOW
await inputs.pins.switch.waitForValue(1, 3000) // wait for switch to go HIGH with timeout
// Monitor all pins
for await (const event of inputs.events()) {
event.pin // Type: 'switch' | 'button'
event.value // Type: 0 | 1
event.timestamp // Type: bigint (nanoseconds)
}
```
## Resource Management
**IMPORTANT:** Always use the `using` keyword to ensure proper cleanup of GPIO resources.
```typescript
// Good - automatic cleanup
{
using led = gpio.output(17)
led.value = 1
} // Automatically released
// Bad - manual cleanup required
const led = gpio.output(17)
led.value = 1
led.close() // Must call manually
```
## Hardware Setup
### Pull Resistors
Pull resistors prevent floating input values when nothing is connected to the pin.
- **Pull-up + button to GND**: When released, pin reads HIGH (1). When pressed, pin reads LOW (0).
- **Pull-down + button to VCC**: When released, pin reads LOW (0). When pressed, pin reads HIGH (1).
**Important:** Match your pull resistor to your wiring:
- Button to ground → use `pull: "up"`
- Button to VCC (3.3V) → use `pull: "down"`
### Debouncing
Mechanical buttons "bounce" - they make and break contact multiple times when pressed. Hardware debouncing eliminates these spurious events at the kernel level:
```typescript
using button = gpio.input(20, {
pull: "up",
debounce: 10, // 10ms debounce - ignore edges within 10ms of previous edge
})
```
Typical debounce values: 5-50ms depending on your button quality.
## Error Handling
```typescript
try {
using led = gpio.output(17)
} catch (err) {
if (err instanceof PermissionError) {
// Add user to gpio group: sudo usermod -aG gpio $USER
} else if (err instanceof PinInUseError) {
// Pin is already in use by another process
} else if (err instanceof ChipNotFoundError) {
// GPIO chip not found at path
}
}
```
## Examples
### Simple LED Blink
```typescript
import { GPIO } from "@/pins"
const gpio = new GPIO()
using led = gpio.output(17)
for (let i = 0; i < 10; i++) {
led.toggle()
await Bun.sleep(500)
}
```
### Button and Switch
```typescript
import { GPIO } from "@/pins"
const gpio = new GPIO()
using inputs = gpio.inputGroup({
button: { pin: 20, pull: "up", debounce: 10 },
switch: { pin: 16, pull: "up" }
})
using led = gpio.output(21)
// Set LED based on switch state
if (inputs.pins.switch.value === 1) {
led.value = 1
}
// Toggle LED when button pressed
for await (const event of inputs.events()) {
if (event.pin === "button" && event.value === 0) {
led.toggle()
} else if (event.pin === "switch") {
led.value = event.value
}
}
```
### Rotary Phone Dialer
```typescript
import { GPIO } from "@/pins"
const gpio = new GPIO()
using inputs = gpio.inputGroup({
hook: { pin: 20, pull: "up" },
rotary: { pin: 21, pull: "up", debounce: 1 },
})
for await (const event of inputs.events()) {
if (event.pin === "hook") {
console.log(event.value === 0 ? "Phone picked up" : "Phone hung up")
} else if (event.pin === "rotary" && event.value === 0) {
console.log("Rotary pulse")
}
}
```
## Troubleshooting
### Permission Denied
Add your user to the `gpio` group:
```bash
sudo usermod -aG gpio $USER
```
Then log out and back in.
### Pin Already in Use
Another process has claimed the pin. Stop that process or use a different pin.
### Chip Not Found
Verify GPIO hardware is enabled:
```bash
ls /dev/gpiochip*
```
If you see "libgpiod v2 (libgpiod.so.3) not found", install it:
```bash
sudo apt-get install libgpiod-dev
```
## Design Philosophy
This library provides a simple, type-safe interface to GPIO:
- **Low-level values** - Events return raw 0/1 values, let users interpret semantics
- **Simple by default** - Sensible defaults for common use cases
- **Explicit when needed** - Full control via options
- **Type-safe** - TypeScript catches errors at compile time and provides autocomplete
- **Resource-safe** - `using` keyword prevents leaks
## References
- [libgpiod v2 documentation](https://libgpiod.readthedocs.io/)
- [Bun FFI documentation](https://bun.sh/docs/api/ffi)

View File

@ -1,53 +0,0 @@
export class GPIOError extends Error {
code: string
constructor(message: string, code: string) {
super(message)
this.name = "GPIOError"
this.code = code
}
}
export class PermissionError extends GPIOError {
constructor(path: string) {
super(
`Permission denied accessing ${path}. Try:\n` +
` 1. Add your user to the 'gpio' group: sudo usermod -aG gpio $USER\n` +
` 2. Log out and back in\n` +
` 3. Or run with sudo (not recommended for production)`,
"PERMISSION_DENIED"
)
this.name = "PermissionError"
}
}
export class PinInUseError extends GPIOError {
constructor(pin: number) {
super(
`Pin ${pin} is already in use by another process or request. ` +
`Only one process can control a GPIO pin at a time.`,
"PIN_IN_USE"
)
this.name = "PinInUseError"
}
}
export class ChipNotFoundError extends GPIOError {
constructor(path: string) {
super(
`GPIO chip not found at ${path}. Check that:\n` +
` 1. The path exists (ls ${path})\n` +
` 2. GPIO hardware is enabled in your system configuration\n` +
` 3. You're running on compatible hardware (Raspberry Pi, etc.)`,
"CHIP_NOT_FOUND"
)
this.name = "ChipNotFoundError"
}
}
export class InvalidConfigError extends GPIOError {
constructor(message: string) {
super(message, "INVALID_CONFIG")
this.name = "InvalidConfigError"
}
}

View File

@ -1,171 +0,0 @@
import { dlopen, FFIType, ptr } from "bun:ffi"
export const cstr = (s: string) => ptr(Buffer.from(s + "\0"))
const findLibgpiod = () => {
try {
dlopen("libgpiod.so.3", {
gpiod_chip_open: {
args: [FFIType.cstring],
returns: FFIType.ptr,
},
})
return "libgpiod.so.3"
} catch {
throw new Error(
"libgpiod v2 (libgpiod.so.3) not found. Install with: sudo apt-get install libgpiod-dev"
)
}
}
// Constants MUST match C header exactly
export const GPIOD_LINE_DIRECTION_AS_IS = 1
export const GPIOD_LINE_DIRECTION_INPUT = 2
export const GPIOD_LINE_DIRECTION_OUTPUT = 3
export const GPIOD_LINE_BIAS_AS_IS = 1
export const GPIOD_LINE_BIAS_UNKNOWN = 2
export const GPIOD_LINE_BIAS_DISABLED = 3
export const GPIOD_LINE_BIAS_PULL_UP = 4
export const GPIOD_LINE_BIAS_PULL_DOWN = 5
export const GPIOD_LINE_EDGE_NONE = 1
export const GPIOD_LINE_EDGE_RISING = 2
export const GPIOD_LINE_EDGE_FALLING = 3
export const GPIOD_LINE_EDGE_BOTH = 4
export const GPIOD_EDGE_EVENT_RISING_EDGE = 1
export const GPIOD_EDGE_EVENT_FALLING_EDGE = 2
const lib = dlopen(findLibgpiod(), {
gpiod_chip_open: {
args: [FFIType.cstring],
returns: FFIType.ptr,
},
gpiod_chip_close: {
args: [FFIType.ptr],
returns: FFIType.void,
},
gpiod_chip_get_info: {
args: [FFIType.ptr],
returns: FFIType.ptr,
},
gpiod_chip_info_get_name: {
args: [FFIType.ptr],
returns: FFIType.cstring,
},
gpiod_chip_info_get_label: {
args: [FFIType.ptr],
returns: FFIType.cstring,
},
gpiod_chip_info_get_num_lines: {
args: [FFIType.ptr],
returns: FFIType.u64,
},
gpiod_request_config_new: {
args: [],
returns: FFIType.ptr,
},
gpiod_request_config_set_consumer: {
args: [FFIType.ptr, FFIType.cstring],
returns: FFIType.void,
},
gpiod_request_config_free: {
args: [FFIType.ptr],
returns: FFIType.void,
},
gpiod_line_config_new: {
args: [],
returns: FFIType.ptr,
},
gpiod_line_config_add_line_settings: {
args: [FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.ptr],
returns: FFIType.i32,
},
gpiod_line_config_free: {
args: [FFIType.ptr],
returns: FFIType.void,
},
gpiod_line_settings_new: {
args: [],
returns: FFIType.ptr,
},
gpiod_line_settings_set_direction: {
args: [FFIType.ptr, FFIType.i32],
returns: FFIType.i32,
},
gpiod_line_settings_set_output_value: {
args: [FFIType.ptr, FFIType.i32],
returns: FFIType.i32,
},
gpiod_line_settings_set_bias: {
args: [FFIType.ptr, FFIType.i32],
returns: FFIType.i32,
},
gpiod_line_settings_set_edge_detection: {
args: [FFIType.ptr, FFIType.i32],
returns: FFIType.i32,
},
gpiod_line_settings_set_debounce_period_us: {
args: [FFIType.ptr, FFIType.u64],
returns: FFIType.i32,
},
gpiod_line_settings_free: {
args: [FFIType.ptr],
returns: FFIType.void,
},
gpiod_chip_request_lines: {
args: [FFIType.ptr, FFIType.ptr, FFIType.ptr],
returns: FFIType.ptr,
},
gpiod_line_request_release: {
args: [FFIType.ptr],
returns: FFIType.void,
},
gpiod_line_request_get_fd: {
args: [FFIType.ptr],
returns: FFIType.i32,
},
gpiod_line_request_set_value: {
args: [FFIType.ptr, FFIType.u32, FFIType.i32],
returns: FFIType.i32,
},
gpiod_line_request_get_value: {
args: [FFIType.ptr, FFIType.u32],
returns: FFIType.i32,
},
gpiod_line_request_wait_edge_events: {
args: [FFIType.ptr, FFIType.i64],
returns: FFIType.i32,
},
gpiod_line_request_read_edge_events: {
args: [FFIType.ptr, FFIType.ptr, FFIType.u64],
returns: FFIType.i32,
},
gpiod_edge_event_buffer_new: {
args: [FFIType.u64],
returns: FFIType.ptr,
},
gpiod_edge_event_buffer_free: {
args: [FFIType.ptr],
returns: FFIType.void,
},
gpiod_edge_event_buffer_get_event: {
args: [FFIType.ptr, FFIType.u64],
returns: FFIType.ptr,
},
gpiod_edge_event_get_line_offset: {
args: [FFIType.ptr],
returns: FFIType.u32,
},
gpiod_edge_event_get_event_type: {
args: [FFIType.ptr],
returns: FFIType.i32,
},
gpiod_edge_event_get_timestamp_ns: {
args: [FFIType.ptr],
returns: FFIType.u64,
},
})
export const gpiod = lib.symbols

View File

@ -1,199 +0,0 @@
import { ptr } from "bun:ffi"
import { readdir } from "node:fs/promises"
import { gpiod, cstr, GPIOD_LINE_DIRECTION_OUTPUT, GPIOD_LINE_DIRECTION_INPUT } from "./ffi"
import { OutputPin } from "./output"
import { InputPin } from "./input"
import { InputGroup } from "./input-group"
import { ChipNotFoundError, PinInUseError } from "./errors"
import { mapPullToLibgpiod, mapEdgeToLibgpiod, hashInputConfig } from "./utils"
import type { OutputOptions, InputOptions, PullMode, ChipInfo } from "./types"
export class GPIO {
#chipPath: string
#resetOnClose: boolean
constructor(options?: { chip?: string; resetOnClose?: boolean }) {
this.#chipPath = options?.chip ?? "/dev/gpiochip0"
this.#resetOnClose = options?.resetOnClose ?? false
}
output(pin: number, options?: OutputOptions): OutputPin {
const initialValue = options?.initialValue ?? 0
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
if (!chip) {
throw new ChipNotFoundError(this.#chipPath)
}
try {
const reqConfig = gpiod.gpiod_request_config_new()
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
const lineSettings = gpiod.gpiod_line_settings_new()
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_OUTPUT)
gpiod.gpiod_line_settings_set_output_value(lineSettings, initialValue)
const lineConfig = gpiod.gpiod_line_config_new()
const offsets = new Uint32Array([pin])
gpiod.gpiod_line_config_add_line_settings(lineConfig, ptr(offsets), 1, lineSettings)
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
gpiod.gpiod_line_settings_free(lineSettings)
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_request_config_free(reqConfig)
if (!request) {
gpiod.gpiod_chip_close(chip)
throw new PinInUseError(pin)
}
let resetValue: 0 | 1 | undefined
if (this.#resetOnClose) {
const currentValue = gpiod.gpiod_line_request_get_value(request, pin)
if (currentValue === -1) {
console.warn(`Failed to read initial value for pin ${pin}, assuming 0`)
resetValue = 0
} else {
resetValue = currentValue as 0 | 1
}
}
return new OutputPin(chip, request, pin, resetValue)
} catch (err) {
gpiod.gpiod_chip_close(chip)
throw err
}
}
input(pin: number, options?: InputOptions): InputPin<"pin"> {
const group = this.inputGroup({
pin: { pin, ...options },
})
return new InputPin(group, "pin")
}
inputGroup<T extends Record<string, { pin: number } & InputOptions>>(
config: T
): InputGroup<keyof T & string> {
const chip = gpiod.gpiod_chip_open(cstr(this.#chipPath))
if (!chip) {
throw new ChipNotFoundError(this.#chipPath)
}
try {
const reqConfig = gpiod.gpiod_request_config_new()
gpiod.gpiod_request_config_set_consumer(reqConfig, cstr("bun-gpio"))
const lineConfig = gpiod.gpiod_line_config_new()
const groups = new Map<
string,
Array<{ name: string; pin: number; pull: PullMode; options: InputOptions }>
>()
for (const [name, pinConfig] of Object.entries(config)) {
const pull = pinConfig.pull ?? "up"
const debounce = pinConfig.debounce ?? 0
const edge = pinConfig.edge ?? "both"
const hash = hashInputConfig(pull, debounce, edge)
if (!groups.has(hash)) groups.set(hash, [])
groups.get(hash)!.push({ name, pin: pinConfig.pin, pull, options: pinConfig })
}
for (const [hash, pins] of groups) {
const firstPin = pins[0]
if (!firstPin) continue
const pull = firstPin.options.pull ?? "up"
const debounce = firstPin.options.debounce ?? 0
const edge = firstPin.options.edge ?? "both"
const lineSettings = gpiod.gpiod_line_settings_new()
gpiod.gpiod_line_settings_set_direction(lineSettings, GPIOD_LINE_DIRECTION_INPUT)
gpiod.gpiod_line_settings_set_bias(lineSettings, mapPullToLibgpiod(pull))
gpiod.gpiod_line_settings_set_edge_detection(lineSettings, mapEdgeToLibgpiod(edge))
gpiod.gpiod_line_settings_set_debounce_period_us(lineSettings, debounce * 1000)
const offsets = new Uint32Array(pins.map((p) => p.pin))
gpiod.gpiod_line_config_add_line_settings(
lineConfig,
ptr(offsets),
pins.length,
lineSettings
)
gpiod.gpiod_line_settings_free(lineSettings)
}
const request = gpiod.gpiod_chip_request_lines(chip, reqConfig, lineConfig)
gpiod.gpiod_line_config_free(lineConfig)
gpiod.gpiod_request_config_free(reqConfig)
if (!request) {
gpiod.gpiod_chip_close(chip)
const firstConfig = Object.values(config)[0]
throw new PinInUseError(firstConfig?.pin ?? 0)
}
const pinMap: Record<string, { offset: number; pull: PullMode }> = {}
for (const [name, pinConfig] of Object.entries(config)) {
pinMap[name] = {
offset: pinConfig.pin,
pull: pinConfig.pull ?? "up",
}
}
return new InputGroup(chip, request, pinMap)
} catch (err) {
gpiod.gpiod_chip_close(chip)
throw err
}
}
async listChips(): Promise<ChipInfo[]> {
const chips: ChipInfo[] = []
try {
const files = await readdir("/dev")
const chipFiles = files.filter((f) => f.startsWith("gpiochip"))
for (const file of chipFiles) {
const path = `/dev/${file}`
try {
const chip = gpiod.gpiod_chip_open(cstr(path))
if (!chip) continue
const info = gpiod.gpiod_chip_get_info(chip)
if (!info) {
gpiod.gpiod_chip_close(chip)
continue
}
const name = gpiod.gpiod_chip_info_get_name(info)
const label = gpiod.gpiod_chip_info_get_label(info)
const numLines = gpiod.gpiod_chip_info_get_num_lines(info)
chips.push({
path,
name: String(name || ""),
label: String(label || ""),
numLines: Number(numLines),
})
gpiod.gpiod_chip_close(chip)
} catch {
continue
}
}
} catch {
// /dev might not be accessible, return empty array
}
return chips
}
}

View File

@ -1,17 +0,0 @@
export { GPIO } from "./gpio"
export {
GPIOError,
PermissionError,
PinInUseError,
ChipNotFoundError,
InvalidConfigError,
} from "./errors"
export type {
InputOptions,
OutputOptions,
InputEvent,
InputGroupEvent,
ChipInfo,
PullMode,
EdgeMode,
} from "./types"

View File

@ -1,194 +0,0 @@
import type { Pointer } from "bun:ffi"
import { gpiod } from "./ffi"
import { mapLibgpiodEdgeToPressedState } from "./utils"
import type { PullMode, InputEvent, InputGroupEvent, PinConfig } from "./types"
export class InputGroup<T extends string = string> {
#closed = false
#chip: Pointer
#request: Pointer
#pinMap: Map<string, { offset: number; pull: PullMode }>
#offsetMap: Map<number, { name: string; pull: PullMode }>
#eventBuffer: Pointer | undefined
#eventListeners: Array<(event: InputGroupEvent<T>) => void> = []
#closeHandlers: Array<() => void> = []
constructor(chip: Pointer, request: Pointer, pinConfig: PinConfig) {
this.#chip = chip
this.#request = request
this.#pinMap = new Map()
this.#offsetMap = new Map()
for (const [name, config] of Object.entries(pinConfig)) {
this.#pinMap.set(name, config)
this.#offsetMap.set(config.offset, { name, pull: config.pull })
}
}
get pins(): Record<T, { readonly value: 0 | 1; waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void> }> {
const result = {} as Record<T, { readonly value: 0 | 1; waitForValue: (targetValue: 0 | 1, timeout?: number) => Promise<void> }>
for (const [name, config] of this.#pinMap) {
const offset = config.offset
const closed = () => this.#closed
const request = this.#request
const pinName = name
Object.defineProperty(result, name, {
get: () => ({
get value(): 0 | 1 {
if (closed()) throw new Error("InputGroup is closed")
const ret = gpiod.gpiod_line_request_get_value(request, offset)
if (ret === -1) throw new Error("Failed to get pin value")
return ret as 0 | 1
},
waitForValue: (targetValue: 0 | 1, timeout?: number) => this.#waitForPinValue(pinName as T, targetValue, timeout)
}),
enumerable: true
})
}
return result
}
async #waitForPinValue(pinName: T, targetValue: 0 | 1, timeout?: number): Promise<void> {
return new Promise((resolve, reject) => {
if (this.#closed) {
reject(new Error("InputGroup is closed"))
return
}
let timeoutId: ReturnType<typeof setTimeout> | undefined
const cleanup = () => {
if (timeoutId) clearTimeout(timeoutId)
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
this.#closeHandlers = this.#closeHandlers.filter((h) => h !== onClose)
}
const onClose = () => {
cleanup()
reject(new Error("InputGroup closed while waiting"))
}
const listener = (event: InputGroupEvent<T>) => {
if (event.pin !== pinName) return
if (event.value !== targetValue) return
cleanup()
resolve()
}
if (timeout) {
timeoutId = setTimeout(() => {
cleanup()
reject(new Error(`Timeout waiting for pin ${pinName} to become ${targetValue}`))
}, timeout)
}
this.#eventListeners.push(listener)
this.#closeHandlers.push(onClose)
this.#startEventLoop()
})
}
async *events(): AsyncGenerator<InputGroupEvent<T>> {
if (this.#closed) throw new Error("InputGroup is closed")
const eventQueue: InputGroupEvent<T>[] = []
let resolve: (() => void) | undefined
const listener = (event: InputGroupEvent<T>) => {
eventQueue.push(event)
resolve?.()
}
this.#eventListeners.push(listener)
this.#startEventLoop()
try {
while (!this.#closed) {
if (eventQueue.length === 0) {
await new Promise<void>((r) => {
resolve = r
})
}
const event = eventQueue.shift()
if (event) yield event
}
} finally {
this.#eventListeners = this.#eventListeners.filter((l) => l !== listener)
}
}
#startEventLoop() {
if (this.#eventBuffer !== undefined) return
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
if (!buffer) throw new Error("Failed to create event buffer")
this.#eventBuffer = buffer
this.#runEventLoop()
}
async #runEventLoop() {
try {
while (!this.#closed && this.#eventListeners.length > 0) {
const ret = gpiod.gpiod_line_request_wait_edge_events(this.#request, 100_000_000)
if (ret === -1 || ret === 0) {
await Bun.sleep(0)
continue
}
const numEvents = gpiod.gpiod_line_request_read_edge_events(
this.#request,
this.#eventBuffer!,
1
)
if (numEvents > 0) {
const event = gpiod.gpiod_edge_event_buffer_get_event(this.#eventBuffer!, 0)
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
const offset = gpiod.gpiod_edge_event_get_line_offset(event)
const pinInfo = this.#offsetMap.get(offset)
if (!pinInfo) continue
const pressed = mapLibgpiodEdgeToPressedState(edgeType, pinInfo.pull)
const value = (pressed ? (pinInfo.pull === "up" ? 0 : 1) : (pinInfo.pull === "up" ? 1 : 0)) as 0 | 1
const inputEvent: InputGroupEvent<T> = { pin: pinInfo.name as T, value, timestamp }
for (const listener of this.#eventListeners) {
listener(inputEvent)
}
}
}
} finally {
if (this.#eventBuffer) {
gpiod.gpiod_edge_event_buffer_free(this.#eventBuffer)
this.#eventBuffer = undefined
}
}
}
close() {
if (this.#closed) return
this.#closed = true
for (const handler of this.#closeHandlers) {
handler()
}
this.#closeHandlers = []
gpiod.gpiod_line_request_release(this.#request)
gpiod.gpiod_chip_close(this.#chip)
}
[Symbol.dispose]() {
this.close()
}
}

View File

@ -1,39 +0,0 @@
import type { Pointer } from "bun:ffi"
import { InputGroup } from "./input-group"
import type { PullMode, InputEvent } from "./types"
export class InputPin<T extends string = string> {
#group: InputGroup<T>
#pinName: T
constructor(group: InputGroup<T>, pinName: T) {
this.#group = group
this.#pinName = pinName
}
get value(): 0 | 1 {
return this.#group.pins[this.#pinName]!.value
}
async waitForValue(targetValue: 0 | 1, timeout?: number): Promise<void> {
for await (const event of this.#group.events()) {
if (event.value === targetValue) {
return
}
}
}
async *events(): AsyncGenerator<InputEvent> {
for await (const event of this.#group.events()) {
yield { value: event.value, timestamp: event.timestamp }
}
}
close() {
this.#group.close()
}
[Symbol.dispose]() {
this.close()
}
}

View File

@ -1,50 +0,0 @@
import type { Pointer } from "bun:ffi"
import { gpiod } from "./ffi"
export class OutputPin {
#closed = false
#chip: Pointer
#request: Pointer
#pin: number
#resetValue?: 0 | 1
constructor(chip: Pointer, request: Pointer, pin: number, resetValue?: 0 | 1) {
this.#chip = chip
this.#request = request
this.#pin = pin
this.#resetValue = resetValue
}
get value(): 0 | 1 {
if (this.#closed) throw new Error("OutputPin is closed")
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#pin)
if (ret === -1) throw new Error("Failed to get pin value")
return ret as 0 | 1
}
set value(val: 0 | 1) {
if (this.#closed) throw new Error("OutputPin is closed")
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#pin, val)
if (ret === -1) throw new Error("Failed to set pin value")
}
toggle() {
this.value = this.value === 0 ? 1 : 0
}
close() {
if (this.#closed) return
this.#closed = true
if (this.#resetValue !== undefined) {
gpiod.gpiod_line_request_set_value(this.#request, this.#pin, this.#resetValue)
}
gpiod.gpiod_line_request_release(this.#request)
gpiod.gpiod_chip_close(this.#chip)
}
[Symbol.dispose]() {
this.close()
}
}

View File

@ -1,30 +0,0 @@
export type PullMode = "up" | "down" | "none"
export type EdgeMode = "rising" | "falling" | "both"
export type InputOptions = {
pull?: PullMode // default: 'up'
debounce?: number // milliseconds, default: 0
edge?: EdgeMode // default: 'both'
}
export type OutputOptions = {
initialValue?: 0 | 1 // default: 0
}
export type InputEvent = {
value: 0 | 1
timestamp: bigint // nanoseconds
}
export type InputGroupEvent<T extends string = string> = InputEvent & {
pin: T // name of the pin that fired
}
export type ChipInfo = {
path: string
name: string
label: string
numLines: number
}
export type PinConfig = Record<string, { offset: number; pull: PullMode }>

View File

@ -1,60 +0,0 @@
import { ptr } from "bun:ffi"
import {
GPIOD_LINE_BIAS_PULL_UP,
GPIOD_LINE_BIAS_PULL_DOWN,
GPIOD_LINE_BIAS_DISABLED,
GPIOD_LINE_EDGE_RISING,
GPIOD_LINE_EDGE_FALLING,
GPIOD_LINE_EDGE_BOTH,
GPIOD_EDGE_EVENT_RISING_EDGE,
GPIOD_EDGE_EVENT_FALLING_EDGE,
} from "./ffi"
import type { PullMode, EdgeMode } from "./types"
export const cstr = (s: string) => ptr(Buffer.from(s + "\0"))
export const mapPullToLibgpiod = (pull: PullMode): number => {
switch (pull) {
case "up":
return GPIOD_LINE_BIAS_PULL_UP
case "down":
return GPIOD_LINE_BIAS_PULL_DOWN
case "none":
return GPIOD_LINE_BIAS_DISABLED
}
}
export const mapEdgeToLibgpiod = (edge: EdgeMode): number => {
switch (edge) {
case "rising":
return GPIOD_LINE_EDGE_RISING
case "falling":
return GPIOD_LINE_EDGE_FALLING
case "both":
return GPIOD_LINE_EDGE_BOTH
}
}
// Hardware logic:
// - Pull-up + button to GND: pressing pulls line LOW (falling edge = pressed)
// - Pull-down + button to VCC: pressing pulls line HIGH (rising edge = pressed)
export const mapLibgpiodEdgeToPressedState = (
edgeType: number,
pull: PullMode
): boolean => {
if (pull === "up") {
return edgeType === GPIOD_EDGE_EVENT_FALLING_EDGE
} else if (pull === "down") {
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE
} else {
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE
}
}
export const hashInputConfig = (
pull: PullMode,
debounce: number,
edge: EdgeMode
): string => {
return `${pull}-${debounce}-${edge}`
}

View File

@ -1,8 +1,8 @@
import { Layout } from "./Layout"
import { Layout } from "./Layout";
type ConnectingPageProps = {
ssid: string
}
ssid: string;
};
export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
<Layout title="Connecting...">
@ -11,9 +11,7 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
<p>
<strong>SSID:</strong> {ssid}
</p>
<p id="status">
Testing connection... <span id="countdown">10</span>s remaining
</p>
<p id="status">Testing connection... <span id="countdown">10</span>s remaining</p>
<p>
<small>Waiting to see if connection succeeds...</small>
</p>
@ -28,12 +26,8 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
<hr />
<h3>Next Steps:</h3>
<ol>
<li>
Switch your device to the <strong>{ssid}</strong> network
</li>
<li>
Visit <a href="http://phone.local">http://phone.local</a>
</li>
<li>Switch your device to the <strong>{ssid}</strong> network</li>
<li>Visit <a href="http://yellow-phone.local">http://yellow-phone.local</a></li>
</ol>
<p>
<small>The AP will shut down automatically since the Pi is now connected to WiFi.</small>
@ -42,14 +36,10 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
<div id="error-state" style="display: none;">
<h1> Connection Failed</h1>
<p>
Could not connect to <strong>{ssid}</strong>
</p>
<p>Could not connect to <strong>{ssid}</strong></p>
<p>The password may be incorrect, or the network is out of range.</p>
<p>The AP is still running - you can try again.</p>
<a href="/" role="button">
Try Again
</a>
<a href="/" role="button"> Try Again</a>
</div>
<script>{`
@ -81,4 +71,4 @@ export const ConnectingPage = ({ ssid }: ConnectingPageProps) => (
}, 1000);
`}</script>
</Layout>
)
);

View File

@ -1,99 +1,98 @@
#!/usr/bin/env bun
import { Hono } from "hono"
import { join } from "node:path"
import { $ } from "bun"
import { IndexPage } from "./components/IndexPage"
import { LogsPage } from "./components/LogsPage"
import { ConnectingPage } from "./components/ConnectingPage"
import { Hono } from "hono";
import {join} from "node:path";
import { $ } from "bun";
import { IndexPage } from "./components/IndexPage";
import { LogsPage } from "./components/LogsPage";
import { ConnectingPage } from "./components/ConnectingPage";
const app = new Hono()
const app = new Hono();
// Ping endpoint for connectivity check
app.get("/ping", (c) => {
return c.json({ ok: true })
})
return c.json({ ok: true });
});
// Serve static CSS
app.get("/pico.css", async (c) => {
const cssPath = join(import.meta.dir, "./static/pico.min.css")
const file = Bun.file(cssPath)
return new Response(file)
})
const cssPath = join(import.meta.dir, "./static/pico.min.css");
const file = Bun.file(cssPath);
return new Response(file);
});
// API endpoint to get available WiFi networks
app.get("/api/networks", async (c) => {
try {
const result = await $`nmcli -t -f SSID device wifi list`.text()
const result = await $`nmcli -t -f SSID device wifi list`.text();
const networks = result
.trim()
.split("\n")
.filter((ssid) => ssid && ssid !== "SSID") // Remove empty and header
.filter((ssid, index, self) => self.indexOf(ssid) === index) // Remove duplicates
return c.json({ networks })
.split('\n')
.filter(ssid => ssid && ssid !== 'SSID') // Remove empty and header
.filter((ssid, index, self) => self.indexOf(ssid) === index); // Remove duplicates
return c.json({ networks });
} catch (error) {
return c.json({ networks: [], error: String(error) }, 500)
return c.json({ networks: [], error: String(error) }, 500);
}
})
});
// API endpoint to get logs (for auto-refresh)
app.get("/api/logs", async (c) => {
try {
const logs =
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
return c.json({ logs: logs.trim() })
const logs = await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text();
return c.json({ logs: logs.trim() });
} catch (error) {
return c.json({ logs: "", error: String(error) }, 500)
return c.json({ logs: '', error: String(error) }, 500);
}
})
});
// Main WiFi configuration page
app.get("/", (c) => {
return c.html(<IndexPage />)
})
return c.html(<IndexPage />);
});
// Service logs with auto-refresh
app.get("/logs", async (c) => {
try {
const logs =
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
return c.html(<LogsPage logs={logs} />)
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text();
return c.html(<LogsPage logs={logs} />);
} catch (error) {
throw new Error(`Failed to fetch logs: ${error}`)
throw new Error(`Failed to fetch logs: ${error}`);
}
})
});
// Handle WiFi configuration submission
app.post("/save", async (c) => {
const formData = await c.req.parseBody()
const ssid = formData.ssid as string
const password = formData.password as string
const formData = await c.req.parseBody();
const ssid = formData.ssid as string;
const password = formData.password as string;
// Return the connecting page immediately
const response = c.html(<ConnectingPage ssid={ssid} />)
const response = c.html(<ConnectingPage ssid={ssid} />);
// Trigger connection in background after a short delay (allows response to be sent)
setTimeout(async () => {
try {
await $`sudo nmcli device wifi connect ${ssid} password ${password}`
console.log(`[WiFi] Successfully connected to ${ssid}`)
await $`sudo nmcli device wifi connect ${ssid} password ${password}`;
console.log(`[WiFi] Successfully connected to ${ssid}`);
} catch (error) {
console.error(`[WiFi] Failed to connect to ${ssid}:`, error)
console.error(`[WiFi] Failed to connect to ${ssid}:`, error);
// Delete the failed connection profile so ap-monitor doesn't try to use it
try {
await $`sudo nmcli connection delete ${ssid}`.nothrow()
console.log(`[WiFi] Deleted failed connection profile for ${ssid}`)
await $`sudo nmcli connection delete ${ssid}`.nothrow();
console.log(`[WiFi] Deleted failed connection profile for ${ssid}`);
} catch (deleteError) {
console.error(`[WiFi] Failed to delete connection profile:`, deleteError)
console.error(`[WiFi] Failed to delete connection profile:`, deleteError);
}
}
}, 1000) // 1 second delay
}, 1000); // 1 second delay
return response
})
return response;
});
export default { port: 80, fetch: app.fetch }
export default { port: 80, fetch: app.fetch };
console.log("Server running on http://0.0.0.0:80")
console.log("Access via WiFi or AP at http://phone.local")
console.log("Server running on http://0.0.0.0:80");
console.log("Access via WiFi or AP at http://yellow-phone.local");