Skip to content

Commit

Permalink
Alpha
Browse files Browse the repository at this point in the history
  • Loading branch information
gvdhoven committed Nov 9, 2020
1 parent 5b9621b commit 4449d6c
Show file tree
Hide file tree
Showing 8 changed files with 712 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
assets/
node_modules/
package-lock.json
6 changes: 6 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git/
assets/
node_modules/
.gitignore
.gitattributes
package-lock.json
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@

# node-red-contrib-slide

API control for the Slide curtains from Innovation In Motion. Only the **LOCAL API** is implemented, the assumption is that you are already running Node-red locally, hence you would want to control your curtains directly without a cloud.
_In case remote API functionality is needed, feel free to submit a pull request for similar remote API functionality._

[![NPM](https://nodei.co/npm/node-red-contrib-slide.png)](https://nodei.co/npm/node-red-contrib-slide/)

## Installation

Run the following command in your Node-RED user directory - typically `~/.node-red`

```bash
$ npm install node-red-contrib-slide.store
```

## Getting started

_The Slide Local API is, in it's current form, intended as a first version, and expected to undergo significant expansions in later updates. The focus of the current API is to allow you to directly control the curtains._

By default, this HTTP API is disabled since it is not yet officially published by Innovation In Motion. I am a beta tester and have therefore access, but I will not expose how to enable to local API until i get the green light from IIM.


The following nodes are implemented which expose a part of the Local API:

- slide-conf
- Contains the `hostaneme` and the `device code` of a single slide.
- slide-get-info
- Read Slide Device ID (MAC)
- Request current Position
- Request whether Touch&Go is on or off (currently not yet adjustable)

## Using the (local) API

Currently the local API does not yet support discovery (such as dns-sd, zeroconf etc.) so you will have to identify which local IP address your Slide is using. Due to a known bug all Slides currently have the hostname `espressif` on the local network, which might make this exercise slightly annoying if you have many Slides online.

### slide.conf

You need to add one configuration node per Slide motor you have installed. A filled in sample looks like this.

![Sample config](https://github.com/gvdhoven/node-red-contrib-slide/blob/main/assets/readme/img/slide.conf.png?raw=true)

As you can see, it contains a 'calibrate' button. Once you have entered the hostname and the devicecode, you can optionally click this button. This starts a custom calibration procedure:
* Closes the curtain
* Polls for a maximum of 30 seconds until the curtain stops moving
* Saves the 'closed' offset (this should be near the 1.0 range)
* Opens the curtain
* Polls for a maximum of 30 seconds until the curtain stops moving
* Saves the 'open' offset (this should be near the 0.0 range)

This offset is then used on subsequent calls on the `slide.setposition` node, to more precisely determine the curtain position and to see if an actual call to the motor is even needed.

For example;
- If the open offset is actually 0.09 and the closed offset 0.87 (just as an example) setting the curtain to 50% would mean that the curtain should move to position: 0.09 + (0.5 * (0.87 - 0.09)) = 0.48 (which is exactly halfway between 0.09 and 0.87.
- If the open offset is actually 0.09 and the curtain is already at 0.09; no `setPosition` command will be issued if you want to open the curtain; meaning you won't hear the motor wirring.
- If the close offset is actually 0.87 and the curtain is already at 0.87; no `setPosition` command will be issued if you want to close the curtain; meaning you won't hear the motor wirring.
208 changes: 208 additions & 0 deletions lib/LocalApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/*jslint node: true */
/*jslint esversion: 6 */
"use strict";

/**
* Module dependencies.
*/
const DigestFetch = require('digest-fetch');


/**
* Slide Local API class
*/
class LocalApi {
/**
* Constructor of the local API.
*
* @param {string} hostname IP address of the slide on the local network
* @param {string} devicecode Devicecode which can be found on top of the slide.
* @param {double} openPosition Position in which the Slide is when opened.
* @param {double} closePosition Position in which the Slide is when closed.
*/
constructor(hostname, devicecode, openPosition=0.0, closedPosition=1.0) {
this.hostname = hostname;
this.devicecode = devicecode;

openPosition = parseFloat(openPosition);
if (openPosition < 0) {
openPosition = 0.0;
} else if (openPosition > 1) {
openPosition = 1.0;
}
this.openPosition = openPosition;

closedPosition = parseFloat(closedPosition);
if (closedPosition < 0) {
closedPosition = 0.0;
} else if (closedPosition > 1) {
closedPosition = 1.0;
}
this.closedPosition = closedPosition;
}

/**
* Makes an async request using Digest authentication towards the specified hostname.
*
* @param {string} path Path of URL to call.
* @param {string} body (optional) JSON body.
* @returns {Promise} Promise object representing the result of the API call.
*/
request(path, body='') {
return new Promise((resolve, reject) => {
const url = ((this.hostname.indexOf('://') === -1) ? 'http://' + this.hostname : this.hostname) + path;
const options = {
'method': 'POST',
'headers': {
'Content-Type': 'application/json'
},
'body': ((body !== '') ? JSON.stringify(body) : '')
};
const client = new DigestFetch('user', this.devicecode, { algorithm: 'MD5' });
client.fetch(url, options)
.then(res => {
if (res.ok) {
return res.json();
} else {
if (res.statusText === 'Unauthorized') {
reject({ 'code': 401, 'title': 'Invalid device code', 'message': 'The Slide at hostname "' + this.hostname +'" rejected device code "' + this.devicecode + '".' });
} else {
reject({ 'code': 400, 'title': res.statusText, 'message': 'The Slide at hostname "' + this.hostname +'" with device code "' + this.devicecode + '" gave an unclear response.' });
}
}
})
.then(json => {
resolve(json);
})
.catch((e) => {
if (e.errno && e.errno === 'ECONNREFUSED') {
reject({ 'code': 404, 'title': 'Unable to connect', 'message': 'The Slide at hostname "' + this.hostname +'" with device code "' + this.devicecode + '" is unresponsive.' });
} else {
reject({ 'code': 500, 'title': 'Unkown error occured', 'message': e });
}
});
});
}

/**
* Sleep function
*
* @param {int} ms Milliseconds to sleep
* @returns {Promise} Promise object representing the timer
*/
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

/**
* Determines the current position of the curtain and then issues a setPos command. When the position does not change anymore, assumes success.
*
* @returns {double} Last reported position
*/
async waitUntilPosition() {
// The loop to rule them all
var lastPos = -1;
var currPos;
while (true) {
await this.sleep(1000);
var result = await this.getInfo();
var currPos = result.pos;
console.log(currPos);

if (lastPos != -1) {
if (currPos == lastPos) {
// We wait for a while to let the Slide stop and take a break etc.
await this.sleep(3000);
break;
}
}
lastPos = currPos;
}

return { 'response': 'success', 'pos': currPos };
}

/**
* Gets information from the Slide.
*
* @returns {Promise} Promise object representing the result of the API call.
*/
getInfo() {
return this.request('/rpc/Slide.GetInfo');
}

/**
* Recalibrates the Slide.
*
* @returns {Promise} Promise object representing the result of the API call.
*/
calibrate() {
return new Promise((resolve, reject) => {
this.request('/rpc/Slide.Calibrate')
.then(() => {
resolve(this.waitUntilPosition());
})
.catch(e => reject(e));
});
}

/**
* Updates the curtain position of the slide.
*
* @returns {Promise} Promise object representing the result of the API call.
*/
async setPos(pos) {
var result = await this.getInfo();
var currPos = result.pos;
return new Promise((resolve, reject) => {
if (currPos === pos) {
resolve({ 'response': 'success', 'pos': currPos });
} else {
this.request('/rpc/Slide.SetPos', { 'pos': pos })
.then(() => { resolve(this.waitUntilPosition()); })
.catch(e => reject(e));
}
});
}

/**
* Stops the motor of the Slide.
*
* @returns {Promise} Promise object representing the result of the API call.
*/
stop() {
return this.request('/rpc/Slide.Stop');
}

/**
* Updates the WiFi configuration of the slide.
*
* @param {string} ssid The new SSID to connect to.
* @param {string} pass THe new SSID password to use.
* @returns {Promise} Promise object representing the result of the API call.
*/
updateWifi(ssid, pass) {
return this.request('/rpc/Slide.Config.WiFi', { 'ssid': ssid, 'pass': pass });
}

/**
* Sends an open command to the Slide.
*
* @returns {Promise} Promise object representing the result of the API call.
*/
async open() {
return this.setPos(this.openPosition);
}

/**
* Sends an close command to the Slide.
*
* @returns {Promise} Promise object representing the result of the API call.
*/
async close() {
return this.setPos(this.closedPosition);
}

}

module.exports = LocalApi;
7 changes: 7 additions & 0 deletions license.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Copyright (c) 2020, Gilles van den Hoven

Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

Source: http://opensource.org/licenses/ISC
36 changes: 36 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "node-red-contrib-slide",
"version": "0.0.0",
"description": "Control the Slide curtains from Innovation In Motion via node-red using the local API.",
"main": "index.js",
"scripts": {
"test": "echo \"No test specified\""
},
"keywords": [
"node-red",
"GoSlide",
"Innovation In Motion",
"API",
"Slide",
"Slide.store"
],
"node-red": {
"nodes": {
"slide": "slide.js"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/gvdhoven/node-red-contrib-slide.git"
},
"bugs": {
"url": "https://github.com/gvdhoven/node-red-contrib-slide/issues"
},
"homepage": "https://github.com/gvdhoven/node-red-contrib-slide#readme",
"author": "Gilles van den Hoven <[email protected]>",
"license": "Apache-2.0",
"dependencies": {
"node-fetch": "^2.6.1",
"digest-fetch": "^1.1.5"
}
}
Loading

0 comments on commit 4449d6c

Please sign in to comment.