Skip to content

Commit

Permalink
Merge pull request #58 from AlCalzone/reconnection
Browse files Browse the repository at this point in the history
Add experimental support for automatic reconnection
  • Loading branch information
AlCalzone authored Mar 17, 2018
2 parents a8abf47 + e3d021f commit 1a5ea77
Show file tree
Hide file tree
Showing 10 changed files with 1,062 additions and 47 deletions.
78 changes: 64 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function tradfri_deviceUpdated(device: Accessory) {
}

function tradfri_deviceRemoved(instanceId: number) {
// clean up
// clean up
}

// later...
Expand Down Expand Up @@ -148,7 +148,7 @@ const tradfri = new TradfriClient(hostname: string);
const tradfri = new TradfriClient(hostname: string, customLogger: LoggerFunction);
const tradfri = new TradfriClient(hostname: string, options: TradfriOptions);
```
As the 2nd parameter, you can provide a custom logger function or some options. By providing a custom logger function to the constructor, all diagnostic output will be sent to that function. By default, the `debug` module is used instead. The logger function has the following signature:
As the 2nd parameter, you can provide a custom logger function or an object with some or all of the options shown below. By providing a custom logger function to the constructor, all diagnostic output will be sent to that function. By default, the `debug` module is used instead. The logger function has the following signature:
```TS
type LoggerFunction = (
message: string,
Expand All @@ -159,11 +159,15 @@ type LoggerFunction = (
The options object looks as follows:
```TS
interface TradfriOptions {
customLogger?: LoggerFunction,
useRawCoAPValues?: boolean,
customLogger: LoggerFunction,
useRawCoAPValues: boolean,
watchConnection: boolean | ConnectionWatcherOptions,
}
```
The custom logger function is used as above. By setting `useRawCoAPValues` to true, you can instruct `TradfriClient` to use raw CoAP values instead of the simplified scales used internally. See below for a detailed description how the scales change.
You can provide all the options or just some of them:
* The custom logger function is used as above.
* By setting `useRawCoAPValues` to true, you can instruct `TradfriClient` to use raw CoAP values instead of the simplified scales used internally. See below for a detailed description how the scales change.
* `watchConnection` accepts a boolean to enable/disable connection watching with default parameters or a set of options. See below ("watching the connection") for a detailed description.

The following code samples use the new `async/await` syntax which is available through TypeScript/Babel or in ES7. If that is no option for you, you can also consume the library by using promises:
```TS
Expand Down Expand Up @@ -264,7 +268,7 @@ tradfri.destroy();
Call this before shutting down your application so the gateway may clean up its resources.

### Subscribe to updates
The `TradfriClient` notifies registered listeners when observed resources are updated or removed. It is using the standard `EventEmitter` [interface](https://nodejs.org/api/events.html), so you can add listeners with `on("event", handler)` and remove them with `removeListener` and `removeAllListeners`.
The `TradfriClient` notifies registered listeners when observed resources are updated or removed. It is using the standard [`EventEmitter` interface](https://nodejs.org/api/events.html), so you can add listeners with `on("event", handler)` and remove them with `removeListener` and `removeAllListeners`.
The currently supported events and their handler signatures are:

#### `"device updated"` - A device was added or changed
Expand Down Expand Up @@ -518,20 +522,66 @@ A DeviceInfo object contains general information about a device. It has the foll
* `manufacturer: string` - The device manufacturer. Usually `"IKEA of Sweden"`.
* `modelNumber: string` - The name/type of the device, e.g. `"TRADFRI bulb E27 CWS opal 600lm"`
* `power: PowerSources` - How the device is powered. One of the following enum values:
* `Unknown (0)`
* `InternalBattery (1)`
* `ExternalBattery (2)`
* `Battery (3)` - Although not in the specs, this is apparently used by the remote
* `PowerOverEthernet (4)`
* `USB (5)`
* `AC_Power (6)`
* `Solar (7)`
* `Unknown (0)`
* `InternalBattery (1)`
* `ExternalBattery (2)`
* `Battery (3)` - Although not in the specs, this is apparently used by the remote
* `PowerOverEthernet (4)`
* `USB (5)`
* `AC_Power (6)`
* `Solar (7)`
* `serialNumber: string` - Not used currently. Always `""`


## Automatically watching the connection and reconnecting
**Note:** This feature is currently experimental and allows you to watch the connection and automatically reconnect without shipping your own reconnection routine.

You can enable it by setting the `watchConnection` param of the constructor options to `true` or an options object with the following structure:
```TS
interface ConnectionWatcherOptions {
/** The interval in ms between consecutive pings */
pingInterval: number; // DEFAULT: 10000ms
/** How many pings have to consecutively fail until the gateway is assumed offline */
failedPingCountUntilOffline: number; // DEFAULT: 1
/**
* How much the interval between consecutive pings should be increased
* while the gateway is offline. The actual interval is calculated by
* <ping interval> * <backoff factor> ** <offline pings)>,
* with the number of offline pings capped at 5.
*/
failedPingBackoffFactor: number; // DEFAULT: 1.5

/** Whether automatic reconnection is enabled */
reconnectionEnabled: boolean; // DEFAULT: enabled
/**
* How many pings have to consecutively fail while the gateway is offline
* until a reconnection is triggered
*/
offlinePingCountUntilReconnect: number; // DEFAULT: 3
/** After how many failed reconnects we give up */
maximumReconnects: number; // DEFAULT: infinite
}
```
All parameters of this object are optional and use the default values if not provided. Monitoring the connection state is possible by subscribing to the following events, similar to [subscribing to updates](#subscribe-to-updates):

* `"ping succeeded"`: Pinging the gateway has succeeded. Callback arguments: none.
* `"ping failed"`: Pinging the gateway has failed one or multiple times in a row. Callback arguments:
* `failedPingCount`: number
* `"connection lost"`: Raised after after the first failed ping. Callback arguments: none.
* `"connection alive"`: The connection is alive again after one or more pings have failed. Callback arguments: none.
* `"gateway offline"`: The threshold for consecutive failed pings has been reached, so the gateway is assumed offline. Callback arguments: none.
* `"reconnecting"`: The threshold for failed pings while the gateway is offline has been reached. A reconnect attempt was started. Callback arguments:
* `reconnectAttempt`: number
* `maximumReconnects`: number
* `"give up"`: The maximum amount of reconnect attempts has been reached. No further attempts will be made until the connection is restored.

**Note:** Reconnection internally calls the `reset()` method. This means pending connections and promises will be dropped and the `"error"` event may be emitted aswell. See [resetting the connection](#resetting-the-connection) for details.

## Changelog

#### __WORK IN PROGRESS__
* (AlCalzone) Fix rounding and hue/saturation when using raw CoAP values
* (AlCalzone) Experimental support for automatic connection watching and reconnection

#### 0.11.0 (2018-21-04) - WARNING: BREAKING CHANGES!
* (AlCalzone) **BREAKING**: The `connect()` method now either resolves with `true` or rejects with an error detailing why the connection failed.
Expand Down
69 changes: 69 additions & 0 deletions build/lib/watcher.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/// <reference types="node" />
import { EventEmitter } from "events";
import { TradfriClient } from "..";
/** Configures options for connection watching and automatic reconnection */
export interface ConnectionWatcherOptions {
/** The interval in ms between consecutive pings */
pingInterval: number;
/** How many pings have to consecutively fail until the gateway is assumed offline */
failedPingCountUntilOffline: number;
/**
* How much the interval between consecutive pings should be increased while the gateway is offline.
* The actual interval is calculated by <ping interval> * <backoff factor> ** <min(5, # offline pings)>
*/
failedPingBackoffFactor: number;
/** Whether automatic reconnection is enabled */
reconnectionEnabled: boolean;
/** How many pings have to consecutively fail while the gateway is offline until a reconnection is triggered */
offlinePingCountUntilReconnect: number;
/** After how many failed reconnects we give up. Number.POSITIVE_INFINITY to never gonna give you up, never gonna let you down... */
maximumReconnects: number;
}
export declare type ConnectionEvents = "ping succeeded" | "ping failed" | "connection alive" | "connection lost" | "gateway offline" | "reconnecting" | "give up";
export declare type PingFailedCallback = (failedPingCount: number) => void;
export declare type ReconnectingCallback = (reconnectAttempt: number, maximumReconnects: number) => void;
export interface ConnectionWatcher {
on(event: "ping succeeded", callback: () => void): this;
on(event: "ping failed", callback: PingFailedCallback): this;
on(event: "connection alive", callback: () => void): this;
on(event: "connection lost", callback: () => void): this;
on(event: "gateway offline", callback: () => void): this;
on(event: "reconnecting", callback: ReconnectingCallback): this;
on(event: "give up", callback: () => void): this;
on(event: ConnectionEvents, callback: (...args: any[]) => void): this;
once(event: "ping succeeded", callback: () => void): this;
once(event: "ping failed", callback: PingFailedCallback): this;
once(event: "connection alive", callback: () => void): this;
once(event: "connection lost", callback: () => void): this;
once(event: "gateway offline", callback: () => void): this;
once(event: "reconnecting", callback: ReconnectingCallback): this;
once(event: "give up", callback: () => void): this;
once(event: ConnectionEvents, callback: (...args: any[]) => void): this;
removeListener(event: "ping succeeded", callback: () => void): this;
removeListener(event: "ping failed", callback: PingFailedCallback): this;
removeListener(event: "connection alive", callback: () => void): this;
removeListener(event: "connection lost", callback: () => void): this;
removeListener(event: "gateway offline", callback: () => void): this;
removeListener(event: "reconnecting", callback: ReconnectingCallback): this;
removeListener(event: "give up", callback: () => void): this;
removeAllListeners(event?: ConnectionEvents): this;
}
/**
* Watches the connection of a TradfriClient and notifies about changes in the connection state
*/
export declare class ConnectionWatcher extends EventEmitter {
private client;
constructor(client: TradfriClient, options?: Partial<ConnectionWatcherOptions>);
private options;
private pingTimer;
/** Starts watching the connection */
start(): void;
private isActive;
/** Stops watching the connection */
stop(): void;
private connectionAlive;
private failedPingCount;
private offlinePingCount;
private resetAttempts;
private pingThread();
}
135 changes: 135 additions & 0 deletions build/lib/watcher.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
const events_1 = require("events");
const logger_1 = require("./logger");
const defaultOptions = Object.freeze({
pingInterval: 10000,
failedPingCountUntilOffline: 1,
failedPingBackoffFactor: 1.5,
reconnectionEnabled: true,
offlinePingCountUntilReconnect: 3,
maximumReconnects: Number.POSITIVE_INFINITY,
});
function checkOptions(opts) {
if (opts.pingInterval != null && (opts.pingInterval < 1000 || opts.pingInterval > 5 * 60000)) {
throw new Error("The ping interval must be between 1s and 5 minutes");
}
if (opts.failedPingCountUntilOffline != null && (opts.failedPingCountUntilOffline < 1 || opts.failedPingCountUntilOffline > 10)) {
throw new Error("The failed ping count to assume the gateway as offline must be between 1 and 10");
}
if (opts.failedPingBackoffFactor != null && (opts.failedPingBackoffFactor < 1 || opts.failedPingBackoffFactor > 3)) {
throw new Error("The interval back-off factor for failed pings must be between 1 and 3");
}
if (opts.offlinePingCountUntilReconnect != null && (opts.offlinePingCountUntilReconnect < 1 || opts.offlinePingCountUntilReconnect > 10)) {
throw new Error("The failed ping count before a reconnect attempt must be between 1 and 10");
}
if (opts.maximumReconnects != null && opts.maximumReconnects < 1) {
throw new Error("The maximum number of reconnect attempts must be positive");
}
}
// tslint:enable:unified-signatures
/**
* Watches the connection of a TradfriClient and notifies about changes in the connection state
*/
class ConnectionWatcher extends events_1.EventEmitter {
constructor(client, options) {
super();
this.client = client;
this.failedPingCount = 0;
this.offlinePingCount = 0;
this.resetAttempts = 0;
if (options == null)
options = {};
checkOptions(options);
this.options = Object.assign({}, defaultOptions, options);
}
/** Starts watching the connection */
start() {
if (this.pingTimer != null)
throw new Error("The connection watcher is already running");
this.isActive = true;
this.pingTimer = setTimeout(() => this.pingThread(), this.options.pingInterval);
}
/** Stops watching the connection */
stop() {
if (this.pingTimer != null) {
clearTimeout(this.pingTimer);
this.pingTimer = null;
}
this.isActive = false;
}
pingThread() {
return __awaiter(this, void 0, void 0, function* () {
const oldValue = this.connectionAlive;
this.connectionAlive = yield this.client.ping();
// see if the connection state has changed
if (this.connectionAlive) {
logger_1.log("ping succeeded", "debug");
this.emit("ping succeeded");
// connection is now alive again
if (oldValue === false) {
logger_1.log(`The connection is alive again after ${this.failedPingCount} failed pings`, "debug");
this.emit("connection alive");
}
// reset all counters because the connection is good again
this.failedPingCount = 0;
this.offlinePingCount = 0;
this.resetAttempts = 0;
}
else {
this.failedPingCount++;
logger_1.log(`ping failed (#${this.failedPingCount})`, "debug");
this.emit("ping failed", this.failedPingCount);
if (oldValue === true) {
logger_1.log("The connection was lost", "debug");
this.emit("connection lost");
}
// connection is dead
if (this.failedPingCount >= this.options.failedPingCountUntilOffline) {
if (this.failedPingCount === this.options.failedPingCountUntilOffline) {
// we just reached the threshold, say the gateway is offline
logger_1.log(`${this.failedPingCount} consecutive pings failed. The gateway is offline.`, "debug");
this.emit("gateway offline");
}
// if we should reconnect automatically, count the offline pings
if (this.options.reconnectionEnabled) {
this.offlinePingCount++;
// as soon as we pass the threshold, reset the client
if (this.offlinePingCount >= this.options.offlinePingCountUntilReconnect) {
if (this.resetAttempts < this.options.maximumReconnects) {
// trigger a reconnect
this.offlinePingCount = 0;
this.resetAttempts++;
logger_1.log(`Trying to reconnect... Attempt ${this.resetAttempts} of ${this.options.maximumReconnects === Number.POSITIVE_INFINITY ? "∞" : this.options.maximumReconnects}`, "debug");
this.emit("reconnecting", this.resetAttempts, this.options.maximumReconnects);
this.client.reset();
}
else if (this.resetAttempts === this.options.maximumReconnects) {
// don't try anymore
logger_1.log("Maximum reconnect attempts reached... giving up.", "debug");
this.emit("give up");
// increase the counter once more so this branch doesn't get hit
this.resetAttempts++;
}
}
}
}
}
// schedule the next ping
if (this.isActive) {
const nextTimeout = Math.round(this.options.pingInterval * Math.pow(this.options.failedPingBackoffFactor, Math.min(5, this.failedPingCount)));
logger_1.log("setting next timeout in " + nextTimeout, "debug");
this.pingTimer = setTimeout(() => this.pingThread(), nextTimeout);
}
});
}
}
exports.ConnectionWatcher = ConnectionWatcher;
Loading

0 comments on commit 1a5ea77

Please sign in to comment.