diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac35ef..7adaa03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- New `getPowerState` function on `LGTV` allowing testing if the TV is on, off, + or in an unknown state. + ## 4.1.1 - 2023-12-31 ### Fixed diff --git a/packages/lgtv-ip-control/README.md b/packages/lgtv-ip-control/README.md index 83ef7b6..30de8ac 100644 --- a/packages/lgtv-ip-control/README.md +++ b/packages/lgtv-ip-control/README.md @@ -156,6 +156,19 @@ Gets the mute state. const muteState = await lgtv.getMuteState(); ``` +### `.getPowerState(): Promise` + +Gets the current TV power state. + +Because the TV might be offline, you should call this function before calling +`.connect()`, otherwise you can get a `TimeoutError`. + +```js +const powerState = await lgtv.getPowerState(); +``` + +See [`PowerStates`](#PowerStates) for available states. + ### `.powerOff(): Promise` Powers the TV off. @@ -319,6 +332,14 @@ See [`ScreenMuteModes`](#ScreenMuteModes) for available modes. | volumeUp | Volume Up | | yellowButton | Yellow Button | +### PowerStates + +| Key | State | +| ------- | -------------------------------------------- | +| on | The TV is on and responding to connections | +| off | The TV is off or powering off | +| unknown | The state of the TV is unknown, possibly off | + ### ScreenMuteModes | Key | Effect | diff --git a/packages/lgtv-ip-control/src/classes/LGTV.ts b/packages/lgtv-ip-control/src/classes/LGTV.ts index cf9a19c..f140922 100644 --- a/packages/lgtv-ip-control/src/classes/LGTV.ts +++ b/packages/lgtv-ip-control/src/classes/LGTV.ts @@ -7,10 +7,11 @@ import { Inputs, Keys, PictureModes, + PowerStates, ScreenMuteModes, } from '../constants/TV.js'; import { LGEncoder, LGEncryption } from './LGEncryption.js'; -import { TinySocket } from './TinySocket.js'; +import { TimeoutError, TinySocket } from './TinySocket.js'; export class ResponseParseError extends Error {} @@ -52,8 +53,8 @@ export class LGTV { await this.socket.connect(options); } - async disconnect(): Promise { - await this.socket.disconnect(); + disconnect() { + this.socket.disconnect(); } async getCurrentApp(): Promise { @@ -101,6 +102,30 @@ export class LGTV { return false; } + async getPowerState(): Promise { + const testPowerState = async () => { + const currentApp = await this.getCurrentApp(); + return currentApp === null ? PowerStates.off : PowerStates.on; + }; + + if (this.connected) { + return testPowerState(); + } + + try { + await this.connect(); + return await testPowerState(); + } catch (error) { + if (error instanceof TimeoutError) { + return PowerStates.unknown; + } else { + throw error; + } + } finally { + this.disconnect(); + } + } + async powerOff(): Promise { throwIfNotOK(await this.sendCommand(`POWER off`)); } diff --git a/packages/lgtv-ip-control/src/classes/TinySocket.ts b/packages/lgtv-ip-control/src/classes/TinySocket.ts index 42aff51..91a19ef 100644 --- a/packages/lgtv-ip-control/src/classes/TinySocket.ts +++ b/packages/lgtv-ip-control/src/classes/TinySocket.ts @@ -68,22 +68,18 @@ export class TinySocket { ); } - #isConnected() { - return ( - this.#connected && !this.#client.connecting && !this.#client.destroyed - ); - } - #assertConnected() { - assert(this.#isConnected(), 'should be connected'); + assert(this.connected, 'should be connected'); } #assertDisconnected() { - assert(!this.#isConnected(), 'should not be connected'); + assert(!this.connected, 'should not be connected'); } get connected() { - return this.#isConnected(); + return ( + this.#connected && !this.#client.connecting && !this.#client.destroyed + ); } wrap( @@ -91,19 +87,32 @@ export class TinySocket { resolve: (value: T) => void, reject: (error: Error) => void, ) => void, + options: { destroyClientOnError?: boolean } = {}, ): Promise { + const { destroyClientOnError = false } = options; + return new Promise((resolve, reject) => { - const handleTimeout = () => { - this.#connected = false; - this.#client.end(); - reject(new TimeoutError()); - }; const cleanup = () => { - this.#client.removeListener('error', reject); + this.#client.removeListener('error', handleError); this.#client.removeListener('timeout', handleTimeout); }; - this.#client.once('error', reject); + const handleError = (error: Error) => { + if (destroyClientOnError) { + this.#connected = false; + this.#client.destroy(); + this.#client = new Socket(); + } + + reject(error); + }; + + const handleTimeout = () => { + cleanup(); + handleError(new TimeoutError()); + }; + + this.#client.once('error', handleError); this.#client.once('timeout', handleTimeout); method( @@ -113,7 +122,7 @@ export class TinySocket { }, (error: Error) => { cleanup(); - reject(error); + handleError(error); }, ); }); @@ -165,13 +174,16 @@ export class TinySocket { }); } - await this.wrap((resolve) => { - this.#client.setTimeout(this.settings.networkTimeout); - this.#client.connect(this.settings.networkPort, this.host, () => { - resolve(undefined); - }); - this.#connected = true; - }); + await this.wrap( + (resolve) => { + this.#client.setTimeout(this.settings.networkTimeout); + this.#client.connect(this.settings.networkPort, this.host, () => { + this.#connected = true; + resolve(undefined); + }); + }, + { destroyClientOnError: true }, + ); } read(): Promise { @@ -203,15 +215,15 @@ export class TinySocket { return this.read(); } - disconnect(): Promise { - if (!this.#isConnected()) { - return Promise.resolve(undefined); + disconnect() { + if (!this.connected) { + return; } - return this.wrap((resolve) => { - this.#connected = false; - this.#client.end(resolve); - }); + this.#connected = false; + this.#client.removeAllListeners(); + this.#client.end(); + this.#client = new Socket(); } wakeOnLan() { diff --git a/packages/lgtv-ip-control/src/constants/TV.ts b/packages/lgtv-ip-control/src/constants/TV.ts index 858ac45..90d730c 100644 --- a/packages/lgtv-ip-control/src/constants/TV.ts +++ b/packages/lgtv-ip-control/src/constants/TV.ts @@ -96,6 +96,12 @@ export enum PictureModes { vivid = 'vivid', } +export enum PowerStates { + on = 'on', + off = 'off', + unknown = 'unknown', +} + export enum ScreenMuteModes { screenMuteOn = 'screenmuteon', videoMuteOn = 'videomuteon', diff --git a/packages/lgtv-ip-control/test/LGTV.test.ts b/packages/lgtv-ip-control/test/LGTV.test.ts index 2a3b6ba..d5cc825 100644 --- a/packages/lgtv-ip-control/test/LGTV.test.ts +++ b/packages/lgtv-ip-control/test/LGTV.test.ts @@ -10,9 +10,11 @@ import { Inputs, Keys, PictureModes, + PowerStates, ScreenMuteModes, } from '../src/constants/TV.js'; +const NULL_IP = '0.0.0.0'; const CRYPT_KEY = 'M9N0AZ62'; const MAC = 'DA:0A:0F:E1:60:CB'; @@ -77,7 +79,11 @@ describe.each([ mockServerSocket = socket; }).listen(); const port = (mockServer.address()).port; - testSettings = { ...DefaultSettings, networkPort: port }; + testSettings = { + ...DefaultSettings, + networkPort: port, + networkTimeout: 50, + }; testTV = new LGTV(address, MAC, crypt ? CRYPT_KEY : null, testSettings); }); @@ -179,6 +185,33 @@ describe.each([ } }); + it.each([{ powerState: PowerStates.on }, { powerState: PowerStates.off }])( + 'gets the TV power state when connected: $powerState', + async ({ powerState }) => { + const mocking = mockResponse( + 'CURRENT_APP', + powerState === PowerStates.on ? 'APP:ANYTHING' : '', + ); + await testTV.connect(); + const actual = testTV.getPowerState(); + await expect(mocking).resolves.not.toThrow(); + await expect(actual).resolves.toBe(powerState); + }, + ); + + it('gets "unknown" TV power state when disconnected', async () => { + const offlineTV = new LGTV( + NULL_IP, + MAC, + crypt ? CRYPT_KEY : null, + testSettings, + ); + + await expect(offlineTV.getPowerState()).resolves.toBe( + PowerStates.unknown, + ); + }); + it.each([ { response: 'OK', error: false }, { response: 'FOO', error: true }, diff --git a/packages/lgtv-ip-control/test/TinySocket.test.ts b/packages/lgtv-ip-control/test/TinySocket.test.ts index e7ab217..cee8ac8 100644 --- a/packages/lgtv-ip-control/test/TinySocket.test.ts +++ b/packages/lgtv-ip-control/test/TinySocket.test.ts @@ -120,7 +120,7 @@ describe.each([ }); it('disconnecting a disconnected socket is a noop', async () => { - expect(socket.disconnect()).resolves.not.toThrow(); + expect(() => socket.disconnect()).not.toThrow(); }); it('reads', async () => {