<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: hisuperdev</title>
    <description>The latest articles on DEV Community by hisuperdev (@hisuperdev).</description>
    <link>https://dev.to/hisuperdev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3949607%2F09f6f7c5-1106-4653-87d1-1b32f87bb954.png</url>
      <title>DEV Community: hisuperdev</title>
      <link>https://dev.to/hisuperdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hisuperdev"/>
    <language>en</language>
    <item>
      <title>I built one iOS app that controls four different Hisense smart-TV platforms — here's how each protocol works</title>
      <dc:creator>hisuperdev</dc:creator>
      <pubDate>Wed, 01 Jul 2026 11:31:23 +0000</pubDate>
      <link>https://dev.to/hisuperdev/i-built-one-ios-app-that-controls-four-different-hisense-smart-tv-platforms-heres-how-each-10m7</link>
      <guid>https://dev.to/hisuperdev/i-built-one-ios-app-that-controls-four-different-hisense-smart-tv-platforms-heres-how-each-10m7</guid>
      <description>&lt;p&gt;The problem&lt;br&gt;
My Hisense remote died last year and I went down a small rabbit hole trying to find a single iOS app that worked across all the Hisense smart-TV variants. Turns out Hisense ships four entirely different smart-TV operating systems depending on the model, each with its own pairing flow and command set:&lt;/p&gt;

&lt;p&gt;VIDAA U6/U7/U8 — Hisense's own OS, the most common on the global market&lt;br&gt;
Roku TV Edition — sold mostly in North America&lt;br&gt;
Google TV / Android TV — A-series and some U-series&lt;br&gt;
Fire TV Edition — Amazon-branded models sold in US/UK&lt;br&gt;
Every iOS app I tried covered one or two of these at most. The "universal" ones used hardware-IR blasters which my iPhone obviously doesn't have. So I spent ~3 months implementing local-network clients for each platform and shipped them in a single app called Hiremote.&lt;/p&gt;

&lt;p&gt;This post is a sketch of what each platform's protocol looks like and the trickiest parts of getting them under one roof. Useful if you're building anything that talks to consumer TVs over the local network.&lt;/p&gt;

&lt;p&gt;**Platform 1: VIDAA — MQTT over local Wi-Fi&lt;br&gt;
**VIDAA is the most Hisense-specific platform and also the one with the least public documentation. The TVs run an MQTT broker locally on port 36669 (TLS) and accept commands over a pinned topic structure.&lt;/p&gt;

&lt;p&gt;Pairing flow:&lt;/p&gt;

&lt;p&gt;Discover the TV via SSDP (M-SEARCH for urn:schemas-upnp-org:device:MediaRenderer:1) or via Bonjour (_hisense._tcp.local.)&lt;br&gt;
Send a request_pairing MQTT message — TV displays a 4-digit PIN on screen&lt;br&gt;
User enters PIN in app — app sends confirm_pairing with the code&lt;br&gt;
TV returns a long-lived auth token, app stores it in Keychain&lt;br&gt;
Commands sent as JSON on /remoteapp/tv/remote_service//actions/sendkey&lt;br&gt;
Command payload looks like:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;{&lt;br&gt;
  "msg_type": "remote_control",&lt;br&gt;
  "keyName": "KEY_HOME",&lt;br&gt;
  "device_type": "iPhone"&lt;br&gt;
}&lt;/code&gt;&lt;br&gt;
The TLS handshake is the part most people get wrong — VIDAA uses a self-signed cert with a non-standard root, so you have to pin the cert or set urlSession(_:didReceive:completionHandler:) to trust the specific cert hash. Apple's Network framework handles this cleanly with sec_protocol_options_set_verify_block.&lt;/p&gt;

&lt;p&gt;Platform 2: Hisense Roku TV — ECP over HTTP&lt;br&gt;
Hisense Roku TVs implement Roku's External Control Protocol (ECP) — a plain HTTP API on port 8060. No pairing needed (this is both the upside and the downside).&lt;/p&gt;

