This commit is contained in:
Evgenii Abramov
2021-01-18 01:52:32 +03:00
commit d6ae5dc1ea
26 changed files with 4083 additions and 0 deletions

14
.eslintrc Normal file
View File

@@ -0,0 +1,14 @@
{
"env": {
"node": true
},
"extends": "airbnb-base",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"modules": true
}
}
}

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.DS_Store
node_modules/
npm-debug.log
yarn-error.log
.env
config.js.def

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 munrexio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

302
README.md Normal file
View File

@@ -0,0 +1,302 @@
# yandex2mqtt
Мост из Яндекс УД в MQTT на Node.js
Форк [Проекта](https://github.com/munrexio/yandex2mqtt) и [Статья на Хабре](https://habr.com/ru/post/465537/) к оригиналу.
## Важно
Те, кто пользуется оригинальным проектом (или его форками), обратите внимание на то, что немного изменились настройки устройств (блок **devices** в файле конфигурации).
На данный момент проверено получение температуры и влажности с датчиков (датчики дверей и движения пока в бета-тесте), и включение/выключение света (вкл./выкл. других устройств по аналогии тоже должно работать).
Прочий функционал (изменение громкости, каналов, отключение звука), поидее, так же должны работать.
## ChangeLog
Проведён рефакторинг кода и, местами, внесены значительные правки.
Добавлена поддержка датчиков (устройств **devices.types.sensor**)
## Требования
- **"Белый" IP адрес и домен**. Если нет своего домена и белого IP адреса можно воспользоваться Dynamic DNS сервисами (например, noip.com).
- **SSL сертификат _(самоподписанный сертификат не подойдёт)_**. Для получения сертификата можно воспользоваться [https://letsencrypt.org](https://letsencrypt.org).
## Установка
Настройка репозитория Node JS
```
curl -sL https://deb.nodesource.com/setup_10.x | bash -
```
Устанавка необходимых пакетов
```
apt-get install -y nodejs git make g++ gcc build-essential
```
Копирование файлов y2m с git
```
git clone https://github.com/lasthead0/yandex2mqtt.git /opt/yandex2mqtt
```
Установка прав на директорию
```
chown -R root:root /opt/yandex2mqtt
```
Установка необходимых модулей nodejs
```
cd /opt/yandex2mqtt
npm install
```
Запуск моста (выполняется после настройки)
```
npm start
```
## Настройка yandex2mqtt
Все основные настройки моста прописываются в файл config.js. Перед запуском обязательно отредактируйте его.
```
mv config.orig.js config.js
```
**Файл конфигурации**
```
module.exports = {
mqtt: {
...
},
https: {
...
},
clients: [
{
...
},
],
users: [
{
...
},
],
devices: [
{
...
},
]
}
```
**Блок настройки mqtt клиента**
Указать данные Вашего MQTT сервера
```
mqtt: {
host: 'localhost',
port: 1883,
user: 'user',
password: 'password'
},
```
**Блок настройки https сервера**
Указать порт, на котором будет работать мост, а так же пути к сертификату ssl.
```
https: {
privateKey: '/etc/letsencrypt/live/your.domain.ru/privkey.pem',
certificate: '/etc/letsencrypt/live/your.domain.ru/fullchain.pem',
port: 4433
},
```
**Блок настройки клиентов**
Здесь используются произвольные данные, далее они понадобятся для подключения к УД Yandex.
```
clients: [
{
id: '1',
name: 'Yandex',
clientId: 'client',
clientSecret: 'secret',
isTrusted: false
},
],
```
**Блок настройки пользователей**
```
users: [
{
id: "1",
username: "admin",
password: "admin",
name: "Administrator"
},
{
id: "2",
username: "user1",
password: "user1",
name: "User"
},
],
```
**Блок настройки устройств**
```
devices: [
{
id: "lvr-003-switch",
name: "Основной свет",
room: "Гостиная",
type: "devices.types.light",
mqtt: [
{
instance: "on",
set: "/yandex/controls/light_LvR_003/state/on",
state: "/yandex/controls/light_LvR_003/state"
},
],
/* mapping значений между yandex и УД */
valueMapping: [
{
type: "on_off",
mapping: [[false, true], [0, 1]] // [yandex, mqtt]
}
],
capabilities: [
{
type: "devices.capabilities.on_off",
retrievable: true,
},
],
},
{
id: "lvr-001-weather",
name: "В гостиной",
room: "Гостиная",
type: "devices.types.sensor",
mqtt: [
{
instance: "temperature",
state: "/yandex/sensors/LvR_001_Weather/temperature"
},
{
instance: "humidity",
state: "/yandex/sensors/LvR_001_Weather/humidity"
}
],
properties: [
{
type: "devices.properties.float",
retrievable: true,
parameters: {
instance: "temperature",
unit: "unit.temperature.celsius"
},
},
{
type: "devices.properties.float",
retrievable: true,
parameters: {
instance: "humidity",
unit: "unit.percent"
},
/* Блок state указывать не обязательно */
state: {
instance: "humidity",
value: 0
}
}
]
},
/* --- end */
]
```
*Рекомендую указывать id в конфиге, чтобы исключить "наложение" новых устройств на "старые", которые уже добавлены в yandex.*
*В случае отсутсвия id в конфиге, он будет назначен автоматически по индексу в массиве.*
###### Mapping значений
Блок valueMapping позволяет настроить конвертацию значений между yandex api и MQTT. Это может быть актуально для умений типа **devices.capabilities.on_off** и **devices.capabilities.toggle**.
*Например, если в УД состояние влючено/выключено соответствует значениям 1/0, то Вам понадобиться их конвертировать, т.к. в навыках Yandex значения true/false.*
```
valueMapping: [
{
type: "on_off",
mapping: [[false, true], [0, 1]] // [yandex, mqtt]
}
]
```
В mapping указывается миссив массивов. Первый массив - значения в yandex, второй - в MQTT.
## Документация Яндекс
- [Типы устройств](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/device-types.html)
- [Типы умений устройства](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/capability-types.html)
- [Типы встроенных датчиков](https://yandex.ru/dev/dialogs/alice/doc/smart-home/concepts/properties-types.html)
## Создание службы
В папке /etc/systemd/system/ создать файл yandex2mqtt.service со следующим содержанем:
```
[Unit]
Description=yandex2mqtt
After=network.target
[Service]
ExecStart=/usr/bin/npm start
WorkingDirectory=/mnt/data/root/yandex2mqtt
StandardOutput=inherit
StandardError=inherit
Restart=always
User=root
[Install]
WantedBy=multi-user.target
```
Для включения службы использовать команду:
```
systemctl enable yandex2mqtt.service
```
Для управления службой использовать команды:
```
service yandex2mqtt start
```
```
service yandex2mqtt stop
```
```
service yandex2mqtt restart
```
## Создание навыка (в Яндекс Диалоги)
Заходим в [Яндекс Диалоги](https://dialogs.yandex.ru/developer) => Создать диалог => Умный дом
###### Основные настройки
- **Название** *Любое*
- **Backend** *Endpoint URL* и указываем https://your.domain.ru:port/provider
- **Тип доступа** *Приватный*
###### Публикация в каталоге
- **Подзаголовок** *Любой текст*
- **Имя разработчика** *Ваше имя*
- **Официальный навык** *Нет*
- **Описание** *Любой текст*
- **Иконка** *Своя иконка*
###### Связка аккаунтов
- **Авторизация** _Кнопка **"Создать"**_
###### Создание связки аккаунтов
- **Идентификатор приложения** *Файл конфигурации clients.clientId*
- **Секрет приложения** *Файл конфигурации clients.clientSecret*
- **URL авторизации** *https://your.domain.ru:port/dialog/authorize*
- **URL для получения токена** *https://your.domain.ru:port/oauth/token*
- **URL для обновления токена** *https://your.domain.ru:port/oauth/token*
**Сохраняем** навык. Далее можно работать с черновиком (тестировать навык) или опубликовать его (кнопка **"Опубликовать"**).
На вкладке **Тестирование** (далее кнопка **+(плюс)**) необходимо **Привязать к Яндексу** наш мост, используя имя пользователя и пароль из файла конфигурации (блок **users**). После этого можно получить список устройств.

121
app.js Normal file
View File

@@ -0,0 +1,121 @@
'use strict';
const fs = require('fs');
const path = require('path');
/* express and https */
const ejs = require('ejs');
const express = require('express');
const app = express();
const https = require('https');
/* parsers */
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
/* error handler */
const errorHandler = require('errorhandler');
/* seesion and passport */
const session = require('express-session');
const passport = require('passport');
/* mqtt client for devices */
const mqtt = require('mqtt');
/* */
const config = require('./config');
const Device = require('./device');
app.engine('ejs', ejs.__express);
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, './views'));
app.use(express.static('views'));
app.use(cookieParser());
app.use(bodyParser.json({
extended: false
}));
app.use(bodyParser.urlencoded({
extended: true
}));
app.use(errorHandler());
app.use(session({
secret: 'keyboard cat',
resave: false,
saveUninitialized: false
}));
/* passport */
app.use(passport.initialize());
app.use(passport.session());
/* passport auth */
require('./auth');
/* routers */
const {site: r_site, oauth2: r_oauth2, user: r_user, client: r_client} = require('./routes');
app.get('/', r_site.index);
app.get('/login', r_site.loginForm);
app.post('/login', r_site.login);
app.get('/logout', r_site.logout);
app.get('/account', r_site.account);
app.get('/dialog/authorize', r_oauth2.authorization);
app.post('/dialog/authorize/decision', r_oauth2.decision);
app.post('/oauth/token', r_oauth2.token);
app.get('/api/userinfo', r_user.info);
app.get('/api/clientinfo', r_client.info);
app.get('/provider/v1.0', r_user.ping);
app.get('/provider', r_user.ping);
app.get('/provider/v1.0/user/devices', r_user.devices);
app.post('/provider/v1.0/user/devices/query', r_user.query);
app.post('/provider/v1.0/user/devices/action', r_user.action);
app.post('/provider/v1.0/user/unlink', r_user.unlink);
/* create https server */
const privateKey = fs.readFileSync(config.https.privateKey, 'utf8');
const certificate = fs.readFileSync(config.https.certificate, 'utf8');
const credentials = {
key: privateKey,
cert: certificate
};
const httpsServer = https.createServer(credentials, app);
httpsServer.listen(config.https.port);
/* cache devices from config to global */
global.devices = [];
if (config.devices) {
config.devices.forEach(opts => {
global.devices.push(new Device(opts));
});
}
/* create subscriptions array */
const subscriptions = [];
global.devices.forEach(device => {
device.data.custom_data.mqtt.forEach(mqtt => {
const {instance, state: topic} = mqtt;
if (instance != undefined && topic != undefined) {
subscriptions.push({deviceId: device.data.id, instance, topic});
}
});
});
/* Create MQTT client (variable) in global */
global.mqttClient = mqtt.connect(`mqtt://${config.mqtt.host}`, {
port: config.mqtt.port,
username: config.mqtt.user,
password: config.mqtt.password
})
/* on connect event handler */
.on('connect', () => {
mqttClient.subscribe(subscriptions.map(pair => pair.topic));
})
/* on offline event handler */
.on('offline', () => {
/* */
})
/* on get message event handler */
.on('message', (topic, message) => {
const subscription = subscriptions.find(sub => topic.toLowerCase() === sub.topic.toLowerCase());
if (subscription == undefined) return;
const {deviceId, instance} = subscription;
const ldevice = global.devices.find(d => d.data.id == deviceId);
ldevice.updateState(`${message}`, instance);
});
module.exports = app;

91
auth/index.js Normal file
View File

@@ -0,0 +1,91 @@
'use strict';
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const BasicStrategy = require('passport-http').BasicStrategy;
const ClientPasswordStrategy = require('passport-oauth2-client-password').Strategy;
const BearerStrategy = require('passport-http-bearer').Strategy;
const db = require('../db');
/**
* LocalStrategy
*
* This strategy is used to authenticate users based on a username and password.
* Anytime a request is made to authorize an application, we must ensure that
* a user is logged in before asking them to approve the request.
*/
passport.use(new LocalStrategy(
(username, password, done) => {
db.users.findByUsername(username, (error, user) => {
if (error) return done(error);
if (!user) return done(null, false);
if (user.password !== password) return done(null, false);
return done(null, user);
});
}
));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
db.users.findById(id, (error, user) => done(error, user));
});
/**
* BasicStrategy & ClientPasswordStrategy
*
* These strategies are used to authenticate registered OAuth clients. They are
* employed to protect the `token` endpoint, which consumers use to obtain
* access tokens. The OAuth 2.0 specification suggests that clients use the
* HTTP Basic scheme to authenticate. Use of the client password strategy
* allows clients to send the same credentials in the request body (as opposed
* to the `Authorization` header). While this approach is not recommended by
* the specification, in practice it is quite common.
*/
function verifyClient(clientId, clientSecret, done) {
db.clients.findByClientId(clientId, (error, client) => {
if (error) return done(error);
if (!client) return done(null, false);
if (client.clientSecret !== clientSecret) return done(null, false);
return done(null, client);
});
}
passport.use(new BasicStrategy(verifyClient));
passport.use(new ClientPasswordStrategy(verifyClient));
/**
* BearerStrategy
*
* This strategy is used to authenticate either users or clients based on an access token
* (aka a bearer token). If a user, they must have previously authorized a client
* application, which is issued an access token to make requests on behalf of
* the authorizing user.
*/
passport.use(new BearerStrategy(
(accessToken, done) => {
db.accessTokens.find(accessToken, (error, token) => {
if (error) return done(error);
if (!token) return done(null, false);
if (token.userId) {
db.users.findById(token.userId, (error, user) => {
if (error) return done(error);
if (!user) return done(null, false);
// To keep this example simple, restricted scopes are not implemented,
// and this is just for illustrative purposes.
done(null, user, { scope: '*' });
});
} else {
// The request came from a client only since userId is null,
// therefore the client is passed back instead of a user.
db.clients.findByClientId(token.clientId, (error, client) => {
if (error) return done(error);
if (!client) return done(null, false);
// To keep this example simple, restricted scopes are not implemented,
// and this is just for illustrative purposes.
done(null, client, { scope: '*' });
});
}
});
}
));

