Extending the device pool

The @roku-sdk/tooling package includes a DeviceProvider interface (a seam, or extension point) so that you can plug your own device-acquisition strategy into the SDK. The built-in provider, LocalProvider, resolves to a single device from the layered devices store (~/roku-dev-config.json for the central list, with a per-worktree active-device override through .roku/roku-dev-config.json). If you operate a private device farm, an internal CI device pool, or any other custom acquisition strategy, you can register your own provider and select it with RK_DEVICE_POOL=<your-kind>.

The seam (the provider interface, the registry, and LocalProvider) and the driving CLI subcommands (rk device lease acquire, rk device lease release, rk device lease reap, and rk device pool status) all ship in @roku-sdk/tooling. Custom providers register through the same registry that the local provider uses. Once registered, you can address them with RK_DEVICE_POOL=<your-kind> from any CLI invocation. See rk device lease and rk device pool for more information about the subcommands.

Deciding when you need a custom provider

The built-in local provider covers single-device development, and the existing RK_DEVICE_IP environment variable covers one-off overrides.

You should write a custom provider when you have:

  • A pool of physical devices fronted by a coordination service (for example, a Selenium-grid-style allocator).
  • A cloud-hosted Roku farm with a programmatic acquire-and-release API.
  • A CI-specific selection strategy (for example, "round-robin across five devices, prefer ones matching the build's firmware").

If you need to pin a specific IP for a specific run, set RK_DEVICE_IP. You do not need a custom provider for this use case.

Implementing the DeviceProvider interface

A provider is any class (or factory-returned object) that satisfies the following interface:

interface DeviceProvider {
    readonly kind: ProviderKind;            // string identifier, for example "my-org-cloud"
    acquire(opts: AcquireOptions): Promise<DeviceLease>;
    release(leaseId: string): Promise<void>;
    status(): Promise<ProviderStatus>;
}

acquire(opts)

The acquire method selects a device from your pool, marks it taken (however your pool tracks ownership), and returns a DeviceLease that downstream tooling reads to know which IP and credentials to use.

The AcquireOptions object is what the caller passes for each call. Per-call overrides (target, requireFw, and ttlSeconds) take precedence over the same-named field inside opts.resolvedConfig. You can read AcquireOptions for what was requested in this call; you do not need to work out resolution precedence again. The following interface defines AcquireOptions:

interface AcquireOptions {
    target?: string;            // device id / IP / tag the caller insisted on
    tags?: string[];            // free-form filter hints
    requireFw?: string;         // semver firmware requirement
    ttlSeconds: number;         // lease lifetime; must be greater than 0
    purpose: string;            // free-form caller-stamped purpose, written into the lease
    ci: { pipelineId: string; jobId: string; retryIndex: number };
    resolvedConfig: ResolvedConfig;     // immutable audit trail of the layered resolution
    onBusy?: "wait" | "fail" | "cloud";  // hint when no device matches; provider may ignore
    force?: boolean;                    // Opt-in sticky-state recovery: a provider can run a
                                        // one-shot reset (DELETE sideloaded app + ECP Home)
                                        // against a `busy` candidate and re-probe before
                                        // declaring it un-acquirable. Optional for providers
                                        // to honor; ignore it if your pool's "busy" semantics
                                        // do not map to the Roku installer-surface model.
    passwordRef?: string;               // Opt-in. Caller-supplied passwordRef (for example,
                                        // "env:DEVICE_PASSWORD_DESK_A") that must be
                                        // written verbatim into the lease. Validated
                                        // against the version 1 prefix policy (env:NAME |
                                        // literal:VALUE). When set, your provider
                                        // should prefer it over its internal default
                                        // because that is the audit invariant that lets a
                                        // custom-pool lease be byte-comparable to a
                                        // CI-pool lease for the same device. Ignore
                                        // if your pool has its own credential plumbing.
}

Inventory schema

