Skip to content

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

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

mikolajadamowicz wants to merge 21 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, a command that presses one hardware key. It accepts 22 named keys (volume, mute, power/sleep/wake, five media keys, channel/guide/info/captions/tv-input, and four color keys) plus android-keycode:<n> for vendor remote buttons that no name covers. tv-remote handles D-pad navigation, but none of these keys had a route before, so agents fell back to raw adb shell input keyevent.

hardwareKey is its own keyed operation, with facts and a binder next to tv-remote and its own case in generic-runtime-execution. Local Android and Limrun Android send input keyevent <n>. Appium Android sends mobile: pressKey. Every other owner refuses with UNSUPPORTED_OPERATION. About 520 production lines; the rest is tests, coverage tables, and docs.

agent-device hardware-key volume-down
agent-device hardware-key android-keycode:300

Validation

At 72d7e99 (on main 7861075). The device run was at ff08e4b. Later commits change only a lazy import, help text, and hints:

  • Live on the Android TV emulator: volume-down moved stream 3 from 11 to 10, android-keycode:24 moved it back to 11, and volume-sideways returned INVALID_ARGS. Transcript is in the PR comments.
  • New provider scenario android-hardware-key.test.ts. integration-progress reports 59/59.
  • pnpm check:affected --run: everything passes except the runner-artifact-manifest symlink tests, which fail the same way in a clean worktree. The full unit-ci suite also passes locally apart from those tests and the matching code-signature symlink tests.
  • Not run: a live Appium Android session. No cloud credentials here, so mobile: pressKey has only the stub-client unit test.

@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?

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.
…ation

Move the key vocabulary to contracts/hardware-key.ts and give hardwareKey
its own operation, facts, and binder beside tv-remote. The daemon routes it
through its own case in generic-runtime-execution, so the system-button
family keeps one no-argument row per command.
…sKey

Android admits hardware-key through the same route the keyboard Enter
press uses. XCUITest has no key press, so the iOS leg still refuses.
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.
…remote modules

The CLI command metadata, the interactor catalog, and the unavailable-facts
hub read the key table and binder synchronously, so a dedicated module
grows each eager closure past its merge-base budget. tv-remote.ts and
tv-remote-runtime.ts already sit in those closures. The Appium leg loads
the keycode table on demand because its closure never reached tv-remote.
@mikolajadamowicz

Copy link
Copy Markdown
Contributor Author

Rebased onto main 7861075. The head is ff08e4b49.

Your two blockers

  • Provider scenario: added test/integration/provider-scenarios/android-hardware-key.test.ts, modeled on android-tv-remote.test.ts. It sends volume-down and android-keycode:300 through the daemon, asserts the shell input keyevent 25 and shell input keyevent 300 argv and the {action,key,message} response, and checks that volume-sideways returns INVALID_ARGS without adding an adb call. integration-progress now reports 59/59.
  • Registry cap: I raised the registry.ts cap in oxlint.config.ts to 2191 instead of splitting RAW_COMMAND_DESCRIPTORS first. If you'd rather have the statement-neutral split land first, I'll do it as a separate PR and rebase onto it.

Design question

hardwareKey is now a standalone operation: its own facts, bindHardwareKey next to bindTvRemote, a case 'hardware-key' in generic-runtime-execution, and no SystemControl union or dispatcher arm in the system-button family. Every owner declares its own denial, and the unavailable hub defaults it through hardwareKeyRuntimeOperationFacts.

I didn't keep a dedicated contracts/hardware-key.ts. The CLI command metadata, the interactor catalog, and the unavailable hub all read the key table or binder synchronously, so a new module grows src/cli.ts, interactor-operation-catalog.ts, and platform-runtime-unavailable.ts past their eager-closure budgets. The key table now sits in tv-remote.ts and the binder in tv-remote-runtime.ts, which those closures already load.

The Appium Android leg now admits the key through mobile: pressKey, so the WebDriver carve-out is gone. XCUITest has no key press, so iOS still refuses.

Other changes

  • I kept the six client-types.ts suppression drops. fallow audit flags all six as stale once the PR touches that file, so the gate fails without the drop.
  • Hoisted params.req.positionals ?? [] in resolveGenericRuntimeExecution. The new case pushed it past fallow's cyclomatic threshold.
  • Moved the WebDriver hardware-key tests into webdriver-interactor-hardware-key.test.ts, because the existing test file would have gone past 1000 lines.