157
config.js.orig Normal file
View File

@@ -0,0 +1,157 @@
module.exports = {
mqtt: {
host: 'localhost',
port: 1883,
user: 'user',
password: 'password'
},
https: {
privateKey: '/etc/letsencrypt/live/your.domain.ru/privkey.pem',
certificate: '/etc/letsencrypt/live/your.domain.ru/fullchain.pem',
port: 4433
},
clients: [
{
id: '1',
name: 'Yandex',
clientId: 'client',
clientSecret: 'secret',
isTrusted: false
},
],
users: [
{
id: "1",
username: "admin",
password: "admin",
name: "Administrator"
},
{
id: "2",
username: "user1",
password: "user1",
name: "User"
},
],
devices: [
{
id: "haw-002-switch",
name: "Свет в коридоре",
room: "Коридор",
type: "devices.types.light",
mqtt: [
{
instance: "on",
set: "/yandex/controls/light_HaW_002/state/on",
state: "/yandex/controls/light_HaW_002/state"
},
],
capabilities: [
{
type: "devices.capabilities.on_off",
retrievable: true,
},
],
},
{
id: "lvr-003-switch",
name: "Основной свет",
room: "Гостиная",
type: "devices.types.light",
mqtt: [
{
instance: "on",
set: "/yandex/controls/light_LvR_003/state/on",
state: "/yandex/controls/light_LvR_003/state"
},
],
valueMapping: [
{
type: "on_off",
mapping: [[false, true], [0, 1]] // [yandex, mqtt]
}
],
capabilities: [
{
type: "devices.capabilities.on_off",
retrievable: true,
},
],
},
{
id: "lvr-001-weather",
name: "В гостиной",
room: "Гостиная",
type: "devices.types.sensor",
mqtt: [
{
instance: "temperature",
state: "/yandex/sensors/LvR_001_Weather/temperature"
},
{
instance: "humidity",
state: "/yandex/sensors/LvR_001_Weather/humidity"
}
],
properties: [
{
type: "devices.properties.float",
retrievable: true,
parameters: {
instance: "temperature",
unit: "unit.temperature.celsius"
},
},
{
type: "devices.properties.float",
retrievable: true,
parameters: {
instance: "humidity",
unit: "unit.percent"
},
}
]
},
{
id: "plug-001-flower",
name: "Розетка для цветка",
room: "Гостиная",
type: "devices.types.socket",
mqtt: [
{
instance: "on",
set: "/yandex/controls/socket_LvR_002/state/on",
state: "/yandex/controls/socket_LvR_002/state/on"
},
{
instance: "power",
state: "/yandex/controls/socket_LvR_002/power"
},
],
capabilities: [
{
type: "devices.capabilities.on_off",
retrievable: true,
},
],
properties: [
{
type: "devices.properties.float",
retrievable: true,
parameters: {
instance: "power",
unit: "unit.watt"
},
},
]
}
/* --- end */
]
}

