Skip to content

Commit

Permalink
feat: Add heartbeat tracker to websocket connection
Browse files Browse the repository at this point in the history
Closes #488
  • Loading branch information
zachowj committed Oct 27, 2021
1 parent 8aeab34 commit 090e8bf
Show file tree
Hide file tree
Showing 12 changed files with 216 additions and 85 deletions.
8 changes: 8 additions & 0 deletions docs/node/config-server.md
Expand Up @@ -44,6 +44,14 @@ A list of strings, not case sensitive, delimited by vertical pipe, |, that will

Enables the caching of the JSON autocomplete requests. Enabling or disabling this may require a restart of Node-RED for it to take effect.

### Enable Heartbeat

Heartbeat will send a ping message using the websocket connection to Home Assistant every X seconds. If a pong response is not received within a 5 seconds Node-RED will attempt to reconnect to Home Assistant.

### Heartbeat Interval

The interval at which the ping message is sent to Home Assistant. The mininum value is 10 seconds.

## Details

Every node requires a configuration attached to define how to contact Home Assistant, which is this config node's main purpose.
Expand Down
2 changes: 2 additions & 0 deletions src/controllers/ConfigServer.js
Expand Up @@ -14,6 +14,8 @@ const nodeDefaults = {
ha_boolean: {},
connectionDelay: {},
cacheJson: {},
heartbeat: {},
heartbeatInterval: {},
};