&lt;p&gt;Discovery is via SSDP for roku:ecp service. Once you have the IP, every command is a POST:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST http://&amp;lt;TV_IP&amp;gt;:8060/keypress/Home&lt;br&gt;
POST http://&amp;lt;TV_IP&amp;gt;:8060/keypress/VolumeUp&lt;br&gt;
POST http://&amp;lt;TV_IP&amp;gt;:8060/launch/12  // launch Netflix&lt;/code&gt;&lt;br&gt;
No auth, no encryption. ECP is the easiest of the four to integrate but you also get nothing for security — if a malicious app on the same LAN knows the IP, it can control the TV. That's a Roku architectural choice, not something we can fix from the client side.&lt;/p&gt;

&lt;p&gt;One thing I built on top: a web-based version of the Roku remote at hiremote.app/hisense-roku-tv-remote that uses the same ECP calls but runs in any browser. No install required. Works because ECP is plain HTTP and we can proxy through a small Vercel edge function (CORS isn't permissive on the TV itself).&lt;/p&gt;

&lt;p&gt;Platform 3: Hisense Google TV — Android TV Remote v2&lt;br&gt;
Google TV-branded Hisense models implement Google's Android TV Remote v2 protocol — the same one the official Google TV app uses. Documentation is sparse, but the open-source androidtv-remote library documents the wire format well.&lt;/p&gt;

&lt;p&gt;It's a TLS connection with mutual auth (both sides present a cert), running gRPC-style binary messages on port 6466 (pairing) and 6467 (control). Pairing uses a Diffie-Hellman key exchange with a 4-digit code displayed on screen.&lt;/p&gt;

&lt;p&gt;The simplified flow:&lt;/p&gt;

&lt;p&gt;TCP connect with TLS on port 6466&lt;br&gt;
Send PairingRequest proto&lt;br&gt;
TV shows code, user enters in app&lt;br&gt;
Send PairingSecret with hash of (DH shared secret + code)&lt;br&gt;
TV verifies, both sides swap certs for future connections&lt;br&gt;
Switch to port 6467 for command messages&lt;br&gt;
Once paired, commands are protobuf-encoded button events. Implementing this in Swift meant writing the protobuf definitions by hand from the open-source captures (Google never published the spec) and using Network framework's NWConnection with manual TLS configuration to handle the mutual auth.&lt;/p&gt;

&lt;p&gt;This was the hardest of the four to get right. About 6 weeks of evening work.&lt;/p&gt;

&lt;p&gt;Platform 4: Fire TV Edition — ADB over TCP&lt;br&gt;
Fire TV Edition models are Android-based and accept ADB (Android Debug Bridge) commands over TCP port 5555. Same protocol Android developers use over USB, except over Wi-Fi.&lt;/p&gt;

&lt;p&gt;Pairing:&lt;/p&gt;

&lt;p&gt;User enables "ADB debugging over network" in TV's Developer Options (which they have to unlock first by tapping the build number seven times — same as on a phone)&lt;br&gt;
App connects to :5555 with TLS&lt;br&gt;
ADB key exchange: app sends its RSA public key, TV shows a confirmation dialog, user approves&lt;br&gt;
Keys are stored, future connections auto-authenticate&lt;br&gt;
Commands are issued as ADB shell commands:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;input keyevent KEYCODE_HOME&lt;br&gt;
input keyevent KEYCODE_VOLUME_UP&lt;br&gt;
am start -n com.netflix.ninja/.MainActivity  // launch Netflix&lt;/code&gt;&lt;br&gt;
The protocol overhead (TLS + ADB framing) is real, so command latency on Fire TV Edition is the worst of the four — about 150ms vs ~30ms on ECP/VIDAA. Not bad, but noticeable.&lt;/p&gt;

&lt;p&gt;This is also the only platform where the user has to dig into developer settings — friction we couldn't engineer away. Onboarding for Fire TV users is a longer flow.&lt;/p&gt;

&lt;p&gt;The hardest part: figuring out which platform a given TV is&lt;br&gt;
Users don't necessarily know which OS their Hisense TV runs. They see "Hisense" on the box. So the app has to detect the platform before showing pairing instructions.&lt;/p&gt;

&lt;p&gt;Initially I just probed all four ports in parallel (tryConnect(IP, 8060) for ECP, tryConnect(IP, 36669) for VIDAA, etc.) and used the first one that responded. Worked but felt hacky and produced false negatives when one of the probes timed out.&lt;/p&gt;

&lt;p&gt;Better approach: use mDNS service discovery. Each platform advertises a distinct service:&lt;/p&gt;

&lt;p&gt;Platform    mDNS service&lt;br&gt;
VIDAA   _hisense._tcp.local.&lt;br&gt;
Roku TV _googlecast._tcp.local. (Roku also advertises this) + roku-info headers&lt;br&gt;
Google TV   _googlecast._tcp.local. + _androidtvremote._tcp.local.&lt;br&gt;
Fire TV _amzn-wplay._tcp.&lt;br&gt;
So a single NetServiceBrowser scan returns a list of TVs with platform labels attached. The remaining edge case is when a user has both a Roku TV and a Chromecast on the network — both advertise _googlecast._tcp.local. — so we also check the TXT record manufacturer field to disambiguate.&lt;/p&gt;

&lt;p&gt;This bit took me longer than the four protocol implementations combined.&lt;/p&gt;

&lt;p&gt;What I shipped&lt;br&gt;
Hiremote — single iOS app, all four platforms, free download. Pro tier ($9.99/year) adds Apple Watch app + Lock Screen widget + custom button layouts. Built solo, 1.3K App Store ratings, 4.6 average.&lt;/p&gt;

&lt;p&gt;Stack:&lt;/p&gt;

&lt;p&gt;iOS app: SwiftUI + Network framework + Bonjour for discovery&lt;br&gt;
Marketing site: Next.js 16 App Router&lt;br&gt;
What I learned&lt;br&gt;
A few non-obvious things for anyone building similar local-network app integrations:&lt;/p&gt;

&lt;p&gt;mDNS first, port-probing as fallback. Service discovery is faster, gentler on the network, and produces fewer false positives. Pure port-probing also looks like port-scanning to some network security appliances.&lt;/p&gt;

&lt;p&gt;TLS pinning matters for consumer TVs. Several platforms use self-signed or non-standard root certs. Network framework's sec_protocol_options_set_verify_block handles this cleanly; URLSession does not.&lt;/p&gt;

&lt;p&gt;Pairing UX is most of the UX. Users don't read instructions. The pairing flow has to handle: "I didn't see the PIN," "The PIN went away," "It says pairing failed," and "Will this work again next time?" — each as a first-class state, not an error.&lt;/p&gt;

&lt;p&gt;AppKeychain for tokens, not UserDefaults. Obvious in retrospect; I burned a day debugging why tokens disappeared on iOS restore from backup before fixing.&lt;/p&gt;

&lt;p&gt;Apple's Network framework is genuinely good for this kind of work. I started with URLSession + raw sockets and migrated halfway through. NWConnection's state machine is cleaner than anything I'd have written by hand.&lt;/p&gt;

&lt;p&gt;Links&lt;br&gt;
App Store: &lt;a href="https://apps.apple.com/us/app/remote-for-hisense-smart-tv/id6740401390" rel="noopener noreferrer"&gt;Hiremote on App Store&lt;/a&gt;&lt;br&gt;
Site + troubleshooting docs: &lt;a href="https://hiremote.app" rel="noopener noreferrer"&gt;hiremote.app&lt;/a&gt;&lt;br&gt;
My other side projects: &lt;a href="https://hisuperdev.github.io" rel="noopener noreferrer"&gt;hisuperdev.github.io&lt;/a&gt;&lt;br&gt;
Happy to answer Qs about any of the four protocols, multi-platform iOS app architecture, ASO for niche utility apps, or solo-dev distribution. Reply or DM me.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>networking</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>How I built a single iOS app that controls all four Hisense smart-TV platforms</title>
      <dc:creator>hisuperdev</dc:creator>
      <pubDate>Thu, 28 May 2026 13:27:48 +0000</pubDate>
      <link>https://dev.to/hisuperdev/how-i-built-a-single-ios-app-that-controls-all-four-hisense-smart-tv-platforms-30ef</link>
      <guid>https://dev.to/hisuperdev/how-i-built-a-single-ios-app-that-controls-all-four-hisense-smart-tv-platforms-30ef</guid>
      <description>&lt;p&gt;Hisense ships TVs with four different operating systems — VIDAA,&lt;br&gt;
Roku TV, Google TV, and Fire TV. The official "Remote for Hisense&lt;br&gt;
TV" you find in their app store only works with VIDAA. When I lost&lt;br&gt;
my own remote, I had a Google TV-flavored Hisense, so I built a&lt;br&gt;
single-app solution that pairs with whichever platform the user has.&lt;/p&gt;

&lt;p&gt;Here's how each of the four local-network protocols works, and how&lt;br&gt;
the app picks the right one without asking the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Local protocol&lt;/th&gt;
&lt;th&gt;Pairing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VIDAA&lt;/td&gt;
&lt;td&gt;MQTT over TLS&lt;/td&gt;
&lt;td&gt;Direct local connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roku TV&lt;/td&gt;
&lt;td&gt;ECP over HTTP&lt;/td&gt;
&lt;td&gt;Open by default on port 8060&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google TV&lt;/td&gt;
&lt;td&gt;gRPC, TLS)&lt;/td&gt;
&lt;td&gt;4-digit PIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fire TV&lt;/td&gt;
&lt;td&gt;ADB-over-Wi-Fi&lt;/td&gt;
&lt;td&gt;One-time TV-side prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four are LAN-only — no cloud involvement once paired.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. VIDAA — MQTT over TLS
&lt;/h2&gt;

