Skip to content
7 changes: 7 additions & 0 deletions src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export class UnauthorizedDeviceResponseError extends Error {
}
}

export class EcpNetworkAccessDisabledError extends Error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be EcpNetworkAccessModeDisabledError? because ECP network access mode is currently set to disabled?

constructor(message: string, public results?: any) {
super(message);
Object.setPrototypeOf(this, EcpNetworkAccessDisabledError.prototype);
}
}

export class UnparsableDeviceResponseError extends Error {
constructor(message: string, public results?: any) {
super(message);
Expand Down
52 changes: 52 additions & 0 deletions src/RokuDeploy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,58 @@ describe('RokuDeploy', () => {
}
assert.fail('Exception should have been thrown');
});

it('throws EcpNetworkAccessDisabledError when response has Roku server header', async () => {
sinon.stub(rokuDeploy as any, 'doGetRequest').rejects({
results: { response: { headers: { server: 'Roku' } } }
});
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
} catch (e) {
expect(e).to.be.instanceof(errors.EcpNetworkAccessDisabledError);
return;
}
assert.fail('Exception should have been thrown');
});

it('rethrows error when server header is not Roku', async () => {
const err = { results: { response: { headers: { server: 'Apache' } } } };
sinon.stub(rokuDeploy as any, 'doGetRequest').rejects(err);
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
} catch (e) {
expect(e).to.equal(err);
return;
}
assert.fail('Exception should have been thrown');
});
});

describe('getEcpNetworkAccessMode', () => {
it('returns ecpSettingMode from device info', async () => {
sinon.stub(rokuDeploy, 'getDeviceInfo').resolves({ ecpSettingMode: 'enabled' });
const result = await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' });
expect(result).to.equal('enabled');
});

it('returns disabled when error has Roku server header', async () => {
sinon.stub(rokuDeploy, 'getDeviceInfo').rejects({
results: { response: { headers: { server: 'Roku' } } }
});
const result = await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' });
expect(result).to.equal('disabled');
});

it('throws UnknownDeviceResponseError when error has no Roku server header', async () => {
sinon.stub(rokuDeploy, 'getDeviceInfo').rejects(new Error('Network error'));
try {
await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' });
} catch (e) {
expect(e).to.be.instanceof(errors.UnknownDeviceResponseError);
return;
}
assert.fail('Exception should have been thrown');
});
});

describe('normalizeDeviceInfoFieldValue', () => {
Expand Down
44 changes: 40 additions & 4 deletions src/RokuDeploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1196,10 +1196,19 @@ export class RokuDeploy {

const url = `http://${options.host}:${options.remotePort}/query/device-info`;

let response = await this.doGetRequest({
url: url,
timeout: options.timeout
});
let response;
try {
response = await this.doGetRequest({
url: url,
timeout: options.timeout
});
} catch (e) {
/* istanbul ignore next: optional chaining creates untestable branches for null errors */
if ((e as any)?.results?.response?.headers.server?.includes('Roku')) {
throw new errors.EcpNetworkAccessDisabledError('ECP Device Info request failed. ECP setting mode is disabled.', response);
}
throw e;
}
try {
const parsedContent = await xml2js.parseStringPromise(response.body, {
explicitArray: false
Expand All @@ -1223,6 +1232,31 @@ export class RokuDeploy {
}
}

/**
* Get the External Control Protocol (ECP) setting mode of the device. This determines whether
* the device accepts remote control commands via the ECP API.
*
* @param options - Configuration options including host, remotePort, timeout, etc.
* @returns The ECP setting mode:
* - 'enabled': fully enabled and accepting commands
* - 'disabled': ECP is disabled (device may still be reachable but ECP commands won't work)
* - 'limited': Restricted functionality, text and movement commands only
* - 'permissive': Full access for internal networks
*/
public async getEcpNetworkAccessMode(options: GetDeviceInfoOptions): Promise<EcpNetworkAccessMode> {
try {
const deviceInfo = await this.getDeviceInfo(options);
return deviceInfo.ecpSettingMode;
} catch (e) {
/* istanbul ignore next: optional chaining creates untestable branches for null errors */
if ((e as any)?.results?.response?.headers.server?.includes('Roku')) {
return 'disabled';
}
throw new errors.UnknownDeviceResponseError('Could not retrieve device ECP setting');
}

}

/**
* Normalize a deviceInfo field value. This includes things like converting boolean strings to booleans, number strings to numbers,
* decoding HtmlEntities, etc.
Expand Down Expand Up @@ -1488,3 +1522,5 @@ export interface GetDeviceInfoOptions {
*/
enhance?: boolean;
}

export type EcpNetworkAccessMode = 'enabled' | 'disabled' | 'limited' | 'permissive';