Skip to content

Commit

Permalink
feat: Add zone node
Browse files Browse the repository at this point in the history
Event node to fire when device/person enter/leaves a selected zone
  • Loading branch information
zachowj committed Sep 18, 2020
1 parent 50a7221 commit 3ac9cf4
Show file tree
Hide file tree
Showing 11 changed files with 376 additions and 7 deletions.
1 change: 1 addition & 0 deletions docs/.vuepress/config.js
Expand Up @@ -91,6 +91,7 @@ module.exports = {
'trigger-state',
'wait-until',
'webhook',
'zone',
],
},
],
Expand Down
49 changes: 49 additions & 0 deletions docs/node/zone.md
@@ -0,0 +1,49 @@
# Zone

Outputs when one of the configured entities enter or leaves one of the defined zones.

## Configuration

### Entities

- Type: `array`

An array of entity ids to monitor for zone changes.

### Event

- Type: `string`

Set when to check an entity. Either entering or leaving a zone.

### Zones

- Type: `array`

An array of zone ids to check when a configured entity updates.

## Outputs

### topic

- Type: `string`

The entity id of the device/person that triggered the update.

### payload

- Type: `string`

The state of the device/person entity that triggered the update.

### data

- Type: `object`

The entity object of the device/person that triggered the update.

### zones

- Type: `array`

An array of zone entities where the device/person entity entered/left after an update of location.
10 changes: 5 additions & 5 deletions gulpfile.js
Expand Up @@ -62,6 +62,7 @@ const nodeMap = {
'trigger-state': { doc: 'trigger-state', type: 'trigger-state' },
'wait-until': { doc: 'wait-until', type: 'ha-wait-until' },
webhook: { doc: 'webhook', type: 'ha-webhook' },
zone: { doc: 'zone', type: 'ha-zone' },
};

let nodemonInstance;
Expand Down Expand Up @@ -140,11 +141,10 @@ const buildHelp = lazypipe()
}

// opening tag
return `<div class="custom-block ${
m[1]
}">\n<p class="custom-block-title">${md.utils.escapeHtml(
m[2] || title
)}</p>\n`;
return `<div class="custom-block ${m[1]
}">\n<p class="custom-block-title">${md.utils.escapeHtml(
m[2] || title
)}</p>\n`;
} else {
// closing tag
return '</div>\n';
Expand Down
2 changes: 2 additions & 0 deletions lib/const.js
Expand Up @@ -5,4 +5,6 @@ module.exports = Object.freeze({
INTEGRATION_NOT_LOADED: 'notloaded',
INTEGRATION_LOADED: 'loaded',
INTEGRATION_UNLOADED: 'unloaded',
ZONE_ENTER: 'enter',
ZONE_LEAVE: 'leave',
});
29 changes: 29 additions & 0 deletions lib/utils.js
@@ -1,3 +1,6 @@
const geolib = require('geolib');
const selectn = require('selectn');

const utils = (module.exports = {});

utils.shouldInclude = (targetString, includeRegex, excludeRegex) => {
Expand Down Expand Up @@ -44,3 +47,29 @@ utils.toCamelCase = (str) => {
return index === 0 ? match.toLowerCase() : match.toUpperCase();
});
};

utils.inZone = (location, zone) => {
const { radius, ...zoneLatLog } = zone;
const inZone = geolib.isPointWithinRadius(location, zoneLatLog, radius);

return inZone;
};

utils.getLocationData = (entity) => {
const coord = {
latitude: entity.attributes.latitude,
longitude: entity.attributes.longitude,
};

return geolib.isValidCoordinate(coord) ? coord : false;
};

utils.getZoneData = (zone) => {
const data = utils.getLocationData(zone);
if (data === false || selectn('attributes.radius', zone) === undefined)
return false;

data.radius = zone.attributes.radius;

return data;
};
17 changes: 17 additions & 0 deletions nodes/zone/ui-zone.html
@@ -0,0 +1,17 @@
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name" />
</div>
<div class="form-row">
<label for="node-input-server"><i class="fa fa-server"></i> Server</label>
<input type="text" id="node-input-server" />
</div>
<div class="form-row"><ol id="entities"></ol></div>
<div class="form-row">
<label for="node-input-event"><i class="fa fa-sign-in"></i> Event</label>
<select id="node-input-event">
<option value="enter">Enter</option>
<option value="leave">Leave</option>
</select>
</div>
<div class="form-row"><ol id="zones"></ol></div>
104 changes: 104 additions & 0 deletions nodes/zone/ui-zone.js
@@ -0,0 +1,104 @@
RED.nodes.registerType('ha-zone', {
category: 'home_assistant',
color: '#038FC7',
outputs: 1,
icon: 'font-awesome/fa-map-marker',
paletteLabel: 'zone',
label: function () {
return this.name || 'zone';
},
labelStyle: nodeVersion.labelStyle,
defaults: {
server: { value: '', type: 'server', required: true },
name: { value: '' },
entities: {
value: [],
required: true,
validate: function (v) {
return v.length;
},
},
event: { value: 'enter' },
zones: {
value: [],
required: true,
validate: function (v) {
return v.length;
},
},
},
oneditprepare: function () {
const $entities = $('#entities');
const $zones = $('#zones');
let zones = [];
let devices = [];

haServer.init(this, '#node-input-server');
haServer.autocomplete('entities', (entities) => {
devices = entities.filter(
(entities) =>
entities.startsWith('person.') ||
entities.startsWith('device_tracker.')
);
zones = entities.filter((entities) => entities.startsWith('zone.'));
});
$entities.editableList({
addButton: true,
removable: true,
height: 'auto',
header: $('<div>').append('Entities'),
addItem: function (container, _, data) {
const $row = $('<div />').appendTo(container);
$('<span />', {
text: 'Entity',
style: 'padding-right: 35px;',
}).appendTo($row);
const value = $.isEmptyObject(data) ? '' : data;
$('<input />', { type: 'text', class: 'input-entity' })
.appendTo($row)
.val(value)
.autocomplete({ source: devices, minLength: 0 });
},
});
$entities.editableList('addItems', this.entities);
$zones.editableList({
addButton: true,
removable: true,
height: 'auto',
header: $('<div>').append('zones'),
addItem: function (container, _, data) {
const $row = $('<div />').appendTo(container);
$('<span />', {
text: 'Zone',
style: 'padding-right: 35px;',
}).appendTo($row);
const value = $.isEmptyObject(data) ? '' : data;
$('<input />', { type: 'text', class: 'input-zone' })
.appendTo($row)
.val(value)
.autocomplete({ source: zones, minLength: 0 });
},
});
$zones.editableList('addItems', this.zones);
},
oneditsave: function () {
const entityList = $('#entities').editableList('items');
const zoneList = $('#zones').editableList('items');
const entities = new Set();
const zones = new Set();
entityList.each(function () {
const val = $(this).find('.input-entity').val().replace(/\s/g, '');
if (val.length) {
entities.add(val);
}
});
zoneList.each(function () {
const val = $(this).find('.input-zone').val().replace(/\s/g, '');
if (val.length) {
zones.add(val);
}
});
this.entities = Array.from(entities);
this.zones = Array.from(zones);
},
});
96 changes: 96 additions & 0 deletions nodes/zone/zone.js
@@ -0,0 +1,96 @@
const cloneDeep = require('lodash.clonedeep');

