Compare commits
No commits in common. "1c717a5b4772147c3b7ce20f512452f13c8cc510" and "18629565318688d5600ed00abef94801d2113546" have entirely different histories.
1c717a5b47
...
1862956531
16
README.md
16
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
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..."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -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)-(.+)$/);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
`)
|
||||
|
|
|
|||
47
src/hq.ts
47
src/hq.ts
|
|
@ -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!`)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
171
src/pins/ffi.ts
171
src/pins/ffi.ts
|
|
@ -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
|
||||
199
src/pins/gpio.ts
199
src/pins/gpio.ts
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }>
|
||||
|
|
@ -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}`
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user