Skip to content

Commit a645cf9

Browse files
committed
Reconnect on closing ws connection and better typing.
1 parent 1fe793c commit a645cf9

File tree

6 files changed

+167
-149
lines changed

6 files changed

+167
-149
lines changed

src/http/index.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import axios from 'axios'
2-
import {AUTH_URL, DEVICES_URL} from "./const";
3-
import {DeviceType} from "../ws/const";
1+
import axios from 'axios';
2+
import {AUTH_URL, DEVICES_URL} from './const';
3+
import {DeviceType} from '../ws/const';
44

55
export type DeviceResponseItem = {
6-
endpoint: any
7-
grants: any
8-
identifier: string
9-
name: string
10-
type: DeviceType
6+
endpoint: any;
7+
grants: any;
8+
identifier: string;
9+
name: string;
10+
type: DeviceType;
1111
};
1212

1313
export const login = (authorization: string): Promise<{ token: string }> => axios.get(AUTH_URL, {
14-
headers: {
15-
Authorization: authorization
16-
}
17-
}).then(response => response.data)
14+
headers: {
15+
Authorization: authorization,
16+
},
17+
}).then(response => response.data);
1818

1919
export const getDevices = (authorization: string): Promise<DeviceResponseItem[]> => axios.get(DEVICES_URL, {
20-
headers: {
21-
Authorization: authorization
22-
}
23-
}).then(response => response.data.devices)
20+
headers: {
21+
Authorization: authorization,
22+
},
23+
}).then(response => response.data.devices);