class ConfigServer {
Expand Down
5 changes: 1 addition & 4 deletions src/homeAssistant/HomeAssistant.ts
Expand Up @@ -110,10 +110,7 @@ export default class HomeAssistant {
}

close(): void {
const client = this?.websocket?.client;
if (client) {
client.close();
}
this?.websocket?.close();
}

addListener(
Expand Down
20 changes: 17 additions & 3 deletions src/homeAssistant/Websocket.ts
Expand Up @@ -52,12 +52,14 @@ import {
import { Credentials } from './';
import { subscribeAreaRegistry, subscribeDeviceRegistry } from './collections';
import createSocket from './createSocket';
import { startHeartbeat, StopHeartbeat } from './heartbeat';

const debug = Debug('home-assistant:ws');

export type WebsocketConfig = Credentials & {
rejectUnauthorizedCerts: boolean;
connectionDelay: boolean;
heartbeatInterval: number;
};

type HassTranslationsResponse = {
Expand All @@ -74,6 +76,7 @@ export default class Websocket {
private statesLoaded = false;
private subscribedEvents = new Set<string>();
private unsubCallback: { [id: string]: () => void } = {};
private stopHeartbeat?: StopHeartbeat;

areas: HassAreas = [];
client!: Connection;
Expand Down Expand Up @@ -436,17 +439,23 @@ export default class Websocket {
this.integrationVersion = 0;
this.isHomeAssistantRunning = false;
this.connectionState = STATE_CONNECTED;
if (this.config.heartbeatInterval) {
this.stopHeartbeat = startHeartbeat(
this.client,
this.config.heartbeatInterval,
this.config.host
);
}
this.emitEvent('ha_client:open');
}

onClientClose(): void {
this.integrationVersion = 0;
this.isHomeAssistantRunning = false;
this.connectionState = STATE_DISCONNECTED;
this.emitEvent('ha_client:close');

debug('events connection closed, cleaning up connection');
this.resetClient();
debug('events connection closed, cleaning up connection');
this.emitEvent('ha_client:close');
}

onClientError(data: unknown): void {
Expand All @@ -460,6 +469,11 @@ export default class Websocket {
this.connectionState = STATE_CONNECTING;
}

close(): void {
typeof this.stopHeartbeat === 'function' && this.stopHeartbeat();
this?.client?.close();
}

resetClient(): void {
this.servicesLoaded = false;
this.statesLoaded = false;
Expand Down
47 changes: 47 additions & 0 deletions src/homeAssistant/heartbeat.ts
@@ -0,0 +1,47 @@
import Debug from 'debug';

import { Connection } from 'home-assistant-js-websocket';

const debug = Debug('home-assistant:ws:heartbeat');

export type StopHeartbeat = () => void;

const HEARTBEAT_TIMEOUT = 5000;
const MIN_HEARTBEAT_INTERVAL = 10000;

export const startHeartbeat = (
client: Connection,
interval: number,
host: string
): StopHeartbeat => {
let heartbeatIntervalId: NodeJS.Timer;
let beatTimeoutId: NodeJS.Timeout;

heartbeatIntervalId = setInterval(
async () => {
beatTimeoutId = setTimeout(() => {
debug(`No pong received from ${host} attempting to reconnect`);
client.suspendReconnectUntil(
new Promise((resolve) => {
resolve();
})
);
client.suspend();
}, HEARTBEAT_TIMEOUT);

debug(`Ping sent to ${host}`);
try {
await client.ping();
clearTimeout(beatTimeoutId);
debug(`Pong received from ${host}`);
} catch (e) {}
},
// mininum of a 10 second heartbeat
Math.max(MIN_HEARTBEAT_INTERVAL, interval * 1000)
);

return () => {
clearInterval(heartbeatIntervalId);
clearTimeout(beatTimeoutId);
};
};
6 changes: 6 additions & 0 deletions src/homeAssistant/index.ts
Expand Up @@ -79,12 +79,18 @@ function createWebsocketConfig(
credentials.host !== SUPERVISOR_URL
? false
: config.connectionDelay ?? false;
const heartbeatInterval = Number(config.heartbeatInterval) ?? 0;
const heartbeat =
config.heartbeat && Number.isInteger(heartbeatInterval)
? heartbeatInterval
: 0;

return {
access_token: credentials.access_token,
host: credentials.host,
rejectUnauthorizedCerts: config.rejectUnauthorizedCerts ?? true,
connectionDelay,
heartbeatInterval: heartbeat,
};
}

Expand Down
12 changes: 12 additions & 0 deletions src/migrations/config-server.js
Expand Up @@ -30,6 +30,18 @@ const migrations = [
return newSchema;
},
},
{
version: 2,
up: (schema) => {
const newSchema = {
...schema,
version: 2,
heartbeat: false,
heartbeatInterval: 30,
};
return newSchema;
},
},
];

module.exports = migrations;
2 changes: 2 additions & 0 deletions src/types/nodes.ts
Expand Up @@ -9,6 +9,8 @@ export interface ServerNodeConfig {
ha_boolean: string;
connectionDelay: boolean;
cacheJson: boolean;
heartbeat: boolean;
heartbeatInterval: number;
}

export interface ServerNode extends Node {
Expand Down
57 changes: 57 additions & 0 deletions test/migrations/config-server.test.js
@@ -0,0 +1,57 @@
const { expect } = require('chai');

const migrations = require('../../src/migrations/config-server');
const { migrate } = require('../../src/migrations');

const VERSION_UNDEFINED = {
id: 'node.id',
type: 'server',
name: 'label of node',
addon: false,
};
const VERSION_0 = {
...VERSION_UNDEFINED,
version: 0,
};
const VERSION_1 = {
...VERSION_0,
version: 1,
rejectUnauthorizedCerts: true,
ha_boolean: 'y|yes|true|on|home|open',
connectionDelay: true,
cacheJson: true,
};
const VERSION_2 = {
...VERSION_1,
version: 2,
heartbeat: false,
heartbeatInterval: 30,
};

describe('Migrations - Server Config Node', function () {
describe('Version 0', function () {
it('should add version 0 to schema when no version is defined', function () {
const migrate = migrations.find((m) => m.version === 0);
const migratedSchema = migrate.up(VERSION_UNDEFINED);
expect(migratedSchema).to.eql(VERSION_0);
});
});
describe('Version 1', function () {
it('should update version 0 to version 1', function () {
const migrate = migrations.find((m) => m.version === 1);
const migratedSchema = migrate.up(VERSION_0);
expect(migratedSchema).to.eql(VERSION_1);
});
});
describe('Version 2', function () {
it('should update version 1 to version 2', function () {
const migrate = migrations.find((m) => m.version === 2);
const migratedSchema = migrate.up(VERSION_1);
expect(migratedSchema).to.eql(VERSION_2);
});
});
it('should update an undefined version to current version', function () {
const migratedSchema = migrate(VERSION_UNDEFINED);
expect(migratedSchema).to.eql(VERSION_2);
});
});
24 changes: 0 additions & 24 deletions ui/css/common.scss
Expand Up @@ -168,30 +168,6 @@ li span.property-type {
}
}

.button-box {
div.text-box {
display: inline-block;
position: relative;
width: 70%;
height: 20px;

div {
position: absolute;
left: 0px;
right: 40px;

input {
width: 100%;
}
}
a {
position: absolute;
right: 0px;
top: 0px;
}
}
}

.badge {
display: inline-block;
font-size: 12px;
Expand Down

0 comments on commit 090e8bf

Please sign in to comment.