Skip to content

feat(hardware-key): press volume, power, media, and TV keys on Android - #3817

Open
mikolajadamowicz wants to merge 7 commits into
callstack:mainfrom
mikolajadamowicz:feat/hardware-keys
Open

mikolajadamowicz wants to merge 7 commits into
callstack:mainfrom
mikolajadamowicz:feat/hardware-keys

Conversation

@mikolajadamowicz

@mikolajadamowicz mikolajadamowicz commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds hardware-key, one command that presses a single hardware key through Android ADB key events. 22 named keys (volume, mute, power/sleep/wake, five media keys, channel/guide/info/captions/tv-input/four color keys), plus android-keycode:<n> for vendor remote buttons no name covers. tv-remote covers D-pad navigation; none of these keys had a route before, so agents fell back to raw adb shell input keyevent.

Presses ride the system-button family's admission and binding: hardwareKey is one more fact cell, so every owner without a key-event channel (Apple, web, Linux, HarmonyOS, WebDriver, Limrun iOS) refuses with UNSUPPORTED_OPERATION through its existing denial, and Limrun Android is admitted like the local family. 49 files, ~346 production lines; the rest is tests, coverage tables, and docs.

agent-device hardware-key volume-down
agent-device hardware-key media-play-pause
agent-device hardware-key android-keycode:300

Validation

Device evidence at 31a9dc2; rebased to 3204bcf, range-diff clean.

  • Live on Android phone emulator (API 37) and Android TV emulator (API 36): all 23 tokens sent; an adb shim captured the exact input keyevent <n> argv for every one; volume-*/mute moved volume_music_speaker and dumpsys audio; sleep/wake/power moved mWakefulness. tv-remote, home, and app-switcher regression-checked on the same session.
  • At 31a9dc2: pnpm check:affected --run green except symlink tests that fail identically on base eef1a3f (code-signature, runner-artifact-manifest; sandbox /private/tmp). Also check:fallow, lint, typecheck, format, mcp-metadata, unit suite (14914 passed).
  • At 3204bcf: typecheck passes; the affected gate stops at the registry.ts max-lines cap from chore: enforce file size limits with oxlint #3804 (see my comment).
  • Final chore commit drops six fallow suppressions already stale on main; verified droppable in a base worktree.

Add `agent-device hardware-key <key>` and `client.command.hardwareKey()`:
one press of a volume (`volume-up`, `volume-down`, `mute`), power (`power`,
`sleep`, `wake`), or media (`media-play-pause`, `media-next`,
`media-previous`, `media-rewind`, `media-fast-forward`) key. It covers QA
checks such as volume and mute handling, standby and wake during playback,
and media-key handling in a video-streaming app.

Android emulators and devices send the key as `adb shell input keyevent`
with the numeric KeyEvent code, locally and on Limrun Android sessions, which
ride the same Android interactor as `home` and `app-switcher`. The key
vocabulary and its Android keycodes sit beside the TV remote table in
`contracts/tv-remote.ts`, and the press joins the system-button family as its
one control with an argument, so every owner that does not name the cell
refuses it with that family's denial. Both homes are modules the CLI, daemon,
and runtime closures already evaluate, so no eager closure grows.

The Apple runner exposes only the home and Action Button presses, so iOS,
tvOS, and macOS refuse the key, as do web, Linux, Vega OS, HarmonyOS, Limrun
iOS, and the WebDriver (Appium) backend. Like `action-button`, the command
declares no post-action observation: the key goes to the system and the media
session, and power or sleep leaves the display off. Session events summarize
the press with its key, and `help tv` points Android TV playback checks at it.
…ables