54
db/access_tokens.js Normal file
View File

@@ -0,0 +1,54 @@
'use strict';
const loki = require('lokijs');
global.dbl = new loki('./loki.json', {
autoload: true,
autosave: true,
autosaveInterval: 5000,
autoloadCallback() {
global.authl = global.dbl.getCollection('tokens');
if (global.authl === null) {
global.authl = global.dbl.addCollection('tokens');
}
}
});
module.exports.find = (key, done) => {
const ltoken = global.authl.findOne({'token': key});
if (ltoken){
console.log('Token found');
const {userId, clientId} = ltoken;
return done(null, {userId, clientId})
} else {
return done(new Error('Token Not Found'));
}
};
module.exports.findByUserIdAndClientId = (userId, clientId, done) => {
const ltoken = global.authl.findOne({'userId': userId});
if (ltoken){
console.log('Load token by userId: User found');
const {token, userId: uid, clientId: cid} = ltoken;
if (uid === userId && cid === clientId) return done(null, token);
else return done(new Error('Token Not Found'));
} else {
console.log('User not found');
return done(new Error('User Not Found'));
}
};
module.exports.save = (token, userId, clientId, done) => {
console.log('Start saving token');
const ltoken = global.authl.findOne({'userId': userId});
if (ltoken){
console.log('User Updated');
global.authl.update(Object.assign({}, ltoken, {token, userId, clientId}));
} else {
console.log('User not Found. Create new...');
global.authl.insert({'type': 'token', token, userId, clientId});
}
done();
};
/* works */