&lt;p&gt;VIDAA TVs accept TLS connections on port 36669 and speak MQTT&lt;br&gt;
underneath. The control message format publishes button presses as&lt;br&gt;
small JSON payloads — &lt;code&gt;{"key": "KEY_VOLUMEUP"}&lt;/code&gt; etc. — to predictable&lt;br&gt;
topics scoped by the TV's MAC address. Other developers in the Home&lt;br&gt;
Assistant community have written about this in detail; my Swift&lt;br&gt;
implementation is on top of that prior work.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Roku TV — ECP is HTTP
&lt;/h2&gt;

&lt;p&gt;Almost too easy. Roku publishes the External Control Protocol&lt;br&gt;
publicly. Every Roku and Hisense Roku TV listens on port 8060:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;curl -X POST http://192.168.1.100:8060/keypress/Home&lt;br&gt;
curl -X POST http://192.168.1.100:8060/keypress/VolumeUp&lt;br&gt;
curl http://192.168.1.100:8060/query/active-app&lt;br&gt;
curl http://192.168.1.100:8060/query/device-info&lt;br&gt;
curl -X POST http://192.168.1.100:8060/launch/12&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;No pairing. No PIN. If your phone and the TV are on the same LAN,&lt;br&gt;
you can drive it from a curl one-liner. This is why I shipped an &lt;a href="https://hiremote.app/hisense-roku-tv-remote#remote" rel="noopener noreferrer"&gt;in-browser Roku remote&lt;/a&gt; — for Hisense Roku TV owners, you don't actually need an app at all. The page implements ECP from JavaScript and runs in any modern browser. Cannibalises my own install funnel for that segment, but it's the right call for the user.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Google TV — gRPC dance
&lt;/h2&gt;

