mirror of
https://github.com/lasthead0/yandex2mqtt.git
synced 2025-08-06 16:27:18 +03:00
Release
This commit is contained in:
14
.eslintrc
Normal file
14
.eslintrc
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.env
|
||||
config.js.def
|
21
LICENSE
Normal file
21
LICENSE
Normal 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
302
README.md
Normal 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
121
app.js
Normal 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
91
auth/index.js
Normal 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
157
config.js.orig
Normal 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
54
db/access_tokens.js
Normal 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
13
db/authorization_codes.js
Normal 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
17
db/clients.js
Normal 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
13
db/index.js
Normal 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
17
db/users.js
Normal 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
198
device.js
Normal 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
2283
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal 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
13
routes/client.js
Normal 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
13
routes/index.js
Normal 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
213
routes/oauth2.js
Normal 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
19
routes/site.js
Normal 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
92
routes/user.js
Normal 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
32
utils/index.js
Normal 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
2
views/account.ejs
Normal file
@@ -0,0 +1,2 @@
|
||||
<p>Username: <%= user.username %></p>
|
||||
<p>Name: <%= user.name %></p>
|
27
views/dialog.ejs
Normal file
27
views/dialog.ejs
Normal 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
9
views/layout.ejs
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OK</title>
|
||||
</head>
|
||||
<body>
|
||||
<%- body %>
|
||||
</body>
|
||||
</html>
|
14
views/login.ejs
Normal file
14
views/login.ejs
Normal 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
302
views/style.css
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user