initial commit

This commit is contained in:
Fredrik Åhs 2018-01-06 15:11:37 +01:00
commit 8954b88a00
8 changed files with 1081 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
build
package-lock.json

0
README.md Normal file
View File

183
dist/node-tradfri.html vendored Executable file
View File

@ -0,0 +1,183 @@
<!-- ======= CONNECTION ========= -->
<script type="text/javascript">
RED.nodes.registerType('tradfri-connection',{
category: 'config',
defaults: {
name: { value:"" },
address: { value:"", required:true },
securityCode: { value:"" },
identity: { value:"" },
psk: { value:"" },
},
label: function() {
return this.name || "tradfri@" + this.address;
}
});
</script>
<script type="text/x-red" data-template-name="tradfri-connection">
<div class="form-row">
<label for="node-config-input-name"><i class="icon-bookmark"></i> Name</label>
<input type="text" id="node-config-input-name">
</div>
<div class="form-row">
<label for="node-config-input-address"><i class="icon-bookmark"></i> Address</label>
<input type="text" id="node-config-input-address">
</div>
<div class="form-row">
<label for="node-config-input-securityCode"><i class="icon-bookmark"></i> Security Code</label>
<input type="text" id="node-config-input-securityCode">
</div>
<div class="form-row">
<label for="node-config-input-identity"><i class="icon-bookmark"></i> Identity</label>
<input type="text" id="node-config-input-identity">
</div>
<div class="form-row">
<label for="node-config-input-psk"><i class="icon-bookmark"></i> Pre-shared key</label>
<input type="text" id="node-config-input-psk">
</div>
</script>
<!-- ======= THREADFREE ========= -->
<script type="text/javascript">
var updateDevices = (currentDeviceId) => {
let configNodeId = $('#node-input-connection').find(":selected").val();
if (configNodeId == null || configNodeId === '_ADD_') { return; }
$.get('tradfri/lights', {nodeId: configNodeId}, function(resp){
let lights = JSON.parse(resp);
if (!Array.isArray(lights) || lights.length <= 0) { return; }
$('#node-input-deviceId').find('option').not(':first').remove();
$.each(lights, function(i, light) {
let opt = {};
opt.value = light.id;
opt.text = light.name;
if (light.id === currentDeviceId) {
opt.selected = "selected";
}
$('#node-input-deviceId').append($('<option>', opt));
});
});
};
RED.nodes.registerType('tradfri',{
category: 'function',
color: '#84E87A',
defaults: {
name: {value:""},
deviceId: {value: "", required:true, validate:RED.validators.number()},
deviceName: {value: ""},
connection: {value:"", type:"tradfri-connection"},
observe: { value:true, required: true },
},
inputs:1,
outputs:1,
icon: "light.png",
label: function() {
return this.deviceName || "tradfri";
},
oneditprepare: function() {
var node = this;
$('#node-input-connection').change(function(){
updateDevices(node.deviceId);
});
$('#tradfri-deviceId-refresh').click(function(){
updateDevices(node.deviceId);
});
$(document).ready(function() {
$('#node-input-msgPassthrough').prop('checked', node.observe);
});
},
oneditsave: function() {
this.deviceName = $('#node-input-deviceId').find(":selected").text();
}
});
</script>
<script type="text/x-red" data-template-name="tradfri">
<div class="form-row">
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-connection"><i class="fa fa-bookmark"></i> Connection</label>
<input type="text" id="node-input-connection" placeholder="Connection">
</div>
<div class="form-row">
<label for="node-input-deviceId"><i class="fa fa-lightbulb-o"></i> Device</label>
<div style="display: inline-block; position: relative; width: 70%; height: 20px;">
<div style="position: absolute; left: 0px; right: 40px;">
<select id="node-input-deviceId" style="width: 100%">
<option value="-1" selected="selected">None</option>
</select>
</div>
<a id="node-input-lookup-connection" class="editor-button" style="position: absolute; right: 0px; top: 0px;"><i id="tradfri-deviceId-refresh-spinner" class="fa fa-refresh"></i></a>
</div>
</div>
<div class="form-row">
<label>&nbsp;</label>
<input type="checkbox" id="node-input-observe" style="display: inline-block; width: auto; vertical-align: top;">
<label for="node-input-observe" style="width: 70%;" >Observe device?</label>
</div>
</script>
<script type="text/x-red" data-help-name="tradfri">
<p>Interface for IKEA Tradfri devices</p>
<h3>Inputs</h3>
<dl class="message-properties">
<dt> payload <span class="property-type"> string </span> </dt>
<dd> A single command can be sent as a string to the node. <dd>
<dt> payload <span class="property-type"> object </span> </dt>
<dd> An object with one or more commands targeting the light. </dd>
</dl>
<h3>Outputs</h3>
<dl class="message-properties">
<dt> payload.light <span class="property-type"> object </span></dt>
<dd> The status of the light. </dd>
</dl>
<h3>Details</h3>
The tradfri node acts as both input and output for a IKEA Tradfri lighbulb.
If the node is set to observe it will send a message with the lights current properties as payload every time the light is updated:
<ul>
<li><code>id</code><code>number</code> The id of the light </li>
<li><code>name</code><code>string</code> The given name of the light </li>
<li><code>model</code><code>string</code> The model of the light </li>
<li><code>firmware</code><code>string</code> The firmware of the light </li>
<li><code>alive</code><code>boolean</code> True if the gateway can communicate with the light, false if not </li>
<li><code>on</code><code>boolean</code> True if the light is on, false if not.</li>
<li><code>brightness</code><code>number</code><code>[0,100]</code> The brightness of the light</li>
<li><code>colorTemperature</code><code>number</code>[0,100] The color temperature of the light</li>
<li><code>color</code><code>string</code> The hex-code for the color of the light. Only fully supported by CWS bulbs.</li>
<li><code>colorX</code><code>number</code> The x component of the xy-color</li>
<li><code>colorY</code><code>number</code> The y component of the xy-color</li>
<li><code>transition</code><code>number</code> The default transition time for operations. However, since the default value of 0.5 makes it impossible to send temperature and brightness updates in the same command, this is overridden and set to 0 by default.</li>
<li><code>created</code><code>number</code> Probably when the light was paired with the gateway for the first time, measured in epoch time.</li>
<li><code>seen</code><code>number</code> When the light was last interacted with by the gateway (or similar), measured in epoch time.</li>
<li><code>type</code><code>number</code> The type of device where 2 is light</li>
<li><code>power</code><code>number</code> The type of power source powering the light. Will most likely always be 1.</li>
</ul>
<h2>Controlling the node</h2>
The can be programmatically controlled by sending a message with <code>msg.payload</code> set to one of the following:
<ul>
<li><code>status</code> The node will output the current status of its target light.
</ul>
<h2>Controlling the lights</h2>
Lights can be controlled by sending an objet with one or more of the following properties as <code>msg.payload</code> to the node.
<ul>
<!-- <li><code>id</code><code>number</code> The id of the light <li>
<li><code>name</code><code>string</code> The given name of the light <li> -->
<li><code>on</code><code>boolean</code><li> Turn the light on or off.</li>
<li><code>brightness</code><code>number</code> The brightness of the light [0,100]</li>
<li><code>colorTemperature</code><code>number</code> The color temperature of the light [0,100]</li>
<li><code>color</code><code>string</code> Sets the color of the light. For WS-bulbs, <code>F5FAF6</code>, <code>F1E0B5</code> and <code>EFD275</code> will set the light to the default cold, normal and warm temperatures respectively.</li>
<li><code>transition</code><code>number</code> The default transition time for operations. Will only work for single operation commands and not for on/off. Defaults to 0. </li>
<li><code>hue</code><code>number</code> Sets the hue of the light. Only for CWS. [0,365] (UNTESTED)</li>
<li><code>saturation</code><code>number</code> Sets the saturation of the light. Only for CWS. [0,100] (UNTESTED)</li>
</ul>
</script>

313
dist/node-tradfri.js vendored Normal file
View File

@ -0,0 +1,313 @@
"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 tradfri = require("node-tradfri-client");
module.exports = function (RED) {
function lightFromAccessory(accessory) {
return Object.assign({}, {
id: accessory.instanceId,
name: accessory.name,
model: accessory.deviceInfo.modelNumber,
firmware: accessory.deviceInfo.firmwareVersion,
alive: accessory.alive,
on: accessory.lightList[0].onOff,
onTime: accessory.lightList[0].onTime,
brightness: accessory.lightList[0].dimmer,
colorTemperature: accessory.lightList[0].colorTemperature,
color: accessory.lightList[0].color,
hue: accessory.lightList[0].hue,
saturation: accessory.lightList[0].saturation,
colorX: accessory.lightList[0].colorX,
colorY: accessory.lightList[0].colorY,
transition: accessory.lightList[0].transitionTime,
created: accessory.createdAt,
seen: accessory.lastSeen,
type: accessory.type,
power: accessory.deviceInfo.power
});
}
function toLightOperation(op) {
let props = { on: "onOff", brightness: "dimmer", transition: "transitionTime" };
for (let k in props) {
if (op.hasOwnProperty(k)) {
op[props[k]] = op[k];
delete op[k];
}
}
return op;
}
RED.httpAdmin.get("/tradfri/lights", RED.auth.needsPermission('tradfri.read'), function (req, res) {
let nodeId = req.query.nodeId;
if (nodeId != null) {
let node = RED.nodes.getNode(nodeId);
let lights = node.getLights();
let ret = [];
for (let k in lights) {
ret.push({ name: lights[k].name, id: k });
}
res.json(JSON.stringify(ret));
}
});
function TradfriConnectionNode(config) {
var node = this;
RED.nodes.createNode(node, config);
node.name = config.name;
node.address = config.address;
node.securityCode = config.securityCode;
node.identity = config.identity;
node.psk = config.psk;
if ((node.identity == null && node.psk != null) || (node.identity != null && node.psk == null)) {
RED.log.error("Must provide both identity and PSK or leave both blank to generate new credentials from security code.");
}
if (node.identity == null && node.psk == null && node.securityCode == null) {
RED.log.error("Must provide either identity and PSK or a security code to connect to the Tradfri hub");
}
var _lights = {};
var _listeners = {};
var _client = null;
var _deviceUpdatedCallback = (accessory) => {
if (accessory.type === tradfri.AccessoryTypes.lightbulb) {
_lights[accessory.instanceId] = accessory;
}
if (_listeners[accessory.instanceId]) {
for (let nodeId in _listeners[accessory.instanceId]) {
_listeners[accessory.instanceId][nodeId](accessory);
}
}
};
let _setupClient = () => __awaiter(this, void 0, void 0, function* () {
let loggerFunction = (message, severity) => {
RED.log.info(severity + ", " + message);
};
let client = new tradfri.TradfriClient(node.address);
if (node.identity == null && node.psk == null) {
const { identity, psk } = yield client.authenticate(node.securityCode);
node.identity = identity;
node.psk = psk;
}
if (yield client.connect(node.identity, node.psk)) {
client.on("device updated", _deviceUpdatedCallback);
client.observeDevices();
_client = client;
}
else {
throw new Error(`Client not available`);
}
});
let _reconnect = () => __awaiter(this, void 0, void 0, function* () {
let timeout = 5000;
if (_client != null) {
_client.destroy();
_client = null;
}
while (_client == null) {
try {
yield _setupClient();
}
catch (e) {
RED.log.trace(`[Tradfri: ${node.id}] ${e.toString()}, reconnecting...`);
}
yield new Promise(resolve => setTimeout(resolve, timeout));
}
});
let pingInterval = 30;
let _ping = setInterval(() => __awaiter(this, void 0, void 0, function* () {
try {
let client = yield node.getClient();
let res = yield client.ping();
RED.log.trace(`[Tradfri: ${node.id}] ping returned '${res}'`);
}
catch (e) {
RED.log.trace(`[Tradfri: ${node.id}] ping returned '${e.toString()}'`);
}
}), pingInterval * 1000);
_reconnect();
node.getClient = () => __awaiter(this, void 0, void 0, function* () {
let maxRetries = 5;
let timeout = 2;
for (let i = 0; i < maxRetries; i++) {
if (_client == null) {
yield new Promise(resolve => setTimeout(resolve, timeout * 1000));
}
else {
return _client;
}
}
throw new Error('Client not available');
});
node.getLight = (instanceId) => __awaiter(this, void 0, void 0, function* () {
let maxRetries = 5;
let timeout = 2;
for (let i = 0; i < maxRetries; i++) {
if (_lights[instanceId] == null) {
yield new Promise(resolve => setTimeout(resolve, timeout * 1000));
}
else {
return _lights[instanceId];
}
}
throw new Error('Light not available');
});
node.getLights = () => {
return _lights;
};
node.register = (nodeId, instanceId, callback) => {
if (!_listeners[instanceId]) {
_listeners[instanceId] = {};
}
_listeners[instanceId][nodeId] = callback;
RED.log.info(`[Tradfri: ${nodeId}] registered event listener for ${instanceId}`);
};
node.unregister = (nodeId) => {
for (let instanceId in _listeners) {
if (_listeners[instanceId].hasOwnProperty(nodeId)) {
delete _listeners[instanceId][nodeId];
RED.log.debug(`[Tradfri: ${nodeId}] unregistered event listeners`);
}
}
};
node.on('close', () => {
clearInterval(_ping);
_client.destroy();
RED.log.debug(`[Tradfri: ${node.id}] Config was closed`);
});
}
RED.nodes.registerType("tradfri-connection", TradfriConnectionNode);
function TradfriNode(config) {
var node = this;
RED.nodes.createNode(node, config);
node.name = config.name;
node.deviceId = config.deviceId;
node.deviceName = config.deviceName;
node.observe = config.observe;
var _config = RED.nodes.getNode(config.connection);
var _prev = {};
var _getPayload = (accessory) => {
let light = lightFromAccessory(accessory);
light['prev'] = Object.assign({}, _prev);
return light;
};
var _deviceUpdated = (accessory) => {
let ret = _getPayload(accessory);
_prev = lightFromAccessory(accessory);
node.send({ payload: { light: ret } });
RED.log.trace(`[Tradfri: ${node.id}] recieved update for '${accessory.name}' (${accessory.instanceId})`);
};
var _getTargetId = (msg) => {
let payload = msg.payload;
if (payload.hasOwnProperty('id') && Array.isArray(payload.id)) {
return payload.id;
}
else if (payload.hasOwnProperty('id')) {
return [payload.id];
}
else if (node.deviceId > 0) {
return [node.deviceId];
}
else {
throw new Error('No valid target device');
}
};
var _handleDirectStatus = (msg) => __awaiter(this, void 0, void 0, function* () {
try {
let client = yield _config.getClient();
let res = yield client.request('15001/' + node.deviceId, 'get');
msg.payload = res;
node.send(msg);
}
catch (e) {
msg.payload = e;
node.send(msg);
}
});
var _handleStatus = (msg) => __awaiter(this, void 0, void 0, function* () {
try {
let accessory = yield _config.getLight(node.deviceId);
msg.payload.light = _getPayload(accessory);
delete msg.payload.status;
node.send(msg);
RED.log.trace(`[Tradfri: ${node.id}] Status request successful`);
}
catch (e) {
RED.log.info(`[Tradfri: ${node.id}] Status request unsuccessful, '${e.toString()}'`);
}
});
var _handleDirectLightOp = (light) => __awaiter(this, void 0, void 0, function* () {
let clamp = (num, min, max) => {
return num <= min ? min : num >= max ? max : num;
};
let cmd = { 3311: [Object.assign({}, {
5851: clamp(light.brightness, 0, 254),
5711: clamp(light.colorTemperature, 200, 454),
5712: light.transition,
5850: light.on,
5707: light.hue,
5708: light.saturation,
5706: light.color // f5faf6, f1e0b5, efd275 are valid for WS bulbs
})] };
try {
let client = yield _config.getClient();
let res = yield client.request('15001/' + node.deviceId, 'put', Object.assign({}, cmd));
RED.log.trace(`[Tradfri: ${node.id}] DirectLightOp '${JSON.stringify(cmd)}' returned '${res}'`);
}
catch (e) {
RED.log.info(`[Tradfri: ${node.id}] DirectLightOp '${JSON.stringify(cmd)}' unsuccessful, '${e.toString()}'`);
}
});
var _handleLightOp = (light) => __awaiter(this, void 0, void 0, function* () {
let lightOp = toLightOperation(light);
if (!lightOp.hasOwnProperty('transitionTime')) {
lightOp['transitionTime'] = 0;
}
try {
let light = yield _config.getLight(node.deviceId);
if (Object.keys(lightOp).length > 0) {
let client = yield _config.getClient();
let res = yield client.operateLight(light, lightOp);
RED.log.trace(`[Tradfri: ${node.id}] LightOp '${JSON.stringify(lightOp)}' returned '${res}'`);
}
}
catch (e) {
RED.log.info(`[Tradfri: ${node.id}] LightOp '${JSON.stringify(lightOp)}' unsuccessful, '${e.toString()}'`);
}
});
if (node.observe) {
_config.register(node.id, node.deviceId, _deviceUpdated);
}
node.on('input', function (msg) {
(() => __awaiter(this, void 0, void 0, function* () {
if (msg.hasOwnProperty('payload')) {
if (msg.payload === "status") {
msg.payload = { status: true };
}
let isDirect = msg.payload.hasOwnProperty('direct');
let isStatus = msg.payload.hasOwnProperty('status');
if (isDirect && isStatus) {
_handleDirectStatus(msg);
}
else if (isStatus) {
_handleStatus(msg);
}
else if (isDirect) {
_handleDirectLightOp(msg.payload);
}
else {
_handleLightOp(msg.payload);
}
}
}))();
});
node.on('close', function () {
RED.log.debug(`[Tradfri: ${node.id}] Node was closed`);
_config.unregister(node.id);
});
}
RED.nodes.registerType("tradfri", TradfriNode);
};

38
gulpfile.js Normal file
View File

@ -0,0 +1,38 @@
var gulp = require('gulp');
var del = require('del');
var shell = require('gulp-shell');
var ts = require('gulp-typescript');
gulp.task('clean:build', () => {
return del(['./build/**']);
});
gulp.task('compile', ['clean:build'], () => {
let numErrors = 0;
let res = gulp.src('src/**/*.ts')
.pipe(
ts({
module: 'commonjs',
target: 'es2015',
declaration: true
})
)
.on('error', () => {
numErrors += 1;
})
return res.pipe(gulp.dest('build'))
});
gulp.task('install', ['compile'], () => {
return gulp.src(['src/*.html', 'build/*.js'])
.pipe(gulp.dest('dist'));
});
gulp.task('exec', ['install'], shell.task('docker restart node-red'));
gulp.task('watch', function() {
return gulp.watch(['src/*.html', 'src/*.ts'], ['exec']);
});
gulp.task('default', ['install']);

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "node-red-contrib-node-tradfri",
"version": "0.1.0",
"description": "Node-RED node to utilize IKEA Trådfri devices. Fully implemented in Node.js.",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "github.com/freahs/node-red-contrib-node-tradfri"
},
"keywords": [
"node-red",
"tradfri"
],
"author": "freahs",
"license": "GPL-3.0",
"devDependencies": {
"@types/node": "^8.5.5",
"del": "^3.0.0",
"gulp": "^3.9.1",
"gulp-shell": "^0.6.5",
"gulp-typescript": "^3.2.3",
"typescript": "^2.6.2"
},
"dependencies": {
"node-tradfri-client": "^0.5.5"
},
"node-red": {
"nodes": {
"tradfri": "dist/node-tradfri.js"
}
}
}

183
src/node-tradfri.html Executable file
View File

@ -0,0 +1,183 @@
<!-- ======= CONNECTION ========= -->
<script type="text/javascript">
RED.nodes.registerType('tradfri-connection',{
category: 'config',
defaults: {
name: { value:"" },
address: { value:"", required:true },
securityCode: { value:"" },
identity: { value:"" },
psk: { value:"" },
},
label: function() {
return this.name || "tradfri@" + this.address;
}
});
</script>
<script type="text/x-red" data-template-name="tradfri-connection">
<div class="form-row">
<label for="node-config-input-name"><i class="icon-bookmark"></i> Name</label>
<input type="text" id="node-config-input-name">
</div>
<div class="form-row">
<label for="node-config-input-address"><i class="icon-bookmark"></i> Address</label>
<input type="text" id="node-config-input-address">
</div>
<div class="form-row">
<label for="node-config-input-securityCode"><i class="icon-bookmark"></i> Security Code</label>
<input type="text" id="node-config-input-securityCode">
</div>
<div class="form-row">
<label for="node-config-input-identity"><i class="icon-bookmark"></i> Identity</label>
<input type="text" id="node-config-input-identity">
</div>
<div class="form-row">
<label for="node-config-input-psk"><i class="icon-bookmark"></i> Pre-shared key</label>
<input type="text" id="node-config-input-psk">
</div>
</script>
<!-- ======= THREADFREE ========= -->
<script type="text/javascript">
var updateDevices = (currentDeviceId) => {
let configNodeId = $('#node-input-connection').find(":selected").val();
if (configNodeId == null || configNodeId === '_ADD_') { return; }
$.get('tradfri/lights', {nodeId: configNodeId}, function(resp){
let lights = JSON.parse(resp);
if (!Array.isArray(lights) || lights.length <= 0) { return; }
$('#node-input-deviceId').find('option').not(':first').remove();
$.each(lights, function(i, light) {
let opt = {};
opt.value = light.id;
opt.text = light.name;
if (light.id === currentDeviceId) {
opt.selected = "selected";
}
$('#node-input-deviceId').append($('<option>', opt));
});
});
};
RED.nodes.registerType('tradfri',{
category: 'function',
color: '#84E87A',
defaults: {
name: {value:""},
deviceId: {value: "", required:true, validate:RED.validators.number()},
deviceName: {value: ""},
connection: {value:"", type:"tradfri-connection"},
observe: { value:true, required: true },
},
inputs:1,
outputs:1,
icon: "light.png",
label: function() {
return this.deviceName || "tradfri";
},
oneditprepare: function() {
var node = this;
$('#node-input-connection').change(function(){
updateDevices(node.deviceId);
});
$('#tradfri-deviceId-refresh').click(function(){
updateDevices(node.deviceId);
});
$(document).ready(function() {
$('#node-input-msgPassthrough').prop('checked', node.observe);
});
},
oneditsave: function() {
this.deviceName = $('#node-input-deviceId').find(":selected").text();
}
});
</script>
<script type="text/x-red" data-template-name="tradfri">
<div class="form-row">
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Name">
</div>
<div class="form-row">
<label for="node-input-connection"><i class="fa fa-bookmark"></i> Connection</label>
<input type="text" id="node-input-connection" placeholder="Connection">
</div>
<div class="form-row">
<label for="node-input-deviceId"><i class="fa fa-lightbulb-o"></i> Device</label>
<div style="display: inline-block; position: relative; width: 70%; height: 20px;">
<div style="position: absolute; left: 0px; right: 40px;">
<select id="node-input-deviceId" style="width: 100%">
<option value="-1" selected="selected">None</option>
</select>
</div>
<a id="node-input-lookup-connection" class="editor-button" style="position: absolute; right: 0px; top: 0px;"><i id="tradfri-deviceId-refresh-spinner" class="fa fa-refresh"></i></a>
</div>
</div>
<div class="form-row">
<label>&nbsp;</label>
<input type="checkbox" id="node-input-observe" style="display: inline-block; width: auto; vertical-align: top;">
<label for="node-input-observe" style="width: 70%;" >Observe device?</label>
</div>
</script>
<script type="text/x-red" data-help-name="tradfri">
<p>Interface for IKEA Tradfri devices</p>
<h3>Inputs</h3>
<dl class="message-properties">
<dt> payload <span class="property-type"> string </span> </dt>
<dd> A single command can be sent as a string to the node. <dd>
<dt> payload <span class="property-type"> object </span> </dt>
<dd> An object with one or more commands targeting the light. </dd>
</dl>
<h3>Outputs</h3>
<dl class="message-properties">
<dt> payload.light <span class="property-type"> object </span></dt>
<dd> The status of the light. </dd>
</dl>
<h3>Details</h3>
The tradfri node acts as both input and output for a IKEA Tradfri lighbulb.
If the node is set to observe it will send a message with the lights current properties as payload every time the light is updated:
<ul>
<li><code>id</code><code>number</code> The id of the light </li>
<li><code>name</code><code>string</code> The given name of the light </li>
<li><code>model</code><code>string</code> The model of the light </li>
<li><code>firmware</code><code>string</code> The firmware of the light </li>
<li><code>alive</code><code>boolean</code> True if the gateway can communicate with the light, false if not </li>
<li><code>on</code><code>boolean</code> True if the light is on, false if not.</li>
<li><code>brightness</code><code>number</code><code>[0,100]</code> The brightness of the light</li>
<li><code>colorTemperature</code><code>number</code>[0,100] The color temperature of the light</li>
<li><code>color</code><code>string</code> The hex-code for the color of the light. Only fully supported by CWS bulbs.</li>
<li><code>colorX</code><code>number</code> The x component of the xy-color</li>
<li><code>colorY</code><code>number</code> The y component of the xy-color</li>
<li><code>transition</code><code>number</code> The default transition time for operations. However, since the default value of 0.5 makes it impossible to send temperature and brightness updates in the same command, this is overridden and set to 0 by default.</li>
<li><code>created</code><code>number</code> Probably when the light was paired with the gateway for the first time, measured in epoch time.</li>
<li><code>seen</code><code>number</code> When the light was last interacted with by the gateway (or similar), measured in epoch time.</li>
<li><code>type</code><code>number</code> The type of device where 2 is light</li>
<li><code>power</code><code>number</code> The type of power source powering the light. Will most likely always be 1.</li>
</ul>
<h2>Controlling the node</h2>
The can be programmatically controlled by sending a message with <code>msg.payload</code> set to one of the following:
<ul>
<li><code>status</code> The node will output the current status of its target light.
</ul>
<h2>Controlling the lights</h2>
Lights can be controlled by sending an objet with one or more of the following properties as <code>msg.payload</code> to the node.
<ul>
<!-- <li><code>id</code><code>number</code> The id of the light <li>
<li><code>name</code><code>string</code> The given name of the light <li> -->
<li><code>on</code><code>boolean</code><li> Turn the light on or off.</li>
<li><code>brightness</code><code>number</code> The brightness of the light [0,100]</li>
<li><code>colorTemperature</code><code>number</code> The color temperature of the light [0,100]</li>
<li><code>color</code><code>string</code> Sets the color of the light. For WS-bulbs, <code>F5FAF6</code>, <code>F1E0B5</code> and <code>EFD275</code> will set the light to the default cold, normal and warm temperatures respectively.</li>
<li><code>transition</code><code>number</code> The default transition time for operations. Will only work for single operation commands and not for on/off. Defaults to 0. </li>
<li><code>hue</code><code>number</code> Sets the hue of the light. Only for CWS. [0,365] (UNTESTED)</li>
<li><code>saturation</code><code>number</code> Sets the saturation of the light. Only for CWS. [0,100] (UNTESTED)</li>
</ul>
</script>

