Does /keypress/VolumeUp actually work on a Roku TV? I get 200 OK on a stick, but nothing happens

Hello. I am building a remote control app for iPhone. It is not released, and I am
not linking it here. I only need a technical answer.

WHAT I MEASURED

I have a Roku Streaming Stick+ (3830RW, Roku OS 15.2.4). I send this:

POST http://<ip>:8060/keypress/VolumeUp

The stick returns HTTP 200. The volume does not change.

I do not think this is a bug in my code. The stick reports is-tv: false. On a stick,
volume is infrared. The signal comes from the emitter inside the physical remote. So
there is nothing on the network to receive a volume command. The 200 only means “I
received the request”. It does not mean “I did it”.

MY QUESTION

The ECP documentation says the Volume, Power and Channel keys are for Roku TVs only.
So on a real Roku TV (TCL, Hisense, onn, Philips) these keys should work.

I cannot test this. I only own a stick.

Can anyone confirm on a real Roku TV?

  1. Does POST /keypress/VolumeUp actually change the volume?
  2. Does POST /keypress/ChannelUp actually change the channel?
  3. Does POST /keypress/PowerOff actually switch the TV off?

I am asking whether the TV acts. I am not asking whether it returns 200. My stick
already taught me that a 200 can mean nothing.

WHY I AM CAREFUL ABOUT THIS

Three things I read this week did not match what I measured on the stick:

  • I read that Limited mode blocks control and allows queries. It is really a
    per-command allowlist. /query/apps returns 403 in Limited.
  • I read that /search/browse is available. My stick returns 503, because
    search-enabled is false.
  • I planned to detect Limited mode from a failed keypress. The device reports
    ecp-setting-mode directly, which is much better.

So I would like a reading from real hardware before I trust my own reading again.

IF YOU WANT TO TEST IT QUICKLY

Here is a small script that asks your TV directly. It is Python 3 and uses only the
standard library, so there is nothing to install. Save it as roku_tv_report.py and
run it:

python3 roku_tv_report.py

It prints a short report that you can paste back. It sends Power only if you pass
–power. It removes the serial number, MAC address and UDN from the report. Nothing
is sent outside your own network. Please read it before you run it.

Thank you. I will post the answers back here.

#!/usr/bin/env python3
"""Ask a volunteer's Roku TV the one question this project cannot answer itself.

Roku's ECP reference marks volume, power and channel as Roku-TV-only. Everything
this project owns is a *stick*, where those keys do not exist — so whether they
actually work on a Roku TV is documentation, never a reading. Three other things
that documentation said this week turned out to be wrong on real hardware, so it
is not a claim to build an App Store listing on.

Hand this to anyone with a Roku TV (TCL, Hisense, onn, Philips, Sharp, or a
Roku-branded set). They need Python 3 and the same Wi-Fi as the TV. One command:

    python3 roku_tv_report.py

It prints a short report to paste back. What it does, so it can be read before
being trusted:

  * Finds Roku devices by trying port 8060 across the local /24. Nothing is sent
    anywhere off the network, then or ever.
  * Reads /query/device-info to see whether the device calls itself a TV.
  * Sends VolumeDown three times and VolumeUp three times — a net change of zero —
    and asks the human whether the volume actually moved. That question is the
    entire point: this project has measured a Roku returning HTTP 200 for a
    volume key and doing absolutely nothing, so a status code proves nothing.
  * Sends ChannelUp once, and asks.

What it deliberately does NOT do:

  * It never sends Power, PowerOff or PowerOn. Switching off a volunteer's
    television to see whether you can is a poor way to treat a volunteer, and the
    answer is inferable from whether volume works. Pass --power to include it,
    only if they have said yes.
  * It never launches or installs anything.
  * It redacts the serial number, MAC and UDN from the report. Those identify a
    specific household's hardware and are nobody's business but theirs.
"""

import argparse
import socket
import sys
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor

PORT = 8060
REDACT = ("serial-number", "device-id", "udn", "wifi-mac", "ethernet-mac",
          "bluetooth-mac", "advertising-id", "network-name", "user-device-location")


def get(url, method="GET"):
    request = urllib.request.Request(url, method=method)
    if method == "POST":
        request.add_header("Content-Length", "0")
    try:
        with urllib.request.urlopen(request, timeout=4) as response:
            return response.status, response.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.code, ""
    except Exception as e:
        return None, f"{type(e).__name__}"


def own_ipv4():
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.connect(("8.8.8.8", 80))
        return sock.getsockname()[0]
    finally:
        sock.close()