&lt;p&gt;Google TV uses gRPC over TLS with cert pinning. Pairing exchanges a&lt;br&gt;
4-digit PIN through paired proto messages — the Android TV Remote v2&lt;br&gt;
protocol. The .proto definitions are discussed in various developer&lt;br&gt;
communities; from there it's standard gRPC client work.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Fire TV — ADB-style local connection
&lt;/h2&gt;

&lt;p&gt;Fire TV runs Android and supports network-mode ADB on port 5555.&lt;br&gt;
After the user enables developer-friendly settings on the TV, the&lt;br&gt;
app authorizes once via a permission prompt on the TV screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform detection via mDNS
&lt;/h2&gt;

&lt;p&gt;The app browses for service types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;_androidtvremote._tcp → Google TV&lt;/li&gt;
&lt;li&gt;_roku-rsp._tcp → Roku TV
&lt;/li&gt;
&lt;li&gt;_amzn-wplay._tcp → Fire TV&lt;/li&gt;
&lt;li&gt;Port-scan port 36669 fallback → VIDAA&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first responder wins. The user never picks "what kind of Hisense&lt;br&gt;
TV is this" unless detection fails.&lt;/p&gt;

&lt;p&gt;The implementation ships as &lt;a href="https://apps.apple.com/us/app/remote-for-hisense-tv/id6740401390" rel="noopener noreferrer"&gt;Remote for Hisense TV on the App Store&lt;/a&gt;, free with a Pro tier ($9.99/year) that adds the Apple Watch app, Lock Screen widget, and removes ads. The marketing site with deeper troubleshooting articles is at &lt;a href="https://hiremote.app" rel="noopener noreferrer"&gt;hiremote.app&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy to answer iOS protocol questions in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>iot</category>
      <category>tv</category>
      <category>ios</category>
    </item>
    <item>
      <title>I built an in-browser Roku TV remote with ~80 lines of TypeScript. Here's how Roku's ECP API actually works</title>
      <dc:creator>hisuperdev</dc:creator>
      <pubDate>Sun, 24 May 2026 21:50:15 +0000</pubDate>
      <link>https://dev.to/hisuperdev/i-built-an-in-browser-roku-tv-remote-with-80-lines-of-typescript-heres-how-rokus-ecp-api-56ni</link>
      <guid>https://dev.to/hisuperdev/i-built-an-in-browser-roku-tv-remote-with-80-lines-of-typescript-heres-how-rokus-ecp-api-56ni</guid>
      <description>&lt;p&gt;Roku ships an HTTP API on every device they sell. It has no authentication, no API key, no documentation page on a marketing site — but it powers every third-party Roku remote app on the App Store and Play Store. It's called &lt;strong&gt;ECP&lt;/strong&gt; (External Control Protocol) and once you've seen it, you'll wonder why the rest of the smart-TV world isn't this simple.&lt;/p&gt;