13
db/authorization_codes.js Normal file
View File

@@ -0,0 +1,13 @@
'use strict';
const codes = {};
module.exports.find = (key, done) => {
if (codes[key]) return done(null, codes[key]);
return done(new Error('Code Not Found'));
};
module.exports.save = (code, clientId, redirectUri, userId, userName, done) => {
codes[code] = {clientId, redirectUri, userId, userName};
done();
};

17
db/clients.js Normal file
View File

@@ -0,0 +1,17 @@
'use strict';
const {clients} = require('../config');
module.exports.findById = (id, done) => {
for (const client of clients) {
if (client.id === id) return done(null, client);
}
return done(new Error('Client Not Found'));
};
module.exports.findByClientId = (clientId, done) => {
for (const client of clients) {
if (client.clientId === clientId) return done(null, client);
}
return done(new Error('Client Not Found'));
};

13
db/index.js Normal file
View File

@@ -0,0 +1,13 @@
'use strict';
const users = require('./users');
const clients = require('./clients');
const accessTokens = require('./access_tokens');
const authorizationCodes = require('./authorization_codes');
module.exports = {
users,
clients,
accessTokens,
authorizationCodes,
};

17
db/users.js Normal file
View File

@@ -0,0 +1,17 @@
'use strict';
const {users} = require('../config');
module.exports.findById = (id, done) => {
for (const user of users) {
if (user.id === id) return done(null, user);
}
return done(new Error('User Not Found'));
};
module.exports.findByUsername = (username, done) => {
for (const user of users) {
if (user.username === username) return done(null, user);
}
return done(new Error('User Not Found'));
};

198
device.js Normal file
View File

@@ -0,0 +1,198 @@
/* function for convert system values to Yandex (depends of capability or property type) */
function convertToYandexValue(val, actType) {
switch(actType) {
case 'range':
case 'float': {
if (val == undefined) return 0.0;
try {
const value = parseFloat(val);
return isNaN(value) ? 0.0 : value;
} catch {
console.error(`Can't parse to float: ${val}`);
return 0.0;
}
}
case 'on_off': {
if (val == undefined) return false;
if (['true', 'on', '1'].indexOf(String(val).toLowerCase()) != -1) return true;
else return false;
}
default:
return val;
}
}
/* Device class defenition */
class Device {
constructor(options) {
var id = global.devices.length;
this.data = {
id: options.id || String(id),
name: options.name || 'Без названия',
description: options.description || '',
room: options.room || '',
type: options.type || 'devices.types.light',
custom_data: {
mqtt: options.mqtt || [],
valueMapping: options.valueMapping || [],
},
capabilities: (options.capabilities || []).map(c => Object.assign({}, c, {state: (c.state == undefined) ? this.initState(c) : c.state})),
properties: (options.properties || []).map(p => Object.assign({}, p, {state: (p.state == undefined) ? this.initState(p) : p.state}))
}
}
initState(cp) {
const {type, parameters} = cp;
const actType = String(type).split('.')[2];
switch(actType) {
case 'float': {
return {
instance: parameters.instance,
value: 0
}
}
case 'on_off': {
return {
instance: 'on',
value: false
}
}
case 'range': {
return {
instance: parameters.instance,
value: parameters.range.min
}
}
case 'mode': {
return {
instance: parameters.instance,
value: parameters.modes[0].value
}
}
default: {
console.error(`Unsupported capability type: ${type}`)
return undefined;
}
}
}
getInfo() {
const {id, name, description, room, type, capabilities, properties} = this.data;
return {id, name, description, room, type, capabilities, properties};
}
/* Find capability by type */
findCapability(type) {
return this.data.capabilities.find(c => c.type === type);
}
/* Unused for now */
findProperty(type) {
return this.data.properties.find(p => p.type === type);
}
/* Find topic by instance*/
findTopicByInstance(instance) {
return this.data.custom_data.mqtt.find(i => i.instance === instance).set;
}
/* Get mapped value (if exist) for capability type */
/**
*
* @param {*} val value
* @param {*} actType capability type
* @param {*} y2m mapping direction (yandex to mqtt, mqtt to yandex)
*/
getMappedValue(val, actType, y2m) {
const map = this.data.custom_data.valueMapping.find(m => m.type == actType);
if (map == undefined) return val;
var from, to;
if (y2m == true) [from, to] = map.mapping;
else [to, from] = map.mapping;
const mappedValue = to[from.indexOf(val)];
return (mappedValue != undefined) ? mappedValue : val;
}
/* Get only needed for response device info (bun not full device defenition) */
getState () {
const {id, capabilities, properties} = this.data;
const device = {
id,
capabilities: (() => {
return capabilities.filter(c => c.retrievable === true).map(c => {
return {
type: c.type,
state: c.state
}
})
})() || [],
properties: (() => {
return properties.filter(p => p.retrievable === true).map(p => {
return {
type: p.type,
state: p.state
}
})
})() || [],
}
return device;
}
/* Change device capability state and publish value to MQTT topic */
setCapabilityState(val, type, instance) {
const {id} = this.data;
const actType = String(type).split('.')[2];
const value = this.getMappedValue(val, actType, true);
let message;
let topic;
try {
const capability = this.findCapability(type);
if (capability == undefined) throw new Error(`Can't find capability '${type}' in device '${id}'`);
capability.state.value = value;
topic = this.findTopicByInstance(instance);
if (topic == undefined) throw new Error(`Can't find set topic for '${type}' in device '${id}'`);
message = `${value}`;
} catch(e) {
topic = false;
console.log(e);
}
if (topic) {
global.mqttClient.publish(topic, message);
}
return {
type,
'state': {
instance,
'action_result': {
'status': 'DONE'
}
}
}
}
/* Update device capability or property state */
updateState(val, instance) {
const {id, capabilities, properties} = this.data;
try {
const cp = [].concat(capabilities, properties).find(cp => (cp.state.instance === instance))
if (cp == undefined) throw new Error(`Can't instance '${instance}' in device '${id}'`);
const actType = String(cp.type).split('.')[2];
const value = this.getMappedValue(val, actType, false);
cp.state = {instance, value: convertToYandexValue(value, actType)};
} catch(e) {
console.error(e);
}
}
}
module.exports = Device;