The six-platform coverage declaration and its Android and Apple evidence
rows, the runtime-binding conformance table, and the Apple provider output
guard's driven set enumerate the public surface, so each one names
`hardware-key`.
The generic dispatcher grew a dedicated hardware-key arm, which with the
telephony and uninstall arms from main put resolveGenericRuntimeExecution
past the cyclomatic bar. The system-button module now owns one entry point
for its no-argument buttons and its keyed control, so the dispatcher keeps a
single arm for the family, and the request positionals are read once instead
of per arm.
Add channel-up, channel-down, last-channel, guide, info, captions,
tv-input, and the four color keys to `hardware-key`. Each name maps to its
standard Android KeyEvent keycode (166, 167, 229, 172, 165, 175, 178,
183-186), so a QA run on a streaming app's Android TV build can drive the
channel and program-guide buttons a TV remote sends. `tv-remote` has none
of them.
Vendor Android TV remotes carry buttons that no standard KeyEvent name
covers, such as operator-specific channel or app keys. `hardware-key
android-keycode:<n>` sends keycode n (1 to 2147483647) through the same
`input keyevent` path as the named keys, so such a button can be driven
without leaving agent-device for a raw adb call, and the press still lands
in the session log and in replay scripts as a positional.

The token rides the existing `key` input instead of a new flag: the
parser, the Android keycode mapping, and the session-log reader each accept
it, and the MCP input becomes a described string, because the schema
vocabulary has no way to express "one of these names or this pattern".
This commit stands alone and can be dropped without touching the rest of
the command.
…ption

The help topic and command description hand-listed keys the HARDWARE_KEY_DEFINITIONS
table owns; the topic copy already omitted power, sleep, and wake. Render
HARDWARE_KEY_USAGE instead and keep only what the usage line cannot say.
Fallow reports no unused-type issue under these directives, so the gate charges the branch
for them once client-types joins its change set. Verified stale on origin/main with the file
alone in scope: removing the directives leaves the re-exports and the published surface
unchanged.
@mikolajadamowicz

Copy link
Copy Markdown
Contributor Author

Rebased onto current main. Head is 3204bcf.

One lint failure is left and I want your call before touching it. #3804 pinned registry.ts at 2179 lines, which is main's exact length today. This PR adds the hardware-key descriptor plus one import, 12 lines, and the file lands at 2191:

packages/command-registry/src/registry.ts:2191:2: error eslint(max-lines): File has too many lines (2191). help: Maximum allowed is 2179.

A new command has to add its descriptor to RAW_COMMAND_DESCRIPTORS, so I can't get these 12 lines under the cap no matter how compactly I write them. CONTRIBUTING says oversized files should split along module boundaries, and per ADR 0027 the split needs to be statement-neutral, which looks like its own PR.

How do you want new commands to land under the new cap? Options I see: raise the cap here, split the registry first as a separate PR, or something else. I didn't want to move a cap you set this morning on my own.

Everything else I could run locally is green: typecheck passes, and the gate stops at this one lint error.

@thymikee

Copy link
Copy Markdown
Member

Thanks for the PR. I found no code defects at 3204bcf, but it is not ready yet: two CI failures come from this diff, and I have a design question at the end. The live ADB run on phone and TV emulators is as you reported it; I did not reproduce it. No conflicts.

Two checks fail, and the diff causes both. Lint fails because the +12 lines in packages/command-registry/src/registry.ts take the file from 2179, exactly the cap, to 2191. Integration fails because integration-progress reports no provider-scenario reference for the new public command hardware-key (58/59). Smoke Tests are still running.

Before merge, please add a provider-scenario test for hardware-key, modeled on android-tv-remote.test.ts. It should send a named key and android-keycode:N through the daemon, assert the shell input keyevent <n> argv and the {action,key,message} response, and assert that a bad key returns INVALID_ARGS before any adb call. The registry cap needs your decision: raise the per-file cap in oxlint.config.ts, or land a statement-neutral split of RAW_COMMAND_DESCRIPTORS first (ADR 0027) and rebase onto it.

Not blocking: the six dropped suppressions in src/client/client-types.ts are unrelated to hardware-key, and I did not confirm they were stale.

Could hardware-key be modeled the way tv-remote is, as a keyed control? That would mean a standalone hardwareKey operation with its own binder beside bindTvRemote, a case 'hardware-key' in generic-runtime-execution, and the key vocabulary in a dedicated contracts/hardware-key.ts. The system-button family would stay one no-argument row per command, with no SystemControl union or dispatcher arm. The cost is that each owner declares the denial, which the helper at platform-runtime-unavailable.ts:248 can default. Could the Appium Android leg also admit the key through the existing mobile: pressKey, so the WebDriver carve-out goes away?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants