Skip to content

Commit

Permalink
Add getPowerState to LGTV to check the TV power state (#123)
Browse files Browse the repository at this point in the history
* Improve disconnection routines on TinySocket

* Add getPowerState to LGTV to check the TV power state
  • Loading branch information
WesSouza authored Jan 3, 2024
1 parent 75026f7 commit 932f3e6
Show file tree
Hide file tree
Showing 7 changed files with 138 additions and 36 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/lgtv-ip-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,19 @@ Gets the mute state.
const muteState = await lgtv.getMuteState();
```

### `.getPowerState(): Promise<PowerStates>`

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<void>`

Powers the TV off.
Expand Down Expand Up @@ -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 |
Expand Down
31 changes: 28 additions & 3 deletions packages/lgtv-ip-control/src/classes/LGTV.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -52,8 +53,8 @@ export class LGTV {
await this.socket.connect(options);
}

async disconnect(): Promise<void> {
await this.socket.disconnect();
disconnect() {
this.socket.disconnect();
}

async getCurrentApp(): Promise<Apps | string | null> {
Expand Down Expand Up @@ -101,6 +102,30 @@ export class LGTV {
return false;
}

async getPowerState(): Promise<PowerStates> {
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<void> {
throwIfNotOK(await this.sendCommand(`POWER off`));
}
Expand Down
74 changes: 43 additions & 31 deletions packages/lgtv-ip-control/src/classes/TinySocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,42 +68,51 @@ 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<T>(
method: (
resolve: (value: T) => void,
reject: (error: Error) => void,
) => void,
options: { destroyClientOnError?: boolean } = {},
): Promise<T> {
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(
Expand All @@ -113,7 +122,7 @@ export class TinySocket {
},
(error: Error) => {
cleanup();
reject(error);
handleError(error);
},
);
});
Expand Down Expand Up @@ -165,13 +174,16 @@ export class TinySocket {
});
}

await this.wrap<undefined>((resolve) => {
this.#client.setTimeout(this.settings.networkTimeout);
this.#client.connect(this.settings.networkPort, this.host, () => {
resolve(undefined);
});
this.#connected = true;
});
await this.wrap<undefined>(
(resolve) => {
this.#client.setTimeout(this.settings.networkTimeout);
this.#client.connect(this.settings.networkPort, this.host, () => {
this.#connected = true;
resolve(undefined);
});
},
{ destroyClientOnError: true },
);
}

read(): Promise<Buffer> {
Expand Down Expand Up @@ -203,15 +215,15 @@ export class TinySocket {
return this.read();
}

disconnect(): Promise<void> {
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() {
Expand Down
6 changes: 6 additions & 0 deletions packages/lgtv-ip-control/src/constants/TV.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
35 changes: 34 additions & 1 deletion packages/lgtv-ip-control/test/LGTV.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -77,7 +79,11 @@ describe.each([
mockServerSocket = socket;
}).listen();
const port = (<AddressInfo>mockServer.address()).port;
testSettings = { ...DefaultSettings, networkPort: port };
testSettings = {
...DefaultSettings,
networkPort: port,
networkTimeout: 50,
};
testTV = new LGTV(address, MAC, crypt ? CRYPT_KEY : null, testSettings);
});

Expand Down Expand Up @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion packages/lgtv-ip-control/test/TinySocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down

0 comments on commit 932f3e6

Please sign in to comment.