2283
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "yandex2mqtt",
"version": "0.2.0",
"repository": "https://github.com/munrexio/yandex2mqtt.git",
"license": "MIT",
"contributors": [
{
"name": "Munrexio",
"url": "https://github.com/munrexio"
}
],
"scripts": {
"start": "node app.js"
},
"dependencies": {
"@poziworld/oauth2orize": "^1.11.1",
"body-parser": "^1.17.1",
"connect-ensure-login": "^0.1.1",
"cookie-parser": "^1.4.3",
"ejs": "^2.5.6",
"errorhandler": "^1.5.0",
"express": "^4.15.2",
"express-session": "^1.15.2",
"https": "^1.0.0",
"lokijs": "^1.5.6",
"mqtt": "^3.0.0",
"mqtt-packet": "^6.1.2",
"oauth2orize": "^1.8.0",
"passport": "^0.3.2",
"passport-http": "^0.3.0",
"passport-http-bearer": "^1.0.1",
"passport-local": "^1.0.0",
"passport-oauth2-client-password": "^0.1.2"
},
"devDependencies": {
"eslint": "^3.19.0",
"eslint-config-airbnb-base": "^11.1.3",
"eslint-plugin-import": "^2.2.0"
}
}

13
routes/client.js Normal file
View File

@@ -0,0 +1,13 @@
'use strict';
const passport = require('passport');
module.exports.info = [
passport.authenticate('bearer', { session: false }), (req, res) => {
// request.authInfo is set using the `info` argument supplied by
// `BearerStrategy`. It is typically used to indicate scope of the token,
// and used in access control checks. For illustrative purposes, this
// example simply returns the scope in the response.
res.json({ client_id: req.user.id, name: req.user.name, scope: req.authInfo.scope });
}
];

13
routes/index.js Normal file
View File

@@ -0,0 +1,13 @@
'use strict';
const site = require('./site');
const oauth2 = require('./oauth2');
const user = require('./user');
const client = require('./client');
module.exports = {
site,
oauth2,
user,
client,
};

213
routes/oauth2.js Normal file
View File