327
src/node-tradfri.ts Normal file
View File

@ -0,0 +1,327 @@
import * as tradfri from "node-tradfri-client";
module.exports = function(RED) {
function lightFromAccessory(accessory: tradfri.Accessory) {
return Object.assign({}, {
id: accessory.instanceId,
name: accessory.name,
model: accessory.deviceInfo.modelNumber,
firmware: accessory.deviceInfo.firmwareVersion,
alive: accessory.alive,
on: accessory.lightList[0].onOff,
onTime: accessory.lightList[0].onTime,
brightness: accessory.lightList[0].dimmer,
colorTemperature: accessory.lightList[0].colorTemperature,
color: accessory.lightList[0].color,
hue: accessory.lightList[0].hue,
saturation: accessory.lightList[0].saturation,
colorX: accessory.lightList[0].colorX,
colorY: accessory.lightList[0].colorY,
transition: accessory.lightList[0].transitionTime,
created: accessory.createdAt,
seen: accessory.lastSeen,
type: accessory.type,
power: accessory.deviceInfo.power
});
}
function toLightOperation(op: any): tradfri.LightOperation {
let props = {on:"onOff", brightness:"dimmer", transition:"transitionTime"};
for (let k in props) {
if (op.hasOwnProperty(k)) {
op[props[k]] = op[k];
delete op[k];
}
}
return op;
}
RED.httpAdmin.get("/tradfri/lights", RED.auth.needsPermission('tradfri.read'), function(req,res) {
let nodeId = req.query.nodeId;
if (nodeId != null) {
let node = RED.nodes.getNode(nodeId);
let lights = node.getLights();
let ret = [];
for (let k in lights) {
ret.push({name: lights[k].name, id: k});
}
res.json(JSON.stringify(ret));
}
});
function TradfriConnectionNode(config) {
var node = this;
RED.nodes.createNode(node, config);
node.name = config.name;
node.address = config.address;
node.securityCode = config.securityCode;
node.identity = config.identity;
node.psk = config.psk;
if ((node.identity == null && node.psk != null) || (node.identity != null && node.psk == null)) {
RED.log.error("Must provide both identity and PSK or leave both blank to generate new credentials from security code.");
}
if (node.identity == null && node.psk == null && node.securityCode == null) {
RED.log.error("Must provide either identity and PSK or a security code to connect to the Tradfri hub");
}
var _lights = {};
var _listeners = {};
var _client = null;
var _deviceUpdatedCallback = (accessory: tradfri.Accessory) => {
if (accessory.type === tradfri.AccessoryTypes.lightbulb) {
_lights[accessory.instanceId] = accessory;
}
if (_listeners[accessory.instanceId]) {
for (let nodeId in _listeners[accessory.instanceId]) {
_listeners[accessory.instanceId][nodeId](accessory);
}
}
}
let _setupClient = async () => {
let loggerFunction = (message: string, severity: string) => {
RED.log.info(severity + ", " + message);
}
let client = new tradfri.TradfriClient(node.address);
if (node.identity == null && node.psk == null) {
const {identity, psk} = await client.authenticate(node.securityCode);
node.identity = identity;
node.psk = psk;
}
if (await client.connect(node.identity, node.psk)) {
client.on("device updated", _deviceUpdatedCallback);
client.observeDevices();
_client = client;
} else {
throw new Error(`Client not available`);
}
}
let _reconnect = async () => {
let timeout = 5000;
if (_client != null) {
_client.destroy();
_client = null;
}
while (_client == null) {
try {
await _setupClient();
} catch (e) {
RED.log.trace(`[Tradfri: ${node.id}] ${e.toString()}, reconnecting...`);
}
await new Promise(resolve => setTimeout(resolve, timeout));
}
}
let pingInterval = 30;
let _ping = setInterval(async () => {
try {
let client = await node.getClient();
let res = await client.ping();
RED.log.trace(`[Tradfri: ${node.id}] ping returned '${res}'`);
}catch (e) {
RED.log.trace(`[Tradfri: ${node.id}] ping returned '${e.toString()}'`);
}
}, pingInterval*1000);
_reconnect();
node.getClient = async (): Promise<tradfri.TradfriClient> => {
let maxRetries = 5;
let timeout = 2;
for (let i = 0; i < maxRetries; i++) {
if (_client == null) {
await new Promise(resolve => setTimeout(resolve, timeout*1000));
} else {
return _client;
}
}
throw new Error ('Client not available');
}
node.getLight = async (instanceId: number): Promise<tradfri.Accessory> => {
let maxRetries = 5;
let timeout = 2;
for (let i = 0; i < maxRetries; i++) {
if (_lights[instanceId] == null) {
await new Promise(resolve => setTimeout(resolve, timeout*1000));
} else {
return _lights[instanceId];
}
}
throw new Error ('Light not available');
}
node.getLights = () => {
return _lights;
}
node.register = (nodeId: string, instanceId: number, callback: (accessory: tradfri.Accessory) => void): void => {
if (!_listeners[instanceId]) {
_listeners[instanceId] = {};
}
_listeners[instanceId][nodeId] = callback;
RED.log.info(`[Tradfri: ${nodeId}] registered event listener for ${instanceId}`);
}
node.unregister = (nodeId: string): void => {
for (let instanceId in _listeners) {
if (_listeners[instanceId].hasOwnProperty(nodeId)) {
delete _listeners[instanceId][nodeId];
RED.log.debug(`[Tradfri: ${nodeId}] unregistered event listeners`);
}
}
}
node.on('close', () => {
clearInterval(_ping);
_client.destroy();
RED.log.debug(`[Tradfri: ${node.id}] Config was closed`);
});
}
RED.nodes.registerType("tradfri-connection", TradfriConnectionNode);
function TradfriNode(config) {
var node = this;
RED.nodes.createNode(node, config);
node.name = config.name;
node.deviceId = config.deviceId;
node.deviceName = config.deviceName;
node.observe = config.observe;
var _config = RED.nodes.getNode(config.connection);
var _prev = {};
var _getPayload = (accessory: tradfri.Accessory) => {
let light = lightFromAccessory(accessory);
light['prev'] = Object.assign({}, _prev);
return light;
}
var _deviceUpdated = (accessory: tradfri.Accessory) => {
let ret = _getPayload(accessory);
_prev = lightFromAccessory(accessory);
node.send({payload: {light: ret}});
RED.log.trace(`[Tradfri: ${node.id}] recieved update for '${accessory.name}' (${accessory.instanceId})`);
}
var _getTargetId = (msg: any): number[] => {
let payload = msg.payload;
if (payload.hasOwnProperty('id') && Array.isArray(payload.id)) {
return payload.id;
} else if (payload.hasOwnProperty('id')) {
return [payload.id];
} else if (node.deviceId > 0) {
return [node.deviceId]
} else {
throw new Error('No valid target device');
}
}
var _handleDirectStatus = async (msg: any) => {
try {
let client = await _config.getClient();
let res = await client.request('15001/' + node.deviceId, 'get');
msg.payload = res;
node.send(msg);
} catch (e) {
msg.payload = e;
node.send(msg);
}
}
var _handleStatus = async (msg: any) => {
try {
let accessory = await _config.getLight(node.deviceId);
msg.payload.light = _getPayload(accessory);
delete msg.payload.status;
node.send(msg);
RED.log.trace(`[Tradfri: ${node.id}] Status request successful`);
} catch (e) {
RED.log.info(`[Tradfri: ${node.id}] Status request unsuccessful, '${e.toString()}'`);
}
}
var _handleDirectLightOp = async (light: any) => {
let clamp = (num: number, min: number, max: number): number => {
return num <= min ? min : num >= max ? max : num;
}
let cmd = {3311:[Object.assign({}, {
5851: clamp(light.brightness, 0, 254),
5711: clamp(light.colorTemperature, 200, 454), // only for WS bulbs
5712: light.transition,
5850: light.on,
5707: light.hue, // only for CWS bulbs
5708: light.saturation, // only for CWS bulbs
5706: light.color // f5faf6, f1e0b5, efd275 are valid for WS bulbs
})]};
try {
let client = await _config.getClient();
let res = await client.request('15001/' + node.deviceId, 'put', Object.assign({}, cmd));
RED.log.trace(`[Tradfri: ${node.id}] DirectLightOp '${JSON.stringify(cmd)}' returned '${res}'`);
} catch (e) {
RED.log.info(`[Tradfri: ${node.id}] DirectLightOp '${JSON.stringify(cmd)}' unsuccessful, '${e.toString()}'`);
}
}
var _handleLightOp = async (light: any) => {
let lightOp = toLightOperation(light);
if (!lightOp.hasOwnProperty('transitionTime')) {
lightOp['transitionTime'] = 0;
}
try {
let light = await _config.getLight(node.deviceId);
if (Object.keys(lightOp).length > 0) {
let client = await _config.getClient();
let res = await client.operateLight(light, lightOp);
RED.log.trace(`[Tradfri: ${node.id}] LightOp '${JSON.stringify(lightOp)}' returned '${res}'`);
}
} catch (e) {
RED.log.info(`[Tradfri: ${node.id}] LightOp '${JSON.stringify(lightOp)}' unsuccessful, '${e.toString()}'`);
}
}
if (node.observe) {
_config.register(node.id, node.deviceId, _deviceUpdated);
}
node.on('input', function(msg) {
(async() => {
if (msg.hasOwnProperty('payload')) {
if (msg.payload === "status") {
msg.payload = {status:true};
}
let isDirect = msg.payload.hasOwnProperty('direct');
let isStatus = msg.payload.hasOwnProperty('status');
if (isDirect && isStatus) {
_handleDirectStatus(msg);
} else if (isStatus) {
_handleStatus(msg);
} else if (isDirect) {
_handleDirectLightOp(msg.payload);
} else {
_handleLightOp(msg.payload);
}
}
})();
});
node.on('close', function() {
RED.log.debug(`[Tradfri: ${node.id}] Node was closed`);
_config.unregister(node.id);
});
}
RED.nodes.registerType("tradfri",TradfriNode);
}