@fortemate/vega-vvd-driver 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jegors Čemisovs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # vega-vvd-driver
2
+
3
+ Drive the Vega Virtual Device from scripts and AI agents: press remote keys, take screenshots, record video with sound, wait for the screen to change and check the TV safe area. A Node library, the `vvd` command and an MCP server.
4
+
5
+ > **Unofficial.** Not made, endorsed or supported by Amazon. Amazon, Fire TV and Vega are trademarks of Amazon.com, Inc. or its affiliates.
6
+
7
+ ## Why
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.
10
+
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
+
13
+ Its methods were worked out while building [Dice Chess for Fire TV](https://github.com/fortemate/dicechess-tv): scripts drove whole sessions with nobody at the emulator, took every screenshot of the game's gallery, checked its move animations frame by frame and recorded its [demo video](https://www.youtube.com/watch?v=Q7wWAmUp2Sc). The obstacles on the way are in that project's [friction log](https://fortemate.github.io/dicechess-tv/friction-log/) for Amazon, as FL-08, FL-09 and FL-28.
14
+
15
+ ## Appium or this driver?
16
+
17
+ | To… | Use |
18
+ | --------------------------------------------------- | ---------------------- |
19
+ | find elements, read their text, run a test suite | Appium |
20
+ | test on a Fire TV Stick | Appium |
21
+ | press keys or take a screenshot from a shell script | this driver, or Appium |
22
+ | record a video with sound | this driver |
23
+ | capture every frame of an animation | this driver |
24
+ | let an AI agent see and operate the VVD over MCP | this driver |
25
+ | check the TV safe area | this driver |
26
+
27
+ ## What you need
28
+
29
+ - The Vega SDK with its Virtual Device. The driver was built and tested with SDK 0.24.12112 on macOS (Apple silicon). The Linux locations are included but untested.
30
+ - Node 20 or later.
31
+ - ffmpeg, only for `record`.
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ npm install -g @fortemate/vega-vvd-driver
37
+ ```
38
+
39
+ Or inside a project: `npm install --save-dev @fortemate/vega-vvd-driver`, then `npx vvd`.
40
+
41
+ To work on the driver itself, clone the repository, then run `npm ci` and `npm link`. Installing straight from Git with `npm install -g github:fortemate/vega-vvd-driver` does not work, at least with npm 11.19: for a global install from Git, npm runs the build without installing its dependencies, and it fails with `tsc: command not found`.
42
+
43
+ ## Quick start
44
+
45
+ ```sh
46
+ vega virtual-device start --no-gui # the window is optional
47
+ vvd enable-grpc # needed after every start of the device
48
+ vvd screenshot home.png
49
+ vvd press down down ok
50
+ vvd record demo.mp4 --seconds 20
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ | Command | What it does |
56
+ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
57
+ | `vvd devices` | Lists running devices whose gRPC endpoint is on |
58
+ | `vvd enable-grpc [--port 8554] [--console-port 5554]` | Turns gRPC on through the emulator console. It is off after every start of the device |
59
+ | `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
+ | `vvd screenshot [file]` | Saves the screen as a PNG |
61
+ | `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]` | Saves every distinct frame as a PNG, named by the emulator's time |
63
+ | `vvd wait-change [--timeout 5000]` | Exits 0 once the screen changes, 1 on timeout |
64
+ | `vvd safe-area [--background #rrggbb] [--margin 0.05]` | Counts what sits in the outer 5% of each edge. Exits 0 when clear |
65
+ | `vvd mcp` | Runs the MCP server on stdio |
66
+
67
+ Quote a repeated key in a shell, `'down*3'`, or zsh reads the `*` as a file pattern. When several devices run, `--pid <n>` picks one; `enable-grpc` picks a device's console with `--console-port` instead. Errors exit with 2, so that a script can tell them from "no change" and "not clear", which exit with 1.
68
+
69
+ ## For AI agents: the MCP server
70
+
71
+ The server lets an agent operate the device and see the result. With Claude Code:
72
+
73
+ ```sh
74
+ claude mcp add vvd -- vvd mcp
75
+ ```
76
+
77
+ Other MCP clients start the command `vvd` with the argument `mcp`. With the driver installed inside a project, the command is `npx vvd mcp`.
78
+
79
+ | Tool | What it does |
80
+ | ----------------- | ------------------------------------------------------------------- |
81
+ | `list_devices` | The running devices with gRPC on |
82
+ | `enable_grpc` | Turns gRPC on after a start of the device |
83
+ | `press_keys` | Presses keys; with `screenshot_after`, returns the resulting screen |
84
+ | `screenshot` | The screen as a PNG image |
85
+ | `wait_for_change` | Waits until the screen changes, then returns it |
86
+ | `record_video` | Records an MP4 with sound |
87
+ | `check_safe_area` | Counts what sits in the TV safe-area margin |
88
+
89
+ Then ask, for example: "Open Settings in the app on the Virtual Device, turn the music off and show me the screen."
90
+
91
+ A model chooses the arguments, so they are bounded: at most 100 key presses per call, recordings of up to 600 seconds, and `record_video` writes only a new `.mp4`, `.mov` or `.mkv` file, unless it is told to `overwrite` one. When the client cancels a call, its presses, wait or recording stop. `vvd mcp --pid <n>` ties the server to one device.
92
+
93
+ ## As a library
94
+
95
+ ```ts
96
+ import { Device, record } from '@fortemate/vega-vvd-driver';
97
+
98
+ const device = Device.connect(); // the newest running VVD with gRPC on
99
+ await device.press(['down', 'down', 'ok']);
100
+ const png = await device.screenshot('png');
101
+ await record(device, {
102
+ file: 'demo.mp4',
103
+ seconds: 10,
104
+ onStart: () => device.press(['right*3']),
105
+ });
106
+ device.close();
107
+ ```
108
+
109
+ `device.waitForChange()`, `device.frames()` and `checkSafeArea()` cover the rest. Every call has a deadline (10 seconds, or `callTimeoutMs` given to `Device.connect`), so a stuck emulator fails a call instead of hanging it. `press`, `waitForChange`, `frames` and `record` take an AbortSignal as `signal`, and a cancelled press still releases its key. `onStart` runs alongside the recording, which ends when both have. See `src/index.ts` for everything exported.
110
+
111
+ ## Recipes
112
+
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, a 220 ms move slide in Dice Chess came through as 10 to 11 frames.
114
+
115
+ **Record a demo.** Script the presses, record while they run, and cut the best takes afterwards. Recording works without the device's window.
116
+
117
+ ```sh
118
+ vvd record take.mp4 --seconds 30 &
119
+ sleep 2 && vvd press 'ok*20' --gap 1300
120
+ wait
121
+ ```
122
+
123
+ **Check the safe area.** Open a screen of the app and run `vvd safe-area --background '#122737'` with the app's background colour. It works on screens with a solid background; the launcher's gradient does not count as one.
124
+
125
+ ## What works, and what silently does not
126
+
127
+ Measured on the VVD with Vega SDK 0.24.12112 on macOS:
128
+
129
+ - **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
+ - **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
+ - **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 90 screenshots a second; `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.
133
+ - **Audio.** `streamAudio` sends nothing while the device is silent. `record` rebuilds the track on the video's clock from each packet's capture time and fills the gaps with silence, so a sound effect stays in sync.
134
+ - **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
+
136
+ ## Troubleshooting
137
+
138
+ - **"No running Vega Virtual Device with gRPC found."** Start the device, then run `vvd enable-grpc`.
139
+ - **"emulator_controller.proto not found."** Set `VVD_PROTO_DIR` to the directory in your Vega SDK that holds it (`…/vvd/images/tv/vmtools/agent/lib`).
140
+ - **Several devices.** Pick one with `--pid`, from `vvd devices`.
141
+ - **"The Vega Virtual Device did not answer in time."** The emulator is busy or stuck. Restart the device if it keeps happening.
142
+ - **`record` fails at once.** Install ffmpeg.
143
+
144
+ ## How it works
145
+
146
+ A running emulator with gRPC on writes `pid_<pid>.ini` into a per-user directory (`~/Library/Caches/TemporaryItems/avd/running` on macOS). It holds the gRPC port and a bearer token. The driver reads it, loads `emulator_controller.proto` from your own Vega SDK, and calls the emulator's `EmulatorController` service. To turn gRPC on, it signs in to the emulator console on port 5554 with the token in `~/.emulator_console_auth_token`.
147
+
148
+ - Tokens are read at run time and never printed or logged. On the objects the library returns, the gRPC token is not enumerable, so it stays out of JSON and `util.inspect`. The console token goes only to a port that greets as an Android emulator console.
149
+ - No file from the Vega SDK is copied or bundled: Amazon licenses the SDK to each developer. The proto is Android emulator code under Apache-2.0, and it is loaded from your installation.
150
+
151
+ ## Development
152
+
153
+ See [CONTRIBUTING.md](CONTRIBUTING.md). In short: `mise run setup`, then `mise run check`; with a device running and gRPC on, `npm run test:device`. The unit tests run the gRPC, console and recording paths against fakes, so they need neither a device nor ffmpeg.
154
+
155
+ ## Licence
156
+
157
+ [MIT](LICENSE). Made by Jegors Čemisovs at [Fortemate](https://github.com/fortemate).
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // The executable behind `vvd`. Kept apart from cli.ts so that the tests can
3
+ // import the command line without running it.
4
+ import { main } from "./cli.js";
5
+ main(process.argv.slice(2)).then((code) => {
6
+ // -1: a long-running command (the MCP server) that exits on its own.
7
+ if (code >= 0)
8
+ process.exitCode = code;
9
+ }, (error) => {
10
+ // 2, so that scripts can tell an error from wait-change's "no change".
11
+ console.error(`vvd: ${error.message ?? String(error)}`);
12
+ process.exitCode = 2;
13
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ export declare const USAGE: string;
2
+ export type Parsed = {
3
+ command: string | undefined;
4
+ positionals: string[];
5
+ values: {
6
+ pid?: string;
7
+ port?: string;
8
+ 'console-port'?: string;
9
+ gap?: string;
10
+ seconds?: string;
11
+ fps?: string;
12
+ 'no-audio'?: boolean;
13
+ timeout?: string;
14
+ background?: string;
15
+ margin?: string;
16
+ help?: boolean;
17
+ version?: boolean;
18
+ };
19
+ };
20
+ export declare const parseCli: (argv: readonly string[]) => Parsed;
21
+ export declare const numberOption: (value: string | undefined, name: string, fallback: number, { min, max, integer }?: {
22
+ min?: number | undefined;
23
+ max?: number | undefined;
24
+ integer?: boolean | undefined;
25
+ }) => number;
26
+ export declare const main: (argv: readonly string[]) => Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,242 @@
1
+ // The `vvd` command line.
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { enableGrpc } from "./console.js";
6
+ import { Device } from "./device.js";
7
+ import { findEmulators } from "./discovery.js";
8
+ import { parseKeys } from "./keys.js";
9
+ import { serveStdio } from "./mcp.js";
10
+ import { encodePng } from "./png.js";
11
+ import { record } from "./record.js";
12
+ import { checkSafeArea, formatColour, parseColour } from "./safearea.js";
13
+ import { VERSION } from "./version.js";
14
+ export const USAGE = `vvd ${VERSION}: drive the Vega Virtual Device from scripts and AI agents.
15
+
16
+ Usage: vvd <command> [options]
17
+
18
+ Commands:
19
+ devices list running devices whose gRPC endpoint is on
20
+ enable-grpc turn gRPC on (it is off after every start of the device)
21
+ --port <n> gRPC port, default 8554
22
+ --console-port <n> emulator console port, even, 5554 to 5682; default 5554
23
+ press <key...> press remote keys in order
24
+ up down left right ok back menu playpause rewind
25
+ fastforward, a KEY_* name or an evdev code;
26
+ down*3 repeats, ok:down / ok:up hold and release
27
+ --gap <ms> pause after each key, default 450
28
+ screenshot [file] save the screen as a PNG, default vvd-<time>.png
29
+ record <file> record the screen with sound to an MP4 (needs ffmpeg)
30
+ --seconds <n> default 10
31
+ --fps <n> default 30
32
+ --no-audio video only
33
+ frames <dir> save every distinct frame as a PNG, to check animations
34
+ --seconds <n> default 3
35
+ wait-change exit 0 once the screen changes, 1 on a timeout
36
+ --timeout <ms> default 5000
37
+ safe-area check the outer 5% of the screen against the background;
38
+ exit 0 when clear, 1 when something sits in the margin
39
+ --background <#rrggbb> default: the most common colour in the margin
40
+ --margin <fraction> default 0.05
41
+ mcp run the MCP server on stdio, for AI agents
42
+
43
+ Options:
44
+ --pid <n> the device to drive when several run
45
+ (every command but enable-grpc, which uses
46
+ --console-port)
47
+ -h, --help, -v, --version
48
+
49
+ Exit status: 0 on success, 1 for "no change" and "not clear", 2 on an error.
50
+ `;
51
+ export const parseCli = (argv) => {
52
+ const { values, positionals } = parseArgs({
53
+ args: [...argv],
54
+ allowPositionals: true,
55
+ strict: true,
56
+ options: {
57
+ pid: { type: 'string' },
58
+ port: { type: 'string' },
59
+ 'console-port': { type: 'string' },
60
+ gap: { type: 'string' },
61
+ seconds: { type: 'string' },
62
+ fps: { type: 'string' },
63
+ 'no-audio': { type: 'boolean' },
64
+ timeout: { type: 'string' },
65
+ background: { type: 'string' },
66
+ margin: { type: 'string' },
67
+ help: { type: 'boolean', short: 'h' },
68
+ version: { type: 'boolean', short: 'v' },
69
+ },
70
+ });
71
+ const [command, ...rest] = positionals;
72
+ return { command, positionals: rest, values };
73
+ };
74
+ // A whole number option, or its default.
75
+ export const numberOption = (value, name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER, integer = true } = {}) => {
76
+ if (value === undefined)
77
+ return fallback;
78
+ const parsed = Number(value);
79
+ if (!Number.isFinite(parsed) ||
80
+ (integer && !Number.isInteger(parsed)) ||
81
+ parsed < min ||
82
+ parsed > max)
83
+ throw new Error(`--${name} must be ${integer ? 'a whole number' : 'a number'} from ${min} to ${max}`);
84
+ return parsed;
85
+ };
86
+ const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
87
+ export const main = async (argv) => {
88
+ const { command, positionals, values } = parseCli(argv);
89
+ if (values.version) {
90
+ console.log(VERSION);
91
+ return 0;
92
+ }
93
+ if (values.help || !command) {
94
+ console.log(USAGE);
95
+ return command || values.help ? 0 : 2;
96
+ }
97
+ const pid = values.pid === undefined
98
+ ? undefined
99
+ : numberOption(values.pid, 'pid', 0, { min: 1 });
100
+ const connect = () => Device.connect({ pid });
101
+ switch (command) {
102
+ case 'devices': {
103
+ const devices = findEmulators({ pid });
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
+ }
242
+ };
@@ -0,0 +1,9 @@
1
+ export type ConsoleOptions = {
2
+ port?: number;
3
+ host?: string;
4
+ token?: string;
5
+ timeoutMs?: number;
6
+ };
7
+ export declare const runConsole: (commands: readonly string[], options?: ConsoleOptions) => Promise<string[]>;
8
+ export declare const isConsolePort: (port: number) => boolean;
9
+ export declare const enableGrpc: (grpcPort?: number, options?: ConsoleOptions) => Promise<void>;
@@ -0,0 +1,87 @@
1
+ // The emulator console: a line-based TCP service on localhost (5554 for the
2
+ // first emulator). It authenticates with the token in
3
+ // ~/.emulator_console_auth_token, which is sent and never printed.
4
+ //
5
+ // The driver needs it for one thing: `grpc <port>` turns on the gRPC endpoint,
6
+ // which is off after every start of the VVD, and only then does the discovery
7
+ // file appear. Keys do not go through here: the console's `event send` is
8
+ // accepted and never reaches an app.
9
+ import { readFileSync } from 'node:fs';
10
+ import { connect } from 'node:net';
11
+ import { consoleTokenFile } from "./discovery.js";
12
+ // Every reply ends with a line that starts with OK or KO.
13
+ const finished = (text) => /(^|\n)(OK|KO)[^\n]*\r?\n$/.test(text);
14
+ // Runs console commands in order and returns each reply. A command answered
15
+ // with KO rejects with the console's own message.
16
+ export const runConsole = (commands, options = {}) => new Promise((resolve, reject) => {
17
+ const token = options.token ?? readFileSync(consoleTokenFile(), 'utf8').trim();
18
+ const port = options.port ?? 5554;
19
+ const socket = connect(port, options.host ?? '127.0.0.1');
20
+ const queue = [`auth ${token}`, ...commands];
21
+ const replies = [];
22
+ let buffer = '';
23
+ let greeted = false;
24
+ let settled = false;
25
+ const settle = (outcome) => {
26
+ if (settled)
27
+ return;
28
+ settled = true;
29
+ clearTimeout(timer);
30
+ outcome();
31
+ };
32
+ const fail = (error) => settle(() => {
33
+ socket.destroy();
34
+ reject(error);
35
+ });
36
+ const timer = setTimeout(() => fail(new Error('the emulator console did not answer in time')), options.timeoutMs ?? 10_000);
37
+ const next = () => {
38
+ const command = queue.shift();
39
+ if (command === undefined) {
40
+ settle(() => {
41
+ socket.end('quit\r\n');
42
+ resolve(replies.slice(1)); // the first reply is to auth
43
+ });
44
+ return;
45
+ }
46
+ socket.write(`${command}\r\n`);
47
+ };
48
+ socket.setEncoding('utf8');
49
+ socket.on('error', fail);
50
+ socket.on('close', () => fail(new Error('the emulator console closed the connection')));
51
+ socket.on('data', (chunk) => {
52
+ buffer += chunk;
53
+ if (!finished(buffer))
54
+ return;
55
+ const reply = buffer;
56
+ buffer = '';
57
+ if (!greeted) {
58
+ // An emulator console greets with lines that start "Android Console",
59
+ // then OK. Whatever else answers on this port never gets the token.
60
+ if (!reply.includes('Android Console')) {
61
+ fail(new Error(`port ${port} is not an Android emulator console`));
62
+ return;
63
+ }
64
+ greeted = true;
65
+ next();
66
+ return;
67
+ }
68
+ const last = reply.trimEnd().split('\n').pop() ?? '';
69
+ if (last.startsWith('KO')) {
70
+ const sent = replies.length === 0 ? 'auth' : commands[replies.length - 1];
71
+ fail(new Error(`console refused "${sent.split(' ')[0]}": ${last.replace(/^KO:?\s*/, '').trim()}`));
72
+ return;
73
+ }
74
+ replies.push(reply);
75
+ next();
76
+ });
77
+ });
78
+ // The emulator takes an even console port from 5554 to 5682.
79
+ export const isConsolePort = (port) => Number.isInteger(port) && port >= 5554 && port <= 5682 && port % 2 === 0;
80
+ // Turns on the emulator's gRPC endpoint. Safe to repeat.
81
+ export const enableGrpc = async (grpcPort = 8554, options = {}) => {
82
+ if (!isConsolePort(options.port ?? 5554))
83
+ throw new Error('the emulator console port is an even number from 5554 to 5682');
84
+ if (!Number.isInteger(grpcPort) || grpcPort < 1024 || grpcPort > 65535)
85
+ throw new Error('the gRPC port must be a whole number from 1024 to 65535');
86
+ await runConsole([`grpc ${grpcPort}`], options);
87
+ };
@@ -0,0 +1,48 @@
1
+ import { type Emulator, type FindOptions } from './discovery.ts';
2
+ import { type KeyStep } from './keys.ts';
3
+ export declare const CALL_TIMEOUT_MS = 10000;
4
+ export type ConnectOptions = FindOptions & {
5
+ protoDirectory?: string;
6
+ callTimeoutMs?: number;
7
+ };
8
+ export type PressOptions = {
9
+ gapMs?: number;
10
+ holdMs?: number;
11
+ signal?: AbortSignal;
12
+ };
13
+ export type CallOptions = {
14
+ signal?: AbortSignal;
15
+ deadline?: number;
16
+ };
17
+ export type Frame = {
18
+ width: number;
19
+ height: number;
20
+ data: Buffer;
21
+ timestampUs: number;
22
+ };
23
+ export type AudioPacket = {
24
+ timestampUs: number;
25
+ pcm: Buffer;
26
+ };
27
+ export declare class TimeoutError extends Error {
28
+ constructor();
29
+ }
30
+ export declare class Device {
31
+ #private;
32
+ readonly emulator: Emulator;
33
+ private constructor();
34
+ static connect(options?: ConnectOptions): Device;
35
+ close(): void;
36
+ press(keys: readonly string[] | readonly KeyStep[], options?: PressOptions): Promise<void>;
37
+ screenshot(format?: 'png' | 'rgb', options?: CallOptions): Promise<Frame>;
38
+ waitForChange(options?: {
39
+ timeoutMs?: number;
40
+ intervalMs?: number;
41
+ signal?: AbortSignal;
42
+ }): Promise<boolean>;
43
+ frames(durationMs: number, onFrame: (frame: Frame) => void | Promise<void>, options?: {
44
+ intervalMs?: number;
45
+ signal?: AbortSignal;
46
+ }): Promise<number>;
47
+ listen(): () => AudioPacket[];
48
+ }