@@ -0,0 +1,213 @@
'use strict';
const oauth2orize = require('@poziworld/oauth2orize');
const passport = require('passport');
const login = require('connect-ensure-login');
const db = require('../db');
const utils = require('../utils');
// Create OAuth 2.0 server
const server = oauth2orize.createServer();
// Register serialization and deserialization functions.
//
// When a client redirects a user to user authorization endpoint, an
// authorization transaction is initiated. To complete the transaction, the
// user must authenticate and approve the authorization request. Because this
// may involve multiple HTTP request/response exchanges, the transaction is
// stored in the session.
//
// An application must supply serialization functions, which determine how the
// client object is serialized into the session. Typically this will be a
// simple matter of serializing the client's ID, and deserializing by finding
// the client by ID from the database.
server.serializeClient((client, done) => done(null, client.id));
server.deserializeClient((id, done) => {
db.clients.findById(id, (error, client) => {
if (error) return done(error);
return done(null, client);
});
});
// Register supported grant types.
//
// OAuth 2.0 specifies a framework that allows users to grant client
// applications limited access to their protected resources. It does this
// through a process of the user granting access, and the client exchanging
// the grant for an access token.
// Grant authorization codes. The callback takes the `client` requesting
// authorization, the `redirectUri` (which is used as a verifier in the
// subsequent exchange), the authenticated `user` granting access, and
// their response, which contains approved scope, duration, etc. as parsed by
// the application. The application issues a code, which is bound to these
// values, and will be exchanged for an access token.
server.grant(oauth2orize.grant.code((client, redirectUri, user, ares, done) => {
const code = utils.getUid(16);
db.authorizationCodes.save(code, client.id, redirectUri, user.id, user.username, (error) => {
if (error) return done(error);
return done(null, code);
});
}));
// Grant implicit authorization. The callback takes the `client` requesting
// authorization, the authenticated `user` granting access, and
// their response, which contains approved scope, duration, etc. as parsed by
// the application. The application issues a token, which is bound to these
// values.
server.grant(oauth2orize.grant.token((client, user, ares, done) => {
const token = utils.getUid(256);
db.accessTokens.save(token, user.id, client.clientId, (error) => {
if (error) return done(error);
return done(null, token);
});
}));
// Exchange authorization codes for access tokens. The callback accepts the
// `client`, which is exchanging `code` and any `redirectUri` from the
// authorization request for verification. If these values are validated, the
// application issues an access token on behalf of the user who authorized the
// code. The issued access token response can include a refresh token and
// custom parameters by adding these to the `done()` call
server.exchange(oauth2orize.exchange.code((client, code, redirectUri, done) => {
db.authorizationCodes.find(code, (error, authCode) => {
if (error) return done(error);
if (client.id !== authCode.clientId) return done(null, false);
if (redirectUri !== authCode.redirectUri) return done(null, false);
const token = utils.getUid(256);
db.accessTokens.save(token, authCode.userId, authCode.clientId, (error) => {
if (error) return done(error);
// Add custom params, e.g. the username
let params = { username: authCode.userName };
// Call `done(err, accessToken, [refreshToken], [params])` to issue an access token
return done(null, token, null, params);
});
});
}));
// Exchange user id and password for access tokens. The callback accepts the
// `client`, which is exchanging the user's name and password from the
// authorization request for verification. If these values are validated, the
// application issues an access token on behalf of the user who authorized the code.
server.exchange(oauth2orize.exchange.password((client, username, password, scope, done) => {
// Validate the client
db.clients.findByClientId(client.clientId, (error, localClient) => {
if (error) return done(error);
if (!localClient) return done(null, false);
if (localClient.clientSecret !== client.clientSecret) return done(null, false);
// Validate the user
db.users.findByUsername(username, (error, user) => {
if (error) return done(error);
if (!user) return done(null, false);
if (password !== user.password) return done(null, false);
// Everything validated, return the token
const token = utils.getUid(256);
db.accessTokens.save(token, user.id, client.clientId, (error) => {
if (error) return done(error);
// Call `done(err, accessToken, [refreshToken], [params])`, see oauth2orize.exchange.code
return done(null, token);
});
});
});
}));
// Exchange the client id and password/secret for an access token. The callback accepts the
// `client`, which is exchanging the client's id and password/secret from the
// authorization request for verification. If these values are validated, the
// application issues an access token on behalf of the client who authorized the code.
server.exchange(oauth2orize.exchange.clientCredentials((client, scope, done) => {
// Validate the client
db.clients.findByClientId(client.clientId, (error, localClient) => {
if (error) return done(error);
if (!localClient) return done(null, false);
if (localClient.clientSecret !== client.clientSecret) return done(null, false);
// Everything validated, return the token
const token = utils.getUid(256);
// Pass in a null for user id since there is no user with this grant type
db.accessTokens.save(token, null, client.clientId, (error) => {
if (error) return done(error);
// Call `done(err, accessToken, [refreshToken], [params])`, see oauth2orize.exchange.code
return done(null, token);
});
});
}));
// User authorization endpoint.
//
// `authorization` middleware accepts a `validate` callback which is
// responsible for validating the client making the authorization request. In
// doing so, is recommended that the `redirectUri` be checked against a
// registered value, although security requirements may vary across
// implementations. Once validated, the `done` callback must be invoked with
// a `client` instance, as well as the `redirectUri` to which the user will be
// redirected after an authorization decision is obtained.
//
// This middleware simply initializes a new authorization transaction. It is
// the application's responsibility to authenticate the user and render a dialog
// to obtain their approval (displaying details about the client requesting
// authorization). We accomplish that here by routing through `ensureLoggedIn()`
// first, and rendering the `dialog` view.
module.exports.authorization = [
login.ensureLoggedIn(),
server.authorization((clientId, redirectUri, done) => {
db.clients.findByClientId(clientId, (error, client) => {
if (error) return done(error);
// WARNING: For security purposes, it is highly advisable to check that
// redirectUri provided by the client matches one registered with
// the server. For simplicity, this example does not. You have
// been warned.
return done(null, client, redirectUri);
});
}, (client, user, done) => {
// Check if grant request qualifies for immediate approval
// Auto-approve
if (client.isTrusted) return done(null, true);
db.accessTokens.findByUserIdAndClientId(user.id, client.clientId, (error, token) => {
// Auto-approve
if (token) return done(null, true);
// Otherwise ask user
return done(null, false);
});
}),
(req, res) => {
res.render('dialog', { transactionId: req.oauth2.transactionID, user: req.user, client: req.oauth2.client });
},
];
// User decision endpoint.
//
// `decision` middleware processes a user's decision to allow or deny access
// requested by a client application. Based on the grant type requested by the
// client, the above grant middleware configured above will be invoked to send
// a response.
module.exports.decision = [
login.ensureLoggedIn(),
server.decision(),
];
// Token endpoint.
//
// `token` middleware handles client requests to exchange authorization grants
// for access tokens. Based on the grant type being exchanged, the above
// exchange middleware will be invoked to handle the request. Clients must
// authenticate when making requests to this endpoint.
module.exports.token = [
passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
server.token(),
server.errorHandler(),
];

19
routes/site.js Normal file
View File

@@ -0,0 +1,19 @@
'use strict';
const passport = require('passport');
const login = require('connect-ensure-login');
module.exports.index = (req, res) => res.send('OAuth 2.0 Server');
module.exports.loginForm = (req, res) => res.render('login');
module.exports.login = passport.authenticate('local', {successReturnToOrRedirect: '/', failureRedirect: '/login'});
module.exports.logout = (req, res) => {
req.logout();
res.redirect('/');
};
module.exports.account = [
login.ensureLoggedIn(), (req, res) => res.render('account', {user: req.user}),
];

92
routes/user.js Normal file
View File