If your provider reads its inventory from a JSON environment variable (the canonical pattern, and what the local pool does), @roku-sdk/tooling ships a public Zod schema that you can reuse. See schemas/device-inventory.ts for more information about DeviceInventorySchema and DeviceInventoryDeviceSchema. You can use any environment variable name; the schema does not require a specific name. The shape is { schemaVersion: 1, devices: [{ id, ip, passwordRef, tags?, disabled? }] }. A provider may extend an entry with its own keys (a firmware pin, for example). The public schema strips unknown keys; therefore, one inventory can feed both a narrower provider schema and this one without forking.

The DEVICE_INVENTORY_ENV_VAR constant was exported alongside those schemas up to @roku-sdk/tooling 2.14.0 and was removed in 2.15.0. It held the environment variable name that one Roku-internal pool reads. A provider outside Roku could not usefully honor that name, and exporting it implied that the schema requires a specific name. If you imported it, declare your own constant for whichever variable your provider reads.

The returned DeviceLease must satisfy DeviceLeaseSchema:

{
    schemaVersion: 1,
    leaseId: string,            // ULID, 26 characters Crockford base32
    providerKind: string,       // your provider's kind
    ip: string,
    passwordRef: string,        // "env:NAME" or "literal:VALUE"; see Handling passwords with passwordRef
    acquiredAt: string,         // ISO-8601 UTC
    expiresAt: string,          // ISO-8601 UTC
    owner: { pid: number, host: string },
    ci?: { pipelineId, jobId, retryIndex }   // optional for interactive use
    // additional fields are tolerated via .passthrough()
}

release(leaseId)

The release method frees the device. It must be idempotent: calling release twice for the same lease (for example, once from a CI after_script and once from a periodic reaper) must not throw. The canonical "released" signal is the absence of the lease file. If you maintain additional pool-side state (for example, a cloud instance that you stop), do that as best effort and ignore "already released" errors.

status()

The status method returns a snapshot of the provider's current inventory:

interface ProviderStatus {
    kind: ProviderKind;
    devices: Array<{
        id: string;
        ip: string;
        state: "free" | "busy" | "leased" | "unreachable";
        fw?: string;
    }>;
}

The rk device pool status command uses it for triage. You should keep implementations lightweight because the command calls this method interactively.

Skeleton implementation

The following skeleton shows a provider that writes lease files. Replace the pool-specific methods with calls to your pool API:

// my-org/src/device-pool/my-org-provider.ts

import {
    type DeviceProvider,
    type AcquireOptions,
    type DeviceLease,
    type ProviderStatus,
    type ResolvedConfig,
    DeviceLeaseSchema,
    DevicePoolError,
    atomicWriteJson,
    leaseFilePath,
    registerDeviceProvider
} from "@roku-sdk/tooling";
import { ulid } from "ulid";
import { promises as fs } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";

export class MyOrgProvider implements DeviceProvider {
    public readonly kind = "my-org" as const;

    constructor(
        private readonly args: {
            cwd: string;
            resolvedConfig: ResolvedConfig;
            leaseDir?: string;
            env?: NodeJS.ProcessEnv;
        }
    ) {
        if (args.resolvedConfig.pool !== "my-org") {
            throw new DevicePoolError(
                "RESOLVER_FORBIDDEN_COMBO",
                { providerKind: "my-org", resolvedPool: args.resolvedConfig.pool },
                `MyOrgProvider constructed with resolvedConfig.pool="${args.resolvedConfig.pool}"; expected "my-org".`
            );
        }
    }

