Skip to content

Commit

Permalink
feat: Custom Integration (#173)
Browse files Browse the repository at this point in the history
* feat: Add custom integration nodes

- sensor node: create sensor and binary sensor in HA
- webhook: create a webhook in HA that will be handled by NR
- event nodes: add ability to expose each event node to HA as a switch

* fix(integration): Set HA Config defaults for old nodes

* fix: Stop sending removal payload when node is not actually registered

* refactor: Improve error message for missing integration

* docs: Note about custom integration needed for certain nodes

* refactor(entity): Add more state types for binary_sensor

* fix: Enabled event nodes that are exposed HA when integration is removed from HA

* feat(webhook): Add request headers to msg object

* fix(sensor): Make sure lastPayload is being saved

* feat(webhook): Add custom location output for payload and headers

* fix: Check that the node is registered with HA before attempting to remove it

* fix: Check that exposedNodes exists

* refactor: Handle options that depend on custom integration more graceful when integration is not installed

* refactor: Check that a server is selected before setting exposedNodes

* docs: Add documentation for the new nodes

* feat(entity): Add customizable output location

* feat(entity): Add debug information output for discovery and update

* refactor(entity): Test for connection before attempting to update

* refactor: Set default values for config values
  • Loading branch information
zachowj authored Dec 9, 2019
1 parent 564670a commit 0d9f94e
Show file tree
Hide file tree
Showing 22 changed files with 1,487 additions and 161 deletions.
38 changes: 26 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,47 +52,61 @@ If you have been using the [@Spartan-II-117](https://github.com/Spartan-II-117/n

The installed nodes have more detailed information in the Node-RED info pane shown when the node is selected. Below is a quick summary

### All Events - `websocket`
### All Events

Listens for all types of events from home assistant with the ability to filter by event type

### State Changed Event - `websocket`
### State Changed Event

Listens for only `state_changed` events from home assistant

### State Trigger - `websocket`
### State Trigger

Much like the `State Changed Node` however provides some advanced functionality around common automation use cases.

### Poll State - `websocket`
### Poll State

Outputs the state of an entity at regular intervals, optionally also at startup and every time the entity changes if desired
Outputs the state of an entity at regular intervals, optionally also at startup
and every time the entity changes if desired

### Call Service - `websocket`
### Webhook

_Need [Custom Integration](https://github.com/zachowj/hass-node-red) installed in Home Assistant for node to function_

Outputs the data received from the created webhook in Home Assistant

### Call Service

Sends a request to home assistant for any domain and service available ( `light/turn_on`, `input_select/select_option`, etc..)

### Fire Event - `http`
### Fire Event

Fire an event on the event bus

### Current State - `websocket`
### Sensor

_Need [Custom Integration](https://github.com/zachowj/hass-node-red) installed in Home Assistant for node to function_

Creates a sensor or binary sensor in Home Assistant which can be updated
from this node

### Current State

Fetches the last known state for any entity on input

### Get Entities - `websocket`
### Get Entities

Get entities based on search criteria with 3 different output options

### Get History - `http`
### Get History

Fetches HomeAssistant history on input

### Get Template - `http`
### Get Template

Allows rendering of templates on input

### Wait Until - `websocket`
### Wait Until

When an input is received the node will wait until the condition is met or the timeout occurs then will pass on the last received message

Expand Down
29 changes: 22 additions & 7 deletions lib/base-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class BaseNode {
if (!this.id)
throw new Error('cannot get node data from db without id');
const db = await this.getDb();
return db.remove(this.nodeDbId).write();
return db.unset(this.nodeDbId).write();
}

send() {
Expand Down Expand Up @@ -154,27 +154,37 @@ class BaseNode {
text: ''
}
) {
if (
Object.prototype.hasOwnProperty.call(this, 'isEnabled') &&
this.isEnabled === false
) {
opts = {
shape: 'dot',
fill: 'grey',
text: 'DISABLED'
};
}
this.node.status(opts);
}

setStatusSuccess(text = 'Success') {
this.status({
this.setStatus({
fill: 'green',
shape: 'dot',
text: `${text} at: ${this.getPrettyDate()}`
});
}

setStatusSending(text = 'Sending') {
this.status({
this.setStatus({
fill: 'yellow',
shape: 'dot',
text: `${text} at: ${this.getPrettyDate()}`
});
}

setStatusFailed(text = 'Failed') {
this.status({
this.setStatus({
fill: 'red',
shape: 'ring',
text: `${text} at: ${this.getPrettyDate()}`
Expand Down Expand Up @@ -210,11 +220,16 @@ class BaseNode {
break;
}
}

if (
Object.prototype.hasOwnProperty.call(this, 'isenabled') &&
this.isenabled === false
Object.prototype.hasOwnProperty.call(this, 'isEnabled') &&
this.isEnabled === false
) {
connectionStatus.text += '(DISABLED)';
connectionStatus = {
shape: 'dot',
fill: 'grey',
text: 'DISABLED'
};
}

if (additionalText) connectionStatus.text += ` ${additionalText}`;
Expand Down
177 changes: 177 additions & 0 deletions lib/events-ha-node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
const merge = require('lodash.merge');
const EventsNode = require('./events-node');

const DEFAULT_NODE_OPTIONS = {
debug: false,
config: {
name: {},
server: {
isNode: true
},
haConfig: {},
exposeToHomeAssistant: nodeDef =>
nodeDef.exposeToHomeAssistant === undefined
? false
: nodeDef.exposeToHomeAssistant
}
};

class EventsHaNode extends EventsNode {
constructor(nodeDefinition, RED, nodeOptions = {}) {
nodeOptions = merge({}, DEFAULT_NODE_OPTIONS, nodeOptions);
super(nodeDefinition, RED, nodeOptions);
this.isEnabled = true;

// Check if there's a server selected
if (this.nodeConfig.server) {
// Determine if node needs to be removed from Home Assistant because it's no longer exposed
this.removeFromHA = !!(
this.nodeConfig.exposeToHomeAssistant === false &&
this.nodeConfig.server.exposedNodes[this.id] === true
);
// Save expose state so we can check if it needs to removed when it's not exposed anymore
this.nodeConfig.server.exposedNodes[
this.id
] = this.nodeConfig.exposeToHomeAssistant;
}
this.loadPersistedData();

if (this.isConnected) {
this.registerEntity();
this.removeFromHomeAssistant();
}
}

async onClose(removed) {
super.onClose(removed);

if (removed) {
if (this.isConnected && this.nodeConfig.exposeToHomeAssistant) {
this.removeFromHomeAssistant(true);
}
await this.removeNodeData();
}

this.removeSubscription();
}

async onHaConfigUpdate() {
this.registerEntity();
this.removeFromHomeAssistant();
}

onHaIntegration(type) {
if (type === 'loaded') {
this.registerEntity();
} else if (type === 'unloaded') {
this.isEnabled = true;
this.registered = false;
if (this.subscription) {
this.subscription();
this.subscription = null;
}
this.updateConnectionStatus();
}
}

async loadPersistedData() {
try {
const data = await this.getNodeData();
if (
data &&
Object.prototype.hasOwnProperty.call(data, 'isEnabled')
) {
this.isEnabled = data.isEnabled;
this.updateConnectionStatus();
}
} catch (e) {
this.error(e.message);
}
}

async registerEntity() {
if (this.subscription || super.registerEntity() === false) {
return;
}

const haConfig = {};
this.nodeConfig.haConfig
.filter(c => {
return c.value.length;
})
.forEach(e => {
haConfig[e.property] = e.value;
});

const payload = {
type: 'nodered/discovery',
server_id: this.nodeConfig.server.id,
node_id: this.id,
component: 'switch',
state: this.isEnabled,
config: haConfig
};

this.subscription = await this.websocketClient.client.subscribeMessage(
this.onEvent.bind(this),
payload
);

this.setStatusSuccess('Registered');
this.registered = true;
}

async onEvent(evt) {
this.isEnabled = evt.state;
this.saveNodeData('isEnabled', this.isEnabled);
this.updateHomeAssistant();
this.updateConnectionStatus();
}

async updateHomeAssistant() {
const message = {
type: 'nodered/entity',
server_id: this.nodeConfig.server.id,
node_id: this.id,
state: this.isEnabled
};

this.websocketClient.send(message);
}

// Remove from Home Assistant when `Expose to Home Assistant` is unchecked
removeFromHomeAssistant(nodeRemoved = false) {
if (
this.websocketClient.integrationVersion === 0 ||
(!this.removeFromHA && !nodeRemoved)
) {
return;
}

const payload = {
type: 'nodered/discovery',
server_id: this.nodeConfig.server.id,
node_id: this.id,
component: 'switch',
remove: true
};

this.websocketClient.send(payload);
this.removeFromHA = false;
this.removeSubscription();

// Enabled node when removing it from Home Assistant as there is no
// way to do so once it's removed except for the trigger-state node
this.isEnabled = true;
this.saveNodeData('isEnabled', this.isEnabled);
}

removeSubscription() {
if (this.subscription) {
this.subscription();
this.subscription = null;
}
}
}

module.exports = EventsHaNode;
Loading

0 comments on commit 0d9f94e

Please sign in to comment.