@@ -0,0 +1,92 @@
'use strict';
const passport = require('passport');
module.exports.info = [
passport.authenticate('bearer', {session: true}), (req, res) => {
const {user} = req;
res.json({user_id: user.id, name: user.name, scope: req.authInfo.scope});
}
];
module.exports.ping = [
passport.authenticate('bearer', {session: true}), (req, res) => {
res.status(200).send('OK');
}
];
module.exports.devices = [
passport.authenticate('bearer', {session: true}), (req, res) => {
const reqId = req.get('X-Request-Id');
const r = {
request_id: reqId,
payload: {
user_id: "1",
devices: []
}
};
for (const d of global.devices) {
r.payload.devices.push(d.getInfo());
};
res.status(200).send(r);
}
];
module.exports.query = [
passport.authenticate('bearer', {session: true}), (req, res) => {
const reqId = req.get('X-Request-Id');
const r = {
request_id: reqId,
payload: {
devices: []
}
};
for (const d of req.body.devices) {
const ldevice = global.devices.find(device => device.data.id == d.id);
r.payload.devices.push(ldevice.getState());
};
res.status(200).send(r);
}
];
module.exports.action = [
passport.authenticate('bearer', {session: true}), (req, res) => {
const reqId = req.get('X-Request-Id');
const r = {
request_id: reqId,
payload: {
devices: []
}
};
for (const payloadDevice of req.body.payload.devices) {
const {id} = payloadDevice;
const capabilities = [];
const ldevice = global.devices.find(device => device.data.id == id);
for (const payloadCapability of payloadDevice.capabilities) {
capabilities.push(ldevice.setCapabilityState(payloadCapability.state.value , payloadCapability.type, payloadCapability.state.instance));
}
r.payload.devices.push({id, capabilities});
};
res.status(200).send(r);
}
];
module.exports.unlink = [
passport.authenticate('bearer', {session: true}), (req, res) => {
const reqId = req.get('X-Request-Id');
const r = {
request_id: reqId,
}
res.status(200).send(r);
}
];

32
utils/index.js Normal file
View File

@@ -0,0 +1,32 @@
'use strict';
/**
* Return a unique identifier with the given `len`.
*
* @param {Number} length
* @return {String}
* @api private
*/
module.exports.getUid = function(length) {
let uid = '';
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charsLength = chars.length;
for (let i = 0; i < length; ++i) {
uid += chars[getRandomInt(0, charsLength - 1)];
}
return uid;
};
/**
* Return a random int, used by `utils.getUid()`.
*
* @param {Number} min
* @param {Number} max
* @return {Number}
* @api private
*/
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}

2
views/account.ejs Normal file
View File

@@ -0,0 +1,2 @@
<p>Username: <%= user.username %></p>
<p>Name: <%= user.name %></p>

27
views/dialog.ejs Normal file
View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<title>login</title>
<link rel="stylesheet" type="text/css" href="/style.css" />
</head>
<body>
<div id="slick-login">
<h1>
<p><%= user.name %>,</p>
<p><b><%= client.name %></b> запрашивает доступ к контроллеру.</p>
<p>Разрешить?</p>
</h1>
<form action="/dialog/authorize/decision" method="post">
<input name="transaction_id" type="hidden" value="<%= transactionId %>" />
<div>
<input type="submit" value="Разрешить" id="allow" />
<input type="submit" value="Запретить" name="cancel" id="deny" />
</div>
</form>
</div>
</body>
</html>

9
views/layout.ejs Normal file
View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>OK</title>
</head>
<body>
<%- body %>
</body>
</html>

14
views/login.ejs Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<title>login</title>
<link rel="stylesheet" type="text/css" href="/style.css" />
</head>
<body>
<form id="slick-login" action="/login" method="post">
<label for="username">Логин:</label><input type="text" name="username" class="placeholder" placeholder="Логин">
<label for="password">Пароль:</label><input type="password" name="password" class="placeholder" placeholder="Пароль">
<input type="submit" value="ВОЙТИ">
</form>
</body>
</html>

302
views/style.css Normal file
View File

