@fortemate/vega-vvd-driver 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -14
- package/dist/bin.js +5 -3
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +173 -143
- package/dist/device.d.ts +1 -0
- package/dist/device.js +16 -3
- package/dist/discovery.js +24 -22
- package/dist/mcp.js +3 -7
- package/dist/record.js +87 -18
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Drive the Vega Virtual Device from scripts and AI agents: press remote keys, tak
|
|
|
6
6
|
|
|
7
7
|
## Why
|
|
8
8
|
|
|
9
|
-
The Vega SDK's `vega` command installs and launches apps on the Vega Virtual Device (VVD), but has no command to press a remote key or take a screenshot. Amazon's [Appium Vega driver](https://developer.amazon.com/docs/vega/0.24/appium-install.html) does both, and much more: it runs UI tests that find elements, on the VVD and on a Fire TV Stick. It needs an Appium 2 server, the driver package and the device's automation toolkit switched on, with Node 22 or earlier.
|
|
9
|
+
The Vega SDK's `vega` command installs and launches apps on the Vega Virtual Device (VVD), but has no command to press a remote key or take a screenshot. On a Fire TV Stick, the device's own `gwsi-tool-screenshooter` takes one through `vega exec vda shell`, but the [VDA reference](https://developer.amazon.com/docs/vega/0.24/vda-tools.html) notes that the VVD doesn't support it. Amazon's [Appium Vega driver](https://developer.amazon.com/docs/vega/0.24/appium-install.html) does both, and much more: it runs UI tests that find elements, on the VVD and on a Fire TV Stick. It needs an Appium 2 server, the driver package and the device's automation toolkit switched on, with Node 22 or earlier.
|
|
10
10
|
|
|
11
11
|
This driver is for lighter jobs on the VVD: a key press or a screenshot from a shell script, a video with sound, every frame of an animation, and an AI coding agent that can see what it built. It talks to the Android emulator that the VVD is built on, through the emulator's own gRPC API and console, so there is nothing to install on the device and no server to run.
|
|
12
12
|
|
|
@@ -14,15 +14,17 @@ Its methods were worked out while building [Dice Chess for Fire TV](https://gith
|
|
|
14
14
|
|
|
15
15
|
## Appium or this driver?
|
|
16
16
|
|
|
17
|
-
| To… |
|
|
18
|
-
|
|
|
19
|
-
| find elements, read their text, run a test suite |
|
|
20
|
-
| test on a Fire TV Stick |
|
|
21
|
-
| press keys or take a screenshot from a shell script |
|
|
22
|
-
| record a video with sound |
|
|
23
|
-
| capture every frame of an animation |
|
|
24
|
-
| let an AI agent see and operate the VVD over MCP |
|
|
25
|
-
| check the TV safe area |
|
|
17
|
+
| To… | Appium | this driver |
|
|
18
|
+
| :-------------------------------------------------- | :----: | :---------: |
|
|
19
|
+
| find elements, read their text, run a test suite | ✅ | — |
|
|
20
|
+
| test on a Fire TV Stick | ✅ | — |
|
|
21
|
+
| press keys or take a screenshot from a shell script | ✅ | ✅ |
|
|
22
|
+
| record a video with sound | — | ✅ |
|
|
23
|
+
| capture every frame of an animation | — | ✅ |
|
|
24
|
+
| let an AI agent see and operate the VVD over MCP | — | ✅ |
|
|
25
|
+
| check the TV safe area | ◯ | ✅ |
|
|
26
|
+
|
|
27
|
+
✅ a good fit · ◯ possible with your own code (take a screenshot, then check its edges) · — not supported
|
|
26
28
|
|
|
27
29
|
## What you need
|
|
28
30
|
|
|
@@ -59,7 +61,7 @@ vvd record demo.mp4 --seconds 20
|
|
|
59
61
|
| `vvd press <key…> [--gap 450]` | Presses remote keys in order: `up down left right ok back menu playpause rewind fastforward`, a `KEY_*` name or an evdev code. `down*3` repeats, `ok:down` and `ok:up` hold and release |
|
|
60
62
|
| `vvd screenshot [file]` | Saves the screen as a PNG |
|
|
61
63
|
| `vvd record <file> [--seconds 10] [--fps 30] [--no-audio]` | Records the screen with sound to an MP4. Needs ffmpeg |
|
|
62
|
-
| `vvd frames <dir> [--seconds 3]`
|
|
64
|
+
| `vvd frames <dir> [--seconds 3] [--max-frames 200]` | Saves every distinct frame as a PNG, named by the emulator's time. The frames wait in memory until the capture ends: 6 MB each at the VVD's 1920x1080, and 2 GB at most |
|
|
63
65
|
| `vvd wait-change [--timeout 5000]` | Exits 0 once the screen changes, 1 on timeout |
|
|
64
66
|
| `vvd safe-area [--background #rrggbb] [--margin 0.05]` | Counts what sits in the outer 5% of each edge. Exits 0 when clear |
|
|
65
67
|
| `vvd mcp` | Runs the MCP server on stdio |
|
|
@@ -110,7 +112,7 @@ device.close();
|
|
|
110
112
|
|
|
111
113
|
## Recipes
|
|
112
114
|
|
|
113
|
-
**Check an animation.** Start `vvd frames ./frames --seconds 3`, then trigger the animation. Each distinct frame lands as a PNG named by the emulator's own clock. On the VVD,
|
|
115
|
+
**Check an animation.** Start `vvd frames ./frames --seconds 3`, then trigger the animation. Each distinct frame lands as a PNG named by the emulator's own clock. The frames wait in memory while the capture runs, about 6 MB each at 1080p, and are written when it ends, because encoding one takes 20 to 200 ms and the screen would go unwatched meanwhile. The capture stops at 200 frames, about 1.2 GB, unless `--max-frames` says otherwise, and in any case before the frames held pass 2 GB, 321 frames at 1080p; the frames it has are written either way. On the VVD on 28 September 2026, with SDK 0.24.12112, the 220 ms pawn slide of Dice Chess's first tutorial move came through with the pawn in flight in 4 to 6 frames, 23 to 55 ms apart. A screenshot takes longer while the screen changes, and that sets the pace.
|
|
114
116
|
|
|
115
117
|
**Record a demo.** Script the presses, record while they run, and cut the best takes afterwards. Recording works without the device's window.
|
|
116
118
|
|
|
@@ -129,8 +131,8 @@ Measured on the VVD with Vega SDK 0.24.12112 on macOS:
|
|
|
129
131
|
- **Keys.** The emulator's gRPC `sendKey`, with Linux evdev codes, reaches apps: it is the path the VVD's own on-screen remote uses. OK is `KEY_KPENTER`, and apps receive it as `kpenter`, not the `select` the remote's documentation names. `KEY_SELECT` and `KEY_OK` never arrive, because the emulator's virtual keyboard does not declare them. Back is `KEY_BACK`; `KEY_ESC` does not reach an app as Back.
|
|
130
132
|
- **Dead ends.** The emulator console's `event send`, QEMU's `send-key` and `inputd-cli` on the device all report success and never reach an app.
|
|
131
133
|
- **Home cannot be sent.** `KEY_HOMEPAGE` (172), `KEY_F1` and 170, the code Amazon's Appium documentation gives for Home, all leave the app on screen. To get back to the launcher, run `vega device launch-app -d VirtualDevice -a com.amazon.keplerlauncherapp.main`.
|
|
132
|
-
- **Screenshots.** `getScreenshot` returns 1920x1080. A running process polls it at about
|
|
133
|
-
- **Audio.** `streamAudio` sends nothing while the device is silent. `record`
|
|
134
|
+
- **Screenshots.** `getScreenshot` returns 1920x1080. A running process polls it in RGB at about 57 screenshots a second of a still screen, 17 ms each, and at 16 to 43 a second while the screen changes, 23 to 61 ms each (measured on 28 September 2026). `vvd screenshot` takes about half a second, most of it starting up. The emulator's `streamScreenshot` has delivered only its first frame while the screen kept changing, so the driver polls instead. The device's `gwsi-tool-screenshooter` is for a Fire TV Stick: the VDA reference notes that the VVD doesn't support it, and on the VVD it wrote a 0-byte PNG or hung, with no error (28 September 2026).
|
|
135
|
+
- **Audio.** `streamAudio` sends nothing while the device is silent, and while a sound plays each packet's capture time wanders by about ±10 ms around where the packet before it ends. `record` lays the packets of one sound back to back and places the sound where their capture times agree best, then fills the gaps between sounds with silence. A sound stays in sync, and a long one, such as speech or music, plays without holes ([#13](https://github.com/fortemate/vega-vvd-driver/issues/13)). Measured on the VVD.
|
|
134
136
|
- **gRPC.** The endpoint is off after every start of the VVD, and the discovery file that the driver reads appears only once `grpc <port>` has been sent to the console. `vvd enable-grpc` does that.
|
|
135
137
|
|
|
136
138
|
## Troubleshooting
|
package/dist/bin.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
// The executable behind `vvd`. Kept apart from cli.ts so that the tests can
|
|
3
3
|
// import the command line without running it.
|
|
4
4
|
import { main } from "./cli.js";
|
|
5
|
-
|
|
5
|
+
try {
|
|
6
|
+
const code = await main(process.argv.slice(2));
|
|
6
7
|
// -1: a long-running command (the MCP server) that exits on its own.
|
|
7
8
|
if (code >= 0)
|
|
8
9
|
process.exitCode = code;
|
|
9
|
-
}
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
10
12
|
// 2, so that scripts can tell an error from wait-change's "no change".
|
|
11
13
|
console.error(`vvd: ${error.message ?? String(error)}`);
|
|
12
14
|
process.exitCode = 2;
|
|
13
|
-
}
|
|
15
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Device } from './device.ts';
|
|
1
2
|
export declare const USAGE: string;
|
|
2
3
|
export type Parsed = {
|
|
3
4
|
command: string | undefined;
|
|
@@ -8,6 +9,7 @@ export type Parsed = {
|
|
|
8
9
|
'console-port'?: string;
|
|
9
10
|
gap?: string;
|
|
10
11
|
seconds?: string;
|
|
12
|
+
'max-frames'?: string;
|
|
11
13
|
fps?: string;
|
|
12
14
|
'no-audio'?: boolean;
|
|
13
15
|
timeout?: string;
|
|
@@ -23,4 +25,15 @@ export declare const numberOption: (value: string | undefined, name: string, fal
|
|
|
23
25
|
max?: number | undefined;
|
|
24
26
|
integer?: boolean | undefined;
|
|
25
27
|
}) => number;
|
|
28
|
+
export declare const MAX_FRAMES = 200;
|
|
29
|
+
export declare const MAX_BYTES = 2000000000;
|
|
30
|
+
export declare const saveFrames: (device: Device, dir: string, options: {
|
|
31
|
+
durationMs: number;
|
|
32
|
+
maxFrames?: number;
|
|
33
|
+
maxBytes?: number;
|
|
34
|
+
encode?: (width: number, height: number, rgb: Buffer) => Buffer;
|
|
35
|
+
}) => Promise<{
|
|
36
|
+
count: number;
|
|
37
|
+
stopped: "max-frames" | "memory" | undefined;
|
|
38
|
+
}>;
|
|
26
39
|
export declare const main: (argv: readonly string[]) => Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -30,8 +30,11 @@ Commands:
|
|
|
30
30
|
--seconds <n> default 10
|
|
31
31
|
--fps <n> default 30
|
|
32
32
|
--no-audio video only
|
|
33
|
-
frames <dir> save every distinct frame as a PNG, to check animations
|
|
33
|
+
frames <dir> save every distinct frame as a PNG, to check animations;
|
|
34
|
+
frames wait in memory until the capture ends, 6 MB each
|
|
35
|
+
at 1080p, and it stops before they pass 2 GB
|
|
34
36
|
--seconds <n> default 3
|
|
37
|
+
--max-frames <n> stop after this many frames, default 200
|
|
35
38
|
wait-change exit 0 once the screen changes, 1 on a timeout
|
|
36
39
|
--timeout <ms> default 5000
|
|
37
40
|
safe-area check the outer 5% of the screen against the background;
|
|
@@ -59,6 +62,7 @@ export const parseCli = (argv) => {
|
|
|
59
62
|
'console-port': { type: 'string' },
|
|
60
63
|
gap: { type: 'string' },
|
|
61
64
|
seconds: { type: 'string' },
|
|
65
|
+
'max-frames': { type: 'string' },
|
|
62
66
|
fps: { type: 'string' },
|
|
63
67
|
'no-audio': { type: 'boolean' },
|
|
64
68
|
timeout: { type: 'string' },
|
|
@@ -84,6 +88,170 @@ export const numberOption = (value, name, fallback, { min = 0, max = Number.MAX_
|
|
|
84
88
|
return parsed;
|
|
85
89
|
};
|
|
86
90
|
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
91
|
+
// How many frames `vvd frames` holds by default: about 1.2 GB at 1080p.
|
|
92
|
+
export const MAX_FRAMES = 200;
|
|
93
|
+
// The most memory the held frames may take, whatever --max-frames says and
|
|
94
|
+
// whatever the screen's size: 321 frames at 1080p.
|
|
95
|
+
export const MAX_BYTES = 2_000_000_000;
|
|
96
|
+
// Captures every distinct frame for `durationMs`, then writes each to `dir`
|
|
97
|
+
// as frame-<ms>ms.png, the milliseconds on the emulator's clock from the
|
|
98
|
+
// first frame. The frames wait in memory until the capture is over: encoding
|
|
99
|
+
// one at 1080p takes 20 to 200 ms, and the next screenshot would wait for it.
|
|
100
|
+
// The capture stops after `maxFrames`, or before the frames held would pass
|
|
101
|
+
// `maxBytes`; the frames it has are written either way.
|
|
102
|
+
export const saveFrames = async (device, dir, options) => {
|
|
103
|
+
const { durationMs, maxFrames = MAX_FRAMES, maxBytes = MAX_BYTES, encode = encodePng, } = options;
|
|
104
|
+
const held = [];
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
// Fires at the first frame that does not fit, which is not kept: frames can
|
|
107
|
+
// differ in size, so each one is checked as it comes.
|
|
108
|
+
const full = new AbortController();
|
|
109
|
+
try {
|
|
110
|
+
await device.frames(durationMs, (frame) => {
|
|
111
|
+
if (bytes + frame.data.length > maxBytes) {
|
|
112
|
+
full.abort();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
held.push(frame);
|
|
116
|
+
bytes += frame.data.length;
|
|
117
|
+
}, { maxFrames, signal: full.signal });
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (!full.signal.aborted)
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
const first = held[0]?.timestampUs ?? 0;
|
|
124
|
+
for (const frame of held) {
|
|
125
|
+
const ms = Math.round((frame.timestampUs - first) / 1000);
|
|
126
|
+
writeFileSync(join(dir, `frame-${String(ms).padStart(6, '0')}ms.png`), encode(frame.width, frame.height, frame.data));
|
|
127
|
+
}
|
|
128
|
+
let stopped;
|
|
129
|
+
if (full.signal.aborted)
|
|
130
|
+
stopped = 'memory';
|
|
131
|
+
else if (held.length >= maxFrames)
|
|
132
|
+
stopped = 'max-frames';
|
|
133
|
+
return { count: held.length, stopped };
|
|
134
|
+
};
|
|
135
|
+
// Connects to the device, runs `body` with it, and lets go of the device
|
|
136
|
+
// however `body` ends.
|
|
137
|
+
const withDevice = async (pid, body) => {
|
|
138
|
+
const device = Device.connect({ pid });
|
|
139
|
+
try {
|
|
140
|
+
return await body(device);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
device.close();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
// The commands by name, each returning its exit status. Options are checked
|
|
147
|
+
// before a device is looked for.
|
|
148
|
+
const COMMANDS = {
|
|
149
|
+
devices({ pid }) {
|
|
150
|
+
const devices = findEmulators({ pid });
|
|
151
|
+
if (devices.length === 0) {
|
|
152
|
+
console.log('No running Vega Virtual Device with gRPC on. Start one, then run: vvd enable-grpc');
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
for (const d of devices)
|
|
156
|
+
console.log(`pid ${d.pid} grpc ${d.grpcPort} console ${d.consolePort ?? '-'} ${d.avdName ?? ''}`.trimEnd());
|
|
157
|
+
return 0;
|
|
158
|
+
},
|
|
159
|
+
async 'enable-grpc'({ values, pid }) {
|
|
160
|
+
if (pid !== undefined)
|
|
161
|
+
throw new Error('enable-grpc talks to an emulator console, not to a pid: use --console-port');
|
|
162
|
+
const port = numberOption(values.port, 'port', 8554, {
|
|
163
|
+
min: 1024,
|
|
164
|
+
max: 65535,
|
|
165
|
+
});
|
|
166
|
+
const consolePort = numberOption(values['console-port'], 'console-port', 5554, { min: 5554, max: 5682 });
|
|
167
|
+
await enableGrpc(port, { port: consolePort });
|
|
168
|
+
console.log(`gRPC is on at port ${port}.`);
|
|
169
|
+
return 0;
|
|
170
|
+
},
|
|
171
|
+
async press({ positionals, values, pid }) {
|
|
172
|
+
if (positionals.length === 0)
|
|
173
|
+
throw new Error('press needs at least one key, for example: vvd press down ok');
|
|
174
|
+
const steps = parseKeys(positionals); // fails before connecting on a typo
|
|
175
|
+
const gapMs = numberOption(values.gap, 'gap', 450, { max: 60000 });
|
|
176
|
+
await withDevice(pid, (device) => device.press(steps, { gapMs }));
|
|
177
|
+
return 0;
|
|
178
|
+
},
|
|
179
|
+
async screenshot({ positionals, pid }) {
|
|
180
|
+
const file = positionals[0] ?? `vvd-${stamp()}.png`;
|
|
181
|
+
const frame = await withDevice(pid, (device) => device.screenshot('png'));
|
|
182
|
+
writeFileSync(file, frame.data);
|
|
183
|
+
console.log(file);
|
|
184
|
+
return 0;
|
|
185
|
+
},
|
|
186
|
+
async record({ positionals, values, pid }) {
|
|
187
|
+
const file = positionals[0];
|
|
188
|
+
if (!file)
|
|
189
|
+
throw new Error('record needs a file, for example: vvd record demo.mp4 --seconds 20');
|
|
190
|
+
const options = {
|
|
191
|
+
file,
|
|
192
|
+
seconds: numberOption(values.seconds, 'seconds', 10, {
|
|
193
|
+
min: 1,
|
|
194
|
+
max: 3600,
|
|
195
|
+
integer: false,
|
|
196
|
+
}),
|
|
197
|
+
fps: numberOption(values.fps, 'fps', 30, { min: 1, max: 60 }),
|
|
198
|
+
audio: !values['no-audio'],
|
|
199
|
+
};
|
|
200
|
+
const result = await withDevice(pid, (device) => record(device, options));
|
|
201
|
+
console.log(`${result.file}: ${result.frames} frames, ${result.audioSeconds.toFixed(1)} s of audio`);
|
|
202
|
+
return 0;
|
|
203
|
+
},
|
|
204
|
+
async frames({ positionals, values, pid }) {
|
|
205
|
+
const dir = positionals[0];
|
|
206
|
+
if (!dir)
|
|
207
|
+
throw new Error('frames needs a directory, for example: vvd frames ./frames --seconds 3');
|
|
208
|
+
const seconds = numberOption(values.seconds, 'seconds', 3, {
|
|
209
|
+
min: 0.1,
|
|
210
|
+
max: 600,
|
|
211
|
+
integer: false,
|
|
212
|
+
});
|
|
213
|
+
const maxFrames = numberOption(values['max-frames'], 'max-frames', MAX_FRAMES, { min: 1, max: 10_000 });
|
|
214
|
+
mkdirSync(dir, { recursive: true });
|
|
215
|
+
const { count, stopped } = await withDevice(pid, (device) => saveFrames(device, dir, { durationMs: seconds * 1000, maxFrames }));
|
|
216
|
+
const why = {
|
|
217
|
+
'max-frames': `stopped at --max-frames ${maxFrames}`,
|
|
218
|
+
memory: `stopped at ${MAX_BYTES / 1e9} GB of frames in memory`,
|
|
219
|
+
};
|
|
220
|
+
console.log(stopped
|
|
221
|
+
? `${count} distinct frames in ${dir}: ${why[stopped]} before the ${seconds} s were up`
|
|
222
|
+
: `${count} distinct frames in ${dir}`);
|
|
223
|
+
return 0;
|
|
224
|
+
},
|
|
225
|
+
async 'wait-change'({ values, pid }) {
|
|
226
|
+
const timeoutMs = numberOption(values.timeout, 'timeout', 5000, {
|
|
227
|
+
min: 1,
|
|
228
|
+
max: 3_600_000,
|
|
229
|
+
});
|
|
230
|
+
const changed = await withDevice(pid, (device) => device.waitForChange({ timeoutMs }));
|
|
231
|
+
console.log(changed ? 'changed' : 'no change');
|
|
232
|
+
return changed ? 0 : 1;
|
|
233
|
+
},
|
|
234
|
+
async 'safe-area'({ values, pid }) {
|
|
235
|
+
const options = {
|
|
236
|
+
background: values.background
|
|
237
|
+
? parseColour(values.background)
|
|
238
|
+
: undefined,
|
|
239
|
+
margin: numberOption(values.margin, 'margin', 0.05, {
|
|
240
|
+
min: 0.01,
|
|
241
|
+
max: 0.25,
|
|
242
|
+
integer: false,
|
|
243
|
+
}),
|
|
244
|
+
};
|
|
245
|
+
const frame = await withDevice(pid, (device) => device.screenshot('rgb'));
|
|
246
|
+
const report = checkSafeArea(frame.data, frame.width, frame.height, options);
|
|
247
|
+
console.log(`${report.clear ? 'clear' : 'NOT clear'}: left=${report.left} right=${report.right} top=${report.top} bottom=${report.bottom} (background ${formatColour(report.background)})`);
|
|
248
|
+
return report.clear ? 0 : 1;
|
|
249
|
+
},
|
|
250
|
+
async mcp({ pid }) {
|
|
251
|
+
await serveStdio({ pid });
|
|
252
|
+
return -1; // keeps running until the client disconnects
|
|
253
|
+
},
|
|
254
|
+
};
|
|
87
255
|
export const main = async (argv) => {
|
|
88
256
|
const { command, positionals, values } = parseCli(argv);
|
|
89
257
|
if (values.version) {
|
|
@@ -97,146 +265,8 @@ export const main = async (argv) => {
|
|
|
97
265
|
const pid = values.pid === undefined
|
|
98
266
|
? undefined
|
|
99
267
|
: numberOption(values.pid, 'pid', 0, { min: 1 });
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (devices.length === 0) {
|
|
105
|
-
console.log('No running Vega Virtual Device with gRPC on. Start one, then run: vvd enable-grpc');
|
|
106
|
-
return 1;
|
|
107
|
-
}
|
|
108
|
-
for (const d of devices)
|
|
109
|
-
console.log(`pid ${d.pid} grpc ${d.grpcPort} console ${d.consolePort ?? '-'} ${d.avdName ?? ''}`.trimEnd());
|
|
110
|
-
return 0;
|
|
111
|
-
}
|
|
112
|
-
case 'enable-grpc': {
|
|
113
|
-
if (pid !== undefined)
|
|
114
|
-
throw new Error('enable-grpc talks to an emulator console, not to a pid: use --console-port');
|
|
115
|
-
const port = numberOption(values.port, 'port', 8554, {
|
|
116
|
-
min: 1024,
|
|
117
|
-
max: 65535,
|
|
118
|
-
});
|
|
119
|
-
const consolePort = numberOption(values['console-port'], 'console-port', 5554, { min: 5554, max: 5682 });
|
|
120
|
-
await enableGrpc(port, { port: consolePort });
|
|
121
|
-
console.log(`gRPC is on at port ${port}.`);
|
|
122
|
-
return 0;
|
|
123
|
-
}
|
|
124
|
-
case 'press': {
|
|
125
|
-
if (positionals.length === 0)
|
|
126
|
-
throw new Error('press needs at least one key, for example: vvd press down ok');
|
|
127
|
-
const steps = parseKeys(positionals); // fails before connecting on a typo
|
|
128
|
-
const device = connect();
|
|
129
|
-
try {
|
|
130
|
-
await device.press(steps, {
|
|
131
|
-
gapMs: numberOption(values.gap, 'gap', 450, { max: 60000 }),
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
finally {
|
|
135
|
-
device.close();
|
|
136
|
-
}
|
|
137
|
-
return 0;
|
|
138
|
-
}
|
|
139
|
-
case 'screenshot': {
|
|
140
|
-
const file = positionals[0] ?? `vvd-${stamp()}.png`;
|
|
141
|
-
const device = connect();
|
|
142
|
-
try {
|
|
143
|
-
const frame = await device.screenshot('png');
|
|
144
|
-
writeFileSync(file, frame.data);
|
|
145
|
-
console.log(file);
|
|
146
|
-
}
|
|
147
|
-
finally {
|
|
148
|
-
device.close();
|
|
149
|
-
}
|
|
150
|
-
return 0;
|
|
151
|
-
}
|
|
152
|
-
case 'record': {
|
|
153
|
-
const file = positionals[0];
|
|
154
|
-
if (!file)
|
|
155
|
-
throw new Error('record needs a file, for example: vvd record demo.mp4 --seconds 20');
|
|
156
|
-
const device = connect();
|
|
157
|
-
try {
|
|
158
|
-
const result = await record(device, {
|
|
159
|
-
file,
|
|
160
|
-
seconds: numberOption(values.seconds, 'seconds', 10, {
|
|
161
|
-
min: 1,
|
|
162
|
-
max: 3600,
|
|
163
|
-
integer: false,
|
|
164
|
-
}),
|
|
165
|
-
fps: numberOption(values.fps, 'fps', 30, { min: 1, max: 60 }),
|
|
166
|
-
audio: !values['no-audio'],
|
|
167
|
-
});
|
|
168
|
-
console.log(`${result.file}: ${result.frames} frames, ${result.audioSeconds.toFixed(1)} s of audio`);
|
|
169
|
-
}
|
|
170
|
-
finally {
|
|
171
|
-
device.close();
|
|
172
|
-
}
|
|
173
|
-
return 0;
|
|
174
|
-
}
|
|
175
|
-
case 'frames': {
|
|
176
|
-
const dir = positionals[0];
|
|
177
|
-
if (!dir)
|
|
178
|
-
throw new Error('frames needs a directory, for example: vvd frames ./frames --seconds 3');
|
|
179
|
-
mkdirSync(dir, { recursive: true });
|
|
180
|
-
const device = connect();
|
|
181
|
-
try {
|
|
182
|
-
let first;
|
|
183
|
-
const count = await device.frames(numberOption(values.seconds, 'seconds', 3, {
|
|
184
|
-
min: 0.1,
|
|
185
|
-
max: 600,
|
|
186
|
-
integer: false,
|
|
187
|
-
}) * 1000, (frame) => {
|
|
188
|
-
first ??= frame.timestampUs;
|
|
189
|
-
const ms = Math.round((frame.timestampUs - first) / 1000);
|
|
190
|
-
writeFileSync(join(dir, `frame-${String(ms).padStart(6, '0')}ms.png`), encodePng(frame.width, frame.height, frame.data));
|
|
191
|
-
});
|
|
192
|
-
console.log(`${count} distinct frames in ${dir}`);
|
|
193
|
-
}
|
|
194
|
-
finally {
|
|
195
|
-
device.close();
|
|
196
|
-
}
|
|
197
|
-
return 0;
|
|
198
|
-
}
|
|
199
|
-
case 'wait-change': {
|
|
200
|
-
const device = connect();
|
|
201
|
-
try {
|
|
202
|
-
const changed = await device.waitForChange({
|
|
203
|
-
timeoutMs: numberOption(values.timeout, 'timeout', 5000, {
|
|
204
|
-
min: 1,
|
|
205
|
-
max: 3_600_000,
|
|
206
|
-
}),
|
|
207
|
-
});
|
|
208
|
-
console.log(changed ? 'changed' : 'no change');
|
|
209
|
-
return changed ? 0 : 1;
|
|
210
|
-
}
|
|
211
|
-
finally {
|
|
212
|
-
device.close();
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
case 'safe-area': {
|
|
216
|
-
const device = connect();
|
|
217
|
-
try {
|
|
218
|
-
const frame = await device.screenshot('rgb');
|
|
219
|
-
const report = checkSafeArea(frame.data, frame.width, frame.height, {
|
|
220
|
-
background: values.background
|
|
221
|
-
? parseColour(values.background)
|
|
222
|
-
: undefined,
|
|
223
|
-
margin: numberOption(values.margin, 'margin', 0.05, {
|
|
224
|
-
min: 0.01,
|
|
225
|
-
max: 0.25,
|
|
226
|
-
integer: false,
|
|
227
|
-
}),
|
|
228
|
-
});
|
|
229
|
-
console.log(`${report.clear ? 'clear' : 'NOT clear'}: left=${report.left} right=${report.right} top=${report.top} bottom=${report.bottom} (background ${formatColour(report.background)})`);
|
|
230
|
-
return report.clear ? 0 : 1;
|
|
231
|
-
}
|
|
232
|
-
finally {
|
|
233
|
-
device.close();
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
case 'mcp':
|
|
237
|
-
await serveStdio({ pid });
|
|
238
|
-
return -1; // keeps running until the client disconnects
|
|
239
|
-
default:
|
|
240
|
-
throw new Error(`unknown command "${command}". Run vvd --help.`);
|
|
241
|
-
}
|
|
268
|
+
// Own names only, so that "constructor" and the like are not commands.
|
|
269
|
+
if (!Object.hasOwn(COMMANDS, command))
|
|
270
|
+
throw new Error(`unknown command "${command}". Run vvd --help.`);
|
|
271
|
+
return COMMANDS[command]({ positionals, values, pid });
|
|
242
272
|
};
|
package/dist/device.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ export declare class Device {
|
|
|
42
42
|
}): Promise<boolean>;
|
|
43
43
|
frames(durationMs: number, onFrame: (frame: Frame) => void | Promise<void>, options?: {
|
|
44
44
|
intervalMs?: number;
|
|
45
|
+
maxFrames?: number;
|
|
45
46
|
signal?: AbortSignal;
|
|
46
47
|
}): Promise<number>;
|
|
47
48
|
listen(): () => AudioPacket[];
|
package/dist/device.js
CHANGED
|
@@ -154,10 +154,21 @@ export class Device {
|
|
|
154
154
|
}
|
|
155
155
|
return false;
|
|
156
156
|
}
|
|
157
|
-
// Calls `onFrame` with every distinct frame for `durationMs
|
|
158
|
-
//
|
|
157
|
+
// Calls `onFrame` with every distinct frame for `durationMs`, or until
|
|
158
|
+
// `maxFrames` of them have come; `maxFrames` is a whole number from 1, and
|
|
159
|
+
// anything else is refused before the first screenshot. Useful to check an
|
|
160
|
+
// animation: on the VVD a screenshot takes 23 to 61 ms while the screen
|
|
161
|
+
// changes, and a 220 ms slide comes through as 4 to 6 frames.
|
|
162
|
+
//
|
|
163
|
+
// The next screenshot waits for `onFrame`, so keep it quick: encoding a
|
|
164
|
+
// 1080p PNG takes 20 to 200 ms, and frames that come meanwhile are missed.
|
|
165
|
+
// Hold the frames and save them once the capture is over, as `vvd frames`
|
|
166
|
+
// does; `maxFrames` bounds the memory, about 6 MB a frame at 1080p.
|
|
159
167
|
async frames(durationMs, onFrame, options = {}) {
|
|
160
|
-
const { signal } = options;
|
|
168
|
+
const { signal, maxFrames = Infinity } = options;
|
|
169
|
+
const whole = Number.isInteger(maxFrames) && maxFrames >= 1;
|
|
170
|
+
if (!whole && maxFrames !== Infinity)
|
|
171
|
+
throw new RangeError(`maxFrames must be a whole number from 1, not ${maxFrames}`);
|
|
161
172
|
const endMs = Date.now() + durationMs;
|
|
162
173
|
let previous;
|
|
163
174
|
let count = 0;
|
|
@@ -169,6 +180,8 @@ export class Device {
|
|
|
169
180
|
previous = frame.data;
|
|
170
181
|
count += 1;
|
|
171
182
|
await onFrame(frame);
|
|
183
|
+
if (count >= maxFrames)
|
|
184
|
+
break;
|
|
172
185
|
}
|
|
173
186
|
if (options.intervalMs)
|
|
174
187
|
await sleep(options.intervalMs, undefined, { signal });
|
package/dist/discovery.js
CHANGED
|
@@ -58,41 +58,43 @@ const toPort = (value) => {
|
|
|
58
58
|
const port = Number(value);
|
|
59
59
|
return Number.isInteger(port) && port > 0 ? port : undefined;
|
|
60
60
|
};
|
|
61
|
+
// The emulator a discovery file describes, or undefined when the file names
|
|
62
|
+
// no gRPC port.
|
|
63
|
+
const readEmulator = (file, pid) => {
|
|
64
|
+
const fields = parseDiscovery(readFileSync(file, 'utf8'));
|
|
65
|
+
const grpcPort = toPort(fields.get('grpc.port'));
|
|
66
|
+
if (grpcPort === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
return Object.defineProperty({
|
|
69
|
+
pid,
|
|
70
|
+
file,
|
|
71
|
+
grpcPort,
|
|
72
|
+
consolePort: toPort(fields.get('port.serial')),
|
|
73
|
+
avdName: fields.get('avd.name') || undefined,
|
|
74
|
+
}, 'grpcToken', { value: fields.get('grpc.token') || undefined, enumerable: false });
|
|
75
|
+
};
|
|
61
76
|
// The running emulators with gRPC on, newest first. Stale files left by an
|
|
62
77
|
// emulator that has exited are skipped.
|
|
63
78
|
export const findEmulators = (options = {}) => {
|
|
64
79
|
const alive = options.alive ?? isAlive;
|
|
80
|
+
const wanted = (pid) => (options.pid === undefined || pid === options.pid) && alive(pid);
|
|
65
81
|
const found = [];
|
|
66
82
|
for (const directory of options.directories ?? runningDirectories()) {
|
|
67
83
|
if (!existsSync(directory))
|
|
68
84
|
continue;
|
|
69
85
|
for (const name of readdirSync(directory)) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const pid = Number(match[1]);
|
|
74
|
-
if (options.pid !== undefined && pid !== options.pid)
|
|
75
|
-
continue;
|
|
76
|
-
if (!alive(pid))
|
|
86
|
+
// NaN for a name that is no discovery file.
|
|
87
|
+
const pid = Number(/^pid_(\d+)\.ini$/.exec(name)?.[1]);
|
|
88
|
+
if (Number.isNaN(pid) || !wanted(pid))
|
|
77
89
|
continue;
|
|
78
90
|
const file = join(directory, name);
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
continue;
|
|
83
|
-
const emulator = Object.defineProperty({
|
|
84
|
-
pid,
|
|
85
|
-
file,
|
|
86
|
-
grpcPort,
|
|
87
|
-
consolePort: toPort(fields.get('port.serial')),
|
|
88
|
-
avdName: fields.get('avd.name') || undefined,
|
|
89
|
-
}, 'grpcToken', { value: fields.get('grpc.token') || undefined, enumerable: false });
|
|
90
|
-
found.push({ emulator, modified: statSync(file).mtimeMs });
|
|
91
|
+
const emulator = readEmulator(file, pid);
|
|
92
|
+
if (emulator)
|
|
93
|
+
found.push({ emulator, modified: statSync(file).mtimeMs });
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.map(({ emulator }) => emulator);
|
|
96
|
+
found.sort((a, b) => b.modified - a.modified);
|
|
97
|
+
return found.map(({ emulator }) => emulator);
|
|
96
98
|
};
|
|
97
99
|
export class NoDeviceError extends Error {
|
|
98
100
|
constructor() {
|
package/dist/mcp.js
CHANGED
|
@@ -40,6 +40,8 @@ export const videoPath = (file, overwrite = false) => {
|
|
|
40
40
|
throw new Error(`${path} exists; set overwrite to replace it`);
|
|
41
41
|
return path;
|
|
42
42
|
};
|
|
43
|
+
// The same emulator, serving the same endpoint with the same token.
|
|
44
|
+
const sameEndpoint = (a, b) => a.pid === b.pid && a.grpcPort === b.grpcPort && a.grpcToken === b.grpcToken;
|
|
43
45
|
export const createServer = (options = {}) => {
|
|
44
46
|
const server = new McpServer({ name: 'vega-vvd-driver', version: VERSION });
|
|
45
47
|
const find = { pid: options.pid, directories: options.directories };
|
|
@@ -48,13 +50,7 @@ export const createServer = (options = {}) => {
|
|
|
48
50
|
let device;
|
|
49
51
|
const connected = () => {
|
|
50
52
|
const [newest] = findEmulators(find);
|
|
51
|
-
|
|
52
|
-
if (device &&
|
|
53
|
-
current &&
|
|
54
|
-
newest &&
|
|
55
|
-
current.pid === newest.pid &&
|
|
56
|
-
current.grpcPort === newest.grpcPort &&
|
|
57
|
-
current.grpcToken === newest.grpcToken)
|
|
53
|
+
if (device && newest && sameEndpoint(device.emulator, newest))
|
|
58
54
|
return device;
|
|
59
55
|
device?.close();
|
|
60
56
|
device = undefined; // a failed connect below must not leave it cached
|
package/dist/record.js
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
// Frames are polled with getScreenshot and written to ffmpeg at a fixed frame
|
|
5
5
|
// rate, repeating the latest frame when the screen is still. The emulator's
|
|
6
6
|
// streamScreenshot was tried first and can stop delivering frames while the
|
|
7
|
-
// screen keeps changing
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// screen keeps changing. Polling does not stop: on the VVD a 1080p screenshot
|
|
8
|
+
// takes about 17 ms, or 23 to 61 ms while the screen changes. The audio comes
|
|
9
|
+
// from streamAudio. The emulator sends nothing while the device is silent, so
|
|
10
|
+
// the track is rebuilt on the video's clock from the packets' capture times,
|
|
11
|
+
// with silence in the gaps; see assembleAudio for how.
|
|
11
12
|
//
|
|
12
13
|
// However a recording ends, it cleans up after itself: ffmpeg is stopped, the
|
|
13
14
|
// audio stream is cancelled and the working directory is removed.
|
|
@@ -48,23 +49,91 @@ const abortable = (promise, signal) => {
|
|
|
48
49
|
};
|
|
49
50
|
const SAMPLE_RATE = 44100;
|
|
50
51
|
const FRAME_BYTES = 4; // 16-bit stereo
|
|
52
|
+
// A packet's capture time wanders by about ±10 ms around where the packet
|
|
53
|
+
// before it ends, and a stream starts with smaller packets (#13). Laid each at
|
|
54
|
+
// its own time, the packets of one sound would leave holes and cut into each
|
|
55
|
+
// other several times a second, which is heard as a rattle. So consecutive
|
|
56
|
+
// packets are laid back to back, the way the device played them, and each run
|
|
57
|
+
// of them is placed as a whole where the capture times agree best: their
|
|
58
|
+
// median. A run ends where the device sent nothing, a silence that leaves a
|
|
59
|
+
// step in time longer than SILENCE_US, and where it has drifted from the clock
|
|
60
|
+
// by more than DRIFT_US. Its first SETTLE_US are not held to the clock: there
|
|
61
|
+
// the small packets' capture times run ahead of their audio, by more than
|
|
62
|
+
// DRIFT_US at the start of some sounds, and splitting there would cut a hole
|
|
63
|
+
// just after the sound begins.
|
|
64
|
+
const SILENCE_US = 50_000;
|
|
65
|
+
const DRIFT_US = 100_000;
|
|
66
|
+
const SETTLE_US = 500_000;
|
|
67
|
+
const framesOf = (packet) => Math.floor(packet.pcm.length / FRAME_BYTES);
|
|
68
|
+
const usOf = (frames) => (frames / SAMPLE_RATE) * 1e6;
|
|
69
|
+
// The packets in the order they arrived, which is the order they were played,
|
|
70
|
+
// cut into runs at each silence.
|
|
71
|
+
const runsOf = (packets) => {
|
|
72
|
+
const runs = [];
|
|
73
|
+
let run = [];
|
|
74
|
+
for (const packet of packets) {
|
|
75
|
+
const last = run.at(-1);
|
|
76
|
+
if (last &&
|
|
77
|
+
Math.abs(packet.timestampUs - (last.timestampUs + usOf(framesOf(last)))) >
|
|
78
|
+
SILENCE_US) {
|
|
79
|
+
runs.push(run);
|
|
80
|
+
run = [];
|
|
81
|
+
}
|
|
82
|
+
run.push(packet);
|
|
83
|
+
}
|
|
84
|
+
if (run.length)
|
|
85
|
+
runs.push(run);
|
|
86
|
+
return runs;
|
|
87
|
+
};
|
|
88
|
+
// Where a run's first frame belongs on a clock that starts at `startUs`, in
|
|
89
|
+
// microseconds: each packet's capture time less the audio before it in the
|
|
90
|
+
// run, and the median of those, which jitter and the first small packets do
|
|
91
|
+
// not move.
|
|
92
|
+
const anchorOf = (run, startUs) => {
|
|
93
|
+
let before = 0;
|
|
94
|
+
const offsets = run.map((packet) => {
|
|
95
|
+
const offset = packet.timestampUs - startUs - usOf(before);
|
|
96
|
+
before += framesOf(packet);
|
|
97
|
+
return offset;
|
|
98
|
+
});
|
|
99
|
+
offsets.sort((a, b) => a - b);
|
|
100
|
+
return offsets[Math.floor(offsets.length / 2)];
|
|
101
|
+
};
|
|
102
|
+
// The run split where a packet, past the run's first SETTLE_US of audio, has
|
|
103
|
+
// drifted more than DRIFT_US from the place the run gives it, so a long sound
|
|
104
|
+
// follows the clock: the part before the first such packet, and the rest.
|
|
105
|
+
const steady = (run, startUs) => {
|
|
106
|
+
const anchor = anchorOf(run, startUs);
|
|
107
|
+
let before = 0;
|
|
108
|
+
for (let i = 0; i < run.length; i++) {
|
|
109
|
+
const drift = run[i].timestampUs - startUs - (anchor + usOf(before));
|
|
110
|
+
if (usOf(before) > SETTLE_US && Math.abs(drift) > DRIFT_US)
|
|
111
|
+
return [run.slice(0, i), run.slice(i)];
|
|
112
|
+
before += framesOf(run[i]);
|
|
113
|
+
}
|
|
114
|
+
return [[...run], []];
|
|
115
|
+
};
|
|
51
116
|
// Lays audio packets on a timeline that starts at `startUs` and lasts
|
|
52
|
-
// `seconds`, as 16-bit stereo PCM
|
|
53
|
-
//
|
|
54
|
-
//
|
|
117
|
+
// `seconds`, as 16-bit stereo PCM, with silence where nothing arrived. Audio
|
|
118
|
+
// placed before the start is cut, and past the end is dropped; the result is
|
|
119
|
+
// exactly as long as asked. Where two runs overlap, the later one is heard.
|
|
55
120
|
export const assembleAudio = (packets, startUs, seconds) => {
|
|
56
121
|
const track = Buffer.alloc(Math.round(seconds * SAMPLE_RATE) * FRAME_BYTES);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
122
|
+
const pending = runsOf(packets);
|
|
123
|
+
while (pending.length) {
|
|
124
|
+
const [run, rest] = steady(pending.shift(), startUs);
|
|
125
|
+
if (rest.length)
|
|
126
|
+
pending.unshift(rest);
|
|
127
|
+
let offset = Math.round((anchorOf(run, startUs) / 1e6) * SAMPLE_RATE);
|
|
128
|
+
for (const packet of run) {
|
|
129
|
+
const frames = framesOf(packet);
|
|
130
|
+
const from = Math.max(0, -offset);
|
|
131
|
+
const at = offset + from;
|
|
132
|
+
offset += frames;
|
|
133
|
+
if (from >= frames || at * FRAME_BYTES >= track.length)
|
|
134
|
+
continue;
|
|
135
|
+
packet.pcm.copy(track, at * FRAME_BYTES, from * FRAME_BYTES, frames * FRAME_BYTES);
|
|
64
136
|
}
|
|
65
|
-
if (from >= frames || offset * FRAME_BYTES >= track.length)
|
|
66
|
-
continue;
|
|
67
|
-
packet.pcm.copy(track, offset * FRAME_BYTES, from * FRAME_BYTES, frames * FRAME_BYTES);
|
|
68
137
|
}
|
|
69
138
|
return track;
|
|
70
139
|
};
|
|
@@ -244,7 +313,7 @@ export const record = async (device, options) => {
|
|
|
244
313
|
}
|
|
245
314
|
finally {
|
|
246
315
|
stopAudio?.();
|
|
247
|
-
if (encoder
|
|
316
|
+
if (encoder?.exitCode === null && encoder.signalCode === null) {
|
|
248
317
|
encoder.stdin.destroy();
|
|
249
318
|
encoder.kill('SIGKILL');
|
|
250
319
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fortemate/vega-vvd-driver",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Unofficial driver for the Vega Virtual Device: remote keys, screenshots and video with sound, from scripts and AI agents. CLI, Node library and MCP server.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vega",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"format": "prettier --write .",
|
|
47
47
|
"format:check": "prettier --check .",
|
|
48
48
|
"test": "node --test test/*.test.ts",
|
|
49
|
+
"coverage": "node -e \"require('node:fs').mkdirSync('coverage', { recursive: true })\" && node --test --experimental-test-coverage --test-coverage-exclude='test/**' --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info test/*.test.ts",
|
|
49
50
|
"test:device": "VVD_INTEGRATION=1 node --test test/device.test.ts",
|
|
50
51
|
"prepare": "npm run build"
|
|
51
52
|
},
|