Skip to content

Commit

Permalink
Add powerOnAndConnect to LGTV to connect after powering the TV on (#118)
Browse files Browse the repository at this point in the history
  • Loading branch information
WesSouza authored Dec 30, 2023
1 parent 3860d1c commit 0e6811b
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 39 deletions.
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- New `powerOnAndConnect` function on `LGTV` which will retry the connection
while the TV powers on.
- Improvements to TinySocket connection handling and tests.

### Changed

- CLI `power on` now waits until the TV can receive commands.

### Fixed

- Handle getCurrentApp response when the TV is powered off
- Handle getCurrentApp response when the TV is powered off.

## 4.0.2 - 2023-12-23

Expand Down
61 changes: 31 additions & 30 deletions packages/lgtv-ip-control-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,24 @@ function rangeInt(min: number, max: number) {
export function makeProgram() {
function wrapTVAction<A extends unknown[], R>(
action: (tv: LGTV, ...args: A) => Promise<R>,
wrapOptions: { connectBefore?: boolean; disconnectAfter?: boolean } = {},
) {
const { connectBefore = true, disconnectAfter = true } = wrapOptions;
return async (...args: A): Promise<R> => {
try {
const opts = program.opts();
const tv = new LGTV(opts.host, opts.mac ?? null, opts.keycode);
return await action(tv, ...args);
if (connectBefore) {
await tv.connect();
}

const actionResult = await action(tv, ...args);

if (disconnectAfter) {
await tv.disconnect();
}

return actionResult;
} catch (err) {
if (err instanceof Error) {
program.error(err.message);
Expand All @@ -59,20 +71,25 @@ export function makeProgram() {

const power = createCommand('power', 'Turn TV on or off.')
.addArgument(new Argument('<state>', 'Power state.').choices(['on', 'off']))
.action(
wrapTVAction(async (tv, state) => {
switch (state) {
case 'on':
await tv.powerOn();
break;
case 'off':
await tv.connect();
.action((state) => {
switch (state) {
case 'on':
return wrapTVAction(
async (tv) => {
await tv.powerOnAndConnect();
},
{ connectBefore: false },
)();

case 'off':
return wrapTVAction(async (tv) => {
await tv.powerOff();
await tv.disconnect();
break;
}
}),
);
})();

default:
throw new Error(`Invalid power state "${state}"`);
}
});

const volume = createCommand('volume', 'Set the volume level.')
.addArgument(
Expand All @@ -83,13 +100,11 @@ export function makeProgram() {
)
.action(
wrapTVAction(async (tv, level) => {
await tv.connect();
if (level === undefined) {
process.stdout.write(String(await tv.getCurrentVolume()) + '\n');
} else {
await tv.setVolume(level);
}
await tv.disconnect();
}),
);

Expand All @@ -102,7 +117,6 @@ export function makeProgram() {
)
.action(
wrapTVAction(async (tv, state) => {
await tv.connect();
switch (state) {
case 'on':
await tv.setVolumeMute(true);
Expand All @@ -114,7 +128,6 @@ export function makeProgram() {
process.stdout.write((await tv.getMuteState()) ? 'on\n' : 'off\n');
break;
}
await tv.disconnect();
}),
);

Expand All @@ -126,9 +139,7 @@ export function makeProgram() {
)
.action(
wrapTVAction(async (tv, input) => {
await tv.connect();
await tv.setInput(Inputs[input as keyof typeof Inputs]);
await tv.disconnect();
}),
);

Expand All @@ -143,11 +154,9 @@ export function makeProgram() {
)
.action(
wrapTVAction(async (tv, level) => {
await tv.connect();
await tv.setEnergySaving(
EnergySavingLevels[level as keyof typeof EnergySavingLevels],
);
await tv.disconnect();
}),
);

Expand All @@ -169,7 +178,6 @@ export function makeProgram() {
)
.action(
wrapTVAction(async (tv, keys, options) => {
await tv.connect();
for (const pressed of keys) {
switch (pressed) {
case 'pause':
Expand All @@ -182,7 +190,6 @@ export function makeProgram() {
break;
}
}
await tv.disconnect();
}),
);

Expand All @@ -194,9 +201,7 @@ export function makeProgram() {
)
.action(
wrapTVAction(async (tv, iface) => {
await tv.connect();
process.stdout.write((await tv.getMacAddress(iface)) + '\n');
await tv.disconnect();
}),
);

Expand All @@ -208,11 +213,9 @@ export function makeProgram() {
)
.action(
wrapTVAction(async (tv, mode) => {
await tv.connect();
await tv.setPictureMode(
PictureModes[mode as keyof typeof PictureModes],
);
await tv.disconnect();
}),
);

Expand All @@ -227,11 +230,9 @@ export function makeProgram() {
)
.action(
wrapTVAction(async (tv, mode) => {
await tv.connect();
await tv.setScreenMute(
ScreenMuteModes[mode as keyof typeof ScreenMuteModes],
);
await tv.disconnect();
}),
);

Expand Down
4 changes: 2 additions & 2 deletions packages/lgtv-ip-control-cli/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ describe('commands', () => {

it('powers on', async () => {
const spy = vi
.spyOn(LGTV.prototype, 'powerOn')
.mockImplementation(() => {});
.spyOn(LGTV.prototype, 'powerOnAndConnect')
.mockResolvedValue(undefined);
await program.parseAsync([...commonOptions, 'power', 'on']);
expect(spy).toHaveBeenCalledTimes(1);
});
Expand Down
27 changes: 25 additions & 2 deletions packages/lgtv-ip-control/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
# LG TV IP Control

<a href="https://github.com/WesSouza/lgtv-ip-control/actions/workflows/lint-typecheck-test-build.yml"><img src="https://github.com/WesSouza/lgtv-ip-control/actions/workflows/lint-typecheck-test-build.yml/badge.svg" alt="Lint, Type Check, Test, Build"></a>

<a href="https://www.npmjs.com/package/lgtv-ip-control"><img src="https://img.shields.io/npm/v/lgtv-ip-control" alt="npm version badge"></a>

This is a JS library that implements TCP network control for LG TVs manufactured
since 2018. It utilizes encryption rules based on a guide found on the internet.
A non-encrypted mode is provided for older models, but hasn't been tested.

This library is not provided by LG, and it is not a complete implementation
for every TV model.

Check compatibility and TV setup instructions on the [root's README.md](../../README.md).

### Requirements

- Node 16+ (at least ES2017)
Expand All @@ -9,8 +24,6 @@
# Using NPM
npm install lgtv-ip-control

# Using NPM, but excluding the CLI
npm install --no-optional lgtv-ip-control

# Using Yarn
yarn add lgtv-ip-control
Expand Down Expand Up @@ -154,6 +167,16 @@ creating the `LGTV` instance.
lgtv.powerOn();
```

### `.powerOnAndConnect(): Promise<void>`

Powers the TV on, using Wake On Lan, and connects to it. Requires MAC address to
be set when creating the `LGTV` instance. Returns a promise that resolves once
the connection is established, or rejects after a number of retries.

```ts
await lgtv.powerOnAndConnect();
```

### `.sendKey(key: Keys): Promise<void>`

Sends a `key`, as if it was pressed on the TV remote control.
Expand Down
16 changes: 14 additions & 2 deletions packages/lgtv-ip-control/src/classes/LGTV.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ export class LGTV {
return this.socket.connected;
}

async connect(): Promise<void> {
await this.socket.connect();
async connect(
options?: Parameters<typeof TinySocket.prototype.connect>[0],
): Promise<void> {
await this.socket.connect(options);
}

async disconnect(): Promise<void> {
Expand Down Expand Up @@ -116,6 +118,16 @@ export class LGTV {
this.socket.wakeOnLan();
}

powerOnAndConnect(
options?: Parameters<typeof TinySocket.prototype.connect>[0],
) {
this.socket.wakeOnLan();
return this.connect({
maxRetries: 10,
...options,
});
}

async sendKey(key: Keys): Promise<void> {
assert(Object.values(Keys).includes(key), 'key must be valid');
throwIfNotOK(await this.sendCommand(`KEY_ACTION ${key}`));
Expand Down
47 changes: 46 additions & 1 deletion packages/lgtv-ip-control/src/classes/TinySocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ function assertSettings(settings: SocketSettings) {
);
}

export class MaxRetriesError extends Error {}

export class TimeoutError extends Error {}

export class TinySocket {
Expand Down Expand Up @@ -117,9 +119,52 @@ export class TinySocket {
});
}

async connect(): Promise<void> {
async connect(
options: {
maxRetries?: number;
retryTimeout?: number;
} = {},
): Promise<void> {
this.#assertDisconnected();

const { maxRetries = 0, retryTimeout = 750 } = options;
if (maxRetries > 0) {
return new Promise((resolve, reject) => {
let retries = 0;

const connect = (error?: Error) => {
this.#client.removeAllListeners();
this.#client.destroy();
this.#client = new Socket();

if (retries >= maxRetries) {
const maxRetriesError = new MaxRetriesError(
`maximum retries of ${retries} reached`,
);
maxRetriesError.cause = error;
reject(maxRetriesError);
return;
}

this.#client.setTimeout(retryTimeout);
this.#client.connect(this.settings.networkPort, this.host);
this.#client.on('error', connect);
this.#client.on('timeout', connect);
this.#client.on('connect', connected);
retries++;
};

const connected = () => {
this.#client.removeAllListeners();
this.#client.setTimeout(this.settings.networkTimeout);
this.#connected = true;
resolve(undefined);
};

connect();
});
}

await this.wrap<undefined>((resolve) => {
this.#client.setTimeout(this.settings.networkTimeout);
this.#client.connect(this.settings.networkPort, this.host, () => {
Expand Down
49 changes: 48 additions & 1 deletion packages/lgtv-ip-control/test/TinySocket.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,60 @@
import { createSocket, Socket as DgramSocket, SocketType } from 'dgram';
import { AddressInfo, Server, Socket } from 'net';
import { promisify } from 'util';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { DefaultSettings } from '../src/constants/DefaultSettings.js';
import { TinySocket } from '../src/classes/TinySocket.js';

const MAC = 'DA:0A:0F:E1:60:CB';

describe('reconnection test', () => {
const address = '127.0.0.1';
let mockServerSocket: Socket;

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('reconnects after retries', async () => {
const maxRetries = 5;
const retryTimeout = 10000;
const port = 10000 + Math.floor(Math.random() * 55535);

const socket = new TinySocket(address, null, {
...DefaultSettings,
networkPort: port,
});

socket.connect({
maxRetries,
retryTimeout,
});

for (let i = 0; i < maxRetries - 1; i++) {
expect(socket.connected).toBe(false);
await vi.advanceTimersByTimeAsync(retryTimeout);
}

const mockServer = new Server((socket) => {
mockServerSocket = socket;
}).listen(port);

await vi.waitUntil(() => socket.connected);

await new Promise((resolve) => {
mockServerSocket.end(() => {
resolve(undefined);
});
mockServer.unref();
});
});
});

describe.each([
{ ipProto: 'IPv4', address: '127.0.0.1' },
{ ipProto: 'IPv6', address: '::1' },
Expand Down

0 comments on commit 0e6811b

Please sign in to comment.