    async acquire(opts: AcquireOptions): Promise<DeviceLease> {
        if (!Number.isFinite(opts.ttlSeconds) || opts.ttlSeconds <= 0) {
            throw new DevicePoolError(
                "RESOLVER_FORBIDDEN_COMBO",
                { ttlSeconds: opts.ttlSeconds },
                `MyOrgProvider.acquire: ttlSeconds must be a positive finite number.`
            );
        }

        const device = await this.allocateFromYourPool(opts);
        if (!device) {
            throw new DevicePoolError(
                "DEVICE_BUSY",
                { tags: opts.tags, requireFw: opts.requireFw },
                `MyOrgProvider: no free device matches the request.`
            );
        }

        const acquiredAt = new Date();
        const lease: DeviceLease = {
            schemaVersion: 1,
            leaseId: ulid(),
            providerKind: this.kind,
            ip: device.ip,
            passwordRef: "env:RK_DEVICE_PASSWORD",
            acquiredAt: acquiredAt.toISOString(),
            expiresAt: new Date(acquiredAt.getTime() + opts.ttlSeconds * 1000).toISOString(),
            owner: { pid: process.pid, host: os.hostname() },
            ci: opts.ci,
            purpose: opts.purpose
        };

        const validated = DeviceLeaseSchema.parse(lease);
        const leaseDir = this.args.leaseDir ?? path.join(this.args.cwd, ".roku", "leases");
        await atomicWriteJson(leaseFilePath(lease.leaseId, leaseDir), validated);
        return validated;
    }

    async release(leaseId: string): Promise<void> {
        const filePath = leaseFilePath(leaseId, this.args.leaseDir ?? path.join(this.args.cwd, ".roku", "leases"));
        try {
            await fs.unlink(filePath);
        } catch (err) {
            if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
        }
        await this.releaseInYourPool(leaseId).catch(() => {
            // best-effort cleanup; ignore "already released"
        });
    }

    async status(): Promise<ProviderStatus> {
        const inventory = await this.snapshotYourPool();
        return {
            kind: this.kind,
            devices: inventory.map(d => ({ id: d.id, ip: d.ip, state: d.state, fw: d.fw }))
        };
    }

    // ----- your pool-specific code -----
    private async allocateFromYourPool(_opts: AcquireOptions): Promise<{ ip: string } | undefined> {
        throw new Error("implement against your pool API");
    }
    private async releaseInYourPool(_leaseId: string): Promise<void> {
        throw new Error("implement against your pool API");
    }
    private async snapshotYourPool(): Promise<Array<{ id: string; ip: string; state: ProviderStatus["devices"][number]["state"]; fw?: string }>> {
        throw new Error("implement against your pool API");
    }
}

Registering your provider

Registering a provider takes two parts: declare the factory, and then trigger its load.

Declaring the factory (top-level call)

Add the following call in the same module as your provider class:

registerDeviceProvider(
    "my-org",
    (resolvedConfig) =>
        new MyOrgProvider({
            cwd: process.cwd(),
            resolvedConfig
        })
);

The registry invokes the factory on every getDeviceProvider("my-org", cfg) call. Re-creating the provider for each call lets the factory bind to per-call config (region, inventory snapshot hash, and so on) without leaking state across calls.

Triggering the load (side-effect import)

Code that uses your provider must load the file once before it calls getDeviceProvider("my-org", …). A side-effect import at the top of the entry point of that code does this:

// my-org/src/test-bootstrap.ts
import "./device-pool/my-org-provider";   // self-registers
import { getDeviceProvider, resolveConfig } from "@roku-sdk/tooling";

const { config } = await resolveConfig({
    defaults: { pool: "my-org", profile: "ci-default", ttlSeconds: 1800, routing: "primary-preferred" },
    profilePath: ".roku/profiles.json",
    envOverlay: process.env,
    cliFlags: {}
});

const provider = getDeviceProvider("my-org", config);
const lease = await provider.acquire({
    ttlSeconds: 600,
    purpose: "smoke-test",
    ci: { pipelineId: "local", jobId: String(process.pid), retryIndex: 0 },
    resolvedConfig: config
});
// … use lease.ip / resolvePasswordRef(lease.passwordRef, process.env, lease.providerKind) …
await provider.release(lease.leaseId);

If you are publishing your provider as its own npm package, you must ship a side-effect-only entry point (for example, import "@my-org/roku-pool") that runs the registration when imported. You must document this in your package README because module load order matters.

