Compare commits
No commits in common. "main" and "ding-a-ling" have entirely different histories.
main
...
ding-a-lin
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -33,8 +33,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
.claude.worktrees/
|
||||
.worktrees/
|
||||
|
||||
docs/learning/
|
||||
docs/plans/
|
||||
.claude
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
<sip:yellow@probablycorey.sip.twilio.com>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes
|
||||
<sip:yellow@probablycorey.sip.twilio.com;transport=tls>;auth_pass=zgm-kwx2bug5hwf3YGF;unregister_on_exit=yes;regint=300
|
||||
|
|
@ -1,26 +1,71 @@
|
|||
call_max_calls 4
|
||||
call_local_timeout 120
|
||||
#
|
||||
# baresip configuration
|
||||
#
|
||||
|
||||
# Audio
|
||||
audio_player alsa,default
|
||||
audio_source alsa,default
|
||||
audio_alert alsa,default
|
||||
|
||||
ring_aufile /dev/null
|
||||
|
||||
# Modules
|
||||
#------------------------------------------------------------------------------
|
||||
|
||||
module_path /usr/lib/baresip/modules
|
||||
# Core
|
||||
poll_method epoll # poll, select, epoll ..
|
||||
ring_aufile none
|
||||
|
||||
# Call
|
||||
call_local_timeout 120
|
||||
call_max_calls 4
|
||||
|
||||
# Audio
|
||||
audio_player alsa,default
|
||||
audio_source alsa,default
|
||||
audio_alert none
|
||||
audio_alert_enable no
|
||||
audio_level no
|
||||
ausrc_format s16 # s16, float, ..
|
||||
auplay_format s16 # s16, float, ..
|
||||
auenc_format s16 # s16, float, ..
|
||||
audec_format s16 # s16, float, ..
|
||||
audio_buffer 20-160 # ms
|
||||
|
||||
# AVT - Audio/Video Transport
|
||||
rtp_tos 184
|
||||
rtcp_mux no
|
||||
jitter_buffer_delay 5-10 # frames
|
||||
rtp_stats no
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Modules
|
||||
|
||||
module_path /usr/lib/baresip/modules
|
||||
|
||||
# UI Modules
|
||||
#module stdio.so
|
||||
|
||||
# Audio codec Modules (in order)
|
||||
module g711.so
|
||||
|
||||
# Audio codec Modules
|
||||
module g711.so
|
||||
|
||||
# Audio driver Modules
|
||||
module alsa.so
|
||||
module alsa.so
|
||||
|
||||
# Media NAT modules
|
||||
module stun.so
|
||||
module turn.so
|
||||
module ice.so
|
||||
|
||||
module httpd.so
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Temporary Modules (loaded then unloaded)
|
||||
|
||||
module_tmp uuid.so
|
||||
module_tmp account.so
|
||||
|
||||
|
||||
#------------------------------------------------------------------------------
|
||||
# Application Modules
|
||||
module_app account.so
|
||||
module_app menu.so
|
||||
|
||||
module httpd.so
|
||||
module_app contact.so
|
||||
module_app debug_cmd.so
|
||||
module_app menu.so
|
||||
|
||||
|
||||
http_listen 0.0.0.0:8000 # httpd - HTTP Serve
|
||||
42
bun.lock
42
bun.lock
|
|
@ -1,17 +1,15 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "tmp",
|
||||
"dependencies": {
|
||||
"robot3": "./packages/robot3",
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0",
|
||||
"robot3": "^1.2.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"prettier": "^3.6.2",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
|
|
@ -19,54 +17,24 @@
|
|||
},
|
||||
},
|
||||
"packages": {
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
|
||||
|
||||
"acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
"@types/react": ["@types/react@19.2.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
"csstype": ["csstype@3.2.2", "", {}, "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g=="],
|
||||
|
||||
"hono": ["hono@4.10.6", "", {}, "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g=="],
|
||||
|
||||
"openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="],
|
||||
"openai": ["openai@6.9.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A=="],
|
||||
|
||||
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||
|
||||
"robot3": ["robot3@file:packages/robot3", { "devDependencies": { "rollup": "^1.21.4", "terser": "^5.16.1" } }],
|
||||
|
||||
"rollup": ["rollup@1.32.1", "", { "dependencies": { "@types/estree": "*", "@types/node": "*", "acorn": "^7.1.0" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||
|
||||
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
|
||||
"robot3": ["robot3@1.2.0", "", {}, "sha512-Xin8KHqCKrD9Rqk1ZzZQYjsb6S9DRggcfwBqnVPeM3DLtNCJLxWWTrPJDYm3E+ZiTO7H3VMdgyPSkIbuYnYP2Q=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
"start": "bun run src/operator.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"prettier": "^3.6.2"
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
|
|
@ -16,7 +15,7 @@
|
|||
"dependencies": {
|
||||
"hono": "^4.10.4",
|
||||
"openai": "^6.9.0",
|
||||
"robot3": "./packages/robot3"
|
||||
"robot3": "^1.2.0"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
# robot3
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- bcc2995: Improve the type definition for state and invoke functions
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0cf6366: '/logging' is now exported, so you can import it in your dev environment to log state changes.
|
||||
|
||||
```ts
|
||||
import 'robot3/logging';
|
||||
|
||||
import {...} from 'robot3';
|
||||
```
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 950b6fa: Fix syntax error in state function type definition that caused TypeScript compilation failures. The previous change had a missing space in a conditional type expression, breaking type inference for state transitions.
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1d6179a: Fixes types for the state() function.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4f6fb69: Autocomplete for service.send()
|
||||
|
||||
This makes it so that the event name in `service.send(event)` is inferred from the transitions used to create the machine.
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9fbdbcb: Set the most deeply nested current service to current
|
||||
- 0409089: Documentation for advanced use of 'invoke()'
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- cc17481: Add debug to package exports
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- 52742ab: Call onChange callbacks for immediate states too
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fc4806e: Adding an export property to the core package.json for 'import' so that destructured imports work, in addition to the default imports handled by the 'default' property
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- cce2ae6: Drop support for Node 14
|
||||
|
||||
This drops support for Node 14, with it no longer being supported by the LTS in February. Robot might still work in Node 14 but is not tested in our CI.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "./machine.min.js",
|
||||
"maxSize": "1.4 kB"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { d, invoke } from './machine.js';
|
||||
|
||||
const invokePromiseType = Object.getPrototypeOf(invoke(Promise.resolve()));
|
||||
|
||||
function unknownState(from, state) {
|
||||
throw new Error(`Cannot transition from ${from} to unknown state: ${state}`);
|
||||
}
|
||||
|
||||
d._create = function(current, states) {
|
||||
if(!(current in states)) {
|
||||
throw new Error(`Initial state [${current}] is not a known state.`);
|
||||
}
|
||||
for(let p in states) {
|
||||
let state = states[p];
|
||||
for(let [, candidates] of state.transitions) {
|
||||
for(let {to} of candidates) {
|
||||
if(!(to in states)) {
|
||||
unknownState(p, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (invokePromiseType.isPrototypeOf(state)) {
|
||||
let hasErrorFrom = false;
|
||||
for(let [, candidates] of state.transitions) {
|
||||
for(let {from} of candidates) {
|
||||
if (from === 'error') hasErrorFrom = true;
|
||||
}
|
||||
}
|
||||
if(!hasErrorFrom) {
|
||||
console.warn(
|
||||
`When using invoke [current state: ${p}] with Promise-returning function, you need to add 'error' state. Otherwise, robot will hide errors in Promise-returning function`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
d._send = function(eventName, currentStateName) {
|
||||
throw new Error(`No transitions for event ${eventName} from the current state [${currentStateName}]`);
|
||||
};
|
||||
253
packages/robot3/index.d.ts
vendored
253
packages/robot3/index.d.ts
vendored
|
|
@ -1,253 +0,0 @@
|
|||
declare module 'robot3' {
|
||||
|
||||
/**
|
||||
* TS Helpers
|
||||
*/
|
||||
type NestedKeys<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]-?: P extends string ? keyof T[P] : never
|
||||
}[keyof T]
|
||||
: never
|
||||
|
||||
type AllStateKeys<T> = NestedKeys<T> | keyof T;
|
||||
|
||||
type MachineStates<S = {}, F extends string = string> = {
|
||||
[K in keyof S]: {
|
||||
final: boolean
|
||||
transitions: Map<string, Transition<F>[]>
|
||||
immediates?: Map<string, Immediate<F>[]>
|
||||
enter?: any
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The debugging object contains an _onEnter method, wich can be set to invoke
|
||||
* this function on every transition.
|
||||
*/
|
||||
export const d: {
|
||||
_onEnter?: OnEnterFunction<Machine<any>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The `createMachine` function creates a state machine. It takes an object of *states* with the key being the state name.
|
||||
* The value is usually *state* but might also be *invoke*.
|
||||
*
|
||||
* @param initial - Creates a machine that has *initial* as it's initial state.
|
||||
* @param states - An object of states, where each key is a state name, and the values are one of *state* or *invoke*.
|
||||
* @param context - A function that returns an object of extended state values. The function can receive an `event` argument.
|
||||
*/
|
||||
export function createMachine<S extends MachineStates<S, F>, C = {}, F extends string = string>(
|
||||
initial: keyof S,
|
||||
states: S,
|
||||
context?: ContextFunction<C>
|
||||
): Machine<S, C, AllStateKeys<S>>
|
||||
/**
|
||||
* The `createMachine` function creates a state machine. It takes an object of *states* with the key being the state name.
|
||||
* The value is usually *state* but might also be *invoke*.
|
||||
*
|
||||
* @param states - An object of states, where each key is a state name, and the values are one of *state* or *invoke*.
|
||||
* @param context - A function that returns an object of extended state values. The function can receive an `event` argument.
|
||||
*/
|
||||
export function createMachine<S extends MachineStates<S, F>, C = {}, F extends string = string>(
|
||||
states: S,
|
||||
context?: ContextFunction<C>
|
||||
): Machine<S, C, AllStateKeys<S>>;
|
||||
|
||||
/**
|
||||
* The `state` function returns a state object. A state can take transitions and immediates as arguments.
|
||||
*
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function state<T extends ReadonlyArray<Transition<any> | Immediate<any>>>(
|
||||
...args: T
|
||||
): MachineState<ExtractTransitionOrImmediateTypes<T>[number]>;
|
||||
|
||||
/**
|
||||
* A `transition` function is used to move from one state to another.
|
||||
*
|
||||
* @param event - This will give the name of the event that triggers this transition.
|
||||
* @param state - The name of the destination state.
|
||||
* @param args - Any extra argument will be evaluated to check if they are one of Reducer, Guard or Action.
|
||||
*/
|
||||
export function transition<F extends string, C, E>(
|
||||
event: F,
|
||||
state: string,
|
||||
...args: (Reducer<C, E> | Guard<C, E> | Action<C, E>)[]
|
||||
): Transition<F>;
|
||||
|
||||
/**
|
||||
* An `immediate` function is a type of transition that occurs immediately; it doesn't wait for an event to proceed.
|
||||
* This is a state that immediately proceeds to the next.
|
||||
*
|
||||
* @param state - The name of the destination state.
|
||||
* @param args - Any extra argument will be evaluated to check if they are a Reducer or a Guard.
|
||||
*/
|
||||
export function immediate<F extends string, C, E>(
|
||||
state: string,
|
||||
...args: (Reducer<C, E> | Guard<C, E> | Action<C, E>)[]
|
||||
): Transition<F>
|
||||
|
||||
/**
|
||||
* A `guard` is a method that determines if a transition can proceed.
|
||||
* Returning true allows the transition to occur, returning false prevents it from doing so and leaves the state in its current place.
|
||||
*
|
||||
* @param guardFunction A Function that can receive *context* and *event* and will return true or false.
|
||||
*/
|
||||
export function guard<C, E>(guardFunction?: GuardFunction<C, E>): Guard<C, E>
|
||||
|
||||
/**
|
||||
* A `reduce` takes a reducer function for changing the context of the machine. A common use case is to set values coming from form fields.
|
||||
*
|
||||
* @param reduceFunction A Function that can receive *context* and *event* and will return the context.
|
||||
*/
|
||||
export function reduce<C, E>(reduceFunction?: ReduceFunction<C, E>): Reducer<C, E>
|
||||
|
||||
/**
|
||||
* An `action` function takes a function that will be run during a transition. The primary purpose of using action is to perform side-effects.
|
||||
*
|
||||
* @param actionFunction A Function that can receive *context* and *event*. Returned values are discarded.
|
||||
*/
|
||||
export function action<C, E>(actionFunction?: ActionFunction<C, E>): Action<C, E>
|
||||
|
||||
/**
|
||||
* The `interpret` function takes a machine and creates a service that can send events into the machine, changing its states.
|
||||
* A service does not mutate a machine, but rather creates derived machines with the current state set.
|
||||
*
|
||||
* @param machine The state `machine`, created with *createMachine* to create a new service for.
|
||||
* @param onChange A callback that is called when the machine completes a transition. Even if the transition results in returning to the same state, the `onChange` callback is still called.
|
||||
* @param event The `event` can be any object. It is passed to the context function
|
||||
*/
|
||||
export function interpret<M extends Machine, E>(
|
||||
machine: M,
|
||||
onChange?: InterpretOnChangeFunction<typeof machine>,
|
||||
initialContext?: M['context'],
|
||||
event?: { [K in keyof E]: any }
|
||||
): Service<typeof machine>
|
||||
|
||||
/**
|
||||
* The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine.
|
||||
*
|
||||
* @param fn - Promise-returning function
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function invoke<C, T, E extends {} = any>(fn: (ctx: C, e?: E) => Promise<T>, ...args: (Transition<any> | Immediate<any>)[]): MachineState<any>
|
||||
|
||||
/**
|
||||
* The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine.
|
||||
*
|
||||
* @param fn - Machine-returning function
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function invoke<C, E extends {} = any, M extends Machine = any>(fn: (ctx: C, e?: E) => M, ...args: (Transition<any> | Immediate<any>)[]): MachineState<any>
|
||||
|
||||
/**
|
||||
* The `invoke` is a special type of state that immediately invokes a Promise-returning or Machine-returning function, or another machine.
|
||||
*
|
||||
* @param machine - Machine
|
||||
* @param args - Any argument needs to be of type Transition or Immediate.
|
||||
*/
|
||||
export function invoke<M extends Machine>(machine: M, ...args: (Transition<any> | Immediate<any>)[]):
|
||||
// fixme: this type isn't 100% correct, as it adds the child's transitions to the parent.
|
||||
MachineState<GetMachineTransitions<M>>
|
||||
|
||||
/* General Types */
|
||||
|
||||
export type ContextFunction<T> = (initialContext: T) => T
|
||||
|
||||
export type GuardFunction<C, E> = (context: C, event: E) => boolean
|
||||
|
||||
export type ActionFunction<C, E> = (context: C, event: E) => unknown
|
||||
|
||||
export type ReduceFunction<C, E> = (context: C, event: E) => C
|
||||
|
||||
export type InterpretOnChangeFunction<T extends Machine> = (
|
||||
service: Service<T>
|
||||
) => void
|
||||
|
||||
export type SendEvent<T extends string = string> = T | { type: T; [key: string]: any }
|
||||
export type SendFunction<T extends string> = (event: SendEvent<T> & {}) => void
|
||||
|
||||
/**
|
||||
* This function is invoked before entering a new state and is bound to the debug
|
||||
* object. It is usable to inspect or log changes.
|
||||
*
|
||||
* @param machine - Machine
|
||||
* @param to - name of the target state
|
||||
* @param state - current state
|
||||
* @param prevState - previous state
|
||||
* @param event - event provoking the state change
|
||||
*/
|
||||
export type OnEnterFunction<M extends Machine<any>> =
|
||||
<C = M['state']>(machine: M, to: string, state: C, prevState: C, event?: SendEvent) => void
|
||||
|
||||
export type Machine<S extends MachineStates<S, F> = {}, C = {}, K = string, F extends string = string> = {
|
||||
context: C
|
||||
current: K
|
||||
states: S
|
||||
state: {
|
||||
name: K
|
||||
value: MachineState<F>
|
||||
}
|
||||
}
|
||||
|
||||
export type Action<C, E> = {
|
||||
fn: ActionFunction<C, E>
|
||||
}
|
||||
|
||||
export type Reducer<C, E> = {
|
||||
fn: ReduceFunction<C, E>
|
||||
}
|
||||
|
||||
export type Guard<C, E> = {
|
||||
fn: GuardFunction<C, E>
|
||||
}
|
||||
|
||||
export interface MachineState<F extends string> {
|
||||
final: boolean
|
||||
transitions: Map<F, Transition<F>[]>
|
||||
immediates?: Map<F, Immediate<F>[]>
|
||||
enter?: any
|
||||
}
|
||||
|
||||
export interface Transition<F extends string> {
|
||||
from: F | null
|
||||
to: string
|
||||
guards: any[]
|
||||
reducers: any[]
|
||||
}
|
||||
|
||||
export interface Service<M extends Machine> {
|
||||
child?: Service<M>
|
||||
machine: M
|
||||
context: M['context']
|
||||
onChange: InterpretOnChangeFunction<M>
|
||||
send: SendFunction<GetMachineTransitions<M>>
|
||||
}
|
||||
|
||||
export type Immediate<F extends string> = Transition<F>;
|
||||
|
||||
// Utilities
|
||||
type IsAny<T> = 0 extends (1 & T) ? true : false;
|
||||
|
||||
// Get state objects from a Machine
|
||||
type GetMachineStateObject<M extends Machine> = M['states'];
|
||||
|
||||
// Create mapped type without the final indexing
|
||||
type GetTransitionsFromStates<S> = {
|
||||
[K in keyof S]: S[K] extends { transitions: Map<string, Array<Transition<infer F>>> }
|
||||
? IsAny<F> extends true
|
||||
? never
|
||||
: F
|
||||
: never
|
||||
}
|
||||
|
||||
type ExtractNonAnyValues<T> = {
|
||||
[K in keyof T]: IsAny<T[K]> extends true ? never : T[K]
|
||||
}[keyof T] & {};
|
||||
|
||||
export type GetMachineTransitions<M extends Machine> =
|
||||
ExtractNonAnyValues<GetTransitionsFromStates<GetMachineStateObject<M>>>;
|
||||
|
||||
type ExtractTransitionOrImmediateTypes<T extends ReadonlyArray<Transition<any> | Immediate<any>>> =
|
||||
{ [K in keyof T]: T[K] extends Transition<infer V> ? V : T[K] extends Immediate<infer V> ? V : never };
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { d } from './machine.js';
|
||||
|
||||
d._onEnter = function(machine, to, state, prevState, event) {
|
||||
console.log(`Enter state ${to}`);
|
||||
console.groupCollapsed(`Details:`);
|
||||
console.log(`Machine`, machine);
|
||||
console.log(`Current state`, state);
|
||||
console.log(`Previous state`, prevState);
|
||||
|
||||
if (typeof event === "string") {
|
||||
console.log(`Event ${event}`);
|
||||
} else if (typeof event === "object") {
|
||||
console.log(`Event`, event);
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
function valueEnumerable(value) {
|
||||
return { enumerable: true, value };
|
||||
}
|
||||
|
||||
function valueEnumerableWritable(value) {
|
||||
return { enumerable: true, writable: true, value };
|
||||
}
|
||||
|
||||
export let d = {};
|
||||
let truthy = () => true;
|
||||
let empty = () => ({});
|
||||
let identity = a => a;
|
||||
let callBoth = (par, fn, self, args) => par.apply(self, args) && fn.apply(self, args);
|
||||
let callForward = (par, fn, self, [a, b]) => fn.call(self, par.call(self, a, b), b);
|
||||
let create = (a, b) => Object.freeze(Object.create(a, b));
|
||||
|
||||
function stack(fns, def, caller) {
|
||||
return fns.reduce((par, fn) => {
|
||||
return function(...args) {
|
||||
return caller(par, fn, this, args);
|
||||
};
|
||||
}, def);
|
||||
}
|
||||
|
||||
function fnType(fn) {
|
||||
return create(this, { fn: valueEnumerable(fn) });
|
||||
}
|
||||
|
||||
let reduceType = {};
|
||||
export let reduce = fnType.bind(reduceType);
|
||||
export let action = fn => reduce((ctx, ev) => !!~fn(ctx, ev) && ctx);
|
||||
|
||||
let guardType = {};
|
||||
export let guard = fnType.bind(guardType);
|
||||
|
||||
function filter(Type, arr) {
|
||||
return arr.filter(value => Type.isPrototypeOf(value));
|
||||
}
|
||||
|
||||
function makeTransition(from, to, ...args) {
|
||||
let guards = stack(filter(guardType, args).map(t => t.fn), truthy, callBoth);
|
||||
let reducers = stack(filter(reduceType, args).map(t => t.fn), identity, callForward);
|
||||
return create(this, {
|
||||
from: valueEnumerable(from),
|
||||
to: valueEnumerable(to),
|
||||
guards: valueEnumerable(guards),
|
||||
reducers: valueEnumerable(reducers)
|
||||
});
|
||||
}
|
||||
|
||||
let transitionType = {};
|
||||
let immediateType = {};
|
||||
export let transition = makeTransition.bind(transitionType);
|
||||
export let immediate = makeTransition.bind(immediateType, null);
|
||||
|
||||
function enterImmediate(machine, service, event) {
|
||||
return transitionTo(service, machine, event, this.immediates) || machine;
|
||||
}
|
||||
|
||||
function transitionsToMap(transitions) {
|
||||
let m = new Map();
|
||||
for(let t of transitions) {
|
||||
if(!m.has(t.from)) m.set(t.from, []);
|
||||
m.get(t.from).push(t);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
let stateType = { enter: identity };
|
||||
export function state(...args) {
|
||||
let transitions = filter(transitionType, args);
|
||||
let immediates = filter(immediateType, args);
|
||||
let desc = {
|
||||
final: valueEnumerable(args.length === 0),
|
||||
transitions: valueEnumerable(transitionsToMap(transitions))
|
||||
};
|
||||
if(immediates.length) {
|
||||
desc.immediates = valueEnumerable(immediates);
|
||||
desc.enter = valueEnumerable(enterImmediate);
|
||||
}
|
||||
return create(stateType, desc);
|
||||
}
|
||||
|
||||
let invokeFnType = {
|
||||
enter(machine2, service, event) {
|
||||
let rn = this.fn.call(service, service.context, event)
|
||||
if(machine.isPrototypeOf(rn))
|
||||
return create(invokeMachineType, {
|
||||
machine: valueEnumerable(rn),
|
||||
transitions: valueEnumerable(this.transitions)
|
||||
}).enter(machine2, service, event)
|
||||
rn
|
||||
.then(data => {
|
||||
if (machine2 === service.machine)
|
||||
return service.send({ type: 'done', data });
|
||||
})
|
||||
.catch(error => {
|
||||
if (machine2 === service.machine)
|
||||
return service.send({ type: 'error', error });
|
||||
});
|
||||
return machine2;
|
||||
}
|
||||
};
|
||||
let invokeMachineType = {
|
||||
enter(machine, service, event) {
|
||||
service.child = interpret(this.machine, s => {
|
||||
service.onChange(s);
|
||||
if(service.child == s && s.machine.state.value.final) {
|
||||
delete service.child;
|
||||
service.send({ type: 'done', data: s.context });
|
||||
}
|
||||
}, service.context, event);
|
||||
if(service.child.machine.state.value.final) {
|
||||
let data = service.child.context;
|
||||
delete service.child;
|
||||
return transitionTo(service, machine, { type: 'done', data }, this.transitions.get('done'));
|
||||
}
|
||||
return machine;
|
||||
}
|
||||
};
|
||||
export function invoke(fn, ...transitions) {
|
||||
let t = valueEnumerable(transitionsToMap(transitions));
|
||||
return machine.isPrototypeOf(fn) ?
|
||||
create(invokeMachineType, {
|
||||
machine: valueEnumerable(fn),
|
||||
transitions: t
|
||||
}) :
|
||||
create(invokeFnType, {
|
||||
fn: valueEnumerable(fn),
|
||||
transitions: t
|
||||
});
|
||||
}
|
||||
|
||||
let machine = {
|
||||
get state() {
|
||||
return {
|
||||
name: this.current,
|
||||
value: this.states[this.current]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function createMachine(current, states, contextFn = empty) {
|
||||
if(typeof current !== 'string') {
|
||||
contextFn = states || empty;
|
||||
states = current;
|
||||
current = Object.keys(states)[0];
|
||||
}
|
||||
if(d._create) d._create(current, states);
|
||||
return create(machine, {
|
||||
context: valueEnumerable(contextFn),
|
||||
current: valueEnumerable(current),
|
||||
states: valueEnumerable(states)
|
||||
});
|
||||
}
|
||||
|
||||
function transitionTo(service, machine, fromEvent, candidates) {
|
||||
let { context } = service;
|
||||
for(let { to, guards, reducers } of candidates) {
|
||||
if(guards(context, fromEvent)) {
|
||||
service.context = reducers.call(service, context, fromEvent);
|
||||
|
||||
let original = machine.original || machine;
|
||||
let newMachine = create(original, {
|
||||
current: valueEnumerable(to),
|
||||
original: { value: original }
|
||||
});
|
||||
|
||||
if (d._onEnter) d._onEnter(machine, to, service.context, context, fromEvent);
|
||||
let state = newMachine.state.value;
|
||||
service.machine = newMachine;
|
||||
let ret = state.enter(newMachine, service, fromEvent);
|
||||
service.onChange(service);
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function send(service, event) {
|
||||
let eventName = event.type || event;
|
||||
let { machine } = service;
|
||||
let { value: state, name: currentStateName } = machine.state;
|
||||
|
||||
if(state.transitions.has(eventName)) {
|
||||
return transitionTo(service, machine, event, state.transitions.get(eventName)) || machine;
|
||||
} else {
|
||||
if(d._send) d._send(eventName, currentStateName);
|
||||
}
|
||||
return machine;
|
||||
}
|
||||
|
||||
let service = {
|
||||
send(event) {
|
||||
send(this, event);
|
||||
}
|
||||
};
|
||||
|
||||
export function interpret(machine, onChange, initialContext, event) {
|
||||
let s = Object.create(service, {
|
||||
machine: valueEnumerableWritable(machine),
|
||||
context: valueEnumerableWritable(machine.context(initialContext, event)),
|
||||
onChange: valueEnumerable(onChange)
|
||||
});
|
||||
s.send = s.send.bind(s);
|
||||
s.machine = s.machine.state.value.enter(s.machine, s, event);
|
||||
return s;
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
{
|
||||
"name": "robot3",
|
||||
"version": "1.3.0",
|
||||
"description": "A functional, immutable Finite State Machine library",
|
||||
"main": "dist/machine.js",
|
||||
"types": "./index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/machine.js",
|
||||
"import": "./machine.js",
|
||||
"default": "./machine.js",
|
||||
"types": "./index.d.ts"
|
||||
},
|
||||
"./debug": {
|
||||
"require": "./dist/debug.js",
|
||||
"import": "./debug.js",
|
||||
"default": "./debug.js"
|
||||
},
|
||||
"./logging": {
|
||||
"require": "./dist/logging.js",
|
||||
"import": "./logging.js",
|
||||
"default": "./logging.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"debug.js",
|
||||
"logging.js",
|
||||
"machine.js",
|
||||
"index.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"minify": "wireit",
|
||||
"bundlesize": "wireit",
|
||||
"server": "wireit",
|
||||
"test": "wireit",
|
||||
"test:types": "wireit",
|
||||
"test:browser": "wireit",
|
||||
"build:cjs": "wireit",
|
||||
"build": "wireit"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/matthewp/robot.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Finite State Machine"
|
||||
],
|
||||
"author": "Matthew Phillips <matthew@matthewphillips.info>",
|
||||
"license": "BSD-2-Clause",
|
||||
"bugs": {
|
||||
"url": "https://github.com/matthewp/robot/issues"
|
||||
},
|
||||
"homepage": "https://github.com/matthewp/robot#readme",
|
||||
"devDependencies": {
|
||||
"rollup": "^1.21.4",
|
||||
"terser": "^5.16.1"
|
||||
},
|
||||
"wireit": {
|
||||
"minify": {
|
||||
"command": "terser machine.js -m --module -o machine.min.js",
|
||||
"files": [
|
||||
"machine.js"
|
||||
],
|
||||
"output": [
|
||||
"machine.min.js"
|
||||
]
|
||||
},
|
||||
"bundlesize": {
|
||||
"command": "bundlesize --config bundlesize.json",
|
||||
"dependencies": [
|
||||
"minify"
|
||||
]
|
||||
},
|
||||
"server": {
|
||||
"command": "ws -p 1965",
|
||||
"service": {
|
||||
"readyWhen": {
|
||||
"lineMatches": "Listening on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"dependencies": [
|
||||
"test:types",
|
||||
"test:browser"
|
||||
]
|
||||
},
|
||||
"test:types": {
|
||||
"command": "tsc -p test/types/tsconfig.json",
|
||||
"files": []
|
||||
},
|
||||
"test:browser": {
|
||||
"command": "node-qunit-puppeteer http://localhost:1965/test/test.html 10000",
|
||||
"dependencies": [
|
||||
"server"
|
||||
],
|
||||
"files": [
|
||||
"machine.js"
|
||||
],
|
||||
"output": []
|
||||
},
|
||||
"build:cjs": {
|
||||
"command": "rollup -d dist -f cjs machine.js debug.js logging.js",
|
||||
"files": [
|
||||
"machine.js",
|
||||
"debug.js",
|
||||
"logging.js"
|
||||
],
|
||||
"output": [
|
||||
"dist"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"dependencies": [
|
||||
"build:cjs",
|
||||
"minify"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { createMachine, action, interpret, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Action', () => {
|
||||
QUnit.test('Can be used to do side-effects', assert => {
|
||||
let count = 0;
|
||||
let orig = {};
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
action(() => count++)
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
}, () => orig);
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
|
||||
assert.equal(service.context, orig, 'context stays the same');
|
||||
assert.equal(count, 1, 'side-effect performed');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { createMachine, interpret, state, transition, reduce, d} from '../machine.js';
|
||||
|
||||
QUnit.module('robot/debug');
|
||||
|
||||
QUnit.test('Errors for transitions to states that don\'t exist', assert => {
|
||||
try {
|
||||
createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
)
|
||||
});
|
||||
} catch(e) {
|
||||
assert.ok(/unknown state/.test(e.message), 'Gets an error about unknown states');
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('Does not error for transitions to states when state does exist', assert => {
|
||||
try {
|
||||
createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
assert.ok(true, 'Created a valid machine!');
|
||||
} catch(e) {
|
||||
assert.ok(false, 'Should not have errored');
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('Errors if an invalid initial state is provided', assert => {
|
||||
try {
|
||||
createMachine('oops', {
|
||||
one: state()
|
||||
});
|
||||
assert.ok(false, 'should have failed');
|
||||
} catch(e) {
|
||||
assert.ok(true, 'it is errored');
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('Errors when no transitions for event from the current state', assert => {
|
||||
try {
|
||||
const machine = createMachine('one', {
|
||||
one: state(),
|
||||
});
|
||||
const { send } = interpret(machine, () => {});
|
||||
send('go');
|
||||
assert.ok(false, 'should have failed');
|
||||
} catch(e) {
|
||||
assert.ok(true, 'it is errored');
|
||||
}
|
||||
});
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { createMachine, interpret, guard, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Guards', hooks => {
|
||||
QUnit.test('Can prevent changing states', assert => {
|
||||
let canProceed = false;
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two', guard(() => canProceed))
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, service => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'one');
|
||||
canProceed = true;
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'two');
|
||||
});
|
||||
|
||||
QUnit.test('If there are multiple guards, any returning false prevents a transition', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
guard(() => false),
|
||||
guard(() => true)
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, service => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'one');
|
||||
});
|
||||
|
||||
QUnit.test('Guards are passed the event', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
guard((ctx, ev) => ev.canProceed)
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send({ type: 'ping' });
|
||||
assert.equal(service.machine.current, 'one', 'still in the initial state');
|
||||
service.send({ type: 'ping', canProceed: true });
|
||||
assert.equal(service.machine.current, 'two', 'now moved');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { createMachine, guard, interpret, immediate, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Immediate', hooks => {
|
||||
QUnit.test('Will immediately transition', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two')
|
||||
),
|
||||
two: state(
|
||||
immediate('three')
|
||||
),
|
||||
three: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'three');
|
||||
});
|
||||
|
||||
QUnit.test('Will not reject state when a guard fails', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two')
|
||||
),
|
||||
two: state(
|
||||
immediate('three', guard(() => false)),
|
||||
transition('next', 'three')
|
||||
),
|
||||
three: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'two');
|
||||
service.send('next');
|
||||
assert.equal(service.machine.current, 'three');
|
||||
});
|
||||
|
||||
QUnit.test('Can immediately transitions past 2 states', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
immediate('two')
|
||||
),
|
||||
two: state(
|
||||
immediate('three')
|
||||
),
|
||||
three: state()
|
||||
});
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
assert.equal(service.machine.current, 'three', 'transitioned to 3');
|
||||
assert.ok(service.machine.state.value.final, 'in the final state');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,387 +0,0 @@
|
|||
import { createMachine, immediate, interpret, invoke, reduce, state, state as final, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Invoke', hooks => {
|
||||
QUnit.module('Promise');
|
||||
|
||||
QUnit.test('Goes to the "done" event when complete', async assert => {
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(() => Promise.resolve(13),
|
||||
transition('done', 'three',
|
||||
reduce((ctx, ev) => ({ ...ctx, age: ev.data }))
|
||||
)
|
||||
),
|
||||
three: state()
|
||||
}, () => ({age: 0}));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('click');
|
||||
await Promise.resolve();
|
||||
assert.equal(service.context.age, 13, 'Invoked');
|
||||
assert.equal(service.machine.current, 'three', 'now in the next state');
|
||||
});
|
||||
|
||||
QUnit.test('Goes to the "error" event when there is an error', async assert => {
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(() => Promise.reject(new Error('oh no')),
|
||||
transition('error', 'three',
|
||||
reduce((ctx, ev) => ({ ...ctx, error: ev.error }))
|
||||
)
|
||||
),
|
||||
three: state()
|
||||
}, () => ({age: 0}));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('click');
|
||||
await Promise.resolve(); await Promise.resolve();
|
||||
assert.equal(service.context.error.message, 'oh no', 'Got the right error');
|
||||
});
|
||||
|
||||
QUnit.test('The initial state can be an invoke', async assert => {
|
||||
let machine = createMachine({
|
||||
one: invoke(() => Promise.resolve(2),
|
||||
transition('done', 'two', reduce((ctx, ev) => ({...ctx, age: ev.data})))
|
||||
),
|
||||
two: state()
|
||||
}, () => ({ age: 0 }));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
await Promise.resolve();
|
||||
assert.equal(service.context.age, 2, 'Invoked immediately');
|
||||
assert.equal(service.machine.current, 'two', 'in the new state');
|
||||
});
|
||||
|
||||
QUnit.test('Should not fire "done" event when state changes', async assert => {
|
||||
const wait = ms => () => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(wait(10),
|
||||
transition('done', 'one'),
|
||||
transition('click', 'three')
|
||||
),
|
||||
three: state(
|
||||
transition('done', 'error'),
|
||||
),
|
||||
error: state(),
|
||||
});
|
||||
|
||||
let service = interpret(machine, () => { });
|
||||
service.send('click');
|
||||
service.send('click');
|
||||
await wait(15)()
|
||||
assert.equal(service.machine.current, 'three', 'now in the next state');
|
||||
});
|
||||
|
||||
QUnit.test('Should fire "done" when context changes', async assert => {
|
||||
const wait = ms => () => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
let machine = createMachine({
|
||||
one: state(transition('click', 'two')),
|
||||
two: invoke(wait(10),
|
||||
transition('done', 'three'),
|
||||
transition('click', 'two', reduce((ctx) => ({ value: ctx.value + 1 })))
|
||||
),
|
||||
three: state(),
|
||||
error: state(),
|
||||
}, () => ({ value: 0 }));
|
||||
|
||||
let service = interpret(machine, () => { });
|
||||
service.send('click');
|
||||
service.send('click');
|
||||
service.send('click');
|
||||
await wait(15)()
|
||||
assert.equal(service.context.value, 2, 'value should be 2');
|
||||
assert.equal(service.machine.current, 'three', 'now in the correct state');
|
||||
});
|
||||
|
||||
QUnit.module('Machine');
|
||||
|
||||
QUnit.test('Can invoke a child machine', async assert => {
|
||||
assert.expect(4);
|
||||
let one = createMachine({
|
||||
nestedOne: state(
|
||||
transition('go', 'nestedTwo')
|
||||
),
|
||||
nestedTwo: final()
|
||||
});
|
||||
let two = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: invoke(one,
|
||||
transition('done', 'three')
|
||||
),
|
||||
three: final()
|
||||
});
|
||||
let c = 0;
|
||||
let service = interpret(two, thisService => {
|
||||
switch(c) {
|
||||
case 0:
|
||||
assert.equal(service.machine.current, 'two');
|
||||
break;
|
||||
case 1:
|
||||
assert.notEqual(thisService, service, 'second time a different service');
|
||||
break;
|
||||
case 2:
|
||||
assert.equal(service.machine.current, 'three', 'now in three state');
|
||||
break;
|
||||
}
|
||||
c++;
|
||||
});
|
||||
service.send('go');
|
||||
service.child.send('go');
|
||||
assert.equal(c, 3, 'there were 3 transitions');
|
||||
});
|
||||
|
||||
QUnit.test('Can invoke a dynamic child machine', async assert => {
|
||||
assert.expect(10);
|
||||
let dynamicMachines = [
|
||||
createMachine({
|
||||
nestedOne: state(
|
||||
transition('go', 'nestedTwo')
|
||||
),
|
||||
nestedTwo: final()
|
||||
}),
|
||||
createMachine({
|
||||
nestedThree: state(
|
||||
transition('go', 'nestedFour')
|
||||
),
|
||||
nestedFour: final()
|
||||
})
|
||||
]
|
||||
|
||||
let root = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: invoke(() => dynamicMachines[0],
|
||||
transition('done', 'three')
|
||||
),
|
||||
three: state(
|
||||
transition('go', 'four')
|
||||
),
|
||||
four: invoke(() => dynamicMachines[1],
|
||||
transition('done', 'five')
|
||||
),
|
||||
five: final()
|
||||
});
|
||||
let c = 0;
|
||||
let service = interpret(root, thisService => {
|
||||
switch(c) {
|
||||
case 0:
|
||||
assert.equal(service.machine.current, 'two');
|
||||
break;
|
||||
case 1:
|
||||
assert.notEqual(thisService, service, 'second time a different service');
|
||||
assert.equal(thisService.machine.current, 'nestedTwo');
|
||||
break;
|
||||
case 2:
|
||||
assert.equal(thisService, service, 'equal service');
|
||||
assert.equal(service.machine.current, 'three', 'now in three state');
|
||||
break;
|
||||
case 3:
|
||||
assert.equal(service.machine.current, 'four');
|
||||
break;
|
||||
case 4:
|
||||
assert.notEqual(thisService, service, 'third time a different service');
|
||||
assert.equal(thisService.machine.current, 'nestedFour');
|
||||
break;
|
||||
case 5:
|
||||
assert.equal(service.machine.current, 'five', 'now in five state');
|
||||
break;
|
||||
}
|
||||
c++;
|
||||
});
|
||||
service.send('go');
|
||||
service.child.send('go');
|
||||
service.send('go');
|
||||
service.child.send('go');
|
||||
assert.equal(c, 6, 'there were 6 transitions');
|
||||
});
|
||||
|
||||
QUnit.test('Child machines receive events from their parents', async assert => {
|
||||
const action = fn =>
|
||||
reduce((ctx, ev) => {
|
||||
fn(ctx, ev);
|
||||
return ctx;
|
||||
});
|
||||
|
||||
const wait = ms => () => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const child = createMachine({
|
||||
init: state(
|
||||
immediate('waiting',
|
||||
action(ctx => {
|
||||
ctx.stuff.push(1);
|
||||
})
|
||||
)
|
||||
),
|
||||
waiting: invoke(
|
||||
wait(50),
|
||||
transition('done', 'fin',
|
||||
action(ctx => {
|
||||
ctx.stuff.push(2);
|
||||
})
|
||||
)
|
||||
),
|
||||
fin: state()
|
||||
}, ctx => ctx);
|
||||
|
||||
const machine = createMachine(
|
||||
{
|
||||
idle: state(transition("next", "child")),
|
||||
child: invoke(child, transition("done", "end")),
|
||||
end: state()
|
||||
},
|
||||
() => ({ stuff: [] })
|
||||
);
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('next');
|
||||
|
||||
await wait(50)();
|
||||
|
||||
assert.deepEqual(service.context.stuff, [1, 2]);
|
||||
});
|
||||
|
||||
QUnit.test('Service does not have a child when not in an invoked state', assert => {
|
||||
const child = createMachine({
|
||||
nestedOne: state(
|
||||
transition('next', 'nestedTwo')
|
||||
),
|
||||
nestedTwo: state()
|
||||
});
|
||||
const parent = createMachine({
|
||||
one: invoke(child,
|
||||
transition('done', 'two')
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
|
||||
let service = interpret(parent, () => {});
|
||||
assert.ok(service.child, 'there is a child service');
|
||||
|
||||
service.child.send('next');
|
||||
assert.notOk(service.child, 'No longer a child');
|
||||
});
|
||||
|
||||
QUnit.test('Multi level nested machines resolve in correct order', async assert => {
|
||||
assert.expect(18);
|
||||
|
||||
const four = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: state(
|
||||
transition('DONE', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
const three = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: invoke(four,
|
||||
transition('done', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
const two = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: invoke(three,
|
||||
transition('done', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
const one = createMachine({
|
||||
init: state(
|
||||
transition('START', 'start'),
|
||||
),
|
||||
start: invoke(two,
|
||||
transition('done', 'done'),
|
||||
),
|
||||
done: state(),
|
||||
})
|
||||
|
||||
let c = 0;
|
||||
let service = interpret(one, thisService => {
|
||||
switch (c) {
|
||||
case 0:
|
||||
assert.equal(service.machine.current, 'start', 'initial state');
|
||||
break;
|
||||
case 1:
|
||||
assert.notEqual(thisService.machine.states, service.machine.states, 'second time a different service');
|
||||
assert.ok(service.child, 'has child');
|
||||
assert.equal(service.child.machine.current, 'start');
|
||||
break;
|
||||
case 2:
|
||||
assert.ok(service.child.child, 'has grand child');
|
||||
assert.equal(service.child.machine.current, 'start');
|
||||
assert.equal(service.child.child.machine.current, 'start');
|
||||
break;
|
||||
case 3:
|
||||
assert.ok(service.child.child.child, 'has grand grand child');
|
||||
assert.equal(service.child.child.machine.current, 'start');
|
||||
assert.equal(service.child.child.child.machine.current, 'start');
|
||||
break;
|
||||
case 4:
|
||||
assert.equal(service.child.child.child.machine.current, 'done');
|
||||
break;
|
||||
case 5:
|
||||
assert.equal(service.child.child.machine.current, 'done');
|
||||
assert.equal(service.child.child.child, undefined, 'child is removed when resolved');
|
||||
break;
|
||||
case 6:
|
||||
assert.equal(service.child.machine.current, 'done');
|
||||
assert.equal(service.child.child, undefined, 'child is removed when resolved');
|
||||
break;
|
||||
case 7:
|
||||
assert.equal(service.machine.current, 'done');
|
||||
assert.equal(service.child, undefined, 'child is removed when resolved');
|
||||
break;
|
||||
}
|
||||
c++;
|
||||
});
|
||||
service.send('START') // machine one
|
||||
service.child.send('START') // machine two
|
||||
service.child.child.send('START') // machine tree
|
||||
service.child.child.child.send('START') // machine four
|
||||
service.child.child.child.send('DONE') // machine four
|
||||
assert.equal(c, 8, 'there were 6 transitions');
|
||||
});
|
||||
|
||||
QUnit.test('Invoking a machine that immediately finishes', async assert => {
|
||||
assert.expect(3);
|
||||
const expectations = [ 'nestedTwo', 'three', 'three' ];
|
||||
|
||||
const child = createMachine({
|
||||
nestedOne: state(
|
||||
immediate('nestedTwo')
|
||||
),
|
||||
nestedTwo: final()
|
||||
});
|
||||
|
||||
const parent = createMachine({
|
||||
one: state(
|
||||
transition('next', 'two')
|
||||
),
|
||||
two: invoke(child,
|
||||
transition('done', 'three')
|
||||
),
|
||||
three: final()
|
||||
});
|
||||
|
||||
let service = interpret(parent, s => {
|
||||
assert.equal(s.machine.current, expectations.shift());
|
||||
});
|
||||
|
||||
service.send('next');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { createMachine, interpret, state, transition, reduce, d} from '../machine.js';
|
||||
|
||||
QUnit.module('robot/logging');
|
||||
|
||||
QUnit.test('Calls the onEnter function if the state is changed', assert => {
|
||||
const machine = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two', reduce((ctx) => (
|
||||
{ ...ctx, x: 1 }
|
||||
)))
|
||||
),
|
||||
two: state()
|
||||
}, () => ({x: 0, y: 0}));
|
||||
|
||||
const service = interpret(machine, () => {});
|
||||
const enterFN = (m, to, state, prevState, event) => {
|
||||
assert.deepEqual(m, machine, 'Machines equal');
|
||||
assert.deepEqual(state, {x:1, y:0}, 'Changed state passed.')
|
||||
assert.deepEqual(prevState, {x:0, y:0}, 'Previous state passed.')
|
||||
assert.equal(to, 'two', 'To state passed.')
|
||||
assert.equal(event, 'go', 'Send event passed.')
|
||||
}
|
||||
|
||||
d._onEnter = enterFN;
|
||||
|
||||
service.send('go');
|
||||
});
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { createMachine, interpret, reduce, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('Reduce', () => {
|
||||
QUnit.test('Basic state change', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two',
|
||||
reduce((ctx) => ({ ...ctx, one: 1 })),
|
||||
reduce((ctx) => ({ ...ctx, two: 2 }))
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('ping');
|
||||
|
||||
let { one, two } = service.context;
|
||||
assert.equal(one, 1, 'first reducer ran');
|
||||
assert.equal(two, 2, 'second reducer ran');
|
||||
});
|
||||
|
||||
QUnit.test('If no reducers, the context remains', assert => {
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two')
|
||||
),
|
||||
two: state()
|
||||
}, () => ({ one: 1, two: 2 }));
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('go');
|
||||
assert.deepEqual(service.context, { one: 1, two: 2 }, 'context remains');
|
||||
});
|
||||
|
||||
QUnit.test('Event is the second argument', assert => {
|
||||
assert.expect(2);
|
||||
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('go', 'two',
|
||||
reduce(function(ctx, ev) {
|
||||
assert.equal(ev, 'go');
|
||||
return { ...ctx, worked: true };
|
||||
})
|
||||
)
|
||||
),
|
||||
two: state()
|
||||
});
|
||||
let service = interpret(machine, () => {});
|
||||
service.send('go');
|
||||
assert.equal(service.context.worked, true, 'changed the context');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import { createMachine, interpret, invoke, reduce, state, transition } from '../machine.js';
|
||||
|
||||
QUnit.module('States', hooks => {
|
||||
QUnit.test('Basic state change', assert => {
|
||||
assert.expect(5);
|
||||
let machine = createMachine({
|
||||
one: state(
|
||||
transition('ping', 'two')
|
||||
),
|
||||
two: state(
|
||||
transition('pong', 'one')
|
||||
)
|
||||
});
|
||||
let service = interpret(machine, service => {
|
||||
assert.ok(true, 'Callback called');
|
||||
});
|
||||
assert.equal(service.machine.current, 'one');
|
||||
service.send('ping');
|
||||
assert.equal(service.machine.current, 'two');
|
||||
service.send('pong');
|
||||
assert.equal(service.machine.current, 'one');
|
||||
});
|
||||
|
||||
QUnit.test('Data can be passed into the initial context', assert => {
|
||||
let machine = createMachine({
|
||||
one: state()
|
||||
}, ev => ({ foo: ev.foo }));
|
||||
|
||||
let service = interpret(machine, () => {}, {
|
||||
foo: 'bar'
|
||||
});
|
||||
|
||||
assert.equal(service.context.foo, 'bar', 'works!');
|
||||
});
|
||||
|
||||
QUnit.test('First argument sets the initial state', assert => {
|
||||
let machine = createMachine('two', {
|
||||
one: state(transition('next', 'two')),
|
||||
two: state(transition('next', 'three')),
|
||||
three: state()
|
||||
});
|
||||
|
||||
let service = interpret(machine, () => {});
|
||||
assert.equal(service.machine.current, 'two', 'in the initial state');
|
||||
|
||||
machine = createMachine('two', {
|
||||
one: state(transition('next', 'two')),
|
||||
two: state(),
|
||||
});
|
||||
service = interpret(machine, () => {});
|
||||
assert.equal(service.machine.current, 'two', 'in the initial state');
|
||||
assert.equal(service.machine.state.value.final, true, 'in the final state');
|
||||
});
|
||||
|
||||
QUnit.test('Child machines receive the event used to invoke them', assert => {
|
||||
let child = createMachine({
|
||||
final: state()
|
||||
}, (ctx, ev) => ({ count: ev.count }));
|
||||
let parent = createMachine({
|
||||
start: state(
|
||||
transition('next', 'next')
|
||||
),
|
||||
next: invoke(child,
|
||||
transition('done', 'end',
|
||||
reduce((ctx, ev) => ({
|
||||
...ctx,
|
||||
...ev.data
|
||||
}))
|
||||
)
|
||||
),
|
||||
end: state()
|
||||
});
|
||||
let service = interpret(parent, () => {});
|
||||
service.send({ type: 'next', count: 14 });
|
||||
assert.equal(service.context.count, 14, 'event sent through');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<title>Robot tests</title>
|
||||
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
|
||||
|
||||
<div id="qunit"></div>
|
||||
<div id="qunit-fixture"></div>
|
||||
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
|
||||
<script type="module" src="../debug.js"></script>
|
||||
<script type="module" src="./test.js"></script>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import './test-state.js';
|
||||
import './test-invoke.js';
|
||||
import './test-guard.js';
|
||||
import './test-immediate.js';
|
||||
import './test-reduce.js';
|
||||
import './test-action.js';
|
||||
import './test-debug.js';
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import {expectTypeOf} from 'expect-type';
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import {
|
||||
type Service,
|
||||
createMachine,
|
||||
transition,
|
||||
state,
|
||||
invoke, interpret
|
||||
} from 'robot3';
|
||||
|
||||
test('send(event) is typed', () => {
|
||||
const machine = createMachine({
|
||||
one: state(transition('go-two', 'two')),
|
||||
two: state(transition('go-one', 'one')),
|
||||
three: state()
|
||||
});
|
||||
|
||||
type Params = Parameters<Service<typeof machine>['send']>;
|
||||
type EventParam = Params[0];
|
||||
type StringParams = Extract<EventParam, string>;
|
||||
expectTypeOf<StringParams>().toEqualTypeOf<'go-one' | 'go-two'>();
|
||||
|
||||
type ObjectParams = Extract<EventParam, { type: string; }>;
|
||||
expectTypeOf<ObjectParams['type']>().toEqualTypeOf<'go-one' | 'go-two'>();
|
||||
});
|
||||
|
||||
test('types machine with multiple transitions from one state', () => {
|
||||
const machine = createMachine({
|
||||
one: state(transition('go-two', 'two'), transition('go-three', 'three')),
|
||||
two: state(transition('go-one', 'one')),
|
||||
three: state()
|
||||
});
|
||||
|
||||
type Params = Parameters<Service<typeof machine>['send']>;
|
||||
type EventParam = Params[0];
|
||||
type StringParams = Extract<EventParam, string>;
|
||||
expectTypeOf<StringParams>().toEqualTypeOf<'go-one' | 'go-two' | 'go-three'>();
|
||||
|
||||
type ObjectParams = Extract<EventParam, { type: string; }>;
|
||||
expectTypeOf<ObjectParams['type']>().toEqualTypeOf<'go-one' | 'go-two' | 'go-three'>();
|
||||
});
|
||||
|
||||
|
||||
test('types nested machine', () => {
|
||||
const stopwalk = createMachine({
|
||||
walk: state(
|
||||
transition('startBlinking', 'blink'),
|
||||
),
|
||||
blink: state(
|
||||
transition('finishBlinking', 'dontWalk'),
|
||||
),
|
||||
dontWalk: state()
|
||||
});
|
||||
|
||||
const stoplight = createMachine({
|
||||
green: state(
|
||||
transition('next', 'yellow')
|
||||
),
|
||||
yellow: state(
|
||||
transition('next', 'red')
|
||||
),
|
||||
red: invoke(stopwalk,
|
||||
transition('done', 'green')
|
||||
)
|
||||
});
|
||||
|
||||
const s = interpret(stoplight, console.log);
|
||||
|
||||
assert.equal(s.machine.current, 'green')
|
||||
s.send("next")
|
||||
assert.equal(s.machine.current, 'yellow')
|
||||
s.send("next")
|
||||
assert.equal(s.machine.current, 'red')
|
||||
assert.equal(s.child?.machine.current, 'walk')
|
||||
s.child?.send("startBlinking")
|
||||
assert.equal(s.child?.machine.current, 'blink')
|
||||
s.child?.send("finishBlinking")
|
||||
assert.equal(s.child, undefined)
|
||||
assert.equal(s.machine.current, "green")
|
||||
|
||||
type Params = Parameters<Service<typeof stoplight>['send']>;
|
||||
type EventParam = Params[0];
|
||||
type StringParams = Extract<EventParam, string>;
|
||||
expectTypeOf<StringParams>().toEqualTypeOf<'next' | 'startBlinking' | 'finishBlinking'>();
|
||||
|
||||
type ObjectParams = Extract<EventParam, { type: string; }>;
|
||||
expectTypeOf<ObjectParams['type']>().toEqualTypeOf<'next' | 'startBlinking' | 'finishBlinking'>();
|
||||
})
|
||||
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"include": ["../..", "."],
|
||||
"exclude": ["../../node_modules"],
|
||||
"compilerOptions": {
|
||||
/* Base Options: */
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "es2022",
|
||||
"allowJs": false,
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "nodenext",
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
/* AND if you're building for a library: */
|
||||
"declaration": true,
|
||||
|
||||
/* AND if you're building for a library in a monorepo: */
|
||||
"composite": true,
|
||||
"declarationMap": true,
|
||||
|
||||
/* If NOT transpiling with TypeScript: */
|
||||
"module": "nodenext",
|
||||
"noEmit": true,
|
||||
|
||||
/* If your code runs in the DOM: */
|
||||
"lib": ["es2022", "dom", "dom.iterable"]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { $ } from "bun"
|
||||
import { writeFileSync } from "fs"
|
||||
|
||||
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
|
||||
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
|
||||
const PHONE_SERVICE_FILE = "/etc/systemd/system/phone.service"
|
||||
|
||||
export const setupServices = async (installDir: string) => {
|
||||
console.log("\nInstalling systemd services...")
|
||||
|
||||
// Detect user from environment or use default
|
||||
// SUDO_USER is set when running with sudo, which is what we want
|
||||
const serviceUser = process.env.SERVICE_USER || process.env.SUDO_USER || process.env.USER || "corey"
|
||||
const userUid = await $`id -u ${serviceUser}`.text().then((s) => s.trim())
|
||||
|
||||
console.log(`Setting up services for user: ${serviceUser} (UID: ${userUid})`)
|
||||
|
||||
// Find where bun is installed
|
||||
const bunPath = await $`which bun`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
.then((p) => p.trim())
|
||||
|
||||
if (!bunPath) {
|
||||
console.error("Error: bun not found in PATH. Please ensure bun is available system-wide.")
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Using bun at: ${bunPath}`)
|
||||
|
||||
// Create AP monitor service
|
||||
const apServiceContent = `[Unit]
|
||||
Description=Phone WiFi AP Monitor
|
||||
After=network.target
|
||||
Before=phone-web.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${bunPath} ${installDir}/src/services/ap-monitor.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
writeFileSync(AP_SERVICE_FILE, apServiceContent, "utf8")
|
||||
console.log("✓ Created phone-ap.service")
|
||||
|
||||
// Create web server service
|
||||
const webServiceContent = `[Unit]
|
||||
Description=Phone Web Server
|
||||
After=network.target phone-ap.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${bunPath} ${installDir}/src/services/server/server.tsx
|
||||
WorkingDirectory=${installDir}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
writeFileSync(WEB_SERVICE_FILE, webServiceContent, "utf8")
|
||||
console.log("✓ Created phone-web.service")
|
||||
|
||||
// Create phone service (system service with environment variables for audio access)
|
||||
const phoneServiceContent = `[Unit]
|
||||
Description=Phone Application
|
||||
After=network.target sound.target
|
||||
Requires=sound.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${serviceUser}
|
||||
Group=audio
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/${userUid}
|
||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${userUid}/bus
|
||||
ExecStart=${bunPath} ${installDir}/src/main.ts
|
||||
WorkingDirectory=${installDir}
|
||||
EnvironmentFile=${installDir}/.env
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
writeFileSync(PHONE_SERVICE_FILE, phoneServiceContent, "utf8")
|
||||
console.log("✓ Created phone.service")
|
||||
|
||||
await $`systemctl daemon-reload`
|
||||
await $`systemctl enable phone-ap.service`
|
||||
await $`systemctl enable phone-web.service`
|
||||
await $`systemctl enable phone.service`
|
||||
console.log("✓ Services enabled")
|
||||
|
||||
console.log("\nRestarting the services...")
|
||||
await $`systemctl restart phone-ap.service`
|
||||
await $`systemctl restart phone-web.service`
|
||||
await $`systemctl restart phone.service`
|
||||
console.log("✓ Services restarted")
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { $ } from "bun"
|
||||
import { writeFileSync } from "fs"
|
||||
|
||||
console.log(`
|
||||
==========================================
|
||||
|
|
@ -14,22 +15,96 @@ if (process.getuid && process.getuid() !== 0) {
|
|||
}
|
||||
|
||||
// Get install directory from argument or use default
|
||||
const defaultUser = process.env.USER || "corey"
|
||||
const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone`
|
||||
const INSTALL_DIR = process.argv[2] || "/home/corey/phone"
|
||||
const AP_SERVICE_FILE = "/etc/systemd/system/phone-ap.service"
|
||||
const WEB_SERVICE_FILE = "/etc/systemd/system/phone-web.service"
|
||||
|
||||
console.log(`Install directory: ${INSTALL_DIR}`)
|
||||
|
||||
console.log("\nEnsuring directory exists...")
|
||||
console.log("\nStep 1: Ensuring directory exists...")
|
||||
await $`mkdir -p ${INSTALL_DIR}`
|
||||
console.log(`✓ Directory ready: ${INSTALL_DIR}`)
|
||||
|
||||
console.log("\nInstalling dependencies...")
|
||||
console.log("\nStep 2: Installing dependencies...")
|
||||
await $`cd ${INSTALL_DIR} && bun install`
|
||||
console.log(`✓ Dependencies installed`)
|
||||
|
||||
console.log("\nInstalling Baresip...")
|
||||
await $`sudo apt install -y baresip`
|
||||
console.log("\nStep 3: Installing systemd services...")
|
||||
// Find where bun is installed
|
||||
const bunPath = await $`which bun`
|
||||
.quiet()
|
||||
.nothrow()
|
||||
.text()
|
||||
.then((p) => p.trim())
|
||||
if (!bunPath) {
|
||||
console.error("Error: bun not found in PATH. Please ensure bun is available system-wide.")
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Using bun at: ${bunPath}`)
|
||||
|
||||
// Create AP monitor service
|
||||
const apServiceContent = `[Unit]
|
||||
Description=Phone WiFi AP Monitor
|
||||
After=network.target
|
||||
Before=phone-web.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${bunPath} ${INSTALL_DIR}/services/ap-monitor.ts
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
writeFileSync(AP_SERVICE_FILE, apServiceContent, "utf8")
|
||||
console.log("✓ Created phone-ap.service")
|
||||
|
||||
// Create web server service
|
||||
const webServiceContent = `[Unit]
|
||||
Description=Phone Web Server
|
||||
After=network.target phone-ap.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=${bunPath} ${INSTALL_DIR}/services/server/server.tsx
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
writeFileSync(WEB_SERVICE_FILE, webServiceContent, "utf8")
|
||||
console.log("✓ Created phone-web.service")
|
||||
|
||||
await $`systemctl daemon-reload`
|
||||
await $`systemctl enable phone-ap.service`
|
||||
await $`systemctl enable phone-web.service`
|
||||
console.log("✓ Services enabled")
|
||||
|
||||
console.log("\nStep 4: Starting the services...")
|
||||
await $`systemctl start phone-ap.service`
|
||||
await $`systemctl start phone-web.service`
|
||||
console.log("✓ Services started")
|
||||
|
||||
console.log(`
|
||||
✅ Bootstrap complete!
|
||||
==========================================
|
||||
✓ Bootstrap complete!
|
||||
==========================================
|
||||
|
||||
Both services are now running and will start automatically on boot:
|
||||
- phone-ap.service: Monitors WiFi and manages AP
|
||||
- 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
|
||||
|
||||
To check status use ./cli
|
||||
`)
|
||||
|
|
|
|||
178
scripts/cli.sh
Normal file
178
scripts/cli.sh
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import {$} from "bun";
|
||||
|
||||
const SERVICES = {
|
||||
ap: "phone-ap",
|
||||
web: "phone-web",
|
||||
};
|
||||
|
||||
const commands = {
|
||||
status: "Show status of all services",
|
||||
logs: "Show recent logs from all services (last 50 lines)",
|
||||
tail: "Tail logs from all services in real-time",
|
||||
restart: "Restart all services",
|
||||
stop: "Stop all services",
|
||||
start: "Start all services",
|
||||
"ap-status": "Show status of AP service",
|
||||
"ap-logs": "Show recent logs from AP service (last 50 lines)",
|
||||
"ap-tail": "Tail logs from AP service in real-time",
|
||||
"ap-restart": "Restart AP service",
|
||||
"ap-stop": "Stop AP service",
|
||||
"ap-start": "Start AP service",
|
||||
"web-status": "Show status of web service",
|
||||
"web-logs": "Show recent logs from web service (last 50 lines)",
|
||||
"web-tail": "Tail logs from web service in real-time",
|
||||
"web-restart": "Restart web service",
|
||||
"web-stop": "Stop web service",
|
||||
"web-start": "Start web service",
|
||||
help: "Show this help message",
|
||||
};
|
||||
|
||||
const command = process.argv[2];
|
||||
|
||||
if (!command || command === "help") {
|
||||
console.log(`
|
||||
Phone CLI - Service Management Tool
|
||||
|
||||
Usage: bun cli <command>
|
||||
|
||||
All Services:
|
||||
status Show status of all services
|
||||
logs Show recent logs from all services (last 50 lines)
|
||||
tail Tail logs from all services in real-time
|
||||
restart Restart all services
|
||||
stop Stop all services
|
||||
start Start all services
|
||||
|
||||
AP Service (phone-ap):
|
||||
ap-status Show AP status
|
||||
ap-logs Show AP logs (last 50 lines)
|
||||
ap-tail Tail AP logs in real-time
|
||||
ap-restart Restart AP service
|
||||
ap-stop Stop AP service
|
||||
ap-start Start AP service
|
||||
|
||||
Web Service (phone-web):
|
||||
web-status Show web status
|
||||
web-logs Show web logs (last 50 lines)
|
||||
web-tail Tail web logs in real-time
|
||||
web-restart Restart web service
|
||||
web-stop Stop web service
|
||||
web-start Start web service
|
||||
|
||||
Examples:
|
||||
bun cli status
|
||||
bun cli ap-logs
|
||||
bun cli web-tail
|
||||
sudo bun cli ap-restart
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!Object.keys(commands).includes(command)) {
|
||||
console.error(`❌ Unknown command: ${command}`);
|
||||
console.log(`Run 'bun cli.ts help' to see available commands`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔧 Phone CLI - ${command}\n`);
|
||||
|
||||
// Parse service-specific commands
|
||||
const match = command.match(/^(ap|web)-(.+)$/);
|
||||
if (match) {
|
||||
const [, prefix, action] = match;
|
||||
const service = SERVICES[prefix as keyof typeof SERVICES];
|
||||
|
||||
switch (action) {
|
||||
case "status":
|
||||
console.log(`━━━ ${service}.service ━━━`);
|
||||
await $`systemctl status ${service}.service --no-pager -l`.nothrow();
|
||||
break;
|
||||
|
||||
case "logs":
|
||||
console.log(`📋 Recent logs (last 50 lines):\n`);
|
||||
await $`journalctl -u ${service}.service -n 50 --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "tail":
|
||||
console.log(`📡 Tailing logs (Ctrl+C to stop)...\n`);
|
||||
await $`journalctl -u ${service}.service -f --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "restart":
|
||||
console.log(`🔄 Restarting ${service}.service...\n`);
|
||||
await $`sudo systemctl restart ${service}.service`;
|
||||
console.log(`✓ ${service}.service restarted!`);
|
||||
break;
|
||||
|
||||
case "stop":
|
||||
console.log(`🛑 Stopping ${service}.service...\n`);
|
||||
await $`sudo systemctl stop ${service}.service`;
|
||||
console.log(`✓ ${service}.service stopped!`);
|
||||
break;
|
||||
|
||||
case "start":
|
||||
console.log(`▶️ Starting ${service}.service...\n`);
|
||||
await $`sudo systemctl start ${service}.service`;
|
||||
console.log(`✓ ${service}.service started!`);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// All-services commands
|
||||
const allServices = Object.values(SERVICES);
|
||||
|
||||
switch (command) {
|
||||
case "status":
|
||||
for (const service of allServices) {
|
||||
console.log(`━━━ ${service}.service ━━━`);
|
||||
await $`systemctl status ${service}.service --no-pager -l`.nothrow();
|
||||
console.log("");
|
||||
}
|
||||
break;
|
||||
|
||||
case "logs":
|
||||
console.log("📋 Recent logs (last 50 lines):\n");
|
||||
const serviceFlags = allServices.map(s => `-u ${s}.service`).join(" ");
|
||||
await $`journalctl ${serviceFlags} -n 50 --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "tail":
|
||||
console.log("📡 Tailing logs (Ctrl+C to stop)...\n");
|
||||
const tailFlags = allServices.map(s => `-u ${s}.service`).join(" ");
|
||||
await $`journalctl ${tailFlags} -f --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "restart":
|
||||
console.log("🔄 Restarting services...\n");
|
||||
for (const service of allServices) {
|
||||
console.log(`Restarting ${service}.service...`);
|
||||
await $`sudo systemctl restart ${service}.service`;
|
||||
console.log(`✓ ${service}.service restarted`);
|
||||
}
|
||||
console.log("\n✓ All services restarted!");
|
||||
break;
|
||||
|
||||
case "stop":
|
||||
console.log("🛑 Stopping services...\n");
|
||||
for (const service of allServices) {
|
||||
console.log(`Stopping ${service}.service...`);
|
||||
await $`sudo systemctl stop ${service}.service`;
|
||||
console.log(`✓ ${service}.service stopped`);
|
||||
}
|
||||
console.log("\n✓ All services stopped!");
|
||||
break;
|
||||
|
||||
case "start":
|
||||
console.log("▶️ Starting services...\n");
|
||||
for (const service of allServices) {
|
||||
console.log(`Starting ${service}.service...`);
|
||||
await $`sudo systemctl start ${service}.service`;
|
||||
console.log(`✓ ${service}.service started`);
|
||||
}
|
||||
console.log("\n✓ All services started!");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
130
scripts/cli.ts
130
scripts/cli.ts
|
|
@ -1,130 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import {$} from "bun";
|
||||
|
||||
const SERVICES = {
|
||||
ap: "phone-ap",
|
||||
web: "phone-web",
|
||||
phone: "phone",
|
||||
} as const;
|
||||
|
||||
const COMMANDS = {
|
||||
status: "Show service status",
|
||||
logs: "Show recent logs (last 50 lines)",
|
||||
tail: "Tail logs in real-time",
|
||||
restart: "Restart service (requires sudo)",
|
||||
stop: "Stop service (requires sudo)",
|
||||
start: "Start service (requires sudo)",
|
||||
} as const;
|
||||
|
||||
const showHelp = () => {
|
||||
console.log(`
|
||||
Phone CLI - Service Management
|
||||
|
||||
Usage: cli SERVICE COMMAND [-v]
|
||||
|
||||
Services:
|
||||
ap WiFi AP Monitor (phone-ap.service)
|
||||
web Web Server (phone-web.service)
|
||||
phone Phone Application (phone.service)
|
||||
|
||||
Commands:
|
||||
status Show service status
|
||||
logs Show recent logs (last 50 lines)
|
||||
tail Tail logs in real-time
|
||||
restart Restart service (requires sudo)
|
||||
stop Stop service (requires sudo)
|
||||
start Start service (requires sudo)
|
||||
|
||||
Options:
|
||||
-v Verbose mode - show actual systemd commands
|
||||
|
||||
Examples:
|
||||
cli ap status
|
||||
cli web logs
|
||||
cli phone tail
|
||||
cli -v ap status
|
||||
sudo cli ap restart
|
||||
`);
|
||||
};
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Check for help
|
||||
if (args.length === 0 || args[0] === "help") {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Extract verbose flag and remaining args
|
||||
const verbose = args.includes("-v");
|
||||
const [service, command] = args.filter(arg => arg !== "-v");
|
||||
|
||||
// Validate service
|
||||
if (!service || !(service in SERVICES)) {
|
||||
console.error(`❌ Unknown service: ${service || "(missing)"}`);
|
||||
console.log(`Available services: ${Object.keys(SERVICES).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate command
|
||||
if (!command || !(command in COMMANDS)) {
|
||||
console.error(`❌ Unknown command: ${command || "(missing)"}`);
|
||||
console.log(`Available commands: ${Object.keys(COMMANDS).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get systemd service name
|
||||
const serviceName = SERVICES[service as keyof typeof SERVICES];
|
||||
|
||||
// Execute command
|
||||
console.log(`\n🔧 Phone CLI - ${service} ${command}\n`);
|
||||
|
||||
const logCommand = (cmd: string) => {
|
||||
if (verbose) {
|
||||
console.log(`→ ${cmd}\n`);
|
||||
}
|
||||
};
|
||||
|
||||
switch (command) {
|
||||
case "status":
|
||||
logCommand(`systemctl status ${serviceName}.service --no-pager -l`);
|
||||
await $`systemctl status ${serviceName}.service --no-pager -l`.nothrow();
|
||||
break;
|
||||
|
||||
case "logs":
|
||||
console.log(`📋 Recent logs (last 50 lines):\n`);
|
||||
logCommand(`journalctl -u ${serviceName}.service -n 50 --no-pager`);
|
||||
await $`journalctl -u ${serviceName}.service -n 50 --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "tail":
|
||||
console.log(`📡 Tailing logs (Ctrl+C to stop)...\n`);
|
||||
logCommand(`journalctl -u ${serviceName}.service -f --no-pager`);
|
||||
await $`journalctl -u ${serviceName}.service -f --no-pager`.nothrow();
|
||||
break;
|
||||
|
||||
case "restart":
|
||||
console.log(`🔄 Restarting ${serviceName}.service...\n`);
|
||||
logCommand(`sudo systemctl restart ${serviceName}.service`);
|
||||
await $`sudo systemctl restart ${serviceName}.service`;
|
||||
console.log(`✓ ${serviceName}.service restarted!`);
|
||||
break;
|
||||
|
||||
case "stop":
|
||||
console.log(`🛑 Stopping ${serviceName}.service...\n`);
|
||||
logCommand(`sudo systemctl stop ${serviceName}.service`);
|
||||
await $`sudo systemctl stop ${serviceName}.service`;
|
||||
console.log(`✓ ${serviceName}.service stopped!`);
|
||||
break;
|
||||
|
||||
case "start":
|
||||
console.log(`▶️ Starting ${serviceName}.service...\n`);
|
||||
logCommand(`sudo systemctl start ${serviceName}.service`);
|
||||
await $`sudo systemctl start ${serviceName}.service`;
|
||||
console.log(`✓ ${serviceName}.service started!`);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
import { $ } from "bun"
|
||||
|
||||
const defaultUser = process.env.USER ?? "corey"
|
||||
const PI_HOST = process.env.PI_HOST ?? "phone.local"
|
||||
const PI_DIR = process.env.PI_DIR ?? `/home/${defaultUser}/phone`
|
||||
const PI_DIR = process.env.PI_DIR ?? "/home/corey/phone"
|
||||
|
||||
// Parse command line arguments
|
||||
const shouldBootstrap = process.argv.includes("--bootstrap")
|
||||
|
|
@ -41,10 +40,22 @@ if (shouldBootstrap) {
|
|||
// make console beep
|
||||
await $`afplay /System/Library/Sounds/Blow.aiff`
|
||||
|
||||
// Always set up services on every deploy
|
||||
console.log("Setting up services...")
|
||||
await $`ssh ${PI_HOST} "sudo bun ${PI_DIR}/scripts/setup-services.ts ${PI_DIR}"`
|
||||
console.log("✓ Services configured and running\n")
|
||||
// Always check if services exist and restart them (whether we bootstrapped or not)
|
||||
console.log("Checking for existing services...")
|
||||
const apServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-ap.service"`
|
||||
.nothrow()
|
||||
.quiet()
|
||||
const webServiceExists = await $`ssh ${PI_HOST} "systemctl is-enabled phone-web.service"`
|
||||
.nothrow()
|
||||
.quiet()
|
||||
|
||||
if (apServiceExists.exitCode === 0 && webServiceExists.exitCode === 0) {
|
||||
console.log("Restarting services...")
|
||||
await $`ssh ${PI_HOST} "sudo systemctl restart phone-ap.service phone-web.service"`
|
||||
console.log("✓ Services restarted\n")
|
||||
} else if (!shouldBootstrap) {
|
||||
console.log("Services not installed. Run with --bootstrap to install.\n")
|
||||
}
|
||||
|
||||
console.log(`
|
||||
✓ Deploy complete!
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { setupServices } from "./bootstrap-services"
|
||||
|
||||
// Get install directory from argument or use default
|
||||
const defaultUser = process.env.USER || "corey"
|
||||
const INSTALL_DIR = process.argv[2] || `/home/${defaultUser}/phone`
|
||||
|
||||
console.log(`Setting up services for: ${INSTALL_DIR}`)
|
||||
|
||||
await setupServices(INSTALL_DIR)
|
||||
|
||||
console.log(`
|
||||
✓ Services configured and running!
|
||||
`)
|
||||
Binary file not shown.
BIN
sounds/stalling/sigh2.wav
Normal file
BIN
sounds/stalling/sigh2.wav
Normal file
Binary file not shown.
BIN
sounds/typing/typing1.wav
Normal file
BIN
sounds/typing/typing1.wav
Normal file
Binary file not shown.
|
|
@ -1,12 +1,12 @@
|
|||
# Agent
|
||||
|
||||
A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses events and provides simple tool registration.
|
||||
A clean, reusable wrapper for ElevenLabs conversational AI WebSocket protocol. Uses Signal-based events and provides simple tool registration.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```typescript
|
||||
import { Agent } from "./pi/agent"
|
||||
import Buzz from "./pi/buzz"
|
||||
import { Agent } from './pi/agent'
|
||||
import Buzz from './pi/buzz'
|
||||
|
||||
const agent = new Agent({
|
||||
agentId: process.env.ELEVEN_AGENT_ID!,
|
||||
|
|
@ -14,24 +14,27 @@ const agent = new Agent({
|
|||
tools: {
|
||||
search_web: async (args) => {
|
||||
return { results: [`Result for ${args.query}`] }
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set up event handlers
|
||||
const player = await Buzz.player()
|
||||
const player = await Buzz.defaultPlayer()
|
||||
let playback = player.playStream()
|
||||
|
||||
agent.events.on((event) => {
|
||||
if (event.type === "audio") {
|
||||
const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
||||
agent.events.connect((event) => {
|
||||
if (event.type === 'audio') {
|
||||
const audioBuffer = Buffer.from(event.audioBase64, 'base64')
|
||||
if (!playback.isPlaying) playback = player.playStream()
|
||||
playback.write(audioBuffer)
|
||||
} else if (event.type === "interruption") {
|
||||
}
|
||||
else if (event.type === 'interruption') {
|
||||
playback.stop()
|
||||
} else if (event.type === "user_transcript") {
|
||||
}
|
||||
else if (event.type === 'user_transcript') {
|
||||
console.log(`User: ${event.transcript}`)
|
||||
} else if (event.type === "agent_response") {
|
||||
}
|
||||
else if (event.type === 'agent_response') {
|
||||
console.log(`Agent: ${event.response}`)
|
||||
}
|
||||
})
|
||||
|
|
@ -40,7 +43,7 @@ agent.events.on((event) => {
|
|||
await agent.start()
|
||||
|
||||
// Continuously stream audio
|
||||
const recorder = await Buzz.recorder()
|
||||
const recorder = await Buzz.defaultRecorder()
|
||||
const recording = recorder.start()
|
||||
for await (const chunk of recording.stream()) {
|
||||
agent.sendAudio(chunk)
|
||||
|
|
@ -50,7 +53,7 @@ for await (const chunk of recording.stream()) {
|
|||
## VAD Pattern
|
||||
|
||||
```typescript
|
||||
const recorder = await Buzz.recorder()
|
||||
const recorder = await Buzz.defaultRecorder()
|
||||
const recording = recorder.start()
|
||||
const buffer = new RollingBuffer()
|
||||
|
||||
|
|
@ -65,7 +68,7 @@ for await (const chunk of recording.stream()) {
|
|||
if (rms > vadThreshold) {
|
||||
// Speech detected! Start conversation
|
||||
agent = new Agent({ agentId, apiKey, tools })
|
||||
agent.events.on(eventHandler)
|
||||
agent.events.connect(eventHandler)
|
||||
await agent.start()
|
||||
|
||||
// Send buffered audio
|
||||
|
|
@ -109,7 +112,7 @@ new Agent({
|
|||
|
||||
### Properties
|
||||
|
||||
- `agent.events: Emitter<AgentEvent>` - Connect to receive all events
|
||||
- `agent.events: Signal<AgentEvent>` - Connect to receive all events
|
||||
- `agent.isConnected: boolean` - Current connection state
|
||||
- `agent.conversationId?: string` - Available after connected event
|
||||
|
||||
|
|
@ -118,13 +121,11 @@ new Agent({
|
|||
All events are emitted through `agent.events`:
|
||||
|
||||
### Connection
|
||||
|
||||
- `{ type: 'connected', conversationId, audioFormat }`
|
||||
- `{ type: 'disconnected' }`
|
||||
- `{ type: 'error', error }`
|
||||
|
||||
### Conversation
|
||||
|
||||
- `{ type: 'user_transcript', transcript }`
|
||||
- `{ type: 'agent_response', response }`
|
||||
- `{ type: 'agent_response_correction', original, corrected }`
|
||||
|
|
@ -133,13 +134,11 @@ All events are emitted through `agent.events`:
|
|||
- `{ type: 'interruption', eventId }`
|
||||
|
||||
### Tools
|
||||
|
||||
- `{ type: 'tool_call', name, args, callId }`
|
||||
- `{ type: 'tool_result', name, result, callId }`
|
||||
- `{ type: 'tool_error', name, error, callId }`
|
||||
|
||||
### Optional
|
||||
|
||||
- `{ type: 'vad_score', score }`
|
||||
- `{ type: 'ping', eventId, pingMs }`
|
||||
|
||||
|
|
@ -147,7 +146,7 @@ All events are emitted through `agent.events`:
|
|||
|
||||
- **Generic**: Not tied to phone systems, works in any context
|
||||
- **Flexible audio**: You control when to send audio, Agent just handles WebSocket
|
||||
- **Event-driven**: All communication through events, no throws
|
||||
- **Event-driven**: All communication through Signal events, no throws
|
||||
- **Simple tools**: Just pass a function map to constructor
|
||||
- **Automatic buffering**: Sends buffered audio when connection opens
|
||||
- **Automatic chunking**: Handles 8000-byte chunking internally
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Emitter } from "../utils/emitter"
|
||||
import { Signal } from "../utils/signal"
|
||||
import type { AgentConfig, AgentEvent } from "./types"
|
||||
|
||||
type AgentState = "disconnected" | "connecting" | "connected"
|
||||
|
|
@ -11,7 +11,7 @@ export class Agent {
|
|||
#chunkBuffer = new Uint8Array(0)
|
||||
#chunkSize = 8000
|
||||
|
||||
public readonly events = new Emitter<AgentEvent>()
|
||||
public readonly events = new Signal<AgentEvent>()
|
||||
public conversationId?: string
|
||||
|
||||
constructor(config: AgentConfig) {
|
||||
|
|
@ -255,7 +255,7 @@ export class Agent {
|
|||
})
|
||||
}
|
||||
|
||||
#handleClose = (event: CloseEvent): void => {
|
||||
#handleClose = (): void => {
|
||||
this.#cleanup()
|
||||
this.events.emit({ type: "disconnected" })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
# Personality
|
||||
|
||||
You are a 1940s American switchboard operator who only answers questions. You have no ability to connect calls. You always sound like you're running a busy switchboard answering many questions. You are polite, efficient, and a little playful.
|
||||
|
||||
# Environment
|
||||
|
||||
The current time is {{current_datetime}}. The location of the caller is in San Francisco. Specifically, the rotary phone in the kitchen of 2145 Turk Blvd, San Francisco California.
|
||||
|
||||
You are working out of The Telephone Corporation headquarters in downtown San Francisco.
|
||||
|
||||
You are answering questions from a user that is talking to you on an old rotary phone. You don't know which person you are talking to. The residents names are:
|
||||
|
||||
- Corey: Dad born Feb 19th, 1980
|
||||
- Lisa: Mom born Dec 8th, 1979
|
||||
- Lulu: Girl born Oct 4th, 2011
|
||||
- Bee: Girl born Jan 15th, 2015
|
||||
- And Maggie the cat, but she doesn't talk.
|
||||
|
||||
# Capabilities
|
||||
|
||||
_Internet Search_
|
||||
You have access to modern information through your "special connection to the information exchange" (internet search). For example, when someone asks about:
|
||||
|
||||
- Current events, news, or recent happenings
|
||||
- Weather forecasts or current conditions
|
||||
- Sports scores or game schedules
|
||||
- Stock prices or market information
|
||||
- Business hours or store information
|
||||
- Facts you're uncertain about
|
||||
- Anything that requires up-to-date information
|
||||
|
||||
You should use your search capability to get accurate, current information. Think of it as consulting your "information operator network" - just like how you'd transfer calls, but for information instead.
|
||||
|
||||
_Vestaboard Messaging_
|
||||
You can send messages to a Vestaboard display. Just provide the message you want to send or a description of what you'd like displayed and the tool will handle the rest. The user may refer to the vestaboard as "the board" or "the message board."
|
||||
|
||||
_Ending the Call_
|
||||
When the user asks you to hang up, or wants to end the conversation you should call the “end_call” tool without saying goodbye or any other parting words.
|
||||
|
||||
# Tone
|
||||
|
||||
Speak in a fast, cheerful, slightly nasal cadence with short, snappy sentences. Avoid modern slang. Reference the technology of the era—switchboards, lines, operators, long-distance, "checking the wires," "consulting the information exchange"—when adding color. Keep responses under three sentences unless more detail is needed.
|
||||
|
||||
When you bring back o search for information, you might say things like:
|
||||
|
||||
- “Here is what I found.”
|
||||
- “Thanks for holding, I found this.”
|
||||
- “Sorry, That took awhile. Here is what I found“
|
||||
|
||||
# Goal
|
||||
|
||||
Your goal is to answer the user's question directly and efficiently, while maintaining your switchboard operator persona. Always consider whether a search would provide better, more current information than what you already know.
|
||||
|
||||
# Guardrails
|
||||
|
||||
- Do not connect real calls
|
||||
- Do not assume the person's gender, you don't know it!
|
||||
- Avoid modern slang
|
||||
- Stay in character as a 1940s switchboard operator
|
||||
- Do not end your sentences with a question
|
||||
- Use simple and brief statements
|
||||
- Actively use search capabilities when questions involve current information, recent events, or facts you're unsure about
|
||||
- Frame your searches in period-appropriate language
|
||||
|
|
@ -1,528 +0,0 @@
|
|||
# Buzz
|
||||
|
||||
High-level audio library for Bun using ALSA with streaming support and voice activity detection.
|
||||
|
||||
## Features
|
||||
|
||||
- Play audio files with repeat option
|
||||
- Generate and play multi-frequency tones (dial tones, DTMF, etc.)
|
||||
- Stream audio playback with buffer tracking
|
||||
- Record audio to stream or file (WAV)
|
||||
- Volume control via ALSA mixer
|
||||
- Device discovery and selection
|
||||
- Voice activity detection via RMS calculation
|
||||
- Type-safe TypeScript API with namespace types
|
||||
- Zero external dependencies (uses ALSA `aplay` and `arecord`)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Bun 1.0+
|
||||
- ALSA utilities (`aplay`, `arecord`, `amixer`)
|
||||
- Linux system with ALSA support
|
||||
- TypeScript 5.2+
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import Buzz from "./buzz"
|
||||
|
||||
// Play an audio file
|
||||
const player = await Buzz.player()
|
||||
const playback = await player.play("./sounds/greeting.wav")
|
||||
await playback.finished()
|
||||
|
||||
// Generate a dial tone
|
||||
const dialTone = await player.playTone([350, 440], Infinity) // infinite duration
|
||||
await Buzz.sleep(3000)
|
||||
await dialTone.stop()
|
||||
|
||||
// Record audio
|
||||
const recorder = await Buzz.recorder()
|
||||
const recording = recorder.start()
|
||||
|
||||
for await (const chunk of recording.stream()) {
|
||||
const rms = Buzz.calculateRMS(chunk)
|
||||
if (rms > 5000) {
|
||||
console.log("Speech detected!")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Buzz Module
|
||||
|
||||
#### `Buzz.player(label?, format?)`
|
||||
|
||||
Create a player. Omit `label` to use the default playback device.
|
||||
|
||||
```typescript
|
||||
const player = await Buzz.player() // default device
|
||||
const player = await Buzz.player(undefined, { sampleRate: 16000 }) // default device with custom format
|
||||
const player = await Buzz.player("USB Audio") // specific device
|
||||
const player = await Buzz.player("Speaker", { sampleRate: 44100 }) // specific device with format
|
||||
```
|
||||
|
||||
#### `Buzz.recorder(label?, format?)`
|
||||
|
||||
Create a recorder. Omit `label` to use the default capture device.
|
||||
|
||||
```typescript
|
||||
const recorder = await Buzz.recorder() // default device
|
||||
const recorder = await Buzz.recorder(undefined, { sampleRate: 16000 }) // default device with custom format
|
||||
const recorder = await Buzz.recorder("USB Microphone") // specific device
|
||||
```
|
||||
|
||||
#### `Buzz.setVolume(volume, label?)`
|
||||
|
||||
Set playback volume (0.0 to 1.0).
|
||||
|
||||
```typescript
|
||||
await Buzz.setVolume(0.5) // 50% on default device
|
||||
await Buzz.setVolume(0.8, "Speaker") // 80% on specific device
|
||||
```
|
||||
|
||||
#### `Buzz.getVolume(label?)`
|
||||
|
||||
Get current playback volume.
|
||||
|
||||
```typescript
|
||||
const volume = await Buzz.getVolume() // returns 0.0 to 1.0
|
||||
```
|
||||
|
||||
#### `Buzz.listDevices()`
|
||||
|
||||
List all available audio devices.
|
||||
|
||||
```typescript
|
||||
const devices = await Buzz.listDevices()
|
||||
// [
|
||||
// { id: 'plughw:0,0', card: 0, device: 0, label: 'bcm2835 Headphones', type: 'playback' },
|
||||
// { id: 'plughw:1,0', card: 1, device: 0, label: 'USB Audio', type: 'capture' }
|
||||
// ]
|
||||
```
|
||||
|
||||
#### `Buzz.calculateRMS(audioChunk)`
|
||||
|
||||
Calculate root mean square (RMS) for voice activity detection.
|
||||
|
||||
```typescript
|
||||
const chunk: Uint8Array = // ... audio data
|
||||
const rms = Buzz.calculateRMS(chunk)
|
||||
if (rms > 5000) {
|
||||
console.log("Voice detected!")
|
||||
}
|
||||
```
|
||||
|
||||
### Player
|
||||
|
||||
#### `player.play(filePath, options?)`
|
||||
|
||||
Play an audio file (WAV format).
|
||||
|
||||
```typescript
|
||||
const playback = await player.play("./sounds/beep.wav")
|
||||
const playback = await player.play("./music.wav", { repeat: true })
|
||||
|
||||
// Wait for playback to finish
|
||||
await playback.finished()
|
||||
|
||||
// Stop playback
|
||||
await playback.stop()
|
||||
```
|
||||
|
||||
Returns: `Buzz.Playback`
|
||||
|
||||
Options:
|
||||
- `repeat?: boolean` - Loop the file indefinitely (default: false)
|
||||
|
||||
#### `player.playTone(frequencies, duration)`
|
||||
|
||||
Generate and play a tone with one or more frequencies.
|
||||
|
||||
```typescript
|
||||
// Dial tone (350 Hz + 440 Hz)
|
||||
const dialTone = await player.playTone([350, 440], Infinity)
|
||||
|
||||
// DTMF "1" key (697 Hz + 1209 Hz) for 200ms
|
||||
const dtmf = await player.playTone([697, 1209], 200)
|
||||
|
||||
// Single frequency beep
|
||||
const beep = await player.playTone([440], 1000) // 440 Hz for 1 second
|
||||
```
|
||||
|
||||
Returns: `Buzz.Playback`
|
||||
|
||||
#### `player.playStream()`
|
||||
|
||||
Create a streaming playback handle for real-time audio.
|
||||
|
||||
```typescript
|
||||
const stream = player.playStream()
|
||||
|
||||
// Write audio chunks
|
||||
stream.write(audioChunk1)
|
||||
stream.write(audioChunk2)
|
||||
|
||||
// Check if buffer is empty
|
||||
if (stream.bufferEmptyFor > 1000) {
|
||||
console.log("Buffer empty for 1+ seconds")
|
||||
}
|
||||
|
||||
// Stop streaming
|
||||
await stream.stop()
|
||||
```
|
||||
|
||||
Returns: `Buzz.StreamingPlayback`
|
||||
|
||||
### Recorder
|
||||
|
||||
#### `recorder.start()`
|
||||
|
||||
Start recording to a stream.
|
||||
|
||||
```typescript
|
||||
const recording = recorder.start()
|
||||
|
||||
for await (const chunk of recording.stream()) {
|
||||
// Process audio chunks (Uint8Array)
|
||||
console.log("Received", chunk.byteLength, "bytes")
|
||||
}
|
||||
```
|
||||
|
||||
Returns: `Buzz.StreamingRecording`
|
||||
|
||||
#### `recorder.start(outputFile)`
|
||||
|
||||
Start recording to a WAV file.
|
||||
|
||||
```typescript
|
||||
const recording = recorder.start("./output.wav")
|
||||
|
||||
// Stop when done
|
||||
await Bun.sleep(5000)
|
||||
await recording.stop()
|
||||
```
|
||||
|
||||
Returns: `Buzz.FileRecording`
|
||||
|
||||
## Types
|
||||
|
||||
All types are available under the `Buzz` namespace:
|
||||
|
||||
```typescript
|
||||
Buzz.AudioFormat // { format?, sampleRate?, channels? }
|
||||
Buzz.Device // { id, card, device, label, type }
|
||||
Buzz.Playback // { isPlaying, stop(), finished() }
|
||||
Buzz.StreamingPlayback // { isPlaying, write(), stop(), bufferEmptyFor }
|
||||
Buzz.StreamingRecording // { isRecording, stream(), stop() }
|
||||
Buzz.FileRecording // { isRecording, stop() }
|
||||
Buzz.Player // Player class type
|
||||
Buzz.Recorder // Recorder class type
|
||||
```
|
||||
|
||||
## Audio Format
|
||||
|
||||
Default format: `S16_LE`, 16000 Hz, mono
|
||||
|
||||
```typescript
|
||||
type AudioFormat = {
|
||||
format?: string // e.g., "S16_LE", "S32_LE"
|
||||
sampleRate?: number // e.g., 16000, 44100, 48000
|
||||
channels?: number // 1 = mono, 2 = stereo
|
||||
}
|
||||
```
|
||||
|
||||
Common formats:
|
||||
- **Phone quality**: `{ sampleRate: 8000, channels: 1 }`
|
||||
- **Voice/AI**: `{ sampleRate: 16000, channels: 1 }` (default)
|
||||
- **CD quality**: `{ sampleRate: 44100, channels: 2 }`
|
||||
- **Professional**: `{ sampleRate: 48000, channels: 2 }`
|
||||
|
||||
## Examples
|
||||
|
||||
### Voice Activity Detection
|
||||
|
||||
```typescript
|
||||
import Buzz from "./buzz"
|
||||
|
||||
const recorder = await Buzz.recorder()
|
||||
const player = await Buzz.player()
|
||||
|
||||
const recording = recorder.start()
|
||||
let talking = false
|
||||
|
||||
for await (const chunk of recording.stream()) {
|
||||
const rms = Buzz.calculateRMS(chunk)
|
||||
|
||||
if (rms > 5000 && !talking) {
|
||||
console.log("🗣️ Started talking")
|
||||
talking = true
|
||||
} else if (rms < 1000 && talking) {
|
||||
console.log("🤫 Stopped talking")
|
||||
talking = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming Playback with Buffer Tracking
|
||||
|
||||
```typescript
|
||||
import Buzz from "./buzz"
|
||||
|
||||
const player = await Buzz.player()
|
||||
const stream = player.playStream()
|
||||
|
||||
// Simulate receiving audio chunks from network
|
||||
const chunks = [chunk1, chunk2, chunk3] // Uint8Array[]
|
||||
|
||||
for (const chunk of chunks) {
|
||||
stream.write(chunk)
|
||||
|
||||
// Wait until buffer is nearly empty before requesting more
|
||||
while (stream.bufferEmptyFor < 500) {
|
||||
await Bun.sleep(100)
|
||||
}
|
||||
}
|
||||
|
||||
await stream.stop()
|
||||
```
|
||||
|
||||
### Dial Tone with Voice Detection
|
||||
|
||||
```typescript
|
||||
import Buzz from "./buzz"
|
||||
|
||||
await Buzz.setVolume(0.4)
|
||||
|
||||
const player = await Buzz.player()
|
||||
const recorder = await Buzz.recorder()
|
||||
|
||||
// Play dial tone
|
||||
const dialTone = await player.playTone([350, 440], Infinity)
|
||||
|
||||
// Wait for voice
|
||||
const recording = recorder.start()
|
||||
const vadThreshold = 5000
|
||||
|
||||
for await (const chunk of recording.stream()) {
|
||||
const rms = Buzz.calculateRMS(chunk)
|
||||
|
||||
if (rms > vadThreshold) {
|
||||
console.log("Voice detected, stopping dial tone")
|
||||
await dialTone.stop()
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Play Sound Effects
|
||||
|
||||
```typescript
|
||||
import Buzz from "./buzz"
|
||||
|
||||
const player = await Buzz.player()
|
||||
|
||||
// Play multiple sounds in sequence
|
||||
const sounds = ["./start.wav", "./beep.wav", "./end.wav"]
|
||||
|
||||
for (const sound of sounds) {
|
||||
const playback = await player.play(sound)
|
||||
await playback.finished()
|
||||
}
|
||||
```
|
||||
|
||||
### Background Music Loop
|
||||
|
||||
```typescript
|
||||
import Buzz from "./buzz"
|
||||
|
||||
const player = await Buzz.player()
|
||||
|
||||
// Play background music on repeat
|
||||
const bgMusic = await player.play("./background.wav", { repeat: true })
|
||||
|
||||
// Stop after 30 seconds
|
||||
await Bun.sleep(30000)
|
||||
await bgMusic.stop()
|
||||
```
|
||||
|
||||
### Record to File
|
||||
|
||||
```typescript
|
||||
import Buzz from "./buzz"
|
||||
|
||||
const recorder = await Buzz.recorder()
|
||||
|
||||
console.log("Recording for 10 seconds...")
|
||||
const recording = recorder.start("./output.wav")
|
||||
|
||||
await Bun.sleep(10000)
|
||||
await recording.stop()
|
||||
|
||||
console.log("Saved to output.wav")
|
||||
```
|
||||
|
||||
### Multi-Device Setup
|
||||
|
||||
```typescript
|
||||
import Buzz from "./buzz"
|
||||
|
||||
// List all devices
|
||||
const devices = await Buzz.listDevices()
|
||||
console.log("Available devices:", devices)
|
||||
|
||||
// Use specific devices
|
||||
const speaker = await Buzz.player("Speaker")
|
||||
const mic = await Buzz.recorder("USB Microphone")
|
||||
|
||||
// Independent volume control
|
||||
await Buzz.setVolume(0.8, "Speaker")
|
||||
await Buzz.setVolume(1.0, "Headphones")
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### ALSA Backend
|
||||
|
||||
Buzz wraps ALSA command-line tools (`aplay`, `arecord`) via Bun's subprocess API:
|
||||
|
||||
- **Playback**: Spawns `aplay` with stdin pipe for streaming or file path for file playback
|
||||
- **Recording**: Spawns `arecord` with stdout pipe for streaming or file path for WAV output
|
||||
- **Volume**: Uses `amixer` for volume control
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- **Simple**: No C bindings or FFI required
|
||||
- **Reliable**: ALSA tools are battle-tested
|
||||
- **Flexible**: Full format support (sample rates, channels, encodings)
|
||||
- **Portable**: Works on any Linux system with ALSA
|
||||
|
||||
### Streaming Architecture
|
||||
|
||||
Streaming playback uses Bun's subprocess stdin pipe:
|
||||
|
||||
1. Spawn `aplay` with raw audio format and stdin input
|
||||
2. Write audio chunks to process stdin as they arrive
|
||||
3. Track buffer duration based on bytes written
|
||||
4. Calculate `bufferEmptyFor` using performance timestamps
|
||||
|
||||
This enables:
|
||||
- Real-time playback of network streams (WebSocket, API responses)
|
||||
- Buffer management for smooth playback
|
||||
- Low-latency audio (<100ms with proper buffering)
|
||||
|
||||
### Voice Activity Detection
|
||||
|
||||
`calculateRMS()` computes the root mean square of audio samples:
|
||||
|
||||
```
|
||||
RMS = sqrt(sum(sample²) / count)
|
||||
```
|
||||
|
||||
This provides a simple but effective measure of audio energy:
|
||||
- Silence: RMS < 1000
|
||||
- Noise: RMS 1000-5000
|
||||
- Speech: RMS > 5000
|
||||
|
||||
Adjust thresholds based on your microphone and environment.
|
||||
|
||||
## Device Selection
|
||||
|
||||
### By Default (Recommended)
|
||||
|
||||
```typescript
|
||||
const player = await Buzz.player()
|
||||
const recorder = await Buzz.recorder()
|
||||
```
|
||||
|
||||
Uses ALSA default device (usually correct).
|
||||
|
||||
### By Label
|
||||
|
||||
```typescript
|
||||
const devices = await Buzz.listDevices()
|
||||
// Find device with label containing "USB"
|
||||
const usbDevice = devices.find(d => d.label.includes("USB"))
|
||||
|
||||
const player = await Buzz.player(usbDevice.label)
|
||||
```
|
||||
|
||||
Useful for multi-device setups (USB audio, HDMI, headphones).
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const player = await Buzz.player()
|
||||
} catch (err) {
|
||||
if (err.message.includes("No playback devices found")) {
|
||||
console.error("No audio output devices available")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Buzz.setVolume(0.5)
|
||||
} catch (err) {
|
||||
if (err.message.includes("Failed to set volume")) {
|
||||
console.error("Could not control volume (check mixer permissions)")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No devices found
|
||||
|
||||
Check ALSA devices:
|
||||
|
||||
```bash
|
||||
aplay -l # list playback devices
|
||||
arecord -l # list capture devices
|
||||
```
|
||||
|
||||
### Volume control fails
|
||||
|
||||
Check mixer controls:
|
||||
|
||||
```bash
|
||||
amixer scontrols
|
||||
amixer sget Master
|
||||
```
|
||||
|
||||
### Crackling or distortion
|
||||
|
||||
Try different buffer sizes by adjusting format:
|
||||
|
||||
```typescript
|
||||
const player = await Buzz.player(undefined, {
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
format: "S16_LE"
|
||||
})
|
||||
```
|
||||
|
||||
### Device already in use
|
||||
|
||||
Only one process can use an ALSA device at a time. Stop other audio applications or use PulseAudio/PipeWire for mixing.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
- **Simple by default** - `player()` and `recorder()` work out of the box without arguments
|
||||
- **Streaming-first** - Built for real-time audio (AI voice, telephony, WebRTC)
|
||||
- **Type-safe** - Namespace types provide autocomplete and compile-time safety
|
||||
- **Flexible** - Support for files, tones, and streams
|
||||
- **Minimal dependencies** - Uses standard ALSA tools, no native bindings
|
||||
|
||||
## Performance
|
||||
|
||||
- **Latency**: ~50-100ms for streaming playback (depends on buffering)
|
||||
- **CPU**: Minimal overhead (subprocess spawning + pipe I/O)
|
||||
- **Memory**: Efficient streaming (no need to load entire files)
|
||||
- **Voice detection**: `calculateRMS()` is fast (~1µs per chunk on modern hardware)
|
||||
|
||||
## References
|
||||
|
||||
- [ALSA documentation](https://www.alsa-project.org/wiki/Main_Page)
|
||||
- [Bun subprocess API](https://bun.sh/docs/api/spawn)
|
||||
- [Audio sample formats](https://en.wikipedia.org/wiki/Audio_bit_depth)
|
||||
|
|
@ -1,21 +1,20 @@
|
|||
import { Player as PlayerClass } from "./player.js"
|
||||
import { Recorder as RecorderClass } from "./recorder.js"
|
||||
import { Player } from "./player.js"
|
||||
import { Recorder } from "./recorder.js"
|
||||
import {
|
||||
listDevices,
|
||||
calculateRMS,
|
||||
findDeviceByLabel,
|
||||
type AudioFormat as AudioFormatType,
|
||||
type Device as DeviceType,
|
||||
type Playback as PlaybackType,
|
||||
type StreamingPlayback as StreamingPlaybackType,
|
||||
type StreamingRecording as StreamingRecordingType,
|
||||
type FileRecording as FileRecordingType,
|
||||
type AudioFormat,
|
||||
type Device,
|
||||
} from "./utils.js"
|
||||
|
||||
const player = (label?: string, format?: AudioFormatType) => PlayerClass.create({ label, format })
|
||||
const defaultPlayer = (format?: AudioFormat) => Player.create({ format })
|
||||
|
||||
const recorder = (label?: string, format?: AudioFormatType) =>
|
||||
RecorderClass.create({ label, format })
|
||||
const player = (label: string, format?: AudioFormat) => Player.create({ label, format })
|
||||
|
||||
const defaultRecorder = (format?: AudioFormat) => Recorder.create({ format })
|
||||
|
||||
const recorder = (label: string, format?: AudioFormat) => Recorder.create({ label, format })
|
||||
|
||||
const getVolumeControl = async (cardNumber?: number): Promise<string> => {
|
||||
const output = cardNumber
|
||||
|
|
@ -81,20 +80,16 @@ const getVolume = async (label?: string): Promise<number> => {
|
|||
|
||||
const Buzz = {
|
||||
listDevices,
|
||||
defaultPlayer,
|
||||
player,
|
||||
defaultRecorder,
|
||||
recorder,
|
||||
setVolume,
|
||||
getVolume,
|
||||
calculateRMS,
|
||||
}
|
||||
|
||||
declare namespace Buzz {
|
||||
export type Playback = PlaybackType
|
||||
export type StreamingPlayback = StreamingPlaybackType
|
||||
export type StreamingRecording = StreamingRecordingType
|
||||
export type FileRecording = FileRecordingType
|
||||
export type Player = PlayerClass
|
||||
export type Recorder = RecorderClass
|
||||
}
|
||||
|
||||
export default Buzz
|
||||
export type { Device, AudioFormat }
|
||||
export { type Player } from "./player.js"
|
||||
export { type Recorder } from "./recorder.js"
|
||||
|
|
|
|||
47
src/hq.ts
Normal file
47
src/hq.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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!`)
|
||||
26
src/main.ts
26
src/main.ts
|
|
@ -1,26 +0,0 @@
|
|||
import { runPhone } from "./phone"
|
||||
|
||||
const apiKey = process.env.ELEVEN_API_KEY
|
||||
const agentId = process.env.ELEVEN_AGENT_ID
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("❌ Error: ELEVEN_API_KEY environment variable is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
console.error(
|
||||
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required",
|
||||
)
|
||||
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`☎️ Starting phone with pid=${process.pid}`)
|
||||
try {
|
||||
await runPhone(agentId, apiKey)
|
||||
} catch (error) {
|
||||
console.error(`❌ Error starting phone: ${(error as Error).message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`👋 Goodbye!`)
|
||||
869
src/phone.ts
869
src/phone.ts
|
|
@ -1,24 +1,13 @@
|
|||
import {
|
||||
d,
|
||||
reduce,
|
||||
createMachine,
|
||||
state,
|
||||
transition,
|
||||
interpret,
|
||||
action,
|
||||
invoke,
|
||||
type Service,
|
||||
} from "robot3"
|
||||
import { d, reduce, createMachine, state, transition, interpret, guard } from "robot3"
|
||||
import { Baresip } from "./sip"
|
||||
import log from "./utils/log"
|
||||
import { log } from "./utils/log"
|
||||
import { sleep } from "bun"
|
||||
import { processStderr, processStdout } from "./utils/stdio"
|
||||
import Buzz from "./buzz"
|
||||
import { join } from "path"
|
||||
import GPIO from "./pins"
|
||||
import { GPIO } from "./pins"
|
||||
import { Agent } from "./agent"
|
||||
import { searchWeb } from "./agent/tools"
|
||||
import { ring } from "./utils"
|
||||
import { getSound, WaitingSounds } from "./utils/waiting-sounds"
|
||||
|
||||
type CancelableTask = () => void
|
||||
|
||||
|
|
@ -26,501 +15,431 @@ type PhoneContext = {
|
|||
lastError?: string
|
||||
peer?: string
|
||||
numberDialed: number
|
||||
cancelDialTone?: CancelableTask
|
||||
cancelRinger?: CancelableTask
|
||||
baresip: Baresip
|
||||
stopAgent?: CancelableTask
|
||||
ringer: GPIO.Output
|
||||
agentId: string
|
||||
agentKey: string
|
||||
dialFailureReason?: string
|
||||
startAgent: () => CancelableTask
|
||||
cancelAgent?: CancelableTask
|
||||
}
|
||||
|
||||
type PhoneService = Service<typeof phoneMachine>
|
||||
const gpio = new GPIO({ resetOnClose: true })
|
||||
using ringer = gpio.output(17)
|
||||
using inputs = gpio.inputGroup({
|
||||
hook: { pin: 27, debounce: 50 },
|
||||
rotaryInUse: { pin: 22, debounce: 50 },
|
||||
rotaryNumber: { pin: 23, debounce: 10 },
|
||||
})
|
||||
|
||||
const player = await Buzz.player()
|
||||
let dialTonePlayback: Buzz.Playback | undefined
|
||||
|
||||
export const runPhone = async (agentId: string, agentKey: string) => {
|
||||
const gpio = new GPIO()
|
||||
using ringer = gpio.output(17, { resetOnClose: true })
|
||||
using hook = gpio.input(27, { pull: "up", debounce: 3 })
|
||||
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
|
||||
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
||||
|
||||
await Buzz.setVolume(0.3)
|
||||
log(`📞 Phone is ${hook.value ? "off hook" : "on hook"}`)
|
||||
|
||||
playStartRing(ringer)
|
||||
|
||||
const phoneService = interpret(phoneMachine, () => {})
|
||||
listenForPhoneEvents(phoneService, hook, rotaryInUse, rotaryNumber)
|
||||
const baresip = await startBaresip(phoneService, hook, ringer)
|
||||
phoneService.send({ type: "config", baresip, agentId, agentKey, ringer })
|
||||
|
||||
process.on("SIGINT", () => cleanup(baresip, ringer))
|
||||
process.on("SIGTERM", () => cleanup(baresip, ringer))
|
||||
|
||||
// Keep process running
|
||||
await new Promise(() => {})
|
||||
export const startPhone = async (agentId: string, apiKey: string) => {
|
||||
await Buzz.setVolume(0.4)
|
||||
log.info(`📞 Hook ${inputs.pins.hook.value}`)
|
||||
await handleInputEvents()
|
||||
}
|
||||
|
||||
const listenForPhoneEvents = (
|
||||
phoneService: PhoneService,
|
||||
hook: GPIO.Input,
|
||||
rotaryInUse: GPIO.Input,
|
||||
rotaryNumber: GPIO.Input,
|
||||
) => {
|
||||
hook.onChange((event) => {
|
||||
const type = event.value == 0 ? "hang-up" : "pick-up"
|
||||
log(`📞 Hook ${event.value} sending ${type}`)
|
||||
phoneService.send({ type })
|
||||
})
|
||||
|
||||
rotaryInUse.onChange((event) => {
|
||||
if (event.value === 0) {
|
||||
phoneService.send({ type: "dial-start" } as any)
|
||||
} else {
|
||||
phoneService.send({ type: "dial-stop" })
|
||||
}
|
||||
})
|
||||
|
||||
rotaryNumber.onChange((event) => {
|
||||
if (event.value === 1) {
|
||||
phoneService.send({ type: "digit_increment" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer: GPIO.Output) => {
|
||||
const baresipConfig = join(import.meta.dir, "..", "baresip")
|
||||
const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
|
||||
|
||||
baresip.registrationSuccess.on(async () => {
|
||||
log("🐻 server connected")
|
||||
if (hook.value === 0) {
|
||||
phoneService.send({ type: "initialized" })
|
||||
} else {
|
||||
phoneService.send({ type: "pick-up" })
|
||||
}
|
||||
})
|
||||
|
||||
baresip.callReceived.on(({ contact }) => {
|
||||
log(`🐻 incoming call from ${contact}`)
|
||||
phoneService.send({ type: "incoming-call", from: contact })
|
||||
})
|
||||
|
||||
baresip.callEstablished.on(({ contact }) => {
|
||||
log(`🐻 call established with ${contact}`)
|
||||
phoneService.send({ type: "answered" } as any)
|
||||
})
|
||||
|
||||
baresip.hungUp.on(() => {
|
||||
log("🐻 call hung up")
|
||||
phoneService.send({ type: "remote-hang-up" })
|
||||
})
|
||||
|
||||
baresip.dialFailed.on(({ reason }) => {
|
||||
log.error("🐻 dial failed:", reason)
|
||||
phoneService.send({ type: "dial-failed", reason } as any)
|
||||
})
|
||||
|
||||
baresip.connect().catch((error) => {
|
||||
log.error("🐻 connection error:", error)
|
||||
phoneService.send({ type: "error", message: error.message })
|
||||
})
|
||||
|
||||
baresip.error.on(async ({ message, statusCode, reason }) => {
|
||||
const errorMessage = statusCode ? `Registration failed: ${statusCode} ${reason}` : message
|
||||
|
||||
log.error("🐻 error:", errorMessage)
|
||||
// Don't send error to state machine - we're retrying, not giving up
|
||||
|
||||
log("🔄 Retrying registration in 2 minutes...")
|
||||
await sleep(2 * 60 * 1000)
|
||||
baresip.restart()
|
||||
})
|
||||
|
||||
return baresip
|
||||
}
|
||||
|
||||
const cleanup = (baresip: Baresip, ringer: GPIO.Output) => {
|
||||
try {
|
||||
log("🛑 Shutting down, stopping agent process")
|
||||
playExitRing(ringer)
|
||||
baresip.kill()
|
||||
} catch (error) {
|
||||
log.error("Error during shutdown:", error)
|
||||
} finally {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => {
|
||||
ctx.lastError = event.message
|
||||
log.error(`Phone Error: ${event.message}`)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const config = (
|
||||
ctx: PhoneContext,
|
||||
event: { baresip: Baresip; agentId: string; agentKey: string; ringer: GPIO.Output },
|
||||
) => {
|
||||
ctx.baresip = event.baresip
|
||||
ctx.agentId = event.agentId
|
||||
ctx.agentKey = event.agentKey
|
||||
ctx.ringer = event.ringer
|
||||
return ctx
|
||||
}
|
||||
|
||||
const startAgent = async (service: Service<typeof phoneMachine>, ctx: PhoneContext, hasDialFailure = false) => {
|
||||
let streamPlayback = player.playStream()
|
||||
|
||||
const agent = new Agent({
|
||||
agentId: ctx.agentId,
|
||||
apiKey: ctx.agentKey,
|
||||
tools: {
|
||||
search_web: (args: { query: string }) => searchWeb(args.query),
|
||||
},
|
||||
})
|
||||
|
||||
handleAgentEvents(service, agent, streamPlayback)
|
||||
|
||||
const stopListening = hasDialFailure
|
||||
? await startListeningAfterDialFailure(agent, ctx.dialFailureReason)
|
||||
: startListening(service, agent)
|
||||
|
||||
ctx.stopAgent = () => {
|
||||
stopListening()
|
||||
dialTonePlayback?.stop()
|
||||
streamPlayback.stop()
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
function startListening(service: Service<typeof phoneMachine>, agent: Agent) {
|
||||
const abortAgent = new AbortController()
|
||||
|
||||
new Promise<void>(async (resolve) => {
|
||||
const recorder = await Buzz.recorder()
|
||||
const listenPlayback = recorder.start()
|
||||
let backgroundNoisePlayback: Buzz.Playback | undefined
|
||||
let waitingForVoice = true
|
||||
const maxPreBufferChunks = 4 // Keep ~1 second of audio before speech detection
|
||||
|
||||
let preConnectionBuffer: Uint8Array[] = []
|
||||
|
||||
agent.events.on(async (event) => {
|
||||
if (event.type === "disconnected") abortAgent.abort()
|
||||
})
|
||||
|
||||
for await (const chunk of listenPlayback.stream()) {
|
||||
if (abortAgent.signal.aborted) {
|
||||
agent.stop()
|
||||
listenPlayback.stop()
|
||||
backgroundNoisePlayback?.stop()
|
||||
|
||||
resolve()
|
||||
break
|
||||
}
|
||||
|
||||
if (waitingForVoice) {
|
||||
preConnectionBuffer.push(chunk)
|
||||
if (preConnectionBuffer.length > maxPreBufferChunks) {
|
||||
preConnectionBuffer.shift()
|
||||
const handleInputEvents = async () => {
|
||||
let digit = 0
|
||||
for await (const event of inputs.events()) {
|
||||
switch (event.pin) {
|
||||
case "hook":
|
||||
const type = event.value == 0 ? "hang_up" : "pick_up"
|
||||
log.info(`📞 Hook ${event.value} sending ${type}`)
|
||||
if (type === "hang_up") {
|
||||
ringer.value = 1
|
||||
} else {
|
||||
ringer.value = 0
|
||||
}
|
||||
break
|
||||
|
||||
const rms = Buzz.calculateRMS(chunk)
|
||||
if (rms > 5000) {
|
||||
dialTonePlayback?.stop()
|
||||
service.send({ type: "start-agent" } as any)
|
||||
waitingForVoice = false
|
||||
|
||||
backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true })
|
||||
|
||||
await agent.start()
|
||||
|
||||
// Send pre-buffered audio
|
||||
for (const chunk of preConnectionBuffer) agent.sendAudio(chunk)
|
||||
preConnectionBuffer = []
|
||||
case "rotaryInUse":
|
||||
if (event.value === 0) {
|
||||
digit = 0
|
||||
} else {
|
||||
log.info(`📞 Dialed digit: ${digit}`)
|
||||
}
|
||||
} else {
|
||||
agent.sendAudio(chunk)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => abortAgent.abort()
|
||||
}
|
||||
|
||||
async function startListeningAfterDialFailure(agent: Agent, dialFailureReason?: string) {
|
||||
const abortAgent = new AbortController()
|
||||
const recorder = await Buzz.recorder()
|
||||
const listenPlayback = recorder.start()
|
||||
|
||||
const message = getFriendlyErrorMessage(dialFailureReason)
|
||||
agent.events.on((event) => {
|
||||
if (event.type === "connected") agent.sendMessage(`[SYSTEM: ${message}]`)
|
||||
if (event.type === "disconnected") abortAgent.abort()
|
||||
})
|
||||
|
||||
const backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true })
|
||||
await agent.start()
|
||||
|
||||
streamAudioToAgent(agent, listenPlayback, backgroundNoisePlayback, abortAgent.signal)
|
||||
|
||||
return () => abortAgent.abort()
|
||||
}
|
||||
|
||||
async function streamAudioToAgent(
|
||||
agent: Agent,
|
||||
listenPlayback: Buzz.StreamingRecording,
|
||||
backgroundNoisePlayback: Buzz.Playback | undefined,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
for await (const chunk of listenPlayback.stream()) {
|
||||
if (signal.aborted) {
|
||||
agent.stop()
|
||||
listenPlayback.stop()
|
||||
backgroundNoisePlayback?.stop()
|
||||
break
|
||||
}
|
||||
agent.sendAudio(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAgentEvents = (
|
||||
service: Service<typeof phoneMachine>,
|
||||
agent: Agent,
|
||||
streamPlayback: Buzz.StreamingPlayback,
|
||||
) => {
|
||||
const waitingIndicator = new WaitingSounds(player)
|
||||
|
||||
agent.events.on(async (event) => {
|
||||
switch (event.type) {
|
||||
case "connected":
|
||||
log("🤖 Connected to AI agent\n")
|
||||
break
|
||||
|
||||
case "user_transcript":
|
||||
log(`🤖 You: ${event.transcript}`)
|
||||
break
|
||||
|
||||
case "agent_response":
|
||||
log(`🤖 Agent: ${event.response}`)
|
||||
break
|
||||
|
||||
case "audio":
|
||||
await waitingIndicator.stop()
|
||||
const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
||||
streamPlayback.write(audioBuffer)
|
||||
break
|
||||
|
||||
case "interruption":
|
||||
log("🤖 User interrupted")
|
||||
await waitingIndicator.stop()
|
||||
streamPlayback?.stop()
|
||||
streamPlayback = player.playStream() // Reset playback stream
|
||||
break
|
||||
|
||||
case "tool_call":
|
||||
waitingIndicator.start(streamPlayback)
|
||||
log(`🤖 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
|
||||
break
|
||||
|
||||
case "tool_result":
|
||||
log(`🤖 Tool result: ${JSON.stringify(event.result)}`)
|
||||
break
|
||||
|
||||
case "tool_error":
|
||||
console.error(`❌ Tool error: ${event.error}`)
|
||||
break
|
||||
|
||||
case "disconnected":
|
||||
log(`🤖 👋 Conversation ended, returning to dialtone`)
|
||||
streamPlayback?.stop()
|
||||
service.send({ type: "remote-hang-up" })
|
||||
break
|
||||
|
||||
case "error":
|
||||
log.error("🤖 Agent error:", event.error)
|
||||
break
|
||||
|
||||
case "ping":
|
||||
case "rotaryNumber":
|
||||
if (event.value === 1) {
|
||||
digit += 1
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
log.debug(`😵 Unknown agent event ${event.type}`)
|
||||
log.error(`📞 Unknown pin event: ${event.pin}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const stopAgent = (ctx: PhoneContext) => {
|
||||
ctx.stopAgent?.()
|
||||
ctx.stopAgent = undefined
|
||||
return ctx
|
||||
}
|
||||
|
||||
const incomingCall = (ctx: PhoneContext, event: { type: "incoming-call"; from?: string }) => {
|
||||
ctx.peer = event.from
|
||||
return ctx
|
||||
}
|
||||
|
||||
const hangUp = (ctx: PhoneContext) => {
|
||||
console.log(`📞 Hanging up call`)
|
||||
ctx.baresip.hangUp()
|
||||
}
|
||||
|
||||
const answerCall = (ctx: PhoneContext) => {
|
||||
log(`📞 Answering call`)
|
||||
ctx.baresip.accept()
|
||||
}
|
||||
|
||||
const storeDialFailure = (ctx: PhoneContext, event: { reason?: string }) => {
|
||||
ctx.dialFailureReason = event.reason
|
||||
return ctx
|
||||
}
|
||||
|
||||
const clearDialFailure = (ctx: PhoneContext) => {
|
||||
ctx.dialFailureReason = undefined
|
||||
return ctx
|
||||
}
|
||||
|
||||
function getFriendlyErrorMessage(rawReason?: string): string {
|
||||
if (rawReason?.includes("Not registered")) {
|
||||
return "The user's call failed. To fix it, they should contact Corey IRL."
|
||||
}
|
||||
|
||||
return "The user's call failed. Twilio isn't working. To fix it, they should contact Corey IRL."
|
||||
}
|
||||
|
||||
const makeCall = async (ctx: PhoneContext) => {
|
||||
log(`Dialing number: ${ctx.numberDialed}`)
|
||||
if (ctx.numberDialed === 1) {
|
||||
ctx.baresip.dial("+13476229543")
|
||||
} else if (ctx.numberDialed === 2) {
|
||||
ctx.baresip.dial("+18109643563")
|
||||
} else {
|
||||
log.error(`No contact for number dialed: ${ctx.numberDialed}`)
|
||||
}
|
||||
const apiKey = process.env.ELEVEN_API_KEY
|
||||
const agentId = process.env.ELEVEN_AGENT_ID
|
||||
|
||||
return ctx
|
||||
if (!apiKey) {
|
||||
console.error("❌ Error: ELEVEN_API_KEY environment variable is required")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const startRinger = async (ctx: PhoneContext) => {
|
||||
let abortController = new AbortController()
|
||||
const keepRinging = async () => {
|
||||
while (!abortController.signal.aborted) {
|
||||
await ring(ctx.ringer, 2000, abortController.signal)
|
||||
await sleep(4000)
|
||||
}
|
||||
}
|
||||
keepRinging().catch((error) => log.error("Ringer error:", error))
|
||||
|
||||
ctx.cancelRinger = () => abortController.abort()
|
||||
|
||||
return ctx
|
||||
if (!agentId) {
|
||||
console.error(
|
||||
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required"
|
||||
)
|
||||
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const stopRinger = (ctx: PhoneContext) => {
|
||||
ctx.cancelRinger?.()
|
||||
ctx.cancelRinger = undefined
|
||||
return ctx
|
||||
}
|
||||
await startPhone(agentId, apiKey)
|
||||
|
||||
async function startDialToneAndAgent(this: any, ctx: PhoneContext) {
|
||||
const hasDialFailure = !!ctx.dialFailureReason
|
||||
ctx = await startAgent(this, ctx, hasDialFailure)
|
||||
// log.info("📞 GPIO inputs initialized")
|
||||
|
||||
if (!hasDialFailure) {
|
||||
await dialTonePlayback?.stop()
|
||||
dialTonePlayback = await player.playTone([350, 440], Infinity)
|
||||
}
|
||||
// // const baresipConfig = join(import.meta.dir, "..", "baresip")
|
||||
// // const baresip = new Baresip(["/usr/bin/baresip", "-v", "-f", baresipConfig])
|
||||
|
||||
return ctx
|
||||
}
|
||||
// // baresip.registrationSuccess.connect(async () => {
|
||||
// // log.info("🐻 server connected")
|
||||
// // const result = await gpio.get(pins.hook)
|
||||
// // if (result.state === "low") {
|
||||
// // phoneService.send({ type: "initialized" })
|
||||
// // } else {
|
||||
// // phoneService.send({ type: "pick_up" })
|
||||
// // }
|
||||
// // })
|
||||
|
||||
const stopDialTone = () => {
|
||||
dialTonePlayback?.stop()
|
||||
}
|
||||
// // baresip.callReceived.connect(({ contact }) => {
|
||||
// // log.info(`🐻 incoming call from ${contact}`)
|
||||
// // phoneService.send({ type: "incoming_call", from: contact })
|
||||
// // })
|
||||
|
||||
const dialStart = (ctx: PhoneContext) => {
|
||||
ctx.numberDialed = 0
|
||||
return ctx
|
||||
}
|
||||
// // baresip.callEstablished.connect(({ contact }) => {
|
||||
// // log.info(`🐻 call established with ${contact}`)
|
||||
// // phoneService.send({ type: "answered" })
|
||||
// // })
|
||||
|
||||
const digitIncrement = (ctx: PhoneContext) => {
|
||||
ctx.numberDialed += 1
|
||||
return ctx
|
||||
}
|
||||
// // baresip.hungUp.connect(() => {
|
||||
// // log.info("🐻 call hung up")
|
||||
// // phoneService.send({ type: "remote_hang_up" })
|
||||
// // })
|
||||
|
||||
const playStartRing = async (ringer: GPIO.Output) => {
|
||||
// Three quick beeps, getting faster = energetic/welcoming
|
||||
ringer.value = 1
|
||||
await Bun.sleep(80)
|
||||
ringer.value = 0
|
||||
await Bun.sleep(120)
|
||||
// // baresip.connect().catch((error) => {
|
||||
// // log.error("🐻 connection error:", error)
|
||||
// // phoneService.send({ type: "error", message: error.message })
|
||||
// // })
|
||||
|
||||
ringer.value = 1
|
||||
await Bun.sleep(80)
|
||||
ringer.value = 0
|
||||
await Bun.sleep(100)
|
||||
// // baresip.error.connect(async ({ message }) => {
|
||||
// // log.error("🐻 error:", message)
|
||||
// // phoneService.send({ type: "error", message })
|
||||
// // for (let i = 0; i < 4; i++) {
|
||||
// // await ring(500)
|
||||
// // await sleep(250)
|
||||
// // }
|
||||
// // process.exit(1)
|
||||
// // })
|
||||
|
||||
ringer.value = 1
|
||||
await Bun.sleep(80)
|
||||
ringer.value = 0
|
||||
}
|
||||
// const agent = new Agent({
|
||||
// agentId,
|
||||
// apiKey,
|
||||
// tools: {
|
||||
// search_web: (args: { query: string }) => searchWeb(args.query),
|
||||
// },
|
||||
// })
|
||||
|
||||
const playExitRing = async (ringer: GPIO.Output) => {
|
||||
ringer.value = 0 // Always try and turn it off!
|
||||
}
|
||||
// handleAgentEvents(agent)
|
||||
|
||||
const t = transition
|
||||
const r = reduce
|
||||
const a = action
|
||||
// const startAgent = () => {
|
||||
// log.info("☎️ Starting agent conversation")
|
||||
|
||||
const phoneMachine = createMachine(
|
||||
"booting",
|
||||
// prettier-ignore
|
||||
{
|
||||
booting: state(
|
||||
t("config", "initializing", r(config))
|
||||
),
|
||||
initializing: state(
|
||||
t("initialized", "idle"),
|
||||
t("pick-up", "ready"),
|
||||
t("error", "fault", r(handleError))),
|
||||
idle: state(
|
||||
t("incoming-call", "incoming", r(incomingCall)),
|
||||
t("pick-up", "ready")),
|
||||
incoming: invoke(startRinger,
|
||||
t("remote-hang-up", "idle", r(stopRinger)),
|
||||
t("pick-up", "connected", r(stopRinger), a(answerCall))),
|
||||
connected: state(
|
||||
t("remote-hang-up", "ready"),
|
||||
t("hang-up", "idle", a(hangUp))),
|
||||
ready: invoke(startDialToneAndAgent,
|
||||
t("dial-start", "dialing", a(stopDialTone), r(dialStart), r(clearDialFailure), a(stopAgent)),
|
||||
t("hang-up", "idle", a(stopDialTone), r(clearDialFailure), a(stopAgent)),
|
||||
t("start-agent", "connectToAgent", a(stopDialTone), r(clearDialFailure))),
|
||||
connectToAgent: state(
|
||||
t("hang-up", "idle", r(stopAgent), r(clearDialFailure)),
|
||||
t("remote-hang-up", "ready", r(stopAgent), r(clearDialFailure))),
|
||||
dialing: state(
|
||||
t("dial-stop", "outgoing"),
|
||||
t("digit_increment", "dialing", r(digitIncrement)),
|
||||
t("hang-up", "idle")),
|
||||
outgoing: invoke(makeCall,
|
||||
t("answered", "connected"),
|
||||
t("dial-failed", "ready", r(storeDialFailure)),
|
||||
t("hang-up", "idle", a(hangUp))),
|
||||
aborted: state(
|
||||
t("hang-up", "idle")),
|
||||
fault: state(),
|
||||
},
|
||||
)
|
||||
// if (agentProcess?.stdin) {
|
||||
// agentProcess.stdin.write("start\n")
|
||||
// } else {
|
||||
// log.error("☎️ No agent process stdin available")
|
||||
// phoneService.send({ type: "remote_hang_up" })
|
||||
// }
|
||||
|
||||
d._onEnter = function (machine, to, state, prevState, event) {
|
||||
log(`📱 ${machine.current} -> ${to} (${(event as any).type})`)
|
||||
}
|
||||
// return () => {
|
||||
// log.info("☎️ Stopping agent conversation")
|
||||
// if (agentProcess?.stdin) {
|
||||
// agentProcess.stdin.write("stop\n")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const context = (initial?: Partial<PhoneContext>): PhoneContext => ({
|
||||
// numberDialed: 0,
|
||||
// baresip,
|
||||
// startAgent,
|
||||
// ...initial,
|
||||
// })
|
||||
|
||||
// const phoneMachine = createMachine(
|
||||
// "initializing",
|
||||
// // prettier-ignore
|
||||
// {
|
||||
// initializing: state(
|
||||
// transition("initialized", "idle"),
|
||||
// transition("pick_up", "ready", reduce(playDialTone)),
|
||||
// transition("error", "fault", reduce(handleError))),
|
||||
// idle: state(
|
||||
// transition("incoming_call", "incoming", reduce(incomingCall)),
|
||||
// transition("pick_up", "ready", reduce(playDialTone))),
|
||||
// incoming: state(
|
||||
// transition("remote_hang_up", "idle", reduce(stopRinger)),
|
||||
// transition("pick_up", "connected", reduce(callAnswered))),
|
||||
// connected: state(
|
||||
// transition("remote_hang_up", "ready", reduce(playDialTone)),
|
||||
// transition("hang_up", "idle", reduce(stopCall))),
|
||||
// ready: state(
|
||||
// transition("dial_start", "dialing", reduce(dialStart)),
|
||||
// transition("dial_timeout", "aborted", reduce(stopDialTone)),
|
||||
// transition("hang_up", "idle", reduce(stopDialTone))),
|
||||
// dialing: state(
|
||||
// transition("dial_stop", "outgoing", reduce(makeCall), guard((ctx) => !callAgentGuard(ctx))),
|
||||
// transition("dial_stop", "connectedToAgent", reduce(makeAgentCall), guard((ctx) => callAgentGuard(ctx))),
|
||||
// transition("digit_increment", "dialing", reduce(digitIncrement)),
|
||||
// transition("hang_up", "idle", reduce(stopDialTone))),
|
||||
// outgoing: state(
|
||||
// transition("start_agent", "connectedToAgent"),
|
||||
// transition("answered", "connected"),
|
||||
// transition("hang_up", "idle", reduce(stopCall))),
|
||||
// connectedToAgent: state(
|
||||
// transition("remote_hang_up", "ready", reduce(stopAgent)),
|
||||
// transition("hang_up", "idle", reduce(stopAgent))),
|
||||
// aborted: state(
|
||||
// transition("hang_up", "idle")),
|
||||
// fault: state(),
|
||||
// },
|
||||
// context
|
||||
// )
|
||||
|
||||
// const phoneService = interpret(phoneMachine, () => {})
|
||||
|
||||
// d._onEnter = function (machine, to, state, prevState, event) {
|
||||
// log.info(`📱 ${machine.current} -> ${to} (${JSON.stringify(event)})`)
|
||||
// }
|
||||
|
||||
// gpio.monitor(pins.hook, { bias: "pull-up" }, (event) => {
|
||||
// const type = event.edge === "falling" ? "hang_up" : "pick_up"
|
||||
// log.info(`📞 Hook ${event.edge} sending ${type}`)
|
||||
// phoneService.send({ type })
|
||||
// })
|
||||
|
||||
// gpio.monitor(pins.rotaryInUse, { bias: "pull-up", throttleMs: 90 }, (event) => {
|
||||
// const type = event.edge === "falling" ? "dial_start" : "dial_stop"
|
||||
// log.debug(`📞 Rotary in-use ${event.edge} sending ${type}`)
|
||||
// phoneService.send({ type })
|
||||
// })
|
||||
|
||||
// gpio.monitor(pins.rotaryNumber, { bias: "pull-up", throttleMs: 90 }, (event) => {
|
||||
// if (event.edge !== "rising") return
|
||||
// phoneService.send({ type: "digit_increment" })
|
||||
// })
|
||||
|
||||
// // Graceful shutdown handling
|
||||
// const cleanup = () => {
|
||||
// log.info("🛑 Shutting down, stopping agent process")
|
||||
// if (agentProcess?.stdin) {
|
||||
// agentProcess.stdin.write("quit\n")
|
||||
// }
|
||||
// }
|
||||
|
||||
// process.on("SIGINT", cleanup)
|
||||
// process.on("SIGTERM", cleanup)
|
||||
// process.on("exit", cleanup)
|
||||
// }
|
||||
|
||||
// const handleAgentEvents = (agent: Agent) => {
|
||||
// agent.events.connect(async (event) => {
|
||||
// switch (event.type) {
|
||||
// case "connected":
|
||||
// console.log("✅ Connected to AI agent\n")
|
||||
// break
|
||||
|
||||
// case "user_transcript":
|
||||
// console.log(`👤 You: ${event.transcript}`)
|
||||
// break
|
||||
|
||||
// case "agent_response":
|
||||
// console.log(`🤖 Agent: ${event.response}`)
|
||||
// break
|
||||
|
||||
// case "audio":
|
||||
// await waitingIndicator.stop()
|
||||
// const audioBuffer = Buffer.from(event.audioBase64, "base64")
|
||||
// streamPlayback.write(audioBuffer)
|
||||
// break
|
||||
|
||||
// case "interruption":
|
||||
// console.log("🛑 User interrupted")
|
||||
// streamPlayback?.stop()
|
||||
// streamPlayback = player.playStream() // Reset playback stream
|
||||
// break
|
||||
|
||||
// case "tool_call":
|
||||
// waitingIndicator.start(streamPlayback)
|
||||
// console.log(`🔧 Tool call: ${event.name}(${JSON.stringify(event.args)})`)
|
||||
// break
|
||||
|
||||
// case "tool_result":
|
||||
// console.log(`✅ Tool result: ${JSON.stringify(event.result)}`)
|
||||
// break
|
||||
|
||||
// case "tool_error":
|
||||
// console.error(`❌ Tool error: ${event.error}`)
|
||||
// break
|
||||
|
||||
// case "disconnected":
|
||||
// console.log("\n👋 Conversation ended, returning to dialtone\n")
|
||||
// streamPlayback?.stop()
|
||||
// state = "WAITING_FOR_VOICE"
|
||||
// phoneService.send({ type: "remote_hang_up" })
|
||||
// break
|
||||
|
||||
// case "error":
|
||||
// console.error("Agent error:", event.error)
|
||||
// break
|
||||
|
||||
// case "ping":
|
||||
// break
|
||||
|
||||
// default:
|
||||
// console.log(`😵💫 ${event.type}`)
|
||||
// break
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// const incomingCallRing = (): CancelableTask => {
|
||||
// let abortController = new AbortController()
|
||||
|
||||
// const playRingtone = async () => {
|
||||
// while (!abortController.signal.aborted) {
|
||||
// await ring(2000, abortController.signal)
|
||||
// await sleep(4000)
|
||||
// }
|
||||
// }
|
||||
// playRingtone().catch((error) => log.error("Ringer error:", error))
|
||||
|
||||
// return () => abortController.abort()
|
||||
// }
|
||||
|
||||
// const handleError = (ctx: PhoneContext, event: { type: "error"; message?: string }) => {
|
||||
// ctx.lastError = event.message
|
||||
// log.error(`Phone error: ${event.message}`)
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const incomingCall = (ctx: PhoneContext, event: { type: "incoming_call"; from?: string }) => {
|
||||
// ctx.peer = event.from
|
||||
// ctx.cancelRinger = incomingCallRing()
|
||||
// log.info(`Incoming call from ${event.from}`)
|
||||
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const stopRinger = (ctx: PhoneContext) => {
|
||||
// ctx.cancelRinger?.()
|
||||
// ctx.cancelRinger = undefined
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const playDialTone = (ctx: PhoneContext) => {
|
||||
// const tone = new ToneGenerator()
|
||||
|
||||
// tone.loopTone([350, 440])
|
||||
|
||||
// ctx.cancelDialTone = () => {
|
||||
// tone.stop()
|
||||
// }
|
||||
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const playOutgoingTone = () => {
|
||||
// const tone = new ToneGenerator()
|
||||
// let canceled = false
|
||||
|
||||
// const play = async () => {
|
||||
// while (!canceled) {
|
||||
// await tone.playTone([440, 480], 2000)
|
||||
// await sleep(4000)
|
||||
// }
|
||||
// }
|
||||
|
||||
// play().catch((error) => log.error("Outgoing tone error:", error))
|
||||
|
||||
// return () => {
|
||||
// tone.stop()
|
||||
// canceled = true
|
||||
// }
|
||||
// }
|
||||
|
||||
// const dialStart = (ctx: PhoneContext) => {
|
||||
// ctx.numberDialed = 0
|
||||
// ctx = stopDialTone(ctx)
|
||||
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const makeCall = (ctx: PhoneContext) => {
|
||||
// log.info(`Dialing number: ${ctx.numberDialed}`)
|
||||
// if (ctx.numberDialed === 1) {
|
||||
// ctx.baresip.dial("+13476229543")
|
||||
// } else if (ctx.numberDialed === 2) {
|
||||
// ctx.baresip.dial("+18109643563")
|
||||
// } else {
|
||||
// const playTone = async () => {
|
||||
// const tone = new ToneGenerator()
|
||||
// await tone.playTone([900], 200)
|
||||
// await tone.playTone([1350], 200)
|
||||
// await tone.playTone([1750], 200)
|
||||
// }
|
||||
// playTone().catch((error) => log.error("Error playing tone:", error))
|
||||
// }
|
||||
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const makeAgentCall = (ctx: PhoneContext) => {
|
||||
// log.info(`Calling agent`)
|
||||
// ctx.cancelAgent = ctx.startAgent()
|
||||
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const callAgentGuard = (ctx: PhoneContext) => {
|
||||
// return ctx.numberDialed === 10
|
||||
// }
|
||||
|
||||
// const callAnswered = (ctx: PhoneContext) => {
|
||||
// ctx.baresip.accept()
|
||||
|
||||
// ctx.cancelDialTone?.()
|
||||
// ctx.cancelDialTone = undefined
|
||||
|
||||
// ctx.cancelRinger?.()
|
||||
// ctx.cancelRinger = undefined
|
||||
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const stopCall = (ctx: PhoneContext) => {
|
||||
// ctx.baresip.hangUp()
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const stopAgent = (ctx: PhoneContext) => {
|
||||
// log.info("🛑 Stopping agent")
|
||||
// ctx.cancelAgent?.()
|
||||
// ctx.cancelAgent = undefined
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const stopDialTone = (ctx: PhoneContext) => {
|
||||
// ctx.cancelDialTone?.()
|
||||
// ctx.cancelDialTone = undefined
|
||||
|
||||
// return ctx
|
||||
// }
|
||||
|
||||
// const digitIncrement = (ctx: PhoneContext) => {
|
||||
// ctx.numberDialed += 1
|
||||
// return ctx
|
||||
// }
|
||||
|
|
|
|||
173
src/pins/FFI-LEARNINGS.md
Normal file
173
src/pins/FFI-LEARNINGS.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# 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
|
||||
|
|
@ -4,12 +4,11 @@ High-level GPIO library for Bun using libgpiod v2 with automatic resource manage
|
|||
|
||||
## Features
|
||||
|
||||
- True event-driven GPIO with worker-based architecture (<10ms latency)
|
||||
- Zero CPU usage when idle (blocking on hardware events)
|
||||
- Type-safe TypeScript API
|
||||
- Type-safe TypeScript API with autocomplete for pin names
|
||||
- Automatic resource cleanup with `using` keyword
|
||||
- Hardware debouncing via kernel
|
||||
- Callback-based event handling with multiple listeners
|
||||
- Event-driven input handling
|
||||
- Efficient multi-pin monitoring with input groups
|
||||
- Zero external dependencies (uses Bun FFI)
|
||||
|
||||
## Requirements
|
||||
|
|
@ -37,13 +36,10 @@ for (let i = 0; i < 10; i++) {
|
|||
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
||||
console.log(button.value)
|
||||
|
||||
// Listen for button events with callback
|
||||
button.onChange((event) => {
|
||||
// Listen for button events
|
||||
for await (const event of button.events()) {
|
||||
console.log(event.value === 0 ? "Pressed!" : "Released")
|
||||
})
|
||||
|
||||
// Keep process running
|
||||
await new Promise(() => {})
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
|
@ -90,6 +86,27 @@ Options:
|
|||
- `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.
|
||||
|
|
@ -100,30 +117,22 @@ console.log(chips)
|
|||
// [{ path: '/dev/gpiochip0', name: 'pinctrl-bcm2835', label: '...', numLines: 58 }]
|
||||
```
|
||||
|
||||
### Input
|
||||
### InputPin
|
||||
|
||||
```typescript
|
||||
using button = gpio.input(20)
|
||||
|
||||
// Read current state (cached from last event)
|
||||
// Read current state
|
||||
const value: 0 | 1 = button.value
|
||||
|
||||
// Listen for changes (returns unsubscribe function)
|
||||
const unsubscribe = button.onChange((event) => {
|
||||
console.log(event.value, event.timestamp)
|
||||
})
|
||||
|
||||
// Add multiple listeners
|
||||
const unsub2 = button.onChange((event) => {
|
||||
console.log("Second listener:", event.value)
|
||||
})
|
||||
|
||||
// Remove specific listener
|
||||
unsubscribe()
|
||||
|
||||
// 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
|
||||
|
|
@ -137,23 +146,29 @@ const value = led.value
|
|||
led.toggle()
|
||||
```
|
||||
|
||||
## Architecture
|
||||
### InputGroup
|
||||
|
||||
### Worker-Based Event Handling
|
||||
```typescript
|
||||
using inputs = gpio.inputGroup({
|
||||
switch: { pin: 16, pull: "up" },
|
||||
button: { pin: 20, pull: "up", debounce: 10 }
|
||||
})
|
||||
|
||||
Each input spawns a dedicated Web Worker that:
|
||||
// Access pins with full type safety
|
||||
inputs.pins.switch.value // ✓ TypeScript autocomplete
|
||||
inputs.pins.button.value // ✓ TypeScript autocomplete
|
||||
|
||||
1. Blocks on `gpiod_line_request_wait_edge_events()` with `-1` timeout (infinite)
|
||||
2. Wakes instantly when hardware GPIO edge event occurs
|
||||
3. Reads event and posts message to main thread
|
||||
4. Main thread fires registered callbacks
|
||||
// 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
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- **True blocking**: Zero CPU usage when idle
|
||||
- **Low latency**: <10ms response time (vs 100ms with polling)
|
||||
- **Independent inputs**: Each input has its own worker
|
||||
- **Clean shutdown**: Workers terminated on close, kernel handles GPIO cleanup
|
||||
// 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
|
||||
|
||||
|
|
@ -161,24 +176,17 @@ Each input spawns a dedicated Web Worker that:
|
|||
|
||||
```typescript
|
||||
// Good - automatic cleanup
|
||||
using led = gpio.output(17) // Automatically released because of `using`
|
||||
led.value = 1
|
||||
```
|
||||
{
|
||||
using led = gpio.output(17)
|
||||
led.value = 1
|
||||
} // Automatically released
|
||||
|
||||
```typescript
|
||||
// Meh - manual cleanup required
|
||||
// Bad - manual cleanup required
|
||||
const led = gpio.output(17)
|
||||
led.value = 1
|
||||
led.close() // Must call manually
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Bad - leaks resources
|
||||
const led = gpio.output(17)
|
||||
led.value = 1
|
||||
// Forgot to close() - resource leak!
|
||||
```
|
||||
|
||||
## Hardware Setup
|
||||
|
||||
### Pull Resistors
|
||||
|
|
@ -189,7 +197,6 @@ Pull resistors prevent floating input values when nothing is connected to the pi
|
|||
- **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"`
|
||||
|
||||
|
|
@ -245,27 +252,26 @@ import { GPIO } from "@/pins"
|
|||
|
||||
const gpio = new GPIO()
|
||||
|
||||
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
||||
using switchInput = gpio.input(16, { pull: "up" })
|
||||
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
|
||||
led.value = switchInput.value
|
||||
if (inputs.pins.switch.value === 1) {
|
||||
led.value = 1
|
||||
}
|
||||
|
||||
// Toggle LED when button pressed
|
||||
button.onChange((event) => {
|
||||
if (event.value === 0) {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// Mirror switch to LED
|
||||
switchInput.onChange((event) => {
|
||||
led.value = event.value
|
||||
})
|
||||
|
||||
// Keep process running
|
||||
await new Promise(() => {})
|
||||
}
|
||||
```
|
||||
|
||||
### Rotary Phone Dialer
|
||||
|
|
@ -275,31 +281,18 @@ import { GPIO } from "@/pins"
|
|||
|
||||
const gpio = new GPIO()
|
||||
|
||||
using hook = gpio.input(27, { pull: "up", debounce: 3 })
|
||||
using rotaryInUse = gpio.input(22, { pull: "up", debounce: 3 })
|
||||
using rotaryNumber = gpio.input(23, { pull: "up", debounce: 3 })
|
||||
|
||||
let digit = 0
|
||||
|
||||
hook.onChange((event) => {
|
||||
console.log(event.value === 0 ? "Phone picked up" : "Phone hung up")
|
||||
using inputs = gpio.inputGroup({
|
||||
hook: { pin: 20, pull: "up" },
|
||||
rotary: { pin: 21, pull: "up", debounce: 1 },
|
||||
})
|
||||
|
||||
rotaryInUse.onChange((event) => {
|
||||
if (event.value === 0) {
|
||||
digit = 0
|
||||
} else {
|
||||
console.log(`Dialed digit: ${digit}`)
|
||||
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")
|
||||
}
|
||||
})
|
||||
|
||||
rotaryNumber.onChange((event) => {
|
||||
if (event.value === 1) {
|
||||
digit += 1
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise(() => {})
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
import type { Pointer } from "bun:ffi"
|
||||
import { gpiod, GPIOD_LINE_DIRECTION_INPUT, GPIOD_LINE_DIRECTION_OUTPUT } from "./ffi"
|
||||
import { cstr, mapPullToLibgpiod, mapEdgeToLibgpiod } from "./utils"
|
||||
import type { PullMode, EdgeMode } from "./types"
|
||||
|
||||
type LineRequestResult = {
|
||||
chip: Pointer
|
||||
request: Pointer
|
||||
}
|
||||
|
||||
export type InputLineConfig = {
|
||||
chipPath: string
|
||||
offset: number
|
||||
pull: PullMode
|
||||
debounce: number
|
||||
edge: EdgeMode
|
||||
}
|
||||
|
||||
export type OutputLineConfig = {
|
||||
chipPath: string
|
||||
offset: number
|
||||
initialValue: 0 | 1
|
||||
}
|
||||
|
||||
const cleanup = (message: string): never => {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const requestLine = (
|
||||
chipPath: string,
|
||||
offset: number,
|
||||
consumer: string,
|
||||
configureSettings: (settings: Pointer) => void
|
||||
): LineRequestResult => {
|
||||
const chip = gpiod.gpiod_chip_open(cstr(chipPath))
|
||||
if (!chip) cleanup("Failed to open GPIO chip")
|
||||
|
||||
const settings = gpiod.gpiod_line_settings_new()
|
||||
if (!settings) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to create line settings")
|
||||
}
|
||||
|
||||
configureSettings(settings!)
|
||||
|
||||
const lineConfig = gpiod.gpiod_line_config_new()
|
||||
if (!lineConfig) {
|
||||
gpiod.gpiod_line_settings_free(settings)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to create line config")
|
||||
}
|
||||
|
||||
const offsets = new Uint32Array([offset])
|
||||
const ret = gpiod.gpiod_line_config_add_line_settings(lineConfig, offsets, 1, settings)
|
||||
gpiod.gpiod_line_settings_free(settings)
|
||||
|
||||
if (ret !== 0) {
|
||||
gpiod.gpiod_line_config_free(lineConfig)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to add line settings")
|
||||
}
|
||||
|
||||
const requestConfig = gpiod.gpiod_request_config_new()
|
||||
if (!requestConfig) {
|
||||
gpiod.gpiod_line_config_free(lineConfig)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to create request config")
|
||||
}
|
||||
|
||||
gpiod.gpiod_request_config_set_consumer(requestConfig, cstr(consumer))
|
||||
|
||||
const request = gpiod.gpiod_chip_request_lines(chip, requestConfig, lineConfig)
|
||||
gpiod.gpiod_request_config_free(requestConfig)
|
||||
gpiod.gpiod_line_config_free(lineConfig)
|
||||
|
||||
if (!request) {
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to request GPIO line")
|
||||
}
|
||||
|
||||
return { chip: chip!, request: request! }
|
||||
}
|
||||
|
||||
export const requestInputLine = (config: InputLineConfig): LineRequestResult => {
|
||||
return requestLine(config.chipPath, config.offset, "bun-gpio-input", (settings) => {
|
||||
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_INPUT)
|
||||
gpiod.gpiod_line_settings_set_bias(settings, mapPullToLibgpiod(config.pull))
|
||||
gpiod.gpiod_line_settings_set_edge_detection(settings, mapEdgeToLibgpiod(config.edge))
|
||||
|
||||
if (config.debounce > 0) {
|
||||
gpiod.gpiod_line_settings_set_debounce_period_us(settings, config.debounce * 1000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const requestOutputLine = (config: OutputLineConfig): LineRequestResult => {
|
||||
return requestLine(config.chipPath, config.offset, "bun-gpio", (settings) => {
|
||||
gpiod.gpiod_line_settings_set_direction(settings, GPIOD_LINE_DIRECTION_OUTPUT)
|
||||
gpiod.gpiod_line_settings_set_output_value(settings, config.initialValue)
|
||||
})
|
||||
}
|
||||
199
src/pins/gpio.ts
Normal file
199
src/pins/gpio.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
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,89 +1,17 @@
|
|||
import { readdir } from "node:fs/promises"
|
||||
import { gpiod, cstr } from "./ffi"
|
||||
import { Output } from "./output"
|
||||
import { Input } from "./input"
|
||||
import type * as Type from "./types"
|
||||
import {
|
||||
export { GPIO } from "./gpio"
|
||||
export {
|
||||
GPIOError,
|
||||
PermissionError,
|
||||
PinInUseError,
|
||||
ChipNotFoundError,
|
||||
InvalidConfigError,
|
||||
} from "./errors"
|
||||
|
||||
class GPIO {
|
||||
#chipPath: string
|
||||
|
||||
constructor(options?: { chip?: string }) {
|
||||
this.#chipPath = options?.chip ?? "/dev/gpiochip0"
|
||||
}
|
||||
|
||||
output(pin: number, options?: Type.OutputOptions): Output {
|
||||
return new Output(this.#chipPath, pin, options)
|
||||
}
|
||||
|
||||
input(pin: number, options?: Type.InputOptions): Input {
|
||||
return new Input(this.#chipPath, pin, options)
|
||||
}
|
||||
|
||||
async listChips(): Promise<Type.ChipInfo[]> {
|
||||
const chips: Type.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
|
||||
}
|
||||
|
||||
static Error = GPIOError
|
||||
static PermissionError = PermissionError
|
||||
static PinInUseError = PinInUseError
|
||||
static ChipNotFoundError = ChipNotFoundError
|
||||
static InvalidConfigError = InvalidConfigError
|
||||
}
|
||||
|
||||
namespace GPIO {
|
||||
export type PullMode = Type.PullMode
|
||||
export type EdgeMode = Type.EdgeMode
|
||||
export type InputOptions = Type.InputOptions
|
||||
export type OutputOptions = Type.OutputOptions
|
||||
export type InputEvent = Type.InputEvent
|
||||
export type Input = import("./input").Input
|
||||
export type Output = import("./output").Output
|
||||
}
|
||||
|
||||
export default GPIO
|
||||
export type {
|
||||
InputOptions,
|
||||
OutputOptions,
|
||||
InputEvent,
|
||||
InputGroupEvent,
|
||||
ChipInfo,
|
||||
PullMode,
|
||||
EdgeMode,
|
||||
} from "./types"
|
||||
|
|
|
|||
205
src/pins/input-group.ts
Normal file
205
src/pins/input-group.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
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>[] = []
|
||||
const listener = (event: InputGroupEvent<T>) => {
|
||||
eventQueue.push(event)
|
||||
}
|
||||
|
||||
this.#eventListeners.push(listener)
|
||||
this.#startEventLoop()
|
||||
|
||||
try {
|
||||
while (!this.#closed) {
|
||||
if (eventQueue.length > 0) {
|
||||
for (const event of eventQueue) {
|
||||
yield event
|
||||
}
|
||||
eventQueue.length = 0
|
||||
} else {
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
}
|
||||
} 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,84 +0,0 @@
|
|||
import { gpiod, GPIOD_EDGE_EVENT_RISING_EDGE } from "./ffi"
|
||||
import { requestInputLine } from "./gpio-helpers"
|
||||
import type { PullMode, EdgeMode } from "./types"
|
||||
|
||||
type WorkerConfig = {
|
||||
chipPath: string
|
||||
offset: number
|
||||
pull: PullMode
|
||||
debounce: number
|
||||
edge: EdgeMode
|
||||
}
|
||||
|
||||
type WorkerMessage =
|
||||
| { type: "ready"; initialValue: 0 | 1 }
|
||||
| { type: "event"; value: 0 | 1; timestamp: bigint }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
const postMessage = (message: WorkerMessage) => {
|
||||
self.postMessage(message)
|
||||
}
|
||||
|
||||
const cleanup = (message: string): never => {
|
||||
postMessage({ type: "error", message })
|
||||
self.close()
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
const mapEdgeToValue = (edgeType: number, pull: PullMode): 0 | 1 => {
|
||||
// Pull-up: rising edge = released (1), falling edge = pressed (0)
|
||||
// Pull-down: rising edge = pressed (1), falling edge = released (0)
|
||||
if (pull === "up") {
|
||||
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
|
||||
}
|
||||
return edgeType === GPIOD_EDGE_EVENT_RISING_EDGE ? 1 : 0
|
||||
}
|
||||
|
||||
const run = (config: WorkerConfig) => {
|
||||
const { chip, request } = requestInputLine(config)
|
||||
|
||||
const initialValue = gpiod.gpiod_line_request_get_value(request, config.offset)
|
||||
if (initialValue === -1) {
|
||||
gpiod.gpiod_line_request_release(request)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to read initial value")
|
||||
}
|
||||
|
||||
postMessage({ type: "ready", initialValue: initialValue as 0 | 1 })
|
||||
|
||||
const buffer = gpiod.gpiod_edge_event_buffer_new(1)
|
||||
if (!buffer) {
|
||||
gpiod.gpiod_line_request_release(request)
|
||||
gpiod.gpiod_chip_close(chip)
|
||||
cleanup("Failed to create event buffer")
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// Block forever (-1 timeout) until edge event occurs
|
||||
const waitResult = gpiod.gpiod_line_request_wait_edge_events(request, -1)
|
||||
|
||||
if (waitResult === 1) {
|
||||
const numEvents = gpiod.gpiod_line_request_read_edge_events(request, buffer, 1)
|
||||
if (numEvents === -1) cleanup("Failed to read edge events")
|
||||
|
||||
const event = gpiod.gpiod_edge_event_buffer_get_event(buffer, 0)
|
||||
const edgeType = gpiod.gpiod_edge_event_get_event_type(event)
|
||||
const timestamp = gpiod.gpiod_edge_event_get_timestamp_ns(event)
|
||||
|
||||
const value = mapEdgeToValue(edgeType, config.pull)
|
||||
|
||||
postMessage({ type: "event", value, timestamp })
|
||||
} else if (waitResult === -1) {
|
||||
cleanup("GPIO wait_edge_events failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Worker terminates - kernel cleans up GPIO resources automatically
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<WorkerConfig>) => {
|
||||
self.onmessage = () => {
|
||||
throw new Error("Worker already initialized")
|
||||
}
|
||||
run(event.data)
|
||||
}
|
||||
|
|
@ -1,90 +1,36 @@
|
|||
import type { InputEvent, InputOptions } from "./types"
|
||||
import type { Pointer } from "bun:ffi"
|
||||
import { InputGroup } from "./input-group"
|
||||
import type { PullMode, InputEvent } from "./types"
|
||||
|
||||
type WorkerMessage =
|
||||
| { type: "ready"; initialValue: 0 | 1 }
|
||||
| { type: "event"; value: 0 | 1; timestamp: bigint }
|
||||
| { type: "error"; message: string }
|
||||
export class InputPin<T extends string = string> {
|
||||
#group: InputGroup<T>
|
||||
#pinName: T
|
||||
|
||||
export class Input {
|
||||
#worker: Worker
|
||||
#callbacks = new Set<(event: InputEvent) => void>()
|
||||
#closed = false
|
||||
#lastValue: 0 | 1 = 0
|
||||
|
||||
constructor(chipPath: string, offset: number, options: InputOptions = {}) {
|
||||
const pull = options.pull ?? "up"
|
||||
const debounce = options.debounce ?? 0
|
||||
const edge = options.edge ?? "both"
|
||||
|
||||
this.#worker = new Worker(new URL("./input-worker.ts", import.meta.url).href)
|
||||
|
||||
this.#worker.onmessage = (msg: MessageEvent<WorkerMessage>) => {
|
||||
if (this.#closed) return
|
||||
|
||||
const data = msg.data
|
||||
|
||||
if (data.type === "ready") {
|
||||
this.#lastValue = data.initialValue
|
||||
} else if (data.type === "event") {
|
||||
this.#lastValue = data.value
|
||||
for (const callback of this.#callbacks) {
|
||||
callback({ value: data.value, timestamp: data.timestamp })
|
||||
}
|
||||
} else if (data.type === "error") {
|
||||
console.error(`GPIO Input Error (pin ${offset}):`, data.message)
|
||||
}
|
||||
}
|
||||
|
||||
this.#worker.postMessage({ chipPath, offset, pull, debounce, edge })
|
||||
constructor(group: InputGroup<T>, pinName: T) {
|
||||
this.#group = group
|
||||
this.#pinName = pinName
|
||||
}
|
||||
|
||||
get value(): 0 | 1 {
|
||||
if (this.#closed) throw new Error("Input is closed")
|
||||
return this.#lastValue
|
||||
}
|
||||
|
||||
onChange(callback: (event: InputEvent) => void): () => void {
|
||||
if (this.#closed) throw new Error("Input is closed")
|
||||
this.#callbacks.add(callback)
|
||||
return () => this.#callbacks.delete(callback)
|
||||
return this.#group.pins[this.#pinName]!.value
|
||||
}
|
||||
|
||||
async waitForValue(targetValue: 0 | 1, timeout?: number): Promise<void> {
|
||||
if (this.#closed) throw new Error("Input is closed")
|
||||
|
||||
if (this.#lastValue === targetValue) return
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
unsubscribe()
|
||||
for await (const event of this.#group.events()) {
|
||||
if (event.value === targetValue) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = this.onChange((event) => {
|
||||
if (event.value === targetValue) {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
if (!timeout) return
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timeout waiting for value ${targetValue}`))
|
||||
}, timeout)
|
||||
})
|
||||
async *events(): AsyncGenerator<InputEvent> {
|
||||
for await (const event of this.#group.events()) {
|
||||
yield { value: event.value, timestamp: event.timestamp }
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.#closed) return
|
||||
this.#closed = true
|
||||
|
||||
this.#callbacks.clear()
|
||||
this.#worker.onmessage = null
|
||||
this.#worker.terminate()
|
||||
this.#group.close()
|
||||
}
|
||||
|
||||
[Symbol.dispose]() {
|
||||
|
|
|
|||
|
|
@ -1,39 +1,30 @@
|
|||
import type { Pointer } from "bun:ffi"
|
||||
import { gpiod } from "./ffi"
|
||||
import { requestOutputLine } from "./gpio-helpers"
|
||||
import type { OutputOptions } from "./types"
|
||||
|
||||
export class Output {
|
||||
export class OutputPin {
|
||||
#closed = false
|
||||
#chip: Pointer
|
||||
#request: Pointer
|
||||
#offset: number
|
||||
#pin: number
|
||||
#resetValue?: 0 | 1
|
||||
|
||||
constructor(chipPath: string, offset: number, options: OutputOptions = {}) {
|
||||
const initialValue = options.initialValue ?? 0
|
||||
const { chip, request } = requestOutputLine({ chipPath, offset, initialValue })
|
||||
|
||||
constructor(chip: Pointer, request: Pointer, pin: number, resetValue?: 0 | 1) {
|
||||
this.#chip = chip
|
||||
this.#request = request
|
||||
this.#offset = offset
|
||||
|
||||
if (options.resetOnClose) {
|
||||
const currentValue = gpiod.gpiod_line_request_get_value(request, offset)
|
||||
this.#resetValue = currentValue === -1 ? 0 : (currentValue as 0 | 1)
|
||||
}
|
||||
this.#pin = pin
|
||||
this.#resetValue = resetValue
|
||||
}
|
||||
|
||||
get value(): 0 | 1 {
|
||||
if (this.#closed) throw new Error("Output is closed")
|
||||
const ret = gpiod.gpiod_line_request_get_value(this.#request, this.#offset)
|
||||
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("Output is closed")
|
||||
const ret = gpiod.gpiod_line_request_set_value(this.#request, this.#offset, val)
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +37,7 @@ export class Output {
|
|||
this.#closed = true
|
||||
|
||||
if (this.#resetValue !== undefined) {
|
||||
gpiod.gpiod_line_request_set_value(this.#request, this.#offset, this.#resetValue)
|
||||
gpiod.gpiod_line_request_set_value(this.#request, this.#pin, this.#resetValue)
|
||||
}
|
||||
|
||||
gpiod.gpiod_line_request_release(this.#request)
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "WebWorker"]
|
||||
},
|
||||
"include": ["input-worker.ts"]
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ export type InputOptions = {
|
|||
|
||||
export type OutputOptions = {
|
||||
initialValue?: 0 | 1 // default: 0
|
||||
resetOnClose?: boolean // default: false
|
||||
}
|
||||
|
||||
export type InputEvent = {
|
||||
|
|
@ -17,9 +16,15 @@ export type InputEvent = {
|
|||
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 }>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,10 @@ export const mapEdgeToLibgpiod = (edge: EdgeMode): number => {
|
|||
// 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 => {
|
||||
export const mapLibgpiodEdgeToPressedState = (
|
||||
edgeType: number,
|
||||
pull: PullMode
|
||||
): boolean => {
|
||||
if (pull === "up") {
|
||||
return edgeType === GPIOD_EDGE_EVENT_FALLING_EDGE
|
||||
} else if (pull === "down") {
|
||||
|
|
@ -48,6 +51,10 @@ export const mapLibgpiodEdgeToPressedState = (edgeType: number, pull: PullMode):
|
|||
}
|
||||
}
|
||||
|
||||
export const hashInputConfig = (pull: PullMode, debounce: number, edge: EdgeMode): string => {
|
||||
export const hashInputConfig = (
|
||||
pull: PullMode,
|
||||
debounce: number,
|
||||
edge: EdgeMode
|
||||
): string => {
|
||||
return `${pull}-${debounce}-${edge}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,12 @@ async function stopAP() {
|
|||
async function checkAndManageAP() {
|
||||
const connected = await isConnectedToWiFi()
|
||||
|
||||
console.log(
|
||||
`[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${
|
||||
apRunning ? "running" : "stopped"
|
||||
}`
|
||||
)
|
||||
|
||||
if (connected && apRunning) {
|
||||
console.log("[checkAndManageAP] WiFi connected and AP running → stopping AP")
|
||||
await stopAP()
|
||||
|
|
@ -128,7 +134,7 @@ async function checkAndManageAP() {
|
|||
const savedNetwork = await findAvailableSavedNetwork()
|
||||
if (savedNetwork) {
|
||||
console.log(
|
||||
`[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...`,
|
||||
`[checkAndManageAP] Found available saved network: ${savedNetwork}, attempting connection...`
|
||||
)
|
||||
|
||||
// Try to connect first
|
||||
|
|
@ -224,12 +230,6 @@ async function tryConnect(connectionName: string): Promise<boolean> {
|
|||
}
|
||||
|
||||
// Initial check
|
||||
const connected = await isConnectedToWiFi()
|
||||
console.log(
|
||||
`[checkAndManageAP] WiFi: ${connected ? "connected" : "disconnected"}, AP: ${
|
||||
apRunning ? "running" : "stopped"
|
||||
}`,
|
||||
)
|
||||
await checkAndManageAP()
|
||||
|
||||
// Check periodically
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ export const IndexPage = () => (
|
|||
</label>
|
||||
<button type="submit">Connect to Network</button>
|
||||
</form>
|
||||
<footer>
|
||||
<small>
|
||||
<a href="/logs">📋 View Service Logs</a>
|
||||
</small>
|
||||
</footer>
|
||||
|
||||
<script dangerouslySetInnerHTML={{__html: `
|
||||
(async () => {
|
||||
|
|
|
|||
|
|
@ -16,18 +16,7 @@ export const Layout = ({ title, children, refresh }: LayoutProps) => (
|
|||
<link rel="stylesheet" href="/pico.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<nav>
|
||||
<ul>
|
||||
<li><strong>☎️ Phone</strong></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a href="/">WiFi Setup</a></li>
|
||||
<li><a href="/logs">Logs</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
{children}
|
||||
</main>
|
||||
<main class="container">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,70 +1,62 @@
|
|||
import { Layout } from "./Layout";
|
||||
|
||||
type LogsPageProps = {
|
||||
service: string;
|
||||
logs: string;
|
||||
};
|
||||
|
||||
export const LogsPage = ({ service, logs }: LogsPageProps) => (
|
||||
export const LogsPage = ({ logs }: LogsPageProps) => (
|
||||
<Layout title="Service Logs">
|
||||
<h1>📋 Service Logs</h1>
|
||||
|
||||
<div role="group">
|
||||
<a
|
||||
href="/logs?service=phone-ap"
|
||||
role="button"
|
||||
aria-current={service === "phone-ap" ? "true" : undefined}
|
||||
>
|
||||
📡 WiFi AP
|
||||
</a>
|
||||
<a
|
||||
href="/logs?service=phone-web"
|
||||
role="button"
|
||||
aria-current={service === "phone-web" ? "true" : undefined}
|
||||
>
|
||||
🌐 Web Server
|
||||
</a>
|
||||
<a
|
||||
href="/logs?service=phone"
|
||||
role="button"
|
||||
aria-current={service === "phone" ? "true" : undefined}
|
||||
>
|
||||
☎️ Phone App
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<pre id="logs" style="margin-top: 1rem; max-height: 70vh; overflow-y: auto;">
|
||||
<code>{logs.trim()}</code>
|
||||
<p>
|
||||
<small>
|
||||
<label>
|
||||
<input type="checkbox" id="auto-refresh" checked /> Auto-refresh
|
||||
</label>
|
||||
{" | "}
|
||||
<a href="/">← Back</a>
|
||||
</small>
|
||||
</p>
|
||||
<pre>
|
||||
<code id="logs-content">{logs.trim()}</code>
|
||||
</pre>
|
||||
|
||||
<script dangerouslySetInnerHTML={{ __html: `
|
||||
(function() {
|
||||
const logsEl = document.getElementById('logs');
|
||||
const codeEl = logsEl.querySelector('code');
|
||||
let userScrolledUp = false;
|
||||
<script>{`
|
||||
let interval = null;
|
||||
const checkbox = document.getElementById('auto-refresh');
|
||||
const logsContent = document.getElementById('logs-content');
|
||||
|
||||
logsEl.addEventListener('scroll', () => {
|
||||
const atBottom = logsEl.scrollTop + logsEl.clientHeight >= logsEl.scrollHeight - 50;
|
||||
userScrolledUp = !atBottom;
|
||||
});
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
const res = await fetch('/api/logs');
|
||||
const data = await res.json();
|
||||
logsContent.textContent = data.logs;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to bottom initially
|
||||
logsEl.scrollTop = logsEl.scrollHeight;
|
||||
function startRefresh() {
|
||||
if (interval) clearInterval(interval);
|
||||
interval = setInterval(refreshLogs, 5000);
|
||||
}
|
||||
|
||||
const service = new URLSearchParams(location.search).get('service') || 'phone-ap';
|
||||
const es = new EventSource('/logs/stream?service=' + encodeURIComponent(service));
|
||||
function stopRefresh() {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
es.onmessage = (e) => {
|
||||
codeEl.textContent += '\\n' + e.data;
|
||||
if (!userScrolledUp) {
|
||||
logsEl.scrollTop = logsEl.scrollHeight;
|
||||
}
|
||||
};
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
if (e.target.checked) {
|
||||
startRefresh();
|
||||
} else {
|
||||
stopRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
console.error('SSE connection lost, reconnecting...');
|
||||
};
|
||||
})();
|
||||
`}} />
|
||||
// Start auto-refresh by default
|
||||
startRefresh();
|
||||
`}</script>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { Hono } from "hono"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { join } from "node:path"
|
||||
import { $ } from "bun"
|
||||
import { IndexPage } from "./components/IndexPage"
|
||||
|
|
@ -35,57 +36,33 @@ app.get("/api/networks", async (c) => {
|
|||
}
|
||||
})
|
||||
|
||||
// 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() })
|
||||
} catch (error) {
|
||||
return c.json({ logs: "", error: String(error) }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// Main WiFi configuration page
|
||||
app.get("/", (c) => {
|
||||
return c.html(<IndexPage />)
|
||||
})
|
||||
|
||||
// Service logs
|
||||
// Service logs with auto-refresh
|
||||
app.get("/logs", async (c) => {
|
||||
const service = c.req.query("service") || "phone-ap"
|
||||
const validServices = ["phone-ap", "phone-web", "phone"]
|
||||
|
||||
// Default to phone-ap if invalid service
|
||||
const selectedService = validServices.includes(service) ? service : "phone-ap"
|
||||
|
||||
try {
|
||||
const logs = await $`journalctl -u ${selectedService}.service -n 200 --no-pager --no-hostname`.text()
|
||||
return c.html(<LogsPage service={selectedService} logs={logs} />)
|
||||
const logs =
|
||||
await $`journalctl -u phone-ap.service -u phone-web.service -n 200 --no-pager`.text()
|
||||
return c.html(<LogsPage logs={logs} />)
|
||||
} catch (error) {
|
||||
return c.html(<LogsPage service={selectedService} logs={`Error loading logs: ${error}`} />)
|
||||
throw new Error(`Failed to fetch logs: ${error}`)
|
||||
}
|
||||
})
|
||||
|
||||
// SSE endpoint for live log streaming
|
||||
app.get("/logs/stream", async (c) => {
|
||||
const service = c.req.query("service") || "phone"
|
||||
const validServices = ["phone-ap", "phone-web", "phone"]
|
||||
const selectedService = validServices.includes(service) ? service : "phone"
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const proc = Bun.spawn(
|
||||
["journalctl", "-u", `${selectedService}.service`, "-f", "-n", "0", "--no-pager", "--no-hostname"],
|
||||
{ stdout: "pipe" }
|
||||
)
|
||||
|
||||
const reader = proc.stdout.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const text = decoder.decode(value)
|
||||
for (const line of text.split("\n").filter(Boolean)) {
|
||||
await stream.writeSSE({ data: line })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
proc.kill()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Handle WiFi configuration submission
|
||||
app.post("/save", async (c) => {
|
||||
const formData = await c.req.parseBody()
|
||||
|
|
@ -116,8 +93,7 @@ app.post("/save", async (c) => {
|
|||
return response
|
||||
})
|
||||
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 80
|
||||
export default { port, fetch: app.fetch }
|
||||
export default { port: 80, fetch: app.fetch }
|
||||
|
||||
console.log(`Server running on http://0.0.0.0:${port}`)
|
||||
console.log("Server running on http://0.0.0.0:80")
|
||||
console.log("Access via WiFi or AP at http://phone.local")
|
||||
|
|
|
|||
69
src/sip.ts
69
src/sip.ts
|
|
@ -1,17 +1,15 @@
|
|||
import log from "./utils/log.ts"
|
||||
import { Emitter } from "./utils/emitter.ts"
|
||||
import { log } from "./utils/log.ts"
|
||||
import { Signal } from "./utils/signal.ts"
|
||||
import { processStdout, processStderr } from "./utils/stdio.ts"
|
||||
|
||||
export class Baresip {
|
||||
baresipArgs: string[]
|
||||
process?: Bun.PipedSubprocess
|
||||
registered = false
|
||||
callEstablished = new Emitter<{ contact: string }>()
|
||||
callReceived = new Emitter<{ contact: string }>()
|
||||
hungUp = new Emitter()
|
||||
dialFailed = new Emitter<{ reason: string }>()
|
||||
error = new Emitter<{ message: string; statusCode?: string; reason?: string }>()
|
||||
registrationSuccess = new Emitter()
|
||||
callEstablished = new Signal<{ contact: string }>()
|
||||
callReceived = new Signal<{ contact: string }>()
|
||||
hungUp = new Signal()
|
||||
error = new Signal<{ message: string }>()
|
||||
registrationSuccess = new Signal()
|
||||
|
||||
constructor(baresipArgs: string[]) {
|
||||
this.baresipArgs = baresipArgs
|
||||
|
|
@ -41,15 +39,8 @@ export class Baresip {
|
|||
executeCommand("a")
|
||||
}
|
||||
|
||||
async dial(phoneNumber: string) {
|
||||
if (!this.registered) {
|
||||
this.dialFailed.emit({ reason: "Not registered with SIP server" })
|
||||
return
|
||||
}
|
||||
const success = await executeCommand(`d${phoneNumber}`)
|
||||
if (!success) {
|
||||
this.dialFailed.emit({ reason: "Command timed out" })
|
||||
}
|
||||
dial(phoneNumber: string) {
|
||||
executeCommand(`d${phoneNumber}`)
|
||||
}
|
||||
|
||||
hangUp() {
|
||||
|
|
@ -57,12 +48,10 @@ export class Baresip {
|
|||
}
|
||||
|
||||
disconnectAll() {
|
||||
this.callEstablished.removeAllListeners()
|
||||
this.callReceived.removeAllListeners()
|
||||
this.hungUp.removeAllListeners()
|
||||
this.registrationSuccess.removeAllListeners()
|
||||
this.dialFailed.removeAllListeners()
|
||||
this.error.removeAllListeners()
|
||||
this.callEstablished.disconnect()
|
||||
this.callReceived.disconnect()
|
||||
this.hungUp.disconnect()
|
||||
this.registrationSuccess.disconnect()
|
||||
}
|
||||
|
||||
kill() {
|
||||
|
|
@ -72,15 +61,6 @@ export class Baresip {
|
|||
this.process = undefined
|
||||
}
|
||||
|
||||
async restart() {
|
||||
this.registered = false
|
||||
if (this.process) {
|
||||
this.process.kill()
|
||||
this.process = undefined
|
||||
}
|
||||
await this.connect()
|
||||
}
|
||||
|
||||
#parseLine(line: string) {
|
||||
log.debug(`📞 Baresip: ${line}`)
|
||||
const callEstablishedMatch = line.match(/Call established: (.+)/)
|
||||
|
|
@ -108,40 +88,27 @@ export class Baresip {
|
|||
|
||||
const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/)
|
||||
if (registrationSuccessMatch) {
|
||||
this.registered = true
|
||||
this.registrationSuccess.emit()
|
||||
}
|
||||
|
||||
const registrationFailedMatch = line.match(/reg: sip:\S+ .*?(\d{3}) (\w+)/)
|
||||
const registrationFailedMatch = line.match(/reg: sip:\S+ 403 Forbidden/)
|
||||
const socketInUseMatch = line.match(/tcp: sock_bind:/)
|
||||
if (registrationFailedMatch) {
|
||||
const [, statusCode, reason] = registrationFailedMatch
|
||||
log.error(`Registration failed: ${statusCode} ${reason}`)
|
||||
this.registered = false
|
||||
this.error.emit({ message: line, statusCode, reason })
|
||||
} else if (socketInUseMatch) {
|
||||
log.error(`Registration failed: socket in use`)
|
||||
this.registered = false
|
||||
if (registrationFailedMatch || socketInUseMatch) {
|
||||
log.error(`⁉️ NOT HANDLED: Registration failed with "${line}"`)
|
||||
this.error.emit({ message: line })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executeCommand = async (command: string): Promise<boolean> => {
|
||||
const executeCommand = async (command: string) => {
|
||||
try {
|
||||
const url = new URL(`/?${command}`, "http://127.0.0.1:8000")
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
const response = await Bun.fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error executing command: ${response.statusText}`)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error("Failed to execute command:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ console.log("")
|
|||
// Test 2: Create player
|
||||
console.log("🔊 Creating default player...")
|
||||
try {
|
||||
const player = await Buzz.player()
|
||||
const player = await Buzz.defaultPlayer()
|
||||
console.log("✅ Player created\n")
|
||||
|
||||
// Test 3: Play sound file
|
||||
|
|
@ -42,7 +42,7 @@ try {
|
|||
// Test 5: Create recorder
|
||||
console.log("🎤 Creating default recorder...")
|
||||
try {
|
||||
const recorder = await Buzz.recorder()
|
||||
const recorder = await Buzz.defaultRecorder()
|
||||
console.log("✅ Recorder created\n")
|
||||
|
||||
// Test 6: Stream recording with RMS
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Buzz from "./buzz/index.ts"
|
||||
import type { Playback } from "./buzz/utils.ts"
|
||||
import { Agent } from "./agent/index.ts"
|
||||
import { searchWeb } from "./agent/tools.ts"
|
||||
import { getSound, WaitingSounds } from "./utils/waiting-sounds.ts"
|
||||
|
|
@ -7,8 +8,8 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
|
|||
console.log("📞 Phone System Starting\n")
|
||||
await Buzz.setVolume(0.4)
|
||||
|
||||
const recorder = await Buzz.recorder()
|
||||
const player = await Buzz.player()
|
||||
const recorder = await Buzz.defaultRecorder()
|
||||
const player = await Buzz.defaultPlayer()
|
||||
|
||||
const agent = new Agent({
|
||||
agentId,
|
||||
|
|
@ -18,13 +19,13 @@ const runPhoneSystem = async (agentId: string, apiKey: string) => {
|
|||
},
|
||||
})
|
||||
|
||||
let currentDialtone: Buzz.Playback | undefined
|
||||
let currentBackgroundNoise: Buzz.Playback | undefined
|
||||
let currentDialtone: Playback | undefined
|
||||
let currentBackgroundNoise: Playback | undefined
|
||||
let streamPlayback = player.playStream()
|
||||
const waitingIndicator = new WaitingSounds(player)
|
||||
|
||||
// Set up agent event listeners
|
||||
agent.events.on(async (event) => {
|
||||
agent.events.connect(async (event) => {
|
||||
switch (event.type) {
|
||||
case "connected":
|
||||
console.log("✅ Connected to AI agent\n")
|
||||
|
|
@ -162,7 +163,7 @@ if (!apiKey) {
|
|||
|
||||
if (!agentId) {
|
||||
console.error(
|
||||
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required",
|
||||
"❌ Error: ELEVEN_AGENT_ID environELEVEN_AGENT_ID=agent_5601k4taw2cvfjzrz6snxpgeh7x8 ELEVEN_API_KEY=sk_0313740f112c5992cb62ed96c974ab19b5916f1ea172471fment variable is required"
|
||||
)
|
||||
console.error(" Create an agent at https://elevenlabs.io/app/conversational-ai")
|
||||
process.exit(1)
|
||||
|
|
|
|||
|
|
@ -1,39 +1,46 @@
|
|||
import GPIO from "./pins"
|
||||
import { GPIO } from "./pins"
|
||||
|
||||
console.log(`kill -9 ${process.pid}`)
|
||||
|
||||
const gpio = new GPIO()
|
||||
const gpio = new GPIO({ resetOnClose: true })
|
||||
|
||||
// // Blink an LED
|
||||
using led = gpio.output(21)
|
||||
using button = gpio.input(20, { pull: "up", debounce: 10 })
|
||||
using switchInput = gpio.input(16, { pull: "up", debounce: 10 })
|
||||
|
||||
led.value = button.value
|
||||
// Read a button
|
||||
using inputs = gpio.inputGroup({
|
||||
button: { pin: 20, pull: "up", debounce: 10 },
|
||||
switch: { pin: 16, pull: "up", debounce: 10 }
|
||||
})
|
||||
|
||||
button.onChange((event) => {
|
||||
led.value = event.value
|
||||
led.value = inputs.pins.button.value
|
||||
|
||||
const iteratorEvents = new Promise(async (resolve) => {
|
||||
for await (const event of inputs.events()) {
|
||||
if (event.pin === "button") {
|
||||
led.value = event.value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const switchEvent = new Promise<void>(async (resolve) => {
|
||||
await switchInput.waitForValue(0)
|
||||
await inputs.pins.switch.waitForValue(0)
|
||||
console.log("Switch pressed!")
|
||||
resolve()
|
||||
})
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
button.close()
|
||||
switchInput.close()
|
||||
inputs.close()
|
||||
led.close()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
button.close()
|
||||
switchInput.close()
|
||||
led.close()
|
||||
inputs.close()
|
||||
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
await switchEvent
|
||||
await Promise.race([iteratorEvents, switchEvent])
|
||||
|
||||
console.log(`👋 Goodbye!`)
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* How to use Emitter:
|
||||
*
|
||||
* Create an emitter:
|
||||
* const chat = new Emitter<{ username: string, message: string }>()
|
||||
*
|
||||
* Listen to events:
|
||||
* const off = chat.on((data) => {
|
||||
* const {username, message} = data;
|
||||
* console.log(`${username} said "${message}"`);
|
||||
* })
|
||||
*
|
||||
* Emit an event:
|
||||
* chat.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
|
||||
*
|
||||
* Remove a specific listener:
|
||||
* off(); // The off function is returned when you add a listener
|
||||
*
|
||||
* Remove all listeners:
|
||||
* chat.removeAllListeners()
|
||||
*/
|
||||
export class Emitter<T = void> {
|
||||
private listeners: Array<(data: T) => void> = []
|
||||
|
||||
on(listener: (data: T) => void) {
|
||||
this.listeners.push(listener)
|
||||
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== listener)
|
||||
}
|
||||
}
|
||||
|
||||
emit(data: T) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
|
||||
removeAllListeners() {
|
||||
this.listeners = []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
import type GPIO from "../pins"
|
||||
|
||||
export const ensure = <T>(value: T, message: string): T => {
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(message)
|
||||
|
|
@ -11,17 +9,3 @@ export const ensure = <T>(value: T, message: string): T => {
|
|||
export const random = <T>(arr: ReadonlyArray<T>): T => {
|
||||
return arr[Math.floor(Math.random() * arr.length)]!
|
||||
}
|
||||
|
||||
export const ring = async (ringer: GPIO.Output, duration: number, signal?: AbortSignal) => {
|
||||
try {
|
||||
const endAt = performance.now() + duration
|
||||
while (performance.now() < endAt && !signal?.aborted) {
|
||||
ringer.value = 1
|
||||
await Bun.sleep(50)
|
||||
ringer.value = 0
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
} finally {
|
||||
ringer.value = 0
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
let showDebug = process.env.DEBUG ?? false
|
||||
let showDebug = true
|
||||
let showInfo = true
|
||||
let showError = true
|
||||
|
||||
export function setLogLevel(level: "debug" | "info" | "error") {
|
||||
export function setLogLevel(level: "debug" | "info" | "error" | "none") {
|
||||
showDebug = level === "debug"
|
||||
showInfo = level === "debug" || level === "info"
|
||||
showError = level !== "none"
|
||||
}
|
||||
|
||||
const log = (...args: any[]) => {
|
||||
if (showInfo) console.log("👁️🗨️ INFO: ", ...args)
|
||||
export const log = {
|
||||
debug: (...args: any[]) => {
|
||||
if (showDebug) console.debug("DEBUG: ", ...args)
|
||||
},
|
||||
info: (...args: any[]) => {
|
||||
if (showInfo) console.log("INFO: ", ...args)
|
||||
},
|
||||
error: (...args: any[]) => {
|
||||
if (showError) console.error("ERROR: ", ...args)
|
||||
},
|
||||
}
|
||||
|
||||
log.debug = (...args: any[]) => {
|
||||
if (showDebug) console.debug("🪲 DEBUG: ", ...args)
|
||||
}
|
||||
|
||||
log.error = (...args: any[]) => {
|
||||
console.error("💥 ERROR: ", ...args)
|
||||
}
|
||||
|
||||
export default log
|
||||
|
|
|
|||
57
src/utils/signal.ts
Normal file
57
src/utils/signal.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* How to use a Signal:
|
||||
*
|
||||
* Create a signal:
|
||||
* const chatSignal = new Signal<{ username: string, message: string }>()
|
||||
*
|
||||
* Connect to the signal:
|
||||
* const disconnect = chatSignal.connect((data) => {
|
||||
* const {username, message} = data;
|
||||
* console.log(`${username} said "${message}"`);
|
||||
* })
|
||||
*
|
||||
* Emit a signal:
|
||||
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
|
||||
*
|
||||
* Forward a signal:
|
||||
* const relaySignal = new Signal<{ username: string, message: string }>()
|
||||
* const disconnectRelay = chatSignal.connect(relaySignal)
|
||||
* // Now, when chatSignal emits, relaySignal will also emit the same data
|
||||
*
|
||||
* Disconnect a single listener:
|
||||
* disconnect(); // The disconnect function is returned when you connect to a signal
|
||||
*
|
||||
* Disconnect all listeners:
|
||||
* chatSignal.disconnect()
|
||||
*/
|
||||
|
||||
export class Signal<T extends object | void> {
|
||||
private listeners: Array<(data: T) => void> = []
|
||||
|
||||
connect(listenerOrSignal: Signal<T> | ((data: T) => void)) {
|
||||
let listener: (data: T) => void
|
||||
|
||||
// If it is a signal, forward the data to the signal
|
||||
if (listenerOrSignal instanceof Signal) {
|
||||
listener = (data: T) => listenerOrSignal.emit(data)
|
||||
} else {
|
||||
listener = listenerOrSignal
|
||||
}
|
||||
|
||||
this.listeners.push(listener)
|
||||
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== listener)
|
||||
}
|
||||
}
|
||||
|
||||
emit(data: T) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.listeners = []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import log from "./log.ts"
|
||||
import { log } from "./log.ts"
|
||||
|
||||
export const LineSplitter = () => {
|
||||
let buffer = ""
|
||||
|
|
|
|||
|
|
@ -1,27 +1,21 @@
|
|||
import Buzz from "../buzz/index.ts"
|
||||
import { type Player } from "../buzz/index.ts"
|
||||
import { join } from "path"
|
||||
import type { Playback, StreamingPlayback } from "../buzz/utils.ts"
|
||||
import { random } from "./index.ts"
|
||||
import { log } from "console"
|
||||
|
||||
export class WaitingSounds {
|
||||
typingPlayback?: Buzz.Playback
|
||||
speakingPlayback?: Buzz.Playback
|
||||
playing = false
|
||||
typingPlayback?: Playback
|
||||
speakingPlayback?: Playback
|
||||
|
||||
constructor(private player: Buzz.Player) {}
|
||||
constructor(private player: Player) {}
|
||||
|
||||
async start(operatorStream: Buzz.StreamingPlayback) {
|
||||
if (this.playing) return // Already playing
|
||||
async start(operatorStream: StreamingPlayback) {
|
||||
if (this.typingPlayback) return // Already playing
|
||||
|
||||
this.playing = true
|
||||
this.#startTypingSounds()
|
||||
this.#startSpeakingSounds(operatorStream)
|
||||
}
|
||||
|
||||
get isPlaying() {
|
||||
return this.playing
|
||||
}
|
||||
|
||||
async #startTypingSounds() {
|
||||
return new Promise<void>(async (resolve) => {
|
||||
do {
|
||||
|
|
@ -35,53 +29,50 @@ export class WaitingSounds {
|
|||
const typingSound = getSound(dir)
|
||||
this.typingPlayback = await this.player.play(typingSound)
|
||||
await this.typingPlayback.finished()
|
||||
} while (this.isPlaying)
|
||||
} while (this.typingPlayback)
|
||||
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async #startSpeakingSounds(operatorStream: Buzz.StreamingPlayback) {
|
||||
async #startSpeakingSounds(operatorStream: StreamingPlayback) {
|
||||
const playedSounds = new Set<string>()
|
||||
let dir: SoundDir | undefined
|
||||
return new Promise<void>(async (resolve) => {
|
||||
// Don't start playing speaking sounds until the operator stream has been silent for a bit
|
||||
while (operatorStream.bufferEmptyFor < 1500) {
|
||||
await Bun.sleep(100)
|
||||
}
|
||||
|
||||
do {
|
||||
const lastSoundDir = dir
|
||||
const value = Math.random() * 100
|
||||
if (lastSoundDir === "body-noises") {
|
||||
dir = "apology"
|
||||
} else if (value > 99 && !lastSoundDir) {
|
||||
dir = "body-noises"
|
||||
} else if (value > 75 && !lastSoundDir) {
|
||||
dir = "stalling"
|
||||
} else {
|
||||
// sleep for 4-6 seconds
|
||||
await Bun.sleep(4000 + Math.random() * 2000)
|
||||
const value = Math.random() * 100
|
||||
|
||||
if (value > 95) {
|
||||
dir = "body-noises"
|
||||
} else {
|
||||
dir = "stalling"
|
||||
}
|
||||
dir = undefined
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
|
||||
const speakingSound = getSound(dir, Array.from(playedSounds))
|
||||
this.speakingPlayback = await this.player.play(speakingSound)
|
||||
playedSounds.add(speakingSound)
|
||||
await this.speakingPlayback.finished()
|
||||
} while (this.isPlaying)
|
||||
if (dir) {
|
||||
const speakingSound = getSound(dir, Array.from(playedSounds))
|
||||
this.speakingPlayback = await this.player.play(speakingSound)
|
||||
playedSounds.add(speakingSound)
|
||||
await this.speakingPlayback.finished()
|
||||
}
|
||||
} while (this.typingPlayback)
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async stop() {
|
||||
log(`🛑 Stopping waiting sounds. Playing? ${this.playing}`)
|
||||
if (!this.playing) return
|
||||
this.playing = false
|
||||
if (!this.typingPlayback) return
|
||||
|
||||
await Promise.all([this.typingPlayback?.stop(), this.speakingPlayback?.finished()])
|
||||
log("🛑 Waiting sounds stopped")
|
||||
await Promise.all([this.typingPlayback.stop(), this.speakingPlayback?.finished()])
|
||||
this.typingPlayback = undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
# vesta
|
||||
|
||||
CLI tool for sending messages to a Vestaboard.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
Add your API key to `.env`:
|
||||
```
|
||||
VESTABOARD_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
bun src/cli.ts <plugin> [args...]
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
bun src/cli.ts words hello world foo
|
||||
```
|
||||
|
||||
## Plugin Ideas
|
||||
|
||||
### Physical Interaction
|
||||
- **SMS gateway** - Text your board from anywhere, let guests text it at parties
|
||||
- **NFC tags** - Tap spots around your house to trigger different displays
|
||||
- **Pi buttons** - Physical "mood buttons" - hit one for motivation, one for jokes, one for chaos
|
||||
- **QR code guest book** - Visitors scan and leave a message
|
||||
|
||||
### Generative/Visual
|
||||
- **Game of Life** - Cellular automata with color tiles, evolving patterns
|
||||
- **Matrix rain** - Characters cascading down with color trails
|
||||
- **Waveform** - Audio input turns into color visualizations
|
||||
- **Sunrise simulator** - Color gradient that shifts throughout the day
|
||||
|
||||
### Data as Art
|
||||
- **GitHub-style heatmap** - Your daily activity as a color grid
|
||||
- **Air quality gradient** - Pulls AQI, renders as color mood
|
||||
- **Heart rate from Apple Watch** - Live biometric ambient display
|
||||
- **Network pulse** - Flickers when devices connect/disconnect
|
||||
|
||||
### Games via SMS
|
||||
- **Wordle** - Text guesses, board shows your progress
|
||||
- **Hangman** - Play with friends remotely
|
||||
- **Simon** - Color memory game with physical buttons
|
||||
|
||||
### Home Awareness
|
||||
- **Who's home** - Each family member gets a row, lights up based on presence
|
||||
- **Chore roulette** - Spin and assign tasks with flair
|
||||
- **Package tracker** - Delivery countdown with dramatic reveal
|
||||
107
src/vesta/cli.ts
107
src/vesta/cli.ts
|
|
@ -1,107 +0,0 @@
|
|||
#!/usr/bin/env bun
|
||||
import { sendGrid } from "./vestaboard"
|
||||
|
||||
const ROWS = 6
|
||||
const COLS = 22
|
||||
|
||||
const charToCode = (c: string): number => {
|
||||
if (c === " ") return 0
|
||||
if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 64
|
||||
if (c >= "a" && c <= "z") return c.charCodeAt(0) - 96
|
||||
if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48 + 27
|
||||
const special: Record<string, number> = {
|
||||
"!": 37,
|
||||
"@": 38,
|
||||
"#": 39,
|
||||
$: 40,
|
||||
"(": 41,
|
||||
")": 42,
|
||||
"-": 44,
|
||||
"+": 46,
|
||||
"&": 47,
|
||||
"=": 48,
|
||||
";": 49,
|
||||
":": 50,
|
||||
"'": 52,
|
||||
'"': 53,
|
||||
"%": 54,
|
||||
",": 55,
|
||||
".": 56,
|
||||
"/": 59,
|
||||
"?": 60,
|
||||
"°": 62,
|
||||
"🟥": 63,
|
||||
"🟧": 64,
|
||||
"🟨": 65,
|
||||
"🟩": 66,
|
||||
"🟦": 67,
|
||||
"🟪": 68,
|
||||
"⬜": 69,
|
||||
"⬛": 70,
|
||||
}
|
||||
return special[c] ?? 0
|
||||
}
|
||||
|
||||
const blankGrid = (): number[][] => Array.from({ length: ROWS }, () => Array(COLS).fill(0))
|
||||
|
||||
const textToGrid = (text: string): number[][] => {
|
||||
const grid = blankGrid()
|
||||
const lines = text.split("\n").slice(0, ROWS)
|
||||
|
||||
lines.forEach((line, row) => {
|
||||
const chars = [...line].slice(0, COLS)
|
||||
const startCol = Math.floor((COLS - chars.length) / 2)
|
||||
chars.forEach((char, i) => {
|
||||
grid[row]![startCol + i] = charToCode(char)
|
||||
})
|
||||
})
|
||||
|
||||
// Vertically center if fewer lines than rows
|
||||
if (lines.length < ROWS) {
|
||||
const offset = Math.floor((ROWS - lines.length) / 2)
|
||||
const centered = blankGrid()
|
||||
lines.forEach((_, i) => {
|
||||
centered[i + offset] = grid[i] as any
|
||||
})
|
||||
return centered
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
|
||||
const usage = `
|
||||
Usage: bun src/vesta/cli.ts <message>
|
||||
|
||||
Examples:
|
||||
bun src/vesta/cli.ts "Hello World"
|
||||
bun src/vesta/cli.ts "Line 1" "Line 2" "Line 3"
|
||||
echo "Piped text" | bun src/vesta/cli.ts
|
||||
|
||||
Special characters: ! @ # $ ( ) - + & = ; : ' " % , . / ?
|
||||
Colors: 🟥 🟧 🟨 🟩 🟦 🟪 ⬜ ⬛
|
||||
`
|
||||
|
||||
const main = async () => {
|
||||
let text: string
|
||||
|
||||
if (process.argv.length > 2) {
|
||||
text = process.argv.slice(2).join("\n")
|
||||
} else if (!process.stdin.isTTY) {
|
||||
text = await Bun.stdin.text()
|
||||
} else {
|
||||
console.log(usage)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
text = text.trim()
|
||||
if (!text) {
|
||||
console.error("Error: Empty message")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Sending: "${text.replace(/\n/g, " | ")}"`)
|
||||
const grid = textToGrid(text)
|
||||
await sendGrid(grid)
|
||||
}
|
||||
|
||||
main()
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
import { test, expect } from "bun:test"
|
||||
import { draw, type DrawCommand } from "./draw"
|
||||
import { renderGridToPng } from "./render"
|
||||
import { mkdirSync, existsSync } from "fs"
|
||||
|
||||
const OUTPUT_DIR = `${import.meta.dir}/test-output`
|
||||
|
||||
if (!existsSync(OUTPUT_DIR)) {
|
||||
mkdirSync(OUTPUT_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const saveTestPng = async (name: string, commands: DrawCommand[]) => {
|
||||
const grid = draw(commands)
|
||||
const png = await renderGridToPng(grid)
|
||||
await Bun.write(`${OUTPUT_DIR}/${name}.png`, png)
|
||||
return grid
|
||||
}
|
||||
|
||||
const ROWS = 6
|
||||
const COLS = 22
|
||||
|
||||
// Helper to safely access grid cells (we know grid is always 6x22)
|
||||
const at = (grid: number[][], r: number, c: number) => grid[r]![c]!
|
||||
const rowAt = (grid: number[][], r: number) => grid[r]!
|
||||
|
||||
test("fill - solid color", async () => {
|
||||
const grid = await saveTestPng("fill-solid-blue", [{ cmd: "fill", color: "blue" }])
|
||||
expect(grid.length).toBe(ROWS)
|
||||
expect(rowAt(grid, 0).length).toBe(COLS)
|
||||
expect(grid.every((r) => r.every((c) => c === 67))).toBe(true)
|
||||
})
|
||||
|
||||
test("rect - simple rectangle", async () => {
|
||||
const grid = await saveTestPng("rect-simple", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "rect", x: 2, y: 1, w: 5, h: 3, color: "red" },
|
||||
])
|
||||
expect(at(grid, 1, 2)).toBe(63) // red
|
||||
expect(at(grid, 2, 4)).toBe(63) // red
|
||||
expect(at(grid, 0, 0)).toBe(70) // black
|
||||
})
|
||||
|
||||
test("rect - full width row", async () => {
|
||||
const grid = await saveTestPng("rect-full-row", [
|
||||
{ cmd: "fill", color: "white" },
|
||||
{ cmd: "rect", x: 0, y: 0, w: 22, h: 1, color: "orange" },
|
||||
{ cmd: "rect", x: 0, y: 5, w: 22, h: 1, color: "orange" },
|
||||
])
|
||||
expect(rowAt(grid, 0).every((c) => c === 64)).toBe(true)
|
||||
expect(rowAt(grid, 5).every((c) => c === 64)).toBe(true)
|
||||
expect(rowAt(grid, 2).every((c) => c === 69)).toBe(true)
|
||||
})
|
||||
|
||||
test("text - centered (default)", async () => {
|
||||
const grid = await saveTestPng("text-centered", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HELLO", row: 2 },
|
||||
])
|
||||
// "HELLO" is 5 chars, centered in 22 = starts at position 8
|
||||
expect(at(grid, 2, 8)).toBe(8) // H
|
||||
expect(at(grid, 2, 9)).toBe(5) // E
|
||||
expect(at(grid, 2, 10)).toBe(12) // L
|
||||
expect(at(grid, 2, 11)).toBe(12) // L
|
||||
expect(at(grid, 2, 12)).toBe(15) // O
|
||||
})
|
||||
|
||||
test("text - left aligned", async () => {
|
||||
const grid = await saveTestPng("text-left", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 0, align: "left" },
|
||||
])
|
||||
expect(at(grid, 0, 0)).toBe(8) // H
|
||||
expect(at(grid, 0, 1)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text - right aligned", async () => {
|
||||
const grid = await saveTestPng("text-right", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 0, align: "right" },
|
||||
])
|
||||
expect(at(grid, 0, 20)).toBe(8) // H
|
||||
expect(at(grid, 0, 21)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text - with startCol/endCol bounds", async () => {
|
||||
const grid = await saveTestPng("text-bounded", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 2, startCol: 5, endCol: 16, align: "center" },
|
||||
])
|
||||
// Width is 12 (5-16 inclusive), "HI" is 2 chars, centered = starts at col 10
|
||||
expect(at(grid, 2, 10)).toBe(8) // H
|
||||
expect(at(grid, 2, 11)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text - left aligned with bounds", async () => {
|
||||
const grid = await saveTestPng("text-bounded-left", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 2, startCol: 4, endCol: 17, align: "left" },
|
||||
])
|
||||
expect(at(grid, 2, 4)).toBe(8) // H
|
||||
expect(at(grid, 2, 5)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text - right aligned with bounds", async () => {
|
||||
const grid = await saveTestPng("text-bounded-right", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text", content: "HI", row: 2, startCol: 4, endCol: 17, align: "right" },
|
||||
])
|
||||
expect(at(grid, 2, 16)).toBe(8) // H
|
||||
expect(at(grid, 2, 17)).toBe(9) // I
|
||||
})
|
||||
|
||||
test("text_block - word wrap", async () => {
|
||||
const grid = await saveTestPng("text-block-wrap", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "HAPPY BIRTHDAY BEATRICE", startRow: 1 },
|
||||
])
|
||||
expect(grid.length).toBe(ROWS)
|
||||
})
|
||||
|
||||
test("text_block - with column bounds", async () => {
|
||||
const grid = await saveTestPng("text-block-bounded", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "THE QUICK BROWN FOX JUMPS", startRow: 0, startCol: 2, endCol: 19 },
|
||||
])
|
||||
// First and last 2 cols should remain black
|
||||
expect(at(grid, 0, 0)).toBe(70)
|
||||
expect(at(grid, 0, 1)).toBe(70)
|
||||
expect(at(grid, 0, 20)).toBe(70)
|
||||
expect(at(grid, 0, 21)).toBe(70)
|
||||
})
|
||||
|
||||
test("text_block - with row bounds", async () => {
|
||||
const grid = await saveTestPng("text-block-row-bounded", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "A B C D E F G H I J K L M", startRow: 2, endRow: 4 },
|
||||
])
|
||||
// Should only use rows 2-4
|
||||
expect(at(grid, 0, 0)).toBe(70) // black, untouched
|
||||
expect(at(grid, 1, 0)).toBe(70) // black, untouched
|
||||
expect(at(grid, 5, 0)).toBe(70) // black, untouched
|
||||
})
|
||||
|
||||
test("text_block - overflow ellipsis", async () => {
|
||||
const grid = await saveTestPng("text-block-overflow", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "THIS IS A VERY LONG MESSAGE THAT WILL NOT FIT", startRow: 2, endRow: 3, overflow: "ellipsis" },
|
||||
])
|
||||
expect(grid.length).toBe(ROWS)
|
||||
})
|
||||
|
||||
test("border - all sides", async () => {
|
||||
const grid = await saveTestPng("border-all", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "border", color: "blue" },
|
||||
])
|
||||
expect(rowAt(grid, 0).every((c) => c === 67)).toBe(true)
|
||||
expect(rowAt(grid, 5).every((c) => c === 67)).toBe(true)
|
||||
expect(at(grid, 2, 0)).toBe(67)
|
||||
expect(at(grid, 2, 21)).toBe(67)
|
||||
expect(at(grid, 2, 10)).toBe(70)
|
||||
})
|
||||
|
||||
test("border - top and bottom only", async () => {
|
||||
const grid = await saveTestPng("border-top-bottom", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "border", color: "orange", sides: ["top", "bottom"] },
|
||||
])
|
||||
expect(rowAt(grid, 0).every((c) => c === 64)).toBe(true)
|
||||
expect(rowAt(grid, 5).every((c) => c === 64)).toBe(true)
|
||||
expect(at(grid, 2, 0)).toBe(70)
|
||||
expect(at(grid, 2, 21)).toBe(70)
|
||||
})
|
||||
|
||||
test("gradient - horizontal", async () => {
|
||||
const grid = await saveTestPng("gradient-horizontal", [
|
||||
{ cmd: "gradient", direction: "horizontal", colors: ["purple", "blue", "green", "yellow", "orange", "red"] },
|
||||
])
|
||||
expect(at(grid, 0, 0)).toBe(68) // purple
|
||||
expect(at(grid, 0, 21)).toBe(63) // red
|
||||
})
|
||||
|
||||
test("gradient - vertical", async () => {
|
||||
const grid = await saveTestPng("gradient-vertical", [
|
||||
{ cmd: "gradient", direction: "vertical", colors: ["blue", "green", "yellow"] },
|
||||
])
|
||||
expect(at(grid, 0, 10)).toBe(67) // blue
|
||||
expect(at(grid, 5, 10)).toBe(65) // yellow
|
||||
})
|
||||
|
||||
test("circle - small", async () => {
|
||||
const grid = await saveTestPng("circle-small", [
|
||||
{ cmd: "fill", color: "white" },
|
||||
{ cmd: "circle", cx: 11, cy: 3, r: 2, color: "red" },
|
||||
])
|
||||
expect(at(grid, 3, 11)).toBe(63) // red center
|
||||
})
|
||||
|
||||
test("line - horizontal", async () => {
|
||||
const grid = await saveTestPng("line-horizontal", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "line", x1: 2, y1: 3, x2: 19, y2: 3, color: "yellow" },
|
||||
])
|
||||
expect(at(grid, 3, 2)).toBe(65)
|
||||
expect(at(grid, 3, 10)).toBe(65)
|
||||
expect(at(grid, 3, 19)).toBe(65)
|
||||
})
|
||||
|
||||
test("line - diagonal", async () => {
|
||||
const grid = await saveTestPng("line-diagonal", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "line", x1: 0, y1: 0, x2: 21, y2: 5, color: "green" },
|
||||
])
|
||||
expect(at(grid, 0, 0)).toBe(66)
|
||||
expect(at(grid, 5, 21)).toBe(66)
|
||||
})
|
||||
|
||||
test("combined - birthday message", async () => {
|
||||
await saveTestPng("combined-birthday", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "border", color: "orange", sides: ["top", "bottom"] },
|
||||
{ cmd: "text_block", content: "HAPPY BIRTHDAY BEATRICE", startRow: 2, endRow: 3 },
|
||||
])
|
||||
})
|
||||
|
||||
test("combined - quote with accent", async () => {
|
||||
await saveTestPng("combined-quote", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "text_block", content: "KINDLE A LIGHT OF MEANING IN THE DARKNESS", startRow: 0, endRow: 3, startCol: 2, endCol: 19 },
|
||||
{ cmd: "text", content: "-CARL JUNG", row: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
test("combined - alert with border", async () => {
|
||||
await saveTestPng("combined-alert", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "border", color: "red" },
|
||||
{ cmd: "text", content: "ALERT", row: 2 },
|
||||
{ cmd: "text", content: "MEETING AT 6PM", row: 3 },
|
||||
])
|
||||
})
|
||||
|
||||
test("combined - asymmetric layout", async () => {
|
||||
await saveTestPng("combined-asymmetric", [
|
||||
{ cmd: "fill", color: "black" },
|
||||
{ cmd: "rect", x: 0, y: 0, w: 4, h: 6, color: "blue" },
|
||||
{ cmd: "text_block", content: "QUOTE GOES HERE ON THE RIGHT SIDE", startCol: 5, endCol: 21, startRow: 1, endRow: 4, align: "left" },
|
||||
])
|
||||
})
|
||||
|
||||
test("layered - shapes overlap correctly", async () => {
|
||||
await saveTestPng("layered-shapes", [
|
||||
{ cmd: "fill", color: "white" },
|
||||
{ cmd: "rect", x: 0, y: 0, w: 22, h: 3, color: "blue" },
|
||||
{ cmd: "rect", x: 5, y: 1, w: 12, h: 4, color: "yellow" },
|
||||
{ cmd: "circle", cx: 11, cy: 3, r: 2, color: "red" },
|
||||
])
|
||||
})
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
const ROWS = 6
|
||||
const COLS = 22
|
||||
|
||||
type Color = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "white" | "black"
|
||||
type Align = "left" | "center" | "right"
|
||||
type Side = "top" | "bottom" | "left" | "right"
|
||||
type Overflow = "ellipsis" | "truncate" | "squeeze" | "error"
|
||||
|
||||
const colorToCode: Record<Color, number> = {
|
||||
red: 63,
|
||||
orange: 64,
|
||||
yellow: 65,
|
||||
green: 66,
|
||||
blue: 67,
|
||||
purple: 68,
|
||||
white: 69,
|
||||
black: 70,
|
||||
}
|
||||
|
||||
const charToCode: Record<string, number> = {
|
||||
" ": 0,
|
||||
A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9,
|
||||
J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17,
|
||||
R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26,
|
||||
"1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36,
|
||||
"!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46,
|
||||
"&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55,
|
||||
".": 56, "/": 59, "?": 60, "°": 62,
|
||||
}
|
||||
|
||||
interface FillCmd {
|
||||
cmd: "fill"
|
||||
color: Color
|
||||
}
|
||||
|
||||
interface RectCmd {
|
||||
cmd: "rect"
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
color: Color
|
||||
}
|
||||
|
||||
interface CircleCmd {
|
||||
cmd: "circle"
|
||||
cx: number
|
||||
cy: number
|
||||
r: number
|
||||
color: Color
|
||||
}
|
||||
|
||||
interface LineCmd {
|
||||
cmd: "line"
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
y2: number
|
||||
color: Color
|
||||
}
|
||||
|
||||
interface TextCmd {
|
||||
cmd: "text"
|
||||
content: string
|
||||
row: number
|
||||
align?: Align
|
||||
startCol?: number
|
||||
endCol?: number
|
||||
}
|
||||
|
||||
interface TextBlockCmd {
|
||||
cmd: "text_block"
|
||||
content: string
|
||||
startRow?: number
|
||||
endRow?: number
|
||||
startCol?: number
|
||||
endCol?: number
|
||||
align?: Align
|
||||
overflow?: Overflow
|
||||
}
|
||||
|
||||
interface BorderCmd {
|
||||
cmd: "border"
|
||||
color: Color
|
||||
sides?: Side[]
|
||||
}
|
||||
|
||||
interface GradientCmd {
|
||||
cmd: "gradient"
|
||||
direction: "horizontal" | "vertical"
|
||||
colors: Color[]
|
||||
}
|
||||
|
||||
export type DrawCommand =
|
||||
| FillCmd
|
||||
| RectCmd
|
||||
| CircleCmd
|
||||
| LineCmd
|
||||
| TextCmd
|
||||
| TextBlockCmd
|
||||
| BorderCmd
|
||||
| GradientCmd
|
||||
|
||||
const createGrid = (): number[][] => {
|
||||
return Array.from({ length: ROWS }, () => Array(COLS).fill(0))
|
||||
}
|
||||
|
||||
const setPixel = (grid: number[][], x: number, y: number, code: number) => {
|
||||
if (x >= 0 && x < COLS && y >= 0 && y < ROWS) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
|
||||
const fill = (grid: number[][], color: Color) => {
|
||||
const code = colorToCode[color]
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rect = (grid: number[][], x: number, y: number, w: number, h: number, color: Color) => {
|
||||
const code = colorToCode[color]
|
||||
for (let dy = 0; dy < h; dy++) {
|
||||
for (let dx = 0; dx < w; dx++) {
|
||||
setPixel(grid, x + dx, y + dy, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const circle = (grid: number[][], cx: number, cy: number, r: number, color: Color) => {
|
||||
const code = colorToCode[color]
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const dx = x - cx
|
||||
const dy = y - cy
|
||||
if (dx * dx + dy * dy <= r * r) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const line = (grid: number[][], x1: number, y1: number, x2: number, y2: number, color: Color) => {
|
||||
const code = colorToCode[color]
|
||||
const dx = Math.abs(x2 - x1)
|
||||
const dy = Math.abs(y2 - y1)
|
||||
const sx = x1 < x2 ? 1 : -1
|
||||
const sy = y1 < y2 ? 1 : -1
|
||||
let err = dx - dy
|
||||
|
||||
let x = x1
|
||||
let y = y1
|
||||
|
||||
while (true) {
|
||||
setPixel(grid, x, y, code)
|
||||
if (x === x2 && y === y2) break
|
||||
const e2 = 2 * err
|
||||
if (e2 > -dy) {
|
||||
err -= dy
|
||||
x += sx
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx
|
||||
y += sy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = (
|
||||
grid: number[][],
|
||||
content: string,
|
||||
row: number,
|
||||
startCol = 0,
|
||||
endCol = COLS - 1,
|
||||
align: Align = "center"
|
||||
) => {
|
||||
const availableWidth = endCol - startCol + 1
|
||||
const textLen = Math.min(content.length, availableWidth)
|
||||
const truncatedContent = content.slice(0, textLen)
|
||||
|
||||
let startX: number
|
||||
if (align === "left") {
|
||||
startX = startCol
|
||||
} else if (align === "right") {
|
||||
startX = endCol - textLen + 1
|
||||
} else {
|
||||
// center
|
||||
startX = startCol + Math.floor((availableWidth - textLen) / 2)
|
||||
}
|
||||
|
||||
for (let i = 0; i < truncatedContent.length; i++) {
|
||||
const char = truncatedContent[i]!.toUpperCase()
|
||||
const code = charToCode[char] ?? 0
|
||||
setPixel(grid, startX + i, row, code)
|
||||
}
|
||||
}
|
||||
|
||||
const wrapText = (content: string, maxWidth: number): string[] => {
|
||||
const words = content.split(" ")
|
||||
const lines: string[] = []
|
||||
let currentLine = ""
|
||||
|
||||
for (const word of words) {
|
||||
if (word.length > maxWidth) {
|
||||
if (currentLine) {
|
||||
lines.push(currentLine.trim())
|
||||
currentLine = ""
|
||||
}
|
||||
let remaining = word
|
||||
while (remaining.length > maxWidth) {
|
||||
lines.push(remaining.slice(0, maxWidth - 1) + "-")
|
||||
remaining = remaining.slice(maxWidth - 1)
|
||||
}
|
||||
currentLine = remaining
|
||||
} else if ((currentLine + " " + word).trim().length <= maxWidth) {
|
||||
currentLine = (currentLine + " " + word).trim()
|
||||
} else {
|
||||
if (currentLine) {
|
||||
lines.push(currentLine)
|
||||
}
|
||||
currentLine = word
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
const textBlock = (
|
||||
grid: number[][],
|
||||
content: string,
|
||||
startRow = 0,
|
||||
endRow = ROWS - 1,
|
||||
startCol = 0,
|
||||
endCol = COLS - 1,
|
||||
align: Align = "center",
|
||||
overflow: Overflow = "ellipsis"
|
||||
) => {
|
||||
const availableWidth = endCol - startCol + 1
|
||||
const availableRows = endRow - startRow + 1
|
||||
let lines = wrapText(content, availableWidth)
|
||||
|
||||
if (lines.length > availableRows) {
|
||||
if (overflow === "ellipsis") {
|
||||
lines = lines.slice(0, availableRows)
|
||||
const lastLine = lines[lines.length - 1]!
|
||||
if (lastLine.length > availableWidth - 3) {
|
||||
lines[lines.length - 1] = lastLine.slice(0, availableWidth - 3) + "..."
|
||||
} else {
|
||||
lines[lines.length - 1] = lastLine + "..."
|
||||
}
|
||||
} else if (overflow === "truncate") {
|
||||
lines = lines.slice(0, availableRows)
|
||||
} else if (overflow === "squeeze") {
|
||||
// Try expanding bounds by 1 on each side
|
||||
const newStartCol = Math.max(0, startCol - 1)
|
||||
const newEndCol = Math.min(COLS - 1, endCol + 1)
|
||||
if (newStartCol < startCol || newEndCol > endCol) {
|
||||
return textBlock(grid, content, startRow, endRow, newStartCol, newEndCol, align, overflow)
|
||||
}
|
||||
lines = lines.slice(0, availableRows)
|
||||
} else if (overflow === "error") {
|
||||
throw new Error(`Text overflow: ${lines.length} lines needed, only ${availableRows} available`)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const row = startRow + i
|
||||
if (row <= endRow) {
|
||||
text(grid, lines[i]!, row, startCol, endCol, align)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const border = (grid: number[][], color: Color, sides?: Side[]) => {
|
||||
const code = colorToCode[color]
|
||||
const allSides = sides ?? ["top", "bottom", "left", "right"]
|
||||
|
||||
if (allSides.includes("top")) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
grid[0]![x] = code
|
||||
}
|
||||
}
|
||||
|
||||
if (allSides.includes("bottom")) {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
grid[ROWS - 1]![x] = code
|
||||
}
|
||||
}
|
||||
|
||||
if (allSides.includes("left")) {
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
grid[y]![0] = code
|
||||
}
|
||||
}
|
||||
|
||||
if (allSides.includes("right")) {
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
grid[y]![COLS - 1] = code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gradient = (grid: number[][], direction: "horizontal" | "vertical", colors: Color[]) => {
|
||||
const codes = colors.map(c => colorToCode[c])
|
||||
|
||||
if (direction === "horizontal") {
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
const t = x / (COLS - 1)
|
||||
const idx = Math.min(Math.floor(t * codes.length), codes.length - 1)
|
||||
const code = codes[idx]!
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let y = 0; y < ROWS; y++) {
|
||||
const t = y / (ROWS - 1)
|
||||
const idx = Math.min(Math.floor(t * codes.length), codes.length - 1)
|
||||
const code = codes[idx]!
|
||||
for (let x = 0; x < COLS; x++) {
|
||||
grid[y]![x] = code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const draw = (commands: DrawCommand[]): number[][] => {
|
||||
const grid = createGrid()
|
||||
|
||||
for (const cmd of commands) {
|
||||
switch (cmd.cmd) {
|
||||
case "fill":
|
||||
fill(grid, cmd.color)
|
||||
break
|
||||
case "rect":
|
||||
rect(grid, cmd.x, cmd.y, cmd.w, cmd.h, cmd.color)
|
||||
break
|
||||
case "circle":
|
||||
circle(grid, cmd.cx, cmd.cy, cmd.r, cmd.color)
|
||||
break
|
||||
case "line":
|
||||
line(grid, cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.color)
|
||||
break
|
||||
case "text":
|
||||
text(grid, cmd.content, cmd.row, cmd.startCol, cmd.endCol, cmd.align)
|
||||
break
|
||||
case "text_block":
|
||||
textBlock(grid, cmd.content, cmd.startRow, cmd.endRow, cmd.startCol, cmd.endCol, cmd.align, cmd.overflow)
|
||||
break
|
||||
case "border":
|
||||
border(grid, cmd.color, cmd.sides)
|
||||
break
|
||||
case "gradient":
|
||||
gradient(grid, cmd.direction, cmd.colors)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
|
|
@ -1,991 +0,0 @@
|
|||
{
|
||||
"testPrompts": [
|
||||
"Happy birthday Beatrice! She is turning 11.",
|
||||
"Put up a morning motivational quote",
|
||||
"Game night at 7pm tonight!",
|
||||
"Make some pretty wintery art",
|
||||
"Soccer practice is cancelled today",
|
||||
"Pizza night! Dinner at 6",
|
||||
"Welcome home grandma!",
|
||||
"5 days until vacation!",
|
||||
"Don't forget your umbrella, it's going to rain",
|
||||
"Good luck on your test today Emma!",
|
||||
"Happy anniversary mom and dad!",
|
||||
"Please remember to feed the dog",
|
||||
"Merry Christmas!",
|
||||
"Big game today - go wildcats!",
|
||||
"The Johnsons are coming for dinner at 7",
|
||||
"Congrats on the promotion!",
|
||||
"Lights out at 9pm tonight",
|
||||
"Taco Tuesday!",
|
||||
"I love you, have a great day",
|
||||
"Movie night - pick your favorite!"
|
||||
],
|
||||
"goldExamples": [
|
||||
{
|
||||
"input": "Give me a matt cooke motivational quote",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "THERE'S A FUTURE",
|
||||
"row": 0,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "VERSION OF YOU TELLING",
|
||||
"row": 1,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "THE STORY OF HOW IT",
|
||||
"row": 2,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "ALL CAME TOGETHER.",
|
||||
"row": 3,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "-MATT COOKE",
|
||||
"row": 4,
|
||||
"align": "left"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Create a tour countdown that shows 15 minutes left",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 1,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 2,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 3,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 4,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 5,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 7,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 8,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 9,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 10,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 13,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 14,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 16,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 17,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 18,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 20,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 21,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 1,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 2,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 3,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 4,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 5,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 6,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 7,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 8,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 9,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 10,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 12,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 13,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 14,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 16,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 17,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 18,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "orange"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 20,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "purple"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 21,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "WELCOME! THE NEXT",
|
||||
"row": 1,
|
||||
"startCol": 2,
|
||||
"endCol": 19,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "TOUR STARTS IN...",
|
||||
"row": 2,
|
||||
"startCol": 2,
|
||||
"endCol": 19,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "15 MINUTES!",
|
||||
"row": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Say mazel tov to zev!",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 22,
|
||||
"h": 1,
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"w": 22,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 22,
|
||||
"h": 1,
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 5,
|
||||
"w": 22,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 2,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 2,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "white"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 3,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 3,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "MAZEL TOV!",
|
||||
"row": 2
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "ZEV",
|
||||
"row": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Put up a bedtime reminder",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "BEDTIME",
|
||||
"row": 0,
|
||||
"startCol": 1,
|
||||
"endCol": 8,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 17,
|
||||
"y": 1,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 16,
|
||||
"y": 2,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 3,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 9,
|
||||
"y": 1,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 2,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 10,
|
||||
"y": 3,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 9,
|
||||
"y": 4,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 3,
|
||||
"y": 2,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 5,
|
||||
"y": 3,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 4,
|
||||
"y": 4,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 3,
|
||||
"y": 5,
|
||||
"w": 4,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Show a train schedule for the polar express",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 2,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 20,
|
||||
"y": 0,
|
||||
"w": 2,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 1,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 21,
|
||||
"y": 1,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "POLAR EXPRESS",
|
||||
"row": 0
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "12:00 AM",
|
||||
"row": 1
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "NORTH",
|
||||
"row": 2,
|
||||
"startCol": 2,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "SOUTH",
|
||||
"row": 2,
|
||||
"startCol": 13,
|
||||
"endCol": 21,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 3,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 0,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 3,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 4,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 11,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "03 MINS",
|
||||
"row": 3,
|
||||
"startCol": 2,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "07 MINS",
|
||||
"row": 4,
|
||||
"startCol": 2,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "10 MINS",
|
||||
"row": 5,
|
||||
"startCol": 2,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "02 MINS",
|
||||
"row": 3,
|
||||
"startCol": 13,
|
||||
"endCol": 21,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "06 MINS",
|
||||
"row": 4,
|
||||
"startCol": 13,
|
||||
"endCol": 21,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "09 MINS",
|
||||
"row": 5,
|
||||
"startCol": 13,
|
||||
"endCol": 21,
|
||||
"align": "left"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Create a stretch reminder for me",
|
||||
"output": [
|
||||
{
|
||||
"cmd": "fill",
|
||||
"color": "black"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "STAND UP",
|
||||
"row": 2,
|
||||
"startCol": 1,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "text",
|
||||
"content": "& STRETCH",
|
||||
"row": 3,
|
||||
"startCol": 1,
|
||||
"endCol": 10,
|
||||
"align": "left"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 16,
|
||||
"y": 1,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 13,
|
||||
"y": 2,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 14,
|
||||
"y": 2,
|
||||
"w": 5,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 19,
|
||||
"y": 2,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "yellow"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 3,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "red"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 4,
|
||||
"w": 3,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 15,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"cmd": "rect",
|
||||
"x": 17,
|
||||
"y": 5,
|
||||
"w": 1,
|
||||
"h": 1,
|
||||
"color": "blue"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "birthday message for Sarah with confetti",
|
||||
"output": [
|
||||
{ "cmd": "fill", "color": "black" },
|
||||
{ "cmd": "rect", "x": 0, "y": 0, "w": 1, "h": 1, "color": "red" },
|
||||
{ "cmd": "rect", "x": 2, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 4, "y": 0, "w": 1, "h": 1, "color": "blue" },
|
||||
{ "cmd": "rect", "x": 6, "y": 0, "w": 1, "h": 1, "color": "purple" },
|
||||
{ "cmd": "rect", "x": 15, "y": 0, "w": 1, "h": 1, "color": "purple" },
|
||||
{ "cmd": "rect", "x": 17, "y": 0, "w": 1, "h": 1, "color": "blue" },
|
||||
{ "cmd": "rect", "x": 19, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 21, "y": 0, "w": 1, "h": 1, "color": "red" },
|
||||
{ "cmd": "rect", "x": 1, "y": 1, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 5, "y": 1, "w": 1, "h": 1, "color": "green" },
|
||||
{ "cmd": "rect", "x": 16, "y": 1, "w": 1, "h": 1, "color": "green" },
|
||||
{ "cmd": "rect", "x": 20, "y": 1, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "text", "content": "HAPPY BIRTHDAY", "row": 2 },
|
||||
{ "cmd": "text", "content": "SARAH!", "row": 3 },
|
||||
{ "cmd": "rect", "x": 1, "y": 4, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 5, "y": 4, "w": 1, "h": 1, "color": "green" },
|
||||
{ "cmd": "rect", "x": 16, "y": 4, "w": 1, "h": 1, "color": "green" },
|
||||
{ "cmd": "rect", "x": 20, "y": 4, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 0, "y": 5, "w": 1, "h": 1, "color": "red" },
|
||||
{ "cmd": "rect", "x": 2, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 4, "y": 5, "w": 1, "h": 1, "color": "blue" },
|
||||
{ "cmd": "rect", "x": 6, "y": 5, "w": 1, "h": 1, "color": "purple" },
|
||||
{ "cmd": "rect", "x": 15, "y": 5, "w": 1, "h": 1, "color": "purple" },
|
||||
{ "cmd": "rect", "x": 17, "y": 5, "w": 1, "h": 1, "color": "blue" },
|
||||
{ "cmd": "rect", "x": 19, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 21, "y": 5, "w": 1, "h": 1, "color": "red" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Baby Emma was born on January 15th 2024 at 3:42pm, 7 pounds 4 ounces, 20 inches",
|
||||
"output": [
|
||||
{ "cmd": "fill", "color": "black" },
|
||||
{ "cmd": "rect", "x": 0, "y": 1, "w": 22, "h": 1, "color": "red" },
|
||||
{ "cmd": "text", "content": "BABY EMMA", "row": 0 },
|
||||
{ "cmd": "text", "content": "DATE: JAN 15 2024", "row": 2, "startCol": 1, "endCol": 21, "align": "left" },
|
||||
{ "cmd": "text", "content": "TIME: 3:42 PM", "row": 3, "startCol": 1, "endCol": 21, "align": "left" },
|
||||
{ "cmd": "text", "content": "WEIGHT: 7 LBS 4 OZ", "row": 4, "startCol": 1, "endCol": 21, "align": "left" },
|
||||
{ "cmd": "text", "content": "LENGTH: 20 IN", "row": 5, "startCol": 1, "endCol": 21, "align": "left" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": "Congrats on the promotion!",
|
||||
"output": [
|
||||
{ "cmd": "fill", "color": "black" },
|
||||
{ "cmd": "rect", "x": 0, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 1, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 2, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 3, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 4, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 5, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 6, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 7, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 8, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 9, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 10, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 11, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 12, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 13, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 14, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 15, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 16, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 17, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 18, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 19, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 20, "y": 0, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 21, "y": 0, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 0, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 1, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 2, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 3, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 4, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 5, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 6, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 7, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 8, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 9, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 10, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 11, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 12, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 13, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 14, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 15, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 16, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 17, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 18, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 19, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "rect", "x": 20, "y": 5, "w": 1, "h": 1, "color": "orange" },
|
||||
{ "cmd": "rect", "x": 21, "y": 5, "w": 1, "h": 1, "color": "yellow" },
|
||||
{ "cmd": "text", "content": "CONGRATS ON THE", "row": 2 },
|
||||
{ "cmd": "text", "content": "PROMOTION!", "row": 3 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { GoogleGenerativeAI } from "@google/generative-ai"
|
||||
import { draw, type DrawCommand } from "./draw"
|
||||
import { sendGrid } from "./vestaboard"
|
||||
|
||||
const buildSystemPrompt = async (): Promise<string> => {
|
||||
const promptMd = await Bun.file(`${import.meta.dir}/prompt.md`).text()
|
||||
const examplesJson = await Bun.file(`${import.meta.dir}/examples.json`).json()
|
||||
|
||||
const examplesText = examplesJson.goldExamples
|
||||
.map((ex: { input: string; output: unknown }) => `Input: "${ex.input}"\nOutput: ${JSON.stringify(ex.output)}`)
|
||||
.join("\n\n")
|
||||
|
||||
return `${promptMd}
|
||||
|
||||
## Examples
|
||||
|
||||
${examplesText}`
|
||||
}
|
||||
|
||||
export const generateVestaboard = async (query: string): Promise<string> => {
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error("GEMINI_API_KEY not set in environment")
|
||||
}
|
||||
|
||||
const genAI = new GoogleGenerativeAI(apiKey)
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: "gemini-2.5-flash",
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
const systemPrompt = await buildSystemPrompt()
|
||||
|
||||
const result = await model.generateContent(`${systemPrompt}\n\n## User Request\n\n${query}`)
|
||||
const text = result.response.text()
|
||||
|
||||
let commands: DrawCommand[]
|
||||
try {
|
||||
commands = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`Failed to parse Gemini response as JSON: ${text}`)
|
||||
}
|
||||
|
||||
if (!Array.isArray(commands)) {
|
||||
throw new Error(`Expected array of commands, got: ${typeof commands}`)
|
||||
}
|
||||
|
||||
const grid = draw(commands)
|
||||
await sendGrid(grid)
|
||||
|
||||
return "The vestaboard design has been generated and sent successfully."
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# Vestaboard Layout Generator
|
||||
|
||||
Output a JSON array of draw commands for a 6-row × 22-column display.
|
||||
|
||||
## Rules
|
||||
1. First command MUST be `fill`
|
||||
2. Text has black cell backgrounds - use black `rect` behind text on colored backgrounds
|
||||
3. Colors: red, orange, yellow, green, blue, purple, white, black
|
||||
4. Coordinates: rows 0-5, columns 0-21
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
fill { "cmd": "fill", "color": "black" }
|
||||
rect { "cmd": "rect", "x": 0, "y": 0, "w": 22, "h": 1, "color": "orange" }
|
||||
text { "cmd": "text", "content": "HELLO", "row": 2, "color": "white" }
|
||||
text_block { "cmd": "text_block", "content": "LONG MESSAGE", "startRow": 1, "endRow": 4, "color": "white" }
|
||||
border { "cmd": "border", "color": "blue", "sides": ["top", "bottom"] }
|
||||
gradient { "cmd": "gradient", "direction": "horizontal", "colors": ["blue", "green", "yellow"] }
|
||||
circle { "cmd": "circle", "cx": 11, "cy": 3, "r": 2, "color": "red" }
|
||||
line { "cmd": "line", "x1": 0, "y1": 0, "x2": 21, "y2": 5, "color": "green" }
|
||||
```
|
||||
|
||||
text/text_block options: `align` (left/center/right), `startCol`, `endCol`
|
||||
|
||||
## Output
|
||||
JSON array only. No markdown, no explanation.
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
import sharp from "sharp"
|
||||
|
||||
// Character to vestaboard code mapping
|
||||
const charToCode: Record<string, number> = {
|
||||
" ": 0,
|
||||
A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9,
|
||||
J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17,
|
||||
R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26,
|
||||
"1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36,
|
||||
"!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46,
|
||||
"&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55,
|
||||
".": 56, "/": 59, "?": 60, "°": 62,
|
||||
"🟥": 63, "🟧": 64, "🟨": 65, "🟩": 66, "🟦": 67, "🟪": 68, "⬜": 69, "⬛": 70,
|
||||
}
|
||||
|
||||
// Code to display info for rendering
|
||||
const codeToDisplay: Record<number, { bg: string; fg: string; char?: string }> = {
|
||||
0: { bg: "#1a1a1a", fg: "#ffffff" }, // space (empty black tile)
|
||||
63: { bg: "#e63946", fg: "#ffffff" }, // red
|
||||
64: { bg: "#f4a261", fg: "#1a1a1a" }, // orange
|
||||
65: { bg: "#e9c46a", fg: "#1a1a1a" }, // yellow
|
||||
66: { bg: "#2a9d8f", fg: "#ffffff" }, // green
|
||||
67: { bg: "#0077b6", fg: "#ffffff" }, // blue
|
||||
68: { bg: "#9b5de5", fg: "#ffffff" }, // purple
|
||||
69: { bg: "#ffffff", fg: "#1a1a1a" }, // white
|
||||
70: { bg: "#1a1a1a", fg: "#ffffff" }, // black
|
||||
}
|
||||
|
||||
// Code to character (for letters/numbers/symbols)
|
||||
const codeToChar: Record<number, string> = {}
|
||||
for (const [char, code] of Object.entries(charToCode)) {
|
||||
if (code >= 1 && code <= 62) {
|
||||
codeToChar[code] = char
|
||||
}
|
||||
}
|
||||
|
||||
const TILE_SIZE = 24
|
||||
const GAP = 2
|
||||
const COLS = 22
|
||||
const ROWS = 6
|
||||
|
||||
// Parse emoji grid string to number array
|
||||
export const parseEmojiGrid = (grid: string): number[][] => {
|
||||
const lines = grid.trim().split("\n")
|
||||
const result: number[][] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const row: number[] = []
|
||||
const chars = [...line] // Handle multi-byte emoji correctly
|
||||
|
||||
for (const char of chars) {
|
||||
const code = charToCode[char.toUpperCase()]
|
||||
if (code !== undefined) {
|
||||
row.push(code)
|
||||
}
|
||||
}
|
||||
|
||||
// Pad or trim to 22 columns
|
||||
while (row.length < COLS) row.push(0)
|
||||
if (row.length > COLS) row.length = COLS
|
||||
|
||||
result.push(row)
|
||||
}
|
||||
|
||||
// Pad or trim to 6 rows
|
||||
while (result.length < ROWS) result.push(Array(COLS).fill(0))
|
||||
if (result.length > ROWS) result.length = ROWS
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Convert number grid to emoji string
|
||||
export const gridToEmoji = (grid: number[][]): string => {
|
||||
const emojiMap: Record<number, string> = {
|
||||
0: " ",
|
||||
63: "🟥", 64: "🟧", 65: "🟨", 66: "🟩", 67: "🟦", 68: "🟪", 69: "⬜", 70: "⬛",
|
||||
}
|
||||
|
||||
return grid
|
||||
.map((row) =>
|
||||
row
|
||||
.map((code) => {
|
||||
if (emojiMap[code] !== undefined) return emojiMap[code]
|
||||
if (codeToChar[code]) return codeToChar[code]
|
||||
return " "
|
||||
})
|
||||
.join("")
|
||||
)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// Render grid to SVG string
|
||||
const gridToSvg = (grid: number[][]): string => {
|
||||
const width = COLS * TILE_SIZE + (COLS - 1) * GAP
|
||||
const height = ROWS * TILE_SIZE + (ROWS - 1) * GAP
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
|
||||
svg += `<rect width="${width}" height="${height}" fill="#0d0d0d"/>` // background
|
||||
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
const code = grid[row]![col]!
|
||||
const x = col * (TILE_SIZE + GAP)
|
||||
const y = row * (TILE_SIZE + GAP)
|
||||
|
||||
// Determine tile appearance
|
||||
let bg = "#1a1a1a"
|
||||
let fg = "#ffffff"
|
||||
let char: string | undefined
|
||||
|
||||
const display = codeToDisplay[code]
|
||||
if (display) {
|
||||
bg = display.bg
|
||||
fg = display.fg
|
||||
} else if (codeToChar[code]) {
|
||||
char = codeToChar[code]
|
||||
bg = "#1a1a1a"
|
||||
fg = "#ffffff"
|
||||
}
|
||||
|
||||
// Draw tile
|
||||
svg += `<rect x="${x}" y="${y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="2" fill="${bg}"/>`
|
||||
|
||||
// Draw character if present
|
||||
if (char) {
|
||||
const fontSize = 14
|
||||
const textX = x + TILE_SIZE / 2
|
||||
const textY = y + TILE_SIZE / 2 + fontSize * 0.35
|
||||
// Escape XML special characters
|
||||
const escaped = char.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
svg += `<text x="${textX}" y="${textY}" font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold" fill="${fg}" text-anchor="middle">${escaped}</text>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += "</svg>"
|
||||
return svg
|
||||
}
|
||||
|
||||
// Render emoji grid string to PNG buffer
|
||||
export const renderToPng = async (emojiGrid: string): Promise<Buffer> => {
|
||||
const grid = parseEmojiGrid(emojiGrid)
|
||||
const svg = gridToSvg(grid)
|
||||
return sharp(Buffer.from(svg)).png().toBuffer()
|
||||
}
|
||||
|
||||
// Render number grid to PNG buffer
|
||||
export const renderGridToPng = async (grid: number[][]): Promise<Buffer> => {
|
||||
const svg = gridToSvg(grid)
|
||||
return sharp(Buffer.from(svg)).png().toBuffer()
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
const API_URL = "https://rw.vestaboard.com/"
|
||||
|
||||
export const sendGrid = async (grid: number[][]): Promise<void> => {
|
||||
const apiKey = process.env.VESTABOARD_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error("VESTABOARD_API_KEY not set in environment")
|
||||
}
|
||||
|
||||
console.log(`🌭 sending`)
|
||||
const response = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Vestaboard-Read-Write-Key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(grid),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
console.log(`🌭 Error ${text}`)
|
||||
throw new Error(`Vestaboard API error (${response.status}): ${text}`)
|
||||
} else {
|
||||
console.log(`🌭 sent successfully`, await response.text())
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,5 @@
|
|||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
},
|
||||
"exclude": ["src/pins/input-worker.ts"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user