&lt;p&gt;I needed an in-browser remote for &lt;a href="https://hiremote.app/hisense-roku-tv-remote" rel="noopener noreferrer"&gt;HiRemote's Hisense Roku TV landing page&lt;/a&gt; — the idea being a visitor who lands on the page can press buttons in the page itself without first installing the iOS app. Three quirks made it harder than the marketing-page version suggests; here's the actual implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Discovery — SSDP is overrated, the user types the IP
&lt;/h2&gt;

&lt;p&gt;Every Roku tutorial starts with "use SSDP multicast on &lt;code&gt;239.255.255.250:1900&lt;/code&gt;". This is true but useless from a browser: browsers can't send UDP. You can't run SSDP from JavaScript.&lt;/p&gt;

&lt;p&gt;For a &lt;em&gt;browser-based&lt;/em&gt; remote, the pragmatic solution is: ask the user for their TV's IP. On the iPhone app side we use Bonjour. On the web page side we just show a one-time input box, then &lt;code&gt;localStorage&lt;/code&gt; it. Roku TVs always run ECP on port &lt;code&gt;8060&lt;/code&gt;, so once you have the IP, the base URL is fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`http://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tvIp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:8060`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only gotcha here is that the user's browser is HTTPS but the TV is HTTP. Modern browsers block mixed-content requests, so you have to either accept this and let the request fail gracefully, or proxy through your own backend. We chose the first option — the input box explains the limitation and tells the user to also try the iOS app which doesn't have this restriction.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Buttons — every command is a POST to a path
&lt;/h2&gt;