Handling passwords with passwordRef

The lease file never contains a plaintext password. Instead, it carries a reference (passwordRef) that the code reading the lease resolves at read time through resolvePasswordRef(). The schema accepts two prefixes in version 1:

PrefixMeaningWhen allowed
env:NAMERead process.env[NAME] at resolve time.Always. This is the production path.
literal:VALUEEmbed the password directly.Only when both RK_ALLOW_LITERAL_PASSWORD=1 and providerKind === "local"

Other prefixes (keychain: and so on) are reserved for version 2, and the schema rejects them.

For your custom provider, always emit env:NAME:

passwordRef: "env:RK_DEVICE_PASSWORD"
// or, if you map per-device:
passwordRef: `env:MY_ORG_PASSWORD_${device.id.toUpperCase()}`

Reading the password back (for example, inside device-test-client):

import { readLeaseFile, resolvePasswordRef } from "@roku-sdk/tooling";

const lease = await readLeaseFile(process.env.RK_DEVICE_LEASE_ID!, ".roku/leases");
const password = resolvePasswordRef(lease.passwordRef, process.env, lease.providerKind);

The resolvePasswordRef function throws DevicePoolError("INVALID_LEASE_FILE", …) if the environment variable is unset or empty, the prefix is unknown, or the literal-policy gate fails. You must not log the resolved password.

Error handling

You must throw only DevicePoolError (or a subclass) with one of the stable codes in the following table:

CodeWhen to use
PROVIDER_NOT_REGISTEREDThrown by the registry; you should not throw this directly.
INVALID_PROFILEMalformed .roku/profiles.json or unknown profile name
INVALID_LEASE_FILEThe lease file is corrupt, passwordRef cannot be resolved, or the provider cannot construct a valid lease (for example, because a password source does not exist).
RESOLVER_FORBIDDEN_COMBOContradictory inputs (for example, pool=cloud + RK_DEVICE_IP=…); also for input-validation failures inside your provider
DEVICE_BUSYAll matching devices are taken.
DEVICE_UNREACHABLESelected device fails network or ECP probe.
FW_MISMATCHDevice firmware does not satisfy requireFw.

The code strings are part of the public contract: telemetry, alerting, and code that reads the errors branch on them. New codes may be added without warning, but existing codes never get renamed.

The following example throws DEVICE_BUSY with structured context:

throw new DevicePoolError(
    "DEVICE_BUSY",
    { tags: opts.tags, attemptedDevices: tried.map(d => d.id) },
    `MyOrgProvider: no free device matches tags=${opts.tags?.join(",") ?? "<none>"}; tried ${tried.length} candidates.`
);

The context object is for structured telemetry; the message is for human triage. You should keep context keys stable so that log queries stay valid across releases.

Verifying release invariants

You should verify that your release implementation meets the following invariants:

  • The release(leaseId) method is idempotent: the second call must not throw.
  • Lease-file deletion is the canonical "released" signal: the reaper checks whether the file is absent.
  • Pool-side cleanup (for example, a cloud instance stop) is best effort: ignore "already released" and "not found" errors so that retries stay idempotent.
  • Only the lease-file unlink failure is allowed to propagate.

Understanding the layered configuration that your provider sees

The AcquireOptions.resolvedConfig field is an immutable audit trail of how the configuration was derived. Each field carries both the value and the source layer (default | profile | env | cli). You should stamp it into your lease (through .passthrough() or by extending the schema) so that triage can answer "why did this run pick this provider?" without re-running the resolver. The following example shows a resolved configuration:

{
    pool: "my-org",
    poolSource: "env",                   // came from RK_DEVICE_POOL=my-org
    profile: "ci-default",
    profileSource: "default",
    ttlSeconds: 1800,
    ttlSecondsSource: "default",
    routing: "primary-preferred",
    target: "device-42",
    targetSource: "cli"                  // came from --target=device-42
    // … inventorySource, firmwareDriftDetected: populated by providers that use them
}