src/platform.ts

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ import {
1111
import {PLATFORM_NAME, PLUGIN_NAME} from './settings';
1212
import {HomewizardPrincessHeaterAccessory} from './platformAccessory';
1313
import {
14+
HelloWsOutgoingMessage,
1415
PrincessHeaterAccessoryContext,
1516
ResponseWsIncomingMessage,
16-
WsOutgoingMessage,
17+
WsIncomingMessage,
1718
} from './ws/types';
1819
import {DeviceType, MessageType} from './ws/const';
1920
import {getDevices, login} from './http';
20-
import {open} from './ws';
21-
import {WsClient} from './ws/client';
21+
import {WsClient} from './ws';
2222

2323
/**
2424
* HomebridgePlatform
@@ -66,39 +66,32 @@ export class HomebridgePrincessHeaterPlatform implements DynamicPlatformPlugin {
6666
* Accessories must only be registered once, previously created accessories
6767
* must not be registered again to prevent "duplicate UUID" errors.
6868
*/
69-
discoverDevices() {
69+
async discoverDevices() {
7070

71-
const authResponsePromise = login(this.config.authorization as string);
72-
const wsPromise = open(this);
71+
const auth = await login(this.config.authorization as string);
7372

74-
Promise.all([authResponsePromise, wsPromise]).then(([auth, ws]) => {
73+
const client = new WsClient(this.log);
7574

76-
const client = new WsClient(ws, this);
77-
78-
ws.on('message', (message: string) => {
79-
const incomingMessage = JSON.parse(message);
80-
if (
81-
'message_id' in incomingMessage &&
82-
incomingMessage.message_id in client.outgoingMessages &&
83-
client.outgoingMessages[incomingMessage.message_id].type === MessageType.Hello
84-
) {
85-
return this.onHelloMessageResponse(incomingMessage, client);
86-
}
87-
});
75+
const helloMessage = await client.send<HelloWsOutgoingMessage>({
76+
type: MessageType.Hello,
77+
version: '2.4.0',
78+
os: 'ios',
79+
source: 'climate',
80+
compatibility: 3,
81+
token: auth.token,
82+
});
8883

89-
const helloMessage: WsOutgoingMessage = {
90-
type: MessageType.Hello,
91-
message_id: client.generateMessageId(),
92-
version: '2.4.0',
93-
os: 'ios',
94-
source: 'climate',
95-
compatibility: 3,
96-
token: auth.token,
97-
};
98-
99-
client.send(helloMessage).catch(err => {
100-
this.log.error('Failed to send Hello message ->', helloMessage.message_id, err);
101-
});
84+
client.on('message', (message: WsIncomingMessage) => {
85+
if (
86+
message.type === 'response' &&
87+
message.message_id === helloMessage.message_id &&
88+
message.status === 200
89+
) {
90+
this.onHelloMessageResponse(
91+
message as ResponseWsIncomingMessage,
92+
client,
93+
);
94+
}
10295
});
10396
}
10497

@@ -140,8 +133,8 @@ export class HomebridgePrincessHeaterPlatform implements DynamicPlatformPlugin {
140133

141134
new HomewizardPrincessHeaterAccessory(
142135
this,
143-
accessory as PlatformAccessory<PrincessHeaterAccessoryContext>,
144-
wsClient,
136+
accessory as PlatformAccessory<PrincessHeaterAccessoryContext>,
137+
wsClient,
145138
);
146139

147140
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);

src/platformAccessory.ts

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import {
77
PrincessHeaterAccessoryContext,
88
PrincessHeaterStateWsIncomingMessage,
99
SubscribeWsOutgoingMessage,
10+
WsIncomingMessage,
1011
} from './ws/types';
11-
import {WsClient} from './ws/client';
12+
import {WsClient} from './ws';
1213
import {MessageType} from './ws/const';
1314

1415
/**
@@ -48,25 +49,21 @@ export class HomewizardPrincessHeaterAccessory {
4849
})
4950
.on('set', this.setTargetTemperature.bind(this));
5051

51-
this.wsClient.ws.on('message', this.onWsMessage.bind(this));
52+
this.wsClient.on('message', this.onWsMessage.bind(this));
5253

5354
this.platform.log.debug('Subscribing to device updates:', this.accessory.context.device.name);
5455

55-
const message: SubscribeWsOutgoingMessage = {
56+
wsClient.send<SubscribeWsOutgoingMessage>({
5657
type: MessageType.SubscribeDevice,
5758
device: this.accessory.context.device.identifier,
58-
message_id: wsClient.generateMessageId(),
59-
};
60-
61-
wsClient.send(message);
59+
});
6260
}
6361

64-
onWsMessage(message: string) {
65-
const incomingMessage = JSON.parse(message);
66-
if ('state' in incomingMessage) {
67-
this.onStateMessage(incomingMessage);
68-
} else if ('type' in incomingMessage && incomingMessage.type === MessageType.JSONPatch) {
69-
this.onJSONPatchMessage(incomingMessage);
62+
onWsMessage(message: WsIncomingMessage) {
63+
if ('state' in message) {
64+
this.onStateMessage(message as PrincessHeaterStateWsIncomingMessage);
65+
} else if ('type' in message && message.type === MessageType.JSONPatch) {
66+
this.onJSONPatchMessage(message as JSONPatchWsOutgoingMessage);
7067
}
7168
}
7269

@@ -149,40 +146,32 @@ export class HomewizardPrincessHeaterAccessory {
149146

150147
this.platform.log.debug('Set Characteristic TargetHeatingCoolingState ->', value);
151148

152-
const message: JSONPatchWsOutgoingMessage = {
149+
this.wsClient.send<JSONPatchWsOutgoingMessage>({
153150
type: MessageType.JSONPatch,
154-
message_id: this.wsClient.generateMessageId(),
155151
device: this.accessory.context.device.identifier,
156152
patch: [{
157153
op: 'replace',
158154
path: '/state/power_on',
159155
value: value === this.platform.Characteristic.TargetHeatingCoolingState.HEAT,
160156
}],
161-
};
162-
163-
this.wsClient.send(message)
157+
})
164158
.then(() => callback(null))
165159
.catch((err) => callback(err));
166160
}
167161

168162
setTargetTemperature(value: CharacteristicValue, callback: CharacteristicSetCallback) {
169163

170-
const message: JSONPatchWsOutgoingMessage = {
164+
this.platform.log.debug('Set Characteristic TargetTemperature ->', value);
165+
166+
this.wsClient.send<JSONPatchWsOutgoingMessage>({
171167
type: MessageType.JSONPatch,
172-
message_id: this.wsClient.generateMessageId(),
173168
device: this.accessory.context.device.identifier,
174169
patch: [{
175170
op: 'replace',
176171
path: '/state/target_temperature',
177172
value: value as number,
178173
}],
179-
};
180-
181-
this.platform.log.debug('Set Characteristic TargetTemperature ->', value);
182-
183-
this.wsClient.send(message);
184-
185-
this.wsClient.send(message)
174+
})
186175
.then(() => callback(null))
187176
.catch((err) => callback(err));
188177
}

src/ws/client.ts

Lines changed: 0 additions & 42 deletions
This file was deleted.

src/ws/index.ts

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,97 @@
11
import WebSocket from 'ws';
2-
import {WS_URL} from './const';
3-
import {HomebridgePrincessHeaterPlatform} from '../platform';
4-
5-
export const open = (platform: HomebridgePrincessHeaterPlatform): Promise<WebSocket> => new Promise((res) => {
6-
platform.log.debug('Opening WS connection to ->', WS_URL);
7-
const ws = new WebSocket(WS_URL);
8-
9-
ws.on('message', (message: string) => {
10-
platform.log.debug('Incoming message:', JSON.parse(message));
11-
});
12-
13-
ws.on('close', () => {
14-
platform.log.debug('Closing WS connection to ->', WS_URL);
15-
});
16-
17-
ws.on('open', () => {
18-
platform.log.debug('Opened WS connection to ->', WS_URL);
19-
res(ws);
20-
});
21-
});
2+
import {WsOutgoingMessage} from './types';
3+
import {MessageType, WS_URL} from './const';
4+
import {EventEmitter} from 'events';
5+
import {Logger} from 'homebridge';
6+
7+
const OPEN_TIMEOUT = 60 * 1000; // 1 minute
8+
9+
export class WsClient extends EventEmitter {
10+
11+
private lastMessageId = 0;
12+
13+
private ws: WebSocket | null = null;
14+
15+
constructor(
16+
private readonly log: Logger,
17+
) {
18+
super();
19+
}
20+
21+
private open(): Promise<WebSocket> {
22+
return new Promise((res, rej) => {
23+
this.log.debug('Opening WS connection to ->', WS_URL);
24+
const ws = new WebSocket(WS_URL);
25+
26+
ws.on('message', (message: string) => {
27+
const json = JSON.parse(message);
28+
this.log.debug('Incoming message:', json);
29+
super.emit(json);
30+
});
31+
32+
ws.on('error', (error) => {
33+
this.log.error('Unexpected error in WS connection ->', error);
34+
});
35+
36+
ws.on('close', () => {
37+
this.log.debug('Closing WS connection to ->', WS_URL);
38+
});
39+
40+
const openTimeout = setTimeout(() => {
41+
rej(new Error(`WS connection was not ready in ${OPEN_TIMEOUT}ms`));
42+
}, OPEN_TIMEOUT);
43+
44+
ws.on('open', () => {
45+
clearTimeout(openTimeout);
46+
this.log.debug('Opened WS connection to ->', WS_URL);
47+
res(ws);
48+
});
49+
});
50+
}
51+
52+
public async send<M extends WsOutgoingMessage>(
53+
message: Omit<M, 'message_id'>,
54+
): Promise<M> {
55+
56+
const wsPromise: Promise<WebSocket> = new Promise((res, rej) => {
57+
if (
58+
!this.ws ||
59+
this.ws.readyState === WebSocket.CLOSING ||
60+
this.ws.readyState === WebSocket.CLOSED
61+
) {
62+
63+
this.log.warn('WS connection is not initialized or closed. Attempting to reopen');
64+
65+
this.open()
66+
.then((ws) => {
67+
this.ws = ws;
68+
res(ws);
69+
})
70+
.catch(err => rej(err));
71+
} else {
72+
return res(this.ws);
73+
}
74+
});
75+
76+
const ws = await wsPromise;
77+
78+
return new Promise((res, rej) => {
79+
const messageId = ++this.lastMessageId;
80+
const fullMessage = {
81+
...message,
82+
message_id: messageId,
83+
} as M;
84+
ws.send(
85+
JSON.stringify(fullMessage),
86+
(err) => {
87+
if (err) {
88+
this.log.warn('Failed to send message ->', fullMessage, err);
89+
rej(err);
90+
} else {
91+
res(fullMessage);
92+
}
93+
},
94+
);
95+
});
96+
}
97+
}

0 commit comments

Comments
 (0)