Live run at ff08e4b (Android TV emulator emulator-5556, stream 3 read with cmd media_session volume --get)

$ agent-device open settings --platform android --serial emulator-5556 --session hwkey
Opened: settings
# before: volume is 11 in range [0..15]
$ agent-device hardware-key volume-down --session hwkey --json
{"success":true,"data":{"action":"hardware-key","key":"volume-down","message":"Pressed hardware key volume-down"}}
# after: volume is 10 in range [0..15]
$ agent-device hardware-key android-keycode:24 --session hwkey
Pressed hardware key android-keycode:24
# after: volume is 11 in range [0..15]
$ agent-device hardware-key volume-sideways --session hwkey
Error (INVALID_ARGS): hardware-key key must be one of: volume-up, volume-down, ...
$ agent-device close --session hwkey
Closed: hwkey

Local gate

pnpm check:affected --run passes format, lint, typecheck, layering, fallow, build, package, and integration-node. In vitest-related, two groups of tests fail:

@thymikee

Copy link
Copy Markdown
Member

The earlier conflict is gone, and all 14 checks pass at 5ab777a. The earlier lint failure and the missing hardware-key provider scenario are fixed by the cap change in oxlint.config.ts and the new android-hardware-key test. One issue remains.

The Appium Android support you added in 25e51da is not reflected in the help text. The hardware-key CLI detail still says "Android only (ADB key events, local or Limrun)", and the Apple refusal hint says the command is supported "through ADB key events". The docs at website/docs/docs/commands.md:131 already list Appium, so help and docs now disagree. Anyone who reads help hardware-key on an Appium, BrowserStack, or AWS Android session will conclude the command is unsupported and fall back to raw adb, which is the fallback this feature removes. Please make every hardware-key support statement (CLI detail, command description, the Apple and WebDriver refusal hints, the help topic) name the same backends the runtime facts admit: local ADB, Limrun Android, and Appium Android through mobile: pressKey. Something like "Android only: ADB key events (local or Limrun) or Appium mobile: pressKey" for the detail, and "hardware-key is supported on Android (ADB or Appium)" for the Apple hint.

Not blocking, and you can take or leave these: the registry comment still calls hardwareKey a member of the system-button family, the resolvePressInteractor hunk in packages/contracts/src/system-button-runtime.ts now serves only the system-button binder, and the key vocabulary living in tv-remote.ts is fine as answered for now.

I did not reproduce the Android TV emulator transcript, so I take it as you reported it for ff08e4b, which differs from this head only by a lazy import. I also did not run mobile: pressKey on a live Appium Android session. The Appium unit test covers the interactor with a stub client only, so please post one live run on an Appium Android session where hardware-key presses a volume key and returns success.

After the help text and hint name Appium Android alongside ADB, nothing else stops this merge.

@mikolajadamowicz

Copy link
Copy Markdown
Contributor Author

Head is now 72d7e99f6.

Help text and hints (32e4b08). Every hardware-key support statement now names the same backends that the runtime facts admit:

  • CLI detail: "Android only: ADB key events (local or Limrun) or Appium mobile: pressKey."
  • Command description: "Supported on Android through ADB key events (local or Limrun) or Appium mobile: pressKey."
  • Apple refusal hint: "hardware-key is supported on Android (ADB or Appium)."
  • WebDriver refusal hint, which the iOS leg returns: same text as the Apple hint. Before, it said "This WebDriver provider runtime exposes no hardware-key channel."
  • Help topic: the Android TV line now says hardware-key uses ADB keyevents or Appium mobile: pressKey on Android.

Not blocking (72d7e99). I took both:

  • The registry comment no longer calls hardwareKey a member of the system-button family.
  • I reverted the resolvePressInteractor hunk, so system-button-runtime.ts matches main again.

Live Appium run. I couldn't do it. I have no BrowserStack, TestMu, or AWS Device Farm credentials, and no local Appium setup, so mobile: pressKey is still covered only by the stub-client unit test. I can post the transcript once someone with a cloud account runs it, or you can run agent-device hardware-key volume-down on any Appium Android session.

Gate at 72d7e99. pnpm check:affected --run passes every stage except vitest-related. There, only the runner-artifact-manifest and code-signature symlink tests fail, the same way they do in a clean worktree.

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