Skip to content
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ module.exports = {
'camelcase': 'off',
'dot-notation': 'off',
'new-cap': 'off',
'no-shadow': 'off'
'no-shadow': 'off',
'prefer-promise-reject-errors': 'off'
}
}
],
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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 EcpNetworkAccessModeDisabledError extends Error {
constructor(message: string, public results?: any) {
super(message);
Object.setPrototypeOf(this, EcpNetworkAccessModeDisabledError.prototype);
}
}

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

it('handles all error scenarios in catch block', async () => {
const doGetRequestStub = sinon.stub(rokuDeploy as any, 'doGetRequest');

doGetRequestStub.rejects({ results: { response: { headers: { server: 'Roku' } } } });
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect(e).to.be.instanceof(errors.EcpNetworkAccessModeDisabledError);
}

doGetRequestStub.rejects({ results: { response: { headers: { server: 'Apache' } } } });
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect((e as any).results.response.headers.server).to.equal('Apache');
}

doGetRequestStub.rejects({ results: { response: { headers: {} } } });
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect((e as any).results.response.headers.server).to.be.undefined;
}

doGetRequestStub.rejects({ results: { response: { headers: { server: null } } } });
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect((e as any).results.response.headers.server).to.be.null;
}

doGetRequestStub.rejects({ results: { response: {} } });
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect((e as any).results.response.headers).to.be.undefined;
}

doGetRequestStub.rejects({ results: {} });
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect((e as any).results.response).to.be.undefined;
}

doGetRequestStub.rejects({});
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect((e as any).results).to.be.undefined;
}

const err = new Error('Network error');
doGetRequestStub.rejects(err);
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect(e).to.equal(err);
}

// eslint-disable-next-line prefer-promise-reject-errors
doGetRequestStub.callsFake(() => Promise.reject(null));
try {
await rokuDeploy.getDeviceInfo({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect(e).to.be.null;
}
});
});

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 response header had Roku in it`, async () => {
const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo');
getDeviceInfoStub.rejects({ results: { response: { headers: { server: 'Roku' } } } });
expect(await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' })).to.equal('disabled');
});

it('handles all error scenarios in catch block', async () => {
const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo');
async function doTest(rejectionValue: any) {
getDeviceInfoStub.rejects(rejectionValue);
try {
await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect(e).to.be.instanceof(errors.UnknownDeviceResponseError);
}
}

await doTest({ results: { response: { headers: { server: 'Apache' } } } });
await doTest({ results: { response: { headers: {} } } });
await doTest({ results: { response: { headers: { server: null } } } });
await doTest({ results: { response: {} } });
await doTest({ results: {} });
await doTest({});
await doTest(new Error('Network error'));
});

it('handles null error from rejected promise', async () => {
const getDeviceInfoStub = sinon.stub(rokuDeploy, 'getDeviceInfo');
getDeviceInfoStub.callsFake(() => Promise.reject(null));
try {
await rokuDeploy.getEcpNetworkAccessMode({ host: '1.1.1.1' });
assert.fail('Exception should have been thrown');
} catch (e) {
expect(e).to.be.instanceof(errors.UnknownDeviceResponseError);
}
});
});

describe('normalizeDeviceInfoFieldValue', () => {
Expand Down
42 changes: 38 additions & 4 deletions src/RokuDeploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1196,10 +1196,18 @@ 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) {
if ((e as any)?.results?.response?.headers?.server?.includes('Roku')) {
throw new errors.EcpNetworkAccessModeDisabledError(`Unable to access device-info because ecp-setting-mode is 'disabled'`, response);
}
throw e;
}
try {
const parsedContent = await xml2js.parseStringPromise(response.body, {
explicitArray: false
Expand All @@ -1223,6 +1231,30 @@ 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) {
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 +1520,5 @@ export interface GetDeviceInfoOptions {
*/
enhance?: boolean;
}

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