&lt;p&gt;The control surface is one of the cleanest API designs I've seen in a consumer device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST http://&amp;lt;tv-ip&amp;gt;:8060/keypress/&amp;lt;KeyName&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;KeyName&amp;gt;&lt;/code&gt; is one of about 30 documented strings: &lt;code&gt;Home&lt;/code&gt;, &lt;code&gt;Up&lt;/code&gt;, &lt;code&gt;Down&lt;/code&gt;, &lt;code&gt;Left&lt;/code&gt;, &lt;code&gt;Right&lt;/code&gt;, &lt;code&gt;Select&lt;/code&gt;, &lt;code&gt;Back&lt;/code&gt;, &lt;code&gt;Play&lt;/code&gt;, &lt;code&gt;Pause&lt;/code&gt;, &lt;code&gt;Rev&lt;/code&gt;, &lt;code&gt;Fwd&lt;/code&gt;, &lt;code&gt;VolumeUp&lt;/code&gt;, &lt;code&gt;VolumeDown&lt;/code&gt;, &lt;code&gt;VolumeMute&lt;/code&gt;, &lt;code&gt;PowerOn&lt;/code&gt;, &lt;code&gt;PowerOff&lt;/code&gt;, plus a few platform-specific ones for Roku TV (channel up/down, input switching).&lt;/p&gt;

&lt;p&gt;No body, no headers, no auth. Just the POST:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RokuKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/keypress/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For typing into search boxes (Netflix login, YouTube search), there's &lt;code&gt;/keypress/Lit_&amp;lt;urlEncodedChar&amp;gt;&lt;/code&gt; — one POST per character. Cleaner than building a virtual keyboard, ugly that it isn't batched, but it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Direct-app-launch — the surprisingly useful one
&lt;/h2&gt;

&lt;p&gt;The endpoint nobody talks about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST http://&amp;lt;tv-ip&amp;gt;:8060/launch/&amp;lt;channel-id&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Channel IDs are stable Roku-assigned numbers. &lt;code&gt;12&lt;/code&gt; is Netflix, &lt;code&gt;13&lt;/code&gt; is Prime Video, &lt;code&gt;837&lt;/code&gt; is YouTube, &lt;code&gt;291097&lt;/code&gt; is Disney+. Posting to &lt;code&gt;/launch/12&lt;/code&gt; boots Netflix on the TV — no D-pad navigation needed.&lt;/p&gt;

&lt;p&gt;This is the killer feature for a remote that lives on a phone or in a browser: you skip the entire "navigate the home screen" UX that makes physical Roku remotes annoying. One tap → on Netflix.&lt;/p&gt;

&lt;p&gt;Full list of channel IDs is in the device's response to &lt;code&gt;GET /query/apps&lt;/code&gt; (returns XML, so use &lt;code&gt;DOMParser&lt;/code&gt; not &lt;code&gt;JSON.parse&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Putting it together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RokuKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Back&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Select&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Down&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Left&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Right&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;VolumeUp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;VolumeDown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;VolumeMute&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PowerOn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PowerOff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Play&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pause&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Rev&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Fwd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RokuRemote&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;tvIp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;base&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`http://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tvIp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:8060`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RokuKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;base&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;/keypress/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;base&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;/keypress/Lit_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;launchApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;base&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;/launch/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channelId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole remote. Render a D-pad with &lt;code&gt;onClick={() =&amp;gt; remote.press("Up")}&lt;/code&gt; and you have a working web-based Roku remote in 80 lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Roku's HTTP control protocol is plain &lt;code&gt;POST /keypress/&amp;lt;KeyName&amp;gt;&lt;/code&gt;. No auth. 80 lines of TypeScript = working remote. Discovery is the only genuinely hard part for a browser context, and "ask the user for the IP" is the right answer there.&lt;/p&gt;

&lt;p&gt;The end result is live at &lt;a href="https://hiremote.app/hisense-roku-tv-remote" rel="noopener noreferrer"&gt;hiremote.app/hisense-roku-tv-remote&lt;/a&gt; — bring your own Roku-TV IP and try it without installing anything.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>iot</category>
      <category>tv</category>
    </item>
  </channel>
</rss>