@@ -0,0 +1,302 @@
/*
CSS RESET
http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section {
display: block;
}
body {
line-height: 1;
}
ol,ul {
list-style: none;
}
blockquote,q {
quotes: none;
}
blockquote:before,blockquote:after,q:before,q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
/* CSS Animations */
@keyframes "login" {
0% {
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";
filter: alpha(opacity=0);
opacity: 0;
margin-top: -50px;
}
100% {
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)";
filter: alpha(opacity=100);
opacity: 1;
margin-top: -75px;
}
}
@-moz-keyframes login {
0% {
filter: alpha(opacity=0);
opacity: 0;
margin-top: -50px;
}
100% {
filter: alpha(opacity=100);
opacity: 1;
margin-top: -75px;
}
}
@-webkit-keyframes "login" {
0% {
filter: alpha(opacity=0);
opacity: 0;
margin-top: -50px;
}
100% {
filter: alpha(opacity=100);
opacity: 1;
margin-top: -75px;
}
}
@-ms-keyframes "login" {
0% {
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";
filter: alpha(opacity=0);
opacity: 0;
margin-top: -50px;
}
100% {
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)";
filter: alpha(opacity=100);
opacity: 1;
margin-top: -75px;
}
}
@-o-keyframes "login" {
0% {
filter: alpha(opacity=0);
opacity: 0;
margin-top: -50px;
}
100% {
filter: alpha(opacity=100);
opacity: 1;
margin-top: -75px;
}
}
/* Main CSS */
* { -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; }
body {
font-family: sans-serif;
background-color: #323B55;
}
#slick-login {
width: 220px;
height: 155px;
position: absolute;
left: 50%;
top: 50%;
margin-left: -110px;
margin-top: -75px;
-webkit-animation: login 1s ease-in-out;
-moz-animation: login 1s ease-in-out;
-ms-animation: login 1s ease-in-out;
-o-animation: login 1s ease-in-out;
animation: login 1s ease-in-out;
}
#slick-login label {
display: none;
}
.placeholder {
color: #444;
}
#slick-login h1 {
background-color: #292829;
border-radius: 5px 5px 0px 0px;
-moz-border-radius: 5px 5px 0px 0px;
-webkit-border-radius: 5px 5px 0px 0px;
color: #fff;
padding: 20px;
text-align: center;
text-transform: uppercase;
font-family: 'Open Sans', sans-serif;
font-size: 14px;
}
#slick-login input[type="text"],#slick-login input[type="password"] {
width: 100%;
height: 40px;
positon: relative;
margin-top: 7px;
font-size: 14px;
color: #444;
outline: none;
border: 1px solid rgba(0, 0, 0, .49);
padding-left: 20px;
-webkit-background-clip: padding-box;
-moz-background-clip: padding-box;
background-clip: padding-box;
border-radius: 6px;
background-image: -webkit-linear-gradient(bottom, #FFFFFF 0%, #F2F2F2 100%);
background-image: -moz-linear-gradient(bottom, #FFFFFF 0%, #F2F2F2 100%);
background-image: -o-linear-gradient(bottom, #FFFFFF 0%, #F2F2F2 100%);
background-image: -ms-linear-gradient(bottom, #FFFFFF 0%, #F2F2F2 100%);
background-image: linear-gradient(bottom, #FFFFFF 0%, #F2F2F2 100%);
-webkit-box-shadow: inset 0px 2px 0px #d9d9d9;
box-shadow: inset 0px 2px 0px #d9d9d9;
-webkit-transition: all .1s ease-in-out;
-moz-transition: all .1s ease-in-out;
-o-transition: all .1s ease-in-out;
-ms-transition: all .1s ease-in-out;
transition: all .1s ease-in-out;
}
#slick-login input[type="text"]:focus,#slick-login input[type="password"]:focus {
-webkit-box-shadow: inset 0px 2px 0px #a7a7a7;
box-shadow: inset 0px 2px 0px #a7a7a7;
}
#slick-login input:first-child {
margin-top: 0px;
}
#slick-login input[type="submit"] {
width: 100%;
height: 50px;
margin-top: 7px;
color: #fff;
font-size: 18px;
font-weight: bold;
text-shadow: 0px -1px 0px #5b6ddc;
outline: none;
border: 1px solid rgba(0, 0, 0, .49);
-webkit-background-clip: padding-box;
-moz-background-clip: padding-box;
background-clip: padding-box;
border-radius: 6px;
background-color: #5466da;
background-image: -webkit-linear-gradient(bottom, #5466da 0%, #768ee4 100%);
background-image: -moz-linear-gradient(bottom, #5466da 0%, #768ee4 100%);
background-image: -o-linear-gradient(bottom, #5466da 0%, #768ee4 100%);
background-image: -ms-linear-gradient(bottom, #5466da 0%, #768ee4 100%);
background-image: linear-gradient(bottom, #5466da 0%, #768ee4 100%);
-webkit-box-shadow: inset 0px 1px 0px #9ab1ec;
box-shadow: inset 0px 1px 0px #9ab1ec;
cursor: pointer;
-webkit-transition: all .1s ease-in-out;
-moz-transition: all .1s ease-in-out;
-o-transition: all .1s ease-in-out;
-ms-transition: all .1s ease-in-out;
transition: all .1s ease-in-out;
}
#slick-login input[type="submit"]:hover {
background-color: #5f73e9;
background-image: -webkit-linear-gradient(bottom, #5f73e9 0%, #859bef 100%);
background-image: -moz-linear-gradient(bottom, #5f73e9 0%, #859bef 100%);
background-image: -o-linear-gradient(bottom, #5f73e9 0%, #859bef 100%);
background-image: -ms-linear-gradient(bottom, #5f73e9 0%, #859bef 100%);
background-image: linear-gradient(bottom, #5f73e9 0%, #859bef 100%);
-webkit-box-shadow: inset 0px 1px 0px #aab9f4;
box-shadow: inset 0px 1px 0px #aab9f4;
margin-top: 10px;
}
#slick-login input[type="submit"]:active {
background-color: #7588e1;
background-image: -webkit-linear-gradient(bottom, #7588e1 0%, #7184df 100%);
background-image: -moz-linear-gradient(bottom, #7588e1 0%, #7184df 100%);
background-image: -o-linear-gradient(bottom, #7588e1 0%, #7184df 100%);
background-image: -ms-linear-gradient(bottom, #7588e1 0%, #7184df 100%);
background-image: linear-gradient(bottom, #7588e1 0%, #7184df 100%);
-webkit-box-shadow: inset 0px 1px 0px #93a9e9;
box-shadow: inset 0px 1px 0px #93a9e9;
}
/* Noeee aa?a vladmaxi, ii?ii oaaeeou */
.vladmaxi-top{
line-height: 24px;
font-size: 11px;
background: #eee;
text-transform: uppercase;
z-index: 9999;
position: fixed;
top:0;
left:0;
width:100%;
font-family: calibri;
font-size: 13px;
box-shadow: 1px 0px 2px rgba(0,0,0,0.2);
-webkit-animation: slideOut 0.5s ease-in-out 0.3s backwards;
}
@-webkit-keyframes slideOut{
0%{top:-30px; opacity: 0;}
100%{top:0px; opacity: 1;}
}
.vladmaxi-top a{
padding: 0px 10px;
letter-spacing: 1px;
color: #333;
text-shadow: 0px 1px 1px #fff;
display: block;
float: left;
}
.vladmaxi-top a:hover{
background: #fff;
}
.vladmaxi-top span.right{
float: right;
}
.vladmaxi-top span.right a{
float: left;
display: block;
}