remoteProtocol

Transport-agnostic protocol constants + pure helpers for the remote-control receiver: the http-gate, the session-socket URL builder, reconnect backoff, and the client KeepAlive frame. No socket, no node, no m.global, so it is fully unit-testable.

Members

(static, constant) CONTRACT_VERSION

The long-poll wire-contract version this client implements. The plugin advertises its own contractVersion in the /info probe body; JellyRock refuses a mismatch and stays dark rather than risk acting on a command shape it might misread. See remote-control-longpoll-contract.md (Versioning).

Default Value
  • 1

(static, constant) PLUGIN_ROUTE

--- HTTPS long-poll transport (#667) --- The plugin route prefix. JellyRock consumes the long-poll over TLS (roUrlTransfer) because Roku can't wss://. Unlike buildSocketUrl, these carry no token in the URL: the caller attaches the standard Authorization header (baseRequest.buildAuthHeader), which already binds the session by DeviceId. See docs/architecture/remote-control-longpoll-contract.md.

Default Value
  • /JellyRock/RemoteControl

Methods

(static) buildLongPollUrl(serverUrl, waitMs, ackIdopt) → {string}

Build the plugin's long-poll command-channel URL for a server base URL. Returns "" for a blank server URL. waitMs is JellyRock's requested hold ceiling; the client-side transfer timeout is set longer so a 204 (empty hold) always arrives before the transfer itself times out.

ack=1 is the at-least-once opt-in flag (contract v1, additive): it tells the plugin this client will acknowledge receipt, so the plugin retains delivered commands and redelivers any we didn't confirm. It is present on every poll (including the first). A plugin that doesn't understand it ignores it and stays at-most-once. ackId is our cumulative ack: the last MessageId we durably received ("" until we've received one, and omitted then). GUIDs are URL-safe, so no encoding is needed. https://host -> https://host/JellyRock/RemoteControl/poll?waitMs=&ack=1[&ackId=]

Parameters:
NameTypeAttributesDefaultDescription
serverUrldynamic
waitMsinteger
ackIddynamic<optional>
""
Returns:
Type: 
string

(static) buildPairPayload(rokuIps, appId, isDev) → {string}

Build the pairing-report JSON body from a device's LAN addresses + app identity. rokuIps is an array of IP strings (the values of roDeviceInfo.GetIPAddrs()); appId is roAppInfo.GetID() ("dev" when sideloaded, else the published channel id); isDev is roAppInfo.IsDev() (lets the plugin name a dev target distinctly). Returns "" when there are no usable IPs (nothing the plugin could wake) OR appId is blank, so the caller can skip a pointless report. The IPs are sanitized (see sanitizeIps).

Parameters:
NameTypeDescription
rokuIpsdynamic
appIddynamic
isDevdynamic
Returns:
Type: 
string

(static) buildPairRequest(serverUrl, rokuIps, appId, isDev) → {dynamic}

Compose the pairing report as a fire-and-forget SideEffect request AA (for SubmitSideEffect). Returns invalid when there's nothing worth reporting (no server url, or no usable wake address / appId, see buildPairPayload), so the caller can skip a pointless POST. No auth in the AA: the SideEffectTask attaches the Authorization header (which binds device/user identity) and Content-Type. rokuIps comes from the caller's roDeviceInfo.GetIPAddrs() (a device call, kept out of this pure module).

Parameters:
NameTypeDescription
serverUrldynamic
rokuIpsdynamic
appIddynamic
isDevdynamic
Returns:
Type: 
dynamic

(static) buildPairUrl(serverUrl) → {string}

Build the pairing-report URL for a server base URL. Returns "" for a blank server url. https://host -> https://host/JellyRock/RemoteControl/pair

Parameters:
NameTypeDescription
serverUrldynamic
Returns:
Type: 
string

(static) buildProbeUrl(serverUrl) → {string}

Build the plugin presence/version probe URL (200 = plugin present, 404 = absent). Returns "" for a blank server URL. No token in the URL (auth is header-based). https://host -> https://host/JellyRock/RemoteControl/info

Parameters:
NameTypeDescription
serverUrldynamic
Returns:
Type: 
string

(static) buildSocketUrl(serverUrl, authToken, deviceId, serverVersion) → {string}

Build the Jellyfin session-socket URL from the server URL + session credentials. Returns "" when the server isn't http:// (never downgrades an https:// token onto ws://) or when a credential is missing. http://host:port -> ws://host:port/socket?ApiKey=&deviceId=(server >= 10.8.0) http://host:port -> ws://host:port/socket?api_key=&deviceId=(10.7.x / unknown)

How the server binds the socket depends on the version:

  • 10.7.x binds the session from the query string alone, reading exactly api_key + deviceId, so both matter there.
  • 10.8 to 10.10 authenticate the upgrade with the Authorization header, but a deviceId query param, when present, overrides the header's DeviceId. The URL carries the same id, so they agree.
  • 10.11 and later take DeviceId from the Authorization header only and ignore the deviceId query param, so the binding is done by the header RemoteControlTask sets on the handshake (assuming otherwise split the session in #743). From 10.8.0 the token in the URL is only the fallback for a proxy that strips Authorization, and it must be ApiKey: api_key is legacy authorization, which 12.0 disables by default. See docs/architecture/remote-control.md (How the DeviceId is bound). An unknown version keeps api_key, matching the conservative legacy fallback of resolveApiVersion.
Parameters:
NameTypeDescription
serverUrldynamic
authTokendynamic
deviceIddynamic
serverVersiondynamic

connected Jellyfin server version string (for example "12.0.0")

Returns:
Type: 
string

(static) isHttpServer(serverUrl) → {boolean}

The http-gate. JellyRock can only open a ws:// socket to an http:// server: Roku can't do wss:// (no socket TLS), so an https:// session stays uncontrolled (the future plugin long-poll path). Both the advertised capability (deviceCapabilities) and the receiver gate on this, so they never disagree.

Parameters:
NameTypeDescription
serverUrldynamic
Returns:
Type: 
boolean

(static) keepAliveFrame() → {string}

The client->server KeepAlive frame. Jellyfin's ForceKeepAlive asks the client to send these on the requested interval so the session isn't reaped by the inactivity timeout. The KeepAlive the receiver answers a ForceKeepAlive with, and sends every half-interval.

The key must stay quoted. BrightScript lowercases a bare identifier key, so FormatJson({ MessageType: ... }) puts {"messagetype":"KeepAlive"} on the wire.

That is fatal HERE and nowhere else in the app, which is what makes it worth a comment: Jellyfin's REST endpoints bind case-INsensitively (so the app's other lowercased bodies are forgiven), but the socket path deserializes directly with JsonDefaults.Options, which does not set PropertyNameCaseInsensitive. An unbound MessageType takes its default enum value, so the server logs nothing, never calls SendKeepAliveResponse, never advances LastKeepAliveDate, and SessionWebSocketListener reaps the socket ~60s after it connects (#934).

The spec asserts the raw STRING. It cannot use ParseJson: a parsed AA is case-insensitive too, so it resolves messagetype happily and the assertion passes against the broken frame.

Returns:
Type: 
string

(static) parseContractVersion(body) → {integer}

Extract the contractVersion from a /info probe body. Returns 0 for a blank/unparseable body, a non-object body, a missing key, or a non-numeric value. Callers treat anything <> CONTRACT_VERSION as "no usable plugin". Never throws on a hostile/garbled body (it's network-controlled input): a string/object/bool contractVersion is rejected before any coercion that could raise at runtime.

Parameters:
NameTypeDescription
bodydynamic
Returns:
Type: 
integer

(static) pluginBaseUrl(serverUrl) → {string}

Shared plugin route-prefix builder. Trims a single trailing slash on the server URL so we never emit "//". Returns "" for a missing/blank server URL.

Parameters:
NameTypeDescription
serverUrldynamic
Returns:
Type: 
string

(static) sanitizeIps(rokuIps) → {object}

Sanitize a raw IP list into the addresses worth reporting: trim, drop blanks, drop loopback (127.x) and the unspecified address (0.0.0.0: GetIPAddrs can surface a not-yet-configured interface as that), and dedupe (a Roku with both wired + wifi up can report the same address on more than one interface). Order-preserving. Always returns an array (never invalid), possibly empty.

Parameters:
NameTypeDescription
rokuIpsdynamic
Returns:
Type: 
object