const EventsHaNode = require('../../lib/events-ha-node');
const { ZONE_ENTER, ZONE_LEAVE } = require('../../lib/const');
const { getLocationData, getZoneData, inZone } = require('../../lib/utils');

module.exports = function (RED) {
const nodeOptions = {
config: {
entities: {},
event: {},
zones: {},
},
};

class Zone extends EventsHaNode {
constructor(nodeDefinition) {
super(nodeDefinition, RED, nodeOptions);

for (const entity of this.nodeConfig.entities) {
console.log(entity);
this.addEventClientListener(
`ha_events:state_changed:${entity}`,
this.onStateChanged.bind(this)
);
}
}

async onStateChanged(evt) {
if (this.isEnabled === false || !this.isHomeAssistantRunning) {
return;
}
const { entity_id: entityId, event } = cloneDeep(evt);

const zones = await this.getValidZones(
event.old_state,
event.new_state
);

if (!zones.length) {
this.setStatusFailed(entityId);
return;
}

event.new_state.timeSinceChangedMs =
Date.now() - new Date(event.new_state.last_changed).getTime();

const msg = {
topic: entityId,
payload: event.new_state.state,
data: event,
zones,
};
const statusMessage = `${entityId} ${
this.nodeConfig.event
} ${zones.map((z) => z.entity_id).join(',')}`;
this.setStatusSuccess(statusMessage);
this.send(msg);
}

async getValidZones(fromState, toState) {
const config = this.nodeConfig;
const fromLocationData = getLocationData(fromState);
const toLocationData = getLocationData(toState);
if (!fromLocationData || !toLocationData) return [];
const zones = await this.getZones();
const validZones = zones.filter((zone) => {
const zoneData = getZoneData(zone);
if (!zoneData) return false;
const fromMatch = inZone(fromLocationData, zoneData);
const toMatch = inZone(toLocationData, zoneData);

return (
(config.event === ZONE_ENTER && !fromMatch && toMatch) ||
(config.event === ZONE_LEAVE && fromMatch && !toMatch)
);
});

return validZones;
}

async getZones() {
const node = this;
const entities = await this.nodeConfig.server.homeAssistant.getStates();
const zones = [];
Object.keys(entities).forEach((entityId) => {
if (node.nodeConfig.zones.includes(entityId)) {
zones.push(entities[entityId]);
}
});

return zones;
}
}
RED.nodes.registerType('ha-zone', Zone);
};
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Expand Up @@ -14,8 +14,8 @@
"lint": "eslint . && prettier --check {docs,lib,nodes,script,test}/**/*.{css,js,md,html}",
"lint:fix": "eslint . --fix && prettier --write {docs,lib,nodes,script,test}/**/*.{css,js,md,html}",
"prepublish": "gulp build",
"test": "mocha \"test/**/*_spec.js\"",
"test:watch": "nodemon -w test/ -w lib/ -w nodes/ --exec ./node_modules/.bin/mocha test/*"
"test": "mocha test/**/*_spec.js",
"test:watch": "mocha --watch --recursive"
},
"repository": {
"type": "git",
Expand All @@ -37,6 +37,7 @@
"trigger-state": "nodes/trigger-state/trigger-state.js",
"poll-state": "nodes/poll-state/poll-state.js",
"ha-webhook": "nodes/webhook/webhook.js",
"ha-zone": "nodes/zone/zone.js",
"api-call-service": "nodes/call-service/call-service.js",
"ha-entity": "nodes/entity/entity.js",
"ha-fire-event": "nodes/fire-event/fire-event.js",
Expand All @@ -53,6 +54,7 @@
"bonjour": "3.5.0",
"debug": "4.1.1",
"flat": "5.0.2",
"geolib": "3.3.1",
"home-assistant-js-websocket": "5.6.0",
"joi": "17.2.1",
"lodash.clonedeep": "4.5.0",
Expand Down

0 comments on commit 3ac9cf4

Please sign in to comment.