def find_rokus():
    base = own_ipv4().rsplit(".", 1)[0]

    def probe(last):
        host = f"{base}.{last}"
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        # Generous: a device idle for hours needs far longer for its first
        # connect than for any after it. Measured at 8x on this project's stick.
        sock.settimeout(1.5)
        try:
            return host if sock.connect_ex((host, PORT)) == 0 else None
        finally:
            sock.close()

    with ThreadPoolExecutor(max_workers=64) as pool:
        return [h for h in pool.map(probe, range(1, 255)) if h]


def field(body, name):
    open_tag, close_tag = f"<{name}>", f"</{name}>"
    if open_tag not in body:
        return None
    return body.split(open_tag, 1)[1].split(close_tag, 1)[0].strip()


def ask(question):
    try:
        answer = input(f"    {question} [y/n] ").strip().lower()
    except (EOFError, KeyboardInterrupt):
        return "skipped"
    return {"y": "YES", "n": "NO"}.get(answer[:1], "unclear")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", help="skip the search and use this address")
    ap.add_argument("--power", action="store_true",
                    help="also test the power key. It will switch the TV off. "
                         "Only pass this if you are happy to switch it back on")
    args = ap.parse_args()

    print("\n  Roku TV capability check")
    print("  Nothing leaves your network. Your serial number and MAC are not printed.\n")

    hosts = [args.host] if args.host else find_rokus()
    if not hosts:
        print("  Found no Roku on this network. Is the TV on, and on this Wi-Fi?")
        return 1

    report = []
    for host in hosts:
        status, info = get(f"http://{host}:{PORT}/query/device-info")
        if status != 200:
            continue

        model = field(info, "model-name") or "unknown"
        is_tv = field(info, "is-tv")
        version = field(info, "software-version") or "unknown"
        mode = field(info, "ecp-setting-mode") or "not reported"

        print(f"  Found: {model}   is-tv={is_tv}   Roku OS {version}")
        print(f"  Network access setting: {mode}")

        if is_tv != "true":
            print("  This is a stick or box, not a Roku TV — it is the case we already")
            print("  have covered. Thank you anyway.\n")
            continue

        if mode == "limited":
            print("\n  This TV is set to Limited, which refuses these commands. To take")
            print("  part, set Settings > System > Advanced system settings >")
            print("  Control by mobile apps > Network access to Enabled, then re-run.\n")
            continue

        print("\n  Please watch the television screen for the next few seconds.\n")

        codes = []
        for key in ("VolumeDown", "VolumeDown", "VolumeDown",
                    "VolumeUp", "VolumeUp", "VolumeUp"):
            code, _ = get(f"http://{host}:{PORT}/keypress/{key}", "POST")
            codes.append(code)
        volume_moved = ask("Did the volume actually change on the TV?")

        channel_code, _ = get(f"http://{host}:{PORT}/keypress/ChannelUp", "POST")
        channel_moved = ask("Did the channel change (or a channel banner appear)?")

        power_code, power_worked = "not tested", "not tested"
        if args.power:
            power_code, _ = get(f"http://{host}:{PORT}/keypress/PowerOff", "POST")
            power_worked = ask("Did the TV switch off?")

        report.append(f"""
  ---------- paste everything below this line ----------
  model            {model}
  roku os          {version}
  is-tv            {is_tv}
  network access   {mode}
  volume keys      HTTP {sorted(set(codes))} -> volume actually moved: {volume_moved}
  channel key      HTTP {channel_code} -> channel actually changed: {channel_moved}
  power key        HTTP {power_code} -> TV actually switched off: {power_worked}
  ------------------------------------------------------
""")

    if not report:
        print("\n  No Roku TV took part. Nothing was recorded.")
        return 0

    print("\n".join(report))
    print("  Thank you. The two 'actually' lines are the whole point — this project")
    print("  has measured a Roku answering 200 for a key it then ignores, so the")
    print("  status code on its own would have told us nothing.\n")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Without answering all your questions (I’ve never looked into Limited mode, although I’d say it’s a given that VolumeUp works with TVs), you’re missing a piece. A stick will adjust the volume via ECP if the TV supports CEC and it’s enabled. I’m not sure if a voice remote with TV controls is necessary to get CEC enabled in the first place (i.e., the remote must use the CEC setup option the Set up remote for TV control settings). There are some old RF remotes for sticks that don’t have TV controls.

This feature was removed from ECP a few years ago when the Roku search functionality moved out of Roku device firmware and into our cloud-powered experiences.