To produce one yourself (for example, inside your bootstrap script):

import { resolveConfig } from "@roku-sdk/tooling";

const { config, warnings } = await resolveConfig({
    defaults: {
        pool: "my-org",
        profile: "ci-default",
        ttlSeconds: 1800,
        routing: "primary-preferred"
    },
    profilePath: ".roku/profiles.json",
    envOverlay: process.env,
    cliFlags: { /* parsed --pool, --profile, --ttl, --target */ }
});

for (const w of warnings) console.warn(w);

The resolveConfig function runs the layered precedence (default → profile → env → cli), validates the result against ResolvedConfigSchema, and throws DevicePoolError for malformed profiles or contradictory inputs.

Testing your provider

The registry is a process-wide singleton; therefore, test files normally rely on Jest's per-file module isolation. If you need to reset between cases inside one file, you can import the test-only reset function directly from the registry module (it is not in the package entry point):

import { __resetProviderRegistryForTests } from "@roku-sdk/tooling/src/device-pool/providers/registry";

beforeEach(() => __resetProviderRegistryForTests());

The following example shows a complete acquire-and-release round trip:

import { jest } from "@jest/globals";
import { getDeviceProvider, readLeaseFile } from "@roku-sdk/tooling";

it("acquires and releases a device", async () => {
    await jest.isolateModulesAsync(async () => {
        await import("../src/device-pool/my-org-provider");   // self-registers

        const provider = getDeviceProvider("my-org", {
            pool: "my-org",
            poolSource: "default",
            profile: "ci-default",
            profileSource: "default",
            ttlSeconds: 1800,
            ttlSecondsSource: "default",
            routing: "primary-preferred"
        });

        const lease = await provider.acquire({
            ttlSeconds: 60,
            purpose: "test",
            ci: { pipelineId: "test", jobId: "1", retryIndex: 0 },
            resolvedConfig: /* same as above */ undefined as never
        });

        const persisted = await readLeaseFile(lease.leaseId, ".roku/leases");
        expect(persisted.providerKind).toBe("my-org");

        await provider.release(lease.leaseId);
        await provider.release(lease.leaseId);   // idempotent
    });
});

Selecting your provider at runtime

Once registered, you select your provider through RK_DEVICE_POOL=my-org (or by passing pool: "my-org" into the defaults of resolveConfig). The rk subcommands route through the same registry. The following example runs two subcommands with the provider selected:

$ RK_DEVICE_POOL=my-org rk device lease acquire --json
{ "schemaVersion": 1, "leaseId": "01HE7P5RZQ…", "providerKind": "my-org", "ip": "…", … }

$ RK_DEVICE_POOL=my-org rk device pool status
[device-pool] kind=my-org
ID            IP             STATE         FW
device-42     10.0.0.42      free          15.2.0
device-43     10.0.0.43      busy          15.2.0

If you prefer not to set an environment variable, you can pass --pool my-org explicitly to each subcommand.

Understanding current limitations

You cannot do the following:

  • Subclass LocalProvider. The class is not part of the public surface; the registry-based seam is the contract. If you need local-provider behavior plus extras, you can write your own provider that internally calls the same tooling-utils helpers.
  • Override RK_* environment variable semantics. The env-allowlist in @roku-sdk/tooling is closed for the public package; only the documented set of generic RK_* variables participates in resolution. Custom keys are ignored.
  • Use inter-process mutual exclusion through the registry. The registry is in-process. If you need cross-process exclusion (CI parallelism), do it in your provider's pool API or through a mechanism such as GitLab's resource_group.

Related documentation

  • device-pool/README.md: Describes the internal navigation of the implementing module.
  • rk device: Describes the base rk device subcommands.
  • rk device lease: Describes the acquire, release, and reap subcommands.
  • rk device pool: Describes the status subcommand and the ProviderStatus output shape.
  • device-pool/: Contains the doc blocks that are the source of truth for every interface, schema, and error code.

Did this page help you?